From 84d2918c0c2ea1db9380f6d59f8e4a7f2162ddd2 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 1 Sep 2026 14:21:43 -0700 Subject: [PATCH] fix(install): reclaim a stub frozen with another build's recovery command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every reference stub outside `.taskless` ends with the line that makes a missing canonical file recoverable, and the invocation inside it was frozen at whichever build wrote the stub. `stubPredatesRecovery` matches only the build-independent tail, deliberately, so prod and a nightly do not treat each other's stubs as stale — but nothing else looked at the command, so no later install could correct it. Reproduced: nightly `init`, then a released `init` in the same directory. The canonical files and `install.cliVersion` revert to the release; the stub keeps `npx @taskless/cli-nightly@ init`, a version that may no longer be published, in the one line a reader reaches for when the canonical file is already gone. `stubRecoveryInvocationStale` reads the command back out of the stub body and compares it to this build's, asymmetrically: the released, version-free form is accepted by every build, anything else is reclaimed. That makes the released form a fixed point every build converges on and none moves away from, so a prod install reclaims a nightly-written stub exactly once and a nightly install afterwards leaves the result alone. The comparison reads the body rather than a new frontmatter field, so a released build's stub bytes are unchanged and correct installs need no migration rewrite. The whole nightly stub path had no coverage: `__TASKLESS_CLI__` is a compile-time define, and canonical-store.test.ts asserts `isProductionInvocation()`, so a nightly's invocation could not appear in any test. vitest now runs two projects, and `test/nightly/` builds stubs under a real nightly define. Fixes #227 --- .changeset/lucky-moons-shave.md | 9 + openspec/specs/cli-init/spec.md | 13 ++ packages/cli/src/install/canonical.ts | 103 ++++++++++-- packages/cli/src/install/install.ts | 6 + packages/cli/test/apply-install-plan.test.ts | 32 +++- packages/cli/test/canonical-store.test.ts | 65 ++++++++ .../nightly/stub-recovery-invocation.test.ts | 157 ++++++++++++++++++ packages/cli/vite.config.ts | 46 +++++ 8 files changed, 419 insertions(+), 12 deletions(-) create mode 100644 .changeset/lucky-moons-shave.md create mode 100644 packages/cli/test/nightly/stub-recovery-invocation.test.ts diff --git a/.changeset/lucky-moons-shave.md b/.changeset/lucky-moons-shave.md new file mode 100644 index 00000000..32e29d90 --- /dev/null +++ b/.changeset/lucky-moons-shave.md @@ -0,0 +1,9 @@ +--- +"@taskless/cli": patch +--- + +Reclaim a reference stub whose recovery instruction names a CLI build you are no longer running. + +Every stub outside `.taskless` ends with the line that makes a missing canonical file recoverable: "If `` does not exist, run `` from the project root to restore it, then read it." That command was frozen at whichever build wrote the stub. Install a nightly once and go back to the released CLI and every install afterwards reported "up to date" while the line kept pointing at `npx @taskless/cli-nightly@` — a version that may no longer be published, in exactly the situation where the reader has nothing else to fall back on. + +An install now rewrites a stub whose recovery command names a build other than its own, with one exception: the released, version-free `npx @taskless/cli init` is left alone by every build. It resolves for any reader, so a nightly has no reason to replace it, and the released and nightly builds do not rewrite each other's stubs on repeat installs. Stub bytes written by a released build are unchanged. diff --git a/openspec/specs/cli-init/spec.md b/openspec/specs/cli-init/spec.md index dcba767b..914bf47c 100644 --- a/openspec/specs/cli-init/spec.md +++ b/openspec/specs/cli-init/spec.md @@ -717,6 +717,19 @@ Stub content SHALL carry nothing that varies per release, so that adding this in A stub already on disk whose body predates this instruction SHALL be rewritten once by the next install, rather than waiting for its frontmatter to change. Detection of such a stub SHALL NOT depend on the build that wrote it, so that builds with different invocations do not rewrite one another's stubs. +An install SHALL also reclaim a stub whose recovery command names a build other than the one installing, EXCEPT when the command named is the released, version-free one. The released form resolves for every reader, so leaving it in place is what keeps a released build and a nightly from rewriting one another's stub on every install; anything else names a build that may not be reachable at the moment the reader needs it, and is replaced. + +#### Scenario: A pinned nightly recovery command is reclaimed by a later install + +- **WHEN** an install finds a stub whose recovery command names a version-pinned nightly other than the build installing +- **THEN** the install SHALL rewrite that stub with its own recovery command + +#### Scenario: A released recovery command survives a nightly install + +- **WHEN** a nightly build installs over a stub whose recovery command is the released, version-free one +- **THEN** the install SHALL leave that stub untouched +- **AND** a subsequent install by either build SHALL leave it untouched + #### Scenario: Stub names the command that restores a missing canonical file - **WHEN** the CLI writes a skill stub or a command stub diff --git a/packages/cli/src/install/canonical.ts b/packages/cli/src/install/canonical.ts index e56e62c3..731a2699 100644 --- a/packages/cli/src/install/canonical.ts +++ b/packages/cli/src/install/canonical.ts @@ -3,7 +3,11 @@ import { join } from "node:path"; import { stringify } from "yaml"; -import { applyCliInvocation, withCliBuildNotice } from "../util/invocation"; +import { + applyCliInvocation, + PROD_INVOCATION, + withCliBuildNotice, +} from "../util/invocation"; import { parseFrontmatter } from "./frontmatter"; /** @@ -98,14 +102,29 @@ function shimMetadata(): Record { return { type: "shim" }; } +/** + * The recovery invocation a released build writes, and the one form of it that + * every build accepts from every other. + * + * It names no version and no machine-local path, so it resolves for anyone, + * forever. That is what makes it the resting state of + * {@link stubRecoveryInvocationStale}: a build whose own invocation differs + * still leaves it alone, which is what keeps prod and a nightly from rewriting + * each other's stub on every install. + */ +const PROD_RESTORE_COMMAND = `${PROD_INVOCATION} init`; + /** * The command a reader runs to restore a canonical file that is not on disk. * * Written in the published `npx @taskless/cli` form and rewritten by * {@link applyCliInvocation}, exactly as canonical content is. A stub that - * hardcoded the released package would tell someone running a `dev`/`self` - * build to fetch a different binary than the one that wrote the stub, and - * would tell a nightly user to install over their nightly. + * hardcoded the released package would tell someone running a `self` build to + * fetch a different binary than the one that wrote the stub, and would tell a + * nightly user to install over their nightly. + * + * Because this is baked into the stub body, it is frozen at whichever build + * wrote the file. {@link stubRecoveryInvocationStale} is what unfreezes it. * * `init` rather than a bare run: a bare invocation only installs from a TTY. * In a non-interactive context it prints a preamble and hands off to `agent`, @@ -118,20 +137,28 @@ function shimMetadata(): Record { * `.taskless` stays byte-stable (see {@link shimMetadata}). */ function restoreCommand(): string { - return applyCliInvocation("npx @taskless/cli init"); + return applyCliInvocation(PROD_RESTORE_COMMAND); } /** * The build-independent tail of the recovery sentence, shared between the * builders and {@link stubPredatesRecovery} so the two cannot drift apart. * - * Detection deliberately keys on this fragment rather than on the whole - * sentence: the invocation inside it differs between a prod build and a - * `dev`/`self` one, and matching on the full text would make each build treat - * the other's stub as stale and rewrite it on every install. + * Detection of a *pre-recovery* stub keys on this fragment rather than on the + * whole sentence: the invocation inside it differs between a prod build and a + * `nightly`/`self` one, and treating the full text as the staleness test would + * make each build treat the other's stub as stale and rewrite it on every + * install. The invocation is compared separately and asymmetrically, by + * {@link stubRecoveryInvocationStale}. */ const RECOVERY_TAIL = "to restore it, then read it."; +/** The literal that opens the recovery sentence's backtick-quoted command. */ +const RECOVERY_RUN = "run `"; + +/** The literal between that command's closing backtick and the tail. */ +const RECOVERY_AFTER_COMMAND = "` from the project root "; + /** * The sentence that turns a missing canonical file from a dead end into a * recoverable state. Without it a stub sends the reader to a path that may not @@ -141,8 +168,8 @@ const RECOVERY_TAIL = "to restore it, then read it."; */ function recoveryInstruction(canonical: string): string { return ( - `If \`${canonical}\` does not exist, run \`${restoreCommand()}\` from the ` + - `project root ${RECOVERY_TAIL}\n` + `If \`${canonical}\` does not exist, ${RECOVERY_RUN}${restoreCommand()}` + + `${RECOVERY_AFTER_COMMAND}${RECOVERY_TAIL}\n` ); } @@ -156,6 +183,60 @@ export function stubPredatesRecovery(content: string): boolean { return !parseFrontmatter(content).content.includes(RECOVERY_TAIL); } +/** + * The invocation recorded inside an existing stub's recovery sentence, or + * `undefined` when the stub has no recovery sentence at all (see + * {@link stubPredatesRecovery}, which is what handles that case). + * + * The stub already carries the writing build's invocation in plain text, so + * this reads it back rather than adding a frontmatter field to record it a + * second time. Keeping it out of the frontmatter is what lets the fix land + * with the prod stub's bytes completely unchanged — no field to add, and so no + * migration rewrite for the installs that are already correct. + */ +export function stubRecoveryInvocation(content: string): string | undefined { + const body = parseFrontmatter(content).content; + const end = body.indexOf(`${RECOVERY_AFTER_COMMAND}${RECOVERY_TAIL}`); + if (end === -1) return undefined; + const start = body.lastIndexOf(RECOVERY_RUN, end); + if (start === -1) return undefined; + return body.slice(start + RECOVERY_RUN.length, end); +} + +/** + * Whether an existing stub's recovery invocation must be reclaimed by this + * build. See taskless/cli#227. + * + * The recovery sentence is the one line a reader reaches for when the canonical + * file is already gone, and until now the invocation inside it was frozen at + * whichever build wrote the stub first. Install a nightly once and go back to + * the released CLI and every later install reported "up to date" while the stub + * kept pointing at `npx @taskless/cli-nightly@` — a version that may no + * longer be published, in the one situation where it has to work. + * + * The test is deliberately ASYMMETRIC, which is what keeps it from + * reintroducing the cross-build rewrite loop `RECOVERY_TAIL` exists to prevent: + * + * - An invocation equal to this build's own is current. Nothing to do. + * - {@link PROD_RESTORE_COMMAND} is accepted by EVERY build, released or not. + * It carries no version and no path, so it resolves for any reader; a nightly + * has no reason to overwrite it. + * - Anything else names a build this one is not — another nightly's pin, a + * `self` path from someone else's checkout — and is rewritten. + * + * So the released form is a fixed point that every build converges on and none + * moves away from: a prod install reclaims a nightly-written stub exactly once, + * and a nightly install afterwards leaves the result alone. Two *different* + * nightlies do rewrite each other, and should — the alternative is leaving a + * pin from a build that is not present, which is the defect itself. + */ +export function stubRecoveryInvocationStale(content: string): boolean { + const recorded = stubRecoveryInvocation(content); + if (recorded === undefined) return false; + if (recorded === restoreCommand()) return false; + return recorded !== PROD_RESTORE_COMMAND; +} + /** Serialize ordered frontmatter fields into a `---`-delimited block. */ function frontmatterBlock(fields: Record): string { const yaml = stringify(fields).trimEnd(); diff --git a/packages/cli/src/install/install.ts b/packages/cli/src/install/install.ts index 9c3915fa..530591ea 100644 --- a/packages/cli/src/install/install.ts +++ b/packages/cli/src/install/install.ts @@ -7,6 +7,7 @@ import { isShimStub, stubFrontmatterDrifted, stubPredatesRecovery, + stubRecoveryInvocationStale, writeCanonicalCommand, writeCanonicalSkill, type CommandStubFrontmatter, @@ -445,6 +446,11 @@ async function referenceNeedsRewrite( if (existing === undefined) return true; if (!isShimStub(existing)) return true; // a full copy — convert it if (stubPredatesRecovery(existing)) return true; // one-time body migration + // The recovery invocation is baked into the body too, and is frozen at + // whichever build wrote the stub — so without this a project that installed a + // nightly once keeps a pinned nightly in its recovery line forever, however + // many released installs follow. See taskless/cli#227. + if (stubRecoveryInvocationStale(existing)) return true; return stubFrontmatterDrifted(existing, meta); } diff --git a/packages/cli/test/apply-install-plan.test.ts b/packages/cli/test/apply-install-plan.test.ts index 8b799975..a838e740 100644 --- a/packages/cli/test/apply-install-plan.test.ts +++ b/packages/cli/test/apply-install-plan.test.ts @@ -18,7 +18,7 @@ import { getEmbeddedCommands, getEmbeddedSkills, } from "../src/install/install"; -import { isShimStub } from "../src/install/canonical"; +import { isShimStub, stubRecoveryInvocation } from "../src/install/canonical"; import { parseFrontmatter } from "../src/install/frontmatter"; import { readInstallState, writeInstallState } from "../src/install/state"; @@ -272,6 +272,36 @@ describe("applyInstallPlan", () => { ).toContain("does not exist"); }); + it("reclaims a stub whose recovery line names a pinned nightly", async () => { + // taskless/cli#227. Reproduced by hand as: nightly `init`, then a released + // `init` in the same directory — the canonical files and + // `install.cliVersion` reverted to the release while the stub kept + // `npx @taskless/cli-nightly@ init` in the one line a reader + // reaches for when the canonical file is already missing. + const skill = tasklessSkill(); + const claudeSkill = join(cwd, ".claude", "skills", "taskless", "SKILL.md"); + const plan = buildInstallPlan([".claude"], [skill], []); + + await applyInstallPlan(cwd, plan, { cliVersion: "0.7.0" }); + const written = await readFile(claudeSkill, "utf8"); + const current = stubRecoveryInvocation(written); + if (current === undefined) throw new Error("stub carries no recovery line"); + const nightly = "npx @taskless/cli-nightly@0.11.0-nightly.20260101 init"; + await writeFile( + claudeSkill, + written.replace(`run \`${current}\``, `run \`${nightly}\``), + "utf8" + ); + + const result = await applyInstallPlan(cwd, plan, { cliVersion: "0.7.0" }); + + expect(result.writtenSkills).toContainEqual({ + target: ".claude", + skill: "taskless", + }); + expect(await readFile(claudeSkill, "utf8")).toBe(written); + }); + it("converts a full per-tool copy into a shim stub", async () => { const skill = tasklessSkill(); const claudeSkill = join(cwd, ".claude", "skills", "taskless", "SKILL.md"); diff --git a/packages/cli/test/canonical-store.test.ts b/packages/cli/test/canonical-store.test.ts index ae2bcb75..3594a247 100644 --- a/packages/cli/test/canonical-store.test.ts +++ b/packages/cli/test/canonical-store.test.ts @@ -9,6 +9,8 @@ import { isShimStub, stubFrontmatterDrifted, stubPredatesRecovery, + stubRecoveryInvocation, + stubRecoveryInvocationStale, writeCanonicalCommand, writeCanonicalSkill, } from "../src/install/canonical"; @@ -270,3 +272,66 @@ describe("stubPredatesRecovery", () => { expect(stubPredatesRecovery(legacyStub)).toBe(true); }); }); + +/** A copy of `stub` whose recovery line names `invocation` instead. */ +function withRecoveryInvocation(stub: string, invocation: string): string { + const current = stubRecoveryInvocation(stub); + if (current === undefined) throw new Error("stub carries no recovery line"); + return stub.replace(`run \`${current}\``, `run \`${invocation}\``); +} + +describe("stubRecoveryInvocation / stubRecoveryInvocationStale", () => { + const meta = { name: "taskless", description: "Use for any Taskless task." }; + // This project runs under a released define, so a stub written here carries + // the released invocation. The nightly side of the same behaviour lives in + // test/nightly/stub-recovery-invocation.test.ts. + const productionRestore = `${buildInvocation()} init`; + + it("reads the invocation back out of a stub it wrote", () => { + expect(stubRecoveryInvocation(buildSkillStub(meta))).toBe( + productionRestore + ); + expect(stubRecoveryInvocation(buildCommandStub(meta, "tskl.md"))).toBe( + productionRestore + ); + }); + + it("returns undefined for a stub that predates the recovery instruction", () => { + expect( + stubRecoveryInvocation("---\nname: t\n---\n\nbody\n") + ).toBeUndefined(); + }); + + it("leaves a stub written by this same build alone", () => { + expect(stubRecoveryInvocationStale(buildSkillStub(meta))).toBe(false); + expect(stubRecoveryInvocationStale(buildCommandStub(meta, "tskl.md"))).toBe( + false + ); + }); + + it("reclaims a stub frozen with a pinned nightly invocation", () => { + // taskless/cli#227: try a nightly once, go back to the released CLI, and + // every later install reported "up to date" while the recovery line kept + // naming a nightly version that may no longer be published. + const frozen = withRecoveryInvocation( + buildSkillStub(meta), + "npx @taskless/cli-nightly@0.11.0-nightly.20260101 init" + ); + expect(stubRecoveryInvocationStale(frozen)).toBe(true); + }); + + it("reclaims a stub frozen with a self build's filesystem path", () => { + const frozen = withRecoveryInvocation( + buildSkillStub(meta), + "node packages/cli/dist-self/index.js init" + ); + expect(stubRecoveryInvocationStale(frozen)).toBe(true); + }); + + it("says nothing about a stub that has no recovery line at all", () => { + // That case belongs to stubPredatesRecovery, which rewrites it anyway. + expect(stubRecoveryInvocationStale("---\nname: t\n---\n\nbody\n")).toBe( + false + ); + }); +}); diff --git a/packages/cli/test/nightly/stub-recovery-invocation.test.ts b/packages/cli/test/nightly/stub-recovery-invocation.test.ts new file mode 100644 index 00000000..1cd1b4c2 --- /dev/null +++ b/packages/cli/test/nightly/stub-recovery-invocation.test.ts @@ -0,0 +1,157 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + buildCommandStub, + buildSkillStub, + stubRecoveryInvocation, + stubRecoveryInvocationStale, +} from "../../src/install/canonical"; +import { + applyInstallPlan, + buildInstallPlan, + getEmbeddedCommands, + getEmbeddedSkills, +} from "../../src/install/install"; +import { + buildInvocation, + isProductionInvocation, + PROD_INVOCATION, +} from "../../src/util/invocation"; + +/** + * Everything in this file runs under the `nightly` vitest project, whose + * `__TASKLESS_CLI__` define is a pinned `@taskless/cli-nightly@` (see + * the `projects` block in vite.config.ts). + * + * That is the whole reason the file exists. The rest of the suite runs under a + * prod define, where the recovery invocation carries no version and the + * divergence taskless/cli#227 describes cannot be expressed, let alone + * asserted on. + */ +const PROD_RESTORE = `${PROD_INVOCATION} init`; +const NIGHTLY_RESTORE = `${buildInvocation()} init`; +const OTHER_NIGHTLY_RESTORE = + "npx @taskless/cli-nightly@0.0.0-nightly.other init"; + +const META = { name: "taskless", description: "Use for any Taskless task." }; + +function withRecoveryInvocation(stub: string, invocation: string): string { + const current = stubRecoveryInvocation(stub); + if (current === undefined) throw new Error("stub carries no recovery line"); + return stub.replace(`run \`${current}\``, `run \`${invocation}\``); +} + +describe("the nightly build's own stubs", () => { + it("runs under a nightly define, not the released one", () => { + expect(isProductionInvocation()).toBe(false); + expect(buildInvocation()).toContain("@taskless/cli-nightly@"); + }); + + it("names its own pinned package in the recovery line", () => { + expect(stubRecoveryInvocation(buildSkillStub(META))).toBe(NIGHTLY_RESTORE); + expect(stubRecoveryInvocation(buildCommandStub(META, "tskl.md"))).toBe( + NIGHTLY_RESTORE + ); + }); + + it("does not consider a stub it just wrote stale", () => { + expect(stubRecoveryInvocationStale(buildSkillStub(META))).toBe(false); + expect(stubRecoveryInvocationStale(buildCommandStub(META, "tskl.md"))).toBe( + false + ); + }); +}); + +describe("stubRecoveryInvocationStale under a nightly build", () => { + const nightlyStub = buildSkillStub(META); + + it("leaves a released build's stub alone (no cross-build ping-pong)", () => { + const productionStub = withRecoveryInvocation(nightlyStub, PROD_RESTORE); + expect(stubRecoveryInvocationStale(productionStub)).toBe(false); + }); + + it("reclaims a different nightly's pin, which names a build that is not here", () => { + const stale = withRecoveryInvocation(nightlyStub, OTHER_NIGHTLY_RESTORE); + expect(stubRecoveryInvocationStale(stale)).toBe(true); + }); + + it("reclaims a self build's filesystem path", () => { + const stale = withRecoveryInvocation( + nightlyStub, + "node packages/cli/dist-self/index.js init" + ); + expect(stubRecoveryInvocationStale(stale)).toBe(true); + }); +}); + +describe("installing a nightly over an existing stub", () => { + let cwd: string; + + beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), "taskless-nightly-stub-")); + await mkdir(join(cwd, ".taskless"), { recursive: true }); + await writeFile( + join(cwd, ".taskless", "taskless.json"), + JSON.stringify({ version: 2, install: {} }), + "utf8" + ); + }); + + afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); + }); + + const stubPath = () => join(cwd, ".claude", "skills", "taskless", "SKILL.md"); + + async function install(): Promise { + const skills = getEmbeddedSkills().filter((s) => s.name === "taskless"); + const plan = buildInstallPlan([".claude"], skills, getEmbeddedCommands()); + await applyInstallPlan(cwd, plan, { cliVersion: "0.0.0-nightly.test" }); + } + + it("leaves a released build's recovery line in place", async () => { + await install(); + const written = await readFile(stubPath(), "utf8"); + await writeFile( + stubPath(), + withRecoveryInvocation(written, PROD_RESTORE), + "utf8" + ); + + await install(); + + // The released invocation resolves for everyone, so a nightly has no reason + // to overwrite it — this is the assertion that the fix for #227 did not + // reintroduce a rewrite-on-every-install loop between the two builds. + expect(stubRecoveryInvocation(await readFile(stubPath(), "utf8"))).toBe( + PROD_RESTORE + ); + }); + + it("reclaims another nightly's pinned recovery line", async () => { + await install(); + const written = await readFile(stubPath(), "utf8"); + await writeFile( + stubPath(), + withRecoveryInvocation(written, OTHER_NIGHTLY_RESTORE), + "utf8" + ); + + await install(); + + expect(stubRecoveryInvocation(await readFile(stubPath(), "utf8"))).toBe( + NIGHTLY_RESTORE + ); + }); + + it("is idempotent: a second nightly install rewrites nothing", async () => { + await install(); + const first = await readFile(stubPath(), "utf8"); + await install(); + expect(await readFile(stubPath(), "utf8")).toBe(first); + expect(dirname(stubPath())).toContain(".claude"); + }); +}); diff --git a/packages/cli/vite.config.ts b/packages/cli/vite.config.ts index bc477431..4385fce0 100644 --- a/packages/cli/vite.config.ts +++ b/packages/cli/vite.config.ts @@ -251,6 +251,25 @@ function assertLibraryGraphs(): Plugin { }; } +/** + * The environment the `nightly` vitest project builds its defines from. + * + * Resolved through the same functions the real build uses rather than + * hardcoded, so a test asserting on a nightly's invocation is asserting on what + * `build:nightly` would actually emit rather than on a string that merely looks + * like one. + */ +const NIGHTLY_TEST_ENVIRONMENT = { + TASKLESS_BUILD_TARGET: "nightly", + TASKLESS_NIGHTLY_VERSION: "0.0.0-nightly.test", +}; +const NIGHTLY_TEST_VERSION = resolveCliVersion( + NIGHTLY_TEST_ENVIRONMENT, + pkg.version +); +const NIGHTLY_TEST_INVOCATION = resolveCliInvocation(NIGHTLY_TEST_ENVIRONMENT); +const NIGHTLY_TEST_NOTICE = resolveCliNotice(NIGHTLY_TEST_ENVIRONMENT); + const cliVersion = resolveCliVersion(process.env, pkg.version); // Fails the build rather than emitting an artifact whose version and @@ -301,5 +320,32 @@ export default defineConfig({ test: { testTimeout: 20_000, hookTimeout: 20_000, + // Two projects, because `__TASKLESS_CLI__` is a compile-time define rather + // than a value a test can stub: whatever this config resolves is what every + // test in the run sees. The suite therefore only ever exercised a prod + // build — test/canonical-store.test.ts asserts `isProductionInvocation()` + // outright — which is how taskless/cli#227 went uncaught: a stub frozen + // with a nightly's pinned invocation is unreachable from a prod define. + // `test/nightly/` runs the same source against a nightly define so that + // path has coverage at all. + projects: [ + { + extends: true, + test: { + name: "cli", + include: ["test/**/*.test.ts"], + exclude: ["test/nightly/**"], + }, + }, + { + extends: true, + define: { + __VERSION__: JSON.stringify(NIGHTLY_TEST_VERSION), + __TASKLESS_CLI__: JSON.stringify(NIGHTLY_TEST_INVOCATION), + __TASKLESS_CLI_NOTICE__: JSON.stringify(NIGHTLY_TEST_NOTICE), + }, + test: { name: "nightly", include: ["test/nightly/**/*.test.ts"] }, + }, + ], }, });