@vincentt-xr/cli 0.8.0 → 0.10.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 +22 -0
- package/dist/cli/args.js +71 -0
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +359 -91
- 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/dx/skeleton.js +1 -0
- 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/initCommand.d.ts +28 -1
- package/dist/lib/initCommand.js +91 -16
- package/dist/lib/initFrom.d.ts +70 -0
- package/dist/lib/initFrom.js +222 -0
- 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/sourceRef.d.ts +46 -0
- package/dist/lib/sourceRef.js +107 -0
- package/dist/lib/sourceRefCopy.d.ts +30 -0
- package/dist/lib/sourceRefCopy.js +116 -0
- package/dist/preview/command.d.ts +11 -0
- package/dist/preview/command.js +19 -1
- package/dist/preview/copy.d.ts +7 -0
- package/dist/preview/copy.js +8 -1
- 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/publish/api.d.ts +20 -0
- package/dist/publish/command.d.ts +3 -0
- package/dist/publish/command.js +39 -4
- package/dist/publish/copy.d.ts +29 -0
- package/dist/publish/copy.js +86 -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/dist/scaffold/index.d.ts +9 -0
- package/dist/scaffold/index.js +13 -1
- 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,97 @@
|
|
|
1
|
+
// The help text, in its own module so it can be rendered without importing the
|
|
2
|
+
// CLI entrypoint — `cli/index.ts` calls `main()` at module scope, so importing it
|
|
3
|
+
// runs the CLI, writes the dx journal and sets process.exitCode.
|
|
4
|
+
export const USAGE_TEXT = `Vincentt CLI — local-first AR dev loop\n\n` +
|
|
5
|
+
`Usage: vincentt <command> [options]\n\n` +
|
|
6
|
+
`Getting started (building a new app with a coding agent)\n` +
|
|
7
|
+
` 1. Confirm a project name with the developer first.\n` +
|
|
8
|
+
` 2. Create: vincentt create <name> (scaffolds if the folder is empty)\n` +
|
|
9
|
+
` 3. The scaffold writes the agent contract into the project root. Read it,\n` +
|
|
10
|
+
` confirm the project is ready, and stop — do NOT invent an app.\n` +
|
|
11
|
+
` Wait for the developer to describe what to build.\n` +
|
|
12
|
+
` Full guide: npm view @vincentt-xr/cli readme\n\n` +
|
|
13
|
+
`Auth\n` +
|
|
14
|
+
` login Sign in; save an access token to ~/.vincentt/config.json\n` +
|
|
15
|
+
` [--api <url>] [--console <url>] to target a non-prod env\n` +
|
|
16
|
+
` logout Remove the saved access token\n` +
|
|
17
|
+
` whoami Show the signed-in account\n\n` +
|
|
18
|
+
`Project\n` +
|
|
19
|
+
` init [dir] Scaffold a Vincentt XR app — no account needed, ever\n` +
|
|
20
|
+
` [--no-install] [--template <version>]\n` +
|
|
21
|
+
` [--from <owner>/<repo>@<tag>] Clone a gallery template\n` +
|
|
22
|
+
` and register it [--name <s>] [--slug <s>]\n` +
|
|
23
|
+
` create [name] Register a project and reserve its address; scaffolds first\n` +
|
|
24
|
+
` if the folder is empty [--name <s>] [--slug <s>]\n` +
|
|
25
|
+
` [--org <s>] [--preview] [--force] [--no-install]\n` +
|
|
26
|
+
` link <project-id> Link this folder to an existing project\n` +
|
|
27
|
+
` unlink Disconnect this folder from its project\n` +
|
|
28
|
+
` projects List the workspace's projects and their ids [--json]\n` +
|
|
29
|
+
` [--org <s>] to list a workspace other than the active one\n\n` +
|
|
30
|
+
`Dev loop\n` +
|
|
31
|
+
` publish Upload the built dist; go live at <slug>.vincentt.app\n` +
|
|
32
|
+
` [--dist <path>] [--note <s>] [--yes]\n` +
|
|
33
|
+
` preview Serve the app at an https link a phone can open\n` +
|
|
34
|
+
` [--port <n>] [--tester <id>] [--no-qr] [--stop]\n` +
|
|
35
|
+
` Re-running prints the live link; --restart mints a new one.\n\n` +
|
|
36
|
+
`Diagnostics (need a running preview)\n` +
|
|
37
|
+
` logs [--errors] Console logs from the device [--json] [--tester <id>]\n` +
|
|
38
|
+
` network fetch/XHR requests [--json] [--tester <id>]\n` +
|
|
39
|
+
` trace Performance samples [--json] [--tester <id>]\n` +
|
|
40
|
+
` --tester t1 filters to one tester (one tab, across\n` +
|
|
41
|
+
` its refreshes). --viewer <label> filters to a single\n` +
|
|
42
|
+
` connection; one tab opens several.\n\n` +
|
|
43
|
+
`Asset library\n` +
|
|
44
|
+
` assets search "<query>" Find curated assets [--kind prop|frame]\n` +
|
|
45
|
+
` [--render 3d-clay] [--palette sage] [--limit 20] [--json]\n` +
|
|
46
|
+
` assets show <id> [<id>…] Everything known about an asset [--json]\n` +
|
|
47
|
+
` assets add <id> [<id>…] Print the URLs to reference from your source\n` +
|
|
48
|
+
` Writes nothing. [--download [--dir <path>] [--force]]\n` +
|
|
49
|
+
` [--json]\n` +
|
|
50
|
+
` Ids look like lib_7f3a2c67d4e5b6a7; search returns\n` +
|
|
51
|
+
` them. Reference only ids the tool returned.\n\n` +
|
|
52
|
+
// Help text is SOLICITED output, so an instruction would be permitted here —
|
|
53
|
+
// and it is still written as facts, because these exact words get quoted into
|
|
54
|
+
// a creator's own agent instructions, where they are unbidden by the time
|
|
55
|
+
// anything acts on them.
|
|
56
|
+
//
|
|
57
|
+
// Every line below is load-bearing. The exit codes are named HERE because a
|
|
58
|
+
// reader who has to look them up elsewhere guesses instead. "Blocking costs
|
|
59
|
+
// nothing while it waits" is the sentence that kills the poll-on-a-timer
|
|
60
|
+
// pattern — that pattern exists because a blocked command is assumed
|
|
61
|
+
// expensive and polling cheap, which is exactly backwards. "the same command
|
|
62
|
+
// may be run again immediately" teaches the re-park loop without naming a
|
|
63
|
+
// host, a shell, or any background mechanism, and "nothing is missed and
|
|
64
|
+
// nothing is re-read" forecloses an invented seen-set or a `--since` flag
|
|
65
|
+
// that does not exist.
|
|
66
|
+
//
|
|
67
|
+
// DELIBERATELY ABSENT: any product name; any shell idiom; any mention of a
|
|
68
|
+
// scheduled task EVEN TO FORBID ONE (naming a bad pattern is how a reader
|
|
69
|
+
// learns it exists); any "recommended for AI" framing.
|
|
70
|
+
`Reverse channel — feedback sent from the phone\n` +
|
|
71
|
+
` feedback --wait Block until feedback arrives, print every annotation\n` +
|
|
72
|
+
` waiting as one JSON object per line, then exit.\n` +
|
|
73
|
+
` [--timeout <seconds>, default 300]\n` +
|
|
74
|
+
` Exit 0 one or more annotations printed\n` +
|
|
75
|
+
` Exit 64 the window passed with nothing waiting\n` +
|
|
76
|
+
` Exit 65 the preview ended; nothing more can arrive\n` +
|
|
77
|
+
` Blocking costs nothing while it waits. On 64 the same\n` +
|
|
78
|
+
` command may be run again immediately; nothing is\n` +
|
|
79
|
+
` missed and nothing is re-read. On 65 a preview has to\n` +
|
|
80
|
+
` be running before there is anything to wait on.\n` +
|
|
81
|
+
` feedback --watch The same stream, printed as it arrives, without exiting.\n` +
|
|
82
|
+
` For a person watching a terminal.\n\n` +
|
|
83
|
+
`Servers\n` +
|
|
84
|
+
` relay Run the diagnostics/annotation relay\n\n` +
|
|
85
|
+
`Meta\n` +
|
|
86
|
+
` version CLI version, and the API this machine resolves to\n` +
|
|
87
|
+
` dx Where this dev loop fought you [--days <n>] [--json]\n` +
|
|
88
|
+
` Reads a local journal of your own runs. Local by default.\n` +
|
|
89
|
+
` --send reports the findings to the Vincentt team; it\n` +
|
|
90
|
+
` shows the exact payload and asks first. [--note <text>]\n` +
|
|
91
|
+
` dx enable Allow reports to be sent from this machine without asking.\n` +
|
|
92
|
+
` Asks you to confirm in your terminal. Stays on until you\n` +
|
|
93
|
+
` disable it, and is shown on \`vincentt dx\` and\n` +
|
|
94
|
+
` \`vincentt version\` while on.\n` +
|
|
95
|
+
` dx disable Turn that off again.\n\n` +
|
|
96
|
+
`Common options: --dir <path> (project dir, default cwd)\n` +
|
|
97
|
+
` --allow-custom-api honor a binding's apiUrl override\n`;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export declare const PINNED_VERBS: readonly ["version", "login", "logout", "whoami", "init", "create", "link", "unlink", "publish", "preview", "logs", "network", "trace", "feedback", "relay", "assets", "dx", "projects"];
|
|
2
|
+
export type PinnedVerb = (typeof PINNED_VERBS)[number];
|
|
3
|
+
export declare const VERB_HANDLERS: {
|
|
4
|
+
readonly version: true;
|
|
5
|
+
readonly login: true;
|
|
6
|
+
readonly logout: true;
|
|
7
|
+
readonly whoami: true;
|
|
8
|
+
readonly init: true;
|
|
9
|
+
readonly create: true;
|
|
10
|
+
readonly link: true;
|
|
11
|
+
readonly unlink: true;
|
|
12
|
+
readonly publish: true;
|
|
13
|
+
readonly preview: true;
|
|
14
|
+
readonly logs: true;
|
|
15
|
+
readonly network: true;
|
|
16
|
+
readonly trace: true;
|
|
17
|
+
readonly feedback: true;
|
|
18
|
+
readonly relay: true;
|
|
19
|
+
readonly assets: true;
|
|
20
|
+
readonly dx: true;
|
|
21
|
+
readonly projects: true;
|
|
22
|
+
};
|
|
23
|
+
export type DispatchVerb = keyof typeof VERB_HANDLERS;
|
|
24
|
+
export declare function resolveVerb(cmd: string | undefined): DispatchVerb | undefined;
|
|
@@ -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/dx/skeleton.js
CHANGED
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
|
}
|