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/init.js ADDED
@@ -0,0 +1,1394 @@
1
+ /**
2
+ * `staysfixed init` — setting this up for somebody who is not the person who wrote it.
3
+ *
4
+ * The owner said the requirement in one sentence: *some people will not have the device
5
+ * access, so they will have to prepare it — the readme explains it to the AI, so the AI can
6
+ * explain to the person: you need this and you need that.* Everything in this file follows
7
+ * from that.
8
+ *
9
+ * So this command is written for an AGENT installing the tool into a stranger's repository,
10
+ * and it holds itself to one rule: **never make a person do what the machine can do.** It
11
+ * reads the project (detect.js), it reads the machine (doctor.js), it works out every setting
12
+ * it can work out, it writes them down with an explanation beside each one, and then it sorts
13
+ * everything still in the way into four buckets and hands the person only the last two:
14
+ *
15
+ * 1. ready — it works. Nobody hears about it.
16
+ * 2. the agent can fix this — a package to install, an app to build, a browser to
17
+ * download. The agent runs the command and never mentions it.
18
+ * 3. only a person can do this — a licence, a device, a password, a real customer id, a
19
+ * pair of hands. Said in plain words, with what it unlocks.
20
+ * 4. not possible here — no command on this machine changes the answer. Say the
21
+ * nearest honest alternative and stop offering it.
22
+ *
23
+ * THREE THINGS IT WILL NOT DO.
24
+ *
25
+ * It never overwrites. A settings file that is already there is somebody's work, and this
26
+ * command will not touch it without being asked twice. What it would have written comes back
27
+ * in the result instead, so an agent can merge the parts that are missing.
28
+ *
29
+ * It never demands journeys. A tool that opens with "now list the twenty things your app
30
+ * does" gets closed. Every journey it can find on its own — the commands in package.json, the
31
+ * routes and channels read out of the source, the pages read out of folder names, the test
32
+ * suite already sitting there — is proposed, filled in and explained. What is left is a short
33
+ * list with a reason on each item.
34
+ *
35
+ * And it never reports readiness it has not got. A project where only the website can be
36
+ * checked is genuinely useful, and it must say "this covers your website; your iPhone app is
37
+ * not being checked and here is why" rather than printing a tick and letting somebody believe
38
+ * something wider.
39
+ */
40
+
41
+ import path from 'node:path';
42
+ import fsp from 'node:fs/promises';
43
+ import { existsSync } from 'node:fs';
44
+
45
+ import { EXIT, messageOf } from '../core/errors.js';
46
+ import { say, ok, warn, fail, blank, heading, paint, mark, shortPath, setLogLevel } from '../core/log.js';
47
+ import { CONFIG_NAMES, DEFAULT_DIR, GITIGNORE_LINES, findConfigFile, rootForConfig } from '../core/paths.js';
48
+ import { PRODUCT_KINDS, detectProject } from './detect.js';
49
+
50
+ /** @typedef {import('./detect.js').ProjectShape} ProjectShape */
51
+ /** @typedef {import('./detect.js').Product} Product */
52
+ /** @typedef {import('./doctor.js').Capabilities} Capabilities */
53
+ /** @typedef {import('./doctor.js').SurfaceState} SurfaceState */
54
+
55
+ /**
56
+ * Who has to act on one thing that is in the way.
57
+ *
58
+ * The word is in the object rather than worked out later from a sentence, because everything
59
+ * downstream — what the agent silently clears, what reaches a person, what is dropped as
60
+ * hopeless — turns on this one field, and a downstream guess about it would eventually be
61
+ * wrong in the direction of bothering somebody.
62
+ *
63
+ * @typedef {'the agent'|'a person'|'nobody'} WhoFixes
64
+ */
65
+
66
+ /**
67
+ * One thing standing between this project and being checked.
68
+ *
69
+ * @typedef {object} Need
70
+ * @property {string} what Plain English, short: 'the built desktop app'.
71
+ * @property {string} why Why it is needed, in one sentence a non-programmer follows.
72
+ * @property {string} unlocks What becomes possible once it is there.
73
+ * @property {string} fix The exact command, or the exact words to say to a person.
74
+ * @property {WhoFixes} who
75
+ * @property {string} [product] Which product this is about, when it is about one.
76
+ * @property {string} [topic] What it is ABOUT, in one word: 'data', 'start', 'app',
77
+ * 'samples', 'identity', 'browser'. Two needs on one topic are
78
+ * one need said twice — doctor asks "what is missing on this
79
+ * machine", this file asks "what is missing from these
80
+ * settings", and on a server with no snapshot both answer.
81
+ */
82
+
83
+ /**
84
+ * One journey this project could walk, and where it came from.
85
+ *
86
+ * @typedef {object} Proposed
87
+ * @property {string} name
88
+ * @property {string} what One plain sentence.
89
+ * @property {string} from 'package.json', 'the source', 'the page folders', 'your own tests'.
90
+ * @property {string} surface
91
+ * @property {boolean} automatic True when nothing has to be written down for it to happen.
92
+ * @property {number} [howMany] When this stands for many journeys of one shape.
93
+ * @property {boolean} ready False when something in `needs` has to land first.
94
+ */
95
+
96
+ /**
97
+ * What one product's situation adds up to.
98
+ *
99
+ * @typedef {object} Readiness
100
+ * @property {string} product
101
+ * @property {string} kind
102
+ * @property {string} surface
103
+ * @property {SurfaceState} state
104
+ * @property {string} summary One plain sentence.
105
+ * @property {Need[]} needs
106
+ * @property {string} [instead] Only on 'not possible here': the nearest honest alternative.
107
+ */
108
+
109
+ /**
110
+ * Everything init worked out, with nothing written yet.
111
+ *
112
+ * @typedef {object} InitPlan
113
+ * @property {string} root
114
+ * @property {ProjectShape} project
115
+ * @property {Readiness[]} readiness
116
+ * @property {Proposed[]} journeys
117
+ * @property {{agent: Need[], person: Need[], impossible: Need[]}} needs
118
+ * @property {{file: string, exists: boolean, format: 'mjs'|'js'|'json', text: string, why: string}} config
119
+ * @property {{covered: string[], partly: string[], notCovered: string[], short: string}} covers
120
+ * @property {{mcp: Record<string, unknown>, next: {command: string, what: string}[]}} wiring
121
+ * @property {string} summary One paragraph, safe to repeat to a person word for word.
122
+ */
123
+
124
+ /**
125
+ * What init actually did.
126
+ *
127
+ * @typedef {object} InitResult
128
+ * @property {InitPlan} plan
129
+ * @property {string[]} written Files created, absolute.
130
+ * @property {string[]} kept Files left exactly as they were, absolute.
131
+ * @property {boolean} ok False only when something was asked for and could not be done.
132
+ * @property {string[]} problems
133
+ */
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Working it all out
137
+ // ---------------------------------------------------------------------------
138
+
139
+ /**
140
+ * Work out everything, write nothing.
141
+ *
142
+ * Separate from {@link init} on purpose: an agent that wants to know what would happen, a
143
+ * `--dry-run`, and the real thing all have to agree, and the only way to guarantee that is
144
+ * for the real thing to call this and then write down what it says.
145
+ *
146
+ * @param {object} [options]
147
+ * @param {string} [options.cwd]
148
+ * @param {boolean} [options.offline] Do not dial other machines while reading this one.
149
+ * @param {boolean} [options.readCode] Read the source for routes and channels. Default true.
150
+ * @returns {Promise<InitPlan>}
151
+ */
152
+ export async function plan(options = {}) {
153
+ const cwd = path.resolve(options.cwd ?? process.cwd());
154
+ const existing = findConfigFile(cwd);
155
+ const root = existing ? rootForConfig(existing) : cwd;
156
+
157
+ const project = await detectProject({ root, readCode: options.readCode });
158
+ const machine = await readMachine({ cwd: root, offline: options.offline });
159
+
160
+ const readiness = readinessFor(project, machine);
161
+ const journeys = proposeJourneys(project);
162
+ const needs = sortNeeds(readiness, project, machine);
163
+ const config = await planConfig(root, project, existing);
164
+ const covers = whatItCovers(readiness);
165
+
166
+ return {
167
+ root,
168
+ project,
169
+ readiness,
170
+ journeys,
171
+ needs,
172
+ config,
173
+ covers,
174
+ wiring: {
175
+ mcp: { mcpServers: { staysfixed: { command: 'npx', args: ['-y', 'staysfixed', 'mcp'], cwd: root } } },
176
+ next: nextCommands(readiness, project),
177
+ },
178
+ summary: `${project.summary} ${covers.short}`,
179
+ };
180
+ }
181
+
182
+ /**
183
+ * Work it out and write it down.
184
+ *
185
+ * @param {object} [options]
186
+ * @param {string} [options.cwd]
187
+ * @param {boolean} [options.offline]
188
+ * @param {boolean} [options.readCode]
189
+ * @param {boolean} [options.dryRun] Work everything out and write nothing.
190
+ * @param {boolean} [options.force] Overwrite a settings file that is already there.
191
+ * @param {boolean} [options.gitignore] Add the throwaway folders to .gitignore. Default true.
192
+ * @returns {Promise<InitResult>}
193
+ */
194
+ export async function init(options = {}) {
195
+ const made = await plan(options);
196
+ /** @type {string[]} */
197
+ const written = [];
198
+ /** @type {string[]} */
199
+ const kept = [];
200
+ /** @type {string[]} */
201
+ const problems = [];
202
+
203
+ if (options.dryRun) {
204
+ return { plan: made, written, kept, ok: true, problems };
205
+ }
206
+
207
+ // The settings file. Never over the top of one that is already there — what would have
208
+ // been written is in `plan.config.text` either way, so nothing is lost by refusing.
209
+ if (made.config.exists && !options.force) {
210
+ kept.push(made.config.file);
211
+ } else {
212
+ try {
213
+ await fsp.mkdir(path.dirname(made.config.file), { recursive: true });
214
+ await fsp.writeFile(made.config.file, made.config.text, 'utf8');
215
+ written.push(made.config.file);
216
+ } catch (error) {
217
+ problems.push(`The settings could not be written to ${made.config.file}: ${messageOf(error)}`);
218
+ }
219
+ }
220
+
221
+ // The ignore lines. Appending lines that are not there is not overwriting, and leaving a
222
+ // folder of run evidence to turn up in somebody's next commit is a real nuisance — but it
223
+ // is still somebody else's file, so only the missing lines are added and the result says so.
224
+ if (options.gitignore !== false) {
225
+ const added = await addIgnoreLines(made.root);
226
+ if (added.file) (added.changed ? written : kept).push(added.file);
227
+ if (added.problem) problems.push(added.problem);
228
+ }
229
+
230
+ return { plan: made, written, kept, ok: problems.length === 0, problems };
231
+ }
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // The machine
235
+ // ---------------------------------------------------------------------------
236
+
237
+ /**
238
+ * What this machine can drive, asked of `doctor` so there is one answer to that question in
239
+ * the whole tool rather than two that disagree.
240
+ *
241
+ * It is allowed to fail. Init has to work on a machine where a probe hangs or a browser
242
+ * survey throws, and the honest degradation is "nothing is known about this machine", which
243
+ * makes every surface a person's problem rather than silently a ready one.
244
+ *
245
+ * @param {{cwd: string, offline?: boolean}} opts
246
+ * @returns {Promise<Capabilities|null>}
247
+ */
248
+ async function readMachine(opts) {
249
+ try {
250
+ const { capabilities } = await import('./doctor.js');
251
+ return await capabilities({ cwd: opts.cwd, offline: opts.offline });
252
+ } catch {
253
+ return null;
254
+ }
255
+ }
256
+
257
+ /** Which of doctor's surfaces answers for one of detect's products. */
258
+ const SURFACE_FOR_PRODUCT = /** @type {Record<string, string>} */ ({
259
+ cli: 'cli', library: 'cli', server: 'server', web: 'web', electron: 'electron',
260
+ ios: 'ios', android: 'android', windows: 'windows',
261
+ });
262
+
263
+ /**
264
+ * What each product's situation adds up to: who has to act, and what they have to do.
265
+ *
266
+ * @param {ProjectShape} project
267
+ * @param {Capabilities|null} machine
268
+ * @returns {Readiness[]}
269
+ */
270
+ function readinessFor(project, machine) {
271
+ /** @type {Readiness[]} */
272
+ const out = [];
273
+
274
+ for (const product of project.products) {
275
+ const kind = PRODUCT_KINDS[product.kind];
276
+ const surface = machine?.surfaces.find((s) => s.id === SURFACE_FOR_PRODUCT[product.surface]) ?? null;
277
+
278
+ // No adapter means no argument. This is the fourth state, and it is the one that has to
279
+ // be said rather than dressed up: there is nothing to install and nothing to ask for.
280
+ //
281
+ // The product's own `adapter` is read, never the static table: whether an adapter exists
282
+ // is worked out by looking at the folder, so the day the iOS one lands this stops saying
283
+ // no without anybody editing this file — and until it lands, an iPhone app is never
284
+ // reported as ready just because the simulator happens to be installed.
285
+ if (product.adapter === null) {
286
+ out.push({
287
+ product: product.name,
288
+ kind: product.kind,
289
+ surface: product.surface,
290
+ state: 'not possible here',
291
+ summary: `${sentenceCase(product.name)} is not being checked. ${kind.what}`,
292
+ needs: [],
293
+ instead: insteadFor(product, project),
294
+ });
295
+ continue;
296
+ }
297
+
298
+ const mine = productNeeds(product, project);
299
+ /** @type {Need[]} */
300
+ const needs = [...mine, ...machineNeeds(surface, product, mine)];
301
+
302
+ if (needs.length === 0) {
303
+ out.push({
304
+ product: product.name,
305
+ kind: product.kind,
306
+ surface: product.surface,
307
+ state: 'ready',
308
+ summary: `${sentenceCase(product.name)} can be checked here now. ${kind.what}`,
309
+ needs,
310
+ });
311
+ continue;
312
+ }
313
+
314
+ const everythingIsACommand = needs.every((need) => need.who === 'the agent');
315
+ out.push({
316
+ product: product.name,
317
+ kind: product.kind,
318
+ surface: product.surface,
319
+ state: everythingIsACommand ? 'the agent can fix this' : 'only a person can do this',
320
+ summary: everythingIsACommand
321
+ ? `${sentenceCase(product.name)} needs ${needs.length === 1 ? 'one thing' : `${needs.length} things`} setting up, and all of it can be done without asking anybody.`
322
+ : `${sentenceCase(product.name)} needs ${needs.filter((n) => n.who === 'a person').length === 1 ? 'one thing' : 'a few things'} only you can supply.`,
323
+ needs,
324
+ });
325
+ }
326
+
327
+ return out;
328
+ }
329
+
330
+ /**
331
+ * The nearest honest alternative for a product nothing here can drive.
332
+ *
333
+ * This is the sentence that stops a limitation reading as a refusal. A phone app whose
334
+ * screens cannot be read is still sharing code with something that CAN be checked, and
335
+ * saying where the cover actually is beats saying no.
336
+ *
337
+ * @param {Product} product
338
+ * @param {ProjectShape} project
339
+ * @returns {string}
340
+ */
341
+ function insteadFor(product, project) {
342
+ const sharedCode = project.products.some((p) => PRODUCT_KINDS[p.kind].adapter !== null);
343
+ const shared = sharedCode
344
+ ? ' The code this repository shares between its products IS checked, and shared code is where most breaks start — so a change that breaks the phone very often shows up there first.'
345
+ : '';
346
+ switch (product.kind) {
347
+ case 'ios':
348
+ return `An iPhone app can only be driven on the simulator, and this copy of the tool has no iOS adapter in it. Two builds on a real phone in your hand can never be compared side by side, on any machine, ever.${shared}`;
349
+ case 'android':
350
+ return `An Android app is driven on an emulator, and this copy of the tool has no Android adapter in it.${shared}`;
351
+ case 'desktopNative':
352
+ return `A native window can only be read from the operating system it runs on, and this copy of the tool has nothing that drives one.${shared}`;
353
+ case 'other':
354
+ return `${sentenceCase(product.name)} is in a language nothing here drives. If it also produces a command you can type, list that under "process" in the settings and the whole command-line half of this tool works on it today.${shared}`;
355
+ default:
356
+ return `This copy of the tool has nothing in it that drives ${product.name}.${shared}`;
357
+ }
358
+ }
359
+
360
+ /**
361
+ * What this particular product is short of, from what was actually found on disk.
362
+ *
363
+ * Built from facts rather than from the sentences detect wrote, because who has to fix a
364
+ * thing is a decision and a decision should be made once, here, where it can be read.
365
+ *
366
+ * @param {Product} product
367
+ * @param {ProjectShape} project
368
+ * @returns {Need[]}
369
+ */
370
+ function productNeeds(product, project) {
371
+ /** @type {Need[]} */
372
+ const needs = [];
373
+ const suggest = product.suggest ?? {};
374
+
375
+ if (product.kind === 'electron') {
376
+ if (!product.built.found) {
377
+ needs.push({
378
+ what: 'the built desktop app',
379
+ why: 'A desktop app is checked by opening the built copy, not the source. There is no built copy here yet.',
380
+ unlocks: 'the window, its menus, every private channel it registers, and everything it writes',
381
+ fix: `Build it the way this project normally does${project.scripts.build ? ` — \`${project.scripts.build}\`${project.scripts.package ? ` then \`${project.scripts.package}\`` : ''}` : ''}, then set electron.binary in the settings to the result.`,
382
+ who: 'the agent',
383
+ product: product.name,
384
+ topic: 'app',
385
+ });
386
+ }
387
+ needs.push({
388
+ what: 'the name of the setting this app uses to know who it is',
389
+ why: 'If the app registers itself somewhere with a device id, two runs claiming the same id would fight over the same slot — and that fight looks exactly like a bug in the product.',
390
+ unlocks: 'running the old build and the new one safely, one after the other',
391
+ fix: 'Look through the main process for an environment variable holding a device or machine id, and put {"identityEnv": {"THAT_VARIABLE": "{identity}"}} under "electron" in the settings. If the app has no such thing, delete the line and nothing is lost.',
392
+ who: 'the agent',
393
+ product: product.name,
394
+ topic: 'identity',
395
+ });
396
+ }
397
+
398
+ if (product.kind === 'web') {
399
+ if (!suggest.start) {
400
+ const hasDev = Boolean(project.scripts.dev);
401
+ needs.push({
402
+ what: 'a command that starts the site',
403
+ why: 'Each build has to be booted on its own. One address can only ever serve one build, so without a command both halves of the comparison read the same running copy and prove nothing.',
404
+ unlocks: 'a real comparison between the build you shipped and the build you have',
405
+ fix: hasDev
406
+ ? `Put {"start": "${project.scripts.dev}"} under "web" in the settings, and make sure it listens on the PORT it is given.`
407
+ : 'This is a site made of files rather than a program, so put a static server under "web" in the settings — anything that serves this folder on the PORT it is given, such as {"start": "npx --yes serve -l $PORT ."}.',
408
+ who: 'the agent',
409
+ product: product.name,
410
+ topic: 'start',
411
+ });
412
+ }
413
+ const needing = project.pages.filter((p) => p.needs.length > 0);
414
+ if (needing.length > 0) {
415
+ const names = [...new Set(needing.flatMap((p) => p.needs))];
416
+ needs.push({
417
+ what: `a real value for ${names.slice(0, 3).map((n) => `"${n}"`).join(', ')}${names.length > 3 ? ' and others' : ''}`,
418
+ why: `${needing.length} page address${needing.length === 1 ? '' : 'es'} ${needing.length === 1 ? 'has' : 'have'} a part that changes — an id, a slug — and only somebody who knows the data knows a value that really exists.`,
419
+ unlocks: `opening ${needing.length === 1 ? 'that page' : 'those pages'} at all, instead of reporting ${needing.length === 1 ? 'it' : 'them'} as never looked at`,
420
+ fix: `Put {"samples": {${names.slice(0, 3).map((n) => `"${n}": "a real one"`).join(', ')}}} under "web" in the settings — one value per name, and it must be a record that exists.`,
421
+ who: 'a person',
422
+ product: product.name,
423
+ topic: 'samples',
424
+ });
425
+ }
426
+ }
427
+
428
+ if (product.kind === 'server') {
429
+ if (!suggest.start) {
430
+ needs.push({
431
+ what: 'the command that starts the server',
432
+ why: 'The routes can be listed by reading the code, but none of them can be asked anything until something is listening.',
433
+ unlocks: `walking ${project.doors.route > 0 ? `all ${project.doors.route} routes` : 'every route'} and seeing what each one quietly does while answering`,
434
+ fix: 'Put {"start": "..."} under "http" in the settings, and have it listen on the PORT it is given.',
435
+ who: project.scripts.start ? 'the agent' : 'a person',
436
+ product: product.name,
437
+ topic: 'start',
438
+ });
439
+ }
440
+ needs.push({
441
+ what: 'a way to put the data back how it was',
442
+ why: 'Both builds have to see the same rows. Without that, the second run sees whatever the first one wrote, and every difference after the first write means nothing.',
443
+ unlocks: 'comparing two builds fairly instead of comparing two different sets of data',
444
+ fix: 'Put {"restore": "..."} under "http" in the settings: a command that resets the database or the data folder to a known state. It must not be one that destroys data it cannot rebuild — a command that looks destructive is refused rather than run.',
445
+ who: 'a person',
446
+ product: product.name,
447
+ topic: 'data',
448
+ });
449
+ const withParts = project.routes.filter((route) => /:[A-Za-z_$]|\[[^\]]+\]|\{[^}]+\}/.test(route.name));
450
+ if (withParts.length > 0) {
451
+ const names = [...new Set(withParts.flatMap((route) => [...route.name.matchAll(/:([A-Za-z_$][\w$]*)|\[\.{0,3}([^\]]+)\]|\{([^}]+)\}/g)].map((hit) => hit[1] ?? hit[2] ?? hit[3])))];
452
+ needs.push({
453
+ what: `a real value for ${names.slice(0, 3).map((n) => `"${n}"`).join(', ')}${names.length > 3 ? ' and others' : ''}`,
454
+ why: `${count(withParts.length, 'route has', 'routes have')} a part that changes — an id, a slug — and only somebody who knows the data knows a value that really exists.`,
455
+ unlocks: `asking ${withParts.length === 1 ? 'that route' : 'those routes'} anything at all, instead of reporting ${withParts.length === 1 ? 'it' : 'them'} as never looked at`,
456
+ fix: `Put {"samples": {${names.slice(0, 3).map((n) => `"${n}": "a real one"`).join(', ')}}} under "http" in the settings — one value per name, and it must be a record that exists.`,
457
+ who: 'a person',
458
+ product: product.name,
459
+ topic: 'samples',
460
+ });
461
+ }
462
+ }
463
+
464
+ if (product.kind === 'server' || product.kind === 'web') {
465
+ const risky = riskyRoutes(project);
466
+ if (risky.length > 0) {
467
+ needs.push({
468
+ what: risky.length === 1
469
+ ? 'a yes or no on one route that looks like it does something that cannot be undone'
470
+ : `a yes or no on ${risky.length} routes that look like they do something that cannot be undone`,
471
+ why: risky.length === 1
472
+ ? `${risky[0].name} is named like something that spends money, sends a message or deletes data. The name is only a guess — only you know what it really does.`
473
+ : `${risky.slice(0, 3).map((r) => r.name).join(', ')}${risky.length > 3 ? ' and others' : ''} are named like things that spend money, send a message or delete data. The names are only a guess — only you know what they really do.`,
474
+ unlocks: 'walking every other route without worrying, and watching these ones ask without letting them succeed',
475
+ fix: `They have been written into the settings under "irreversible" already. Take out any that are actually safe, and add any that are not on the list. A route listed there is watched at the moment it asks and stopped before anything happens.`,
476
+ who: 'a person',
477
+ product: product.name,
478
+ topic: 'irreversible',
479
+ });
480
+ }
481
+ }
482
+
483
+ return needs;
484
+ }
485
+
486
+ /**
487
+ * Routes whose NAME says they do something that cannot be taken back.
488
+ *
489
+ * A guess, and it says so. Guessing wrong in this direction costs a route being watched
490
+ * rather than walked; guessing wrong in the other direction costs somebody real money. The
491
+ * asymmetry is the whole argument for erring towards the list.
492
+ *
493
+ * @param {ProjectShape} project
494
+ * @returns {ProjectShape['routes']}
495
+ */
496
+ export function riskyRoutes(project) {
497
+ const dangerous = /(charge|payment|pay\b|refund|invoice|subscribe|checkout|purchase|order\b|billing|send|email|sms|notify|message|invite|publish|deploy|delete|destroy|purge|drop|wipe|reset|migrate)/i;
498
+ return project.routes.filter((route) => dangerous.test(route.name) || (route.method !== 'GET' && route.method !== 'HEAD' && dangerous.test(route.name)));
499
+ }
500
+
501
+ /**
502
+ * What this MACHINE is short of for one product, taken straight from doctor so the two can
503
+ * never say different things. `automatic` is doctor's word for "no person needs to hear
504
+ * about this", and it maps onto who has to act without any interpretation.
505
+ *
506
+ * @param {import('./doctor.js').SurfaceReport|null} surface
507
+ * @param {Product} product
508
+ * @param {Need[]} covered What this file has already said about the same product.
509
+ * @returns {Need[]}
510
+ */
511
+ function machineNeeds(surface, product, covered) {
512
+ if (!surface) {
513
+ return [{
514
+ what: 'a look at this machine',
515
+ why: 'The check of what is installed here did not finish, so nothing is known about what this machine can drive.',
516
+ unlocks: 'knowing which of your products can actually be checked here',
517
+ fix: 'staysfixed doctor',
518
+ who: 'the agent',
519
+ product: product.name,
520
+ }];
521
+ }
522
+ const already = new Set(covered.map((need) => need.topic).filter(Boolean));
523
+ return surface.needs
524
+ // Doctor answers "what is missing on this machine right now", and right now is before
525
+ // this command has written anything. A need whose whole fix is "run staysfixed init",
526
+ // asked while staysfixed init is running and already holding the value, is not a need —
527
+ // it is an echo, and repeating it back at somebody is how a set-up list never empties.
528
+ .filter((need) => !(/staysfixed init/.test(need.fix) && alreadyAnswered(product)))
529
+ .map((need) => ({
530
+ what: need.what,
531
+ why: need.why,
532
+ unlocks: `checking ${product.name} the way it is meant to be checked`,
533
+ fix: need.fix,
534
+ who: /** @type {WhoFixes} */ (need.automatic ? 'the agent' : 'a person'),
535
+ product: product.name,
536
+ topic: topicOf(need.what + ' ' + need.fix),
537
+ }))
538
+ .filter((need) => !(need.topic && already.has(need.topic)));
539
+ }
540
+
541
+ /**
542
+ * What a sentence is ABOUT, in one word, so the same problem said two ways is said once.
543
+ * Deliberately a short list: a topic nothing recognises stays undefined and nothing is
544
+ * merged, which is the safe direction — a repeated line is untidy, a swallowed one is a lie.
545
+ *
546
+ * @param {string} text
547
+ * @returns {string|undefined}
548
+ */
549
+ function topicOf(text) {
550
+ const words = text.toLowerCase();
551
+ if (/snapshot|restore|database|the same data|data folder/.test(words)) return 'data';
552
+ if (/starts it|command that starts|start\b/.test(words)) return 'start';
553
+ if (/built app|app\.binary|electron\.binary/.test(words)) return 'app';
554
+ if (/device id|identity|identityenv/.test(words)) return 'identity';
555
+ if (/sample|real value/.test(words)) return 'samples';
556
+ if (/browser|playwright|chromium/.test(words)) return 'browser';
557
+ return undefined;
558
+ }
559
+
560
+ /**
561
+ * Does the settings file this command is about to write already carry what that need asked
562
+ * for? Only the things init genuinely fills in count.
563
+ *
564
+ * @param {Product} product
565
+ * @returns {boolean}
566
+ */
567
+ function alreadyAnswered(product) {
568
+ const suggest = product.suggest ?? {};
569
+ if (product.kind === 'electron') return Boolean(suggest.binary);
570
+ if (product.kind === 'web' || product.kind === 'server') return Boolean(suggest.start);
571
+ return Object.keys(suggest).length > 0;
572
+ }
573
+
574
+ /**
575
+ * Everything in the way, sorted by who has to act, plus the two things that are about the
576
+ * project as a whole rather than about any one product.
577
+ *
578
+ * @param {Readiness[]} readiness
579
+ * @param {ProjectShape} project
580
+ * @param {Capabilities|null} machine
581
+ * @returns {{agent: Need[], person: Need[], impossible: Need[]}}
582
+ */
583
+ function sortNeeds(readiness, project, machine) {
584
+ /** @type {Need[]} */
585
+ const all = [];
586
+
587
+ if (machine && !machine.project.hasReference) {
588
+ all.push({
589
+ what: 'one build on record as working',
590
+ why: 'Until one build has been recorded there is nothing to compare a new one against, and a clean result would mean nothing at all.',
591
+ unlocks: 'every check from then on',
592
+ fix: 'staysfixed check --paired (or ship once with `staysfixed ship` at the end of your release)',
593
+ who: 'the agent',
594
+ });
595
+ }
596
+ if (!project.isGitRepo) {
597
+ all.push({
598
+ what: 'this folder being a git repository',
599
+ why: 'Without it a difference cannot be ranked by how far it sits from the code that changed — which is the whole way an accidental side effect rises to the top of the list.',
600
+ unlocks: 'ranking, and comparing against a tag or a commit',
601
+ fix: 'git init',
602
+ who: 'a person',
603
+ });
604
+ }
605
+
606
+ for (const item of readiness) all.push(...item.needs);
607
+
608
+ /** @type {Need[]} */
609
+ const impossible = readiness
610
+ .filter((r) => r.state === 'not possible here')
611
+ .map((r) => ({
612
+ what: r.product,
613
+ why: r.summary,
614
+ unlocks: 'nothing that any command on this machine can turn on',
615
+ fix: r.instead ?? 'There is nothing to do here.',
616
+ who: 'nobody',
617
+ product: r.product,
618
+ }));
619
+
620
+ return {
621
+ agent: dedupeNeeds(all.filter((n) => n.who === 'the agent')),
622
+ person: dedupeNeeds(all.filter((n) => n.who === 'a person')),
623
+ impossible,
624
+ };
625
+ }
626
+
627
+ /**
628
+ * @param {Need[]} needs
629
+ * @returns {Need[]}
630
+ */
631
+ function dedupeNeeds(needs) {
632
+ /** @type {Map<string, Need>} */
633
+ const seen = new Map();
634
+ for (const need of needs) {
635
+ const key = `${need.what}|${need.fix}`;
636
+ if (!seen.has(key)) seen.set(key, need);
637
+ }
638
+ return [...seen.values()];
639
+ }
640
+
641
+ // ---------------------------------------------------------------------------
642
+ // Journeys, proposed rather than demanded
643
+ // ---------------------------------------------------------------------------
644
+
645
+ /**
646
+ * Everything this project could walk, and where each one came from.
647
+ *
648
+ * Ranked exactly the way the design ranks the sources of a journey: read out of the code
649
+ * first because it is free and exact, then the project's own test suite because somebody
650
+ * already wrote it and already keeps it working, then anything a person would have to write.
651
+ * Nothing here is invented, and nothing here is asked for.
652
+ *
653
+ * @param {ProjectShape} project
654
+ * @returns {Proposed[]}
655
+ */
656
+ export function proposeJourneys(project) {
657
+ /** @type {Proposed[]} */
658
+ const out = [];
659
+
660
+ const doorsFound = project.doors.route + project.doors.ipc + project.doors.export + project.doors.command;
661
+ if (project.doors.read && doorsFound > 0) {
662
+ out.push({
663
+ name: 'the-code',
664
+ what: `read every door out of the source without running any of it — ${count(project.doors.route, 'route', 'routes')}, ${count(project.doors.ipc, 'private channel', 'private channels')}, ${count(project.doors.export, 'exported name', 'exported names')}, ${count(project.doors.command, 'command', 'commands')}`,
665
+ from: 'the source',
666
+ surface: 'any',
667
+ automatic: true,
668
+ ready: true,
669
+ });
670
+ }
671
+
672
+ for (const product of project.products) {
673
+ const suggest = product.suggest ?? {};
674
+ if (product.kind === 'cli' && Array.isArray(suggest.commands)) {
675
+ for (const command of suggest.commands) {
676
+ out.push({
677
+ name: String(command.name),
678
+ what: `run \`${String(command.run)}\` and compare what it printed, what it exited with and every file it touched`,
679
+ from: 'package.json',
680
+ surface: 'cli',
681
+ automatic: false,
682
+ ready: true,
683
+ });
684
+ }
685
+ }
686
+ if (product.kind === 'library' && Array.isArray(suggest.imports)) {
687
+ for (const entry of suggest.imports) {
688
+ out.push({
689
+ name: String(entry.name),
690
+ what: `import ${String(entry.module)} and compare what it exports`,
691
+ from: 'package.json',
692
+ surface: 'library',
693
+ automatic: false,
694
+ ready: true,
695
+ });
696
+ }
697
+ }
698
+ if (product.kind === 'server' && project.doors.route > 0) {
699
+ out.push({
700
+ name: 'every route',
701
+ what: `ask the server for each of the ${project.doors.route} routes written in its own source, and watch what it quietly does while answering`,
702
+ from: 'the source',
703
+ surface: 'server',
704
+ automatic: true,
705
+ howMany: project.doors.route,
706
+ ready: Boolean(suggest.start),
707
+ });
708
+ }
709
+ if (product.kind === 'web') {
710
+ if (project.pages.length > 0) {
711
+ out.push({
712
+ name: 'every page',
713
+ what: `open each of the ${project.pages.length} page addresses read out of the folder names and read what the screen says each control is and does`,
714
+ from: 'the page folders',
715
+ surface: 'web',
716
+ automatic: true,
717
+ howMany: project.pages.length,
718
+ ready: Boolean(suggest.start),
719
+ });
720
+ }
721
+ if (Array.isArray(suggest.screens) && suggest.screens.length > 0) {
722
+ const many = suggest.screens.length;
723
+ out.push({
724
+ name: many === 1 ? 'the page in this folder' : 'the pages in this folder',
725
+ what: many === 1 ? 'open the single HTML file sitting in this folder' : `open each of the ${many} HTML files sitting in this folder`,
726
+ from: 'the folder itself',
727
+ surface: 'web',
728
+ automatic: false,
729
+ howMany: many,
730
+ ready: Boolean(suggest.start),
731
+ });
732
+ }
733
+ }
734
+ if (product.kind === 'android' && product.adapter === 'android') {
735
+ out.push({
736
+ name: 'open-the-app',
737
+ what: 'install the app on an emulator of its own, open it, read what the screen says every control is and does, then take it off again',
738
+ from: 'the app itself',
739
+ surface: 'android',
740
+ automatic: true,
741
+ ready: product.built.found,
742
+ });
743
+ }
744
+ if (product.kind === 'desktopNative' && product.adapter === 'windows') {
745
+ out.push({
746
+ name: 'open-the-window',
747
+ what: 'open the app on a Windows machine over ssh and read what its window says every control is and does',
748
+ from: 'the app itself',
749
+ surface: 'windows',
750
+ automatic: true,
751
+ ready: false,
752
+ });
753
+ }
754
+ if (product.kind === 'electron') {
755
+ out.push({
756
+ name: 'open-the-app',
757
+ what: `open the app and read everything it shows and all ${project.doors.ipc} channels it registers`,
758
+ from: 'the source',
759
+ surface: 'electron',
760
+ automatic: true,
761
+ ready: product.built.found,
762
+ });
763
+ }
764
+ }
765
+
766
+ if (project.tests.files > 0) {
767
+ out.push({
768
+ name: 'your own tests',
769
+ what: `run the ${project.tests.files} test file${project.tests.files === 1 ? '' : 's'} this project already has, under instrumentation, and compare what each one saw — journeys somebody already wrote and already keeps working`,
770
+ from: 'your own tests',
771
+ surface: 'any',
772
+ automatic: false,
773
+ howMany: project.tests.files,
774
+ ready: true,
775
+ });
776
+ }
777
+
778
+ return out;
779
+ }
780
+
781
+ // ---------------------------------------------------------------------------
782
+ // The settings file
783
+ // ---------------------------------------------------------------------------
784
+
785
+ /**
786
+ * Where the settings would go, what would be in them, and whether anything is there already.
787
+ *
788
+ * @param {string} root
789
+ * @param {ProjectShape} project
790
+ * @param {string|null} existing
791
+ * @returns {Promise<InitPlan['config']>}
792
+ */
793
+ async function planConfig(root, project, existing) {
794
+ const mine = existing && rootForConfig(existing) === root ? existing : null;
795
+ const pkg = await readJson(path.join(root, 'package.json'));
796
+ // A project that has not said it is ES modules gets a .mjs file, so `export default` works
797
+ // without anybody having to edit their package.json to install a checking tool.
798
+ const format = /** @type {'mjs'|'js'} */ (pkg?.type === 'module' ? 'js' : 'mjs');
799
+ const file = mine ?? path.join(root, `staysfixed.config.${format}`);
800
+ return {
801
+ file,
802
+ exists: mine !== null,
803
+ format: mine ? formatOf(mine) : format,
804
+ text: configText(project),
805
+ why: mine
806
+ ? `There are already settings at ${shortPath(mine)}, and they will not be touched. What would have been written is here in full, so anything missing can be copied across by hand.`
807
+ : `The settings will be written to ${shortPath(file)}, with an explanation beside every option.`,
808
+ };
809
+ }
810
+
811
+ /**
812
+ * @param {string} file
813
+ * @returns {'mjs'|'js'|'json'}
814
+ */
815
+ function formatOf(file) {
816
+ const ext = path.extname(file);
817
+ return ext === '.json' ? 'json' : ext === '.mjs' ? 'mjs' : 'js';
818
+ }
819
+
820
+ /**
821
+ * The settings file, written out.
822
+ *
823
+ * Every option that matters is in here, with a sentence saying what it does and what happens
824
+ * without it. The ones that do not apply to this project are present and commented out rather
825
+ * than left out: a setting somebody cannot see is a setting they will never turn on, and the
826
+ * list of what this tool can be told is exactly the list of what it can be made to check.
827
+ *
828
+ * @param {ProjectShape} project
829
+ * @returns {string}
830
+ */
831
+ export function configText(project) {
832
+ const has = (/** @type {string} */ kind) => project.products.find((p) => p.kind === kind) ?? null;
833
+ const electron = has('electron');
834
+ const web = has('web');
835
+ const server = has('server');
836
+ const cli = has('cli');
837
+ const library = has('library');
838
+
839
+ /** @type {string[]} */
840
+ const out = [];
841
+ const w = (/** @type {string} */ line) => out.push(line);
842
+
843
+ w('/**');
844
+ w(' * Stays Fixed — settings for this project.');
845
+ w(' *');
846
+ w(` * Written by \`staysfixed init\` on ${new Date().toISOString().slice(0, 10)}, from what is actually in this`);
847
+ w(' * repository. Everything below was read out of the code, the folder names and package.json;');
848
+ w(' * nothing was guessed, and nothing was asked.');
849
+ w(' *');
850
+ w(` * WHAT THIS REPOSITORY MAKES: ${project.summary}`);
851
+ w(' *');
852
+ w(' * WHAT THE TOOL DOES WITH THIS FILE. It runs your product through the same steps twice,');
853
+ w(' * compares the result against the build you were last happy with, subtracts anything your');
854
+ w(' * product disagrees with itself about, and reports only what is left. Everything that did');
855
+ w(' * not change is never mentioned.');
856
+ w(' *');
857
+ w(' * EVERY OPTION THAT MATTERS IS IN THIS FILE. The ones that do not apply to this project are');
858
+ w(' * commented out rather than left out, so nothing is hidden from you. Delete freely.');
859
+ w(' */');
860
+ w('');
861
+ w('export default {');
862
+ w(' // The name this record is kept under. Each product keeps its own record of what');
863
+ w(' // "working" means, so the name is how two of them are told apart.');
864
+ if (project.products.length > 1) {
865
+ w(' // This repository makes more than one thing. A check covers whichever of them the');
866
+ w(' // settings below describe; run the others with `staysfixed check --product <name>`.');
867
+ }
868
+ w(` product: ${JSON.stringify(project.name)},`);
869
+ w('');
870
+
871
+ // ── source ────────────────────────────────────────────────────────────────
872
+ w(' // ───────────────────────────────────────────────────────────────────────');
873
+ w(' // Reading the code. Free, exact, runs nothing, and it cannot break anything.');
874
+ w(' // This is the only channel that sees a door nobody has ever opened.');
875
+ w(' // ───────────────────────────────────────────────────────────────────────');
876
+ w(' source: {');
877
+ w(' // Folders to read. Left out, it reads the usual ones: src, lib, app, bin, server,');
878
+ w(' // pages, api, electron, main, packages.');
879
+ w(" // folders: ['src', 'lib'],");
880
+ if (project.doors.read) {
881
+ w(` // Last read: ${project.doors.route} routes, ${project.doors.ipc} private channels, ${project.doors.export} exported names, ${project.doors.command} commands, ${project.doors.env} settings it reads.`);
882
+ }
883
+ w(' },');
884
+ w('');
885
+
886
+ // ── process ───────────────────────────────────────────────────────────────
887
+ w(' // ───────────────────────────────────────────────────────────────────────');
888
+ w(' // Commands and libraries. Each one runs in a throwaway copy of this project —');
889
+ w(' // never your working copy — with the clock stopped and every outbound');
890
+ w(' // connection recorded and then refused.');
891
+ w(' // ───────────────────────────────────────────────────────────────────────');
892
+ w(' process: {');
893
+ w(' // Commands worth running. Nothing is ever guessed here: a guess would mean running');
894
+ w(' // something that deletes files. Add any command whose output you would notice changing.');
895
+ const commands = /** @type {any[]} */ (cli?.suggest?.commands ?? []);
896
+ if (commands.length > 0) {
897
+ w(' commands: [');
898
+ for (const command of commands) {
899
+ w(` { name: ${JSON.stringify(String(command.name))}, run: ${JSON.stringify(String(command.run))}, describe: ${JSON.stringify(String(command.describe ?? ''))} },`);
900
+ }
901
+ w(' ],');
902
+ w(' // Each entry also takes: cwd, stdin, env, timeoutMs, and irreversible: true for a');
903
+ w(' // command that would spend money or send a message — that one is watched asking and');
904
+ w(' // never allowed to ask.');
905
+ } else {
906
+ w(" // commands: [{ name: 'help', run: 'node bin/cli.js --help', describe: 'print the help' }],");
907
+ w(' commands: [],');
908
+ }
909
+ const imports = /** @type {any[]} */ (library?.suggest?.imports ?? []);
910
+ w(' // Modules to import and compare the exports of.');
911
+ if (imports.length > 0) {
912
+ w(' imports: [');
913
+ for (const entry of imports) w(` { name: ${JSON.stringify(String(entry.name))}, module: ${JSON.stringify(String(entry.module))} },`);
914
+ w(' ],');
915
+ } else {
916
+ w(" // imports: [{ name: 'the package entry', module: './src/index.js' }],");
917
+ }
918
+ w(' },');
919
+ w('');
920
+
921
+ // ── http ──────────────────────────────────────────────────────────────────
922
+ w(' // ───────────────────────────────────────────────────────────────────────');
923
+ w(' // Servers and APIs. Every route is read out of your own source, so a route');
924
+ w(' // nobody links to is checked like any other. Booted on a spare port, one');
925
+ w(' // build at a time, never two at once.');
926
+ w(' // ───────────────────────────────────────────────────────────────────────');
927
+ w(server ? ' http: {' : ' // http: {');
928
+ const httpOn = server ? ' ' : ' // ';
929
+ w(`${httpOn}// The command that starts it. It must listen on the PORT it is given.`);
930
+ const httpStart = server?.suggest?.start;
931
+ w(httpStart ? `${httpOn}start: ${JSON.stringify(String(httpStart))},` : `${httpOn}// start: 'npm start',`);
932
+ w(`${httpOn}// A command that puts the data back how it was, so both builds see the same rows.`);
933
+ w(`${httpOn}// Without it the second run sees whatever the first one wrote. A command that looks`);
934
+ w(`${httpOn}// like it destroys data it cannot rebuild is refused rather than run.`);
935
+ w(`${httpOn}// restore: 'npm run db:reset',`);
936
+ w(`${httpOn}// One real value per changing part of a route address. A route with a part nobody`);
937
+ w(`${httpOn}// has given a value for is reported as never looked at, never quietly skipped.`);
938
+ w(`${httpOn}// samples: { id: '1', slug: 'a-real-one' },`);
939
+ w(`${httpOn}// Extra requests the source cannot show — anything needing a body or a header.`);
940
+ w(`${httpOn}// requests: [{ name: 'sign in', method: 'POST', url: '/api/session', body: { email: '...' } }],`);
941
+ w(`${httpOn}// Routes that spend money, send a message or destroy data. Watched at the moment`);
942
+ w(`${httpOn}// they are asked for, and stopped before anything happens.`);
943
+ const risky = riskyRoutes(project);
944
+ if (server && risky.length > 0) {
945
+ w(`${httpOn}// These were picked out by their NAMES, which is a guess. Take out any that are`);
946
+ w(`${httpOn}// really safe; add any that are missing. Erring towards the list costs a route`);
947
+ w(`${httpOn}// being watched instead of walked. Erring the other way costs somebody money.`);
948
+ w(`${httpOn}irreversible: [${risky.map((r) => JSON.stringify(r.name)).join(', ')}],`);
949
+ } else {
950
+ w(`${httpOn}// irreversible: ['/api/charge'],`);
951
+ }
952
+ w(`${httpOn}// Also: env, nodeEnv, startTimeoutMs, watch (folders to notice writes in).`);
953
+ w(server ? ' },' : ' // },');
954
+ w('');
955
+
956
+ // ── web ───────────────────────────────────────────────────────────────────
957
+ w(' // ───────────────────────────────────────────────────────────────────────');
958
+ w(' // Websites. Opened in a throwaway browser with the clock stopped, motion');
959
+ w(' // killed, randomness seeded and the internet cut off. What is compared is');
960
+ w(' // what the screen MEANS — the roles, names and states a screen reader would');
961
+ w(' // read — never the markup, so a restyled page reports nothing at all.');
962
+ w(' // ───────────────────────────────────────────────────────────────────────');
963
+ w(web ? ' web: {' : ' // web: {');
964
+ const webOn = web ? ' ' : ' // ';
965
+ w(`${webOn}// The command that starts it, listening on the PORT it is given. Much better than`);
966
+ w(`${webOn}// an address: one address can only serve one build, so with an address alone both`);
967
+ w(`${webOn}// halves of the comparison read the same running copy and prove nothing.`);
968
+ const webStart = web?.suggest?.start;
969
+ const flatSite = !webStart && Array.isArray(web?.suggest?.screens) && web.suggest.screens.length > 0;
970
+ if (webStart) {
971
+ w(`${webOn}start: ${JSON.stringify(String(webStart))},`);
972
+ } else if (flatSite) {
973
+ w(`${webOn}// This is a site made of files rather than a program, so anything that serves this`);
974
+ w(`${webOn}// folder on the PORT it is given will do. Left commented because it fetches a`);
975
+ w(`${webOn}// package the first time it runs, and that is a decision rather than a default.`);
976
+ w(`${webOn}// start: 'npx --yes serve -l $PORT .',`);
977
+ } else {
978
+ w(`${webOn}// start: 'npm run dev',`);
979
+ }
980
+ w(`${webOn}// Or, if it is already running somewhere and you accept the weaker answer:`);
981
+ w(`${webOn}// url: 'http://localhost:3000',`);
982
+ const screens = /** @type {any[]} */ (web?.suggest?.screens ?? []);
983
+ if (screens.length > 0) {
984
+ w(`${webOn}// The pages to open. These are the HTML files found sitting in this folder.`);
985
+ w(`${webOn}screens: [`);
986
+ for (const screen of screens.slice(0, 40)) {
987
+ w(`${webOn} { name: ${JSON.stringify(String(screen.name))}, url: ${JSON.stringify(String(screen.url))} },`);
988
+ }
989
+ w(`${webOn}],`);
990
+ } else if (project.pages.length > 0) {
991
+ w(`${webOn}// ${project.pages.length} page address${project.pages.length === 1 ? '' : 'es'} are read out of your folder names automatically — nothing to list here.`);
992
+ w(`${webOn}// Add a screen only for something a walk has to DO rather than just open:`);
993
+ w(`${webOn}// screens: [{ name: 'signing in', url: '/login', steps: [{ fill: '#email', with: 'a@b.c' }, { click: 'Sign in' }] }],`);
994
+ } else {
995
+ w(`${webOn}// screens: [{ name: 'the front page', url: '/' }],`);
996
+ }
997
+ w(`${webOn}// One real value per changing part of a page address.`);
998
+ w(`${webOn}// samples: { slug: 'a-real-one' },`);
999
+ w(`${webOn}// Also: viewport { width, height, deviceScaleFactor }, colorScheme, timezone, locale,`);
1000
+ w(`${webOn}// allowHosts (addresses the page is allowed to reach), refuse, allowWrites,`);
1001
+ w(`${webOn}// timeoutMs, settleTimeoutMs, startTimeoutMs, env, nodeEnv, restore, everyStep.`);
1002
+ w(web ? ' },' : ' // },');
1003
+ w('');
1004
+
1005
+ // ── electron ──────────────────────────────────────────────────────────────
1006
+ w(' // ───────────────────────────────────────────────────────────────────────');
1007
+ w(' // Desktop apps. Opened on their own — own settings folder, own ports, own');
1008
+ w(' // name — and read on both sides: the window, and the private channels behind');
1009
+ w(' // it. A channel is only ever ASKED to answer when you name it here, because');
1010
+ w(' // knocking on an unknown door could do anything.');
1011
+ w(' // ───────────────────────────────────────────────────────────────────────');
1012
+ w(electron ? ' electron: {' : ' // electron: {');
1013
+ const elOn = electron ? ' ' : ' // ';
1014
+ w(`${elOn}// The built app. On a Mac that is the .app; on Windows the .exe.`);
1015
+ const binary = electron?.suggest?.binary;
1016
+ w(binary ? `${elOn}binary: ${JSON.stringify(String(binary))},` : `${elOn}// binary: 'release/mac-arm64/Your App.app',`);
1017
+ w(`${elOn}// If your app tells a server who it is, name the setting that carries the id and it`);
1018
+ w(`${elOn}// is given a different one per run. Without this, two runs can claim the same slot`);
1019
+ w(`${elOn}// and fight over it — which looks exactly like a bug in your product.`);
1020
+ w(`${elOn}// identityEnv: { YOUR_APP_DEVICE_ID: '{identity}' },`);
1021
+ w(`${elOn}// Private channels that are safe to ask — read-only ones. Each becomes its own`);
1022
+ w(`${elOn}// journey, so a channel that stops answering is caught, not just one that stops existing.`);
1023
+ w(`${elOn}// exercise: ['settings:read', 'sessions:list'],`);
1024
+ w(`${elOn}// Walks through the window itself.`);
1025
+ w(`${elOn}// journeys: [{ name: 'opening a session', steps: [{ click: 'New session' }] }],`);
1026
+ w(`${elOn}// Also: appId, args, env, windowMatch, startTimeoutMs, settleTries, settleGapMs.`);
1027
+ w(electron ? ' },' : ' // },');
1028
+ w('');
1029
+
1030
+ // ── android ───────────────────────────────────────────────────────────────
1031
+ const android = has('android');
1032
+ const androidHere = android?.adapter === 'android';
1033
+ w(' // ───────────────────────────────────────────────────────────────────────');
1034
+ w(' // Android apps. Installed on an emulator of its own, walked, then removed.');
1035
+ w(' // Compared against the stored record — whether two emulator snapshots come');
1036
+ w(' // back byte for byte is unproven, and a run says which mode it used.');
1037
+ w(' // ───────────────────────────────────────────────────────────────────────');
1038
+ w(androidHere ? ' android: {' : ' // android: {');
1039
+ const andOn = androidHere ? ' ' : ' // ';
1040
+ const apk = android?.suggest?.apk;
1041
+ w(`${andOn}// The built package. Left out, it looks in the usual build folders.`);
1042
+ w(apk ? `${andOn}apk: ${JSON.stringify(String(apk))},` : `${andOn}// apk: 'app/build/outputs/apk/debug/app-debug.apk',`);
1043
+ w(`${andOn}// Which emulator to use. Left out, it takes the first one that is not a Play Store image.`);
1044
+ w(`${andOn}// avd: 'Pixel_7_API_35',`);
1045
+ w(`${andOn}// Or a device already plugged in or already running.`);
1046
+ w(`${andOn}// serial: 'emulator-5554',`);
1047
+ w(`${andOn}// Walks through the app. Left out, it opens the app and reads the first screen.`);
1048
+ w(`${andOn}// journeys: [{ name: 'signing in', steps: [{ tap: 'Sign in' }] }],`);
1049
+ w(`${andOn}// Also: headless (false shows the emulator window), timezone, locale, reset,`);
1050
+ w(`${andOn}// allowTo (addresses the app is allowed to reach), settleTries.`);
1051
+ w(androidHere ? ' },' : ' // },');
1052
+ w('');
1053
+
1054
+ // ── windows ───────────────────────────────────────────────────────────────
1055
+ const windows = has('desktopNative');
1056
+ const windowsHere = windows?.adapter === 'windows';
1057
+ w(' // ───────────────────────────────────────────────────────────────────────');
1058
+ w(' // Native Windows apps. A Windows window can only be read from Windows, so');
1059
+ w(' // this needs a machine running it — an ssh host you already have counts, and');
1060
+ w(' // no VM and no CI account is needed. Two builds can never run at once, because');
1061
+ w(' // Windows only ever shows one desktop, so runs are one after the other.');
1062
+ w(' // ───────────────────────────────────────────────────────────────────────');
1063
+ w(windowsHere ? ' windows: {' : ' // windows: {');
1064
+ const winOn = windowsHere ? ' ' : ' // ';
1065
+ w(`${winOn}// The ssh host that reaches a logged-in Windows desktop.`);
1066
+ w(`${winOn}// host: 'your-windows-box',`);
1067
+ w(`${winOn}// The built .exe here, which is copied over — or one already on that machine.`);
1068
+ w(`${winOn}// exe: 'release/YourApp.exe',`);
1069
+ w(`${winOn}// remoteExe: 'C:\\Users\\you\\YourApp\\YourApp.exe',`);
1070
+ w(`${winOn}// Folders on that machine to watch for files the app writes.`);
1071
+ w(`${winOn}// watchDirs: ['C:\\Users\\you\\AppData\\Roaming\\YourApp'],`);
1072
+ w(`${winOn}// Also: args, cwd, journeys.`);
1073
+ w(windowsHere ? ' },' : ' // },');
1074
+ w('');
1075
+ if (has('ios')) {
1076
+ w(' // There is no "ios" section, and that is not an oversight: this copy of the tool has');
1077
+ w(' // no iOS adapter in it, so a setting here would do nothing. The iPhone app in this');
1078
+ w(' // repository is not being checked, and `staysfixed init` says so every time it runs.');
1079
+ w('');
1080
+ }
1081
+
1082
+ // ── the rest ──────────────────────────────────────────────────────────────
1083
+ w(' // ───────────────────────────────────────────────────────────────────────');
1084
+ w(' // Two things that are NOT settings in this tool, said here so nobody looks:');
1085
+ w(' //');
1086
+ w(' // There is no tolerance, and there never will be. How much your product');
1087
+ w(' // disagrees with itself is MEASURED, by running the new build twice, and');
1088
+ w(' // subtracted. A number you tune by hand is how a tool like this dies: too');
1089
+ w(' // loose to catch the real thing, too tight to leave switched on.');
1090
+ w(' //');
1091
+ w(' // There is nothing to approve. The build you say `staysfixed ship` about');
1092
+ w(' // becomes what "working" means. Nobody opens this tool to bless a picture.');
1093
+ w(' //');
1094
+ w(' // What normalisation hides — a version number in a footer, a timestamp — is');
1095
+ w(' // data too, kept in .staysfixed/rules.json so it can be read in a pull');
1096
+ w(' // request. Each rule has to say what real change it could wrongly hide.');
1097
+ w(' // ───────────────────────────────────────────────────────────────────────');
1098
+ w('};');
1099
+ w('');
1100
+
1101
+ return out.join('\n');
1102
+ }
1103
+
1104
+ // ---------------------------------------------------------------------------
1105
+ // Honest degradation
1106
+ // ---------------------------------------------------------------------------
1107
+
1108
+ /**
1109
+ * What a clean run in this project would actually mean, in the words to repeat to a person.
1110
+ *
1111
+ * This is the paragraph the whole design turns on. A project where only the website can be
1112
+ * checked is useful; a project where only the website can be checked and the report says
1113
+ * "all good" is worse than nothing.
1114
+ *
1115
+ * @param {Readiness[]} readiness
1116
+ * @returns {InitPlan['covers']}
1117
+ */
1118
+ function whatItCovers(readiness) {
1119
+ const covered = readiness.filter((r) => r.state === 'ready').map((r) => r.product);
1120
+ const partly = readiness.filter((r) => r.state === 'the agent can fix this' || r.state === 'only a person can do this').map((r) => r.product);
1121
+ const notCovered = readiness.filter((r) => r.state === 'not possible here').map((r) => r.product);
1122
+
1123
+ /** @type {string[]} */
1124
+ const parts = [];
1125
+ if (covered.length > 0) parts.push(`Right now a check here covers ${plainList(covered)} in full.`);
1126
+ else parts.push('Right now a check here covers nothing in full.');
1127
+ if (partly.length > 0) parts.push(`${plainList(partly, true)} ${partly.length === 1 ? 'is' : 'are'} not covered yet, and the list below says exactly what is in the way and who has to do it.`);
1128
+ if (notCovered.length > 0) parts.push(`${plainList(notCovered, true)} ${notCovered.length === 1 ? 'is' : 'are'} not checked at all, so a clean result says nothing whatever about ${notCovered.length === 1 ? 'it' : 'them'}.`);
1129
+ if (partly.length === 0 && notCovered.length === 0 && covered.length > 0) parts.push('Nothing is being left out.');
1130
+
1131
+ return { covered, partly, notCovered, short: parts.join(' ') };
1132
+ }
1133
+
1134
+ /**
1135
+ * The commands to run next, in order, each with what it is for.
1136
+ *
1137
+ * @param {Readiness[]} readiness
1138
+ * @param {ProjectShape} project
1139
+ * @returns {{command: string, what: string}[]}
1140
+ */
1141
+ function nextCommands(readiness, project) {
1142
+ /** @type {{command: string, what: string}[]} */
1143
+ const next = [];
1144
+ next.push({ command: 'staysfixed doctor --json', what: 'What this machine can and cannot drive, as one object. The first call an agent should make.' });
1145
+ if (readiness.some((r) => r.state === 'ready')) {
1146
+ next.push({ command: 'staysfixed check --paired', what: 'The first real run. It records what working looks like, so later runs have something to compare against.' });
1147
+ }
1148
+ if (project.tests.files > 0) {
1149
+ next.push({ command: 'staysfixed check --journeys suite', what: `Walk the ${project.tests.files} test${project.tests.files === 1 ? '' : 's'} this project already has, under instrumentation.` });
1150
+ }
1151
+ next.push({ command: 'staysfixed check --json', what: 'The everyday run. Only what changed comes back.' });
1152
+ next.push({ command: 'staysfixed ship', what: 'Run this at the end of your release script. The build that went out becomes what "working" means.' });
1153
+ return next;
1154
+ }
1155
+
1156
+ // ---------------------------------------------------------------------------
1157
+ // Words
1158
+ // ---------------------------------------------------------------------------
1159
+
1160
+ /**
1161
+ * The plan, said out loud, short.
1162
+ *
1163
+ * Short is a requirement rather than a preference. Everything here also exists as data in the
1164
+ * plan object, and an agent reads that; a person reads this, and a person reading twenty lines
1165
+ * reads none of them.
1166
+ *
1167
+ * @param {InitPlan} plan
1168
+ * @returns {string[]}
1169
+ */
1170
+ export function describePlan(plan) {
1171
+ /** @type {string[]} */
1172
+ const lines = [];
1173
+ lines.push(plan.project.summary);
1174
+ lines.push('');
1175
+ lines.push(plan.covers.short);
1176
+
1177
+ if (plan.needs.person.length > 0) {
1178
+ lines.push('');
1179
+ lines.push(plan.needs.person.length === 1 ? 'One thing needs you:' : `${plan.needs.person.length} things need you:`);
1180
+ for (const need of plan.needs.person) {
1181
+ lines.push(` ${need.what} — ${need.why} It unlocks ${need.unlocks}.`);
1182
+ lines.push(` ${need.fix}`);
1183
+ }
1184
+ }
1185
+ for (const need of plan.needs.impossible) {
1186
+ lines.push('');
1187
+ lines.push(`Not possible here: ${need.what}. ${need.fix}`);
1188
+ }
1189
+ // Code nothing could account for is the quietest way a run over-claims, so it is said in
1190
+ // the summary a person reads rather than left in the data an agent might not open.
1191
+ if (plan.project.unsure.length > 0) {
1192
+ lines.push('');
1193
+ lines.push('Worth knowing:');
1194
+ for (const doubt of plan.project.unsure) lines.push(` ${doubt}`);
1195
+ }
1196
+ return lines;
1197
+ }
1198
+
1199
+ /**
1200
+ * @param {InitResult} result
1201
+ * @returns {string[]}
1202
+ */
1203
+ export function describeResult(result) {
1204
+ const lines = describePlan(result.plan);
1205
+ lines.push('');
1206
+ if (result.written.length > 0) lines.push(`Written: ${result.written.map((f) => shortPath(f)).join(', ')}.`);
1207
+ if (result.kept.length > 0) lines.push(`Left exactly as it was: ${result.kept.map((f) => shortPath(f)).join(', ')}.`);
1208
+ for (const problem of result.problems) lines.push(problem);
1209
+ return lines;
1210
+ }
1211
+
1212
+ // ---------------------------------------------------------------------------
1213
+ // Small helpers
1214
+ // ---------------------------------------------------------------------------
1215
+
1216
+ /**
1217
+ * Add the throwaway folders to .gitignore, and only the lines that are not there already.
1218
+ *
1219
+ * @param {string} root
1220
+ * @returns {Promise<{file: string|null, changed: boolean, problem?: string}>}
1221
+ */
1222
+ async function addIgnoreLines(root) {
1223
+ const file = path.join(root, '.gitignore');
1224
+ let text = '';
1225
+ try {
1226
+ text = await fsp.readFile(file, 'utf8');
1227
+ } catch {
1228
+ if (!existsSync(path.join(root, '.git'))) return { file: null, changed: false };
1229
+ }
1230
+ const missing = GITIGNORE_LINES.filter((line) => line.startsWith('#') === false && !text.includes(line));
1231
+ if (missing.length === 0) return { file, changed: false };
1232
+ try {
1233
+ const prefix = text === '' || text.endsWith('\n') ? '' : '\n';
1234
+ await fsp.writeFile(file, `${text}${prefix}\n${GITIGNORE_LINES[0]}\n${missing.join('\n')}\n`, 'utf8');
1235
+ return { file, changed: true };
1236
+ } catch (error) {
1237
+ return { file, changed: false, problem: `The ignore list at ${shortPath(file)} could not be added to: ${messageOf(error)}` };
1238
+ }
1239
+ }
1240
+
1241
+ /**
1242
+ * @param {string} file
1243
+ * @returns {Promise<any|null>}
1244
+ */
1245
+ async function readJson(file) {
1246
+ try {
1247
+ return JSON.parse(await fsp.readFile(file, 'utf8'));
1248
+ } catch {
1249
+ return null;
1250
+ }
1251
+ }
1252
+
1253
+ /**
1254
+ * "a, b and c" — a comma-separated list reads like a machine wrote it.
1255
+ * @param {string[]} items
1256
+ * @param {boolean} [capitalise]
1257
+ * @returns {string}
1258
+ */
1259
+ function plainList(items, capitalise = false) {
1260
+ const list = items.length <= 1 ? (items[0] ?? 'nothing') : `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`;
1261
+ return capitalise ? list.charAt(0).toUpperCase() + list.slice(1) : list;
1262
+ }
1263
+
1264
+ /**
1265
+ * "1 route" and "3 routes", so no sentence in this tool ever reads like a machine wrote it.
1266
+ * @param {number} n
1267
+ * @param {string} one
1268
+ * @param {string} many
1269
+ * @returns {string}
1270
+ */
1271
+ function count(n, one, many) {
1272
+ return `${n} ${n === 1 ? one : many}`;
1273
+ }
1274
+
1275
+ /**
1276
+ * @param {string} text
1277
+ * @returns {string}
1278
+ */
1279
+ function sentenceCase(text) {
1280
+ return text.charAt(0).toUpperCase() + text.slice(1);
1281
+ }
1282
+
1283
+ // ---------------------------------------------------------------------------
1284
+ // The command
1285
+ // ---------------------------------------------------------------------------
1286
+
1287
+ /**
1288
+ * `staysfixed init`, in the same shape the rest of the command table uses. Merged into the
1289
+ * front door the same way version 2's other commands are:
1290
+ *
1291
+ * import { INIT_COMMANDS } from '../v2/init.js';
1292
+ * Object.assign(COMMANDS, INIT_COMMANDS);
1293
+ *
1294
+ * @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>}>}>}
1295
+ */
1296
+ export const INIT_COMMANDS = {
1297
+ init: {
1298
+ summary: 'Set this up for this project, working out everything that can be worked out.',
1299
+ usage: 'staysfixed init [--json] [--dry-run] [--force] [--offline]',
1300
+ describe:
1301
+ 'Reads your project — package.json, the folder shapes, the framework config, the built\nartifacts, the test suite, and every route and channel written in the source — and works\nout what it makes. Then it reads this machine and works out what can be driven here.\nThen it writes settings with an explanation beside every option.\n\nIt never overwrites settings that are already there. It never asks you for anything it\ncould work out on its own. And it tells you, in plain words, what is left: what the tool\nwill install itself, what genuinely needs you, and what is not possible here at all.\n\n--json is the whole answer as one object, and it is what an agent installing this should\nread. Everything in the printed version is in there, plus the settings it would write.',
1302
+ options: [
1303
+ ['--json', 'The whole answer as one JSON object and nothing else. For agents.'],
1304
+ ['--dry-run', 'Work everything out and write nothing.'],
1305
+ ['--force', 'Overwrite settings that are already there. Off by default, on purpose.'],
1306
+ ['--offline', 'Do not dial any other machine while looking at this one. Faster.'],
1307
+ ['--no-gitignore', 'Do not add the throwaway folders to .gitignore.'],
1308
+ ['--shallow', 'Do not read the source. Much faster on a very large repository, and it sees less.'],
1309
+ ],
1310
+ examples: ['staysfixed init', 'staysfixed init --json', 'staysfixed init --dry-run'],
1311
+ spec: { booleans: ['json', 'dry-run', 'force', 'offline', 'gitignore', 'shallow'] },
1312
+ load: async () => ({ run }),
1313
+ },
1314
+ };
1315
+
1316
+ /**
1317
+ * `staysfixed init`.
1318
+ *
1319
+ * @param {import('../cli/index.js').CliContext} ctx
1320
+ * @returns {Promise<number>}
1321
+ */
1322
+ export async function run(ctx) {
1323
+ const asJson = ctx.bool('json');
1324
+ // Nothing meant for a person may reach standard output when an agent asked for JSON. One
1325
+ // stray sentence in front of the object is a parse error rather than a warning.
1326
+ if (asJson) setLogLevel({ quiet: true });
1327
+
1328
+ const result = await init({
1329
+ cwd: ctx.cwd,
1330
+ offline: ctx.bool('offline'),
1331
+ readCode: !ctx.bool('shallow'),
1332
+ dryRun: ctx.bool('dry-run'),
1333
+ force: ctx.bool('force'),
1334
+ gitignore: ctx.flags['gitignore'] !== false,
1335
+ });
1336
+
1337
+ if (asJson) {
1338
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
1339
+ return result.ok ? EXIT.ok : EXIT.error;
1340
+ }
1341
+
1342
+ heading('Stays Fixed — setting up for this project');
1343
+ blank();
1344
+ say(result.plan.project.summary);
1345
+ blank();
1346
+
1347
+ for (const item of result.plan.readiness) {
1348
+ if (item.state === 'ready') ok(item.summary);
1349
+ else if (item.state === 'not possible here') fail(item.summary);
1350
+ else warn(item.summary);
1351
+ if (item.state === 'not possible here') {
1352
+ if (item.instead) say(paint.grey(` ${mark.info} ${item.instead}`));
1353
+ continue;
1354
+ }
1355
+ for (const need of item.needs) {
1356
+ say(paint.grey(` ${mark.info} ${need.what} — ${need.who === 'the agent' ? 'the tool can do this itself: ' : 'somebody has to: '}${need.fix}`));
1357
+ }
1358
+ }
1359
+
1360
+ blank();
1361
+ say(paint.grey(` ${mark.info} ${result.plan.covers.short}`));
1362
+ for (const doubt of result.plan.project.unsure) say(paint.grey(` ${mark.info} ${doubt}`));
1363
+
1364
+ if (result.plan.journeys.length > 0) {
1365
+ blank();
1366
+ heading('What it would walk');
1367
+ for (const journey of result.plan.journeys) {
1368
+ say(` ${journey.name}`);
1369
+ say(paint.grey(` ${journey.what} — from ${journey.from}${journey.ready ? '' : ', once the things above are in place'}`));
1370
+ }
1371
+ }
1372
+
1373
+ blank();
1374
+ if (result.written.length > 0) ok(`Written: ${result.written.map((f) => shortPath(f)).join(', ')}`);
1375
+ if (result.kept.length > 0) warn(`Left exactly as it was: ${result.kept.map((f) => shortPath(f)).join(', ')}`);
1376
+ for (const problem of result.problems) fail(problem);
1377
+
1378
+ blank();
1379
+ heading('What to run next');
1380
+ for (const step of result.plan.wiring.next) {
1381
+ say(` ${step.command}`);
1382
+ say(paint.grey(` ${step.what}`));
1383
+ }
1384
+ blank();
1385
+ say(paint.grey(' The same answer as JSON, which is what an agent should read: staysfixed init --json'));
1386
+ blank();
1387
+
1388
+ return result.ok ? EXIT.ok : EXIT.error;
1389
+ }
1390
+
1391
+ // Named so a reader of this file can see, in one place, where the settings may live and what
1392
+ // the folder inside a project is called — both owned by src/core/paths.js, both re-stated
1393
+ // here because init is the one command whose whole job is those two facts.
1394
+ export { CONFIG_NAMES, DEFAULT_DIR };