diff --git a/.dagger/config.toml b/.dagger/config.toml index b0d2cf9..c723024 100644 --- a/.dagger/config.toml +++ b/.dagger/config.toml @@ -8,3 +8,6 @@ source = ".." # Marker filename that skips generate when found at or above a TypeScript SDK module root. # settings.skipGenerateFilename = "" + +[modules.e2e] +source = "modules/e2e" diff --git a/.dagger/lock b/.dagger/lock new file mode 100644 index 0000000..e69de29 diff --git a/.dagger/modules/e2e/.dagger/lock b/.dagger/modules/e2e/.dagger/lock new file mode 100644 index 0000000..e69de29 diff --git a/.dagger/modules/e2e/.dagger/modules/e2e/.dagger/lock b/.dagger/modules/e2e/.dagger/modules/e2e/.dagger/lock new file mode 100644 index 0000000..e69de29 diff --git a/.dagger/modules/e2e/fixtures/config/.dagger-typescript-sdk-skip-generate b/.dagger/modules/e2e/fixtures/config/.dagger-typescript-sdk-skip-generate new file mode 100644 index 0000000..e69de29 diff --git a/.dagger/modules/e2e/fixtures/config/app/dagger.json b/.dagger/modules/e2e/fixtures/config/app/dagger.json new file mode 100644 index 0000000..585e253 --- /dev/null +++ b/.dagger/modules/e2e/fixtures/config/app/dagger.json @@ -0,0 +1,7 @@ +{ + "name": "config-app", + "engineVersion": "latest", + "sdk": { + "source": "typescript" + } +} diff --git a/.dagger/modules/e2e/fixtures/config/app/package.json b/.dagger/modules/e2e/fixtures/config/app/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/.dagger/modules/e2e/fixtures/config/app/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/.dagger/modules/e2e/fixtures/config/app/src/index.ts b/.dagger/modules/e2e/fixtures/config/app/src/index.ts new file mode 100644 index 0000000..e81e0f0 --- /dev/null +++ b/.dagger/modules/e2e/fixtures/config/app/src/index.ts @@ -0,0 +1,9 @@ +import { object, func } from "@dagger.io/dagger" + +@object() +export class ConfigApp { + @func() + hello(): string { + return "hello" + } +} diff --git a/.dagger/modules/e2e/fixtures/config/app/tsconfig.json b/.dagger/modules/e2e/fixtures/config/app/tsconfig.json new file mode 100644 index 0000000..44cf48f --- /dev/null +++ b/.dagger/modules/e2e/fixtures/config/app/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "ES2020", + "experimentalDecorators": true, + "paths": { + "@dagger.io/dagger": ["./sdk/index.ts"], + "@dagger.io/dagger/telemetry": ["./sdk/telemetry.ts"] + } + } +} diff --git a/.dagger/modules/e2e/fixtures/config/configured-deno/dagger.json b/.dagger/modules/e2e/fixtures/config/configured-deno/dagger.json new file mode 100644 index 0000000..0f26507 --- /dev/null +++ b/.dagger/modules/e2e/fixtures/config/configured-deno/dagger.json @@ -0,0 +1,7 @@ +{ + "name": "config-configured-deno", + "engineVersion": "latest", + "sdk": { + "source": "typescript" + } +} diff --git a/.dagger/modules/e2e/fixtures/config/configured-deno/deno.json b/.dagger/modules/e2e/fixtures/config/configured-deno/deno.json new file mode 100644 index 0000000..bef74ca --- /dev/null +++ b/.dagger/modules/e2e/fixtures/config/configured-deno/deno.json @@ -0,0 +1,13 @@ +{ + "nodeModulesDir": "auto", + "compilerOptions": { + "experimentalDecorators": true + }, + "imports": { + "@dagger.io/dagger": "./sdk/index.ts", + "@dagger.io/dagger/telemetry": "./sdk/telemetry.ts" + }, + "dagger": { + "baseImage": "denoland/deno:alpine-2.0.0" + } +} diff --git a/.dagger/modules/e2e/fixtures/config/configured-deno/src/index.ts b/.dagger/modules/e2e/fixtures/config/configured-deno/src/index.ts new file mode 100644 index 0000000..aae88e6 --- /dev/null +++ b/.dagger/modules/e2e/fixtures/config/configured-deno/src/index.ts @@ -0,0 +1,9 @@ +import { object, func } from "@dagger.io/dagger" + +@object() +export class ConfigConfiguredDeno { + @func() + hello(): string { + return "hello" + } +} diff --git a/.dagger/modules/e2e/fixtures/config/configured/dagger.json b/.dagger/modules/e2e/fixtures/config/configured/dagger.json new file mode 100644 index 0000000..e97ecb8 --- /dev/null +++ b/.dagger/modules/e2e/fixtures/config/configured/dagger.json @@ -0,0 +1,7 @@ +{ + "name": "config-configured", + "engineVersion": "latest", + "sdk": { + "source": "typescript" + } +} diff --git a/.dagger/modules/e2e/fixtures/config/configured/package.json b/.dagger/modules/e2e/fixtures/config/configured/package.json new file mode 100644 index 0000000..f009a5a --- /dev/null +++ b/.dagger/modules/e2e/fixtures/config/configured/package.json @@ -0,0 +1,7 @@ +{ + "type": "module", + "packageManager": "pnpm@8.15.4", + "dagger": { + "baseImage": "node:23.2.0-alpine" + } +} diff --git a/.dagger/modules/e2e/fixtures/config/configured/src/index.ts b/.dagger/modules/e2e/fixtures/config/configured/src/index.ts new file mode 100644 index 0000000..b992f8d --- /dev/null +++ b/.dagger/modules/e2e/fixtures/config/configured/src/index.ts @@ -0,0 +1,9 @@ +import { object, func } from "@dagger.io/dagger" + +@object() +export class ConfigConfigured { + @func() + hello(): string { + return "hello" + } +} diff --git a/.dagger/modules/e2e/fixtures/config/configured/tsconfig.json b/.dagger/modules/e2e/fixtures/config/configured/tsconfig.json new file mode 100644 index 0000000..44cf48f --- /dev/null +++ b/.dagger/modules/e2e/fixtures/config/configured/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "ES2020", + "experimentalDecorators": true, + "paths": { + "@dagger.io/dagger": ["./sdk/index.ts"], + "@dagger.io/dagger/telemetry": ["./sdk/telemetry.ts"] + } + } +} diff --git a/.dagger/modules/e2e/fixtures/generate/app/.gitattributes b/.dagger/modules/e2e/fixtures/generate/app/.gitattributes new file mode 100644 index 0000000..8274184 --- /dev/null +++ b/.dagger/modules/e2e/fixtures/generate/app/.gitattributes @@ -0,0 +1 @@ +/sdk/** linguist-generated diff --git a/.dagger/modules/e2e/fixtures/generate/app/.gitignore b/.dagger/modules/e2e/fixtures/generate/app/.gitignore new file mode 100644 index 0000000..040187c --- /dev/null +++ b/.dagger/modules/e2e/fixtures/generate/app/.gitignore @@ -0,0 +1,4 @@ +/sdk +/**/node_modules/** +/**/.pnpm-store/** +/.env diff --git a/.dagger/modules/e2e/fixtures/generate/app/package.json b/.dagger/modules/e2e/fixtures/generate/app/package.json new file mode 100644 index 0000000..fe4de26 --- /dev/null +++ b/.dagger/modules/e2e/fixtures/generate/app/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +,"dependencies":{"typescript":"5.9.3"}} \ No newline at end of file diff --git a/.dagger/modules/e2e/fixtures/generate/app/src/index.ts b/.dagger/modules/e2e/fixtures/generate/app/src/index.ts new file mode 100644 index 0000000..dc8ca25 --- /dev/null +++ b/.dagger/modules/e2e/fixtures/generate/app/src/index.ts @@ -0,0 +1,41 @@ +/** + * A generated module for GenerateApp functions + * + * This module has been generated via dagger module init and serves as a reference to + * basic module structure as you get started with Dagger. + * + * Two functions have been pre-created. You can modify, delete, or add to them, + * as needed. They demonstrate usage of arguments and return types using simple + * echo and grep commands. The functions can be called from the dagger CLI or + * from one of the SDKs. + * + * The first line in this comment block is a short description line and the + * rest is a long description with more detail on the module's purpose or usage, + * if appropriate. All modules should have a short description. + */ +import { dag, Container, Directory, object, func } from "@dagger.io/dagger" + +@object() +export class GenerateApp { + /** + * Returns a container that echoes whatever string argument is provided + */ + @func() + containerEcho(stringArg: string): Container { + return dag.container().from("alpine:latest").withExec(["echo", stringArg]) + } + + /** + * Returns lines that match a pattern in the files of the provided Directory + */ + @func() + async grepDir(directoryArg: Directory, pattern: string): Promise { + return dag + .container() + .from("alpine:latest") + .withMountedDirectory("/mnt", directoryArg) + .withWorkdir("/mnt") + .withExec(["grep", "-R", pattern, "."]) + .stdout() + } +} diff --git a/.dagger/modules/e2e/fixtures/generate/app/tsconfig.json b/.dagger/modules/e2e/fixtures/generate/app/tsconfig.json new file mode 100644 index 0000000..4ec0c2d --- /dev/null +++ b/.dagger/modules/e2e/fixtures/generate/app/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "moduleResolution": "Node", + "experimentalDecorators": true, + "strict": true, + "skipLibCheck": true, + "paths": { + "@dagger.io/dagger": ["./sdk/index.ts"], + "@dagger.io/dagger/telemetry": ["./sdk/telemetry.ts"] + } + } +} diff --git a/.dagger/modules/e2e/fixtures/generate/app/yarn.lock b/.dagger/modules/e2e/fixtures/generate/app/yarn.lock new file mode 100644 index 0000000..07e8392 --- /dev/null +++ b/.dagger/modules/e2e/fixtures/generate/app/yarn.lock @@ -0,0 +1,8 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +typescript@5.9.3: + version "5.9.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" + integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== diff --git a/.dagger/modules/e2e/main.dang b/.dagger/modules/e2e/main.dang index 103676a..4b4b3ea 100644 --- a/.dagger/modules/e2e/main.dang +++ b/.dagger/modules/e2e/main.dang @@ -14,6 +14,9 @@ type E2e { let existingNodePath: String! = fixtureRoot + "/init-existing/node-app" let existingDenoPath: String! = fixtureRoot + "/init-existing/deno-app" let existingBunPath: String! = fixtureRoot + "/init-existing/bun-app" + let configModulePath: String! = fixtureRoot + "/config/app" + let configuredModulePath: String! = fixtureRoot + "/config/configured" + let configuredDenoModulePath: String! = fixtureRoot + "/config/configured-deno" """ Fail the current check when a condition is false. @@ -65,6 +68,16 @@ type E2e { assert(changes.removedPaths.length == 0, "dependency edit should not remove generated files") } + """ + Assert that a config edit modified only the named file (no adds, no removes). + """ + let assertOnlyFileChanged(changes: Changeset!, path: String!, message: String!): Void { + assert(contains(changes.modifiedPaths, path), message + ": did not modify " + path) + assert(changes.modifiedPaths.length == 1, message + ": modified more than one file") + assert(changes.addedPaths.length == 0, message + ": should not add files") + assert(changes.removedPaths.length == 0, message + ": should not remove files") + } + """ The helper should expose the generate skip marker used by callers. """ @@ -185,7 +198,7 @@ type E2e { pub generateCheck(ws: Workspace!): Void @check { let changes = typescriptSdk.mod(ws, path: generateModulePath).generate(ws) - assertAdded(changes, generateModulePath + "/src/index.ts") + assertAdded(changes, generateModulePath + "/sdk/index.ts") null } @@ -196,7 +209,7 @@ type E2e { pub generateAllCheck(ws: Workspace!): Void @check { let changes = typescriptSdk.generateAll(ws) - assertAdded(changes, generateModulePath + "/src/index.ts") + assertAdded(changes, generateModulePath + "/sdk/index.ts") assert(contains(changes.addedPaths, lookupModulePath + "/src/index.ts") == false, "generateAll generated a lookup fixture that should be skipped") assert(contains(changes.addedPaths, depsModulePath + "/src/index.ts") == false, "generateAll generated dependency fixtures that should be skipped") assert(contains(changes.addedPaths, skipModulePath + "/src/index.ts") == false, "generateAll generated a skipped module") @@ -288,6 +301,100 @@ type E2e { null } + """ + Config readers should reflect package.json (or deno.json), and mutators + should edit only the targeted config file while preserving unrelated keys. + """ + pub configCheck(ws: Workspace!): Void @check { + let app = typescriptSdk.mod(ws, path: configModulePath).config + let configured = typescriptSdk.mod(ws, path: configuredModulePath).config + let configuredDeno = typescriptSdk.mod(ws, path: configuredDenoModulePath).config + + # Defaults: unconfigured node module reads empty. + assert(app.packageManager == "", "default packageManager should be empty") + assert(app.baseImage == "", "default baseImage should be empty") + + # Configured node module reads the override values. + assert(configured.packageManager == "pnpm@8.15.4", "configured packageManager should read pnpm@8.15.4") + assert(configured.baseImage == "node:23.2.0-alpine", "configured baseImage should read the override") + + # Deno module: no package.json, baseImage comes from deno.json. + assert(configuredDeno.packageManager == "", "deno-only module should expose no packageManager") + assert(configuredDeno.baseImage == "denoland/deno:alpine-2.0.0", "deno baseImage should read from deno.json") + + let appPkg = configModulePath + "/package.json" + + let pm = app.setPackageManager("yarn@1.22.22") + assertOnlyFileChanged(pm, appPkg, "setPackageManager") + assertContains(pm.after.file(appPkg).contents, "yarn@1.22.22", "setPackageManager did not write the new value") + assertContains(pm.after.file(appPkg).contents, "\"type\": \"module\"", "setPackageManager dropped unrelated keys") + + let img = app.setBaseImage("node:23.2.0-alpine") + assertOnlyFileChanged(img, appPkg, "setBaseImage") + assertContains(img.after.file(appPkg).contents, "node:23.2.0-alpine", "setBaseImage did not write the image") + + let configuredPkg = configuredModulePath + "/package.json" + + let unsetPm = configured.unsetPackageManager + assertOnlyFileChanged(unsetPm, configuredPkg, "unsetPackageManager") + assertNotContains(unsetPm.after.file(configuredPkg).contents, "packageManager", "unsetPackageManager left the field") + assertContains(unsetPm.after.file(configuredPkg).contents, "node:23.2.0-alpine", "unsetPackageManager clobbered dagger.baseImage") + + let unsetImg = configured.unsetBaseImage + assertOnlyFileChanged(unsetImg, configuredPkg, "unsetBaseImage") + assertNotContains(unsetImg.after.file(configuredPkg).contents, "baseImage", "unsetBaseImage left the override") + assertContains(unsetImg.after.file(configuredPkg).contents, "pnpm@8.15.4", "unsetBaseImage clobbered packageManager") + + # Deno modules: baseImage edits route to deno.json, not package.json. + let configuredDenoFile = configuredDenoModulePath + "/deno.json" + + let denoImg = configuredDeno.setBaseImage("denoland/deno:latest") + assertOnlyFileChanged(denoImg, configuredDenoFile, "setBaseImage on deno module") + assertContains(denoImg.after.file(configuredDenoFile).contents, "denoland/deno:latest", "setBaseImage did not write to deno.json") + + let unsetDenoImg = configuredDeno.unsetBaseImage + assertOnlyFileChanged(unsetDenoImg, configuredDenoFile, "unsetBaseImage on deno module") + assertNotContains(unsetDenoImg.after.file(configuredDenoFile).contents, "baseImage", "unsetBaseImage left the override in deno.json") + assertContains(unsetDenoImg.after.file(configuredDenoFile).contents, "nodeModulesDir", "unsetBaseImage clobbered unrelated deno keys") + + null + } + + """ + Init flags should write configuration into the generated config files. By + default, no config keys are written; the runtime decides which file the + base image override lands in. + """ + pub initConfigCheck(ws: Workspace!): Void @check { + let configuredPath = outputRoot + "/init-configured" + let configuredDenoPath = outputRoot + "/init-configured-deno" + let defaultPath = outputRoot + "/init-config-default" + + let configured = typescriptSdk.init(ws, name: "init-configured", path: configuredPath, packageManager: "pnpm@8.15.4", baseImage: "node:23.2.0-alpine") + let pkg = configuredPath + "/package.json" + assertAdded(configured, pkg) + assert(configured.modifiedPaths.length == 0, "configured init should not modify existing files") + assert(configured.removedPaths.length == 0, "configured init should not remove files") + assertContains(configured.layer.file(pkg).contents, "pnpm@8.15.4", "init --package-manager not written") + assertContains(configured.layer.file(pkg).contents, "node:23.2.0-alpine", "init --base-image not written") + + # Deno init with baseImage routes to deno.json; no package.json is created. + let configuredDeno = typescriptSdk.init(ws, name: "init-configured-deno", path: configuredDenoPath, runtime: TypescriptSdkRuntime.DENO, baseImage: "denoland/deno:alpine-2.0.0") + let denoFile = configuredDenoPath + "/deno.json" + assertAdded(configuredDeno, denoFile) + assertContains(configuredDeno.layer.file(denoFile).contents, "denoland/deno:alpine-2.0.0", "init deno --base-image not written") + assert(contains(configuredDeno.addedPaths, configuredDenoPath + "/package.json") == false, "deno init should not generate package.json even with baseImage") + + # Default init has no config keys. + let default = typescriptSdk.init(ws, name: "init-config-default", path: defaultPath) + let defaultPkg = defaultPath + "/package.json" + assertAdded(default, defaultPkg) + assertNotContains(default.layer.file(defaultPkg).contents, "packageManager", "default init should not write packageManager") + assertNotContains(default.layer.file(defaultPkg).contents, "baseImage", "default init should not write baseImage") + + null + } + """ Listing modules in a workspace should return every module whose dagger.json declares the TypeScript SDK, and nothing else. diff --git a/README.md b/README.md index afe9f98..cfdb633 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,44 @@ settings are preserved. `init` only seeds template and config files. Run `mod ... generate` to produce the generated SDK. +### Configure a module at creation + +`init` accepts configuration flags written into the module's `package.json` +(or `deno.json` for Deno modules): + +```sh +dagger call typescript-sdk init --name my-module \ + --package-manager pnpm@8.15.4 \ + --base-image node:23.2.0-alpine +``` + +Both flags are optional. By default no `packageManager` field is written and +no base image override is set. `--package-manager` is not valid with +`--runtime DENO`. + +## Configure an existing module + +Read current configuration: + +```sh +dagger call typescript-sdk mod --path my-module config package-manager +dagger call typescript-sdk mod --path my-module config base-image +``` + +Change configuration (each prints a diff to confirm before writing): + +```sh +dagger call typescript-sdk mod --path my-module \ + config set-package-manager --value pnpm@8.15.4 +dagger call typescript-sdk mod --path my-module \ + config set-base-image --image node:23.2.0-alpine +dagger call typescript-sdk mod --path my-module config unset-package-manager +dagger call typescript-sdk mod --path my-module config unset-base-image +``` + +`base-image` writes to `deno.json` for Deno modules and to `package.json` +otherwise — matching where the engine reads it from. + ## Generate SDK files For a single module: diff --git a/dagger.json b/dagger.json index 1c8156e..3c91b01 100644 --- a/dagger.json +++ b/dagger.json @@ -8,7 +8,7 @@ { "name": "polyfill", "source": "github.com/dagger/sdk-sdk/polyfill@main", - "pin": "d6ab6406586e6b3853b8936b2b3a96bba2554071" + "pin": "09da9576688ec4b495ae4a9443a4719da86ed25e" } ], "source": "." diff --git a/helpers/module-config/config.go b/helpers/module-config/config.go new file mode 100644 index 0000000..c534c18 --- /dev/null +++ b/helpers/module-config/config.go @@ -0,0 +1,49 @@ +package main + +import ( + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// getPackageManager reads the top-level "packageManager" field from a +// package.json. Returns "" when the key is absent. +func getPackageManager(input string) string { + return gjson.Get(input, "packageManager").String() +} + +// setPackageManager writes the top-level "packageManager" field. +func setPackageManager(input, value string) (string, error) { + return sjson.Set(input, "packageManager", value) +} + +// unsetPackageManager removes the top-level "packageManager" field. +func unsetPackageManager(input string) (string, error) { + return sjson.Delete(input, "packageManager") +} + +// getBaseImage reads "dagger.baseImage" from either a package.json or a +// deno.json (the engine accepts the same key in both). Returns "" when absent. +func getBaseImage(input string) string { + return gjson.Get(input, "dagger.baseImage").String() +} + +// setBaseImage writes "dagger.baseImage", creating the nested "dagger" object +// when necessary. +func setBaseImage(input, value string) (string, error) { + return sjson.Set(input, "dagger.baseImage", value) +} + +// unsetBaseImage removes "dagger.baseImage" and prunes the "dagger" object +// when it would otherwise be left empty, so the file does not carry an empty +// table after the round-trip. +func unsetBaseImage(input string) (string, error) { + out, err := sjson.Delete(input, "dagger.baseImage") + if err != nil { + return "", err + } + dagger := gjson.Get(out, "dagger") + if dagger.IsObject() && len(dagger.Map()) == 0 { + return sjson.Delete(out, "dagger") + } + return out, nil +} diff --git a/helpers/module-config/config_test.go b/helpers/module-config/config_test.go new file mode 100644 index 0000000..f3e775a --- /dev/null +++ b/helpers/module-config/config_test.go @@ -0,0 +1,97 @@ +package main + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +const samplePackageJSON = `{ + "type": "module", + "name": "demo", + "dependencies": { + "typescript": "5.9.3" + } +}` + +const configuredPackageJSON = `{ + "type": "module", + "packageManager": "pnpm@8.15.4", + "dagger": { + "baseImage": "node:23.2.0-alpine" + } +}` + +const sampleDenoJSON = `{ + "imports": { + "@user/lib": "./lib.ts" + } +}` + +func TestGetPackageManager(t *testing.T) { + require.Equal(t, "", getPackageManager(samplePackageJSON)) + require.Equal(t, "pnpm@8.15.4", getPackageManager(configuredPackageJSON)) + require.Equal(t, "", getPackageManager("")) +} + +func TestGetBaseImage(t *testing.T) { + require.Equal(t, "", getBaseImage(samplePackageJSON)) + require.Equal(t, "node:23.2.0-alpine", getBaseImage(configuredPackageJSON)) + require.Equal(t, "", getBaseImage(sampleDenoJSON)) +} + +func TestSetPackageManagerPreservesData(t *testing.T) { + out, err := setPackageManager(samplePackageJSON, "yarn@1.22.22") + require.NoError(t, err) + require.Contains(t, out, `"packageManager":"yarn@1.22.22"`) + require.Contains(t, out, `"typescript": "5.9.3"`, "unrelated keys were dropped") +} + +func TestUnsetPackageManager(t *testing.T) { + out, err := unsetPackageManager(configuredPackageJSON) + require.NoError(t, err) + require.NotContains(t, out, "packageManager") + require.Contains(t, out, `"baseImage"`, "unrelated dagger config should be preserved") +} + +func TestSetBaseImageOnEmpty(t *testing.T) { + out, err := setBaseImage("{}", "node:23.2.0-alpine") + require.NoError(t, err) + require.Equal(t, "node:23.2.0-alpine", getBaseImage(out)) +} + +func TestSetBaseImagePreservesSiblings(t *testing.T) { + out, err := setBaseImage(samplePackageJSON, "node:23.2.0-alpine") + require.NoError(t, err) + require.Equal(t, "node:23.2.0-alpine", getBaseImage(out)) + require.Contains(t, out, `"typescript": "5.9.3"`, "unrelated keys were dropped") +} + +func TestUnsetBaseImagePrunesEmptyDagger(t *testing.T) { + in := `{"dagger":{"baseImage":"foo"}}` + out, err := unsetBaseImage(in) + require.NoError(t, err) + require.NotContains(t, out, "dagger", "empty dagger object should be pruned") +} + +func TestUnsetBaseImageKeepsSiblings(t *testing.T) { + in := `{"dagger":{"baseImage":"foo","runtime":"node@20.15.0"}}` + out, err := unsetBaseImage(in) + require.NoError(t, err) + require.NotContains(t, out, "baseImage") + require.Contains(t, out, "runtime", "sibling dagger keys should survive an unset") +} + +func TestUnsetBaseImageOnEmptyDoc(t *testing.T) { + out, err := unsetBaseImage("{}") + require.NoError(t, err) + require.Equal(t, "", strings.TrimSpace(strings.Trim(out, "{}"))) +} + +func TestSetBaseImageOnDenoJSON(t *testing.T) { + out, err := setBaseImage(sampleDenoJSON, "denoland/deno:alpine") + require.NoError(t, err) + require.Equal(t, "denoland/deno:alpine", getBaseImage(out)) + require.Contains(t, out, "@user/lib", "unrelated deno keys should be preserved") +} diff --git a/helpers/module-config/go.mod b/helpers/module-config/go.mod new file mode 100644 index 0000000..741ee81 --- /dev/null +++ b/helpers/module-config/go.mod @@ -0,0 +1,17 @@ +module module-config + +go 1.25.1 + +require ( + github.com/stretchr/testify v1.11.1 + github.com/tidwall/gjson v1.19.0 + github.com/tidwall/sjson v1.2.5 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/helpers/module-config/go.sum b/helpers/module-config/go.sum new file mode 100644 index 0000000..7fe7087 --- /dev/null +++ b/helpers/module-config/go.sum @@ -0,0 +1,19 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= +github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/helpers/module-config/main.go b/helpers/module-config/main.go new file mode 100644 index 0000000..480f00a --- /dev/null +++ b/helpers/module-config/main.go @@ -0,0 +1,99 @@ +// module-config edits Dagger module configuration written into a JSON config +// file (package.json or deno.json). get-* commands print the current value; +// set-*/unset-* commands edit the file in place, preserving unrelated keys. +package main + +import ( + "bytes" + "errors" + "fmt" + "io/fs" + "os" +) + +func main() { + if err := run(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +// run dispatches a subcommand. get-* commands print to stdout (no trailing +// newline). set-*/unset-* commands rewrite the file in place. +// +// usage: module-config [value] +func run(args []string) error { + if len(args) < 2 { + return fmt.Errorf("usage: module-config [value]") + } + cmd, path := args[0], args[1] + + input, err := readOrEmpty(path) + if err != nil { + return err + } + + switch cmd { + case "get-package-manager": + fmt.Print(getPackageManager(input)) + return nil + case "get-base-image": + fmt.Print(getBaseImage(input)) + return nil + case "set-package-manager": + v, err := value(args) + if err != nil { + return err + } + out, err := setPackageManager(input, v) + if err != nil { + return err + } + return os.WriteFile(path, []byte(out), 0o644) + case "set-base-image": + v, err := value(args) + if err != nil { + return err + } + out, err := setBaseImage(input, v) + if err != nil { + return err + } + return os.WriteFile(path, []byte(out), 0o644) + case "unset-package-manager": + out, err := unsetPackageManager(input) + if err != nil { + return err + } + return os.WriteFile(path, []byte(out), 0o644) + case "unset-base-image": + out, err := unsetBaseImage(input) + if err != nil { + return err + } + return os.WriteFile(path, []byte(out), 0o644) + default: + return fmt.Errorf("unknown command: %s", cmd) + } +} + +func value(args []string) (string, error) { + if len(args) < 3 { + return "", fmt.Errorf("%s requires a value", args[0]) + } + return args[2], nil +} + +func readOrEmpty(path string) (string, error) { + contents, err := os.ReadFile(path) + switch { + case errors.Is(err, fs.ErrNotExist): + return "{}", nil + case err != nil: + return "", fmt.Errorf("read %s: %w", path, err) + } + if len(bytes.TrimSpace(contents)) == 0 { + return "{}", nil + } + return string(contents), nil +} diff --git a/helpers/module-config/main_test.go b/helpers/module-config/main_test.go new file mode 100644 index 0000000..b3da613 --- /dev/null +++ b/helpers/module-config/main_test.go @@ -0,0 +1,60 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func writeTemp(t *testing.T, contents string) string { + t.Helper() + dir := t.TempDir() + p := filepath.Join(dir, "package.json") + require.NoError(t, os.WriteFile(p, []byte(contents), 0o644)) + return p +} + +func TestRunSetThenGet(t *testing.T) { + p := writeTemp(t, samplePackageJSON) + + require.NoError(t, run([]string{"set-base-image", p, "node:23.2.0-alpine"})) + + data, err := os.ReadFile(p) + require.NoError(t, err) + require.Contains(t, string(data), "node:23.2.0-alpine") +} + +func TestRunGetDoesNotWrite(t *testing.T) { + p := writeTemp(t, configuredPackageJSON) + before, err := os.ReadFile(p) + require.NoError(t, err) + + require.NoError(t, run([]string{"get-package-manager", p})) + + after, err := os.ReadFile(p) + require.NoError(t, err) + require.Equal(t, before, after, "get-* must not modify the file") +} + +func TestRunUnknownCommand(t *testing.T) { + p := writeTemp(t, samplePackageJSON) + require.Error(t, run([]string{"bogus", p})) +} + +func TestRunRequiresValue(t *testing.T) { + p := writeTemp(t, samplePackageJSON) + require.Error(t, run([]string{"set-base-image", p})) +} + +func TestRunOnMissingFileTreatsAsEmpty(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "package.json") + + require.NoError(t, run([]string{"set-base-image", p, "node:23.2.0-alpine"})) + + data, err := os.ReadFile(p) + require.NoError(t, err) + require.Equal(t, "node:23.2.0-alpine", getBaseImage(string(data))) +} diff --git a/mod-config.dang b/mod-config.dang new file mode 100644 index 0000000..8deaeef --- /dev/null +++ b/mod-config.dang @@ -0,0 +1,124 @@ +""" +TypeScript SDK build configuration stored in a module's package.json +or deno.json. +""" +type ModConfig { + """ + Workspace-relative path of the module root. + """ + let path: String! + + """ + The workspace this module belongs to. + """ + let ws: Workspace! + + """ + The Node-standard packageManager field (e.g. "pnpm@8.15.4"), read from + package.json. Empty when unset, or when the module has no package.json. + """ + pub packageManager: String! { + if (hasFile(packageJsonPath)) { + tool(packageJsonPath).withExec(["module-config", "get-package-manager", toolPath]).stdout + } else { + "" + } + } + + """ + The base container image override, read from dagger.baseImage. For Deno + modules (deno.json present) the value comes from deno.json; otherwise from + package.json. Empty when no override is set. + """ + pub baseImage: String! { + let file = baseImageFile + if (hasFile(file)) { + tool(file).withExec(["module-config", "get-base-image", toolPath]).stdout + } else { + "" + } + } + + """ + Set the Node-standard packageManager field in package.json. + """ + pub setPackageManager(value: String!): Changeset! { + edit(packageJsonPath, ["module-config", "set-package-manager", toolPath, value]) + } + + """ + Remove the packageManager field from package.json. + """ + pub unsetPackageManager: Changeset! { + edit(packageJsonPath, ["module-config", "unset-package-manager", toolPath]) + } + + """ + Override the base container image. Writes to deno.json for Deno modules, + otherwise to package.json. + """ + pub setBaseImage(image: String!): Changeset! { + edit(baseImageFile, ["module-config", "set-base-image", toolPath, image]) + } + + """ + Remove the base image override and fall back to the SDK default. Edits + whichever file currently holds dagger.baseImage. + """ + pub unsetBaseImage: Changeset! { + edit(baseImageFile, ["module-config", "unset-base-image", toolPath]) + } + + let packageJsonPath: String! { + if (path == ".") { "package.json" } else { path + "/package.json" } + } + + let denoJsonPath: String! { + if (path == ".") { "deno.json" } else { path + "/deno.json" } + } + + """ + The config file that owns dagger.baseImage for this module: deno.json + when present at the module root, otherwise package.json. The engine reads + either, but per-module we keep one source of truth. + """ + let baseImageFile: String! { + if (hasFile(denoJsonPath)) { denoJsonPath } else { packageJsonPath } + } + + let toolPath: String! = "/work/config.json" + + let hasFile(file: String!): Boolean! { + ws.directory("/", include: [file]).exists(file) + } + + """ + Container with the module-config helper built and the requested config file + mounted at toolPath. Missing files are seeded as "{}" so set-* commands + can materialize a new file (e.g. set-package-manager on a deno-only + module). + """ + let tool(file: String!): Container! { + let base = container + .from("golang:1.25-alpine") + .withoutEntrypoint + .withMountedCache("/go/pkg/mod", cacheVolume("go-mod")) + .withMountedCache("/root/.cache/go-build", cacheVolume("go-build")) + .withDirectory("/helper", currentModule.source.directory("helpers/module-config")) + .withWorkdir("/helper") + .withExec(["go", "build", "-o", "/usr/local/bin/module-config", "."]) + if (hasFile(file)) { + base.withFile(toolPath, ws.directory("/", include: [file]).file(file)) + } else { + base.withNewFile(toolPath, "{}") + } + } + + """ + Run an edit command on a config file and return the resulting change. + """ + let edit(file: String!, args: [String!]!): Changeset! { + let edited = tool(file).withExec(args).file(toolPath).contents + polyfill.workspace(ws).fork.withNewFile(file, edited).changes + } +} diff --git a/mod.dang b/mod.dang index 131c46b..8485212 100644 --- a/mod.dang +++ b/mod.dang @@ -44,6 +44,17 @@ type Mod { ) } + """ + Manage this module's TypeScript SDK build configuration (package.json, + deno.json). + """ + pub config: ModConfig! { + ModConfig( + path: path, + ws: ws, + ) + } + """ Whether this module root or an ancestor contains a marker filename. """ diff --git a/typescript-sdk.dang b/typescript-sdk.dang index 9c5ff1c..04675c5 100644 --- a/typescript-sdk.dang +++ b/typescript-sdk.dang @@ -95,8 +95,13 @@ type TypescriptSdk { By default, future generated SDK files are checked into version control. Pass `ignoreGenerated` to configure generation to add generated SDK paths to .gitignore instead. + + Pass `packageManager` or `baseImage` to customize the generated config. + Defaults leave the template unconfigured: no `packageManager` field is + written, and no base image override is set. `packageManager` is invalid + with `runtime = DENO` since Deno does not use a Node package manager. """ - pub init(ws: Workspace!, name: String!, path: String! = "", template: String! = "", runtime: Runtime! = Runtime.NODE, ignoreGenerated: Boolean! = false): Changeset! { + pub init(ws: Workspace!, name: String!, path: String! = "", template: String! = "", runtime: Runtime! = Runtime.NODE, ignoreGenerated: Boolean! = false, packageManager: String! = "", baseImage: String! = ""): Changeset! { let rawPath = if (path == "") { let daggerDir = ws.findUp(".dagger", ".") if (daggerDir == null) { @@ -124,6 +129,8 @@ type TypescriptSdk { raise "module already exists: " + modPath } else if (currentModule.source.exists("templates/" + selectedTemplate) == false) { raise "unknown init template: " + template + } else if (runtime == Runtime.DENO and packageManager != "") { + raise "packageManager is only supported for node/bun runtimes; omit --package-manager when --runtime is DENO" } else { let includePrefix = if (modPath == ".") { "" } else { modPath + "/" } let existing = ws.directory("/", include: [ @@ -134,11 +141,12 @@ type TypescriptSdk { ]) let config = "{\n \"name\": " + toJSON(name) + ",\n \"engineVersion\": \"latest\",\n \"sdk\": {\n \"source\": \"typescript\"\n },\n \"codegen\": {\n \"automaticGitignore\": " + toJSON(ignoreGenerated) + "\n }\n}\n" - let templateSource = if (selectedTemplate == "default") { + let renderedSource = if (selectedTemplate == "default") { renderedDefaultTemplate(name, runtime, existing, modPath) } else { currentModule.source.directory("templates/" + selectedTemplate) } + let templateSource = configuredTemplate(renderedSource, runtime, packageManager, baseImage) let seeded = fork .withDirectory(modPath, templateSource) .withNewFile(configPath, config) @@ -224,6 +232,43 @@ type TypescriptSdk { withConfig.directory("/rendered") } + """ + Apply non-default `packageManager` / `baseImage` flags to a rendered template. + + When both flags are empty the source is returned unchanged (no helper run, + no reformatting). Otherwise the module-config helper edits the rendered + config files in place: `packageManager` always writes to package.json, + `baseImage` writes to deno.json for the DENO runtime and to package.json + otherwise — matching where ModConfig later reads from. + """ + let configuredTemplate(source: Directory!, runtime: Runtime!, packageManager: String!, baseImage: String!): Directory! { + if (packageManager == "" and baseImage == "") { + source + } else { + let baseImageFile = if (runtime == Runtime.DENO) { "/rendered/deno.json" } else { "/rendered/package.json" } + let built = container + .from("golang:1.25-alpine") + .withoutEntrypoint + .withMountedCache("/go/pkg/mod", cacheVolume("go-mod")) + .withMountedCache("/root/.cache/go-build", cacheVolume("go-build")) + .withDirectory("/helper", currentModule.source.directory("helpers/module-config")) + .withWorkdir("/helper") + .withExec(["go", "build", "-o", "/usr/local/bin/module-config", "."]) + .withDirectory("/rendered", source) + let withPm = if (packageManager == "") { + built + } else { + built.withExec(["module-config", "set-package-manager", "/rendered/package.json", packageManager]) + } + let withImg = if (baseImage == "") { + withPm + } else { + withPm.withExec(["module-config", "set-base-image", baseImageFile, baseImage]) + } + withImg.directory("/rendered") + } + } + """ Generate all discovered Go SDK modules. Modules with the generate skip marker are skipped.