Skip to content

About

Develop Dagger modules in TypeScript

Resources

Code of conduct

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

typescript-sdk

A Dagger SDK for authoring Dagger modules and generating typed clients in TypeScript.

This module implements the Dagger CLI 1.0 SDK provider interface. The engine records SDK scopes in dagger.toml, sets the workspace cwd to one, and asks this module for that scope's complete desired state through findClientRoot and generateScope. The module writes the files it owns; the engine owns the workspace bookkeeping, and its builtin TypeScript runtime executes what we write.

Workspace state, module discovery and codegen go through the engine's native Workspace and ModuleSource APIs, which requires an engine at v1.0.0-beta.11 or newer. Module manifests are built with dagger/sdk-helpers, the one module dependency.

Install

Install the TypeScript SDK into your workspace:

dagger module install github.com/dagger/typescript-sdk

The engine inspects the installed module. Because it implements the complete SDK-module interface, the engine also records it as an SDK, so dagger module init typescript and dagger module client add typescript dispatch to it. List what is registered with dagger sdk list.

Commands that write to the workspace print the diff and prompt for confirmation. Pass --auto-apply to skip the prompt.

Create a new module

Create a TypeScript SDK module under the default .dagger/modules/<name>/:

dagger module init typescript --name my-module

Pick a different location with --path:

dagger module init typescript --name my-module --path some/dir/my-module

Init writes the module's dagger-module.toml, seeds src/index.ts from a template, and generates the module's own bindings, dispatch entrypoint and runtime config in one step — there is no separate dagger generate afterwards.

The runtime decides which config files a module gets:

  • node / bun → package.json, tsconfig.json
  • deno → deno.json

Init never removes what is already at the target path. If package.json, tsconfig.json, or deno.json are there, it merges Dagger-required keys into them rather than overwriting — your scripts, path aliases, unstable flags, and other custom settings are preserved — and any other file is left untouched.

A scope whose module is still configured by a pre-1.0 dagger.json is migrated on its first generation: the manifest is rewritten as dagger-module.toml, with source, include and [[dependencies]] carried over, and the dagger.json is removed. The module's own source is generated, never scaffolded over. Its engineVersion is not carried over: a module is served the core API of the release it pins, and a pre-1.0 pin yields bindings without serveModule, which the generated clients call — so the migrated manifest is pinned to the release this SDK's bundle is built for, and a dagger-module.toml still pinned to a pre-1.0 release (dagger setup keeps the old pin) is raised the same way on its next generation. The /sdk and /__dagger.entrypoint.ts lines the pre-1.0 engine put in the module's .gitignore go the same way — a 1.0 module commits those files, and a stale line hides them from the engine — while every other line stays.

Settings

The SDK's settings become flags on dagger module init and dagger module client add, and are persisted per scope in dagger.toml:

Setting Flag Default
runtime --runtime detected from the scope's config files, else node
template --template default (a small working module; empty is a bare @object class)
packageManager --package-manager unset
baseImage --base-image unset
legacyRuntime --legacy-runtime false (the module declares an [entrypoint])
targetVersion --target-version unset (the release this SDK targets, the only one it accepts)
dagger module init typescript --name my-module --runtime bun
dagger module init typescript --name my-module --template empty
dagger module init typescript --name my-module \
    --package-manager pnpm@8.15.4 \
    --base-image node:23.2.0-alpine

runtime is detected rather than defaulted, so adopting an existing project, or regenerating a module created before the setting existed, does not silently move a Bun or Deno project onto Node. Setting it moves the scope to that runtime on the next generation. A switch adds the new runtime's config files and leaves the old ones in place — a Node module moved to Deno gains a deno.json and keeps its package.json and tsconfig.json until you remove them — because generation never removes what it did not write.

--package-manager accepts the Node-standard name@version syntax (e.g. npm@10.7.0, pnpm@8.15.4, yarn@4.9.1). It is only valid with the Node runtime; Bun and Deno bundle their own. A yarn other than the 1.x the base image ships is installed through corepack, and installs the module's devDependencies along with the rest — Yarn Berry has no --prod of its own.

--base-image writes to deno.json for Deno modules and to package.json otherwise — matching where the engine reads it from.

--legacy-runtime picks the loading path, and a module is on one of them: by default it declares an [entrypoint] and carries an entrypoint/ directory holding main.dang plus the __dagger.dispatch.ts that program runs, which needs an engine carrying dagger/dagger#14038 — v1.0.0-beta.15 or newer. Pass the flag for an older engine and the module declares a [runtime] with the __dagger.entrypoint.ts that runtime execs instead.

