staysfixed 0.3.0 → 0.4.0

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.
Files changed (47) hide show
  1. package/README.md +534 -402
  2. package/package.json +8 -3
  3. package/src/cli/index.js +14 -0
  4. package/src/v2/adapters/android-driver.js +1705 -0
  5. package/src/v2/adapters/android.js +1117 -0
  6. package/src/v2/adapters/contract.js +565 -0
  7. package/src/v2/adapters/electron.js +1594 -0
  8. package/src/v2/adapters/http.js +733 -0
  9. package/src/v2/adapters/ios-driver.js +1551 -0
  10. package/src/v2/adapters/ios.js +989 -0
  11. package/src/v2/adapters/isolate.js +739 -0
  12. package/src/v2/adapters/process.js +920 -0
  13. package/src/v2/adapters/source.js +1241 -0
  14. package/src/v2/adapters/web-driver.js +1532 -0
  15. package/src/v2/adapters/web.js +1009 -0
  16. package/src/v2/adapters/windows.js +1329 -0
  17. package/src/v2/browsers.js +1203 -0
  18. package/src/v2/cause.js +364 -0
  19. package/src/v2/check.js +1331 -0
  20. package/src/v2/ci.js +1209 -0
  21. package/src/v2/cli.js +657 -0
  22. package/src/v2/cluster.js +372 -0
  23. package/src/v2/coverage.js +1116 -0
  24. package/src/v2/detect.js +1199 -0
  25. package/src/v2/doctor.js +1690 -0
  26. package/src/v2/escalate.js +679 -0
  27. package/src/v2/init.js +1394 -0
  28. package/src/v2/intent.js +659 -0
  29. package/src/v2/journeys/from-routes.js +498 -0
  30. package/src/v2/journeys/from-suite.js +988 -0
  31. package/src/v2/journeys/index.js +651 -0
  32. package/src/v2/journeys/record.js +516 -0
  33. package/src/v2/mcp/server.js +374 -0
  34. package/src/v2/mcp/tools.js +1571 -0
  35. package/src/v2/normalise.js +783 -0
  36. package/src/v2/observation.js +877 -0
  37. package/src/v2/rank.js +672 -0
  38. package/src/v2/reference.js +1051 -0
  39. package/src/v2/remote.js +911 -0
  40. package/src/v2/run.js +964 -0
  41. package/src/v2/sealed.js +564 -0
  42. package/src/v2/selfcheck.js +564 -0
  43. package/src/v2/ship.js +684 -0
  44. package/src/v2/store.js +703 -0
  45. package/src/v2/types.js +503 -0
  46. package/src/v2/waiver.js +511 -0
  47. package/src/watch/panel.js +73 -44
