viewable-one 0.0.0-stage → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +1460 -0
  2. package/LICENSE +21 -0
  3. package/README.md +755 -2
  4. package/SPAWN.md +2105 -0
  5. package/glytch.mjs +517 -0
  6. package/lib/acp.mjs +978 -0
  7. package/lib/admission.mjs +105 -0
  8. package/lib/agent-selection.mjs +123 -0
  9. package/lib/agents.mjs +913 -0
  10. package/lib/atomic.mjs +96 -0
  11. package/lib/auth.mjs +1387 -0
  12. package/lib/authenticatedHttp.mjs +68 -0
  13. package/lib/boardside.mjs +225 -0
  14. package/lib/child.mjs +1252 -0
  15. package/lib/cli-output.mjs +176 -0
  16. package/lib/cloudflared-release.json +25 -0
  17. package/lib/cloudflared.mjs +81 -0
  18. package/lib/codex-rpc.mjs +92 -0
  19. package/lib/codex.mjs +747 -0
  20. package/lib/codexApps.mjs +332 -0
  21. package/lib/codexPlanWindow.mjs +84 -0
  22. package/lib/codexToolInputs.mjs +139 -0
  23. package/lib/codingChecks.mjs +55 -0
  24. package/lib/compose.mjs +76 -0
  25. package/lib/computerApps.mjs +110 -0
  26. package/lib/conversationRules.mjs +45 -0
  27. package/lib/detect.mjs +605 -0
  28. package/lib/devserver.mjs +659 -0
  29. package/lib/digest.mjs +290 -0
  30. package/lib/doctor.mjs +464 -0
  31. package/lib/exitClass.mjs +64 -0
  32. package/lib/glytchFolder.mjs +105 -0
  33. package/lib/hosted.mjs +112 -0
  34. package/lib/imageAttachments.mjs +31 -0
  35. package/lib/init-connect.mjs +418 -0
  36. package/lib/kill.mjs +104 -0
  37. package/lib/logindaemon.mjs +976 -0
  38. package/lib/logs.mjs +120 -0
  39. package/lib/mcpjson.mjs +242 -0
  40. package/lib/memberPreview.mjs +165 -0
  41. package/lib/multiwatch.mjs +428 -0
  42. package/lib/pagePictures.mjs +396 -0
  43. package/lib/pageRoutes.mjs +67 -0
  44. package/lib/paths.mjs +9 -0
  45. package/lib/permissionAsk.mjs +418 -0
  46. package/lib/picker.mjs +300 -0
  47. package/lib/platform.mjs +60 -0
  48. package/lib/previewGateway.mjs +129 -0
  49. package/lib/previewTunnel.mjs +85 -0
  50. package/lib/procs.mjs +428 -0
  51. package/lib/project-mcp.mjs +288 -0
  52. package/lib/projectPaths.mjs +31 -0
  53. package/lib/refusal.mjs +70 -0
  54. package/lib/registry.mjs +204 -0
  55. package/lib/repomap.mjs +322 -0
  56. package/lib/restartPass.mjs +151 -0
  57. package/lib/retired-address.mjs +78 -0
  58. package/lib/rowsAnswers.mjs +389 -0
  59. package/lib/rowsEvents.mjs +86 -0
  60. package/lib/run-engine.mjs +16 -0
  61. package/lib/runner-health.mjs +57 -0
  62. package/lib/runner.mjs +1569 -0
  63. package/lib/runnerAdmission.mjs +35 -0
  64. package/lib/runnerApi.mjs +312 -0
  65. package/lib/runnerCompletion.mjs +406 -0
  66. package/lib/runnerLease.mjs +37 -0
  67. package/lib/runnerMaintenance.mjs +27 -0
  68. package/lib/runnerPace.mjs +283 -0
  69. package/lib/runnerPerformance.mjs +26 -0
  70. package/lib/runnerRealtime.mjs +340 -0
  71. package/lib/runnerRegister.mjs +96 -0
  72. package/lib/runnerSettings.mjs +40 -0
  73. package/lib/runnerSettlement.mjs +138 -0
  74. package/lib/runnerWorkspace.mjs +162 -0
  75. package/lib/serverSweep.mjs +90 -0
  76. package/lib/sharedPreview.mjs +292 -0
  77. package/lib/signIn.mjs +47 -0
  78. package/lib/spawn.mjs +330 -0
  79. package/lib/sweep.mjs +161 -0
  80. package/lib/sync.mjs +484 -0
  81. package/lib/uninstall-project.mjs +588 -0
  82. package/lib/uninstall.mjs +457 -0
  83. package/lib/update.mjs +302 -0
  84. package/lib/usageLimit.mjs +108 -0
  85. package/lib/windows-command.mjs +85 -0
  86. package/lib/worktree.mjs +1138 -0
  87. package/lib/writePolicy.mjs +45 -0
  88. package/package.json +58 -4
  89. package/spawn-runner.mjs +167 -0
