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,288 @@
|
|
|
1
|
+
// Fresh configs write viewable (#2328). Existing glytch entries stay readable and keep their name (#2196).
|
|
2
|
+
// Add only the glytch entry, preserving comments, other servers, and any
|
|
3
|
+
// glytch entry the user already owns. Validate both before and after editing.
|
|
4
|
+
//
|
|
5
|
+
// RENAMED 2026-09-12 (brief 31, Move 3): the key written is `glytch`, not
|
|
6
|
+
// `board` - Codex gets `[mcp_servers.glytch]`, opencode gets `mcp.glytch`.
|
|
7
|
+
// An existing `board` entry recognised as ours - the old spawned-command
|
|
8
|
+
// shape, or a `board` entry already at our hosted URL from before this
|
|
9
|
+
// rename - is renamed to `glytch` in place, the same upgrade path
|
|
10
|
+
// `mcpjson.mjs` takes for `.mcp.json`. A `board` entry that is a stranger's
|
|
11
|
+
// own (a different URL, no recognisable legacy command) is left exactly
|
|
12
|
+
// alone, reported `present` as before.
|
|
13
|
+
//
|
|
14
|
+
// MOVED 2026-10-03: `prompt.glytch.ai` is retired for `app.viewable.one`
|
|
15
|
+
// (lib/mcpjson.mjs says why a redirect is not enough). An entry at the
|
|
16
|
+
// retired address counts as ours, and wherever a merge keeps an entry of ours
|
|
17
|
+
// (a `board` renamed to `glytch`, or an existing `glytch`) its `url` is
|
|
18
|
+
// replaced with the current one, other fields kept. A `glytch` entry at any
|
|
19
|
+
// other url is left alone.
|
|
20
|
+
import { mkdir, readFile, stat } from "node:fs/promises";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
22
|
+
import { parse as parseToml, stringify } from "smol-toml";
|
|
23
|
+
import { parse as parseJsonc, modify, applyEdits } from "jsonc-parser";
|
|
24
|
+
import { BOARD_MCP_URL, CODEX_TOOL_TIMEOUT_SEC, RETIRED_BOARD_MCP_URLS, entryIsAtRetiredUrl, isLegacyBoardCommand, moveRetiredUrl } from "./mcpjson.mjs";
|
|
25
|
+
import { writeFileAtomic } from "./atomic.mjs";
|
|
26
|
+
|
|
27
|
+
const object = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
28
|
+
export async function readOptional(path) {
|
|
29
|
+
try { return await readFile(path, "utf8"); }
|
|
30
|
+
catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Every server name already on disk for this agent, newest first, so sign-out
|
|
34
|
+
* can reach each credential before its entry is removed (#2196, #2328). */
|
|
35
|
+
export async function projectMcpServerKeys(root, agent) {
|
|
36
|
+
if (!root) return [];
|
|
37
|
+
try {
|
|
38
|
+
let servers;
|
|
39
|
+
if (agent === "codex") {
|
|
40
|
+
servers = parseToml(await readOptional(join(root, ".codex/config.toml")) ?? "").mcp_servers;
|
|
41
|
+
} else if (agent === "opencode") {
|
|
42
|
+
const text = await readOptional(join(root, "opencode.jsonc")) ?? await readOptional(join(root, "opencode.json"));
|
|
43
|
+
servers = parseJsonc(text ?? "{}")?.mcp;
|
|
44
|
+
} else {
|
|
45
|
+
servers = JSON.parse(await readOptional(join(root, ".mcp.json")) ?? "{}").mcpServers;
|
|
46
|
+
}
|
|
47
|
+
// "glytch" is the compatibility read: keep existing MCP names.
|
|
48
|
+
return ["viewable", "glytch"].filter((name) => Object.hasOwn(servers ?? {}, name));
|
|
49
|
+
} catch { /* The writer reports malformed configs; sign-in must not rewrite them. */ }
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Use the name already on disk for sign-in, including pre-Viewable installs (#2196).
|
|
54
|
+
* Reuse the same readers/parsers as the config writers below. */
|
|
55
|
+
export async function projectMcpServerKey(root, agent, fallback = "viewable") {
|
|
56
|
+
return (await projectMcpServerKeys(root, agent))[0] ?? fallback;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Remove a TOML table by its exact header - e.g. `"mcp_servers.board"` or
|
|
60
|
+
* `"mcp_servers.glytch"` - and any of its own sub-tables (`header.*`, e.g.
|
|
61
|
+
* the old `.env` table carrying `PM_BOARD_ROOT`), stopping at the next
|
|
62
|
+
* top-level table header or end of file. Returns null when no such header
|
|
63
|
+
* is found, so a caller never silently no-ops a rewrite it could not
|
|
64
|
+
* locate.
|
|
65
|
+
*
|
|
66
|
+
* This is a TEXT splice rather than a round trip through the TOML parser on
|
|
67
|
+
* purpose: smol-toml can stringify a whole document but not patch one line
|
|
68
|
+
* in place while keeping someone else's comments, which is the same reason
|
|
69
|
+
* `mergeCodexBoard` below always re-parses and checks the result before
|
|
70
|
+
* trusting it. Used here to remove a `board` table recognised as OURS
|
|
71
|
+
* (`boardEntryIsOurs` below, covering both the retired command shape and a
|
|
72
|
+
* `board` entry already at our hosted URL) - a hand-written custom entry
|
|
73
|
+
* never reaches this.
|
|
74
|
+
*
|
|
75
|
+
* EXPORTED (brief 31 review, 2026-09-12), parameterised on `header`, so
|
|
76
|
+
* lib/uninstall-project.mjs can remove `[mcp_servers.glytch]` with the exact
|
|
77
|
+
* same splice rather than keeping its own byte-identical copy. */
|
|
78
|
+
export function withoutTomlTable(text, header) {
|
|
79
|
+
const lines = text.split("\n");
|
|
80
|
+
const start = lines.findIndex((l) => l.trim() === `[${header}]`);
|
|
81
|
+
if (start === -1) return null;
|
|
82
|
+
let end = start + 1;
|
|
83
|
+
while (end < lines.length) {
|
|
84
|
+
const trimmed = lines[end].trim();
|
|
85
|
+
if (trimmed.startsWith("[") && !trimmed.startsWith(`[${header}.`)) break;
|
|
86
|
+
end++;
|
|
87
|
+
}
|
|
88
|
+
let from = start;
|
|
89
|
+
if (from > 0 && lines[from - 1].trim() === "") from--;
|
|
90
|
+
return [...lines.slice(0, from), ...lines.slice(end)].join("\n");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Replace the retired `url` inside one TOML table with the current one, as a
|
|
94
|
+
* TEXT edit of that single line (same reason as `withoutTomlTable`: keep
|
|
95
|
+
* someone else's comments and layout). Only the table's own key lines are
|
|
96
|
+
* searched - up to the next header of any kind. Returns null when the line
|
|
97
|
+
* cannot be located (an inline table, dotted keys), so the caller refuses
|
|
98
|
+
* rather than guesses. */
|
|
99
|
+
function withMovedTomlUrl(text, header, to) {
|
|
100
|
+
const lines = text.split("\n");
|
|
101
|
+
const start = lines.findIndex((l) => l.trim() === `[${header}]`);
|
|
102
|
+
if (start === -1) return null;
|
|
103
|
+
const pattern = new RegExp(`^(\\s*url\\s*=\\s*)(["'])(?:${RETIRED_BOARD_MCP_URLS.map(url => url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\2`);
|
|
104
|
+
for (let i = start + 1; i < lines.length && !lines[i].trim().startsWith("["); i++) {
|
|
105
|
+
if (pattern.test(lines[i])) {
|
|
106
|
+
lines[i] = lines[i].replace(pattern, (_all, lead, quote) => `${lead}${quote}${to}${quote}`);
|
|
107
|
+
return lines.join("\n");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Is this Codex `board` table ours - the retired spawned-command shape, or
|
|
114
|
+
* a `board` entry already sitting at our hosted URL from before this
|
|
115
|
+
* rename? Mirrors `boardEntryIsOurs` in lib/mcpjson.mjs; kept as its own
|
|
116
|
+
* function here because the TOML shape (`{url}`, no `type`) is Codex's own.
|
|
117
|
+
*
|
|
118
|
+
* EXPORTED (brief 31 review, 2026-09-12) for the same reuse reason as
|
|
119
|
+
* `withoutTomlTable` above. */
|
|
120
|
+
export function boardEntryIsOurs(entry, env) {
|
|
121
|
+
if (!object(entry)) return false;
|
|
122
|
+
if (isLegacyBoardCommand(entry.command, entry.args)) return true;
|
|
123
|
+
return typeof entry.url === "string" && (entry.url === BOARD_MCP_URL(env) || RETIRED_BOARD_MCP_URLS.includes(entry.url));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** opencode's own shape: a `command` ARRAY rather than separate
|
|
127
|
+
* `command`/`args` strings, plus `{type, url}` for the hosted shape. Kept as
|
|
128
|
+
* its own function (rather than reusing `boardEntryIsOurs` above) because
|
|
129
|
+
* opencode's `command` field is an array where Codex's and `.mcp.json`'s are
|
|
130
|
+
* separate `command`/`args` strings - EXPORTED for the same reuse reason as
|
|
131
|
+
* `withoutTomlTable` and `boardEntryIsOurs` above: lib/uninstall-project.mjs
|
|
132
|
+
* used to keep a byte-identical copy of exactly this check. */
|
|
133
|
+
export function opencodeBoardEntryIsOurs(entry, env) {
|
|
134
|
+
if (!object(entry)) return false;
|
|
135
|
+
if (Array.isArray(entry.command) && isLegacyBoardCommand(entry.command[0], entry.command.slice(1))) return true;
|
|
136
|
+
return typeof entry.url === "string" && (entry.url === BOARD_MCP_URL(env) || RETIRED_BOARD_MCP_URLS.includes(entry.url));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function mergeCodexBoard(existing, env = process.env) {
|
|
140
|
+
try {
|
|
141
|
+
const parsed = parseToml(existing ?? "");
|
|
142
|
+
if (parsed.mcp_servers !== undefined && !object(parsed.mcp_servers)) return { kind: "unreadable" };
|
|
143
|
+
const servers = parsed.mcp_servers ?? {};
|
|
144
|
+
// Compatibility: keep existing names; only fresh entries use viewable (#2196).
|
|
145
|
+
if (Object.hasOwn(servers, "viewable")) return { kind: "present" };
|
|
146
|
+
// A fresh entry gets the tool timeout (see CODEX_TOOL_TIMEOUT_SEC); an
|
|
147
|
+
// entry already there keeps its own settings.
|
|
148
|
+
const server = { url: BOARD_MCP_URL(env), tool_timeout_sec: CODEX_TOOL_TIMEOUT_SEC };
|
|
149
|
+
const boardEntry = servers.board;
|
|
150
|
+
const boardIsOurs = boardEntryIsOurs(boardEntry, env);
|
|
151
|
+
|
|
152
|
+
if (!Object.hasOwn(servers, "glytch")) {
|
|
153
|
+
if (!Object.hasOwn(servers, "board")) {
|
|
154
|
+
const added = stringify({ mcp_servers: { viewable: server } });
|
|
155
|
+
const text = `${existing ?? ""}\n${added}\n`;
|
|
156
|
+
// Inline tables cannot be extended. Refuse rather than rewrite
|
|
157
|
+
// someone's entire config and lose comments or alter unrelated
|
|
158
|
+
// settings.
|
|
159
|
+
parseToml(text);
|
|
160
|
+
return { kind: existing === null ? "create" : "add", text };
|
|
161
|
+
}
|
|
162
|
+
if (!boardIsOurs) return { kind: "present" };
|
|
163
|
+
const stripped = withoutTomlTable(existing ?? "", "mcp_servers.board");
|
|
164
|
+
// Could not safely locate the old table's textual span (a hand-edited
|
|
165
|
+
// inline table, say) - never guess at a splice, leave it exactly as
|
|
166
|
+
// the presence rule always has.
|
|
167
|
+
if (stripped === null) return { kind: "present" };
|
|
168
|
+
const glytchValue = isLegacyBoardCommand(boardEntry.command, boardEntry.args) ? server : moveRetiredUrl(boardEntry, env);
|
|
169
|
+
const added = stringify({ mcp_servers: { glytch: glytchValue } });
|
|
170
|
+
const text = `${stripped}\n${added}\n`;
|
|
171
|
+
const check = parseToml(text);
|
|
172
|
+
if (JSON.stringify(check.mcp_servers?.glytch) !== JSON.stringify(glytchValue)) return { kind: "unreadable" };
|
|
173
|
+
return { kind: "legacy", ...(entryIsAtRetiredUrl(boardEntry, env) ? { moved: true } : {}), text };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// `glytch` is already there. Drop a leftover `board` table that is ours;
|
|
177
|
+
// leave everything else, including a stranger's own "board", untouched.
|
|
178
|
+
// A `glytch` table at the retired address is ours too: its `url` line is
|
|
179
|
+
// replaced in place.
|
|
180
|
+
const glytchMoved = entryIsAtRetiredUrl(servers.glytch, env);
|
|
181
|
+
if (!boardIsOurs && !glytchMoved) return { kind: "present" };
|
|
182
|
+
let text = existing ?? "";
|
|
183
|
+
if (boardIsOurs) {
|
|
184
|
+
const stripped = withoutTomlTable(text, "mcp_servers.board");
|
|
185
|
+
if (stripped === null) return { kind: "present" };
|
|
186
|
+
text = stripped;
|
|
187
|
+
}
|
|
188
|
+
if (glytchMoved) {
|
|
189
|
+
// Not locatable as a plain `url = "..."` line: say so rather than
|
|
190
|
+
// report a dead address as an entry that is kept.
|
|
191
|
+
const moved = withMovedTomlUrl(text, "mcp_servers.glytch", BOARD_MCP_URL(env));
|
|
192
|
+
if (moved === null) return { kind: "unreadable" };
|
|
193
|
+
text = moved;
|
|
194
|
+
}
|
|
195
|
+
const check = parseToml(text);
|
|
196
|
+
if (!object(check.mcp_servers) || !Object.hasOwn(check.mcp_servers, "glytch")) return { kind: "unreadable" };
|
|
197
|
+
if (glytchMoved && check.mcp_servers.glytch?.url !== BOARD_MCP_URL(env)) return { kind: "unreadable" };
|
|
198
|
+
return { kind: "legacy", ...(glytchMoved ? { moved: true } : {}), text };
|
|
199
|
+
} catch { return { kind: "unreadable" }; }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function mergeOpencodeBoard(existing, env = process.env) {
|
|
203
|
+
const text = existing ?? "{}\n";
|
|
204
|
+
const errors = [];
|
|
205
|
+
const parsed = parseJsonc(text, errors, { allowTrailingComma: true });
|
|
206
|
+
if (errors.length || !object(parsed) || (parsed.mcp !== undefined && !object(parsed.mcp))) return { kind: "unreadable" };
|
|
207
|
+
if (Object.hasOwn(parsed.mcp ?? {}, "viewable")) return { kind: "present" };
|
|
208
|
+
const glytchPresent = Object.hasOwn(parsed.mcp ?? {}, "glytch");
|
|
209
|
+
const boardPresent = Object.hasOwn(parsed.mcp ?? {}, "board");
|
|
210
|
+
const existingBoard = parsed.mcp?.board;
|
|
211
|
+
const boardIsOurs = boardPresent && opencodeBoardEntryIsOurs(existingBoard, env);
|
|
212
|
+
const legacyCommand = boardIsOurs && object(existingBoard) && Array.isArray(existingBoard.command) &&
|
|
213
|
+
isLegacyBoardCommand(existingBoard.command[0], existingBoard.command.slice(1));
|
|
214
|
+
const server = { type: "remote", url: BOARD_MCP_URL(env), enabled: true };
|
|
215
|
+
const formattingOptions = { insertSpaces: true, tabSize: 2, eol: text.includes("\r\n") ? "\r\n" : "\n" };
|
|
216
|
+
|
|
217
|
+
if (!glytchPresent) {
|
|
218
|
+
if (boardPresent && !boardIsOurs) return { kind: "present" };
|
|
219
|
+
if (!boardPresent) {
|
|
220
|
+
const updated = applyEdits(text, modify(text, ["mcp", "viewable"], server, { formattingOptions }));
|
|
221
|
+
const updatedErrors = [];
|
|
222
|
+
const checked = parseJsonc(updated, updatedErrors, { allowTrailingComma: true });
|
|
223
|
+
if (updatedErrors.length || JSON.stringify(checked?.mcp?.viewable) !== JSON.stringify(server)) {
|
|
224
|
+
return { kind: "unreadable" };
|
|
225
|
+
}
|
|
226
|
+
return { kind: existing === null ? "create" : "add", text: updated };
|
|
227
|
+
}
|
|
228
|
+
// A `board` entry recognised as ours: move it to `glytch` and remove
|
|
229
|
+
// `board`. A legacy command entry is replaced with the canonical
|
|
230
|
+
// remote-server value; a url-match entry (already ours, just under the
|
|
231
|
+
// old key) keeps its own value untouched.
|
|
232
|
+
const glytchValue = legacyCommand ? server : moveRetiredUrl(existingBoard, env);
|
|
233
|
+
const edits = [
|
|
234
|
+
...modify(text, ["mcp", "glytch"], glytchValue, { formattingOptions }),
|
|
235
|
+
];
|
|
236
|
+
const afterGlytch = applyEdits(text, edits);
|
|
237
|
+
const removed = applyEdits(afterGlytch, modify(afterGlytch, ["mcp", "board"], undefined, { formattingOptions }));
|
|
238
|
+
const updatedErrors = [];
|
|
239
|
+
const checked = parseJsonc(removed, updatedErrors, { allowTrailingComma: true });
|
|
240
|
+
if (updatedErrors.length || JSON.stringify(checked?.mcp?.glytch) !== JSON.stringify(glytchValue) || Object.hasOwn(checked?.mcp ?? {}, "board")) {
|
|
241
|
+
return { kind: "unreadable" };
|
|
242
|
+
}
|
|
243
|
+
return { kind: "legacy", ...(entryIsAtRetiredUrl(existingBoard, env) ? { moved: true } : {}), text: removed };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// `glytch` is already there. Drop a leftover `board` entry that is ours;
|
|
247
|
+
// leave everything else, including a stranger's own "board", untouched.
|
|
248
|
+
// A `glytch` entry at the retired address is ours too: its `url` moves.
|
|
249
|
+
const glytchMoved = entryIsAtRetiredUrl(parsed.mcp?.glytch, env);
|
|
250
|
+
if (!boardIsOurs && !glytchMoved) return { kind: "present" };
|
|
251
|
+
let updated = text;
|
|
252
|
+
if (boardIsOurs) updated = applyEdits(updated, modify(updated, ["mcp", "board"], undefined, { formattingOptions }));
|
|
253
|
+
if (glytchMoved) updated = applyEdits(updated, modify(updated, ["mcp", "glytch", "url"], BOARD_MCP_URL(env), { formattingOptions }));
|
|
254
|
+
const updatedErrors = [];
|
|
255
|
+
const checked = parseJsonc(updated, updatedErrors, { allowTrailingComma: true });
|
|
256
|
+
if (updatedErrors.length || (boardIsOurs && Object.hasOwn(checked?.mcp ?? {}, "board"))) return { kind: "unreadable" };
|
|
257
|
+
if (glytchMoved && checked?.mcp?.glytch?.url !== BOARD_MCP_URL(env)) return { kind: "unreadable" };
|
|
258
|
+
return { kind: "legacy", ...(glytchMoved ? { moved: true } : {}), text: updated };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export async function syncProjectMcp(root, agent, { write = true, env = process.env } = {}) {
|
|
262
|
+
let relative = agent === "codex" ? ".codex/config.toml" : "opencode.json";
|
|
263
|
+
// OpenCode gives JSONC precedence; extend that file if it exists.
|
|
264
|
+
if (agent === "opencode" && await readOptional(join(root, "opencode.jsonc")) !== null) relative = "opencode.jsonc";
|
|
265
|
+
const path = join(root, relative);
|
|
266
|
+
const existing = await readOptional(path);
|
|
267
|
+
const merge = (agent === "codex" ? mergeCodexBoard : mergeOpencodeBoard)(existing, env);
|
|
268
|
+
const row = (ok, state, detail) => ({ key: agent, label: `${agent} Viewable server`, ok, state, detail });
|
|
269
|
+
if (merge.kind === "present") return row(true, "present", `${relative}: existing board connection kept`);
|
|
270
|
+
if (merge.kind === "unreadable") return row(false, "unreadable", `${relative} could not be safely extended; fix its config and run viewable init again. Nothing was overwritten.`);
|
|
271
|
+
if (!write) {
|
|
272
|
+
return merge.kind === "legacy"
|
|
273
|
+
? row(false, "legacy-command", merge.moved
|
|
274
|
+
? `${relative} still names the retired board address - run viewable init to move it to app.viewable.one`
|
|
275
|
+
: `${relative} still names the retired local MCP server - run viewable init to switch it to the hosted board URL`)
|
|
276
|
+
: row(false, "missing", `${relative} needs the viewable entry - run: viewable init`);
|
|
277
|
+
}
|
|
278
|
+
await mkdir(dirname(path), { recursive: true });
|
|
279
|
+
// Atomic replacement creates a new inode: carry forward private config
|
|
280
|
+
// permissions instead of reverting to the process's default creation mode.
|
|
281
|
+
const mode = existing === null ? 0o600 : (await stat(path)).mode & 0o777;
|
|
282
|
+
await writeFileAtomic(path, merge.text, "utf8", { mode });
|
|
283
|
+
return merge.kind === "legacy"
|
|
284
|
+
? row(true, "upgraded", merge.moved
|
|
285
|
+
? `${relative}: glytch entry moved to app.viewable.one`
|
|
286
|
+
: `${relative}: glytch entry upgraded from the old command shape to the hosted URL`)
|
|
287
|
+
: row(true, merge.kind, `${relative}: viewable entry registered for this project`);
|
|
288
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { lstatSync, realpathSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** Refuse symlinks below a linked root, including dangling links. The root
|
|
5
|
+
* itself may be an OS alias (for example macOS /var). Missing descendants
|
|
6
|
+
* are permitted for worktrees that have not yet been created. */
|
|
7
|
+
export function staysInProject(root, target) {
|
|
8
|
+
const base = resolve(root);
|
|
9
|
+
const path = resolve(root, target);
|
|
10
|
+
const rel = relative(base, path);
|
|
11
|
+
if (isAbsolute(rel) || rel === ".." || rel.startsWith(`..${sep}`) || resolve(base, rel) !== path) return false;
|
|
12
|
+
let current = base;
|
|
13
|
+
for (const part of rel.split(sep).filter(Boolean)) {
|
|
14
|
+
current = resolve(current, part);
|
|
15
|
+
try {
|
|
16
|
+
if (lstatSync(current).isSymbolicLink()) return false;
|
|
17
|
+
} catch (error) {
|
|
18
|
+
if (error.code !== "ENOENT") return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** One spelling per directory, for comparing two paths. The native realpath
|
|
25
|
+
* expands Windows 8.3 short names (`RUNNER~1`, as `os.tmpdir()` returns) to
|
|
26
|
+
* the long form Git prints, where the JS one keeps them. Windows paths are
|
|
27
|
+
* case-insensitive, so they are folded there. */
|
|
28
|
+
export function canonicalPath(path) {
|
|
29
|
+
const real = realpathSync.native(path);
|
|
30
|
+
return process.platform === "win32" ? real.toLowerCase() : real;
|
|
31
|
+
}
|
package/lib/refusal.mjs
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Refusing a call HERE rather than letting the app refuse the file THERE.
|
|
2
|
+
//
|
|
3
|
+
// The failure this exists to stop, observed twice on 2026-08-25: a session
|
|
4
|
+
// called report with a 74-character title, this bridge cheerfully wrote the
|
|
5
|
+
// ops file and returned success, and the APP refused the whole file - so the
|
|
6
|
+
// session believed it had reported, not one circle existed, and the error
|
|
7
|
+
// landed as a red panel on the user's screen instead of in the transcript of
|
|
8
|
+
// the session that could have fixed it in one turn.
|
|
9
|
+
//
|
|
10
|
+
// Two rules come out of that, and both are enforced by this module's shape:
|
|
11
|
+
//
|
|
12
|
+
// 1. EVERY constraint the bridge states, the bridge checks - before it writes
|
|
13
|
+
// anything. A documented rule nothing enforces is worse than no rule: it
|
|
14
|
+
// reads as a guarantee and behaves as a wish.
|
|
15
|
+
// 2. EVERY violation is reported at once. Fixing item 4 and then discovering
|
|
16
|
+
// item 7 is two more round trips and two more chances for a doomed file to
|
|
17
|
+
// reach him.
|
|
18
|
+
//
|
|
19
|
+
// Refusing is never truncating. Standing ruling (INBOX_OPS.md, "Reporting"):
|
|
20
|
+
// longer is refused, never shortened. Truncation is what produced a report
|
|
21
|
+
// ending mid-word with an ellipsis, and silently editing a session's headline
|
|
22
|
+
// is worse than making it write a shorter one.
|
|
23
|
+
|
|
24
|
+
/** A call refused before anything was written, carrying every reason. */
|
|
25
|
+
export class Refusal extends Error {
|
|
26
|
+
constructor(problems, what) {
|
|
27
|
+
super(formatProblems(problems, what));
|
|
28
|
+
this.name = "Refusal";
|
|
29
|
+
this.problems = problems;
|
|
30
|
+
this.what = what;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const isRefusal = (error) => error instanceof Refusal;
|
|
35
|
+
|
|
36
|
+
/** Collect problems, keep going, refuse once with all of them.
|
|
37
|
+
*
|
|
38
|
+
* A caller stops its pass early only when there is genuinely nothing left to
|
|
39
|
+
* learn - a `did` that is not an array has no items to walk. Everything
|
|
40
|
+
* per-item keeps walking, which is the whole point. */
|
|
41
|
+
export function problems() {
|
|
42
|
+
const found = [];
|
|
43
|
+
return {
|
|
44
|
+
note: (message) => found.push(message),
|
|
45
|
+
get list() {
|
|
46
|
+
return found;
|
|
47
|
+
},
|
|
48
|
+
get any() {
|
|
49
|
+
return found.length > 0;
|
|
50
|
+
},
|
|
51
|
+
/** Throw one Refusal carrying all of them, or return the value unchanged. */
|
|
52
|
+
settle(what, value) {
|
|
53
|
+
if (found.length > 0) throw new Refusal(found, what);
|
|
54
|
+
return value;
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The refusal a session reads. Same voice as the app's own failures
|
|
60
|
+
* (src/inboxOps.ts, src/boardApi.ts): name the field, the offending index,
|
|
61
|
+
* the actual number, the cap, and what to do instead. */
|
|
62
|
+
export function formatProblems(list, what = "call") {
|
|
63
|
+
const nothing = `Nothing was written: no file reached inbox/ops/, so nothing is half-landed on the board. Fix and call again.`;
|
|
64
|
+
if (list.length === 1) return `This ${what} was refused. ${list[0]}\n${nothing}`;
|
|
65
|
+
return [
|
|
66
|
+
`This ${what} was refused: ${list.length} things to fix.`,
|
|
67
|
+
...list.map((message, i) => ` ${i + 1}. ${message}`),
|
|
68
|
+
`${nothing} All ${list.length} are listed because the app stops at the FIRST one and refuses the whole file - and that refusal lands on the user's screen, not here.`,
|
|
69
|
+
].join("\n");
|
|
70
|
+
}
|
package/lib/registry.mjs
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// WHICH FOLDERS THIS MACHINE WATCHES: ~/.glytch/folders.json, a JSON list of
|
|
2
|
+
// absolute paths.
|
|
3
|
+
//
|
|
4
|
+
// The daemon that brief 19 installs runs ONCE per machine and not once per
|
|
5
|
+
// project, so something has to say which projects it is for. That is this
|
|
6
|
+
// file, and it is a list of paths and nothing else: no per-folder settings, no
|
|
7
|
+
// state, no cache. A folder is in it or it is not, and `glytch init` /
|
|
8
|
+
// `glytch forget` are the only two things that write it.
|
|
9
|
+
//
|
|
10
|
+
// GLYTCH_HOME EXISTS FOR THE TESTS, and only for them. Every path in this
|
|
11
|
+
// package that would otherwise land in the real ~/.glytch goes through
|
|
12
|
+
// glytchHome(), so a test suite that sets GLYTCH_HOME to a temp directory
|
|
13
|
+
// cannot touch the machine it runs on - which matters more here than usual,
|
|
14
|
+
// because the other half of this feature loads a LaunchAgent.
|
|
15
|
+
|
|
16
|
+
import { existsSync } from "node:fs";
|
|
17
|
+
import { mkdir, readFile, rm, stat } from "node:fs/promises";
|
|
18
|
+
import { homedir, tmpdir } from "node:os";
|
|
19
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
20
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
21
|
+
import { writeFileAtomic } from "./atomic.mjs";
|
|
22
|
+
import { acquireAdmission } from "./admission.mjs";
|
|
23
|
+
|
|
24
|
+
/** ~/.glytch, or GLYTCH_HOME when set. */
|
|
25
|
+
export const glytchHome = (env = process.env) =>
|
|
26
|
+
(env.VIEWABLE_HOME ?? env.GLYTCH_HOME)?.trim() || join(homedir(), ".glytch");
|
|
27
|
+
|
|
28
|
+
export const registryPath = (env = process.env) => join(glytchHome(env), "folders.json");
|
|
29
|
+
|
|
30
|
+
// Restore and Remove run in different processes. Reuse admission's exclusive
|
|
31
|
+
// file lock and dead-owner recovery, in a directory separate from run claims.
|
|
32
|
+
export async function withRegistryLock(fn, env = process.env) {
|
|
33
|
+
const deadline = Date.now() + 10_000;
|
|
34
|
+
for (;;) {
|
|
35
|
+
const held = await acquireAdmission(glytchHome(env), { directory: join(glytchHome(env), "registry") });
|
|
36
|
+
if (held.ok) {
|
|
37
|
+
try { return await fn(); }
|
|
38
|
+
finally { await held.release(); }
|
|
39
|
+
}
|
|
40
|
+
if (Date.now() >= deadline) throw new Error(held.detail);
|
|
41
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const restorePath = (folder, env) => join(glytchHome(env), "registry", `${createHash("sha256").update(resolve(folder)).digest("hex")}.restore`);
|
|
46
|
+
|
|
47
|
+
export async function folderRestoreGeneration(folder, env = process.env) {
|
|
48
|
+
try { return await readFile(restorePath(folder, env), "utf8"); }
|
|
49
|
+
catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Called under the registry lock BEFORE init restores the hosted row, so a
|
|
53
|
+
* failed local write cannot leave a successful restore exposed to old ops. */
|
|
54
|
+
export async function markFolderRestore(folder, env = process.env) {
|
|
55
|
+
await writeFileAtomic(restorePath(folder, env), randomUUID());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// TEMPORARY FOLDERS ARE NEVER REGISTERED (QA 2026-10-01, ticket e7). An agent
|
|
59
|
+
// once started a runner from its scratchpad, and "proof-project
|
|
60
|
+
// (/private/tmp/claude-501/...)" sat in Settings > Connection for days. A
|
|
61
|
+
// folder under the OS temp dir (or /tmp, /private/tmp, /var/folders) is
|
|
62
|
+
// somebody's scratch work, not a project. The exception is a SCRATCH HOME:
|
|
63
|
+
// when GLYTCH_HOME itself lives under a temp root this is a test or a verifier
|
|
64
|
+
// (see the header), and its temp folders are the whole point.
|
|
65
|
+
const tempRoots = () => [tmpdir(), "/tmp", "/private/tmp", "/var/folders", "/private/var/folders"].map((p) => resolve(p));
|
|
66
|
+
const underTemp = (path) => tempRoots().some((root) => path === root || path.startsWith(`${root}${sep}`));
|
|
67
|
+
export const isScratchFolder = (folder, env = process.env) =>
|
|
68
|
+
underTemp(resolve(folder)) && !underTemp(resolve(glytchHome(env)));
|
|
69
|
+
|
|
70
|
+
/** The folders with no git repo, for the presence body (QA 2026-10-01,
|
|
71
|
+
* ticket e2). Such a folder runs IN PLACE - `makeWorktree` finds no repo - so
|
|
72
|
+
* the prompt editor says so instead of promising "Isolated". A `.git` entry
|
|
73
|
+
* (the directory, or the file a worktree has) in the folder or any parent is
|
|
74
|
+
* a repo; a plain stat walk, because this runs on every beat. */
|
|
75
|
+
export function foldersWithoutGit(folders) {
|
|
76
|
+
return folders.filter((folder) => {
|
|
77
|
+
for (let dir = resolve(folder); ; dir = dirname(dir)) {
|
|
78
|
+
if (existsSync(join(dir, ".git"))) return false;
|
|
79
|
+
if (dirname(dir) === dir) return true;
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const isDir = async (path) => {
|
|
85
|
+
try {
|
|
86
|
+
return (await stat(path)).isDirectory();
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** The registered folders, absolute and de-duplicated, oldest first.
|
|
93
|
+
*
|
|
94
|
+
* A missing file is an empty list, never an error: a machine that has never
|
|
95
|
+
* run `init` is not broken, it is new. A file that is not JSON, or is JSON of
|
|
96
|
+
* the wrong shape, is also an empty list rather than a throw - the daemon
|
|
97
|
+
* reads this on every start, and a corrupt registry must not be the reason
|
|
98
|
+
* nothing watches. `init` rewrites it either way. */
|
|
99
|
+
export async function readRegistry(env = process.env) {
|
|
100
|
+
return (await readRegistryChecked(env)).folders;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The same list, and whether the file could actually be read as one.
|
|
104
|
+
*
|
|
105
|
+
* `readRegistry` collapses "missing", "not JSON" and "genuinely empty" into
|
|
106
|
+
* one empty list, which is right for a one-shot read at start and wrong for
|
|
107
|
+
* the daemon's RE-read (lib/multiwatch.mjs, 2026-09-06): reading an
|
|
108
|
+
* unreadable registry as "every folder was just removed" would stop every
|
|
109
|
+
* watcher on the machine over a file someone was half-way through editing.
|
|
110
|
+
* `ok: false` means "this is not news - keep the last good list".
|
|
111
|
+
*
|
|
112
|
+
* A file that is not there is `ok: false` for the same reason. The only
|
|
113
|
+
* writer in this package is writeFileAtomic, which renames a whole file into
|
|
114
|
+
* place, so a registry that has gone missing is something outside taking it
|
|
115
|
+
* away, and the daemon fails toward keeping what it already watches. A
|
|
116
|
+
* first-ever start is unaffected: it reads through `readRegistry`, gets the
|
|
117
|
+
* empty list, and still says to run `viewable init`. */
|
|
118
|
+
export async function readRegistryChecked(env = process.env) {
|
|
119
|
+
const none = { folders: [], ok: false };
|
|
120
|
+
let raw;
|
|
121
|
+
try {
|
|
122
|
+
raw = await readFile(registryPath(env), "utf8");
|
|
123
|
+
} catch {
|
|
124
|
+
return none;
|
|
125
|
+
}
|
|
126
|
+
let parsed;
|
|
127
|
+
try {
|
|
128
|
+
parsed = JSON.parse(raw);
|
|
129
|
+
} catch {
|
|
130
|
+
return none;
|
|
131
|
+
}
|
|
132
|
+
if (!Array.isArray(parsed)) return none;
|
|
133
|
+
const seen = new Set();
|
|
134
|
+
const out = [];
|
|
135
|
+
for (const entry of parsed) {
|
|
136
|
+
if (typeof entry !== "string" || entry.trim() === "") continue;
|
|
137
|
+
const path = resolve(entry.trim());
|
|
138
|
+
if (seen.has(path)) continue;
|
|
139
|
+
seen.add(path);
|
|
140
|
+
out.push(path);
|
|
141
|
+
}
|
|
142
|
+
return { folders: out, ok: true };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function writeRegistry(folders, env = process.env) {
|
|
146
|
+
await mkdir(glytchHome(env), { recursive: true });
|
|
147
|
+
await writeFileAtomic(registryPath(env), `${JSON.stringify(folders, null, 2)}\n`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Add a folder. Idempotent by absolute path: running `init` twice in one
|
|
151
|
+
* folder leaves one entry, and the second run is not an error. Returns
|
|
152
|
+
* {folders, added}. */
|
|
153
|
+
export async function addFolder(folder, env = process.env) {
|
|
154
|
+
const path = resolve(folder);
|
|
155
|
+
if (isScratchFolder(path, env)) throw new Error(`${path} is a temporary folder, and those are never registered. Run viewable init in the project folder itself.`);
|
|
156
|
+
const folders = await readRegistry(env);
|
|
157
|
+
if (folders.includes(path)) return { folders, added: false };
|
|
158
|
+
const next = [...folders, path];
|
|
159
|
+
await writeRegistry(next, env);
|
|
160
|
+
return { folders: next, added: true };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Write the folder list back unchanged. The running daemon reads a rewrite
|
|
164
|
+
* as a request for one beat from every folder it watches (multiwatch.mjs),
|
|
165
|
+
* which is how `viewable init` in a folder already watched gets the fresh beat
|
|
166
|
+
* it waits for (#813 review). A registry that is not there stays absent. */
|
|
167
|
+
export async function touchRegistry(env = process.env) {
|
|
168
|
+
const { folders, ok } = await readRegistryChecked(env);
|
|
169
|
+
if (!ok || folders.length === 0) return false;
|
|
170
|
+
await writeRegistry(folders, env);
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Remove a folder. Returns {folders, removed}. Forgetting a folder that was
|
|
175
|
+
* never registered is not an error either - the end state is what was asked
|
|
176
|
+
* for. */
|
|
177
|
+
export async function removeFolder(folder, env = process.env) {
|
|
178
|
+
const path = resolve(folder);
|
|
179
|
+
const folders = await readRegistry(env);
|
|
180
|
+
if (!folders.includes(path)) return { folders, removed: false };
|
|
181
|
+
const next = folders.filter((f) => f !== path);
|
|
182
|
+
await writeRegistry(next, env);
|
|
183
|
+
return { folders: next, removed: true };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Drop entries whose folder no longer exists. Returns the paths dropped, so
|
|
187
|
+
* the caller can log one line each (the sweep does). A folder that is gone is
|
|
188
|
+
* gone: nothing here creates it back, and nothing exits over it. */
|
|
189
|
+
export async function pruneMissing(env = process.env) {
|
|
190
|
+
const folders = await readRegistry(env);
|
|
191
|
+
const kept = [];
|
|
192
|
+
const dropped = [];
|
|
193
|
+
for (const folder of folders) {
|
|
194
|
+
if (await isDir(folder)) kept.push(folder);
|
|
195
|
+
else dropped.push(folder);
|
|
196
|
+
}
|
|
197
|
+
if (dropped.length > 0) await writeRegistry(kept, env);
|
|
198
|
+
return { kept, dropped };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Forget everything. Only the tests and an uninstall would want this. */
|
|
202
|
+
export async function clearRegistry(env = process.env) {
|
|
203
|
+
await rm(registryPath(env), { force: true }).catch(() => {});
|
|
204
|
+
}
|