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
package/lib/update.mjs
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// "THERE IS A NEWER ONE", once a day and no more.
|
|
2
|
+
//
|
|
3
|
+
// A daemon installed once and never thought about again is a daemon that
|
|
4
|
+
// stays on whatever version the tester installed in September. So it asks npm
|
|
5
|
+
// what the latest is, once a day, and writes the answer into the mark file
|
|
6
|
+
// the app already reads every tick - `version` and `updateAvailable`, beside
|
|
7
|
+
// `at`. NEW KEYS ONLY: `readRunnerPresence` in the app reads `at` exactly as
|
|
8
|
+
// it did, an old app ignores what it does not know, and an old runner writing
|
|
9
|
+
// neither key still satisfies a new app. That is the whole compatibility
|
|
10
|
+
// story and it is why nothing existing was renamed.
|
|
11
|
+
//
|
|
12
|
+
// THE DAILY CHECK IS A NOTICE. The checker never downloads, installs or
|
|
13
|
+
// restarts anything: an auto-updating daemon that spends credits is a
|
|
14
|
+
// different decision from the one ruling 1 made. The app shows one quiet line
|
|
15
|
+
// and the person explicitly runs `viewable update` when they choose.
|
|
16
|
+
|
|
17
|
+
import { createRequire } from "node:module";
|
|
18
|
+
import { execFile } from "node:child_process";
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
20
|
+
import { realpath } from "node:fs/promises";
|
|
21
|
+
import { basename, join, posix, win32 } from "node:path";
|
|
22
|
+
import { readRunnerHealth, waitForRunnerHealth } from "./runner-health.mjs";
|
|
23
|
+
import { noDaemonInstall } from "./logindaemon.mjs";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
|
|
26
|
+
import { runnableCommand } from "./windows-command.mjs";
|
|
27
|
+
|
|
28
|
+
export const DAY_MS = 86_400_000;
|
|
29
|
+
export const PACKAGE_NAME = "viewable-one";
|
|
30
|
+
/** npm over a cold cache is slow and this runs inside `doctor`, where a
|
|
31
|
+
* person is waiting. Offline is "unknown", not a hang. */
|
|
32
|
+
export const NPM_TIMEOUT_MS = 8_000;
|
|
33
|
+
|
|
34
|
+
/** This package's own version, read from the package.json beside it. */
|
|
35
|
+
export function packageVersion() {
|
|
36
|
+
try {
|
|
37
|
+
const path = fileURLToPath(new URL("../package.json", import.meta.url));
|
|
38
|
+
return JSON.parse(readFileSync(path, "utf8")).version ?? "0.0.0";
|
|
39
|
+
} catch {
|
|
40
|
+
return "0.0.0";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Compare two dotted versions. Returns >0 when a is newer. Pre-release tags
|
|
45
|
+
* are ignored rather than ordered: "is there a newer one" does not need the
|
|
46
|
+
* full semver grammar, and half an implementation of it would be worse than
|
|
47
|
+
* none. */
|
|
48
|
+
export function compareVersions(a, b) {
|
|
49
|
+
const parts = (v) =>
|
|
50
|
+
String(v ?? "")
|
|
51
|
+
.split("-")[0]
|
|
52
|
+
.split(".")
|
|
53
|
+
.map((n) => Number(n) || 0);
|
|
54
|
+
const left = parts(a);
|
|
55
|
+
const right = parts(b);
|
|
56
|
+
for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
|
|
57
|
+
const diff = (left[i] ?? 0) - (right[i] ?? 0);
|
|
58
|
+
if (diff !== 0) return diff;
|
|
59
|
+
}
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Ask npm. Returns the version string, or null for offline / not published /
|
|
64
|
+
* anything else. `exec` is injected so the tests never touch the network.
|
|
65
|
+
*
|
|
66
|
+
* npm installs as `npm.cmd` on Windows, so this goes through the same
|
|
67
|
+
* `runnableCommand` resolution as every other binary this package looks for -
|
|
68
|
+
* otherwise the version row lies and says "offline" on a machine with a
|
|
69
|
+
* perfectly good connection. */
|
|
70
|
+
export const execNpm = (args, { timeoutMs = NPM_TIMEOUT_MS, platform = process.platform, env = process.env, exists, readFile, execImpl = execFile } = {}) =>
|
|
71
|
+
new Promise((resolve) => {
|
|
72
|
+
let resolved;
|
|
73
|
+
try {
|
|
74
|
+
resolved = runnableCommand("npm", args, { platform, env, ...(exists ? { exists } : {}), ...(readFile ? { readFile } : {}) });
|
|
75
|
+
} catch {
|
|
76
|
+
resolve({ ok: false, stdout: "" });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
execImpl(resolved.file, resolved.args, { timeout: timeoutMs, windowsHide: true, env, shell: resolved.shell }, (error, stdout) =>
|
|
80
|
+
resolve({ ok: !error, stdout: String(stdout ?? "") }),
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
export async function latestFromNpm({ exec = execNpm, name = PACKAGE_NAME, timeoutMs = NPM_TIMEOUT_MS } = {}) {
|
|
85
|
+
const result = await exec(["view", name, "version"], { timeoutMs });
|
|
86
|
+
if (!result?.ok) return null;
|
|
87
|
+
const version = String(result.stdout).trim().split(/\s+/)[0];
|
|
88
|
+
return /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version) ? version : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The daily checker the daemon's beat calls.
|
|
92
|
+
*
|
|
93
|
+
* `mark()` is SYNCHRONOUS and cheap, because it is called from the liveness
|
|
94
|
+
* beat and the beat must never wait on the network: it returns what is known
|
|
95
|
+
* now and starts a check in the background if one is due. The first beat of a
|
|
96
|
+
* process therefore writes `updateAvailable: false` and the next one, after
|
|
97
|
+
* npm answers, writes the truth. */
|
|
98
|
+
export function makeUpdateChecker({
|
|
99
|
+
version = packageVersion(),
|
|
100
|
+
latest = latestFromNpm,
|
|
101
|
+
intervalMs = DAY_MS,
|
|
102
|
+
now = () => Date.now(),
|
|
103
|
+
log = () => {},
|
|
104
|
+
} = {}) {
|
|
105
|
+
// null rather than 0, so the FIRST beat always asks. With Date.now() a zero
|
|
106
|
+
// would happen to work; with an injected clock starting at zero it silently
|
|
107
|
+
// never checks, and the daemon that never checks is the whole failure this
|
|
108
|
+
// module is for.
|
|
109
|
+
let checkedAt = null;
|
|
110
|
+
let known = null;
|
|
111
|
+
let running = false;
|
|
112
|
+
const state = () => ({
|
|
113
|
+
version,
|
|
114
|
+
updateAvailable: known !== null && compareVersions(known, version) > 0,
|
|
115
|
+
...(known ? { latest: known } : {}),
|
|
116
|
+
});
|
|
117
|
+
const run = async () => {
|
|
118
|
+
running = true;
|
|
119
|
+
checkedAt = now();
|
|
120
|
+
try {
|
|
121
|
+
const found = await latest();
|
|
122
|
+
if (found) {
|
|
123
|
+
known = found;
|
|
124
|
+
if (compareVersions(found, version) > 0) log(`update available: ${version} -> ${found} (npm i -g ${PACKAGE_NAME})`);
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
/* offline is not an error worth a line every day */
|
|
128
|
+
} finally {
|
|
129
|
+
running = false;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
return {
|
|
133
|
+
state,
|
|
134
|
+
/** What goes into the mark file, and a check kicked off when one is due. */
|
|
135
|
+
mark() {
|
|
136
|
+
if (!running && (checkedAt === null || now() - checkedAt >= intervalMs)) void run();
|
|
137
|
+
return { version: state().version, updateAvailable: state().updateAvailable };
|
|
138
|
+
},
|
|
139
|
+
/** Await one check. `doctor` and the tests use this. */
|
|
140
|
+
async once() {
|
|
141
|
+
await run();
|
|
142
|
+
return state();
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A new terminal starts with the normal system PATH, then reads the user's
|
|
148
|
+
* shell startup files. Never mistake a one-off PATH export for persistence.
|
|
149
|
+
* Commands are fixed strings; credentials never enter a shell argument. */
|
|
150
|
+
export async function verifyInstalledCli({
|
|
151
|
+
env = process.env, platform = process.platform, expectedVersion = packageVersion(),
|
|
152
|
+
scriptPath = fileURLToPath(new URL("../glytch.mjs", import.meta.url)),
|
|
153
|
+
// SIGKILL: an interactive zsh ignores SIGTERM, so a hung `mise which` from
|
|
154
|
+
// a shell-function manager (mise activate, asdf before 0.16) would hang init.
|
|
155
|
+
execFileImpl = execFile,
|
|
156
|
+
exec = (file, args, options) => new Promise((resolve) => {
|
|
157
|
+
execFileImpl(file, args, { ...options, shell: false, windowsHide: true, timeout: 15_000, killSignal: "SIGKILL" },
|
|
158
|
+
(error, stdout) => resolve({ ok: !error, stdout: String(stdout ?? "") }));
|
|
159
|
+
}),
|
|
160
|
+
resolvePath = realpath, resolveCommand = runnableCommand, allowVoltaPackage = false, log = () => {},
|
|
161
|
+
} = {}) {
|
|
162
|
+
const cleanEnv = { ...env };
|
|
163
|
+
// Compatibility: also strip the old credential alias from installer children.
|
|
164
|
+
delete cleanEnv.GLYTCH_BOARD_TOKEN;
|
|
165
|
+
delete cleanEnv.VIEWABLE_BOARD_TOKEN;
|
|
166
|
+
delete cleanEnv.GLYTCH_SETUP_ID;
|
|
167
|
+
delete cleanEnv.VIEWABLE_SETUP_ID;
|
|
168
|
+
let result;
|
|
169
|
+
const shell = env.SHELL || "/bin/sh";
|
|
170
|
+
if (platform === "win32") {
|
|
171
|
+
// Read persisted PATH, not just the launching terminal's stale environment.
|
|
172
|
+
const script = '$env:Path=[Environment]::GetEnvironmentVariable("Path","Machine")+";"+[Environment]::GetEnvironmentVariable("Path","User"); $c=(Get-Command glytch.cmd -ErrorAction Stop).Source; Write-Output "GLYTCH_CLI=$c"; & $c -v; exit $LASTEXITCODE';
|
|
173
|
+
result = await exec("powershell.exe", ["-NonInteractive", "-Command", script], { env: cleanEnv });
|
|
174
|
+
} else {
|
|
175
|
+
cleanEnv.PATH = "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin";
|
|
176
|
+
result = await exec(shell, ["-lic", 'printf "GLYTCH_CLI=%s\\n" "$(command -v glytch)"; glytch -v'], { env: cleanEnv });
|
|
177
|
+
}
|
|
178
|
+
const output = result?.stdout?.trim() ?? "";
|
|
179
|
+
const command = /^GLYTCH_CLI=(.+)$/m.exec(output)?.[1]?.trim();
|
|
180
|
+
const version = output.split(/\r?\n/).at(-1)?.trim();
|
|
181
|
+
let actualScript;
|
|
182
|
+
let verifiedScriptPath = scriptPath;
|
|
183
|
+
let mustMatchScript = platform === "win32";
|
|
184
|
+
try {
|
|
185
|
+
// `command -v` prints a bare name for a shell function or alias, and a
|
|
186
|
+
// relative name would resolve against the folder init runs in.
|
|
187
|
+
if (platform !== "win32" && command && !posix.isAbsolute(command)) throw new Error("Relative CLI command");
|
|
188
|
+
const runningScript = await resolvePath(scriptPath).catch(() => scriptPath);
|
|
189
|
+
if (platform === "win32" && command) {
|
|
190
|
+
const resolved = resolveCommand(command, [], { platform, env: cleanEnv });
|
|
191
|
+
actualScript = resolved.args[0];
|
|
192
|
+
} else if (command) actualScript = command;
|
|
193
|
+
// Volta puts one shared launcher (volta-shim) on PATH for every tool it
|
|
194
|
+
// installs, so the command never resolves to our script. Ask Volta, in the
|
|
195
|
+
// same fresh shell, which file that launcher would run (#707).
|
|
196
|
+
if (platform !== "win32" && actualScript && basename(await resolvePath(actualScript)) === "volta-shim") {
|
|
197
|
+
mustMatchScript = true;
|
|
198
|
+
// Homebrew's bin directory may be absent from the fresh shell's PATH.
|
|
199
|
+
// Use the binary beside the resolved launcher, quoting it as shell data.
|
|
200
|
+
const voltaPath = posix.join(posix.dirname(await resolvePath(actualScript)), "volta");
|
|
201
|
+
const volta = await exec(shell, ["-lic", `'${voltaPath.replaceAll("'", "'\\''")}' which glytch`], { env: cleanEnv });
|
|
202
|
+
actualScript = volta?.ok ? volta.stdout.trim().split(/\r?\n/).at(-1)?.trim() : null;
|
|
203
|
+
// Only update may replace npm's Node-image guess with Volta's package
|
|
204
|
+
// image. Init must still verify the running script before registering it.
|
|
205
|
+
if (allowVoltaPackage) {
|
|
206
|
+
// Resolve the PATH directory before traversing out of Volta's bin:
|
|
207
|
+
// its spelling may itself be a symlink outside VOLTA_HOME.
|
|
208
|
+
const shimDirectory = await resolvePath(posix.dirname(command));
|
|
209
|
+
const packageScript = posix.join(shimDirectory, "..", "tools", "image", "packages", PACKAGE_NAME, "lib", "node_modules", PACKAGE_NAME, "glytch.mjs");
|
|
210
|
+
verifiedScriptPath = await resolvePath(packageScript);
|
|
211
|
+
}
|
|
212
|
+
} else if (platform !== "win32" && actualScript) {
|
|
213
|
+
actualScript = await resolvePath(actualScript);
|
|
214
|
+
// asdf uses a script shim; mise may use a script or a symlink to mise.
|
|
215
|
+
// Read the launcher rather than assuming a default shims directory:
|
|
216
|
+
// both managers support custom locations.
|
|
217
|
+
let launcher = "";
|
|
218
|
+
if (actualScript !== runningScript && basename(actualScript) !== "mise") {
|
|
219
|
+
try { launcher = readFileSync(actualScript, "utf8").slice(0, 4096); } catch { /* path-only diagnostic below */ }
|
|
220
|
+
}
|
|
221
|
+
const manager = basename(actualScript) === "mise" || /^exec .*\bmise\b/m.test(launcher) ? "mise"
|
|
222
|
+
: /^exec .*\basdf\b/m.test(launcher) ? "asdf" : null;
|
|
223
|
+
if (manager) {
|
|
224
|
+
const located = await exec(shell, ["-lic", manager === "asdf" ? "asdf which glytch" : "mise which glytch"], { env: cleanEnv });
|
|
225
|
+
actualScript = located?.ok ? located.stdout.trim().split(/\r?\n/).at(-1)?.trim() : null;
|
|
226
|
+
if (!actualScript) throw new Error("No installed script");
|
|
227
|
+
verifiedScriptPath = await resolvePath(actualScript);
|
|
228
|
+
actualScript = verifiedScriptPath;
|
|
229
|
+
} else if (basename(actualScript) !== "glytch.mjs") {
|
|
230
|
+
// A matching version does not make a shell wrapper runnable by Node.
|
|
231
|
+
// Without a known manager, only the resolved JS entry may be used.
|
|
232
|
+
throw new Error("Unresolved CLI launcher");
|
|
233
|
+
}
|
|
234
|
+
verifiedScriptPath = actualScript;
|
|
235
|
+
// A same-version install can be reached through a launcher or another
|
|
236
|
+
// global prefix. Its PATH spelling is useful evidence, not a gate (#600).
|
|
237
|
+
log(`Viewable PATH ${command} ${(await resolvePath(command)) === runningScript ? "matches" : "differs from"} ${scriptPath}.`);
|
|
238
|
+
}
|
|
239
|
+
if (actualScript) actualScript = await resolvePath(actualScript);
|
|
240
|
+
if (mustMatchScript && actualScript && actualScript !== await resolvePath(verifiedScriptPath)) {
|
|
241
|
+
// npm may still expose the old package's bin. Follow only our exact
|
|
242
|
+
// forwarder, using Node's resolution from that installed package.
|
|
243
|
+
const forwarder = "#!/usr/bin/env node\nprocess.stderr.write(\"Glytch is now Viewable: npx viewable-one\\n\");\nawait import(\"viewable-one/glytch.mjs\");";
|
|
244
|
+
if (readFileSync(actualScript, "utf8").replace(/\r\n/g, "\n").trim() === forwarder) {
|
|
245
|
+
actualScript = await resolvePath(createRequire(actualScript).resolve("viewable-one/glytch.mjs"));
|
|
246
|
+
}
|
|
247
|
+
if (actualScript !== await resolvePath(verifiedScriptPath)) actualScript = null;
|
|
248
|
+
}
|
|
249
|
+
} catch { actualScript = null; }
|
|
250
|
+
// Whatever path comes back is what the service will run with Node, so it
|
|
251
|
+
// must be an absolute path to a glytch.mjs, whichever manager named it.
|
|
252
|
+
const runnable = (platform === "win32" ? win32 : posix).isAbsolute(verifiedScriptPath ?? "") && /(^|[\\/])glytch\.mjs$/.test(verifiedScriptPath);
|
|
253
|
+
if (!result?.ok || version !== expectedVersion || !actualScript || !runnable || [scriptPath, command, actualScript, verifiedScriptPath].some((path) => /[\\/]_npx[\\/]/.test(path ?? ""))) {
|
|
254
|
+
return { ok: false, error: "Viewable could not be verified in a new normal terminal. Install with npm i -g viewable-one@latest without sudo, persist the npm bin directory in your shell startup configuration or Windows user PATH, then open a new terminal and check glytch -v. Setup is not Connected." };
|
|
255
|
+
}
|
|
256
|
+
return { ok: true, version, scriptPath: verifiedScriptPath };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** The script init registers: the one a fresh terminal proved, never merely
|
|
260
|
+
* the one running now. A same-version copy in a temporary folder passes the
|
|
261
|
+
* version check, and a service pointed at it breaks once it is deleted. With
|
|
262
|
+
* service changes disabled nothing is verified and the default stands. */
|
|
263
|
+
export async function initServiceScript({ env = process.env, verify = verifyInstalledCli, log = () => {} } = {}) {
|
|
264
|
+
if (noDaemonInstall(env)) return { ok: true };
|
|
265
|
+
const checked = await verify({ env, log });
|
|
266
|
+
return checked.ok ? { ok: true, scriptPath: checked.scriptPath } : checked;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Explicit only. Installation finishes before touching the existing service.
|
|
270
|
+
* No init/login/config rewrite, agent execution, or automatic updater. */
|
|
271
|
+
export async function runUpdate({
|
|
272
|
+
env = process.env, platform = process.platform, exec = execNpm,
|
|
273
|
+
verify = verifyInstalledCli, sync, wait = waitForRunnerHealth,
|
|
274
|
+
readHealth = () => readRunnerHealth(env), now = Date.now, log = () => {},
|
|
275
|
+
} = {}) {
|
|
276
|
+
const failure = (error) => ({ ok: false, error });
|
|
277
|
+
if (noDaemonInstall(env)) return failure("Service changes are disabled. Nothing was installed or restarted.");
|
|
278
|
+
const npm = (args, options) => exec(args, { env, platform, ...options });
|
|
279
|
+
const latest = await latestFromNpm({ exec: npm });
|
|
280
|
+
if (!latest) return failure("Could not check the latest published version. Check your connection and try viewable update again. Nothing was restarted.");
|
|
281
|
+
const root = await npm(["root", "-g"]);
|
|
282
|
+
if (!root?.ok || !root.stdout.trim()) return failure("Could not find the npm installation directory. Check npm and try again.");
|
|
283
|
+
const scriptPath = (platform === "win32" ? win32 : { join }).join(root.stdout.trim(), PACKAGE_NAME, "glytch.mjs");
|
|
284
|
+
log(`Installing Viewable ${latest}…`);
|
|
285
|
+
const installed = await npm(["install", "-g", `${PACKAGE_NAME}@${latest}`, "--force"], { timeoutMs: 180_000 });
|
|
286
|
+
if (!installed?.ok) return failure("Installation failed. Check your connection and npm directory permissions; never run sudo. The service was left alone. Retry viewable update after fixing the install.");
|
|
287
|
+
const checked = await verify({ env, platform, expectedVersion: latest, scriptPath, allowVoltaPackage: true, log });
|
|
288
|
+
if (!checked.ok) return failure(`The package was installed, but ${checked.error}`);
|
|
289
|
+
const before = readHealth();
|
|
290
|
+
const after = now();
|
|
291
|
+
const restart = sync ?? (await import("./sync.mjs")).syncLaunchAgent;
|
|
292
|
+
let result;
|
|
293
|
+
try { result = await restart({ env, platform, scriptPath: checked.scriptPath, version: latest, restart: true }); }
|
|
294
|
+
catch { return failure("The package was installed, but restarting failed. Folders and sign-in were kept. Run viewable doctor, then retry viewable update."); }
|
|
295
|
+
if (!result.ok || /not loaded|no systemd|not registered|not restarted/.test(result.state)) {
|
|
296
|
+
return failure(`Viewable ${latest} was installed, but your computer did not restart: ${result.detail}. Run viewable doctor, then retry viewable update.`);
|
|
297
|
+
}
|
|
298
|
+
const health = await wait({ env, version: latest, after, previousInstance: before?.instance });
|
|
299
|
+
if (!health.ok) return failure(`Viewable ${latest} was installed, but ${health.error}`);
|
|
300
|
+
log(`Viewable ${latest} is installed. Your computer restarted and has a fresh connection.`);
|
|
301
|
+
return { ok: true, version: latest };
|
|
302
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// A PLAN OUT OF USAGE, READ FROM THE AGENT'S OWN WORDS (#482, ruling #470:
|
|
2
|
+
// "plan out of usage with the time it comes back"). Claude Code and Codex
|
|
3
|
+
// each end a turn with an error when the person's plan has no usage left, and
|
|
4
|
+
// each says when it comes back. This reads that sentence and nothing else:
|
|
5
|
+
// no call to either company, no guess when the words carry no time.
|
|
6
|
+
//
|
|
7
|
+
// The runner keeps the result in the row's sidecar (`usageLimit`), and the
|
|
8
|
+
// app says "Your Claude plan is out of usage for now. It comes back at 4 PM."
|
|
9
|
+
//
|
|
10
|
+
// The samples it knows (bridge/test/usageLimit.test.mjs has each one):
|
|
11
|
+
// Claude Code "Claude AI usage limit reached|1760536800" (epoch seconds)
|
|
12
|
+
// "5-hour limit reached ∙ resets 4pm"
|
|
13
|
+
// "You've hit your limit · resets 4pm (Europe/London)"
|
|
14
|
+
// "Weekly limit reached ∙ resets Oct 16, 3pm"
|
|
15
|
+
// Codex "You've hit your usage limit. ... try again at 4:05 PM."
|
|
16
|
+
// "... or try again at Oct 16th, 2026 4:05 PM."
|
|
17
|
+
// "... try again in 2 hours 5 minutes."
|
|
18
|
+
|
|
19
|
+
const LIMIT = /usage limit|limit reached|hit your limit|out of usage|rate_limit_exceeded|usageLimitExceeded/i;
|
|
20
|
+
const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
|
|
21
|
+
|
|
22
|
+
/** The wall clock in `timeZone` at `at`, as if it were UTC, in ms. */
|
|
23
|
+
function wallMs(at, timeZone) {
|
|
24
|
+
const parts = Object.fromEntries(
|
|
25
|
+
new Intl.DateTimeFormat("en-US", {
|
|
26
|
+
timeZone, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit",
|
|
27
|
+
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
|
28
|
+
}).formatToParts(at).map((p) => [p.type, p.value]),
|
|
29
|
+
);
|
|
30
|
+
return Date.UTC(+parts.year, +parts.month - 1, +parts.day, +parts.hour, +parts.minute, +parts.second);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The next moment after `now` that reads `hour:minute` (on `month`/`day`
|
|
34
|
+
* when given) on a clock in `timeZone`, or this computer's own clock. */
|
|
35
|
+
function nextAt(now, { hour, minute, month, day, year }, timeZone) {
|
|
36
|
+
let zone = timeZone;
|
|
37
|
+
try { if (zone) new Intl.DateTimeFormat("en-US", { timeZone: zone }); } catch { zone = undefined; }
|
|
38
|
+
const offset = zone ? wallMs(now, zone) - now.getTime() : -now.getTimezoneOffset() * 60_000;
|
|
39
|
+
const wallNow = new Date(now.getTime() + offset);
|
|
40
|
+
const pick = (y, m, d) => Date.UTC(y, m, d, hour, minute) - offset;
|
|
41
|
+
if (month !== undefined) {
|
|
42
|
+
let at = pick(year ?? wallNow.getUTCFullYear(), month, day);
|
|
43
|
+
if (year === undefined && at <= now.getTime()) at = pick(wallNow.getUTCFullYear() + 1, month, day);
|
|
44
|
+
return new Date(at);
|
|
45
|
+
}
|
|
46
|
+
let at = pick(wallNow.getUTCFullYear(), wallNow.getUTCMonth(), wallNow.getUTCDate());
|
|
47
|
+
if (at <= now.getTime()) at += 24 * 60 * 60_000;
|
|
48
|
+
return new Date(at);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** "4pm", "4:05 PM", "16:05" as {hour, minute}, or null. */
|
|
52
|
+
function clockOf(text) {
|
|
53
|
+
const m = /^(\d{1,2})(?::(\d{2}))?\s*([ap]\.?m\.?)?$/i.exec(text.trim());
|
|
54
|
+
if (!m) return null;
|
|
55
|
+
let hour = Number(m[1]);
|
|
56
|
+
const minute = Number(m[2] ?? 0);
|
|
57
|
+
const half = m[3]?.[0]?.toLowerCase();
|
|
58
|
+
if (!half && m[2] === undefined) return null;
|
|
59
|
+
if (half === "p" && hour < 12) hour += 12;
|
|
60
|
+
if (half === "a" && hour === 12) hour = 0;
|
|
61
|
+
return hour < 24 && minute < 60 ? { hour, minute } : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** "Oct 16" or "Oct 16th, 2026" as {month, day, year?}, or null. */
|
|
65
|
+
function dateOf(text) {
|
|
66
|
+
const m = /^([A-Za-z]{3})[a-z]*\.?\s+(\d{1,2})(?:st|nd|rd|th)?(?:,?\s+(\d{4}))?$/.exec(text.trim());
|
|
67
|
+
const month = m ? MONTHS.indexOf(m[1].toLowerCase()) : -1;
|
|
68
|
+
return month < 0 ? null : { month, day: Number(m[2]), ...(m[3] ? { year: Number(m[3]) } : {}) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** When the plan comes back, from one stretch of the agent's words. */
|
|
72
|
+
function resetOf(text, now) {
|
|
73
|
+
// Claude Code's older form: the epoch after a bar.
|
|
74
|
+
const epoch = /usage limit reached\|(\d{10,13})/i.exec(text);
|
|
75
|
+
if (epoch) {
|
|
76
|
+
const n = Number(epoch[1]);
|
|
77
|
+
return new Date(epoch[1].length > 10 ? n : n * 1000);
|
|
78
|
+
}
|
|
79
|
+
// "try again in 2 hours 5 minutes", "try again in 3 days 1 hour".
|
|
80
|
+
const span = /try again in ((?:\d+\s*(?:days?|hours?|minutes?|mins?|seconds?|secs?)[\s,and]*)+)/i.exec(text);
|
|
81
|
+
if (span) {
|
|
82
|
+
let ms = 0;
|
|
83
|
+
for (const [, n, unit] of span[1].matchAll(/(\d+)\s*(d|h|m|s)/gi)) {
|
|
84
|
+
ms += Number(n) * { d: 86_400_000, h: 3_600_000, m: 60_000, s: 1000 }[unit.toLowerCase()];
|
|
85
|
+
}
|
|
86
|
+
if (ms > 0) return new Date(now.getTime() + ms);
|
|
87
|
+
}
|
|
88
|
+
// "resets 4pm (Europe/London)", "resets Oct 16, 3pm", "try again at Oct
|
|
89
|
+
// 16th, 2026 4:05 PM", "reset at 4pm".
|
|
90
|
+
const at = /(?:\bresets?(?:\s+at)?|try again at)\s+([^\n]+)/i.exec(text);
|
|
91
|
+
if (!at) return undefined;
|
|
92
|
+
const zone = /\(([A-Za-z_]+\/[A-Za-z_/+-]+|UTC)\)/.exec(at[1])?.[1];
|
|
93
|
+
const words = at[1].split("(")[0].replace(/\.(\s.*)?$/, "").trim();
|
|
94
|
+
const clockAt = /(\d{1,2}(?::\d{2})?\s*(?:[ap]\.?m\.?)?)$/i.exec(words);
|
|
95
|
+
const clock = clockAt ? clockOf(clockAt[1]) : null;
|
|
96
|
+
if (!clock) return undefined;
|
|
97
|
+
const date = dateOf(words.slice(0, clockAt.index).replace(/[,\s]+$/, ""));
|
|
98
|
+
return nextAt(now, { ...clock, ...(date ?? {}) }, zone);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Did the agent say the plan is out of usage? `{resetsAt}` (an ISO time,
|
|
102
|
+
* absent when the words carry none) or null when it said something else. */
|
|
103
|
+
export function readUsageLimit(text, { now = new Date() } = {}) {
|
|
104
|
+
const words = typeof text === "string" ? text : "";
|
|
105
|
+
if (!LIMIT.test(words)) return null;
|
|
106
|
+
const resetsAt = resetOf(words, now);
|
|
107
|
+
return resetsAt && !Number.isNaN(resetsAt.getTime()) ? { resetsAt: resetsAt.toISOString() } : {};
|
|
108
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Resolve Windows npm shims to their JavaScript entry points. Passing request
|
|
2
|
+
// arguments through cmd.exe would let shell metacharacters in model/session
|
|
3
|
+
// metadata run commands before an agent's own permission checks apply.
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import { win32 } from "node:path";
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_PATHEXT = [".COM", ".EXE", ".BAT", ".CMD"];
|
|
8
|
+
|
|
9
|
+
const splitList = (value) => String(value ?? "").split(";").map((s) => s.trim()).filter(Boolean);
|
|
10
|
+
const isPathLike = (bin) => /[\\/]/.test(bin) || /^[a-z]:/i.test(bin);
|
|
11
|
+
const envLookup = (env, name) => {
|
|
12
|
+
const key = Object.keys(env ?? {}).find((k) => k.toUpperCase() === name.toUpperCase());
|
|
13
|
+
return key ? env[key] : undefined;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** npm/cmd-shim forwards %* to either a Node script or a native executable
|
|
17
|
+
* under its own directory. Parse only that rooted, quoted target. Older
|
|
18
|
+
* OpenCode packages have an extensionless Node script; newer ones can point
|
|
19
|
+
* directly at opencode.exe. Arbitrary batch programs fail closed.
|
|
20
|
+
*/
|
|
21
|
+
function shimTarget(shim, { exists, readFile, nodePath }) {
|
|
22
|
+
let source;
|
|
23
|
+
try { source = readFile(shim, "utf8"); } catch { return null; }
|
|
24
|
+
if (!/^@echo off\s*$/im.test(source) && !/^@?IF EXIST "%(?:~dp0|dp0%)[\\/]node\.exe"\s*\(/im.test(source)) return null;
|
|
25
|
+
const targets = [...source.matchAll(/"%(?:~dp0|dp0%)[\\/]([^"\r\n]+)"\s+%\*(?=\s|$)/gim)];
|
|
26
|
+
if (targets.length === 0) return null;
|
|
27
|
+
const target = win32.resolve(win32.dirname(shim), targets[0][1]);
|
|
28
|
+
// Older npm shims repeat the same entry in IF/ELSE Node-launch branches.
|
|
29
|
+
// Resolve every match and refuse divergent targets rather than picking one.
|
|
30
|
+
if (targets.some((match) => win32.resolve(win32.dirname(shim), match[1]).toLowerCase() !== target.toLowerCase())) return null;
|
|
31
|
+
if (!exists(target)) return null;
|
|
32
|
+
const lines = targets.map((match) => source.slice(source.lastIndexOf("\n", match.index) + 1, match.index));
|
|
33
|
+
const line = lines[0];
|
|
34
|
+
if (targets.length === 1 && /\.(?:exe|com)$/i.test(target) && !/"%_prog%"\s*$/i.test(line)) {
|
|
35
|
+
return { file: target, prefix: [] };
|
|
36
|
+
}
|
|
37
|
+
const nodeLauncher = lines.every((candidate) => /(?:"%_prog%"|"%(?:~dp0|dp0%)[\\/]node\.exe"|\bnode(?:\.exe)?)\s*$/i.test(candidate));
|
|
38
|
+
if (!nodeLauncher || !/\bnode\b/i.test(source)) return null;
|
|
39
|
+
if (!/\.(?:js|mjs|cjs)$/i.test(target)) {
|
|
40
|
+
let firstLine;
|
|
41
|
+
try { firstLine = String(readFile(target, "utf8")).split(/\r?\n/, 1)[0]; } catch { return null; }
|
|
42
|
+
if (!/^#!.*(?:\s|\/)node(?:\.exe)?(?:\s|$)/i.test(firstLine)) return null;
|
|
43
|
+
}
|
|
44
|
+
return { file: nodePath, prefix: [target] };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The file a Windows `Path` x `PATHEXT` search finds for `bin`, or null.
|
|
48
|
+
* The one search both `runnableCommand` and detect.mjs's
|
|
49
|
+
* `resolveAgentBinary` use, so "can it be found" and "what gets spawned"
|
|
50
|
+
* never disagree. A bin that already carries a PATHEXT extension and names
|
|
51
|
+
* a place is returned as given; its existence is the spawn's to report.
|
|
52
|
+
*/
|
|
53
|
+
export function findWindowsCommand(bin, { env = process.env, exists = existsSync } = {}) {
|
|
54
|
+
const pathext = splitList(envLookup(env, "PATHEXT") || DEFAULT_PATHEXT.join(";"));
|
|
55
|
+
const dirs = splitList(envLookup(env, "Path"));
|
|
56
|
+
if (/\.(?:com|exe|bat|cmd)$/i.test(bin) && !isPathLike(bin)) {
|
|
57
|
+
return dirs.map((dir) => win32.join(dir, bin)).find(exists) ?? null;
|
|
58
|
+
}
|
|
59
|
+
if (pathext.some((ext) => bin.toLowerCase().endsWith(ext.toLowerCase()))) return bin;
|
|
60
|
+
if (isPathLike(bin)) return pathext.map((ext) => `${bin}${ext}`).find(exists) ?? null;
|
|
61
|
+
return dirs.flatMap((dir) => pathext.map((ext) => win32.join(dir, `${bin}${ext}`))).find(exists) ?? null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Return a shell-free spawn description on every platform. An unresolved
|
|
65
|
+
* command is left to Node's ordinary ENOENT reporting, never cmd.exe search.
|
|
66
|
+
* Injectable filesystem hooks permit Windows-resolution tests on POSIX.
|
|
67
|
+
*/
|
|
68
|
+
export function runnableCommand(bin, args = [], {
|
|
69
|
+
platform = process.platform,
|
|
70
|
+
env = process.env,
|
|
71
|
+
exists = existsSync,
|
|
72
|
+
readFile = readFileSync,
|
|
73
|
+
nodePath = process.execPath,
|
|
74
|
+
} = {}) {
|
|
75
|
+
if (platform !== "win32") return { file: bin, args, shell: false };
|
|
76
|
+
|
|
77
|
+
const target = findWindowsCommand(bin, { env, exists }) ?? bin;
|
|
78
|
+
|
|
79
|
+
if (/\.(?:cmd|bat)$/i.test(target)) {
|
|
80
|
+
const resolved = shimTarget(target, { exists, readFile, nodePath });
|
|
81
|
+
if (!resolved) throw new Error(`Cannot safely launch Windows batch command: ${target}`);
|
|
82
|
+
return { file: resolved.file, args: [...resolved.prefix, ...args], shell: false };
|
|
83
|
+
}
|
|
84
|
+
return { file: target, args, shell: false };
|
|
85
|
+
}
|