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.
- package/CHANGELOG.md +1483 -0
- package/LICENSE +21 -0
- package/README.md +738 -2
- package/SPAWN.md +2108 -0
- package/glytch.mjs +517 -0
- package/lib/acp.mjs +1018 -0
- package/lib/admission.mjs +105 -0
- package/lib/agent-selection.mjs +124 -0
- package/lib/agents.mjs +914 -0
- package/lib/atomic.mjs +96 -0
- package/lib/auth.mjs +1491 -0
- package/lib/authenticatedHttp.mjs +75 -0
- package/lib/boardside.mjs +225 -0
- package/lib/child.mjs +1292 -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 +767 -0
- package/lib/codexApps.mjs +344 -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 +629 -0
- package/lib/devserver.mjs +670 -0
- package/lib/digest.mjs +290 -0
- package/lib/doctor.mjs +464 -0
- package/lib/exitClass.mjs +64 -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 +173 -0
- package/lib/multiwatch.mjs +428 -0
- package/lib/pagePictures.mjs +396 -0
- package/lib/pageRoutes.mjs +70 -0
- package/lib/paths.mjs +10 -0
- package/lib/permissionAsk.mjs +418 -0
- package/lib/picker.mjs +300 -0
- package/lib/platform.mjs +60 -0
- package/lib/previewGateway.mjs +130 -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 +323 -0
- package/lib/restartPass.mjs +151 -0
- package/lib/retired-address.mjs +78 -0
- package/lib/rowsAnswers.mjs +388 -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 +1640 -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 +145 -0
- package/lib/runnerWorkspace.mjs +177 -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 +589 -0
- package/lib/uninstall.mjs +464 -0
- package/lib/update.mjs +302 -0
- package/lib/usageLimit.mjs +108 -0
- package/lib/viewableFolder.mjs +105 -0
- package/lib/windows-command.mjs +85 -0
- package/lib/worktree.mjs +1142 -0
- package/lib/writePolicy.mjs +45 -0
- package/package.json +58 -4
- package/spawn-runner.mjs +167 -0
|
@@ -0,0 +1,464 @@
|
|
|
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 { viewableHome, 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, .viewable/agents.json and legacy .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, .viewable/agents.json and legacy .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 = tokens.length === 0;
|
|
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 !== true || response.status !== 200) {
|
|
265
|
+
if (badStatus === null) badStatus = response?.status ?? "unknown";
|
|
266
|
+
}
|
|
267
|
+
} catch {
|
|
268
|
+
networkFailed = true;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
// Older servers answered 200 without recognising personal keys. Verify
|
|
272
|
+
// refusal at the board door before discarding our only retry credential.
|
|
273
|
+
if (badStatus === null && !networkFailed) {
|
|
274
|
+
for (const token of tokens.filter((value) => value.startsWith("pmb_"))) {
|
|
275
|
+
try {
|
|
276
|
+
const response = await fetchImpl(`${appUrl}/mcp`, {
|
|
277
|
+
method: "POST",
|
|
278
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
279
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
|
|
280
|
+
});
|
|
281
|
+
if (response?.status !== 401) badStatus = response?.status ?? "unknown";
|
|
282
|
+
} catch {
|
|
283
|
+
networkFailed = true;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
// A refusal or missing response leaves revocation unconfirmed. The
|
|
288
|
+
// credential is kept rather than deleted here, and the caller
|
|
289
|
+
// (runUninstall) skips deleting ~/.glytch too - the returned
|
|
290
|
+
// `keptCredential` flag is how it knows - so a re-run of `glytch
|
|
291
|
+
// uninstall` still has a token on disk to retry the revoke with. Deleting
|
|
292
|
+
// it now would strand the grant at the board with nothing left on this
|
|
293
|
+
// machine that could ever ask it to revoke again.
|
|
294
|
+
if (badStatus !== null || networkFailed) {
|
|
295
|
+
return {
|
|
296
|
+
...warnRow(
|
|
297
|
+
"revoke",
|
|
298
|
+
"Board sign-in",
|
|
299
|
+
"could not revoke",
|
|
300
|
+
`could not revoke at ${revokeUrl} (${badStatus !== null ? `HTTP ${badStatus}` : "no confirmed response"}); the credential is kept locally so a retry can revoke it - or revoke it in Settings`,
|
|
301
|
+
),
|
|
302
|
+
keptCredential: true,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
// Only discard our retry credential after a confirmed server response.
|
|
306
|
+
await logout(env);
|
|
307
|
+
return row("revoke", "Board sign-in", true, "revoked", `revoked at ${revokeUrl} and removed the local credential`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Step 2, row 3: delete `~/.glytch` outright - folders.json, auth.json,
|
|
311
|
+
* logs/, the cloudflared cache, and everything else this package ever wrote
|
|
312
|
+
* under it. Never anything outside viewableHome(). */
|
|
313
|
+
export async function deleteHomeRow({ env, dryRun, keepLogin }) {
|
|
314
|
+
const home = viewableHome(env);
|
|
315
|
+
if (keepLogin) {
|
|
316
|
+
return warnRow("home", "Delete ~/.glytch", "kept", `kept - the sign-in credential lives at ${join(home, "auth.json")} (--keep-login)`);
|
|
317
|
+
}
|
|
318
|
+
if (dryRun) {
|
|
319
|
+
return row("home", "Delete ~/.glytch", true, "would delete", home);
|
|
320
|
+
}
|
|
321
|
+
await rm(home, { recursive: true, force: true });
|
|
322
|
+
return row("home", "Delete ~/.glytch", true, "deleted", home);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** The two steps this command cannot do for you, as hints under step 3.
|
|
326
|
+
* "Is this a checkout" is asked of the RUNNING script's own path - a global
|
|
327
|
+
* install's is a symlink through some `node_modules/.bin`, and a checkout's
|
|
328
|
+
* is not - the same distinguishing fact logindaemon.mjs's own daemon-version
|
|
329
|
+
* check reads a package.json beside a script path for. */
|
|
330
|
+
export function uninstallGuideLines(scriptPath = process.argv[1]) {
|
|
331
|
+
const globalInstall = /node_modules[\\/]/.test(String(scriptPath ?? ""));
|
|
332
|
+
return [
|
|
333
|
+
globalInstall
|
|
334
|
+
? "npm uninstall -g viewable-one glytch"
|
|
335
|
+
: "This is a checkout: run `npm unlink -g viewable-one` from bridge/.",
|
|
336
|
+
"Unlink the folder in the app's Project panel.",
|
|
337
|
+
];
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** The whole command. Returns the exit code; the caller (glytch.mjs) is the
|
|
341
|
+
* one that calls `process.exit`, same as every other one-shot command here. */
|
|
342
|
+
export async function runUninstall({
|
|
343
|
+
root,
|
|
344
|
+
args = [],
|
|
345
|
+
options = uninstallOptions(args),
|
|
346
|
+
env = process.env,
|
|
347
|
+
exec = execProbe,
|
|
348
|
+
fetchImpl = fetch,
|
|
349
|
+
daemon = loginDaemon({ env }),
|
|
350
|
+
uninstallProjectFn = uninstallProject,
|
|
351
|
+
detect = detectAgents,
|
|
352
|
+
isTTY = Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
353
|
+
ask = readlineAsk(),
|
|
354
|
+
log = () => {},
|
|
355
|
+
color = colorEnabled(),
|
|
356
|
+
} = {}) {
|
|
357
|
+
const paint = { color };
|
|
358
|
+
const roots = options.project ? [root] : await readRegistry(env);
|
|
359
|
+
const plan = uninstallPlan(options, { folderCount: roots.length });
|
|
360
|
+
const proceed = await confirmUninstall({ options, plan, isTTY, ask, log, color });
|
|
361
|
+
if (!proceed) return 1;
|
|
362
|
+
|
|
363
|
+
const titles = options.project ? ["Projects", "What is left for you"] : ["Projects", "Your computer", "What is left for you"];
|
|
364
|
+
const allRows = [];
|
|
365
|
+
let n = 1;
|
|
366
|
+
|
|
367
|
+
log("");
|
|
368
|
+
log(step(n, titles.length, "Projects", paint));
|
|
369
|
+
n += 1;
|
|
370
|
+
if (roots.length === 0) {
|
|
371
|
+
log(warn("No project folders are registered.", paint));
|
|
372
|
+
} else {
|
|
373
|
+
const agents = await detect({ exec, env });
|
|
374
|
+
for (const projectRoot of roots) {
|
|
375
|
+
log("");
|
|
376
|
+
log(bold(projectRoot, paint));
|
|
377
|
+
// BUG 3, found 2026-09-12 by a real, non-dry run: `uninstallProject`'s
|
|
378
|
+
// `log` calls back with the ROW ITSELF, once per row, in the same
|
|
379
|
+
// order the returned array carries them (its own test: "a `log`
|
|
380
|
+
// callback sees every row as it is produced") - a live-progress hook,
|
|
381
|
+
// not a string logger. This used to forward each row straight into
|
|
382
|
+
// `hint()`, which stringifies whatever it is given
|
|
383
|
+
// (`String(aPlainObject)` is "[object Object]") and printed eight of
|
|
384
|
+
// those above the real rows on a real project. Painting each row here,
|
|
385
|
+
// as it lands, is also the ONLY place these rows get printed - nothing
|
|
386
|
+
// re-walks the returned array below, so a row is painted once, not
|
|
387
|
+
// twice.
|
|
388
|
+
let rows;
|
|
389
|
+
try {
|
|
390
|
+
rows = await uninstallProjectFn(projectRoot, {
|
|
391
|
+
agents,
|
|
392
|
+
exec,
|
|
393
|
+
env,
|
|
394
|
+
dryRun: options.dryRun,
|
|
395
|
+
log: (r) => { for (const line of printedRows([r], paint)) log(line); },
|
|
396
|
+
});
|
|
397
|
+
} catch (error) {
|
|
398
|
+
// U4: uninstallProjectFn is documented to never throw, but one bad
|
|
399
|
+
// folder (a config path that turned out to be a directory, a
|
|
400
|
+
// permission error) must not abort every folder after it and the
|
|
401
|
+
// machine steps that follow. This mirrors the per-step wrapping in
|
|
402
|
+
// lib/uninstall-project.mjs itself.
|
|
403
|
+
const failed = row("project", projectRoot, false, "error", `${error?.code ? `${error.code}: ` : ""}${error?.message ?? String(error)}`);
|
|
404
|
+
for (const line of printedRows([failed], paint)) log(line);
|
|
405
|
+
rows = [failed];
|
|
406
|
+
}
|
|
407
|
+
allRows.push(...(rows ?? []));
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (!options.project) {
|
|
412
|
+
log("");
|
|
413
|
+
log(step(n, titles.length, "Your computer", paint));
|
|
414
|
+
n += 1;
|
|
415
|
+
|
|
416
|
+
// U3: `allRows` at this point is exactly the project rows (nothing
|
|
417
|
+
// machine-level has been pushed yet). A project step that failed means
|
|
418
|
+
// this run did not fully undo what `init` did to that folder - deleting
|
|
419
|
+
// ~/.glytch now would remove the only thing a retry has to compare
|
|
420
|
+
// against, and would take the registry with it before registryRow's own
|
|
421
|
+
// U3 gate ever got a chance to leave the folder registered.
|
|
422
|
+
const projectsOk = allRows.every((r) => r.ok);
|
|
423
|
+
|
|
424
|
+
const daemonRow = await daemonUninstallRow({ daemon, env, dryRun: options.dryRun });
|
|
425
|
+
for (const line of printedRows([daemonRow], paint)) log(line);
|
|
426
|
+
allRows.push(daemonRow);
|
|
427
|
+
|
|
428
|
+
const stored = await readAuthFile(env);
|
|
429
|
+
const revoke = await revokeRow({ stored, env, fetchImpl, dryRun: options.dryRun, keepLogin: options.keepLogin });
|
|
430
|
+
for (const line of printedRows([revoke], paint)) log(line);
|
|
431
|
+
allRows.push(revoke);
|
|
432
|
+
|
|
433
|
+
// An unconfirmed revoke keeps the credential on disk for retry. Deleting
|
|
434
|
+
// ~/.glytch now would take that credential with it and strand the grant
|
|
435
|
+
// with nothing left able to retry the revoke. U3: a failed project step
|
|
436
|
+
// (checked above, before this section pushed any machine rows) means a
|
|
437
|
+
// retry needs ~/.glytch left in place too.
|
|
438
|
+
const home = !projectsOk
|
|
439
|
+
? warnRow(
|
|
440
|
+
"home",
|
|
441
|
+
"Delete ~/.glytch",
|
|
442
|
+
"left in place",
|
|
443
|
+
"a project step failed - ~/.glytch was left in place so `viewable uninstall` can retry",
|
|
444
|
+
)
|
|
445
|
+
: revoke.keptCredential
|
|
446
|
+
? warnRow(
|
|
447
|
+
"home",
|
|
448
|
+
"Delete ~/.glytch",
|
|
449
|
+
"kept",
|
|
450
|
+
"the board sign-in could not be revoked and its credential was kept so a retry can revoke it; ~/.glytch was not deleted",
|
|
451
|
+
)
|
|
452
|
+
: await deleteHomeRow({ env, dryRun: options.dryRun, keepLogin: options.keepLogin });
|
|
453
|
+
for (const line of printedRows([home], paint)) log(line);
|
|
454
|
+
allRows.push(home);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
log("");
|
|
458
|
+
log(step(n, titles.length, "What is left for you", paint));
|
|
459
|
+
for (const line of uninstallGuideLines()) log(hint(line, paint));
|
|
460
|
+
|
|
461
|
+
log("");
|
|
462
|
+
log(summary(allRows, paint));
|
|
463
|
+
return allRows.every((r) => r.ok) ? 0 : 1;
|
|
464
|
+
}
|