@mostra/cli 0.2.0 → 0.4.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.
@@ -0,0 +1,2 @@
1
+ import { t as run } from "./run-DP-RoOv_.js";
2
+ export { run };
@@ -1,4 +1,4 @@
1
- import { r as TelemetryStore, t as flush } from "../flush-BCuWs0Zh.js";
1
+ import { r as TelemetryStore, t as flush } from "../flush-C2lroV03.js";
2
2
  //#region src/telemetry/flush-main.ts
3
3
  /**
4
4
  * The flusher process (D140 item 6): `node flush-main.js <config dir>`, started detached by the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mostra/cli",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "The mostra command: author, verify, publish, share and wait on agent artifacts, as a thin client of the Mostra host protocol.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "The Mostra Authors",
@@ -34,12 +34,12 @@
34
34
  "provenance": true
35
35
  },
36
36
  "dependencies": {
37
- "@mostra/protocol": "0.2.0",
38
- "@mostra/sdk": "0.2.0",
37
+ "@mostra/protocol": "0.4.0",
38
+ "@mostra/sdk": "0.4.0",
39
39
  "zod": "4.6.5"
40
40
  },
41
41
  "devDependencies": {
42
- "@mostra/runtime": "0.2.0",
42
+ "@mostra/runtime": "0.4.0",
43
43
  "@mostra/tools": "0.0.0",
44
44
  "@types/node": "24.19.1",
45
45
  "tsdown": "0.23.0",
package/src/browser.ts CHANGED
@@ -3,13 +3,16 @@
3
3
  * review finding 4). A program's arguments are readable by every local user (`ps`,
4
4
  * `/proc/<pid>/cmdline`), so the opener gets the path of a page that redirects to the launch URL,
5
5
  * written mode 0600 in a fresh directory of mode 0700, as Jupyter's server does for its token.
6
- * The launch ticket in the page is single-use and lives a minute, so a page left behind is
7
- * harmless; each launch removes the ones older than that.
6
+ * Windows has no modes: there the page goes under the user's own profile
7
+ * (`%LOCALAPPDATA%\mostra-cli\open`), whose access list only the user, `SYSTEM` and
8
+ * administrators pass, never under `%TEMP%`, which may name a folder other users can read (Opus
9
+ * review of WP-W2, finding 1). The launch ticket in the page is single-use and lives a minute, so a
10
+ * page left behind is harmless; each launch removes the ones older than that.
8
11
  */
9
12
  import { spawn } from "node:child_process";
