viewable-one 0.0.0-stage → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +1483 -0
  2. package/LICENSE +21 -0
  3. package/README.md +738 -2
  4. package/SPAWN.md +2108 -0
  5. package/glytch.mjs +517 -0
  6. package/lib/acp.mjs +1018 -0
  7. package/lib/admission.mjs +105 -0
  8. package/lib/agent-selection.mjs +124 -0
  9. package/lib/agents.mjs +914 -0
  10. package/lib/atomic.mjs +96 -0
  11. package/lib/auth.mjs +1491 -0
  12. package/lib/authenticatedHttp.mjs +75 -0
  13. package/lib/boardside.mjs +225 -0
  14. package/lib/child.mjs +1292 -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 +767 -0
  20. package/lib/codexApps.mjs +344 -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 +629 -0
  28. package/lib/devserver.mjs +670 -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/hosted.mjs +112 -0
  33. package/lib/imageAttachments.mjs +31 -0
  34. package/lib/init-connect.mjs +418 -0
  35. package/lib/kill.mjs +104 -0
  36. package/lib/logindaemon.mjs +976 -0
  37. package/lib/logs.mjs +120 -0
  38. package/lib/mcpjson.mjs +242 -0
  39. package/lib/memberPreview.mjs +173 -0
  40. package/lib/multiwatch.mjs +428 -0
  41. package/lib/pagePictures.mjs +396 -0
  42. package/lib/pageRoutes.mjs +70 -0
  43. package/lib/paths.mjs +10 -0
  44. package/lib/permissionAsk.mjs +418 -0
  45. package/lib/picker.mjs +300 -0
  46. package/lib/platform.mjs +60 -0
  47. package/lib/previewGateway.mjs +130 -0
  48. package/lib/previewTunnel.mjs +85 -0
  49. package/lib/procs.mjs +428 -0
  50. package/lib/project-mcp.mjs +288 -0
  51. package/lib/projectPaths.mjs +31 -0
  52. package/lib/refusal.mjs +70 -0
  53. package/lib/registry.mjs +204 -0
  54. package/lib/repomap.mjs +323 -0
  55. package/lib/restartPass.mjs +151 -0
  56. package/lib/retired-address.mjs +78 -0
  57. package/lib/rowsAnswers.mjs +388 -0
  58. package/lib/rowsEvents.mjs +86 -0
  59. package/lib/run-engine.mjs +16 -0
  60. package/lib/runner-health.mjs +57 -0
  61. package/lib/runner.mjs +1640 -0
  62. package/lib/runnerAdmission.mjs +35 -0
  63. package/lib/runnerApi.mjs +312 -0
  64. package/lib/runnerCompletion.mjs +406 -0
  65. package/lib/runnerLease.mjs +37 -0
  66. package/lib/runnerMaintenance.mjs +27 -0
  67. package/lib/runnerPace.mjs +283 -0
  68. package/lib/runnerPerformance.mjs +26 -0
  69. package/lib/runnerRealtime.mjs +340 -0
  70. package/lib/runnerRegister.mjs +96 -0
  71. package/lib/runnerSettings.mjs +40 -0
  72. package/lib/runnerSettlement.mjs +145 -0
  73. package/lib/runnerWorkspace.mjs +177 -0
  74. package/lib/serverSweep.mjs +90 -0
  75. package/lib/sharedPreview.mjs +292 -0
  76. package/lib/signIn.mjs +47 -0
  77. package/lib/spawn.mjs +330 -0
  78. package/lib/sweep.mjs +161 -0
  79. package/lib/sync.mjs +484 -0
  80. package/lib/uninstall-project.mjs +589 -0
  81. package/lib/uninstall.mjs +464 -0
  82. package/lib/update.mjs +302 -0
  83. package/lib/usageLimit.mjs +108 -0
  84. package/lib/viewableFolder.mjs +105 -0
  85. package/lib/windows-command.mjs +85 -0
  86. package/lib/worktree.mjs +1142 -0
  87. package/lib/writePolicy.mjs +45 -0
  88. package/package.json +58 -4
  89. package/spawn-runner.mjs +167 -0
