From 65dd9baf614537a2bc53c7973f4f26a86ca6071d Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Sun, 11 Oct 2026 14:12:56 +0200 Subject: [PATCH 1/6] feat: scaffold publishable package libraries --- .changeset/yellow-tables-shop.md | 10 + .github/workflows/check-cli.yml | 3 + .okf/domain/contributions.md | 6 +- .okf/domain/planning.md | 5 +- .okf/guides/add-new-module.md | 15 +- apps/cli/package.json | 3 +- apps/cli/scripts/verify-library.ts | 258 ++++++++++++++++++ apps/cli/src/commands/catalog.ts | 15 +- apps/docs/app/content/how-it-works.mdx | 2 + .../app/content/publish-catalog-registry.mdx | 1 + apps/docs/app/content/publish-library.mdx | 53 ++++ apps/docs/app/lib/docs-manifest.ts | 8 + apps/docs/app/nav.config.ts | 1 + apps/docs/app/routes.ts | 1 + apps/docs/vercel.json | 9 + catalogs/official/src/moduleRegistry.test.ts | 15 +- catalogs/official/src/modules/packages.ts | 103 +++++++ catalogs/official/src/targetRegistry.ts | 2 +- .../templates/package-library/README.md | 32 +++ .../package-library/scripts/clean.mjs | 5 + .../templates/package-library/src/index.ts | 2 + .../package-library/tsconfig.build.json | 21 ++ docs/research/publishable-library-scaffold.md | 115 ++++++++ packages/authoring/src/buildCatalog.test.ts | 31 +++ packages/authoring/src/buildCatalog.ts | 10 +- packages/catalog/src/CatalogProtocol.ts | 1 + packages/domain/src/Catalog.ts | 30 +- packages/domain/src/Plan.ts | 12 + .../scaffold/src/service/ScaffolFormatter.ts | 1 + .../src/service/apply/JsonComposer.ts | 9 + .../src/service/plan/ContributionResolver.ts | 24 +- .../src/service/plan/PackageJsonFieldValue.ts | 11 + .../service/plan/PackageJsonFields.test.ts | 250 +++++++++++++++++ .../src/service/plan/PlanAssessor.test.ts | 1 + .../scaffold/src/service/plan/PlanAssessor.ts | 41 ++- .../scaffold/src/service/plan/PlanRenderer.ts | 10 + .../service/plan/PlanningIntentCompiler.ts | 46 +++- 37 files changed, 1131 insertions(+), 31 deletions(-) create mode 100644 .changeset/yellow-tables-shop.md create mode 100644 apps/cli/scripts/verify-library.ts create mode 100644 apps/docs/app/content/publish-library.mdx create mode 100644 catalogs/official/templates/package-library/README.md create mode 100644 catalogs/official/templates/package-library/scripts/clean.mjs create mode 100644 catalogs/official/templates/package-library/src/index.ts create mode 100644 catalogs/official/templates/package-library/tsconfig.build.json create mode 100644 docs/research/publishable-library-scaffold.md create mode 100644 packages/scaffold/src/service/plan/PackageJsonFieldValue.ts create mode 100644 packages/scaffold/src/service/plan/PackageJsonFields.test.ts diff --git a/.changeset/yellow-tables-shop.md b/.changeset/yellow-tables-shop.md new file mode 100644 index 00000000..88df2186 --- /dev/null +++ b/.changeset/yellow-tables-shop.md @@ -0,0 +1,10 @@ +--- +"stack-effect": minor +"@stack-effect/author": minor +--- + +Scaffold publishable ESM libraries with compiled JavaScript and TypeScript declarations. + +For example, `stack-effect add --target package/sdk:package-library` creates a library under `packages/sdk` with one root export and a build before npm packing. The starter has no child modules. + +Catalog authors can use `pkg-json-field` to set publishing fields such as `files`, `peerDependencies`, and conditional `exports`. Differing existing values require an Apply decision. diff --git a/.github/workflows/check-cli.yml b/.github/workflows/check-cli.yml index 7ce0bc59..704c95f2 100644 --- a/.github/workflows/check-cli.yml +++ b/.github/workflows/check-cli.yml @@ -109,3 +109,6 @@ jobs: - name: Verify Clean Install run: bun run --cwd packages/author verify:package + + - name: Verify Packed Library + run: bun run --cwd apps/cli verify:library diff --git a/.okf/domain/contributions.md b/.okf/domain/contributions.md index aa9e58ca..ddbecd91 100644 --- a/.okf/domain/contributions.md +++ b/.okf/domain/contributions.md @@ -8,7 +8,7 @@ sources: resource: ../../packages/domain/src/Catalog.ts - id: source-2 resource: ../../packages/domain/src/Scaffold.ts -generated: { by: codex, at: "2026-09-22T17:40:50+00:00" } +generated: { by: codex, at: "2026-10-11T14:06:18+02:00" } --- # Contributions and configuration @@ -20,7 +20,9 @@ Declarative desired repository state from targets/modules. Invariants: -- `Contribution` is a tagged union of `file`, `pkg-json-entry`, `barrel-export`, `ts-call-arg`, `ts-object-field`, and `jsx-slot`. `NormalizedContributions` groups resolved target and module contributions. +- `Contribution` is a tagged union of `file`, `pkg-json-entry`, `pkg-json-field`, `barrel-export`, `ts-call-arg`, `ts-object-field`, and `jsx-slot`. `NormalizedContributions` groups resolved target and module contributions. +- `pkg-json-field` sets one complete `private`, `files`, `types`, `publishConfig`, `peerDependencies`, `dependencies`, or `exports` field from a JSON value. Object values replace the field rather than merge its entries. Nested string values resolve contribution tokens. +- Competing values for a field, or whole-field and entry contributions to the same field, are invalid planning intent. Differing values already on disk produce `packageJsonField` conflicts for Apply decisions. - Values are declarative intent, not direct apply results. Connected terms: diff --git a/.okf/domain/planning.md b/.okf/domain/planning.md index f038cdfc..90ac66c9 100644 --- a/.okf/domain/planning.md +++ b/.okf/domain/planning.md @@ -8,7 +8,7 @@ sources: resource: ../../packages/domain/src/Plan.ts - id: source-2 resource: ../../packages/domain/src/Apply.ts -generated: { by: codex, at: "2026-09-22T17:40:50+00:00" } +generated: { by: codex, at: "2026-10-11T14:06:18+02:00" } --- # Planning and conflict terms @@ -39,7 +39,8 @@ Invariants: - Operations are tagged unions with `_tag` and `fileType` discriminators. - `fileType` is `"json"` or `"typescript"` for type-safe filtering. -- JSON operations: `json-pkg-exports`, `json-pkg-deps`, `json-pkg-scripts`. +- JSON operations: `json-pkg-exports`, `json-pkg-deps`, `json-pkg-scripts`, `json-pkg-fields`, and `json-workspace-members`. +- `json-pkg-fields` replaces the requested complete fields and preserves unrelated metadata. Existing composed package manifests are unchanged when all requested values are present; their creation seed is not reapplied. - TypeScript operations: `ts-add-import`, `ts-add-reexport`, `ts-append-call-arg`, `ts-object-field`, and `ts-jsx-slot`. - Composition behavior depends on the operation. Check the composer tests for duplicate handling and missing AST targets; do not assume every operation silently skips duplicates. - Composition errors flow through Effect error channel as typed tagged errors. diff --git a/.okf/guides/add-new-module.md b/.okf/guides/add-new-module.md index de2c226b..0a217d03 100644 --- a/.okf/guides/add-new-module.md +++ b/.okf/guides/add-new-module.md @@ -10,7 +10,7 @@ sources: resource: ../../catalogs/official/src/modules/domain.ts - id: source-3 resource: ../../catalogs/official/src/moduleRegistry.ts -generated: { by: claude, at: "2026-09-29T18:00:00+02:00" } +generated: { by: codex, at: "2026-10-11T14:06:18+02:00" } --- # Add a catalog module @@ -78,6 +78,19 @@ An implication uses `{ targetKind, moduleId }` in `implies`. It connects selecti Use `file` for authoritative content; `pkg-json-entry` for an export, dependency, devDependency, or script; `barrel-export` for a re-export; and `ts-call-arg`, `ts-object-field`, or `jsx-slot` for composition into existing code. A file contribution can set `conflictOnModify` when replacing existing content should require a decision. +Use `pkg-json-field` for a complete publishing field. Its `field` is `private`, `files`, `types`, `publishConfig`, `peerDependencies`, `dependencies`, or `exports`; its `value` is JSON. For example: + +```typescript +{ + _tag: "pkg-json-field", + path: "{{targetPath}}/package.json", + field: "exports", + value: { ".": { types: "./dist/index.d.ts", import: "./dist/index.js" } }, +} +``` + +Object values replace the complete field. Do not combine a whole-field contribution with `pkg-json-entry` contributions to that same field. Different existing values produce a Plan conflict rather than an implicit override. The build declares `contribution:pkg-json-field` only for catalogs that use it. + A package dependency entry has this shape: ```typescript diff --git a/apps/cli/package.json b/apps/cli/package.json index d3cf7b46..88e244b3 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -41,7 +41,8 @@ "test:e2e": "vitest run --config vitest.e2e.config.ts", "type-check": "tsc --noEmit", "clean": "git clean -xdf .cache .turbo dist node_modules", - "verify:create-path": "bun scripts/verify-create-path.ts" + "verify:create-path": "bun scripts/verify-create-path.ts", + "verify:library": "bun scripts/verify-library.ts" }, "devDependencies": { "@effect/platform-bun": "4.0.0", diff --git a/apps/cli/scripts/verify-library.ts b/apps/cli/scripts/verify-library.ts new file mode 100644 index 00000000..ab4e321a --- /dev/null +++ b/apps/cli/scripts/verify-library.ts @@ -0,0 +1,258 @@ +// Qualify the local catalog's library tarball in consumers outside this repository. +import { NodeRuntime, NodeServices } from "@effect/platform-node"; +import { OfficialCatalogLayer } from "@repo/catalog-official/service"; +import { Apply } from "@repo/domain/Apply"; +import { ModuleId, TargetIdentity, TargetKind } from "@repo/domain/Catalog"; +import { StackConfig } from "@repo/domain/Scaffold"; +import { ApplyService, BlueprintService, PlanService } from "@repo/scaffold"; +import { + Console, + Data, + Effect, + FileSystem, + Layer, + Path, + Schema, + Stream, +} from "effect"; +import { ChildProcess } from "effect/process"; +import { ChildProcessSpawner } from "effect/process/ChildProcessSpawner"; + +const json = Schema.encodeSync(Schema.fromJsonString(Schema.Json)); + +class VerificationFailure extends Data.TaggedError("VerificationFailure")<{ + readonly message: string; +}> {} + +const requireThat = (condition: boolean, message: string) => + condition ? Effect.void : Effect.fail(new VerificationFailure({ message })); + +const run = (cwd: string, command: string, args: ReadonlyArray) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner; + const handle = yield* spawner.spawn( + ChildProcess.make(command, args, { cwd, stdout: "pipe", stderr: "pipe" }), + ); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + Stream.mkString(Stream.decodeText(handle.stdout)), + Stream.mkString(Stream.decodeText(handle.stderr)), + handle.exitCode, + ], + { concurrency: "unbounded" }, + ); + yield* requireThat( + exitCode === 0, + `${command} ${args.join(" ")} failed\n${stdout}\n${stderr}`, + ); + return stdout; + }).pipe(Effect.scoped); + +const Packed = Schema.fromJsonString( + Schema.Array( + Schema.Struct({ + filename: Schema.String, + files: Schema.Array(Schema.Struct({ path: Schema.String })), + }), + ), +); + +const verify = (runtime: "bun" | "node") => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const blueprintService = yield* BlueprintService; + const planService = yield* PlanService; + const applyService = yield* ApplyService; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: `stack-library-${runtime}-`, + }); + const project = path.join(root, "project"); + yield* fs.makeDirectory(project); + const config = new StackConfig({ + name: "library-proof", + runtime: + runtime === "bun" + ? { _tag: "bun" } + : { _tag: "node", packageManager: "npm" }, + typescript: "7", + }); + const blueprint = yield* blueprintService.resolve( + { + targets: [ + { + identity: new TargetIdentity({ + kind: TargetKind.make("workspace"), + name: "root", + }), + modules: [{ id: ModuleId.make("workspace-typescript-7") }], + }, + { + identity: new TargetIdentity({ + kind: TargetKind.make("package"), + name: "sdk", + }), + modules: [{ id: ModuleId.make("package-library") }], + }, + ], + }, + config, + ); + const plan = yield* planService.build({ + blueprint, + config, + repoRoot: project, + }); + yield* requireThat( + plan.conflicts.length === 0, + "Fresh library must not require Apply decisions", + ); + const applied = yield* applyService.apply({ + apply: new Apply({ plan, decisions: [] }), + repoRoot: project, + }); + yield* requireThat(applied.failed.length === 0, "Library Apply failed"); + yield* run(project, runtime === "bun" ? "bun" : "npm", [ + "install", + "--ignore-scripts", + ]); + + const library = path.join(project, "packages/sdk"); + yield* fs.writeFileString( + path.join(library, "src/index.ts"), + 'export { answer } from "./Answer.js";\n', + ); + yield* fs.writeFileString( + path.join(library, "src/Answer.ts"), + 'import { Effect } from "effect";\nexport const answer: Effect.Effect = Effect.succeed(42);\n', + ); + yield* fs.writeFileString( + path.join(library, "src/Obsolete.ts"), + "export const obsolete = true;\n", + ); + yield* run(library, "npm", ["run", "build"]); + yield* requireThat( + yield* fs.exists(path.join(library, "dist/Obsolete.js")), + "Build must emit the source file before the cleanup check", + ); + yield* fs.remove(path.join(library, "src/Obsolete.ts")); + const packed = yield* Schema.decodeEffect(Packed)( + yield* run(library, "npm", [ + "pack", + ".", + "--json", + "--pack-destination", + root, + ]), + ); + const archive = packed[0]; + if (archive === undefined) + return yield* new VerificationFailure({ + message: "npm pack did not return an archive", + }); + const files = archive.files.map((file) => file.path); + yield* requireThat( + [ + "dist/index.js", + "dist/index.d.ts", + "dist/Answer.js", + "dist/Answer.d.ts", + ].every((file) => files.includes(file)), + "Tarball is missing public JavaScript or declarations", + ); + yield* requireThat( + files.every( + (file) => + ["package.json", "README.md", "LICENSE"].includes(file) || + (file.startsWith("dist/") && + !file.includes("Obsolete") && + !file.endsWith(".map")), + ), + "Tarball contains stale or development files", + ); + + const consumer = path.join(root, "consumer"); + yield* fs.makeDirectory(consumer); + yield* fs.writeFileString( + path.join(consumer, "package.json"), + json({ + name: "library-consumer", + private: true, + type: "module", + }), + ); + yield* run(consumer, "npm", [ + "install", + "--ignore-scripts", + "--no-audit", + "--no-fund", + path.join(root, archive.filename), + "typescript@6.0.3", + ]); + yield* fs.writeFileString( + path.join(consumer, "index.mjs"), + `import { answer } from "@repo/sdk"; +import { Effect } from "effect"; +if (Effect.runSync(answer) !== 42) throw new Error("Library returned the wrong value"); +try { + await import("@repo/sdk/Answer"); + throw new Error("Unlisted subpath was exposed"); +} catch (error) { + if (error.code !== "ERR_PACKAGE_PATH_NOT_EXPORTED") throw error; +} +`, + ); + yield* run(consumer, "node", ["index.mjs"]); + yield* fs.writeFileString( + path.join(consumer, "index.ts"), + 'import { answer } from "@repo/sdk";\nimport { Effect } from "effect";\nconst typed: Effect.Effect = answer;\nconst result: number = Effect.runSync(typed);\nvoid result;\n', + ); + yield* run(consumer, "npm", [ + "exec", + "--", + "tsc", + "--noEmit", + "--strict", + "--target", + "ES2022", + "--lib", + "ES2023,ESNext.Disposable,DOM,DOM.Iterable", + "--module", + "NodeNext", + "--moduleResolution", + "NodeNext", + "index.ts", + ]); + yield* run(consumer, "npm", [ + "exec", + "--", + "tsc", + "--noEmit", + "--strict", + "--target", + "ES2022", + "--lib", + "ES2023,ESNext.Disposable,DOM,DOM.Iterable", + "--module", + "ESNext", + "--moduleResolution", + "bundler", + "index.ts", + ]); + yield* Console.log( + `${runtime}: tarball import, NodeNext and bundler declarations, private subpaths, and stale-output cleanup passed`, + ); + }).pipe(Effect.scoped); + +const Services = Layer.mergeAll( + BlueprintService.layer, + PlanService.layer, + ApplyService.layer, +).pipe(Layer.provide(OfficialCatalogLayer), Layer.provide(NodeServices.layer)); + +NodeRuntime.runMain( + Effect.gen(function* () { + yield* verify("bun"); + yield* verify("node"); + }).pipe(Effect.provide(Layer.mergeAll(Services, NodeServices.layer))), +); diff --git a/apps/cli/src/commands/catalog.ts b/apps/cli/src/commands/catalog.ts index b812a28b..767cf26d 100644 --- a/apps/cli/src/commands/catalog.ts +++ b/apps/cli/src/commands/catalog.ts @@ -108,12 +108,19 @@ export class WorkspaceValidationFailed extends Data.TaggedError( } } -const targetIdentityFrom = (supportedOn: typeof SupportedOn.Type) => +const targetIdentityFrom = ( + supportedOn: typeof SupportedOn.Type, + moduleId: string, +) => supportedOn._tag === "identity" ? supportedOn.identity : new TargetIdentity({ kind: supportedOn.kind, - name: defaultTargetNames.get(supportedOn.kind) ?? supportedOn.kind, + // Generic package starters need their own authoring package, away from domain content. + name: + supportedOn.kind === "package" + ? moduleId.replace(/^package-/, "") + : (defaultTargetNames.get(supportedOn.kind) ?? supportedOn.kind), }); const contributionPath = (contribution: typeof Contribution.Type) => @@ -210,7 +217,9 @@ const buildWorkspaceSelection = Effect.fn("catalog.workspace.buildSelection")( if (supportedOn._tag === "kind" && supportedOn.kind === "workspace") { continue; } - const target = ensureTarget(targetIdentityFrom(supportedOn)); + const target = ensureTarget( + targetIdentityFrom(supportedOn, moduleDefinition.id), + ); const provides = moduleDefinition.provides ?? []; const hasExistingProvider = provides.some((capability) => target.capabilities.has(capability), diff --git a/apps/docs/app/content/how-it-works.mdx b/apps/docs/app/content/how-it-works.mdx index d8339f27..f8fbb76c 100644 --- a/apps/docs/app/content/how-it-works.mdx +++ b/apps/docs/app/content/how-it-works.mdx @@ -30,6 +30,8 @@ The name `sdk/api` creates the server at `apps/sdk/server-api`, with `server-sdk Stack Effect keeps npm names flat. `package/sdk/client` and `package/sdk-client` therefore cannot coexist even though their directories differ. It also rejects nested package roots and attempts to relocate an existing package. Grouping directories can contain multiple packages; a package directory owns its contents. +To create a package with compiled JavaScript and declarations for npm, use the `package-library` module. See [Create a publishable library](/publish-library). + Required dependencies keep the names declared by the catalog. Giving a server a grouped name does not move its dependencies into that group. ## First, Stack Effect resolves the project shape diff --git a/apps/docs/app/content/publish-catalog-registry.mdx b/apps/docs/app/content/publish-catalog-registry.mdx index cbc5d4a8..551371d9 100644 --- a/apps/docs/app/content/publish-catalog-registry.mdx +++ b/apps/docs/app/content/publish-catalog-registry.mdx @@ -133,6 +133,7 @@ A contribution is one of these types: | --- | --- | | `file` | A whole file. Set `conflictOnModify: true` to report a conflict instead of replacing an existing file | | `pkg-json-entry` | One entry in `exports`, `dependencies`, `devDependencies`, or `scripts` of a `package.json` | +| `pkg-json-field` | One complete publishing field, including nested JSON values such as conditional `exports` or `peerDependencies` | | `barrel-export` | An `export * from` line in a barrel file | | `ts-call-arg` | An argument to a function call, with its import, such as a Layer in `Layer.mergeAll` | | `ts-object-field` | A property in an object passed to a function call | diff --git a/apps/docs/app/content/publish-library.mdx b/apps/docs/app/content/publish-library.mdx new file mode 100644 index 00000000..088b2fda --- /dev/null +++ b/apps/docs/app/content/publish-library.mdx @@ -0,0 +1,53 @@ +# Create a publishable library + +Add a small ESM library to an existing Stack Effect project with a Bun or Node runtime: + +```sh +stack-effect add --target package/sdk:package-library +``` + +Use a new package name. This starter exposes one root entry point and has no child modules. It creates these files under `packages/sdk`: + +```text +package.json +README.md +tsconfig.json +tsconfig.build.json +scripts/clean.mjs +src/index.ts +``` + +## Add your public API + +Add implementation files under `src/` and export them from `src/index.ts`: + +```ts +export { greet } from "./greet.js"; +``` + +Keep `.js` extensions in relative imports and exports. TypeScript resolves these paths to your source files and emits imports that work in Node. + +Consumers import your library through its package name. Add explicit `package.json` export entries when you need public subpaths. + +## Build and pack + +Install the workspace dependencies, then run these commands from `packages/sdk`: + +```sh +npm run build +npm pack +``` + +Node and npm are required for this packaging workflow, including in a Bun workspace. The build checks NodeNext module resolution and emits JavaScript and declarations to `dist/`. Each build removes the previous output. The `prepack` script rebuilds the package before npm packs or publishes it. + +The tarball contains `dist/` and package documentation. Effect is a peer dependency, so consumers supply the compatible Effect version. Remove both Effect entries if your library does not use Effect. + +## Prepare publication + +Replace the placeholder `@repo/sdk` package name and update workspace consumers. Choose a version and license, add the appropriate license file, and fill in your description and repository metadata. + +Install the packed tarball in a separate project and check its runtime imports and declarations. Keep unpublished `@repo/*` dependencies out of the emitted files. The generated README includes the publication checklist. + +The package declares public npm access. Publish deliberately after inspecting the tarball; Stack Effect does not publish it automatically. + +For repository maintainers, `bun run --cwd apps/cli verify:library` generates Bun and npm workspaces and tests their tarballs in clean consumers. It checks runtime imports, declarations, private subpaths, and removal of stale build files. All temporary projects are removed afterward. diff --git a/apps/docs/app/lib/docs-manifest.ts b/apps/docs/app/lib/docs-manifest.ts index 43ae1cc0..bc5eb200 100644 --- a/apps/docs/app/lib/docs-manifest.ts +++ b/apps/docs/app/lib/docs-manifest.ts @@ -40,6 +40,14 @@ export const docPages: ReadonlyArray = [ summary: "Understand Selection, Blueprint, Plan, Apply, and Finalize.", group: "Start here", }, + { + route: "/publish-library", + source: "publish-library.mdx", + title: "Create a publishable library", + summary: + "Build and pack an ESM library with TypeScript declarations under packages/.", + group: "Start here", + }, { route: "/catalog-registry", source: "catalog-registry.mdx", diff --git a/apps/docs/app/nav.config.ts b/apps/docs/app/nav.config.ts index 5a91aa69..f9a074a2 100644 --- a/apps/docs/app/nav.config.ts +++ b/apps/docs/app/nav.config.ts @@ -29,6 +29,7 @@ export const navigation: NavSection[] = [ { label: "How it works", href: "/how-it-works" }, { label: "Catalog updates", href: "/catalog-registry" }, { label: "Publish a catalog", href: "/publish-catalog-registry" }, + { label: "Create a library", href: "/publish-library" }, { label: "Use with coding agents", href: "/use-with-coding-agents", diff --git a/apps/docs/app/routes.ts b/apps/docs/app/routes.ts index 3808e492..c18c5252 100644 --- a/apps/docs/app/routes.ts +++ b/apps/docs/app/routes.ts @@ -12,6 +12,7 @@ export default [ route("how-it-works", "content/how-it-works.mdx"), route("catalog-registry", "content/catalog-registry.mdx"), route("publish-catalog-registry", "content/publish-catalog-registry.mdx"), + route("publish-library", "content/publish-library.mdx"), route("use-with-coding-agents", "routes/use-with-coding-agents.tsx"), ...prefix("reference/cli", [ index("content/reference/cli/index.mdx"), diff --git a/apps/docs/vercel.json b/apps/docs/vercel.json index 5590191d..c3856258 100644 --- a/apps/docs/vercel.json +++ b/apps/docs/vercel.json @@ -142,6 +142,15 @@ } ] }, + { + "source": "/publish-library", + "headers": [ + { + "key": "Link", + "value": "; rel=\"alternate\"; type=\"text/markdown\", ; rel=\"describedby\"" + } + ] + }, { "source": "/reference/cli", "headers": [ diff --git a/catalogs/official/src/moduleRegistry.test.ts b/catalogs/official/src/moduleRegistry.test.ts index c5eed793..8fcdc36d 100644 --- a/catalogs/official/src/moduleRegistry.test.ts +++ b/catalogs/official/src/moduleRegistry.test.ts @@ -9,6 +9,19 @@ const moduleRegistry = document.modules; const targetRegistry = document.targets; describe("moduleRegistry", () => { + it("offers a publishable library on named package targets without child modules", () => { + const library = moduleRegistry.find( + (module) => module.id === "package-library", + ); + const packageTarget = targetRegistry.find( + (target) => target.kind === "package", + ); + expect(library?.supportedOn).toEqual([{ _tag: "kind", kind: "package" }]); + expect(library?.children ?? []).toEqual([]); + expect(library?.dependencies).toEqual([]); + expect(packageTarget?.visibility ?? "public").toBe("public"); + expect(packageTarget?.defaultName).toBe("library"); + }); it("should expose every compatible catalog target and module for Deno", () => { expect( targetRegistry @@ -19,7 +32,7 @@ describe("moduleRegistry", () => { moduleRegistry .filter((module) => !module.supportedRuntimes?.includes("deno")) .map((module) => module.id), - ).toEqual(["workspace-monorepo-turbo"]); + ).toEqual(["workspace-monorepo-turbo", "package-library"]); }); it("should list Nx and Vite+ as Turbo conflicts when monorepo modules are registered", () => { diff --git a/catalogs/official/src/modules/packages.ts b/catalogs/official/src/modules/packages.ts index 9a55623f..80eb7026 100644 --- a/catalogs/official/src/modules/packages.ts +++ b/catalogs/official/src/modules/packages.ts @@ -3,6 +3,109 @@ import { defineModules, templates } from "@repo/authoring"; const template = templates(new URL("../../templates/", import.meta.url)); export const packageModules = defineModules(import.meta.url, [ + { + id: "package-library", + supportedRuntimes: ["bun", "node"], + title: "Publishable Library", + description: + "A small ESM library with compiled JavaScript and TypeScript declarations", + supportedOn: [{ _tag: "kind", kind: "package" }], + dependencies: [], + contributions: [ + { + _tag: "file", + path: "{{targetPath}}/src/index.ts", + contents: template("./package-library/src/index.ts"), + conflictOnModify: true, + }, + { + _tag: "file", + path: "{{targetPath}}/tsconfig.build.json", + contents: template("./package-library/tsconfig.build.json"), + conflictOnModify: true, + }, + { + _tag: "file", + path: "{{targetPath}}/scripts/clean.mjs", + contents: template("./package-library/scripts/clean.mjs"), + conflictOnModify: true, + }, + { + _tag: "file", + path: "{{targetPath}}/README.md", + contents: template("./package-library/README.md"), + conflictOnModify: true, + }, + { + _tag: "pkg-json-field", + path: "{{targetPath}}/package.json", + field: "private", + value: false, + }, + { + _tag: "pkg-json-field", + path: "{{targetPath}}/package.json", + field: "files", + value: ["dist"], + }, + { + _tag: "pkg-json-field", + path: "{{targetPath}}/package.json", + field: "types", + value: "./dist/index.d.ts", + }, + { + _tag: "pkg-json-field", + path: "{{targetPath}}/package.json", + field: "publishConfig", + value: { access: "public" }, + }, + { + _tag: "pkg-json-field", + path: "{{targetPath}}/package.json", + field: "exports", + value: { + ".": { types: "./dist/index.d.ts", import: "./dist/index.js" }, + }, + }, + { + _tag: "pkg-json-field", + path: "{{targetPath}}/package.json", + field: "dependencies", + value: {}, + }, + { + _tag: "pkg-json-field", + path: "{{targetPath}}/package.json", + field: "peerDependencies", + value: { effect: "4.0.0" }, + }, + { + _tag: "pkg-json-entry", + path: "{{targetPath}}/package.json", + field: "devDependencies", + name: "effect", + value: "4.0.0", + }, + { + _tag: "pkg-json-entry", + path: "{{targetPath}}/package.json", + field: "scripts", + name: "build", + value: "node scripts/clean.mjs && tsc -p tsconfig.build.json", + }, + { + _tag: "pkg-json-entry", + path: "{{targetPath}}/package.json", + field: "scripts", + name: "prepack", + value: "npm run build", + }, + ], + nextSteps: [ + "Library: edit {{targetPath}}/src/index.ts, then run `npm run build` and `npm pack` from {{targetPath}}. Choose an npm package name and license before publishing; see {{targetPath}}/README.md. Node and npm are required for packaging.", + ], + }, { id: "package-ai-core", supportedRuntimes: ["bun", "node", "deno"], diff --git a/catalogs/official/src/targetRegistry.ts b/catalogs/official/src/targetRegistry.ts index 33c7d7ba..cca079f2 100644 --- a/catalogs/official/src/targetRegistry.ts +++ b/catalogs/official/src/targetRegistry.ts @@ -574,7 +574,7 @@ export const targetGroup = defineTargets(import.meta.url, [ supportedRuntimes: ["bun", "node", "deno"], title: "Shared Package", description: "A shared library package for code reuse across targets", - visibility: "internal", + defaultName: "library", requiredModules: [], contributions: [ { diff --git a/catalogs/official/templates/package-library/README.md b/catalogs/official/templates/package-library/README.md new file mode 100644 index 00000000..9257f19c --- /dev/null +++ b/catalogs/official/templates/package-library/README.md @@ -0,0 +1,32 @@ +# {{packageName}} + +A small ESM library with TypeScript declarations. + +## Add public exports + +Add your implementation under `src/` and export it from `src/index.ts`: + +```ts +export { greet } from "./greet.js"; +``` + +Keep the `.js` extension in relative imports and exports. TypeScript resolves it to the source `.ts` file and emits paths that work in Node. + +The package initially exposes only its root entry point. Add explicit `exports` entries if you need public subpaths. + +## Build and pack + +Run `npm run build` and `npm pack` from this directory. Packing rebuilds the JavaScript and declarations in `dist/`. Node and npm are required for this publishing workflow, including in a Bun workspace. Install the workspace dependencies before building. + +Effect is a peer dependency. Consumers supply the compatible Effect version; the development dependency lets this package compile. If the public API does not use Effect, remove both entries. + +## Before publishing + +- Replace the placeholder `{{packageName}}` name with your npm package name and update workspace consumers. +- Choose your version and license, and add the appropriate license file. +- Add your package description, repository, and other author metadata. +- Verify the packed tarball in a clean consumer, including runtime imports and TypeScript declarations. +- Keep unpublished `@repo/*` dependencies out of emitted JavaScript and declarations. +- Publish deliberately with npm after reviewing the packed files. + +`publishConfig.access` is `public`. This scaffold does not publish automatically. diff --git a/catalogs/official/templates/package-library/scripts/clean.mjs b/catalogs/official/templates/package-library/scripts/clean.mjs new file mode 100644 index 00000000..4a62519a --- /dev/null +++ b/catalogs/official/templates/package-library/scripts/clean.mjs @@ -0,0 +1,5 @@ +// oxlint-disable-next-line effecttsgo/node-builtin-import -- Packaging cleanup uses Node before the library is built. +import { rmSync } from "node:fs"; + +// Resolve from this script so cleaning does not depend on the working directory. +rmSync(new URL("../dist/", import.meta.url), { recursive: true, force: true }); diff --git a/catalogs/official/templates/package-library/src/index.ts b/catalogs/official/templates/package-library/src/index.ts new file mode 100644 index 00000000..4b18db87 --- /dev/null +++ b/catalogs/official/templates/package-library/src/index.ts @@ -0,0 +1,2 @@ +// Export the public API here. Use .js extensions for relative exports. +export {}; diff --git a/catalogs/official/templates/package-library/tsconfig.build.json b/catalogs/official/templates/package-library/tsconfig.build.json new file mode 100644 index 00000000..9e8188d0 --- /dev/null +++ b/catalogs/official/templates/package-library/tsconfig.build.json @@ -0,0 +1,21 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "verbatimModuleSyntax": true, + "noEmit": false, + "composite": false, + "declaration": true, + "declarationMap": false, + "sourceMap": false, + "inlineSources": false, + "rootDir": "src", + "outDir": "dist", + "types": [], + "skipLibCheck": false, + "lib": ["ES2023", "ESNext.Disposable", "DOM", "DOM.Iterable"] + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules", "**/*.test.ts"] +} diff --git a/docs/research/publishable-library-scaffold.md b/docs/research/publishable-library-scaffold.md new file mode 100644 index 00000000..f0c53eba --- /dev/null +++ b/docs/research/publishable-library-scaffold.md @@ -0,0 +1,115 @@ +# Publishable library scaffold + +Initial research and proposal, 2026-10-11. The original design discussion below records the catalog before implementation. + +## Implementation result + +The `package-library` module now supports Bun and Node package targets with custom names, one compiled root export, and no child modules. The shared package target is visible in target selection. The build uses a separate NodeNext configuration and removes stale output before emitting JavaScript and declarations. + +`pkg-json-field` composes complete publishing fields, including conditional exports and Effect peer dependencies. Competing contributions fail planning; differing values on disk require an Apply decision. Catalogs that do not use the new operation retain their previous capability requirements. + +The clean-consumer verifier passed for generated Bun and npm workspaces, including Node imports, NodeNext and bundler declarations, private subpaths, and stale-output cleanup. See the [usage guide](../../apps/docs/app/content/publish-library.mdx) and [repeatable verifier](../../apps/cli/scripts/verify-library.ts). + +## Recommendation + +Add one public `package-library` module to the existing `package` target kind. It creates a small ESM library under `packages/`, with one root export and no child modules. Use TypeScript to emit JavaScript and declarations. Add build tooling, subpath exports, and release automation only when a library needs them. + +Proposed selection: + +```sh +stack-effect add --target package/library:package-library +``` + +This command describes the proposed module; it is not available yet. Prefer kind-level support so users can choose names such as `utils` or `sdk`. Existing content modules for `domain`, `ai`, and `db` are identity-specific. Initially treat this module as a starter for a new package, rather than a supported conversion of those existing packages. + +## What the existing examples show + +| Example | Current behavior | Lesson for this scaffold | +| --- | --- | --- | +| Official shared package | Private, Effect dependency, shared TypeScript configuration; content modules export `src/*.ts` | Keep the existing private-package default. Publishing needs a separate selection. | +| `stack-effect` CLI | Bundled Node executable, shebang, `bin`, `dist` allowlist | Reuse the output boundary, but a library needs exports and portable declarations rather than executable setup. | +| `@stack-effect/author` | ESM root export, declarations, Effect peer, tsdown bundles private workspace packages, packed-consumer verification | Closest in-repository library precedent. Its bundling solves workspace dependencies that a fresh library need not have. | +| Effect Boxes | Explicit root and named subpath exports, tsdown JS build, separate declaration build, NodeNext compatibility check | Add explicit subpaths when needed. Do not copy its full SDK setup into the starter. | + +Sources: [package target](../../catalogs/official/src/targetRegistry.ts), [package template](../../catalogs/official/templates/package/package.json), [package modules](../../catalogs/official/src/modules/packages.ts), [CLI manifest](../../apps/cli/package.json), [author manifest](../../packages/author/package.json), [author build](../../packages/author/tsdown.config.ts), [packed verification](../../packages/author/scripts/verify-package.ts), [Effect Boxes manifest](../../.reference/effect-boxes/packages/effect-boxes/package.json), [Effect Boxes build](../../.reference/effect-boxes/packages/effect-boxes/tsdown.config.ts). These are inspected local source snapshots, not claims about the latest remote releases. + +## Generated structure + +```text +packages// + package.json + README.md + tsconfig.json + tsconfig.build.json + scripts/ + clean.mjs + src/ + index.ts + dist/ # generated, ignored + index.js + index.d.ts +``` + +Start `src/index.ts` with `export {};` and a short instruction to add public exports. When adding a file, export it deliberately: + +```ts +export { greet } from "./greet.js"; +``` + +The `.js` spelling works with the emitted ESM files; TypeScript resolves the source to `greet.ts`. Root exports keep implementation files private to package consumers. Add a named package subpath only when it is a supported public entry point. [TypeScript library guidance](https://www.typescriptlang.org/docs/handbook/modules/guides/choosing-compiler-options.html#im-writing-a-library), [Node package entry points](https://nodejs.org/api/packages.html#package-entry-points). + +Proposed manifest core, with the publication name chosen by the author: + +```json +{ + "name": "@your-scope/your-library", + "version": "0.0.0", + "private": false, + "type": "module", + "files": ["dist"], + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "publishConfig": { "access": "public" }, + "scripts": { + "build": "node scripts/clean.mjs && tsc -p tsconfig.build.json", + "type-check": "tsc --noEmit -p tsconfig.json", + "prepack": "npm run build" + } +} +``` + +The generated identity currently names packages `@repo/`. Do not present that placeholder as a ready npm publication name. For the first version, document renaming it and updating workspace consumers before publishing. A future publish-name input should remain distinct from target identity and directory ownership. Do not invent an author, repository URL, or license; the README should list these publication setup steps. [Identity implementation](../../packages/domain/src/Catalog.ts), [npm package metadata](https://docs.npmjs.com/cli/v11/configuring-npm/package-json/). + +`files` limits shipped files, while npm includes package metadata, README, and license files automatically. `prepack` builds before npm packing or publishing. This proposed hook uses npm even in a Bun-authored workspace; npm is therefore required for this packaging workflow. Do not claim equivalent lifecycle behavior for every package manager without testing it. [npm package metadata](https://docs.npmjs.com/cli/v11/configuring-npm/package-json/), [npm lifecycle scripts](https://docs.npmjs.com/cli/v11/using-npm/scripts/). + +The library TypeScript configuration should extend the workspace base for strictness but override `module` and `moduleResolution` to `NodeNext`, enable `verbatimModuleSyntax`, and disable inherited declaration/source maps initially. The check configuration can include tests; the build configuration must include only `src`, set `rootDir: "src"`, `outDir: "dist"`, `noEmit: false`, `declaration: true`, and `composite: false`. Clean `dist` before a build using a small portable Node script so deleted source files cannot remain in the tarball. NodeNext follows newer Node semantics with compiler upgrades; a fixed Node mode is an alternative if the project establishes a supported Node baseline. The current shared base uses bundler resolution, which is inappropriate for checking unbundled Node ESM output. [Shared base](../../catalogs/official/templates/workspace/packages/config-typescript/base.json), [TypeScript guidance](https://www.typescriptlang.org/docs/handbook/modules/guides/choosing-compiler-options.html#im-writing-a-library). + +Use the workspace's selected TypeScript toolchain. Do not add a competing compiler version to every generated library. Keep the minimal library free of platform-specific runtime imports. Qualify npm publishing for Bun and Node workspace configurations first; Deno workspace support requires a separate check of compiler scripts and package lifecycle behavior. + +For an Effect library, put Effect in both `peerDependencies` and `devDependencies`, using the catalog's qualified compatible version. The consumer supplies Effect and the development copy lets the library compile. Avoid unpublished `@repo/*` dependencies in emitted JavaScript or declarations. If the library later needs private workspace code, either publish that dependency or introduce deliberate JS and declaration bundling like `@stack-effect/author`. Do not automatically bundle all dependencies. [Author precedent](../../packages/author/package.json), [npm peer dependencies](https://docs.npmjs.com/cli/v11/configuring-npm/package-json/#peerdependencies). + +## Catalog implementation constraint + +`pkg-json-entry` currently accepts string values and only `exports`, `dependencies`, `devDependencies`, and `scripts`. It cannot express the manifest above, including nested export conditions, peer dependencies, `private`, or `files`. [Contribution contract](../../packages/domain/src/Catalog.ts). + +Do not assume a second whole-file package manifest will safely override the package target's manifest. Before implementation, inspect and test how multiple authoritative file contributions are resolved. The durable approach is to extend typed package JSON composition to support the needed fields, with explicit conflicts when values differ. Keep the contracts in `@repo/domain` and the composition behavior in `@repo/scaffold`. Then let `package-library` contribute its publishing settings while retaining the private shared-package default. + +This extension must preserve Selection, Blueprint, Plan, and Apply boundaries. Plan reports differences; Apply resolves conflicted paths. Peer dependency removal from the base `dependencies` field also needs an explicit supported operation or a library-specific base strategy. Adding a peer entry alone would leave the existing Effect runtime dependency in place. Do not hide this through a finalize script that rewrites package metadata. [Package template](../../catalogs/official/templates/package/package.json), [planning assessment](../../packages/scaffold/src/service/plan/PlanAssessor.ts). + +A separate reusable `package-publish` configuration module makes sense after composition supports these changes and a second consumer needs it. For the initial feature, one library starter is easier to select and qualify. A new `library` target kind would currently map to `apps/library-*`; it would require changing path ownership rules to satisfy the requested `packages/` placement. [Target path implementation](../../packages/domain/src/Catalog.ts). + +## Acceptance checks for implementation + +1. Prototype concrete generated files in the catalog authoring workspace before editing templates. +2. Generate a new library with the proposed selection; verify placement, scripts, peer dependencies, and public exports. +3. Build, delete a source file, rebuild, and check that its previous output disappears. +4. Pack with npm, install the actual tarball into a disposable consumer, import the root in Node, and compile consumers using NodeNext and bundler resolution. Include a public type referring to Effect so declaration dependency checks exercise real behavior. +5. Confirm the tarball contains every exported file and no workspace-only runtime or declaration imports. Root exports should reject unpublished subpaths. +6. Run the catalog workspace validation, scoped catalog/scaffold/CLI tests for affected behavior, and the required repository format, lint, and type checks. Clean up consumer fixtures. + +Defer CommonJS, wildcard export generation, browser bundles, Changesets, provenance workflows, and CI publication until requested. The first scaffold should prove that one small library can be built, packed, installed, imported, and type-checked. diff --git a/packages/authoring/src/buildCatalog.test.ts b/packages/authoring/src/buildCatalog.test.ts index bcafe1a1..eb57cd21 100644 --- a/packages/authoring/src/buildCatalog.test.ts +++ b/packages/authoring/src/buildCatalog.test.ts @@ -164,6 +164,37 @@ const symlinkedCatalog = Effect.gen(function* () { }); layer(NodeServices.layer)("buildCatalog", (it) => { + it.effect( + "declares publishing-field support only when the authored catalog uses it", + () => + Effect.gen(function* () { + const plain = yield* buildCatalog(catalog, { catalogId: "acme", root }); + const publishing = yield* buildCatalog( + withModule({ + ...standaloneModule, + contributions: [ + { + _tag: "pkg-json-field", + path: "package.json", + field: "files", + value: ["dist"], + }, + ], + }), + { catalogId: "acme", root: packageRoot }, + ); + assert.isFalse( + plain.document.requiredCapabilities.includes( + "contribution:pkg-json-field", + ), + ); + assert.isTrue( + publishing.document.requiredCapabilities.includes( + "contribution:pkg-json-field", + ), + ); + }), + ); it.effect( "should match the golden v1 document byte for byte when the standalone fixture is built twice", () => diff --git a/packages/authoring/src/buildCatalog.ts b/packages/authoring/src/buildCatalog.ts index 75a204d7..70f8f6c2 100644 --- a/packages/authoring/src/buildCatalog.ts +++ b/packages/authoring/src/buildCatalog.ts @@ -570,8 +570,14 @@ export const buildCatalog = Effect.fn("Authoring.buildCatalog")(function* ( catalogId, requiredCapabilities: V1_INTERPRETER_CAPABILITIES.filter( (capability) => - capability !== "target:hierarchical-name" || - hierarchicalNameCapabilities(authored).includes(capability), + (capability !== "target:hierarchical-name" || + hierarchicalNameCapabilities(authored).includes(capability)) && + (capability !== "contribution:pkg-json-field" || + [...authored.targets, ...authored.modules].some((definition) => + definition.contributions.some( + (contribution) => contribution._tag === "pkg-json-field", + ), + )), ), ...(official === undefined ? {} : { requires: ["official"] }), targets: composed.targets.filter((target) => diff --git a/packages/catalog/src/CatalogProtocol.ts b/packages/catalog/src/CatalogProtocol.ts index 985b84d0..17365329 100644 --- a/packages/catalog/src/CatalogProtocol.ts +++ b/packages/catalog/src/CatalogProtocol.ts @@ -12,6 +12,7 @@ import { Array as Arr, Effect, Schema } from "effect"; const contributionTags = [ "file", "pkg-json-entry", + "pkg-json-field", "barrel-export", "ts-call-arg", "ts-object-field", diff --git a/packages/domain/src/Catalog.ts b/packages/domain/src/Catalog.ts index d311e33b..5d3f9007 100644 --- a/packages/domain/src/Catalog.ts +++ b/packages/domain/src/Catalog.ts @@ -179,15 +179,21 @@ export const ModuleImplication = Schema.Struct({ moduleId: ModuleId, }); -/** - * A Contribution declares a single unit of desired repository state. - * - * Tagged union of contribution types: - * - `file`: Authoritative file content (replaces entire file) - * - `pkg-json-entry`: Entry in package.json (exports, dependencies, scripts) - * - `barrel-export`: Re-export statement in a TypeScript barrel file - * - `ts-call-arg`: Argument appended to a TypeScript function call - */ +/** Whole package.json fields. Object values replace the field, rather than merge it. */ +export const PackageJsonField = Schema.Struct({ + field: Schema.Literals([ + "private", + "files", + "types", + "publishConfig", + "peerDependencies", + "dependencies", + "exports", + ]), + value: Schema.Json, +}); + +/** A Contribution declares desired files, package.json fields/entries, or TypeScript composition. */ export const Contribution = Schema.TaggedUnion({ /** * Authoritative file content - the complete desired file. @@ -216,6 +222,12 @@ export const Contribution = Schema.TaggedUnion({ value: Schema.String, }, + /** Sets one complete field, with differing existing values reported as conflicts. */ + "pkg-json-field": { + path: Schema.String, + ...PackageJsonField.fields, + }, + /** * Barrel file re-export - adds `export * from "..."` to a barrel file. */ diff --git a/packages/domain/src/Plan.ts b/packages/domain/src/Plan.ts index c2a82700..5a90d973 100644 --- a/packages/domain/src/Plan.ts +++ b/packages/domain/src/Plan.ts @@ -1,4 +1,5 @@ import { Array as Arr, Order, Schema } from "effect"; +import { PackageJsonField } from "./Catalog"; import { pathOrd } from "./Order"; import { StackConfig } from "./Scaffold"; import { Selection } from "./Selection"; @@ -147,7 +148,13 @@ export const JsonWorkspaceMembersOp = Schema.TaggedStruct( }, ); +export const JsonPkgFieldsOp = Schema.TaggedStruct("json-pkg-fields", { + fileType: Schema.tag("json"), + entries: Schema.Array(PackageJsonField), +}); + const JsonCompositionOperationSchema = Schema.Union([ + JsonPkgFieldsOp, JsonWorkspaceMembersOp, JsonPkgExportsOp, JsonPkgDepsOp, @@ -197,6 +204,10 @@ export const PlanOutcome = Schema.TaggedUnion({ }); export const PlanConflict = Schema.TaggedUnion({ + packageJsonField: { + path: Schema.String, + field: PackageJsonField.fields.field, + }, exports: { path: Schema.String, name: Schema.String, @@ -233,6 +244,7 @@ export const PlanConflict = Schema.TaggedUnion({ const planConflictKey = (conflict: typeof PlanConflict.Type): string => PlanConflict.match(conflict, { + packageJsonField: (c) => JSON.stringify([c._tag, c.path, c.field]), exports: (c) => JSON.stringify([c._tag, c.path, c.name]), dependencies: (c) => JSON.stringify([c._tag, c.path, c.section, c.name]), scripts: (c) => JSON.stringify([c._tag, c.path, c.name]), diff --git a/packages/scaffold/src/service/ScaffolFormatter.ts b/packages/scaffold/src/service/ScaffolFormatter.ts index 1edd685e..607da574 100644 --- a/packages/scaffold/src/service/ScaffolFormatter.ts +++ b/packages/scaffold/src/service/ScaffolFormatter.ts @@ -378,6 +378,7 @@ const formatConflictLine = (conflict: typeof PlanConflict.Type) => Match.value(conflict).pipe( Match.tags({ completeFile: () => "merge: complete file", + packageJsonField: (c) => `merge: ${c.field}`, barrelExport: (c) => `merge: export ${c.exportPath}`, dependencies: (c) => `merge: ${c.section}.${c.name}`, exports: (c) => `merge: exports ${c.name}`, diff --git a/packages/scaffold/src/service/apply/JsonComposer.ts b/packages/scaffold/src/service/apply/JsonComposer.ts index 8404f4dc..7d44902a 100644 --- a/packages/scaffold/src/service/apply/JsonComposer.ts +++ b/packages/scaffold/src/service/apply/JsonComposer.ts @@ -68,6 +68,15 @@ const applyJsonOperation = ( op: JsonCompositionOperation, ): Effect.Effect => Match.typeTags()({ + "json-pkg-fields": (o) => + Effect.sync(() => { + Object.assign( + pkg, + Object.fromEntries( + o.entries.map(({ field, value }) => [field, value]), + ), + ); + }), "json-workspace-members": (o) => Effect.sync(() => { pkg[o.field] = o.members; diff --git a/packages/scaffold/src/service/plan/ContributionResolver.ts b/packages/scaffold/src/service/plan/ContributionResolver.ts index edf7197e..3f488897 100644 --- a/packages/scaffold/src/service/plan/ContributionResolver.ts +++ b/packages/scaffold/src/service/plan/ContributionResolver.ts @@ -8,7 +8,7 @@ import { type StackConfig, TargetContribution, } from "@repo/domain/Scaffold"; -import { Array as Arr, Context, Effect, Layer, Option } from "effect"; +import { Array as Arr, Context, Effect, Layer, Option, Schema } from "effect"; export class ContributionResolver extends Context.Service()( "ContributionResolver", @@ -97,10 +97,32 @@ const resolveContributionTokens = ( context: ContributionTokenContext, ): ReadonlyArray => { const resolveString = (value: string) => context.resolve(value); + const resolveJson = ( + value: typeof Schema.Json.Type, + ): typeof Schema.Json.Type => + typeof value === "string" + ? resolveString(value) + : Array.isArray(value) + ? value.map(resolveJson) + : value !== null && typeof value === "object" + ? Object.fromEntries( + Object.entries(value).map(([key, entry]) => [ + key, + resolveJson(entry), + ]), + ) + : value; return Arr.flatMap( contributions, Contribution.match({ + "pkg-json-field": (c): ReadonlyArray => [ + Contribution.cases["pkg-json-field"].make({ + path: resolveString(c.path), + field: c.field, + value: resolveJson(c.value), + }), + ], file: (c): ReadonlyArray => { const path = resolveString(c.path).trim(); if (path.length === 0) return []; diff --git a/packages/scaffold/src/service/plan/PackageJsonFieldValue.ts b/packages/scaffold/src/service/plan/PackageJsonFieldValue.ts new file mode 100644 index 00000000..d9cdcf3f --- /dev/null +++ b/packages/scaffold/src/service/plan/PackageJsonFieldValue.ts @@ -0,0 +1,11 @@ +import type { PackageJsonField } from "@repo/domain/Catalog"; +import { Schema } from "effect"; + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Json)); + +/** Conditional exports resolve in property order, unlike ordinary metadata. */ +export const comparablePackageJsonFieldValue = ({ + field, + value, +}: typeof PackageJsonField.Type) => + field === "exports" ? encodeJson(value) : value; diff --git a/packages/scaffold/src/service/plan/PackageJsonFields.test.ts b/packages/scaffold/src/service/plan/PackageJsonFields.test.ts new file mode 100644 index 00000000..028a3b77 --- /dev/null +++ b/packages/scaffold/src/service/plan/PackageJsonFields.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Contribution, ModuleId, TargetKey } from "@repo/domain/Catalog"; +import { PlanFailure } from "@repo/domain/Plan"; +import { Effect, Layer, Schema } from "effect"; +import { JsonComposer } from "../apply/JsonComposer"; +import { PlanAssessor } from "./PlanAssessor"; +import { PlanningIntentCompiler } from "./PlanningIntentCompiler"; + +const json = Schema.encodeSync(Schema.fromJsonString(Schema.Json)); +const parseJson = Schema.decodeSync(Schema.fromJsonString(Schema.Json)); + +const path = "packages/library/package.json"; +const publishing: ReadonlyArray = [ + { _tag: "pkg-json-field", path, field: "private", value: false }, + { _tag: "pkg-json-field", path, field: "files", value: ["dist"] }, + { + _tag: "pkg-json-field", + path, + field: "exports", + value: { ".": { types: "./dist/index.d.ts", import: "./dist/index.js" } }, + }, + { + _tag: "pkg-json-field", + path, + field: "peerDependencies", + value: { effect: "4.0.0" }, + }, + { _tag: "pkg-json-field", path, field: "dependencies", value: {} }, +]; + +const compile = (contributions: ReadonlyArray) => + Effect.gen(function* () { + const compiler = yield* PlanningIntentCompiler; + const paths = yield* compiler.compile({ + targets: [], + modules: [ + { + targetKey: TargetKey.make("packages/library"), + moduleId: ModuleId.make("package-library"), + contributions, + }, + ], + }); + const result = paths.find((entry) => entry.path === path); + if (result === undefined) + return yield* Effect.die("Expected package.json intent"); + return result; + }); + +const TestLayer = Layer.mergeAll( + PlanningIntentCompiler.layer, + PlanAssessor.layer, + JsonComposer.layer, +); + +describe("publishing package.json fields", () => { + it.effect("reports reordered export conditions as competing outcomes", () => + Effect.gen(function* () { + const assessor = yield* PlanAssessor; + const first = { + _tag: "pkg-json-field", + path, + field: "exports", + value: { ".": { default: "./a.js", import: "./b.js" } }, + } as const; + const second = { + ...first, + value: { ".": { import: "./b.js", default: "./a.js" } }, + }; + const intent = yield* compile([first]); + expect( + assessor.assessPlanningPath({ + planningPath: intent, + snapshotPath: { + _tag: "file", + path, + contents: json({ exports: second.value }), + }, + }).conflicts, + ).toEqual([{ _tag: "packageJsonField", path, field: "exports" }]); + expect(yield* Effect.flip(compile([first, second]))).toBeInstanceOf( + PlanFailure, + ); + }).pipe(Effect.provide(TestLayer)), + ); + it.effect( + "creates a publishable manifest and leaves repeat planning unchanged", + () => + Effect.gen(function* () { + const assessor = yield* PlanAssessor; + const composer = yield* JsonComposer; + const intent = yield* compile([ + { + _tag: "file", + path, + contents: json({ + name: "@repo/library", + private: true, + dependencies: { effect: "4.0.0" }, + }), + }, + ...publishing, + ]); + const assessment = assessor.assessPlanningPath({ + planningPath: intent, + snapshotPath: { _tag: "missing", path }, + }); + expect(assessment.classification).toBe("create"); + const outcome = assessor.toPlannedFileOutcome({ + planningPath: intent, + classification: assessment.classification, + }); + if (outcome._tag !== "composed") + return yield* Effect.die("Expected composed manifest"); + const manifest = yield* composer.compose( + outcome.seedContents ?? "{}", + outcome.operations.filter( + (operation) => operation.fileType === "json", + ), + ); + expect(parseJson(manifest)).toMatchObject({ + private: false, + files: ["dist"], + dependencies: {}, + peerDependencies: { effect: "4.0.0" }, + exports: { + ".": { types: "./dist/index.d.ts", import: "./dist/index.js" }, + }, + }); + expect( + assessor.assessPlanningPath({ + planningPath: intent, + snapshotPath: { _tag: "file", path, contents: manifest }, + }), + ).toEqual({ classification: "unchanged", conflicts: [] }); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect( + "reports differing publishing values and preserves unrelated metadata when composed", + () => + Effect.gen(function* () { + const assessor = yield* PlanAssessor; + const composer = yield* JsonComposer; + const intent = yield* compile(publishing); + const contents = json({ + name: "@acme/library", + description: "Keep this", + private: true, + files: ["src"], + dependencies: { effect: "4.0.0" }, + }); + expect( + assessor.assessPlanningPath({ + planningPath: intent, + snapshotPath: { _tag: "file", path, contents }, + }).conflicts, + ).toEqual([ + { _tag: "packageJsonField", path, field: "private" }, + { _tag: "packageJsonField", path, field: "files" }, + { _tag: "packageJsonField", path, field: "dependencies" }, + ]); + const result = yield* composer.compose(contents, [ + { + _tag: "json-pkg-fields", + fileType: "json", + entries: intent.packageJsonFields, + }, + ]); + expect(parseJson(result)).toMatchObject({ + name: "@acme/library", + description: "Keep this", + private: false, + }); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect( + "rejects competing field values and overlapping entry contributions", + () => + Effect.gen(function* () { + const conflict = yield* Effect.flip( + compile([ + ...publishing, + { _tag: "pkg-json-field", path, field: "files", value: ["src"] }, + ]), + ); + expect(conflict).toBeInstanceOf(PlanFailure); + const overlap = yield* Effect.flip( + compile([ + ...publishing, + { + _tag: "pkg-json-entry", + path, + field: "exports", + name: ".", + value: "./src/index.ts", + }, + ]), + ); + expect(overlap.message).toContain( + "Whole-field and entry contributions overlap", + ); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect( + "accepts equivalent JSON objects and reports malformed manifests", + () => + Effect.gen(function* () { + const assessor = yield* PlanAssessor; + const intent = yield* compile([ + { + _tag: "pkg-json-field", + path, + field: "publishConfig", + value: { access: "public", registry: "https://registry.npmjs.org" }, + }, + { + _tag: "pkg-json-field", + path, + field: "publishConfig", + value: { registry: "https://registry.npmjs.org", access: "public" }, + }, + ]); + expect(intent.packageJsonFields).toHaveLength(1); + expect( + assessor.assessPlanningPath({ + planningPath: intent, + snapshotPath: { + _tag: "file", + path, + contents: json({ + publishConfig: { + registry: "https://registry.npmjs.org", + access: "public", + }, + }), + }, + }).classification, + ).toBe("unchanged"); + expect( + assessor.assessPlanningPath({ + planningPath: intent, + snapshotPath: { _tag: "file", path, contents: "invalid json" }, + }).conflicts, + ).toEqual([{ _tag: "packageJsonField", path, field: "publishConfig" }]); + }).pipe(Effect.provide(TestLayer)), + ); +}); diff --git a/packages/scaffold/src/service/plan/PlanAssessor.test.ts b/packages/scaffold/src/service/plan/PlanAssessor.test.ts index 9fad5785..3502a432 100644 --- a/packages/scaffold/src/service/plan/PlanAssessor.test.ts +++ b/packages/scaffold/src/service/plan/PlanAssessor.test.ts @@ -5,6 +5,7 @@ import { PlanAssessor, type PlanningIntentPath } from "./PlanAssessor"; const makeJsxSlotPath = (contents?: string): PlanningIntentPath => ({ path: "apps/web/src/App.tsx", contents, + packageJsonFields: [], exports: [], dependencies: [], scripts: [], diff --git a/packages/scaffold/src/service/plan/PlanAssessor.ts b/packages/scaffold/src/service/plan/PlanAssessor.ts index 2a81e4a1..3b08e3f2 100644 --- a/packages/scaffold/src/service/plan/PlanAssessor.ts +++ b/packages/scaffold/src/service/plan/PlanAssessor.ts @@ -1,3 +1,4 @@ +import { PackageJsonField } from "@repo/domain/Catalog"; import { CompositionOperation, Plan, @@ -11,6 +12,7 @@ import { Array as Arr, Context, Effect, + Equal, Layer, Match, Option, @@ -20,6 +22,7 @@ import { String as Str, } from "effect"; import { findJsxSlotMarker, makeJsxSourceFile } from "../JsxSlot"; +import { comparablePackageJsonFieldValue } from "./PackageJsonFieldValue"; type PlanningIntentPackageJsonDependency = { readonly section: "dependencies" | "devDependencies"; @@ -68,6 +71,7 @@ export type PlanningIntentJsxSlot = { }; export type PlanningIntentPath = { + readonly packageJsonFields: ReadonlyArray; readonly path: string; readonly contents: string | undefined; readonly exports: ReadonlyArray<{ name: string; value: string }>; @@ -158,6 +162,14 @@ function toCompositionOperations( ): Array { const operations: Array = []; + if (planningPath.packageJsonFields.length > 0) { + operations.push({ + _tag: "json-pkg-fields", + fileType: "json", + entries: planningPath.packageJsonFields, + }); + } + if (planningPath.exports.length > 0) { operations.push({ _tag: "json-pkg-exports", @@ -268,6 +280,7 @@ function assessPlanningPath({ }): PathAssessment { const hasContents = planningPath.contents !== undefined; const hasPackageJsonFields = + planningPath.packageJsonFields.length > 0 || planningPath.exports.length > 0 || planningPath.dependencies.length > 0 || planningPath.scripts.length > 0; @@ -637,10 +650,8 @@ const assessPackageJsonMerge = ( }); } - const requiredContents = planningPath.contents!; - const authoritativeContentMatches = existingContents === requiredContents; - - if (authoritativeContentMatches && !hasAdditions) { + // Existing composed files are edited from disk; their creation seed is not reapplied. + if (!hasAdditions) { return createPathAssessment({ classification: "unchanged" }); } @@ -698,6 +709,9 @@ const collectInvalidPackageJsonConflicts = ( scripts: requiredScripts, } = planningPath; return [ + ...Arr.map(planningPath.packageJsonFields, (entry) => + PlanConflict.cases.packageJsonField.make({ path, field: entry.field }), + ), ...Arr.map(requiredExports, (e) => planConflict.exports(path, e.name)), ...Arr.map(requiredDependencies, (d) => planConflict.dependencies(path, d)), ...Arr.map(requiredScripts, (s) => planConflict.scripts(path, s.name)), @@ -712,6 +726,20 @@ const assessPackageJsonEntries = ({ planningPath: PlanningIntentPath; }): FlatStringRecordAssessment => { const { path } = planningPath; + const fieldConflicts = Arr.flatMap( + planningPath.packageJsonFields, + ({ field, value }) => + packageJson[field] === undefined || + Equal.equals( + comparablePackageJsonFieldValue({ + field, + value: Schema.decodeUnknownSync(Schema.Json)(packageJson[field]), + }), + comparablePackageJsonFieldValue({ field, value }), + ) + ? [] + : [PlanConflict.cases.packageJsonField.make({ path, field })], + ); const dependenciesBySection = Arr.groupBy( planningPath.dependencies, (plannedDependency) => plannedDependency.section, @@ -752,6 +780,7 @@ const assessPackageJsonEntries = ({ return { conflicts: [ + ...fieldConflicts, ...exportAssessment.conflicts, ...Arr.flatMap( dependencyAssessments, @@ -760,6 +789,10 @@ const assessPackageJsonEntries = ({ ...scriptAssessment.conflicts, ], hasAdditions: + Arr.some( + planningPath.packageJsonFields, + ({ field }) => packageJson[field] === undefined, + ) || exportAssessment.hasAdditions || Arr.some( dependencyAssessments, diff --git a/packages/scaffold/src/service/plan/PlanRenderer.ts b/packages/scaffold/src/service/plan/PlanRenderer.ts index af0a2f6a..9caa17e1 100644 --- a/packages/scaffold/src/service/plan/PlanRenderer.ts +++ b/packages/scaffold/src/service/plan/PlanRenderer.ts @@ -49,6 +49,11 @@ const renderOperation = ( op: typeof CompositionOperation.Type, ): string => Match.value(op).pipe( + Match.tag( + "json-pkg-fields", + (o) => + `In \`${path}\`, set these complete package.json fields:\n${o.entries.map((e) => ` "${e.field}": ${JSON.stringify(e.value)}`).join("\n")}`, + ), Match.tag("json-pkg-exports", (o) => { const entries = o.entries .map((e) => ` "${e.name}": "${e.value}"`) @@ -101,6 +106,11 @@ const renderConflict = ( conflict: typeof PlanConflict.Type, ): { path: string; kind: string; description: string } => Match.value(conflict).pipe( + Match.tag("packageJsonField", (c) => ({ + path: c.path, + kind: "package-json-field", + description: `Package.json field "${c.field}" differs in ${c.path}`, + })), Match.tag("exports", (c) => ({ path: c.path, kind: "exports", diff --git a/packages/scaffold/src/service/plan/PlanningIntentCompiler.ts b/packages/scaffold/src/service/plan/PlanningIntentCompiler.ts index 614963bc..b9bef4b3 100644 --- a/packages/scaffold/src/service/plan/PlanningIntentCompiler.ts +++ b/packages/scaffold/src/service/plan/PlanningIntentCompiler.ts @@ -1,10 +1,11 @@ -import { Contribution } from "@repo/domain/Catalog"; +import { Contribution, PackageJsonField } from "@repo/domain/Catalog"; import { PlanFailure } from "@repo/domain/Plan"; import type { NormalizedContributions } from "@repo/domain/Scaffold"; import { Array as Arr, Context, Effect, + Equal, Layer, Match, Option, @@ -12,6 +13,7 @@ import { Record, Schema, } from "effect"; +import { comparablePackageJsonFieldValue } from "./PackageJsonFieldValue"; import type { PlanningIntentPath } from "./PlanAssessor"; export interface PlanningIntentCompilerShape { @@ -60,6 +62,10 @@ function compile(normalizedContributions: typeof NormalizedContributions.Type) { } const PlanningIntentEntry = Schema.TaggedUnion({ + packageJsonField: { + path: Schema.String, + ...PackageJsonField.fields, + }, authoritative: { path: Schema.String, contents: Schema.String, @@ -139,6 +145,13 @@ const toPlanningIntentEntries = ( Arr.flatMap( contributions, Contribution.match({ + "pkg-json-field": (c): ReadonlyArray => [ + PlanningIntentEntry.cases.packageJsonField.make({ + path: c.path, + field: c.field, + value: c.value, + }), + ], file: (c): ReadonlyArray => [ PlanningIntentEntry.cases.authoritative.make({ path: c.path, @@ -212,6 +225,9 @@ const toPlanningIntentEntries = ( ); type PlanningIntentEntryGroups = { + readonly packageJsonFields: ReadonlyArray< + typeof PlanningIntentEntry.cases.packageJsonField.Type + >; readonly authoritative: ReadonlyArray< typeof PlanningIntentEntry.cases.authoritative.Type >; @@ -235,6 +251,9 @@ type PlanningIntentEntryGroups = { const groupPlanningIntentEntries = ( entries: ReadonlyArray, ): PlanningIntentEntryGroups => ({ + packageJsonFields: entries.filter( + PlanningIntentEntry.guards.packageJsonField, + ), authoritative: entries.filter(PlanningIntentEntry.guards.authoritative), packageJson: entries.filter(PlanningIntentEntry.guards.packageJsonEntry), barrel: entries.filter(PlanningIntentEntry.guards.barrelExport), @@ -244,6 +263,7 @@ const groupPlanningIntentEntries = ( }); const emptyPackageJsonFields = { + packageJsonFields: [], exports: [], dependencies: [], scripts: [], @@ -270,7 +290,7 @@ const makePlanningIntentPath = ({ readonly contents: string | undefined; readonly packageJsonFields?: Pick< PlanningIntentPath, - "exports" | "dependencies" | "scripts" + "exports" | "dependencies" | "scripts" | "packageJsonFields" >; readonly barrelExports?: PlanningIntentPath["barrelExports"]; readonly compositions?: PlanningIntentPath["compositions"]; @@ -310,6 +330,16 @@ const derivePlanningIntentPath = ({ groups.authoritative.some((e) => e.conflictOnModify); const resolvePackageJsonFields = () => { + const overlappingField = groups.packageJsonFields.find((field) => + groups.packageJson.some((entry) => entry.field === field.field), + ); + if (overlappingField) + return Effect.fail( + new PlanFailure({ + reason: "invalidPlanIntent", + message: `Whole-field and entry contributions overlap for ${path} field ${overlappingField.field}.`, + }), + ); type PackageJsonCase = typeof PlanningIntentEntry.cases.packageJsonEntry.Type; @@ -330,6 +360,13 @@ const derivePlanningIntentPath = ({ ); return Effect.all({ + packageJsonFields: collectUniqueEntries({ + entries: groups.packageJsonFields, + keyOf: (entry) => entry.field, + valueOf: comparablePackageJsonFieldValue, + toResult: ({ field, value }) => ({ field, value }), + errorMessage: `Conflicting package.json field outcomes for ${path}.`, + }), exports: collectUniqueEntries({ entries: exportEntries, keyOf: (entry) => entry.name, @@ -562,6 +599,7 @@ type PlanningIntentFamily = | "jsxSlot"; const toPlanningIntentFamily = PlanningIntentEntry.match({ + packageJsonField: () => "packageJson" as const, authoritative: () => "authoritative" as const, packageJsonEntry: () => "packageJson" as const, barrelExport: () => "barrel" as const, @@ -588,7 +626,7 @@ const requireSingleValue = ({ }), ), onSome: (firstValue) => - Arr.every(values, (value) => value === firstValue) + Arr.every(values, (value) => Equal.equals(value, firstValue)) ? Effect.succeed(firstValue) : Effect.fail( new PlanFailure({ @@ -608,7 +646,7 @@ const collectUniqueEntries = ({ }: { entries: ReadonlyArray; keyOf: (entry: Entry) => string; - valueOf: (entry: Entry) => string; + valueOf: (entry: Entry) => unknown; toResult: (entry: Entry) => Result; errorMessage: string; }) => From 42bfb7eab16adb230a208b6e80862ebc2ab34051 Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Sun, 11 Oct 2026 14:26:18 +0200 Subject: [PATCH 2/6] fix: isolate library authoring and matrix selections --- apps/cli/e2e/matrix.test.ts | 2 +- apps/cli/src/commands/catalog.ts | 10 +++- .../cli/src/commands/catalogWorkspace.test.ts | 59 +++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 apps/cli/src/commands/catalogWorkspace.test.ts diff --git a/apps/cli/e2e/matrix.test.ts b/apps/cli/e2e/matrix.test.ts index 4771240c..14ee83b2 100644 --- a/apps/cli/e2e/matrix.test.ts +++ b/apps/cli/e2e/matrix.test.ts @@ -67,7 +67,7 @@ const defaultTargetNames = new Map([ ["client-foldkit", "app"], ["server-mcp", ""], ["cli", "app"], - ["package", "domain"], + ["package", "library"], ]); const catalogModules = await Effect.runPromise( diff --git a/apps/cli/src/commands/catalog.ts b/apps/cli/src/commands/catalog.ts index 767cf26d..0cf9a221 100644 --- a/apps/cli/src/commands/catalog.ts +++ b/apps/cli/src/commands/catalog.ts @@ -128,6 +128,8 @@ const contributionPath = (contribution: typeof Contribution.Type) => ? contribution.barrelPath : contribution.path; +export { buildWorkspaceSelection }; + const buildWorkspaceSelection = Effect.fn("catalog.workspace.buildSelection")( function* ( config: typeof StackConfig.Type, @@ -212,7 +214,13 @@ const buildWorkspaceSelection = Effect.fn("catalog.workspace.buildSelection")( initTarget.modules.add(moduleId); } - for (const moduleDefinition of catalog.getModules()) { + for (const moduleDefinition of catalog + .getModules() + .filter( + (module) => + module.supportedRuntimes === undefined || + module.supportedRuntimes.includes(config.runtime._tag), + )) { for (const supportedOn of moduleDefinition.supportedOn) { if (supportedOn._tag === "kind" && supportedOn.kind === "workspace") { continue; diff --git a/apps/cli/src/commands/catalogWorkspace.test.ts b/apps/cli/src/commands/catalogWorkspace.test.ts new file mode 100644 index 00000000..9d164364 --- /dev/null +++ b/apps/cli/src/commands/catalogWorkspace.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "@effect/vitest"; +import { OfficialCatalogLayer } from "@repo/catalog-official/service"; +import { StackConfig } from "@repo/domain/Scaffold"; +import { + BlueprintService, + RecipeService, + RecipeTargetString, +} from "@repo/scaffold"; +import { Effect, Layer, Option, Schema } from "effect"; +import { buildWorkspaceSelection } from "./catalog"; + +const config = new StackConfig({ + name: "catalog-built", + runtime: { _tag: "deno" }, + typescript: "7", +}); +const TestLayer = Layer.mergeAll( + RecipeService.layer, + BlueprintService.layer, +).pipe(Layer.provideMerge(OfficialCatalogLayer)); + +describe("catalog authoring workspace runtime", () => { + it.effect( + "generates the default Deno blueprint without unsupported modules", + () => + Effect.gen(function* () { + const selection = yield* buildWorkspaceSelection(config, Option.none()); + expect( + selection.targets.flatMap((target) => + target.modules.map((module) => module.id), + ), + ).not.toContain("package-library"); + const blueprint = yield* (yield* BlueprintService).resolve( + selection, + config, + ); + expect(blueprint).toBeDefined(); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("rejects an explicit unsupported Deno library", () => + Effect.gen(function* () { + const selection = yield* buildWorkspaceSelection( + config, + Option.some([ + yield* Schema.decodeEffect(RecipeTargetString)( + "package/library:package-library", + ), + ]), + ); + const error = yield* Effect.flip( + (yield* BlueprintService).resolve(selection, config), + ); + expect(error.message).toContain( + "Runtime deno does not support module package-library", + ); + }).pipe(Effect.provide(TestLayer)), + ); +}); From 0a0d887c62aab9ba12f349f49c6136b2dac9f402 Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Sun, 11 Oct 2026 14:41:29 +0200 Subject: [PATCH 3/6] test: split maximal bundles by capability provider --- apps/cli/e2e/matrix.test.ts | 41 ++++++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/apps/cli/e2e/matrix.test.ts b/apps/cli/e2e/matrix.test.ts index 14ee83b2..c9bd662a 100644 --- a/apps/cli/e2e/matrix.test.ts +++ b/apps/cli/e2e/matrix.test.ts @@ -108,10 +108,13 @@ const requiredProviderSelections = ( } if (dependency._tag !== "required-capability") continue; - const provider = getCapabilityProviders({ + const providers = getCapabilityProviders({ capability: dependency.capability, target: dependency.target, - })[0]; + }); + const provider = + providers.find((candidate) => moduleIds.includes(candidate.id)) ?? + providers[0]; if (!provider) continue; const target = identityToTarget(dependency.target); @@ -164,6 +167,32 @@ function groupModulesByTarget( return groups; } +const providedCapabilities = (moduleId: string) => + catalogModulesById.get(ModuleId.make(moduleId))?.provides ?? []; + +/** Maximal bundles choose one provider for each capability. */ +const maximalBundles = (moduleIds: ReadonlyArray) => + [...new Set(moduleIds.flatMap(providedCapabilities))].reduce< + ReadonlyArray> + >( + (bundles, capability) => + bundles.flatMap((bundle) => { + const providers = bundle.filter((id) => + providedCapabilities(id).includes(capability), + ); + return providers.length <= 1 + ? [bundle] + : providers.map((provider) => + bundle.filter( + (id) => + id === provider || + !providedCapabilities(id).includes(capability), + ), + ); + }), + [moduleIds], + ); + const buildMatrix = Effect.gen(function* () { const catalog = yield* CatalogService; const entries: Array = []; @@ -181,11 +210,13 @@ const buildMatrix = Effect.gen(function* () { }); } - if (moduleIds.length > 1) { + for (const bundle of maximalBundles(moduleIds).filter( + (bundle) => bundle.length > 1, + )) { entries.push({ target, - modules: moduleIds, - label: `${target} + [all: ${moduleIds.join(", ")}]`, + modules: bundle, + label: `${target} + [all: ${bundle.join(", ")}]`, }); } } From 7c90db8c18d829e657c1b4f30a4c4767ccdb3fec Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Sun, 11 Oct 2026 15:32:38 +0200 Subject: [PATCH 4/6] refactor: keep library publishing in catalog templates --- .changeset/yellow-tables-shop.md | 10 - .okf/domain/contributions.md | 6 +- .okf/domain/planning.md | 5 +- .okf/guides/add-new-module.md | 15 +- apps/cli/scripts/verify-library.ts | 15 +- .../app/content/publish-catalog-registry.mdx | 1 - apps/docs/app/content/publish-library.mdx | 33 ++- catalogs/official/src/modules/packages.ts | 63 ++--- .../templates/package-library/README.md | 21 +- .../package-library/package.publish.json | 20 ++ .../scripts/prepare-package.mjs | 13 + docs/research/publishable-library-scaffold.md | 10 +- packages/authoring/src/buildCatalog.test.ts | 31 --- packages/authoring/src/buildCatalog.ts | 10 +- packages/catalog/src/CatalogProtocol.ts | 1 - packages/domain/src/Catalog.ts | 30 +-- packages/domain/src/Plan.ts | 12 - .../scaffold/src/service/ScaffolFormatter.ts | 1 - .../src/service/apply/JsonComposer.ts | 9 - .../src/service/plan/ContributionResolver.ts | 24 +- .../src/service/plan/PackageJsonFieldValue.ts | 11 - .../service/plan/PackageJsonFields.test.ts | 250 ------------------ .../src/service/plan/PlanAssessor.test.ts | 1 - .../scaffold/src/service/plan/PlanAssessor.ts | 41 +-- .../scaffold/src/service/plan/PlanRenderer.ts | 10 - .../service/plan/PlanningIntentCompiler.ts | 46 +--- 26 files changed, 118 insertions(+), 571 deletions(-) delete mode 100644 .changeset/yellow-tables-shop.md create mode 100644 catalogs/official/templates/package-library/package.publish.json create mode 100644 catalogs/official/templates/package-library/scripts/prepare-package.mjs delete mode 100644 packages/scaffold/src/service/plan/PackageJsonFieldValue.ts delete mode 100644 packages/scaffold/src/service/plan/PackageJsonFields.test.ts diff --git a/.changeset/yellow-tables-shop.md b/.changeset/yellow-tables-shop.md deleted file mode 100644 index 88df2186..00000000 --- a/.changeset/yellow-tables-shop.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"stack-effect": minor -"@stack-effect/author": minor ---- - -Scaffold publishable ESM libraries with compiled JavaScript and TypeScript declarations. - -For example, `stack-effect add --target package/sdk:package-library` creates a library under `packages/sdk` with one root export and a build before npm packing. The starter has no child modules. - -Catalog authors can use `pkg-json-field` to set publishing fields such as `files`, `peerDependencies`, and conditional `exports`. Differing existing values require an Apply decision. diff --git a/.okf/domain/contributions.md b/.okf/domain/contributions.md index ddbecd91..aa9e58ca 100644 --- a/.okf/domain/contributions.md +++ b/.okf/domain/contributions.md @@ -8,7 +8,7 @@ sources: resource: ../../packages/domain/src/Catalog.ts - id: source-2 resource: ../../packages/domain/src/Scaffold.ts -generated: { by: codex, at: "2026-10-11T14:06:18+02:00" } +generated: { by: codex, at: "2026-09-22T17:40:50+00:00" } --- # Contributions and configuration @@ -20,9 +20,7 @@ Declarative desired repository state from targets/modules. Invariants: -- `Contribution` is a tagged union of `file`, `pkg-json-entry`, `pkg-json-field`, `barrel-export`, `ts-call-arg`, `ts-object-field`, and `jsx-slot`. `NormalizedContributions` groups resolved target and module contributions. -- `pkg-json-field` sets one complete `private`, `files`, `types`, `publishConfig`, `peerDependencies`, `dependencies`, or `exports` field from a JSON value. Object values replace the field rather than merge its entries. Nested string values resolve contribution tokens. -- Competing values for a field, or whole-field and entry contributions to the same field, are invalid planning intent. Differing values already on disk produce `packageJsonField` conflicts for Apply decisions. +- `Contribution` is a tagged union of `file`, `pkg-json-entry`, `barrel-export`, `ts-call-arg`, `ts-object-field`, and `jsx-slot`. `NormalizedContributions` groups resolved target and module contributions. - Values are declarative intent, not direct apply results. Connected terms: diff --git a/.okf/domain/planning.md b/.okf/domain/planning.md index 90ac66c9..f038cdfc 100644 --- a/.okf/domain/planning.md +++ b/.okf/domain/planning.md @@ -8,7 +8,7 @@ sources: resource: ../../packages/domain/src/Plan.ts - id: source-2 resource: ../../packages/domain/src/Apply.ts -generated: { by: codex, at: "2026-10-11T14:06:18+02:00" } +generated: { by: codex, at: "2026-09-22T17:40:50+00:00" } --- # Planning and conflict terms @@ -39,8 +39,7 @@ Invariants: - Operations are tagged unions with `_tag` and `fileType` discriminators. - `fileType` is `"json"` or `"typescript"` for type-safe filtering. -- JSON operations: `json-pkg-exports`, `json-pkg-deps`, `json-pkg-scripts`, `json-pkg-fields`, and `json-workspace-members`. -- `json-pkg-fields` replaces the requested complete fields and preserves unrelated metadata. Existing composed package manifests are unchanged when all requested values are present; their creation seed is not reapplied. +- JSON operations: `json-pkg-exports`, `json-pkg-deps`, `json-pkg-scripts`. - TypeScript operations: `ts-add-import`, `ts-add-reexport`, `ts-append-call-arg`, `ts-object-field`, and `ts-jsx-slot`. - Composition behavior depends on the operation. Check the composer tests for duplicate handling and missing AST targets; do not assume every operation silently skips duplicates. - Composition errors flow through Effect error channel as typed tagged errors. diff --git a/.okf/guides/add-new-module.md b/.okf/guides/add-new-module.md index 0a217d03..de2c226b 100644 --- a/.okf/guides/add-new-module.md +++ b/.okf/guides/add-new-module.md @@ -10,7 +10,7 @@ sources: resource: ../../catalogs/official/src/modules/domain.ts - id: source-3 resource: ../../catalogs/official/src/moduleRegistry.ts -generated: { by: codex, at: "2026-10-11T14:06:18+02:00" } +generated: { by: claude, at: "2026-09-29T18:00:00+02:00" } --- # Add a catalog module @@ -78,19 +78,6 @@ An implication uses `{ targetKind, moduleId }` in `implies`. It connects selecti Use `file` for authoritative content; `pkg-json-entry` for an export, dependency, devDependency, or script; `barrel-export` for a re-export; and `ts-call-arg`, `ts-object-field`, or `jsx-slot` for composition into existing code. A file contribution can set `conflictOnModify` when replacing existing content should require a decision. -Use `pkg-json-field` for a complete publishing field. Its `field` is `private`, `files`, `types`, `publishConfig`, `peerDependencies`, `dependencies`, or `exports`; its `value` is JSON. For example: - -```typescript -{ - _tag: "pkg-json-field", - path: "{{targetPath}}/package.json", - field: "exports", - value: { ".": { types: "./dist/index.d.ts", import: "./dist/index.js" } }, -} -``` - -Object values replace the complete field. Do not combine a whole-field contribution with `pkg-json-entry` contributions to that same field. Different existing values produce a Plan conflict rather than an implicit override. The build declares `contribution:pkg-json-field` only for catalogs that use it. - A package dependency entry has this shape: ```typescript diff --git a/apps/cli/scripts/verify-library.ts b/apps/cli/scripts/verify-library.ts index ab4e321a..2b04bf83 100644 --- a/apps/cli/scripts/verify-library.ts +++ b/apps/cli/scripts/verify-library.ts @@ -138,8 +138,10 @@ const verify = (runtime: "bun" | "node") => yield* fs.remove(path.join(library, "src/Obsolete.ts")); const packed = yield* Schema.decodeEffect(Packed)( yield* run(library, "npm", [ + "--silent", + "run", "pack", - ".", + "--", "--json", "--pack-destination", root, @@ -152,19 +154,16 @@ const verify = (runtime: "bun" | "node") => }); const files = archive.files.map((file) => file.path); yield* requireThat( - [ - "dist/index.js", - "dist/index.d.ts", - "dist/Answer.js", - "dist/Answer.d.ts", - ].every((file) => files.includes(file)), + ["index.js", "index.d.ts", "Answer.js", "Answer.d.ts"].every((file) => + files.includes(file), + ), "Tarball is missing public JavaScript or declarations", ); yield* requireThat( files.every( (file) => ["package.json", "README.md", "LICENSE"].includes(file) || - (file.startsWith("dist/") && + ((file.endsWith(".js") || file.endsWith(".d.ts")) && !file.includes("Obsolete") && !file.endsWith(".map")), ), diff --git a/apps/docs/app/content/publish-catalog-registry.mdx b/apps/docs/app/content/publish-catalog-registry.mdx index 551371d9..cbc5d4a8 100644 --- a/apps/docs/app/content/publish-catalog-registry.mdx +++ b/apps/docs/app/content/publish-catalog-registry.mdx @@ -133,7 +133,6 @@ A contribution is one of these types: | --- | --- | | `file` | A whole file. Set `conflictOnModify: true` to report a conflict instead of replacing an existing file | | `pkg-json-entry` | One entry in `exports`, `dependencies`, `devDependencies`, or `scripts` of a `package.json` | -| `pkg-json-field` | One complete publishing field, including nested JSON values such as conditional `exports` or `peerDependencies` | | `barrel-export` | An `export * from` line in a barrel file | | `ts-call-arg` | An argument to a function call, with its import, such as a Layer in `Layer.mergeAll` | | `ts-object-field` | A property in an object passed to a function call | diff --git a/apps/docs/app/content/publish-library.mdx b/apps/docs/app/content/publish-library.mdx index 088b2fda..fb6b420b 100644 --- a/apps/docs/app/content/publish-library.mdx +++ b/apps/docs/app/content/publish-library.mdx @@ -6,17 +6,21 @@ Add a small ESM library to an existing Stack Effect project with a Bun or Node r stack-effect add --target package/sdk:package-library ``` -Use a new package name. This starter exposes one root entry point and has no child modules. It creates these files under `packages/sdk`: +Use a new package name. This starter has one root entry point and no child modules. It creates these files under `packages/sdk`: ```text package.json +package.publish.json README.md tsconfig.json tsconfig.build.json scripts/clean.mjs +scripts/prepare-package.mjs src/index.ts ``` +The workspace package stays private and exports TypeScript source for local use. `package.publish.json` owns the npm publication metadata. The build copies that manifest into `dist/package.json`, beside the compiled JavaScript and declarations. + ## Add your public API Add implementation files under `src/` and export them from `src/index.ts`: @@ -25,29 +29,34 @@ Add implementation files under `src/` and export them from `src/index.ts`: export { greet } from "./greet.js"; ``` -Keep `.js` extensions in relative imports and exports. TypeScript resolves these paths to your source files and emits imports that work in Node. +Keep `.js` extensions in relative imports and exports. TypeScript resolves these paths to source files and emits imports that work in Node. -Consumers import your library through its package name. Add explicit `package.json` export entries when you need public subpaths. +Published consumers import the library through its npm name. Add explicit entries to `package.publish.json` when you need public subpaths. ## Build and pack -Install the workspace dependencies, then run these commands from `packages/sdk`: +Install the workspace dependencies, then run this command from `packages/sdk`: ```sh -npm run build -npm pack +npm run pack ``` -Node and npm are required for this packaging workflow, including in a Bun workspace. The build checks NodeNext module resolution and emits JavaScript and declarations to `dist/`. Each build removes the previous output. The `prepack` script rebuilds the package before npm packs or publishes it. +The command rebuilds the library and packs `dist/`. Each build removes previous output, checks NodeNext module resolution, emits JavaScript and declarations, and copies the publication manifest and documentation. Node and npm are required for packaging, including in a Bun workspace. -The tarball contains `dist/` and package documentation. Effect is a peer dependency, so consumers supply the compatible Effect version. Remove both Effect entries if your library does not use Effect. +Use `npm run build` to inspect `dist/` without creating a tarball. The tarball exposes one compiled root export and declares Effect as a peer dependency. Remove the peer entry from `package.publish.json` if your public API does not use Effect. ## Prepare publication -Replace the placeholder `@repo/sdk` package name and update workspace consumers. Choose a version and license, add the appropriate license file, and fill in your description and repository metadata. +Set your npm name, version, license, description, and repository in `package.publish.json`. Add a `LICENSE` file beside that manifest; the build copies it into `dist/` when present. The workspace package can keep its private `@repo/sdk` name. + +Install the tarball in a separate project and check runtime imports and declarations. Keep unpublished workspace dependencies out of emitted files. The generated README includes the publication checklist. + +After reviewing the tarball, publish the compiled package: -Install the packed tarball in a separate project and check its runtime imports and declarations. Keep unpublished `@repo/*` dependencies out of the emitted files. The generated README includes the publication checklist. +```sh +npm publish ./dist +``` -The package declares public npm access. Publish deliberately after inspecting the tarball; Stack Effect does not publish it automatically. +Publish `dist/`, which declares public npm access. The workspace root package remains private. Stack Effect does not publish automatically. -For repository maintainers, `bun run --cwd apps/cli verify:library` generates Bun and npm workspaces and tests their tarballs in clean consumers. It checks runtime imports, declarations, private subpaths, and removal of stale build files. All temporary projects are removed afterward. +Repository maintainers can run `bun run --cwd apps/cli verify:library` to test generated Bun and npm libraries in clean consumers. It checks the packed JavaScript and declarations, private subpaths, and stale-output cleanup. All temporary projects are removed afterward. diff --git a/catalogs/official/src/modules/packages.ts b/catalogs/official/src/modules/packages.ts index 80eb7026..6f0410ed 100644 --- a/catalogs/official/src/modules/packages.ts +++ b/catalogs/official/src/modules/packages.ts @@ -37,73 +37,42 @@ export const packageModules = defineModules(import.meta.url, [ conflictOnModify: true, }, { - _tag: "pkg-json-field", - path: "{{targetPath}}/package.json", - field: "private", - value: false, - }, - { - _tag: "pkg-json-field", - path: "{{targetPath}}/package.json", - field: "files", - value: ["dist"], - }, - { - _tag: "pkg-json-field", - path: "{{targetPath}}/package.json", - field: "types", - value: "./dist/index.d.ts", - }, - { - _tag: "pkg-json-field", - path: "{{targetPath}}/package.json", - field: "publishConfig", - value: { access: "public" }, - }, - { - _tag: "pkg-json-field", - path: "{{targetPath}}/package.json", - field: "exports", - value: { - ".": { types: "./dist/index.d.ts", import: "./dist/index.js" }, - }, - }, - { - _tag: "pkg-json-field", - path: "{{targetPath}}/package.json", - field: "dependencies", - value: {}, + _tag: "file", + path: "{{targetPath}}/scripts/prepare-package.mjs", + contents: template("./package-library/scripts/prepare-package.mjs"), + conflictOnModify: true, }, { - _tag: "pkg-json-field", - path: "{{targetPath}}/package.json", - field: "peerDependencies", - value: { effect: "4.0.0" }, + _tag: "file", + path: "{{targetPath}}/package.publish.json", + contents: template("./package-library/package.publish.json"), + conflictOnModify: true, }, { _tag: "pkg-json-entry", path: "{{targetPath}}/package.json", - field: "devDependencies", - name: "effect", - value: "4.0.0", + field: "exports", + name: ".", + value: "./src/index.ts", }, { _tag: "pkg-json-entry", path: "{{targetPath}}/package.json", field: "scripts", name: "build", - value: "node scripts/clean.mjs && tsc -p tsconfig.build.json", + value: + "node scripts/clean.mjs && tsc -p tsconfig.build.json && node scripts/prepare-package.mjs", }, { _tag: "pkg-json-entry", path: "{{targetPath}}/package.json", field: "scripts", - name: "prepack", - value: "npm run build", + name: "pack", + value: "npm run build && npm pack ./dist", }, ], nextSteps: [ - "Library: edit {{targetPath}}/src/index.ts, then run `npm run build` and `npm pack` from {{targetPath}}. Choose an npm package name and license before publishing; see {{targetPath}}/README.md. Node and npm are required for packaging.", + "Library: edit {{targetPath}}/src/index.ts, then run `npm run pack` from {{targetPath}}. Choose an npm package name in package.publish.json and a license before publishing; see {{targetPath}}/README.md. Node and npm are required for packaging.", ], }, { diff --git a/catalogs/official/templates/package-library/README.md b/catalogs/official/templates/package-library/README.md index 9257f19c..c1e93bc3 100644 --- a/catalogs/official/templates/package-library/README.md +++ b/catalogs/official/templates/package-library/README.md @@ -10,23 +10,22 @@ Add your implementation under `src/` and export it from `src/index.ts`: export { greet } from "./greet.js"; ``` -Keep the `.js` extension in relative imports and exports. TypeScript resolves it to the source `.ts` file and emits paths that work in Node. +Keep `.js` extensions in relative imports and exports. TypeScript resolves them to source `.ts` files and emits paths that work in Node. -The package initially exposes only its root entry point. Add explicit `exports` entries if you need public subpaths. +The workspace package stays private and exposes its source entry point. `package.publish.json` describes the published package, which exposes only its compiled root entry point. Add explicit exports there when you need public subpaths. ## Build and pack -Run `npm run build` and `npm pack` from this directory. Packing rebuilds the JavaScript and declarations in `dist/`. Node and npm are required for this publishing workflow, including in a Bun workspace. Install the workspace dependencies before building. +Install the workspace dependencies, then run `npm run pack` from this directory. The command rebuilds JavaScript and declarations, copies the publication manifest and README into `dist/`, and packs that directory. Each build removes stale output. Node and npm are required, including in a Bun workspace. -Effect is a peer dependency. Consumers supply the compatible Effect version; the development dependency lets this package compile. If the public API does not use Effect, remove both entries. +Use `npm run build` to inspect the output without packing. The published package declares Effect as a peer dependency; workspace development uses the existing Effect dependency. If your public API does not use Effect, remove its peer entry from `package.publish.json`. ## Before publishing -- Replace the placeholder `{{packageName}}` name with your npm package name and update workspace consumers. -- Choose your version and license, and add the appropriate license file. -- Add your package description, repository, and other author metadata. -- Verify the packed tarball in a clean consumer, including runtime imports and TypeScript declarations. -- Keep unpublished `@repo/*` dependencies out of emitted JavaScript and declarations. -- Publish deliberately with npm after reviewing the packed files. +- Set your npm name, version, license, description, and repository in `package.publish.json`. +- Add a `LICENSE` file here; the build copies it into `dist/` when present. +- Verify the tarball in a clean consumer, including runtime imports and TypeScript declarations. +- Keep unpublished `@repo/*` imports out of emitted JavaScript and declarations. +- Inspect the packed files, then run `npm publish ./dist` deliberately. -`publishConfig.access` is `public`. This scaffold does not publish automatically. +The publication manifest declares public npm access. Publish `dist/`; the workspace root package remains private. This scaffold does not publish automatically. diff --git a/catalogs/official/templates/package-library/package.publish.json b/catalogs/official/templates/package-library/package.publish.json new file mode 100644 index 00000000..6ed71b4e --- /dev/null +++ b/catalogs/official/templates/package-library/package.publish.json @@ -0,0 +1,20 @@ +{ + "name": "{{packageName}}", + "version": "0.0.0", + "type": "module", + "private": false, + "files": ["**/*.js", "**/*.d.ts"], + "types": "./index.d.ts", + "exports": { + ".": { + "types": "./index.d.ts", + "import": "./index.js" + } + }, + "peerDependencies": { + "effect": "4.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/catalogs/official/templates/package-library/scripts/prepare-package.mjs b/catalogs/official/templates/package-library/scripts/prepare-package.mjs new file mode 100644 index 00000000..65916552 --- /dev/null +++ b/catalogs/official/templates/package-library/scripts/prepare-package.mjs @@ -0,0 +1,13 @@ +// oxlint-disable-next-line effecttsgo/node-builtin-import -- Packaging operates on compiled artifacts. +import { copyFileSync, existsSync } from "node:fs"; + +const root = new URL("../", import.meta.url); +const output = new URL("dist/", root); +copyFileSync( + new URL("package.publish.json", root), + new URL("package.json", output), +); +copyFileSync(new URL("README.md", root), new URL("README.md", output)); +if (existsSync(new URL("LICENSE", root))) { + copyFileSync(new URL("LICENSE", root), new URL("LICENSE", output)); +} diff --git a/docs/research/publishable-library-scaffold.md b/docs/research/publishable-library-scaffold.md index f0c53eba..4cddda57 100644 --- a/docs/research/publishable-library-scaffold.md +++ b/docs/research/publishable-library-scaffold.md @@ -4,13 +4,15 @@ Initial research and proposal, 2026-10-11. The original design discussion below ## Implementation result -The `package-library` module now supports Bun and Node package targets with custom names, one compiled root export, and no child modules. The shared package target is visible in target selection. The build uses a separate NodeNext configuration and removes stale output before emitting JavaScript and declarations. +The `package-library` module supports Bun and Node packages with custom names, one compiled root export, and no child modules. It uses the existing catalog contribution types; no interpreter extension or CLI release is required to scaffold it. -`pkg-json-field` composes complete publishing fields, including conditional exports and Effect peer dependencies. Competing contributions fail planning; differing values on disk require an Apply decision. Catalogs that do not use the new operation retain their previous capability requirements. +The workspace package remains private and exports its source. A separate `package.publish.json` describes the published package. `npm run pack` rebuilds JavaScript and declarations into `dist/`, copies the publication manifest and documentation there, and packs that directory. Publication uses `npm publish ./dist`. -The clean-consumer verifier passed for generated Bun and npm workspaces, including Node imports, NodeNext and bundler declarations, private subpaths, and stale-output cleanup. See the [usage guide](../../apps/docs/app/content/publish-library.mdx) and [repeatable verifier](../../apps/cli/scripts/verify-library.ts). +This supersedes the root-manifest composition proposal below. The original proposal needed a new `pkg-json-field` operation because the existing engine cannot set complete publishing fields on an existing package manifest. Keeping publication metadata in its own ordinary file avoids that engine change and its release changeset. -## Recommendation +The clean-consumer verifier covers Bun and npm workspaces, Node execution, NodeNext and bundler declarations, private subpaths, and stale-output cleanup. See the [usage guide](../../apps/docs/app/content/publish-library.mdx) and [verifier](../../apps/cli/scripts/verify-library.ts). + +## Original recommendation (superseded) Add one public `package-library` module to the existing `package` target kind. It creates a small ESM library under `packages/`, with one root export and no child modules. Use TypeScript to emit JavaScript and declarations. Add build tooling, subpath exports, and release automation only when a library needs them. diff --git a/packages/authoring/src/buildCatalog.test.ts b/packages/authoring/src/buildCatalog.test.ts index eb57cd21..bcafe1a1 100644 --- a/packages/authoring/src/buildCatalog.test.ts +++ b/packages/authoring/src/buildCatalog.test.ts @@ -164,37 +164,6 @@ const symlinkedCatalog = Effect.gen(function* () { }); layer(NodeServices.layer)("buildCatalog", (it) => { - it.effect( - "declares publishing-field support only when the authored catalog uses it", - () => - Effect.gen(function* () { - const plain = yield* buildCatalog(catalog, { catalogId: "acme", root }); - const publishing = yield* buildCatalog( - withModule({ - ...standaloneModule, - contributions: [ - { - _tag: "pkg-json-field", - path: "package.json", - field: "files", - value: ["dist"], - }, - ], - }), - { catalogId: "acme", root: packageRoot }, - ); - assert.isFalse( - plain.document.requiredCapabilities.includes( - "contribution:pkg-json-field", - ), - ); - assert.isTrue( - publishing.document.requiredCapabilities.includes( - "contribution:pkg-json-field", - ), - ); - }), - ); it.effect( "should match the golden v1 document byte for byte when the standalone fixture is built twice", () => diff --git a/packages/authoring/src/buildCatalog.ts b/packages/authoring/src/buildCatalog.ts index 70f8f6c2..75a204d7 100644 --- a/packages/authoring/src/buildCatalog.ts +++ b/packages/authoring/src/buildCatalog.ts @@ -570,14 +570,8 @@ export const buildCatalog = Effect.fn("Authoring.buildCatalog")(function* ( catalogId, requiredCapabilities: V1_INTERPRETER_CAPABILITIES.filter( (capability) => - (capability !== "target:hierarchical-name" || - hierarchicalNameCapabilities(authored).includes(capability)) && - (capability !== "contribution:pkg-json-field" || - [...authored.targets, ...authored.modules].some((definition) => - definition.contributions.some( - (contribution) => contribution._tag === "pkg-json-field", - ), - )), + capability !== "target:hierarchical-name" || + hierarchicalNameCapabilities(authored).includes(capability), ), ...(official === undefined ? {} : { requires: ["official"] }), targets: composed.targets.filter((target) => diff --git a/packages/catalog/src/CatalogProtocol.ts b/packages/catalog/src/CatalogProtocol.ts index 17365329..985b84d0 100644 --- a/packages/catalog/src/CatalogProtocol.ts +++ b/packages/catalog/src/CatalogProtocol.ts @@ -12,7 +12,6 @@ import { Array as Arr, Effect, Schema } from "effect"; const contributionTags = [ "file", "pkg-json-entry", - "pkg-json-field", "barrel-export", "ts-call-arg", "ts-object-field", diff --git a/packages/domain/src/Catalog.ts b/packages/domain/src/Catalog.ts index 5d3f9007..d311e33b 100644 --- a/packages/domain/src/Catalog.ts +++ b/packages/domain/src/Catalog.ts @@ -179,21 +179,15 @@ export const ModuleImplication = Schema.Struct({ moduleId: ModuleId, }); -/** Whole package.json fields. Object values replace the field, rather than merge it. */ -export const PackageJsonField = Schema.Struct({ - field: Schema.Literals([ - "private", - "files", - "types", - "publishConfig", - "peerDependencies", - "dependencies", - "exports", - ]), - value: Schema.Json, -}); - -/** A Contribution declares desired files, package.json fields/entries, or TypeScript composition. */ +/** + * A Contribution declares a single unit of desired repository state. + * + * Tagged union of contribution types: + * - `file`: Authoritative file content (replaces entire file) + * - `pkg-json-entry`: Entry in package.json (exports, dependencies, scripts) + * - `barrel-export`: Re-export statement in a TypeScript barrel file + * - `ts-call-arg`: Argument appended to a TypeScript function call + */ export const Contribution = Schema.TaggedUnion({ /** * Authoritative file content - the complete desired file. @@ -222,12 +216,6 @@ export const Contribution = Schema.TaggedUnion({ value: Schema.String, }, - /** Sets one complete field, with differing existing values reported as conflicts. */ - "pkg-json-field": { - path: Schema.String, - ...PackageJsonField.fields, - }, - /** * Barrel file re-export - adds `export * from "..."` to a barrel file. */ diff --git a/packages/domain/src/Plan.ts b/packages/domain/src/Plan.ts index 5a90d973..c2a82700 100644 --- a/packages/domain/src/Plan.ts +++ b/packages/domain/src/Plan.ts @@ -1,5 +1,4 @@ import { Array as Arr, Order, Schema } from "effect"; -import { PackageJsonField } from "./Catalog"; import { pathOrd } from "./Order"; import { StackConfig } from "./Scaffold"; import { Selection } from "./Selection"; @@ -148,13 +147,7 @@ export const JsonWorkspaceMembersOp = Schema.TaggedStruct( }, ); -export const JsonPkgFieldsOp = Schema.TaggedStruct("json-pkg-fields", { - fileType: Schema.tag("json"), - entries: Schema.Array(PackageJsonField), -}); - const JsonCompositionOperationSchema = Schema.Union([ - JsonPkgFieldsOp, JsonWorkspaceMembersOp, JsonPkgExportsOp, JsonPkgDepsOp, @@ -204,10 +197,6 @@ export const PlanOutcome = Schema.TaggedUnion({ }); export const PlanConflict = Schema.TaggedUnion({ - packageJsonField: { - path: Schema.String, - field: PackageJsonField.fields.field, - }, exports: { path: Schema.String, name: Schema.String, @@ -244,7 +233,6 @@ export const PlanConflict = Schema.TaggedUnion({ const planConflictKey = (conflict: typeof PlanConflict.Type): string => PlanConflict.match(conflict, { - packageJsonField: (c) => JSON.stringify([c._tag, c.path, c.field]), exports: (c) => JSON.stringify([c._tag, c.path, c.name]), dependencies: (c) => JSON.stringify([c._tag, c.path, c.section, c.name]), scripts: (c) => JSON.stringify([c._tag, c.path, c.name]), diff --git a/packages/scaffold/src/service/ScaffolFormatter.ts b/packages/scaffold/src/service/ScaffolFormatter.ts index 607da574..1edd685e 100644 --- a/packages/scaffold/src/service/ScaffolFormatter.ts +++ b/packages/scaffold/src/service/ScaffolFormatter.ts @@ -378,7 +378,6 @@ const formatConflictLine = (conflict: typeof PlanConflict.Type) => Match.value(conflict).pipe( Match.tags({ completeFile: () => "merge: complete file", - packageJsonField: (c) => `merge: ${c.field}`, barrelExport: (c) => `merge: export ${c.exportPath}`, dependencies: (c) => `merge: ${c.section}.${c.name}`, exports: (c) => `merge: exports ${c.name}`, diff --git a/packages/scaffold/src/service/apply/JsonComposer.ts b/packages/scaffold/src/service/apply/JsonComposer.ts index 7d44902a..8404f4dc 100644 --- a/packages/scaffold/src/service/apply/JsonComposer.ts +++ b/packages/scaffold/src/service/apply/JsonComposer.ts @@ -68,15 +68,6 @@ const applyJsonOperation = ( op: JsonCompositionOperation, ): Effect.Effect => Match.typeTags()({ - "json-pkg-fields": (o) => - Effect.sync(() => { - Object.assign( - pkg, - Object.fromEntries( - o.entries.map(({ field, value }) => [field, value]), - ), - ); - }), "json-workspace-members": (o) => Effect.sync(() => { pkg[o.field] = o.members; diff --git a/packages/scaffold/src/service/plan/ContributionResolver.ts b/packages/scaffold/src/service/plan/ContributionResolver.ts index 3f488897..edf7197e 100644 --- a/packages/scaffold/src/service/plan/ContributionResolver.ts +++ b/packages/scaffold/src/service/plan/ContributionResolver.ts @@ -8,7 +8,7 @@ import { type StackConfig, TargetContribution, } from "@repo/domain/Scaffold"; -import { Array as Arr, Context, Effect, Layer, Option, Schema } from "effect"; +import { Array as Arr, Context, Effect, Layer, Option } from "effect"; export class ContributionResolver extends Context.Service()( "ContributionResolver", @@ -97,32 +97,10 @@ const resolveContributionTokens = ( context: ContributionTokenContext, ): ReadonlyArray => { const resolveString = (value: string) => context.resolve(value); - const resolveJson = ( - value: typeof Schema.Json.Type, - ): typeof Schema.Json.Type => - typeof value === "string" - ? resolveString(value) - : Array.isArray(value) - ? value.map(resolveJson) - : value !== null && typeof value === "object" - ? Object.fromEntries( - Object.entries(value).map(([key, entry]) => [ - key, - resolveJson(entry), - ]), - ) - : value; return Arr.flatMap( contributions, Contribution.match({ - "pkg-json-field": (c): ReadonlyArray => [ - Contribution.cases["pkg-json-field"].make({ - path: resolveString(c.path), - field: c.field, - value: resolveJson(c.value), - }), - ], file: (c): ReadonlyArray => { const path = resolveString(c.path).trim(); if (path.length === 0) return []; diff --git a/packages/scaffold/src/service/plan/PackageJsonFieldValue.ts b/packages/scaffold/src/service/plan/PackageJsonFieldValue.ts deleted file mode 100644 index d9cdcf3f..00000000 --- a/packages/scaffold/src/service/plan/PackageJsonFieldValue.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { PackageJsonField } from "@repo/domain/Catalog"; -import { Schema } from "effect"; - -const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Json)); - -/** Conditional exports resolve in property order, unlike ordinary metadata. */ -export const comparablePackageJsonFieldValue = ({ - field, - value, -}: typeof PackageJsonField.Type) => - field === "exports" ? encodeJson(value) : value; diff --git a/packages/scaffold/src/service/plan/PackageJsonFields.test.ts b/packages/scaffold/src/service/plan/PackageJsonFields.test.ts deleted file mode 100644 index 028a3b77..00000000 --- a/packages/scaffold/src/service/plan/PackageJsonFields.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { Contribution, ModuleId, TargetKey } from "@repo/domain/Catalog"; -import { PlanFailure } from "@repo/domain/Plan"; -import { Effect, Layer, Schema } from "effect"; -import { JsonComposer } from "../apply/JsonComposer"; -import { PlanAssessor } from "./PlanAssessor"; -import { PlanningIntentCompiler } from "./PlanningIntentCompiler"; - -const json = Schema.encodeSync(Schema.fromJsonString(Schema.Json)); -const parseJson = Schema.decodeSync(Schema.fromJsonString(Schema.Json)); - -const path = "packages/library/package.json"; -const publishing: ReadonlyArray = [ - { _tag: "pkg-json-field", path, field: "private", value: false }, - { _tag: "pkg-json-field", path, field: "files", value: ["dist"] }, - { - _tag: "pkg-json-field", - path, - field: "exports", - value: { ".": { types: "./dist/index.d.ts", import: "./dist/index.js" } }, - }, - { - _tag: "pkg-json-field", - path, - field: "peerDependencies", - value: { effect: "4.0.0" }, - }, - { _tag: "pkg-json-field", path, field: "dependencies", value: {} }, -]; - -const compile = (contributions: ReadonlyArray) => - Effect.gen(function* () { - const compiler = yield* PlanningIntentCompiler; - const paths = yield* compiler.compile({ - targets: [], - modules: [ - { - targetKey: TargetKey.make("packages/library"), - moduleId: ModuleId.make("package-library"), - contributions, - }, - ], - }); - const result = paths.find((entry) => entry.path === path); - if (result === undefined) - return yield* Effect.die("Expected package.json intent"); - return result; - }); - -const TestLayer = Layer.mergeAll( - PlanningIntentCompiler.layer, - PlanAssessor.layer, - JsonComposer.layer, -); - -describe("publishing package.json fields", () => { - it.effect("reports reordered export conditions as competing outcomes", () => - Effect.gen(function* () { - const assessor = yield* PlanAssessor; - const first = { - _tag: "pkg-json-field", - path, - field: "exports", - value: { ".": { default: "./a.js", import: "./b.js" } }, - } as const; - const second = { - ...first, - value: { ".": { import: "./b.js", default: "./a.js" } }, - }; - const intent = yield* compile([first]); - expect( - assessor.assessPlanningPath({ - planningPath: intent, - snapshotPath: { - _tag: "file", - path, - contents: json({ exports: second.value }), - }, - }).conflicts, - ).toEqual([{ _tag: "packageJsonField", path, field: "exports" }]); - expect(yield* Effect.flip(compile([first, second]))).toBeInstanceOf( - PlanFailure, - ); - }).pipe(Effect.provide(TestLayer)), - ); - it.effect( - "creates a publishable manifest and leaves repeat planning unchanged", - () => - Effect.gen(function* () { - const assessor = yield* PlanAssessor; - const composer = yield* JsonComposer; - const intent = yield* compile([ - { - _tag: "file", - path, - contents: json({ - name: "@repo/library", - private: true, - dependencies: { effect: "4.0.0" }, - }), - }, - ...publishing, - ]); - const assessment = assessor.assessPlanningPath({ - planningPath: intent, - snapshotPath: { _tag: "missing", path }, - }); - expect(assessment.classification).toBe("create"); - const outcome = assessor.toPlannedFileOutcome({ - planningPath: intent, - classification: assessment.classification, - }); - if (outcome._tag !== "composed") - return yield* Effect.die("Expected composed manifest"); - const manifest = yield* composer.compose( - outcome.seedContents ?? "{}", - outcome.operations.filter( - (operation) => operation.fileType === "json", - ), - ); - expect(parseJson(manifest)).toMatchObject({ - private: false, - files: ["dist"], - dependencies: {}, - peerDependencies: { effect: "4.0.0" }, - exports: { - ".": { types: "./dist/index.d.ts", import: "./dist/index.js" }, - }, - }); - expect( - assessor.assessPlanningPath({ - planningPath: intent, - snapshotPath: { _tag: "file", path, contents: manifest }, - }), - ).toEqual({ classification: "unchanged", conflicts: [] }); - }).pipe(Effect.provide(TestLayer)), - ); - - it.effect( - "reports differing publishing values and preserves unrelated metadata when composed", - () => - Effect.gen(function* () { - const assessor = yield* PlanAssessor; - const composer = yield* JsonComposer; - const intent = yield* compile(publishing); - const contents = json({ - name: "@acme/library", - description: "Keep this", - private: true, - files: ["src"], - dependencies: { effect: "4.0.0" }, - }); - expect( - assessor.assessPlanningPath({ - planningPath: intent, - snapshotPath: { _tag: "file", path, contents }, - }).conflicts, - ).toEqual([ - { _tag: "packageJsonField", path, field: "private" }, - { _tag: "packageJsonField", path, field: "files" }, - { _tag: "packageJsonField", path, field: "dependencies" }, - ]); - const result = yield* composer.compose(contents, [ - { - _tag: "json-pkg-fields", - fileType: "json", - entries: intent.packageJsonFields, - }, - ]); - expect(parseJson(result)).toMatchObject({ - name: "@acme/library", - description: "Keep this", - private: false, - }); - }).pipe(Effect.provide(TestLayer)), - ); - - it.effect( - "rejects competing field values and overlapping entry contributions", - () => - Effect.gen(function* () { - const conflict = yield* Effect.flip( - compile([ - ...publishing, - { _tag: "pkg-json-field", path, field: "files", value: ["src"] }, - ]), - ); - expect(conflict).toBeInstanceOf(PlanFailure); - const overlap = yield* Effect.flip( - compile([ - ...publishing, - { - _tag: "pkg-json-entry", - path, - field: "exports", - name: ".", - value: "./src/index.ts", - }, - ]), - ); - expect(overlap.message).toContain( - "Whole-field and entry contributions overlap", - ); - }).pipe(Effect.provide(TestLayer)), - ); - - it.effect( - "accepts equivalent JSON objects and reports malformed manifests", - () => - Effect.gen(function* () { - const assessor = yield* PlanAssessor; - const intent = yield* compile([ - { - _tag: "pkg-json-field", - path, - field: "publishConfig", - value: { access: "public", registry: "https://registry.npmjs.org" }, - }, - { - _tag: "pkg-json-field", - path, - field: "publishConfig", - value: { registry: "https://registry.npmjs.org", access: "public" }, - }, - ]); - expect(intent.packageJsonFields).toHaveLength(1); - expect( - assessor.assessPlanningPath({ - planningPath: intent, - snapshotPath: { - _tag: "file", - path, - contents: json({ - publishConfig: { - registry: "https://registry.npmjs.org", - access: "public", - }, - }), - }, - }).classification, - ).toBe("unchanged"); - expect( - assessor.assessPlanningPath({ - planningPath: intent, - snapshotPath: { _tag: "file", path, contents: "invalid json" }, - }).conflicts, - ).toEqual([{ _tag: "packageJsonField", path, field: "publishConfig" }]); - }).pipe(Effect.provide(TestLayer)), - ); -}); diff --git a/packages/scaffold/src/service/plan/PlanAssessor.test.ts b/packages/scaffold/src/service/plan/PlanAssessor.test.ts index 3502a432..9fad5785 100644 --- a/packages/scaffold/src/service/plan/PlanAssessor.test.ts +++ b/packages/scaffold/src/service/plan/PlanAssessor.test.ts @@ -5,7 +5,6 @@ import { PlanAssessor, type PlanningIntentPath } from "./PlanAssessor"; const makeJsxSlotPath = (contents?: string): PlanningIntentPath => ({ path: "apps/web/src/App.tsx", contents, - packageJsonFields: [], exports: [], dependencies: [], scripts: [], diff --git a/packages/scaffold/src/service/plan/PlanAssessor.ts b/packages/scaffold/src/service/plan/PlanAssessor.ts index 3b08e3f2..2a81e4a1 100644 --- a/packages/scaffold/src/service/plan/PlanAssessor.ts +++ b/packages/scaffold/src/service/plan/PlanAssessor.ts @@ -1,4 +1,3 @@ -import { PackageJsonField } from "@repo/domain/Catalog"; import { CompositionOperation, Plan, @@ -12,7 +11,6 @@ import { Array as Arr, Context, Effect, - Equal, Layer, Match, Option, @@ -22,7 +20,6 @@ import { String as Str, } from "effect"; import { findJsxSlotMarker, makeJsxSourceFile } from "../JsxSlot"; -import { comparablePackageJsonFieldValue } from "./PackageJsonFieldValue"; type PlanningIntentPackageJsonDependency = { readonly section: "dependencies" | "devDependencies"; @@ -71,7 +68,6 @@ export type PlanningIntentJsxSlot = { }; export type PlanningIntentPath = { - readonly packageJsonFields: ReadonlyArray; readonly path: string; readonly contents: string | undefined; readonly exports: ReadonlyArray<{ name: string; value: string }>; @@ -162,14 +158,6 @@ function toCompositionOperations( ): Array { const operations: Array = []; - if (planningPath.packageJsonFields.length > 0) { - operations.push({ - _tag: "json-pkg-fields", - fileType: "json", - entries: planningPath.packageJsonFields, - }); - } - if (planningPath.exports.length > 0) { operations.push({ _tag: "json-pkg-exports", @@ -280,7 +268,6 @@ function assessPlanningPath({ }): PathAssessment { const hasContents = planningPath.contents !== undefined; const hasPackageJsonFields = - planningPath.packageJsonFields.length > 0 || planningPath.exports.length > 0 || planningPath.dependencies.length > 0 || planningPath.scripts.length > 0; @@ -650,8 +637,10 @@ const assessPackageJsonMerge = ( }); } - // Existing composed files are edited from disk; their creation seed is not reapplied. - if (!hasAdditions) { + const requiredContents = planningPath.contents!; + const authoritativeContentMatches = existingContents === requiredContents; + + if (authoritativeContentMatches && !hasAdditions) { return createPathAssessment({ classification: "unchanged" }); } @@ -709,9 +698,6 @@ const collectInvalidPackageJsonConflicts = ( scripts: requiredScripts, } = planningPath; return [ - ...Arr.map(planningPath.packageJsonFields, (entry) => - PlanConflict.cases.packageJsonField.make({ path, field: entry.field }), - ), ...Arr.map(requiredExports, (e) => planConflict.exports(path, e.name)), ...Arr.map(requiredDependencies, (d) => planConflict.dependencies(path, d)), ...Arr.map(requiredScripts, (s) => planConflict.scripts(path, s.name)), @@ -726,20 +712,6 @@ const assessPackageJsonEntries = ({ planningPath: PlanningIntentPath; }): FlatStringRecordAssessment => { const { path } = planningPath; - const fieldConflicts = Arr.flatMap( - planningPath.packageJsonFields, - ({ field, value }) => - packageJson[field] === undefined || - Equal.equals( - comparablePackageJsonFieldValue({ - field, - value: Schema.decodeUnknownSync(Schema.Json)(packageJson[field]), - }), - comparablePackageJsonFieldValue({ field, value }), - ) - ? [] - : [PlanConflict.cases.packageJsonField.make({ path, field })], - ); const dependenciesBySection = Arr.groupBy( planningPath.dependencies, (plannedDependency) => plannedDependency.section, @@ -780,7 +752,6 @@ const assessPackageJsonEntries = ({ return { conflicts: [ - ...fieldConflicts, ...exportAssessment.conflicts, ...Arr.flatMap( dependencyAssessments, @@ -789,10 +760,6 @@ const assessPackageJsonEntries = ({ ...scriptAssessment.conflicts, ], hasAdditions: - Arr.some( - planningPath.packageJsonFields, - ({ field }) => packageJson[field] === undefined, - ) || exportAssessment.hasAdditions || Arr.some( dependencyAssessments, diff --git a/packages/scaffold/src/service/plan/PlanRenderer.ts b/packages/scaffold/src/service/plan/PlanRenderer.ts index 9caa17e1..af0a2f6a 100644 --- a/packages/scaffold/src/service/plan/PlanRenderer.ts +++ b/packages/scaffold/src/service/plan/PlanRenderer.ts @@ -49,11 +49,6 @@ const renderOperation = ( op: typeof CompositionOperation.Type, ): string => Match.value(op).pipe( - Match.tag( - "json-pkg-fields", - (o) => - `In \`${path}\`, set these complete package.json fields:\n${o.entries.map((e) => ` "${e.field}": ${JSON.stringify(e.value)}`).join("\n")}`, - ), Match.tag("json-pkg-exports", (o) => { const entries = o.entries .map((e) => ` "${e.name}": "${e.value}"`) @@ -106,11 +101,6 @@ const renderConflict = ( conflict: typeof PlanConflict.Type, ): { path: string; kind: string; description: string } => Match.value(conflict).pipe( - Match.tag("packageJsonField", (c) => ({ - path: c.path, - kind: "package-json-field", - description: `Package.json field "${c.field}" differs in ${c.path}`, - })), Match.tag("exports", (c) => ({ path: c.path, kind: "exports", diff --git a/packages/scaffold/src/service/plan/PlanningIntentCompiler.ts b/packages/scaffold/src/service/plan/PlanningIntentCompiler.ts index b9bef4b3..614963bc 100644 --- a/packages/scaffold/src/service/plan/PlanningIntentCompiler.ts +++ b/packages/scaffold/src/service/plan/PlanningIntentCompiler.ts @@ -1,11 +1,10 @@ -import { Contribution, PackageJsonField } from "@repo/domain/Catalog"; +import { Contribution } from "@repo/domain/Catalog"; import { PlanFailure } from "@repo/domain/Plan"; import type { NormalizedContributions } from "@repo/domain/Scaffold"; import { Array as Arr, Context, Effect, - Equal, Layer, Match, Option, @@ -13,7 +12,6 @@ import { Record, Schema, } from "effect"; -import { comparablePackageJsonFieldValue } from "./PackageJsonFieldValue"; import type { PlanningIntentPath } from "./PlanAssessor"; export interface PlanningIntentCompilerShape { @@ -62,10 +60,6 @@ function compile(normalizedContributions: typeof NormalizedContributions.Type) { } const PlanningIntentEntry = Schema.TaggedUnion({ - packageJsonField: { - path: Schema.String, - ...PackageJsonField.fields, - }, authoritative: { path: Schema.String, contents: Schema.String, @@ -145,13 +139,6 @@ const toPlanningIntentEntries = ( Arr.flatMap( contributions, Contribution.match({ - "pkg-json-field": (c): ReadonlyArray => [ - PlanningIntentEntry.cases.packageJsonField.make({ - path: c.path, - field: c.field, - value: c.value, - }), - ], file: (c): ReadonlyArray => [ PlanningIntentEntry.cases.authoritative.make({ path: c.path, @@ -225,9 +212,6 @@ const toPlanningIntentEntries = ( ); type PlanningIntentEntryGroups = { - readonly packageJsonFields: ReadonlyArray< - typeof PlanningIntentEntry.cases.packageJsonField.Type - >; readonly authoritative: ReadonlyArray< typeof PlanningIntentEntry.cases.authoritative.Type >; @@ -251,9 +235,6 @@ type PlanningIntentEntryGroups = { const groupPlanningIntentEntries = ( entries: ReadonlyArray, ): PlanningIntentEntryGroups => ({ - packageJsonFields: entries.filter( - PlanningIntentEntry.guards.packageJsonField, - ), authoritative: entries.filter(PlanningIntentEntry.guards.authoritative), packageJson: entries.filter(PlanningIntentEntry.guards.packageJsonEntry), barrel: entries.filter(PlanningIntentEntry.guards.barrelExport), @@ -263,7 +244,6 @@ const groupPlanningIntentEntries = ( }); const emptyPackageJsonFields = { - packageJsonFields: [], exports: [], dependencies: [], scripts: [], @@ -290,7 +270,7 @@ const makePlanningIntentPath = ({ readonly contents: string | undefined; readonly packageJsonFields?: Pick< PlanningIntentPath, - "exports" | "dependencies" | "scripts" | "packageJsonFields" + "exports" | "dependencies" | "scripts" >; readonly barrelExports?: PlanningIntentPath["barrelExports"]; readonly compositions?: PlanningIntentPath["compositions"]; @@ -330,16 +310,6 @@ const derivePlanningIntentPath = ({ groups.authoritative.some((e) => e.conflictOnModify); const resolvePackageJsonFields = () => { - const overlappingField = groups.packageJsonFields.find((field) => - groups.packageJson.some((entry) => entry.field === field.field), - ); - if (overlappingField) - return Effect.fail( - new PlanFailure({ - reason: "invalidPlanIntent", - message: `Whole-field and entry contributions overlap for ${path} field ${overlappingField.field}.`, - }), - ); type PackageJsonCase = typeof PlanningIntentEntry.cases.packageJsonEntry.Type; @@ -360,13 +330,6 @@ const derivePlanningIntentPath = ({ ); return Effect.all({ - packageJsonFields: collectUniqueEntries({ - entries: groups.packageJsonFields, - keyOf: (entry) => entry.field, - valueOf: comparablePackageJsonFieldValue, - toResult: ({ field, value }) => ({ field, value }), - errorMessage: `Conflicting package.json field outcomes for ${path}.`, - }), exports: collectUniqueEntries({ entries: exportEntries, keyOf: (entry) => entry.name, @@ -599,7 +562,6 @@ type PlanningIntentFamily = | "jsxSlot"; const toPlanningIntentFamily = PlanningIntentEntry.match({ - packageJsonField: () => "packageJson" as const, authoritative: () => "authoritative" as const, packageJsonEntry: () => "packageJson" as const, barrelExport: () => "barrel" as const, @@ -626,7 +588,7 @@ const requireSingleValue = ({ }), ), onSome: (firstValue) => - Arr.every(values, (value) => Equal.equals(value, firstValue)) + Arr.every(values, (value) => value === firstValue) ? Effect.succeed(firstValue) : Effect.fail( new PlanFailure({ @@ -646,7 +608,7 @@ const collectUniqueEntries = ({ }: { entries: ReadonlyArray; keyOf: (entry: Entry) => string; - valueOf: (entry: Entry) => unknown; + valueOf: (entry: Entry) => string; toResult: (entry: Entry) => Result; errorMessage: string; }) => From d148f9fb09d138de559548cf06504112cd41f5d0 Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Sun, 11 Oct 2026 17:18:08 +0200 Subject: [PATCH 5/6] refactor: use one manifest for publishable libraries --- .changeset/library-single-manifest.md | 8 ++++++ apps/cli/scripts/verify-library.ts | 23 ++++++++++++--- apps/docs/app/content/publish-library.mdx | 24 ++++++++-------- catalogs/official/src/modules/packages.ts | 28 ++++++++----------- .../templates/package-library/README.md | 24 +++++++++------- .../package-library/package.publish.json | 20 ------------- .../scripts/prepare-package.mjs | 13 --------- .../official/templates/package/package.json | 9 ++++-- packages/authoring/src/buildCatalog.ts | 13 ++++++--- packages/catalog/src/CatalogProtocol.ts | 2 ++ packages/domain/src/Scaffold.test.ts | 23 +++++++++++++++ packages/domain/src/Scaffold.ts | 10 +++++++ .../src/service/plan/ContributionResolver.ts | 4 +++ 13 files changed, 119 insertions(+), 82 deletions(-) create mode 100644 .changeset/library-single-manifest.md delete mode 100644 catalogs/official/templates/package-library/package.publish.json delete mode 100644 catalogs/official/templates/package-library/scripts/prepare-package.mjs diff --git a/.changeset/library-single-manifest.md b/.changeset/library-single-manifest.md new file mode 100644 index 00000000..ce71de56 --- /dev/null +++ b/.changeset/library-single-manifest.md @@ -0,0 +1,8 @@ +--- +"stack-effect": minor +"@stack-effect/author": minor +--- + +Catalog templates can select content based on the modules attached to a target with `module` and `noModule` conditions. + +Publishable libraries now use one `package.json` for workspace use and npm publication. Build before importing the library in the workspace, use `npm run dev` to watch changes, and run `npm run pack` or `npm publish` from the library directory. diff --git a/apps/cli/scripts/verify-library.ts b/apps/cli/scripts/verify-library.ts index 2b04bf83..524576a2 100644 --- a/apps/cli/scripts/verify-library.ts +++ b/apps/cli/scripts/verify-library.ts @@ -131,6 +131,17 @@ const verify = (runtime: "bun" | "node") => "export const obsolete = true;\n", ); yield* run(library, "npm", ["run", "build"]); + yield* requireThat( + !(yield* fs.exists(path.join(library, "package.publish.json"))) && + !(yield* fs.exists(path.join(library, "scripts/prepare-package.mjs"))), + "Library must use a single manifest without a preparation script", + ); + yield* fs.writeFileString( + path.join(library, "verify-workspace.mjs"), + 'import { answer } from "@repo/sdk";\nimport { Effect } from "effect";\nif (Effect.runSync(answer) !== 42) throw new Error("Workspace compiled export failed");\n', + ); + yield* run(library, "node", ["verify-workspace.mjs"]); + yield* requireThat( yield* fs.exists(path.join(library, "dist/Obsolete.js")), "Build must emit the source file before the cleanup check", @@ -154,16 +165,20 @@ const verify = (runtime: "bun" | "node") => }); const files = archive.files.map((file) => file.path); yield* requireThat( - ["index.js", "index.d.ts", "Answer.js", "Answer.d.ts"].every((file) => - files.includes(file), - ), + [ + "dist/index.js", + "dist/index.d.ts", + "dist/Answer.js", + "dist/Answer.d.ts", + ].every((file) => files.includes(file)), "Tarball is missing public JavaScript or declarations", ); yield* requireThat( files.every( (file) => ["package.json", "README.md", "LICENSE"].includes(file) || - ((file.endsWith(".js") || file.endsWith(".d.ts")) && + (file.startsWith("dist/") && + (file.endsWith(".js") || file.endsWith(".d.ts")) && !file.includes("Obsolete") && !file.endsWith(".map")), ), diff --git a/apps/docs/app/content/publish-library.mdx b/apps/docs/app/content/publish-library.mdx index fb6b420b..69856ec2 100644 --- a/apps/docs/app/content/publish-library.mdx +++ b/apps/docs/app/content/publish-library.mdx @@ -10,16 +10,14 @@ Use a new package name. This starter has one root entry point and no child modul ```text package.json -package.publish.json README.md tsconfig.json tsconfig.build.json scripts/clean.mjs -scripts/prepare-package.mjs src/index.ts ``` -The workspace package stays private and exports TypeScript source for local use. `package.publish.json` owns the npm publication metadata. The build copies that manifest into `dist/package.json`, beside the compiled JavaScript and declarations. +One `package.json` describes both workspace use and npm publication. It exports compiled files from `dist/` and sets `private` to `false`. ## Add your public API @@ -29,34 +27,36 @@ Add implementation files under `src/` and export them from `src/index.ts`: export { greet } from "./greet.js"; ``` -Keep `.js` extensions in relative imports and exports. TypeScript resolves these paths to source files and emits imports that work in Node. +Keep `.js` extensions in relative imports and exports. TypeScript resolves these paths to source files and emits imports that work in Node. Add explicit entries to `package.json` exports when you need public subpaths. -Published consumers import the library through its npm name. Add explicit entries to `package.publish.json` when you need public subpaths. +## Build and develop + +Install workspace dependencies, then run `npm run build` from `packages/sdk` before another workspace package imports the library. Run `npm run dev` to recompile changes during development. Workspace and published consumers both use the compiled root entry point. ## Build and pack -Install the workspace dependencies, then run this command from `packages/sdk`: +Run this command from `packages/sdk`: ```sh npm run pack ``` -The command rebuilds the library and packs `dist/`. Each build removes previous output, checks NodeNext module resolution, emits JavaScript and declarations, and copies the publication manifest and documentation. Node and npm are required for packaging, including in a Bun workspace. +The command rebuilds the library and packs the package root. Each build removes previous output and emits JavaScript and declarations using NodeNext module resolution. The manifest's `files` list includes compiled JavaScript and declarations under `dist/`. npm also includes the root manifest, README, and LICENSE when present. Node and npm are required for packaging, including in a Bun workspace. -Use `npm run build` to inspect `dist/` without creating a tarball. The tarball exposes one compiled root export and declares Effect as a peer dependency. Remove the peer entry from `package.publish.json` if your public API does not use Effect. +Effect is a peer dependency for consumers and a development dependency for local builds. Remove both entries if your library does not use Effect. ## Prepare publication -Set your npm name, version, license, description, and repository in `package.publish.json`. Add a `LICENSE` file beside that manifest; the build copies it into `dist/` when present. The workspace package can keep its private `@repo/sdk` name. +Set your npm name, version, license, description, and repository in `package.json`. Update workspace imports and dependency entries if you change the generated `@repo/sdk` name. Add a `LICENSE` file beside the manifest. Install the tarball in a separate project and check runtime imports and declarations. Keep unpublished workspace dependencies out of emitted files. The generated README includes the publication checklist. -After reviewing the tarball, publish the compiled package: +After reviewing the tarball, publish from `packages/sdk`: ```sh -npm publish ./dist +npm publish ``` -Publish `dist/`, which declares public npm access. The workspace root package remains private. Stack Effect does not publish automatically. +The library declares public npm access. Stack Effect does not publish automatically. Repository maintainers can run `bun run --cwd apps/cli verify:library` to test generated Bun and npm libraries in clean consumers. It checks the packed JavaScript and declarations, private subpaths, and stale-output cleanup. All temporary projects are removed afterward. diff --git a/catalogs/official/src/modules/packages.ts b/catalogs/official/src/modules/packages.ts index 6f0410ed..5596742b 100644 --- a/catalogs/official/src/modules/packages.ts +++ b/catalogs/official/src/modules/packages.ts @@ -36,43 +36,37 @@ export const packageModules = defineModules(import.meta.url, [ contents: template("./package-library/README.md"), conflictOnModify: true, }, - { - _tag: "file", - path: "{{targetPath}}/scripts/prepare-package.mjs", - contents: template("./package-library/scripts/prepare-package.mjs"), - conflictOnModify: true, - }, - { - _tag: "file", - path: "{{targetPath}}/package.publish.json", - contents: template("./package-library/package.publish.json"), - conflictOnModify: true, - }, { _tag: "pkg-json-entry", path: "{{targetPath}}/package.json", field: "exports", name: ".", - value: "./src/index.ts", + value: "./dist/index.js", }, { _tag: "pkg-json-entry", path: "{{targetPath}}/package.json", field: "scripts", name: "build", - value: - "node scripts/clean.mjs && tsc -p tsconfig.build.json && node scripts/prepare-package.mjs", + value: "node scripts/clean.mjs && tsc -p tsconfig.build.json", }, { _tag: "pkg-json-entry", path: "{{targetPath}}/package.json", field: "scripts", name: "pack", - value: "npm run build && npm pack ./dist", + value: "npm run build && npm pack", + }, + { + _tag: "pkg-json-entry", + path: "{{targetPath}}/package.json", + field: "scripts", + name: "dev", + value: "tsc -p tsconfig.build.json --watch", }, ], nextSteps: [ - "Library: edit {{targetPath}}/src/index.ts, then run `npm run pack` from {{targetPath}}. Choose an npm package name in package.publish.json and a license before publishing; see {{targetPath}}/README.md. Node and npm are required for packaging.", + "Library: edit {{targetPath}}/src/index.ts, then run `npm run pack` from {{targetPath}}. Choose an npm package name in package.json and a license before publishing; see {{targetPath}}/README.md. Node and npm are required for packaging.", ], }, { diff --git a/catalogs/official/templates/package-library/README.md b/catalogs/official/templates/package-library/README.md index c1e93bc3..25113c78 100644 --- a/catalogs/official/templates/package-library/README.md +++ b/catalogs/official/templates/package-library/README.md @@ -4,7 +4,7 @@ A small ESM library with TypeScript declarations. ## Add public exports -Add your implementation under `src/` and export it from `src/index.ts`: +Add implementation files under `src/` and export them from `src/index.ts`: ```ts export { greet } from "./greet.js"; @@ -12,20 +12,24 @@ export { greet } from "./greet.js"; Keep `.js` extensions in relative imports and exports. TypeScript resolves them to source `.ts` files and emits paths that work in Node. -The workspace package stays private and exposes its source entry point. `package.publish.json` describes the published package, which exposes only its compiled root entry point. Add explicit exports there when you need public subpaths. +`package.json` describes both the workspace package and the published package. Its root export points to `dist/index.js`; TypeScript resolves the adjacent `dist/index.d.ts`. Add explicit entries to `exports` when you need public subpaths. -## Build and pack +## Build and develop -Install the workspace dependencies, then run `npm run pack` from this directory. The command rebuilds JavaScript and declarations, copies the publication manifest and README into `dist/`, and packs that directory. Each build removes stale output. Node and npm are required, including in a Bun workspace. +Install workspace dependencies, then run `npm run build` before importing the library from another workspace package. Run `npm run dev` to recompile changes during development. Workspace consumers use the same compiled entry point as npm consumers. -Use `npm run build` to inspect the output without packing. The published package declares Effect as a peer dependency; workspace development uses the existing Effect dependency. If your public API does not use Effect, remove its peer entry from `package.publish.json`. +## Pack and publish -## Before publishing +Run `npm run pack` from this directory to clean previous output, compile JavaScript and declarations, and pack the package root. The `files` list includes only compiled JavaScript and declarations under `dist/`; npm also includes the root manifest, README, and LICENSE when present. Node and npm are required, including in a Bun workspace. -- Set your npm name, version, license, description, and repository in `package.publish.json`. -- Add a `LICENSE` file here; the build copies it into `dist/` when present. +Effect is a peer dependency for consumers and a development dependency for local builds. Remove both entries if your library does not use Effect. + +Before publishing: + +- Set your npm name, version, license, description, and repository in `package.json`. Update workspace imports and dependency entries if you rename the package. +- Add a `LICENSE` file beside `package.json`. - Verify the tarball in a clean consumer, including runtime imports and TypeScript declarations. - Keep unpublished `@repo/*` imports out of emitted JavaScript and declarations. -- Inspect the packed files, then run `npm publish ./dist` deliberately. +- Inspect the packed files, then run `npm publish` from this directory. -The publication manifest declares public npm access. Publish `dist/`; the workspace root package remains private. This scaffold does not publish automatically. +The library manifest sets `private` to `false` and declares public npm access. Stack Effect does not publish automatically. diff --git a/catalogs/official/templates/package-library/package.publish.json b/catalogs/official/templates/package-library/package.publish.json deleted file mode 100644 index 6ed71b4e..00000000 --- a/catalogs/official/templates/package-library/package.publish.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "{{packageName}}", - "version": "0.0.0", - "type": "module", - "private": false, - "files": ["**/*.js", "**/*.d.ts"], - "types": "./index.d.ts", - "exports": { - ".": { - "types": "./index.d.ts", - "import": "./index.js" - } - }, - "peerDependencies": { - "effect": "4.0.0" - }, - "publishConfig": { - "access": "public" - } -} diff --git a/catalogs/official/templates/package-library/scripts/prepare-package.mjs b/catalogs/official/templates/package-library/scripts/prepare-package.mjs deleted file mode 100644 index 65916552..00000000 --- a/catalogs/official/templates/package-library/scripts/prepare-package.mjs +++ /dev/null @@ -1,13 +0,0 @@ -// oxlint-disable-next-line effecttsgo/node-builtin-import -- Packaging operates on compiled artifacts. -import { copyFileSync, existsSync } from "node:fs"; - -const root = new URL("../", import.meta.url); -const output = new URL("dist/", root); -copyFileSync( - new URL("package.publish.json", root), - new URL("package.json", output), -); -copyFileSync(new URL("README.md", root), new URL("README.md", output)); -if (existsSync(new URL("LICENSE", root))) { - copyFileSync(new URL("LICENSE", root), new URL("LICENSE", output)); -} diff --git a/catalogs/official/templates/package/package.json b/catalogs/official/templates/package/package.json index 49384629..b218255a 100644 --- a/catalogs/official/templates/package/package.json +++ b/catalogs/official/templates/package/package.json @@ -1,13 +1,18 @@ { "name": "{{packageName}}", - "private": true, + "private": {{#if module=package-library}}false{{/if}}{{#if noModule=package-library}}true{{/if}}, +{{#if module=package-library}} "files": ["dist/**/*.js", "dist/**/*.d.ts"], + "peerDependencies": { "effect": "4.0.0" }, + "publishConfig": { "access": "public" }, +{{/if}} "version": "0.0.0", "type": "module", "scripts": {}, "dependencies": { - "effect": "4.0.0" +{{#if noModule=package-library}} "effect": "4.0.0"{{/if}} }, "devDependencies": { +{{#if module=package-library}} "effect": "4.0.0",{{/if}} "@repo/config-typescript": "{{workspaceDependency}}", "vitest": "^4.1.4" } diff --git a/packages/authoring/src/buildCatalog.ts b/packages/authoring/src/buildCatalog.ts index 75a204d7..fbc0cf3f 100644 --- a/packages/authoring/src/buildCatalog.ts +++ b/packages/authoring/src/buildCatalog.ts @@ -568,10 +568,15 @@ export const buildCatalog = Effect.fn("Authoring.buildCatalog")(function* ( const document: CatalogDocument = { formatVersion: 1, catalogId, - requiredCapabilities: V1_INTERPRETER_CAPABILITIES.filter( - (capability) => - capability !== "target:hierarchical-name" || - hierarchicalNameCapabilities(authored).includes(capability), + requiredCapabilities: V1_INTERPRETER_CAPABILITIES.filter((capability) => + capability === "target:hierarchical-name" + ? hierarchicalNameCapabilities(authored).includes(capability) + : capability === "condition:module" || + capability === "condition:noModule" + ? inlineStrings(authored) + .flatMap(templateCapabilities) + .includes(capability) + : true, ), ...(official === undefined ? {} : { requires: ["official"] }), targets: composed.targets.filter((target) => diff --git a/packages/catalog/src/CatalogProtocol.ts b/packages/catalog/src/CatalogProtocol.ts index 985b84d0..2b33e546 100644 --- a/packages/catalog/src/CatalogProtocol.ts +++ b/packages/catalog/src/CatalogProtocol.ts @@ -37,6 +37,8 @@ const tokenNames = [ ] as const; const conditionalNames = [ + "module", + "noModule", "runtime", "packageManager", "typescript", diff --git a/packages/domain/src/Scaffold.test.ts b/packages/domain/src/Scaffold.test.ts index 305900ac..d5fa228c 100644 --- a/packages/domain/src/Scaffold.test.ts +++ b/packages/domain/src/Scaffold.test.ts @@ -265,6 +265,29 @@ describe("ContributionTokenContext.resolve", () => { }), }); + it("selects template branches using modules attached to this target", () => { + const template = + "{{#if module=package-library}}public{{/if}}{{#if noModule=package-library}}private{{/if}}"; + const base = makeContext(); + expect(base.resolve(template)).toBe("private"); + expect( + new ContributionTokenContext({ + targetKey: base.targetKey, + identity: base.identity, + config: base.config, + moduleIds: ["package-library"], + }).resolve(template), + ).toBe("public"); + expect( + new ContributionTokenContext({ + targetKey: base.targetKey, + identity: base.identity, + config: base.config, + moduleIds: ["other"], + }).resolve(template), + ).toBe("private"); + }); + describe("simple tokens", () => { it("resolves {{lint}} token", () => { const ctx = makeContext({ lint: "biome" }); diff --git a/packages/domain/src/Scaffold.ts b/packages/domain/src/Scaffold.ts index 94e53b26..bee4847e 100644 --- a/packages/domain/src/Scaffold.ts +++ b/packages/domain/src/Scaffold.ts @@ -142,6 +142,7 @@ export class ContributionTokenContext extends Schema.Class { + if ( + value !== undefined && + (field === "module" || field === "noModule") + ) { + const selected = this.moduleIds?.includes(value) ?? false; + return (field === "module" ? selected : !selected) ? content : ""; + } const configValue = getConfigValue(field); if (value !== undefined) { return configValue === value ? content : ""; diff --git a/packages/scaffold/src/service/plan/ContributionResolver.ts b/packages/scaffold/src/service/plan/ContributionResolver.ts index edf7197e..3a06fdb6 100644 --- a/packages/scaffold/src/service/plan/ContributionResolver.ts +++ b/packages/scaffold/src/service/plan/ContributionResolver.ts @@ -29,6 +29,10 @@ export class ContributionResolver extends Context.Service( targetKey: node.id, identity: node.identity, config, + moduleIds: blueprint.nodes + .filter(BlueprintNode.guards["attached-module"]) + .filter((module) => module.targetId === node.id) + .map((module) => module.moduleId), }); return { From 206ac8c87eaa88111681f0be24274235d83bc126 Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Sun, 11 Oct 2026 18:12:03 +0200 Subject: [PATCH 6/6] feat: add a runnable Effect library example --- apps/cli/scripts/verify-library.ts | 6 ++++++ apps/docs/app/content/publish-library.mdx | 19 ++++++++++++++++++- catalogs/official/src/modules/packages.ts | 15 ++++++++++++++- .../templates/package-library/README.md | 8 +++++++- .../package-library/scripts/example.mjs | 5 +++++ .../templates/package-library/src/index.ts | 7 +++++-- 6 files changed, 55 insertions(+), 5 deletions(-) create mode 100644 catalogs/official/templates/package-library/scripts/example.mjs diff --git a/apps/cli/scripts/verify-library.ts b/apps/cli/scripts/verify-library.ts index 524576a2..b71d232b 100644 --- a/apps/cli/scripts/verify-library.ts +++ b/apps/cli/scripts/verify-library.ts @@ -118,6 +118,12 @@ const verify = (runtime: "bun" | "node") => ]); const library = path.join(project, "packages/sdk"); + yield* requireThat( + (yield* run(library, "npm", ["run", "example"])).includes( + "Hello, Effect!", + ), + "Generated library example must run through its public export", + ); yield* fs.writeFileString( path.join(library, "src/index.ts"), 'export { answer } from "./Answer.js";\n', diff --git a/apps/docs/app/content/publish-library.mdx b/apps/docs/app/content/publish-library.mdx index 69856ec2..c4765a6e 100644 --- a/apps/docs/app/content/publish-library.mdx +++ b/apps/docs/app/content/publish-library.mdx @@ -14,14 +14,31 @@ README.md tsconfig.json tsconfig.build.json scripts/clean.mjs +scripts/example.mjs src/index.ts ``` One `package.json` describes both workspace use and npm publication. It exports compiled files from `dist/` and sets `private` to `false`. +## Run the example + +After installing workspace dependencies, run `npm run example` from `packages/sdk`. It builds the library and prints `Hello, Effect!`. + +The public entry point exports a small Effect you can build on: + +```ts +import { Effect } from "effect"; + +export const greet = Effect.fnUntraced(function* (name: string) { + return yield* Effect.succeed(`Hello, ${name}!`); +}); +``` + +`scripts/example.mjs` imports `greet` through the package name and runs it. Edit the greeting or use the script to try additional public exports. + ## Add your public API -Add implementation files under `src/` and export them from `src/index.ts`: +Replace the starter greeting or add implementation files under `src/` and export them from `src/index.ts`: ```ts export { greet } from "./greet.js"; diff --git a/catalogs/official/src/modules/packages.ts b/catalogs/official/src/modules/packages.ts index 5596742b..b61bd971 100644 --- a/catalogs/official/src/modules/packages.ts +++ b/catalogs/official/src/modules/packages.ts @@ -30,6 +30,12 @@ export const packageModules = defineModules(import.meta.url, [ contents: template("./package-library/scripts/clean.mjs"), conflictOnModify: true, }, + { + _tag: "file", + path: "{{targetPath}}/scripts/example.mjs", + contents: template("./package-library/scripts/example.mjs"), + conflictOnModify: true, + }, { _tag: "file", path: "{{targetPath}}/README.md", @@ -64,9 +70,16 @@ export const packageModules = defineModules(import.meta.url, [ name: "dev", value: "tsc -p tsconfig.build.json --watch", }, + { + _tag: "pkg-json-entry", + path: "{{targetPath}}/package.json", + field: "scripts", + name: "example", + value: "npm run build && node scripts/example.mjs", + }, ], nextSteps: [ - "Library: edit {{targetPath}}/src/index.ts, then run `npm run pack` from {{targetPath}}. Choose an npm package name in package.json and a license before publishing; see {{targetPath}}/README.md. Node and npm are required for packaging.", + "Library: run `npm run example` from {{targetPath}} to try the greeting, edit src/index.ts, then run `npm run pack` from {{targetPath}}. Choose an npm package name in package.json and a license before publishing; see {{targetPath}}/README.md. Node and npm are required for packaging.", ], }, { diff --git a/catalogs/official/templates/package-library/README.md b/catalogs/official/templates/package-library/README.md index 25113c78..ad0be778 100644 --- a/catalogs/official/templates/package-library/README.md +++ b/catalogs/official/templates/package-library/README.md @@ -2,9 +2,15 @@ A small ESM library with TypeScript declarations. +## Run the example + +Run `npm run example` from this directory to build the library and print `Hello, Effect!`. + +`src/index.ts` exports `greet(name)`, which returns an Effect containing a greeting. `scripts/example.mjs` imports the compiled public API by package name, runs the Effect, and prints its result. Change the greeting or use this script to try new exports. + ## Add public exports -Add implementation files under `src/` and export them from `src/index.ts`: +Replace the starter greeting or add implementation files under `src/` and export them from `src/index.ts`: ```ts export { greet } from "./greet.js"; diff --git a/catalogs/official/templates/package-library/scripts/example.mjs b/catalogs/official/templates/package-library/scripts/example.mjs new file mode 100644 index 00000000..bbac9ce7 --- /dev/null +++ b/catalogs/official/templates/package-library/scripts/example.mjs @@ -0,0 +1,5 @@ +import { Console, Effect } from "effect"; +import { greet } from "{{packageName}}"; + +const message = await Effect.runPromise(greet("Effect")); +await Effect.runPromise(Console.log(message)); diff --git a/catalogs/official/templates/package-library/src/index.ts b/catalogs/official/templates/package-library/src/index.ts index 4b18db87..0a123e5a 100644 --- a/catalogs/official/templates/package-library/src/index.ts +++ b/catalogs/official/templates/package-library/src/index.ts @@ -1,2 +1,5 @@ -// Export the public API here. Use .js extensions for relative exports. -export {}; +import { Effect } from "effect"; + +export const greet = Effect.fnUntraced(function* (name: string) { + return yield* Effect.succeed(`Hello, ${name}!`); +});