An existing module moves between the two on its next generation: set legacyRuntime in the scope's settings in dagger.toml and run dagger generate. Onto an entrypoint, the manifest drops engineVersion, source, include and [[dependencies]] with the [runtime] they hang off. Back onto a runtime, the manifest is written fresh — engineVersion pinned to the release this SDK ships for, [[dependencies]] rebuilt from the scope's clients — and entrypoint/ and the clients/ packages are removed. A source or include the original manifest carried is not restored.

Pass it on a current engine too, for now: #14038 gates the entrypoint path on a manifestVersion that the manifest builder has no field for, so an [entrypoint] manifest written here reads as a runtime-less module and does not load anywhere yet. The default is what the generated tree should be; the flag is what runs today.

The engine release a module targets

Each release of this SDK targets one engine release: the one its library bundle is built for, v1.0.0-beta.25 today. A module's core bindings, the client.gen.ts in its sdk/ or clients/dagger/, are the library's own copy, generated for that release, and the library tells the session it starts to serve that release. So the core follows the SDK release, not the engine: updating the engine and running dagger generate writes nothing, and updating the SDK is what changes the generated core.

The clients for other modules, the <module>.gen.ts files, still come from loading those modules on the running engine, which is the only way to get their types. They can change when the engine does, and a dependency that uses a core type newer than the targeted release does not type-check until the SDK targets that release.

targetVersion pins the release a scope is generated for. This release of the SDK ships one bundle, so the only value it accepts is its own release, with or without the v; any other is refused by name, and an older release needs the SDK release built for it.

The Dagger version in entrypoint/dang.toml

Generating an entrypoint module writes entrypoint/dang.toml, with Dang language 2.1 and [imports.Dagger] dagger set to the release the SDK targets. The generated program also declares that release in DAGGER_CLIENT_VERSION. Language 2.1 is the language the entrypoint is written in; parsing a versioned import separately needs the Dang implementation at v2.2.0 or later, so older standalone Dang tooling may reject the file.

The two values reach the engine separately:

  • DAGGER_CLIENT_VERSION: v1.0.0-beta.22 reads it, through dagger/dagger#14881. Earlier releases do not.
  • dang.toml: released engines through v1.0.0-beta.22 ignore it, because they copy only .dang files when they evaluate an entrypoint. dagger/dagger#14902, still open, reads the nearest dang.toml inside the module. Its version is meant to set the entrypoint's view, and the module process is meant to use that view by default.

v1.0.0-beta.22 is the first prerelease with an API view of its own; v1.0.0-beta.20 and v1.0.0-beta.21 share one.

The version checks read what generation writes, which does not depend on the engine running them. No check shows either value taking effect at run time. An existing module gets the file on its next dagger generate.

An entrypoint this SDK generated has its Dagger version managed: dagger = true or another version is replaced on every generation. Other settings and imports are kept by value. The file is rendered through TOML, so its comments and formatting are lost the first time it changes.

Generation refuses, and writes nothing, when the file:

  • declares a Dang language other than 2.1
  • gives the Dagger import a path or a non-empty endpoint
  • pins another Dagger import to a different release, or gives a versioned one a non-empty endpoint

Spellings of the target release that targetVersion accepts count as that release.

It also refuses a Dagger version it did not generate, in an entrypoint/dang.toml beside a remote or hand-written entrypoint, which generation replaces.

#14902 does not merge files, so generation refuses to lose settings from the file that governs the local entrypoint it replaces: the nearest dang.toml from that entrypoint's source up to the module root, even when entrypoint/dang.toml already exists. A module-root file is also refused when a new entrypoint/dang.toml would shadow it. That covers a differing pin and any setting other than the Dagger version. Move the settings to entrypoint/dang.toml; relative paths are not rebased for you. Generation does not read a dang.toml above the module, which #14902 also ignores, and leaves it alone. The same goes for one that only shares a remote entrypoint's leading path segments.

A local entrypoint source that leaves the module directory is refused, as the engine refuses it. Fix source in dagger-module.toml first.

Moving a generated module to legacyRuntime removes entrypoint/dang.toml only when it still says exactly what generation writes. A file that stays keeps its contents, and moving back keeps them too as long as its Dagger version is the target release. Otherwise generation refuses until you update or remove that version.

Generate a typed client

Record a client for a module in the current scope:

dagger module client add typescript .dagger/modules/api
dagger module client add typescript github.com/acme/payments

