mirror-mind 0.33.2 → 0.33.4

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.
@@ -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.33.4 — A Name Is a Key](v0.33.4.md) — patch release of three first-run corrections: two from a contributor's PR #43 and CR141, which its review opened. `init --help` created a home named `--help`, `init ../escaped` created one outside `.mirror-minds`, and `init "My User"` put a space into `MIRROR_USER`; a user name is now a kebab-case key like a journey slug or a persona id, refused otherwise with a name that would work and nothing created. `identity edit` on Windows opens Notepad when no editor is named. Existing homes untouched. No migration.
16
+ - [v0.33.3 — Mirror Finds Itself Under Any Path](v0.33.3.md) — patch release of one Change Request, CR140, carried by a contributor's PR #42: Mirror read its own install path from `URL.pathname`, so a checkout or package under a path with a space or a non-ASCII character read as `Install: unknown`, the welcome card showed no update, and `runtime update` refused. The path is now a filesystem path, a guard keeps the idiom out, and an `unknown` install names the path it looked up from. On Windows, npm's global layout is recognized and npm runs with no shell, verified on Windows in CI for the two files they touch; there is still no Windows product. Affected installs update once by hand. No migration.
15
17
  - [v0.33.2 — Git Runs Where Mirror Lives](v0.33.2.md) — patch release of one Change Request, CR138: on a clone install, `runtime status`, `version`, and `diagnose` describe the install from any directory, and `diagnose` grades Mirror's own tree. Git now runs only in the install's own checkout, so a checkout someone else made can no longer run a `core.fsmonitor` command through these commands; `version --start PATH` is the one explicit exception. Package installs were never exposed. No migration.
16
18
  - [v0.33.1 — The Install Answers For Itself](v0.33.1.md) — patch release of six RS010 Change Requests: the welcome card, the status line, and both `release-notes` readers answer for the install that is running, never for the cwd or the repository around the install. A package asks its own tree and the npm registry, a clone its own checkout and remote; `pending` says `unknown` when it cannot tell, and a `--ref` value can no longer run a command through git. No migration.
17
19
  - [v0.33.0 — One Plugin, Three Runtimes](v0.33.0.md) — minor release closing CV21.DS2: the one plugin works as it is on Claude Code, Codex, and Grok. Its MCP server starts on all three, the explicit Mirror form is recognized in every runtime's spelling, and each session is logged under its runtime with every reply beside its prompt. Also: a faster Pi session start, and notice that Gemini CLI is retired in favor of Antigravity.
