@vincentt-xr/cli 0.12.3 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,216 @@
1
+ /**
2
+ * The location store — which folder a project was last worked in, on THIS
3
+ * machine, never transmitted.
4
+ *
5
+ * The binding points folder → project. This points project → folder, so a
6
+ * project can be resumed by name from anywhere on the machine it was worked on.
7
+ * It is written as a byproduct of resolving or creating a binding: there is no
8
+ * command to run and nothing for the creator to remember.
9
+ *
10
+ * It lives beside the journal and reuses its file mechanics deliberately —
11
+ * append-only NDJSON, O_APPEND atomicity in place of a lock, skip-malformed on
12
+ * read, never-throws on write, 0700/0600 with the explicit post-write chmod, and
13
+ * the same Windows caveat (chmodSync toggles the read-only bit there and nothing
14
+ * else; config.json holds the PAT in the same directory with the same exposure,
15
+ * so hardening this file alone would imply a boundary the more valuable file
16
+ * does not have).
17
+ *
18
+ * It deviates from the journal in exactly two places, and both have the same
19
+ * reason: a lost journal line costs a slightly-wrong friction report, and a lost
20
+ * entry here costs the creator their shortcut, which is the feature.
21
+ *
22
+ * 1. Compaction happens ON READ by temp-file-plus-rename, not on every write
23
+ * by whole-file rewrite. The journal's rewrite is not atomic and two
24
+ * trimming processes can lose entries.
25
+ * 2. The writer TERMINATES DEFENSIVELY. The journal appends `entry + "\n"` and
26
+ * trusts the previous write; a process killed mid-append leaves an
27
+ * unterminated fragment, and the next append concatenates onto it, making
28
+ * one unparseable line that destroys BOTH the torn entry and a good entry
29
+ * that was never at risk.
30
+ *
31
+ * It holds a path where the journal may not (B-F7-1) for a narrow reason: the
32
+ * journal is a candidate for transmission and describes Vincentt; this file is
33
+ * never transmitted, by any command, on any schedule, in any report, on any
34
+ * failure path, and describes only the creator's own filesystem. Any future
35
+ * proposal to send it is a re-groom, not an increment.
36
+ */
37
+ import { randomBytes } from "node:crypto";
38
+ import fs from "node:fs";
39
+ import os from "node:os";
40
+ import path from "node:path";
41
+ const LOCATIONS_FILE = "locations.ndjson";
42
+ /**
43
+ * An entry is a DISTINCT FOLDER, not a run, so the bound is far smaller than the
44
+ * journal's 2000. 500 folders is beyond any real creator and holds the file
45
+ * under ~60 KB.
46
+ */
47
+ export const MAX_ENTRIES = 500;
48
+ /**
49
+ * Resolved per call rather than captured at module load — the same discipline
50
+ * and the same reason as `journalPath()`: the home directory is the seam tests
51
+ * move, and a frozen path silently writes the developer's real one.
52
+ */
53
+ export function locationsPath() {
54
+ return path.join(os.homedir(), ".vincentt", LOCATIONS_FILE);
55
+ }
56
+ function isEntry(value) {
57
+ if (typeof value !== "object" || value === null)
58
+ return false;
59
+ const e = value;
60
+ return typeof e.p === "string" && e.p !== "" && typeof e.d === "string" && e.d !== "";
61
+ }
62
+ /**
63
+ * Read oldest-first. Malformed lines are skipped, and an unreadable file yields
64
+ * an empty list — a store that FAILS is indistinguishable from a store that is
65
+ * EMPTY, which is the correct blast radius: a fresh install is exactly that
66
+ * state and is fully supported.
67
+ */
68
+ export function readLocations() {
69
+ let raw;
70
+ try {
71
+ raw = fs.readFileSync(locationsPath(), "utf8");
72
+ }
73
+ catch {
74
+ return [];
75
+ }
76
+ const out = [];
77
+ for (const line of raw.split("\n")) {
78
+ if (!line.trim())
79
+ continue;
80
+ try {
81
+ const parsed = JSON.parse(line);
82
+ if (isEntry(parsed))
83
+ out.push({ p: parsed.p, d: parsed.d, t: parsed.t ?? "" });
84
+ }
85
+ catch {
86
+ // A torn line from a killed process is not a reason to lose the rest.
87
+ }
88
+ }
89
+ return out;
90
+ }
91
+ /**
92
+ * Does the file end in a newline, or is there nothing there yet?
93
+ *
94
+ * Two syscalls against the page cache (`statSync` for the size, a one-byte read
95
+ * at `size - 1`), skipped entirely when the file is absent or empty. Writing
96
+ * `"\n" + line` unconditionally was rejected: it makes a leading blank line the
97
+ * normal case the reader must then tolerate, hiding the torn-write signal
98
+ * instead of repairing it.
99
+ */
100
+ export function needsLeadingNewline(file) {
101
+ let size;
102
+ try {
103
+ size = fs.statSync(file).size;
104
+ }
105
+ catch {
106
+ return false;
107
+ }
108
+ if (size === 0)
109
+ return false;
110
+ let fd;
111
+ try {
112
+ fd = fs.openSync(file, "r");
113
+ const buf = Buffer.alloc(1);
114
+ const read = fs.readSync(fd, buf, 0, 1, size - 1);
115
+ return read === 1 && buf[0] !== 0x0a;
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ finally {
121
+ if (fd !== undefined) {
122
+ try {
123
+ fs.closeSync(fd);
124
+ }
125
+ catch {
126
+ // Nothing to salvage; the append below is best-effort anyway.
127
+ }
128
+ }
129
+ }
130
+ }
131
+ /**
132
+ * Append one entry. Never throws, for the journal's stated reason:
133
+ * instrumentation that breaks the tool it measures is worse than none. Every
134
+ * failure mode here (a read-only home, a full disk, a directory where the file
135
+ * should be) is one the creator would rather have silently ignored than turned
136
+ * into a failed `publish`.
137
+ *
138
+ * `locationsPath()` is INSIDE the try because it calls `os.homedir()`, which can
139
+ * itself throw on a hostile environment.
140
+ */
141
+ export function appendLocation(entry) {
142
+ try {
143
+ const file = locationsPath();
144
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
145
+ const prefix = needsLeadingNewline(file) ? "\n" : "";
146
+ fs.appendFileSync(file, prefix + JSON.stringify(entry) + "\n", {
147
+ encoding: "utf8",
148
+ mode: 0o600,
149
+ });
150
+ // `mode` on appendFile only applies when it CREATES the file, so a store
151
+ // that predates this (or a looser umask) would keep its old perms.
152
+ fs.chmodSync(file, 0o600);
153
+ }
154
+ catch {
155
+ // A shortcut index that can fail a publish is not worth having.
156
+ }
157
+ }
158
+ /**
159
+ * Collapse duplicate `(p, d)` pairs to their newest `t`, then keep the newest
160
+ * MAX_ENTRIES. Returned oldest-first, which is the order the file holds.
161
+ */
162
+ export function compactEntries(entries) {
163
+ const newest = new Map();
164
+ for (const e of entries) {
165
+ const key = `${e.p}\0${e.d}`;
166
+ const seen = newest.get(key);
167
+ if (!seen || (e.t ?? "") >= (seen.t ?? ""))
168
+ newest.set(key, e);
169
+ }
170
+ const all = [...newest.values()].sort((a, b) => (a.t ?? "").localeCompare(b.t ?? ""));
171
+ return all.slice(-MAX_ENTRIES);
172
+ }
173
+ /**
174
+ * Rewrite the store with the survivors, ATOMICALLY: a unique temp file at 0600,
175
+ * chmod'ed explicitly, renamed over the target, and removed if the rename fails.
176
+ * The discipline `persistMachineConfig` already uses for the credential file.
177
+ *
178
+ * Rewrite-with-survivors rather than delete-lines, so a crash leaves either the
179
+ * original file or the fully compacted one and never a truncated store — which
180
+ * would be indistinguishable from an empty one BY DESIGN, and therefore
181
+ * invisible at every behavioral tier.
182
+ *
183
+ * An append landing inside the rename window is lost. That is accepted and
184
+ * self-healing (the folder that appended is a folder a verb just ran in), and it
185
+ * is the one place the no-lock choice has a visible cost.
186
+ *
187
+ * Never throws: it runs AFTER the answer has printed, and a compaction that
188
+ * fails must not delay or break it.
189
+ */
190
+ export function compactLocations(keep) {
191
+ const file = locationsPath();
192
+ const tmp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
193
+ try {
194
+ const body = keep.map((e) => JSON.stringify(e)).join("\n");
195
+ fs.writeFileSync(tmp, body === "" ? "" : body + "\n", {
196
+ encoding: "utf8",
197
+ mode: 0o600,
198
+ });
199
+ fs.chmodSync(tmp, 0o600);
200
+ fs.renameSync(tmp, file);
201
+ }
202
+ catch {
203
+ try {
204
+ fs.rmSync(tmp, { force: true });
205
+ }
206
+ catch {
207
+ // A stray temp file is not worth failing a listing over.
208
+ }
209
+ }
210
+ }
211
+ /** The real store, reading and writing the creator's own home directory. */
212
+ export const fileLocationStore = {
213
+ read: readLocations,
214
+ append: appendLocation,
215
+ compact: compactLocations,
216
+ };
@@ -0,0 +1,33 @@
1
+ import { type LocationStore } from "./locationStore.js";
2
+ export interface RecordLocationDeps {
3
+ /** Injectable for tests; defaults to the real home-directory store. */
4
+ append?: LocationStore["append"];
5
+ /**
6
+ * Resolve a path through its symlinks. Injectable following
7
+ * `ProjectCommandDeps.realpath`, so the case-twin and symlink arms are
8
+ * testable on a Linux runner that cannot reproduce either natively.
9
+ */
10
+ realpath?: (dir: string) => string;
11
+ now?: () => Date;
12
+ }
13
+ /**
14
+ * Record that `projectId` lives in `dir`.
15
+ *
16
+ * `d` is the REALPATH-resolved directory, falling back to the unresolved path on
17
+ * failure exactly as `create` and `workingTreeKey` already do. A symlinked path,
18
+ * a macOS case-insensitive twin, or a bind mount otherwise yields two different
19
+ * `d` strings for one directory, which neither the dedup set nor compaction
20
+ * collapses — and the listing then reports one folder twice as an ambiguity that
21
+ * does not exist, asking the creator to choose between two spellings of the same
22
+ * place. Falling back rather than dropping keeps an unresolvable folder recorded.
23
+ *
24
+ * The dedup key's SHAPE is unchanged — `${projectId}\0${dir}` — but `dir` is now
25
+ * guaranteed canonical, which is what makes the key correct rather than merely
26
+ * well-formed. A folder rebound from project A to project B still legitimately
27
+ * holds two entries, because the key includes the project.
28
+ *
29
+ * Never throws.
30
+ */
31
+ export declare function recordLocation(projectId: string, dir: string, deps?: RecordLocationDeps): void;
32
+ /** Test seam only — the dedup set is process-lifetime state. */
33
+ export declare function resetLocationDedupForTest(): void;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The write hook — one binding resolution or creation becomes one store entry.
3
+ *
4
+ * It hangs off `locateProjectBinding`'s success return and off
5
+ * `writeProjectBinding`, which are the only two ways a folder comes to name a
6
+ * project. A hook per verb was rejected: it creates the class of bug where a
7
+ * newly added verb silently does not record, and one hook in the one resolver
8
+ * makes "any command that successfully resolves a binding records that folder"
9
+ * true by construction rather than by a checklist somebody has to keep current.
10
+ *
11
+ * SYNCHRONOUS on purpose, inside async callers. The CLI's top-level handler
12
+ * calls `process.exit(1)` and the feedback watch calls `process.exit(0)` on
13
+ * SIGINT; both abandon a pending async write, so an async store would lose
14
+ * exactly the runs worth recording.
15
+ */
16
+ import fs from "node:fs";
17
+ import { appendLocation } from "./locationStore.js";
18
+ /**
19
+ * Deduplicated per PROCESS, not per call. `locateProjectBinding` runs more than
20
+ * once per invocation on several paths — `publish` reaches it via
21
+ * `resolveConfig` and via its own `binding` dep — so without this the file grows
22
+ * with internal resolution calls rather than with distinct folders touched.
23
+ */
24
+ const written = new Set();
25
+ function defaultRealpath(dir) {
26
+ return fs.realpathSync(dir);
27
+ }
28
+ /**
29
+ * Record that `projectId` lives in `dir`.
30
+ *
31
+ * `d` is the REALPATH-resolved directory, falling back to the unresolved path on
32
+ * failure exactly as `create` and `workingTreeKey` already do. A symlinked path,
33
+ * a macOS case-insensitive twin, or a bind mount otherwise yields two different
34
+ * `d` strings for one directory, which neither the dedup set nor compaction
35
+ * collapses — and the listing then reports one folder twice as an ambiguity that
36
+ * does not exist, asking the creator to choose between two spellings of the same
37
+ * place. Falling back rather than dropping keeps an unresolvable folder recorded.
38
+ *
39
+ * The dedup key's SHAPE is unchanged — `${projectId}\0${dir}` — but `dir` is now
40
+ * guaranteed canonical, which is what makes the key correct rather than merely
41
+ * well-formed. A folder rebound from project A to project B still legitimately
42
+ * holds two entries, because the key includes the project.
43
+ *
44
+ * Never throws.
45
+ */
46
+ export function recordLocation(projectId, dir, deps = {}) {
47
+ try {
48
+ if (!projectId || !dir)
49
+ return;
50
+ const resolve = deps.realpath ?? defaultRealpath;
51
+ let resolved;
52
+ try {
53
+ resolved = resolve(dir);
54
+ }
55
+ catch {
56
+ resolved = dir;
57
+ }
58
+ const key = `${projectId}\0${resolved}`;
59
+ if (written.has(key))
60
+ return;
61
+ written.add(key);
62
+ const append = deps.append ?? appendLocation;
63
+ append({
64
+ p: projectId,
65
+ d: resolved,
66
+ t: (deps.now ? deps.now() : new Date()).toISOString(),
67
+ });
68
+ }
69
+ catch {
70
+ // A shortcut index must never break the command it rides on.
71
+ }
72
+ }
73
+ /** Test seam only — the dedup set is process-lifetime state. */
74
+ export function resetLocationDedupForTest() {
75
+ written.clear();
76
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincentt-xr/cli",
3
- "version": "0.12.3",
3
+ "version": "0.14.0",
4
4
  "description": "Vincentt CLI — the vincentt command-line dev loop (login, create, publish, preview, diagnostics, feedback, relay)",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",