From 8f15689a3d527c42d5d3db364afd9ab2f0ae8624 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Fri, 28 Aug 2026 21:19:07 -0700 Subject: [PATCH] chore: remove the dev build target `dev` existed to emit an absolute-path invocation so a local build could be exercised from another repository. A published `@taskless/cli-nightly` does that job better: it resolves on any machine and is pinned to the build whose instructions it carries. `prod`, `self`, and `nightly` are untouched. `self` still covers dogfooding inside this repository with its repo-root-relative path. `TASKLESS_BUILD_TARGET=dev` now fails with an error naming the removal rather than falling back to prod, so a stale caller is not left hunting for a `dist-dev/index.js` that nothing writes. --- .gitignore | 3 +- CONTRIBUTING.md | 6 +- README.md | 22 ++--- eslint.config.js | 1 - .../proposal.md | 62 +++++++++++++ .../specs/infrastructure/spec.md | 50 +++++++++++ .../tasks.md | 32 +++++++ openspec/specs/infrastructure/spec.md | 34 ++++++- package.json | 1 - packages/cli/package.json | 1 - packages/cli/scripts/build-target.ts | 90 +++++++++++-------- packages/cli/src/util/invocation.ts | 13 +-- packages/cli/test/build-target.test.ts | 60 ++++++++----- .../cli/test/recipe-cross-references.test.ts | 2 +- packages/cli/tsconfig.json | 2 +- packages/cli/tsconfig.prompts.json | 10 +-- packages/cli/vite.config.ts | 12 +-- tsconfig.json | 2 +- turbo.json | 2 +- 19 files changed, 308 insertions(+), 97 deletions(-) create mode 100644 openspec/changes/archive/2026-08-29-remove-dev-build-target/proposal.md create mode 100644 openspec/changes/archive/2026-08-29-remove-dev-build-target/specs/infrastructure/spec.md create mode 100644 openspec/changes/archive/2026-08-29-remove-dev-build-target/tasks.md diff --git a/.gitignore b/.gitignore index 435e22e5..32e445c2 100644 --- a/.gitignore +++ b/.gitignore @@ -4,9 +4,8 @@ # Dependencies node_modules/ -# Build output (prod, dev, and self targets each emit to their own directory) +# Build output (the prod and self targets each emit to their own directory) **/dist/ -**/dist-dev/ **/dist-self/ *.tsbuildinfo .turbo/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 005b7b80..cc0429fb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,7 +70,11 @@ without building the repository yourself. **A nightly is also how you use this CLI from another repository.** To exercise unreleased behavior somewhere other than this checkout, merge it to `main` and -install the nightly that follows. +install the nightly that follows. No build target emits an artifact runnable +outside this checkout: `pnpm build:self` bakes in a repo-root-relative path and +only works from here. That is deliberate, and it is the reason a nightly is the +answer rather than a workaround. Unmerged work is exercised in this repository +or not at all. ```bash npx @taskless/cli-nightly@latest --version diff --git a/README.md b/README.md index e6dadd14..0b4a1f68 100644 --- a/README.md +++ b/README.md @@ -41,18 +41,16 @@ npx @taskless/cli@latest info ## Local development The CLI bakes its own invocation string into the skill, command, and recipe -content it installs. Three build targets pick that string, all driven by the +content it installs. Build targets pick that string, all driven by the `TASKLESS_BUILD_TARGET` env var via Vite `define` (same source files, no edits): -Each target also emits to its own directory so the three never overwrite one -another. Prod → `dist/`, dev → `dist-dev/`, self → `dist-self/` (all -gitignored): +Each target also emits to its own directory so they never overwrite one another. +Prod → `dist/`, self → `dist-self/` (both gitignored): -| Command | Output dir | Baked invocation | Use for | -| ----------------- | ------------ | ------------------------------------------- | ------------------------------------------------------------------------------------ | -| `pnpm build` | `dist/` | `npx @taskless/cli` | Production / published builds (default). | -| `pnpm build:dev` | `dist-dev/` | `node /packages/cli/dist-dev/index.js` | Validating this build from **another** repo (absolute path resolves anywhere). | -| `pnpm build:self` | `dist-self/` | `node packages/cli/dist-self/index.js` | Dogfooding **in this repo** (path is repo-root-relative; run the CLI from the root). | +| Command | Output dir | Baked invocation | Use for | +| ----------------- | ------------ | -------------------------------------- | ------------------------------------------------------------------------------------ | +| `pnpm build` | `dist/` | `npx @taskless/cli` | Production / published builds (default). | +| `pnpm build:self` | `dist-self/` | `node packages/cli/dist-self/index.js` | Dogfooding **in this repo** (path is repo-root-relative; run the CLI from the root). | `pnpm build:self` builds the CLI with the relative invocation and then runs `taskless init --no-interactive` to install into this repo, so `.claude` gets @@ -60,8 +58,10 @@ real reference stubs that delegate to the canonical `.taskless/` content, exactl like any other install. (This replaces the former raw-symlink `link-skills` step, so local dogfooding always matches a true install.) -> The `dev`/`self` invocations are local paths and must never be published: -> only `pnpm build` (or `pnpm package`) produces a release artifact. +> The `self` invocation is a local path and must never be published: only +> `pnpm build` (or `pnpm package`) produces a release artifact. It is also +> relative to this repo root, so no build target here yields a CLI you can run +> from another repository. That case is served by a published nightly. ### Running the local build diff --git a/eslint.config.js b/eslint.config.js index 2444286a..b42f5d29 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -8,7 +8,6 @@ export default tseslint.config( ignores: [ "node_modules/", "**/dist/", - "**/dist-dev/", "**/dist-self/", "**/*.config.js", "**/*.config.ts", diff --git a/openspec/changes/archive/2026-08-29-remove-dev-build-target/proposal.md b/openspec/changes/archive/2026-08-29-remove-dev-build-target/proposal.md new file mode 100644 index 00000000..4eaeeb43 --- /dev/null +++ b/openspec/changes/archive/2026-08-29-remove-dev-build-target/proposal.md @@ -0,0 +1,62 @@ +## Why + +The `dev` build target exists for exactly one job: emit a CLI whose baked +invocation is an absolute path (`node /abs/path/packages/cli/dist-dev/index.js`) +so that an unmerged build in this checkout can be exercised from **another** +repository. Nothing else distinguishes it from `self`, which does the same +dogfooding inside this repository with a repo-relative path. + +That job now has a better answer. Other repositories consume +`@taskless/cli-nightly`, a published, version-pinned artifact that resolves on +any machine and that the `nightly` target already emits correctly. An absolute +path baked into shipped skill and recipe content is the weaker mechanism: it is +machine-specific, it names a path that may not exist, and it needs a build +notice banner to explain itself. + +Keeping `dev` costs a fourth branch in every place the target set is spelled +out: the resolver, the output directories, the invocation switch, the notice +builder, two package manifests, the turbo cache, the eslint ignores, two +tsconfigs, the README table, and the specified build-output contract. + +## What Changes + +- **The `dev` target is removed.** `build:dev` in both manifests, `OUT_DIRS.dev`, + the `dev` branches of `resolveCliInvocation` and `resolveCliNotice`, and every + mention of `dist-dev` in tooling configuration. +- **`TASKLESS_BUILD_TARGET=dev` is rejected, not ignored.** An unknown target + still falls back to `prod`. A stale `dev` does not: it names a retired target + and gets an error saying so. Falling back would hand the caller a prod build + in `dist/` while they went looking for `dist-dev/index.js`, which is a + confusing failure at a path nobody will grep for. +- **`self`, `prod`, and `nightly` are untouched.** Their output directories, + invocations, notices, and version handling are unchanged. +- **The accepted trade is documented.** `CONTRIBUTING.md` states plainly that no + build target emits an artifact runnable outside this checkout, and that this + is deliberate. + +The trade: unmerged work can no longer be exercised from another repository. +Inside this repository `build:self` still covers it; elsewhere the work merges +to `main` and the nightly that follows carries it. This is intended. + +**Delivery is a single PR.** The target is one concept spelled out in a dozen +places, and removing half of them leaves a build that does not run. Spec, +implementation, and archive land together. + +## Capabilities + +### Modified Capabilities + +- `infrastructure`: the set of cached build outputs no longer includes + `dist-dev/**`, because no build emits it. + +## Impact + +- **Modified**: `package.json`, `packages/cli/package.json`, + `packages/cli/scripts/build-target.ts`, + `packages/cli/test/build-target.test.ts`, `turbo.json`, `eslint.config.js`, + `.gitignore`, `tsconfig.json`, `packages/cli/tsconfig.json`, + `packages/cli/tsconfig.prompts.json`, `packages/cli/vite.config.ts`, + `packages/cli/src/util/invocation.ts`, `README.md`, `CONTRIBUTING.md`. +- **Unchanged**: every code path for `prod`, `self`, and `nightly`; the + published package's behavior; `openspec/changes/archive/**`, whose references + to `build:dev` record what was true when they landed. diff --git a/openspec/changes/archive/2026-08-29-remove-dev-build-target/specs/infrastructure/spec.md b/openspec/changes/archive/2026-08-29-remove-dev-build-target/specs/infrastructure/spec.md new file mode 100644 index 00000000..6268460a --- /dev/null +++ b/openspec/changes/archive/2026-08-29-remove-dev-build-target/specs/infrastructure/spec.md @@ -0,0 +1,50 @@ +## MODIFIED Requirements + +### Requirement: Build pipeline runs across all packages + +The root `pnpm build` command SHALL invoke `turbo run build`, which runs the `build` script in every workspace package that defines one. Build outputs (`dist/**`, `dist-self/**`) SHALL be cached. + +The cached output set SHALL name only directories a build target emits. `dist-dev/**` is no longer among them: the `dev` build target has been removed, and no build writes that directory. + +#### Scenario: Root build command runs CLI build + +- **WHEN** `pnpm build` is run at the repo root +- **THEN** Turborepo SHALL execute `build` in `@taskless/cli` +- **AND** the CLI `dist/` output SHALL be produced + +#### Scenario: Cached build skips re-execution + +- **WHEN** `pnpm build` is run twice without source changes +- **THEN** the second run SHALL be a cache hit and complete near-instantly + +#### Scenario: No cached output is declared for a removed target + +- **WHEN** `turbo.json` is inspected +- **THEN** the `build` task outputs SHALL NOT include `dist-dev/**` + +## ADDED Requirements + +### Requirement: The CLI has three build targets + +The CLI SHALL support exactly three build targets, selected by `TASKLESS_BUILD_TARGET`: `prod` (the default, emitting `dist/` with the published `npx @taskless/cli` invocation), `self` (emitting `dist-self/` with a repo-root-relative path, for dogfooding inside this repository), and `nightly` (emitting `dist/` with `npx @taskless/cli-nightly@`). + +There SHALL NOT be a build target that emits an invocation runnable from outside this repository's checkout. Exercising unreleased behavior elsewhere is served by a published nightly, which resolves on any machine and is pinned to the build whose instructions it carries, rather than by an absolute filesystem path baked into shipped content. + +An unset or unrecognized `TASKLESS_BUILD_TARGET` SHALL resolve to `prod`. The retired value `dev` SHALL be an exception: it SHALL fail the build with an error naming the target as removed, because a caller who sets it is looking for an output directory that will not be written, and a silent fall back to `prod` would surface only as a missing file at a path nothing else mentions. + +#### Scenario: The self target stays repo-relative + +- **WHEN** a build runs with `TASKLESS_BUILD_TARGET=self` +- **THEN** the output SHALL be written to `dist-self/` +- **AND** the baked invocation SHALL be a path relative to the repo root + +#### Scenario: A retired target fails loudly + +- **WHEN** a build runs with `TASKLESS_BUILD_TARGET=dev` +- **THEN** the build SHALL fail with an error stating the target has been removed +- **AND** SHALL NOT fall back to the `prod` target + +#### Scenario: An unknown target still defaults to prod + +- **WHEN** a build runs with `TASKLESS_BUILD_TARGET` unset or set to an unrecognized value other than `dev` +- **THEN** the build SHALL resolve to the `prod` target diff --git a/openspec/changes/archive/2026-08-29-remove-dev-build-target/tasks.md b/openspec/changes/archive/2026-08-29-remove-dev-build-target/tasks.md new file mode 100644 index 00000000..d693d094 --- /dev/null +++ b/openspec/changes/archive/2026-08-29-remove-dev-build-target/tasks.md @@ -0,0 +1,32 @@ +Delivery shape: **single PR**. One build target spelled out across a dozen files; a partial removal leaves a build that does not run. Spec, implementation, and archive land together. + +## 1. Build machinery + +- [x] 1.1 Drop `OUT_DIRS.dev` and the `dev` case from `resolveCliInvocation` +- [x] 1.2 Reject `TASKLESS_BUILD_TARGET=dev` in `resolveBuildTarget` rather than falling back to prod +- [x] 1.3 Drop the `dev` branch of `resolveCliNotice` +- [x] 1.4 Remove `build:dev` from `package.json` and `packages/cli/package.json` + +## 2. Tooling configuration + +- [x] 2.1 Remove `dist-dev/**` from `turbo.json` outputs +- [x] 2.2 Remove `**/dist-dev/` from `eslint.config.js` and `.gitignore` +- [x] 2.3 Remove `dist-dev` from the root and CLI `tsconfig.json` excludes + +## 3. Prose + +- [x] 3.1 Update comments in `vite.config.ts`, `tsconfig.prompts.json`, and `src/util/invocation.ts` +- [x] 3.2 Update the README build-target table and surrounding text +- [x] 3.3 Strengthen the `CONTRIBUTING.md` nightlies paragraph to state the trade plainly + +## 4. Tests + +- [x] 4.1 Replace the dev-target assertions with a rejection test +- [x] 4.2 Narrow the per-target parameterised lists to prod/self/nightly + +## 5. Verification + +- [x] 5.1 `pnpm build`, `pnpm build:self`, `pnpm typecheck`, `pnpm lint`, `pnpm test` +- [x] 5.2 `pnpm openspec validate --all --strict` +- [x] 5.3 `pnpm cli check` shows no new Vale findings +- [x] 5.4 No changeset: a development build target is removed and the published package's behavior does not change diff --git a/openspec/specs/infrastructure/spec.md b/openspec/specs/infrastructure/spec.md index 8afc82ba..eb37649b 100644 --- a/openspec/specs/infrastructure/spec.md +++ b/openspec/specs/infrastructure/spec.md @@ -106,7 +106,9 @@ The repository SHALL have `turbo` as a root devDependency and a `turbo.json` con ### Requirement: Build pipeline runs across all packages -The root `pnpm build` command SHALL invoke `turbo run build`, which runs the `build` script in every workspace package that defines one. Build outputs (`dist/**`, `dist-dev/**`, `dist-self/**`) SHALL be cached. +The root `pnpm build` command SHALL invoke `turbo run build`, which runs the `build` script in every workspace package that defines one. Build outputs (`dist/**`, `dist-self/**`) SHALL be cached. + +The cached output set SHALL name only directories a build target emits. `dist-dev/**` is no longer among them: the `dev` build target has been removed, and no build writes that directory. #### Scenario: Root build command runs CLI build @@ -119,6 +121,11 @@ The root `pnpm build` command SHALL invoke `turbo run build`, which runs the `bu - **WHEN** `pnpm build` is run twice without source changes - **THEN** the second run SHALL be a cache hit and complete near-instantly +#### Scenario: No cached output is declared for a removed target + +- **WHEN** `turbo.json` is inspected +- **THEN** the `build` task outputs SHALL NOT include `dist-dev/**` + ### Requirement: Test pipeline runs across all packages The root `pnpm test` command SHALL invoke `turbo run test`, which runs the `test` script in every workspace package that defines one. The test pipeline SHALL depend on the build pipeline so that built artifacts are available. @@ -431,3 +438,28 @@ Publishing workflows SHALL NOT be serialized merely because they publish. Where - **WHEN** two runs of a publishing flow evaluate the same commit or version - **THEN** at most one artifact SHALL be published, and the other run SHALL neither fail nor publish a duplicate + +### Requirement: The CLI has three build targets + +The CLI SHALL support exactly three build targets, selected by `TASKLESS_BUILD_TARGET`: `prod` (the default, emitting `dist/` with the published `npx @taskless/cli` invocation), `self` (emitting `dist-self/` with a repo-root-relative path, for dogfooding inside this repository), and `nightly` (emitting `dist/` with `npx @taskless/cli-nightly@`). + +There SHALL NOT be a build target that emits an invocation runnable from outside this repository's checkout. Exercising unreleased behavior elsewhere is served by a published nightly, which resolves on any machine and is pinned to the build whose instructions it carries, rather than by an absolute filesystem path baked into shipped content. + +An unset or unrecognized `TASKLESS_BUILD_TARGET` SHALL resolve to `prod`. The retired value `dev` SHALL be an exception: it SHALL fail the build with an error naming the target as removed, because a caller who sets it is looking for an output directory that will not be written, and a silent fall back to `prod` would surface only as a missing file at a path nothing else mentions. + +#### Scenario: The self target stays repo-relative + +- **WHEN** a build runs with `TASKLESS_BUILD_TARGET=self` +- **THEN** the output SHALL be written to `dist-self/` +- **AND** the baked invocation SHALL be a path relative to the repo root + +#### Scenario: A retired target fails loudly + +- **WHEN** a build runs with `TASKLESS_BUILD_TARGET=dev` +- **THEN** the build SHALL fail with an error stating the target has been removed +- **AND** SHALL NOT fall back to the `prod` target + +#### Scenario: An unknown target still defaults to prod + +- **WHEN** a build runs with `TASKLESS_BUILD_TARGET` unset or set to an unrecognized value other than `dev` +- **THEN** the build SHALL resolve to the `prod` target diff --git a/package.json b/package.json index 2e953391..d83b20ab 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,6 @@ "scripts": { "build": "pnpm build:compile", "build:compile": "turbo run build", - "build:dev": "pnpm --filter @taskless/cli build:dev", "build:nightly": "pnpm --filter @taskless/cli build:nightly", "build:self": "run-s build:self:compile build:self:install", "build:self:compile": "pnpm --filter @taskless/cli build:self", diff --git a/packages/cli/package.json b/packages/cli/package.json index f99938a0..ecfd863b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -9,7 +9,6 @@ }, "scripts": { "build": "vite build && tsc -p tsconfig.prompts.json", - "build:dev": "TASKLESS_BUILD_TARGET=dev vite build", "build:nightly": "TASKLESS_BUILD_TARGET=nightly vite build && tsc -p tsconfig.prompts.json", "build:self": "TASKLESS_BUILD_TARGET=self vite build", "generate:api": "openapi-typescript https://app.taskless.io/cli/api/__schema -o src/generated/api.d.ts", diff --git a/packages/cli/scripts/build-target.ts b/packages/cli/scripts/build-target.ts index 6edd632d..7ca52dcb 100644 --- a/packages/cli/scripts/build-target.ts +++ b/packages/cli/scripts/build-target.ts @@ -1,5 +1,3 @@ -import { resolve } from "node:path"; - /** * How a build target decides what CLI invocation is baked into the skill, * command, and recipe content it emits. @@ -26,9 +24,22 @@ const NIGHTLY_PACKAGE = "@taskless/cli-nightly"; export const NIGHTLY_VERSION_ENV = "TASKLESS_NIGHTLY_VERSION"; /** - * Each build target emits to its own directory so prod, dev, and self builds - * never overwrite one another. Keyed by TASKLESS_BUILD_TARGET; anything other - * than a key here is treated as prod. + * The build target this repository used to have, removed and not coming back. + * + * `dev` emitted an absolute path so a local build could be exercised from + * ANOTHER repository. That job belongs to the published nightly now: it + * resolves on any machine and is pinned to the build whose instructions it + * carries, where an absolute path is machine-specific and may not exist. + * + * Named here rather than deleted outright so a stale `TASKLESS_BUILD_TARGET=dev` + * gets an answer. See `resolveBuildTarget`. + */ +const REMOVED_TARGET = "dev"; + +/** + * Each build target emits to its own directory so prod and self builds never + * overwrite one another. Keyed by TASKLESS_BUILD_TARGET; anything other than a + * key here is treated as prod. * * `nightly` IS THE EXCEPTION: it emits to `dist`, the same directory as prod, * and a local `build:nightly` therefore OVERWRITES a prod build in place. That @@ -39,7 +50,6 @@ export const NIGHTLY_VERSION_ENV = "TASKLESS_NIGHTLY_VERSION"; */ export const OUT_DIRS = { prod: "dist", - dev: "dist-dev", self: "dist-self", nightly: "dist", } as const; @@ -53,11 +63,32 @@ const SEMVER_PATTERN = /** A build environment: `process.env`, or a literal in a test. */ export type BuildEnvironment = Record; +/** + * The target this build is for, defaulting to prod. + * + * An unrecognized value falls back to prod, which is the right leniency for a + * typo: prod is what a build with no opinion should be, and it is the only + * target that needs no extra environment. + * + * `dev` is the one value that does NOT get that leniency. It was a real target + * that emitted `dist-dev/`, so a caller who still sets it is not expressing no + * opinion — they are waiting on an artifact that will never be written. Falling + * back would build prod into `dist/` (overwriting a prod build) and report + * nothing, and the caller would meet the removal as a missing + * `dist-dev/index.js` at a path no remaining file mentions. An error here names + * the cause once, at the moment it can still be acted on. + */ export function resolveBuildTarget(environment: BuildEnvironment): BuildTarget { const target = environment.TASKLESS_BUILD_TARGET; - return target === "dev" || target === "self" || target === "nightly" - ? target - : "prod"; + if (target === REMOVED_TARGET) { + throw new Error( + `TASKLESS_BUILD_TARGET=${REMOVED_TARGET} names a build target that has been ` + + `removed. It existed to run a local build from another repository; use ` + + `a published ${NIGHTLY_PACKAGE} for that. To dogfood inside this ` + + `repository, use "pnpm build:self".` + ); + } + return target === "self" || target === "nightly" ? target : "prod"; } export function resolveOutputDirectory(environment: BuildEnvironment): string { @@ -159,31 +190,25 @@ export function assertVersionConsistency( /** * The CLI invocation baked into emitted skill/command/recipe content, chosen by - * the TASKLESS_BUILD_TARGET env var (see package.json build:dev/build:self/ + * the TASKLESS_BUILD_TARGET env var (see package.json build:self/ * build:nightly): * - prod (default): the published `npx @taskless/cli` - * - dev: an absolute path, for validating this build from another repo * - self: a repo-root-relative path, for dogfooding inside this repo * - nightly: `npx @taskless/cli-nightly@`, PINNED to the exact * version being published — a floating `@taskless/cli-nightly` would send * an agent to whatever nightly is newest, which is not the build whose * skills it is reading. - * The dev/self paths point at their own output directory (dist-dev/dist-self). * - * `packageDirectory` is `packages/cli`, used only by the `dev` target, which - * emits an absolute path. + * `self` is the only target whose invocation is a path, and it is relative to + * the repo root, so it only resolves for a CLI run from inside this checkout. + * That is the whole local story: nothing here emits an invocation usable from + * another repository, and a published nightly is what serves that case. */ -export function resolveCliInvocation( - environment: BuildEnvironment, - packageDirectory: string -): string { +export function resolveCliInvocation(environment: BuildEnvironment): string { switch (resolveBuildTarget(environment)) { case "self": { return `node packages/cli/${OUT_DIRS.self}/index.js`; } - case "dev": { - return `node ${resolve(packageDirectory, OUT_DIRS.dev, "index.js")}`; - } case "nightly": { return `npx ${NIGHTLY_PACKAGE}@${resolveNightlyVersion(environment)}`; } @@ -194,29 +219,24 @@ export function resolveCliInvocation( } /** - * A one-time banner prepended to canonical skill/command bodies for local - * builds, so an agent that's told to call the local CLI knows how to produce it + * A one-time banner prepended to canonical skill/command bodies for the `self` + * build, so an agent that's told to call the local CLI knows how to produce it * if the build artifact is missing. Empty for prod (no banner is emitted). * - * ALSO EMPTY FOR `nightly`, deliberately. The banner exists for one reason: - * `dev`/`self` invocations name a filesystem path that may not exist yet, and - * the agent needs to be told how to create it. A nightly's invocation is a + * ALSO EMPTY FOR `nightly`, deliberately. The banner exists for one reason: the + * `self` invocation names a filesystem path that may not exist yet, and the + * agent needs to be told how to create it. A nightly's invocation is a * published, version-pinned package that `npx` resolves on any machine, so * there is no such failure to pre-empt — and the invocation itself already * reads `@taskless/cli-nightly@`, which says everything a banner * would. Adding one would put a permanent build-provenance notice into the body * of every installed skill, where it is noise on every read. */ -export function resolveCliNotice( - environment: BuildEnvironment, - packageDirectory: string -): string { - const target = resolveBuildTarget(environment); - if (target !== "self" && target !== "dev") return ""; - const rebuild = target === "self" ? "pnpm build:self" : "pnpm build:dev"; +export function resolveCliNotice(environment: BuildEnvironment): string { + if (resolveBuildTarget(environment) !== "self") return ""; return ( `> **Local Taskless build.** The commands below call a locally built CLI ` + - `(\`${resolveCliInvocation(environment, packageDirectory)}\`). If that ` + - `path does not exist yet, run \`${rebuild}\` from the repo root first.` + `(\`${resolveCliInvocation(environment)}\`). If that ` + + `path does not exist yet, run \`pnpm build:self\` from the repo root first.` ); } diff --git a/packages/cli/src/util/invocation.ts b/packages/cli/src/util/invocation.ts index d3797352..85cffdda 100644 --- a/packages/cli/src/util/invocation.ts +++ b/packages/cli/src/util/invocation.ts @@ -1,10 +1,11 @@ /** * The published CLI invocation baked into skill, command, and recipe source. - * Build targets other than prod rewrite it: to a local path so a locally built - * CLI can be dogfooded in this repo (`build:self`) or validated from another - * repo (`build:dev`), and to `npx @taskless/cli-nightly@` for a - * nightly, whose shipped instructions must name the package the reader actually - * installed rather than the released one. See `scripts/build-target.ts`, + * Build targets other than prod rewrite it: to a repo-root-relative path so a + * locally built CLI can be dogfooded in this repo (`build:self`), and to + * `npx @taskless/cli-nightly@` for a nightly, whose shipped + * instructions must name the package the reader actually installed rather than + * the released one. There is no target that emits a path usable from outside + * this checkout; a nightly covers that. See `scripts/build-target.ts`, * `vite.config.ts`, and the root `package.json` scripts. */ export const PROD_INVOCATION = "npx @taskless/cli"; @@ -13,7 +14,7 @@ export const PROD_INVOCATION = "npx @taskless/cli"; * Whether this build's invocation is the released one. * * `false` means the build knows exactly what it is — a `nightly` pinned to its - * published version, or a `dev`/`self` path — and its instructions must say so + * published version, or a `self` path — and its instructions must say so * rather than naming `@taskless/cli`. `true` means the build is the released * package and has no idea how it was launched, which is a different situation * from knowing it was launched as `npx @taskless/cli`. diff --git a/packages/cli/test/build-target.test.ts b/packages/cli/test/build-target.test.ts index 175c1406..36f13a8d 100644 --- a/packages/cli/test/build-target.test.ts +++ b/packages/cli/test/build-target.test.ts @@ -11,9 +11,6 @@ import { resolveOutputDirectory, } from "../scripts/build-target"; -/** Stand-in for `packages/cli`; only the `dev` target reads it. */ -const PACKAGE_DIR = "/repo/packages/cli"; - const NIGHTLY_VERSION = "0.11.0-20260818123456x05b3c88"; const nightlyEnvironment = { @@ -27,31 +24,49 @@ describe("build target resolution", () => { expect(resolveBuildTarget({ TASKLESS_BUILD_TARGET: "nightlies" })).toBe( "prod" ); - expect(resolveCliInvocation({}, PACKAGE_DIR)).toBe("npx @taskless/cli"); - expect(resolveCliNotice({}, PACKAGE_DIR)).toBe(""); + expect(resolveCliInvocation({})).toBe("npx @taskless/cli"); + expect(resolveCliNotice({})).toBe(""); }); - it("keeps the local targets pointed at their own output directories", () => { + it("keeps the self target pointed at its own output directory", () => { const self = { TASKLESS_BUILD_TARGET: "self" }; - expect(resolveCliInvocation(self, PACKAGE_DIR)).toBe( + expect(resolveCliInvocation(self)).toBe( `node packages/cli/${OUT_DIRS.self}/index.js` ); - expect(resolveCliNotice(self, PACKAGE_DIR)).toContain("pnpm build:self"); + expect(resolveCliNotice(self)).toContain("pnpm build:self"); + }); - const developmentTarget = { TASKLESS_BUILD_TARGET: "dev" }; - expect(resolveCliInvocation(developmentTarget, PACKAGE_DIR)).toBe( - `node ${PACKAGE_DIR}/${OUT_DIRS.dev}/index.js` - ); - expect(resolveCliNotice(developmentTarget, PACKAGE_DIR)).toContain( - "pnpm build:dev" + // `dev` emitted an absolute path so a local build could be run from another + // repository; a published nightly does that job now. It is the one value that + // does not get the lenient fall back to prod, because a caller who sets it is + // waiting on `dist-dev/`, which nothing writes any more. Falling back would + // build prod into `dist/` and report nothing, and the removal would surface + // as a missing file at a path no remaining source mentions. + it("refuses the removed dev target instead of falling back to prod", () => { + const removed = { TASKLESS_BUILD_TARGET: "dev" }; + expect(() => resolveBuildTarget(removed)).toThrow(/has been removed/); + expect(() => resolveOutputDirectory(removed)).toThrow(/has been removed/); + expect(() => resolveCliInvocation(removed)).toThrow(/has been removed/); + expect(() => resolveCliNotice(removed)).toThrow(/has been removed/); + }); + + // The error has to be actionable, not just loud: whoever set `dev` wants a + // local build, and both remaining answers are named. + it("names the replacements for the removed target", () => { + expect(() => resolveBuildTarget({ TASKLESS_BUILD_TARGET: "dev" })).toThrow( + /@taskless\/cli-nightly[\s\S]*pnpm build:self/ ); }); + + it("has no output directory for the removed target", () => { + expect(Object.keys(OUT_DIRS)).toStrictEqual(["prod", "self", "nightly"]); + }); }); describe("the version a build reports as its own", () => { - // Only nightly diverges. dev/self/prod all run from a checkout whose + // Only nightly diverges. self/prod both run from a checkout whose // package.json IS the version they are, so reading it is correct there. - it.each(["prod", "dev", "self"])( + it.each(["prod", "self"])( "uses the committed package version for the %s target", (target) => { expect( @@ -103,11 +118,10 @@ describe("a build must agree with itself about its version", () => { }); // A version pin is meaningless for the other targets: prod names a floating - // package deliberately, and dev/self name a filesystem path. Asserting there + // package deliberately, and self names a filesystem path. Asserting there // would fail every ordinary build. it.each([ ["prod", "npx @taskless/cli"], - ["dev", "node /repo/packages/cli/dist-dev/index.js"], ["self", "node packages/cli/dist-self/index.js"], ])("does not constrain the %s target", (target, invocation) => { expect(() => @@ -126,7 +140,7 @@ describe("the nightly target", () => { // version, `npx @taskless/cli-nightly` would float to whatever nightly is // newest — a different build from the one whose instructions are being read. it("names the nightly package pinned to the exact published version", () => { - expect(resolveCliInvocation(nightlyEnvironment, PACKAGE_DIR)).toBe( + expect(resolveCliInvocation(nightlyEnvironment)).toBe( `npx @taskless/cli-nightly@${NIGHTLY_VERSION}` ); }); @@ -151,18 +165,18 @@ describe("the nightly target", () => { ).toThrow(NIGHTLY_VERSION_ENV); }); - // `dist`, unlike dev/self — the tarball is packed with `files: ["dist"]` and + // `dist`, unlike self — the tarball is packed with `files: ["dist"]` and // `bin: ./dist/index.js`, so a nightly build overwrites a prod build. it("emits to dist, the same directory as prod", () => { expect(resolveOutputDirectory(nightlyEnvironment)).toBe(OUT_DIRS.prod); expect(OUT_DIRS.nightly).toBe("dist"); }); - // dev/self carry a banner because their invocation is a path that may not + // self carries a banner because its invocation is a path that may not // exist. A published, version-pinned package always resolves, and the // invocation already says which package it is. it("emits no build notice", () => { - expect(resolveCliNotice(nightlyEnvironment, PACKAGE_DIR)).toBe(""); + expect(resolveCliNotice(nightlyEnvironment)).toBe(""); }); // The failure that matters. Falling back to `npx @taskless/cli` here would @@ -180,7 +194,7 @@ describe("the nightly target", () => { }, ]) { expect( - () => resolveCliInvocation(environment, PACKAGE_DIR), + () => resolveCliInvocation(environment), `${JSON.stringify(environment[NIGHTLY_VERSION_ENV])} must fail the build, not fall back` ).toThrowError(new RegExp(NIGHTLY_VERSION_ENV)); } diff --git a/packages/cli/test/recipe-cross-references.test.ts b/packages/cli/test/recipe-cross-references.test.ts index 966103c8..b80727d6 100644 --- a/packages/cli/test/recipe-cross-references.test.ts +++ b/packages/cli/test/recipe-cross-references.test.ts @@ -55,7 +55,7 @@ const SUBCOMMANDS = SUBCOMMAND_NAMES.map((name) => escapeRegExp(name)).join( * `` is what a prod build with no detected launcher renders, and * `buildInvocation()` is whatever THIS build bakes in — `npx @taskless/cli` for * prod, `npx @taskless/cli-nightly@` for a nightly, and a bare - * `node /index.js` for `dev`/`self`. That last one is why the list is + * `node /index.js` for `self`. That last one is why the list is * derived rather than written out: a fixed `taskless|@taskless/cli` anchor * matches nothing under `TASKLESS_BUILD_TARGET=self`, so every citation check * below would find zero citations and pass without checking anything. The two diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index d4cdd36c..da68eddd 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -8,5 +8,5 @@ "noUncheckedIndexedAccess": true }, "include": ["src", "test", "scripts"], - "exclude": ["node_modules", "dist", "dist-dev", "dist-self", "test/fixtures"] + "exclude": ["node_modules", "dist", "dist-self", "test/fixtures"] } diff --git a/packages/cli/tsconfig.prompts.json b/packages/cli/tsconfig.prompts.json index 8508b067..28227f01 100644 --- a/packages/cli/tsconfig.prompts.json +++ b/packages/cli/tsconfig.prompts.json @@ -17,11 +17,11 @@ // // `declarationDir` is `dist` rather than following `TASKLESS_BUILD_TARGET` the // way `vite.config.ts`'s `resolveOutDir()` does, because the `./prompts` export -// resolves to `./dist/prompts.js` unconditionally. The `dev` and `self` targets -// exist to run the CLI binary from a local path, and nothing resolves a prompts -// entry out of `dist-dev`/`dist-self`. Wiring this step into those builds means -// overriding `--declarationDir` to match, or the declarations land beside the -// wrong JavaScript. +// resolves to `./dist/prompts.js` unconditionally. The `self` target exists to +// run the CLI binary from a local path, and nothing resolves a prompts entry out +// of `dist-self`. Wiring this step into that build means overriding +// `--declarationDir` to match, or the declarations land beside the wrong +// JavaScript. { "extends": "./tsconfig.json", "compilerOptions": { diff --git a/packages/cli/vite.config.ts b/packages/cli/vite.config.ts index 2cda547a..49467c03 100644 --- a/packages/cli/vite.config.ts +++ b/packages/cli/vite.config.ts @@ -23,11 +23,11 @@ const pkg = JSON.parse( // Target resolution lives in ./scripts/build-target.ts so it can be unit-tested // (test/build-target.test.ts) over an explicit environment. Everything below -// binds those pure functions to this process and this package directory. +// binds those pure functions to this process. const buildTarget = resolveBuildTarget(process.env); const outDir = resolveOutputDirectory(process.env); -const cliInvocation = resolveCliInvocation(process.env, import.meta.dirname); -const cliNotice = resolveCliNotice(process.env, import.meta.dirname); +const cliInvocation = resolveCliInvocation(process.env); +const cliNotice = resolveCliNotice(process.env); // A nightly emits to `dist` — the same directory as prod, because that is what // the tarball carries (see OUT_DIRS). Say so at the call site rather than @@ -185,9 +185,9 @@ function assertPromptsGraph(): Plugin { name: "assert-prompts-graph", generateBundle(_options, bundle) { const entry = findEntryChunk(bundle, PROMPTS_ENTRY); - // Not an error: `build:self`/`build:dev` and any future single-entry - // build legitimately emit no prompts entry. Nothing to check, not a - // failure to check it. + // Not an error: `build:self` and any future single-entry build + // legitimately emit no prompts entry. Nothing to check, not a failure to + // check it. if (entry === undefined) return; const binFile = findEntryChunk(bundle, BIN_ENTRY)?.fileName; diff --git a/tsconfig.json b/tsconfig.json index e959fe03..c154c4d8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,5 +7,5 @@ "noImplicitReturns": true, "noUncheckedIndexedAccess": true }, - "exclude": ["node_modules", "dist", "dist-dev", "dist-self", "packages"] + "exclude": ["node_modules", "dist", "dist-self", "packages"] } diff --git a/turbo.json b/turbo.json index 7f81a09a..65256009 100644 --- a/turbo.json +++ b/turbo.json @@ -2,7 +2,7 @@ "$schema": "https://turbo.build/schema.json", "tasks": { "build": { - "outputs": ["dist/**", "dist-dev/**", "dist-self/**"], + "outputs": ["dist/**", "dist-self/**"], "env": ["TASKLESS_BUILD_TARGET"] }, "test": {