@vincentt-xr/cli 0.9.0 → 0.11.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 +40 -13
- package/dist/cli/args.d.ts +12 -0
- package/dist/cli/args.js +40 -0
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +308 -95
- package/dist/cli/usage-text.d.ts +1 -0
- package/dist/cli/usage-text.js +97 -0
- package/dist/cli/verb-surface.d.ts +24 -0
- package/dist/cli/verb-surface.js +49 -0
- package/dist/dx/clusters.d.ts +0 -17
- package/dist/dx/clusters.js +50 -1
- package/dist/lib/backend.d.ts +11 -0
- package/dist/lib/backend.js +23 -0
- package/dist/lib/classifyTarget.d.ts +42 -0
- package/dist/lib/classifyTarget.js +83 -0
- package/dist/lib/feedback.d.ts +46 -5
- package/dist/lib/feedback.js +76 -19
- package/dist/lib/feedbackCopy.d.ts +70 -0
- package/dist/lib/feedbackCopy.js +163 -0
- package/dist/lib/initFrom.d.ts +26 -4
- package/dist/lib/initFrom.js +65 -41
- package/dist/lib/lifecycle.d.ts +28 -3
- package/dist/lib/lifecycle.js +99 -7
- package/dist/lib/listProjects.d.ts +41 -0
- package/dist/lib/listProjects.js +77 -0
- package/dist/lib/projects.d.ts +64 -8
- package/dist/lib/projects.js +316 -24
- package/dist/lib/sourceRefCopy.d.ts +19 -0
- package/dist/lib/sourceRefCopy.js +36 -0
- package/dist/preview/command.d.ts +11 -0
- package/dist/preview/command.js +19 -1
- package/dist/preview/copy.d.ts +19 -3
- package/dist/preview/copy.js +21 -5
- package/dist/preview/runner.d.ts +6 -0
- package/dist/preview/runner.js +1 -0
- package/dist/preview/stream.d.ts +11 -0
- package/dist/preview/stream.js +13 -0
- package/dist/relay/annotations.d.ts +28 -4
- package/dist/relay/annotations.js +23 -7
- package/dist/relay/qa.f12.fixtures.d.ts +154 -0
- package/dist/relay/qa.f12.fixtures.js +358 -0
- package/dist/relay/server.d.ts +41 -3
- package/dist/relay/server.js +90 -32
- package/package.json +2 -2
- package/dist/library/qa.f5-b32.spec.d.ts +0 -1
- package/dist/library/qa.f5-b32.spec.js +0 -202
- package/dist/library/qa.f5-cli.spec.d.ts +0 -1
- package/dist/library/qa.f5-cli.spec.js +0 -433
- package/dist/library/qa.f5-contract.spec.d.ts +0 -1
- package/dist/library/qa.f5-contract.spec.js +0 -206
- package/dist/relay/injectors.d.ts +0 -29
- package/dist/relay/injectors.js +0 -72
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The accepted-verb surface, pinned. B-F3-8: a verb may be added or aliased,
|
|
2
|
+
// never renamed or removed — grounding naming these verbs ships into creators'
|
|
3
|
+
// repositories and is never read back, so a rename is unseeable, unwarnable and
|
|
4
|
+
// unfixable. Append only; never edit or delete a line.
|
|
5
|
+
// prettier-ignore
|
|
6
|
+
export const PINNED_VERBS = [
|
|
7
|
+
"version", "login", "logout", "whoami",
|
|
8
|
+
"init", "create", "link", "unlink",
|
|
9
|
+
"publish", "preview",
|
|
10
|
+
"logs", "network", "trace",
|
|
11
|
+
"feedback", "relay", "assets", "dx",
|
|
12
|
+
// Appended: a read-only listing. Nothing above was renamed or removed, which
|
|
13
|
+
// is the whole of what B-F3-8 pins — an addition is always permitted.
|
|
14
|
+
"projects",
|
|
15
|
+
];
|
|
16
|
+
// The dispatch's verb set, as data. `main()` switches over this, so the two can
|
|
17
|
+
// never disagree: a verb added to the CLI is a key added here, and QA-AHG-01
|
|
18
|
+
// reads it as the converse arm of the pin.
|
|
19
|
+
export const VERB_HANDLERS = {
|
|
20
|
+
version: true,
|
|
21
|
+
login: true,
|
|
22
|
+
logout: true,
|
|
23
|
+
whoami: true,
|
|
24
|
+
init: true,
|
|
25
|
+
create: true,
|
|
26
|
+
link: true,
|
|
27
|
+
unlink: true,
|
|
28
|
+
publish: true,
|
|
29
|
+
preview: true,
|
|
30
|
+
logs: true,
|
|
31
|
+
network: true,
|
|
32
|
+
trace: true,
|
|
33
|
+
feedback: true,
|
|
34
|
+
relay: true,
|
|
35
|
+
assets: true,
|
|
36
|
+
dx: true,
|
|
37
|
+
projects: true,
|
|
38
|
+
};
|
|
39
|
+
// Answers "is this a verb this CLI accepts" without running it. The
|
|
40
|
+
// console-redirect strings are NOT accepted here: they reach their explanation
|
|
41
|
+
// through the dispatch's default branch, and treating them as verbs would put
|
|
42
|
+
// the console's vocabulary inside B-F3-8.
|
|
43
|
+
export function resolveVerb(cmd) {
|
|
44
|
+
if (cmd === undefined)
|
|
45
|
+
return undefined;
|
|
46
|
+
return Object.prototype.hasOwnProperty.call(VERB_HANDLERS, cmd)
|
|
47
|
+
? cmd
|
|
48
|
+
: undefined;
|
|
49
|
+
}
|
package/dist/dx/clusters.d.ts
CHANGED
|
@@ -1,20 +1,3 @@
|
|
|
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
1
|
import type { JournalEntry } from "./journal.js";
|
|
19
2
|
export type ClusterKind = "error" | "repetition";
|
|
20
3
|
export interface Cluster {
|
package/dist/dx/clusters.js
CHANGED
|
@@ -1,3 +1,47 @@
|
|
|
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 { verbOf } from "./skeleton.js";
|
|
19
|
+
/**
|
|
20
|
+
* Non-zero exits a verb DEFINES as ordinary outcomes, not failures.
|
|
21
|
+
*
|
|
22
|
+
* `feedback --wait` exits 64 when its window elapsed with nothing waiting and 65
|
|
23
|
+
* when the preview ended. Both are facts about the channel, and the whole point
|
|
24
|
+
* of the verb is that it is re-run in a tight loop: a caller re-parks after every
|
|
25
|
+
* 64, hundreds of times a day. Counted as errors, those runs read as a
|
|
26
|
+
* fight-with-the-tool that never happened, and the nudge narrates the intended
|
|
27
|
+
* loop as friction — on stderr, which in this loop is a caller's own input.
|
|
28
|
+
*
|
|
29
|
+
* A verb's neutral exits are ITS OWN. Nothing here generalizes 64/65 across the
|
|
30
|
+
* CLI; another verb adding a non-zero-but-fine exit adds a row.
|
|
31
|
+
*/
|
|
32
|
+
const NEUTRAL_EXITS = {
|
|
33
|
+
feedback: new Set([64, 65]),
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Verbs whose contract is "run it again". Repeating them is not a caller failing
|
|
37
|
+
* to get what they needed on the first try; it is the documented way to use them.
|
|
38
|
+
*/
|
|
39
|
+
const LOOPING_VERBS = new Set(["feedback"]);
|
|
40
|
+
function isFailure(e) {
|
|
41
|
+
if (e.exit === 0)
|
|
42
|
+
return false;
|
|
43
|
+
return !NEUTRAL_EXITS[verbOf(e.skel.split(" "))]?.has(e.exit);
|
|
44
|
+
}
|
|
1
45
|
/** A repetition run must be at least this long to count as friction. */
|
|
2
46
|
const REPETITION_MIN = 3;
|
|
3
47
|
/**
|
|
@@ -23,8 +67,12 @@ export function findClusters(entries) {
|
|
|
23
67
|
let j = i;
|
|
24
68
|
while (j < entries.length && entries[j].skel === skel)
|
|
25
69
|
run.push(entries[j++]);
|
|
26
|
-
const errors = run.filter(
|
|
70
|
+
const errors = run.filter(isFailure).length;
|
|
27
71
|
const spanMs = span(run);
|
|
72
|
+
// A verb whose whole contract is "run it again" cannot be detected as
|
|
73
|
+
// friction by repetition — the repetition IS the design. Its error arm still
|
|
74
|
+
// applies: an exit 1 from it is a real fault and clusters normally.
|
|
75
|
+
const loops = LOOPING_VERBS.has(verbOf(skel.split(" ")));
|
|
28
76
|
// A cluster needs at least two calls. A lone failure is usually the tool
|
|
29
77
|
// working — a signed-out `whoami`, a typo'd verb — and reporting each one
|
|
30
78
|
// buries the real findings under noise, which is the failure mode that
|
|
@@ -40,6 +88,7 @@ export function findClusters(entries) {
|
|
|
40
88
|
});
|
|
41
89
|
}
|
|
42
90
|
else if (errors === 0 &&
|
|
91
|
+
!loops &&
|
|
43
92
|
run.length >= REPETITION_MIN &&
|
|
44
93
|
spanMs <= REPETITION_WINDOW_MS) {
|
|
45
94
|
clusters.push({
|
package/dist/lib/backend.d.ts
CHANGED
|
@@ -43,6 +43,17 @@ export declare class ProjectUnreachableError extends BackendError {
|
|
|
43
43
|
* a creator's address is always a name they chose.
|
|
44
44
|
*/
|
|
45
45
|
export declare function createProject(cfg: ResolvedConfig, orgSlug: string, name: string, slug?: string): Promise<CreatedProject>;
|
|
46
|
+
/**
|
|
47
|
+
* List an org's projects. A READ, and the only new call `vincentt projects`
|
|
48
|
+
* makes — the same path `createProject` POSTs to, which is why the org is a slug
|
|
49
|
+
* here and not a bare id.
|
|
50
|
+
*
|
|
51
|
+
* The wire shape is `{projects: [...]}`, each member the same `projectView`
|
|
52
|
+
* `GET /projects/{id}` returns, so it goes through `parseProjectView` rather
|
|
53
|
+
* than a second reader: two parsers for one shape is how a contract break
|
|
54
|
+
* survives a green suite on one of them.
|
|
55
|
+
*/
|
|
56
|
+
export declare function listProjects(cfg: ResolvedConfig, orgSlug: string): Promise<ProjectView[]>;
|
|
46
57
|
/** Read a project by id. Backs `link`; the org is derived server-side from the row. */
|
|
47
58
|
export declare function getProject(cfg: ResolvedConfig, projectId: string): Promise<ProjectView>;
|
|
48
59
|
export interface MintedTunnel {
|
package/dist/lib/backend.js
CHANGED
|
@@ -95,6 +95,29 @@ export async function createProject(cfg, orgSlug, name, slug) {
|
|
|
95
95
|
liveUrl: p.liveUrl,
|
|
96
96
|
};
|
|
97
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* List an org's projects. A READ, and the only new call `vincentt projects`
|
|
100
|
+
* makes — the same path `createProject` POSTs to, which is why the org is a slug
|
|
101
|
+
* here and not a bare id.
|
|
102
|
+
*
|
|
103
|
+
* The wire shape is `{projects: [...]}`, each member the same `projectView`
|
|
104
|
+
* `GET /projects/{id}` returns, so it goes through `parseProjectView` rather
|
|
105
|
+
* than a second reader: two parsers for one shape is how a contract break
|
|
106
|
+
* survives a green suite on one of them.
|
|
107
|
+
*/
|
|
108
|
+
export async function listProjects(cfg, orgSlug) {
|
|
109
|
+
const res = await authFetch(cfg, `/orgs/${encodeURIComponent(orgSlug)}/projects`);
|
|
110
|
+
if (!res.ok) {
|
|
111
|
+
const body = await errorBody(res);
|
|
112
|
+
throw new BackendError(res.status === 401
|
|
113
|
+
? authFailureMessage("list projects failed", res.status, await safeText(res))
|
|
114
|
+
: (body.message ??
|
|
115
|
+
`list projects failed (${res.status}): ${await safeText(res)}`), res.status);
|
|
116
|
+
}
|
|
117
|
+
const raw = (await res.json());
|
|
118
|
+
const rows = Array.isArray(raw.projects) ? raw.projects : [];
|
|
119
|
+
return rows.map((p) => parseProjectView(p, "list projects"));
|
|
120
|
+
}
|
|
98
121
|
/** Read a project by id. Backs `link`; the org is derived server-side from the row. */
|
|
99
122
|
export async function getProject(cfg, projectId) {
|
|
100
123
|
const res = await authFetch(cfg, `/projects/${encodeURIComponent(projectId)}`);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export type FolderCase = "vincentt-app" | "empty" | "occupied";
|
|
2
|
+
/** Only the two fields the classifier reads, so a probe is trivial to fake. */
|
|
3
|
+
export interface TargetStat {
|
|
4
|
+
isDirectory(): boolean;
|
|
5
|
+
isSymbolicLink(): boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface ClassifyProbes {
|
|
8
|
+
looksLikeApp?: (dir: string) => Promise<boolean>;
|
|
9
|
+
/** lstat, NEVER stat — see rule 2. */
|
|
10
|
+
lstat?: (dir: string) => Promise<TargetStat>;
|
|
11
|
+
readdir?: (dir: string) => Promise<string[]>;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Which of the three cases the target directory is in.
|
|
15
|
+
*
|
|
16
|
+
* Four rules, each of which a caller depends on:
|
|
17
|
+
*
|
|
18
|
+
* 1. `ENOENT` is the ONLY error that means empty. `EACCES`, `EPERM`, `ENOTDIR`,
|
|
19
|
+
* `ELOOP`, `ENAMETOOLONG` and anything unforeseen all return `occupied` —
|
|
20
|
+
* "I could not establish that this is safe to write into" is a refusal.
|
|
21
|
+
* 2. `lstat`, never `stat`. A symlink is `occupied` whatever it points at;
|
|
22
|
+
* `stat` follows it, which is how a scaffold writes outside the directory
|
|
23
|
+
* the creator named.
|
|
24
|
+
* 3. A non-directory is `occupied`. A regular file at the path is not an empty
|
|
25
|
+
* folder.
|
|
26
|
+
* 4. A `readdir` that throws after a successful `lstat` (a TOCTOU window, or a
|
|
27
|
+
* permission change) is `occupied`. The unknown answer is never the writing
|
|
28
|
+
* answer.
|
|
29
|
+
*
|
|
30
|
+
* `looksLikeApp` runs FIRST and unconditionally: order is the contract, because
|
|
31
|
+
* case 2 is defined by BEING a Vincentt app, not by what else is in the folder.
|
|
32
|
+
*
|
|
33
|
+
* Both IO probes are injected so every arm — including the error arms — is
|
|
34
|
+
* testable with no real filesystem. That matters for more than convenience: a
|
|
35
|
+
* mode-000 directory is a no-op as root, so a test staged on a real filesystem
|
|
36
|
+
* would pass for the wrong reason in CI.
|
|
37
|
+
*
|
|
38
|
+
* NOTE: the classifier answers a question at a MOMENT. The window between this
|
|
39
|
+
* answer and the write is closed by the writer's own guards (a non-recursive
|
|
40
|
+
* mkdir, and a realpath re-check), not here.
|
|
41
|
+
*/
|
|
42
|
+
export declare function classifyTarget(dir: string, probes?: ClassifyProbes): Promise<FolderCase>;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// The three-way folder classification `vincentt create` acts on, and it FAILS
|
|
2
|
+
// CLOSED.
|
|
3
|
+
//
|
|
4
|
+
// `create` used to be a pure registration verb, so "is this a Vincentt app" was a
|
|
5
|
+
// HINT: a wrong answer cost one advisory line. It now decides whether ~30 template
|
|
6
|
+
// files get written, so a wrong answer is a silent write into a directory the
|
|
7
|
+
// creator cares about. That is the hint-becomes-a-gate transition, and it is why
|
|
8
|
+
// this is a security control with its own file rather than another export from
|
|
9
|
+
// projects.ts.
|
|
10
|
+
//
|
|
11
|
+
// This deliberately does NOT call `isNonEmptyDir` (scaffold/index.ts). That
|
|
12
|
+
// predicate catches EVERY error and returns false, which this design would read as
|
|
13
|
+
// "empty". Four measured mis-answers it gives: a mode-000 directory holding files,
|
|
14
|
+
// a symlink to an empty directory (the scaffold writes THROUGH the link, outside
|
|
15
|
+
// the parent the creator named), a dangling symlink, and a regular file at the
|
|
16
|
+
// path. Under `init` a false-empty reaches a confirm prompt a human sees; here it
|
|
17
|
+
// reaches a silent write. `isNonEmptyDir` is left exactly as it is and keeps
|
|
18
|
+
// serving `init`, where its error contract is correct: two predicates, two
|
|
19
|
+
// contracts, each stated in terms of what its caller does with the answer.
|
|
20
|
+
//
|
|
21
|
+
// `needsScaffold` is not used either: it answers only "has a package.json", so a
|
|
22
|
+
// stranger's non-Vincentt Node app lands in its scaffoldable arm.
|
|
23
|
+
import { promises as fs } from "node:fs";
|
|
24
|
+
import { looksLikeVincenttApp } from "./projects.js";
|
|
25
|
+
/**
|
|
26
|
+
* Which of the three cases the target directory is in.
|
|
27
|
+
*
|
|
28
|
+
* Four rules, each of which a caller depends on:
|
|
29
|
+
*
|
|
30
|
+
* 1. `ENOENT` is the ONLY error that means empty. `EACCES`, `EPERM`, `ENOTDIR`,
|
|
31
|
+
* `ELOOP`, `ENAMETOOLONG` and anything unforeseen all return `occupied` —
|
|
32
|
+
* "I could not establish that this is safe to write into" is a refusal.
|
|
33
|
+
* 2. `lstat`, never `stat`. A symlink is `occupied` whatever it points at;
|
|
34
|
+
* `stat` follows it, which is how a scaffold writes outside the directory
|
|
35
|
+
* the creator named.
|
|
36
|
+
* 3. A non-directory is `occupied`. A regular file at the path is not an empty
|
|
37
|
+
* folder.
|
|
38
|
+
* 4. A `readdir` that throws after a successful `lstat` (a TOCTOU window, or a
|
|
39
|
+
* permission change) is `occupied`. The unknown answer is never the writing
|
|
40
|
+
* answer.
|
|
41
|
+
*
|
|
42
|
+
* `looksLikeApp` runs FIRST and unconditionally: order is the contract, because
|
|
43
|
+
* case 2 is defined by BEING a Vincentt app, not by what else is in the folder.
|
|
44
|
+
*
|
|
45
|
+
* Both IO probes are injected so every arm — including the error arms — is
|
|
46
|
+
* testable with no real filesystem. That matters for more than convenience: a
|
|
47
|
+
* mode-000 directory is a no-op as root, so a test staged on a real filesystem
|
|
48
|
+
* would pass for the wrong reason in CI.
|
|
49
|
+
*
|
|
50
|
+
* NOTE: the classifier answers a question at a MOMENT. The window between this
|
|
51
|
+
* answer and the write is closed by the writer's own guards (a non-recursive
|
|
52
|
+
* mkdir, and a realpath re-check), not here.
|
|
53
|
+
*/
|
|
54
|
+
export async function classifyTarget(dir, probes = {}) {
|
|
55
|
+
const looksLikeApp = probes.looksLikeApp ?? looksLikeVincenttApp;
|
|
56
|
+
const lstat = probes.lstat ?? ((d) => fs.lstat(d));
|
|
57
|
+
const readdir = probes.readdir ?? ((d) => fs.readdir(d));
|
|
58
|
+
if (await looksLikeApp(dir))
|
|
59
|
+
return "vincentt-app";
|
|
60
|
+
try {
|
|
61
|
+
const st = await lstat(dir);
|
|
62
|
+
if (st.isSymbolicLink())
|
|
63
|
+
return "occupied";
|
|
64
|
+
if (!st.isDirectory())
|
|
65
|
+
return "occupied";
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
// Read defensively rather than through a helper. `err.code` on a thrown
|
|
69
|
+
// string or null THROWS, and this catch sits inside a security control: an
|
|
70
|
+
// exception escaping here would propagate as an unhandled failure of the
|
|
71
|
+
// whole verb rather than as the refusal the contract promises.
|
|
72
|
+
const code = err?.code;
|
|
73
|
+
if (code === "ENOENT")
|
|
74
|
+
return "empty"; // `create <name>` creates it
|
|
75
|
+
return "occupied";
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
return (await readdir(dir)).length === 0 ? "empty" : "occupied";
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return "occupied";
|
|
82
|
+
}
|
|
83
|
+
}
|
package/dist/lib/feedback.d.ts
CHANGED
|
@@ -2,14 +2,55 @@ import type { Annotation } from "@vincentt-xr/harness/events";
|
|
|
2
2
|
export declare function cursorPath(cwd: string): string;
|
|
3
3
|
/** Last-consumed annotation seq for this project (0 if none consumed yet). */
|
|
4
4
|
export declare function readCursor(cwd: string): Promise<number>;
|
|
5
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* Advance the cursor. Called ONLY after the annotation was handed off.
|
|
7
|
+
*
|
|
8
|
+
* Temp file + same-directory rename, because `fs.writeFile` is not atomic and a
|
|
9
|
+
* kill mid-write is reachable on exactly the kill-mid-drain path this channel is
|
|
10
|
+
* designed around. A torn file reads as 0 under the strict parser, so the
|
|
11
|
+
* failure degrades to re-delivering the whole run — safe, but noisy and
|
|
12
|
+
* avoidable. Same directory keeps the rename on one filesystem, which is what
|
|
13
|
+
* makes it atomic.
|
|
14
|
+
*/
|
|
6
15
|
export declare function writeCursor(cwd: string, seq: number): Promise<void>;
|
|
7
16
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
17
|
+
* The three outcomes of a poll. `Annotation | null` could only express two, and
|
|
18
|
+
* a channel that has ENDED is not a channel that is merely quiet: a caller
|
|
19
|
+
* re-parks on `empty` and must not on `closed`.
|
|
20
|
+
*/
|
|
21
|
+
export type NextResult = {
|
|
22
|
+
kind: "annotation";
|
|
23
|
+
annotation: Annotation;
|
|
24
|
+
}
|
|
25
|
+
/** The window elapsed with nothing pending — re-parking is correct and free. */
|
|
26
|
+
| {
|
|
27
|
+
kind: "empty";
|
|
28
|
+
}
|
|
29
|
+
/** The preview session has ended; nothing more can arrive on this channel. */
|
|
30
|
+
| {
|
|
31
|
+
kind: "closed";
|
|
32
|
+
reason: ClosedReason;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Which of the two session-end routes fired. They are indistinguishable to the
|
|
36
|
+
* creator and print identically by default; the difference is triage detail
|
|
37
|
+
* surfaced under `--verbose`.
|
|
38
|
+
*/
|
|
39
|
+
export type ClosedReason =
|
|
40
|
+
/** The relay answered a parked long-poll 410 as it tore down. */
|
|
41
|
+
"signalled"
|
|
42
|
+
/** No relay is answering for this tree — a kill, a reboot, a stale marker. */
|
|
43
|
+
| "unreachable";
|
|
44
|
+
/**
|
|
45
|
+
* Long-poll the relay for the next annotation after `after`.
|
|
46
|
+
*
|
|
47
|
+
* A missing marker and an unreachable relay both resolve `closed` rather than
|
|
48
|
+
* throwing: "no preview is running for this tree" is the same fact the graceful
|
|
49
|
+
* 410 carries, and the caller's correct response to both is identical. Throwing
|
|
50
|
+
* would reach the top-level catch and exit 1 — a fault code for an ordinary,
|
|
51
|
+
* expected end.
|
|
11
52
|
*/
|
|
12
53
|
export declare function fetchNext(cwd: string, opts: {
|
|
13
54
|
after: number;
|
|
14
55
|
timeoutMs: number;
|
|
15
|
-
}): Promise<
|
|
56
|
+
}): Promise<NextResult>;
|
package/dist/lib/feedback.js
CHANGED
|
@@ -1,12 +1,29 @@
|
|
|
1
|
-
// The
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// The host-neutral reverse-channel consumer behind `vincentt feedback`. Long-polls
|
|
2
|
+
// the running preview's relay for the next annotation after a locally persisted
|
|
3
|
+
// cursor.
|
|
4
|
+
//
|
|
5
|
+
// Delivery is at-least-once, and every rule in this file exists to keep the error
|
|
6
|
+
// on that side: the caller advances the cursor only after the annotation's write
|
|
7
|
+
// has COMPLETED (see the drain in cli/index.ts), the cursor parser can only ever
|
|
8
|
+
// read LOW, and the cursor write is atomic. A cursor that lags re-delivers; a
|
|
9
|
+
// cursor that runs ahead skips silently, which is the one failure the channel
|
|
10
|
+
// forbids.
|
|
6
11
|
import { promises as fs } from "node:fs";
|
|
7
12
|
import path from "node:path";
|
|
8
|
-
import { readRelayInfo, relayInfoDir } from "./relayInfo.js";
|
|
13
|
+
import { isProcessAlive, isRelayResponding, readRelayInfo, relayInfoDir, } from "./relayInfo.js";
|
|
9
14
|
const CURSOR_FILE = ".vincentt/annotations/offset";
|
|
15
|
+
/**
|
|
16
|
+
* A cursor that reads too LOW re-delivers, which is the accepted cost of
|
|
17
|
+
* at-least-once. A cursor that reads too HIGH silently SKIPS, which is the
|
|
18
|
+
* failure this channel forbids. So the parser is allowed to err only in one
|
|
19
|
+
* direction: anything that is not a bare non-negative integer is 0.
|
|
20
|
+
*
|
|
21
|
+
* `Number(raw.trim()) || 0` erred the forbidden way — `1e3` is 1000 (a thousand
|
|
22
|
+
* annotations skipped with no error anywhere), `-5` is -5, `0x10` is 16. And §9
|
|
23
|
+
* invites the creator to inspect and repair this file by hand, so a hand-edited
|
|
24
|
+
* value is foreseeable input rather than a hypothetical one.
|
|
25
|
+
*/
|
|
26
|
+
const CURSOR_RE = /^\d+$/;
|
|
10
27
|
export function cursorPath(cwd) {
|
|
11
28
|
return path.join(cwd, CURSOR_FILE);
|
|
12
29
|
}
|
|
@@ -23,7 +40,12 @@ async function treeCursorPath(cwd) {
|
|
|
23
40
|
export async function readCursor(cwd) {
|
|
24
41
|
try {
|
|
25
42
|
const raw = await fs.readFile(await treeCursorPath(cwd), "utf8");
|
|
26
|
-
|
|
43
|
+
const trimmed = raw.trim();
|
|
44
|
+
if (!CURSOR_RE.test(trimmed))
|
|
45
|
+
return 0;
|
|
46
|
+
const n = Number(trimmed);
|
|
47
|
+
// Beyond MAX_SAFE_INTEGER, Number() rounds — and rounding UP would skip.
|
|
48
|
+
return Number.isSafeInteger(n) ? n : 0;
|
|
27
49
|
}
|
|
28
50
|
catch (err) {
|
|
29
51
|
if (err.code === "ENOENT")
|
|
@@ -31,22 +53,42 @@ export async function readCursor(cwd) {
|
|
|
31
53
|
throw err;
|
|
32
54
|
}
|
|
33
55
|
}
|
|
34
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* Advance the cursor. Called ONLY after the annotation was handed off.
|
|
58
|
+
*
|
|
59
|
+
* Temp file + same-directory rename, because `fs.writeFile` is not atomic and a
|
|
60
|
+
* kill mid-write is reachable on exactly the kill-mid-drain path this channel is
|
|
61
|
+
* designed around. A torn file reads as 0 under the strict parser, so the
|
|
62
|
+
* failure degrades to re-delivering the whole run — safe, but noisy and
|
|
63
|
+
* avoidable. Same directory keeps the rename on one filesystem, which is what
|
|
64
|
+
* makes it atomic.
|
|
65
|
+
*/
|
|
35
66
|
export async function writeCursor(cwd, seq) {
|
|
36
67
|
const p = await treeCursorPath(cwd);
|
|
37
68
|
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
38
|
-
|
|
69
|
+
const tmp = `${p}.${process.pid}.tmp`;
|
|
70
|
+
await fs.writeFile(tmp, `${seq}\n`, "utf8");
|
|
71
|
+
try {
|
|
72
|
+
await fs.rename(tmp, p);
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
await fs.rm(tmp, { force: true });
|
|
76
|
+
throw err;
|
|
77
|
+
}
|
|
39
78
|
}
|
|
40
79
|
/**
|
|
41
|
-
* Long-poll the relay for the next annotation after `after`.
|
|
42
|
-
*
|
|
43
|
-
*
|
|
80
|
+
* Long-poll the relay for the next annotation after `after`.
|
|
81
|
+
*
|
|
82
|
+
* A missing marker and an unreachable relay both resolve `closed` rather than
|
|
83
|
+
* throwing: "no preview is running for this tree" is the same fact the graceful
|
|
84
|
+
* 410 carries, and the caller's correct response to both is identical. Throwing
|
|
85
|
+
* would reach the top-level catch and exit 1 — a fault code for an ordinary,
|
|
86
|
+
* expected end.
|
|
44
87
|
*/
|
|
45
88
|
export async function fetchNext(cwd, opts) {
|
|
46
89
|
const info = await readRelayInfo(cwd);
|
|
47
|
-
if (!info)
|
|
48
|
-
|
|
49
|
-
}
|
|
90
|
+
if (!info)
|
|
91
|
+
return { kind: "closed", reason: "unreachable" };
|
|
50
92
|
const base = `http://localhost:${info.relayPort}`;
|
|
51
93
|
const u = new URL(`${info.harnessPath}/feedback/next`, base);
|
|
52
94
|
u.searchParams.set("project", cwd);
|
|
@@ -56,12 +98,27 @@ export async function fetchNext(cwd, opts) {
|
|
|
56
98
|
try {
|
|
57
99
|
res = await fetch(u);
|
|
58
100
|
}
|
|
59
|
-
catch
|
|
60
|
-
|
|
101
|
+
catch {
|
|
102
|
+
// The socket died under us. The pid gate + relay-identity probe is what
|
|
103
|
+
// separates "the preview ended" from "the request failed but a relay is
|
|
104
|
+
// still there" — only the latter is a fault worth an exit 1.
|
|
105
|
+
if (await relayGone(info.pid, info.relayPort)) {
|
|
106
|
+
return { kind: "closed", reason: "unreachable" };
|
|
107
|
+
}
|
|
108
|
+
throw new Error(`Could not reach the preview relay on :${info.relayPort}. The relay is answering, so this is not a session end.`);
|
|
61
109
|
}
|
|
62
110
|
if (res.status === 204)
|
|
63
|
-
return
|
|
111
|
+
return { kind: "empty" };
|
|
112
|
+
// BEFORE the not-ok throw: 410 is not ok, and a graceful session end must not
|
|
113
|
+
// become a fault.
|
|
114
|
+
if (res.status === 410)
|
|
115
|
+
return { kind: "closed", reason: "signalled" };
|
|
64
116
|
if (!res.ok)
|
|
65
117
|
throw new Error(`relay feedback/next returned ${res.status}`);
|
|
66
|
-
return (await res.json());
|
|
118
|
+
return { kind: "annotation", annotation: (await res.json()) };
|
|
119
|
+
}
|
|
120
|
+
async function relayGone(pid, port) {
|
|
121
|
+
if (pid !== undefined && !isProcessAlive(pid))
|
|
122
|
+
return true;
|
|
123
|
+
return !(await isRelayResponding(port));
|
|
67
124
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** Flow C1 — the two lines before the park. */
|
|
2
|
+
export declare function waiting(): string[];
|
|
3
|
+
/** Flow C6 — the opening line of a watch. */
|
|
4
|
+
export declare function watching(): string[];
|
|
5
|
+
/**
|
|
6
|
+
* Flow C2 — the ending of a drain. A count and nothing else: a fact about what
|
|
7
|
+
* this process did, naming no next step.
|
|
8
|
+
*
|
|
9
|
+
* It exists because a human running this in their own terminal otherwise reads
|
|
10
|
+
* three dense JSON lines and cannot tell whether the command is finished. It is
|
|
11
|
+
* deliberately NOT one line per annotation, which would duplicate the preview
|
|
12
|
+
* stream's arrival line in a second place and triple the stderr noise of the
|
|
13
|
+
* exact case this feature exists for.
|
|
14
|
+
*/
|
|
15
|
+
export declare function handedOver(n: number): string[];
|
|
16
|
+
/**
|
|
17
|
+
* Flow C3 — the bounded window elapsed. One line, one integer, no glyph.
|
|
18
|
+
*
|
|
19
|
+
* `The preview is still live` is the load-bearing half: it is the fact that
|
|
20
|
+
* makes exit 64 actionable without an instruction. The window is named with its
|
|
21
|
+
* number because an unexplained stop is worse than the calibration.
|
|
22
|
+
*/
|
|
23
|
+
export declare function windowElapsed(timeoutMs: number): string[];
|
|
24
|
+
/**
|
|
25
|
+
* Flow C4 — the session ended. `✗`, because this one stops: 65 is not a fault,
|
|
26
|
+
* but it is the end of a channel, and the glyph separates it from 64 at a glance
|
|
27
|
+
* in scrollback.
|
|
28
|
+
*
|
|
29
|
+
* The last line answers the creator's actual fear ("did my annotations die with
|
|
30
|
+
* the preview?") before they can ask it. It does NOT say to start a preview —
|
|
31
|
+
* that precondition is stated in `--help`, which is where a recipe reads it.
|
|
32
|
+
*
|
|
33
|
+
* `0 already handed over` reads slightly oddly and is the price of one sentence
|
|
34
|
+
* covering both counts; suppressing the line at zero would make the ending's
|
|
35
|
+
* shape depend on the count and give a reader two endings to learn.
|
|
36
|
+
*/
|
|
37
|
+
export declare function sessionEnded(handed: number): string[];
|
|
38
|
+
/**
|
|
39
|
+
* The triage detail behind a 65, one flag away.
|
|
40
|
+
*
|
|
41
|
+
* The two paths print IDENTICALLY by default: a polling host reaches 65 on every
|
|
42
|
+
* re-park once a preview has ended, and a creator cannot act on the difference
|
|
43
|
+
* between a graceful teardown and a killed process. The distinction belongs to
|
|
44
|
+
* whoever is diagnosing, not to the hot path.
|
|
45
|
+
*/
|
|
46
|
+
export declare function sessionEndedDetail(reason: "signalled" | "unreachable", tree: string, cursor: number): string[];
|
|
47
|
+
/** Flow C6 — a human stopped a watch. The preview is not what ended. */
|
|
48
|
+
export declare function stoppedWatching(): string[];
|
|
49
|
+
/** Flow C5 — the usage error. */
|
|
50
|
+
export declare const USAGE = "Usage: vincentt feedback --wait | --watch [--timeout <seconds>]";
|
|
51
|
+
/**
|
|
52
|
+
* Flow C6's human render, on stderr, while stdout keeps the exact NDJSON. One
|
|
53
|
+
* observation, two fidelities — the rule that already governs the perf stream.
|
|
54
|
+
*
|
|
55
|
+
* Columns are the preview stream's columns (time, then the tester, then the
|
|
56
|
+
* content), so a creator who has read a preview stream reads this with nothing
|
|
57
|
+
* new to learn. The screenshot path is its own line, unadorned, so it is
|
|
58
|
+
* copy-clean. NOT rendered: id, seq, projectDir — a human can act on none of
|
|
59
|
+
* them.
|
|
60
|
+
*/
|
|
61
|
+
export interface WatchEntry {
|
|
62
|
+
createdAt: number;
|
|
63
|
+
tester?: string;
|
|
64
|
+
message: string;
|
|
65
|
+
screenshotPath: string;
|
|
66
|
+
}
|
|
67
|
+
export declare function watchEntry(e: WatchEntry, columns: number | undefined): string[];
|
|
68
|
+
export declare function stripControl(s: string): string;
|
|
69
|
+
/** Hard wrap at `width`, breaking on spaces where one is available. */
|
|
70
|
+
export declare function wrap(s: string, width: number): string[];
|