@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,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The read half of the location store: verify a candidate folder, and join the
|
|
3
|
+
* store against the listing's API rows.
|
|
4
|
+
*
|
|
5
|
+
* Kept out of `listProjects.ts` so the predicate is a pure function a human can
|
|
6
|
+
* reproduce with one `cat` and no CLI — which is the whole triage story for the
|
|
7
|
+
* report that will actually arrive ("it says it does not know where my project
|
|
8
|
+
* is, and I am looking at the folder").
|
|
9
|
+
*/
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
/**
|
|
13
|
+
* At most this many folder checks per listing. 64 reads is ~1-3 ms against an
|
|
14
|
+
* API call the listing already spent 50-200 ms on. The ceiling exists so a
|
|
15
|
+
* pathological store — an agent looping `create` in generated folders — cannot
|
|
16
|
+
* turn a listing into a filesystem walk, and it FAILS TOWARD "unknown", never
|
|
17
|
+
* toward "here is a path we did not check", which is the only direction the
|
|
18
|
+
* verify-at-read rule permits.
|
|
19
|
+
*/
|
|
20
|
+
export const VERIFY_LIMIT = 64;
|
|
21
|
+
/**
|
|
22
|
+
* Does `dir` still hold a binding naming `projectId`?
|
|
23
|
+
*
|
|
24
|
+
* ENOENT → dead (deleted, moved, drive unmounted, unbound)
|
|
25
|
+
* present but unparseable → dead
|
|
26
|
+
* present with no projectId → dead
|
|
27
|
+
* projectId !== the entry's → dead (bound to a different project)
|
|
28
|
+
* otherwise → live
|
|
29
|
+
*
|
|
30
|
+
* A DIRECT read, not `locateProjectBinding`'s upward walk. The store recorded
|
|
31
|
+
* the directory that HOLDS the file, and walking upward from it would let a
|
|
32
|
+
* PARENT repo's binding validate a child folder the store should have dropped —
|
|
33
|
+
* answering with a plausible-but-wrong path, which is precisely what
|
|
34
|
+
* verify-at-read exists to prevent.
|
|
35
|
+
*
|
|
36
|
+
* Exactly one `readFile` of an ~80-byte file. Not a `stat` then a read: ENOENT
|
|
37
|
+
* is the negative answer.
|
|
38
|
+
*/
|
|
39
|
+
export function verifyLocation(dir, projectId) {
|
|
40
|
+
let raw;
|
|
41
|
+
try {
|
|
42
|
+
raw = fs.readFileSync(path.join(dir, ".vincentt", "project.json"), "utf8");
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(raw);
|
|
49
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
50
|
+
return false;
|
|
51
|
+
return parsed.projectId === projectId;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Join the store against the ids in this listing, verify newest-first, and
|
|
59
|
+
* report what survives.
|
|
60
|
+
*
|
|
61
|
+
* Verification is SCOPED TO THE JOIN: entries for projects in another workspace
|
|
62
|
+
* are not read, not touched, and not dropped. That is a correctness rule before
|
|
63
|
+
* it is a cost one — dropping an entry because you listed a DIFFERENT workspace
|
|
64
|
+
* would be a bug, and scoping to the join makes it unrepresentable.
|
|
65
|
+
*
|
|
66
|
+
* Candidates are sorted by `t` DESCENDING before the ceiling applies, and the
|
|
67
|
+
* ordering is part of the contract. Store order (append order, oldest first) is
|
|
68
|
+
* the implementation a reader falls into without deciding, and its failure is
|
|
69
|
+
* the worst shape available: a creator with 70 stale entries and one live one
|
|
70
|
+
* spends all 64 reads on the oldest dead ones, is told their project has no
|
|
71
|
+
* known folder, and then compaction drops those 64 so the NEXT listing succeeds
|
|
72
|
+
* — an intermittent failure that heals itself, which is the hardest possible
|
|
73
|
+
* triage. Newest-first fails as "you have not touched this folder in a very long
|
|
74
|
+
* time", which is diagnosable.
|
|
75
|
+
*
|
|
76
|
+
* Presentation order and verification order are the same sort, applied once.
|
|
77
|
+
*/
|
|
78
|
+
export function joinLocations(entries, projectIds, verify = verifyLocation) {
|
|
79
|
+
// Exact equality on the FULL project id — never a prefix or a substring. Ids
|
|
80
|
+
// are 12-char opaque hex, and a truncating join looks reasonable to a future
|
|
81
|
+
// reader while silently leaking another tenant's folder into this listing.
|
|
82
|
+
const inScope = new Set(projectIds);
|
|
83
|
+
const candidates = [];
|
|
84
|
+
const untouched = [];
|
|
85
|
+
for (const e of entries) {
|
|
86
|
+
if (inScope.has(e.p))
|
|
87
|
+
candidates.push(e);
|
|
88
|
+
else
|
|
89
|
+
untouched.push(e);
|
|
90
|
+
}
|
|
91
|
+
const ordered = [...candidates].sort((a, b) => (b.t ?? "").localeCompare(a.t ?? ""));
|
|
92
|
+
const byProject = new Map();
|
|
93
|
+
const live = [];
|
|
94
|
+
let reads = 0;
|
|
95
|
+
const dead = new Set();
|
|
96
|
+
for (const e of ordered) {
|
|
97
|
+
if (reads >= VERIFY_LIMIT)
|
|
98
|
+
break;
|
|
99
|
+
reads += 1;
|
|
100
|
+
if (verify(e.d, e.p)) {
|
|
101
|
+
live.push(e);
|
|
102
|
+
const at = byProject.get(e.p);
|
|
103
|
+
if (at)
|
|
104
|
+
at.push(e.d);
|
|
105
|
+
else
|
|
106
|
+
byProject.set(e.p, [e.d]);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
dead.add(e);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Beyond the ceiling an entry is neither reported nor dropped: it was not
|
|
113
|
+
// checked, and dropping what you did not check is the one thing the ceiling
|
|
114
|
+
// must not do.
|
|
115
|
+
const survivors = entries.filter((e) => !dead.has(e));
|
|
116
|
+
return { byProject, survivors, dropped: dead.size > 0 };
|
|
117
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** Thrown when the wait elapses with no answer from the browser. */
|
|
2
|
+
export declare class LoopbackTimeoutError extends Error {
|
|
3
|
+
constructor(message: string);
|
|
4
|
+
}
|
|
5
|
+
/** Thrown when the callback is a terminal failure (a cancel, a malformed answer). */
|
|
6
|
+
export declare class LoopbackCallbackError extends Error {
|
|
7
|
+
constructor(message: string);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* What the caller's parser decided about one callback.
|
|
11
|
+
*
|
|
12
|
+
* `retry` is the third state and the reason this is not a boolean: a state mismatch is
|
|
13
|
+
* a stray or forged callback, and the right response is to answer it and KEEP WAITING
|
|
14
|
+
* for the genuine one. Treating it as a failure would let anyone who can reach the
|
|
15
|
+
* loopback port cancel a legitimate consent flow.
|
|
16
|
+
*/
|
|
17
|
+
export type CallbackDecision<T> = {
|
|
18
|
+
ok: true;
|
|
19
|
+
value: T;
|
|
20
|
+
page: LoopbackPage;
|
|
21
|
+
} | {
|
|
22
|
+
ok: false;
|
|
23
|
+
retry: boolean;
|
|
24
|
+
message: string;
|
|
25
|
+
page: LoopbackPage;
|
|
26
|
+
};
|
|
27
|
+
export interface LoopbackPage {
|
|
28
|
+
status: number;
|
|
29
|
+
html: string;
|
|
30
|
+
}
|
|
31
|
+
export interface LoopbackWaitOptions<T> {
|
|
32
|
+
/** Pick a free loopback port. Injected so tests never race a real ephemeral picker. */
|
|
33
|
+
pickPort: () => Promise<number>;
|
|
34
|
+
/** Build the console URL the browser opens, given the loopback redirect_uri. */
|
|
35
|
+
buildUrl: (redirectUri: string) => string;
|
|
36
|
+
/** Launch the browser. A no-op fake in tests; never fatal in production. */
|
|
37
|
+
openBrowser: (url: string) => void;
|
|
38
|
+
/** Interpret one callback's query params. Must verify `state` itself. */
|
|
39
|
+
parse: (params: URLSearchParams) => CallbackDecision<T>;
|
|
40
|
+
/** Abort if the round trip does not complete in time. */
|
|
41
|
+
timeoutMs: number;
|
|
42
|
+
/** Built when the wait times out, so each flow words its own transcript. */
|
|
43
|
+
onTimeout: () => Error;
|
|
44
|
+
/**
|
|
45
|
+
* Built when a callback is a TERMINAL failure. Injected for the same reason as
|
|
46
|
+
* onTimeout: each flow keeps its own error type, so callers can keep matching on
|
|
47
|
+
* theirs rather than on a shared one this extraction happened to introduce.
|
|
48
|
+
*/
|
|
49
|
+
onCallbackError?: (message: string) => Error;
|
|
50
|
+
/** Narration (the "→ Opening…" / printed-URL / "Waiting…" lines). */
|
|
51
|
+
onProgress?: (line: string) => void;
|
|
52
|
+
/** The loopback path the console redirects to. Defaults to /callback. */
|
|
53
|
+
path?: string;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Run one loopback round trip and resolve with whatever `parse` extracted.
|
|
57
|
+
*
|
|
58
|
+
* Ordering is security-load-bearing and is the reason this function exists at all:
|
|
59
|
+
* 1. pick the port and START LISTENING,
|
|
60
|
+
* 2. only then open the browser + print the fallback URL,
|
|
61
|
+
* 3. verify `state` on every callback before reading anything else from it,
|
|
62
|
+
* 4. bound the wait.
|
|
63
|
+
*/
|
|
64
|
+
export declare function waitForLoopbackCallback<T>(opts: LoopbackWaitOptions<T>): Promise<T>;
|
|
65
|
+
/**
|
|
66
|
+
* Verify `state` constant-time and hand back the remaining params.
|
|
67
|
+
*
|
|
68
|
+
* State is checked FIRST, always, before any other field is read, so a forged or stray
|
|
69
|
+
* callback can never drive a decision. Shared with pkce.parseCallback's discipline; kept
|
|
70
|
+
* here as a helper so a caller writing its own `parse` cannot forget it.
|
|
71
|
+
*/
|
|
72
|
+
export declare function verifiedState(params: URLSearchParams, expected: string): boolean;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// The browser round-trip primitive: start a loopback listener on 127.0.0.1, open the
|
|
2
|
+
// browser at a console URL, and wait for the console to redirect back with a verified
|
|
3
|
+
// `state`.
|
|
4
|
+
//
|
|
5
|
+
// EXTRACTED from pkceLogin.ts rather than copied. `vincentt login` (PKCE) and
|
|
6
|
+
// `vincentt dx enable` (account consent) are two flows with the same security-critical
|
|
7
|
+
// skeleton, and the skeleton is exactly the part that must not drift between them: the
|
|
8
|
+
// listener comes up BEFORE the browser (a browser that redirects to a port nobody is
|
|
9
|
+
// listening on loses the answer), `state` is verified constant-time on every callback,
|
|
10
|
+
// a mismatch is answered but NOT accepted, and the wait is bounded. Two copies of that
|
|
11
|
+
// would mean a fix applied to one and not the other — and the un-fixed one is a consent
|
|
12
|
+
// gate.
|
|
13
|
+
//
|
|
14
|
+
// Every external boundary stays injected (port picker, listen, browser open, clock) so
|
|
15
|
+
// callers are unit-testable with no real socket.
|
|
16
|
+
import { createServer } from "node:http";
|
|
17
|
+
import { stateMatches } from "./pkce.js";
|
|
18
|
+
/** Thrown when the wait elapses with no answer from the browser. */
|
|
19
|
+
export class LoopbackTimeoutError extends Error {
|
|
20
|
+
constructor(message) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = "LoopbackTimeoutError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Thrown when the callback is a terminal failure (a cancel, a malformed answer). */
|
|
26
|
+
export class LoopbackCallbackError extends Error {
|
|
27
|
+
constructor(message) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "LoopbackCallbackError";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Run one loopback round trip and resolve with whatever `parse` extracted.
|
|
34
|
+
*
|
|
35
|
+
* Ordering is security-load-bearing and is the reason this function exists at all:
|
|
36
|
+
* 1. pick the port and START LISTENING,
|
|
37
|
+
* 2. only then open the browser + print the fallback URL,
|
|
38
|
+
* 3. verify `state` on every callback before reading anything else from it,
|
|
39
|
+
* 4. bound the wait.
|
|
40
|
+
*/
|
|
41
|
+
export function waitForLoopbackCallback(opts) {
|
|
42
|
+
const callbackPath = opts.path ?? "/callback";
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
void (async () => {
|
|
45
|
+
let port;
|
|
46
|
+
try {
|
|
47
|
+
port = await opts.pickPort();
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
reject(err);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const redirectUri = `http://127.0.0.1:${port}${callbackPath}`;
|
|
54
|
+
const url = opts.buildUrl(redirectUri);
|
|
55
|
+
const progress = opts.onProgress ?? (() => { });
|
|
56
|
+
const server = createServer((req, res) => {
|
|
57
|
+
// A received request always carries a url; the base makes it absolute for parsing.
|
|
58
|
+
const parsed = new URL(req.url, `http://127.0.0.1:${port}`);
|
|
59
|
+
if (parsed.pathname !== callbackPath) {
|
|
60
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
61
|
+
res.end("Not found");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const decision = opts.parse(parsed.searchParams);
|
|
65
|
+
res.writeHead(decision.page.status, {
|
|
66
|
+
"content-type": "text/html; charset=utf-8",
|
|
67
|
+
});
|
|
68
|
+
res.end(decision.page.html);
|
|
69
|
+
if (decision.ok) {
|
|
70
|
+
server.close();
|
|
71
|
+
resolve(decision.value);
|
|
72
|
+
}
|
|
73
|
+
else if (!decision.retry) {
|
|
74
|
+
server.close();
|
|
75
|
+
reject(opts.onCallbackError?.(decision.message) ??
|
|
76
|
+
new LoopbackCallbackError(decision.message));
|
|
77
|
+
}
|
|
78
|
+
// retry === true: answered, still listening for the genuine callback.
|
|
79
|
+
});
|
|
80
|
+
server.on("error", reject);
|
|
81
|
+
server.listen(port, "127.0.0.1", () => {
|
|
82
|
+
for (const line of progressLines(url))
|
|
83
|
+
progress(line);
|
|
84
|
+
opts.openBrowser(url);
|
|
85
|
+
});
|
|
86
|
+
const timer = setTimeout(() => {
|
|
87
|
+
server.close();
|
|
88
|
+
reject(opts.onTimeout());
|
|
89
|
+
}, opts.timeoutMs);
|
|
90
|
+
timer.unref();
|
|
91
|
+
})();
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The narration, including the printed URL.
|
|
96
|
+
*
|
|
97
|
+
* The URL is printed on EVERY run, not only when the open fails, because the CLI cannot
|
|
98
|
+
* tell whether a browser actually appeared — `open`/`xdg-open` exits 0 on a headless box
|
|
99
|
+
* with nothing to show. On the agent-driven flow this whole feature exists for, the
|
|
100
|
+
* printed URL is frequently the only way the human ever reaches the page.
|
|
101
|
+
*/
|
|
102
|
+
function progressLines(url) {
|
|
103
|
+
return [
|
|
104
|
+
"→ Opening your browser…",
|
|
105
|
+
` If it doesn't open, visit: ${url}`,
|
|
106
|
+
"",
|
|
107
|
+
" Waiting for you to answer in the browser…",
|
|
108
|
+
];
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Verify `state` constant-time and hand back the remaining params.
|
|
112
|
+
*
|
|
113
|
+
* State is checked FIRST, always, before any other field is read, so a forged or stray
|
|
114
|
+
* callback can never drive a decision. Shared with pkce.parseCallback's discipline; kept
|
|
115
|
+
* here as a helper so a caller writing its own `parse` cannot forget it.
|
|
116
|
+
*/
|
|
117
|
+
export function verifiedState(params, expected) {
|
|
118
|
+
return stateMatches(expected, params.get("state"));
|
|
119
|
+
}
|
|
@@ -43,6 +43,7 @@ type SpawnLike = (cmd: string, args: string[], opts: {
|
|
|
43
43
|
};
|
|
44
44
|
/** Launch the browser via an injected spawn; a throw is swallowed (fallback = the printed URL). */
|
|
45
45
|
export declare function launchBrowser(url: string, spawn: SpawnLike, platform?: string): void;
|
|
46
|
+
export declare function browserPage(ok: boolean, heading: string, body: string): string;
|
|
46
47
|
/**
|
|
47
48
|
* Run the loopback + PKCE exchange, persist the PAT, and confirm identity via /me.
|
|
48
49
|
* Resolves with the confirmed identity + the config path; rejects with LoginTimeoutError
|
package/dist/login/pkceLogin.js
CHANGED
|
@@ -11,11 +11,11 @@
|
|
|
11
11
|
// Every external boundary is injected (getFreePort, listen, openBrowser, backend, clock/
|
|
12
12
|
// timeout, config write) so the whole flow is unit-testable against a stub backend + fake
|
|
13
13
|
// browser + temp config dir, with no real socket or network.
|
|
14
|
-
import { createServer } from "node:http";
|
|
15
14
|
import { getFreePort } from "../preview/net.js";
|
|
16
15
|
import { writeMachineConfig } from "../shared/config.js";
|
|
17
16
|
import { makePkceMaterial, buildCliAuthUrl, parseCallback, } from "./pkce.js";
|
|
18
17
|
import { httpAuthBackend } from "./authBackend.js";
|
|
18
|
+
import { waitForLoopbackCallback } from "./loopback.js";
|
|
19
19
|
/** Thrown when the wait elapses with no approval; drives the L4 timeout transcript. */
|
|
20
20
|
export class LoginTimeoutError extends Error {
|
|
21
21
|
constructor() {
|
|
@@ -57,7 +57,10 @@ function defaultOpenBrowser(url) {
|
|
|
57
57
|
// AuthLayout auth-card is mirrored here token-for-token: --color-sand page, white
|
|
58
58
|
// --color-surface card (rounded-2xl, p-7), the terminal glyph on --color-primary-faint,
|
|
59
59
|
// and the light/dark palette. A subtle green/red tints only the glyph on ok/failure.
|
|
60
|
-
|
|
60
|
+
//
|
|
61
|
+
// Exported so `dx enable`'s consent round trip renders the SAME page rather than growing
|
|
62
|
+
// a second, drifting copy of the console's card.
|
|
63
|
+
export function browserPage(ok, heading, body) {
|
|
61
64
|
// The terminal glyph the console uses on the Grant-CLI screen (icons.terminal).
|
|
62
65
|
const glyph = '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
|
|
63
66
|
'stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
|
|
@@ -102,51 +105,44 @@ export async function runPkceLogin(opts) {
|
|
|
102
105
|
((apiUrl, pat) => writeMachineConfig({ apiUrl, pat, consoleUrl: opts.consoleUrl }));
|
|
103
106
|
const progress = opts.onProgress ?? (() => { });
|
|
104
107
|
const material = makePkceMaterial();
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
"
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
: browserPage(false, "Sign-in failed", result.message));
|
|
108
|
+
// (1)–(4): listener up first, browser second, state verified on every callback,
|
|
109
|
+
// bounded wait. The mechanics live in waitForLoopbackCallback, shared with
|
|
110
|
+
// `dx enable` so the two flows cannot drift apart.
|
|
111
|
+
const code = await waitForLoopbackCallback({
|
|
112
|
+
pickPort,
|
|
113
|
+
buildUrl: (redirectUri) => buildCliAuthUrl(opts.consoleUrl, redirectUri, material),
|
|
114
|
+
openBrowser,
|
|
115
|
+
timeoutMs: opts.timeoutMs ?? 5 * 60_000,
|
|
116
|
+
onTimeout: () => new LoginTimeoutError(),
|
|
117
|
+
onCallbackError: (message) => new LoginCallbackError(message),
|
|
118
|
+
onProgress: (line) => {
|
|
119
|
+
// The login transcript says "sign in" where the generic narration says
|
|
120
|
+
// "answer" — same lines, this flow's words.
|
|
121
|
+
progress(line
|
|
122
|
+
.replace("→ Opening your browser…", "→ Opening your browser to sign in…")
|
|
123
|
+
.replace(" Waiting for you to answer in the browser…", " Waiting for you to approve access in the browser…"));
|
|
124
|
+
},
|
|
125
|
+
parse: (params) => {
|
|
126
|
+
const result = parseCallback(params, material.state);
|
|
125
127
|
if (result.ok) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
128
|
+
return {
|
|
129
|
+
ok: true,
|
|
130
|
+
value: result.code,
|
|
131
|
+
page: {
|
|
132
|
+
status: 200,
|
|
133
|
+
html: browserPage(true, "You're all set", "Return to your terminal — you can close this tab."),
|
|
134
|
+
},
|
|
135
|
+
};
|
|
134
136
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
});
|
|
145
|
-
const timer = setTimeout(() => {
|
|
146
|
-
server.close();
|
|
147
|
-
reject(new LoginTimeoutError());
|
|
148
|
-
}, opts.timeoutMs ?? 5 * 60_000);
|
|
149
|
-
timer.unref();
|
|
137
|
+
// A cancel is a real, terminal failure. A state mismatch is a stray/forged
|
|
138
|
+
// callback — answer it but keep waiting for the genuine one (or the timeout).
|
|
139
|
+
return {
|
|
140
|
+
ok: false,
|
|
141
|
+
retry: result.reason === "state_mismatch",
|
|
142
|
+
message: result.message,
|
|
143
|
+
page: { status: 400, html: browserPage(false, "Sign-in failed", result.message) },
|
|
144
|
+
};
|
|
145
|
+
},
|
|
150
146
|
});
|
|
151
147
|
// (5) redeem the code for the raw PAT; (6) persist then confirm identity.
|
|
152
148
|
const pat = await backend.exchange(code, material.codeVerifier);
|
package/dist/shared/config.d.ts
CHANGED
|
@@ -56,6 +56,14 @@ export declare function writeDxEnablement(next: {
|
|
|
56
56
|
* other field by design, which would leave a disconnected machine still armed to
|
|
57
57
|
* transmit for whoever signs in next — a creator who disconnects a machine has
|
|
58
58
|
* disconnected it.
|
|
59
|
+
*
|
|
60
|
+
* The ACCOUNT-WIDE grant is deliberately NOT revoked here, and that asymmetry is the
|
|
61
|
+
* point: the local flag is a statement about this machine, so disconnecting the machine
|
|
62
|
+
* retracts it, while the account grant is a separate deliberate decision that lives in
|
|
63
|
+
* the console and covers machines this one knows nothing about. Signing out of a laptop
|
|
64
|
+
* is not a statement about a desktop. `vincentt dx disable` is the verb that revokes the
|
|
65
|
+
* account grant; `logout` only ever clears local state, which also keeps it free of a
|
|
66
|
+
* network call that could fail and strand a sign-out.
|
|
59
67
|
*/
|
|
60
68
|
export declare function clearMachineToken(): Promise<{
|
|
61
69
|
path: string;
|
package/dist/shared/config.js
CHANGED
|
@@ -12,6 +12,7 @@ import { randomBytes } from "node:crypto";
|
|
|
12
12
|
import { promises as fs } from "node:fs";
|
|
13
13
|
import os from "node:os";
|
|
14
14
|
import path from "node:path";
|
|
15
|
+
import { recordLocation } from "./recordLocation.js";
|
|
15
16
|
export const MACHINE_CONFIG_PATH = path.join(os.homedir(), ".vincentt", "config.json");
|
|
16
17
|
const BINDING_DIR = ".vincentt";
|
|
17
18
|
const BINDING_FILE = "project.json";
|
|
@@ -111,6 +112,14 @@ export async function writeDxEnablement(next) {
|
|
|
111
112
|
* other field by design, which would leave a disconnected machine still armed to
|
|
112
113
|
* transmit for whoever signs in next — a creator who disconnects a machine has
|
|
113
114
|
* disconnected it.
|
|
115
|
+
*
|
|
116
|
+
* The ACCOUNT-WIDE grant is deliberately NOT revoked here, and that asymmetry is the
|
|
117
|
+
* point: the local flag is a statement about this machine, so disconnecting the machine
|
|
118
|
+
* retracts it, while the account grant is a separate deliberate decision that lives in
|
|
119
|
+
* the console and covers machines this one knows nothing about. Signing out of a laptop
|
|
120
|
+
* is not a statement about a desktop. `vincentt dx disable` is the verb that revokes the
|
|
121
|
+
* account grant; `logout` only ever clears local state, which also keeps it free of a
|
|
122
|
+
* network call that could fail and strand a sign-out.
|
|
114
123
|
*/
|
|
115
124
|
export async function clearMachineToken() {
|
|
116
125
|
const current = await loadMachineConfig();
|
|
@@ -137,8 +146,12 @@ export async function locateProjectBinding(cwd) {
|
|
|
137
146
|
let dir = path.resolve(cwd);
|
|
138
147
|
for (;;) {
|
|
139
148
|
const binding = await readJsonIfExists(projectBindingPath(dir));
|
|
140
|
-
if (binding)
|
|
149
|
+
if (binding) {
|
|
150
|
+
// The ONE success return, so every verb that resolves a binding records
|
|
151
|
+
// its folder without a per-verb checklist. Best-effort and synchronous.
|
|
152
|
+
recordLocation(binding.projectId, dir);
|
|
141
153
|
return { binding, dir, path: projectBindingPath(dir) };
|
|
154
|
+
}
|
|
142
155
|
if (await isGitRoot(dir))
|
|
143
156
|
return undefined;
|
|
144
157
|
const parent = path.dirname(dir);
|
|
@@ -165,6 +178,10 @@ export async function writeProjectBinding(cwd, binding) {
|
|
|
165
178
|
const p = projectBindingPath(cwd);
|
|
166
179
|
await fs.writeFile(p, JSON.stringify(binding, null, 2) + "\n", "utf8");
|
|
167
180
|
await ensureGitignored(cwd, `${BINDING_DIR}/`);
|
|
181
|
+
// `create` and `link` WRITE a binding rather than resolve one, so they never
|
|
182
|
+
// pass through the resolver's hook. Between the two, every way a folder comes
|
|
183
|
+
// to name a project records that folder.
|
|
184
|
+
recordLocation(binding.projectId, path.resolve(cwd));
|
|
168
185
|
return p;
|
|
169
186
|
}
|
|
170
187
|
/** Remove the per-tree binding so the directory can be re-created/rebound. */
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export interface LocationEntry {
|
|
2
|
+
/** The project id — the join key against the listing's API rows. */
|
|
3
|
+
p: string;
|
|
4
|
+
/**
|
|
5
|
+
* The absolute directory holding `.vincentt/project.json`, REALPATH-resolved.
|
|
6
|
+
* Not the cwd: the cwd may be a subdirectory of it.
|
|
7
|
+
*/
|
|
8
|
+
d: string;
|
|
9
|
+
/** ISO8601 UTC, when the entry was last written. The presentation tiebreak. */
|
|
10
|
+
t: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* An entry is a DISTINCT FOLDER, not a run, so the bound is far smaller than the
|
|
14
|
+
* journal's 2000. 500 folders is beyond any real creator and holds the file
|
|
15
|
+
* under ~60 KB.
|
|
16
|
+
*/
|
|
17
|
+
export declare const MAX_ENTRIES = 500;
|
|
18
|
+
/**
|
|
19
|
+
* Resolved per call rather than captured at module load — the same discipline
|
|
20
|
+
* and the same reason as `journalPath()`: the home directory is the seam tests
|
|
21
|
+
* move, and a frozen path silently writes the developer's real one.
|
|
22
|
+
*/
|
|
23
|
+
export declare function locationsPath(): string;
|
|
24
|
+
/** The store's whole surface, so the listing can be driven with no filesystem. */
|
|
25
|
+
export interface LocationStore {
|
|
26
|
+
read(): LocationEntry[];
|
|
27
|
+
append(entry: LocationEntry): void;
|
|
28
|
+
compact(keep: LocationEntry[]): void;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Read oldest-first. Malformed lines are skipped, and an unreadable file yields
|
|
32
|
+
* an empty list — a store that FAILS is indistinguishable from a store that is
|
|
33
|
+
* EMPTY, which is the correct blast radius: a fresh install is exactly that
|
|
34
|
+
* state and is fully supported.
|
|
35
|
+
*/
|
|
36
|
+
export declare function readLocations(): LocationEntry[];
|
|
37
|
+
/**
|
|
38
|
+
* Does the file end in a newline, or is there nothing there yet?
|
|
39
|
+
*
|
|
40
|
+
* Two syscalls against the page cache (`statSync` for the size, a one-byte read
|
|
41
|
+
* at `size - 1`), skipped entirely when the file is absent or empty. Writing
|
|
42
|
+
* `"\n" + line` unconditionally was rejected: it makes a leading blank line the
|
|
43
|
+
* normal case the reader must then tolerate, hiding the torn-write signal
|
|
44
|
+
* instead of repairing it.
|
|
45
|
+
*/
|
|
46
|
+
export declare function needsLeadingNewline(file: string): boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Append one entry. Never throws, for the journal's stated reason:
|
|
49
|
+
* instrumentation that breaks the tool it measures is worse than none. Every
|
|
50
|
+
* failure mode here (a read-only home, a full disk, a directory where the file
|
|
51
|
+
* should be) is one the creator would rather have silently ignored than turned
|
|
52
|
+
* into a failed `publish`.
|
|
53
|
+
*
|
|
54
|
+
* `locationsPath()` is INSIDE the try because it calls `os.homedir()`, which can
|
|
55
|
+
* itself throw on a hostile environment.
|
|
56
|
+
*/
|
|
57
|
+
export declare function appendLocation(entry: LocationEntry): void;
|
|
58
|
+
/**
|
|
59
|
+
* Collapse duplicate `(p, d)` pairs to their newest `t`, then keep the newest
|
|
60
|
+
* MAX_ENTRIES. Returned oldest-first, which is the order the file holds.
|
|
61
|
+
*/
|
|
62
|
+
export declare function compactEntries(entries: LocationEntry[]): LocationEntry[];
|
|
63
|
+
/**
|
|
64
|
+
* Rewrite the store with the survivors, ATOMICALLY: a unique temp file at 0600,
|
|
65
|
+
* chmod'ed explicitly, renamed over the target, and removed if the rename fails.
|
|
66
|
+
* The discipline `persistMachineConfig` already uses for the credential file.
|
|
67
|
+
*
|
|
68
|
+
* Rewrite-with-survivors rather than delete-lines, so a crash leaves either the
|
|
69
|
+
* original file or the fully compacted one and never a truncated store — which
|
|
70
|
+
* would be indistinguishable from an empty one BY DESIGN, and therefore
|
|
71
|
+
* invisible at every behavioral tier.
|
|
72
|
+
*
|
|
73
|
+
* An append landing inside the rename window is lost. That is accepted and
|
|
74
|
+
* self-healing (the folder that appended is a folder a verb just ran in), and it
|
|
75
|
+
* is the one place the no-lock choice has a visible cost.
|
|
76
|
+
*
|
|
77
|
+
* Never throws: it runs AFTER the answer has printed, and a compaction that
|
|
78
|
+
* fails must not delay or break it.
|
|
79
|
+
*/
|
|
80
|
+
export declare function compactLocations(keep: LocationEntry[]): void;
|
|
81
|
+
/** The real store, reading and writing the creator's own home directory. */
|
|
82
|
+
export declare const fileLocationStore: LocationStore;
|