@vincentt-xr/cli 0.5.0 → 0.6.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/dist/cli/args.d.ts +2 -0
- package/dist/cli/args.js +4 -1
- package/dist/cli/index.js +59 -7
- package/dist/dx/clusters.d.ts +41 -0
- package/dist/dx/clusters.js +71 -0
- package/dist/dx/command.d.ts +17 -0
- package/dist/dx/command.js +95 -0
- package/dist/dx/journal.d.ts +51 -0
- package/dist/dx/journal.js +129 -0
- package/dist/dx/skeleton.d.ts +12 -0
- package/dist/dx/skeleton.js +55 -0
- package/dist/lib/feedback.js +12 -3
- package/dist/lib/lifecycle.js +7 -2
- package/dist/lib/relayInfo.d.ts +23 -0
- package/dist/lib/relayInfo.js +20 -2
- package/dist/login/pkceLogin.js +5 -1
- package/dist/preview/copy.d.ts +12 -0
- package/dist/preview/copy.js +22 -0
- package/dist/preview/runner.d.ts +13 -0
- package/dist/preview/runner.js +8 -3
- package/dist/publish/consoleVerbs.js +1 -1
- package/package.json +1 -1
package/dist/cli/args.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ export declare function isHelpInvocation(args: string[]): boolean;
|
|
|
13
13
|
* skip a publish the creator asked for. Anywhere else it stays an unknown flag.
|
|
14
14
|
*/
|
|
15
15
|
export declare function isVersionInvocation(args: string[]): boolean;
|
|
16
|
+
/** Flags that stand alone; every other `--flag` consumes the next argument. */
|
|
17
|
+
export declare const BOOLEAN_FLAGS: Set<string>;
|
|
16
18
|
export declare function refusedFlag(args: string[]): string | undefined;
|
|
17
19
|
/**
|
|
18
20
|
* The bare arguments, with flag VALUES removed. Without this, `vincentt link --dir
|
package/dist/cli/args.js
CHANGED
|
@@ -18,7 +18,7 @@ export function isVersionInvocation(args) {
|
|
|
18
18
|
return args[0] === "--version" || args[0] === "-v";
|
|
19
19
|
}
|
|
20
20
|
/** Flags that stand alone; every other `--flag` consumes the next argument. */
|
|
21
|
-
const BOOLEAN_FLAGS = new Set([
|
|
21
|
+
export const BOOLEAN_FLAGS = new Set([
|
|
22
22
|
"--force",
|
|
23
23
|
"--preview",
|
|
24
24
|
"--json",
|
|
@@ -26,6 +26,9 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
26
26
|
"--wait",
|
|
27
27
|
"--watch",
|
|
28
28
|
"--stop",
|
|
29
|
+
"--restart",
|
|
30
|
+
"--no-qr",
|
|
31
|
+
"--verbose",
|
|
29
32
|
"--no-install",
|
|
30
33
|
"--allow-custom-api",
|
|
31
34
|
"--yes",
|
package/dist/cli/index.js
CHANGED
|
@@ -31,14 +31,18 @@ import { consoleVerbCommand, isConsoleOnlyVerb } from "../publish/consoleVerbs.j
|
|
|
31
31
|
import { PreviewUi } from "../preview/command.js";
|
|
32
32
|
import { httpRelayClient } from "../lib/diagnostics.js";
|
|
33
33
|
import { readLivePreview } from "../lib/relayInfo.js";
|
|
34
|
-
import { mintRateLimited, notSignedIn, previewConflict, startBlock, } from "../preview/copy.js";
|
|
34
|
+
import { attachedToRunning, mintRateLimited, notSignedIn, previewConflict, startBlock, } from "../preview/copy.js";
|
|
35
35
|
import { renderTerminalQr, shouldRenderQr } from "../preview/qr.js";
|
|
36
36
|
import { PreviewConflictError, PreviewRateLimitedError } from "../preview/api.js";
|
|
37
37
|
import { fetchNext, readCursor, writeCursor } from "../lib/feedback.js";
|
|
38
|
+
import { record as recordInvocation } from "../dx/journal.js";
|
|
39
|
+
import { runDx } from "../dx/command.js";
|
|
38
40
|
import { isHelpInvocation, isVersionInvocation, positionalArgs, refusedFlag, } from "./args.js";
|
|
39
41
|
// The console origin serves /cli-auth (the browser target for `vincentt login`). VINCENTT_
|
|
40
42
|
// CONSOLE_URL overrides for dev/dogfood; VINCENTT_EDITOR_URL is honored as a legacy alias.
|
|
41
|
-
|
|
43
|
+
// This is the prod console; vincentt.app is the published-PROJECT apex, not the console, so a
|
|
44
|
+
// first-run `vincentt login` (no machine config yet) must default here or it opens a dead page.
|
|
45
|
+
const DEFAULT_CONSOLE_URL = "https://console.vincentt.studio";
|
|
42
46
|
const DEFAULT_API_URL = "https://api.vincentt.studio";
|
|
43
47
|
// ---- arg helpers ------------------------------------------------------------
|
|
44
48
|
const argv = process.argv.slice(2);
|
|
@@ -303,8 +307,23 @@ async function preview() {
|
|
|
303
307
|
// treated as absent, which lands on the mint the creator actually asked for.
|
|
304
308
|
const existing = await readLivePreview(cwd);
|
|
305
309
|
if (existing !== undefined) {
|
|
306
|
-
|
|
307
|
-
|
|
310
|
+
if (has("--restart")) {
|
|
311
|
+
// Asked for explicitly, so the old session is ended before the mint —
|
|
312
|
+
// otherwise the server's supersede rule would race the new one.
|
|
313
|
+
await previewStopSignal(cwd);
|
|
314
|
+
}
|
|
315
|
+
else if (filterTester === undefined && filterViewer === undefined) {
|
|
316
|
+
// No filter means the caller wanted the preview, not a diagnostics stream:
|
|
317
|
+
// hand them the link and exit rather than blocking in the follow loop.
|
|
318
|
+
// Following a live preview is what `vincentt logs`/`network`/`trace` are.
|
|
319
|
+
for (const l of attachedToRunning(existing.url, existing.expiresAt))
|
|
320
|
+
console.log(l);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
await followPreview(existing, ui, filterTester, filterViewer);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
308
327
|
}
|
|
309
328
|
let ending = null;
|
|
310
329
|
let resolveDone;
|
|
@@ -400,6 +419,10 @@ async function preview() {
|
|
|
400
419
|
*/
|
|
401
420
|
async function followPreview(info, ui, filterTester, filterViewer) {
|
|
402
421
|
console.log("→ Attaching to the preview running in this folder");
|
|
422
|
+
// The link, on its own line: a caller who is following a tester still needs to
|
|
423
|
+
// know which preview they landed on, and re-running to find out is the loop
|
|
424
|
+
// this printing exists to break.
|
|
425
|
+
console.log(info.url);
|
|
403
426
|
const following = filterTester ?? filterViewer;
|
|
404
427
|
if (following !== undefined) {
|
|
405
428
|
console.log(`✓ Following ${following}`);
|
|
@@ -591,7 +614,8 @@ function usage() {
|
|
|
591
614
|
` publish Upload the built dist; go live at <slug>.vincentt.app\n` +
|
|
592
615
|
` [--dist <path>] [--note <s>] [--yes]\n` +
|
|
593
616
|
` preview Serve the app at an https link a phone can open\n` +
|
|
594
|
-
` [--port <n>] [--tester <id>] [--no-qr] [--stop]\n
|
|
617
|
+
` [--port <n>] [--tester <id>] [--no-qr] [--stop]\n` +
|
|
618
|
+
` Re-running prints the live link; --restart mints a new one.\n\n` +
|
|
595
619
|
`Diagnostics (need a running preview)\n` +
|
|
596
620
|
` logs [--errors] Console logs from the device [--json] [--tester <id>]\n` +
|
|
597
621
|
` network fetch/XHR requests [--json] [--tester <id>]\n` +
|
|
@@ -605,7 +629,9 @@ function usage() {
|
|
|
605
629
|
`Servers\n` +
|
|
606
630
|
` relay Run the diagnostics/annotation relay\n\n` +
|
|
607
631
|
`Meta\n` +
|
|
608
|
-
` version CLI version, and the API this machine resolves to\n
|
|
632
|
+
` version CLI version, and the API this machine resolves to\n` +
|
|
633
|
+
` dx Where this dev loop fought you [--days <n>] [--json]\n` +
|
|
634
|
+
` Reads a local journal of your own runs. Never sent.\n\n` +
|
|
609
635
|
`Common options: --dir <path> (project dir, default cwd)\n` +
|
|
610
636
|
` --allow-custom-api honor a binding's apiUrl override\n`);
|
|
611
637
|
}
|
|
@@ -653,6 +679,9 @@ async function main() {
|
|
|
653
679
|
return feedback();
|
|
654
680
|
case "relay":
|
|
655
681
|
return relay();
|
|
682
|
+
case "dx":
|
|
683
|
+
process.exitCode = runDx(argv, (l) => console.log(l));
|
|
684
|
+
return;
|
|
656
685
|
default:
|
|
657
686
|
// Flow L30: rolling back and taking an address offline are console
|
|
658
687
|
// actions, and a bare "unknown command" here would be the product failing
|
|
@@ -664,7 +693,30 @@ async function main() {
|
|
|
664
693
|
process.exitCode = 1;
|
|
665
694
|
}
|
|
666
695
|
}
|
|
667
|
-
|
|
696
|
+
// Every invocation is journaled here, at the one site that sees all of them —
|
|
697
|
+
// the verbs, both pre-switch gates, the console-only fallback and the unknown
|
|
698
|
+
// command. Recording is best-effort and local-only (see dx/journal.ts); it never
|
|
699
|
+
// changes what the CLI does or what it exits with.
|
|
700
|
+
const startedAt = Date.now();
|
|
701
|
+
function journal(exitCode, error) {
|
|
702
|
+
recordInvocation({
|
|
703
|
+
argv,
|
|
704
|
+
cliVersion: cliVersion(),
|
|
705
|
+
cwd: process.cwd(),
|
|
706
|
+
startedAt,
|
|
707
|
+
endedAt: Date.now(),
|
|
708
|
+
exitCode,
|
|
709
|
+
error,
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
main()
|
|
713
|
+
.then(() => {
|
|
714
|
+
// Verbs report failure by assigning process.exitCode and returning, so the
|
|
715
|
+
// resolved path is not necessarily the successful one.
|
|
716
|
+
journal(Number(process.exitCode ?? 0));
|
|
717
|
+
})
|
|
718
|
+
.catch((err) => {
|
|
668
719
|
console.error(`\n✗ ${err instanceof Error ? err.message : String(err)}`);
|
|
720
|
+
journal(1, err);
|
|
669
721
|
process.exit(1);
|
|
670
722
|
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Friction detection over journal entries.
|
|
3
|
+
*
|
|
4
|
+
* Two cluster types, because the two halves of "DX" fail differently:
|
|
5
|
+
*
|
|
6
|
+
* - `error` — the caller fought the tool and it pushed back. This is the
|
|
7
|
+
* classic retry loop (guessed a flag, got an error, tried again).
|
|
8
|
+
* - `repetition` — the caller ran the same thing repeatedly and it WORKED
|
|
9
|
+
* every time. No error is ever raised, so an error-keyed detector is blind
|
|
10
|
+
* to it, yet it is friction: the caller didn't know the first run had
|
|
11
|
+
* already given them what they needed. The reported case that motivated
|
|
12
|
+
* this file was exactly that shape — repeated `preview` runs, each one
|
|
13
|
+
* succeeding, each one minting a fresh link.
|
|
14
|
+
*
|
|
15
|
+
* Detection is deliberately loose. A false positive costs a line in a report a
|
|
16
|
+
* human is already reading; a miss costs a DX gap nobody ever hears about.
|
|
17
|
+
*/
|
|
18
|
+
import type { JournalEntry } from "./journal.js";
|
|
19
|
+
export type ClusterKind = "error" | "repetition";
|
|
20
|
+
export interface Cluster {
|
|
21
|
+
kind: ClusterKind;
|
|
22
|
+
skeleton: string;
|
|
23
|
+
entries: JournalEntry[];
|
|
24
|
+
/** Wall time from the first to the last call in the run, in ms. */
|
|
25
|
+
spanMs: number;
|
|
26
|
+
errors: number;
|
|
27
|
+
/** True when the run ended on a success — the caller got there eventually. */
|
|
28
|
+
recovered: boolean;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Group consecutive same-skeleton entries and keep the runs that look like
|
|
32
|
+
* friction. Entries are expected in chronological order.
|
|
33
|
+
*/
|
|
34
|
+
export declare function findClusters(entries: JournalEntry[]): Cluster[];
|
|
35
|
+
/**
|
|
36
|
+
* Rank by cost × recurrence: a cluster's cost is the calls it wasted, and a
|
|
37
|
+
* skeleton seen across several clusters is a recurring gap rather than a bad
|
|
38
|
+
* afternoon. Error clusters outrank repetition ones at equal weight — the
|
|
39
|
+
* caller was actively blocked rather than merely inefficient.
|
|
40
|
+
*/
|
|
41
|
+
export declare function rankClusters(clusters: Cluster[]): Cluster[];
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** A repetition run must be at least this long to count as friction. */
|
|
2
|
+
const REPETITION_MIN = 3;
|
|
3
|
+
/**
|
|
4
|
+
* How close together repeated calls must sit to read as one struggle. Three
|
|
5
|
+
* `preview` runs across a working day are three sessions; three inside ten
|
|
6
|
+
* minutes are one caller not getting what they needed.
|
|
7
|
+
*/
|
|
8
|
+
const REPETITION_WINDOW_MS = 10 * 60 * 1000;
|
|
9
|
+
function span(run) {
|
|
10
|
+
const first = Date.parse(run[0].ts);
|
|
11
|
+
const last = Date.parse(run[run.length - 1].ts);
|
|
12
|
+
return Number.isFinite(first) && Number.isFinite(last) ? last - first : 0;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Group consecutive same-skeleton entries and keep the runs that look like
|
|
16
|
+
* friction. Entries are expected in chronological order.
|
|
17
|
+
*/
|
|
18
|
+
export function findClusters(entries) {
|
|
19
|
+
const clusters = [];
|
|
20
|
+
for (let i = 0; i < entries.length;) {
|
|
21
|
+
const skel = entries[i].skel;
|
|
22
|
+
const run = [];
|
|
23
|
+
let j = i;
|
|
24
|
+
while (j < entries.length && entries[j].skel === skel)
|
|
25
|
+
run.push(entries[j++]);
|
|
26
|
+
const errors = run.filter((e) => e.exit !== 0).length;
|
|
27
|
+
const spanMs = span(run);
|
|
28
|
+
// A cluster needs at least two calls. A lone failure is usually the tool
|
|
29
|
+
// working — a signed-out `whoami`, a typo'd verb — and reporting each one
|
|
30
|
+
// buries the real findings under noise, which is the failure mode that
|
|
31
|
+
// trains a reader to skim past the report entirely.
|
|
32
|
+
if (errors > 0 && run.length >= 2) {
|
|
33
|
+
clusters.push({
|
|
34
|
+
kind: "error",
|
|
35
|
+
skeleton: skel,
|
|
36
|
+
entries: run,
|
|
37
|
+
spanMs,
|
|
38
|
+
errors,
|
|
39
|
+
recovered: run[run.length - 1].exit === 0,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
else if (errors === 0 &&
|
|
43
|
+
run.length >= REPETITION_MIN &&
|
|
44
|
+
spanMs <= REPETITION_WINDOW_MS) {
|
|
45
|
+
clusters.push({
|
|
46
|
+
kind: "repetition",
|
|
47
|
+
skeleton: skel,
|
|
48
|
+
entries: run,
|
|
49
|
+
spanMs,
|
|
50
|
+
errors: 0,
|
|
51
|
+
recovered: true,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
i = j;
|
|
55
|
+
}
|
|
56
|
+
return clusters;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Rank by cost × recurrence: a cluster's cost is the calls it wasted, and a
|
|
60
|
+
* skeleton seen across several clusters is a recurring gap rather than a bad
|
|
61
|
+
* afternoon. Error clusters outrank repetition ones at equal weight — the
|
|
62
|
+
* caller was actively blocked rather than merely inefficient.
|
|
63
|
+
*/
|
|
64
|
+
export function rankClusters(clusters) {
|
|
65
|
+
const recurrence = new Map();
|
|
66
|
+
for (const c of clusters) {
|
|
67
|
+
recurrence.set(c.skeleton, (recurrence.get(c.skeleton) ?? 0) + 1);
|
|
68
|
+
}
|
|
69
|
+
const weight = (c) => c.entries.length * (recurrence.get(c.skeleton) ?? 1) * (c.kind === "error" ? 2 : 1);
|
|
70
|
+
return [...clusters].sort((a, b) => weight(b) - weight(a));
|
|
71
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { JournalEntry } from "./journal.js";
|
|
2
|
+
export interface DxDeps {
|
|
3
|
+
entries: () => JournalEntry[];
|
|
4
|
+
print: (line: string) => void;
|
|
5
|
+
now: () => number;
|
|
6
|
+
enabled: () => boolean;
|
|
7
|
+
path: () => string;
|
|
8
|
+
}
|
|
9
|
+
export interface DxOptions {
|
|
10
|
+
/** Only consider runs from the last N days. */
|
|
11
|
+
days: number;
|
|
12
|
+
json: boolean;
|
|
13
|
+
}
|
|
14
|
+
export declare function parseDxOptions(args: string[]): DxOptions;
|
|
15
|
+
export declare function dxCommand(deps: DxDeps, options: DxOptions): number;
|
|
16
|
+
/** Wire the command to the real journal. */
|
|
17
|
+
export declare function runDx(args: string[], print: (line: string) => void): number;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { findClusters, rankClusters } from "./clusters.js";
|
|
2
|
+
import { journalEnabled, journalPath, readJournal } from "./journal.js";
|
|
3
|
+
const DEFAULT_DAYS = 7;
|
|
4
|
+
export function parseDxOptions(args) {
|
|
5
|
+
const i = args.indexOf("--days");
|
|
6
|
+
const raw = i !== -1 ? Number(args[i + 1]) : NaN;
|
|
7
|
+
return {
|
|
8
|
+
days: Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_DAYS,
|
|
9
|
+
json: args.includes("--json"),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
function withinDays(entries, days, now) {
|
|
13
|
+
const cutoff = now - days * 24 * 60 * 60 * 1000;
|
|
14
|
+
return entries.filter((e) => {
|
|
15
|
+
const t = Date.parse(e.ts);
|
|
16
|
+
return Number.isFinite(t) && t >= cutoff;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
function seconds(ms) {
|
|
20
|
+
if (ms < 1000)
|
|
21
|
+
return `${ms}ms`;
|
|
22
|
+
const s = Math.round(ms / 1000);
|
|
23
|
+
if (s < 60)
|
|
24
|
+
return `${s}s`;
|
|
25
|
+
return `${Math.round(s / 60)}m`;
|
|
26
|
+
}
|
|
27
|
+
function describe(c) {
|
|
28
|
+
const head = c.kind === "error"
|
|
29
|
+
? `${c.entries.length}× ${c.skeleton} — ${c.errors} failed${c.recovered ? ", then succeeded" : ", never recovered"}`
|
|
30
|
+
: `${c.entries.length}× ${c.skeleton} — all succeeded, within ${seconds(c.spanMs)}`;
|
|
31
|
+
const why = c.kind === "error"
|
|
32
|
+
? c.recovered
|
|
33
|
+
? " The caller got there in the end. What did the first failure not tell them?"
|
|
34
|
+
: " The caller retried, then gave up. This one blocked real work."
|
|
35
|
+
: " Repeated with no error: the caller did not get what they needed the first time.";
|
|
36
|
+
const errs = [
|
|
37
|
+
...new Set(c.entries.map((e) => e.err).filter((e) => e !== null)),
|
|
38
|
+
];
|
|
39
|
+
const lines = [`• ${head}`, why];
|
|
40
|
+
if (errs.length > 0)
|
|
41
|
+
lines.push(` Errors: ${errs.join(", ")}`);
|
|
42
|
+
return lines;
|
|
43
|
+
}
|
|
44
|
+
export function dxCommand(deps, options) {
|
|
45
|
+
if (!deps.enabled()) {
|
|
46
|
+
deps.print("The DX journal is off (VINCENTT_DX_JOURNAL=0). Nothing is being recorded.");
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
const all = deps.entries();
|
|
50
|
+
if (all.length === 0) {
|
|
51
|
+
deps.print("No journal yet — it fills as you use the CLI.");
|
|
52
|
+
deps.print(` ${deps.path()}`);
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
const entries = withinDays(all, options.days, deps.now());
|
|
56
|
+
const clusters = rankClusters(findClusters(entries));
|
|
57
|
+
if (options.json) {
|
|
58
|
+
deps.print(JSON.stringify({ window: `${options.days}d`, runs: entries.length, clusters }, null, 2));
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
deps.print(`${entries.length} runs in the last ${options.days} days.`);
|
|
62
|
+
deps.print("");
|
|
63
|
+
if (clusters.length === 0) {
|
|
64
|
+
// A clean window is a real result. Manufacturing a finding to fill the
|
|
65
|
+
// report is how a signal becomes noise.
|
|
66
|
+
deps.print("No friction detected.");
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
deps.print(`${clusters.length} friction ${clusters.length === 1 ? "point" : "points"}, worst first:`);
|
|
70
|
+
deps.print("");
|
|
71
|
+
for (const c of clusters) {
|
|
72
|
+
for (const l of describe(c))
|
|
73
|
+
deps.print(l);
|
|
74
|
+
deps.print("");
|
|
75
|
+
}
|
|
76
|
+
deps.print("If an agent ran these, ask it what it was trying to do — the journal");
|
|
77
|
+
deps.print("records the shape of a run, not the intent behind it.");
|
|
78
|
+
}
|
|
79
|
+
// Printed on every report, findings or not: where the file is and that it
|
|
80
|
+
// stays put is the claim that lets this run without asking permission first,
|
|
81
|
+
// and a claim only made on some runs is one a reader cannot rely on.
|
|
82
|
+
deps.print("");
|
|
83
|
+
deps.print(`Journal: ${deps.path()} (local only, never sent)`);
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
/** Wire the command to the real journal. */
|
|
87
|
+
export function runDx(args, print) {
|
|
88
|
+
return dxCommand({
|
|
89
|
+
entries: readJournal,
|
|
90
|
+
print,
|
|
91
|
+
now: () => Date.now(),
|
|
92
|
+
enabled: () => journalEnabled(),
|
|
93
|
+
path: journalPath,
|
|
94
|
+
}, parseDxOptions(args));
|
|
95
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export interface JournalEntry {
|
|
2
|
+
/** ISO8601 UTC, invocation start. */
|
|
3
|
+
ts: string;
|
|
4
|
+
/** CLI version, so a finding can be dropped once the fix ships. */
|
|
5
|
+
v: string;
|
|
6
|
+
verb: string;
|
|
7
|
+
/** Flag names with values elided — the retry-cluster key. */
|
|
8
|
+
skel: string;
|
|
9
|
+
exit: number;
|
|
10
|
+
ms: number;
|
|
11
|
+
/** Error CLASS (`err.name`), never a message — messages carry user data. */
|
|
12
|
+
err: string | null;
|
|
13
|
+
/** Hash of the working tree, so runs group by project without recording where it is. */
|
|
14
|
+
wt: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Beside the machine config, so the journal shares the home the PAT already
|
|
18
|
+
* lives in — and so a run made outside any project (`login`, `version`) is still
|
|
19
|
+
* recorded, which a per-project file could not do.
|
|
20
|
+
*
|
|
21
|
+
* Resolved per call rather than captured at module load: the home directory is
|
|
22
|
+
* the seam tests move, and a frozen path would silently write to the real one.
|
|
23
|
+
*/
|
|
24
|
+
export declare function journalPath(): string;
|
|
25
|
+
/** `VINCENTT_DX_JOURNAL=0` turns recording off. A local file still needs an off switch. */
|
|
26
|
+
export declare function journalEnabled(env?: NodeJS.ProcessEnv): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* A stable, non-reversible handle for "the folder this run happened in". Enough
|
|
29
|
+
* to group runs by project — which is what makes the one-session-per-tree
|
|
30
|
+
* friction visible — without recording anyone's directory layout.
|
|
31
|
+
*/
|
|
32
|
+
export declare function workingTreeId(cwd: string): string;
|
|
33
|
+
export interface RecordInput {
|
|
34
|
+
argv: string[];
|
|
35
|
+
cliVersion: string;
|
|
36
|
+
cwd: string;
|
|
37
|
+
startedAt: number;
|
|
38
|
+
endedAt: number;
|
|
39
|
+
exitCode: number;
|
|
40
|
+
error?: unknown;
|
|
41
|
+
}
|
|
42
|
+
export declare function buildEntry(input: RecordInput): JournalEntry;
|
|
43
|
+
/**
|
|
44
|
+
* Append one entry. Never throws: a journal that can break a publish is a
|
|
45
|
+
* liability, and every failure mode here (read-only home, full disk, a
|
|
46
|
+
* concurrent writer) is one the creator would rather have silently ignored than
|
|
47
|
+
* turned into a failed command.
|
|
48
|
+
*/
|
|
49
|
+
export declare function record(input: RecordInput): void;
|
|
50
|
+
/** Read the journal oldest-first. Malformed lines are skipped, not fatal. */
|
|
51
|
+
export declare function readJournal(): JournalEntry[];
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The DX journal — one line per `vincentt` invocation, on the creator's own
|
|
3
|
+
* machine, never transmitted.
|
|
4
|
+
*
|
|
5
|
+
* This is instrumentation without transmission. The file is the creator's, the
|
|
6
|
+
* way a shell history is: they can read it, delete it, or turn it off. Nothing
|
|
7
|
+
* here contacts the network, and nothing outside this module may make it. That
|
|
8
|
+
* property is what keeps the journal clear of the diagnostics-retention rule
|
|
9
|
+
* (B19) — there is no platform side to it.
|
|
10
|
+
*
|
|
11
|
+
* What it records is deliberately narrow: the SHAPE of a run (verb, flag names,
|
|
12
|
+
* exit code, duration), never its CONTENT (no flag values, no paths, no error
|
|
13
|
+
* messages, no output). A slug, a project id, or a token typed on a command
|
|
14
|
+
* line must never reach this file — see `skeleton()`.
|
|
15
|
+
*
|
|
16
|
+
* Writes are best-effort and synchronous. Best-effort because instrumentation
|
|
17
|
+
* that breaks the tool it measures is worse than no instrumentation; synchronous
|
|
18
|
+
* because the CLI's top-level handler calls `process.exit(1)`, which abandons a
|
|
19
|
+
* pending async write.
|
|
20
|
+
*/
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import os from "node:os";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import { createHash } from "node:crypto";
|
|
25
|
+
import { skeleton, verbOf } from "./skeleton.js";
|
|
26
|
+
const JOURNAL_FILE = "dx.ndjson";
|
|
27
|
+
/** Keep the journal bounded: a rolling window, trimmed on write. */
|
|
28
|
+
const MAX_ENTRIES = 2000;
|
|
29
|
+
/**
|
|
30
|
+
* Beside the machine config, so the journal shares the home the PAT already
|
|
31
|
+
* lives in — and so a run made outside any project (`login`, `version`) is still
|
|
32
|
+
* recorded, which a per-project file could not do.
|
|
33
|
+
*
|
|
34
|
+
* Resolved per call rather than captured at module load: the home directory is
|
|
35
|
+
* the seam tests move, and a frozen path would silently write to the real one.
|
|
36
|
+
*/
|
|
37
|
+
export function journalPath() {
|
|
38
|
+
return path.join(os.homedir(), ".vincentt", JOURNAL_FILE);
|
|
39
|
+
}
|
|
40
|
+
/** `VINCENTT_DX_JOURNAL=0` turns recording off. A local file still needs an off switch. */
|
|
41
|
+
export function journalEnabled(env = process.env) {
|
|
42
|
+
return env.VINCENTT_DX_JOURNAL !== "0";
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A stable, non-reversible handle for "the folder this run happened in". Enough
|
|
46
|
+
* to group runs by project — which is what makes the one-session-per-tree
|
|
47
|
+
* friction visible — without recording anyone's directory layout.
|
|
48
|
+
*/
|
|
49
|
+
export function workingTreeId(cwd) {
|
|
50
|
+
return createHash("sha256").update(path.resolve(cwd)).digest("hex").slice(0, 12);
|
|
51
|
+
}
|
|
52
|
+
function classify(err) {
|
|
53
|
+
if (err === undefined || err === null)
|
|
54
|
+
return null;
|
|
55
|
+
return err instanceof Error ? err.name : "unknown";
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Trim to the newest MAX_ENTRIES. Reads the whole file, which is fine at this
|
|
59
|
+
* bound (a few hundred KB at worst) and keeps the writer a single atomic
|
|
60
|
+
* rewrite rather than a partial in-place edit.
|
|
61
|
+
*/
|
|
62
|
+
function trim(file) {
|
|
63
|
+
const lines = fs
|
|
64
|
+
.readFileSync(file, "utf8")
|
|
65
|
+
.split("\n")
|
|
66
|
+
.filter((l) => l.trim() !== "");
|
|
67
|
+
if (lines.length <= MAX_ENTRIES)
|
|
68
|
+
return;
|
|
69
|
+
fs.writeFileSync(file, lines.slice(-MAX_ENTRIES).join("\n") + "\n", "utf8");
|
|
70
|
+
}
|
|
71
|
+
export function buildEntry(input) {
|
|
72
|
+
return {
|
|
73
|
+
ts: new Date(input.startedAt).toISOString(),
|
|
74
|
+
v: input.cliVersion,
|
|
75
|
+
verb: verbOf(input.argv),
|
|
76
|
+
skel: skeleton(input.argv),
|
|
77
|
+
exit: input.exitCode,
|
|
78
|
+
ms: Math.max(0, input.endedAt - input.startedAt),
|
|
79
|
+
err: classify(input.error),
|
|
80
|
+
wt: workingTreeId(input.cwd),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Append one entry. Never throws: a journal that can break a publish is a
|
|
85
|
+
* liability, and every failure mode here (read-only home, full disk, a
|
|
86
|
+
* concurrent writer) is one the creator would rather have silently ignored than
|
|
87
|
+
* turned into a failed command.
|
|
88
|
+
*/
|
|
89
|
+
export function record(input) {
|
|
90
|
+
if (!journalEnabled())
|
|
91
|
+
return;
|
|
92
|
+
try {
|
|
93
|
+
const file = journalPath();
|
|
94
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
95
|
+
fs.appendFileSync(file, JSON.stringify(buildEntry(input)) + "\n", {
|
|
96
|
+
encoding: "utf8",
|
|
97
|
+
mode: 0o600,
|
|
98
|
+
});
|
|
99
|
+
// `mode` on appendFile only applies when it CREATES the file, so a journal
|
|
100
|
+
// that predates this (or a looser umask) would keep its old perms.
|
|
101
|
+
fs.chmodSync(file, 0o600);
|
|
102
|
+
trim(file);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// Instrumentation must not break the tool it measures.
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** Read the journal oldest-first. Malformed lines are skipped, not fatal. */
|
|
109
|
+
export function readJournal() {
|
|
110
|
+
let raw;
|
|
111
|
+
try {
|
|
112
|
+
raw = fs.readFileSync(journalPath(), "utf8");
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
const out = [];
|
|
118
|
+
for (const line of raw.split("\n")) {
|
|
119
|
+
if (!line.trim())
|
|
120
|
+
continue;
|
|
121
|
+
try {
|
|
122
|
+
out.push(JSON.parse(line));
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// A torn line from a killed process is not a reason to lose the rest.
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize an argv into `verb --flag=<v> <v>` form — the key a retry cluster
|
|
3
|
+
* groups on. Two calls that differ only in their values share a skeleton, which
|
|
4
|
+
* is what makes "the caller ran the same thing four times" detectable.
|
|
5
|
+
*/
|
|
6
|
+
export declare function skeleton(args: string[]): string;
|
|
7
|
+
/**
|
|
8
|
+
* The dispatched verb — the first bare token, skipping flags AND the values they
|
|
9
|
+
* consume. `vincentt --dir /tmp publish` runs publish, not `/tmp`; recording the
|
|
10
|
+
* path as the verb would both mis-bucket the run and leak the directory.
|
|
11
|
+
*/
|
|
12
|
+
export declare function verbOf(args: string[]): string;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The journal's privacy boundary lives here: a skeleton keeps flag NAMES (which
|
|
3
|
+
* carry the DX signal — a caller guessing `--name` when the flag is `--slug` is
|
|
4
|
+
* exactly the friction worth finding) and discards every VALUE (which carries
|
|
5
|
+
* the risk — slugs, paths, ids, anything typed on a command line).
|
|
6
|
+
*
|
|
7
|
+
* Value elision is not cosmetic. It is what lets `vincentt dx --send` ship a
|
|
8
|
+
* finding without a scrubbing pass, and what keeps the on-disk journal a
|
|
9
|
+
* shell-history-class artifact rather than a record of the creator's work.
|
|
10
|
+
*/
|
|
11
|
+
import { BOOLEAN_FLAGS } from "../cli/args.js";
|
|
12
|
+
const ELIDED = "<v>";
|
|
13
|
+
/**
|
|
14
|
+
* Normalize an argv into `verb --flag=<v> <v>` form — the key a retry cluster
|
|
15
|
+
* groups on. Two calls that differ only in their values share a skeleton, which
|
|
16
|
+
* is what makes "the caller ran the same thing four times" detectable.
|
|
17
|
+
*/
|
|
18
|
+
export function skeleton(args) {
|
|
19
|
+
const out = [];
|
|
20
|
+
for (let i = 0; i < args.length; i++) {
|
|
21
|
+
const a = args[i];
|
|
22
|
+
if (!a.startsWith("-")) {
|
|
23
|
+
// The verb is the first bare token and is kept verbatim; every later
|
|
24
|
+
// positional is a value (a slug, an id, a path) and is elided.
|
|
25
|
+
out.push(out.length === 0 ? a : ELIDED);
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const name = a.split("=")[0];
|
|
29
|
+
if (BOOLEAN_FLAGS.has(a)) {
|
|
30
|
+
out.push(name);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
// `--flag=value` carries its value inline; `--flag value` consumes the next
|
|
34
|
+
// argv entry. Both collapse to the same skeleton so they cluster together.
|
|
35
|
+
out.push(`${name}=${ELIDED}`);
|
|
36
|
+
if (!a.includes("="))
|
|
37
|
+
i++;
|
|
38
|
+
}
|
|
39
|
+
return out.join(" ");
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The dispatched verb — the first bare token, skipping flags AND the values they
|
|
43
|
+
* consume. `vincentt --dir /tmp publish` runs publish, not `/tmp`; recording the
|
|
44
|
+
* path as the verb would both mis-bucket the run and leak the directory.
|
|
45
|
+
*/
|
|
46
|
+
export function verbOf(args) {
|
|
47
|
+
for (let i = 0; i < args.length; i++) {
|
|
48
|
+
const a = args[i];
|
|
49
|
+
if (!a.startsWith("-"))
|
|
50
|
+
return a;
|
|
51
|
+
if (!BOOLEAN_FLAGS.has(a) && !a.includes("="))
|
|
52
|
+
i++;
|
|
53
|
+
}
|
|
54
|
+
return "";
|
|
55
|
+
}
|
package/dist/lib/feedback.js
CHANGED
|
@@ -5,15 +5,24 @@
|
|
|
5
5
|
// before that re-delivers on the next call.
|
|
6
6
|
import { promises as fs } from "node:fs";
|
|
7
7
|
import path from "node:path";
|
|
8
|
-
import { readRelayInfo } from "./relayInfo.js";
|
|
8
|
+
import { readRelayInfo, relayInfoDir } from "./relayInfo.js";
|
|
9
9
|
const CURSOR_FILE = ".vincentt/annotations/offset";
|
|
10
10
|
export function cursorPath(cwd) {
|
|
11
11
|
return path.join(cwd, CURSOR_FILE);
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* The cursor belongs to the tree, beside the annotations the relay writes. A
|
|
15
|
+
* cursor anchored on the raw cwd would restart at 0 when `vincentt feedback` is
|
|
16
|
+
* run from a different folder of the same project, re-delivering every
|
|
17
|
+
* annotation already handed off.
|
|
18
|
+
*/
|
|
19
|
+
async function treeCursorPath(cwd) {
|
|
20
|
+
return cursorPath(await relayInfoDir(cwd));
|
|
21
|
+
}
|
|
13
22
|
/** Last-consumed annotation seq for this project (0 if none consumed yet). */
|
|
14
23
|
export async function readCursor(cwd) {
|
|
15
24
|
try {
|
|
16
|
-
const raw = await fs.readFile(
|
|
25
|
+
const raw = await fs.readFile(await treeCursorPath(cwd), "utf8");
|
|
17
26
|
return Number(raw.trim()) || 0;
|
|
18
27
|
}
|
|
19
28
|
catch (err) {
|
|
@@ -24,7 +33,7 @@ export async function readCursor(cwd) {
|
|
|
24
33
|
}
|
|
25
34
|
/** Advance the cursor. Called ONLY after the annotation was handed off. */
|
|
26
35
|
export async function writeCursor(cwd, seq) {
|
|
27
|
-
const p =
|
|
36
|
+
const p = await treeCursorPath(cwd);
|
|
28
37
|
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
29
38
|
await fs.writeFile(p, `${seq}\n`, "utf8");
|
|
30
39
|
}
|
package/dist/lib/lifecycle.js
CHANGED
|
@@ -129,19 +129,24 @@ export async function previewStart(projectCwd, onLog, extra = {}) {
|
|
|
129
129
|
const preview = await startPreview({
|
|
130
130
|
...extra,
|
|
131
131
|
projectCwd,
|
|
132
|
+
projectRoot: located.dir,
|
|
132
133
|
config,
|
|
133
134
|
projectId: located.binding.projectId,
|
|
134
135
|
onLog,
|
|
135
136
|
});
|
|
136
137
|
activePreview = preview;
|
|
137
|
-
|
|
138
|
+
// The marker is the tree's, not the folder's: `readLivePreview` resolves the
|
|
139
|
+
// binding the same way, so a preview started in a subdirectory is still found
|
|
140
|
+
// from the repo root instead of minting a second live session for one tree.
|
|
141
|
+
activeProjectCwd = located.dir;
|
|
138
142
|
// Record the OS-assigned relay port so a separate `vincentt logs`/`feedback`
|
|
139
143
|
// invocation can find this preview's relay.
|
|
140
|
-
await writeRelayInfo(
|
|
144
|
+
await writeRelayInfo(located.dir, {
|
|
141
145
|
relayPort: preview.relayPort,
|
|
142
146
|
url: preview.url,
|
|
143
147
|
pid: process.pid,
|
|
144
148
|
harnessPath: "/__harness",
|
|
149
|
+
expiresAt: preview.expiresAt,
|
|
145
150
|
});
|
|
146
151
|
return { preview, started: true };
|
|
147
152
|
}
|
package/dist/lib/relayInfo.d.ts
CHANGED
|
@@ -7,9 +7,32 @@ export interface RelayInfo {
|
|
|
7
7
|
pid: number;
|
|
8
8
|
/** WS/HTTP path prefix routed to the relay (default /__harness). */
|
|
9
9
|
harnessPath: string;
|
|
10
|
+
/**
|
|
11
|
+
* When the session's hard maximum lifetime expires, ISO8601.
|
|
12
|
+
*
|
|
13
|
+
* A live marker proves the LOCAL relay is up; it does not prove the tunnel
|
|
14
|
+
* URL still resolves — a session can be superseded from another machine or
|
|
15
|
+
* hit its hard max while this process sits idle. Carrying the expiry lets a
|
|
16
|
+
* re-run say "this link is still good until X" instead of printing a URL it
|
|
17
|
+
* only assumes is live. Optional: markers written by an older CLI lack it.
|
|
18
|
+
*/
|
|
19
|
+
expiresAt?: string;
|
|
10
20
|
}
|
|
11
21
|
export declare function relayInfoPath(cwd: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* The directory a marker for this tree lives in: the binding's directory when
|
|
24
|
+
* there is one, else the cwd unchanged.
|
|
25
|
+
*
|
|
26
|
+
* A preview is one per WORKING TREE, and a binding is found by walking up from
|
|
27
|
+
* the cwd — so `apps/web/` and the repo root are the same tree and must find the
|
|
28
|
+
* same marker. Anchoring on the raw cwd instead made them two, and a second
|
|
29
|
+
* `vincentt preview` from the repo root minted a second live session for a
|
|
30
|
+
* project that already had one (the server could not supersede it either: its
|
|
31
|
+
* tree key is derived from the same path).
|
|
32
|
+
*/
|
|
33
|
+
export declare function relayInfoDir(cwd: string): Promise<string>;
|
|
12
34
|
export declare function writeRelayInfo(cwd: string, info: RelayInfo): Promise<string>;
|
|
35
|
+
/** Reads the marker for the TREE containing cwd, not only the folder itself. */
|
|
13
36
|
export declare function readRelayInfo(cwd: string): Promise<RelayInfo | undefined>;
|
|
14
37
|
export declare function clearRelayInfo(cwd: string): Promise<void>;
|
|
15
38
|
/**
|
package/dist/lib/relayInfo.js
CHANGED
|
@@ -4,20 +4,38 @@
|
|
|
4
4
|
// the gitignored .vincentt/ dir, so it never rides a commit.
|
|
5
5
|
import { promises as fs } from "node:fs";
|
|
6
6
|
import path from "node:path";
|
|
7
|
+
import { locateProjectBinding } from "../shared/config.js";
|
|
7
8
|
const RELAY_INFO_FILE = "relay.json";
|
|
8
9
|
const VINCENTT_DIR = ".vincentt";
|
|
9
10
|
export function relayInfoPath(cwd) {
|
|
10
11
|
return path.join(cwd, VINCENTT_DIR, RELAY_INFO_FILE);
|
|
11
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* The directory a marker for this tree lives in: the binding's directory when
|
|
15
|
+
* there is one, else the cwd unchanged.
|
|
16
|
+
*
|
|
17
|
+
* A preview is one per WORKING TREE, and a binding is found by walking up from
|
|
18
|
+
* the cwd — so `apps/web/` and the repo root are the same tree and must find the
|
|
19
|
+
* same marker. Anchoring on the raw cwd instead made them two, and a second
|
|
20
|
+
* `vincentt preview` from the repo root minted a second live session for a
|
|
21
|
+
* project that already had one (the server could not supersede it either: its
|
|
22
|
+
* tree key is derived from the same path).
|
|
23
|
+
*/
|
|
24
|
+
export async function relayInfoDir(cwd) {
|
|
25
|
+
const located = await locateProjectBinding(cwd);
|
|
26
|
+
return located?.dir ?? cwd;
|
|
27
|
+
}
|
|
12
28
|
export async function writeRelayInfo(cwd, info) {
|
|
13
29
|
const p = relayInfoPath(cwd);
|
|
14
30
|
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
15
31
|
await fs.writeFile(p, JSON.stringify(info, null, 2) + "\n", "utf8");
|
|
16
32
|
return p;
|
|
17
33
|
}
|
|
34
|
+
/** Reads the marker for the TREE containing cwd, not only the folder itself. */
|
|
18
35
|
export async function readRelayInfo(cwd) {
|
|
19
36
|
try {
|
|
20
|
-
|
|
37
|
+
const dir = await relayInfoDir(cwd);
|
|
38
|
+
return JSON.parse(await fs.readFile(relayInfoPath(dir), "utf8"));
|
|
21
39
|
}
|
|
22
40
|
catch (err) {
|
|
23
41
|
if (err.code === "ENOENT")
|
|
@@ -27,7 +45,7 @@ export async function readRelayInfo(cwd) {
|
|
|
27
45
|
}
|
|
28
46
|
export async function clearRelayInfo(cwd) {
|
|
29
47
|
try {
|
|
30
|
-
await fs.unlink(relayInfoPath(cwd));
|
|
48
|
+
await fs.unlink(relayInfoPath(await relayInfoDir(cwd)));
|
|
31
49
|
}
|
|
32
50
|
catch {
|
|
33
51
|
// Already gone — clearing a non-existent info file is a no-op.
|
package/dist/login/pkceLogin.js
CHANGED
|
@@ -95,7 +95,11 @@ export async function runPkceLogin(opts) {
|
|
|
95
95
|
const backend = opts.backend ?? httpAuthBackend(opts.apiUrl);
|
|
96
96
|
const pickPort = opts.pickPort ?? getFreePort;
|
|
97
97
|
const openBrowser = opts.openBrowser ?? defaultOpenBrowser;
|
|
98
|
-
|
|
98
|
+
// Persist consoleUrl alongside apiUrl + pat so a later `vincentt login` resolves the
|
|
99
|
+
// console origin from config instead of falling back to the DEFAULT_CONSOLE_URL — the
|
|
100
|
+
// resolution the very first login could not use because nothing was stored yet.
|
|
101
|
+
const saveToken = opts.saveToken ??
|
|
102
|
+
((apiUrl, pat) => writeMachineConfig({ apiUrl, pat, consoleUrl: opts.consoleUrl }));
|
|
99
103
|
const progress = opts.onProgress ?? (() => { });
|
|
100
104
|
const material = makePkceMaterial();
|
|
101
105
|
const port = await pickPort();
|
package/dist/preview/copy.d.ts
CHANGED
|
@@ -139,6 +139,18 @@ export declare function hardMaxWarning(clockTime: string, minutesLeft: number):
|
|
|
139
139
|
export declare function hardMaxReached(): string[];
|
|
140
140
|
/** c. Superseded — the terminal that LOST. */
|
|
141
141
|
export declare function supersededLoser(): string[];
|
|
142
|
+
/**
|
|
143
|
+
* Re-running `vincentt preview` on a folder that already has one attaches to it
|
|
144
|
+
* rather than minting a second — but a caller who is not SHOWN the link cannot
|
|
145
|
+
* tell the difference between "attached" and "failed to give me a URL". The
|
|
146
|
+
* reported behavior was an agent re-running preview until it saw a link, and
|
|
147
|
+
* each of those re-runs superseded the previous session: the link really did
|
|
148
|
+
* keep changing, because the attach never printed one.
|
|
149
|
+
*
|
|
150
|
+
* The URL renders bare on its own line, matching the start block, so anything
|
|
151
|
+
* scraping for a link finds it in the same shape either way.
|
|
152
|
+
*/
|
|
153
|
+
export declare function attachedToRunning(url: string, expiresAt?: string): string[];
|
|
142
154
|
/**
|
|
143
155
|
* c. Superseded — the terminal that WON. This sits ABOVE the `✓ Preview is live`
|
|
144
156
|
* block so the consequence is read before the URL is copied.
|
package/dist/preview/copy.js
CHANGED
|
@@ -299,6 +299,28 @@ export function supersededLoser() {
|
|
|
299
299
|
"",
|
|
300
300
|
];
|
|
301
301
|
}
|
|
302
|
+
/**
|
|
303
|
+
* Re-running `vincentt preview` on a folder that already has one attaches to it
|
|
304
|
+
* rather than minting a second — but a caller who is not SHOWN the link cannot
|
|
305
|
+
* tell the difference between "attached" and "failed to give me a URL". The
|
|
306
|
+
* reported behavior was an agent re-running preview until it saw a link, and
|
|
307
|
+
* each of those re-runs superseded the previous session: the link really did
|
|
308
|
+
* keep changing, because the attach never printed one.
|
|
309
|
+
*
|
|
310
|
+
* The URL renders bare on its own line, matching the start block, so anything
|
|
311
|
+
* scraping for a link finds it in the same shape either way.
|
|
312
|
+
*/
|
|
313
|
+
export function attachedToRunning(url, expiresAt) {
|
|
314
|
+
const lines = ["", "→ A preview is already running for this folder.", "", url, ""];
|
|
315
|
+
// Stated only when known: a marker written by an older CLI has no expiry, and
|
|
316
|
+
// inventing one would be the over-claim this copy exists to avoid.
|
|
317
|
+
if (expiresAt !== undefined) {
|
|
318
|
+
lines.push(` This link is good until ${expiresAt}.`);
|
|
319
|
+
}
|
|
320
|
+
lines.push(" Start a fresh one: vincentt preview --restart");
|
|
321
|
+
lines.push("");
|
|
322
|
+
return lines;
|
|
323
|
+
}
|
|
302
324
|
/**
|
|
303
325
|
* c. Superseded — the terminal that WON. This sits ABOVE the `✓ Preview is live`
|
|
304
326
|
* block so the consequence is read before the URL is copied.
|
package/dist/preview/runner.d.ts
CHANGED
|
@@ -33,6 +33,19 @@ export type PreviewEndReason = "stopped"
|
|
|
33
33
|
export interface StartPreviewOptions {
|
|
34
34
|
/** Project directory — its .vincentt binding + dev script drive the preview. */
|
|
35
35
|
projectCwd: string;
|
|
36
|
+
/**
|
|
37
|
+
* The directory holding the binding — the tree's IDENTITY, as distinct from
|
|
38
|
+
* the directory the dev server runs in.
|
|
39
|
+
*
|
|
40
|
+
* These differ whenever a creator runs `vincentt preview` from a subdirectory
|
|
41
|
+
* of a bound repo: `locateProjectBinding` walks up to find the binding, so the
|
|
42
|
+
* same project resolves from `apps/web/` and from the repo root. Keying the
|
|
43
|
+
* one-session-per-tree rule off the raw cwd made those two spellings two
|
|
44
|
+
* trees, which is precisely what `workingTreeKey` normalizes symlinks and
|
|
45
|
+
* relative paths to prevent. Defaults to projectCwd when the caller has not
|
|
46
|
+
* located a binding.
|
|
47
|
+
*/
|
|
48
|
+
projectRoot?: string;
|
|
36
49
|
/** The API host + PAT. Resolved by the caller so the runner makes no policy. */
|
|
37
50
|
config: ResolvedConfig;
|
|
38
51
|
projectId: string;
|
package/dist/preview/runner.js
CHANGED
|
@@ -22,7 +22,7 @@ import { getFreePort, isPortListening, waitForApp } from "./net.js";
|
|
|
22
22
|
import { endPreviewSession, heartbeatPreviewSession, mintPreviewSession, workingTreeKey, PreviewEndedError, } from "./api.js";
|
|
23
23
|
import { ProjectUnreachableError } from "../lib/backend.js";
|
|
24
24
|
export async function startPreview(opts) {
|
|
25
|
-
const { projectCwd, config, projectId, harnessPath = "/__harness", devCommand = ["npm", "run", "dev"], appStdio = "ignore", appReadyTimeoutMs = 8_000, onLog = () => undefined, fetchImpl = fetch, } = opts;
|
|
25
|
+
const { projectCwd, projectRoot = projectCwd, config, projectId, harnessPath = "/__harness", devCommand = ["npm", "run", "dev"], appStdio = "ignore", appReadyTimeoutMs = 8_000, onLog = () => undefined, fetchImpl = fetch, } = opts;
|
|
26
26
|
const appPort = opts.appPort ?? 5173;
|
|
27
27
|
const relayPort = opts.relayPort ?? (await getFreePort());
|
|
28
28
|
const frontPort = opts.frontPort ?? (await getFreePort());
|
|
@@ -56,7 +56,9 @@ export async function startPreview(opts) {
|
|
|
56
56
|
// The MINT comes first. Everything below it costs the creator a dev-server
|
|
57
57
|
// start and a port; if the mint is going to fail — rate limit, revoked
|
|
58
58
|
// membership, no binding — it should fail before any of that happens.
|
|
59
|
-
|
|
59
|
+
// Keyed on the tree's identity, not the folder the command was typed in:
|
|
60
|
+
// the supersede rule must see one tree however the creator addressed it.
|
|
61
|
+
const key = await workingTreeKey(projectRoot);
|
|
60
62
|
minted = await mintPreviewSession(config, projectId, key, fetchImpl);
|
|
61
63
|
if (reuseApp) {
|
|
62
64
|
onLog(`reusing the dev server already on :${appPort}`);
|
|
@@ -73,7 +75,10 @@ export async function startPreview(opts) {
|
|
|
73
75
|
relay = startRelay({
|
|
74
76
|
port: relayPort,
|
|
75
77
|
path: harnessPath,
|
|
76
|
-
|
|
78
|
+
// Annotations land beside the binding, so `vincentt feedback` finds them
|
|
79
|
+
// from anywhere in the tree rather than only from the folder that started
|
|
80
|
+
// the preview.
|
|
81
|
+
projectDir: projectRoot,
|
|
77
82
|
onLog: (m) => onLog(`[relay] ${m}`),
|
|
78
83
|
onEvents: opts.onEvents,
|
|
79
84
|
onAttribute: opts.onAttribute,
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
//
|
|
4
4
|
// This is ARGUMENT PARSING. Exit 1, no network call, nothing looked up.
|
|
5
5
|
import { CONSOLE_ONLY_VERBS, consoleVerb } from "./copy.js";
|
|
6
|
-
const DEFAULT_CONSOLE_URL = "https://console.vincentt.
|
|
6
|
+
const DEFAULT_CONSOLE_URL = "https://console.vincentt.studio";
|
|
7
7
|
export function isConsoleOnlyVerb(verb) {
|
|
8
8
|
return CONSOLE_ONLY_VERBS.includes(verb);
|
|
9
9
|
}
|
package/package.json
CHANGED