@@ -0,0 +1,589 @@
1
+ // THE PER-PROJECT HALF OF `glytch uninstall` (brief 31, 2026-09-12).
2
+ //
3
+ // `glytch init` writes four things into a project and one credential into
4
+ // each selected agent: the `.mcp.json` / `.codex/config.toml` /
5
+ // `opencode.json` entry (lib/project-mcp.mjs, lib/mcpjson.mjs), the saved
6
+ // choice of which agents this project uses (`.glytch/agents.json`,
7
+ // lib/agent-selection.mjs), the folder's row in ~/.glytch/folders.json
8
+ // (lib/registry.mjs), and - since brief 31 Move 2 - each agent's own OAuth
9
+ // token for the `glytch` MCP server (lib/init-connect.mjs). This file is the
10
+ // reverse of all four, run from the project being disconnected. The
11
+ // machine-level half (the daemon, the login agent, the signed-in machine
12
+ // itself) is a different question and a different worker's file - this one
13
+ // only undoes what `init` did TO THIS FOLDER.
14
+ //
15
+ // ORDER MATTERS. The OAuth sign-out (step 1) runs before the config files
16
+ // are touched (step 2), because at least one agent's `logout`/`remove` verb
17
+ // resolves the server by NAME against its own configured list, and until the
18
+ // config file is edited, `.mcp.json`'s `glytch` entry is still how that name
19
+ // resolves for Claude Code's project scope.
20
+ //
21
+ // NEVER THROWS. A missing file, a missing agent, a server nobody ever
22
+ // connected - every one of those is a row, not an exception, the same
23
+ // promise lib/doctor.mjs's rows keep. `dryRun` runs every read (a probe, a
24
+ // file read, a registry lookup) so its rows are accurate, and skips every
25
+ // write (no exec call, no file write, no delete, no registry edit).
26
+
27
+ import { readdir, rm, stat } from "node:fs/promises";
28
+ import { join, resolve } from "node:path";
29
+ import { parse as parseToml } from "smol-toml";
30
+ import { parse as parseJsonc, modify, applyEdits } from "jsonc-parser";
31
+
32
+ import { boardEntryIsOurs as jsonBoardEntryIsOurs } from "./mcpjson.mjs";
33
+ import { boardEntryIsOurs as codexBoardEntryIsOurs, opencodeBoardEntryIsOurs, projectMcpServerKeys, readOptional, withoutTomlTable } from "./project-mcp.mjs";
34
+ import { detectAgents, execProbe } from "./detect.mjs";
35
+ import { SERVER_KEY, lastStderrLine } from "./init-connect.mjs";
36
+ import { writeFileAtomic } from "./atomic.mjs";
37
+ import { readRegistry, removeFolder } from "./registry.mjs";
38
+ import { row, warnRow } from "./cli-output.mjs";
39
+
40
+ /** `code: message`, or just the message when there is no `.code` (a plain
41
+ * `Error`, not a Node `fs` errno one). Used only by the per-step try/catch
42
+ * wrapping below (U4, brief 31 review) to turn an exception into a FAIL row
43
+ * someone can actually act on, rather than a stack trace that aborts the
44
+ * whole `viewable uninstall` run over one bad folder. */
45
+ const describeError = (error) => (error?.code ? `${error.code}: ${error.message ?? error}` : String(error?.message ?? error));
46
+
47
+ // --------------------------------------------------------------- Step 1
48
+ //
49
+ // Each agent's own verb, run whenever the agent is INSTALLED - not whenever
50
+ // this project selected it. A token is per machine, and the verb is cheap
51
+ // and idempotent (every agent below answered instantly, with no browser, in
52
+ // testing), so there is no reason to skip it for a project that happened to
53
+ // pick only one or two agents: it costs nothing to also clear a token this
54
+ // project never used, and it is the only chance `uninstall` gets to leave
55
+ // the machine as if `init` had never touched it.
56
+ //
57
+ // `claude mcp remove glytch -s project` was considered and dropped
58
+ // (`claude mcp remove --help`, 2026-09-12 on 2.1.269): its only scope
59
+ // argument is "local, user, or project", and "project" scope IS the
60
+ // `.mcp.json` file this module already edits directly in step 2 - Claude
61
+ // Code has no separate bookkeeping for that scope beyond the file itself.
62
+ // Running it would either no-op against a file step 2 already rewrote, or
63
+ // (in the other order) delete the same entry step 2 was about to delete
64
+ // anyway. Redundant either way, so it is skipped.
65
+ export const LOGOUT_VERBS = {
66
+ claude: { label: "Claude Code", bin: "claude", verb: ["mcp", "logout", SERVER_KEY] },
67
+ codex: { label: "codex", bin: "codex", verb: ["mcp", "logout", SERVER_KEY] },
68
+ opencode: { label: "opencode", bin: "opencode", verb: ["mcp", "logout", SERVER_KEY] },
69
+ };
70
+
71
+ /** Logout is local-only - no browser, no person to wait on - so it gets a
72
+ * short timeout rather than init-connect's LOGIN_TIMEOUT_MS (five minutes,
73
+ * sized for a person finding their password manager). Every logout call
74
+ * measured below returned in well under a second. */
75
+ export const LOGOUT_TIMEOUT_MS = 15_000;
76
+
77
+ /** THE WORDING EACH BINARY USES FOR "no credential was ever stored", MEASURED
78
+ * 2026-09-12 by adding a throwaway HTTP server (a name no real project
79
+ * uses, pointed at a URL nobody has ever authenticated against) and running
80
+ * each agent's own logout verb against it, then removing the fixture:
81
+ *
82
+ * claude 2.1.269: `claude mcp logout <name>` prints
83
+ * `Signed out of "<name>". Run \`claude mcp login <name>\` to
84
+ * authenticate again.` with exit 0 - the SAME wording whether
85
+ * or not a credential was ever issued. Claude Code has no
86
+ * distinct "nothing was stored" message to key on, so every
87
+ * exit-0 result is reported plainly as "signed out"; there is
88
+ * no `claude` entry in the map below on purpose. (A name with
89
+ * no configured server at all is a DIFFERENT, non-zero-exit
90
+ * case: `No MCP server named "<name>". Configured servers:
91
+ * ...` - that falls through to the generic failure branch.)
92
+ * codex 0.153.4: `codex mcp logout <name>` prints
93
+ * `No OAuth credentials stored for '<name>'.` with exit 0 -
94
+ * for a configured-but-never-authenticated server AND for a
95
+ * name with no configured server at all; codex's logout is a
96
+ * credential-store lookup, not a config lookup.
97
+ * opencode 1.18.23: `opencode mcp logout <name>` prints (through its
98
+ * boxed CLI chrome) `No credentials found for: <name>` with
99
+ * exit 0 - again for both a configured-but-unauthenticated
100
+ * server and a name that was never configured at all.
101
+ */
102
+ const NOTHING_STORED = {
103
+ codex: /No OAuth credentials stored for/i,
104
+ opencode: /No credentials found for/i,
105
+ };
106
+
107
+ /** A NON-ZERO EXIT THAT IS NOT A FAILURE - the agent is telling us there was
108
+ * never a credential in reach for THIS project, not that the sign-out
109
+ * attempt broke. Both were found by a real run of `viewable uninstall`
110
+ * (2026-09-12) against a project whose `.mcp.json` had never been opened in
111
+ * an interactive session:
112
+ *
113
+ * claude 2.1.269, exit 1: Claude Code refuses to touch an MCP server it
114
+ * has never had a person approve - a project-scope `.mcp.json`
115
+ * entry needs a session opened in that directory and the
116
+ * approval prompt clicked through once, and nobody had:
117
+ * "glytch" is from .mcp.json and awaiting approval. Run
118
+ * `claude` in this directory to review it first.
119
+ * There is no credential to revoke, so this is `ok`, not `fail`.
120
+ * codex 0.153.4, exit 1: Codex only reads a project's
121
+ * `.codex/config.toml` at all when the project is TRUSTED
122
+ * (`codex --help`'s own trust model) - an untrusted project, or
123
+ * one whose entry is simply gone, is invisible to it rather than
124
+ * "present but unauthenticated":
125
+ * Error: No MCP server named 'glytch' found in configuration.
126
+ * Same conclusion: nothing was ever configured here for codex to
127
+ * hold a credential for, so this is `ok`, not `fail`.
128
+ *
129
+ * opencode needs no entry here - its own logout (NOTHING_STORED above) is
130
+ * already `ok` on exit 0 for a name that was never configured at all, the
131
+ * same wording either way. */
132
+ const NOT_A_FAILURE = {
133
+ claude: [
134
+ { pattern: /is from \.mcp\.json and awaiting approval/i, state: "not approved yet", detail: "nothing to sign out of" },
135
+ // A Codex-only project: Claude is installed but never had this server.
136
+ { pattern: /No MCP server named/i, state: "not configured", detail: "Claude Code has no Viewable server to sign out of" },
137
+ ],
138
+ codex: [{ pattern: /No MCP server named .* found in configuration/i, state: "not configured", detail: "codex has no glytch server to sign out of" }],
139
+ };
140
+
141
+ /** `cwd: root` (brief 31 review, U1): Claude Code resolves a project-scoped
142
+ * MCP server by NAME against the project open in the CURRENT WORKING
143
+ * DIRECTORY, not against `root` as a plain argument - `claude mcp logout
144
+ * glytch` run from anywhere else cannot see this project's `.mcp.json`
145
+ * entry at all, the exact "awaiting approval" / "no MCP server named"
146
+ * outcomes NOT_A_FAILURE above exists to word correctly are also what a
147
+ * correctly-configured project looks like from the wrong cwd. codex and
148
+ * opencode read their own project config the same way. */
149
+ async function logoutRows({ root, agents, exec, env, dryRun, log }) {
150
+ const rows = [];
151
+ for (const key of Object.keys(LOGOUT_VERBS)) {
152
+ if (!agents?.[key]?.found) continue;
153
+ const { label, bin } = LOGOUT_VERBS[key];
154
+ // Every name on disk gets signed out: its entry is deleted in step 2, and
155
+ // a credential left behind under a deleted name is never reached again.
156
+ const names = await projectMcpServerKeys(root, key);
157
+ for (const serverKey of names.length ? names : [SERVER_KEY]) {
158
+ const verb = [...LOGOUT_VERBS[key].verb.slice(0, -1), serverKey];
159
+ const command = `${bin} ${verb.join(" ")}`;
160
+ const rowKey = `${key}Logout`;
161
+ const rowLabel = names.length > 1 ? `${label} sign-out (${serverKey})` : `${label} sign-out`;
162
+ let r;
163
+ try {
164
+ if (dryRun) {
165
+ r = row(rowKey, rowLabel, true, "would sign out", `would run: ${command}`);
166
+ } else {
167
+ const result = await exec(bin, verb, { timeoutMs: LOGOUT_TIMEOUT_MS, env, cwd: root });
168
+ const text = `${result?.stdout ?? ""}${result?.stderr ?? ""}`;
169
+ if (result?.ok) {
170
+ const pattern = NOTHING_STORED[key];
171
+ r = pattern && pattern.test(text)
172
+ ? row(rowKey, rowLabel, true, "nothing stored", `ran: ${command}`)
173
+ : row(rowKey, rowLabel, true, "signed out", `ran: ${command}`);
174
+ } else {
175
+ const notAFailure = NOT_A_FAILURE[key]?.find((entry) => entry.pattern.test(text));
176
+ r = notAFailure
177
+ ? row(rowKey, rowLabel, true, notAFailure.state, notAFailure.detail)
178
+ : row(rowKey, rowLabel, false, "failed", lastStderrLine(result));
179
+ }
180
+ }
181
+ } catch (error) {
182
+ // U4: an `exec` that throws (rather than resolving `{ok:false, ...}`,
183
+ // its documented contract) must not abort every step after it for
184
+ // this folder - one agent's logout is not another's.
185
+ r = row(rowKey, rowLabel, false, "error", describeError(error));
186
+ }
187
+ log?.(r);
188
+ rows.push(r);
189
+ }
190
+ }
191
+ return rows;
192
+ }
193
+
194
+ // --------------------------------------------------------------- Step 2
195
+ //
196
+ // The removal side of lib/mcpjson.mjs and lib/project-mcp.mjs's merges.
197
+ // Those two files only ever ADD; there is no un-merge in them to import. The
198
+ // "is this entry ours" check for each format - `boardEntryIsOurs` (JSON,
199
+ // lib/mcpjson.mjs), its Codex-TOML equivalent and `opencodeBoardEntryIsOurs`
200
+ // (both lib/project-mcp.mjs) - is imported from the same file that WROTE the
201
+ // entry in the first place (brief 31 review, U6/reuse, 2026-09-12: these
202
+ // were three private, byte-identical copies here before), rather than
203
+ // re-derived.
204
+ //
205
+ // PRESENCE IS THE KEY, NOT THE VALUE, for `glytch` itself - same rule
206
+ // mergeBoardServer documents: any `glytch` entry, whatever its shape, is
207
+ // ours to remove. A `board` entry is only removed when it is RECOGNISED as
208
+ // ours (the retired command shape, or at our hosted URL, old or new);
209
+ // a stranger's own server merely named "board" is left exactly alone.
210
+
211
+ const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
212
+ const render = (value) => `${JSON.stringify(value, null, 2)}\n`;
213
+
214
+ /** `$schema` is not content - it is a JSON Schema pointer editors and some
215
+ * agents' own CLIs attach automatically (opencode does this: any `opencode`
216
+ * invocation with a project as its cwd, even a plain `--version` probe
217
+ * during agent detection, was observed 2026-09-12 to stamp a bare
218
+ * `opencode.json` with `{"$schema": "https://opencode.ai/config.json"}`
219
+ * before this file's own writer ever touches it). A JSON config file whose
220
+ * only OTHER top-level key besides the servers map is `$schema` is exactly
221
+ * as empty as one with no other keys at all, and is deleted the same way -
222
+ * otherwise every project that ever ran `opencode` once is left with a
223
+ * schema pointer and nothing it points at. TOML has no such convention, so
224
+ * `removeFromCodexToml` below does not use this. */
225
+ const IGNORED_TOP_KEYS = new Set(["$schema"]);
226
+ const meaningfulKeys = (keys) => keys.filter((k) => !IGNORED_TOP_KEYS.has(k));
227
+
228
+ // `boardEntryIsOurs` (for `.mcp.json`, from lib/mcpjson.mjs, imported above
229
+ // as `jsonBoardEntryIsOurs`), the Codex-TOML equivalent (from
230
+ // lib/project-mcp.mjs as `codexBoardEntryIsOurs`) and the opencode
231
+ // equivalent (`opencodeBoardEntryIsOurs`, same file) used to be
232
+ // byte-identical private copies kept local to this file - EXPORTED from
233
+ // their own modules and imported here instead (brief 31 review, U6/reuse,
234
+ // 2026-09-12), since each one already has to agree with the merge logic
235
+ // that WROTE the entry in the first place.
236
+
237
+ function removeFromMcpJson(existing, env) {
238
+ if (existing === null) return { kind: "not-present" };
239
+ let parsed;
240
+ try {
241
+ parsed = JSON.parse(existing);
242
+ } catch {
243
+ return { kind: "unreadable" };
244
+ }
245
+ if (!isObject(parsed)) return { kind: "unreadable" };
246
+ const servers = parsed.mcpServers;
247
+ if (servers === undefined) return { kind: "not-present" };
248
+ if (!isObject(servers)) return { kind: "unreadable" };
249
+ const hasGlytch = Object.hasOwn(servers, "glytch");
250
+ const hasViewable = Object.hasOwn(servers, "viewable");
251
+ const hasOurBoard = Object.hasOwn(servers, "board") && jsonBoardEntryIsOurs(servers.board, env);
252
+ if (!hasGlytch && !hasViewable && !hasOurBoard) return { kind: "not-present" };
253
+
254
+ const nextServers = { ...servers };
255
+ delete nextServers.glytch;
256
+ delete nextServers.viewable;
257
+ if (hasOurBoard) delete nextServers.board;
258
+ const remaining = Object.keys(nextServers);
259
+ const otherTopKeys = meaningfulKeys(Object.keys(parsed).filter((k) => k !== "mcpServers"));
260
+ if (remaining.length === 0 && otherTopKeys.length === 0) return { kind: "removed", deleteFile: true };
261
+
262
+ const nextParsed = { ...parsed };
263
+ if (remaining.length === 0) delete nextParsed.mcpServers;
264
+ else nextParsed.mcpServers = nextServers;
265
+ return { kind: "removed", text: render(nextParsed) };
266
+ }
267
+
268
+ // `withoutTomlTable(text, header)` (the text splice that removes one TOML
269
+ // table, stopping at the next top-level header so a hand-written comment
270
+ // elsewhere survives) is imported from lib/project-mcp.mjs, which needs the
271
+ // exact same splice for its own `[mcp_servers.board]` removal - see that
272
+ // file for the full doc comment. Returns null when the header cannot be
273
+ // found; this file's own callers report "unreadable" rather than guess.
274
+
275
+ function removeFromCodexToml(existing, env) {
276
+ if (existing === null) return { kind: "not-present" };
277
+ let parsed;
278
+ try {
279
+ parsed = parseToml(existing);
280
+ } catch {
281
+ return { kind: "unreadable" };
282
+ }
283
+ if (parsed.mcp_servers !== undefined && !isObject(parsed.mcp_servers)) return { kind: "unreadable" };
284
+ const servers = parsed.mcp_servers ?? {};
285
+ const hasGlytch = Object.hasOwn(servers, "glytch");
286
+ const hasViewable = Object.hasOwn(servers, "viewable");
287
+ const hasOurBoard = Object.hasOwn(servers, "board") && codexBoardEntryIsOurs(servers.board, env);
288
+ if (!hasGlytch && !hasViewable && !hasOurBoard) return { kind: "not-present" };
289
+
290
+ let text = existing;
291
+ if (hasGlytch) {
292
+ const stripped = withoutTomlTable(text, "mcp_servers.glytch");
293
+ if (stripped === null) return { kind: "unreadable" };
294
+ text = stripped;
295
+ }
296
+ if (hasViewable) {
297
+ const stripped = withoutTomlTable(text, "mcp_servers.viewable");
298
+ if (stripped === null) return { kind: "unreadable" };
299
+ text = stripped;
300
+ }
301
+ if (hasOurBoard) {
302
+ const stripped = withoutTomlTable(text, "mcp_servers.board");
303
+ if (stripped === null) return { kind: "unreadable" };
304
+ text = stripped;
305
+ }
306
+
307
+ let check;
308
+ try {
309
+ check = parseToml(text);
310
+ } catch {
311
+ return { kind: "unreadable" };
312
+ }
313
+ const remaining = isObject(check.mcp_servers) ? Object.keys(check.mcp_servers) : [];
314
+ const otherTopKeys = Object.keys(check).filter((k) => k !== "mcp_servers");
315
+ if (remaining.length === 0 && otherTopKeys.length === 0) return { kind: "removed", deleteFile: true };
316
+ return { kind: "removed", text };
317
+ }
318
+
319
+ function removeFromOpencodeJsonc(existing, env) {
320
+ if (existing === null) return { kind: "not-present" };
321
+ const errors = [];
322
+ const parsed = parseJsonc(existing, errors, { allowTrailingComma: true });
323
+ if (errors.length || !isObject(parsed)) return { kind: "unreadable" };
324
+ if (parsed.mcp !== undefined && !isObject(parsed.mcp)) return { kind: "unreadable" };
325
+ const mcp = parsed.mcp ?? {};
326
+ const hasGlytch = Object.hasOwn(mcp, "glytch");
327
+ const hasViewable = Object.hasOwn(mcp, "viewable");
328
+ const hasOurBoard = Object.hasOwn(mcp, "board") && opencodeBoardEntryIsOurs(mcp.board, env);
329
+ if (!hasGlytch && !hasViewable && !hasOurBoard) return { kind: "not-present" };
330
+
331
+ const formattingOptions = { insertSpaces: true, tabSize: 2, eol: existing.includes("\r\n") ? "\r\n" : "\n" };
332
+ let text = existing;
333
+ if (hasViewable) text = applyEdits(text, modify(text, ["mcp", "viewable"], undefined, { formattingOptions }));
334
+ if (hasGlytch) text = applyEdits(text, modify(text, ["mcp", "glytch"], undefined, { formattingOptions }));
335
+ if (hasOurBoard) text = applyEdits(text, modify(text, ["mcp", "board"], undefined, { formattingOptions }));
336
+
337
+ const afterErrors = [];
338
+ const after = parseJsonc(text, afterErrors, { allowTrailingComma: true });
339
+ if (afterErrors.length || !isObject(after)) return { kind: "unreadable" };
340
+ const remaining = isObject(after.mcp) ? Object.keys(after.mcp) : [];
341
+ const otherTopKeys = meaningfulKeys(Object.keys(after).filter((k) => k !== "mcp"));
342
+ if (remaining.length === 0 && otherTopKeys.length === 0) return { kind: "removed", deleteFile: true };
343
+ if (remaining.length === 0) text = applyEdits(text, modify(text, ["mcp"], undefined, { formattingOptions }));
344
+ return { kind: "removed", text };
345
+ }
346
+
347
+ async function configRow({ key, label, path, decide, env, dryRun, log }) {
348
+ let r;
349
+ try {
350
+ const existing = await readOptional(path);
351
+ const decision = decide(existing, env);
352
+ if (decision.kind === "not-present") {
353
+ r = row(key, label, true, "not present", `no Viewable connection in ${label}`);
354
+ } else if (decision.kind === "unreadable") {
355
+ r = row(
356
+ key,
357
+ label,
358
+ false,
359
+ "unreadable",
360
+ `${path} could not be safely edited - it is someone's work, so nothing was touched. Remove the Viewable connection and any leftover board entry by hand.`,
361
+ );
362
+ } else if (dryRun) {
363
+ r = decision.deleteFile
364
+ ? row(key, label, true, "would delete", `${path} would have nothing left after removing the board connection; would delete the file`)
365
+ : row(key, label, true, "would remove", `would remove the board connection from ${path}, other servers kept`);
366
+ } else if (decision.deleteFile) {
367
+ await rm(path, { force: true });
368
+ r = row(key, label, true, "deleted", `${path} had nothing left after removing the board connection; deleted`);
369
+ } else {
370
+ const mode = (await stat(path).catch(() => null))?.mode;
371
+ await writeFileAtomic(path, decision.text, "utf8", mode ? { mode: mode & 0o777 } : {});
372
+ r = row(key, label, true, "removed", `glytch entry removed from ${path}, other servers kept`);
373
+ }
374
+ } catch (error) {
375
+ // U4: `readOptional` rethrows anything but ENOENT (EISDIR - the config
376
+ // path is actually a directory, EACCES - unreadable permissions, ...).
377
+ // One such folder must not abort the whole `glytch uninstall` run; it
378
+ // becomes a FAIL row naming the real error instead.
379
+ r = row(key, label, false, "error", `${path}: ${describeError(error)}`);
380
+ }
381
+ log?.(r);
382
+ return r;
383
+ }
384
+
385
+ /** `.codex/` itself, left empty once `config.toml` is the last thing removed
386
+ * from it (real-run leftover found 2026-09-12, U7): `viewable init` creates
387
+ * the directory only to hold that one file, so an empty `.codex/` after
388
+ * uninstall is debris nobody asked to keep. Mirrors `glytchDirRow`'s own
389
+ * "only remove when nothing else is in it" rule - anything else Codex or a
390
+ * person put there (a `log/`, a hand-written `AGENTS.md`) leaves the
391
+ * directory in place untouched. */
392
+ async function removeCodexDirIfEmpty(root) {
393
+ const dir = join(root, ".codex");
394
+ let entries;
395
+ try {
396
+ entries = await readdir(dir);
397
+ } catch {
398
+ return; // not present, or not a directory - nothing to do
399
+ }
400
+ if (entries.length === 0) await rm(dir, { recursive: true, force: true }).catch(() => {});
401
+ }
402
+
403
+ /** `skip`: agent keys whose sign-out failed. Their config stays, so a retry
404
+ * still finds the name that reaches the credential; the other agents' configs
405
+ * are cleaned regardless. */
406
+ async function projectMcpRows(root, { env, dryRun, log, skip = new Set() }) {
407
+ const rows = [];
408
+ if (!skip.has("claude")) {
409
+ rows.push(
410
+ await configRow({
411
+ key: "mcpJson",
412
+ label: ".mcp.json",
413
+ path: join(root, ".mcp.json"),
414
+ decide: removeFromMcpJson,
415
+ env,
416
+ dryRun,
417
+ log,
418
+ }),
419
+ );
420
+ }
421
+ if (!skip.has("codex")) {
422
+ const codexConfig = await configRow({
423
+ key: "codexConfig",
424
+ label: ".codex/config.toml",
425
+ path: join(root, ".codex", "config.toml"),
426
+ decide: removeFromCodexToml,
427
+ env,
428
+ dryRun,
429
+ log,
430
+ });
431
+ rows.push(codexConfig);
432
+ if (!dryRun && codexConfig.ok && codexConfig.state === "deleted") await removeCodexDirIfEmpty(root);
433
+ }
434
+ if (!skip.has("opencode")) {
435
+ // OpenCode gives JSONC precedence - the same rule syncProjectMcp applies
436
+ // when it WRITES the entry, applied here when removing it.
437
+ const useJsonc = (await readOptional(join(root, "opencode.jsonc"))) !== null;
438
+ rows.push(
439
+ await configRow({
440
+ key: "opencodeConfig",
441
+ label: useJsonc ? "opencode.jsonc" : "opencode.json",
442
+ path: join(root, useJsonc ? "opencode.jsonc" : "opencode.json"),
443
+ decide: removeFromOpencodeJsonc,
444
+ env,
445
+ dryRun,
446
+ log,
447
+ }),
448
+ );
449
+ }
450
+ return rows;
451
+ }
452
+
453
+ // --------------------------------------------------------------- Step 3
454
+ //
455
+ // `.glytch/agents.json` is this project's own file (lib/agent-selection.mjs)
456
+ // and is always safe to delete outright - unlike the config files above, it
457
+ // carries nothing anyone hand-edited that is worth preserving. `.glytch/`
458
+ // itself is only removed when NOTHING else is left in it: images staging and
459
+ // the repomap cache are not this function's business, and a folder that
460
+ // still holds either is left in place and named in the row detail (there is
461
+ // only one row for this whole step, since both actions describe one fact:
462
+ // what happened to .glytch/agents.json, and what became of the directory).
463
+ async function projectDirRow(root, { dryRun, log }, directory = ".glytch") {
464
+ const dir = join(root, directory);
465
+ const agentsPath = join(dir, "agents.json");
466
+ let r;
467
+ try {
468
+ const hadAgents = (await readOptional(agentsPath)) !== null;
469
+
470
+ let entries;
471
+ try {
472
+ entries = await readdir(dir);
473
+ } catch {
474
+ entries = null;
475
+ }
476
+
477
+ if (entries === null) {
478
+ r = row(directory === ".glytch" ? "glytchDir" : "viewableDir", `${directory}/`, true, "not present", `no ${directory}/ folder to remove`);
479
+ } else {
480
+ const others = entries.filter((name) => name !== "agents.json");
481
+ const wouldBeEmpty = others.length === 0;
482
+ const othersNote = others.length ? `; ${directory}/ kept (${others.join(", ")} remain)` : "";
483
+ if (!hadAgents) {
484
+ r = row(directory === ".glytch" ? "glytchDir" : "viewableDir", `${directory}/`, true, "not present", `no ${directory}/agents.json to remove${othersNote}`);
485
+ } else if (dryRun) {
486
+ r = wouldBeEmpty
487
+ ? row(directory === ".glytch" ? "glytchDir" : "viewableDir", `${directory}/`, true, "would delete", `would delete ${directory}/agents.json, then ${directory}/ (empty afterward)`)
488
+ : row(directory === ".glytch" ? "glytchDir" : "viewableDir", `${directory}/`, true, "would delete", `would delete ${directory}/agents.json${othersNote}`);
489
+ } else {
490
+ await rm(agentsPath, { force: true });
491
+ if (wouldBeEmpty) {
492
+ await rm(dir, { recursive: true, force: true });
493
+ r = row(directory === ".glytch" ? "glytchDir" : "viewableDir", `${directory}/`, true, "deleted", `deleted ${directory}/agents.json, then ${directory}/ (was empty)`);
494
+ } else {
495
+ r = row(directory === ".glytch" ? "glytchDir" : "viewableDir", `${directory}/`, true, "deleted", `deleted ${directory}/agents.json${othersNote}`);
496
+ }
497
+ }
498
+ }
499
+ } catch (error) {
500
+ // U4: `readOptional(agentsPath)` rethrows anything but ENOENT (EISDIR,
501
+ // EACCES, ...) - one bad folder must not abort the whole run.
502
+ r = row(directory === ".glytch" ? "glytchDir" : "viewableDir", `${directory}/`, false, "error", `${agentsPath}: ${describeError(error)}`);
503
+ }
504
+ log?.(r);
505
+ return r;
506
+ }
507
+
508
+ // --------------------------------------------------------------- Step 4
509
+
510
+ /** `allOk` (brief 31 review, U3): unregistering a folder whose earlier steps
511
+ * did not all succeed loses the only record that this project ever needed
512
+ * cleaning up - a retry of `viewable uninstall` reads ~/.glytch/folders.json
513
+ * to know which folders to visit at all, so a folder dropped from it here
514
+ * is a folder a retry will never look at again, even though its `.mcp.json`
515
+ * (say) is still sitting there unreadable. Left registered instead, with a
516
+ * warn row saying why, so the next run finds it. */
517
+ async function registryRow(root, { env, dryRun, log, allOk = true }) {
518
+ const target = resolve(root);
519
+ let r;
520
+ try {
521
+ if (!allOk) {
522
+ const folders = await readRegistry(env);
523
+ r = folders.includes(target)
524
+ ? warnRow(
525
+ "registry",
526
+ "folder registered",
527
+ "left registered",
528
+ "an earlier step in this project failed - left registered so `viewable uninstall` can retry",
529
+ )
530
+ : row("registry", "folder registered", true, "not present", "this folder is not in ~/.glytch/folders.json");
531
+ } else if (dryRun) {
532
+ const folders = await readRegistry(env);
533
+ r = folders.includes(target)
534
+ ? row("registry", "folder registered", true, "would unregister", "would remove this folder from ~/.glytch/folders.json")
535
+ : row("registry", "folder registered", true, "not present", "this folder is not in ~/.glytch/folders.json");
536
+ } else {
537
+ const result = await removeFolder(root, env);
538
+ r = result.removed
539
+ ? row("registry", "folder registered", true, "unregistered", "removed from ~/.glytch/folders.json")
540
+ : row("registry", "folder registered", true, "not present", "this folder was not in ~/.glytch/folders.json");
541
+ }
542
+ } catch (error) {
543
+ // U4: this should be rare (the registry file is JSON we control), but a
544
+ // bad folder must not abort the whole run here either.
545
+ r = row("registry", "folder registered", false, "error", describeError(error));
546
+ }
547
+ log?.(r);
548
+ return r;
549
+ }
550
+
551
+ // --------------------------------------------------------------- Entry point
552
+
553
+ /**
554
+ * Undo, in this one project folder, everything `viewable init` did: each
555
+ * installed agent's own OAuth token for the `glytch` server (step 1), the
556
+ * `glytch` (and recognised leftover `board`) entry in `.mcp.json`,
557
+ * `.codex/config.toml` and `opencode.json`/`opencode.jsonc` (step 2), this
558
+ * project's saved agent selection (step 3), and this folder's row in
559
+ * ~/.glytch/folders.json (step 4).
560
+ *
561
+ * NEVER THROWS for a missing file or an absent agent - both are rows. `agents`
562
+ * is a `detectAgents()` result; when omitted, this calls `detectAgents`
563
+ * itself (through the same `exec`, so a test's injected exec sees every call
564
+ * this function makes and nothing it does not). `dryRun` performs no exec
565
+ * call, no file write, no delete and no registry edit - every row it
566
+ * produces is read from what is already on disk, worded "would ...".
567
+ *
568
+ * `log`, WHEN GIVEN, IS CALLED WITH THE ROW ITSELF, once per row, in the
569
+ * order the returned array carries them - a live-progress hook, not a string
570
+ * logger. FIXED 2026-09-12 (brief 31 review, bug found integrating with
571
+ * lib/uninstall.mjs): this used to call `log?.(\`${r.label} — ${r.state}\`)`
572
+ * at all four sites below, while the caller (`runUninstall`) already treats
573
+ * `log` as receiving the row object and paints it through
574
+ * `cli-output.mjs`'s `renderRow` - the mismatch printed "FAIL undefined"
575
+ * eight times on a real run, once per row, because `renderRow` read
576
+ * `.warn`/`.ok`/`.label`/`.state` off a plain string.
577
+ */
578
+ export async function uninstallProject(root, { agents, exec = execProbe, env = process.env, dryRun = false, log } = {}) {
579
+ const found = agents ?? (await detectAgents({ exec, env }));
580
+ const rows = [];
581
+ rows.push(...(await logoutRows({ root, agents: found, exec, env, dryRun, log })));
582
+ // Keep an agent's saved server names until its own logout succeeds, so retries reach the same credential.
583
+ const skip = new Set(Object.keys(LOGOUT_VERBS).filter((key) => rows.some((r) => r.key === `${key}Logout` && !r.ok)));
584
+ rows.push(...(await projectMcpRows(root, { env, dryRun, log, skip })));
585
+ rows.push(await projectDirRow(root, { dryRun, log }));
586
+ rows.push(await projectDirRow(root, { dryRun, log }, ".viewable"));
587
+ rows.push(await registryRow(root, { env, dryRun, log, allOk: rows.every((r) => r.ok) }));
588
+ return rows;
589
+ }