package/src/v2/ship.js ADDED
@@ -0,0 +1,684 @@
1
+ /**
2
+ * The hook. One line in a release script, and a person never approves anything again.
3
+ *
4
+ * THE WHOLE IDEA IN ONE PARAGRAPH. Every regression tool ever built asks somebody to say
5
+ * what "working" looks like, and then quietly rots when nobody keeps saying it. This one
6
+ * takes the answer from an act the person already performs for their own reasons: shipping.
7
+ * The build that went out is, by definition, the build they were happy with. So the moment
8
+ * a release happens, this file writes that down, and from then on every check compares
9
+ * against it. Nobody opens the tool. Nobody approves a picture. Nobody sees a list.
10
+ *
11
+ * THREE PROMISES THIS FILE KEEPS, and the third is the one that matters most.
12
+ *
13
+ * IT IS SAFE TO CALL FROM ANYWHERE — a release script, a git hook, an npm lifecycle
14
+ * script, an agent skill. It takes no arguments it cannot work out for itself.
15
+ *
16
+ * IT IS IDEMPOTENT. A hook that fires on the tag and again on the push, or a release
17
+ * script re-run after a network failure, records one release. It does not retire a
18
+ * second round of waivers or write a second entry that makes the history read as two
19
+ * releases where there was one.
20
+ *
21
+ * IT NEVER FAILS A RELEASE. Not on a missing store, not on a broken config, not on a
22
+ * build it cannot find, not on a refusal, not on an exception it did not expect. A tool
23
+ * that blocks somebody's ship because it could not record something has made their day
24
+ * worse, which is the exact opposite of the point of this phase. Everything it could not
25
+ * do comes back as sentences in the result, and the caller decides whether to read them.
26
+ * `--strict` exists for somebody who genuinely wants the opposite, and it is off.
27
+ *
28
+ * WHAT IT REFUSES TO DO ANYWAY. It will not make a build the standard when that build was
29
+ * never checked, or was checked and found broken. The refusal is not an error — the release
30
+ * still succeeds, and the summary says the reference did NOT move and why. That combination
31
+ * is deliberate: shipping is the person's call and never the tool's, but calling a broken
32
+ * build "working" would poison every check that follows, so it declines and says so.
33
+ *
34
+ * WHAT IT DETECTS RATHER THAN BEING TOLD. A new git tag on the current commit, a version
35
+ * bump in package.json, an npm publish it was called from. It says which of those it saw,
36
+ * because a hook that silently guesses wrong is worse than one that asks.
37
+ */
38
+
39
+ import fsp from 'node:fs/promises';
40
+ import path from 'node:path';
41
+ import { execFile } from 'node:child_process';
42
+ import { promisify } from 'node:util';
43
+
44
+ import { EXIT, messageOf } from '../core/errors.js';
45
+ import { say, warn, ok, blank, heading, setLogLevel } from '../core/log.js';
46
+ import { findConfigFile, rootForConfig } from '../core/paths.js';
47
+ import { openStore, ensureStore, listBuilds } from './store.js';
48
+ import { cutReference, shouldCut, referenceHistory, currentReference } from './reference.js';
49
+
50
+ const exec = promisify(execFile);
51
+
52
+ /** @typedef {import('./types.js').Store} Store */
53
+ /** @typedef {import('./types.js').BuildFingerprint} BuildFingerprint */
54
+ /** @typedef {import('./reference.js').ReferenceCut} ReferenceCut */
55
+ /** @typedef {import('./reference.js').CutDecision} CutDecision */
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Shapes
59
+ // ---------------------------------------------------------------------------
60
+
61
+ /**
62
+ * What the hook worked out had just been released.
63
+ *
64
+ * @typedef {object} Release
65
+ * @property {'told'|'npm-publish'|'git-tag'|'version-bump'|'commit'|'unknown'} how
66
+ * @property {string} what What to call this release: 'v0.13.0', 'a1b2c3d'.
67
+ * @property {string} [version]
68
+ * @property {string} [tag]
69
+ * @property {string|null} [gitSha]
70
+ * @property {string|null} [branch]
71
+ * @property {boolean} [dirty] The working tree had uncommitted changes when it shipped.
72
+ * @property {string} describe One plain sentence: what was detected, and how.
73
+ */
74
+
75
+ /**
76
+ * What `onShip` hands back. Always. It never throws.
77
+ *
78
+ * @typedef {object} ShipResult
79
+ * @property {boolean} ok Nothing went wrong. NOT the same as "a reference was cut".
80
+ * @property {boolean} cut The reference actually moved.
81
+ * @property {boolean} unchanged This build was already the standard; nothing to do.
82
+ * @property {string} product
83
+ * @property {string} root
84
+ * @property {Release} [release]
85
+ * @property {string} [buildId]
86
+ * @property {ReferenceCut} [reference]
87
+ * @property {CutDecision} [decision]
88
+ * @property {string} [refused] Why the reference did not move, in full.
89
+ * @property {string} [error] Something unexpected. The release is still fine.
90
+ * @property {string[]} warnings
91
+ * @property {string} summary ONE line, for the closing summary he already reads.
92
+ * @property {string[]} lines The longer version, still plain English.
93
+ */
94
+
95
+ /**
96
+ * What somebody has to paste into their own release script to make this work.
97
+ *
98
+ * @typedef {object} WiringAdvice
99
+ * @property {string} line The one line. This is the answer to the question.
100
+ * @property {string} npmScript For a package.json that publishes to npm.
101
+ * @property {string} gitHook For somebody who tags releases by hand.
102
+ * @property {string} agent For an agent's own release skill.
103
+ * @property {string} explain Why it goes there, in two sentences, for a person.
104
+ */
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // The hook
108
+ // ---------------------------------------------------------------------------
109
+
110
+ /**
111
+ * Record that a build shipped, and make it the new definition of working.
112
+ *
113
+ * @param {object} [opts]
114
+ * @param {string} [opts.root] Project folder. Defaults to where we were started.
115
+ * @param {string} [opts.product] Which product shipped. One repo can build five.
116
+ * @param {string|BuildFingerprint} [opts.build] The exact build, when the caller knows it.
117
+ * @param {string} [opts.note] What the release was, in a person's words.
118
+ * @param {string} [opts.why] The same thing under the name the rest of the tool uses.
119
+ * @param {string} [opts.version] Tell it the version instead of letting it detect one.
120
+ * @param {string} [opts.tag] Tell it the tag instead of letting it detect one.
121
+ * @param {string} [opts.setBy] Who did it: 'ship-everywhere', 'staysfixed ship', a person.
122
+ * @param {boolean} [opts.force] Cut past a refusal. It goes on the record as forced.
123
+ * @param {string} [opts.at] ISO, for recording a release that already happened.
124
+ * @returns {Promise<ShipResult>}
125
+ */
126
+ export async function onShip(opts = {}) {
127
+ const root = projectRoot(opts.root);
128
+ /** @type {string[]} */
129
+ const warnings = [];
130
+
131
+ /** @type {ShipResult} */
132
+ const result = {
133
+ ok: true,
134
+ cut: false,
135
+ unchanged: false,
136
+ product: '',
137
+ root,
138
+ warnings,
139
+ summary: '',
140
+ lines: [],
141
+ };
142
+
143
+ try {
144
+ const product = opts.product ?? (await productName(root));
145
+ result.product = product;
146
+
147
+ const release = await detectRelease({ root, version: opts.version, tag: opts.tag, build: opts.build });
148
+ result.release = release;
149
+
150
+ if (release.dirty) {
151
+ warnings.push(
152
+ 'This release was made from a working tree with uncommitted changes, so what shipped and what is in git are not the same thing. The reference points at what was actually checked.'
153
+ );
154
+ }
155
+
156
+ const store = openStore({ root });
157
+ await ensureStore(store);
158
+
159
+ const build = await resolveBuild(store, product, release, opts.build);
160
+ if (!build) {
161
+ result.cut = false;
162
+ result.lines = [
163
+ `${product} ${release.describe}`,
164
+ `Stays Fixed has no record of this build, so it did not become the reference. Nothing about ${product} is being compared against anything yet.`,
165
+ 'Run `staysfixed check` once before the next release and it will record itself from then on.',
166
+ ];
167
+ result.summary = `${product} shipped ${release.what}, but Stays Fixed had never seen this build, so what "working" means has not moved. Run a check before the next release.`;
168
+ return result;
169
+ }
170
+
171
+ result.buildId = build.id;
172
+
173
+ const decision = await shouldCut(store, product, build);
174
+ result.decision = decision;
175
+
176
+ if (!decision.ok && opts.force !== true) {
177
+ result.cut = false;
178
+ result.refused = decision.refusal ?? decision.why;
179
+ result.lines = [
180
+ `${product} ${release.describe}`,
181
+ `The reference did NOT move. ${result.refused}`,
182
+ 'Your release is unaffected — this only decides what future checks compare against.',
183
+ ];
184
+ result.summary = `${product} shipped ${release.what}. What "working" means did NOT move: ${decision.why} Future checks still compare against the previous reference.`;
185
+ return result;
186
+ }
187
+
188
+ const cut = await cutReference(store, {
189
+ product,
190
+ build,
191
+ why: opts.why ?? opts.note ?? release.describe,
192
+ setBy: opts.setBy ?? 'staysfixed ship',
193
+ force: opts.force === true,
194
+ at: opts.at,
195
+ });
196
+ result.reference = cut;
197
+ result.cut = cut.unchanged !== true;
198
+ result.unchanged = cut.unchanged === true;
199
+
200
+ if (cut.unchanged) {
201
+ result.lines = [
202
+ `${product} ${release.describe}`,
203
+ `That build was already what ${product} calls working, so nothing moved and no waivers were retired. Recording a release twice is safe.`,
204
+ ];
205
+ result.summary = `${product} ${release.what} was already the reference — nothing changed.`;
206
+ return result;
207
+ }
208
+
209
+ result.lines = [
210
+ `${product} ${release.describe}`,
211
+ cut.summary,
212
+ // The summary already carries one sentence about steadiness. The long form only
213
+ // earns its place when it says something the summary cannot: that some journeys
214
+ // ran only once, so part of this reference has no steadiness record behind it.
215
+ ...(cut.stability.measuredJourneys < cut.stability.journeys ? [cut.stability.note] : []),
216
+ 'Nobody has to approve anything. The next check compares against this.',
217
+ ];
218
+ result.summary = cut.summary;
219
+ return result;
220
+ } catch (e) {
221
+ // The one thing this function may never do is take a release down with it. Whatever
222
+ // went wrong, it goes back as words and the caller carries on shipping.
223
+ result.ok = false;
224
+ result.error = messageOf(e);
225
+ result.lines = [
226
+ `Stays Fixed could not record this release: ${result.error}`,
227
+ 'Your release is unaffected. What this means is that future checks are still comparing against the previous reference, so a regression introduced by this build would be reported rather than adopted — the safe direction.',
228
+ ];
229
+ result.summary = `Stays Fixed could not record this release (${result.error}). Nothing about the release is affected; future checks still compare against the previous reference.`;
230
+ return result;
231
+ }
232
+ }
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // What just shipped?
236
+ // ---------------------------------------------------------------------------
237
+
238
+ /**
239
+ * Work out what was released, rather than being told.
240
+ *
241
+ * The order is by how much each signal actually means. Somebody who names the version means
242
+ * it. An npm publish is unambiguous. A tag on the current commit is what almost everybody's
243
+ * release does. A version bump in the last commit is the fallback for people who tag later.
244
+ * And if none of that is there, this says so plainly instead of pretending it knows.
245
+ *
246
+ * @param {{root: string, version?: string, tag?: string, build?: string|BuildFingerprint}} opts
247
+ * @returns {Promise<Release>}
248
+ */
249
+ export async function detectRelease(opts) {
250
+ const root = opts.root;
251
+ const sha = await git(root, ['rev-parse', 'HEAD']);
252
+ const branch = await git(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
253
+ // The tool's own store lives at .staysfixed/ and is usually untracked, so a plain
254
+ // `git status --porcelain` calls every single release dirty and the warning stops meaning
255
+ // anything. What matters is whether the PRODUCT had uncommitted changes.
256
+ const status = await git(root, ['status', '--porcelain']);
257
+ const changed = (status ?? '')
258
+ .split('\n')
259
+ .map((line) => line.slice(3).trim())
260
+ .filter((file) => file !== '' && !file.startsWith('.staysfixed/') && file !== '.staysfixed');
261
+ const dirty = changed.length > 0;
262
+ const short = sha ? sha.slice(0, 7) : null;
263
+
264
+ /** @type {Release} */
265
+ const base = {
266
+ how: 'unknown',
267
+ what: short ?? 'this build',
268
+ describe: '',
269
+ };
270
+ if (sha) base.gitSha = sha;
271
+ if (branch && branch !== 'HEAD') base.branch = branch;
272
+ if (dirty) base.dirty = true;
273
+
274
+ // 1 — told.
275
+ if (opts.tag || opts.version || (opts.build && typeof opts.build !== 'string' && opts.build.version)) {
276
+ const version = opts.version ?? (typeof opts.build === 'object' ? opts.build.version : undefined);
277
+ const what = opts.tag ?? version ?? base.what;
278
+ return {
279
+ ...base,
280
+ how: 'told',
281
+ what,
282
+ ...(version ? { version } : {}),
283
+ ...(opts.tag ? { tag: opts.tag } : {}),
284
+ describe: `released ${what} — you told it so.`,
285
+ };
286
+ }
287
+
288
+ // 2 — npm publish. npm sets these while running a lifecycle script, so a `postpublish`
289
+ // hook knows exactly what it is without being passed anything.
290
+ const lifecycle = process.env.npm_lifecycle_event ?? '';
291
+ const npmCommand = process.env.npm_command ?? '';
292
+ const npmVersion = process.env.npm_package_version;
293
+ if (npmCommand === 'publish' || lifecycle === 'postpublish' || lifecycle === 'publish') {
294
+ const version = npmVersion ?? (await packageVersion(root)) ?? undefined;
295
+ return {
296
+ ...base,
297
+ how: 'npm-publish',
298
+ what: version ? `v${version}` : base.what,
299
+ ...(version ? { version } : {}),
300
+ describe: `was published to npm${version ? ` as ${version}` : ''}.`,
301
+ };
302
+ }
303
+
304
+ // 3 — a tag on this exact commit. The commonest shape of a real release.
305
+ const tags = sha ? await git(root, ['tag', '--points-at', sha]) : null;
306
+ const tag = tags ? tags.split('\n').map((t) => t.trim()).filter(Boolean).sort().pop() : null;
307
+ if (tag) {
308
+ const version = (await packageVersion(root)) ?? undefined;
309
+ return {
310
+ ...base,
311
+ how: 'git-tag',
312
+ what: tag,
313
+ tag,
314
+ ...(version ? { version } : {}),
315
+ describe: `was tagged ${tag}${short ? ` on commit ${short}` : ''}.`,
316
+ };
317
+ }
318
+
319
+ // 4 — the last commit changed the version in package.json.
320
+ const bumped = await versionBump(root);
321
+ if (bumped) {
322
+ return {
323
+ ...base,
324
+ how: 'version-bump',
325
+ what: `v${bumped.to}`,
326
+ version: bumped.to,
327
+ describe: `had its version bumped from ${bumped.from} to ${bumped.to} in the last commit${short ? ` (${short})` : ''}.`,
328
+ };
329
+ }
330
+
331
+ // 5 — nothing that looks like a release. Say so; do not invent one.
332
+ const version = (await packageVersion(root)) ?? undefined;
333
+ return {
334
+ ...base,
335
+ how: 'commit',
336
+ what: short ?? 'this build',
337
+ ...(version ? { version } : {}),
338
+ describe: short
339
+ ? `shipped at commit ${short}. There was no new tag, no version bump and no npm publish to go on, so this is being recorded as the release because somebody said it shipped.`
340
+ : 'shipped. This is not a git repository, so there is nothing to name the release by beyond the fact that somebody said so.',
341
+ };
342
+ }
343
+
344
+ /**
345
+ * Did the last commit change the version in package.json?
346
+ * @param {string} root
347
+ * @returns {Promise<{from: string, to: string}|null>}
348
+ */
349
+ async function versionBump(root) {
350
+ const now = await packageVersion(root);
351
+ if (!now) return null;
352
+ const before = await git(root, ['show', 'HEAD~1:package.json']);
353
+ if (!before) return null;
354
+ try {
355
+ const parsed = JSON.parse(before);
356
+ const was = typeof parsed?.version === 'string' ? parsed.version : null;
357
+ if (!was || was === now) return null;
358
+ return { from: was, to: now };
359
+ } catch {
360
+ return null;
361
+ }
362
+ }
363
+
364
+ // ---------------------------------------------------------------------------
365
+ // Which stored build is the one that shipped?
366
+ // ---------------------------------------------------------------------------
367
+
368
+ /**
369
+ * Find the build in the store that the release corresponds to.
370
+ *
371
+ * The store is keyed by what was actually observed, and the release is described by git. The
372
+ * join between them is the commit: a clean tree is stored as `git-<sha>`, so the same commit
373
+ * checked yesterday and shipped today is one build.
374
+ *
375
+ * It deliberately does NOT fall back to "the newest build of this product". That would make
376
+ * the reference point at whatever was last walked — often a scratch edit from twenty minutes
377
+ * ago — and be indistinguishable from working correctly. When it cannot make the join it
378
+ * says so and cuts nothing.
379
+ *
380
+ * @param {Store} store
381
+ * @param {string} product
382
+ * @param {Release} release
383
+ * @param {string|BuildFingerprint} [told]
384
+ * @returns {Promise<BuildFingerprint|null>}
385
+ */
386
+ async function resolveBuild(store, product, release, told) {
387
+ const builds = await listBuilds(store, { product });
388
+
389
+ if (told) {
390
+ const id = typeof told === 'string' ? told : told.id;
391
+ const hit = builds.find((b) => b.fingerprint.id === id);
392
+ if (hit) return hit.fingerprint;
393
+ return typeof told === 'string' ? { id: told, product } : told;
394
+ }
395
+
396
+ const sha = release.gitSha;
397
+ if (sha) {
398
+ const sameCommit = builds.filter((b) => b.fingerprint.gitSha === sha);
399
+ // A clean checkout of the commit beats a build made from the same commit with edits on
400
+ // top: the second one is not what shipped.
401
+ const clean = sameCommit.find((b) => !b.fingerprint.dirty);
402
+ if (clean) return clean.fingerprint;
403
+ if (sameCommit.length > 0) return sameCommit[0].fingerprint;
404
+
405
+ const byId = builds.find((b) => b.fingerprint.id === `git-${sha.slice(0, 12)}`);
406
+ if (byId) return byId.fingerprint;
407
+
408
+ // We know the commit and nothing in the store was built from it. Matching on the version
409
+ // number instead would be a guess, and it is the guess that goes wrong: a release commit
410
+ // usually carries the version that was ALREADY in package.json, so the version match
411
+ // would happily hand back the previous build and the reference would never move. Found
412
+ // by a smoke test that shipped a second commit and was told it was already the standard.
413
+ return null;
414
+ }
415
+
416
+ // No git at all — a tarball, a copied folder, a build machine with no history. Then the
417
+ // version is the only join there is, and saying so is better than refusing outright.
418
+ if (release.version) {
419
+ const byVersion = builds.find((b) => b.fingerprint.version === release.version || b.fingerprint.version === `v${release.version}`);
420
+ if (byVersion) return byVersion.fingerprint;
421
+ }
422
+
423
+ return null;
424
+ }
425
+
426
+ // ---------------------------------------------------------------------------
427
+ // Working out where we are, without being told
428
+ // ---------------------------------------------------------------------------
429
+
430
+ /**
431
+ * @param {string} [from]
432
+ * @returns {string}
433
+ */
434
+ function projectRoot(from) {
435
+ const start = path.resolve(from ?? process.cwd());
436
+ const config = findConfigFile(start);
437
+ return config ? rootForConfig(config) : start;
438
+ }
439
+
440
+ /**
441
+ * Which product is this? The settings file first, because one repo builds five things and
442
+ * only the settings file knows what they are called.
443
+ *
444
+ * @param {string} root
445
+ * @returns {Promise<string>}
446
+ */
447
+ async function productName(root) {
448
+ const configFile = findConfigFile(root);
449
+ if (configFile && configFile.endsWith('.json')) {
450
+ try {
451
+ const parsed = JSON.parse(await fsp.readFile(configFile, 'utf8'));
452
+ if (typeof parsed?.product === 'string' && parsed.product) return parsed.product;
453
+ } catch {
454
+ // A settings file nobody can parse is somebody else's problem to report. Falling
455
+ // through to the package name keeps the release recorded either way.
456
+ }
457
+ }
458
+ const pkg = await packageJson(root);
459
+ if (typeof pkg?.name === 'string' && pkg.name) return pkg.name;
460
+ return path.basename(root);
461
+ }
462
+
463
+ /**
464
+ * @param {string} root
465
+ * @returns {Promise<Record<string, any>|null>}
466
+ */
467
+ async function packageJson(root) {
468
+ try {
469
+ return JSON.parse(await fsp.readFile(path.join(root, 'package.json'), 'utf8'));
470
+ } catch {
471
+ return null;
472
+ }
473
+ }
474
+
475
+ /**
476
+ * @param {string} root
477
+ * @returns {Promise<string|null>}
478
+ */
479
+ async function packageVersion(root) {
480
+ const pkg = await packageJson(root);
481
+ return typeof pkg?.version === 'string' ? pkg.version : null;
482
+ }
483
+
484
+ /**
485
+ * Read-only git, and every call is allowed to fail: this tool works in folders that are not
486
+ * repositories, it just cannot name the release as precisely there.
487
+ *
488
+ * @param {string} cwd
489
+ * @param {string[]} args
490
+ * @returns {Promise<string|null>}
491
+ */
492
+ async function git(cwd, args) {
493
+ try {
494
+ const { stdout } = await exec('git', args, { cwd, timeout: 10_000, maxBuffer: 8 * 1024 * 1024 });
495
+ return stdout.trim();
496
+ } catch {
497
+ return null;
498
+ }
499
+ }
500
+
501
+ // ---------------------------------------------------------------------------
502
+ // The one line a stranger has to paste
503
+ // ---------------------------------------------------------------------------
504
+
505
+ /**
506
+ * What to put in your own release script, written for somebody who has never seen this tool.
507
+ *
508
+ * This exists because the design says the tool must describe itself to whoever installs it.
509
+ * A hook nobody knows how to install is a feature that does not exist, and "read the docs"
510
+ * is not an answer when the reader is an agent wiring this up for a person who is not a
511
+ * programmer.
512
+ *
513
+ * @param {{root?: string, product?: string}} [opts]
514
+ * @returns {WiringAdvice}
515
+ */
516
+ export function wiringAdvice(opts = {}) {
517
+ const product = opts.product ? ` --product ${shellArg(opts.product)}` : '';
518
+ return {
519
+ line: `npx staysfixed ship${product}`,
520
+ npmScript: `"postpublish": "npx staysfixed ship${product} || true"`,
521
+ gitHook: [
522
+ '# .git/hooks/post-tag is not a thing, so put it where you actually tag:',
523
+ `git tag -a "$VERSION" -m "$VERSION" && git push --tags && npx staysfixed ship${product} --why "$VERSION"`,
524
+ ].join('\n'),
525
+ agent: `After the release goes out, run: npx staysfixed ship${product} --why "<what you shipped>"`,
526
+ explain: [
527
+ 'Put that line at the END of whatever you already run to release — after the upload, the publish or the tag push, so it only records builds that really went out.',
528
+ 'It never fails: if it cannot work something out it prints a sentence and exits 0, so it can never be the reason a release stops.',
529
+ ].join(' '),
530
+ };
531
+ }
532
+
533
+ /**
534
+ * @param {string} value
535
+ * @returns {string}
536
+ */
537
+ function shellArg(value) {
538
+ return /^[A-Za-z0-9._@\/-]+$/.test(value) ? value : `'${value.split("'").join(`'\\''`)}'`;
539
+ }
540
+
541
+ // ---------------------------------------------------------------------------
542
+ // The command line
543
+ // ---------------------------------------------------------------------------
544
+
545
+ /**
546
+ * `staysfixed ship`, in exactly the shape `src/cli/index.js` already uses, so wiring it in
547
+ * is one merge:
548
+ *
549
+ * import { SHIP_COMMANDS } from '../v2/ship.js';
550
+ * Object.assign(COMMANDS, SHIP_COMMANDS);
551
+ *
552
+ * @type {Record<string, {summary: string, usage: string, describe: string, options: [string,string][], examples: string[], spec: {booleans?: string[], strings?: string[], arrays?: string[]}, load: () => Promise<{run: (ctx: any) => Promise<number>}>}>}
553
+ */
554
+ export const SHIP_COMMANDS = {
555
+ ship: {
556
+ summary: 'Say a build went out. That build becomes what "working" means from now on.',
557
+ usage: 'staysfixed ship [--product <name>] [--why "<what you shipped>"] [--json]',
558
+ describe:
559
+ 'Run this at the end of your release script, after the thing has actually gone out.\nThe build you shipped becomes the standard every later check is compared against,\nso nobody ever has to open this tool and approve anything.\n\nIt works out what was released on its own — a new git tag, a version bump, an npm\npublish — and says which of those it saw. It never fails your release: if it cannot\nwork something out it says so in plain English and exits 0.\n\nIt will refuse to make a build the standard if that build was never checked, or was\nchecked and found broken. Your release still succeeds; it just tells you that what\n"working" means did not move, and why. Cutting a broken build as the standard is how\na safety net turns into a rubber stamp.',
560
+ options: [
561
+ ['--product <name>', 'Which product shipped. One repository can build five of them.'],
562
+ ['--why "<text>"', 'What the release was, in your own words. It is kept with the reference.'],
563
+ ['--build <id>', 'The exact build that shipped, when you already know its id.'],
564
+ ['--version <v>', 'Name the version instead of letting it detect one.'],
565
+ ['--tag <tag>', 'Name the tag instead of letting it detect one.'],
566
+ ['--force', 'Cut the reference past a refusal. It is recorded as forced, with the refusal beside it.'],
567
+ ['--history', 'Print every reference ever cut for this product and change nothing.'],
568
+ ['--wire-up', 'Print the one line to paste into your own release script.'],
569
+ ['--strict', 'Exit non-zero when the reference did not move. Off by default, on purpose.'],
570
+ ['--json', 'The whole answer as one JSON object. This is what an agent reads.'],
571
+ ],
572
+ examples: [
573
+ 'staysfixed ship',
574
+ 'staysfixed ship --why "0.14.0 to TestFlight"',
575
+ 'staysfixed ship --product terminaldeck-ios',
576
+ 'staysfixed ship --history',
577
+ 'staysfixed ship --wire-up',
578
+ ],
579
+ spec: {
580
+ booleans: ['json', 'force', 'history', 'wire-up', 'strict'],
581
+ strings: ['product', 'why', 'build', 'version', 'tag'],
582
+ },
583
+ load: async () => ({ run }),
584
+ },
585
+ };
586
+
587
+ /**
588
+ * `staysfixed ship`.
589
+ *
590
+ * The exit code is 0 unless somebody asked for `--strict`. That is not sloppiness: this
591
+ * command is designed to sit at the end of a release script, and a release script that
592
+ * stops because a bookkeeping tool was unhappy is a worse product than one that does not.
593
+ *
594
+ * @param {import('../cli/index.js').CliContext} ctx
595
+ * @returns {Promise<number>}
596
+ */
597
+ export async function run(ctx) {
598
+ const asJson = ctx.bool('json');
599
+ if (asJson) setLogLevel({ quiet: true });
600
+
601
+ const root = projectRoot(ctx.cwd);
602
+
603
+ if (ctx.bool('wire-up')) {
604
+ const advice = wiringAdvice({ root, product: ctx.str('product') });
605
+ if (asJson) {
606
+ process.stdout.write(JSON.stringify(advice, null, 2) + '\n');
607
+ return EXIT.ok;
608
+ }
609
+ heading('Put this at the end of your release script');
610
+ say(` ${advice.line}`);
611
+ blank();
612
+ say(advice.explain);
613
+ blank();
614
+ say('If you publish to npm, this does it for you:');
615
+ say(` ${advice.npmScript}`);
616
+ return EXIT.ok;
617
+ }
618
+
619
+ if (ctx.bool('history')) return await printHistory(ctx, root, asJson);
620
+
621
+ const result = await onShip({
622
+ root,
623
+ product: ctx.str('product'),
624
+ why: ctx.str('why'),
625
+ build: ctx.str('build'),
626
+ version: ctx.str('version'),
627
+ tag: ctx.str('tag'),
628
+ force: ctx.bool('force'),
629
+ setBy: 'staysfixed ship',
630
+ });
631
+
632
+ if (asJson) {
633
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
634
+ } else {
635
+ // The headline is always `summary`, and it is always the one line somebody would
636
+ // paste into a closing summary. Everything else follows it, once, in order.
637
+ if (result.cut) ok(result.summary);
638
+ else if (result.unchanged) say(result.summary);
639
+ else warn(result.summary);
640
+ for (const line of result.lines) {
641
+ if (line !== result.summary) say(line);
642
+ }
643
+ for (const line of result.warnings) warn(line);
644
+ }
645
+
646
+ if (!ctx.bool('strict')) return EXIT.ok;
647
+ if (!result.ok) return EXIT.error;
648
+ return result.cut || result.unchanged ? EXIT.ok : EXIT.failed;
649
+ }
650
+
651
+ /**
652
+ * Every reference ever cut, newest first — which is how a regression gets traced back to
653
+ * the release that introduced it.
654
+ *
655
+ * @param {import('../cli/index.js').CliContext} ctx
656
+ * @param {string} root
657
+ * @param {boolean} asJson
658
+ * @returns {Promise<number>}
659
+ */
660
+ async function printHistory(ctx, root, asJson) {
661
+ const store = openStore({ root });
662
+ const product = ctx.str('product') ?? (await productName(root));
663
+ const history = await referenceHistory(store, product, { includeArchive: true });
664
+ const current = await currentReference(store, product);
665
+
666
+ if (asJson) {
667
+ process.stdout.write(JSON.stringify({ product, current: current?.pointer ?? null, history }, null, 2) + '\n');
668
+ return EXIT.ok;
669
+ }
670
+
671
+ if (history.length === 0) {
672
+ warn(`${product} has never had a reference cut, so there is nothing to compare any build against yet.`);
673
+ say('Ship once with `staysfixed ship` at the end of your release script and the next check has a standard to work from.');
674
+ return EXIT.ok;
675
+ }
676
+
677
+ heading(`What ${product} has called working`);
678
+ for (const cut of history) {
679
+ const marker = current?.pointer.buildId === cut.buildId ? '→ ' : ' ';
680
+ say(`${marker}${cut.at.slice(0, 16).replace('T', ' ')} ${cut.build?.version ?? cut.buildId}${cut.forced ? ' (FORCED)' : ''}`);
681
+ say(` ${cut.summary}`);
682
+ }
683
+ return EXIT.ok;
684
+ }