@botbuddy/cli 1.2.3 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,205 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import { bootstrapProfile, defaultProfileAgentName, profileShellRefresh } from "./profile-bootstrap.mjs";
5
+ import { execFile } from "node:child_process";
6
+ import { promisify } from "node:util";
7
+
8
+ const execFileAsync = promisify(execFile);
9
+ const bootstrapForTest = (profile, options) => bootstrapProfile(profile, { ensureBackend: () => {}, ...options });
10
+
11
+ test("BOT-1353: the published CLI identifies this profile-bootstrap release", async () => {
12
+ const { stdout } = await execFileAsync("node", ["bin/botbuddy.mjs", "--version"], {
13
+ cwd: new URL("..", import.meta.url),
14
+ });
15
+
16
+ assert.equal(stdout.trim(), "botbuddy v1.4.1");
17
+ });
18
+
19
+ test("BOT-1382: profile --help succeeds and documents the subcommands", async () => {
20
+ for (const flag of ["--help", "-h"]) {
21
+ const { stdout } = await execFileAsync("node", ["bin/botbuddy.mjs", "profile", flag], {
22
+ cwd: new URL("..", import.meta.url),
23
+ });
24
+ assert.match(stdout, /botbuddy profile <setup\|env>/);
25
+ }
26
+ });
27
+
28
+ test("BOT-1353: default profile agent names are globally collision-resistant", () => {
29
+ const first = defaultProfileAgentName("supplyguard-dev");
30
+ const second = defaultProfileAgentName("supplyguard-dev");
31
+
32
+ assert.match(first, /^supplyguard-dev-[a-z0-9-]+-[a-f0-9]{12}$/);
33
+ assert.notEqual(first, second);
34
+ });
35
+
36
+ test("BOT-1353: the published CLI tarball contains every profile-bootstrap dependency", async () => {
37
+ const { stdout } = await execFileAsync("npm", ["pack", "--dry-run", "--json"], {
38
+ cwd: new URL("..", import.meta.url),
39
+ });
40
+ const files = JSON.parse(stdout)[0].files.map(({ path }) => path);
41
+
42
+ assert(files.includes("src/profile-bootstrap.mjs"));
43
+ assert(files.includes("src/agent-credential-store.mjs"));
44
+ // BOT-1383: the loopback-login runtime files must ship in the tarball too.
45
+ assert(files.includes("src/auth.mjs"));
46
+ assert(files.includes("src/oauth-loopback.mjs"));
47
+ });
48
+
49
+ test("BOT-1353: profile bootstrap binds registration to Supply Guard and stores only the returned agent credential", async () => {
50
+ const calls = [];
51
+ const stored = [];
52
+
53
+ const result = await bootstrapForTest("supplyguard-dev", {
54
+ name: "supplyguard-dev-jonos-mbp",
55
+ call: async (tool, args) => {
56
+ calls.push({ tool, args });
57
+ return {
58
+ ok: true,
59
+ isError: true,
60
+ data: {
61
+ code: "FRESH_CONNECTION_REQUIRED",
62
+ agent_id: "agent-sg-1",
63
+ tenant_id: "supply-guard",
64
+ agent_api_key: "fixture-agent-credential",
65
+ },
66
+ };
67
+ },
68
+ store: async (entry) => stored.push(entry),
69
+ });
70
+
71
+ assert.deepEqual(calls, [{
72
+ tool: "register_agent",
73
+ args: {
74
+ name: "supplyguard-dev-jonos-mbp",
75
+ type: "codex",
76
+ tenant_id: "supply-guard",
77
+ },
78
+ }]);
79
+ assert.deepEqual(stored, [{
80
+ profile: "supplyguard-dev",
81
+ tenant: "supply-guard",
82
+ agentId: "agent-sg-1",
83
+ name: "supplyguard-dev-jonos-mbp",
84
+ token: "fixture-agent-credential",
85
+ }]);
86
+ assert.deepEqual(result, {
87
+ schema_version: 1,
88
+ outcome: "installed",
89
+ profile: "supplyguard-dev",
90
+ tenant_id: "supply-guard",
91
+ agent_id: "agent-sg-1",
92
+ credential_source: "keychain_profile_slot",
93
+ shell_refresh: "source <(npx --yes @botbuddy/cli@latest profile env supplyguard-dev)",
94
+ gui_refresh: "$HOME/.local/bin/botbuddy-mcp-env.sh",
95
+ });
96
+ assert(!JSON.stringify(result).includes("fixture-agent-credential"));
97
+ });
98
+
99
+ test("BOT-1353: profile setup gives the invoking shell a deterministic Keychain refresh", () => {
100
+ assert.equal(
101
+ profileShellRefresh("supplyguard-dev"),
102
+ "if botbuddy_profile_token=\"$(security find-generic-password -a \"$USER\" -s \"BOTBUDDY_SG_AGENT_KEY\" -w)\"; then export BOTBUDDY_SG_AGENT_KEY=\"$botbuddy_profile_token\"; unset botbuddy_profile_token; else unset botbuddy_profile_token; false; fi",
103
+ );
104
+ assert.throws(() => profileShellRefresh("unknown"), (error) => error?.code === "profile_required");
105
+ });
106
+
107
+ test("BOT-1353: bootstrap reconnect is tenant-attested and never installs a wrong-tenant credential", async () => {
108
+ let stored = false;
109
+
110
+ await assert.rejects(
111
+ bootstrapForTest("supplyguard-dev", {
112
+ call: async () => ({
113
+ ok: true,
114
+ data: {
115
+ agent_id: "agent-bb-1",
116
+ tenant_id: "botbuddy",
117
+ api_key: "fixture-wrong-tenant-credential",
118
+ },
119
+ }),
120
+ store: async () => { stored = true; },
121
+ }),
122
+ (err) => err?.code === "profile_credential_wrong_tenant",
123
+ );
124
+ assert.equal(stored, false);
125
+ });
126
+
127
+ test("BOT-1353: profile setup reconnects the stored identity without changing its tenant", async () => {
128
+ let registration;
129
+ await bootstrapForTest("supplyguard-dev", {
130
+ readIdentity: async () => ({ agentId: "agent-sg-1", tenant: "supply-guard", name: "supplyguard-dev-existing", token: "fixture-old-credential" }),
131
+ call: async (_tool, args) => {
132
+ registration = args;
133
+ return { ok: true, data: { agent_id: "agent-sg-1", tenant_id: "supply-guard", api_key: "fixture-reconnected-credential" } };
134
+ },
135
+ store: async () => {},
136
+ });
137
+ assert.equal(registration.agent_id, "agent-sg-1");
138
+ assert.equal(registration.name, "supplyguard-dev-existing");
139
+ assert.equal(registration.tenant_id, "supply-guard");
140
+ });
141
+
142
+ test("BOT-1353: bootstrap refuses an un-attested registration response without storing its credential", async () => {
143
+ let stored = false;
144
+ let retryIdentity;
145
+
146
+ await assert.rejects(
147
+ bootstrapForTest("supplyguard-dev", {
148
+ call: async () => ({
149
+ ok: true,
150
+ data: { agent_id: "agent-sg-1", agent_api_key: "fixture-unattested-credential" },
151
+ }),
152
+ store: async () => { stored = true; },
153
+ recordRetryIdentity: async (entry) => { retryIdentity = entry; },
154
+ }),
155
+ (err) => err?.code === "profile_tenant_attestation_missing",
156
+ );
157
+ assert.equal(stored, false);
158
+ assert.deepEqual(retryIdentity, { profile: "supplyguard-dev", agentId: "agent-sg-1", name: retryIdentity.name });
159
+ assert.match(retryIdentity.name, /^supplyguard-dev-/);
160
+ });
161
+
162
+ test("BOT-1353: an unattested registration retries the same untrusted agent identity", async () => {
163
+ let retryIdentity;
164
+ await assert.rejects(bootstrapForTest("supplyguard-dev", {
165
+ call: async () => ({ ok: true, data: { agent_id: "agent-sg-pending", api_key: "fixture" } }),
166
+ recordRetryIdentity: async (entry) => { retryIdentity = entry; },
167
+ }), (error) => error?.code === "profile_tenant_attestation_missing");
168
+ let retryArgs;
169
+ await bootstrapForTest("supplyguard-dev", {
170
+ readRetryIdentity: async () => ({ agentId: retryIdentity.agentId, name: retryIdentity.name }),
171
+ call: async (_tool, args) => {
172
+ retryArgs = args;
173
+ return { ok: true, data: { agent_id: "agent-sg-pending", tenant_id: "supply-guard", api_key: "fixture" } };
174
+ },
175
+ store: async () => {},
176
+ });
177
+ assert.equal(retryArgs.agent_id, "agent-sg-pending");
178
+ assert.equal(retryArgs.name, retryIdentity.name);
179
+ });
180
+
181
+ test("BOT-1353: an unavailable Keychain backend fails before agent registration", async () => {
182
+ let called = false;
183
+ await assert.rejects(
184
+ bootstrapProfile("supplyguard-dev", {
185
+ ensureBackend: () => { throw new Error("unavailable"); },
186
+ call: async () => { called = true; return { ok: true }; },
187
+ }),
188
+ (error) => error?.code === "profile_agent_required",
189
+ );
190
+ assert.equal(called, false);
191
+ });
192
+
193
+ test("BOT-1353: wrong-tenant metadata is replaced by a fresh tenant-bound registration", async () => {
194
+ let registration;
195
+ await bootstrapForTest("supplyguard-dev", {
196
+ readIdentity: async () => ({ agentId: "agent-bb-1", tenant: "botbuddy" }),
197
+ call: async (_tool, args) => {
198
+ registration = args;
199
+ return { ok: true, data: { agent_id: "agent-sg-2", tenant_id: "supply-guard", api_key: "fixture-replacement-credential" } };
200
+ },
201
+ store: async () => {},
202
+ });
203
+ assert.equal(registration.agent_id, undefined);
204
+ assert.equal(registration.tenant_id, "supply-guard");
205
+ });
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env node
2
+ // BOT-1382: decide whether a locally-packed CLI package is equivalent to the
3
+ // already-published npm tarball of the same (immutable) version.
4
+ //
5
+ // Used by .github/workflows/publish-cli-package.yml when the version already
6
+ // exists on npm: an equivalent package is a safe idempotent skip; any drift
7
+ // means cli/ changed without a version bump and must fail loudly so the change
8
+ // cannot merge green while staying unpublished.
9
+ //
10
+ // The comparison excludes ONLY npm-injected manifest fields (notably gitHead,
11
+ // which npm stamps into the tarball's package.json from the publishing commit).
12
+ // Every other manifest field (bin, files, engines, …) and every shipped file
13
+ // is compared, so a genuine metadata change still counts as drift.
14
+
15
+ import { readFileSync, readdirSync, statSync } from "node:fs";
16
+ import { join, relative } from "node:path";
17
+
18
+ // npm does NOT inject any field into the packed tarball's package.json —
19
+ // verified against the published @botbuddy/cli tarball, which carries neither
20
+ // `gitHead` nor `_`-prefixed keys (those live in the registry packument, not
21
+ // the tarball). So no field is ignored: every package.json field, gitHead
22
+ // included, is compared. Key ORDER is still canonicalised so incidental
23
+ // ordering never reads as drift.
24
+ const NPM_INJECTED_KEYS = [];
25
+
26
+ // Deep, key-sorted canonical form so incidental key ordering never reads as a
27
+ // difference.
28
+ function canon(v) {
29
+ if (Array.isArray(v)) return v.map(canon);
30
+ if (v && typeof v === "object") {
31
+ return Object.keys(v)
32
+ .sort()
33
+ .reduce((o, k) => {
34
+ o[k] = canon(v[k]);
35
+ return o;
36
+ }, {});
37
+ }
38
+ return v;
39
+ }
40
+
41
+ // Parse a STABLE semver ("x.y.z") into numeric [major, minor, patch]. Throws
42
+ // on anything with a prerelease/build suffix — this gate is stable-only.
43
+ export function parseStable(v) {
44
+ const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(v).trim());
45
+ if (!m) throw new Error(`not a stable semver: ${v}`);
46
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
47
+ }
48
+
49
+ // True when stable `next` is strictly greater than stable `current`, compared
50
+ // numerically per component (so 1.10.0 > 1.9.0). Equal is NOT higher. Used to
51
+ // refuse a stable publish that would move the `latest` dist-tag backward.
52
+ export function isHigherStable(next, current) {
53
+ const a = parseStable(next);
54
+ const b = parseStable(current);
55
+ for (let i = 0; i < 3; i++) {
56
+ if (a[i] !== b[i]) return a[i] > b[i];
57
+ }
58
+ return false;
59
+ }
60
+
61
+ // Parse any semver, including a prerelease suffix ("2.0.0-rc.1"). Build
62
+ // metadata (after "+") is ignored for precedence, per semver §10.
63
+ export function parseSemver(v) {
64
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(String(v).trim());
65
+ if (!m) throw new Error(`not a semver: ${v}`);
66
+ return { main: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ? m[4].split(".") : [] };
67
+ }
68
+
69
+ // Compare two prerelease identifier lists per semver §11.
70
+ function comparePre(a, b) {
71
+ // A version with no prerelease outranks one that has a prerelease.
72
+ if (a.length === 0 && b.length === 0) return 0;
73
+ if (a.length === 0) return 1;
74
+ if (b.length === 0) return -1;
75
+ const n = Math.min(a.length, b.length);
76
+ for (let i = 0; i < n; i++) {
77
+ const ai = a[i], bi = b[i];
78
+ const an = /^\d+$/.test(ai), bn = /^\d+$/.test(bi);
79
+ if (an && bn) {
80
+ const d = Number(ai) - Number(bi);
81
+ if (d !== 0) return d < 0 ? -1 : 1;
82
+ } else if (an !== bn) {
83
+ return an ? -1 : 1; // numeric identifiers have lower precedence than alphanumeric
84
+ } else if (ai !== bi) {
85
+ return ai < bi ? -1 : 1;
86
+ }
87
+ }
88
+ if (a.length !== b.length) return a.length < b.length ? -1 : 1; // more fields => higher
89
+ return 0;
90
+ }
91
+
92
+ // Full semver comparison: -1 | 0 | 1 for a<b | a==b | a>b.
93
+ export function compareSemver(a, b) {
94
+ const pa = parseSemver(a), pb = parseSemver(b);
95
+ for (let i = 0; i < 3; i++) {
96
+ if (pa.main[i] !== pb.main[i]) return pa.main[i] < pb.main[i] ? -1 : 1;
97
+ }
98
+ return comparePre(pa.pre, pb.pre);
99
+ }
100
+
101
+ // True when `next` is strictly greater than `current` under full semver
102
+ // precedence (prereleases included). Used to refuse a publish that would move
103
+ // EITHER the `latest` (stable) or `next` (prerelease) dist-tag backward.
104
+ export function isHigher(next, current) {
105
+ return compareSemver(next, current) > 0;
106
+ }
107
+
108
+ // True iff the version carries a semver prerelease component (the part after
109
+ // `-`, before any `+build` metadata). A raw hyphen check is wrong: build
110
+ // metadata like `1.4.0+build-1` is a STABLE release, not a prerelease.
111
+ export function isPrerelease(v) {
112
+ return parseSemver(v).pre.length > 0;
113
+ }
114
+
115
+ export function normalizeManifest(json) {
116
+ const m = JSON.parse(json);
117
+ for (const k of NPM_INJECTED_KEYS) delete m[k];
118
+ return JSON.stringify(canon(m));
119
+ }
120
+
121
+ function walkRelative(root) {
122
+ const out = [];
123
+ const rec = (dir) => {
124
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
125
+ const p = join(dir, entry.name);
126
+ if (entry.isDirectory()) rec(p);
127
+ // Always relative to the original root, so nested paths keep their prefix.
128
+ else out.push(relative(root, p));
129
+ }
130
+ };
131
+ rec(root);
132
+ return out.sort();
133
+ }
134
+
135
+ // Compare two extracted `package/` directories. Returns { equal, reason }.
136
+ export function packagesEquivalent(localDir, pubDir) {
137
+ const localFiles = walkRelative(localDir);
138
+ const pubFiles = walkRelative(pubDir);
139
+ if (localFiles.join("\n") !== pubFiles.join("\n")) {
140
+ return {
141
+ equal: false,
142
+ reason: `shipped file set differs\n local: ${localFiles.join(", ")}\n published: ${pubFiles.join(", ")}`,
143
+ };
144
+ }
145
+ for (const rel of localFiles) {
146
+ const a = readFileSync(join(localDir, rel));
147
+ const b = readFileSync(join(pubDir, rel));
148
+ if (rel === "package.json") {
149
+ if (normalizeManifest(a.toString("utf8")) !== normalizeManifest(b.toString("utf8"))) {
150
+ return { equal: false, reason: "package.json differs beyond npm-injected fields (e.g. bin/files/engines)" };
151
+ }
152
+ } else if (!a.equals(b)) {
153
+ return { equal: false, reason: `shipped file content differs: ${rel}` };
154
+ }
155
+ // npm pack preserves the executable bit, so a mode-only change (e.g. a bin
156
+ // file going 0644 -> 0755) is a real release even when the bytes match.
157
+ // Both dirs are extracted under the same umask, so the umask cancels out.
158
+ const ma = statSync(join(localDir, rel)).mode & 0o777;
159
+ const mb = statSync(join(pubDir, rel)).mode & 0o777;
160
+ if (ma !== mb) {
161
+ return { equal: false, reason: `file mode differs: ${rel} (${ma.toString(8)} vs ${mb.toString(8)})` };
162
+ }
163
+ }
164
+ return { equal: true };
165
+ }
166
+
167
+ // CLI: `node cli-publish-equal.mjs <localPackageDir> <publishedPackageDir>`.
168
+ // Exit 0 = equivalent (safe skip); exit 3 = drift (must fail the publish job).
169
+ if (import.meta.url === `file://${process.argv[1]}`) {
170
+ const args = process.argv.slice(2);
171
+ // `--gt <next> <current>`: exit 0 iff stable `next` > stable `current`.
172
+ // Used by the publish workflow to refuse moving `latest` backward.
173
+ if (args[0] === "--gt") {
174
+ const [, next, current] = args;
175
+ let higher;
176
+ try {
177
+ higher = isHigher(next, current);
178
+ } catch (err) {
179
+ console.error(String(err.message ?? err));
180
+ process.exit(2);
181
+ }
182
+ process.exit(higher ? 0 : 1);
183
+ }
184
+ // `--is-prerelease <version>`: prints "true"/"false" (structural, not a raw
185
+ // hyphen check). Used by the workflow to classify the local version.
186
+ if (args[0] === "--is-prerelease") {
187
+ try {
188
+ console.log(isPrerelease(args[1]) ? "true" : "false");
189
+ process.exit(0);
190
+ } catch (err) {
191
+ console.error(String(err.message ?? err));
192
+ process.exit(2);
193
+ }
194
+ }
195
+ const [localDir, pubDir] = args;
196
+ if (!localDir || !pubDir) {
197
+ console.error("usage: publish-equal.mjs <localPackageDir> <publishedPackageDir> | --gt <next> <current>");
198
+ process.exit(2);
199
+ }
200
+ const result = packagesEquivalent(localDir, pubDir);
201
+ if (result.equal) {
202
+ console.log("identical");
203
+ process.exit(0);
204
+ }
205
+ console.error("drift: " + result.reason);
206
+ process.exit(3);
207
+ }
@@ -0,0 +1,176 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, chmodSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ import { normalizeManifest, packagesEquivalent, isHigherStable, isHigher, isPrerelease } from "./publish-equal.mjs";
8
+
9
+ // BOT-1382: the publish workflow skips an already-published version ONLY when
10
+ // the local package is equivalent to the published tarball. These tests
11
+ // exercise that decision behaviourally (Codex review round 2, line 108): a
12
+ // changed manifest field must count as drift, while npm-injected fields
13
+ // (gitHead) must not.
14
+
15
+ const BASE_MANIFEST = {
16
+ name: "@botbuddy/cli",
17
+ version: "1.4.0",
18
+ bin: { botbuddy: "./bin/botbuddy.mjs" },
19
+ files: ["bin/", "src/"],
20
+ engines: { node: ">=18.0.0" },
21
+ };
22
+
23
+ function makePkg(manifest, files) {
24
+ const root = mkdtempSync(join(tmpdir(), "cli-eq-"));
25
+ const pkg = join(root, "package");
26
+ mkdirSync(join(pkg, "bin"), { recursive: true });
27
+ mkdirSync(join(pkg, "src"), { recursive: true });
28
+ writeFileSync(join(pkg, "package.json"), JSON.stringify(manifest, null, 2));
29
+ for (const [rel, content] of Object.entries(files)) {
30
+ writeFileSync(join(pkg, rel), content);
31
+ }
32
+ return { root, pkg };
33
+ }
34
+
35
+ const DEFAULT_FILES = { "bin/botbuddy.mjs": "#!/usr/bin/env node\n", "src/version.mjs": "export const V=1;\n" };
36
+
37
+ test("isHigherStable: a stable release must strictly exceed the current latest (no backward move)", () => {
38
+ assert.equal(isHigherStable("1.4.0", "1.2.3"), true);
39
+ assert.equal(isHigherStable("1.4.1", "1.4.0"), true);
40
+ assert.equal(isHigherStable("2.0.0", "1.9.9"), true);
41
+ assert.equal(isHigherStable("1.10.0", "1.9.0"), true); // numeric, not lexical
42
+ assert.equal(isHigherStable("1.4.0", "1.4.0"), false); // equal is not higher
43
+ assert.equal(isHigherStable("1.3.5", "1.4.0"), false); // downgrade => backward
44
+ assert.equal(isHigherStable("1.2.0", "1.10.0"), false); // numeric, not lexical
45
+ });
46
+
47
+ test("isHigher: full semver precedence incl. prereleases (guards the next tag too)", () => {
48
+ // Stable still behaves like isHigherStable.
49
+ assert.equal(isHigher("1.4.0", "1.2.3"), true);
50
+ assert.equal(isHigher("1.10.0", "1.9.0"), true);
51
+ assert.equal(isHigher("1.4.0", "1.4.0"), false);
52
+ // Prerelease precedence (semver §11).
53
+ assert.equal(isHigher("2.0.0-rc.2", "2.0.0-rc.1"), true);
54
+ assert.equal(isHigher("2.0.0-rc.10", "2.0.0-rc.2"), true); // numeric identifiers compare numerically
55
+ assert.equal(isHigher("2.0.0-rc.1", "1.9.0-rc.1"), true); // higher core wins
56
+ assert.equal(isHigher("1.9.0-rc.1", "2.0.0-rc.1"), false); // lower core => backward
57
+ assert.equal(isHigher("2.0.0-rc.1", "2.0.0-rc.1"), false); // equal is not higher
58
+ assert.equal(isHigher("2.0.0", "2.0.0-rc.1"), true); // a release outranks its prerelease
59
+ assert.equal(isHigher("2.0.0-rc.1", "2.0.0"), false);
60
+ });
61
+
62
+ test("isPrerelease: reads the semver prerelease component, not a raw hyphen (build metadata is stable)", () => {
63
+ assert.equal(isPrerelease("2.0.0-rc.1"), true);
64
+ assert.equal(isPrerelease("1.4.0"), false);
65
+ assert.equal(isPrerelease("1.4.0+build-1"), false); // hyphen in build metadata is NOT a prerelease
66
+ assert.equal(isPrerelease("2.0.0-rc.1+build-1"), true);
67
+ assert.equal(isPrerelease("1.4.0+2026-08-27"), false);
68
+ });
69
+
70
+ test("normalizeManifest only canonicalises key order — it ignores NO manifest fields", () => {
71
+ // npm does not inject gitHead or _-prefixed fields into the packed tarball
72
+ // (verified against the published @botbuddy/cli tarball), so every field —
73
+ // gitHead and _-prefixed included — is compared; a change is drift.
74
+ for (const change of [
75
+ { gitHead: "aaaa" },
76
+ { _id: "@botbuddy/cli@1.4.0" },
77
+ { _npmUser: "alice" },
78
+ ]) {
79
+ const a = JSON.stringify({ ...BASE_MANIFEST });
80
+ const b = JSON.stringify({ ...BASE_MANIFEST, ...change });
81
+ assert.notEqual(normalizeManifest(a), normalizeManifest(b));
82
+ }
83
+ });
84
+
85
+ test("normalizeManifest treats key-order-only differences as identical", () => {
86
+ const a = JSON.stringify({ name: "x", version: "1.0.0", bin: { a: "1", b: "2" } });
87
+ const b = JSON.stringify({ bin: { b: "2", a: "1" }, version: "1.0.0", name: "x" });
88
+ assert.equal(normalizeManifest(a), normalizeManifest(b));
89
+ });
90
+
91
+ test("a byte-identical package => equivalent (idempotent skip)", () => {
92
+ const local = makePkg({ ...BASE_MANIFEST }, DEFAULT_FILES);
93
+ const pub = makePkg({ ...BASE_MANIFEST }, DEFAULT_FILES);
94
+ try {
95
+ assert.equal(packagesEquivalent(local.pkg, pub.pkg).equal, true);
96
+ } finally {
97
+ rmSync(local.root, { recursive: true, force: true });
98
+ rmSync(pub.root, { recursive: true, force: true });
99
+ }
100
+ });
101
+
102
+ test("a package differing only in gitHead => drift (npm does not inject it into the tarball)", () => {
103
+ const local = makePkg({ ...BASE_MANIFEST, gitHead: "local" }, DEFAULT_FILES);
104
+ const pub = makePkg({ ...BASE_MANIFEST, gitHead: "published" }, DEFAULT_FILES);
105
+ try {
106
+ assert.equal(packagesEquivalent(local.pkg, pub.pkg).equal, false);
107
+ } finally {
108
+ rmSync(local.root, { recursive: true, force: true });
109
+ rmSync(pub.root, { recursive: true, force: true });
110
+ }
111
+ });
112
+
113
+ test("an authored README manifest field change counts as drift (only npm-injected fields are ignored)", () => {
114
+ const pubManifest = { ...BASE_MANIFEST, readme: "# One", readmeFilename: "README.md" };
115
+ const localManifest = { ...BASE_MANIFEST, readme: "# Two — changed", readmeFilename: "README.md" };
116
+ const local = makePkg(localManifest, DEFAULT_FILES);
117
+ const pub = makePkg(pubManifest, DEFAULT_FILES);
118
+ try {
119
+ assert.equal(packagesEquivalent(local.pkg, pub.pkg).equal, false);
120
+ } finally {
121
+ rmSync(local.root, { recursive: true, force: true });
122
+ rmSync(pub.root, { recursive: true, force: true });
123
+ }
124
+ });
125
+
126
+ test("a changed manifest field (bin/files/engines) counts as drift", () => {
127
+ for (const change of [
128
+ { bin: { botbuddy: "./bin/other.mjs" } },
129
+ { files: ["bin/", "src/", "README.md"] },
130
+ { engines: { node: ">=20.0.0" } },
131
+ ]) {
132
+ const local = makePkg({ ...BASE_MANIFEST, ...change }, DEFAULT_FILES);
133
+ const pub = makePkg({ ...BASE_MANIFEST }, DEFAULT_FILES);
134
+ try {
135
+ assert.equal(packagesEquivalent(local.pkg, pub.pkg).equal, false);
136
+ } finally {
137
+ rmSync(local.root, { recursive: true, force: true });
138
+ rmSync(pub.root, { recursive: true, force: true });
139
+ }
140
+ }
141
+ });
142
+
143
+ test("changed shipped source content counts as drift", () => {
144
+ const local = makePkg({ ...BASE_MANIFEST }, { ...DEFAULT_FILES, "src/version.mjs": "export const V=2;\n" });
145
+ const pub = makePkg({ ...BASE_MANIFEST }, DEFAULT_FILES);
146
+ try {
147
+ assert.equal(packagesEquivalent(local.pkg, pub.pkg).equal, false);
148
+ } finally {
149
+ rmSync(local.root, { recursive: true, force: true });
150
+ rmSync(pub.root, { recursive: true, force: true });
151
+ }
152
+ });
153
+
154
+ test("a changed executable bit counts as drift (npm pack preserves file mode)", () => {
155
+ const local = makePkg({ ...BASE_MANIFEST }, DEFAULT_FILES);
156
+ const pub = makePkg({ ...BASE_MANIFEST }, DEFAULT_FILES);
157
+ try {
158
+ chmodSync(join(local.pkg, "bin/botbuddy.mjs"), 0o755);
159
+ chmodSync(join(pub.pkg, "bin/botbuddy.mjs"), 0o644);
160
+ assert.equal(packagesEquivalent(local.pkg, pub.pkg).equal, false);
161
+ } finally {
162
+ rmSync(local.root, { recursive: true, force: true });
163
+ rmSync(pub.root, { recursive: true, force: true });
164
+ }
165
+ });
166
+
167
+ test("an added/removed shipped file counts as drift", () => {
168
+ const local = makePkg({ ...BASE_MANIFEST }, { ...DEFAULT_FILES, "src/extra.mjs": "export const X=1;\n" });
169
+ const pub = makePkg({ ...BASE_MANIFEST }, DEFAULT_FILES);
170
+ try {
171
+ assert.equal(packagesEquivalent(local.pkg, pub.pkg).equal, false);
172
+ } finally {
173
+ rmSync(local.root, { recursive: true, force: true });
174
+ rmSync(pub.root, { recursive: true, force: true });
175
+ }
176
+ });