mirror-mind 0.39.0 → 0.39.2
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/.pi/extensions/mirror-logger.ts +170 -4
- package/.pi/skills/mm-identity/SKILL.md +9 -2
- package/.pi/skills/mm-journey/SKILL.md +3 -1
- package/docs/releases/index.md +2 -0
- package/docs/releases/v0.39.0.md +28 -0
- package/docs/releases/v0.39.1.md +109 -0
- package/docs/releases/v0.39.2.md +113 -0
- package/package.json +1 -1
- package/plugins/mirror-mind/.claude-plugin/plugin.json +1 -1
- package/plugins/mirror-mind/plugin.json +1 -1
- package/plugins/mirror-mind/skills/mm-identity/SKILL.md +9 -2
- package/plugins/mirror-mind/skills/mm-journey/SKILL.md +9 -1
- package/ts/src/frontDoor/cli.ts +53 -11
- package/ts/src/frontDoor/textInput.ts +55 -0
- package/ts/src/runtime/diagnose.ts +11 -3
|
@@ -43,6 +43,14 @@
|
|
|
43
43
|
* it supplies the Operating Instructions (`AGENTS.md`) itself (D13), and says
|
|
44
44
|
* so once when `mirror` is not on the PATH.
|
|
45
45
|
*
|
|
46
|
+
* The `node` it runs the core on is resolved as the hook wrappers resolve
|
|
47
|
+
* theirs (CR191): `MIRROR_NODE`, the PATH, the Node that installed the
|
|
48
|
+
* package, then nvm, Homebrew, and `/usr/local`. A GUI-launched Pi, or one from
|
|
49
|
+
* a window older than the Node install, has none on its PATH. When nothing
|
|
50
|
+
* resolves the session says so once, names the fix, and writes one line to
|
|
51
|
+
* `<mirror home>/hooks.log`, where `runtime diagnose` reads it; it spawns
|
|
52
|
+
* nothing, and Pi stays up.
|
|
53
|
+
*
|
|
46
54
|
* External skill prep note:
|
|
47
55
|
* Pi reads installed external skills from
|
|
48
56
|
* ~/.mirror-minds/<user>/runtime/skills/pi/extensions.json
|
|
@@ -52,7 +60,7 @@
|
|
|
52
60
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
53
61
|
import { accessSync, appendFileSync, closeSync, constants, existsSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
54
62
|
import { homedir } from "node:os";
|
|
55
|
-
import { delimiter, dirname, join } from "node:path";
|
|
63
|
+
import { delimiter, dirname, join, posix, win32 } from "node:path";
|
|
56
64
|
import { fileURLToPath } from "node:url";
|
|
57
65
|
import { spawn } from "node:child_process";
|
|
58
66
|
import { randomUUID } from "node:crypto";
|
|
@@ -180,6 +188,95 @@ function _mirrorOnPath(): string | null {
|
|
|
180
188
|
return null;
|
|
181
189
|
}
|
|
182
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Where a hook wrapper looks for Node after `MIRROR_NODE`, the PATH, and the
|
|
193
|
+
* Node beside the entry it runs (CR191). The same list as the core's
|
|
194
|
+
* `HOOK_NODE_CANDIDATES` (`ts/src/runtime/diagnose.ts`), which this file
|
|
195
|
+
* cannot import: it is a thin dispatcher and duplicates the contracts it
|
|
196
|
+
* needs, as it does the homes-root names above. `mirrorLoggerExtension.test.ts`
|
|
197
|
+
* fails if the two lists differ, as `hooks.test.ts` does for the wrappers.
|
|
198
|
+
*/
|
|
199
|
+
export const NODE_CANDIDATES: readonly string[] = [
|
|
200
|
+
"$HOME/.nvm/current/bin/node",
|
|
201
|
+
"/opt/homebrew/bin/node",
|
|
202
|
+
"/usr/local/bin/node",
|
|
203
|
+
];
|
|
204
|
+
|
|
205
|
+
/** What `resolveNode` found at a path it probed. */
|
|
206
|
+
export type PathKind = "executable" | "directory" | "not executable" | "missing";
|
|
207
|
+
|
|
208
|
+
/** An executable FILE: a directory passes `X_OK` on its own. */
|
|
209
|
+
function pathKind(path: string): PathKind {
|
|
210
|
+
try {
|
|
211
|
+
if (statSync(path).isDirectory()) return "directory";
|
|
212
|
+
} catch {
|
|
213
|
+
return "missing";
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
accessSync(path, constants.X_OK);
|
|
217
|
+
return "executable";
|
|
218
|
+
} catch {
|
|
219
|
+
return "not executable";
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export interface NodeResolution {
|
|
224
|
+
/** The Node to run the core on, by its path; null when none resolves. */
|
|
225
|
+
node: string | null;
|
|
226
|
+
/** A `MIRROR_NODE` the search would not use, and why: the caller says so once. */
|
|
227
|
+
rejected: { value: string; reason: string } | null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The Node the core runs on, in the hook wrappers' order (CR191): `MIRROR_NODE`
|
|
232
|
+
* when it is an absolute path to an executable file; `node` on the PATH; the
|
|
233
|
+
* Node beside the tree this file ships in, which for a global install is the
|
|
234
|
+
* `<prefix>/bin/node` that installed the package (and nothing for a checkout);
|
|
235
|
+
* then `NODE_CANDIDATES`. Not `process.execPath`: Pi may run on a Node older
|
|
236
|
+
* than the core needs. On Windows the PATH is the last place looked.
|
|
237
|
+
*
|
|
238
|
+
* A relative `MIRROR_NODE` is refused here, though the wrappers accept one: it
|
|
239
|
+
* would resolve against whatever project Pi was opened in.
|
|
240
|
+
*
|
|
241
|
+
* The absolute candidates make "nothing anywhere" a fact about the host, so
|
|
242
|
+
* under `MEMORY_ENV=test` only, `MIRROR_TEST_NODE_SEARCH_ROOT` re-roots them
|
|
243
|
+
* and the prefix candidate. A production session never reads it.
|
|
244
|
+
*/
|
|
245
|
+
export function resolveNode(
|
|
246
|
+
env: Record<string, string | undefined>,
|
|
247
|
+
treeRoot: string,
|
|
248
|
+
kindOf: (path: string) => PathKind = pathKind,
|
|
249
|
+
platform: NodeJS.Platform = process.platform,
|
|
250
|
+
): NodeResolution {
|
|
251
|
+
const paths = platform === "win32" ? win32 : posix;
|
|
252
|
+
let rejected: NodeResolution["rejected"] = null;
|
|
253
|
+
const explicit = (env.MIRROR_NODE ?? "").trim();
|
|
254
|
+
if (explicit !== "") {
|
|
255
|
+
if (!paths.isAbsolute(explicit)) rejected = { value: explicit, reason: "relative" };
|
|
256
|
+
else {
|
|
257
|
+
const kind = kindOf(explicit);
|
|
258
|
+
if (kind === "executable") return { node: explicit, rejected: null };
|
|
259
|
+
rejected = { value: explicit, reason: kind === "directory" ? "a directory" : kind };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const name = platform === "win32" ? "node.exe" : "node";
|
|
263
|
+
for (const dir of (env.PATH ?? "").split(paths.delimiter).filter(Boolean)) {
|
|
264
|
+
const candidate = paths.join(dir, name);
|
|
265
|
+
if (kindOf(candidate) === "executable") return { node: candidate, rejected };
|
|
266
|
+
}
|
|
267
|
+
if (platform === "win32") return { node: null, rejected };
|
|
268
|
+
const searchRoot = env.MEMORY_ENV === "test" ? (env.MIRROR_TEST_NODE_SEARCH_ROOT ?? "") : "";
|
|
269
|
+
const home = env.HOME ?? homedir();
|
|
270
|
+
const candidates = [
|
|
271
|
+
paths.resolve(treeRoot, "..", "..", "..", "bin", "node"),
|
|
272
|
+
...NODE_CANDIDATES.map((candidate) => candidate.replace("$HOME", home)),
|
|
273
|
+
].map((candidate) => (searchRoot ? paths.join(searchRoot, candidate) : candidate));
|
|
274
|
+
for (const candidate of candidates) {
|
|
275
|
+
if (kindOf(candidate) === "executable") return { node: candidate, rejected };
|
|
276
|
+
}
|
|
277
|
+
return { node: null, rejected };
|
|
278
|
+
}
|
|
279
|
+
|
|
183
280
|
/** True when `path` is the same file as this tree's AGENTS.md (symlinks resolved). */
|
|
184
281
|
function _isOurAgentsFile(path: string): boolean {
|
|
185
282
|
try {
|
|
@@ -277,6 +374,41 @@ export default function (pi: ExtensionAPI) {
|
|
|
277
374
|
*/
|
|
278
375
|
const FRONT_DOOR_ARGV = [MIRROR_BIN];
|
|
279
376
|
|
|
377
|
+
/**
|
|
378
|
+
* The Node every front-door process runs on (CR191), or null when none
|
|
379
|
+
* resolves: the caller spawns nothing and logs why. A `MIRROR_NODE` the search
|
|
380
|
+
* refused is said once per registration, with the reason, so the log is
|
|
381
|
+
* enough to fix it; the search went on without it, as the wrappers' does.
|
|
382
|
+
*/
|
|
383
|
+
let mirrorNodeWarned = false;
|
|
384
|
+
function nodeFor(): string | null {
|
|
385
|
+
const found = resolveNode(process.env, TREE_ROOT);
|
|
386
|
+
if (found.rejected && !mirrorNodeWarned) {
|
|
387
|
+
mirrorNodeWarned = true;
|
|
388
|
+
log("WARN", `MIRROR_NODE=${found.rejected.value} is ${found.rejected.reason}; searching the PATH and the usual places instead`);
|
|
389
|
+
}
|
|
390
|
+
return found.node;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** The line said once at session start when no Node resolves; also the log's and, in short, hooks.log's. */
|
|
394
|
+
const NO_NODE_NOTICE =
|
|
395
|
+
"Mirror: `node` is not on the PATH Pi inherited, nor in the usual places — set MIRROR_NODE=/path/to/node (Node 24 or later) where Pi is launched. Mirror will not log this session.";
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* One line in `<mirror home>/hooks.log`, in the format the wrappers write and
|
|
399
|
+
* `hookFailureFindings` parses (`<stamp> <runtime>:<hook>: <reason>`), so
|
|
400
|
+
* `runtime diagnose` reports it for seven days. Same directory as this log,
|
|
401
|
+
* mode 0600 as `noteHookFailure` writes it.
|
|
402
|
+
*/
|
|
403
|
+
function noteHookFailure(hook: string, reason: string): void {
|
|
404
|
+
try {
|
|
405
|
+
mkdirSync(MIRROR_DIR, { recursive: true });
|
|
406
|
+
appendFileSync(join(MIRROR_DIR, "hooks.log"), `${new Date().toISOString()} pi:${hook}: ${reason}\n`, { mode: 0o600 });
|
|
407
|
+
} catch {
|
|
408
|
+
// A hook cannot report that it could not report.
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
280
412
|
function log(level: string, msg: string): void {
|
|
281
413
|
try {
|
|
282
414
|
const ts = new Date().toISOString();
|
|
@@ -288,11 +420,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
288
420
|
}
|
|
289
421
|
|
|
290
422
|
function runMirrorBackground(args: string[], label: string): void {
|
|
423
|
+
const node = nodeFor();
|
|
424
|
+
if (node === null) {
|
|
425
|
+
log("ERROR", `${label} skipped: no node (see the session's notice)`);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
291
428
|
let logFd: number | undefined;
|
|
292
429
|
try {
|
|
293
430
|
mkdirSync(MIRROR_DIR, { recursive: true });
|
|
294
431
|
logFd = openSync(LOG_FILE, "a");
|
|
295
|
-
const child = spawn(
|
|
432
|
+
const child = spawn(node, [...FRONT_DOOR_ARGV, ...args], {
|
|
296
433
|
cwd: process.cwd(),
|
|
297
434
|
stdio: ["ignore", logFd, logFd],
|
|
298
435
|
// Windows: a detached console child materializes as a visible
|
|
@@ -303,8 +440,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
303
440
|
detached: process.platform !== "win32",
|
|
304
441
|
windowsHide: true,
|
|
305
442
|
});
|
|
443
|
+
// A child that cannot start reports through 'error', not by throwing,
|
|
444
|
+
// so the catch below never sees it; without a listener the event is an
|
|
445
|
+
// uncaught exception and Pi exits ("pi exiting due to uncaughtException:
|
|
446
|
+
// Error: spawn node ENOENT", PR #47). The path was an executable file a
|
|
447
|
+
// moment ago (`resolveNode`), so this is the record of one that vanished
|
|
448
|
+
// in between, or of any other start failure. A logging failure must never
|
|
449
|
+
// break anything: log it and move on.
|
|
450
|
+
child.on("error", (error) => {
|
|
451
|
+
log("ERROR", `${label} failed to start: ${error.message.slice(0, 500)}`);
|
|
452
|
+
});
|
|
453
|
+
// Its stdout and stderr are in this log already; the exit code says
|
|
454
|
+
// whether to read them (a Node too old for the core exits before it runs).
|
|
455
|
+
child.on("exit", (code, signal) => {
|
|
456
|
+
if (code !== 0) log("ERROR", `${label} exited ${code ?? `on ${signal}`}`);
|
|
457
|
+
});
|
|
306
458
|
child.unref();
|
|
307
|
-
|
|
459
|
+
// No pid, no process: the 'error' above is on its way.
|
|
460
|
+
if (child.pid !== undefined) log("INFO", `${label} started in detached background process ${child.pid}`);
|
|
308
461
|
} catch (err: unknown) {
|
|
309
462
|
const message = err instanceof Error ? err.message : String(err);
|
|
310
463
|
log("ERROR", `${label} failed: ${message.slice(0, 500)}`);
|
|
@@ -331,10 +484,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
331
484
|
}
|
|
332
485
|
|
|
333
486
|
async function runMirror(args: string[]): Promise<string> {
|
|
487
|
+
const node = nodeFor();
|
|
488
|
+
if (node === null) {
|
|
489
|
+
log("ERROR", `runMirror skipped [${args.slice(0, 2).join(" ")}]: no node (see the session's notice)`);
|
|
490
|
+
return "";
|
|
491
|
+
}
|
|
334
492
|
try {
|
|
335
493
|
const delay = testFrontDoorDelayMs();
|
|
336
494
|
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
|
|
337
|
-
const result = await pi.exec(
|
|
495
|
+
const result = await pi.exec(node, [...FRONT_DOOR_ARGV, ...args], {
|
|
338
496
|
timeout: 30_000,
|
|
339
497
|
});
|
|
340
498
|
const stderr = (result?.stderr ?? "").trim();
|
|
@@ -682,6 +840,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
682
840
|
log("WARN", line);
|
|
683
841
|
if (ctx.hasUI) ctx.ui.notify(line, "warning");
|
|
684
842
|
}
|
|
843
|
+
// Likewise when no Node resolves (CR191): once, visibly, naming the fix,
|
|
844
|
+
// and one line where `runtime diagnose` reads. The calls below then skip
|
|
845
|
+
// themselves; a later one that finds nothing logs only.
|
|
846
|
+
if (nodeFor() === null) {
|
|
847
|
+
log("WARN", NO_NODE_NOTICE);
|
|
848
|
+
if (ctx.hasUI) ctx.ui.notify(NO_NODE_NOTICE, "warning");
|
|
849
|
+
noteHookFailure("session-start", "node not found on PATH or in the usual places; Mirror is off for this session. Set MIRROR_NODE.");
|
|
850
|
+
}
|
|
685
851
|
const sessionId = ctx.sessionManager.getSessionFile() ?? null;
|
|
686
852
|
const openArgs = ["conversation-logger", "session-start", "--fast", "--welcome", "--format", "json"];
|
|
687
853
|
if (sessionId) openArgs.push("--session-id", sessionId);
|
|
@@ -59,9 +59,15 @@ Update identity content directly in the database.
|
|
|
59
59
|
|
|
60
60
|
```bash
|
|
61
61
|
mirror identity set <layer> <key> --content "..." [--mirror-home PATH]
|
|
62
|
+
mirror identity set <layer> <key> --file <path> [--mirror-home PATH]
|
|
62
63
|
```
|
|
63
64
|
|
|
64
|
-
If `--content` is
|
|
65
|
+
If neither `--content` nor `--file` is given, content is read from stdin.
|
|
66
|
+
|
|
67
|
+
For anything longer than a line, write the text to a file and pass `--file`:
|
|
68
|
+
Mirror reads it as bytes, so the shell never splits, substitutes, or re-encodes
|
|
69
|
+
it. On Windows PowerShell 5.1 it is the only safe route: the pipe is ASCII by
|
|
70
|
+
default and turns every accented letter into `?`. The pipe stays for one-liners.
|
|
65
71
|
|
|
66
72
|
A new `journey` or `persona` key must be kebab-case: lowercase letters, digits,
|
|
67
73
|
and single hyphens, up to 80 characters, such as `product-launch`. Agents put
|
|
@@ -70,7 +76,8 @@ one that would work. A key created before this rule keeps working.
|
|
|
70
76
|
|
|
71
77
|
**Examples:**
|
|
72
78
|
- `mirror identity set ego behavior --content "Be direct."`
|
|
73
|
-
- `
|
|
79
|
+
- `mirror identity set self soul --file new-soul.md`
|
|
80
|
+
- `echo "Be direct." | mirror identity set ego behavior` (a one-liner; not on PowerShell 5.1)
|
|
74
81
|
|
|
75
82
|
---
|
|
76
83
|
|
|
@@ -16,7 +16,9 @@ When receiving `/mm-journey update <slug> <content>`: answered by the TS core (C
|
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
18
|
mirror journey update <slug> "<content>"
|
|
19
|
-
#
|
|
19
|
+
# longer than a line: write it to a file, read as bytes, untouched by the shell
|
|
20
|
+
mirror journey update <slug> --file <path>
|
|
21
|
+
# a one-liner through stdin (not on Windows PowerShell 5.1, whose pipe is ASCII):
|
|
20
22
|
echo "<content>" | mirror journey update <slug> -
|
|
21
23
|
```
|
|
22
24
|
|
package/docs/releases/index.md
CHANGED
|
@@ -12,6 +12,8 @@ Future release notes should use the structure defined in [Release Notes](../proc
|
|
|
12
12
|
|
|
13
13
|
## Releases
|
|
14
14
|
|
|
15
|
+
- [v0.39.2 — Input, Read the Platform's Way](v0.39.2.md) — patch release of PR #49 and PR #48 (@rodrigoimmaginario), CR194, and CR193: `runtime diagnose` stops warning every Windows install about Node; a PowerShell byte-order mark is not stored; stdin is decoded by its mark, UTF-8 or UTF-16, and `identity set` and `journey update` gain `--file`, the route around PowerShell 5.1's ASCII pipe; `package-lock.json` names the package at its version, and the release doctor checks it. No migration.
|
|
16
|
+
- [v0.39.1 — Pi Finds Its Node, or Says So](v0.39.1.md) — patch release of PR #47 (@rodrigoimmaginario) and CR191: a Pi opened where `node` is not on the PATH no longer exits; the Pi extension resolves Node where the hook wrappers look, so a global install needs nothing configured; when no Node resolves, one warning names the fix and `runtime diagnose` reports the `hooks.log` line. No migration.
|
|
15
17
|
- [v0.39.0 — The Core as a Library](v0.39.0.md) — minor release of CV24's first eight Delivery Stories: `mirror-mind/desktop`, a library entry through which a host such as Mirror Desktop 0.3 embeds this core; calls for a home's migration state, Conversations, sidebar reads, home creation, migration on request, Mirror's context, learning from open Conversations, and Journey administration, each with an explicit path and no environment. The new `exports` field closes every other deep import. No migration.
|
|
16
18
|
- [v0.38.1 — The Refusal Names the Way Out](v0.38.1.md) — patch release of three Change Requests, CR185, CR186, and CR187: a package with no home configured names the homes it finds and the `mirror init <name>` that selects one, and `init` adopts a home that already exists; a missing or refused OpenRouter key is one line that names the file, before anything is touched; a `stable` clone ahead only by `main`'s commits is told that `stable` is behind it, not to push or reset. No migration.
|
|
17
19
|
- [v0.38.0 — The Model Behind the Memories, Chosen by Measurement](v0.38.0.md) — minor release closing CV9.DS9: the model behind memories, tasks, titles, summaries, tags, reception, journal, shadow, and consolidation is Claude Haiku 5.5 at reasoning off, chosen on the eval suite with reasoning on and off and on 24 of the owner's own conversations ranked blind, where it came first on 16 of 26 surfaces. Every extraction-pin call can carry a reasoning effort, and a pin carries its own default. With CR180, summaries open with their subject, every lifecycle prompt sees the owner's name, and a framed opening is written once more. Release gate 70 of 70 probes. Rollback is one env line. No migration.
|
package/docs/releases/v0.39.0.md
CHANGED
|
@@ -32,6 +32,8 @@ digest: >
|
|
|
32
32
|
- **Breaking for deep imports only.** The package now declares `exports` with `./desktop` and
|
|
33
33
|
`./package.json`. Any other `import "mirror-mind/…"` path that resolved before no longer does.
|
|
34
34
|
The `mirror` and `mirror-hook` bins are unaffected.
|
|
35
|
+
- **A Windows package install can update itself** (PR #46 by @rodrigoimmaginario, CR189;
|
|
36
|
+
*added on 2026-10-31*). An earlier version needs one manual install to get here; see Upgrading.
|
|
35
37
|
- No migration, and no model or prompt change, so the model-behavior release gate does not apply.
|
|
36
38
|
|
|
37
39
|
## Where We Started
|
|
@@ -87,6 +89,32 @@ Existing code changed only where the entry needed it: the loader, three exported
|
|
|
87
89
|
required), and `package.json`. No existing test changed. The npm smoke installs the package and
|
|
88
90
|
imports the entry from a host project for DS1 to DS5.
|
|
89
91
|
|
|
92
|
+
*Added on 2026-10-31:* this release also carries one change outside CV24, merged to `main` the
|
|
93
|
+
day before and missing from this note when it was published, so the paragraph above covers CV24
|
|
94
|
+
only. PR #46 (CR189, by @rodrigoimmaginario) runs the package update's `npm install -g` from the
|
|
95
|
+
parent of `node_modules` instead of from inside the package. npm renames the package's directory
|
|
96
|
+
aside before it lays down the new version, and Windows refuses to rename a directory a process is
|
|
97
|
+
running in, so on Windows `runtime update` failed at apply with `EBUSY` every time. Nothing
|
|
98
|
+
changes on macOS or Linux. See Upgrading.
|
|
99
|
+
|
|
100
|
+
## Upgrading
|
|
101
|
+
|
|
102
|
+
*Added on 2026-10-31.* Everyone updates as usual:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
mirror runtime update # or: npm install -g mirror-mind
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
On Windows, a package install of an earlier version cannot install this one itself: its
|
|
109
|
+
`runtime update` fails at apply with `EBUSY`. Once, by hand, from any directory other than the
|
|
110
|
+
package's own:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
npm install -g mirror-mind@latest
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
From then on `mirror runtime update` works.
|
|
117
|
+
|
|
90
118
|
## Conscious Exclusions
|
|
91
119
|
|
|
92
120
|
- **Renaming a Journey's slug.** The slug keys Conversations, memories and tasks; renaming it is a
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
---
|
|
2
|
+
digest: >
|
|
3
|
+
v0.39.1 is a patch release about one failure: a Pi opened where `node` is not on the
|
|
4
|
+
PATH, which a desktop launch or a window older than the Node install gives it. Through
|
|
5
|
+
v0.39.0 that Pi exited at startup with `spawn node ENOENT`. PR #47 (Rodrigo Immaginario)
|
|
6
|
+
keeps it up, and CR191 makes the Pi extension find Node where the hook wrappers already
|
|
7
|
+
look, so a machine with a global install needs nothing configured; when no Node resolves,
|
|
8
|
+
the session says so once, with the fix, and `runtime diagnose` can see it. No migration,
|
|
9
|
+
and no model or prompt change.
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# v0.39.1 — Pi Finds Its Node, or Says So
|
|
13
|
+
|
|
14
|
+
**Date:** 2026-11-02
|
|
15
|
+
|
|
16
|
+
## Highlights
|
|
17
|
+
|
|
18
|
+
- **A Pi opened without `node` on its PATH no longer exits**
|
|
19
|
+
([PR #47](https://github.com/mirror-mind-ai/mirror/pull/47), by @rodrigoimmaginario). The
|
|
20
|
+
extension's detached maintenance process had no `'error'` listener, so a `node` that
|
|
21
|
+
could not start was an uncaught exception and Pi went down with it. It is now logged.
|
|
22
|
+
- **The Pi extension resolves Node the way the hook wrappers do**
|
|
23
|
+
([CR191](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr191-the-pi-extension-cannot-find-node-where-the-hook-wrappers-can.md)):
|
|
24
|
+
`MIRROR_NODE`, the PATH, the Node that installed the package (`<prefix>/bin/node`), then
|
|
25
|
+
`~/.nvm/current/bin`, `/opt/homebrew/bin`, `/usr/local/bin`. On a machine with a global
|
|
26
|
+
install this needs no configuration: the session that used to crash opens as usual.
|
|
27
|
+
- **When no Node resolves, the failure reaches someone.** Pi stays up, shows one warning at
|
|
28
|
+
session start that names the fix (`MIRROR_NODE=/path/to/node`, Node 24 or later), and
|
|
29
|
+
writes one line to `<mirror home>/hooks.log`, where `mirror runtime diagnose` reports it
|
|
30
|
+
for seven days as `hook_failures_recorded`, as it does for the other runtimes' hooks.
|
|
31
|
+
- **A `MIRROR_NODE` the extension will not use is warned once, with the reason**: missing, a
|
|
32
|
+
directory, not executable, or relative. The search goes on without it.
|
|
33
|
+
- No migration, and no model or prompt change.
|
|
34
|
+
|
|
35
|
+
## Where We Started
|
|
36
|
+
|
|
37
|
+
On 2026-10-16 a Pi opened from a terminal window whose PATH predated the Node install
|
|
38
|
+
exited at once:
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
pi exiting due to uncaughtException:
|
|
42
|
+
Error: spawn node ENOENT
|
|
43
|
+
spawnargs: [ '…/node_modules/mirror-mind/bin/mirror.js', 'conversation-logger', 'session-maintenance' ]
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The same happens wherever `node` is not on the PATH Pi inherited, and `runtime diagnose`
|
|
47
|
+
had long named that the usual case for a runtime launched from the desktop: `node` lives
|
|
48
|
+
under nvm or Homebrew, not where a GUI looks. The hook wrappers the other runtimes use
|
|
49
|
+
were built for exactly this. They search the usual places and record a failure in
|
|
50
|
+
`hooks.log`. The Pi extension, which runs no wrapper, knew only the bare name `node`.
|
|
51
|
+
|
|
52
|
+
Rodrigo Immaginario's PR #47 fixed the crash: the listener, and `MIRROR_NODE` honored by
|
|
53
|
+
the extension as the wrappers honor it. Reviewed through four lenses on 2026-11-01, it was
|
|
54
|
+
merged as it was. The review also showed what it left: without `MIRROR_NODE`, that Pi now
|
|
55
|
+
ran with Mirror silently absent, no welcome card, no logging, no context, and a line in a
|
|
56
|
+
file no one is pointed at. That is the failure the wrappers' silence-is-forbidden rule was
|
|
57
|
+
written against, and CR191 is its answer for Pi.
|
|
58
|
+
|
|
59
|
+
## What Changed
|
|
60
|
+
|
|
61
|
+
**One resolver, the wrappers' order (CR191).** The extension resolves Node itself, in the
|
|
62
|
+
same order the wrappers use, taking only an executable file: `MIRROR_NODE`, then `node` on
|
|
63
|
+
each PATH entry, then the `bin/` of the prefix the package is installed under, three
|
|
64
|
+
directories up from the tree the extension ships in, which is the Node that installed the
|
|
65
|
+
package, then nvm, Homebrew, `/usr/local`. The candidate list is the core's
|
|
66
|
+
`HOOK_NODE_CANDIDATES`, duplicated in the extension because it imports no core module, and a
|
|
67
|
+
test fails if the two ever differ, as one already did for the wrappers. On Windows the
|
|
68
|
+
search stops after the PATH, as before.
|
|
69
|
+
|
|
70
|
+
**Nothing found: no spawn, one notice, one line.** When no Node resolves, no front-door
|
|
71
|
+
process is attempted: each call logs that it was skipped. At session start, the extension
|
|
72
|
+
says so once, the way it already says `mirror` is not on the PATH:
|
|
73
|
+
|
|
74
|
+
```text
|
|
75
|
+
Mirror: `node` is not on the PATH Pi inherited, nor in the usual places — set MIRROR_NODE=/path/to/node (Node 24 or later) where Pi is launched. Mirror will not log this session.
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
and writes one line to `hooks.log` in the wrappers' format, `pi:session-start: node not
|
|
79
|
+
found on PATH or in the usual places; …`, which `runtime diagnose` counts.
|
|
80
|
+
|
|
81
|
+
**The log tells one story.** A child that did not start is not logged as started; one that
|
|
82
|
+
exits non-zero leaves its code beside its own output. PR #47's listener stays as the record
|
|
83
|
+
of a file that vanished between the check and the spawn.
|
|
84
|
+
|
|
85
|
+
Validated against Pi 1.5.0 with `PATH=/usr/bin:/bin`, the incident's configuration: the
|
|
86
|
+
session opened through `/opt/homebrew/bin/node` with nothing configured; a bad
|
|
87
|
+
`MIRROR_NODE` was warned once; with no Node anywhere, Pi stayed up, the warning was on
|
|
88
|
+
screen, and `runtime diagnose` reported the `hooks.log` line.
|
|
89
|
+
|
|
90
|
+
## Conscious Exclusions
|
|
91
|
+
|
|
92
|
+
- The hook wrappers and the MCP locator still accept a relative `MIRROR_NODE`; the Pi
|
|
93
|
+
extension refuses one, since it would resolve against whatever project Pi was opened in.
|
|
94
|
+
Whether the family follows is a separate decision.
|
|
95
|
+
- A "Node too old" message with the version, as the wrappers print: the exit code and the
|
|
96
|
+
loader's own output are in the log.
|
|
97
|
+
- Windows candidates beyond `MIRROR_NODE` and the PATH.
|
|
98
|
+
|
|
99
|
+
## Upgrading
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
mirror runtime update
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
A Pi session running the previous extension keeps it until Pi restarts.
|
|
106
|
+
|
|
107
|
+
## Next Horizon
|
|
108
|
+
|
|
109
|
+
CR190, captured this week, and the open RS010 backlog. CV24 continues on its branch.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
---
|
|
2
|
+
digest: >
|
|
3
|
+
v0.39.2 is a patch release about input read the platform's way, most of it on Windows.
|
|
4
|
+
`runtime diagnose` stops warning every Windows install that Node is unresolvable, and a
|
|
5
|
+
PowerShell byte-order mark is no longer stored as identity content (PR #49, Rodrigo
|
|
6
|
+
Immaginario). From that review, CR194: text on stdin is decoded by its mark, UTF-8 or
|
|
7
|
+
UTF-16, and `identity set` and `journey update` gain `--file`, the route for any text
|
|
8
|
+
longer than a line and the only safe one on Windows PowerShell 5.1, whose pipe is ASCII.
|
|
9
|
+
`package-lock.json` names the package at its version after nine releases that left it
|
|
10
|
+
behind (PR #48), and the release doctor now checks it (CR193). No migration, and no model
|
|
11
|
+
or prompt change.
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# v0.39.2 — Input, Read the Platform's Way
|
|
15
|
+
|
|
16
|
+
**Date:** 2026-11-03
|
|
17
|
+
|
|
18
|
+
## Highlights
|
|
19
|
+
|
|
20
|
+
- **`runtime diagnose` no longer warns every Windows install that Node is unresolvable**
|
|
21
|
+
([PR #49](https://github.com/mirror-mind-ai/mirror/pull/49), by @rodrigoimmaginario).
|
|
22
|
+
It split the `PATH` on `:` and looked for `node`; on Windows that is `;` and `node.exe`,
|
|
23
|
+
so nothing ever matched. It now looks the platform's way, and the test proves the Windows
|
|
24
|
+
branch on every host.
|
|
25
|
+
- **A PowerShell byte-order mark is not content** (PR #49). `identity set` and
|
|
26
|
+
`journey update -` drop the one at the head of stdin instead of storing it as the first
|
|
27
|
+
character of an entry.
|
|
28
|
+
- **Text on stdin is decoded by its mark, and `--file` reads a file as bytes**
|
|
29
|
+
([CR194](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr194-powershell-stdin-reaches-identity-set-as-utf-16-or-ascii.md)).
|
|
30
|
+
UTF-16, as Windows PowerShell 5.1's `>` writes it, is read as UTF-16. The pipe's ASCII
|
|
31
|
+
default, which turns every accented letter into `?`, cannot be detected, so
|
|
32
|
+
`identity set <layer> <key> --file <path>` and `journey update <slug> --file <path>` read
|
|
33
|
+
the file themselves: the route for anything longer than a line on every platform, and
|
|
34
|
+
the only safe one on PowerShell 5.1. The troubleshooting page names the three PowerShell
|
|
35
|
+
behaviors and the way around each.
|
|
36
|
+
- **`package-lock.json` names `mirror-mind` at the package's version**
|
|
37
|
+
([PR #48](https://github.com/mirror-mind-ai/mirror/pull/48), by @rodrigoimmaginario),
|
|
38
|
+
with a test that keeps it so. Its root had said `mirror-core 0.0.0` through nine releases,
|
|
39
|
+
and the first `npm install` on any clone left the tree dirty.
|
|
40
|
+
- **The release doctor fails on a lock left behind**
|
|
41
|
+
([CR193](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr193-the-release-doctor-does-not-check-the-lockfile.md)),
|
|
42
|
+
before anything is tagged, naming the command that levels it. This is the first release it
|
|
43
|
+
checked.
|
|
44
|
+
- No migration, and no model or prompt change.
|
|
45
|
+
|
|
46
|
+
## Where We Started
|
|
47
|
+
|
|
48
|
+
Rodrigo Immaginario's PRs #48 and #49 had waited since 2026-10-19 and 2026-10-21. One
|
|
49
|
+
described a lockfile that nine releases in a row had left behind, each rebase of the PR
|
|
50
|
+
proving it again, the last of them v0.39.1 the afternoon before it merged. The other
|
|
51
|
+
described two inputs misread on Windows: a `PATH` split as if it were POSIX, so `runtime
|
|
52
|
+
diagnose` prescribed `MIRROR_NODE` to every healthy Windows install, and a byte-order mark
|
|
53
|
+
stored as the first character of an identity entry, repaired by hand on 2026-10-17.
|
|
54
|
+
|
|
55
|
+
Both were merged as written, each with one maintainer commit: the lock brought to the
|
|
56
|
+
current version so the PR's own test was green at merge, and `hookNodeFindings` given the
|
|
57
|
+
platform as a parameter so the branch the PR exists for is proved on macOS and Ubuntu,
|
|
58
|
+
where CI runs the suite, and not only on a Windows machine.
|
|
59
|
+
|
|
60
|
+
The review of PR #49 found the two hazards the BOM fix sat beside. In Windows PowerShell
|
|
61
|
+
5.1, `>` writes UTF-16LE, and the pipe to a native command goes through `$OutputEncoding`,
|
|
62
|
+
whose default is ASCII: Portuguese text arrives with every accent as `?`, and Mirror stored
|
|
63
|
+
what arrived. The second is not detectable — a `?` is a character — which is what shaped
|
|
64
|
+
CR194.
|
|
65
|
+
|
|
66
|
+
## What Changed
|
|
67
|
+
|
|
68
|
+
**One decoder for human text** (`ts/src/frontDoor/textInput.ts`). A UTF-8 or UTF-16 mark
|
|
69
|
+
at the head of the bytes chooses the decoding and is dropped; nothing else about the text
|
|
70
|
+
is touched. UTF-16 without a mark is not guessed at. `identity set` and `journey update -`
|
|
71
|
+
read stdin through it.
|
|
72
|
+
|
|
73
|
+
**`--file`, the route around the pipe.** Mirror reads the file as bytes, so the shell never
|
|
74
|
+
splits, substitutes, or re-encodes the text. `--file` beside `--content`, or beside `-`, is
|
|
75
|
+
refused in one line; a path that cannot be read is refused naming the path and nothing from
|
|
76
|
+
the error; nothing is written in either case. The skills present `--file` for anything
|
|
77
|
+
longer than a line, and keep the pipe for one-liners.
|
|
78
|
+
|
|
79
|
+
**`runtime diagnose` on Windows** looks for `node.exe` on a `;`-separated `PATH`, and only
|
|
80
|
+
`node.exe`: on Windows the executable check is an existence check, and an extensionless
|
|
81
|
+
`node` is nothing the system would run.
|
|
82
|
+
|
|
83
|
+
**The lockfile.** Regenerated once with `npm install --package-lock-only --ignore-scripts`;
|
|
84
|
+
`packageLock.test.ts` fails CI when its root and `package.json` disagree; the release doctor
|
|
85
|
+
makes the same four comparisons before the tag exists, and the versioning page names the
|
|
86
|
+
lock among what a release commit changes.
|
|
87
|
+
|
|
88
|
+
Validated on 2026-11-03 against a scratch copy of the maintainer's database: UTF-8, UTF-8
|
|
89
|
+
with a mark, and UTF-16LE as PowerShell 5.1 writes it, through `--file` and through stdin;
|
|
90
|
+
the refusals writing nothing; the one-liner pipe unchanged.
|
|
91
|
+
|
|
92
|
+
## Conscious Exclusions
|
|
93
|
+
|
|
94
|
+
- Windows PowerShell 5.1 itself was not run: there is no Windows machine on the maintainer's
|
|
95
|
+
side. The decoder's cases are pinned byte for byte; a measurement on the OS is asked of
|
|
96
|
+
the reporter.
|
|
97
|
+
- Detecting text the ASCII pipe damaged: not possible without guessing.
|
|
98
|
+
- The hook wrappers and the MCP locator still accept a relative `MIRROR_NODE`
|
|
99
|
+
([CR192](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr192-the-hook-wrappers-and-the-mcp-locator-accept-a-relative-mirror-node.md),
|
|
100
|
+
captured).
|
|
101
|
+
- The JSON stdin readers (`conversations append`, the hooks' payloads) are unchanged: their
|
|
102
|
+
input comes from runtimes, which write UTF-8 without a mark.
|
|
103
|
+
|
|
104
|
+
## Upgrading
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
mirror runtime update
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Next Horizon
|
|
111
|
+
|
|
112
|
+
Rodrigo Immaginario's PRs #50, #53, and #54, all Windows and packaging; CR190 and CR192 in
|
|
113
|
+
RS010's backlog. CV24 continues on its branch.
|
package/package.json
CHANGED
|
@@ -59,9 +59,15 @@ Update identity content directly in the database.
|
|
|
59
59
|
|
|
60
60
|
```bash
|
|
61
61
|
mirror identity set <layer> <key> --content "..." [--mirror-home PATH]
|
|
62
|
+
mirror identity set <layer> <key> --file <path> [--mirror-home PATH]
|
|
62
63
|
```
|
|
63
64
|
|
|
64
|
-
If `--content` is
|
|
65
|
+
If neither `--content` nor `--file` is given, content is read from stdin.
|
|
66
|
+
|
|
67
|
+
For anything longer than a line, write the text to a file and pass `--file`:
|
|
68
|
+
Mirror reads it as bytes, so the shell never splits, substitutes, or re-encodes
|
|
69
|
+
it. On Windows PowerShell 5.1 it is the only safe route: the pipe is ASCII by
|
|
70
|
+
default and turns every accented letter into `?`. The pipe stays for one-liners.
|
|
65
71
|
|
|
66
72
|
A new `journey` or `persona` key must be kebab-case: lowercase letters, digits,
|
|
67
73
|
and single hyphens, up to 80 characters, such as `product-launch`. Agents put
|
|
@@ -70,7 +76,8 @@ one that would work. A key created before this rule keeps working.
|
|
|
70
76
|
|
|
71
77
|
**Examples:**
|
|
72
78
|
- `mirror identity set ego behavior --content "Be direct."`
|
|
73
|
-
- `
|
|
79
|
+
- `mirror identity set self soul --file new-soul.md`
|
|
80
|
+
- `echo "Be direct." | mirror identity set ego behavior` (a one-liner; not on PowerShell 5.1)
|
|
74
81
|
|
|
75
82
|
---
|
|
76
83
|
|
|
@@ -30,7 +30,15 @@ If the journey path appears outdated relative to recent conversations and memori
|
|
|
30
30
|
mirror journey update JOURNEY "UPDATED_CONTENT"
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
For
|
|
33
|
+
For anything longer than a line, write it to a file and pass `--file`: Mirror
|
|
34
|
+
reads it as bytes, untouched by the shell (on Windows PowerShell 5.1 the pipe
|
|
35
|
+
is ASCII by default, and this is the only safe route):
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
mirror journey update JOURNEY --file PATH
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
A one-liner can go through stdin:
|
|
34
42
|
|
|
35
43
|
```bash
|
|
36
44
|
echo "CONTENT" | mirror journey update JOURNEY -
|
package/ts/src/frontDoor/cli.ts
CHANGED
|
@@ -80,6 +80,7 @@ import { configFilePath, loadConfiguration, silenceExperimentalWarnings } from "
|
|
|
80
80
|
import { runSeed } from "#seed/seed.ts";
|
|
81
81
|
import { getTasksForWeek, listTasks } from "#tasks/taskStore.ts";
|
|
82
82
|
import { computeWeekRange } from "#tasks/weekView.ts";
|
|
83
|
+
import { expandHome } from "#util/paths.ts";
|
|
83
84
|
import { PROGRAM } from "#util/program.ts";
|
|
84
85
|
import { newId, nowIso } from "#util/pyGenerators.ts";
|
|
85
86
|
import { hasOption, optionValue, stripOptionWithValue } from "./args.ts";
|
|
@@ -187,6 +188,7 @@ import {
|
|
|
187
188
|
applyTasksStatusChange,
|
|
188
189
|
type TaskStatusTarget,
|
|
189
190
|
} from "./tasksWriteRoute.ts";
|
|
191
|
+
import { readStdinText, readTextFile } from "./textInput.ts";
|
|
190
192
|
import { answerUsage } from "./usage.ts";
|
|
191
193
|
|
|
192
194
|
/**
|
|
@@ -522,12 +524,19 @@ function runShadowRead(db: Database, args: readonly string[]): number {
|
|
|
522
524
|
return 0;
|
|
523
525
|
}
|
|
524
526
|
|
|
527
|
+
/** Stdin as human text, decoded by its mark (PR #49, CR194: `textInput.ts`). */
|
|
525
528
|
function readStdinContent(): string {
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
529
|
+
return readStdinText();
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* CR194: `--file <path>`, the text read by Mirror as bytes, so the shell's pipe
|
|
534
|
+
* never touches it (PowerShell 5.1's pipe is ASCII by default, and its `>` is
|
|
535
|
+
* UTF-16LE). `~` expands as it does for `--mirror-home`; a relative path is
|
|
536
|
+
* the cwd's. The refusal names the path and nothing from the error.
|
|
537
|
+
*/
|
|
538
|
+
function readFileContent(path: string): { text: string } | { error: string } {
|
|
539
|
+
return readTextFile(expandHome(path));
|
|
531
540
|
}
|
|
532
541
|
|
|
533
542
|
/** `conversations append` (CV22.DS7.US10 slice B′). */
|
|
@@ -840,7 +849,10 @@ function refusedNewIdentityKey(args: readonly string[], layer: string, key: stri
|
|
|
840
849
|
function runIdentityWrite(argv: readonly string[]): number {
|
|
841
850
|
const args = argv.slice(2);
|
|
842
851
|
const positionals = stripOptionWithValue(
|
|
843
|
-
stripOptionWithValue(
|
|
852
|
+
stripOptionWithValue(
|
|
853
|
+
stripOptionWithValue(stripOptionWithValue(args, "--content"), "--file"),
|
|
854
|
+
"--db-path",
|
|
855
|
+
),
|
|
844
856
|
"--mirror-home",
|
|
845
857
|
);
|
|
846
858
|
const layer = positionals[0];
|
|
@@ -854,7 +866,23 @@ function runIdentityWrite(argv: readonly string[]): number {
|
|
|
854
866
|
console.error(`Error: ${refused}`);
|
|
855
867
|
return 1;
|
|
856
868
|
}
|
|
857
|
-
const
|
|
869
|
+
const inlineContent = optionValue(args, "--content");
|
|
870
|
+
const filePath = optionValue(args, "--file");
|
|
871
|
+
if (inlineContent !== null && filePath !== null) {
|
|
872
|
+
console.error("Error: --file and --content name two sources for one content; pass one.");
|
|
873
|
+
return 1;
|
|
874
|
+
}
|
|
875
|
+
let content: string;
|
|
876
|
+
if (filePath !== null) {
|
|
877
|
+
const read = readFileContent(filePath);
|
|
878
|
+
if ("error" in read) {
|
|
879
|
+
console.error(`Error: ${read.error}`);
|
|
880
|
+
return 1;
|
|
881
|
+
}
|
|
882
|
+
content = read.text;
|
|
883
|
+
} else {
|
|
884
|
+
content = inlineContent ?? readStdinContent();
|
|
885
|
+
}
|
|
858
886
|
if (!content.trim()) {
|
|
859
887
|
console.error("Error: content is empty.");
|
|
860
888
|
return 1;
|
|
@@ -1007,22 +1035,36 @@ function runJourneyWrite(argv: readonly string[]): number {
|
|
|
1007
1035
|
*/
|
|
1008
1036
|
const JOURNEY_UPDATE_USAGE =
|
|
1009
1037
|
`Usage: ${PROGRAM} journey update <slug> <content>\n` +
|
|
1010
|
-
" Pass '-' as <content> to read it from stdin.";
|
|
1038
|
+
" Pass '-' as <content> to read it from stdin, or --file <path> to read a file.";
|
|
1011
1039
|
const FLAG_SHAPED_CONTENT = /^--?[A-Za-z]/;
|
|
1012
1040
|
|
|
1013
1041
|
function runJourneyUpdateWrite(argv: readonly string[]): number {
|
|
1014
1042
|
const args = argv.slice(2);
|
|
1015
1043
|
const positionals = stripOptionWithValue(
|
|
1016
|
-
stripOptionWithValue(args, "--db-path"),
|
|
1044
|
+
stripOptionWithValue(stripOptionWithValue(args, "--file"), "--db-path"),
|
|
1017
1045
|
"--mirror-home",
|
|
1018
1046
|
);
|
|
1019
1047
|
const slug = positionals[0];
|
|
1020
1048
|
let content = positionals[1];
|
|
1021
|
-
|
|
1049
|
+
const filePath = optionValue(args, "--file");
|
|
1050
|
+
if (!slug || (content === undefined && filePath === null)) {
|
|
1022
1051
|
console.error(JOURNEY_UPDATE_USAGE);
|
|
1023
1052
|
return 1;
|
|
1024
1053
|
}
|
|
1025
|
-
if (
|
|
1054
|
+
if (filePath !== null) {
|
|
1055
|
+
if (content !== undefined) {
|
|
1056
|
+
console.error(
|
|
1057
|
+
`Error: --file and ${content === "-" ? "'-'" : "<content>"} name two sources for one content; pass one.`,
|
|
1058
|
+
);
|
|
1059
|
+
return 1;
|
|
1060
|
+
}
|
|
1061
|
+
const read = readFileContent(filePath);
|
|
1062
|
+
if ("error" in read) {
|
|
1063
|
+
console.error(`Error: ${read.error}`);
|
|
1064
|
+
return 1;
|
|
1065
|
+
}
|
|
1066
|
+
content = read.text;
|
|
1067
|
+
} else if (content === "-") {
|
|
1026
1068
|
content = readStdinContent();
|
|
1027
1069
|
} else if (FLAG_SHAPED_CONTENT.test(content)) {
|
|
1028
1070
|
console.error(
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Human text that reaches the front door as bytes: stdin behind `identity set`
|
|
2
|
+
// and `journey update -`, and the file behind `--file` (CR194).
|
|
3
|
+
//
|
|
4
|
+
// The bytes carry no encoding but what their first bytes say. PowerShell 5.1
|
|
5
|
+
// writes a UTF-8 mark at the head of what it pipes (PR #49) and UTF-16LE from
|
|
6
|
+
// `>`; an editor writes UTF-8 with or without a mark. The mark is an encoding's
|
|
7
|
+
// signature, not content: it chooses the decoding and is dropped. UTF-16
|
|
8
|
+
// without a mark is not detectable and is not attempted: a guess about bytes
|
|
9
|
+
// would be wrong about some file, silently.
|
|
10
|
+
//
|
|
11
|
+
// What the ASCII pipe does to a text (`$OutputEncoding` in PowerShell 5.1
|
|
12
|
+
// turns every accented letter into `?`) is not detectable either: a `?` is a
|
|
13
|
+
// character. `--file` is the route that never meets the pipe.
|
|
14
|
+
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
|
|
17
|
+
/** The text the bytes carry, by their mark; the mark itself is never returned. */
|
|
18
|
+
export function decodeTextInput(bytes: Buffer): string {
|
|
19
|
+
if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) {
|
|
20
|
+
return bytes.subarray(2).toString("utf16le");
|
|
21
|
+
}
|
|
22
|
+
if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) {
|
|
23
|
+
const payload = Buffer.from(bytes.subarray(2));
|
|
24
|
+
// An odd trailing byte is not a code unit; `swap16` refuses it.
|
|
25
|
+
return (payload.length % 2 === 0 ? payload : payload.subarray(0, payload.length - 1))
|
|
26
|
+
.swap16()
|
|
27
|
+
.toString("utf16le");
|
|
28
|
+
}
|
|
29
|
+
if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
|
|
30
|
+
return bytes.subarray(3).toString("utf8");
|
|
31
|
+
}
|
|
32
|
+
return bytes.toString("utf8");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Stdin to its end, decoded; "" when there is none. */
|
|
36
|
+
export function readStdinText(): string {
|
|
37
|
+
try {
|
|
38
|
+
return decodeTextInput(readFileSync(0));
|
|
39
|
+
} catch {
|
|
40
|
+
return "";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The file behind `--file`, decoded; or the one-line refusal. The line names
|
|
46
|
+
* the path and nothing from the error: a directory, a missing file, and a
|
|
47
|
+
* permission refused all read the same to the person, and `errno` is noise.
|
|
48
|
+
*/
|
|
49
|
+
export function readTextFile(path: string): { text: string } | { error: string } {
|
|
50
|
+
try {
|
|
51
|
+
return { text: decodeTextInput(readFileSync(path)) };
|
|
52
|
+
} catch {
|
|
53
|
+
return { error: `cannot read ${path}` };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
// inconclusive without a key; the live fetch belongs to DS8.
|
|
26
26
|
|
|
27
27
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
28
|
-
import { basename, dirname, join } from "node:path";
|
|
28
|
+
import { basename, dirname, join, posix, win32 } from "node:path";
|
|
29
29
|
import { openDatabaseReadOnly } from "#db/database.ts";
|
|
30
30
|
import {
|
|
31
31
|
type ExtensionPluginReport,
|
|
@@ -913,16 +913,24 @@ export function hookFailureFindings(
|
|
|
913
913
|
export function hookNodeFindings(
|
|
914
914
|
env: NodeJS.ProcessEnv,
|
|
915
915
|
isExecutable: (path: string) => boolean,
|
|
916
|
+
platform: NodeJS.Platform = process.platform,
|
|
916
917
|
): DriftFinding[] {
|
|
917
918
|
const explicit = env.MIRROR_NODE ?? "";
|
|
918
919
|
if (explicit && isExecutable(explicit)) return [];
|
|
919
920
|
const candidates = HOOK_NODE_CANDIDATES.map((candidate) =>
|
|
920
921
|
candidate.replace("$HOME", env.HOME ?? ""),
|
|
921
922
|
);
|
|
923
|
+
// The platform's PATH separator and its executable name: `;` and `node.exe`
|
|
924
|
+
// on Windows, where splitting on `:` cut `C:\Program Files\nodejs` into `C`
|
|
925
|
+
// and a path and the finding fired on every install that had Node (PR #49).
|
|
926
|
+
// `platform` is a parameter so the test runs both branches on every host:
|
|
927
|
+
// CI's Windows job runs two files and this one is not among them.
|
|
928
|
+
const paths = platform === "win32" ? win32 : posix;
|
|
929
|
+
const name = platform === "win32" ? "node.exe" : "node";
|
|
922
930
|
const onPath = (env.PATH ?? "")
|
|
923
|
-
.split(
|
|
931
|
+
.split(paths.delimiter)
|
|
924
932
|
.filter(Boolean)
|
|
925
|
-
.some((entry) => isExecutable(
|
|
933
|
+
.some((entry) => isExecutable(paths.join(entry, name)));
|
|
926
934
|
if (onPath || candidates.some((candidate) => isExecutable(candidate))) return [];
|
|
927
935
|
return [
|
|
928
936
|
{
|