@mostra/cli 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +18 -2
  3. package/dist/bin.d.ts +1 -0
  4. package/dist/bin.js +79 -0
  5. package/dist/bin.js.map +1 -0
  6. package/dist/bootstrap-DfEe_yPr.js +113 -0
  7. package/dist/bootstrap-DfEe_yPr.js.map +1 -0
  8. package/dist/exit-CBIf8nAU.js +79 -0
  9. package/dist/exit-CBIf8nAU.js.map +1 -0
  10. package/dist/flush-BCuWs0Zh.js +718 -0
  11. package/dist/flush-BCuWs0Zh.js.map +1 -0
  12. package/dist/index.d.ts +230 -0
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.js +3 -0
  15. package/dist/reference/cli.md +977 -0
  16. package/dist/reference/diagnostics.json +1264 -0
  17. package/dist/reference/errors/MOSTRA_A11Y_MINOR.md +23 -0
  18. package/dist/reference/errors/MOSTRA_A11Y_SERIOUS.md +23 -0
  19. package/dist/reference/errors/MOSTRA_ACTION_CANCELLED.md +23 -0
  20. package/dist/reference/errors/MOSTRA_ACTION_EVENT_INVALID.md +23 -0
  21. package/dist/reference/errors/MOSTRA_ACTION_EXPORT_INVALID.md +23 -0
  22. package/dist/reference/errors/MOSTRA_ACTION_INTERRUPTED.md +23 -0
  23. package/dist/reference/errors/MOSTRA_ACTION_RUN_NOT_FOUND.md +23 -0
  24. package/dist/reference/errors/MOSTRA_ACTION_THREW.md +23 -0
  25. package/dist/reference/errors/MOSTRA_ACTION_TOO_LARGE.md +23 -0
  26. package/dist/reference/errors/MOSTRA_ACTION_UNKNOWN_FUNCTION.md +23 -0
  27. package/dist/reference/errors/MOSTRA_ACTION_UNSERIALIZABLE.md +23 -0
  28. package/dist/reference/errors/MOSTRA_ARTIFACT_DIR_NOT_EMPTY.md +23 -0
  29. package/dist/reference/errors/MOSTRA_ARTIFACT_ENTRY_AMBIGUOUS.md +23 -0
  30. package/dist/reference/errors/MOSTRA_ARTIFACT_NOT_FOUND.md +23 -0
  31. package/dist/reference/errors/MOSTRA_ARTIFACT_NO_ENTRY.md +23 -0
  32. package/dist/reference/errors/MOSTRA_ARTIFACT_NO_PAGE.md +23 -0
  33. package/dist/reference/errors/MOSTRA_ARTIFACT_SOURCE_MISSING.md +23 -0
  34. package/dist/reference/errors/MOSTRA_ARTIFACT_SOURCE_UNREADABLE.md +23 -0
  35. package/dist/reference/errors/MOSTRA_ARTIFACT_TEMPLATE_UNKNOWN.md +23 -0
  36. package/dist/reference/errors/MOSTRA_ARTIFACT_VERSION_COLLECTED.md +23 -0
  37. package/dist/reference/errors/MOSTRA_ARTIFACT_VERSION_NOT_FOUND.md +23 -0
  38. package/dist/reference/errors/MOSTRA_BUILD_CRASHED.md +23 -0
  39. package/dist/reference/errors/MOSTRA_BUILD_FAILED.md +23 -0
  40. package/dist/reference/errors/MOSTRA_BUILD_TIMEOUT.md +23 -0
  41. package/dist/reference/errors/MOSTRA_BUILD_UNRESOLVED.md +23 -0
  42. package/dist/reference/errors/MOSTRA_CLI_CONFIG_UNWRITABLE.md +23 -0
  43. package/dist/reference/errors/MOSTRA_CLI_UNKNOWN_COMMAND.md +23 -0
  44. package/dist/reference/errors/MOSTRA_CLI_USAGE.md +23 -0
  45. package/dist/reference/errors/MOSTRA_CONSOLE_ERROR.md +23 -0
  46. package/dist/reference/errors/MOSTRA_CONSOLE_WARNING.md +23 -0
  47. package/dist/reference/errors/MOSTRA_DEP_INSTALL_FAILED.md +23 -0
  48. package/dist/reference/errors/MOSTRA_DEP_MISSING.md +23 -0
  49. package/dist/reference/errors/MOSTRA_DEP_NOT_FOUND.md +23 -0
  50. package/dist/reference/errors/MOSTRA_DEP_OFFLINE.md +23 -0
  51. package/dist/reference/errors/MOSTRA_DEP_SPEC_INVALID.md +23 -0
  52. package/dist/reference/errors/MOSTRA_DEP_TIMEOUT.md +23 -0
  53. package/dist/reference/errors/MOSTRA_DEP_WARM_CONFLICT.md +23 -0
  54. package/dist/reference/errors/MOSTRA_ENV_BIND_FAILED.md +23 -0
  55. package/dist/reference/errors/MOSTRA_ENV_BROWSER_MISSING.md +23 -0
  56. package/dist/reference/errors/MOSTRA_ENV_BUNDLE_DOWNLOAD_FAILED.md +23 -0
  57. package/dist/reference/errors/MOSTRA_ENV_BUNDLE_INVALID.md +23 -0
  58. package/dist/reference/errors/MOSTRA_ENV_BUNDLE_UNAVAILABLE.md +23 -0
  59. package/dist/reference/errors/MOSTRA_ENV_NODE_UNSUPPORTED.md +23 -0
  60. package/dist/reference/errors/MOSTRA_ENV_PORT_TAKEN.md +23 -0
  61. package/dist/reference/errors/MOSTRA_ENV_RUNTIME_MISSING.md +23 -0
  62. package/dist/reference/errors/MOSTRA_ENV_SERVICE_FAILED.md +23 -0
  63. package/dist/reference/errors/MOSTRA_ENV_SHELL_MISSING.md +23 -0
  64. package/dist/reference/errors/MOSTRA_ENV_WARM_MISSING.md +23 -0
  65. package/dist/reference/errors/MOSTRA_FIELD_CONFLICT.md +23 -0
  66. package/dist/reference/errors/MOSTRA_FIELD_DUPLICATE_ID.md +23 -0
  67. package/dist/reference/errors/MOSTRA_FIELD_ID_INVALID.md +23 -0
  68. package/dist/reference/errors/MOSTRA_FIELD_ID_UNSTABLE.md +23 -0
  69. package/dist/reference/errors/MOSTRA_FIELD_MUTATION_ID_REUSED.md +23 -0
  70. package/dist/reference/errors/MOSTRA_FIELD_SHARED.md +23 -0
  71. package/dist/reference/errors/MOSTRA_FIELD_TOO_LARGE.md +23 -0
  72. package/dist/reference/errors/MOSTRA_FIELD_UNBOUND.md +23 -0
  73. package/dist/reference/errors/MOSTRA_HOME_DISK_FULL.md +23 -0
  74. package/dist/reference/errors/MOSTRA_HOME_GC_INCOMPLETE.md +23 -0
  75. package/dist/reference/errors/MOSTRA_HOME_GONE.md +23 -0
  76. package/dist/reference/errors/MOSTRA_HOME_INTEGRITY.md +23 -0
  77. package/dist/reference/errors/MOSTRA_HOME_LEDGER_UNREADABLE.md +23 -0
  78. package/dist/reference/errors/MOSTRA_HOME_LOCKED.md +23 -0
  79. package/dist/reference/errors/MOSTRA_HOME_MISMATCH.md +23 -0
  80. package/dist/reference/errors/MOSTRA_HOME_NOT_ALWAYS_ON.md +23 -0
  81. package/dist/reference/errors/MOSTRA_HOME_NOT_OWNED.md +23 -0
  82. package/dist/reference/errors/MOSTRA_HOME_SOCKET_TOO_LONG.md +23 -0
  83. package/dist/reference/errors/MOSTRA_HOME_TOO_NEW.md +23 -0
  84. package/dist/reference/errors/MOSTRA_HOST_CONNECTION_CLOSED.md +23 -0
  85. package/dist/reference/errors/MOSTRA_HOST_CURSOR_COMPACTED.md +23 -0
  86. package/dist/reference/errors/MOSTRA_HOST_IDENTITY_UNAVAILABLE.md +23 -0
  87. package/dist/reference/errors/MOSTRA_HOST_INITIALIZE_ORDER.md +23 -0
  88. package/dist/reference/errors/MOSTRA_HOST_INTERNAL_ERROR.md +23 -0
  89. package/dist/reference/errors/MOSTRA_HOST_INVALID_PARAMS.md +23 -0
  90. package/dist/reference/errors/MOSTRA_HOST_MALFORMED.md +23 -0
  91. package/dist/reference/errors/MOSTRA_HOST_MESSAGE_TOO_LARGE.md +23 -0
  92. package/dist/reference/errors/MOSTRA_HOST_METHOD_UNKNOWN.md +23 -0
  93. package/dist/reference/errors/MOSTRA_HOST_NOTIFICATION_UNSUPPORTED.md +23 -0
  94. package/dist/reference/errors/MOSTRA_HOST_PORT_UNSUPPORTED.md +23 -0
  95. package/dist/reference/errors/MOSTRA_HOST_REQUEST_ID_REUSED.md +23 -0
  96. package/dist/reference/errors/MOSTRA_HOST_REVISION_UNSUPPORTED.md +23 -0
  97. package/dist/reference/errors/MOSTRA_HOST_SHUTDOWN_REFUSED.md +23 -0
  98. package/dist/reference/errors/MOSTRA_HOST_SUBSCRIPTION_UNKNOWN.md +23 -0
  99. package/dist/reference/errors/MOSTRA_HOST_TIMEOUT.md +23 -0
  100. package/dist/reference/errors/MOSTRA_HYGIENE_CONTENT_TYPE.md +23 -0
  101. package/dist/reference/errors/MOSTRA_HYGIENE_CSRF_MISSING.md +23 -0
  102. package/dist/reference/errors/MOSTRA_HYGIENE_HEADERS_TOO_LARGE.md +23 -0
  103. package/dist/reference/errors/MOSTRA_HYGIENE_HOST_REFUSED.md +23 -0
  104. package/dist/reference/errors/MOSTRA_HYGIENE_NOT_SERVED.md +23 -0
  105. package/dist/reference/errors/MOSTRA_HYGIENE_ORIGIN_REFUSED.md +23 -0
  106. package/dist/reference/errors/MOSTRA_HYGIENE_RATE_LIMITED.md +23 -0
  107. package/dist/reference/errors/MOSTRA_HYGIENE_REQUEST_MALFORMED.md +23 -0
  108. package/dist/reference/errors/MOSTRA_HYGIENE_SCOPE_DENIED.md +23 -0
  109. package/dist/reference/errors/MOSTRA_HYGIENE_SESSION_CONFLICT.md +23 -0
  110. package/dist/reference/errors/MOSTRA_HYGIENE_SESSION_MISSING.md +23 -0
  111. package/dist/reference/errors/MOSTRA_HYGIENE_TOKEN_INVALID.md +23 -0
  112. package/dist/reference/errors/MOSTRA_HYGIENE_TOKEN_REVOKED.md +23 -0
  113. package/dist/reference/errors/MOSTRA_LIVE_SOURCE_FAILED.md +23 -0
  114. package/dist/reference/errors/MOSTRA_LIVE_TOO_LARGE.md +23 -0
  115. package/dist/reference/errors/MOSTRA_MANIFEST_INVALID.md +23 -0
  116. package/dist/reference/errors/MOSTRA_MANIFEST_NOT_LITERAL.md +23 -0
  117. package/dist/reference/errors/MOSTRA_MANIFEST_UNKNOWN_KEY.md +23 -0
  118. package/dist/reference/errors/MOSTRA_NETWORK_EXTERNAL_FAILED.md +23 -0
  119. package/dist/reference/errors/MOSTRA_NETWORK_REQUEST_FAILED.md +23 -0
  120. package/dist/reference/errors/MOSTRA_PLATFORM_UNSUPPORTED.md +23 -0
  121. package/dist/reference/errors/MOSTRA_PUBLISH_ID_CONFLICT.md +23 -0
  122. package/dist/reference/errors/MOSTRA_PUBLISH_SOURCE_CHANGED.md +23 -0
  123. package/dist/reference/errors/MOSTRA_PUBLISH_VERIFY_FAILED.md +23 -0
  124. package/dist/reference/errors/MOSTRA_REGRESSION_ACTION_REMOVED.md +23 -0
  125. package/dist/reference/errors/MOSTRA_REGRESSION_ARIA.md +23 -0
  126. package/dist/reference/errors/MOSTRA_REGRESSION_FIELD_REMOVED.md +23 -0
  127. package/dist/reference/errors/MOSTRA_REGRESSION_LIVE_REMOVED.md +23 -0
  128. package/dist/reference/errors/MOSTRA_REGRESSION_PAGE_REMOVED.md +23 -0
  129. package/dist/reference/errors/MOSTRA_REGRESSION_PIXELS.md +23 -0
  130. package/dist/reference/errors/MOSTRA_RUNTIME_BLANK.md +23 -0
  131. package/dist/reference/errors/MOSTRA_RUNTIME_NOT_READY.md +23 -0
  132. package/dist/reference/errors/MOSTRA_RUNTIME_PAGE_ERROR.md +23 -0
  133. package/dist/reference/errors/MOSTRA_RUNTIME_WORKER_CRASHED.md +23 -0
  134. package/dist/reference/errors/MOSTRA_RUNTIME_WORKER_PARKED.md +23 -0
  135. package/dist/reference/errors/MOSTRA_SECRET_MISSING.md +23 -0
  136. package/dist/reference/errors/MOSTRA_SMOKE_BUDGET.md +23 -0
  137. package/dist/reference/errors/MOSTRA_SMOKE_NO_EFFECT.md +23 -0
  138. package/dist/reference/errors/MOSTRA_SUBMIT_ALREADY.md +23 -0
  139. package/dist/reference/errors/MOSTRA_SUBMIT_BLOCKED.md +23 -0
  140. package/dist/reference/errors/MOSTRA_SUBMIT_CHANNEL_DENIED.md +23 -0
  141. package/dist/reference/errors/MOSTRA_SUBMIT_NOT_FOUND.md +23 -0
  142. package/dist/reference/errors/MOSTRA_SUBMIT_POLICY_NONE.md +23 -0
  143. package/dist/reference/errors/MOSTRA_SUBMIT_REVISION_MOVED.md +23 -0
  144. package/dist/reference/errors/MOSTRA_SUBMIT_SIGNAL_INVALID.md +23 -0
  145. package/dist/reference/errors/MOSTRA_SUBMIT_VIEW_INVALID.md +23 -0
  146. package/dist/reference/errors/MOSTRA_TARGET_UNAVAILABLE.md +23 -0
  147. package/dist/reference/errors/MOSTRA_TYPE_ERROR.md +23 -0
  148. package/dist/reference/errors/MOSTRA_TYPE_UNTYPED_PACKAGE.md +23 -0
  149. package/dist/reference/errors/MOSTRA_VERIFY_ACTION_SKIPPED.md +23 -0
  150. package/dist/reference/errors/MOSTRA_VERIFY_BASELINE_MISSING.md +23 -0
  151. package/dist/reference/errors/MOSTRA_VERIFY_CRASHED.md +23 -0
  152. package/dist/reference/errors/MOSTRA_VERIFY_LIVE_FAILED.md +23 -0
  153. package/dist/reference/errors/MOSTRA_VERIFY_NO_BASELINE.md +23 -0
  154. package/dist/reference/errors/MOSTRA_VERIFY_SECRET_MISSING.md +23 -0
  155. package/dist/reference/errors/MOSTRA_VERIFY_SKIP_CONSEQUENCE.md +23 -0
  156. package/dist/reference/errors/MOSTRA_VERIFY_TIMEOUT.md +23 -0
  157. package/dist/reference/telemetry.md +82 -0
  158. package/dist/run-BJaRnz-Q.js +3824 -0
  159. package/dist/run-BJaRnz-Q.js.map +1 -0
  160. package/dist/run-CMyjOm0Q.js +2 -0
  161. package/dist/telemetry/flush-main.d.ts +1 -0
  162. package/dist/telemetry/flush-main.js +14 -0
  163. package/dist/telemetry/flush-main.js.map +1 -0
  164. package/package.json +62 -3
  165. package/src/bin.ts +70 -0
  166. package/src/bootstrap.ts +156 -0
  167. package/src/browser.ts +186 -0
  168. package/src/client.ts +313 -0
  169. package/src/command.ts +147 -0
  170. package/src/commands/admin.ts +791 -0
  171. package/src/commands/authoring.ts +244 -0
  172. package/src/commands/dev.ts +106 -0
  173. package/src/commands/index.ts +71 -0
  174. package/src/commands/interaction.ts +288 -0
  175. package/src/commands/shared.ts +71 -0
  176. package/src/commands/viewing.ts +153 -0
  177. package/src/docs.ts +111 -0
  178. package/src/exit.ts +89 -0
  179. package/src/help.ts +170 -0
  180. package/src/home.ts +111 -0
  181. package/src/index.ts +23 -0
  182. package/src/node-gate.ts +15 -0
  183. package/src/parse.ts +246 -0
  184. package/src/run.ts +387 -0
  185. package/src/telemetry/decision.ts +101 -0
  186. package/src/telemetry/events.ts +147 -0
  187. package/src/telemetry/flush-main.ts +13 -0
  188. package/src/telemetry/flush.ts +143 -0
  189. package/src/telemetry/harness.ts +39 -0
  190. package/src/telemetry/index.ts +318 -0
  191. package/src/telemetry/store.ts +379 -0
  192. package/src/telemetry/timezone.ts +72 -0
  193. package/src/telemetry/words.ts +87 -0
  194. package/src/telemetry/zone-sets.ts +182 -0
  195. package/src/telemetry/zones.generated.ts +720 -0
  196. package/src/version.ts +43 -0
