@zocomputer/cli 0.3.0 → 0.4.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.
@@ -0,0 +1,42 @@
1
+ /** Edits that turn one word into another: insert, delete, replace, or swap two neighbors. */
2
+ function editDistance(left, right) {
3
+ let previous = [];
4
+ let current = Array.from({ length: right.length + 1 }, (_, index) => index);
5
+ for (let row = 1; row <= left.length; row++) {
6
+ const next = [row];
7
+ for (let column = 1; column <= right.length; column++) {
8
+ const same = left[row - 1] === right[column - 1];
9
+ let cost = Math.min((current[column] ?? 0) + 1, (next[column - 1] ?? 0) + 1, (current[column - 1] ?? 0) + (same ? 0 : 1));
10
+ if (row > 1 &&
11
+ column > 1 &&
12
+ left[row - 1] === right[column - 2] &&
13
+ left[row - 2] === right[column - 1])
14
+ cost = Math.min(cost, (previous[column - 2] ?? 0) + 1);
15
+ next.push(cost);
16
+ }
17
+ previous = current;
18
+ current = next;
19
+ }
20
+ return current[right.length] ?? 0;
21
+ }
22
+ /**
23
+ * The candidate a mistyped word most likely meant: the nearest within one
24
+ * edit per three letters, else the shortest one the word begins.
25
+ */
26
+ export function closest(word, candidates) {
27
+ const typed = word.toLowerCase();
28
+ let best;
29
+ let bestDistance = Math.max(1, Math.floor(typed.length / 3)) + 1;
30
+ for (const candidate of candidates) {
31
+ const distance = editDistance(typed, candidate);
32
+ if (distance < bestDistance) {
33
+ best = candidate;
34
+ bestDistance = distance;
35
+ }
36
+ }
37
+ if (best !== undefined || typed.length < 3)
38
+ return best;
39
+ return candidates
40
+ .filter((candidate) => candidate.startsWith(typed))
41
+ .sort((left, right) => left.length - right.length)[0];
42
+ }
@@ -0,0 +1,21 @@
1
+ import type { FetchLike } from "@zocomputer/system-api";
2
+ export interface UpdateOptions {
3
+ /** The standalone executable's version. npm installations have none. */
4
+ version?: string;
5
+ execPath?: string;
6
+ env?: NodeJS.ProcessEnv;
7
+ fetch?: FetchLike;
8
+ signal?: AbortSignal;
9
+ stderr?: {
10
+ write(text: string): unknown;
11
+ };
12
+ /** Runs the release installer and resolves with its exit status. */
13
+ runInstaller?: (script: Buffer, args: string[]) => Promise<number>;
14
+ }
15
+ export interface UpdateResult {
16
+ /** The version the zo command runs after this update. */
17
+ version: string;
18
+ latest_version: string;
19
+ updated: boolean;
20
+ }
21
+ export declare function update(options?: UpdateOptions): Promise<UpdateResult>;
package/dist/update.js ADDED
@@ -0,0 +1,195 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { readFile, readlink, realpath } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { delimiter, dirname, isAbsolute, join } from "node:path";
6
+ import { CliError, record, safeText, throwIfInterrupted } from "./errors.js";
7
+ const releases = "https://github.com/zocomputer/zo-cli/releases";
8
+ const installCommand = "curl -fsSL https://zo.computer/install.sh | sh";
9
+ const versionPattern = /^(\d+)\.(\d+)\.(\d+)(-[A-Za-z0-9][A-Za-z0-9.-]*)?$/;
10
+ // The installer links <data home>/zo/cli/releases/<version>/<target>/zo.
11
+ const managedPath = /^(\/.*\/zo\/cli)\/releases\/[^/]+\/[^/]+\/zo$/;
12
+ function unreachable() {
13
+ return new CliError("Could not download the latest Zo CLI release. Check your connection and try again.", 5, "transport_error");
14
+ }
15
+ /** Follows redirects by hand so a release can never be fetched over plain HTTP. */
16
+ async function download(url, fetcher, signal) {
17
+ const timeout = AbortSignal.timeout(60_000);
18
+ const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
19
+ try {
20
+ let target = url;
21
+ for (let hop = 0; hop < 5; hop++) {
22
+ const response = await fetcher(target, {
23
+ redirect: "manual",
24
+ signal: combined,
25
+ });
26
+ const location = response.headers.get("location");
27
+ if (response.status >= 300 && response.status < 400 && location) {
28
+ await response.body?.cancel();
29
+ target = new URL(location, target).href;
30
+ if (!target.startsWith("https://"))
31
+ break;
32
+ continue;
33
+ }
34
+ if (!response.ok || !response.body)
35
+ break;
36
+ // The manifest and installer are a few kilobytes; refuse anything else.
37
+ const reader = response.body.getReader();
38
+ const chunks = [];
39
+ for (let size = 0;;) {
40
+ const { value, done } = await reader.read();
41
+ if (done)
42
+ return Buffer.concat(chunks);
43
+ size += value.length;
44
+ if (size > 1_048_576) {
45
+ await reader.cancel().catch(() => { });
46
+ break;
47
+ }
48
+ chunks.push(value);
49
+ }
50
+ break;
51
+ }
52
+ }
53
+ catch {
54
+ throwIfInterrupted(signal);
55
+ }
56
+ throw unreachable();
57
+ }
58
+ function isNewer(candidate, current) {
59
+ const next = versionPattern.exec(candidate);
60
+ const now = versionPattern.exec(current);
61
+ if (!next || !now)
62
+ return false;
63
+ for (const index of [1, 2, 3]) {
64
+ const difference = Number(next[index]) - Number(now[index]);
65
+ if (difference !== 0)
66
+ return difference > 0;
67
+ }
68
+ // 0.4.0 follows 0.4.0-rc.1; the latest release is never a prerelease.
69
+ return now[4] !== undefined && next[4] === undefined;
70
+ }
71
+ /** Finds the installer's link on PATH, else in the installer's default directory. */
72
+ async function managedCommand(executable, env) {
73
+ const directories = (env.PATH ?? "").split(delimiter);
74
+ directories.push(join(env.HOME ?? homedir(), ".local/bin"));
75
+ let shadowedBy;
76
+ for (const directory of directories) {
77
+ if (!isAbsolute(directory))
78
+ continue;
79
+ const command = join(directory, "zo");
80
+ const resolved = await realpath(command).catch(() => undefined);
81
+ if (resolved === undefined)
82
+ continue;
83
+ if (resolved !== executable) {
84
+ shadowedBy ??= command;
85
+ continue;
86
+ }
87
+ // The installer replaces only a link it wrote, so an alias that points at
88
+ // that link is passed over for the link itself.
89
+ const root = managedPath.exec(await readlink(command).catch(() => ""))?.[1];
90
+ if (root)
91
+ return { directory, root, shadowedBy };
92
+ }
93
+ return undefined;
94
+ }
95
+ function shell(env, signal, stderr) {
96
+ return (script, args) => new Promise((resolve, reject) => {
97
+ // The installer reads itself from stdin, as it does under `curl | sh`.
98
+ // It gets its own process group so an interrupt also stops its download;
99
+ // a shell alone would wait for curl before running its trap.
100
+ const child = spawn("/bin/sh", ["-s", "--", ...args], {
101
+ env,
102
+ detached: true,
103
+ stdio: ["pipe", "ignore", "pipe"],
104
+ });
105
+ const stop = () => {
106
+ try {
107
+ if (child.pid !== undefined)
108
+ process.kill(-child.pid, "SIGTERM");
109
+ }
110
+ catch {
111
+ /* The installer already exited. */
112
+ }
113
+ };
114
+ signal?.addEventListener("abort", stop, { once: true });
115
+ if (signal?.aborted)
116
+ stop();
117
+ child.on("error", reject);
118
+ child.on("close", (code) => {
119
+ signal?.removeEventListener("abort", stop);
120
+ resolve(code ?? 1);
121
+ });
122
+ child.stderr.on("data", (chunk) => stderr.write(String(chunk)));
123
+ // A shell that exits early closes the pipe; its status reports why.
124
+ child.stdin.on("error", () => { });
125
+ child.stdin.end(script);
126
+ });
127
+ }
128
+ export async function update(options = {}) {
129
+ const env = options.env ?? process.env;
130
+ const stderr = options.stderr ?? process.stderr;
131
+ const current = options.version;
132
+ if (current === undefined)
133
+ throw new CliError("zo update replaces the standalone executable. This zo was installed with npm; run npm install --global @zocomputer/cli@latest instead.", 2, "unsupported_installation");
134
+ const unmanaged = (reason) => new CliError(`${reason} Install the latest release with: ${installCommand}`, 2, "unsupported_installation");
135
+ const executable = await realpath(options.execPath ?? process.execPath);
136
+ const command = await managedCommand(executable, env);
137
+ if (managedPath.test(executable) && !command)
138
+ throw unmanaged("No zo command on your PATH links to this executable.");
139
+ const marker = command
140
+ ? await readFile(join(command.root, ".installer-v1"), "utf8").catch(() => "")
141
+ : "";
142
+ if (!command || marker.trim() !== "zo-cli-installer-v1")
143
+ throw unmanaged("This zo was not installed by the standalone installer, so it cannot update itself.");
144
+ const fetcher = options.fetch ?? fetch;
145
+ let manifest;
146
+ try {
147
+ manifest = JSON.parse((await download(`${releases}/latest/download/release.json`, fetcher, options.signal)).toString("utf8"));
148
+ }
149
+ catch (error) {
150
+ if (error instanceof CliError)
151
+ throw error;
152
+ }
153
+ const invalid = new CliError("The latest Zo CLI release could not be verified. Nothing was changed.", 5, "invalid_response");
154
+ if (!record(manifest) ||
155
+ manifest.schema_version !== 1 ||
156
+ typeof manifest.cli_version !== "string" ||
157
+ !versionPattern.test(manifest.cli_version) ||
158
+ typeof manifest.installer_sha256 !== "string")
159
+ throw invalid;
160
+ const latest = manifest.cli_version;
161
+ if (!isNewer(latest, current)) {
162
+ stderr.write(latest === current
163
+ ? `zo ${current} is the latest version.\n`
164
+ : `zo ${current} is newer than the latest release, ${latest}. Nothing to update; to install ${latest}, run: ${installCommand}\n`);
165
+ return { version: current, latest_version: latest, updated: false };
166
+ }
167
+ // Each release publishes the installer pinned to its own version.
168
+ const installer = await download(`${releases}/download/v${latest}/install.sh`, fetcher, options.signal);
169
+ if (createHash("sha256").update(installer).digest("hex") !==
170
+ manifest.installer_sha256)
171
+ throw invalid;
172
+ stderr.write(`Updating zo ${current} to ${latest}…\n`);
173
+ // The installer derives its root from XDG_DATA_HOME, which may have changed
174
+ // since installation; the link records the root it must keep using.
175
+ const run = options.runInstaller ??
176
+ shell({ ...env, XDG_DATA_HOME: dirname(dirname(command.root)) }, options.signal, stderr);
177
+ let status;
178
+ try {
179
+ status = await run(installer, [
180
+ "--bin-dir",
181
+ command.directory,
182
+ "--no-modify-path",
183
+ ]);
184
+ }
185
+ catch {
186
+ status = 1;
187
+ }
188
+ throwIfInterrupted(options.signal);
189
+ if (status !== 0)
190
+ throw new CliError(`The update did not finish. zo ${current} is still installed.`, 5, "update_failed");
191
+ stderr.write(`Updated zo to ${latest}. Sign-ins are unchanged.\n`);
192
+ if (command.shadowedBy)
193
+ stderr.write(`Another zo at ${safeText(command.shadowedBy)} comes first on your PATH and was not updated.\n`);
194
+ return { version: latest, latest_version: latest, updated: true };
195
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zocomputer/cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "description": "Public Zo command line client",
6
6
  "bin": {
@@ -14,7 +14,7 @@
14
14
  "node": "^22.23.4 || ^24.22.1 || ^26.13.0"
15
15
  },
16
16
  "dependencies": {
17
- "@zocomputer/system-api": "0.2.0"
17
+ "@zocomputer/system-api": "0.3.1"
18
18
  },
19
19
  "publishConfig": {
20
20
  "access": "public",