@dreamlake/ml-dash 0.1.0 → 0.1.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/README.md CHANGED
@@ -27,9 +27,11 @@ The command is still `ml-dash`. The package is scoped because the unscoped
27
27
  name is not claimable — npm refuses `ml-dash` as too similar to the existing
28
28
  `mldash`.
29
29
 
30
- *Not published yet:* the npm channel is built and tested on every release run,
31
- but no version has reached the registry. Until one does, use the installers
32
- above. `docs/RELEASE.md` tracks what is outstanding.
30
+ *Current state:* `@dreamlake/ml-dash@0.1.0` is on the registry. On R2 the
31
+ 0.1.0 artifacts and both installers are published, but the `latest` and
32
+ `stable` channel pointers have not been written yet — so the two install
33
+ commands above need an explicit `--version 0.1.0` until a release run moves
34
+ them. `docs/RELEASE.md` tracks what is outstanding.
33
35
 
34
36
  Both channels run the same code — the binaries are `src/index.ts` compiled
35
37
  ahead of time, the npm package is the same source compiled to `dist/`.
@@ -59,6 +61,46 @@ install path is already occupied by a file this installer did not write — a
59
61
  symlink, a pipx shim, anything without its receipt — it stops instead of
60
62
  overwriting; `--force` / `-Force` takes over deliberately.
61
63
 
64
+ ## Updating
65
+
66
+ ```sh
67
+ ml-dash update # update to the latest release
68
+ ml-dash update --check # report whether one exists; change nothing
69
+ ```
70
+
71
+ `update` uses the channel this copy was installed from, and works it out from
72
+ the running process rather than guessing: an npm install runs
73
+ `npm install -g @dreamlake/ml-dash@<version>`, a standalone binary replaces
74
+ itself. Run from a source checkout it refuses outright, so `npm run cli --
75
+ update` in this repository cannot quietly rewrite the `ml-dash` on your PATH.
76
+
77
+ For a standalone binary, an update is only written after it has been proven:
78
+
79
+ - the version comes from the `latest` pointer in the same bucket the binary was
80
+ installed from — the URL is compiled in, not configurable by a user;
81
+ - the download is checked against the sha256 **and** the byte count in that
82
+ release's `manifest.json`, and is cut off the moment it runs past the
83
+ declared length;
84
+ - the downloaded binary is run once (`ml-dash version`) and has to report the
85
+ version that was asked for — the same gate `install.sh` applies, which is
86
+ what catches a correct build for the wrong libc;
87
+ - only then is it renamed over the running binary, keeping its permissions, and
88
+ the install receipt is refreshed so `install.sh` still recognises the install.
89
+
90
+ Any failure leaves the working binary exactly as it was and removes the
91
+ download. `update` never downgrades: if the pointer moves backwards it stops
92
+ and tells you to use `install.sh --version` if that is really what you want.
93
+
94
+ It will refuse, rather than force, an install it does not own. A binary sitting
95
+ in a Homebrew cellar, a Nix store, a pipx venv or a `node_modules` tree is left
96
+ alone with a pointer to that tool's own upgrade command, and so is a directory
97
+ it cannot write to.
98
+
99
+ `--version <x.y.z>` installs an exact release. `--json` prints the report for
100
+ scripts. On Windows the running `.exe` cannot be replaced while it is open, so
101
+ the verified file is swapped in as the command exits; `ml-dash update` tells
102
+ you that and your next `ml-dash` is the new one.
103
+
62
104
  ## Usage
63
105
 
