Read the CLI version from the manifest it publishes from

e630419b28a2 · AtlantisPleb · · parent 360c93673ffb

Read the CLI version from the manifest it publishes from

`openagents --version` reported a release that was never published. The
constant at `cli.ts:31` said `0.1.7`, `package.json` said `0.2.1`, and npm
served `0.3.1`. A version restated by hand in a second place will drift from
the first, and this one drifted twice without anything noticing.

`version.ts` reads `package.json`, which sits one directory above the compiled
output in both layouts that matter: a local build and the published tarball,
where npm includes the manifest whatever the `files` list says. A manifest it
cannot read reports `unknown` rather than a number, because a wrong version
gets believed and pasted into a bug report while an obvious absence does not.

The manifest moves to `0.3.2`. Source sat behind the registry, which is the
condition under which a published `forum` command came to exist in no source
file at all.

`verify-packed-install.mjs` already compared the packed CLI's `--version` to
the manifest and would have caught the drift; nothing ran it, because it is
`verify:package` and the gate is `verify`. It now also compares the packed
command surface against a clean build's, so a command that ships without
source fails the check that exists to notice exactly that. Wiring it into a
gate is the remaining half and belongs to whoever owns the release path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DTmy4SEXrHXouw5sZbs3f4
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified packages/openagents-cli/package.json
  • modified packages/openagents-cli/scripts/verify-packed-install.mjs
  • modified packages/openagents-cli/src/cli.ts
  • added packages/openagents-cli/src/version.ts
  • added packages/openagents-cli/test/version.test.ts

Diff

5 files changed, +145 -4

packages/openagents-cli/package.json modified +1 -1

@@ -1,6 +1,6 @@

