@botbuddy/cli 1.5.1 → 1.5.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.5.1",
3
+ "version": "1.5.2",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1 @@
1
+ {"schema_version":1,"source_version":"1.5.1","source_identity":"0f390ab84c23f56dcae6e8cc1331fb2fa8533ae2f026f396f8adb76d9485b2d8"}
@@ -4,14 +4,15 @@
4
4
  //
5
5
  // Used by .github/workflows/publish-cli-package.yml when the version already
6
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.
7
+ // triggers a fresh release-time version allocation so the change cannot merge
8
+ // green while staying unpublished.
9
9
  //
10
10
  // The comparison excludes ONLY npm-injected manifest fields (notably gitHead,
11
11
  // which npm stamps into the tarball's package.json from the publishing commit).
12
12
  // Every other manifest field (bin, files, engines, …) and every shipped file
13
13
  // is compared, so a genuine metadata change still counts as drift.
14
14
 
15
+ import { createHash } from "node:crypto";
15
16
  import { readFileSync, readdirSync, statSync } from "node:fs";
16
17
  import { join, relative } from "node:path";
17
18
 
@@ -22,6 +23,7 @@ import { join, relative } from "node:path";
22
23
  // included, is compared. Key ORDER is still canonicalised so incidental
23
24
  // ordering never reads as drift.
24
25
  const NPM_INJECTED_KEYS = [];
26
+ const RELEASE_REPAIR_RECEIPT = "src/botbuddy-release-repair.json";
25
27
 
26
28
  // Deep, key-sorted canonical form so incidental key ordering never reads as a
27
29
  // difference.
@@ -112,9 +114,62 @@ export function isPrerelease(v) {
112
114
  return parseSemver(v).pre.length > 0;
113
115
  }
114
116
 