There is no path argument: the SDK picks the layout. Which scope the client lands in comes from findClientRoot, which answers with the directory of the nearest package.json, deno.json, deno.jsonc or tsconfig.json above your cwd — so a client belongs to the TypeScript project you are standing in. A directory with no TypeScript project above it is not a scope this SDK can claim.

What a scope gets depends on what the scope is:

Scope Client output
A module sdk/ (the library) plus clients/ (its per-module clients)
Your own project clients/ — the vendored library plus one package per target, wired through your package.json

A module keeps its targets in a clients/ directory beside the library: every module — a dependency, a recorded target, the module itself — becomes its own clients/<module>.gen.ts client with its own dag and entrypoint functions, reached through its own specifier. sdk/ stays the core-only @dagger.io/dagger library:

import { dag, Container } from "@dagger.io/dagger" // core API (sdk/)
import { api } from "@dagger.io/api"               // a bound module (clients/api.gen.ts)

api().deploy(dag.container().from("alpine"))

The @dagger.io/<module> aliases are written into tsconfig.json (or deno.json) at generation, so a client added inside a module is usable from it straight away.

A standalone client scope renders the same client files as a module, packaged as real npm packages under the scope:

<scope>/
  package.json     yours; gains a file: dependency on each package below
  clients/
    dagger/        the vendored @dagger.io/dagger library
    <module>/      one package per target, named @dagger.io/<module>

The scope's package.json is the link. The SDK writes "@dagger.io/dagger": "file:./clients/dagger" and one "@dagger.io/<module>": "file:./clients/<module>" per target into its dependencies, pins typescript unless you declare one in any section, and sets "type": "module" when the field is absent. That is the whole edit: every other key is yours, an explicit "type" — "commonjs" included — stays, and you run the install. Each client's own file: dependencies pull the library (and any sibling it references) along:

npm install
import { connection, dag } from "@dagger.io/dagger"
import { api } from "@dagger.io/api"

await connection(async () => {
  console.log(await api(dag.currentWorkspace()).deploy(dag.container().from("alpine")))
})

Run it through dagger run, which opens the session connection attaches to:

dagger run -- npx -y tsx main.ts

The -- is needed: dagger run parses flags after the command, so -y would be read as its own. And it is tsx rather than node because the packages ship .ts sources with .js import specifiers, which plain node main.ts rejects with ERR_MODULE_NOT_FOUND.

No path aliases, no remote @dagger.io/dagger dependency — plain package resolution against the vendored, offline tree. The same packages can later come from a registry instead of a file: link.

A Deno project — one with a deno.json — is wired through its import map instead: @dagger.io/dagger and each @dagger.io/<module> land in deno.json's imports, with the unstable flags the library needs under Deno, and no package.json is written — plain deno check and deno run resolve them, with no install and no --unstable-* flags.

clients is the complete desired set, so dagger module client rm is just regeneration without that target: its <module>/ package goes, and the last target leaving takes the vendored dagger/ library and the scope's file: dependencies with it. Only what the SDK generates is removed — every *.gen.ts under clients/, a package's package.json, the library's bundle files — and a directory only once that leaves nothing in it. A file of your own under clients/, beside the packages or inside one, stays, and so does the directory holding it.

Regenerate

dagger generate

The engine reads the scope list, orders it so a module is generated before anything holding a client for it, threads each result into the next, and calls this SDK once per scope.

Development

Run the checks:

dagger check

e-2-e:* drives this SDK's functions the way the engine does, one file per surface (discovery, init, generate, client), sharing the assertions in util.dang and the fixture tree under .dagger/modules/e2e/fixtures. runtimes:* generates a module per JavaScript runtime and loads it. List them with dagger check -l, or run one group with dagger check "e-2-e:discovery:*".

engine-e-2-e:* covers the half no dang check can reach: it builds an engine from dagger/dagger#13992, runs it as a playground with this checkout mounted, and drives the real CLI through sdk list, module init with and without settings, call, and the whole check suite. Provider validation is silent when it fails — the engine simply never records [sdks.typescript] — so this is what tells you the interface still matches. Bumping the branch means changing both the engine-dev dependency in .dagger/modules/engine-e2e/dagger-module.toml and engineCommit in .dagger/modules/engine-e2e/main.dang.

See typescript-sdk.dang for the full type surface and design/module-max.md for why it is shaped this way.

About

Develop Dagger modules in TypeScript

Resources

Code of conduct

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages