zcode-acp-server 0.46.6 → 0.47.0
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/README.md +1 -1
- package/dist/backend/listener.d.ts.map +1 -1
- package/dist/backend/listener.js +5 -1
- package/dist/backend/listener.js.map +1 -1
- package/dist/code-fingerprint.json +2 -2
- package/dist/config/auto-compact.d.ts.map +1 -1
- package/dist/config/auto-compact.js +4 -2
- package/dist/config/auto-compact.js.map +1 -1
- package/dist/config/model-cache.js +4 -1
- package/dist/config/model-cache.js.map +1 -1
- package/dist/config/options.js +4 -1
- package/dist/config/options.js.map +1 -1
- package/dist/goal-loop/driver.d.ts +14 -3
- package/dist/goal-loop/driver.d.ts.map +1 -1
- package/dist/goal-loop/driver.js +39 -17
- package/dist/goal-loop/driver.js.map +1 -1
- package/dist/handlers/replay.d.ts +39 -0
- package/dist/handlers/replay.d.ts.map +1 -1
- package/dist/handlers/replay.js +37 -1
- package/dist/handlers/replay.js.map +1 -1
- package/dist/handlers/session.d.ts.map +1 -1
- package/dist/handlers/session.js +53 -13
- package/dist/handlers/session.js.map +1 -1
- package/dist/remote/endpoint.d.ts.map +1 -1
- package/dist/remote/endpoint.js +6 -0
- package/dist/remote/endpoint.js.map +1 -1
- package/dist/remote/hub-server.d.ts.map +1 -1
- package/dist/remote/hub-server.js +102 -0
- package/dist/remote/hub-server.js.map +1 -1
- package/dist/remote/settings-endpoint.d.ts +45 -0
- package/dist/remote/settings-endpoint.d.ts.map +1 -0
- package/dist/remote/settings-endpoint.js +920 -0
- package/dist/remote/settings-endpoint.js.map +1 -0
- package/dist/settings/agents-config.d.ts +158 -0
- package/dist/settings/agents-config.d.ts.map +1 -0
- package/dist/settings/agents-config.js +598 -0
- package/dist/settings/agents-config.js.map +1 -0
- package/dist/settings/app-update.d.ts +193 -0
- package/dist/settings/app-update.d.ts.map +1 -0
- package/dist/settings/app-update.js +659 -0
- package/dist/settings/app-update.js.map +1 -0
- package/dist/settings/atomic-write.d.ts +106 -0
- package/dist/settings/atomic-write.d.ts.map +1 -0
- package/dist/settings/atomic-write.js +228 -0
- package/dist/settings/atomic-write.js.map +1 -0
- package/dist/settings/cli-config.d.ts +145 -0
- package/dist/settings/cli-config.d.ts.map +1 -0
- package/dist/settings/cli-config.js +320 -0
- package/dist/settings/cli-config.js.map +1 -0
- package/dist/settings/coding-plan.d.ts +106 -0
- package/dist/settings/coding-plan.d.ts.map +1 -0
- package/dist/settings/coding-plan.js +299 -0
- package/dist/settings/coding-plan.js.map +1 -0
- package/dist/settings/file-lock.d.ts +39 -0
- package/dist/settings/file-lock.d.ts.map +1 -0
- package/dist/settings/file-lock.js +282 -0
- package/dist/settings/file-lock.js.map +1 -0
- package/dist/settings/provider-config.d.ts +123 -0
- package/dist/settings/provider-config.d.ts.map +1 -0
- package/dist/settings/provider-config.js +311 -0
- package/dist/settings/provider-config.js.map +1 -0
- package/dist/settings/skills.d.ts +72 -0
- package/dist/settings/skills.d.ts.map +1 -0
- package/dist/settings/skills.js +245 -0
- package/dist/settings/skills.js.map +1 -0
- package/dist/settings/usage-stats.d.ts +74 -0
- package/dist/settings/usage-stats.d.ts.map +1 -0
- package/dist/settings/usage-stats.js +235 -0
- package/dist/settings/usage-stats.js.map +1 -0
- package/dist/translators/projection-differ.d.ts +12 -0
- package/dist/translators/projection-differ.d.ts.map +1 -1
- package/dist/translators/projection-differ.js +17 -0
- package/dist/translators/projection-differ.js.map +1 -1
- package/dist/utils.d.ts +14 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +22 -0
- package/dist/utils.js.map +1 -1
- package/docs/ARCHITECTURE.md +65 -0
- package/docs/REMOTE-CLIENTS.md +252 -0
- package/package.json +1 -1
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ZCode desktop app self-update.
|
|
3
|
+
*
|
|
4
|
+
* The desktop app updates itself through Electron's autoUpdater, which needs an
|
|
5
|
+
* Electron runtime this process does not have. The parts that DO work headless
|
|
6
|
+
* are the ones that decide WHAT to install: the release manifest
|
|
7
|
+
* (`/api/v1/releases/electron/manifest`), the platform/arch/channel spelling,
|
|
8
|
+
* and the CDN download. So this module reproduces those and hands the actual
|
|
9
|
+
* install back to the OS.
|
|
10
|
+
*
|
|
11
|
+
* Whether the bundle can be swapped is PROBED, never assumed (see
|
|
12
|
+
* `canWriteInstallLocation`): the obvious reading of `/Applications`'s
|
|
13
|
+
* `drwxrwxr-x root:admin` is that an admin user cannot write it, yet the
|
|
14
|
+
* installed `ZCode.app` is user-owned on the machine this was written for — so
|
|
15
|
+
* the code branches on `accessSync` and is correct either way, and no claim is
|
|
16
|
+
* made about which branch a given install takes. What is deliberately NOT done
|
|
17
|
+
* is privileged escalation (a password prompt, a Finder Apple Event, a shipped
|
|
18
|
+
* privileged helper): a long-lived daemon has no good place to ask, and the
|
|
19
|
+
* one-shot confirmation macOS raises when a user drags a bundle into place is
|
|
20
|
+
* already the right amount of trust.
|
|
21
|
+
*
|
|
22
|
+
* What the flow does NOT do is silently swap a running app. The app is replaced
|
|
23
|
+
* on disk while it may be running, and the user is told to quit and reopen —
|
|
24
|
+
* attempting to kill the app from here would be both rude and unsafe (it may
|
|
25
|
+
* have an in-flight session whose state lives in the app process).
|
|
26
|
+
*/
|
|
27
|
+
import { createHash } from "node:crypto";
|
|
28
|
+
import { accessSync, createWriteStream, existsSync } from "node:fs";
|
|
29
|
+
import { constants } from "node:fs";
|
|
30
|
+
import { chmod, mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises";
|
|
31
|
+
import { execFile } from "node:child_process";
|
|
32
|
+
import { readFile } from "node:fs/promises";
|
|
33
|
+
import { tmpdir } from "node:os";
|
|
34
|
+
import path from "node:path";
|
|
35
|
+
import { promisify } from "node:util";
|
|
36
|
+
import { log, warn } from "../utils.js";
|
|
37
|
+
const execFileAsync = promisify(execFile);
|
|
38
|
+
/** The API origin; overridable for testing and for a self-hosted gateway. */
|
|
39
|
+
function apiOrigin(env = process.env) {
|
|
40
|
+
return env.ZCODE_ENDPOINT_ORIGIN ?? env.ZCODE_BASE_URL ?? "https://zcode.z.ai";
|
|
41
|
+
}
|
|
42
|
+
const MANIFEST_PATH = "/api/v1/releases/electron/manifest";
|
|
43
|
+
const MANIFEST_ACCEPT = "application/x-yaml,text/yaml,text/plain,*/*";
|
|
44
|
+
const REQUEST_TIMEOUT_MS = 20_000;
|
|
45
|
+
/** Channel → the API's numeric spelling (manifestUpdateProvider.ts:41-42). */
|
|
46
|
+
const CHANNEL_VALUES = { stable: 1, preview: 3 };
|
|
47
|
+
/** Node platform → the manifest's spelling (manifestUpdateProvider.ts:57-68). */
|
|
48
|
+
export function releasePlatform(platform, arch) {
|
|
49
|
+
const os = platform === "win32" ? "windows" : platform === "darwin" ? "darwin" : platform;
|
|
50
|
+
const cpu = arch === "arm64" ? "aarch64" : arch === "x64" ? "x86_64" : arch === "ia32" ? "x86" : arch;
|
|
51
|
+
return `${os}-${cpu}`;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Where the app is installed, or null when it is not found.
|
|
55
|
+
*
|
|
56
|
+
* `ZCODE_APP_PATH` overrides the whole lookup so a test (or a non-standard
|
|
57
|
+
* install) can point somewhere safe. The candidate list mirrors the table in
|
|
58
|
+
* `backend/resolve.ts`: /Applications first, then the per-user location, then
|
|
59
|
+
* the Linux prefixes.
|
|
60
|
+
*/
|
|
61
|
+
export function appBundlePath(env = process.env) {
|
|
62
|
+
const explicit = env.ZCODE_APP_PATH?.trim();
|
|
63
|
+
if (explicit) {
|
|
64
|
+
const resolved = path.resolve(explicit);
|
|
65
|
+
// An override that does not exist means "nowhere", not "trust me" — the
|
|
66
|
+
// caller decides what to do about a missing install, and answering with a
|
|
67
|
+
// phantom path would make an install target that cannot be written.
|
|
68
|
+
return existsSync(resolved) ? resolved : null;
|
|
69
|
+
}
|
|
70
|
+
const home = env.HOME || process.env.HOME || "";
|
|
71
|
+
const candidates = process.platform === "win32"
|
|
72
|
+
? [
|
|
73
|
+
path.join(env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Programs", "ZCode", "ZCode.exe"),
|
|
74
|
+
]
|
|
75
|
+
: process.platform === "darwin"
|
|
76
|
+
? [
|
|
77
|
+
"/Applications/ZCode.app",
|
|
78
|
+
path.join(home, "Applications", "ZCode.app"),
|
|
79
|
+
"/opt/ZCode/ZCode.app",
|
|
80
|
+
"/usr/share/zcode/ZCode.app",
|
|
81
|
+
]
|
|
82
|
+
: ["/opt/ZCode/zcode", "/usr/share/zcode/zcode", "/usr/local/bin/zcode"];
|
|
83
|
+
return candidates.find((c) => existsSync(c)) ?? null;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The installed app's version, read from its bundle.
|
|
87
|
+
*
|
|
88
|
+
* macOS: `CFBundleShortVersionString` out of `Info.plist`, parsed without a
|
|
89
|
+
* plist library (the file is XML for these builds; a binary plist falls back to
|
|
90
|
+
* `defaults read`, which handles both). Windows: the file version of the .exe.
|
|
91
|
+
* Linux: null — there is no reliable in-bundle version.
|
|
92
|
+
*/
|
|
93
|
+
export async function installedAppVersion(appPath, env = process.env, platform = updatePlatform) {
|
|
94
|
+
if (platform !== "darwin")
|
|
95
|
+
return env.ZCODE_APP_VERSION?.trim() || null;
|
|
96
|
+
const plist = path.join(appPath, "Contents", "Info.plist");
|
|
97
|
+
try {
|
|
98
|
+
const text = await readFile(plist, "utf8");
|
|
99
|
+
const match = /<key>CFBundleShortVersionString<\/key>\s*<string>([^<]+)<\/string>/u.exec(text);
|
|
100
|
+
if (match)
|
|
101
|
+
return match[1].trim();
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
/* fall through to defaults */
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
const { stdout } = await execFileAsync("defaults", [
|
|
108
|
+
"read",
|
|
109
|
+
plist,
|
|
110
|
+
"CFBundleShortVersionString",
|
|
111
|
+
]);
|
|
112
|
+
const value = stdout.trim();
|
|
113
|
+
return value || null;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Compare two version strings numerically, segment by segment.
|
|
121
|
+
*
|
|
122
|
+
* Local on purpose: `utils.compareVersions` is fine for semver-ish strings but
|
|
123
|
+
* the manifest can carry a build suffix (`3.14.1.7714`), and comparing
|
|
124
|
+
* `7714` against a missing segment must not flip the result.
|
|
125
|
+
*/
|
|
126
|
+
export function isNewerVersion(candidate, current) {
|
|
127
|
+
const parse = (v) => v
|
|
128
|
+
.split(/[.-]/u)
|
|
129
|
+
.map((part) => Number.parseInt(part, 10))
|
|
130
|
+
.map((n) => (Number.isNaN(n) ? 0 : n));
|
|
131
|
+
const a = parse(candidate);
|
|
132
|
+
const b = parse(current);
|
|
133
|
+
const len = Math.max(a.length, b.length);
|
|
134
|
+
for (let i = 0; i < len; i++) {
|
|
135
|
+
const na = a[i] ?? 0;
|
|
136
|
+
const nb = b[i] ?? 0;
|
|
137
|
+
if (na !== nb)
|
|
138
|
+
return na > nb;
|
|
139
|
+
}
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Parse the manifest YAML.
|
|
144
|
+
*
|
|
145
|
+
* The manifest is a small, fixed-shape document (version + files + release
|
|
146
|
+
* notes), so a real YAML parser would be a dependency for no benefit. This
|
|
147
|
+
* reader handles exactly the shapes the endpoint emits: a flat top level, a
|
|
148
|
+
* `files:` list of mappings, and the `releaseNotesByLocale` nesting. Block
|
|
149
|
+
* scalars (`|-`) are collected by indentation.
|
|
150
|
+
*
|
|
151
|
+
* Anything it does not understand throws rather than guessing — a misparsed
|
|
152
|
+
* manifest would install the wrong build.
|
|
153
|
+
*/
|
|
154
|
+
export function parseManifest(raw) {
|
|
155
|
+
const lines = raw.replace(/\r\n/gu, "\n").split("\n");
|
|
156
|
+
const root = {};
|
|
157
|
+
const files = [];
|
|
158
|
+
const byLocale = {};
|
|
159
|
+
let context = "root";
|
|
160
|
+
let file = null;
|
|
161
|
+
let locale = null;
|
|
162
|
+
let block = null;
|
|
163
|
+
const flushBlock = () => {
|
|
164
|
+
if (!block)
|
|
165
|
+
return;
|
|
166
|
+
// Dedent by the smallest indentation actually present: YAML block scalars
|
|
167
|
+
// are relative to their key, and the first body line sets the baseline.
|
|
168
|
+
const indents = block.lines.filter((l) => l.trim() !== "").map((l) => leadingSpaces(l));
|
|
169
|
+
const base = indents.length > 0 ? Math.min(...indents) : 0;
|
|
170
|
+
const text = block.lines
|
|
171
|
+
.map((l) => (l.trim() === "" ? "" : l.slice(base)))
|
|
172
|
+
.join("\n")
|
|
173
|
+
.replace(/\n+$/u, "");
|
|
174
|
+
if (block.target === "root")
|
|
175
|
+
root[block.key] = text;
|
|
176
|
+
else if (block.target === "file" && file)
|
|
177
|
+
file[block.key] = text;
|
|
178
|
+
else if (block.target === "locale" && locale)
|
|
179
|
+
locale[block.key] = text;
|
|
180
|
+
block = null;
|
|
181
|
+
};
|
|
182
|
+
for (const line of lines) {
|
|
183
|
+
if (block) {
|
|
184
|
+
const indent = leadingSpaces(line);
|
|
185
|
+
// A block scalar ends at the first line that is not indented deeper than
|
|
186
|
+
// its key (blank lines belong to it, so their indent is ignored).
|
|
187
|
+
if (line.trim() === "" || indent > block.indent) {
|
|
188
|
+
block.lines.push(line);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
flushBlock();
|
|
192
|
+
}
|
|
193
|
+
if (line.trim() === "" || line.trimStart().startsWith("#"))
|
|
194
|
+
continue;
|
|
195
|
+
const indent = leadingSpaces(line);
|
|
196
|
+
const trimmed = line.trim();
|
|
197
|
+
// A list item starts a new file entry.
|
|
198
|
+
if (context === "file" && trimmed.startsWith("- ")) {
|
|
199
|
+
file = {};
|
|
200
|
+
files.push(file);
|
|
201
|
+
assign(file, trimmed.slice(2));
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const colon = trimmed.indexOf(":");
|
|
205
|
+
if (colon < 0)
|
|
206
|
+
continue;
|
|
207
|
+
const key = trimmed.slice(0, colon).trim();
|
|
208
|
+
const value = trimmed.slice(colon + 1).trim();
|
|
209
|
+
if (value === "|-" || value === "|" || value === ">") {
|
|
210
|
+
block = { indent, key, lines: [], target: context };
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (indent === 0) {
|
|
214
|
+
flushBlock();
|
|
215
|
+
context = "root";
|
|
216
|
+
file = null;
|
|
217
|
+
locale = null;
|
|
218
|
+
if (key === "files") {
|
|
219
|
+
context = "file";
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (key === "releaseNotesByLocale") {
|
|
223
|
+
context = "locale";
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
root[key] = unquote(value);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (context === "file" && file) {
|
|
230
|
+
file[key] = unquote(value);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (context === "locale") {
|
|
234
|
+
// A locale key is a bare mapping key with no value (`en-US:`).
|
|
235
|
+
if (value === "") {
|
|
236
|
+
locale = byLocale[key] ?? {};
|
|
237
|
+
byLocale[key] = locale;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (locale)
|
|
241
|
+
locale[key] = unquote(value);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
flushBlock();
|
|
245
|
+
if (!root["version"])
|
|
246
|
+
throw new Error("manifest_missing_version");
|
|
247
|
+
return {
|
|
248
|
+
version: root["version"],
|
|
249
|
+
...(root["releaseName"] ? { releaseName: root["releaseName"] } : {}),
|
|
250
|
+
...(root["releaseNotes"] ? { releaseNotes: root["releaseNotes"] } : {}),
|
|
251
|
+
...(root["path"] ? { path: root["path"] } : {}),
|
|
252
|
+
files: files
|
|
253
|
+
.filter((f) => typeof f["url"] === "string")
|
|
254
|
+
.map((f) => ({ url: f["url"], ...(f["sha512"] ? { sha512: f["sha512"] } : {}) })),
|
|
255
|
+
...(Object.keys(byLocale).length > 0 ? { releaseNotesByLocale: byLocale } : {}),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
function leadingSpaces(line) {
|
|
259
|
+
const match = /^\s*/u.exec(line);
|
|
260
|
+
return match ? match[0].length : 0;
|
|
261
|
+
}
|
|
262
|
+
/** Assign the first `key: value` pair of a `- key: value` list item. */
|
|
263
|
+
function assign(target, text) {
|
|
264
|
+
const colon = text.indexOf(":");
|
|
265
|
+
if (colon < 0)
|
|
266
|
+
return;
|
|
267
|
+
target[text.slice(0, colon).trim()] = unquote(text.slice(colon + 1).trim());
|
|
268
|
+
}
|
|
269
|
+
/** Strip the quoting YAML allows around a scalar. */
|
|
270
|
+
function unquote(value) {
|
|
271
|
+
if (value.length >= 2) {
|
|
272
|
+
const first = value[0];
|
|
273
|
+
const last = value[value.length - 1];
|
|
274
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
275
|
+
return value.slice(1, -1);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return value;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Pick the artifact to download for this platform.
|
|
282
|
+
*
|
|
283
|
+
* macOS prefers the zip (it extracts to a bundle directly and needs no mount);
|
|
284
|
+
* Windows takes the exe; Linux takes the distro package. Falls back to the
|
|
285
|
+
* manifest's top-level `path`, which is what electron-updater itself uses when
|
|
286
|
+
* no per-arch entry matches.
|
|
287
|
+
*/
|
|
288
|
+
export function pickReleaseFile(manifest) {
|
|
289
|
+
const suffixes = process.platform === "win32"
|
|
290
|
+
? [".exe"]
|
|
291
|
+
: process.platform === "darwin"
|
|
292
|
+
? [".zip", ".dmg"]
|
|
293
|
+
: [".appimage", ".deb", ".rpm", ".pkg.tar.zst"];
|
|
294
|
+
for (const suffix of suffixes) {
|
|
295
|
+
const found = manifest.files.find((f) => f.url.toLowerCase().endsWith(suffix));
|
|
296
|
+
if (found)
|
|
297
|
+
return found;
|
|
298
|
+
}
|
|
299
|
+
return manifest.files[0] ?? null;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Fetch and parse the release manifest.
|
|
303
|
+
*
|
|
304
|
+
* `fetchImpl` defaults to the module seam (not bare `fetch`) so a test that
|
|
305
|
+
* overrides the network also covers this path, not just `checkForAppUpdate`.
|
|
306
|
+
*/
|
|
307
|
+
export async function fetchReleaseManifest(channel, env = process.env, fetchImpl = manifestNetwork) {
|
|
308
|
+
const platform = releasePlatform(process.platform, process.arch);
|
|
309
|
+
const url = new URL(MANIFEST_PATH, apiOrigin(env));
|
|
310
|
+
url.searchParams.set("platform", platform);
|
|
311
|
+
url.searchParams.set("channel", String(CHANNEL_VALUES[channel]));
|
|
312
|
+
const res = await fetchImpl(url.toString(), {
|
|
313
|
+
headers: {
|
|
314
|
+
Accept: MANIFEST_ACCEPT,
|
|
315
|
+
"X-Platform": platform,
|
|
316
|
+
"X-Release-Channel": String(CHANNEL_VALUES[channel]),
|
|
317
|
+
},
|
|
318
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
319
|
+
});
|
|
320
|
+
if (!res.ok)
|
|
321
|
+
throw new Error(`manifest_http_${res.status}`);
|
|
322
|
+
const text = await res.text();
|
|
323
|
+
return parseManifest(text);
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* The network used for the manifest read AND the artifact download, overridable
|
|
327
|
+
* so tests (and the settings endpoint's route tests) never touch the real CDN.
|
|
328
|
+
*
|
|
329
|
+
* A module-level seam rather than threading a parameter through every caller:
|
|
330
|
+
* the route handler would otherwise have to accept an injected fetch from the
|
|
331
|
+
* HTTP layer, which no client can supply.
|
|
332
|
+
*/
|
|
333
|
+
let manifestNetwork = fetch;
|
|
334
|
+
/** Override the update network. Pass `fetch` to restore the default. */
|
|
335
|
+
export function setManifestNetworkForTest(impl) {
|
|
336
|
+
manifestNetwork = impl;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* The platform the update flows run as.
|
|
340
|
+
*
|
|
341
|
+
* Defaults to `process.platform`, which makes the feature untestable from any
|
|
342
|
+
* OS but the one it targets: on a Linux runner the whole macOS install path
|
|
343
|
+
* (bundle discovery, plist version, the rename swap) is skipped, so the route
|
|
344
|
+
* tests that exercise it pass on a developer's Mac and fail everywhere else.
|
|
345
|
+
* A module-level seam, same shape and rationale as `manifestNetwork` — the
|
|
346
|
+
* platform is not something an HTTP caller can supply.
|
|
347
|
+
*/
|
|
348
|
+
let updatePlatform = process.platform;
|
|
349
|
+
/** Override the platform the update flows run as (test seam). */
|
|
350
|
+
export function setAppUpdatePlatformForTest(platform) {
|
|
351
|
+
updatePlatform = platform;
|
|
352
|
+
}
|
|
353
|
+
/** The platform the update flows run as. */
|
|
354
|
+
function currentPlatform(overridden) {
|
|
355
|
+
return overridden ?? updatePlatform;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Compare the installed app against the manifest.
|
|
359
|
+
*
|
|
360
|
+
* A missing install (the bridge running on a machine without the app) is not an
|
|
361
|
+
* error: `updateAvailable` stays false and `currentVersion` is null, so the
|
|
362
|
+
* client can hide the row instead of showing a failure.
|
|
363
|
+
*/
|
|
364
|
+
export async function checkForAppUpdate(options = {}) {
|
|
365
|
+
const env = options.env ?? process.env;
|
|
366
|
+
const platform = currentPlatform(options.platform);
|
|
367
|
+
const channel = options.channel ?? "stable";
|
|
368
|
+
const appPath = appBundlePath(env);
|
|
369
|
+
const currentVersion = appPath ? await installedAppVersion(appPath, env, platform) : null;
|
|
370
|
+
const manifest = await fetchReleaseManifest(channel, env, manifestNetwork);
|
|
371
|
+
const updateAvailable = currentVersion !== null && isNewerVersion(manifest.version, currentVersion);
|
|
372
|
+
return {
|
|
373
|
+
updateAvailable,
|
|
374
|
+
currentVersion,
|
|
375
|
+
latestVersion: manifest.version,
|
|
376
|
+
channel,
|
|
377
|
+
platform: releasePlatform(process.platform, process.arch),
|
|
378
|
+
...(updateAvailable ? { release: manifest } : {}),
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Download an artifact into a temp directory, streaming and hashing as it goes.
|
|
383
|
+
*
|
|
384
|
+
* The hash is verified before anything is installed: a truncated or tampered
|
|
385
|
+
* download that failed here is a failed download, not a bad install.
|
|
386
|
+
*
|
|
387
|
+
* The body is consumed as a web `ReadableStream` (what `fetch` returns), not a
|
|
388
|
+
* Node stream — `pipe` does not exist on it.
|
|
389
|
+
*/
|
|
390
|
+
export async function downloadRelease(file, options = {}) {
|
|
391
|
+
const dir = options.destDir ?? (await mkdtemp(path.join(tmpdir(), "zcode-acp-update-")));
|
|
392
|
+
const name = path.basename(new URL(file.url).pathname) || "zcode-release.zip";
|
|
393
|
+
const target = path.join(dir, name);
|
|
394
|
+
const fetchImpl = options.fetchImpl ?? manifestNetwork;
|
|
395
|
+
// A full app is ~250 MB, so there is no request timeout — an idle-stall guard
|
|
396
|
+
// would fire on a slow link. `AbortSignal.timeout(0)` fires immediately, so
|
|
397
|
+
// no signal is passed at all.
|
|
398
|
+
const res = await fetchImpl(file.url);
|
|
399
|
+
if (!res.ok || !res.body)
|
|
400
|
+
throw new Error(`download_http_${res.status}`);
|
|
401
|
+
const total = Number(res.headers.get("content-length") ?? "") || null;
|
|
402
|
+
const hash = createHash("sha512");
|
|
403
|
+
const out = createWriteStream(target);
|
|
404
|
+
let received = 0;
|
|
405
|
+
const finished = new Promise((resolve, reject) => {
|
|
406
|
+
out.on("error", reject);
|
|
407
|
+
out.on("close", () => resolve());
|
|
408
|
+
});
|
|
409
|
+
try {
|
|
410
|
+
for await (const chunk of res.body) {
|
|
411
|
+
const buf = chunk;
|
|
412
|
+
received += buf.length;
|
|
413
|
+
hash.update(buf);
|
|
414
|
+
out.write(buf);
|
|
415
|
+
options.onProgress?.({ receivedBytes: received, totalBytes: total });
|
|
416
|
+
}
|
|
417
|
+
out.end();
|
|
418
|
+
await finished;
|
|
419
|
+
}
|
|
420
|
+
catch (error) {
|
|
421
|
+
out.destroy();
|
|
422
|
+
throw error;
|
|
423
|
+
}
|
|
424
|
+
const sha512 = hash.digest("base64");
|
|
425
|
+
const verified = !file.sha512 || sha512 === file.sha512;
|
|
426
|
+
if (!verified)
|
|
427
|
+
throw new Error("download_checksum_mismatch");
|
|
428
|
+
await chmod(target, 0o644);
|
|
429
|
+
log(`app-update: downloaded ${name} (${received} bytes, sha512 ${verified ? "ok" : "MISMATCH"})`);
|
|
430
|
+
return { file: target, sha512, sizeBytes: received, verified };
|
|
431
|
+
}
|
|
432
|
+
let installState = {
|
|
433
|
+
stage: "idle",
|
|
434
|
+
version: null,
|
|
435
|
+
receivedBytes: 0,
|
|
436
|
+
totalBytes: null,
|
|
437
|
+
};
|
|
438
|
+
/** Current install state — the endpoint reports it verbatim. */
|
|
439
|
+
export function appUpdateState() {
|
|
440
|
+
return { ...installState };
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* The unzip command used for a downloaded archive.
|
|
444
|
+
*
|
|
445
|
+
* `ditto` is the macOS one (it preserves the resource forks and extended
|
|
446
|
+
* attributes an Electron bundle relies on, which `unzip -X` can corrupt on
|
|
447
|
+
* older builds). A module-level seam so tests can supply an unzip that exists
|
|
448
|
+
* on the host they run on — `ditto` is absent from Linux, and a test that
|
|
449
|
+
* depends on it cannot assert the extraction logic at all.
|
|
450
|
+
*/
|
|
451
|
+
let extractCommand = defaultExtract;
|
|
452
|
+
async function defaultExtract(zipPath, destDir) {
|
|
453
|
+
await execFileAsync("ditto", ["-xk", zipPath, destDir], { timeout: 600_000 });
|
|
454
|
+
}
|
|
455
|
+
/** Override the unzip used by the installer (test seam). */
|
|
456
|
+
export function setExtractForTest(impl) {
|
|
457
|
+
extractCommand = impl;
|
|
458
|
+
}
|
|
459
|
+
/** Restore the platform's own unzip (test seam). */
|
|
460
|
+
export function resetExtractForTest() {
|
|
461
|
+
extractCommand = defaultExtract;
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Extract a downloaded zip into a staging directory.
|
|
465
|
+
*
|
|
466
|
+
* The zip contains a single `ZCode.app/` at its root, so extraction happens
|
|
467
|
+
* into a scratch directory and the resulting bundle is what gets installed —
|
|
468
|
+
* that way a partially extracted archive is never the thing that replaces the
|
|
469
|
+
* app.
|
|
470
|
+
*/
|
|
471
|
+
async function extractZip(zipPath, destDir) {
|
|
472
|
+
await mkdir(destDir, { recursive: true });
|
|
473
|
+
await extractCommand(zipPath, destDir);
|
|
474
|
+
const bundle = path.join(destDir, "ZCode.app");
|
|
475
|
+
let info;
|
|
476
|
+
try {
|
|
477
|
+
info = await stat(bundle);
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
throw new Error("archive_missing_app_bundle");
|
|
481
|
+
}
|
|
482
|
+
// A FILE named ZCode.app is a malformed archive, not a bundle. Renaming it
|
|
483
|
+
// over the installed app would leave a file where a directory is expected and
|
|
484
|
+
// the app would no longer launch.
|
|
485
|
+
if (!info.isDirectory())
|
|
486
|
+
throw new Error("archive_app_bundle_is_not_a_directory");
|
|
487
|
+
return bundle;
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Whether this process can write the install location itself.
|
|
491
|
+
*
|
|
492
|
+
* Probing rather than assuming: `/Applications` looks unwritable
|
|
493
|
+
* (`drwxrwxr-x root:admin`) yet the installed `ZCode.app` is user-owned, so the
|
|
494
|
+
* permission bits do not settle it — TCC may or may not gate the write
|
|
495
|
+
* depending on how the bridge was launched. `accessSync` on the PARENT (a
|
|
496
|
+
* bundle swap is a rename within it) answers it at install time, and the code is
|
|
497
|
+
* correct either way: writable → swap, unwritable → hand the bundle back.
|
|
498
|
+
*
|
|
499
|
+
* Every attempt to characterise this from inside a bridge session is
|
|
500
|
+
* inconclusive: the session runs under Seatbelt, where even writing the user's
|
|
501
|
+
* own HOME is denied. Treat that as a sandbox denial, not a filesystem verdict.
|
|
502
|
+
*/
|
|
503
|
+
function canWriteInstallLocation(appPath) {
|
|
504
|
+
const parent = path.dirname(appPath);
|
|
505
|
+
try {
|
|
506
|
+
// Access the PARENT, because replacing a bundle is a rename within it.
|
|
507
|
+
accessSync(parent, constants.W_OK);
|
|
508
|
+
return true;
|
|
509
|
+
}
|
|
510
|
+
catch {
|
|
511
|
+
return false;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Run a full update: download, verify, extract, install.
|
|
516
|
+
*
|
|
517
|
+
* Single-flight. A second call while one is running joins the running install
|
|
518
|
+
* instead of starting a parallel download — two concurrent installs would race
|
|
519
|
+
* on the same destination bundle.
|
|
520
|
+
*
|
|
521
|
+
* The outcome depends on where the app is installed:
|
|
522
|
+
*
|
|
523
|
+
* - writable location (e.g. `~/Applications`): the bundle is replaced and the
|
|
524
|
+
* state ends `done`, with `restartRequired` telling the client to ask the
|
|
525
|
+
* user to quit and reopen. The app is never killed from here — it may hold
|
|
526
|
+
* in-flight session state, and macOS keeps a running process's inode alive
|
|
527
|
+
* across the rename anyway.
|
|
528
|
+
* - not writable (e.g. `/Applications`): the download and verification still
|
|
529
|
+
* run, but the swap is not attempted. The state ends `needs-user-install`
|
|
530
|
+
* with `artifactPath` pointing at the verified bundle, and the client shows
|
|
531
|
+
* the user a "move this into place" instruction (or opens it in Finder so
|
|
532
|
+
* they can drag it). Reporting success here would be a lie.
|
|
533
|
+
*/
|
|
534
|
+
let inFlight = null;
|
|
535
|
+
/**
|
|
536
|
+
* Bumped by every reset so an install abandoned mid-flight cannot write its
|
|
537
|
+
* outcome over the state a newer caller is setting.
|
|
538
|
+
*
|
|
539
|
+
* Without it, a reset between two tests clears `inFlight` while the old install
|
|
540
|
+
* is still running; the next `startAppUpdate` then starts fresh and the
|
|
541
|
+
* abandoned install's `finally` later overwrites its state — which is how a
|
|
542
|
+
* "second install is refused" test saw a stale success instead of the 409.
|
|
543
|
+
*/
|
|
544
|
+
let installGeneration = 0;
|
|
545
|
+
/**
|
|
546
|
+
* The rename used to swap bundles. A module-level seam (rather than a
|
|
547
|
+
* parameter threaded through `startAppUpdate`) so a test can fail the SECOND
|
|
548
|
+
* rename specifically — the one window where a naive implementation would
|
|
549
|
+
* leave the machine with no app. No filesystem setup can produce that failure:
|
|
550
|
+
* anything that blocks rename #2 blocks rename #1 too.
|
|
551
|
+
*/
|
|
552
|
+
let renameFile = rename;
|
|
553
|
+
/** Override the rename used by the installer (test seam). */
|
|
554
|
+
export function setInstallRenameForTest(impl) {
|
|
555
|
+
renameFile = impl;
|
|
556
|
+
}
|
|
557
|
+
export function startAppUpdate(file, version, options = {}) {
|
|
558
|
+
if (inFlight)
|
|
559
|
+
return inFlight;
|
|
560
|
+
const env = options.env ?? process.env;
|
|
561
|
+
const platform = currentPlatform(options.platform);
|
|
562
|
+
// Defaults to the seam (not bare `fetch`) so a test that overrides the
|
|
563
|
+
// network also covers the install path, not just the manifest read.
|
|
564
|
+
const fetchImpl = options.fetchImpl ?? manifestNetwork;
|
|
565
|
+
const generation = installGeneration;
|
|
566
|
+
// Every state write goes through here, so an abandoned install (one whose
|
|
567
|
+
// generation was superseded by a reset) stops reporting the moment it is
|
|
568
|
+
// orphaned rather than at its next await point.
|
|
569
|
+
const publish = (next) => {
|
|
570
|
+
if (generation !== installGeneration)
|
|
571
|
+
return;
|
|
572
|
+
installState = { ...installState, ...next };
|
|
573
|
+
};
|
|
574
|
+
installState = { stage: "downloading", version, receivedBytes: 0, totalBytes: file.size ?? null };
|
|
575
|
+
const run = (async () => {
|
|
576
|
+
try {
|
|
577
|
+
const staging = await mkdtemp(path.join(tmpdir(), "zcode-acp-update-"));
|
|
578
|
+
try {
|
|
579
|
+
const downloaded = await downloadRelease(file, {
|
|
580
|
+
destDir: staging,
|
|
581
|
+
fetchImpl,
|
|
582
|
+
onProgress: (p) => {
|
|
583
|
+
publish({ receivedBytes: p.receivedBytes, totalBytes: p.totalBytes });
|
|
584
|
+
},
|
|
585
|
+
});
|
|
586
|
+
publish({ stage: "installing" });
|
|
587
|
+
const appPath = appBundlePath(env);
|
|
588
|
+
if (!appPath)
|
|
589
|
+
throw new Error("app_not_installed");
|
|
590
|
+
if (platform !== "darwin") {
|
|
591
|
+
// Linux/Windows installs belong to a package manager (apt, dnf,
|
|
592
|
+
// pacman, winget); a raw file move would bypass it and leave the
|
|
593
|
+
// package database disagreeing with the filesystem. Hand the verified
|
|
594
|
+
// artifact back and let the client's instructions do the rest.
|
|
595
|
+
publish({ stage: "needs-user-install", artifactPath: downloaded.file });
|
|
596
|
+
return { ...installState };
|
|
597
|
+
}
|
|
598
|
+
const extracted = await extractZip(downloaded.file, path.join(staging, "extracted"));
|
|
599
|
+
if (!canWriteInstallLocation(appPath)) {
|
|
600
|
+
// The download and checksum succeeded, but the swap is not ours to
|
|
601
|
+
// do: no password prompt, no Finder Apple Event, no shipped helper.
|
|
602
|
+
// Report the artifact rather than claiming the app was updated.
|
|
603
|
+
warn(`app-update: ${path.dirname(appPath)} is not writable by this process — ` +
|
|
604
|
+
"the verified bundle is left at " +
|
|
605
|
+
extracted);
|
|
606
|
+
publish({ stage: "needs-user-install", artifactPath: extracted });
|
|
607
|
+
return { ...installState };
|
|
608
|
+
}
|
|
609
|
+
// Replace atomically-ish: move the old bundle aside, move the new one
|
|
610
|
+
// in, then delete the old one. A failure at the second rename restores
|
|
611
|
+
// the previous bundle, so the window between them can never leave the
|
|
612
|
+
// machine with no app.
|
|
613
|
+
const previous = `${appPath}.previous-${Date.now()}`;
|
|
614
|
+
await renameFile(appPath, previous);
|
|
615
|
+
try {
|
|
616
|
+
await renameFile(extracted, appPath);
|
|
617
|
+
}
|
|
618
|
+
catch (error) {
|
|
619
|
+
// Put the old bundle back. If THIS also fails the machine is in a
|
|
620
|
+
// state no code can fix (a read-only install dir), so surface the
|
|
621
|
+
// original error rather than masking it with the restore failure.
|
|
622
|
+
await renameFile(previous, appPath).catch(() => undefined);
|
|
623
|
+
throw error;
|
|
624
|
+
}
|
|
625
|
+
await rm(previous, { recursive: true, force: true }).catch(() => undefined);
|
|
626
|
+
publish({ stage: "done", restartRequired: true });
|
|
627
|
+
return { ...installState };
|
|
628
|
+
}
|
|
629
|
+
finally {
|
|
630
|
+
// Only sweep the staging dir when nothing in it is still needed. A
|
|
631
|
+
// needs-user-install outcome deliberately keeps the extracted bundle.
|
|
632
|
+
if (installState.stage !== "needs-user-install") {
|
|
633
|
+
await rm(staging, { recursive: true, force: true }).catch(() => undefined);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
catch (error) {
|
|
638
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
639
|
+
warn(`app-update: install failed: ${message}`);
|
|
640
|
+
publish({ stage: "failed", error: message });
|
|
641
|
+
return { ...installState };
|
|
642
|
+
}
|
|
643
|
+
finally {
|
|
644
|
+
if (generation === installGeneration)
|
|
645
|
+
inFlight = null;
|
|
646
|
+
}
|
|
647
|
+
})();
|
|
648
|
+
inFlight = run;
|
|
649
|
+
return run;
|
|
650
|
+
}
|
|
651
|
+
/** Clear the stored install state (test seam). */
|
|
652
|
+
export function resetAppUpdateStateForTest() {
|
|
653
|
+
installState = { stage: "idle", version: null, receivedBytes: 0, totalBytes: null };
|
|
654
|
+
inFlight = null;
|
|
655
|
+
// Orphan any install still running: it stops reporting and stops clearing
|
|
656
|
+
// `inFlight`, so the next caller gets a clean single-flight slot.
|
|
657
|
+
installGeneration += 1;
|
|
658
|
+
}
|
|
659
|
+
//# sourceMappingURL=app-update.js.map
|