diff --git a/.dagger/modules/e2e/main.dang b/.dagger/modules/e2e/main.dang index 76a2d1d..ebdc02f 100644 --- a/.dagger/modules/e2e/main.dang +++ b/.dagger/modules/e2e/main.dang @@ -170,6 +170,34 @@ type E2e { null } + """ + The default template should render a working module (a class with a container + function that reads source from the workspace), and the empty template should + render a bare @object class. Both must have their {{ .ModuleName }} + placeholder fully substituted. + """ + pub templateCheck(ws: Workspace!): Void @check { + let defaultPath = outputRoot + "/template-default" + let emptyPath = outputRoot + "/template-empty" + + let defaultChanges = typescriptSdk.initModule(ws, name: "template-default", path: defaultPath) + let defaultIndex = defaultChanges.layer.file(defaultPath + "/src/index.ts").contents + assertContains(defaultIndex, "export class TemplateDefault", "default template should render the module class") + assertContains(defaultIndex, "container(): Container", "default template should expose the container function") + assertContains(defaultIndex, "ws.directory(", "default template constructor should read source from the workspace") + assertContains(defaultIndex, "baseImageAddress", "default template should expose the base image field") + assertNotContains(defaultIndex, "{{", "default template placeholders should be fully rendered") + + let emptyChanges = typescriptSdk.initModule(ws, name: "template-empty", path: emptyPath, template: "empty") + assertAdded(emptyChanges, emptyPath + "/src/index.ts") + assertAdded(emptyChanges, emptyPath + "/package.json") + let emptyIndex = emptyChanges.layer.file(emptyPath + "/src/index.ts").contents + assertContains(emptyIndex, "export class TemplateEmpty {}", "empty template should render a bare class") + assertNotContains(emptyIndex, "{{", "empty template placeholders should be fully rendered") + + null + } + """ Init against a path that already contains user-authored package.json, tsconfig.json, or deno.json should merge Dagger-required keys into those diff --git a/design/archives/new-default-template-round-1.md b/design/archives/new-default-template-round-1.md new file mode 100644 index 0000000..f3de8bd --- /dev/null +++ b/design/archives/new-default-template-round-1.md @@ -0,0 +1,352 @@ +> **Archived — round 1 of 3.** Superseded by +> [`../new-default-template.md`](../new-default-template.md). Kept for design +> history. This round proposed three *object-shape* styles (build environment / +> container wrapper / CI pipeline) before feedback moved the design toward +> first-class Dagger verbs and a toolchain-neutral default. + +# New default TypeScript init template + +## Goal + +Today `dagger module init typescript ` seeds an empty class: + +```ts +import { object } from "@dagger.io/dagger" + +@object() +export class MyModule {} +``` + +That is a correct starting point but gives the user nothing to build on. They +open the file to a blank object and have to remember how to accept a directory, +mount source, build a container, and chain state — the exact boilerplate almost +every module repeats. + +This doc proposes replacing the default with a *small but useful* module so a +fresh `init` already shows the day-to-day shape of a Dagger module: take source +from the workspace, build a container from it, and chain state onto it. + +## Plan at a glance + +1. Rename the current empty template `templates/minimal` → `templates/empty`, + and keep it reachable via `--template empty` for people who want a clean + slate. +2. Add a new useful template and make it the `initModule` default. +3. Keep both wired through the existing `render-template` + `config-updator` + pipeline (see [Mechanics](#mechanics--wiring)). + +The rest of this doc is about **which** useful template to ship. Three styles +are proposed below — pick one (or a hybrid) and we iterate. + +## Constraints (from the task) + +- Realistic argument and function names — no `stringArg`/`foo`. +- Realistic use of state: **at least one object field** and **at least one + chainable function that mutates object state**. +- **A constructor** taking a workspace / directory argument with a **default + ignore** directive. +- Small — not overcomplicated. +- Not over-documented. If a user's reflex is to delete every doc comment + because it's noise, we've failed. One short line per public surface, max. +- TypeScript template (not Dang). + +## Shared conventions + +All three styles share the same skeleton, so the choice is really about *what +the object does*, not its plumbing: + +- Class name is rendered from the module name: `{{ .ModuleName }}` (the only + placeholder `render-template` substitutes — camel-cased). +- Constructor captures workspace source via a `Directory` argument with + `defaultPath` + `ignore`. `Directory` (not `Workspace`) is the idiomatic + choice for a user module: `Workspace` is the engine/SDK-facing CLI-1.0 type, + and user modules receive plain `Directory` arguments. Example from the docs' + cookbook (`set-common-default-path`): + + ```ts + constructor( + @argument({ defaultPath: "." }) + source: Directory, + ) { + this.source = source + } + ``` + +- Base image defaults to `alpine:latest` so the template is **language-agnostic** + and every function actually runs on a fresh `dagger call` with no external + setup. + +Two decisions apply to all three (see [Open questions](#open-questions)): +`defaultPath` = `"/"` (workspace root, matches "source of the workspace") vs +`"."` (module dir); and the exact default `ignore` list. + +--- + +## Style 1 — Build environment *(recommended)* + +The object *is* a reproducible environment built from your source. State is the +source plus a swappable base image; `container()` assembles the env and `run()` +executes in it. This is the `buildEnv` idiom that shows up in nearly every real +module. + +```ts +import { dag, Directory, Container, object, func, argument } from "@dagger.io/dagger" + +@object() +export class {{ .ModuleName }} { + source: Directory + baseImage: string + + constructor( + @argument({ defaultPath: "/", ignore: ["**/node_modules", "**/.git", "**/dist"] }) + source: Directory, + baseImage = "alpine:latest", + ) { + this.source = source + this.baseImage = baseImage + } + + /** Build the environment on a different base image. */ + @func() + withBaseImage(image: string): {{ .ModuleName }} { + this.baseImage = image + return this + } + + /** A container with the source mounted at /src. */ + @func() + container(): Container { + return dag + .container() + .from(this.baseImage) + .withDirectory("/src", this.source) + .withWorkdir("/src") + } + + /** Run a command in the environment and return its output. */ + @func() + async run(args: string[]): Promise { + return this.container().withExec(args).stdout() + } +} +``` + +Constraints: field ✓ (`source`, `baseImage`) · chainable mutation ✓ +(`withBaseImage`) · constructor + dir + ignore ✓ · 3 funcs, ~30 lines ✓ · one +line of doc each ✓. + +**Pros** +- Smallest module that still exercises all four pillars (source+ignore, field, + chaining, container build). +- Language-agnostic — nothing assumes npm/apk, so it's never "wrong" for a repo. +- Every function runs out of the box, no credentials or project-specific commands. +- `container()` returns a core `Container` other modules can keep chaining on. + +**Cons** +- `withBaseImage` chaining is idiomatic but mild — the base image could just be + a per-call arg; it's state mainly to demonstrate the pattern. +- `run(args)` is generic; it teaches the mechanism but not a concrete verb like + "build" or "test". + +--- + +## Style 2 — Container wrapper + +State *is* an evolving `Container`. The constructor seeds it (base + source), +and each chainable method layers onto the held container. Mirrors the core +Container API, so it feels immediately familiar to Dagger users. + +```ts +import { dag, Directory, Container, object, func, argument } from "@dagger.io/dagger" + +@object() +export class {{ .ModuleName }} { + ctr: Container + + constructor( + @argument({ defaultPath: "/", ignore: ["**/node_modules", "**/.git", "**/dist"] }) + source: Directory, + base = "alpine:latest", + ) { + this.ctr = dag + .container() + .from(base) + .withDirectory("/src", source) + .withWorkdir("/src") + } + + /** Install a package with apk. */ + @func() + withPackage(name: string): {{ .ModuleName }} { + this.ctr = this.ctr.withExec(["apk", "add", "--no-cache", name]) + return this + } + + /** Set an environment variable. */ + @func() + withEnvVariable(name: string, value: string): {{ .ModuleName }} { + this.ctr = this.ctr.withEnvVariable(name, value) + return this + } + + /** The assembled container. */ + @func() + container(): Container { + return this.ctr + } +} +``` + +Constraints: field ✓ (`ctr`) · chainable mutation ✓ (`withPackage`, +`withEnvVariable`) · constructor + dir + ignore ✓ · small ✓ · light docs ✓. + +**Pros** +- Most fluent of the three; the "wrap a container and keep extending it" mental + model is exactly how Dagger users already think. +- The constructor does "build a container + add workspace source" in one place — + a direct hit on the task's example use case. +- Exposing `ctr` lets other modules grab the built container. + +**Cons** +- `withPackage` hardcodes `apk`, so it silently assumes an Alpine base. Swap the + base to Debian and the method breaks — a subtle trap in a *starter* template. +- Holding a whole `Container` in state bloats the object's identity. The docs + explicitly caution against over-stuffed state (weakens caching); a starter + template arguably shouldn't model the anti-pattern. +- Reads as a thin re-wrap of `Container` — less obviously "useful" than a verb. + +--- + +## Style 3 — CI pipeline (build → test → publish) + +The object models an end-to-end delivery. Source in the constructor, a mutable +`tag`, and the three verbs that sell Dagger. The most "complete" skeleton for a +real CI module. + +```ts +import { dag, Directory, Container, object, func, argument } from "@dagger.io/dagger" + +@object() +export class {{ .ModuleName }} { + source: Directory + tag: string + + constructor( + @argument({ defaultPath: "/", ignore: ["**/node_modules", "**/.git", "**/dist"] }) + source: Directory, + tag = "latest", + ) { + this.source = source + this.tag = tag + } + + /** Set the image tag used by publish. */ + @func() + withTag(tag: string): {{ .ModuleName }} { + this.tag = tag + return this + } + + /** Build the application image from source. */ + @func() + build(): Container { + return dag + .container() + .from("alpine:latest") + .withDirectory("/app", this.source) + .withWorkdir("/app") + } + + /** Run the test suite and return its output. */ + @func() + async test(): Promise { + return this.build().withExec(["sh", "-c", "echo 'add your tests here'"]).stdout() + } + + /** Publish the built image to a registry. */ + @func() + async publish(registry: string): Promise { + return this.build().publish(`${registry}:${this.tag}`) + } +} +``` + +Constraints: field ✓ (`source`, `tag`) · chainable mutation ✓ (`withTag`, and +it genuinely affects `publish`) · constructor + dir + ignore ✓ · 4 funcs — +borderline on "small" · light docs ✓. + +**Pros** +- Tells the whole story: build → test → publish, the arc that makes Dagger click. +- `withTag` is a *meaningful* chain — the mutated state changes `publish`'s result. +- Closest to a drop-in skeleton for an actual delivery module. + +**Cons** +- `test()` has to be a stub (`echo 'add your tests here'`) — it can't know the + project's test command. That's precisely the kind of placeholder users delete, + which cuts against "make them gain time." +- `publish` needs a registry (and usually credentials); the first `dagger call` + a curious user tries may fail confusingly on a brand-new module. +- Largest of the three; leans toward "overcomplicated" for a *default*. + +--- + +## Comparison + +| | Style 1 · Build env | Style 2 · Container wrapper | Style 3 · CI pipeline | +|---|---|---|---| +| Object field | `source`, `baseImage` | `ctr` | `source`, `tag` | +| Chainable mutation | `withBaseImage` | `withPackage`, `withEnvVariable` | `withTag` | +| Runs out of the box | ✓ all | ✓ all | build ✓, publish needs registry | +| Language-agnostic | ✓ | ✗ (`apk`) | ✓ | +| Size | smallest | small | largest | +| Models good state hygiene | ✓ | ✗ (Container in state) | ✓ | +| "Aha" factor | medium | medium | highest | + +## Recommendation + +Ship **Style 1**. It is the smallest template that still demonstrates every +required pillar, it's language-agnostic, and every function works immediately +with zero setup — which is what actually saves a new user time. Style 2's `apk` +trap and heavy-state model make it a poor thing to hand someone as *the* +example; Style 3's stub `test()` and credential-dependent `publish()` are the +kind of scaffolding people delete. + +If we want more of Style 3's "aha," the cheap hybrid is to add a single concrete +verb to Style 1 (e.g. a `build(): Container` that runs one real step, or a +`publish(registry)`), while keeping `run()` for the generic case — without +importing the stub-`test()` problem. + +## Mechanics / wiring + +Changes needed in [`typescript-sdk.dang`](../../typescript-sdk.dang): + +- `initModule`'s default: `template: String! = "minimal"` → the new default's + directory name. +- `renderedDefaultTemplate` currently special-cases the string `"minimal"` (it + runs `render-template` for `{{ .ModuleName }}` and `config-updator` for + package.json/tsconfig/deno.json). That path must key off the *new* default's + name (or be generalized so any template with a `.tmpl` file gets rendered). +- `templates/empty` (the renamed old template) should skip the render/config + pipeline the same way non-default templates do today, or be given a trivial + `index.ts.tmpl` so `{{ .ModuleName }}` still resolves — decide which. + +No new placeholders are required: `render-template` only substitutes +`{{ .ModuleName }}`, which all three sketches already use for the class name. + +## Open questions + +1. **`defaultPath`: `"/"` vs `"."`** — `"/"` captures the workspace/context root + ("source of the workspace", matches the task wording); `"."` captures the + module dir. Since generated modules live under `.dagger/modules//`, + `"/"` is likely what a CI-style module wants, but it then pulls in `.dagger` + itself unless ignored. +2. **Default `ignore` list** — proposed `["**/node_modules", "**/.git", + "**/dist"]`. Add `.dagger`? Add `**/build`? Keep it short so it's obviously + editable. +3. **Name of the useful template** — `default`? Keep `minimal` (as + small-but-useful) and use `empty` for the blank one? Go's SDK uses + `minimal` = empty and `legacy` = the rich old default; we don't have to + mirror that. +4. **Expose a field via `@func()`?** — the sketches keep fields as private + state. Decorating one (e.g. `baseImage`/`tag`) surfaces it in + `dagger functions`; worth it, or noise? diff --git a/design/archives/new-default-template-round-2.md b/design/archives/new-default-template-round-2.md new file mode 100644 index 0000000..e6d2a28 --- /dev/null +++ b/design/archives/new-default-template-round-2.md @@ -0,0 +1,413 @@ +> **Archived — round 2 of 3.** Superseded by +> [`../new-default-template.md`](../new-default-template.md). Kept for design +> history. This round reframed the choice around which first-class Dagger verb +> the default should teach (`publish` / `dagger check` / `dagger generate`) and +> proposed a Node-flavored skeleton. Later feedback dropped the Node/npm +> assumption (a TS module may not manage a JS/TS project) and deferred +> testing/linting to `dagger setup`, converging on a toolchain-neutral default. + +# New default TypeScript init template + +## Goal + +Today `dagger module init typescript ` seeds an empty class: + +```ts +import { object } from "@dagger.io/dagger" + +@object() +export class MyModule {} +``` + +Correct, but it gives the user nothing to build on. They open the file to a +blank object and have to re-derive the boilerplate every module repeats: take +source from the workspace, build a container from it, run something, ship it. + +We want `init` to drop the user into a *small but useful* module that already +shows that shape. + +## Plan + +1. Rename the current empty template `templates/minimal` → `templates/empty` + (reachable via `--template empty` for a clean slate). +2. Add a useful `templates/default`, and make it the `initModule` default. + +This revision reworks the three candidate styles around the round-1 feedback. +The interesting decision is no longer *"container builder vs pipeline"* — it's +**which first-class Dagger verb the default should teach**: `publish`, +`dagger check`, or `dagger generate`. The three styles below each headline one. + +## Decisions locked from round 1 + +- **Constructor source**: `Directory` argument, `@argument({ defaultPath: "/", + ignore: [...] })`. `/` = workspace root (`.` would resolve to the module dir, + which isn't what "source of the workspace" means). +- **Ignore list**: `["**/node_modules", "**/.git", "**/dist", "**/.dagger"]` + (dropped `**/build` — projects build into `dist`; added `.dagger`). +- **`baseImage`** is an object field decorated with `@func()`, and defaults to a + **pinned** image — never `alpine:latest`. +- **No `withBaseImage`** — the base image comes from the constructor, so a + chainable setter for it is redundant. The chainable mutation instead carries + state the constructor *shouldn't* hold (credentials/secrets). +- **Template name**: `default`. + +## What every style shares + +The three styles differ only in their verbs; the skeleton is identical, and it +already satisfies the structural constraints (constructor + ignore, a `@func()` +field, and one meaningful chainable mutation): + +```ts +@object() +export class {{ .ModuleName }} { + /** Base image the build runs on. */ + @func() + baseImage: string + + source: Directory + + constructor( + @argument({ + defaultPath: "/", + ignore: ["**/node_modules", "**/.git", "**/dist", "**/.dagger"], + }) + source: Directory, + baseImage = "node:22-slim", + ) { + this.source = source + this.baseImage = baseImage + } + + /** Development container with dependencies installed. */ + @func() + buildEnv(): Container { + return dag + .container() + .from(this.baseImage) + .withMountedCache("/root/.npm", dag.cacheVolume("npm")) + .withDirectory("/app", this.source) + .withWorkdir("/app") + .withExec(["npm", "ci"]) + } +} +``` + +**Node-flavored on purpose.** The commands assume `npm`. This is the TypeScript +SDK — leaning into the JS/TS toolchain keeps the verbs *real* (`npm test`, +`npm run build`) instead of the `echo TODO` stub that made round-1 Style 3 weak. +The one-line escape hatch for other stacks: swap `baseImage` and the commands; +the object shape is unchanged. (Open question below: keep this, or ship a +generic `alpine` variant with a clearly-marked placeholder command.) + +`{{ .ModuleName }}` is the only token `render-template` substitutes (camel-cased +class name). + +--- + +## Style A — Publish pipeline *(recommended)* + +Headlines `publish`, with a `@check()` test gate. The base image is a +constructor arg (the `@func` field); the publish **tag is a function arg**; +registry **credentials arrive via the chainable** (they're sensitive and +optional, so they don't belong in the constructor). + +```ts +import { dag, Directory, Container, Secret, object, func, check, argument } from "@dagger.io/dagger" + +@object() +export class {{ .ModuleName }} { + /** Base image the build runs on. */ + @func() + baseImage: string + + source: Directory + username = "" + secret?: Secret + + constructor( + @argument({ + defaultPath: "/", + ignore: ["**/node_modules", "**/.git", "**/dist", "**/.dagger"], + }) + source: Directory, + baseImage = "node:22-slim", + ) { + this.source = source + this.baseImage = baseImage + } + + /** Store registry credentials used when publishing. */ + @func() + withRegistryAuth(username: string, secret: Secret): {{ .ModuleName }} { + this.username = username + this.secret = secret + return this + } + + /** Development container with dependencies installed. */ + @func() + buildEnv(): Container { + return dag + .container() + .from(this.baseImage) + .withMountedCache("/root/.npm", dag.cacheVolume("npm")) + .withDirectory("/app", this.source) + .withWorkdir("/app") + .withExec(["npm", "ci"]) + } + + /** Run the test suite. */ + @func() + @check() + async test(): Promise { + await this.buildEnv().withExec(["npm", "test"]).sync() + } + + /** Build and publish the image, returning its reference. */ + @func() + async publish(address: string, tag = "latest"): Promise { + let ctr = this.buildEnv().withExec(["npm", "run", "build"]) + if (this.secret) { + ctr = ctr.withRegistryAuth(address, this.username, this.secret) + } + return ctr.publish(`${address}:${tag}`) + } +} +``` + +**Pros** +- Tells the full build → test → ship story, which is what sells Dagger. +- `withRegistryAuth` is a *genuinely* meaningful chainable: it adds a `Secret` + the constructor shouldn't carry, and `publish` consumes it. Not + constructor-redundant, not an API shadow. +- Runs `dagger check` (test gate) and produces a real published ref. + +**Cons** +- Largest of the three (four verbs + a chainable). +- `publish` needs a registry the user supplies; the first thing a curious user + runs is more likely `test` or `buildEnv`, which is fine, but `publish` won't + work until they have somewhere to push. + +--- + +## Style B — Checks module + +Headlines `dagger check`. Two checks (`lint`, `test`) are the product; no +publish. The chainable injects a token for private dependencies (a real reason +checks need a `Secret`). + +```ts +import { dag, Directory, Container, Secret, object, func, check, argument } from "@dagger.io/dagger" + +@object() +export class {{ .ModuleName }} { + /** Base image the checks run on. */ + @func() + baseImage: string + + source: Directory + token?: Secret + + constructor( + @argument({ + defaultPath: "/", + ignore: ["**/node_modules", "**/.git", "**/dist", "**/.dagger"], + }) + source: Directory, + baseImage = "node:22-slim", + ) { + this.source = source + this.baseImage = baseImage + } + + /** Provide a token for installing private dependencies. */ + @func() + withNpmToken(token: Secret): {{ .ModuleName }} { + this.token = token + return this + } + + /** Development container with dependencies installed. */ + @func() + buildEnv(): Container { + let ctr = dag + .container() + .from(this.baseImage) + .withMountedCache("/root/.npm", dag.cacheVolume("npm")) + .withDirectory("/app", this.source) + .withWorkdir("/app") + if (this.token) { + ctr = ctr.withSecretVariable("NPM_TOKEN", this.token) + } + return ctr.withExec(["npm", "ci"]) + } + + /** Check the code style. */ + @func() + @check() + async lint(): Promise { + await this.buildEnv().withExec(["npm", "run", "lint"]).sync() + } + + /** Run the test suite. */ + @func() + @check() + async test(): Promise { + await this.buildEnv().withExec(["npm", "test"]).sync() + } +} +``` + +**Pros** +- Cleanest demonstration of the `@check()` decorator and `dagger check` (running + multiple checks at once is the "aha"). +- Every function runs out of the box with no external setup (no registry). +- Smallest, most self-contained of the three. + +**Cons** +- `npm run lint` assumes a `lint` script exists; missing script → the check + fails until the user wires one up (still better than a stub — it's a real + command, not dead code). +- No shipping story; a user who wants build/publish has to add it. + +--- + +## Style C — Generate module + +Headlines `dagger generate`. Keeps a `@check()` test, and adds a `@generate()` +`format` that returns a `Changeset` — directly answering the round-1 "can we +show `@generate`?" question. `format` deliberately runs in a lean container +(no `npm ci`) so the changeset diffs only real source edits, not `node_modules`. + +```ts +import { dag, Directory, Container, Changeset, object, func, check, generate, argument } from "@dagger.io/dagger" + +@object() +export class {{ .ModuleName }} { + /** Base image the tasks run on. */ + @func() + baseImage: string + + source: Directory + + constructor( + @argument({ + defaultPath: "/", + ignore: ["**/node_modules", "**/.git", "**/dist", "**/.dagger"], + }) + source: Directory, + baseImage = "node:22-slim", + ) { + this.source = source + this.baseImage = baseImage + } + + /** Development container with dependencies installed. */ + @func() + buildEnv(): Container { + return dag + .container() + .from(this.baseImage) + .withMountedCache("/root/.npm", dag.cacheVolume("npm")) + .withDirectory("/app", this.source) + .withWorkdir("/app") + .withExec(["npm", "ci"]) + } + + /** Run the test suite. */ + @func() + @check() + async test(): Promise { + await this.buildEnv().withExec(["npm", "test"]).sync() + } + + /** Format the source and return the changes. */ + @func() + @generate() + format(): Changeset { + return dag + .container() + .from(this.baseImage) + .withDirectory("/app", this.source) + .withWorkdir("/app") + .withExec(["npx", "--yes", "prettier", "--write", "."]) + .directory("/app") + .changes(this.source) + } +} +``` + +**Pros** +- Shows both new first-class verbs (`@check` + `@generate`) in one small module. +- `format` is a real, useful generator: run it, review the diff, apply it — the + exact `dagger generate` loop. +- The changeset-hygiene detail (lean container, diff vs `this.source`) teaches a + non-obvious best practice by example. + +**Cons** +- This style has **no meaningful chainable** — the structural "chainable + mutation" constraint isn't met unless we graft one on (e.g. Style B's + `withNpmToken`), which starts to bloat it. +- `format` overwrites files; a user who expected a read-only default may be + surprised (mitigated by `dagger generate`'s review-before-apply flow). + +--- + +## Comparison + +| | A · Publish | B · Checks | C · Generate | +|---|---|---|---| +| Headline verb | `publish` | `dagger check` | `dagger generate` | +| Decorators shown | `@check` | `@check` ×2 | `@check` + `@generate` | +| `@func` field | `baseImage` | `baseImage` | `baseImage` | +| Chainable mutation | `withRegistryAuth` (Secret) | `withNpmToken` (Secret) | — (needs one grafted) | +| Runs with zero setup | test ✓, publish needs registry | ✓ all | test ✓, format ✓ | +| Size | largest | smallest | medium | +| Meets all structural constraints | ✓ | ✓ | ✗ (no chainable) | + +## Recommendation + +**Style A**, optionally grafting Style C's `@generate() format()` as a fifth +verb if we want the default to advertise `dagger generate` too. + +Reasoning: A is the only one that tells the whole story (build → check → ship), +its `withRegistryAuth` chainable is the most defensible use of state (a `Secret` +the constructor shouldn't hold), and it exercises `@check` along the way. B is +the better pick if we'd rather the default be dead-simple and 100% runnable with +no registry. C's `@generate` is the most novel thing to show, but on its own it +lacks a natural chainable — it works best as an *addition* to A or B, not as the +standalone default. + +Concretely, I'd ship **A**, and I'm inclined to add `format` from **C** so a +fresh module demonstrates `@check`, `@generate`, and `publish` — the three verbs +that make a module "first-class" — while staying under ~60 lines. + +## Remaining open questions + +1. **Node-flavored vs generic base.** Recommendation ships `node:22-slim` + npm + commands. Alternative: `alpine:3.21` + a clearly-marked placeholder command, + for repos that aren't Node. Node-flavored keeps the verbs real; generic is + broader but reintroduces a placeholder. I lean Node-flavored + the one-line + swap note. (Also: pin the patch — `node:22.11.0-slim` — for full reproducibility?) +2. **How many verbs is "small"?** A alone (4) vs A+`format` (5). Where's the line + before it becomes something users trim? +3. **Does the default need a chainable at all?** The original constraint says + yes. If we adopt C-style (generate-first) we'd have to add one artificially. + Worth confirming the chainable is still a hard requirement vs a nice-to-have. +4. **`@up()`?** Not proposed here (services don't fit a generic default), but + it's the one first-class verb none of the three show. Skip for the default? + +## Mechanics / wiring + +In [`typescript-sdk.dang`](../../typescript-sdk.dang): + +- `initModule` default: `template: String! = "minimal"` → `"default"`. +- `renderedDefaultTemplate` special-cases the string `"minimal"` (runs + `render-template` for `{{ .ModuleName }}` and `config-updator` for + package.json/tsconfig/deno.json). Repoint that check at `"default"`, or + generalize it so any template carrying a `.tmpl` file gets the same treatment. +- `templates/empty` (the renamed old template) keeps skipping that pipeline the + way non-default templates do today. + +No new template tokens are needed — `render-template` only substitutes +`{{ .ModuleName }}`, which every sketch uses for the class name. diff --git a/design/new-default-template.md b/design/new-default-template.md new file mode 100644 index 0000000..5fde134 --- /dev/null +++ b/design/new-default-template.md @@ -0,0 +1,104 @@ +# New default TypeScript init template + +> **Status: implemented.** See `templates/default/`, the `initModule` wiring in +> `typescript-sdk.dang`, and the `e-2-e:template-check` test. Earlier iterations +> are kept under [`archives/`](./archives). + +## Summary + +When you run `dagger module init typescript `, the SDK creates a new +module from a template. Today that template is an empty class, so every user +starts by writing the same boilerplate: take the workspace source and build a +container from it. + +This proposal replaces the empty default with a small working module that +already does that. The current empty template is kept and renamed `empty`, so +`--template empty` still gives a blank start. + +## Plan + +1. Rename `templates/minimal` to `templates/empty`. It stays available as + `--template empty`. +2. Add `templates/default` (below) and make it the template `init` uses by + default. + +## The template + +```ts +import { dag, Workspace, Directory, Container, object, func } from "@dagger.io/dagger" + +@object() +export class {{ .ModuleName }} { + /** Image the build runs on. */ + @func() + baseImageAddress: string + + source: Directory + + constructor( + ws: Workspace, + baseImageAddress = "alpine:3.21", + ) { + this.source = ws.directory("/", { + exclude: ["**/node_modules", "**/.git", "**/dist", "**/.dagger"], + }) + this.baseImageAddress = baseImageAddress + } + + /** A container with the source mounted, ready to build on. */ + @func() + container(): Container { + return dag + .container() + .from(this.baseImageAddress) + .withDirectory("/src", this.source) + .withWorkdir("/src") + } +} +``` + +## What each function does + +- The **constructor** takes the `Workspace` (as `ws`) and the base image to + build on. It loads the workspace root with `ws.directory("/", { exclude: + [...] })`, leaving out directories that shouldn't go into the build. The + parameter is named `ws`, not `workspace`, because a `workspace` constructor + arg generates a `--workspace` flag that collides with the top-level + `--workspace` flag and makes the module fail to run. +- **`container()`** returns a container with the source mounted. This is where + the user adds their own build steps with `.withExec([...])`. + +## Design decisions + +**The source comes from the workspace.** The constructor takes a `Workspace` and +calls `ws.directory("/", { exclude: [...] })` to load the workspace root. +`exclude` skips `node_modules`, `.git`, `dist`, and `.dagger`; `include` is +available too if a module only cares about part of the tree. + +**Base image is `alpine:3.21`.** It is neutral and pinned to a version rather +than `latest`. The default cannot assume the user's project is JavaScript or +TypeScript — someone may write a TypeScript module that builds a Python repo or +some infrastructure — so it does not default to a Node image. We can pin by +digest (`alpine:3.21@sha256:...`) if we want stricter reproducibility. + +**The template stays minimal.** It gives the user a source directory and a +container to build on, and nothing else. The goal is to save time, not to teach: +there are no publish, check, or test functions to read through and delete. +`dagger setup` already suggests the modules that fit a project (`vitest`, `jest`, +`bun`, `biomejs`) when the user needs testing or linting. + +## Implementation notes + +Changes in [`typescript-sdk.dang`](../typescript-sdk.dang): + +- Change the `initModule` default from `template: String! = "minimal"` to + `"default"`. +- Generalize `renderedDefaultTemplate` (renamed `renderedTemplate`) so it + renders any named template rather than special-casing `"minimal"`: it + substitutes the class name with `render-template` and merges the runtime + config files with `config-updator`. This runs for every built-in template, so + `empty` also gets a valid `package.json`/`tsconfig.json` — it ships only + `src/index.ts.tmpl`, the same as `default`. + +`render-template` substitutes one token, `{{ .ModuleName }}` (the class name, +camel-cased). The template already uses it, so no new tokens are needed. diff --git a/templates/default/src/index.ts.tmpl b/templates/default/src/index.ts.tmpl new file mode 100644 index 0000000..bafc2f2 --- /dev/null +++ b/templates/default/src/index.ts.tmpl @@ -0,0 +1,30 @@ +import { dag, Workspace, Directory, Container, object, func } from "@dagger.io/dagger" + +@object() +export class {{ .ModuleName }} { + /** Image the build runs on. */ + @func() + baseImageAddress: string + + source: Directory + + constructor( + ws: Workspace, + baseImageAddress = "alpine:3.21", + ) { + this.source = ws.directory("/", { + exclude: ["**/node_modules", "**/.git", "**/dist", "**/.dagger"], + }) + this.baseImageAddress = baseImageAddress + } + + /** A container with the source mounted, ready to build on. */ + @func() + container(): Container { + return dag + .container() + .from(this.baseImageAddress) + .withDirectory("/src", this.source) + .withWorkdir("/src") + } +} diff --git a/templates/minimal/src/index.ts.tmpl b/templates/empty/src/index.ts.tmpl similarity index 100% rename from templates/minimal/src/index.ts.tmpl rename to templates/empty/src/index.ts.tmpl diff --git a/typescript-sdk.dang b/typescript-sdk.dang index 4cdf217..eecbddb 100644 --- a/typescript-sdk.dang +++ b/typescript-sdk.dang @@ -156,8 +156,8 @@ type TypescriptSdk { `dagger.json`; this function only returns the SDK-owned files to layer onto `path` (the rendered template plus runtime-specific config). - Pass `template` to materialize files from templates/