@botbuddy/cli 1.4.2 → 1.5.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.
- package/package.json +3 -2
- package/src/commands.mjs +7 -0
- package/src/docker-hygiene.mjs +1062 -0
- package/src/run.mjs +5 -1
- package/src/stack-file-lock.mjs +180 -0
- package/src/stack.mjs +257 -36
- package/src/auth.test.mjs +0 -404
- package/src/discovery.test.mjs +0 -195
- package/src/locks.test.mjs +0 -60
- package/src/profile-bootstrap.test.mjs +0 -205
- package/src/publish-equal.test.mjs +0 -176
- package/src/publish-workflow.test.mjs +0 -122
- package/src/quiet-runner.test.mjs +0 -109
- package/src/run.test.mjs +0 -173
- package/src/stack.test.mjs +0 -434
- package/src/wait-profile.test.mjs +0 -30
- package/src/wait.test.mjs +0 -266
|
@@ -1,205 +0,0 @@
|
|
|
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.2");
|
|
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
|
-
});
|
|
@@ -1,176 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import test from "node:test";
|
|
3
|
-
import { readFileSync } from "node:fs";
|
|
4
|
-
|
|
5
|
-
// BOT-1382: the CLI publish workflow is the artifact under test. These
|
|
6
|
-
// contract assertions make its trigger, working directory, skip behaviour,
|
|
7
|
-
// and post-publish verification reviewable locally, without a YAML parser
|
|
8
|
-
// dependency (the CLI package intentionally has none).
|
|
9
|
-
const wf = readFileSync(
|
|
10
|
-
new URL("../../.github/workflows/publish-cli-package.yml", import.meta.url),
|
|
11
|
-
"utf8",
|
|
12
|
-
);
|
|
13
|
-
|
|
14
|
-
test("BOT-1382: triggers ONLY on main pushes touching cli/ or the workflow — no credential-bearing dispatch", () => {
|
|
15
|
-
assert.match(wf, /on:/);
|
|
16
|
-
assert.match(wf, /branches:\s*\[main\]/);
|
|
17
|
-
assert.match(wf, /- "cli\/\*\*"/);
|
|
18
|
-
assert.match(wf, /- "\.github\/workflows\/publish-cli-package\.yml"/);
|
|
19
|
-
// workflow_dispatch is intentionally NOT enabled: it accepts an arbitrary
|
|
20
|
-
// --ref, so a feature branch could run its own (guard-stripped) workflow with
|
|
21
|
-
// NPM_TOKEN. The on: block must not declare it.
|
|
22
|
-
assert.doesNotMatch(wf, /^\s*workflow_dispatch:/m);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
test("BOT-1382: read-only contents permission and a static, non-cancelling concurrency group", () => {
|
|
26
|
-
assert.match(wf, /permissions:\s*\n\s*contents: read/);
|
|
27
|
-
assert.match(wf, /cancel-in-progress: false/);
|
|
28
|
-
// A STATIC group serializes publishes so the backward-move guard is correct
|
|
29
|
-
// (a per-commit key would let runs race the guard and corrupt the dist-tag).
|
|
30
|
-
assert.match(wf, /group: publish-cli-package\n/);
|
|
31
|
-
assert.doesNotMatch(wf, /group: publish-cli-package-\$\{\{ github\.sha \}\}/);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
test("BOT-1382: Node 20 on the public npm registry", () => {
|
|
35
|
-
assert.match(wf, /node-version: 20/);
|
|
36
|
-
assert.match(wf, /registry-url: "https:\/\/registry\.npmjs\.org"/);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test("BOT-1382: fails loudly on a missing NPM_TOKEN and never echoes it", () => {
|
|
40
|
-
assert.match(wf, /NPM_TOKEN repo secret is not set/);
|
|
41
|
-
assert.match(wf, /secrets\.NPM_TOKEN/);
|
|
42
|
-
// The token must only ever flow through env, never be printed.
|
|
43
|
-
assert.doesNotMatch(wf, /echo[^\n]*\$\{?NPM_TOKEN/);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
test("BOT-1382: reads name and version dynamically and never hard-codes the version", () => {
|
|
47
|
-
assert.match(wf, /require\('\.\/cli\/package\.json'\)\.name/);
|
|
48
|
-
assert.match(wf, /require\('\.\/cli\/package\.json'\)\.version/);
|
|
49
|
-
assert.doesNotMatch(wf, /1\.4\.0/);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
test("BOT-1382: prerelease is classified structurally, not by a raw hyphen", () => {
|
|
53
|
-
assert.match(wf, /publish-equal\.mjs" --is-prerelease/);
|
|
54
|
-
// The naive raw-hyphen classifier must be gone (build metadata like
|
|
55
|
-
// 1.4.0+build-1 is a stable release).
|
|
56
|
-
assert.doesNotMatch(wf, /\[\[ "\$LOCAL" == \*-\* \]\]/);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
test("BOT-1382: the exact-version probe fails closed — only E404 means unpublished", () => {
|
|
60
|
-
const check = wf.slice(wf.indexOf("Decide publish vs skip"), wf.indexOf("Guard against moving a dist-tag backward"));
|
|
61
|
-
assert.match(check, /E404/);
|
|
62
|
-
assert.match(check, /registry error/);
|
|
63
|
-
assert.doesNotMatch(check, /npm view "\$\{PKG_NAME\}@\$\{LOCAL\}" version >\/dev\/null 2>&1/);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
test("BOT-1382: runs the full CLI test suite before publishing", () => {
|
|
67
|
-
assert.match(wf, /node --test cli\/src\/\*\.test\.mjs/);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
test("BOT-1382: performs an npm pack dry-run from cli/ before publishing", () => {
|
|
71
|
-
assert.match(wf, /working-directory: cli/);
|
|
72
|
-
assert.match(wf, /npm pack --dry-run/);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
test("BOT-1382: skips an already-published version and otherwise publishes with public access", () => {
|
|
76
|
-
assert.match(wf, /npm view "\$\{PKG_NAME\}@\$\{LOCAL\}" version/);
|
|
77
|
-
assert.match(wf, /npm publish --access public/);
|
|
78
|
-
assert.match(wf, /if: steps\.check\.outputs\.published == 'false'/);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test("BOT-1382: verifies the public artifact — exact version, latest tag, and profile/wait --help", () => {
|
|
82
|
-
assert.match(wf, /npm view "\$\{PKG_NAME\}@\$\{LOCAL\}" version/);
|
|
83
|
-
assert.match(wf, /npm view "\$\{PKG_NAME\}@latest" version/);
|
|
84
|
-
assert.match(wf, /profile --help/);
|
|
85
|
-
assert.match(wf, /wait --help/);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
test("BOT-1382: a changed CLI that reuses a published version fails instead of silently skipping", () => {
|
|
89
|
-
// Only a byte-identical published package is an idempotent skip; a changed
|
|
90
|
-
// cli/ that reused a published (immutable) version must fail loudly. The
|
|
91
|
-
// decision is delegated to the unit-tested publish-equal helper.
|
|
92
|
-
assert.match(wf, /dist\.tarball/);
|
|
93
|
-
assert.match(wf, /versions are immutable/);
|
|
94
|
-
assert.match(wf, /cli\/src\/publish-equal\.mjs/);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
test("BOT-1382: prereleases publish under a non-latest tag and must not move latest", () => {
|
|
98
|
-
assert.match(wf, /--tag next/);
|
|
99
|
-
assert.match(wf, /moved the latest dist-tag/);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
test("BOT-1382: a publish that would move a dist-tag backward is refused (latest for stable, next for prerelease)", () => {
|
|
103
|
-
assert.match(wf, /Guard against moving a dist-tag backward/);
|
|
104
|
-
assert.match(wf, /publish-equal\.mjs" --gt/);
|
|
105
|
-
assert.match(wf, /move the \$\{TAG\} dist-tag backward/);
|
|
106
|
-
// The guarded tag is chosen by prerelease-ness: next for prereleases, latest otherwise.
|
|
107
|
-
assert.match(wf, /if \[\[ "\$PRERELEASE" == "true" \]\]; then TAG="next"; else TAG="latest"; fi/);
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
test("BOT-1382: publishing is restricted to main even via workflow_dispatch", () => {
|
|
111
|
-
assert.match(wf, /must only run on main/);
|
|
112
|
-
assert.match(wf, /GITHUB_REF.*!= "refs\/heads\/main"|"\$\{GITHUB_REF\}" != "refs\/heads\/main"/);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
test("BOT-1382: the backward-move guard reads the dist-tag fail-closed — a registry error aborts, only E404 is a first publish", () => {
|
|
116
|
-
// A non-404 read failure must abort rather than be treated as "no tag".
|
|
117
|
-
const guard = wf.slice(wf.indexOf("Guard against moving a dist-tag backward"));
|
|
118
|
-
assert.match(guard, /E404/);
|
|
119
|
-
assert.match(guard, /registry error/);
|
|
120
|
-
// The guard must NOT swallow read errors into an empty "first publish".
|
|
121
|
-
assert.doesNotMatch(guard.slice(0, guard.indexOf("Publish to npm")), /2>\/dev\/null \|\| true/);
|
|
122
|
-
});
|
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
import test from "node:test";
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
import { mkdtemp, readFile } from "node:fs/promises";
|
|
4
|
-
import { tmpdir } from "node:os";
|
|
5
|
-
import { join } from "node:path";
|
|
6
|
-
import { QuietRunner, boundedSnapshot, failureTail } from "./quiet-runner.mjs";
|
|
7
|
-
|
|
8
|
-
test("quiet runner writes complete output while waking only for transitions and terminal state", async () => {
|
|
9
|
-
const artifactDir = await mkdtemp(join(tmpdir(), "botbuddy-quiet-runner-"));
|
|
10
|
-
const notifications = []; const heartbeats = [];
|
|
11
|
-
const runner = new QuietRunner({
|
|
12
|
-
artifactDir, now: (() => { let now = 0; return () => (now += 1_000); })(),
|
|
13
|
-
launch: async () => ({ async *poll() { yield { output: "waiting\\n" }; yield { output: "ready\\n", transition: "tests_started" }; yield { output: "done\\n", terminal: true, exitCode: 0 }; } }),
|
|
14
|
-
notify: async (value) => notifications.push(value), heartbeat: async (value) => heartbeats.push(value),
|
|
15
|
-
});
|
|
16
|
-
const result = await runner.run({ runId: "run-1", command: ["pnpm", "test"] });
|
|
17
|
-
assert.equal(result.status, "succeeded");
|
|
18
|
-
assert.equal(await readFile(result.receipt_path, "utf8"), "waiting\\nready\\ndone\\n");
|
|
19
|
-
assert.equal(notifications.length, 2, "unchanged polls never wake the agent");
|
|
20
|
-
assert.equal(notifications[0].reason, "meaningful_transition");
|
|
21
|
-
assert.equal(notifications[1].reason, "terminal");
|
|
22
|
-
assert.equal(heartbeats.at(-1).status, "terminal");
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
test("quiet runner sanitizes failure and snapshots stay bounded", async () => {
|
|
26
|
-
const artifactDir = await mkdtemp(join(tmpdir(), "botbuddy-quiet-runner-"));
|
|
27
|
-
const runner = new QuietRunner({ artifactDir, launch: async () => ({ async *poll() { yield { output: `token=super-secret\\n${"x".repeat(3_000)}`, terminal: true, exitCode: 1 }; } }) });
|
|
28
|
-
const result = await runner.run({ runId: "run-2", command: ["false"] });
|
|
29
|
-
assert.equal(result.failure.exit_code, 1);
|
|
30
|
-
assert.equal(result.failure.tail.length <= 2048, true);
|
|
31
|
-
assert.equal(result.failure.tail.includes("super-secret"), false);
|
|
32
|
-
assert.equal(boundedSnapshot({ status: "running", elapsedSeconds: 1, transition: "x".repeat(5_000), receiptPath: "receipt://x", nextWakeCondition: "terminal" }).length <= 800, true);
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
test("quiet runner redacts bearer headers and known credential prefixes", () => {
|
|
36
|
-
const tail = failureTail("Authorization: Bearer bb_live_abc123\\nsk-proj-secret\\nghp_githubsecret\\ntoken=plain-secret\\n{\"password\":\"hunter2\",\"api_key\":\"opaque-secret\"}");
|
|
37
|
-
assert.equal(tail.includes("bb_live_abc123"), false);
|
|
38
|
-
assert.equal(tail.includes("sk-proj-secret"), false);
|
|
39
|
-
assert.equal(tail.includes("ghp_githubsecret"), false);
|
|
40
|
-
assert.equal(tail.includes("plain-secret"), false);
|
|
41
|
-
assert.equal(tail.includes("hunter2"), false);
|
|
42
|
-
assert.equal(tail.includes("opaque-secret"), false);
|
|
43
|
-
assert.match(tail, /Authorization: Bearer \[REDACTED\]/);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
test("quiet runner redacts a credential even when it crosses the failure-tail boundary", () => {
|
|
47
|
-
const secret = "x".repeat(3_000);
|
|
48
|
-
const tail = failureTail(`token=${secret}`);
|
|
49
|
-
assert.equal(tail.includes(secret.slice(-64)), false);
|
|
50
|
-
assert.match(tail, /token=\[REDACTED\]/);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
test("quiet runner redacts a credential before bounded capture drops its label", async () => {
|
|
54
|
-
const artifactDir = await mkdtemp(join(tmpdir(), "botbuddy-quiet-runner-"));
|
|
55
|
-
const secret = "x".repeat(70_000);
|
|
56
|
-
const runner = new QuietRunner({ artifactDir, launch: async () => ({ async *poll() { yield { output: `token=${secret}`, terminal: true, exitCode: 1 }; } }) });
|
|
57
|
-
const result = await runner.run({ runId: "large-secret", command: ["false"] });
|
|
58
|
-
assert.equal(result.failure.tail.includes(secret.slice(-64)), false);
|
|
59
|
-
assert.match(result.failure.tail, /token=\[REDACTED\]/);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
test("quiet runner redacts a credential whose label and value arrive in separate polls", async () => {
|
|
63
|
-
const artifactDir = await mkdtemp(join(tmpdir(), "botbuddy-quiet-runner-"));
|
|
64
|
-
const secret = "x".repeat(70_000);
|
|
65
|
-
const runner = new QuietRunner({ artifactDir, launch: async () => ({ async *poll() {
|
|
66
|
-
yield { output: "token=" };
|
|
67
|
-
yield { output: secret, terminal: true, exitCode: 1 };
|
|
68
|
-
} }) });
|
|
69
|
-
const result = await runner.run({ runId: "split-large-secret", command: ["false"] });
|
|
70
|
-
assert.equal(result.failure.tail.includes(secret.slice(-64)), false);
|
|
71
|
-
assert.match(result.failure.tail, /token=\[REDACTED\]/);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
test("bounded snapshots remain parseable JSON when fields are oversized", () => {
|
|
75
|
-
const snapshot = boundedSnapshot({ status: "running", elapsedSeconds: 1, transition: '"'.repeat(5_000), receiptPath: "receipt://" + "\\".repeat(5_000), nextWakeCondition: "x".repeat(5_000) });
|
|
76
|
-
assert.equal(snapshot.length <= 800, true);
|
|
77
|
-
assert.equal(JSON.parse(snapshot).status, "running");
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
test("quiet runner keeps only bounded failure state while recording a large receipt", async () => {
|
|
81
|
-
const artifactDir = await mkdtemp(join(tmpdir(), "botbuddy-quiet-runner-"));
|
|
82
|
-
const output = "x".repeat(200_000);
|
|
83
|
-
const runner = new QuietRunner({ artifactDir, launch: async () => ({ async *poll() { yield { output, terminal: true, exitCode: 1 }; } }) });
|
|
84
|
-
const result = await runner.run({ runId: "large-output", command: ["false"] });
|
|
85
|
-
assert.equal(result.output_characters, output.length);
|
|
86
|
-
assert.equal((await readFile(result.receipt_path)).length, output.length);
|
|
87
|
-
assert.equal(result.failure.tail.length <= 2048, true);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
test("quiet runner preserves a registered receipt URI while writing its local artifact", async () => {
|
|
91
|
-
const artifactDir = await mkdtemp(join(tmpdir(), "botbuddy-quiet-runner-"));
|
|
92
|
-
const transitions = [];
|
|
93
|
-
const runner = new QuietRunner({
|
|
94
|
-
artifactDir,
|
|
95
|
-
launch: async () => ({ async *poll() { yield { output: "complete\\n", transition: "finished", terminal: true, exitCode: 0 }; } }),
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
const result = await runner.run({
|
|
99
|
-
runId: "registered-receipt",
|
|
100
|
-
command: ["pnpm", "test"],
|
|
101
|
-
receiptPath: "receipt://tenant/registered-receipt",
|
|
102
|
-
onTransition: async (transition) => transitions.push(transition),
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
assert.equal(result.receipt_path, "receipt://tenant/registered-receipt");
|
|
106
|
-
assert.equal(result.receipt.artifact_uri, "receipt://tenant/registered-receipt");
|
|
107
|
-
assert.equal(await readFile(result.receipt.path, "utf8"), "complete\\n");
|
|
108
|
-
assert.equal(transitions[0].receipt_path, "receipt://tenant/registered-receipt");
|
|
109
|
-
});
|