115
- export function normalizeManifest(json) {
117
+ function compareMain(a, b) {
118
+ for (let i = 0; i < 3; i++) {
119
+ if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
120
+ }
121
+ return 0;
122
+ }
123
+
124
+ function incrementPatch(main) {
125
+ if (!Number.isSafeInteger(main[2]) || main[2] === Number.MAX_SAFE_INTEGER) {
126
+ throw new Error(`cannot allocate a patch version after ${main.join(".")}`);
127
+ }
128
+ return [main[0], main[1], main[2] + 1];
129
+ }
130
+
131
+ // Allocate a release-only version once a tarball drifted from an immutable npm
132
+ // version. Stable releases advance from the greatest published stable version,
133
+ // preserving the established latest-tag ordering. Prereleases advance past the
134
+ // greatest published core version and retain a prerelease component, so they
135
+ // cannot accidentally become latest while still being greater than `next`.
136
+ // `unique` is a monotonic CI-run identity, not a timestamp, and is only used
137
+ // after a drift is confirmed.
138
+ export function nextUnpublishedVersion(local, publishedVersions, unique = "release") {
139
+ const parsedLocal = parseSemver(local);
140
+ if (!Array.isArray(publishedVersions)) throw new Error("published versions must be an array");
141
+
142
+ let greatestStable = parsedLocal.main;
143
+ let greatestCore = parsedLocal.main;
144
+ for (const version of publishedVersions) {
145
+ const parsed = parseSemver(version);
146
+ if (compareMain(parsed.main, greatestCore) > 0) greatestCore = parsed.main;
147
+ if (parsed.pre.length === 0 && compareMain(parsed.main, greatestStable) > 0) {
148
+ greatestStable = parsed.main;
149
+ }
150
+ }
151
+
152
+ if (parsedLocal.pre.length === 0) return incrementPatch(greatestStable).join(".");
153
+ if (!/^[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*$/.test(unique)) {
154
+ throw new Error(`not a prerelease identifier: ${unique}`);
155
+ }
156
+ return `${incrementPatch(greatestCore).join(".")}-release.${unique}`;
157
+ }
158
+
159
+ // Kept as the narrow public helper for the stable publish path and for
160
+ // straightforward unit fixtures. The workflow uses nextUnpublishedVersion so
161
+ // a drifted prerelease remains safely tagged as a prerelease too.
162
+ export function nextUnpublishedStableVersion(local, publishedVersions) {
163
+ parseStable(local);
164
+ const next = nextUnpublishedVersion(local, publishedVersions);
165
+ parseStable(next);
166
+ return next;
167
+ }
168
+
169
+ export function normalizeManifest(json, { ignoreVersion = false } = {}) {
116
170
  const m = JSON.parse(json);
117
171
  for (const k of NPM_INJECTED_KEYS) delete m[k];
172
+ if (ignoreVersion) delete m.version;
118
173
  return JSON.stringify(canon(m));
119
174
  }
120
175
 
@@ -133,7 +188,7 @@ function walkRelative(root) {
133
188
  }
134
189
 
135
190
  // Compare two extracted `package/` directories. Returns { equal, reason }.
136
- export function packagesEquivalent(localDir, pubDir) {
191
+ export function packagesEquivalent(localDir, pubDir, { ignoreVersion = false } = {}) {
137
192
  const localFiles = walkRelative(localDir);
138
193
  const pubFiles = walkRelative(pubDir);
139
194
  if (localFiles.join("\n") !== pubFiles.join("\n")) {
@@ -146,7 +201,7 @@ export function packagesEquivalent(localDir, pubDir) {
146
201
  const a = readFileSync(join(localDir, rel));
147
202
  const b = readFileSync(join(pubDir, rel));
148
203
  if (rel === "package.json") {
149
- if (normalizeManifest(a.toString("utf8")) !== normalizeManifest(b.toString("utf8"))) {
204
+ if (normalizeManifest(a.toString("utf8"), { ignoreVersion }) !== normalizeManifest(b.toString("utf8"), { ignoreVersion })) {
150
205
  return { equal: false, reason: "package.json differs beyond npm-injected fields (e.g. bin/files/engines)" };
151
206
  }
152
207
  } else if (!a.equals(b)) {
@@ -164,8 +219,47 @@ export function packagesEquivalent(localDir, pubDir) {
164
219
  return { equal: true };
165
220
  }
166
221
 
222
+ // A release-time repair carries a shipped receipt. It prevents an intentional
223
+ // restore of any historical tarball from being mistaken for an earlier repair
224
+ // of this exact source package. Versions and the receipt itself are excluded
225
+ // so the identity represents only the source package being repaired.
226
+ export function releaseRepairIdentity(packageDir) {
227
+ const hash = createHash("sha256");
228
+ for (const rel of walkRelative(packageDir)) {
229
+ if (rel === RELEASE_REPAIR_RECEIPT) continue;
230
+ const path = join(packageDir, rel);
231
+ const content = rel === "package.json"
232
+ ? normalizeManifest(readFileSync(path, "utf8"), { ignoreVersion: true })
233
+ : readFileSync(path);
234
+ hash.update(rel);
235
+ hash.update("\0");
236
+ hash.update(String(statSync(path).mode & 0o777));
237
+ hash.update("\0");
238
+ hash.update(content);
239
+ hash.update("\0");
240
+ }
241
+ return hash.digest("hex");
242
+ }
243
+
244
+ // True only when `candidateDir` bears the receipt emitted by this workflow for
245
+ // the exact local source. A content-only historical match is deliberately not
246
+ // enough: it must still publish so the restored artifact becomes latest.
247
+ export function matchesReleaseRepair(localDir, candidateDir) {
248
+ try {
249
+ const receipt = JSON.parse(readFileSync(join(candidateDir, RELEASE_REPAIR_RECEIPT), "utf8"));
250
+ const localVersion = JSON.parse(readFileSync(join(localDir, "package.json"), "utf8")).version;
251
+ const sourceIdentity = releaseRepairIdentity(localDir);
252
+ return receipt.schema_version === 1
253
+ && receipt.source_version === localVersion
254
+ && receipt.source_identity === sourceIdentity
255
+ && releaseRepairIdentity(candidateDir) === sourceIdentity;
256
+ } catch {
257
+ return false;
258
+ }
259
+ }
260
+
167
261
  // CLI: `node cli-publish-equal.mjs <localPackageDir> <publishedPackageDir>`.
168
- // Exit 0 = equivalent (safe skip); exit 3 = drift (must fail the publish job).
262
+ // Exit 0 = equivalent (safe skip); exit 3 = drift (must allocate a new version).
169
263
  if (import.meta.url === `file://${process.argv[1]}`) {
170
264
  const args = process.argv.slice(2);
171
265
  // `--gt <next> <current>`: exit 0 iff stable `next` > stable `current`.
@@ -192,6 +286,60 @@ if (import.meta.url === `file://${process.argv[1]}`) {
192
286
  process.exit(2);
193
287
  }
194
288
  }
289
+ // `--next-version <local> <unique>` reads the registry's JSON versions list
290
+ // on stdin and prints an absent semver suitable for a drifted package.
291
+ if (args[0] === "--next-version") {
292
+ const [, local, unique] = args;
293
+ try {
294
+ const input = JSON.parse(readFileSync(0, "utf8"));
295
+ const versions = Array.isArray(input) ? input : [input];
296
+ console.log(nextUnpublishedVersion(local, versions, unique));
297
+ process.exit(0);
298
+ } catch (err) {
299
+ console.error(String(err.message ?? err));
300
+ process.exit(2);
301
+ }
302
+ }
303
+ if (args[0] === "--repair-identity") {
304
+ const [, packageDir] = args;
305
+ if (!packageDir) {
306
+ console.error("usage: publish-equal.mjs --repair-identity <packageDir>");
307
+ process.exit(2);
308
+ }
309
+ try {
310
+ console.log(releaseRepairIdentity(packageDir));
311
+ process.exit(0);
312
+ } catch (err) {
313
+ console.error(String(err.message ?? err));
314
+ process.exit(2);
315
+ }
316
+ }
317
+ if (args[0] === "--is-release-repair") {
318
+ const [, localDir, candidateDir] = args;
319
+ if (!localDir || !candidateDir) {
320
+ console.error("usage: publish-equal.mjs --is-release-repair <localPackageDir> <candidatePackageDir>");
321
+ process.exit(2);
322
+ }
323
+ process.exit(matchesReleaseRepair(localDir, candidateDir) ? 0 : 3);
324
+ }
325
+ // `--ignore-version <localPackageDir> <publishedPackageDir>` compares the
326
+ // shipped package while disregarding only package.json's version. The
327
+ // workflow uses this after confirmed drift to recognize an earlier
328
+ // release-time repair of the same source tarball on a rerun.
329
+ if (args[0] === "--ignore-version") {
330
+ const [, localDir, pubDir] = args;
331
+ if (!localDir || !pubDir) {
332
+ console.error("usage: publish-equal.mjs --ignore-version <localPackageDir> <publishedPackageDir>");
333
+ process.exit(2);
334
+ }
335
+ const result = packagesEquivalent(localDir, pubDir, { ignoreVersion: true });
336
+ if (result.equal) {
337
+ console.log("identical except package version");
338
+ process.exit(0);
339
+ }
340
+ console.error("drift: " + result.reason);
341
+ process.exit(3);
342
+ }
195
343
  const [localDir, pubDir] = args;
196
344
  if (!localDir || !pubDir) {
197
345
  console.error("usage: publish-equal.mjs <localPackageDir> <publishedPackageDir> | --gt <next> <current>");