@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.
- package/dist/bin.js +1 -1
- package/dist/{flush-BCuWs0Zh.js → flush-C2lroV03.js} +106 -20
- package/dist/flush-C2lroV03.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/reference/cli.md +3 -3
- package/dist/reference/diagnostics.json +14 -5
- package/dist/reference/errors/MOSTRA_ENV_HELPER_MISSING.md +23 -0
- package/dist/reference/errors/MOSTRA_ENV_SERVICE_FAILED.md +2 -2
- package/dist/reference/errors/MOSTRA_HOME_NOT_ALWAYS_ON.md +1 -1
- package/dist/reference/errors/MOSTRA_PLATFORM_UNSUPPORTED.md +2 -2
- package/dist/reference/telemetry.md +1 -1
- package/dist/{run-BJaRnz-Q.js → run-DP-RoOv_.js} +137 -53
- package/dist/run-DP-RoOv_.js.map +1 -0
- package/dist/run-DdLe9hN3.js +2 -0
- package/dist/telemetry/flush-main.js +1 -1
- package/package.json +4 -4
- package/src/browser.ts +72 -14
- package/src/client.ts +28 -27
- package/src/commands/admin.ts +7 -10
- package/src/commands/dev.ts +5 -5
- package/src/commands/viewing.ts +5 -2
- package/src/detached.ts +49 -0
- package/src/home.ts +36 -8
- package/src/platform.ts +24 -0
- package/src/run.ts +5 -7
- package/src/telemetry/events.ts +2 -2
- package/src/telemetry/flush.ts +7 -1
- package/src/telemetry/index.ts +4 -1
- package/src/telemetry/store.ts +66 -15
- package/dist/flush-BCuWs0Zh.js.map +0 -1
- package/dist/run-BJaRnz-Q.js.map +0 -1
- package/dist/run-CMyjOm0Q.js +0 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { r as TelemetryStore, t as flush } from "../flush-
|
|
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.
|
|
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.
|
|
38
|
-
"@mostra/sdk": "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.
|
|
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
|
-
*
|
|
7
|
-
*
|
|
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
|
|
126
|
-
* `%s` replaced by the target, else the
|
|
127
|
-
* Python's `webbrowser` read it), else the
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
10
|
-
import {
|
|
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
|
-
|
|
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
|
|
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`;
|
package/src/commands/admin.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
-
|
|
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({
|
package/src/commands/dev.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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(
|
|
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", {
|
package/src/commands/viewing.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
package/src/detached.ts
ADDED
|
@@ -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
|
|
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 (
|
|
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
|
|
41
|
-
*
|
|
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(
|
|
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). */
|
package/src/platform.ts
ADDED
|
@@ -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
|
-
|
|
144
|
-
|
|
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 {
|
package/src/telemetry/events.ts
CHANGED
|
@@ -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;
|
package/src/telemetry/flush.ts
CHANGED
|
@@ -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
|
-
|
|
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();
|
package/src/telemetry/index.ts
CHANGED
|
@@ -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:
|
|
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,
|