64
106
  ```sh
@@ -0,0 +1,250 @@
1
+ /**
2
+ * `ml-dash update` — update this install, in place, from wherever it came.
3
+ *
4
+ * Two channels ship ml-dash and they are updated in completely different ways,
5
+ * so the first thing this does is work out which one is running (see
6
+ * src/update/channel.ts) and refuse if the answer is "neither" — a source
7
+ * checkout must not quietly rewrite somebody's global install.
8
+ *
9
+ * npm ask registry.npmjs.org for dist-tags.latest, then run
10
+ * `npm install -g @dreamlake/ml-dash@<exact>` and re-read what
11
+ * npm actually left installed.
12
+ * standalone read the `latest` pointer and the release manifest from the
13
+ * same bucket the binary was installed from, download this
14
+ * platform's build, check its size and sha256 against the
15
+ * manifest, run it once, and only then rename it over the
16
+ * running binary.
17
+ *
18
+ * Failure at any point leaves the installed binary untouched and removes the
19
+ * download. Nothing is ever written next to the binary before its checksum has
20
+ * been verified.
21
+ */
22
+ import { spawnSync } from "node:child_process";
23
+ import { rm } from "node:fs/promises";
24
+ import path from "node:path";
25
+ import semver from "semver";
26
+ import { bold, cyan, dim, green, red, yellow } from "../util/ansi.js";
27
+ import { VERSION } from "../version.js";
28
+ import { detectChannel, PACKAGE_NAME } from "../update/channel.js";
29
+ import { detectPlatform, UnsupportedPlatformError } from "../update/platform.js";
30
+ import { binaryUrl, downloadTo, entryFor, isStrictSemver, readChannelVersion, readManifest, resolveBaseUrl, UpdateSourceError, } from "../update/release.js";
31
+ import { findNpm, installedGlobalVersion, latestPublishedVersion, resolveRegistry, runNpmInstall, versionExists, } from "../update/npm.js";
32
+ import { checkReplaceable, ReplaceError, scheduleWindowsSwap, swapInPlace } from "../update/replace.js";
33
+ export const spec = {
34
+ name: "update",
35
+ help: "Update ml-dash to the latest version",
36
+ description: "Check for a newer ml-dash and install it.\n\n" +
37
+ "The update uses whichever channel this copy was installed from: an npm\n" +
38
+ "install is updated with 'npm install -g', a standalone binary replaces\n" +
39
+ "itself with the build published for this platform. A standalone download\n" +
40
+ "is checked against the sha256 in the release manifest and run once before\n" +
41
+ "it replaces anything, so a bad download leaves the working binary in place.",
42
+ options: [
43
+ { flags: ["--check"], dest: "check", boolean: true, help: "Report whether an update exists; change nothing" },
44
+ { flags: ["--version"], dest: "version", metavar: "VERSION", help: "Install this exact version instead of the latest" },
45
+ { flags: ["--json"], dest: "json", boolean: true, help: "Output as JSON" },
46
+ ],
47
+ };
48
+ const emit = (report, json, human) => {
49
+ if (json)
50
+ console.log(JSON.stringify(report, null, 2));
51
+ else
52
+ for (const line of human)
53
+ console.log(line);
54
+ return 0;
55
+ };
56
+ const fail = (message, json) => {
57
+ if (json)
58
+ console.log(JSON.stringify({ error: message }, null, 2));
59
+ else
60
+ console.error(`${red("✗ Update failed:")} ${message}`);
61
+ return 1;
62
+ };
63
+ export async function run(args) {
64
+ const json = args.json === true;
65
+ const check = args.check === true;
66
+ const pinned = typeof args.version === "string" ? args.version.trim().replace(/^v/, "") : undefined;
67
+ if (pinned !== undefined && !isStrictSemver(pinned)) {
68
+ return fail(`'${String(args.version)}' is not a version — expected something like 0.1.1`, json);
69
+ }
70
+ const channel = detectChannel();
71
+ if (channel.kind === "source") {
72
+ return fail(`this is not an installed ml-dash — ${channel.reason}.\n` +
73
+ ` There is nothing here to replace, and updating the copy on your PATH from a\n` +
74
+ ` checkout would change an install this process does not own. To update that\n` +
75
+ ` copy, run 'ml-dash update' from it instead.`, json);
76
+ }
77
+ try {
78
+ return channel.kind === "npm"
79
+ ? await updateNpm(channel, { check, pinned, json })
80
+ : await updateStandalone(channel, { check, pinned, json });
81
+ }
82
+ catch (e) {
83
+ if (e instanceof UpdateSourceError || e instanceof ReplaceError || e instanceof UnsupportedPlatformError) {
84
+ return fail(e.message, json);
85
+ }
86
+ throw e;
87
+ }
88
+ }
89
+ /**
90
+ * Decide what `target` means relative to what is installed.
91
+ *
92
+ * A downgrade is refused rather than performed. `update` is the command people
93
+ * run to move forward, and it is also the command a script runs unattended;
94
+ * silently installing an older build because a pointer moved backwards is the
95
+ * one outcome nobody asks for. Installing an older version deliberately is
96
+ * what install.sh --version is for, and the message says so.
97
+ */
98
+ function compareOrThrow(current, target) {
99
+ if (!semver.valid(current))
100
+ throw new UpdateSourceError(`this build reports version '${current}'`);
101
+ if (semver.eq(target, current))
102
+ return "same";
103
+ if (semver.lt(target, current)) {
104
+ throw new UpdateSourceError(`${target} is older than the installed ${current}, and update does not downgrade.\n` +
105
+ ` To install an older build deliberately:\n` +
106
+ ` curl -fsSL https://pub-42e1dcc7de574d4a92984865fdc95f10.r2.dev/install.sh | sh -s -- --version ${target}`);
107
+ }
108
+ return "newer";
109
+ }
110
+ // ── npm ──────────────────────────────────────────────────────────────────────
111
+ async function updateNpm(channel, opts) {
112
+ const { url: registry, overridden } = resolveRegistry();
113
+ if (overridden)
114
+ console.error(yellow(`note: using registry ${registry} (ML_DASH_UPDATE_REGISTRY)`));
115
+ const target = opts.pinned ?? (await latestPublishedVersion(registry));
116
+ if (opts.pinned && !(await versionExists(registry, opts.pinned))) {
117
+ throw new UpdateSourceError(`${PACKAGE_NAME}@${opts.pinned} is not published on ${registry}`);
118
+ }
119
+ const relation = compareOrThrow(VERSION, target);
120
+ const base = {
121
+ channel: "npm",
122
+ current: VERSION,
123
+ target,
124
+ update_available: relation === "newer",
125
+ action: relation === "same" ? "up-to-date" : "available",
126
+ };
127
+ if (relation === "same") {
128
+ return emit(base, opts.json, [`${green("✓")} ml-dash ${VERSION} is the latest version on npm.`]);
129
+ }
130
+ if (opts.check) {
131
+ return emit(base, opts.json, [
132
+ `${yellow("↑")} ml-dash ${cyan(target)} is available — you have ${VERSION}.`,
133
+ ` Install it with: ${bold("ml-dash update")}`,
134
+ ]);
135
+ }
136
+ const npm = findNpm();
137
+ if (!opts.json)
138
+ console.log(`Updating ml-dash ${VERSION} → ${cyan(target)} via npm`);
139
+ // Under --json this process's stdout carries one document and nothing else,
140
+ // so npm's own output is moved to stderr rather than interleaved into it.
141
+ const code = await runNpmInstall(npm, target, opts.json ? "stderr" : "inherit");
142
+ if (code !== 0) {
143
+ throw new UpdateSourceError(`npm install exited ${code}. Nothing here changed the install; npm's own output above says why.\n` +
144
+ ` A global install often needs elevated permissions — see 'npm config get prefix'.`);
145
+ }
146
+ // npm exiting 0 is not the same as the right version being installed: a
147
+ // global prefix this process cannot see, or an install that resolved a
148
+ // different tree, would both exit 0 and leave the old binary on PATH.
149
+ const now = installedGlobalVersion(npm, channel.packageDir);
150
+ if (now !== target) {
151
+ throw new UpdateSourceError(`npm reported success, but the globally installed version is ${now ?? "unreadable"}, not ${target}.\n` +
152
+ ` Check which ml-dash is first on your PATH: 'npm ls -g ${PACKAGE_NAME}' and 'which ml-dash'.`);
153
+ }
154
+ return emit({ ...base, action: "updated" }, opts.json, [
155
+ "",
156
+ `${green("✓ Updated to ml-dash " + target)}`,
157
+ dim(` npm reports ${PACKAGE_NAME}@${now} installed globally.`),
158
+ ]);
159
+ }
160
+ // ── standalone ───────────────────────────────────────────────────────────────
161
+ async function updateStandalone(channel, opts) {
162
+ const { url: baseUrl, overridden } = resolveBaseUrl();
163
+ if (overridden)
164
+ console.error(yellow(`note: updating from ${baseUrl} (ML_DASH_UPDATE_BASE_URL)`));
165
+ const platform = detectPlatform();
166
+ const target = opts.pinned ?? (await readChannelVersion(baseUrl, "latest"));
167
+ const relation = compareOrThrow(VERSION, target);
168
+ const base = {
169
+ channel: "standalone",
170
+ current: VERSION,
171
+ target,
172
+ update_available: relation === "newer",
173
+ action: relation === "same" ? "up-to-date" : "available",
174
+ };
175
+ if (relation === "same") {
176
+ return emit(base, opts.json, [`${green("✓")} ml-dash ${VERSION} is the latest ${platform} release.`]);
177
+ }
178
+ if (opts.check) {
179
+ return emit(base, opts.json, [
180
+ `${yellow("↑")} ml-dash ${cyan(target)} is available for ${platform} — you have ${VERSION}.`,
181
+ ` Install it with: ${bold("ml-dash update")}`,
182
+ ]);
183
+ }
184
+ const manifest = await readManifest(baseUrl, target);
185
+ const entry = entryFor(manifest, platform);
186
+ const executable = channel.executable;
187
+ await checkReplaceable(executable);
188
+ const source = binaryUrl(baseUrl, target, platform, entry.binary);
189
+ // Staged in the install directory so the last step is a rename within one
190
+ // filesystem, and named after this pid so two concurrent updates cannot
191
+ // write the same file.
192
+ const staged = path.join(path.dirname(executable), `.ml-dash.update.${process.pid}.tmp`);
193
+ let handedOff = false;
194
+ try {
195
+ if (!opts.json)
196
+ console.log(`Updating ml-dash ${VERSION} → ${cyan(target)} (${platform})`);
197
+ const sha = await downloadTo(source, staged, entry.size);
198
+ if (sha !== entry.checksum) {
199
+ throw new UpdateSourceError(`checksum mismatch — the download is not the published ${target} build.\n` +
200
+ ` expected ${entry.checksum}\n` +
201
+ ` got ${sha}\n` +
202
+ ` Nothing was installed; ${VERSION} is still in place.`);
203
+ }
204
+ if (!opts.json)
205
+ console.log(` ${green("sha256 ok")}`);
206
+ smokeTest(staged, target);
207
+ if (process.platform === "win32") {
208
+ // Windows keeps an image section open on a running .exe, so the rename
209
+ // has to happen after this process is gone.
210
+ await scheduleWindowsSwap({ target: executable, staged, version: target, platform, sha256: sha, source });
211
+ handedOff = true;
212
+ return emit({ ...base, action: "scheduled", note: "applied when this command exits" }, opts.json, [
213
+ "",
214
+ `${green("✓ ml-dash " + target + " is verified and ready.")}`,
215
+ ` It replaces the running binary as this command exits — your next ${bold("ml-dash")} is ${target}.`,
216
+ ]);
217
+ }
218
+ await swapInPlace({ target: executable, staged, version: target, platform, sha256: sha, source });
219
+ handedOff = true;
220
+ return emit({ ...base, action: "updated" }, opts.json, [
221
+ "",
222
+ `${green("✓ Updated to ml-dash " + target)}`,
223
+ dim(` ${executable}`),
224
+ ]);
225
+ }
226
+ finally {
227
+ // On every failure, and on Windows never: the helper still needs the file.
228
+ if (!handedOff)
229
+ await rm(staged, { force: true });
230
+ }
231
+ }
232
+ /**
233
+ * Run the downloaded binary before trusting it, the same gate install.sh
234
+ * applies. A correctly hashed build for the wrong libc is a real outcome —
235
+ * Bun's musl binaries need libstdc++, which Alpine does not ship — and it
236
+ * would otherwise surface as a broken ml-dash at the user's next command
237
+ * rather than here, with the working binary still in place.
238
+ */
239
+ function smokeTest(staged, expected) {
240
+ const r = spawnSync(staged, ["version"], { encoding: "utf8", shell: false, timeout: 60_000 });
241
+ const output = `${r.stdout ?? ""}${r.stderr ?? ""}`.trim();
242
+ if (r.status !== 0) {
243
+ throw new UpdateSourceError(`the downloaded ${expected} binary does not run on this system:\n` +
244
+ `${output.split("\n").map((l) => ` ${l}`).join("\n")}\n` +
245
+ ` Nothing was installed.`);
246
+ }
247
+ if (!output.includes(expected)) {
248
+ throw new UpdateSourceError(`the downloaded binary reports '${output}' rather than ${expected} — refusing to install it.`);
249
+ }
250
+ }
package/dist/index.js CHANGED
@@ -18,6 +18,7 @@ const loaders = {
18
18
  list: () => import("./commands/list.js"),
19
19
  upload: () => import("./commands/upload.js"),
20
20
  download: () => import("./commands/download.js"),
21
+ update: () => import("./commands/update.js"),
21
22
  };
