@vincentt-xr/cli 0.5.1 → 0.7.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 +5 -1
- package/dist/cli/index.js +110 -6
- package/dist/dx/clusters.d.ts +41 -0
- package/dist/dx/clusters.js +71 -0
- package/dist/dx/command.d.ts +50 -0
- package/dist/dx/command.js +167 -0
- package/dist/dx/journal.d.ts +51 -0
- package/dist/dx/journal.js +129 -0
- package/dist/dx/nudge.d.ts +7 -0
- package/dist/dx/nudge.js +54 -0
- package/dist/dx/send.d.ts +70 -0
- package/dist/dx/send.js +108 -0
- package/dist/dx/skeleton.d.ts +12 -0
- package/dist/dx/skeleton.js +138 -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/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/package.json +1 -1
|
@@ -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,7 @@
|
|
|
1
|
+
import type { JournalEntry } from "./journal.js";
|
|
2
|
+
/**
|
|
3
|
+
* The nudge fires only on a cluster the CURRENT run just completed, so it
|
|
4
|
+
* appears at the moment of friction rather than as a standing complaint about
|
|
5
|
+
* something that happened this morning.
|
|
6
|
+
*/
|
|
7
|
+
export declare function nudgeFor(entries: JournalEntry[], now: number): string[];
|
package/dist/dx/nudge.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The nudge: a one-line hint, printed after a command that just completed a
|
|
3
|
+
* friction cluster, telling the creator the report exists.
|
|
4
|
+
*
|
|
5
|
+
* It is here because a command nobody remembers produces no data. A creator
|
|
6
|
+
* building an AR app is not going to think "I should audit my tooling" — they
|
|
7
|
+
* are going to swear at the fourth preview and keep going. The moment the
|
|
8
|
+
* friction happens is the only moment the report is cheap to ask for.
|
|
9
|
+
*
|
|
10
|
+
* It is a HINT and never an action. Nothing is sent, nothing is queued, and the
|
|
11
|
+
* creator still has to type the command — which is what keeps `--send` an
|
|
12
|
+
* explicit act rather than a dialog they clicked through while annoyed.
|
|
13
|
+
*/
|
|
14
|
+
import { findClusters } from "./clusters.js";
|
|
15
|
+
/** How far back a nudge looks. A cluster older than this is not "right now". */
|
|
16
|
+
const WINDOW_MS = 15 * 60 * 1000;
|
|
17
|
+
/**
|
|
18
|
+
* The nudge fires only on a cluster the CURRENT run just completed, so it
|
|
19
|
+
* appears at the moment of friction rather than as a standing complaint about
|
|
20
|
+
* something that happened this morning.
|
|
21
|
+
*/
|
|
22
|
+
export function nudgeFor(entries, now) {
|
|
23
|
+
if (entries.length === 0)
|
|
24
|
+
return [];
|
|
25
|
+
const recent = entries.filter((e) => {
|
|
26
|
+
const t = Date.parse(e.ts);
|
|
27
|
+
return Number.isFinite(t) && now - t <= WINDOW_MS;
|
|
28
|
+
});
|
|
29
|
+
if (recent.length === 0)
|
|
30
|
+
return [];
|
|
31
|
+
const last = recent[recent.length - 1];
|
|
32
|
+
const clusters = findClusters(recent);
|
|
33
|
+
// Only a cluster the LAST entry belongs to. A cluster the caller already
|
|
34
|
+
// worked past is not friction they are feeling now, and nagging about it is
|
|
35
|
+
// how a hint becomes noise a reader learns to skip.
|
|
36
|
+
const live = clusters.find((c) => c.skeleton === last.skel && c.entries[c.entries.length - 1].ts === last.ts);
|
|
37
|
+
if (live === undefined)
|
|
38
|
+
return [];
|
|
39
|
+
const what = live.kind === "repetition"
|
|
40
|
+
? `${live.entries.length} runs of \`${live.skeleton}\` in a few minutes.`
|
|
41
|
+
: `\`${live.skeleton}\` failed ${live.errors}× just now.`;
|
|
42
|
+
// STATES A FACT, NEVER AN INSTRUCTION.
|
|
43
|
+
//
|
|
44
|
+
// This line lands in an agent's transcript, because in the agent-driven loop
|
|
45
|
+
// the CLI's stdout IS the agent's input. "Run `vincentt dx --send` to report
|
|
46
|
+
// this" is a directive an autonomous caller will simply obey — and a report
|
|
47
|
+
// an agent decided to send is automation wearing consent's clothes, not the
|
|
48
|
+
// opt-in this feature is built around. Naming that a command exists is
|
|
49
|
+
// information; telling a caller to transmit is not.
|
|
50
|
+
//
|
|
51
|
+
// Note it names `vincentt dx` (which only READS, locally) and never the
|
|
52
|
+
// sending form.
|
|
53
|
+
return ["", ` ! ${what}`, " `vincentt dx` shows friction from your recent runs."];
|
|
54
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `vincentt dx --send` — the one place anything from the journal leaves the
|
|
3
|
+
* machine.
|
|
4
|
+
*
|
|
5
|
+
* The journal itself is local and unconditional. Sending is neither: it happens
|
|
6
|
+
* only when a creator types this flag, and only after they have been shown the
|
|
7
|
+
* exact payload. That ordering is the whole consent model — a report is an act,
|
|
8
|
+
* not a background transmission — so nothing in this file may be called from a
|
|
9
|
+
* path the creator did not ask for.
|
|
10
|
+
*
|
|
11
|
+
* What goes: the SHAPE of the friction (skeleton, counts, timing, error class
|
|
12
|
+
* names) plus the CLI version and platform. What never goes: the raw journal,
|
|
13
|
+
* flag values, paths, error messages, or anything about which project this was.
|
|
14
|
+
* The server re-validates all of it — a client that leaked a value cannot
|
|
15
|
+
* persist it by asking — but the boundary is drawn here first, because a payload
|
|
16
|
+
* that needs server-side scrubbing was built wrong.
|
|
17
|
+
*/
|
|
18
|
+
import type { Cluster } from "./clusters.js";
|
|
19
|
+
/** A finding as it goes on the wire. Mirrors the API's dxFindingRequest. */
|
|
20
|
+
export interface WireFinding {
|
|
21
|
+
kind: "error" | "repetition";
|
|
22
|
+
skeleton: string;
|
|
23
|
+
calls: number;
|
|
24
|
+
errors: number;
|
|
25
|
+
spanMs: number;
|
|
26
|
+
errorClasses?: string[];
|
|
27
|
+
note?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface WireReport {
|
|
30
|
+
cliVersion: string;
|
|
31
|
+
platform: string;
|
|
32
|
+
findings: WireFinding[];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The server bounds a report at 50 findings and refuses the whole thing past
|
|
36
|
+
* that, so the worst clusters are sent rather than the request rejected. They
|
|
37
|
+
* arrive ranked, so this is a head, not a sample.
|
|
38
|
+
*/
|
|
39
|
+
export declare const MAX_SENT_FINDINGS = 50;
|
|
40
|
+
export declare function platformTag(): string;
|
|
41
|
+
/**
|
|
42
|
+
* Build the wire payload from ranked clusters.
|
|
43
|
+
*
|
|
44
|
+
* Every field here is derived from the journal's own skeletonized entries — this
|
|
45
|
+
* function reads counts and timings, never values. If a future journal field
|
|
46
|
+
* carries content, it must not be added to this mapping without the same
|
|
47
|
+
* scrutiny the skeleton got.
|
|
48
|
+
*/
|
|
49
|
+
export declare function buildReport(clusters: Cluster[], cliVersion: string, note?: string): WireReport;
|
|
50
|
+
/**
|
|
51
|
+
* Render the payload for the creator to read BEFORE it is sent.
|
|
52
|
+
*
|
|
53
|
+
* This is not a summary of the payload — it is the payload, formatted. A preview
|
|
54
|
+
* that paraphrased what would be sent would be exactly the wrong thing to show
|
|
55
|
+
* someone deciding whether to send it.
|
|
56
|
+
*/
|
|
57
|
+
export declare function renderPayload(report: WireReport): string[];
|
|
58
|
+
export interface SendResult {
|
|
59
|
+
ref: string;
|
|
60
|
+
findings: number;
|
|
61
|
+
expiresAt: string;
|
|
62
|
+
}
|
|
63
|
+
export declare class DXSendError extends Error {
|
|
64
|
+
readonly status?: number | undefined;
|
|
65
|
+
constructor(message: string, status?: number | undefined);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* POST the report. The caller has already shown the payload and had it accepted.
|
|
69
|
+
*/
|
|
70
|
+
export declare function postReport(apiUrl: string, pat: string, report: WireReport, fetchImpl?: typeof fetch): Promise<SendResult>;
|
package/dist/dx/send.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The server bounds a report at 50 findings and refuses the whole thing past
|
|
3
|
+
* that, so the worst clusters are sent rather than the request rejected. They
|
|
4
|
+
* arrive ranked, so this is a head, not a sample.
|
|
5
|
+
*/
|
|
6
|
+
export const MAX_SENT_FINDINGS = 50;
|
|
7
|
+
/** Error classes per finding, matching the server's bound. */
|
|
8
|
+
const MAX_ERROR_CLASSES = 10;
|
|
9
|
+
export function platformTag() {
|
|
10
|
+
return `${process.platform}-${process.arch}`;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Build the wire payload from ranked clusters.
|
|
14
|
+
*
|
|
15
|
+
* Every field here is derived from the journal's own skeletonized entries — this
|
|
16
|
+
* function reads counts and timings, never values. If a future journal field
|
|
17
|
+
* carries content, it must not be added to this mapping without the same
|
|
18
|
+
* scrutiny the skeleton got.
|
|
19
|
+
*/
|
|
20
|
+
export function buildReport(clusters, cliVersion, note) {
|
|
21
|
+
const findings = clusters.slice(0, MAX_SENT_FINDINGS).map((c) => {
|
|
22
|
+
const classes = [
|
|
23
|
+
...new Set(c.entries.map((e) => e.err).filter((e) => e !== null)),
|
|
24
|
+
].slice(0, MAX_ERROR_CLASSES);
|
|
25
|
+
const f = {
|
|
26
|
+
kind: c.kind,
|
|
27
|
+
skeleton: c.skeleton,
|
|
28
|
+
calls: c.entries.length,
|
|
29
|
+
errors: c.errors,
|
|
30
|
+
spanMs: Math.max(0, Math.round(c.spanMs)),
|
|
31
|
+
};
|
|
32
|
+
if (classes.length > 0)
|
|
33
|
+
f.errorClasses = classes;
|
|
34
|
+
return f;
|
|
35
|
+
});
|
|
36
|
+
// The note rides on the first finding: it is the creator's own words about the
|
|
37
|
+
// friction they are reporting, and the report is about one sitting.
|
|
38
|
+
if (note !== undefined && note.trim() !== "" && findings.length > 0) {
|
|
39
|
+
findings[0].note = note.trim();
|
|
40
|
+
}
|
|
41
|
+
return { cliVersion, platform: platformTag(), findings };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Render the payload for the creator to read BEFORE it is sent.
|
|
45
|
+
*
|
|
46
|
+
* This is not a summary of the payload — it is the payload, formatted. A preview
|
|
47
|
+
* that paraphrased what would be sent would be exactly the wrong thing to show
|
|
48
|
+
* someone deciding whether to send it.
|
|
49
|
+
*/
|
|
50
|
+
export function renderPayload(report) {
|
|
51
|
+
const hasNote = report.findings.some((f) => f.note !== undefined && f.note !== "");
|
|
52
|
+
const lines = [
|
|
53
|
+
"This is what would be sent:",
|
|
54
|
+
"",
|
|
55
|
+
...JSON.stringify(report, null, 2)
|
|
56
|
+
.split("\n")
|
|
57
|
+
.map((l) => ` ${l}`),
|
|
58
|
+
"",
|
|
59
|
+
];
|
|
60
|
+
// The generated fields carry shape only. The NOTE does not, and saying
|
|
61
|
+
// otherwise above a note that may hold a path or a token would be asking the
|
|
62
|
+
// creator to consent on a false claim — the one thing this screen exists to
|
|
63
|
+
// prevent. So the sentence changes when a note is present.
|
|
64
|
+
if (hasNote) {
|
|
65
|
+
lines.push(" The generated fields carry no paths, flag values, or error messages.", " YOUR NOTE IS SENT EXACTLY AS WRITTEN — check it for paths, URLs,", " tokens, or customer names before sending.");
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
lines.push(" No paths, no flag values, no error messages, and nothing about which", " project this was.");
|
|
69
|
+
}
|
|
70
|
+
lines.push(" Your journal stays on this machine.");
|
|
71
|
+
return lines;
|
|
72
|
+
}
|
|
73
|
+
export class DXSendError extends Error {
|
|
74
|
+
status;
|
|
75
|
+
constructor(message, status) {
|
|
76
|
+
super(message);
|
|
77
|
+
this.status = status;
|
|
78
|
+
this.name = "DXSendError";
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* POST the report. The caller has already shown the payload and had it accepted.
|
|
83
|
+
*/
|
|
84
|
+
export async function postReport(apiUrl, pat, report, fetchImpl = fetch) {
|
|
85
|
+
const res = await fetchImpl(`${apiUrl}/dx-reports`, {
|
|
86
|
+
method: "POST",
|
|
87
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${pat}` },
|
|
88
|
+
body: JSON.stringify(report),
|
|
89
|
+
});
|
|
90
|
+
if (!res.ok) {
|
|
91
|
+
let detail = "";
|
|
92
|
+
try {
|
|
93
|
+
const body = (await res.json());
|
|
94
|
+
detail = body.message ?? "";
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// A non-JSON body is not more informative than the status.
|
|
98
|
+
}
|
|
99
|
+
if (res.status === 401) {
|
|
100
|
+
throw new DXSendError("Your access token is no longer valid — run `vincentt login`.", 401);
|
|
101
|
+
}
|
|
102
|
+
// A 400 here means the payload this CLI built failed the server's own
|
|
103
|
+
// grammar. Surfaced verbatim rather than softened: it is a bug in this CLI,
|
|
104
|
+
// and the creator is the one who can report it.
|
|
105
|
+
throw new DXSendError(detail !== "" ? detail : `The report was not accepted (${res.status}).`, res.status);
|
|
106
|
+
}
|
|
107
|
+
return (await res.json());
|
|
108
|
+
}
|
|
@@ -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,138 @@
|
|
|
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
|
+
* The verbs this CLI dispatches. A first bare token that is not one of these is
|
|
15
|
+
* not a verb — it is whatever the caller typed, and it must not be kept.
|
|
16
|
+
*
|
|
17
|
+
* Keeping it verbatim was a leak: an agent that mistypes
|
|
18
|
+
* `vincentt publish-acme-demo-to-prod`, fails, and retries builds a top-ranked
|
|
19
|
+
* friction cluster, so a customer's name ended up at the head of both the
|
|
20
|
+
* on-disk journal and any report built from it. Lowercase kebab-case is exactly
|
|
21
|
+
* what slugs and project names are made of, so no character-class check catches
|
|
22
|
+
* this — only a list of the verbs that actually exist.
|
|
23
|
+
*/
|
|
24
|
+
const KNOWN_VERBS = new Set([
|
|
25
|
+
"create",
|
|
26
|
+
"dx",
|
|
27
|
+
"feedback",
|
|
28
|
+
"init",
|
|
29
|
+
"link",
|
|
30
|
+
"login",
|
|
31
|
+
"logout",
|
|
32
|
+
"logs",
|
|
33
|
+
"network",
|
|
34
|
+
"preview",
|
|
35
|
+
"publish",
|
|
36
|
+
"relay",
|
|
37
|
+
"trace",
|
|
38
|
+
"unlink",
|
|
39
|
+
"version",
|
|
40
|
+
"whoami",
|
|
41
|
+
"help",
|
|
42
|
+
]);
|
|
43
|
+
/** Flags the CLI defines. An unknown `--flag` name is caller text, not signal. */
|
|
44
|
+
const KNOWN_FLAGS = new Set([
|
|
45
|
+
"--allow-custom-api",
|
|
46
|
+
"--api",
|
|
47
|
+
"--console",
|
|
48
|
+
"--days",
|
|
49
|
+
"--dir",
|
|
50
|
+
"--dist",
|
|
51
|
+
"--editor",
|
|
52
|
+
"--errors",
|
|
53
|
+
"--force",
|
|
54
|
+
"--help",
|
|
55
|
+
"--json",
|
|
56
|
+
"--limit",
|
|
57
|
+
"--name",
|
|
58
|
+
"--no-install",
|
|
59
|
+
"--no-qr",
|
|
60
|
+
"--note",
|
|
61
|
+
"--org",
|
|
62
|
+
"--port",
|
|
63
|
+
"--preview",
|
|
64
|
+
"--restart",
|
|
65
|
+
"--send",
|
|
66
|
+
"--session",
|
|
67
|
+
"--since",
|
|
68
|
+
"--slug",
|
|
69
|
+
"--stop",
|
|
70
|
+
"--template",
|
|
71
|
+
"--tester",
|
|
72
|
+
"--timeout",
|
|
73
|
+
"--verbose",
|
|
74
|
+
"--version",
|
|
75
|
+
"--viewer",
|
|
76
|
+
"--wait",
|
|
77
|
+
"--watch",
|
|
78
|
+
"--yes",
|
|
79
|
+
]);
|
|
80
|
+
/**
|
|
81
|
+
* Placeholders for tokens the CLI does not recognize. Angle brackets cannot
|
|
82
|
+
* appear in a token the caller typed, so a caller cannot forge one.
|
|
83
|
+
*
|
|
84
|
+
* A mistyped verb still produces a finding — it clusters under `<verb>`, which
|
|
85
|
+
* is itself worth seeing (repeated unknown verbs mean an agent is guessing at a
|
|
86
|
+
* command that should exist).
|
|
87
|
+
*/
|
|
88
|
+
const UNKNOWN_VERB = "<verb>";
|
|
89
|
+
const UNKNOWN_FLAG = "--<flag>";
|
|
90
|
+
/**
|
|
91
|
+
* Normalize an argv into `verb --flag=<v> <v>` form — the key a retry cluster
|
|
92
|
+
* groups on. Two calls that differ only in their values share a skeleton, which
|
|
93
|
+
* is what makes "the caller ran the same thing four times" detectable.
|
|
94
|
+
*/
|
|
95
|
+
export function skeleton(args) {
|
|
96
|
+
const out = [];
|
|
97
|
+
for (let i = 0; i < args.length; i++) {
|
|
98
|
+
const a = args[i];
|
|
99
|
+
if (!a.startsWith("-")) {
|
|
100
|
+
// The first bare token is the verb — kept only when it is one we actually
|
|
101
|
+
// dispatch. Every later positional is a value (a slug, an id, a path).
|
|
102
|
+
if (out.length === 0) {
|
|
103
|
+
out.push(KNOWN_VERBS.has(a) ? a : UNKNOWN_VERB);
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
out.push(ELIDED);
|
|
107
|
+
}
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const rawName = a.split("=")[0];
|
|
111
|
+
const name = KNOWN_FLAGS.has(rawName) ? rawName : UNKNOWN_FLAG;
|
|
112
|
+
if (BOOLEAN_FLAGS.has(a)) {
|
|
113
|
+
out.push(name);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
// `--flag=value` carries its value inline; `--flag value` consumes the next
|
|
117
|
+
// argv entry. Both collapse to the same skeleton so they cluster together.
|
|
118
|
+
out.push(`${name}=${ELIDED}`);
|
|
119
|
+
if (!a.includes("="))
|
|
120
|
+
i++;
|
|
121
|
+
}
|
|
122
|
+
return out.join(" ");
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The dispatched verb — the first bare token, skipping flags AND the values they
|
|
126
|
+
* consume. `vincentt --dir /tmp publish` runs publish, not `/tmp`; recording the
|
|
127
|
+
* path as the verb would both mis-bucket the run and leak the directory.
|
|
128
|
+
*/
|
|
129
|
+
export function verbOf(args) {
|
|
130
|
+
for (let i = 0; i < args.length; i++) {
|
|
131
|
+
const a = args[i];
|
|
132
|
+
if (!a.startsWith("-"))
|
|
133
|
+
return KNOWN_VERBS.has(a) ? a : UNKNOWN_VERB;
|
|
134
|
+
if (!BOOLEAN_FLAGS.has(a) && !a.includes("="))
|
|
135
|
+
i++;
|
|
136
|
+
}
|
|
137
|
+
return "";
|
|
138
|
+
}
|
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.
|