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.
- package/CHANGELOG.md +1460 -0
- package/LICENSE +21 -0
- package/README.md +755 -2
- package/SPAWN.md +2105 -0
- package/glytch.mjs +517 -0
- package/lib/acp.mjs +978 -0
- package/lib/admission.mjs +105 -0
- package/lib/agent-selection.mjs +123 -0
- package/lib/agents.mjs +913 -0
- package/lib/atomic.mjs +96 -0
- package/lib/auth.mjs +1387 -0
- package/lib/authenticatedHttp.mjs +68 -0
- package/lib/boardside.mjs +225 -0
- package/lib/child.mjs +1252 -0
- package/lib/cli-output.mjs +176 -0
- package/lib/cloudflared-release.json +25 -0
- package/lib/cloudflared.mjs +81 -0
- package/lib/codex-rpc.mjs +92 -0
- package/lib/codex.mjs +747 -0
- package/lib/codexApps.mjs +332 -0
- package/lib/codexPlanWindow.mjs +84 -0
- package/lib/codexToolInputs.mjs +139 -0
- package/lib/codingChecks.mjs +55 -0
- package/lib/compose.mjs +76 -0
- package/lib/computerApps.mjs +110 -0
- package/lib/conversationRules.mjs +45 -0
- package/lib/detect.mjs +605 -0
- package/lib/devserver.mjs +659 -0
- package/lib/digest.mjs +290 -0
- package/lib/doctor.mjs +464 -0
- package/lib/exitClass.mjs +64 -0
- package/lib/glytchFolder.mjs +105 -0
- package/lib/hosted.mjs +112 -0
- package/lib/imageAttachments.mjs +31 -0
- package/lib/init-connect.mjs +418 -0
- package/lib/kill.mjs +104 -0
- package/lib/logindaemon.mjs +976 -0
- package/lib/logs.mjs +120 -0
- package/lib/mcpjson.mjs +242 -0
- package/lib/memberPreview.mjs +165 -0
- package/lib/multiwatch.mjs +428 -0
- package/lib/pagePictures.mjs +396 -0
- package/lib/pageRoutes.mjs +67 -0
- package/lib/paths.mjs +9 -0
- package/lib/permissionAsk.mjs +418 -0
- package/lib/picker.mjs +300 -0
- package/lib/platform.mjs +60 -0
- package/lib/previewGateway.mjs +129 -0
- package/lib/previewTunnel.mjs +85 -0
- package/lib/procs.mjs +428 -0
- package/lib/project-mcp.mjs +288 -0
- package/lib/projectPaths.mjs +31 -0
- package/lib/refusal.mjs +70 -0
- package/lib/registry.mjs +204 -0
- package/lib/repomap.mjs +322 -0
- package/lib/restartPass.mjs +151 -0
- package/lib/retired-address.mjs +78 -0
- package/lib/rowsAnswers.mjs +389 -0
- package/lib/rowsEvents.mjs +86 -0
- package/lib/run-engine.mjs +16 -0
- package/lib/runner-health.mjs +57 -0
- package/lib/runner.mjs +1569 -0
- package/lib/runnerAdmission.mjs +35 -0
- package/lib/runnerApi.mjs +312 -0
- package/lib/runnerCompletion.mjs +406 -0
- package/lib/runnerLease.mjs +37 -0
- package/lib/runnerMaintenance.mjs +27 -0
- package/lib/runnerPace.mjs +283 -0
- package/lib/runnerPerformance.mjs +26 -0
- package/lib/runnerRealtime.mjs +340 -0
- package/lib/runnerRegister.mjs +96 -0
- package/lib/runnerSettings.mjs +40 -0
- package/lib/runnerSettlement.mjs +138 -0
- package/lib/runnerWorkspace.mjs +162 -0
- package/lib/serverSweep.mjs +90 -0
- package/lib/sharedPreview.mjs +292 -0
- package/lib/signIn.mjs +47 -0
- package/lib/spawn.mjs +330 -0
- package/lib/sweep.mjs +161 -0
- package/lib/sync.mjs +484 -0
- package/lib/uninstall-project.mjs +588 -0
- package/lib/uninstall.mjs +457 -0
- package/lib/update.mjs +302 -0
- package/lib/usageLimit.mjs +108 -0
- package/lib/windows-command.mjs +85 -0
- package/lib/worktree.mjs +1138 -0
- package/lib/writePolicy.mjs +45 -0
- package/package.json +58 -4
- package/spawn-runner.mjs +167 -0
|
@@ -0,0 +1,588 @@
|
|
|
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 glytchDirRow(root, { dryRun, log }) {
|
|
464
|
+
const dir = join(root, ".glytch");
|
|
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("glytchDir", ".glytch/", true, "not present", "no .glytch/ 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 ? `; .glytch/ kept (${others.join(", ")} remain)` : "";
|
|
483
|
+
if (!hadAgents) {
|
|
484
|
+
r = row("glytchDir", ".glytch/", true, "not present", `no .glytch/agents.json to remove${othersNote}`);
|
|
485
|
+
} else if (dryRun) {
|
|
486
|
+
r = wouldBeEmpty
|
|
487
|
+
? row("glytchDir", ".glytch/", true, "would delete", "would delete .glytch/agents.json, then .glytch/ (empty afterward)")
|
|
488
|
+
: row("glytchDir", ".glytch/", true, "would delete", `would delete .glytch/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("glytchDir", ".glytch/", true, "deleted", "deleted .glytch/agents.json, then .glytch/ (was empty)");
|
|
494
|
+
} else {
|
|
495
|
+
r = row("glytchDir", ".glytch/", true, "deleted", `deleted .glytch/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("glytchDir", ".glytch/", 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 glytchDirRow(root, { dryRun, log }));
|
|
586
|
+
rows.push(await registryRow(root, { env, dryRun, log, allOk: rows.every((r) => r.ok) }));
|
|
587
|
+
return rows;
|
|
588
|
+
}
|