10
- import { lstatSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
11
- import { tmpdir } from "node:os";
12
- import { join } from "node:path";
13
+ import { lstatSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
14
+ import { homedir, tmpdir } from "node:os";
15
+ import { join, win32 } from "node:path";
13
16
  import type { Environment } from "./home.ts";
14
17
 
15
18
  /** The prefix of the directories launch pages live in. */
@@ -121,10 +124,48 @@ export function splitCommand(command: string): string[] {
121
124
  return words;
122
125
  }
123
126
 
127
+ /** The program that opens a file with its default application, by platform. */
128
+ function platformOpener(platform: string): string[] {
129
+ if (platform === "darwin") return ["open"];
130
+ // Explorer opens a path with the program associated with its type, `.html` with the default
131
+ // browser, as a double click would (VERIFIED_FACTS, WP-W2); its arguments go to it directly,
132
+ // never through `cmd`.
133
+ if (platform === "win32") return ["explorer.exe"];
134
+ return ["xdg-open"];
135
+ }
136
+
137
+ /**
138
+ * Splits a command as Windows programs read their command lines, for `$BROWSER` there: words
139
+ * separated by spaces, double quotes grouping words with spaces (`"C:\Program Files\Firefox\
140
+ * firefox.exe" %s`), and backslashes kept as they are.
141
+ */
142
+ export function splitWindowsCommand(command: string): string[] {
143
+ const words: string[] = [];
144
+ let word = "";
145
+ let inWord = false;
146
+ let quoted = false;
147
+ for (const char of command) {
148
+ if (char === '"') {
149
+ quoted = !quoted;
150
+ inWord = true;
151
+ } else if (!quoted && /\s/.test(char)) {
152
+ if (inWord) words.push(word);
153
+ word = "";
154
+ inWord = false;
155
+ } else {
156
+ word += char;
157
+ inWord = true;
158
+ }
159
+ }
160
+ if (inWord) words.push(word);
161
+ return words;
162
+ }
163
+
124
164
  /**
125
- * The commands that may open `target`, in order: each entry of `$BROWSER` (separated by `:`, its
126
- * `%s` replaced by the target, else the target appended, `%%` a literal `%`, as `xdg-open` and
127
- * Python's `webbrowser` read it), else the platform's opener.
165
+ * The commands that may open `target`, in order: each entry of `$BROWSER` (separated by the
166
+ * platform's path delimiter, `:` or `;` on Windows, its `%s` replaced by the target, else the
167
+ * target appended, `%%` a literal `%`, as `xdg-open` and Python's `webbrowser` read it), else the
168
+ * platform's opener.
128
169
  */
129
170
  export function browserCommands(
130
171
  target: string,
@@ -132,13 +173,14 @@ export function browserCommands(
132
173
  platform: string = process.platform,
133
174
  ): string[][] {
134
175
  const chosen = env["BROWSER"];
135
- const entries =
136
- chosen !== undefined && chosen.trim() !== ""
137
- ? chosen.split(":").filter((entry) => entry.trim() !== "")
138
- : [platform === "darwin" ? "open" : "xdg-open"];
176
+ if (chosen === undefined || chosen.trim() === "") return [[...platformOpener(platform), target]];
177
+ const entries = chosen
178
+ .split(platform === "win32" ? ";" : ":")
179
+ .filter((entry) => entry.trim() !== "");
139
180
  const commands: string[][] = [];
140
181
  for (const entry of entries) {
141
- const words = splitCommand(entry);
182
+ // A Windows path's backslashes are separators, never escapes.
183
+ const words = platform === "win32" ? splitWindowsCommand(entry) : splitCommand(entry);
142
184
  if (words.length === 0) continue;
143
185
  let substituted = false;
144
186
  const argv = words.map((word) =>
@@ -171,11 +213,27 @@ function start(argv: readonly string[]): Promise<boolean> {
171
213
  });
172
214
  }
173
215
 
216
+ /**
217
+ * Where launch pages go: the system's temporary directory on POSIX (the page's modes protect it),
218
+ * the user's own profile on Windows, whatever `%TEMP%` names.
219
+ */
220
+ export function launchBase(env: Environment, platform: string = process.platform): string {
221
+ if (platform !== "win32") return tmpdir();
222
+ const local = env["LOCALAPPDATA"];
223
+ const base =
224
+ local !== undefined && local !== "" && win32.isAbsolute(local)
225
+ ? local
226
+ : win32.join(homedir(), "AppData", "Local");
227
+ return win32.join(base, "mostra-cli", "open");
228
+ }
229
+
174
230
  /** Opens `url` in a browser through a launch page; whether a browser command started. */
175
231
  export async function openInBrowser(url: string, env: Environment): Promise<boolean> {
176
232
  let page: string;
177
233
  try {
178
- page = writeLaunchPage(url);
234
+ const base = launchBase(env);
235
+ mkdirSync(base, { recursive: true, mode: 0o700 });
236
+ page = writeLaunchPage(url, base);
179
237
  } catch {
180
238
  return false;
181
239
  }
package/src/client.ts CHANGED
@@ -6,8 +6,8 @@
6
6
  */
7
7
  import { spawn } from "node:child_process";
8
8
  import { readFileSync } from "node:fs";
9
- import { createConnection, type Socket } from "node:net";
10
- import { join } from "node:path";
9
+ import { join, resolve } from "node:path";
10
+ import type { Duplex } from "node:stream";
11
11
  import {
12
12
  ARTIFACT_PROGRESS,
13
13
  CLI_CLIENT_NAME,
@@ -23,6 +23,8 @@ import {
23
23
  RpcError,
24
24
  RpcPeer,
25
25
  } from "@mostra/protocol";
26
+ import { connectControl, type Runtime } from "@mostra/sdk";
27
+ import { detachedDirectory, detachedEnvironment } from "./detached.ts";
26
28
  import { type Environment, locateRuntime } from "./home.ts";
27
29
  import { cliVersion } from "./version.ts";
28
30
 
@@ -98,6 +100,17 @@ export async function connect(options: ConnectOptions): Promise<InstanceConnecti
98
100
  return open(started.socket, options, started.status === "started");
99
101
  }
100
102
 
103
+ /**
104
+ * Opens the endpoint `instance.json` or a starting daemon named: a Unix socket, or on Windows the
105
+ * owner-only pipe through the runtime's helper, which refuses a pipe another user created under
106
+ * the name (D150, the SDK's `connectControl`). Only Windows needs the runtime resolved first.
107
+ */
108
+ async function openEndpoint(endpoint: string, options: ConnectOptions): Promise<Duplex> {
109
+ const runtime: Runtime | undefined =
110
+ process.platform === "win32" ? (await locateRuntime(options.env)).runtime : undefined;
111
+ return connectControl(endpoint, { runtime, env: options.env, timeoutMs: ATTACH_TIMEOUT_MS });
112
+ }
113
+
101
114
  /** The socket `instance.json` records (D81), if the file exists and reads. */
102
115
  function readSocket(home: string): string | undefined {
103
116
  try {
@@ -135,7 +148,10 @@ export async function startDaemon(options: ConnectOptions): Promise<DaemonLine>
135
148
  const runtime = await locateRuntime(options.env);
136
149
  if (Date.now() >= deadlineAt) throw timedOut();
137
150
  const args = [
138
- runtime.entry,
151
+ // Absolute: the daemon runs in the file system's root, where a relative entry (a relative
152
+ // `MOSTRA_RUNTIME_BIN`, checked against this process's directory) would name nothing (GPT
153
+ // review of WP-W2).
154
+ resolve(runtime.entry),
139
155
  "daemon",
140
156
  // The home this CLI resolved, so the daemon owns exactly it whatever its own environment.
141
157
  "--home",
@@ -144,8 +160,15 @@ export async function startDaemon(options: ConnectOptions): Promise<DaemonLine>
144
160
  return new Promise((resolve, reject) => {
145
161
  const child = spawn(runtime.node, args, {
146
162
  detached: true,
163
+ // Not the command's directory but its home's file system root, which always exists: the
164
+ // daemon outlives the command by up to 30 idle minutes, and on Windows nobody can remove or
165
+ // rename a running process's working directory, so a project the CLI ran in would stay
166
+ // locked (WP-W2).
167
+ cwd: detachedDirectory(options.home),
168
+ // No console window for the daemon on Windows.
169
+ windowsHide: true,
147
170
  stdio: ["ignore", "pipe", "ignore"],
148
- env: { ...process.env, ...options.env } as NodeJS.ProcessEnv,
171
+ env: detachedEnvironment({ ...process.env, ...options.env }) as NodeJS.ProcessEnv,
149
172
  });
150
173
  const splitter = new LineSplitter();
151
174
  let settled = false;
@@ -218,21 +241,7 @@ async function open(
218
241
  options: ConnectOptions,
219
242
  started: boolean,
220
243
  ): Promise<InstanceConnection> {
221
- const socket = await new Promise<Socket>((resolve, reject) => {
222
- const connection = createConnection(socketPath);
223
- const timer = setTimeout(() => {
224
- connection.destroy();
225
- reject(closed(socketPath, "timed out"));
226
- }, ATTACH_TIMEOUT_MS);
227
- connection.once("error", (error: NodeJS.ErrnoException) => {
228
- clearTimeout(timer);
229
- reject(closed(socketPath, error.code ?? "failed"));
230
- });
231
- connection.once("connect", () => {
232
- clearTimeout(timer);
233
- resolve(connection);
234
- });
235
- });
244
+ const socket = await openEndpoint(socketPath, options);
236
245
  const handlers = new Map<string, (params: unknown) => void>();
237
246
  const peer = new RpcPeer({
238
247
  send: (message) => {
@@ -297,14 +306,6 @@ async function open(
297
306
  };
298
307
  }
299
308
 
300
- function closed(path: string, why: string): RpcError {
301
- return new RpcError(
302
- createDiagnostic("MOSTRA_HOST_CONNECTION_CLOSED", {
303
- message: `Couldn't reach the instance at ${path} (${why}).`,
304
- }),
305
- );
306
- }
307
-
308
309
  /** A limit as people write it: `250 ms`, `5 s`, `1.5 s`. */
309
310
  function formatDuration(ms: number): string {
310
311
  if (ms < 1000) return `${ms} ms`;
@@ -27,6 +27,7 @@ import { docsIndex, indexMarkdown, nearestTopics } from "../docs.ts";
27
27
  import { EXIT, exitCodeFor } from "../exit.ts";
28
28
  import { embeddedHome, locateRuntime, readHomeMarks } from "../home.ts";
29
29
  import { usage } from "../parse.ts";
30
+ import { checkPlatform } from "../platform.ts";
30
31
  import { TelemetryStore, TURNED, telemetryStatus } from "../telemetry/index.ts";
31
32
  import { NOTICE_DEFAULT } from "../telemetry/words.ts";
32
33
  import { cliVersion } from "../version.ts";
@@ -199,7 +200,8 @@ async function service(
199
200
  const noStart = {
200
201
  name: "no-start",
201
202
  kind: "boolean" as const,
202
- summary: "Write or remove the service's file without asking launchd or systemd to load it.",
203
+ summary:
204
+ "Write or remove the service's definition without asking launchd, systemd or Task Scheduler to load it.",
203
205
  };
204
206
 
205
207
  /** `mostra daemon start`. */
@@ -294,7 +296,7 @@ export const daemonStatusCommand: CommandDef = defineCommand({
294
296
  export const daemonInstallCommand: CommandDef = defineCommand({
295
297
  path: ["daemon", "install"],
296
298
  summary:
297
- "Run the home's daemon as a login service (launchd or systemd), so links answer at any hour.",
299
+ "Run the home's daemon as a login service (launchd, systemd or Task Scheduler), so links answer at any hour.",
298
300
  flags: [noStart],
299
301
  output: { type: "ServiceStatus", topic: "cli" },
300
302
  exits: [],
@@ -447,20 +449,15 @@ export const doctorCommand: CommandDef = defineCommand({
447
449
  async run(context) {
448
450
  const checks: DoctorCheck[] = [];
449
451
  const node = Number(process.versions.node.split(".")[0]);
452
+ const unsupported = checkPlatform(process.platform, process.arch, context.env);
450
453
  checks.push(
451
- process.platform === "darwin" || process.platform === "linux"
454
+ unsupported === undefined
452
455
  ? {
453
456
  name: "platform",
454
457
  status: "ok",
455
458
  detail: `${process.platform} ${process.arch}, Node ${process.versions.node}`,
456
459
  }
457
- : {
458
- name: "platform",
459
- status: "error",
460
- diagnostic: createDiagnostic("MOSTRA_PLATFORM_UNSUPPORTED", {
461
- message: `Mostra runs on macOS and Linux; this is ${process.platform}.`,
462
- }),
463
- },
460
+ : { name: "platform", status: "error", diagnostic: unsupported },
464
461
  );
465
462
  if (node < 24) {
466
463
  checks.push({
@@ -79,16 +79,16 @@ export const devCommand: CommandDef = defineCommand({
79
79
  `Development preview, not published: ${started.url}\nPress Ctrl-C to stop.\n`,
80
80
  );
81
81
  for (const event of early.splice(0)) if (event.sessionId === sessionId) show(event);
82
- // The session lasts until Ctrl-C (or SIGTERM), or until the instance ends it.
82
+ // The session lasts until Ctrl-C (or SIGTERM; Ctrl+Break on Windows), or until the instance
83
+ // ends it.
83
84
  let stop: () => void = () => {};
85
+ const signals = ["SIGINT", "SIGTERM", "SIGBREAK"] as const;
84
86
  const ended = await new Promise<"signal" | "closed">((done) => {
85
87
  stop = (): void => done("signal");
86
- process.once("SIGINT", stop);
87
- process.once("SIGTERM", stop);
88
+ for (const signal of signals) process.once(signal, stop);
88
89
  void connection?.closed.then(() => done("closed"));
89
90
  });
90
- process.off("SIGINT", stop);
91
- process.off("SIGTERM", stop);
91
+ for (const signal of signals) process.off(signal, stop);
92
92
  if (ended === "closed") {
93
93
  throw new RpcError(
94
94
  createDiagnostic("MOSTRA_HOST_CONNECTION_CLOSED", {
@@ -20,10 +20,13 @@ import type { Environment } from "../home.ts";
20
20
  import { usage } from "../parse.ts";
21
21
  import { arg, artifactRef, flag } from "./shared.ts";
22
22
 
23
- /** Whether this session can show a browser: not over SSH without a display. */
23
+ /**
24
+ * Whether this session can show a browser: not over SSH without a display. On macOS and Windows a
25
+ * local session always has the user's desktop; an SSH session there has none.
26
+ */
24
27
  export function hasDisplay(env: Environment, platform: string = process.platform): boolean {
25
28
  const ssh = env["SSH_CONNECTION"] !== undefined || env["SSH_TTY"] !== undefined;
26
- if (platform === "darwin") return !ssh;
29
+ if (platform === "darwin" || platform === "win32") return !ssh;
27
30
  return env["DISPLAY"] !== undefined || env["WAYLAND_DISPLAY"] !== undefined;
28
31
  }
29
32
 
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The directory and environment of the processes the CLI leaves running: its home's daemon and
3
+ * the telemetry flusher. They run in their file system's root, because on Windows nobody can
4
+ * rename or remove a running process's working directory, and they outlive the command (WP-W2,
5
+ * D151 item 3). Every path the environment names relative to the command's directory is made
6
+ * absolute first, so it still names what it named for the command (Opus review of WP-W2,
7
+ * finding 3).
8
+ */
9
+ import { isAbsolute, parse, resolve } from "node:path";
10
+
11
+ /**
12
+ * The variables whose values are paths: Mostra's own, as the runtime, the SDK and the CLI read
13
+ * them. A test keeps the list complete.
14
+ */
15
+ export const PATH_VARIABLES: readonly string[] = [
16
+ "MOSTRA_HOME",
17
+ "MOSTRA_RUNTIME_DIR",
18
+ "MOSTRA_CONFIG_DIR",
19
+ "MOSTRA_CACHE_DIR",
20
+ "MOSTRA_BUNDLE",
21
+ "MOSTRA_RUNTIME_BIN",
22
+ "MOSTRA_SHELL_DIR",
23
+ "MOSTRA_WARM_DIR",
24
+ "MOSTRA_WORKER_ENTRY",
25
+ "MOSTRA_VERIFY_ENTRY",
26
+ "MOSTRA_WIN_HELPER",
27
+ "MOSTRA_TEST_WORKER_HOST",
28
+ "MOSTRA_TEST_BUNDLE_PINS",
29
+ ];
30
+
31
+ /** `env` with every relative path variable resolved against `cwd`. */
32
+ export function detachedEnvironment(
33
+ env: Readonly<Record<string, string | undefined>>,
34
+ cwd: string = process.cwd(),
35
+ ): Record<string, string | undefined> {
36
+ const result: Record<string, string | undefined> = { ...env };
37
+ for (const name of PATH_VARIABLES) {
38
+ const value = result[name];
39
+ if (value !== undefined && value !== "" && !isAbsolute(value)) {
40
+ result[name] = resolve(cwd, value);
41
+ }
42
+ }
43
+ return result;
44
+ }
45
+
46
+ /** The working directory of a process the CLI leaves running: the root of `anchor`'s file system. */
47
+ export function detachedDirectory(anchor: string): string {
48
+ return parse(resolve(anchor)).root;
49
+ }
package/src/home.ts CHANGED
@@ -5,8 +5,8 @@
5
5
  */
6
6
  import { readFileSync } from "node:fs";
7
7
  import { homedir } from "node:os";
8
- import { isAbsolute, join, resolve } from "node:path";
9
- import { resolveRuntime } from "@mostra/sdk";
8
+ import { isAbsolute, join, resolve, win32 } from "node:path";
9
+ import { type Runtime, resolveRuntime } from "@mostra/sdk";
10
10
 
11
11
  /** The environment the CLI reads. */
12
12
  export type Environment = Readonly<Record<string, string | undefined>>;
@@ -16,7 +16,8 @@ const nonEmpty = (value: string | undefined): string | undefined =>
16
16
 
17
17
  /**
18
18
  * The home, in D84's order: `--home`, then `MOSTRA_HOME`, then the platform default (`~/.mostra`
19
- * on macOS, `${XDG_DATA_HOME:-~/.local/share}/mostra` on Linux), the same order the runtime uses.
19
+ * on macOS, `${XDG_DATA_HOME:-~/.local/share}/mostra` on Linux, `%LOCALAPPDATA%\mostra` on
20
+ * Windows), the same order and defaults the runtime uses (its `platform` module).
20
21
  */
21
22
  export function resolveHome(options: {
22
23
  readonly home?: string | undefined;
@@ -26,8 +27,19 @@ export function resolveHome(options: {
26
27
  }): string {
27
28
  const chosen = nonEmpty(options.home) ?? nonEmpty(options.env["MOSTRA_HOME"]);
28
29
  if (chosen !== undefined) return resolve(options.cwd, chosen);
30
+ const platform = options.platform ?? process.platform;
31
+ if (platform === "win32") {
32
+ // As the runtime: the user's profile from the system, never a `HOME` a POSIX shell set.
33
+ const local = nonEmpty(options.env["LOCALAPPDATA"]);
34
+ return win32.join(
35
+ local !== undefined && win32.isAbsolute(local)
36
+ ? local
37
+ : win32.join(homedir(), "AppData", "Local"),
38
+ "mostra",
39
+ );
40
+ }
29
41
  const user = nonEmpty(options.env["HOME"]) ?? homedir();
30
- if ((options.platform ?? process.platform) === "darwin") return join(user, ".mostra");
42
+ if (platform === "darwin") return join(user, ".mostra");
31
43
  const data = nonEmpty(options.env["XDG_DATA_HOME"]);
32
44
  return join(
33
45
  data !== undefined && isAbsolute(data) ? data : join(user, ".local", "share"),
@@ -37,12 +49,26 @@ export function resolveHome(options: {
37
49
 
38
50
  /**
39
51
  * The CLI's config directory (D140 item 5): `MOSTRA_CONFIG_DIR`, else
40
- * `${XDG_CONFIG_HOME:-~/.config}/mostra`, on both platforms. It holds telemetry's choice and
41
- * queue, per operating-system user, never per home.
52
+ * `${XDG_CONFIG_HOME:-~/.config}/mostra` on macOS and Linux, and `%APPDATA%\mostra` on Windows,
53
+ * where per-user settings live (D151). It holds telemetry's choice and queue, per
54
+ * operating-system user, never per home.
42
55
  */
43
- export function configDir(env: Environment, cwd: string): string {
56
+ export function configDir(
57
+ env: Environment,
58
+ cwd: string,
59
+ platform: string = process.platform,
60
+ ): string {
44
61
  const override = nonEmpty(env["MOSTRA_CONFIG_DIR"]);
45
62
  if (override !== undefined) return resolve(cwd, override);
63
+ if (platform === "win32") {
64
+ const roaming = nonEmpty(env["APPDATA"]);
65
+ return win32.join(
66
+ roaming !== undefined && win32.isAbsolute(roaming)
67
+ ? roaming
68
+ : win32.join(homedir(), "AppData", "Roaming"),
69
+ "mostra",
70
+ );
71
+ }
46
72
  const xdg = nonEmpty(env["XDG_CONFIG_HOME"]);
47
73
  const base =
48
74
  xdg !== undefined && isAbsolute(xdg)
@@ -57,6 +83,8 @@ export interface RuntimeLocation {
57
83
  readonly node: string;
58
84
  /** `mostra-runtime`'s entry. */
59
85
  readonly entry: string;
86
+ /** The runtime as the SDK resolved it (its bundle, for the Windows helper beside it). */
87
+ readonly runtime: Runtime;
60
88
  }
61
89
 
62
90
  /**
@@ -67,7 +95,7 @@ export interface RuntimeLocation {
67
95
  */
68
96
  export async function locateRuntime(env: Environment): Promise<RuntimeLocation> {
69
97
  const runtime = await resolveRuntime({ env });
70
- return { node: runtime.node, entry: runtime.runtimeEntry };
98
+ return { node: runtime.node, entry: runtime.runtimeEntry, runtime };
71
99
  }
72
100
 
73
101
  /** What the CLI reads of a home's `config.json` (DATA_MODEL §1). */
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The CLI's platform gate (D35, D146, RT-07), the runtime's rule (`home/platform.ts` in
3
+ * `@mostra/runtime`) repeated where the CLI must decide before it reaches any runtime: macOS and
4
+ * Linux on x64 and arm64; Windows on x64 and arm64 only behind the development switch
5
+ * `MOSTRA_DEV_WINDOWS=1`, which only Mostra's CI sets until WP-W3; everything else fails closed.
6
+ */
7
+ import { createDiagnostic, type Diagnostic } from "@mostra/protocol";
8
+ import type { Environment } from "./home.ts";
9
+
10
+ /** `MOSTRA_PLATFORM_UNSUPPORTED` for a platform Mostra doesn't run on, else `undefined`. */
11
+ export function checkPlatform(
12
+ platform: string,
13
+ arch: string,
14
+ env: Environment,
15
+ ): Diagnostic | undefined {
16
+ if (arch === "x64" || arch === "arm64") {
17
+ if (platform === "darwin" || platform === "linux") return undefined;
18
+ if (platform === "win32" && env["MOSTRA_DEV_WINDOWS"] === "1") return undefined;
19
+ }
20
+ const name = platform === "win32" ? `Windows (${platform}-${arch})` : `${platform}-${arch}`;
21
+ return createDiagnostic("MOSTRA_PLATFORM_UNSUPPORTED", {
22
+ message: `Mostra doesn't run on ${name}; it supports macOS and Linux on x64 and arm64.`,
23
+ });
24
+ }
package/src/run.ts CHANGED
@@ -33,6 +33,7 @@ import {
33
33
  resolveHome,
34
34
  } from "./home.ts";
35
35
  import { globalFlags, parseCommandLine } from "./parse.ts";
36
+ import { checkPlatform } from "./platform.ts";
36
37
  import { telemetrySession } from "./telemetry/index.ts";
37
38
  import { cliVersion } from "./version.ts";
38
39
 
@@ -54,6 +55,8 @@ export interface RunnerIo {
54
55
  readonly tty: { readonly stdin: boolean; readonly stdout: boolean; readonly stderr: boolean };
55
56
  /** The platform; default process.platform. */
56
57
  readonly platform?: string;
58
+ /** The architecture; default process.arch. */
59
+ readonly arch?: string;
57
60
  }
58
61
 
59
62
  /** The diagnostic an exception is, for the envelope: its own, or an internal error. */
@@ -140,13 +143,8 @@ export async function run(io: RunnerIo): Promise<number> {
140
143
 
141
144
  // Every entry point fails closed on an unsupported platform before touching the home (D35).
142
145
  const platform = io.platform ?? process.platform;
143
- if (platform !== "darwin" && platform !== "linux") {
144
- return fail(
145
- createDiagnostic("MOSTRA_PLATFORM_UNSUPPORTED", {
146
- message: `Mostra runs on macOS and Linux; this is ${platform}.`,
147
- }),
148
- );
149
- }
146
+ const unsupported = checkPlatform(platform, io.arch ?? process.arch, io.env);
147
+ if (unsupported) return fail(unsupported);
150
148
 
151
149
  let parsed: ReturnType<typeof parseCommandLine>;
152
150
  try {
@@ -37,7 +37,7 @@ const common = {
37
37
  at: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:00Z$/),
38
38
  installId: z.uuid(),
39
39
  version: z.string().regex(/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/),
40
- os: z.enum(["darwin", "linux"]),
40
+ os: z.enum(["darwin", "linux", "win32"]),
41
41
  arch: z.string().regex(/^[a-z0-9]{2,12}$/),
42
42
  node: z.int().min(0).max(999),
43
43
  harness: z.enum([...HARNESSES, "other", "none"]),
@@ -51,7 +51,7 @@ export interface CommonFields {
51
51
  readonly at: string;
52
52
  readonly installId: string;
53
53
  readonly version: string;
54
- readonly os: "darwin" | "linux";
54
+ readonly os: "darwin" | "linux" | "win32";
55
55
  readonly arch: string;
56
56
  readonly node: number;
57
57
  readonly harness: Harness;
@@ -5,10 +5,12 @@
5
5
  * but `Content-Type`, `User-Agent` and the ones HTTP itself needs, and keeps what a failed
6
6
  * attempt couldn't send for the next flush.
7
7
  */
8
+
8
9
  import { spawn } from "node:child_process";
9
10
  import { request as httpRequest } from "node:http";
10
11
  import { request as httpsRequest } from "node:https";
11
12
  import { join } from "node:path";
13
+ import { detachedDirectory, detachedEnvironment } from "../detached.ts";
12
14
  import type { Environment } from "../home.ts";
13
15
  import { cliVersion, fromSource, packageRoot } from "../version.ts";
14
16
  import { buildEndpoint, decide } from "./decision.ts";
@@ -133,7 +135,11 @@ export function startFlusher(store: TelemetryStore, env: Environment, now: numbe
133
135
  const child = spawn(process.execPath, [...nodeArgs, entry, store.dir], {
134
136
  detached: true,
135
137
  stdio: "ignore",
136
- env: { ...process.env, ...env } as NodeJS.ProcessEnv,
138
+ // Not the command's directory but its file system's root, which always exists: on Windows
139
+ // a process's working directory can't be removed or renamed while it runs, and the flusher
140
+ // outlives the command (WP-W2).
141
+ cwd: detachedDirectory(store.dir),
142
+ env: detachedEnvironment({ ...process.env, ...env }) as NodeJS.ProcessEnv,
137
143
  });
138
144
  child.on("error", () => {});
139
145
  child.unref();
@@ -210,7 +210,10 @@ export function telemetrySession(options: TelemetryOptions): TelemetrySession {
210
210
  at: minute(new Date(now())),
211
211
  installId: id.id,
212
212
  version: cliVersion(),
213
- os: process.platform === "darwin" ? "darwin" : "linux",
213
+ os:
214
+ process.platform === "darwin" || process.platform === "win32"
215
+ ? process.platform
216
+ : "linux",
214
217
  arch: process.arch,
215
218
  node: Number(process.versions.node.split(".")[0]),
216
219
  harness,