package/src/run.ts ADDED
@@ -0,0 +1,387 @@
1
+ /**
2
+ * The runner (INTERFACES §9, D140 item 16): reads the command line, runs one command, and owns
3
+ * everything commands share. stdout carries exactly one JSON value in `--json` mode (one per line
4
+ * for a stream), whatever happens, usage errors and crashes included; logs, progress and warnings
5
+ * go to stderr; the exit code comes from one table; telemetry's notice or question comes before
6
+ * the command's work and its events after it.
7
+ */
8
+ import { spawn } from "node:child_process";
9
+ import { resolve } from "node:path";
10
+ import { createInterface } from "node:readline";
11
+ import {
12
+ createDiagnostic,
13
+ type Diagnostic,
14
+ type MethodDefinition,
15
+ RpcError,
16
+ } from "@mostra/protocol";
17
+ import { connect, type InstanceConnection, type InstanceSummary } from "./client.ts";
18
+ import {
19
+ type CommandContext,
20
+ type CommandDef,
21
+ type CommandOutcome,
22
+ commandName,
23
+ } from "./command.ts";
24
+ import { COMMANDS } from "./commands/index.ts";
25
+ import { EXIT, exitCodeFor } from "./exit.ts";
26
+ import { commandDefinition, commandHelp, groupHelp, topHelp, UNBOUNDED } from "./help.ts";
27
+ import {
28
+ configDir,
29
+ type Environment,
30
+ embeddedHome,
31
+ locateRuntime,
32
+ readHomeMarks,
33
+ resolveHome,
34
+ } from "./home.ts";
35
+ import { globalFlags, parseCommandLine } from "./parse.ts";
36
+ import { telemetrySession } from "./telemetry/index.ts";
37
+ import { cliVersion } from "./version.ts";
38
+
39
+ /** What the runner reads and writes; the binary passes the process's, tests their own. */
40
+ export interface RunnerIo {
41
+ /** The words after mostra. */
42
+ readonly argv: readonly string[];
43
+ /** The environment. */
44
+ readonly env: Environment;
45
+ /** The working directory. */
46
+ readonly cwd: string;
47
+ /** Writes to stdout. */
48
+ readonly stdout: (text: string) => void;
49
+ /** Writes to stderr. */
50
+ readonly stderr: (text: string) => void;
51
+ /** Standard input. */
52
+ readonly stdin: NodeJS.ReadableStream;
53
+ /** Which of the streams are terminals. */
54
+ readonly tty: { readonly stdin: boolean; readonly stdout: boolean; readonly stderr: boolean };
55
+ /** The platform; default process.platform. */
56
+ readonly platform?: string;
57
+ }
58
+
59
+ /** The diagnostic an exception is, for the envelope: its own, or an internal error. */
60
+ function diagnosticOf(error: unknown): Diagnostic {
61
+ if (error instanceof RpcError) return error.diagnostic;
62
+ return createDiagnostic("MOSTRA_HOST_INTERNAL_ERROR", {
63
+ message: `mostra failed unexpectedly: ${error instanceof Error ? error.message : String(error)}`,
64
+ });
65
+ }
66
+
67
+ /** A diagnostic for people, on stderr. */
68
+ function describe(diagnostic: Diagnostic): string {
69
+ const hint = diagnostic.hint ? `\n ${diagnostic.hint}` : "";
70
+ const fix =
71
+ diagnostic.fix?.kind === "command" && diagnostic.fix.command
72
+ ? `\n Run: ${diagnostic.fix.command}`
73
+ : "";
74
+ return `${diagnostic.severity} ${diagnostic.code}: ${diagnostic.message}${hint}${fix}\n ${diagnostic.docs}\n`;
75
+ }
76
+
77
+ /** Reads the whole of stdin. */
78
+ function readAll(stream: NodeJS.ReadableStream): Promise<string> {
79
+ return new Promise((resolve, reject) => {
80
+ const chunks: Buffer[] = [];
81
+ stream.on("data", (chunk: Buffer | string) =>
82
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)),
83
+ );
84
+ stream.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
85
+ stream.on("error", reject);
86
+ });
87
+ }
88
+
89
+ /** Asks one question on stderr, read from stdin: the answer, `undefined` after the timeout, or Ctrl-C. */
90
+ function ask(
91
+ io: RunnerIo,
92
+ question: string,
93
+ timeoutMs: number,
94
+ ): Promise<string | undefined | "interrupt"> {
95
+ return new Promise((resolve) => {
96
+ const rl = createInterface({ input: io.stdin, output: process.stderr, terminal: true });
97
+ let done = false;
98
+ const finish = (answer: string | undefined | "interrupt"): void => {
99
+ if (done) return;
100
+ done = true;
101
+ clearTimeout(timer);
102
+ rl.close();
103
+ resolve(answer);
104
+ };
105
+ const timer = setTimeout(() => {
106
+ io.stderr("\n");
107
+ finish(undefined);
108
+ }, timeoutMs);
109
+ rl.on("SIGINT", () => {
110
+ io.stderr("\n");
111
+ finish("interrupt");
112
+ });
113
+ rl.on("close", () => finish(""));
114
+ rl.question(question, (answer) => finish(answer));
115
+ });
116
+ }
117
+
118
+ /** Runs one command line and returns its exit code. */
119
+ export async function run(io: RunnerIo): Promise<number> {
120
+ const started = Date.now();
121
+ const global = globalFlags(io.argv);
122
+ const json = global.json;
123
+ const warnings: Diagnostic[] = [];
124
+ let summary: InstanceSummary | undefined;
125
+ let connection: InstanceConnection | undefined;
126
+ let streamed = false;
127
+
128
+ const writeJson = (value: unknown): void => io.stdout(`${JSON.stringify(value)}\n`);
129
+ const fail = (diagnostic: Diagnostic): number => {
130
+ if (json) {
131
+ writeJson({
132
+ ok: false,
133
+ error: diagnostic,
134
+ ...(summary && !streamed ? { instance: summary } : {}),
135
+ warnings,
136
+ });
137
+ } else io.stderr(describe(diagnostic));
138
+ return exitCodeFor(diagnostic.code);
139
+ };
140
+
141
+ // Every entry point fails closed on an unsupported platform before touching the home (D35).
142
+ 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
+ }
150
+
151
+ let parsed: ReturnType<typeof parseCommandLine>;
152
+ try {
153
+ parsed = parseCommandLine(COMMANDS, io.argv);
154
+ } catch (error) {
155
+ return fail(diagnosticOf(error));
156
+ }
157
+ if (parsed.kind === "version") {
158
+ if (json) writeJson({ ok: true, result: { version: cliVersion() }, warnings });
159
+ else io.stdout(`${cliVersion()}\n`);
160
+ return EXIT.ok;
161
+ }
162
+ if (parsed.kind === "help") {
163
+ if (json) {
164
+ const result = parsed.command
165
+ ? commandDefinition(parsed.command)
166
+ : {
167
+ commands: COMMANDS.filter(
168
+ (command) => !parsed.group || command.path[0] === parsed.group,
169
+ ).map(commandDefinition),
170
+ };
171
+ writeJson({ ok: true, result, warnings });
172
+ } else {
173
+ io.stdout(
174
+ parsed.command
175
+ ? commandHelp(parsed.command)
176
+ : parsed.group
177
+ ? groupHelp(COMMANDS, parsed.group)
178
+ : topHelp(COMMANDS),
179
+ );
180
+ }
181
+ return EXIT.ok;
182
+ }
183
+
184
+ const { command } = parsed;
185
+ if (global.timeoutMs !== undefined && UNBOUNDED.has(command.path[0] ?? "")) {
186
+ return fail(
187
+ createDiagnostic("MOSTRA_CLI_USAGE", {
188
+ message: `mostra ${commandName(command)} runs until Ctrl-C or its connection ends, so --timeout doesn't apply to it.`,
189
+ }),
190
+ );
191
+ }
192
+ // `wait --timeout` is the wait's own length, which ends with still_waiting (exit 5), never exit 4.
193
+ const timeoutMs = command.path[0] === "wait" ? undefined : global.timeoutMs;
194
+ const cwd = global.cwd !== undefined ? resolve(io.cwd, global.cwd) : io.cwd;
195
+ const home = resolveHome({ home: global.home, env: io.env, cwd, platform });
196
+ const config = configDir(io.env, cwd);
197
+ const marks = readHomeMarks(home);
198
+ const readsStdin =
199
+ typeof command.readsStdin === "function"
200
+ ? command.readsStdin(parsed.flags)
201
+ : command.readsStdin === true;
202
+ const telemetry = telemetrySession({
203
+ env: io.env,
204
+ configDir: config,
205
+ command: commandName(command),
206
+ json,
207
+ tty: io.tty,
208
+ readsStdin,
209
+ quiet: command.quiet === true,
210
+ hostHome: embeddedHome(marks),
211
+ stderr: io.stderr,
212
+ ask: (question, timeoutMs) => ask(io, question, timeoutMs),
213
+ });
214
+
215
+ let connectionClosed = false;
216
+ // Whether this command is starting the home's daemon: a --timeout that runs out then says so.
217
+ let startingDaemon = false;
218
+ const getConnection = async (autostart = true): Promise<InstanceConnection | undefined> => {
219
+ // A session the instance ended (`daemon stop` before `daemon restart`) is opened again.
220
+ if (connection && !connectionClosed) return connection;
221
+ connectionClosed = false;
222
+ const opened = await connect({
223
+ home,
224
+ env: io.env,
225
+ autostart,
226
+ onStarting: (starting) => {
227
+ startingDaemon = starting;
228
+ },
229
+ });
230
+ if (!opened) return undefined;
231
+ connection = opened;
232
+ void opened.closed.then(() => {
233
+ if (connection === opened) connectionClosed = true;
234
+ });
235
+ summary = opened.summary;
236
+ for (const warning of opened.warnings) {
237
+ warnings.push(warning);
238
+ io.stderr(describe(warning));
239
+ }
240
+ return opened;
241
+ };
242
+
243
+ const context: CommandContext = {
244
+ args: parsed.args,
245
+ flags: parsed.flags,
246
+ cwd,
247
+ json,
248
+ timeoutMs,
249
+ env: io.env,
250
+ tty: io.tty,
251
+ home,
252
+ configDir: config,
253
+ stdout: io.stdout,
254
+ stderr: io.stderr,
255
+ async call<P, R>(method: MethodDefinition<P, R>, params: P): Promise<R> {
256
+ const session = await getConnection();
257
+ if (!session)
258
+ throw new RpcError(
259
+ createDiagnostic("MOSTRA_HOST_CONNECTION_CLOSED", {
260
+ message: "No instance runs for this home.",
261
+ }),
262
+ );
263
+ const remaining =
264
+ timeoutMs === undefined ? undefined : Math.max(1, timeoutMs - (Date.now() - started));
265
+ return session.call(method, params, remaining);
266
+ },
267
+ connection: getConnection,
268
+ readStdin: () => readAll(io.stdin),
269
+ log: (line) => io.stderr(`${line}\n`),
270
+ emit(event, human) {
271
+ if (json) {
272
+ writeJson({ ok: true, event, ...(summary && !streamed ? { instance: summary } : {}) });
273
+ streamed = true;
274
+ } else if (human !== undefined) io.stdout(human);
275
+ },
276
+ warn(diagnostic) {
277
+ warnings.push(diagnostic);
278
+ io.stderr(describe(diagnostic));
279
+ },
280
+ record: (event) => telemetry.record(event),
281
+ runtime: (args, stdio = "pipe") => runRuntime(io, args, stdio),
282
+ };
283
+
284
+ let exit: number;
285
+ let error: string | undefined;
286
+ try {
287
+ // Telemetry never stops a command: a failure in it is a run without telemetry.
288
+ if (!(await telemetry.begin().catch(() => true))) return 130;
289
+ const outcome: CommandOutcome = await withTimeout(
290
+ command,
291
+ context,
292
+ timeoutMs,
293
+ () => startingDaemon,
294
+ );
295
+ exit = outcome.exit ?? EXIT.ok;
296
+ if (outcome.raw) return exit;
297
+ if (json) {
298
+ writeJson({
299
+ ok: true,
300
+ result: outcome.result ?? null,
301
+ ...(summary && !streamed && command.local !== true ? { instance: summary } : {}),
302
+ warnings,
303
+ });
304
+ } else io.stdout(outcome.human ?? `${JSON.stringify(outcome.result, null, 2)}\n`);
305
+ } catch (caught) {
306
+ const diagnostic = diagnosticOf(caught);
307
+ error = diagnostic.code;
308
+ exit = fail(diagnostic);
309
+ } finally {
310
+ connection?.close();
311
+ }
312
+ try {
313
+ telemetry.end({
314
+ exit,
315
+ durationMs: Date.now() - started,
316
+ hostInstance: summary?.mode === "host",
317
+ ...(error !== undefined ? { error } : {}),
318
+ });
319
+ } catch {
320
+ // Telemetry never changes a command's outcome.
321
+ }
322
+ return exit;
323
+ }
324
+
325
+ /**
326
+ * Runs a command, bounded by `--timeout` (exit 4 with `MOSTRA_HOST_TIMEOUT`). `host` and `dev` run
327
+ * until their connection or Ctrl-C ends them, so `--timeout` bounds neither.
328
+ */
329
+ async function withTimeout(
330
+ command: CommandDef,
331
+ context: CommandContext,
332
+ timeoutMs: number | undefined,
333
+ startingDaemon: () => boolean,
334
+ ): Promise<CommandOutcome> {
335
+ if (timeoutMs === undefined || command.path[0] === "host" || command.path[0] === "dev")
336
+ return command.run(context);
337
+ let timer: ReturnType<typeof setTimeout> | undefined;
338
+ try {
339
+ return await Promise.race([
340
+ command.run(context),
341
+ new Promise<never>((_, reject) => {
342
+ timer = setTimeout(
343
+ () =>
344
+ reject(
345
+ new RpcError(
346
+ createDiagnostic("MOSTRA_HOST_TIMEOUT", {
347
+ // One timer owns the deadline and names what was underway (Opus review
348
+ // finding 7): starting the daemon, or the command itself.
349
+ message: startingDaemon()
350
+ ? `The home's daemon didn't start within ${timeoutMs} ms (--timeout).`
351
+ : `mostra ${commandName(command)} didn't finish within --timeout (${timeoutMs} ms); what it asked for may still take effect.`,
352
+ }),
353
+ ),
354
+ ),
355
+ timeoutMs,
356
+ );
357
+ }),
358
+ ]);
359
+ } finally {
360
+ clearTimeout(timer);
361
+ }
362
+ }
363
+
364
+ /** Runs `mostra-runtime …` and collects its stdout. */
365
+ async function runRuntime(
366
+ io: RunnerIo,
367
+ args: readonly string[],
368
+ stdio: "pipe" | "inherit",
369
+ ): Promise<{ code: number; stdout: string }> {
370
+ const runtime = await locateRuntime(io.env);
371
+ return new Promise((resolve) => {
372
+ const child = spawn(runtime.node, [runtime.entry, ...args], {
373
+ stdio: stdio === "inherit" ? "inherit" : ["ignore", "pipe", "pipe"],
374
+ env: io.env as NodeJS.ProcessEnv,
375
+ });
376
+ let stdout = "";
377
+ child.stdout?.setEncoding("utf8");
378
+ child.stdout?.on("data", (chunk: string) => {
379
+ stdout += chunk;
380
+ });
381
+ child.stderr?.setEncoding("utf8");
382
+ child.stderr?.on("data", (chunk: string) => io.stderr(chunk));
383
+ child.on("error", () => resolve({ code: EXIT.environment, stdout }));
384
+ // `close`, not `exit`: the line may still be in the pipe when the process has exited.
385
+ child.on("close", (code) => resolve({ code: code ?? EXIT.environment, stdout }));
386
+ });
387
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Whether telemetry sends, and why (D140 item 3). The first match decides, so an explicit "off"
3
+ * anywhere wins over any "on", and withdrawn consent sticks.
4
+ */
5
+ import type { Environment } from "../home.ts";
6
+ import type { ReadSettings } from "./store.ts";
7
+ import type { TelemetryReason } from "./words.ts";
8
+
9
+ /** The endpoint this build sends to, set only by the release workflow at bundle time (D140 item 7). */
10
+ declare const __MOSTRA_TELEMETRY_ENDPOINT__: string | undefined;
11
+
12
+ /** The build's endpoint: empty in this package and in every development build. */
13
+ export function buildEndpoint(): string {
14
+ return typeof __MOSTRA_TELEMETRY_ENDPOINT__ === "string" ? __MOSTRA_TELEMETRY_ENDPOINT__ : "";
15
+ }
16
+
17
+ /** What the decision reads. */
18
+ export interface DecisionInputs {
19
+ readonly env: Environment;
20
+ /** The build's endpoint ({@link buildEndpoint}); tests pass their own. */
21
+ readonly buildEndpoint: string;
22
+ /** Whether this use is embedded: a host-spawned instance, or a home a host configures. */
23
+ readonly embedded: boolean;
24
+ readonly settings: ReadSettings;
25
+ /** Whether this machine's time zone asks first. */
26
+ readonly askFirst: boolean;
27
+ }
28
+
29
+ /** The decision. */
30
+ export interface TelemetryDecision {
31
+ readonly enabled: boolean;
32
+ readonly reason: TelemetryReason;
33
+ readonly askFirst: boolean;
34
+ /** The endpoint events go to, when there is one. */
35
+ readonly endpoint?: string;
36
+ /** For an enabled decision: whether the person said yes, or it is the default. */
37
+ readonly consent?: "explicit" | "default";
38
+ }
39
+
40
+ const OFF_VALUES = new Set(["0", "false", "off", "no"]);
41
+ const ON_VALUES = new Set(["1", "true", "on", "yes"]);
42
+
43
+ /** `DO_NOT_TRACK` and `MOSTRA_OFFLINE`'s parser: set to anything but empty, `0` or `false`. */
44
+ export function truthy(value: string | undefined): boolean {
45
+ if (value === undefined) return false;
46
+ const normalized = value.trim().toLowerCase();
47
+ return normalized !== "" && normalized !== "0" && normalized !== "false";
48
+ }
49
+
50
+ /**
51
+ * The endpoint in use: `MOSTRA_TELEMETRY_ENDPOINT` when it is `https:` or `http:` to a loopback
52
+ * address, else the build's; `undefined` when there is none (D140 item 7).
53
+ */
54
+ export function endpointOf(env: Environment, build: string): string | undefined {
55
+ const override = env["MOSTRA_TELEMETRY_ENDPOINT"];
56
+ const chosen = override !== undefined && override !== "" ? override : build;
57
+ if (chosen === "") return undefined;
58
+ let url: URL;
59
+ try {
60
+ url = new URL(chosen);
61
+ } catch {
62
+ return undefined;
63
+ }
64
+ if (url.username !== "" || url.password !== "") return undefined;
65
+ if (url.protocol === "https:") return url.href;
66
+ const loopback =
67
+ url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "localhost";
68
+ return url.protocol === "http:" && loopback ? url.href : undefined;
69
+ }
70
+
71
+ /** Decides, in D140's order. */
72
+ export function decide(inputs: DecisionInputs): TelemetryDecision {
73
+ const { askFirst } = inputs;
74
+ const off = (reason: TelemetryReason): TelemetryDecision => ({
75
+ enabled: false,
76
+ reason,
77
+ askFirst,
78
+ });
79
+ const endpoint = endpointOf(inputs.env, inputs.buildEndpoint);
80
+ if (endpoint === undefined) return off("no-collector");
81
+ const at = { askFirst, endpoint };
82
+ if (inputs.embedded) return { ...at, enabled: false, reason: "embedded" };
83
+ if (truthy(inputs.env["DO_NOT_TRACK"])) return { ...at, enabled: false, reason: "do-not-track" };
84
+ const variable = inputs.env["MOSTRA_TELEMETRY"];
85
+ const normalized = variable?.trim().toLowerCase();
86
+ if (normalized !== undefined && OFF_VALUES.has(normalized)) {
87
+ return { ...at, enabled: false, reason: "variable" };
88
+ }
89
+ if (!inputs.settings.ok) return { ...at, enabled: false, reason: "unreadable" };
90
+ const choice = inputs.settings.settings.choice;
91
+ if (choice === "off") return { ...at, enabled: false, reason: "setting" };
92
+ if (normalized !== undefined && normalized !== "") {
93
+ return ON_VALUES.has(normalized)
94
+ ? { ...at, enabled: true, reason: "variable", consent: "explicit" }
95
+ : { ...at, enabled: false, reason: "variable-invalid" };
96
+ }
97
+ if (choice === "on") return { ...at, enabled: true, reason: "setting", consent: "explicit" };
98
+ return askFirst
99
+ ? { ...at, enabled: false, reason: "default" }
100
+ : { ...at, enabled: true, reason: "default", consent: "default" };
101
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Telemetry's event schema 1 (D140 item 8), the one published in `docs/reference/telemetry.md`.
3
+ * Every event is checked against it before it is queued and again before it is sent, so nothing
4
+ * that isn't on that page can leave the machine: the objects are strict, every string is from a
5
+ * closed set or a pattern, and no field takes free text.
6
+ */
7
+ import { DIAGNOSTIC_AREAS } from "@mostra/protocol";
8
+ import * as z from "zod";
9
+ import { HARNESSES, type Harness } from "./harness.ts";
10
+
11
+ /** The schema version every event carries. */
12
+ export const SCHEMA = 1;
13
+
14
+ /** The duration buckets of `cli.command`. */
15
+ export const DURATIONS = ["<1s", "1-5s", "5-30s", "30s-2m", "2-10m", ">10m"] as const;
16
+
17
+ /** A command's duration bucket. */
18
+ export function durationBucket(ms: number): (typeof DURATIONS)[number] {
19
+ if (ms < 1000) return "<1s";
20
+ if (ms < 5000) return "1-5s";
21
+ if (ms < 30_000) return "5-30s";
22
+ if (ms < 120_000) return "30s-2m";
23
+ if (ms < 600_000) return "2-10m";
24
+ return ">10m";
25
+ }
26
+
27
+ /** A catalog-shaped diagnostic code: `MOSTRA_<AREA>_<NAME>`, never a message. */
28
+ const code = z
29
+ .string()
30
+ .regex(new RegExp(`^MOSTRA_(${DIAGNOSTIC_AREAS.join("|")})_[A-Z0-9_]{1,64}$`));
31
+
32
+ /** A command path from the definitions: lowercase words. */
33
+ const command = z.string().regex(/^[a-z]+( [a-z]+)?$/);
34
+
35
+ const common = {
36
+ schema: z.literal(SCHEMA),
37
+ at: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:00Z$/),
38
+ installId: z.uuid(),
39
+ version: z.string().regex(/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/),
40
+ os: z.enum(["darwin", "linux"]),
41
+ arch: z.string().regex(/^[a-z0-9]{2,12}$/),
42
+ node: z.int().min(0).max(999),
43
+ harness: z.enum([...HARNESSES, "other", "none"]),
44
+ ci: z.boolean(),
45
+ consent: z.enum(["explicit", "default"]),
46
+ };
47
+
48
+ /** The fields every event carries. */
49
+ export interface CommonFields {
50
+ readonly schema: typeof SCHEMA;
51
+ readonly at: string;
52
+ readonly installId: string;
53
+ readonly version: string;
54
+ readonly os: "darwin" | "linux";
55
+ readonly arch: string;
56
+ readonly node: number;
57
+ readonly harness: Harness;
58
+ readonly ci: boolean;
59
+ readonly consent: "explicit" | "default";
60
+ }
61
+
62
+ /** One event, as sent (D140 item 8). */
63
+ export type TelemetryEvent = CommonFields &
64
+ (
65
+ | { readonly event: "cli.first_run" }
66
+ | {
67
+ readonly event: "cli.command";
68
+ readonly command: string;
69
+ readonly exit: number;
70
+ readonly duration: (typeof DURATIONS)[number];
71
+ readonly json: boolean;
72
+ readonly error?: string | undefined;
73
+ }
74
+ | {
75
+ readonly event: "verify.result";
76
+ readonly status: "pass" | "fail";
77
+ readonly codes: readonly string[];
78
+ readonly codesTruncated: boolean;
79
+ }
80
+ | { readonly event: "publish.done"; readonly reused: boolean; readonly forced: boolean }
81
+ | {
82
+ readonly event: "share.done";
83
+ readonly kind: "tailnet" | "funnel" | "cloudflare" | "remote";
84
+ }
85
+ | {
86
+ readonly event: "mcp.session";
87
+ readonly client: (typeof HARNESSES)[number] | "other";
88
+ readonly clientVersion?: string | undefined;
89
+ }
90
+ );
91
+
92
+ /** Every event Mostra may send, as a strict union on `event`. */
93
+ export const TelemetryEvent: z.ZodType<TelemetryEvent> = z.discriminatedUnion("event", [
94
+ z.strictObject({ ...common, event: z.literal("cli.first_run") }),
95
+ z.strictObject({
96
+ ...common,
97
+ event: z.literal("cli.command"),
98
+ command,
99
+ exit: z.int().min(0).max(255),
100
+ duration: z.enum(DURATIONS),
101
+ json: z.boolean(),
102
+ error: code.optional(),
103
+ }),
104
+ z.strictObject({
105
+ ...common,
106
+ event: z.literal("verify.result"),
107
+ status: z.enum(["pass", "fail"]),
108
+ codes: z.array(code).max(50),
109
+ codesTruncated: z.boolean(),
110
+ }),
111
+ z.strictObject({
112
+ ...common,
113
+ event: z.literal("publish.done"),
114
+ reused: z.boolean(),
115
+ forced: z.boolean(),
116
+ }),
117
+ // Emitted by WP-13 and WP-29.
118
+ z.strictObject({
119
+ ...common,
120
+ event: z.literal("share.done"),
121
+ kind: z.enum(["tailnet", "funnel", "cloudflare", "remote"]),
122
+ }),
123
+ // Emitted by WP-15: a known agent's ID for the MCP client, or `other`; its version only then.
124
+ z.strictObject({
125
+ ...common,
126
+ event: z.literal("mcp.session"),
127
+ client: z.enum([...HARNESSES, "other"]),
128
+ clientVersion: z
129
+ .string()
130
+ .regex(/^[0-9A-Za-z.+-]{1,32}$/)
131
+ .optional(),
132
+ }),
133
+ ]);
134
+
135
+ /** `at`: now in UTC, rounded down to the minute. */
136
+ export function minute(now: Date): string {
137
+ return `${now.toISOString().slice(0, 16)}:00Z`;
138
+ }
139
+
140
+ /** The distinct catalog codes of `codes`, sorted, at most 50, and whether some were left out. */
141
+ export function catalogCodes(
142
+ codes: readonly string[],
143
+ known: ReadonlySet<string>,
144
+ ): { codes: string[]; codesTruncated: boolean } {
145
+ const distinct = [...new Set(codes.filter((value) => known.has(value)))].sort();
146
+ return { codes: distinct.slice(0, 50), codesTruncated: distinct.length > 50 };
147
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The flusher process (D140 item 6): `node flush-main.js <config dir>`, started detached by the
3
+ * CLI. It sends what the queue holds and exits; nothing it does reaches the command that started
4
+ * it.
5
+ */
6
+ import { flush } from "./flush.ts";
7
+ import { TelemetryStore } from "./store.ts";
8
+
9
+ const dir = process.argv[2];
10
+ if (dir !== undefined) {
11
+ await flush(new TelemetryStore(dir), process.env, Date.now).catch(() => {});
12
+ }
13
+ process.exit(0);