@@ -0,0 +1,457 @@
1
+ // `glytch uninstall` - the ruling, 2026-09-12: removing glytch, its OAuth
2
+ // grants and everything it wrote must be one straightforward command for any
3
+ // user. Before this, undoing `glytch init` was four separate half-measures
4
+ // (`forget`, `stop`, `logout`, and a person deleting `~/.glytch` by hand) and
5
+ // none of them touched the board's own record of the grant - a machine that
6
+ // "uninstalled" glytch was still a machine the board would accept a bearer
7
+ // token from.
8
+ //
9
+ // THE SHAPE: a project step (every registered folder, or just `--project`'s
10
+ // cwd) delegates to `uninstallProject` (bridge/lib/uninstall-project.mjs,
11
+ // built on its own branch - see the stub note below), then three
12
+ // machine-wide steps run unless `--project` was passed: stop and remove the
13
+ // login daemon, revoke this machine's board grant and sign out, delete
14
+ // `~/.glytch` outright. `--dry-run` reports "would ..." everywhere and
15
+ // changes nothing; `--keep-login` leaves the machine signed in and its
16
+ // credential on disk.
17
+ //
18
+ // EVERY SEAM IS INJECTED, the logindaemon.mjs rule that runs through this
19
+ // whole package: `exec`, `fetchImpl`, `daemon` and `uninstallProject` itself
20
+ // are parameters with real defaults, so a test drives the whole command with
21
+ // fakes and nothing here ever calls launchctl/schtasks/systemctl, opens a
22
+ // socket, or touches a real ~/.glytch.
23
+ //
24
+ // THE STUB. `uninstallProject` is being written on a separate branch
25
+ // (`bridge/lib/uninstall-project.mjs`, doctor-shaped rows: agent logouts,
26
+ // config entry removal, `.glytch/agents.json`, the registry entry for one
27
+ // folder). Until that lands here, a stub of the same path and signature
28
+ // (returning `[]`) lets this module and its tests import and run; the stub
29
+ // is not part of this branch's commit, and the merge brings the real file in
30
+ // over it.
31
+
32
+ import { createInterface } from "node:readline";
33
+ import { rm } from "node:fs/promises";
34
+ import { join } from "node:path";
35
+
36
+ import { bold, bullets, colorEnabled, heading, hint, renderRow, row, step, summary, warn, warnRow } from "./cli-output.mjs";
37
+ import { appUrlFrom, logout as hostedLogout, readAuthFile } from "./auth.mjs";
38
+ import { detectAgents, execProbe } from "./detect.mjs";
39
+ import { glytchHome, readRegistry } from "./registry.mjs";
40
+ import { loginDaemon } from "./logindaemon.mjs";
41
+ import { uninstallProject } from "./uninstall-project.mjs";
42
+
43
+ /** `--project`, `--keep-login`, `--dry-run`, `--yes` - the same "throw on
44
+ * anything else" shape `initOptions` uses (agent-selection.mjs), so a typo in
45
+ * a flag is refused rather than silently ignored. */
46
+ export function uninstallOptions(args) {
47
+ let project = false;
48
+ let keepLogin = false;
49
+ let dryRun = false;
50
+ let yes = false;
51
+ for (const arg of args) {
52
+ if (arg === "--project") project = true;
53
+ else if (arg === "--keep-login") keepLogin = true;
54
+ else if (arg === "--dry-run") dryRun = true;
55
+ else if (arg === "--yes" || arg === "-y") yes = true;
56
+ else throw new Error(`Unknown uninstall option: ${arg}`);
57
+ }
58
+ return { project, keepLogin, dryRun, yes };
59
+ }
60
+
61
+ // `row`/`warnRow`, imported from cli-output.mjs (brief 31 review, U6/reuse,
62
+ // 2026-09-12 - this used to be a local copy, byte-identical to doctor.mjs's
63
+ // own and lib/uninstall-project.mjs's own): the exact doctor-shaped contract
64
+ // `uninstallProject` answers in too, {key, label, ok, state, detail,
65
+ // action?}, so every row this command prints - its own and the other
66
+ // worker's - paints the same way.
67
+
68
+ /** A readline `ask`, matching the shape `chooseAgents`' own fallback prompt
69
+ * takes (agent-selection.mjs: `ask(question) => Promise<string>`, injected
70
+ * there for the exact same reason - a test drives it with a canned answer,
71
+ * and nothing here ever has to touch a real terminal to be exercised). This
72
+ * is the "type an answer" half of that same pattern, spelled out with
73
+ * `node:readline` because uninstall has one yes/no question and no rows to
74
+ * pick from - there is nothing here for the arrow-key picker to do. */
75
+ export function readlineAsk(input = process.stdin, output = process.stdout) {
76
+ return (question) =>
77
+ new Promise((resolvePromise) => {
78
+ const rl = createInterface({ input, output });
79
+ let answered = false;
80
+ rl.question(question, (answer) => {
81
+ answered = true;
82
+ rl.close();
83
+ resolvePromise(answer);
84
+ });
85
+ // FIXED 2026-09-12 (brief 31 review, U5): with no `close` handler,
86
+ // Ctrl-D (or any input stream that ends before an answer) left
87
+ // `rl.question`'s callback never called and this promise hung
88
+ // forever - `confirmUninstall` above never got an answer to compare
89
+ // against "yes", so the whole command just sat there. `close` fires
90
+ // either way (readline calls it itself once `question`'s callback
91
+ // resolves), so this only resolves a SECOND time when the input
92
+ // ended without ever answering - treated as "" here, the same "not
93
+ // yes" `confirmUninstall` already refuses on.
94
+ rl.on("close", () => {
95
+ if (!answered) resolvePromise("");
96
+ });
97
+ });
98
+ }
99
+
100
+ /** The bulleted plan printed before the question is ever asked - "print what
101
+ * will happen" first, so "Type yes to continue" means something. */
102
+ export function uninstallPlan(options, { folderCount = 0 } = {}) {
103
+ const lines = [];
104
+ if (options.project) {
105
+ lines.push(
106
+ "Disconnect this project folder only: agent sign-outs, its config entries, .glytch/agents.json, and its registry entry.",
107
+ );
108
+ } else {
109
+ lines.push(
110
+ folderCount > 0
111
+ ? `Disconnect ${folderCount} registered project folder${folderCount === 1 ? "" : "s"}: agent sign-outs, config entries, .glytch/agents.json, and each registry entry.`
112
+ : "No project folders are registered: nothing to disconnect there.",
113
+ );
114
+ lines.push("Stop watching folders and remove the watcher's file.");
115
+ lines.push(
116
+ options.keepLogin
117
+ ? "Keep your computer signed in to the board (--keep-login)."
118
+ : "Revoke your computer's board sign-in at the board and forget the credential.",
119
+ );
120
+ lines.push(
121
+ options.keepLogin
122
+ ? "Leave ~/.glytch in place: the sign-in credential lives there (--keep-login)."
123
+ : "Delete ~/.glytch entirely.",
124
+ );
125
+ }
126
+ lines.push("Print the two steps left to finish by hand: uninstalling the npm package, and unlinking the folder in the app.");
127
+ return lines;
128
+ }
129
+
130
+ /** Print the plan and get a yes. `--yes` skips the question outright; a
131
+ * non-TTY without it prints the plan and refuses rather than opening a
132
+ * prompt nothing can answer - the same call chooseAgents already makes for
133
+ * its own picker (agent-selection.mjs). */
134
+ export async function confirmUninstall({ options, plan, isTTY, ask, log, color }) {
135
+ const paint = { color };
136
+ log(heading("Viewable · Uninstall", paint));
137
+ log(bold("This will:", paint));
138
+ for (const line of bullets(plan, paint)) log(line);
139
+ log("");
140
+ if (options.yes) return true;
141
+ if (!isTTY) {
142
+ log(warn("No terminal here, nothing was changed.", paint));
143
+ log("Run again with --yes to uninstall without a prompt.");
144
+ return false;
145
+ }
146
+ const answer = await ask("Type yes to continue: ");
147
+ if (String(answer ?? "").trim().toLowerCase() !== "yes") {
148
+ log("");
149
+ log(warn("Not confirmed, nothing was changed.", paint));
150
+ return false;
151
+ }
152
+ return true;
153
+ }
154
+
155
+ /** One row per doctor-shaped row, painted exactly the way `checkSections`
156
+ * paints each of its own - same status column, same wrap, same indent - but
157
+ * with no grouping into Project/Runner/Coding agents/System and no derived
158
+ * "Next steps" (doctor's defaults there, "run viewable init", are the wrong
159
+ * advice for a command that is in the middle of removing glytch).
160
+ *
161
+ * Built on `renderRow` (cli-output.mjs), the single-row painter
162
+ * `checkSections` itself uses - the two used to be separate, byte-identical
163
+ * implementations (brief 31 review, U6/reuse, 2026-09-12). */
164
+ export function printedRows(rows, options = {}) {
165
+ const lines = [];
166
+ for (const r of rows ?? []) lines.push(...renderRow(r, options));
167
+ return lines;
168
+ }
169
+
170
+ /** Step 2, row 1: stop the daemon AND remove its file (`removeFile: true`
171
+ * already deletes the Windows launcher and task XML together with the
172
+ * launcher path itself - see schtasks.stop in logindaemon.mjs - so there is
173
+ * nothing platform-specific to add here).
174
+ *
175
+ * NO `exec` PARAMETER, ON PURPOSE (bug found 2026-09-12 by a real, non-dry
176
+ * run): this used to accept `exec` and hand it straight to `daemon.stop()`,
177
+ * which meant whatever `runUninstall` was given for AGENT probing -
178
+ * `execProbe`, shaped `exec(bin, args, options)` - silently replaced
179
+ * `daemon.stop()`'s own correct default (`execLaunchctl` / `execSchtasks` /
180
+ * `execSystemctl`, shaped `exec(argsArray, env)`). The two shapes are not
181
+ * compatible: `execFile`'s own argument-shifting (a non-array second
182
+ * argument is treated as `options`, and the real `options` shifts into the
183
+ * callback slot) turned into "The 'callback' argument must be of type
184
+ * function. Received an instance of Object" the moment step 2 tried to stop
185
+ * a real daemon. `daemon.stop()` already knows how to reach launchctl,
186
+ * schtasks or systemctl for the machine it is actually running on - exactly
187
+ * the way `viewable stop` above calls it with no `exec` of its own - so this
188
+ * function has no business choosing one for it.
189
+ *
190
+ * THE ROW READS THE FILE, NOT JUST `result.unloaded` (Bug 2, found the same
191
+ * day, same real run): the daemon implementations already delete the file
192
+ * unconditionally once `removeFile: true` is asked for and the file exists,
193
+ * whether or not the unload itself succeeded - a plist written under
194
+ * GLYTCH_NO_DAEMON_INSTALL (never loaded in the first place), or a scheduled
195
+ * task already gone, both hit this. Before Bug 1 was fixed the file simply
196
+ * never got deleted, because the crash aborted `stop()` before it reached
197
+ * that line - so this looked like it was Bug 1's fault too, until fixing
198
+ * Bug 1 alone left the row still calling a successful deletion a "failed"
199
+ * uninstall, because the row only ever looked at `result.unloaded`.
200
+ * Re-reading the file after `stop()` returns is what tells "the unload
201
+ * failed but the file is gone" (ok - uninstalling was the goal, not
202
+ * unloading) from "the file could not be removed" (the one real failure). */
203
+ export async function daemonUninstallRow({ daemon, env, dryRun }) {
204
+ const label = daemon.rowLabel;
205
+ const file = daemon.filePath(env);
206
+ const installed = (await daemon.read(env)) !== null;
207
+ if (!installed) {
208
+ return row("daemon", `Stop the ${label}`, true, "not installed", `nothing at ${file}`);
209
+ }
210
+ if (dryRun) {
211
+ return row("daemon", `Stop the ${label}`, true, "would stop", `would unload and delete ${file}`);
212
+ }
213
+ const result = await daemon.stop({ env, removeFile: true });
214
+ const stillThere = (await daemon.read(env)) !== null;
215
+ if (stillThere) {
216
+ return row("daemon", `Stop the ${label}`, false, "failed", result.why || "the file could not be removed");
217
+ }
218
+ return result.unloaded
219
+ ? row("daemon", `Stop the ${label}`, true, "stopped", `${file} removed`)
220
+ : row(
221
+ "daemon",
222
+ `Stop the ${label}`,
223
+ true,
224
+ "removed (was not loaded)",
225
+ result.why ? `${file} removed; ${result.why}` : `${file} removed, it was not loaded`,
226
+ );
227
+ }
228
+
229
+ /** Step 2, row 2: revoke this machine's grant server-side, then forget the
230
+ * credential locally. `stored` is read once by the caller and passed in
231
+ * (rather than re-read here) so dry-run and the real run agree on exactly
232
+ * what they saw. */
233
+ export async function revokeRow({ stored, env, fetchImpl, dryRun, keepLogin, logout = hostedLogout }) {
234
+ if (keepLogin) {
235
+ return warnRow("revoke", "Board sign-in", "kept", "your computer stays signed in (--keep-login)");
236
+ }
237
+ if (!stored) {
238
+ return row("revoke", "Board sign-in", true, "not signed in", "no stored credential to revoke");
239
+ }
240
+ const appUrl = stored.appUrl || appUrlFrom(undefined, env);
241
+ const revokeUrl = `${appUrl}/oauth/revoke`;
242
+ if (dryRun) {
243
+ return row("revoke", "Board sign-in", true, "would revoke", `would revoke at ${revokeUrl} and delete the local credential`);
244
+ }
245
+ // Either half of the pair kills the whole family server-side (oauth.mjs's
246
+ // own revokeToken), so presenting both is belt and suspenders against
247
+ // whichever one the server happens to still recognise - never a second
248
+ // grant to clean up.
249
+ const tokens = [stored.refreshToken, stored.accessToken].filter((t) => typeof t === "string" && t !== "");
250
+ let networkFailed = false;
251
+ let badStatus = null;
252
+ for (const token of tokens) {
253
+ try {
254
+ const response = await fetchImpl(revokeUrl, {
255
+ method: "POST",
256
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
257
+ body: new URLSearchParams({ token }),
258
+ });
259
+ // FIXED 2026-09-12 (brief 31 review, U2): the response status was
260
+ // never read. A 404/500 from `/oauth/revoke` does not throw - only a
261
+ // network-level failure does - so this used to call the row "revoked"
262
+ // and delete the credential even when the board plainly said the
263
+ // revoke did not happen.
264
+ if (response && response.ok === false && badStatus === null) badStatus = response.status;
265
+ } catch {
266
+ networkFailed = true;
267
+ }
268
+ }
269
+ // A STATUS THE BOARD ACTUALLY SENT BACK IS A DEFINITE "NO", unlike a
270
+ // network failure (below) where nothing is known either way. The
271
+ // credential is KEPT rather than deleted here, and the caller
272
+ // (runUninstall) skips deleting ~/.glytch too - the returned
273
+ // `keptCredential` flag is how it knows - so a re-run of `glytch
274
+ // uninstall` still has a token on disk to retry the revoke with. Deleting
275
+ // it now would strand the grant at the board with nothing left on this
276
+ // machine that could ever ask it to revoke again.
277
+ if (badStatus !== null) {
278
+ return {
279
+ ...warnRow(
280
+ "revoke",
281
+ "Board sign-in",
282
+ "could not revoke",
283
+ `could not revoke at ${revokeUrl} (HTTP ${badStatus}); the credential is kept locally so a retry can revoke it - or revoke it in Settings`,
284
+ ),
285
+ keptCredential: true,
286
+ };
287
+ }
288
+ // Deleted locally either way: a laptop with no network right now should
289
+ // still end up signed out on its own side, same rule as `logout()`'s own
290
+ // header comment.
291
+ await logout(env);
292
+ return networkFailed
293
+ ? warnRow(
294
+ "revoke",
295
+ "Board sign-in",
296
+ "could not reach the board",
297
+ "could not reach the board to revoke; the credential is deleted locally, revoke it in Settings",
298
+ )
299
+ : row("revoke", "Board sign-in", true, "revoked", `revoked at ${revokeUrl} and removed the local credential`);
300
+ }
301
+
302
+ /** Step 2, row 3: delete `~/.glytch` outright - folders.json, auth.json,
303
+ * logs/, the cloudflared cache, and everything else this package ever wrote
304
+ * under it. Never anything outside glytchHome(). */
305
+ export async function deleteHomeRow({ env, dryRun, keepLogin }) {
306
+ const home = glytchHome(env);
307
+ if (keepLogin) {
308
+ return warnRow("home", "Delete ~/.glytch", "kept", `kept - the sign-in credential lives at ${join(home, "auth.json")} (--keep-login)`);
309
+ }
310
+ if (dryRun) {
311
+ return row("home", "Delete ~/.glytch", true, "would delete", home);
312
+ }
313
+ await rm(home, { recursive: true, force: true });
314
+ return row("home", "Delete ~/.glytch", true, "deleted", home);
315
+ }
316
+
317
+ /** The two steps this command cannot do for you, as hints under step 3.
318
+ * "Is this a checkout" is asked of the RUNNING script's own path - a global
319
+ * install's is a symlink through some `node_modules/.bin`, and a checkout's
320
+ * is not - the same distinguishing fact logindaemon.mjs's own daemon-version
321
+ * check reads a package.json beside a script path for. */
322
+ export function uninstallGuideLines(scriptPath = process.argv[1]) {
323
+ const globalInstall = /node_modules[\\/]/.test(String(scriptPath ?? ""));
324
+ return [
325
+ globalInstall
326
+ ? "npm uninstall -g viewable-one glytch"
327
+ : "This is a checkout: run `npm unlink -g viewable-one` from bridge/.",
328
+ "Unlink the folder in the app's Project panel.",
329
+ ];
330
+ }
331
+
332
+ /** The whole command. Returns the exit code; the caller (glytch.mjs) is the
333
+ * one that calls `process.exit`, same as every other one-shot command here. */
334
+ export async function runUninstall({
335
+ root,
336
+ args = [],
337
+ options = uninstallOptions(args),
338
+ env = process.env,
339
+ exec = execProbe,
340
+ fetchImpl = fetch,
341
+ daemon = loginDaemon({ env }),
342
+ uninstallProjectFn = uninstallProject,
343
+ detect = detectAgents,
344
+ isTTY = Boolean(process.stdin.isTTY && process.stdout.isTTY),
345
+ ask = readlineAsk(),
346
+ log = () => {},
347
+ color = colorEnabled(),
348
+ } = {}) {
349
+ const paint = { color };
350
+ const roots = options.project ? [root] : await readRegistry(env);
351
+ const plan = uninstallPlan(options, { folderCount: roots.length });
352
+ const proceed = await confirmUninstall({ options, plan, isTTY, ask, log, color });
353
+ if (!proceed) return 1;
354
+
355
+ const titles = options.project ? ["Projects", "What is left for you"] : ["Projects", "Your computer", "What is left for you"];
356
+ const allRows = [];
357
+ let n = 1;
358
+
359
+ log("");
360
+ log(step(n, titles.length, "Projects", paint));
361
+ n += 1;
362
+ if (roots.length === 0) {
363
+ log(warn("No project folders are registered.", paint));
364
+ } else {
365
+ const agents = await detect({ exec, env });
366
+ for (const projectRoot of roots) {
367
+ log("");
368
+ log(bold(projectRoot, paint));
369
+ // BUG 3, found 2026-09-12 by a real, non-dry run: `uninstallProject`'s
370
+ // `log` calls back with the ROW ITSELF, once per row, in the same
371
+ // order the returned array carries them (its own test: "a `log`
372
+ // callback sees every row as it is produced") - a live-progress hook,
373
+ // not a string logger. This used to forward each row straight into
374
+ // `hint()`, which stringifies whatever it is given
375
+ // (`String(aPlainObject)` is "[object Object]") and printed eight of
376
+ // those above the real rows on a real project. Painting each row here,
377
+ // as it lands, is also the ONLY place these rows get printed - nothing
378
+ // re-walks the returned array below, so a row is painted once, not
379
+ // twice.
380
+ let rows;
381
+ try {
382
+ rows = await uninstallProjectFn(projectRoot, {
383
+ agents,
384
+ exec,
385
+ env,
386
+ dryRun: options.dryRun,
387
+ log: (r) => { for (const line of printedRows([r], paint)) log(line); },
388
+ });
389
+ } catch (error) {
390
+ // U4: uninstallProjectFn is documented to never throw, but one bad
391
+ // folder (a config path that turned out to be a directory, a
392
+ // permission error) must not abort every folder after it and the
393
+ // machine steps that follow. This mirrors the per-step wrapping in
394
+ // lib/uninstall-project.mjs itself.
395
+ const failed = row("project", projectRoot, false, "error", `${error?.code ? `${error.code}: ` : ""}${error?.message ?? String(error)}`);
396
+ for (const line of printedRows([failed], paint)) log(line);
397
+ rows = [failed];
398
+ }
399
+ allRows.push(...(rows ?? []));
400
+ }
401
+ }
402
+
403
+ if (!options.project) {
404
+ log("");
405
+ log(step(n, titles.length, "Your computer", paint));
406
+ n += 1;
407
+
408
+ // U3: `allRows` at this point is exactly the project rows (nothing
409
+ // machine-level has been pushed yet). A project step that failed means
410
+ // this run did not fully undo what `init` did to that folder - deleting
411
+ // ~/.glytch now would remove the only thing a retry has to compare
412
+ // against, and would take the registry with it before registryRow's own
413
+ // U3 gate ever got a chance to leave the folder registered.
414
+ const projectsOk = allRows.every((r) => r.ok);
415
+
416
+ const daemonRow = await daemonUninstallRow({ daemon, env, dryRun: options.dryRun });
417
+ for (const line of printedRows([daemonRow], paint)) log(line);
418
+ allRows.push(daemonRow);
419
+
420
+ const stored = await readAuthFile(env);
421
+ const revoke = await revokeRow({ stored, env, fetchImpl, dryRun: options.dryRun, keepLogin: options.keepLogin });
422
+ for (const line of printedRows([revoke], paint)) log(line);
423
+ allRows.push(revoke);
424
+
425
+ // U2: a revoke the board definitely refused (a real HTTP status, not a
426
+ // network failure) kept the credential on disk on purpose - deleting
427
+ // ~/.glytch now would take that credential with it and strand the grant
428
+ // with nothing left able to retry the revoke. U3: a failed project step
429
+ // (checked above, before this section pushed any machine rows) means a
430
+ // retry needs ~/.glytch left in place too.
431
+ const home = !projectsOk
432
+ ? warnRow(
433
+ "home",
434
+ "Delete ~/.glytch",
435
+ "left in place",
436
+ "a project step failed - ~/.glytch was left in place so `viewable uninstall` can retry",
437
+ )
438
+ : revoke.keptCredential
439
+ ? warnRow(
440
+ "home",
441
+ "Delete ~/.glytch",
442
+ "kept",
443
+ "the board sign-in could not be revoked and its credential was kept so a retry can revoke it; ~/.glytch was not deleted",
444
+ )
445
+ : await deleteHomeRow({ env, dryRun: options.dryRun, keepLogin: options.keepLogin });
446
+ for (const line of printedRows([home], paint)) log(line);
447
+ allRows.push(home);
448
+ }
449
+
450
+ log("");
451
+ log(step(n, titles.length, "What is left for you", paint));
452
+ for (const line of uninstallGuideLines()) log(hint(line, paint));
453
+
454
+ log("");
455
+ log(summary(allRows, paint));
456
+ return allRows.every((r) => r.ok) ? 0 : 1;
457
+ }