@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.
- package/README.md +20 -1
- package/dist/cli/index.js +43 -19
- package/dist/cli/usage-text.js +4 -1
- package/dist/dx/command.d.ts +79 -15
- package/dist/dx/command.js +173 -22
- package/dist/dx/consent.d.ts +77 -0
- package/dist/dx/consent.js +197 -0
- package/dist/dx/enablement.d.ts +35 -1
- package/dist/dx/enablement.js +56 -8
- package/dist/lib/listProjects.d.ts +12 -1
- package/dist/lib/listProjects.js +99 -8
- package/dist/lib/locations.d.ts +59 -0
- package/dist/lib/locations.js +117 -0
- package/dist/login/loopback.d.ts +72 -0
- package/dist/login/loopback.js +119 -0
- package/dist/login/pkceLogin.d.ts +1 -0
- package/dist/login/pkceLogin.js +41 -45
- package/dist/shared/config.d.ts +8 -0
- package/dist/shared/config.js +18 -1
- package/dist/shared/locationStore.d.ts +82 -0
- package/dist/shared/locationStore.js +216 -0
- package/dist/shared/recordLocation.d.ts +33 -0
- package/dist/shared/recordLocation.js +76 -0
- package/package.json +1 -1
|
@@ -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