1 1
{
2 2
  "name": "@openagentsinc/cli",
3
  "version": "0.2.1",
3
  "version": "0.3.2",
4 4
  "private": false,
5 5
  "description": "Effect TypeScript command-line client for OpenAgents repositories",
6 6
  "keywords": [
packages/openagents-cli/scripts/verify-packed-install.mjs modified +28 -1

@@ -26,6 +26,14 @@ const run = (command, arguments_, options = {}) => {

26 26
  return { stdout: result.stdout, stderr: result.stderr };
27 27
};
28 28
29
30
/** Subcommand names from `--help`, which lists them one per line after SUBCOMMANDS. */
31
const subcommands = (help) => {
32
  const section = help.split("SUBCOMMANDS")[1];
33
  if (section === undefined) return [];
34
  return [...section.matchAll(/^\s{2}([a-z][a-z-]*)\s{2,}/gm)].map((match) => match[1]).sort();
35
};
36
29 37
try {
30 38
  mkdirSync(tarballDirectory);
31 39
  mkdirSync(consumerDirectory);

@@ -65,7 +73,26 @@ try {

65 73
    throw new Error("The packed repository import help output is incomplete");
66 74
  }
67 75
68
  process.stdout.write("Packed npm install and CLI entry point passed.\n");
76
  // The published package once carried a `forum` command that existed in no
77
  // source file, because nothing compared what shipped against what a clean
78
  // build produces. Comparing the two command surfaces is that check.
79
  const packedSubcommands = subcommands(help);
80
  const localHelp = run("node", [join(packageRoot, "dist", "main.js"), "--help"]).stdout;
81
  const localSubcommands = subcommands(localHelp);
82
83
  const onlyPacked = packedSubcommands.filter((name) => !localSubcommands.includes(name));
84
  const onlyLocal = localSubcommands.filter((name) => !packedSubcommands.includes(name));
85
  if (onlyPacked.length > 0 || onlyLocal.length > 0) {
86
    throw new Error(
87
      "The packed CLI and a clean build disagree about their commands. " +
88
        `Only in the tarball: ${onlyPacked.join(", ") || "none"}. ` +
89
        `Only in the build: ${onlyLocal.join(", ") || "none"}.`,
90
    );
91
  }
92
93
  process.stdout.write(
94
    `Packed npm install, CLI entry point, and command surface passed (${packedSubcommands.join(", ")}).\n`,
95
  );
69 96
} finally {
70 97
  rmSync(temporaryRoot, { recursive: true, force: true });
71 98
}
packages/openagents-cli/src/cli.ts modified +4 -2

@@ -53,7 +53,9 @@ import { SecretInput } from "./secret-input.js";

53 53
import { findToken, resolveApiEndpoint, resolveApiSession } from "./session.js";
54 54
import { TerminalSession } from "./terminal-session.js";
55 55
56
export const VERSION = "0.1.7";
56
// The version lives in `package.json`; see `version.ts`.
57
export { VERSION } from "./version.js";
58
import { VERSION as CLI_VERSION } from "./version.js";
57 59
58 60
const profileFlag = Flag.choice("profile", ["production", "staging", "local"]).pipe(
59 61
  Flag.withSchema(Profile),

@@ -1676,4 +1678,4 @@ export const openagentsCommand = rootCommand.pipe(

1676 1678
  ]),
1677 1679
);
1678 1680
1679
export const runCliWith = Command.runWith(openagentsCommand, { version: VERSION });
1681
export const runCliWith = Command.runWith(openagentsCommand, { version: CLI_VERSION });
packages/openagents-cli/src/version.ts added +41

@@ -0,0 +1,41 @@

1
/**
2
 * The CLI's version, read from `package.json` rather than restated.
3
 *
4
 * A hand-edited constant drifted: the published package reported `0.3.0` while
5
 * the committed constant said `0.1.7` and `package.json` said `0.2.1`, so
6
 * `openagents --version` named a release that was never published and nothing
7
 * failed. There is one place a version can live, and it is the manifest npm
8
 * publishes from.
9
 *
10
 * `package.json` sits one directory above the compiled output in both layouts
11
 * that matter: `dist/version.js` in a local build, and `dist/version.js` beside
12
 * the manifest in the published tarball. npm always includes `package.json`
13
 * regardless of the `files` list.
14
 */
15
16
import { readFileSync } from "node:fs";
17
import { dirname, join } from "node:path";
18
import { fileURLToPath } from "node:url";
19
20
/**
21
 * Reported when the manifest cannot be read. It is deliberately not a version:
22
 * a wrong number is worse than an obvious absence, because a number gets
23
 * believed and pasted into a bug report.
24
 */
25
const UNKNOWN = "unknown";
26
27
export const VERSION: string = readVersion();
28
29
export function manifestPath(): string {
30
  return join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
31
}
32
33
function readVersion(): string {
34
  try {
35
    const manifest = JSON.parse(readFileSync(manifestPath(), "utf8")) as Record<string, unknown>;
36
    const version = manifest["version"];
37
    return typeof version === "string" && version.length > 0 ? version : UNKNOWN;
38
  } catch {
39
    return UNKNOWN;
40
  }
41
}
packages/openagents-cli/test/version.test.ts added +71

@@ -0,0 +1,71 @@

1
import { execFileSync } from "node:child_process";
2
import { existsSync, readFileSync } from "node:fs";
3
import { dirname, join } from "node:path";
4
import { fileURLToPath } from "node:url";
5
6
import { describe, expect, it } from "vitest";
7
8
import { VERSION, manifestPath } from "../src/version.js";
9
10
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
11
const manifest = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")) as {
12
  version: string;
13
};
14
15
describe("VERSION", () => {
16
  it("is the version in package.json", () => {
17
    // The defect this replaces: a hand-edited constant said 0.1.7 while the
18
    // manifest said 0.2.1 and npm served 0.3.0, and nothing failed.
19
    expect(VERSION).toBe(manifest.version);
20
  });
21
22
  it("is a real version rather than the unknown marker", () => {
23
    expect(VERSION).not.toBe("unknown");
24
    expect(VERSION).toMatch(/^\d+\.\d+\.\d+/);
25
  });
26
27
  it("resolves the manifest from the compiled layout, not only from source", () => {
28
    // `dist/version.js` must find `package.json` one directory up, which is
29
    // also the published tarball's layout.
30
    expect(existsSync(manifestPath())).toBe(true);
31
  });
32
33
  it("reports the manifest version through the built binary", () => {
34
    const built = join(packageRoot, "dist", "main.js");
35
    if (!existsSync(built)) return; // `pnpm run test` may run before a build.
36
37
    const printed = execFileSync("node", [built, "--version"], { encoding: "utf8" }).trim();
38
    expect(printed).toContain(manifest.version);
39
  });
40
41
  it("does not sit behind the version already published to npm", () => {
42
    // The published package was ahead of source once, which is how a shipped
43
    // command came to exist in no source file. A source version below the
44
    // registry's is the signal that it happened again.
45
    let published: string;
46
    try {
47
      published = execFileSync("npm", ["view", "@openagentsinc/cli", "version"], {
48
        encoding: "utf8",
49
        stdio: ["ignore", "pipe", "ignore"],
50
        timeout: 15_000,
51
      }).trim();
52
    } catch {
53
      return; // Offline, or the registry is unreachable. Not this test's failure.
54
    }
55
56
    expect(compare(manifest.version, published)).toBeGreaterThanOrEqual(0);
57
  });
58
});
59
60
/** Compare two `major.minor.patch` versions, ignoring any prerelease suffix. */
61
function compare(left: string, right: string): number {
62
  const parse = (value: string) =>
63
    (value.split("-")[0] ?? "").split(".").map((part) => Number.parseInt(part, 10) || 0);
64
  const a = parse(left);
65
  const b = parse(right);
66
  for (let index = 0; index < 3; index += 1) {
67
    const difference = (a[index] ?? 0) - (b[index] ?? 0);
68
    if (difference !== 0) return difference;
69
  }
70
  return 0;
71
}

This page updates live while a promote is in flight · changelog