@@ -0,0 +1,158 @@
1
+ ---
2
+ digest: >
3
+ v0.33.3 is a maintenance release of one Change Request, CR140, carried by a
4
+ contributor's pull request. Mirror read its own install path from
5
+ `URL.pathname`, which spells a space as `%20`, `Código` as `C%C3%B3digo`, and
6
+ `C:\` as `/C:/`. Under such a path a checkout or a package read as `Install:
7
+ unknown`, the welcome card showed no update, and `runtime update` refused for
8
+ a reason that was not true. The path is now a filesystem path, a guard keeps
9
+ the idiom out of the code, and an `unknown` install names the path it looked
10
+ up from. On Windows, npm's global layout is recognized and npm runs with no
11
+ shell, verified on Windows in CI for the two files they touch; there is still
12
+ no Windows product. People under such a path update once by hand; everyone
13
+ else as usual. No migration.
14
+ ---
15
+
16
+ # v0.33.3 — Mirror Finds Itself Under Any Path
17
+
18
+ **Date:** 2026-10-16
19
+
20
+ ## Highlights
21
+
22
+ - **Mirror recognizes its own install under any path** (CR140, PR #42 by
23
+ @rodrigoimmaginario, with the macOS reproduction by @lgarciasbr). A checkout or a
24
+ package under `My Projects`, `Código`, or `C:\Users\John Doe` read as `Install:
25
+ unknown`; it reads as what it is.
26
+ - **An `unknown` install names the path it looked up from.** `Install: unknown (<reason>;
27
+ looked up from <path>)`, with the path spelled as the detector saw it, so a wrong
28
+ spelling is wrong in the report instead of hidden behind `not a git checkout`.
29
+ - **The idiom cannot come back.** A retired-surface row, `url-pathname-as-path`, sweeps
30
+ `ts/src/` and `ts/scripts/` for a URL's pathname read as a filesystem path. Widening it
31
+ to the scripts caught a second instance on the first run.
32
+ - **On Windows, the install answers too.** npm's Windows global layout
33
+ (`<prefix>\node_modules\<name>` and a `<prefix>\<bin>.cmd` shim) is recognized, and npm
34
+ runs with no shell, as `node.exe` running `npm-cli.js`. Both run on Windows in CI, for
35
+ the two test files they touch. There is still no Windows product.
36
+ - **People this release is for cannot take it the usual way.** Their `runtime update`
37
+ refuses them; see Upgrading.
38
+
39
+ ## Where We Started
40
+
41
+ Every route that asks "how is this Mirror installed" hands the detector the front door's
42
+ own path, and two of them computed it as `new URL("…/cli.ts", import.meta.url).pathname`.
43
+ A URL's pathname is not a filesystem path: it percent-encodes every reserved or non-ASCII
44
+ character, and on Windows it spells a drive as `/C:/`. None of those paths exist, so no
45
+ `.git` was found above the front door, no manifest either, and the install was `unknown`
46
+ with a reason that was false for a checkout whose branch the same command named a few
47
+ lines down.
48
+
49
+ Three surfaces failed together. `runtime status` printed `Install: unknown`, and since
50
+ `v0.33.2` the repository rows went blank with it. The welcome card skips the remote update
51
+ check for an `unknown` install, so nobody under such a path was told an update existed.
52
+ And `runtime update` refused at its status gate, so the updater could not ship its own
53
+ fix to the people it affected.
54
+
55
+ It was reported twice within a day, independently: on Windows 11, where it first looked
56
+ Windows-specific, and on macOS under `~/Documents/Código/mirror` at `0.32.1` and `0.33.2`.
57
+ Review found the common case neither report named: a space. `/tmp/space only` read as
58
+ `unknown` the same way, and on Windows that is most home directories.
59
+
60
+ ## What Changed
61
+
62
+ **The path never enters URL form.** One `thisFrontDoorPath()` in
63
+ `ts/src/runtime/installKind.ts` joins from `import.meta.dirname`, and every route hands
64
+ the detector that. The first version of the fix converted out of the URL with
65
+ `fileURLToPath`; the review asked for the form in which the class of bug is not possible,
66
+ which is the idiom `ts/src/runtime/treeRoot.ts` already used.
67
+
68
+ **A guard, not a memory.** The retired-surface table gained its first row that retires an
69
+ idiom rather than a deleted file: `url-pathname-as-path`, with the code shapes as patterns
70
+ so prose may still name `URL.pathname`. Its live-tree sweep runs in the suite. We checked
71
+ the pin by putting `.pathname` back locally and watching it fail.
72
+
73
+ **The `unknown` reason carries evidence.** It ends with `; looked up from <path>`. Had
74
+ that line existed, the first report would have carried its own diagnosis.
75
+
76
+ **Windows, honestly scoped.** The layout detector accepts npm's Windows shape, with the
77
+ shim's own text as the evidence that the bin reaches into this package, since nothing
78
+ resolves. npm is spawned as `npm.cmd` itself spawns it, `npm-cli.js` beside `node.exe`
79
+ through the running node: no shell, so the welcome card's timeout kills npm itself rather
80
+ than a shell above it (CVE-2024-27980 is why Node refuses `.cmd` without one). When no
81
+ `npm-cli.js` sits beside the node, the answer is a named absence reported as "npm
82
+ unavailable", not a module-not-found stack. One accepted difference from `npm.cmd`: it
83
+ runs the npm bundled with the running node, not one the person upgraded under the
84
+ prefix.
85
+
86
+ A `windows-install-kind` job on `windows-latest` runs `installKind.test.ts` and
87
+ `packageStrategy.test.ts`, about 25 seconds. On its first run on `main` it failed: on a
88
+ test written on macOS the same day, by the reviewer who asked for the job, comparing a
89
+ fixture against its POSIX spelling where Windows adds a drive. Fixed the way the PR had
90
+ fixed six others.
91
+
92
+ **Verified where it matters.** The install-from-nothing smoke, which packs this tree and
93
+ installs it into a scratch prefix, was run under `/tmp/mirror pkg Código`: 76 checks, all
94
+ passing, through `init`, `seed`, `status`, every runtime's hooks, and the MCP launcher.
95
+ The smoke itself needed one fix first: its Gemini section split the work directory on
96
+ the space, the same assumption this release removes from the product.
97
+
98
+ ### What you will notice
99
+
100
+ - Under a path with a space or an accent, `runtime status` reads `Install: clone (…)` or
101
+ `Install: package (…)`, the welcome card checks for updates again, and `runtime update`
102
+ works, after the one manual update below.
103
+ - Under any other path, nothing. The POSIX layout is checked first and is unchanged.
104
+ - An install that is still `unknown` tells you where it looked.
105
+
106
+ ## Upgrading
107
+
108
+ If your Mirror lives under a path with a space or a non-ASCII character anywhere above
109
+ it, the version you have cannot update itself: `runtime update` refuses it. Once, by
110
+ hand:
111
+
112
+ ```bash
113
+ # a clone: in the checkout
114
+ git pull --ff-only
115
+ # a package
116
+ npm install -g mirror-mind@latest
117
+ ```
118
+
119
+ From then on `mirror runtime update` works as documented. Everyone else:
120
+
121
+ ```bash
122
+ mirror runtime update # or: npm install -g mirror-mind
123
+ ```
124
+
125
+ There is no database migration and no runtime wiring change.
126
+
127
+ ## Conscious Exclusions
128
+
129
+ - **A Windows product.** REFERENCE.md's platform envelope stands: POSIX. The Windows
130
+ changes here are correct where they run and verified on Windows for their two files;
131
+ the hook wrappers, the plugin's MCP entry, `init`'s wiring, and the suite as a whole
132
+ are not. The contributor describes a series toward that; its shape is not decided.
133
+ - **The `frame-installer` guard row's `absentSuffixes`** (`.cmd`, `.ps1`) will meet that
134
+ series and is to be raised there, not here.
135
+ - **The guard's `identifier.pathname` pattern** would also flag a legitimate
136
+ `request.pathname` if `ts/src` ever grows HTTP routing. The row's `exemptions` field is
137
+ for that day.
138
+ - **Counting verdicts as front-door errors** (CR139), unchanged from `v0.33.2`.
139
+
140
+ ## What We Learned
141
+
142
+ A diagnostic that names a conclusion is a diagnostic that lies when the conclusion is
143
+ wrong. `not a git checkout` was false, and nothing in the line let anyone see why. A
144
+ diagnostic that names evidence, the path it walked, is true even when the detector is
145
+ wrong, and it turns a two-platform debugging session into one line read once.
146
+
147
+ The strong form of a fix removes the class, not the instance. `fileURLToPath` fixed every
148
+ path that exists; `import.meta.dirname` made the question of URL spelling unaskable, and
149
+ the guard made the answer durable. The review asked for the second, and the contributor
150
+ delivered it with a guard that found a second instance on its own.
151
+
152
+ A CI job earns its place on its first catch, and this one's first catch was the person
153
+ who asked for it.
154
+
155
+ ## Next Horizon
156
+
157
+ CR139, the verdict loop, is the visible next step in RS010. The contributor's Windows
158
+ series, if it comes, begins with the suite on `windows-latest`.
@@ -0,0 +1,104 @@
1
+ ---
2
+ digest: >
3
+ v0.33.4 is a maintenance release of three first-run corrections, two from a
4
+ contributor's PR #43 and one Change Request, CR141, that its review opened.
5
+ `mirror init --help` created a home named `--help`; `init ../escaped` created
6
+ one outside `.mirror-minds`; `init "My User"` put a space into `MIRROR_USER`
7
+ for every wiring command. A user name is now a kebab-case key, like a journey
8
+ slug or a persona id, refused otherwise with a name that would work and
9
+ nothing created. And `identity edit` on Windows opens Notepad when no editor
10
+ is named, instead of failing to start nano. Existing homes are untouched; no
11
+ migration.
12
+ ---
13
+
14
+ # v0.33.4 — A Name Is a Key
15
+
16
+ **Date:** 2026-10-17
17
+
18
+ ## Highlights
19
+
20
+ - **`init` takes a kebab-case key for a user name, and nothing else** (CR141, from the
21
+ review of PR #43). `init ../escaped` created `~/escaped`, outside the directory the
22
+ product owns; `init "My User"` put a space into `MIRROR_USER`, which every wiring
23
+ command `init` prints then carried; `init a/b` nested. Each is refused before anything
24
+ is created, with the sentence `identity set` already uses: the consequence, the rule,
25
+ and a key that would work.
26
+ - **`init --help` no longer creates a home named `--help`** (PR #43 by
27
+ @rodrigoimmaginario). The command has no flags; a word that starts with `-` gets the
28
+ usage line and exit 2.
29
+ - **`identity edit` opens Notepad on Windows when no editor is named** (PR #43). The
30
+ order is unchanged, `EDITOR`, then `VISUAL`, then the platform's default: `notepad` on
31
+ Windows, where nano exists only inside Git Bash, `nano` elsewhere.
32
+ - **Existing homes are untouched.** `init` only creates; a home with another kind of name
33
+ keeps it.
34
+
35
+ ## Where We Started
36
+
37
+ A contributor's first run on Windows met two small things and sent a PR for each:
38
+ `init --help` created a home, and `identity edit` with no `EDITOR` set failed to start
39
+ nano. The first was not a Windows bug at all; it reproduced on macOS in a second.
40
+
41
+ Its review asked what else `init` accepted. The answer was anything. The name becomes
42
+ `~/.mirror-minds/<user>` and the value of `MIRROR_USER`, and the repository had already
43
+ decided, in CR104, that a key which goes into paths and shell commands must be one plain
44
+ word a shell cannot split, substitute, or read as an option. Journey slugs and persona
45
+ ids had met that rule since `v0.32.0`. The user name was the last key of its kind that
46
+ did not.
47
+
48
+ ## What Changed
49
+
50
+ **One sentence for every key.** `newUserNameProblem` joins `newIdentityKeyProblem` in
51
+ `ts/src/identity/identityKey.ts`, both over one builder, so `init`, `identity set`,
52
+ `identity edit`, and `seed` refuse a non-key the same way. The key is printed escaped: a
53
+ name with a newline or a `;` in it cannot forge output or a command. `runInit` asks before
54
+ `initUserHome`, so a refusal leaves `$HOME` as it was: no home, no identity directory, no
55
+ config file.
56
+
57
+ **The test is the PR's, widened.** `../escaped`, `My User`, `a/b`, and an accented name go
58
+ through the real front door; each asserts the exit, the sentence with its suggestion, and
59
+ an empty home afterwards. PR #43's own `init --help` test stands beside it.
60
+
61
+ **Notepad, with its evidence still owed.** The platform is a dependency of
62
+ `resolveEditor`, so both defaults are tested on every OS. The PR verified the Windows
63
+ round trip on an unchanged buffer, which prints `No changes detected.` whether Notepad
64
+ blocked or returned at once; early Windows 11 Notepad did return at once. A round trip
65
+ that changes the buffer is asked for on the PR, and the default ships as the better of
66
+ the two available answers: nano could not start at all.
67
+
68
+ ### What you will notice
69
+
70
+ - A first `mirror init` with a name that is not a plain lowercase word is refused with
71
+ a name that would work. `mirror init your-name`, as Getting Started has always said,
72
+ is unchanged.
73
+ - On Windows, `mirror identity edit …` opens Notepad instead of failing.
74
+ - Nothing else.
75
+
76
+ ## Upgrading
77
+
78
+ ```bash
79
+ mirror runtime update # or: npm install -g mirror-mind
80
+ ```
81
+
82
+ There is no database migration and no runtime wiring change. If `v0.33.3`'s note applied
83
+ to you, a Mirror under a path with a space or an accent, and you have not yet taken its
84
+ one manual update, take it first; from `v0.33.3` on, `runtime update` works.
85
+
86
+ ## Conscious Exclusions
87
+
88
+ - **Selecting an existing home by name.** `MIRROR_USER` pointing at a home created
89
+ before this rule, under whatever name, still selects it. Only creation is constrained.
90
+ - **`init`'s wiring lines on Windows.** They print `ln -s`, which Windows has no answer
91
+ to; that waits for the runtimes to be verified there, as the contributor's PR says.
92
+ - **Counting verdicts as front-door errors** (CR139), unchanged since `v0.33.2`.
93
+
94
+ ## What We Learned
95
+
96
+ A fix for an instance invites the question "what else?", and the cheapest moment to ask
97
+ it is the review. The `-` check was right; the class was one line away, and the rule
98
+ already existed with its sentence, its escaping, and its tests. When a repository has
99
+ decided something once, the second instance should cost a reference, not a decision.
100
+
101
+ ## Next Horizon
102
+
103
+ CR139, the verdict loop, is the visible next step in RS010. The contributor's Windows
104
+ series, if it comes, begins with the suite on `windows-latest`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mirror-mind",
3
- "version": "0.33.2",
3
+ "version": "0.33.4",
4
4
  "description": "Mirror Mind: a local-first memory and identity framework for agentic AI runtimes (Pi, Claude Code, Gemini CLI, Codex)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mirror-mind",
3
- "version": "0.33.2",
3
+ "version": "0.33.4",
4
4
  "description": "Mirror Mind — local-first memory and identity for agentic runtimes.",
5
5
  "author": {
6
6
  "name": "Mirror Mind"
@@ -43,7 +43,7 @@ import { type Database, openDatabaseReadOnly, type WritableDatabase } from "#db/
43
43
  import { ensureDatabaseReady } from "#db/readyOnOpen.ts";
44
44
  import { assertSchemaState, SchemaStateError } from "#db/schemaState.ts";
45
45
  import { allDescriptors, descriptorsByLayer } from "#descriptor/descriptorRead.ts";
46
- import { newIdentityKeyProblem } from "#identity/identityKey.ts";
46
+ import { newIdentityKeyProblem, newUserNameProblem } from "#identity/identityKey.ts";
47
47
  import { identityRowExists, listIdentityByLayer } from "#identity/identityRead.ts";
48
48
  import { listJourneysForListCommand } from "#identity/journeyListing.ts";
49
49
  import { listPersonas } from "#identity/personaListing.ts";
@@ -885,10 +885,20 @@ function isInit(argv: readonly string[]): boolean {
885
885
  */
886
886
  function runInit(argv: readonly string[], env: NodeJS.ProcessEnv = process.env): number {
887
887
  const user = argv[1];
888
- if (!user) {
888
+ // A word that starts with `-` is a flag this command does not have, not a
889
+ // user: `init --help` once created a home named `--help`.
890
+ if (!user || user.startsWith("-")) {
889
891
  console.error("Usage: init <user>");
890
892
  return 2;
891
893
  }
894
+ // And any other word is a key: the home's directory name and the value of
895
+ // MIRROR_USER in the wiring commands printed below. CR104's rule for slugs
896
+ // and persona ids, asked before anything is created (CR141).
897
+ const problem = newUserNameProblem(user);
898
+ if (problem !== null) {
899
+ console.error(`Error: ${problem}`);
900
+ return 2;
901
+ }
892
902
  try {
893
903
  const identityRoot = initUserHome(user);
894
904
  const mirrorHome = dirname(identityRoot);
@@ -42,7 +42,12 @@ import {
42
42
  unavailableUpdateCheck,
43
43
  upstreamFor,
44
44
  } from "#runtime/git.ts";
45
- import { detectInstallKind, type InstallKind, inspectionRootFor } from "#runtime/installKind.ts";
45
+ import {
46
+ detectInstallKind,
47
+ type InstallKind,
48
+ inspectionRootFor,
49
+ thisFrontDoorPath,
50
+ } from "#runtime/installKind.ts";
46
51
  import { MIGRATE_DECLINED_EXIT, renderMigrate, runMigrate } from "#runtime/migrate.ts";
47
52
  import {
48
53
  buildPendingReleaseNotes,
@@ -514,7 +519,7 @@ function runRuntimeUpdate(
514
519
  const install =
515
520
  io.install ??
516
521
  detectInstallKind({
517
- frontDoorPath: frontDoorSelfPath(),
522
+ frontDoorPath: thisFrontDoorPath(),
518
523
  npmRootGlobal: npmRootGlobal(runningTreeRoot()),
519
524
  });
520
525
  const channel = channelFor(install, env, homedir(), channelOverride);
@@ -648,11 +653,6 @@ function spawner(env: NodeJS.ProcessEnv): UpdateSpawn {
648
653
  return frontDoorSpawner(join(runningTreeRoot(), "bin", "mirror.js"), env);
649
654
  }
650
655
 
651
- /** This front door's own path, which is also how the install kind is read. */
652
- function frontDoorSelfPath(): string {
653
- return new URL("./cli.ts", import.meta.url).pathname;
654
- }
655
-
656
656
  /**
657
657
  * How THIS front door is installed, by what the filesystem shows and nothing
658
658
  * spawned: a checkout by its `.git`, a package by npm's global layout and the
@@ -660,7 +660,7 @@ function frontDoorSelfPath(): string {
660
660
  * lane, probes `npm root -g` itself.
661
661
  */
662
662
  function detectThisInstall(): InstallKind {
663
- return detectInstallKind({ frontDoorPath: frontDoorSelfPath() });
663
+ return detectInstallKind({ frontDoorPath: thisFrontDoorPath() });
664
664
  }
665
665
 
666
666
  /**
@@ -460,6 +460,24 @@ export const RETIRED: readonly RetiredSurface[] = [
460
460
  // exploration's record is `docs/project/exploration/es-004-*.md`.
461
461
  exemptions: {},
462
462
  },
463
+ {
464
+ // Not a deleted surface: a retired IDIOM. A URL's pathname is not a
465
+ // filesystem path -- it percent-encodes `Código` and `John Doe`, and on
466
+ // Windows spells `C:\...` as `/C:/...` -- and read as one it made every
467
+ // install under such a directory `unknown` (PR #42, CR140). The fix is
468
+ // `import.meta.dirname` or `fileURLToPath`; this row keeps the idiom from
469
+ // coming back in code. Tests may spell the bug to pin it, so the scope is
470
+ // the product and its scripts.
471
+ surfaceId: "url-pathname-as-path",
472
+ story: "PR #42 (CR140)",
473
+ absentPaths: [],
474
+ // The code shapes: a property read off a call (`new URL(...).pathname`) or
475
+ // off a variable (`url.pathname`). Prose writes `URL.pathname`, capital U,
476
+ // which neither pattern matches, so a comment may still name the cause.
477
+ forbiddenPatterns: ["\\)\\.pathname\\b", "\\b[a-z_$][\\w$]*\\.pathname\\b"],
478
+ residueScope: ["ts/src/", "ts/scripts/"],
479
+ exemptions: {},
480
+ },
463
481
  ];
464
482
 
465
483
  export interface SurfaceProblem {
@@ -34,6 +34,8 @@ export interface IdentityEditDeps {
34
34
  /** The editor argv[0], resolved the way Python resolves it. */
35
35
  editor?: string | undefined;
36
36
  visual?: string | undefined;
37
+ /** Decides the editor when neither variable is set. Default: this process's. */
38
+ platform?: NodeJS.Platform;
37
39
  nowIso: () => string;
38
40
  /** Injected so a test can watch the buffer without driving a real editor. */
39
41
  spawn?: typeof spawnSync;
@@ -42,9 +44,16 @@ export interface IdentityEditDeps {
42
44
  newId: () => string;
43
45
  }
44
46
 
45
- /** Python's `os.environ.get("EDITOR") or os.environ.get("VISUAL") or "nano"`. */
47
+ /**
48
+ * Python's `os.environ.get("EDITOR") or os.environ.get("VISUAL") or "nano"`,
49
+ * with one platform default: Windows has no nano outside Git Bash, so a
50
+ * PowerShell user with neither variable set saw `Editor 'nano' could not be
51
+ * started.` Notepad ships with every Windows and blocks until it is closed,
52
+ * which is what the edit needs. Either variable still wins when set.
53
+ */
46
54
  export function resolveEditor(deps: IdentityEditDeps): string {
47
- return deps.editor || deps.visual || "nano";
55
+ const platform = deps.platform ?? process.platform;
56
+ return deps.editor || deps.visual || (platform === "win32" ? "notepad" : "nano");
48
57
  }
49
58
 
50
59
  /**
@@ -44,11 +44,27 @@ export function identityKeyBreaksGrammar(layer: string, key: string): boolean {
44
44
  */
45
45
  export function newIdentityKeyProblem(layer: string, key: string): string | null {
46
46
  const keyName = KEY_NAMES.get(layer);
47
- if (keyName === undefined || isKebabSlug(key)) return null;
47
+ if (keyName === undefined) return null;
48
+ return keyProblem(layer, keyName, key);
49
+ }
50
+
51
+ /**
52
+ * Why `init` cannot take `user`, or null when it can (CR141). A user name is
53
+ * a key of the same kind: it becomes `~/.mirror-minds/<user>` and the value of
54
+ * `MIRROR_USER` in every wiring command `init` prints. `init ../escaped` once
55
+ * created `~/escaped`, outside the directory the product owns.
56
+ */
57
+ export function newUserNameProblem(user: string): string | null {
58
+ return keyProblem("user home", "user name", user);
59
+ }
60
+
61
+ /** The one sentence every key refusal is, or null when `key` is a kebab-case slug. */
62
+ function keyProblem(created: string, keyName: string, key: string): string | null {
63
+ if (isKebabSlug(key)) return null;
48
64
  const suggestion = kebabSlug(key);
49
65
  const example = suggestion ? `, for example ${pyRepr(suggestion)}` : "";
50
66
  return (
51
- `no ${layer} was created: ${pyRepr(key)} is not a ${keyName}. ` +
67
+ `no ${created} was created: ${pyRepr(key)} is not a ${keyName}. ` +
52
68
  `Use lowercase letters, digits, and single hyphens, up to 80 characters${example}.`
53
69
  );
54
70
  }
@@ -139,13 +139,19 @@ function packageUnderNpmRoot(
139
139
 
140
140
  /**
141
141
  * The installed package holding this front door, by its layout, with no
142
- * subprocess: the manifest sits at `<prefix>/lib/node_modules/<name>` -- the
143
- * global layout npm uses on every platform this core supports -- AND one of
144
- * the manifest's bins exists at `<prefix>/bin/<bin>` and resolves back into
145
- * the package. The bin is what makes a global install global; a project that
146
- * happens to live at `<x>/lib` has the first half and not the second.
147
- * (CV22.DS10.US3 plateau 4: the read-only routes run where npm may be off
148
- * the PATH and must still say what this is.)
142
+ * subprocess (CV22.DS10.US3 plateau 4: the read-only routes run where npm may
143
+ * be off the PATH and must still say what this is). npm has two global
144
+ * layouts, and in both the bin is what makes a global install global -- a
145
+ * project that happens to live at `<x>/lib` has the manifest half and not the
146
+ * bin half:
147
+ *
148
+ * - POSIX: the manifest at `<prefix>/lib/node_modules/<name>`, and one of its
149
+ * bins at `<prefix>/bin/<bin>`, a symlink that resolves back into the
150
+ * package.
151
+ * - Windows: the manifest at `<prefix>\node_modules\<name>`, and one of its
152
+ * bins at `<prefix>\<bin>.cmd`, a shim file that runs
153
+ * `%dp0%\node_modules\<name>\...`. Nothing resolves, so the shim's own
154
+ * text is the evidence that the bin reaches into THIS package.
149
155
  */
150
156
  function packageByLayout(
151
157
  frontDoorPath: string,
@@ -156,15 +162,52 @@ function packageByLayout(
156
162
  const manifest = nearestManifest(frontDoorPath, null, exists, readFile);
157
163
  if (manifest === null) return null;
158
164
  const nodeModules = dirname(manifest.root);
165
+ if (basename(nodeModules) !== "node_modules") return null;
166
+ const linked =
167
+ binLinkedUnderPosixPrefix(manifest, nodeModules, exists, realpath) ||
168
+ binShimmedUnderWindowsPrefix(manifest, nodeModules, exists, readFile);
169
+ if (!linked) return null;
170
+ return { kind: "package", root: manifest.root, name: manifest.name, version: manifest.version };
171
+ }
172
+
173
+ function binLinkedUnderPosixPrefix(
174
+ manifest: Manifest,
175
+ nodeModules: string,
176
+ exists: (path: string) => boolean,
177
+ realpath: (path: string) => string,
178
+ ): boolean {
159
179
  const lib = dirname(nodeModules);
160
- if (basename(nodeModules) !== "node_modules" || basename(lib) !== "lib") return null;
180
+ if (basename(lib) !== "lib") return false;
161
181
  const prefix = dirname(lib);
162
- const linked = manifest.bins.some((bin) => {
182
+ return manifest.bins.some((bin) => {
163
183
  const link = join(prefix, "bin", bin);
164
184
  return exists(link) && isInside(manifest.root, realpath(link));
165
185
  });
166
- if (!linked) return null;
167
- return { kind: "package", root: manifest.root, name: manifest.name, version: manifest.version };
186
+ }
187
+
188
+ /**
189
+ * Not gated by platform, on purpose: the layout is evidence wherever it is
190
+ * found, and a Windows-only branch could be exercised only on Windows. Kept
191
+ * open, macOS and Linux run the Windows tests with fixture paths, and the
192
+ * `windows-latest` job runs the same tests on the real thing.
193
+ */
194
+ function binShimmedUnderWindowsPrefix(
195
+ manifest: Manifest,
196
+ nodeModules: string,
197
+ exists: (path: string) => boolean,
198
+ readFile: (path: string) => string,
199
+ ): boolean {
200
+ const prefix = dirname(nodeModules);
201
+ const runsThisPackage = `node_modules/${manifest.name}/`;
202
+ return manifest.bins.some((bin) => {
203
+ const shim = join(prefix, `${bin}.cmd`);
204
+ if (!exists(shim)) return false;
205
+ try {
206
+ return readFile(shim).replace(/\\/g, "/").includes(runsThisPackage);
207
+ } catch {
208
+ return false;
209
+ }
210
+ });
168
211
  }
169
212
 
170
213
  /** `realpathSync`, except that a path that does not exist is returned as given. */
@@ -176,6 +219,21 @@ function safeRealpath(path: string): string {
176
219
  }
177
220
  }
178
221
 
222
+ /**
223
+ * This front door's own path, as the filesystem spells it: what every route
224
+ * hands `detectInstallKind`. From `import.meta.dirname`, so the path never
225
+ * passes through URL form: a URL's pathname is not a filesystem path. It
226
+ * percent-encodes every reserved character -- a directory named `Código` or
227
+ * `John Doe` becomes `C%C3%B3digo`, `John%20Doe` -- and on Windows it spells
228
+ * `C:\...` as `/C:/...`. Read as a path, none of those exist, so no `.git`
229
+ * was found above the front door and every such install was `unknown`: the
230
+ * updater refused, and after CR138 the git rows went blank. The guard row
231
+ * `url-pathname-as-path` keeps the idiom out of `ts/src` and `ts/scripts`.
232
+ */
233
+ export function thisFrontDoorPath(): string {
234
+ return join(import.meta.dirname, "..", "frontDoor", "cli.ts");
235
+ }
236
+
179
237
  export function detectInstallKind(probe: InstallKindProbe): InstallKind {
180
238
  const exists = probe.exists ?? existsSync;
181
239
  const readFile = probe.readFile ?? ((path: string) => readFileSync(path, "utf8"));
@@ -190,15 +248,17 @@ export function detectInstallKind(probe: InstallKindProbe): InstallKind {
190
248
  const repository = cloneRootFor(dirname(frontDoorPath), exists);
191
249
  if (repository !== null) return { kind: "clone", repository };
192
250
 
193
- return {
194
- kind: "unknown",
195
- reason:
196
- probe.npmRootGlobal === null
197
- ? "not a git checkout, and npm is unavailable to identify a package install"
198
- : probe.npmRootGlobal === undefined
199
- ? "not a git checkout, and not laid out as a global npm install"
200
- : "not a git checkout, and not under the global npm root",
201
- };
251
+ // The reason carries the path the walk started from, spelled as the detector
252
+ // saw it (CR140): a path that is wrong -- a URL's `%C3%B3` for `ó`, a `/C:/`
253
+ // for `C:\` -- is then wrong in the report, where `not a git checkout` alone
254
+ // sent two contributors on two platforms debugging what this line shows.
255
+ const because =
256
+ probe.npmRootGlobal === null
257
+ ? "not a git checkout, and npm is unavailable to identify a package install"
258
+ : probe.npmRootGlobal === undefined
259
+ ? "not a git checkout, and not laid out as a global npm install"
260
+ : "not a git checkout, and not under the global npm root";
261
+ return { kind: "unknown", reason: `${because}; looked up from ${frontDoorPath}` };
202
262
  }
203
263
 
204
264
  /**
@@ -40,6 +40,7 @@ import {
40
40
  type InstallKind,
41
41
  inspectionRootFor,
42
42
  renderInstallLines,
43
+ thisFrontDoorPath,
43
44
  } from "./installKind.ts";
44
45
 
45
46
  export interface CoreMigrationHealth {
@@ -390,9 +391,7 @@ function mirrorHomeForStatus(env: DbPathEnv): { home: string | null; error: stri
390
391
  /** Port of `build_runtime_status`. */
391
392
  export function buildRuntimeStatus(options: BuildStatusOptions = {}): RuntimeStatusReport {
392
393
  const env = options.env ?? process.env;
393
- const install =
394
- options.install ??
395
- detectInstallKind({ frontDoorPath: new URL("../frontDoor/cli.ts", import.meta.url).pathname });
394
+ const install = options.install ?? detectInstallKind({ frontDoorPath: thisFrontDoorPath() });
396
395
  // The repository rows are the install's own, or the one `start` names; never
397
396
  // the cwd's (CR138). The git facts are a clone's: a package has no
398
397
  // repository, and grading the cwd as one read "not a git repository" as a git
@@ -11,7 +11,8 @@
11
11
  // the pipeline's decisions are testable without a registry.
12
12
 
13
13
  import { execFileSync } from "node:child_process";
14
- import { basename, join } from "node:path";
14
+ import { existsSync } from "node:fs";
15
+ import { basename, dirname, join } from "node:path";
15
16
  import { type ApplyStrategy, KNOWN_CHANNELS } from "#runtime/updatePipeline.ts";
16
17
  import { compareSemver, isPlainSemver } from "#util/semver.ts";
17
18
 
@@ -40,9 +41,44 @@ export type NpmRunner = (
40
41
  stderr: string;
41
42
  };
42
43
 
44
+ /**
45
+ * How npm is spawned on this platform.
46
+ *
47
+ * Elsewhere `npm` is a file on the PATH, spawned directly. On Windows it is
48
+ * `npm.cmd`, which Node runs only through a shell (since CVE-2024-27980 it
49
+ * refuses otherwise) -- and a shell's timeout ends the shell, not the npm
50
+ * under it: an orphan that outlives the welcome card's budget and holds the
51
+ * directory it ran in. So on Windows Mirror runs npm the way `npm.cmd` itself
52
+ * does: the `npm-cli.js` every Windows distribution of Node ships beside
53
+ * `node.exe`, through the node already running. No shell, and the budget's
54
+ * kill reaches npm itself. Without this every probe answered "npm is
55
+ * unavailable" on a machine with npm on its PATH, and the update lane refused
56
+ * for a reason that was not true.
57
+ *
58
+ * One difference from `npm.cmd`, accepted: the shim prefers an npm the person
59
+ * upgraded under `<prefix>\node_modules\npm` when there is one; this runs the
60
+ * npm bundled with the node that is running. And when no `npm-cli.js` sits
61
+ * beside that node -- a node from a manager that ships npm elsewhere -- the
62
+ * answer is a named absence, so the status line can say "npm is unavailable"
63
+ * and where it looked, rather than a module-not-found stack.
64
+ */
65
+ export function npmInvocation(
66
+ args: readonly string[],
67
+ platform: NodeJS.Platform,
68
+ execPath: string,
69
+ exists: (path: string) => boolean = existsSync,
70
+ ): { file: string; args: string[] } | { unavailable: string } {
71
+ if (platform !== "win32") return { file: "npm", args: [...args] };
72
+ const cli = join(dirname(execPath), "node_modules", "npm", "bin", "npm-cli.js");
73
+ if (!exists(cli)) return { unavailable: `npm-cli.js not found beside node: ${cli}` };
74
+ return { file: execPath, args: [cli, ...args] };
75
+ }
76
+
43
77
  export const defaultNpmRunner: NpmRunner = (args, call = {}) => {
78
+ const invocation = npmInvocation(args, process.platform, process.execPath);
79
+ if ("unavailable" in invocation) return { code: -1, stdout: "", stderr: invocation.unavailable };
44
80
  try {
45
- const stdout = execFileSync("npm", [...args], {
81
+ const stdout = execFileSync(invocation.file, invocation.args, {
46
82
  encoding: "utf8",
47
83
  stdio: ["ignore", "pipe", "pipe"],
48
84
  ...(call.cwd === undefined ? {} : { cwd: call.cwd }),