22
23
  export async function main(argv) {
23
24
  const [command, ...rest] = argv;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Which of the two shipping channels is this process?
3
+ *
4
+ * `ml-dash update` must never guess. Updating the npm channel runs
5
+ * `npm install -g`; updating the standalone channel overwrites a file on disk.
6
+ * Doing either from a source checkout would mean a developer running
7
+ * `npm run cli -- update` silently mutates their global install, so a checkout
8
+ * is a third, explicit answer: refuse.
9
+ *
10
+ * The discriminator is exact rather than heuristic:
11
+ *
12
+ * standalone `bun build --compile` maps the bundle at `/$bunfs/root/...`,
13
+ * so `import.meta.url` is under that virtual root while
14
+ * `process.execPath` is the real single-file binary. Bun running
15
+ * loose source reports a real path for both, so the two cases
16
+ * cannot be confused. (Verified against bun 1.3.14 — the version
17
+ * scripts/build-release.ts pins.)
18
+ * npm a Node process whose own module resolves inside a
19
+ * `node_modules` tree rooted at this package. A checkout has the
20
+ * same package.json and no such ancestor.
21
+ */
22
+ import { readFileSync } from "node:fs";
23
+ import path from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+ /** Kept equal to package.json's `name` by a test — the two must not drift. */
26
+ export const PACKAGE_NAME = "@dreamlake/ml-dash";
27
+ const BUNFS = "/$bunfs/";
28
+ /** True when this module was loaded out of a `bun build --compile` bundle. */
29
+ export function isCompiledBinary(moduleUrl = import.meta.url) {
30
+ return process.versions.bun !== undefined && moduleUrl.includes(BUNFS);
31
+ }
32
+ /**
33
+ * The package root above `from`, if it is this package installed under a
34
+ * `node_modules` tree. Returns undefined for a checkout, for a different
35
+ * package, and for an unreadable or malformed package.json.
36
+ */
37
+ function npmPackageDir(from) {
38
+ let dir = from;
39
+ for (let depth = 0; depth < 10; depth++) {
40
+ const manifest = path.join(dir, "package.json");
41
+ let name;
42
+ try {
43
+ name = JSON.parse(readFileSync(manifest, "utf8")).name;
44
+ }
45
+ catch {
46
+ const parent = path.dirname(dir);
47
+ if (parent === dir)
48
+ return undefined;
49
+ dir = parent;
50
+ continue;
51
+ }
52
+ if (name !== PACKAGE_NAME)
53
+ return undefined;
54
+ // A checkout has this package.json too. Only an installed copy sits inside
55
+ // a node_modules tree, and that — not the file's contents — is what makes
56
+ // `npm install -g` the right thing to run.
57
+ return dir.split(path.sep).includes("node_modules") ? dir : undefined;
58
+ }
59
+ return undefined;
60
+ }
61
+ export function detectChannel(moduleUrl = import.meta.url) {
62
+ if (isCompiledBinary(moduleUrl)) {
63
+ return { kind: "standalone", executable: process.execPath };
64
+ }
65
+ if (moduleUrl.includes(BUNFS)) {
66
+ // Bun-mapped but not a compiled binary: nothing here knows what to update.
67
+ return { kind: "source", reason: "running from a bundled source tree" };
68
+ }
69
+ let here;
70
+ try {
71
+ here = path.dirname(fileURLToPath(moduleUrl));
72
+ }
73
+ catch {
74
+ return { kind: "source", reason: "this build has no resolvable install path" };
75
+ }
76
+ const packageDir = npmPackageDir(here);
77
+ if (packageDir)
78
+ return { kind: "npm", packageDir };
79
+ return {
80
+ kind: "source",
81
+ reason: process.versions.bun
82
+ ? "running from a source checkout under bun"
83
+ : "running from a source checkout, not an installed package",
84
+ };
85
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * The npm channel: ask the registry, then let npm do the install.
3
+ *
4
+ * Nothing here unpacks a tarball or writes into node_modules. `npm install -g`
5
+ * is the only supported way to change an npm install — it owns the global
6
+ * prefix, the bin shims and the lockfile-free global tree — so this module's
7
+ * whole job is to find the right npm, hand it an exact version, and check
8
+ * afterwards that the version it left behind is the one that was asked for.
9
+ */
10
+ import { spawn, spawnSync } from "node:child_process";
11
+ import { accessSync, constants, existsSync, readFileSync, realpathSync } from "node:fs";
12
+ import path from "node:path";
13
+ import { PACKAGE_NAME } from "./channel.js";
14
+ import { UpdateSourceError, isStrictSemver, readBounded, requireSafeOrigin } from "./release.js";
15
+ export const DEFAULT_REGISTRY = "https://registry.npmjs.org";
16
+ const REGISTRY_TIMEOUT_MS = 20_000;
17
+ /** The abbreviated packument is a few KB; the full one can be megabytes. */
18
+ const PACKUMENT_MAX_BYTES = 4 * 1024 * 1024;
19
+ /** Same narrow rule as the standalone channel's base URL — see release.ts. */
20
+ export function resolveRegistry(env = process.env) {
21
+ const raw = env.ML_DASH_UPDATE_REGISTRY?.trim();
22
+ if (!raw)
23
+ return { url: DEFAULT_REGISTRY, overridden: false };
24
+ return { url: requireSafeOrigin(raw, "ML_DASH_UPDATE_REGISTRY").replace(/\/+$/, ""), overridden: true };
25
+ }
26
+ /**
27
+ * `dist-tags.latest` for the package, read over plain HTTP rather than through
28
+ * `npm view`: `npm view` exits non-zero for "no such package" and for "the
29
+ * registry is unreachable" alike, and scripts/publish-release.sh already
30
+ * refuses to conflate those two for exactly this reason.
31
+ */
32
+ export async function latestPublishedVersion(registry) {
33
+ const url = `${registry}/${encodeURIComponent(PACKAGE_NAME)}`;
34
+ let res;
35
+ try {
36
+ res = await fetch(url, {
37
+ headers: { accept: "application/vnd.npm.install-v1+json" },
38
+ signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS),
39
+ redirect: "follow",
40
+ });
41
+ }
42
+ catch (e) {
43
+ throw new UpdateSourceError(`cannot reach the npm registry at ${url}: ${e instanceof Error ? e.message : String(e)}`);
44
+ }
45
+ requireSafeOrigin(res.url || url, "the registry that answered");
46
+ if (res.status === 404) {
47
+ throw new UpdateSourceError(`${PACKAGE_NAME} is not published on ${registry}`);
48
+ }
49
+ if (!res.ok)
50
+ throw new UpdateSourceError(`${url} returned HTTP ${res.status}`);
51
+ const text = await readBounded(res, PACKUMENT_MAX_BYTES, `the registry document at ${url}`);
52
+ let doc;
53
+ try {
54
+ doc = JSON.parse(text);
55
+ }
56
+ catch {
57
+ throw new UpdateSourceError(`the registry document at ${url} is not JSON`);
58
+ }
59
+ const latest = doc["dist-tags"]?.latest;
60
+ if (typeof latest !== "string" || !isStrictSemver(latest)) {
61
+ throw new UpdateSourceError(`${registry} reports no usable 'latest' version for ${PACKAGE_NAME}`);
62
+ }
63
+ return latest;
64
+ }
65
+ /** Whether the registry actually has this exact version, before npm is run. */
66
+ export async function versionExists(registry, version) {
67
+ const url = `${registry}/${encodeURIComponent(PACKAGE_NAME)}/${encodeURIComponent(version)}`;
68
+ try {
69
+ const res = await fetch(url, { signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS), redirect: "follow" });
70
+ if (res.status === 404)
71
+ return false;
72
+ if (!res.ok)
73
+ throw new UpdateSourceError(`${url} returned HTTP ${res.status}`);
74
+ return true;
75
+ }
76
+ catch (e) {
77
+ if (e instanceof UpdateSourceError)
78
+ throw e;
79
+ throw new UpdateSourceError(`cannot reach ${url}: ${e instanceof Error ? e.message : String(e)}`);
80
+ }
81
+ }
82
+ function which(name) {
83
+ const sep = process.platform === "win32" ? ";" : ":";
84
+ for (const dir of (process.env.PATH ?? "").split(sep)) {
85
+ if (!dir)
86
+ continue;
87
+ const candidate = path.join(dir, name);
88
+ try {
89
+ accessSync(candidate, process.platform === "win32" ? constants.F_OK : constants.X_OK);
90
+ return candidate;
91
+ }
92
+ catch {
93
+ // Not here; keep looking.
94
+ }
95
+ }
96
+ return undefined;
97
+ }
98
+ /**
99
+ * How to run npm without a shell.
100
+ *
101
+ * Preferring npm's own `npm-cli.js` over the `npm` wrapper is what makes that
102
+ * possible on Windows: since the 2024 fix for CVE-2024-27980, Node refuses to
103
+ * spawn a `.cmd` without `shell: true`, and turning the shell on would mean
104
+ * the package name and version were parsed by cmd.exe. Running the script with
105
+ * this process's own Node interpreter sidesteps the wrapper entirely and is
106
+ * the identical code path on every platform.
107
+ */
108
+ export function findNpm() {
109
+ const names = process.platform === "win32" ? ["npm.cmd", "npm.exe", "npm"] : ["npm"];
110
+ for (const name of names) {
111
+ const found = which(name);
112
+ if (!found)
113
+ continue;
114
+ // On Unix the `npm` on PATH is usually a symlink straight to npm-cli.js.
115
+ let resolved = found;
116
+ try {
117
+ resolved = realpathSync(found);
118
+ }
119
+ catch {
120
+ // Keep the unresolved path.
121
+ }
122
+ if (resolved.endsWith(".js"))
123
+ return { command: process.execPath, prefix: [resolved] };
124
+ // Otherwise look for the CLI script beside the wrapper, which is where
125
+ // every npm layout (Windows, nvm, Homebrew, Volta) puts it.
126
+ for (const relative of [
127
+ ["node_modules", "npm", "bin", "npm-cli.js"],
128
+ ["..", "lib", "node_modules", "npm", "bin", "npm-cli.js"],
129
+ ]) {
130
+ const cli = path.join(path.dirname(resolved), ...relative);
131
+ if (existsSync(cli))
132
+ return { command: process.execPath, prefix: [cli] };
133
+ }
134
+ // A wrapper we cannot see through. Safe to spawn directly everywhere
135
+ // except Windows, where Node will refuse a .cmd without a shell — and a
136
+ // shell is not something this command is willing to introduce.
137
+ if (process.platform === "win32" && /\.(cmd|bat)$/i.test(resolved))
138
+ continue;
139
+ return { command: resolved, prefix: [] };
140
+ }
141
+ throw new UpdateSourceError(`no npm executable was found on PATH, so this npm install cannot be updated from here.\n` +
142
+ ` Update it directly with: npm install -g ${PACKAGE_NAME}@latest`);
143
+ }
144
+ export const installArgs = (version) => [
145
+ "install",
146
+ "--global",
147
+ `${PACKAGE_NAME}@${version}`,
148
+ ];
149
+ /**
150
+ * Run `npm install -g <pkg>@<exact>` and return its exit code.
151
+ *
152
+ * Arguments go across as an array with `shell: false`, so the version string
153
+ * is an argument rather than something a shell re-parses. npm's stderr is
154
+ * always inherited — a failing install has to be able to say why — and only
155
+ * its stdout is diverted, onto this process's stderr rather than discarded,
156
+ * so `--json` loses none of the diagnosis it would otherwise print.
157
+ */
158
+ export function runNpmInstall(npm, version, output = "inherit") {
159
+ if (!isStrictSemver(version)) {
160
+ // Belt and braces: nothing should reach here with an unvalidated version.
161
+ throw new UpdateSourceError(`refusing to install '${version}': not a version`);
162
+ }
163
+ return new Promise((resolve, reject) => {
164
+ const child = spawn(npm.command, [...npm.prefix, ...installArgs(version)], {
165
+ stdio: npmStdio(output),
166
+ shell: false,
167
+ });
168
+ // "pipe" only ever means "send it to stderr instead": nothing is dropped.
169
+ child.stdout?.pipe(process.stderr);
170
+ child.on("error", reject);
171
+ child.on("close", (code) => resolve(code ?? 1));
172
+ });
173
+ }
174
+ /** stdin, stdout, stderr — separated out so the contract can be asserted. */
175
+ export const npmStdio = (output) => [
176
+ "inherit",
177
+ output === "stderr" ? "pipe" : "inherit",
178
+ "inherit",
179
+ ];
180
+ /**
181
+ * What is globally installed now, asked of npm rather than inferred.
182
+ *
183
+ * The running copy's own package.json is the fallback and not the primary
184
+ * answer: `npm install -g` writes to npm's global prefix, which is not
185
+ * necessarily the tree this process was loaded from.
186
+ */
187
+ export function installedGlobalVersion(npm, packageDir) {
188
+ const listed = npmLsVersion(npm);
189
+ if (listed)
190
+ return listed;
191
+ try {
192
+ return JSON.parse(readFileSync(path.join(packageDir, "package.json"), "utf8"))
193
+ .version;
194
+ }
195
+ catch {
196
+ return undefined;
197
+ }
198
+ }
199
+ function npmLsVersion(npm) {
200
+ const r = spawnSync(npm.command, [...npm.prefix, "ls", "--global", "--depth", "0", "--json", PACKAGE_NAME], {
201
+ encoding: "utf8",
202
+ shell: false,
203
+ timeout: 60_000,
204
+ });
205
+ if (!r.stdout)
206
+ return undefined;
207
+ try {
208
+ const doc = JSON.parse(r.stdout);
209
+ return doc.dependencies?.[PACKAGE_NAME]?.version;
210
+ }
211
+ catch {
212
+ return undefined;
213
+ }
214
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The platform key a release manifest is indexed by.
3
+ *
4
+ * These are the same eight keys `scripts/build-release.ts` builds and the same
5
+ * rules `install.sh` and `install.ps1` apply when they pick one — including the
6
+ * musl suffix, because a glibc binary does not start on Alpine at all. A
7
+ * divergence here would not fail loudly: it would download a real, correctly
8
+ * hashed binary for the wrong libc and only break at the next run.
9
+ */
10
+ import { existsSync, readdirSync } from "node:fs";
11
+ /** Every key `scripts/build-release.ts` emits. Used to validate a manifest. */
12
+ export const PLATFORM_KEYS = [
13
+ "darwin-arm64",
14
+ "darwin-x64",
15
+ "linux-x64",
16
+ "linux-arm64",
17
+ "linux-x64-musl",
18
+ "linux-arm64-musl",
19
+ "windows-x64",
20
+ "windows-arm64",
21
+ ];
22
+ export class UnsupportedPlatformError extends Error {
23
+ }
24
+ /**
25
+ * Probed rather than assumed, matching install.sh: `ldd --version | grep musl`
26
+ * is the shell's version of this, and the loader's own file name is the same
27
+ * evidence without a subprocess. `/etc/alpine-release` is a second signal for
28
+ * the case where /lib is not readable.
29
+ */
30
+ function isMusl() {
31
+ try {
32
+ if (readdirSync("/lib").some((f) => f.startsWith("ld-musl-")))
33
+ return true;
34
+ }
35
+ catch {
36
+ // /lib unreadable or absent; fall through to the distro marker.
37
+ }
38
+ return existsSync("/etc/alpine-release");
39
+ }
40
+ export function detectPlatform(platform = process.platform, arch = process.arch, musl = isMusl) {
41
+ let os;
42
+ switch (platform) {
43
+ case "darwin":
44
+ os = "darwin";
45
+ break;
46
+ case "linux":
47
+ os = "linux";
48
+ break;
49
+ case "win32":
50
+ os = "windows";
51
+ break;
52
+ default:
53
+ throw new UnsupportedPlatformError(`ml-dash has no build for '${platform}'. Supported: macOS, Linux and Windows.`);
54
+ }
55
+ let cpu;
56
+ switch (arch) {
57
+ case "x64":
58
+ cpu = "x64";
59
+ break;
60
+ case "arm64":
61
+ cpu = "arm64";
62
+ break;
63
+ default:
64
+ throw new UnsupportedPlatformError(`ml-dash has no build for '${arch}'. Supported architectures: x64 and arm64.`);
65
+ }
66
+ const key = os === "linux" && musl() ? `linux-${cpu}-musl` : `${os}-${cpu}`;
67
+ return key;
68
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Reading the standalone channel: the pointer, the manifest, and the bytes.
3
+ *
4
+ * This is the download half of install.sh expressed in TypeScript, and it
5
+ * makes the same promises: the origin is fixed at build time, the version is
6
+ * a pointer object published by scripts/publish-release.sh, and nothing is
7
+ * written anywhere until the downloaded bytes match the sha256 and the size
8
+ * the manifest records for this exact platform.
9
+ */
10
+ import { createHash } from "node:crypto";
11
+ import { createWriteStream } from "node:fs";
12
+ import { open } from "node:fs/promises";
13
+ import { Readable } from "node:stream";
14
+ import { pipeline } from "node:stream/promises";
15
+ import { PLATFORM_KEYS } from "./platform.js";
16
+ /**
17
+ * The bucket the releases are actually served from — the same constant
18
+ * scripts/build-release.ts bakes into the installers, checked against it by
19
+ * that script so the two cannot drift. A binary must not learn where to fetch
20
+ * its own replacement from anywhere but its own build.
21
+ */
22
+ export const DEFAULT_PUBLIC_URL = "https://pub-42e1dcc7de574d4a92984865fdc95f10.r2.dev";
23
+ export const RELEASE_PREFIX = "ml-dash-cli/releases";
24
+ /** Small documents. A pointer is a version string; a manifest is a few KB. */
25
+ const POINTER_MAX_BYTES = 256;
26
+ const MANIFEST_MAX_BYTES = 1024 * 1024;
27
+ const METADATA_TIMEOUT_MS = 20_000;
28
+ /**
29
+ * Binaries are 60–100 MB today. The cap is not the real check — the manifest's
30
+ * own `size` is, and it is enforced byte by byte below — it is the bound that
31
+ * applies before any manifest is trusted at all.
32
+ */
33
+ export const MAX_DOWNLOAD_BYTES = 512 * 1024 * 1024;
34
+ const DOWNLOAD_TIMEOUT_MS = 15 * 60_000;
35
+ export class UpdateSourceError extends Error {
36
+ }
37
+ /**
38
+ * Where to fetch from.
39
+ *
40
+ * `ML_DASH_UPDATE_BASE_URL` mirrors the `--base-url` / `ML_DASH_BASE_URL`
41
+ * override install.sh and install.ps1 have always documented, so a staging
42
+ * bucket can be tested end to end. It is deliberately narrow: HTTPS only,
43
+ * except for a loopback host, which is how the test fixture injects a local
44
+ * server without opening a hole anywhere a network attacker sits. A caller
45
+ * that overrides it is told so on stderr — an update that came from somewhere
46
+ * other than the official origin must never be silent.
47
+ */
48
+ export function resolveBaseUrl(env = process.env) {
49
+ const raw = env.ML_DASH_UPDATE_BASE_URL?.trim();
50
+ if (!raw)
51
+ return { url: DEFAULT_PUBLIC_URL, overridden: false };
52
+ return { url: requireSafeOrigin(raw, "ML_DASH_UPDATE_BASE_URL").replace(/\/+$/, ""), overridden: true };
53
+ }
54
+ const isLoopback = (hostname) => hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
55
+ /** HTTPS, or plain HTTP to a loopback address and nothing else. */
56
+ export function requireSafeOrigin(value, what) {
57
+ let parsed;
58
+ try {
59
+ parsed = new URL(value);
60
+ }
61
+ catch {
62
+ throw new UpdateSourceError(`${what} is not a URL: ${value}`);
63
+ }
64
+ if (parsed.protocol === "https:")
65
+ return value;
66
+ if (parsed.protocol === "http:" && isLoopback(parsed.hostname))
67
+ return value;
68
+ throw new UpdateSourceError(`${what} must be an https:// URL (got ${parsed.protocol}//${parsed.hostname}). ` +
69
+ `Plain http is accepted only for a loopback address.`);
70
+ }
71
+ async function getText(url, limit, what) {
72
+ let res;
73
+ try {
74
+ res = await fetch(url, { signal: AbortSignal.timeout(METADATA_TIMEOUT_MS), redirect: "follow" });
75
+ }
76
+ catch (e) {
77
+ throw new UpdateSourceError(`cannot reach ${url} to read ${what}: ${e instanceof Error ? e.message : String(e)}`);
78
+ }
79
+ // A redirect can leave the origin rule behind, so the URL that actually
80
+ // answered is checked, not just the one that was asked.
81
+ requireSafeOrigin(res.url || url, `the server that answered for ${what}`);
82
+ if (!res.ok) {
83
+ throw new UpdateSourceError(`${url} returned HTTP ${res.status} for ${what}`);
84
+ }
85
+ return readBounded(res, limit, `${what} at ${url}`);
86
+ }
87
+ /**
88
+ * Read a response body, giving up the moment it passes `limit`.
89
+ *
90
+ * `await res.text()` would buffer the whole thing first and only then let the
91
+ * limit be checked, which makes the limit useless against the case it exists
92
+ * for: a source that answers a 300-byte pointer request with an endless body.
93
+ * The declared Content-Length is rejected first when there is one, and the
94
+ * stream is counted either way, because Content-Length is a claim.
95
+ */
96
+ export async function readBounded(res, limit, what) {
97
+ const tooBig = () => new UpdateSourceError(`${what} is larger than ${limit} bytes — refusing to read it`);
98
+ const declared = Number(res.headers.get("content-length"));
99
+ if (Number.isFinite(declared) && declared > limit)
100
+ throw tooBig();
101
+ if (!res.body)
102
+ return "";
103
+ const reader = res.body.getReader();
104
+ const decoder = new TextDecoder();
105
+ let out = "";
106
+ let seen = 0;
107
+ try {
108
+ for (;;) {
109
+ const { done, value } = await reader.read();
110
+ if (done)
111
+ break;
112
+ seen += value.byteLength;
113
+ if (seen > limit)
114
+ throw tooBig();
115
+ out += decoder.decode(value, { stream: true });
116
+ }
117
+ }
118
+ finally {
119
+ await reader.cancel().catch(() => { });
120
+ }
121
+ return out + decoder.decode();
122
+ }
123
+ /** Strict semver, the same shape .github/workflows/release.yml accepts. */
124
+ const SEMVER = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/;
125
+ export const isStrictSemver = (v) => SEMVER.test(v);
126
+ /** The version a channel pointer names, e.g. `.../releases/latest`. */
127
+ export async function readChannelVersion(baseUrl, channel) {
128
+ const url = `${baseUrl}/${RELEASE_PREFIX}/${encodeURIComponent(channel)}`;
129
+ const version = (await getText(url, POINTER_MAX_BYTES, `the '${channel}' channel`)).trim();
130
+ if (!isStrictSemver(version)) {
131
+ throw new UpdateSourceError(`the '${channel}' channel at ${url} reads '${version.slice(0, 64)}', which is not a version`);
132
+ }
133
+ return version;
134
+ }
135
+ /**
136
+ * A file name and nothing else. The manifest decides what to write next to the
137
+ * running binary, so `../`, an absolute path and a nested path are all refused
138
+ * before the name is ever joined onto a directory.
139
+ */
140
+ const isPlainFilename = (name) => /^[A-Za-z0-9._-]+$/.test(name) && name !== "." && name !== "..";
141
+ export async function readManifest(baseUrl, version) {
142
+ if (!isStrictSemver(version))
143
+ throw new UpdateSourceError(`'${version}' is not a version`);
144
+ const url = `${baseUrl}/${RELEASE_PREFIX}/${version}/manifest.json`;
145
+ const text = await getText(url, MANIFEST_MAX_BYTES, `the manifest for ${version}`);
146
+ let parsed;
147
+ try {
148
+ parsed = JSON.parse(text);
149
+ }
150
+ catch {
151
+ throw new UpdateSourceError(`the manifest at ${url} is not JSON`);
152
+ }
153
+ const m = parsed;
154
+ if (m.version !== version) {
155
+ throw new UpdateSourceError(`the manifest at ${url} declares version ${String(m.version)}, not ${version}`);
156
+ }
157
+ if (!m.platforms || typeof m.platforms !== "object") {
158
+ throw new UpdateSourceError(`the manifest at ${url} lists no platforms`);
159
+ }
160
+ const platforms = {};
161
+ for (const [key, entry] of Object.entries(m.platforms)) {
162
+ if (!PLATFORM_KEYS.includes(key))
163
+ continue;
164
+ const { binary, checksum, size } = entry ?? {};
165
+ if (typeof binary !== "string" || !isPlainFilename(binary)) {
166
+ throw new UpdateSourceError(`the manifest at ${url} gives ${key} an unusable file name`);
167
+ }
168
+ if (typeof checksum !== "string" || !/^[0-9a-f]{64}$/.test(checksum)) {
169
+ throw new UpdateSourceError(`the manifest at ${url} gives ${key} no sha256`);
170
+ }
171
+ if (typeof size !== "number" || !Number.isInteger(size) || size <= 0 || size > MAX_DOWNLOAD_BYTES) {
172
+ throw new UpdateSourceError(`the manifest at ${url} gives ${key} an unusable size (${String(size)})`);
173
+ }
174
+ platforms[key] = { binary, checksum, size };
175
+ }
176
+ return { version, platforms };
177
+ }
178
+ export function entryFor(manifest, platform) {
179
+ const entry = manifest.platforms[platform];
180
+ if (!entry) {
181
+ throw new UpdateSourceError(`release ${manifest.version} has no build for ${platform} — it publishes: ` +
182
+ `${Object.keys(manifest.platforms).join(", ") || "nothing"}`);
183
+ }
184
+ return entry;
185
+ }
186
+ export const binaryUrl = (baseUrl, version, platform, binary) => `${baseUrl}/${RELEASE_PREFIX}/${version}/${platform}/${binary}`;
187
+ /**
188
+ * Stream a release binary to `dest`, refusing to write more bytes than the
189
+ * manifest says exist, and return the sha256 of what actually landed.
190
+ *
191
+ * The length is enforced while streaming rather than checked afterwards so a
192
+ * server that answers with an endless body fills no disk.
193
+ */
194
+ export async function downloadTo(url, dest, expectedSize) {
195
+ requireSafeOrigin(url, "the download URL");
196
+ let res;
197
+ try {
198
+ res = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS), redirect: "follow" });
199
+ }
200
+ catch (e) {
201
+ throw new UpdateSourceError(`download failed: ${e instanceof Error ? e.message : String(e)}`);
202
+ }
203
+ requireSafeOrigin(res.url || url, "the server that answered the download");
204
+ if (!res.ok)
205
+ throw new UpdateSourceError(`${url} returned HTTP ${res.status}`);
206
+ if (!res.body)
207
+ throw new UpdateSourceError(`${url} returned no body`);
208
+ const hash = createHash("sha256");
209
+ let seen = 0;
210
+ const body = Readable.fromWeb(res.body);
211
+ // A path-based stream, deliberately, rather than one wrapped around a
212
+ // FileHandle: when the transform below aborts an over-long download,
213
+ // pipeline() destroys the sink, and a stream that shares an fd with a
214
+ // FileHandle then leaves that handle closed underneath it. Closing it again
215
+ // throws EBADF from the cleanup path and replaces the real error — which is
216
+ // exactly the diagnosis a failed update must not lose. One owner for the fd
217
+ // removes the problem rather than working around it.
218
+ await pipeline(body, async function* (source) {
219
+ for await (const chunk of source) {
220
+ seen += chunk.length;
221
+ if (seen > expectedSize) {
222
+ throw new UpdateSourceError(`download is longer than the ${expectedSize} bytes the manifest declares — stopped`);
223
+ }
224
+ hash.update(chunk);
225
+ yield chunk;
226
+ }
227
+ }, createWriteStream(dest, { flags: "w", mode: 0o700 }));
228
+ // fsync before the caller renames: rename is atomic for the directory entry
229
+ // only, so an unflushed replacement is a truncated binary after a crash.
230
+ const handle = await open(dest, "r+");
231
+ try {
232
+ await handle.sync();
233
+ }
234
+ finally {
235
+ await handle.close();
236
+ }
237
+ if (seen !== expectedSize) {
238
+ throw new UpdateSourceError(`download is ${seen} bytes, the manifest declares ${expectedSize}`);
239
+ }
240
+ return hash.digest("hex");
241
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Putting the verified bytes where the old binary was.
3
+ *
4
+ * The rule throughout: a failure at any point leaves the currently installed
5
+ * binary exactly as it was, and leaves no temporary file behind. The staged
6
+ * file is therefore created inside the install directory — so the final step
7
+ * is a rename within one filesystem rather than a copy across two — and the
8
+ * caller unlinks it on every error path.
9
+ */
10
+ import { spawn } from "node:child_process";
11
+ import { constants, existsSync } from "node:fs";
12
+ import { access, chmod, lstat, rename, rm, stat, writeFile } from "node:fs/promises";
13
+ import path from "node:path";
14
+ export class ReplaceError extends Error {
15
+ }
16
+ /** The install receipt install.sh and install.ps1 write, in their format. */
17
+ export const RECEIPT_NAME = ".ml-dash.receipt";
18
+ export const receiptContents = (version, platform, sha256, source) => `channel=r2\nversion=${version}\nplatform=${platform}\nsha256=${sha256}\nsource=${source}\n`;
19
+ /**
20
+ * Directories that belong to another package manager.
21
+ *
22
+ * This is the check that actually protects a symlinked install, and it exists
23
+ * because the obvious one does not: `process.execPath` is already
24
+ * symlink-resolved by the OS (verified on this host — invoking a compiled
25
+ * binary through a symlink reports the link's *target*). So when
26
+ * `~/.local/bin/ml-dash` points into a Homebrew cellar or a pipx venv, the
27
+ * path this command is handed is the store's real file, and the lstat check
28
+ * below never sees a link at all. Recognising the store by its path is what is
29
+ * left, and it is the case worth catching: replacing a file another tool
30
+ * believes it owns leaves that tool's metadata describing bytes that are no
31
+ * longer there.
32
+ */
33
+ const MANAGED_STORES = [
34
+ { pattern: /(^|\/)\.?nix\/store\//, manager: "Nix", fix: "nix profile upgrade" },
35
+ { pattern: /(^|\/)(Cellar|linuxbrew|homebrew)\//, manager: "Homebrew", fix: "brew upgrade ml-dash" },
36
+ { pattern: /(^|\/)site-packages\//, manager: "pip", fix: "pip install --upgrade ml-dash" },
37
+ { pattern: /(^|\/)(pipx|\.venv|venv)\//, manager: "pipx or a virtualenv", fix: "pipx upgrade ml-dash" },
38
+ { pattern: /(^|\/)node_modules\//, manager: "npm", fix: "npm install -g @dreamlake/ml-dash@latest" },
39
+ { pattern: /(^|\/)\.cargo\/(registry|bin)\//, manager: "cargo", fix: "cargo install --force ml-dash" },
40
+ ];
41
+ /**
42
+ * Refuse anything that is not a plain, writable, regular file we can swap, and
43
+ * anything that belongs to a package manager other than this one.
44
+ */
45
+ export async function checkReplaceable(target) {
46
+ const store = MANAGED_STORES.find((s) => s.pattern.test(target));
47
+ if (store) {
48
+ throw new ReplaceError(`${target} is inside a directory ${store.manager} manages, so it is not this command's to replace.\n` +
49
+ ` Replacing it would leave ${store.manager} describing bytes that are no longer there.\n` +
50
+ ` Update it with: ${store.fix}`);
51
+ }
52
+ let info;
53
+ try {
54
+ info = await lstat(target);
55
+ }
56
+ catch (e) {
57
+ throw new ReplaceError(`cannot inspect ${target}: ${e.message}`);
58
+ }
59
+ if (info.isSymbolicLink()) {
60
+ throw new ReplaceError(`${target} is a symlink, so it is managed by something else (a package manager, or a shim).\n` +
61
+ ` Update it with whatever installed it, or install a standalone copy with install.sh.`);
62
+ }
63
+ if (!info.isFile()) {
64
+ throw new ReplaceError(`${target} is not a regular file`);
65
+ }
66
+ const dir = path.dirname(target);
67
+ try {
68
+ await access(dir, constants.W_OK);
69
+ }
70
+ catch {
71
+ throw new ReplaceError(`cannot write to ${dir}.\n` +
72
+ ` ml-dash update replaces the binary in place, so that directory has to be writable.\n` +
73
+ ` Re-run with the permissions that own it, or reinstall into a directory you own:\n` +
74
+ ` curl -fsSL https://pub-42e1dcc7de574d4a92984865fdc95f10.r2.dev/install.sh | sh -s -- --install-dir ~/.local/bin`);
75
+ }
76
+ }
77
+ /** The mode of the binary being replaced, so an update does not widen or narrow it. */
78
+ export async function currentMode(target) {
79
+ try {
80
+ return (await stat(target)).mode & 0o777;
81
+ }
82
+ catch {
83
+ return 0o755;
84
+ }
85
+ }
86
+ /**
87
+ * POSIX: rename(2) over the running binary.
88
+ *
89
+ * Replacing a running executable is fine here — the kernel keeps the old inode
90
+ * alive for the current process and the directory entry flips atomically — so
91
+ * the update takes effect for the next invocation with no helper and no window
92
+ * in which `ml-dash` is missing from PATH.
93
+ */
94
+ export async function swapInPlace(swap) {
95
+ await chmod(swap.staged, await currentMode(swap.target));
96
+ await rename(swap.staged, swap.target);
97
+ await writeReceiptIfOwned(swap);
98
+ }
99
+ /**
100
+ * Only where the installer already left one. Writing a receipt that was never
101
+ * there would be this command claiming an install it did not make; not
102
+ * refreshing one that is there would leave install.sh convinced the binary had
103
+ * been tampered with, and refusing to run without --force.
104
+ */
105
+ async function writeReceiptIfOwned(swap) {
106
+ const receipt = path.join(path.dirname(swap.target), RECEIPT_NAME);
107
+ if (!existsSync(receipt))
108
+ return;
109
+ try {
110
+ await writeFile(receipt, receiptContents(swap.version, swap.platform, swap.sha256, swap.source));
111
+ }
112
+ catch {
113
+ // The binary is already updated and correct; a stale receipt only costs a
114
+ // --force on some future install.sh run.
115
+ }
116
+ }
117
+ /** Single-quote a string for PowerShell, where '' is the escape for '. */
118
+ const psQuote = (s) => `'${s.replace(/'/g, "''")}'`;
119
+ /**
120
+ * The PowerShell that finishes a Windows update after this process is gone.
121
+ *
122
+ * Windows holds an open image section on a running .exe, so the rename in
123
+ * swapInPlace fails with a sharing violation rather than succeeding. The
124
+ * smallest correct answer is to wait for this exact PID to exit and then do
125
+ * the same move — no service, no scheduled task, nothing left installed. The
126
+ * script deletes the staged file if the move fails and deletes itself last, so
127
+ * neither it nor the download survives a failure.
128
+ */
129
+ export function windowsHelperScript(swap, pid) {
130
+ const receipt = path.join(path.dirname(swap.target), RECEIPT_NAME);
131
+ return [
132
+ "$ErrorActionPreference = 'Stop'",
133
+ // An already-exited PID makes Wait-Process throw; that is the success case.
134
+ `try { Wait-Process -Id ${pid} -Timeout 300 } catch { }`,
135
+ "try {",
136
+ ` Move-Item -LiteralPath ${psQuote(swap.staged)} -Destination ${psQuote(swap.target)} -Force`,
137
+ ` if (Test-Path -LiteralPath ${psQuote(receipt)}) {`,
138
+ ` @(${[
139
+ `'channel=r2'`,
140
+ psQuote(`version=${swap.version}`),
141
+ psQuote(`platform=${swap.platform}`),
142
+ psQuote(`sha256=${swap.sha256}`),
143
+ psQuote(`source=${swap.source}`),
144
+ ].join(", ")}) | Set-Content -LiteralPath ${psQuote(receipt)} -Encoding ASCII`,
145
+ " }",
146
+ "} catch {",
147
+ ` Remove-Item -LiteralPath ${psQuote(swap.staged)} -Force -ErrorAction SilentlyContinue`,
148
+ "} finally {",
149
+ " Remove-Item -LiteralPath $PSCommandPath -Force -ErrorAction SilentlyContinue",
150
+ "}",
151
+ "",
152
+ ].join("\n");
153
+ }
154
+ export const windowsHelperArgs = (helperPath) => [
155
+ "-NoProfile",
156
+ "-NonInteractive",
157
+ "-ExecutionPolicy",
158
+ "Bypass",
159
+ "-File",
160
+ helperPath,
161
+ ];
162
+ /**
163
+ * Write the helper and hand it off. Detached and unref'd so `ml-dash update`
164
+ * can exit normally — which is the thing the helper is waiting for.
165
+ */
166
+ export async function scheduleWindowsSwap(swap, pid = process.pid) {
167
+ const helper = path.join(path.dirname(swap.target), `.ml-dash.update.${pid}.ps1`);
168
+ await writeFile(helper, windowsHelperScript(swap, pid), "utf8");
169
+ try {
170
+ const child = spawn("powershell.exe", windowsHelperArgs(helper), {
171
+ detached: true,
172
+ stdio: "ignore",
173
+ shell: false,
174
+ windowsHide: true,
175
+ });
176
+ child.unref();
177
+ }
178
+ catch (e) {
179
+ await rm(helper, { force: true });
180
+ throw new ReplaceError(`cannot start the updater: ${e.message}`);
181
+ }
182
+ }
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Rewritten by scripts/build-release.ts at release time so a compiled binary
2
2
  // can never misreport itself. The npm channel reads the same constant.
3
- export const VERSION = "0.1.0";
3
+ export const VERSION = "0.1.1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dreamlake/ml-dash",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "ml-dash \u2014 ML experiment tracking CLI. Authenticate, inspect projects and experiments, and move experiment data to and from an ML-Dash server.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -34,15 +34,17 @@
34
34
  "build": "tsc",
35
35
  "cli": "tsx src/index.ts",
36
36
  "typecheck": "tsc --noEmit",
37
- "test": "npm run build && node --import tsx --test test/*.test.ts",
37
+ "test": "npm run build && node --import tsx --test --test-timeout=180000 test/*.test.ts",
38
38
  "build:release": "bun run scripts/build-release.ts"
39
39
  },
40
40
  "dependencies": {
41
- "qrcode": "^1.5.4"
41
+ "qrcode": "^1.5.4",
42
+ "semver": "^7.8.5"
42
43
  },
43
44
  "devDependencies": {
44
45
  "@types/node": "^22.10.0",
45
46
  "@types/qrcode": "^1.5.5",
47
+ "@types/semver": "^7.8.0",
46
48
  "tsx": "^4.19.0",
47
49
  "typescript": "^5.7.0"
48
50
  }