zcode-acp-server 0.46.7 → 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.
Files changed (55) hide show
  1. package/README.md +1 -1
  2. package/dist/code-fingerprint.json +2 -2
  3. package/dist/remote/endpoint.d.ts.map +1 -1
  4. package/dist/remote/endpoint.js +6 -0
  5. package/dist/remote/endpoint.js.map +1 -1
  6. package/dist/remote/hub-server.d.ts.map +1 -1
  7. package/dist/remote/hub-server.js +102 -0
  8. package/dist/remote/hub-server.js.map +1 -1
  9. package/dist/remote/settings-endpoint.d.ts +45 -0
  10. package/dist/remote/settings-endpoint.d.ts.map +1 -0
  11. package/dist/remote/settings-endpoint.js +920 -0
  12. package/dist/remote/settings-endpoint.js.map +1 -0
  13. package/dist/settings/agents-config.d.ts +158 -0
  14. package/dist/settings/agents-config.d.ts.map +1 -0
  15. package/dist/settings/agents-config.js +598 -0
  16. package/dist/settings/agents-config.js.map +1 -0
  17. package/dist/settings/app-update.d.ts +193 -0
  18. package/dist/settings/app-update.d.ts.map +1 -0
  19. package/dist/settings/app-update.js +659 -0
  20. package/dist/settings/app-update.js.map +1 -0
  21. package/dist/settings/atomic-write.d.ts +106 -0
  22. package/dist/settings/atomic-write.d.ts.map +1 -0
  23. package/dist/settings/atomic-write.js +228 -0
  24. package/dist/settings/atomic-write.js.map +1 -0
  25. package/dist/settings/cli-config.d.ts +145 -0
  26. package/dist/settings/cli-config.d.ts.map +1 -0
  27. package/dist/settings/cli-config.js +320 -0
  28. package/dist/settings/cli-config.js.map +1 -0
  29. package/dist/settings/coding-plan.d.ts +106 -0
  30. package/dist/settings/coding-plan.d.ts.map +1 -0
  31. package/dist/settings/coding-plan.js +299 -0
  32. package/dist/settings/coding-plan.js.map +1 -0
  33. package/dist/settings/file-lock.d.ts +39 -0
  34. package/dist/settings/file-lock.d.ts.map +1 -0
  35. package/dist/settings/file-lock.js +282 -0
  36. package/dist/settings/file-lock.js.map +1 -0
  37. package/dist/settings/provider-config.d.ts +123 -0
  38. package/dist/settings/provider-config.d.ts.map +1 -0
  39. package/dist/settings/provider-config.js +311 -0
  40. package/dist/settings/provider-config.js.map +1 -0
  41. package/dist/settings/skills.d.ts +72 -0
  42. package/dist/settings/skills.d.ts.map +1 -0
  43. package/dist/settings/skills.js +245 -0
  44. package/dist/settings/skills.js.map +1 -0
  45. package/dist/settings/usage-stats.d.ts +74 -0
  46. package/dist/settings/usage-stats.d.ts.map +1 -0
  47. package/dist/settings/usage-stats.js +235 -0
  48. package/dist/settings/usage-stats.js.map +1 -0
  49. package/dist/utils.d.ts +14 -0
  50. package/dist/utils.d.ts.map +1 -1
  51. package/dist/utils.js +22 -0
  52. package/dist/utils.js.map +1 -1
  53. package/docs/ARCHITECTURE.md +65 -0
  54. package/docs/REMOTE-CLIENTS.md +252 -0
  55. 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