@vincentt-xr/cli 0.9.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 +12 -0
- package/dist/cli/args.js +40 -0
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +296 -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 +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/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,163 @@
|
|
|
1
|
+
// Every line `vincentt feedback` writes to STDERR (Flows C1-C6). Pure functions
|
|
2
|
+
// returning string arrays, so the transcripts are assertable without a terminal.
|
|
3
|
+
//
|
|
4
|
+
// The verb has two readers on two streams and they are different audiences:
|
|
5
|
+
// stdout is a MACHINE (one JSON annotation per line, nothing else, ever) and
|
|
6
|
+
// stderr is a HUMAN. Nothing in this file may ever be written to stdout.
|
|
7
|
+
//
|
|
8
|
+
// Two rules govern all of it:
|
|
9
|
+
// - UNBIDDEN OUTPUT STATES A FACT, NEVER A COMMAND. `--wait` prints on its own
|
|
10
|
+
// initiative into what is often an agent's input, so no line here names
|
|
11
|
+
// something to run. Every ending states what is true about the world
|
|
12
|
+
// ("The preview is still live", "The preview ended") where the CLI's older
|
|
13
|
+
// verbs would have ended with an instruction.
|
|
14
|
+
// - An exit code that means "retry is correct" carries NO GLYPH. 64 is neither
|
|
15
|
+
// a failure nor a success: `✗` would teach a polling host's reader that the
|
|
16
|
+
// normal case looks broken, and `✓` would claim something happened.
|
|
17
|
+
//
|
|
18
|
+
// The annotation's own content is never echoed here on the `--wait` path. It is
|
|
19
|
+
// viewer-authored and hostile, and the reader is a parked consumer that is about
|
|
20
|
+
// to receive the whole record on stdout anyway.
|
|
21
|
+
/** Flow C1 — the two lines before the park. */
|
|
22
|
+
export function waiting() {
|
|
23
|
+
return [
|
|
24
|
+
" Waiting for feedback from the preview in this folder.",
|
|
25
|
+
// The verb is ambiguous on first contact — `feedback` could plausibly mean
|
|
26
|
+
// "send feedback to Vincentt", which is `vincentt dx`. This disambiguates by
|
|
27
|
+
// naming DIRECTION rather than by issuing an instruction.
|
|
28
|
+
" Nothing is sent from here; this only reads what your phone sends.",
|
|
29
|
+
];
|
|
30
|
+
}
|
|
31
|
+
/** Flow C6 — the opening line of a watch. */
|
|
32
|
+
export function watching() {
|
|
33
|
+
return [" Watching for feedback from the preview in this folder. Ctrl-C to stop."];
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Flow C2 — the ending of a drain. A count and nothing else: a fact about what
|
|
37
|
+
* this process did, naming no next step.
|
|
38
|
+
*
|
|
39
|
+
* It exists because a human running this in their own terminal otherwise reads
|
|
40
|
+
* three dense JSON lines and cannot tell whether the command is finished. It is
|
|
41
|
+
* deliberately NOT one line per annotation, which would duplicate the preview
|
|
42
|
+
* stream's arrival line in a second place and triple the stderr noise of the
|
|
43
|
+
* exact case this feature exists for.
|
|
44
|
+
*/
|
|
45
|
+
export function handedOver(n) {
|
|
46
|
+
return ["", ` ${n} handed over.`, ""];
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Flow C3 — the bounded window elapsed. One line, one integer, no glyph.
|
|
50
|
+
*
|
|
51
|
+
* `The preview is still live` is the load-bearing half: it is the fact that
|
|
52
|
+
* makes exit 64 actionable without an instruction. The window is named with its
|
|
53
|
+
* number because an unexplained stop is worse than the calibration.
|
|
54
|
+
*/
|
|
55
|
+
export function windowElapsed(timeoutMs) {
|
|
56
|
+
return [
|
|
57
|
+
"",
|
|
58
|
+
` No feedback in the last ${windowLabel(timeoutMs)}. The preview is still live.`,
|
|
59
|
+
"",
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
/** Whole minutes read as minutes; anything else reads as seconds. */
|
|
63
|
+
function windowLabel(timeoutMs) {
|
|
64
|
+
const seconds = Math.max(Math.round(timeoutMs / 1000), 0);
|
|
65
|
+
if (seconds >= 60 && seconds % 60 === 0) {
|
|
66
|
+
const minutes = seconds / 60;
|
|
67
|
+
return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
|
|
68
|
+
}
|
|
69
|
+
return `${seconds} ${seconds === 1 ? "second" : "seconds"}`;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Flow C4 — the session ended. `✗`, because this one stops: 65 is not a fault,
|
|
73
|
+
* but it is the end of a channel, and the glyph separates it from 64 at a glance
|
|
74
|
+
* in scrollback.
|
|
75
|
+
*
|
|
76
|
+
* The last line answers the creator's actual fear ("did my annotations die with
|
|
77
|
+
* the preview?") before they can ask it. It does NOT say to start a preview —
|
|
78
|
+
* that precondition is stated in `--help`, which is where a recipe reads it.
|
|
79
|
+
*
|
|
80
|
+
* `0 already handed over` reads slightly oddly and is the price of one sentence
|
|
81
|
+
* covering both counts; suppressing the line at zero would make the ending's
|
|
82
|
+
* shape depend on the count and give a reader two endings to learn.
|
|
83
|
+
*/
|
|
84
|
+
export function sessionEnded(handed) {
|
|
85
|
+
return [
|
|
86
|
+
"",
|
|
87
|
+
"✗ The preview ended. Nothing more can arrive on it.",
|
|
88
|
+
"",
|
|
89
|
+
` Nothing was missed — the ${handed} already handed over stay handed over.`,
|
|
90
|
+
"",
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The triage detail behind a 65, one flag away.
|
|
95
|
+
*
|
|
96
|
+
* The two paths print IDENTICALLY by default: a polling host reaches 65 on every
|
|
97
|
+
* re-park once a preview has ended, and a creator cannot act on the difference
|
|
98
|
+
* between a graceful teardown and a killed process. The distinction belongs to
|
|
99
|
+
* whoever is diagnosing, not to the hot path.
|
|
100
|
+
*/
|
|
101
|
+
export function sessionEndedDetail(reason, tree, cursor) {
|
|
102
|
+
const which = reason === "signalled"
|
|
103
|
+
? "The preview signalled it was ending"
|
|
104
|
+
: "The relay stopped answering (no teardown signal)";
|
|
105
|
+
return [` ${which} · ${tree}`, ` Cursor unchanged at ${cursor}.`, ""];
|
|
106
|
+
}
|
|
107
|
+
/** Flow C6 — a human stopped a watch. The preview is not what ended. */
|
|
108
|
+
export function stoppedWatching() {
|
|
109
|
+
return ["", " Stopped watching. The preview is still live.", ""];
|
|
110
|
+
}
|
|
111
|
+
/** Flow C5 — the usage error. */
|
|
112
|
+
export const USAGE = "Usage: vincentt feedback --wait | --watch [--timeout <seconds>]";
|
|
113
|
+
/** Content indent + the time column + its gutter — where the tester starts. */
|
|
114
|
+
const TESTER_COL = 9;
|
|
115
|
+
export function watchEntry(e, columns) {
|
|
116
|
+
const time = clockLabel(e.createdAt);
|
|
117
|
+
const tester = e.tester ?? "";
|
|
118
|
+
const head = ` ${time} ${tester}`;
|
|
119
|
+
const gutter = " ".repeat(TESTER_COL);
|
|
120
|
+
// The message is viewer-authored and hostile. A raw `\n` or an ANSI escape in
|
|
121
|
+
// it must not forge a second entry or repaint the screen, so control
|
|
122
|
+
// characters are STRIPPED (not escaped — this stream is for reading) before
|
|
123
|
+
// the text is wrapped. stdout's equivalent protection is JSON.stringify.
|
|
124
|
+
const width = wrapWidth(columns);
|
|
125
|
+
const indent = " ".repeat(head.length + 2);
|
|
126
|
+
const body = wrap(stripControl(e.message), Math.max(width - indent.length, 20));
|
|
127
|
+
const out = [`${head} ${body[0] ?? ""}`];
|
|
128
|
+
for (const line of body.slice(1))
|
|
129
|
+
out.push(`${indent}${line}`);
|
|
130
|
+
out.push(`${gutter}${stripControl(e.screenshotPath)}`);
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
function clockLabel(createdAt) {
|
|
134
|
+
const d = new Date(createdAt);
|
|
135
|
+
if (Number.isNaN(d.getTime()))
|
|
136
|
+
return "--:--";
|
|
137
|
+
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
138
|
+
}
|
|
139
|
+
function wrapWidth(columns) {
|
|
140
|
+
return columns !== undefined && columns >= 40 ? columns : 80;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* C0 and C1 controls out, including ESC, CR, LF, NUL and the DEL range. A
|
|
144
|
+
* replacement character would still occupy a cell for something the tester did
|
|
145
|
+
* not type, so they are removed rather than substituted.
|
|
146
|
+
*/
|
|
147
|
+
const CONTROL_RE = /[\u0000-\u001f\u007f-\u009f]/gu;
|
|
148
|
+
export function stripControl(s) {
|
|
149
|
+
return s.replace(CONTROL_RE, "");
|
|
150
|
+
}
|
|
151
|
+
/** Hard wrap at `width`, breaking on spaces where one is available. */
|
|
152
|
+
export function wrap(s, width) {
|
|
153
|
+
const out = [];
|
|
154
|
+
let rest = s;
|
|
155
|
+
while (rest.length > width) {
|
|
156
|
+
const cut = rest.lastIndexOf(" ", width);
|
|
157
|
+
const at = cut > 0 ? cut : width;
|
|
158
|
+
out.push(rest.slice(0, at));
|
|
159
|
+
rest = rest.slice(cut > 0 ? at + 1 : at);
|
|
160
|
+
}
|
|
161
|
+
out.push(rest);
|
|
162
|
+
return out;
|
|
163
|
+
}
|
package/dist/lib/initFrom.d.ts
CHANGED
|
@@ -23,13 +23,31 @@ export interface RegisterDeps {
|
|
|
23
23
|
message: string;
|
|
24
24
|
}>;
|
|
25
25
|
/**
|
|
26
|
-
* f2's shipped
|
|
26
|
+
* f2's shipped registration half, injected. Returns the exit code; it owns the
|
|
27
27
|
* binding write, which happens ONLY after the 201.
|
|
28
28
|
*/
|
|
29
29
|
create: (opts: {
|
|
30
30
|
name: string;
|
|
31
31
|
slug: string;
|
|
32
32
|
}) => Promise<number>;
|
|
33
|
+
/**
|
|
34
|
+
* Whether a scaffold ran above this call. It changes exactly one word, and that
|
|
35
|
+
* word is the on-disk state: "Nothing else was written." (source exists) versus
|
|
36
|
+
* "Nothing was written." (it does not).
|
|
37
|
+
*
|
|
38
|
+
* `create` in an ALREADY-scaffolded folder (case 2) reaches this orchestrator
|
|
39
|
+
* without having written anything, so the shipped default — true, f6's combined
|
|
40
|
+
* path, where a clone always precedes it — would tell that creator their source
|
|
41
|
+
* was written by this command.
|
|
42
|
+
*/
|
|
43
|
+
scaffolded?: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Whether the resume block carries a `cd <dir>`. `create` run in the cwd has
|
|
46
|
+
* nowhere to cd to, so the line would be `cd .`.
|
|
47
|
+
*
|
|
48
|
+
* Defaults to true: f6's path always scaffolds INTO a named directory.
|
|
49
|
+
*/
|
|
50
|
+
resumeNeedsCd?: boolean;
|
|
33
51
|
}
|
|
34
52
|
export type OrgSource = "active" | "env" | "flag";
|
|
35
53
|
export interface RegisterOptions {
|
|
@@ -39,9 +57,13 @@ export interface RegisterOptions {
|
|
|
39
57
|
slug?: string;
|
|
40
58
|
}
|
|
41
59
|
/**
|
|
42
|
-
* The registration half
|
|
43
|
-
*
|
|
44
|
-
*
|
|
60
|
+
* The registration half — the below-the-seam orchestrator shared by `init --from`
|
|
61
|
+
* (f6) and by `vincentt create` (create-scaffolds). One implementation, because a
|
|
62
|
+
* second implementation of f2's rules is a second place for them to be wrong.
|
|
63
|
+
*
|
|
64
|
+
* `deps.scaffolded` says whether source was written above this call, and it decides
|
|
65
|
+
* one word in every failure's last line: that word IS the on-disk state, read at a
|
|
66
|
+
* glance in scrollback by someone who wants exactly one bit of information.
|
|
45
67
|
*/
|
|
46
68
|
export declare function registerAfterScaffold(deps: RegisterDeps, opts?: RegisterOptions): Promise<number>;
|
|
47
69
|
/** The `· from owner/repo@tag` bullet in the start block. */
|
package/dist/lib/initFrom.js
CHANGED
|
@@ -23,23 +23,47 @@ const ORG_SOURCE_LABEL = {
|
|
|
23
23
|
function reassure(print, dirLabel) {
|
|
24
24
|
print(` Your source is fine — ${dirLabel} is on disk and it's yours.`);
|
|
25
25
|
}
|
|
26
|
-
/** The resume block: `cd <dir>` then the exact create command. */
|
|
27
|
-
function resume(print, dirLabel, command) {
|
|
28
|
-
|
|
26
|
+
/** The resume block: `cd <dir>` (when there is one) then the exact create command. */
|
|
27
|
+
function resume(print, dirLabel, command, needsCd = true) {
|
|
28
|
+
if (needsCd)
|
|
29
|
+
print(` cd ${stripLeadingDotSlash(dirLabel)}`);
|
|
29
30
|
print(` ${command}`);
|
|
30
31
|
}
|
|
31
32
|
function stripLeadingDotSlash(label) {
|
|
32
33
|
return label.startsWith("./") ? label.slice(2) : label;
|
|
33
34
|
}
|
|
34
35
|
/**
|
|
35
|
-
* The registration half
|
|
36
|
-
*
|
|
37
|
-
*
|
|
36
|
+
* The registration half — the below-the-seam orchestrator shared by `init --from`
|
|
37
|
+
* (f6) and by `vincentt create` (create-scaffolds). One implementation, because a
|
|
38
|
+
* second implementation of f2's rules is a second place for them to be wrong.
|
|
39
|
+
*
|
|
40
|
+
* `deps.scaffolded` says whether source was written above this call, and it decides
|
|
41
|
+
* one word in every failure's last line: that word IS the on-disk state, read at a
|
|
42
|
+
* glance in scrollback by someone who wants exactly one bit of information.
|
|
38
43
|
*/
|
|
39
44
|
export async function registerAfterScaffold(deps, opts = {}) {
|
|
40
45
|
const defaultName = opts.name ?? path.basename(path.resolve(deps.targetDir));
|
|
41
46
|
const defaultSlug = opts.slug ?? slugify(defaultName);
|
|
42
47
|
const resumeCommand = `vincentt create --name "${defaultName}" --slug ${defaultSlug || "<address>"}`;
|
|
48
|
+
const scaffolded = deps.scaffolded ?? true;
|
|
49
|
+
const needsCd = deps.resumeNeedsCd ?? true;
|
|
50
|
+
const closing = scaffolded ? " Nothing else was written." : " Nothing was written.";
|
|
51
|
+
// Reached two ways — runLogin threw, and runLogin returned with still no token
|
|
52
|
+
// (the creator closed the tab). One creator-visible outcome, so one block: two
|
|
53
|
+
// copies of nine lines diverge on the first edit to either.
|
|
54
|
+
const printSignInAbandoned = () => {
|
|
55
|
+
deps.print("");
|
|
56
|
+
deps.print("✗ Sign-in was not completed.");
|
|
57
|
+
deps.print("");
|
|
58
|
+
if (scaffolded)
|
|
59
|
+
reassure(deps.print, deps.dirLabel);
|
|
60
|
+
deps.print(" The project was not registered, so no address is reserved yet.");
|
|
61
|
+
deps.print("");
|
|
62
|
+
deps.print(" Finish when you're ready:");
|
|
63
|
+
resume(deps.print, deps.dirLabel, "vincentt create", needsCd);
|
|
64
|
+
deps.print("");
|
|
65
|
+
deps.print(closing);
|
|
66
|
+
};
|
|
43
67
|
// TRAP 2 inverted deliberately: the session is checked AFTER the scaffold, and
|
|
44
68
|
// `ensureLoggedIn` is only reachable on a TTY because it opens a browser.
|
|
45
69
|
const token = await deps.currentToken();
|
|
@@ -48,48 +72,45 @@ export async function registerAfterScaffold(deps, opts = {}) {
|
|
|
48
72
|
deps.print("");
|
|
49
73
|
deps.print("✗ Not signed in, and signing in needs a browser.");
|
|
50
74
|
deps.print("");
|
|
51
|
-
|
|
75
|
+
if (scaffolded)
|
|
76
|
+
reassure(deps.print, deps.dirLabel);
|
|
52
77
|
deps.print(" No address is reserved yet.");
|
|
53
78
|
deps.print("");
|
|
54
79
|
deps.print(" On a machine with a browser:");
|
|
55
80
|
deps.print(" vincentt login");
|
|
56
81
|
deps.print("");
|
|
57
82
|
deps.print(" Then finish:");
|
|
58
|
-
resume(deps.print, deps.dirLabel, resumeCommand);
|
|
83
|
+
resume(deps.print, deps.dirLabel, resumeCommand, needsCd);
|
|
59
84
|
deps.print("");
|
|
60
|
-
deps.print(
|
|
85
|
+
deps.print(closing);
|
|
61
86
|
return 1;
|
|
62
87
|
}
|
|
63
88
|
deps.print("");
|
|
64
89
|
deps.print("→ Now registering the project so it gets an address.");
|
|
65
90
|
deps.print(" You're not signed in yet — opening the browser to authorize this device…");
|
|
91
|
+
// The open path's load-bearing home, and the only new sentence in this flow.
|
|
92
|
+
// The reader is a human holding working source who is being asked for an
|
|
93
|
+
// account thirty seconds after watching an app appear on their disk — the one
|
|
94
|
+
// person for whom "you did not have to" is news, and nobody reads `--help` at
|
|
95
|
+
// the moment they are being asked to sign in. It is a statement of FACT, not
|
|
96
|
+
// an offer to abort: their source is already on disk, so the sentence has no
|
|
97
|
+
// action attached and costs the reader nothing.
|
|
98
|
+
//
|
|
99
|
+
// Only when a scaffold actually happened. In an already-scaffolded folder the
|
|
100
|
+
// creator has source `init` did not write, so the sentence would be advice to
|
|
101
|
+
// re-run a verb that has nothing left to do.
|
|
102
|
+
if (scaffolded) {
|
|
103
|
+
deps.print(" (`vincentt init` scaffolds with no account at all, if you'd rather not sign in.)");
|
|
104
|
+
}
|
|
66
105
|
try {
|
|
67
106
|
await deps.runLogin();
|
|
68
107
|
}
|
|
69
108
|
catch {
|
|
70
|
-
|
|
71
|
-
deps.print("✗ Sign-in was not completed.");
|
|
72
|
-
deps.print("");
|
|
73
|
-
reassure(deps.print, deps.dirLabel);
|
|
74
|
-
deps.print(" The project was not registered, so no address is reserved yet.");
|
|
75
|
-
deps.print("");
|
|
76
|
-
deps.print(" Finish when you're ready:");
|
|
77
|
-
resume(deps.print, deps.dirLabel, "vincentt create");
|
|
78
|
-
deps.print("");
|
|
79
|
-
deps.print(" Nothing else was written.");
|
|
109
|
+
printSignInAbandoned();
|
|
80
110
|
return 1;
|
|
81
111
|
}
|
|
82
112
|
if (!(await deps.currentToken())) {
|
|
83
|
-
|
|
84
|
-
deps.print("✗ Sign-in was not completed.");
|
|
85
|
-
deps.print("");
|
|
86
|
-
reassure(deps.print, deps.dirLabel);
|
|
87
|
-
deps.print(" The project was not registered, so no address is reserved yet.");
|
|
88
|
-
deps.print("");
|
|
89
|
-
deps.print(" Finish when you're ready:");
|
|
90
|
-
resume(deps.print, deps.dirLabel, "vincentt create");
|
|
91
|
-
deps.print("");
|
|
92
|
-
deps.print(" Nothing else was written.");
|
|
113
|
+
printSignInAbandoned();
|
|
93
114
|
return 1;
|
|
94
115
|
}
|
|
95
116
|
}
|
|
@@ -98,13 +119,14 @@ export async function registerAfterScaffold(deps, opts = {}) {
|
|
|
98
119
|
deps.print("");
|
|
99
120
|
deps.print("✗ Can't register the project without a name and an address, and I can't ask.");
|
|
100
121
|
deps.print("");
|
|
101
|
-
|
|
122
|
+
if (scaffolded)
|
|
123
|
+
reassure(deps.print, deps.dirLabel);
|
|
102
124
|
deps.print(" No address is reserved yet.");
|
|
103
125
|
deps.print("");
|
|
104
126
|
deps.print(" Finish it:");
|
|
105
|
-
resume(deps.print, deps.dirLabel, resumeCommand);
|
|
127
|
+
resume(deps.print, deps.dirLabel, resumeCommand, needsCd);
|
|
106
128
|
deps.print("");
|
|
107
|
-
deps.print(
|
|
129
|
+
deps.print(closing);
|
|
108
130
|
return 1;
|
|
109
131
|
}
|
|
110
132
|
const org = await deps.resolveOrg();
|
|
@@ -112,15 +134,15 @@ export async function registerAfterScaffold(deps, opts = {}) {
|
|
|
112
134
|
deps.print("");
|
|
113
135
|
deps.print(org.message);
|
|
114
136
|
deps.print("");
|
|
115
|
-
|
|
137
|
+
if (scaffolded)
|
|
138
|
+
reassure(deps.print, deps.dirLabel);
|
|
116
139
|
deps.print(" No address is reserved yet.");
|
|
117
140
|
deps.print("");
|
|
118
141
|
deps.print(" Check who you are: vincentt whoami");
|
|
119
142
|
deps.print(" Then finish:");
|
|
120
|
-
deps.print
|
|
121
|
-
deps.print(" vincentt create --org <workspace>");
|
|
143
|
+
resume(deps.print, deps.dirLabel, "vincentt create --org <workspace>", needsCd);
|
|
122
144
|
deps.print("");
|
|
123
|
-
deps.print(
|
|
145
|
+
deps.print(closing);
|
|
124
146
|
return 1;
|
|
125
147
|
}
|
|
126
148
|
// AF-3: name the org AND where it came from, before the address prompt. The
|
|
@@ -157,7 +179,8 @@ export async function registerAfterScaffold(deps, opts = {}) {
|
|
|
157
179
|
deps.print("");
|
|
158
180
|
deps.print("✗ That address is taken.");
|
|
159
181
|
deps.print("");
|
|
160
|
-
|
|
182
|
+
if (scaffolded)
|
|
183
|
+
reassure(deps.print, deps.dirLabel);
|
|
161
184
|
deps.print(" No address is reserved yet.");
|
|
162
185
|
if (err.suggestions.length > 0) {
|
|
163
186
|
deps.print("");
|
|
@@ -169,17 +192,18 @@ export async function registerAfterScaffold(deps, opts = {}) {
|
|
|
169
192
|
deps.print(deps.ask
|
|
170
193
|
? " Finish with an address of your own:"
|
|
171
194
|
: " Finish with one of those, or your own:");
|
|
172
|
-
deps.print
|
|
173
|
-
deps.print(` vincentt create --name "${name}" --slug <address>`);
|
|
195
|
+
resume(deps.print, deps.dirLabel, `vincentt create --name "${name}" --slug <address>`, needsCd);
|
|
174
196
|
deps.print("");
|
|
175
|
-
deps.print(
|
|
197
|
+
deps.print(closing);
|
|
176
198
|
return 1;
|
|
177
199
|
}
|
|
178
200
|
// A routine, expected collision mid-flow: `!` and a re-ask, not `✗` and an
|
|
179
201
|
// exit. The command DOES proceed, so a `✗` would announce a stop that is
|
|
180
202
|
// not happening.
|
|
181
203
|
deps.print("");
|
|
182
|
-
deps.print(
|
|
204
|
+
deps.print(scaffolded
|
|
205
|
+
? "! That address is taken. Your source is fine — pick another."
|
|
206
|
+
: "! That address is taken. Pick another.");
|
|
183
207
|
if (err.suggestions.length > 0) {
|
|
184
208
|
deps.print("");
|
|
185
209
|
deps.print(" Available alternatives:");
|
package/dist/lib/lifecycle.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { OrgSource } from "./initFrom.js";
|
|
2
|
+
import type { Ask } from "../publish/prompt.js";
|
|
1
3
|
import { type ResolvedOrg } from "./org.js";
|
|
2
4
|
import { type RelayClient } from "./diagnostics.js";
|
|
3
5
|
import { type ResolvedConfig } from "../shared/config.js";
|
|
@@ -12,6 +14,18 @@ export interface CreateOptions {
|
|
|
12
14
|
org?: string;
|
|
13
15
|
/** Honor a binding's apiUrl override (--allow-custom-api). */
|
|
14
16
|
allowCustomApi?: boolean;
|
|
17
|
+
/** The positional, already through `sanitizeAppName`. Resolves against the cwd. */
|
|
18
|
+
dir?: string;
|
|
19
|
+
/** `--no-install`, the same meaning it has on `init`. */
|
|
20
|
+
skipInstall?: boolean;
|
|
21
|
+
/** Free-text prompt. Absent → not a TTY, so nothing may be asked. */
|
|
22
|
+
ask?: Ask;
|
|
23
|
+
/** The PAT that would authenticate a call, if any. Never triggers a login. */
|
|
24
|
+
currentToken?: () => Promise<string | undefined>;
|
|
25
|
+
/** The interactive browser login. Only ever reached on a TTY. */
|
|
26
|
+
runLogin?: () => Promise<void>;
|
|
27
|
+
/** Where the resolved org came from, so a surprising value is diagnosable. */
|
|
28
|
+
orgSource?: () => OrgSource;
|
|
15
29
|
/**
|
|
16
30
|
* Stream the transcript as it happens. The CLI passes console.log so a slow
|
|
17
31
|
* create shows progress and the apiUrl advisory lands before the request; MCP
|
|
@@ -30,8 +44,9 @@ export interface CreateResult {
|
|
|
30
44
|
* `project_create` tool, so the two surfaces can never diverge in behavior OR in
|
|
31
45
|
* what they tell the caller.
|
|
32
46
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
47
|
+
* A two-half verb: it scaffolds an EMPTY folder from the standard starter, only
|
|
48
|
+
* registers a folder that is already a Vincentt app, and refuses anything else.
|
|
49
|
+
* See `createCommand`.
|
|
35
50
|
*/
|
|
36
51
|
export declare function projectCreate(projectCwd: string, opts?: CreateOptions): Promise<CreateResult>;
|
|
37
52
|
/**
|
|
@@ -95,6 +110,16 @@ export interface PreviewStopOutcome {
|
|
|
95
110
|
stopped: boolean;
|
|
96
111
|
message: string;
|
|
97
112
|
}
|
|
113
|
+
export interface PreviewStopDeps {
|
|
114
|
+
/**
|
|
115
|
+
* The relay to ask for presence. Injected for tests; in production it is built
|
|
116
|
+
* from the marker's port. A client that throws, hangs, or answers a body of
|
|
117
|
+
* the wrong shape yields NO count, which yields no line.
|
|
118
|
+
*/
|
|
119
|
+
relay?: RelayClient;
|
|
120
|
+
/** Injected so the SIGTERM is observable without signalling a real process. */
|
|
121
|
+
kill?: (pid: number, signal: NodeJS.Signals) => void;
|
|
122
|
+
}
|
|
98
123
|
/**
|
|
99
124
|
* Stop a preview started by a *different* process (the common case: `vincentt
|
|
100
125
|
* preview` runs in the background and the agent later wants it down). Reads the
|
|
@@ -107,7 +132,7 @@ export interface PreviewStopOutcome {
|
|
|
107
132
|
* closes, the edge sees the tunnel go, and the session drains on its own — so
|
|
108
133
|
* SIGTERM plus clearing the stale relay info is the whole of the cleanup.
|
|
109
134
|
*/
|
|
110
|
-
export declare function previewStopSignal(projectCwd: string): Promise<PreviewStopOutcome>;
|
|
135
|
+
export declare function previewStopSignal(projectCwd: string, deps?: PreviewStopDeps): Promise<PreviewStopOutcome>;
|
|
111
136
|
export interface DiagQueryOptions {
|
|
112
137
|
since?: number;
|
|
113
138
|
limit?: number;
|
package/dist/lib/lifecycle.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// lower-level pieces (backend calls, scaffold, preview, diagnostics); no CLI or
|
|
4
4
|
// MCP SDK concerns leak in here. Both surfaces render the returned data their
|
|
5
5
|
// own way.
|
|
6
|
+
import path from "node:path";
|
|
6
7
|
import { getMe } from "./backend.js";
|
|
7
8
|
import { createCommand } from "./projects.js";
|
|
8
9
|
import { publishCommand } from "../publish/command.js";
|
|
@@ -11,13 +12,18 @@ import { filterErrors, httpRelayClient, renderResult, } from "./diagnostics.js";
|
|
|
11
12
|
import { clearRelayInfo, readLivePreview, readRelayInfo, writeRelayInfo, } from "./relayInfo.js";
|
|
12
13
|
import { loadMachineConfig, locateProjectBinding, resolveConfig, } from "../shared/config.js";
|
|
13
14
|
import { startPreview, } from "../preview/index.js";
|
|
15
|
+
// The SHIPPED stopping line, reused rather than reworded: the interrupt path and
|
|
16
|
+
// this path are one sentence about one event, and it says CONNECTIONS because one
|
|
17
|
+
// tab opens several under HTTP/1.1 (preview/copy.ts).
|
|
18
|
+
import * as previewCopy from "../preview/copy.js";
|
|
14
19
|
/**
|
|
15
20
|
* The shared `create` orchestration behind both `vincentt create` and the MCP
|
|
16
21
|
* `project_create` tool, so the two surfaces can never diverge in behavior OR in
|
|
17
22
|
* what they tell the caller.
|
|
18
23
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
24
|
+
* A two-half verb: it scaffolds an EMPTY folder from the standard starter, only
|
|
25
|
+
* registers a folder that is already a Vincentt app, and refuses anything else.
|
|
26
|
+
* See `createCommand`.
|
|
21
27
|
*/
|
|
22
28
|
export async function projectCreate(projectCwd, opts = {}) {
|
|
23
29
|
const lines = [];
|
|
@@ -25,11 +31,16 @@ export async function projectCreate(projectCwd, opts = {}) {
|
|
|
25
31
|
lines.push(line);
|
|
26
32
|
opts.onLine?.(line);
|
|
27
33
|
};
|
|
34
|
+
// ONE target: the positional names a child of `projectCwd`, and the config must
|
|
35
|
+
// be read from the folder the verb ACTS on, not from its parent — a parent's
|
|
36
|
+
// binding carrying an apiUrl override would otherwise decide where the child's
|
|
37
|
+
// project is registered.
|
|
38
|
+
const target = opts.dir ? path.resolve(projectCwd, opts.dir) : path.resolve(projectCwd);
|
|
28
39
|
// Resolved once, lazily: a create refused by a local guard must not require a
|
|
29
40
|
// PAT and must not reach the network at all.
|
|
30
41
|
let cached;
|
|
31
42
|
const config = async () => {
|
|
32
|
-
cached ??= await resolveConfig(
|
|
43
|
+
cached ??= await resolveConfig(target, {
|
|
33
44
|
allowCustomApi: opts.allowCustomApi,
|
|
34
45
|
warn: print,
|
|
35
46
|
});
|
|
@@ -40,7 +51,17 @@ export async function projectCreate(projectCwd, opts = {}) {
|
|
|
40
51
|
cwd: projectCwd,
|
|
41
52
|
config,
|
|
42
53
|
org: async () => resolveOrgForCommand(await config(), { flag: opts.org }),
|
|
43
|
-
|
|
54
|
+
ask: opts.ask,
|
|
55
|
+
currentToken: opts.currentToken,
|
|
56
|
+
runLogin: opts.runLogin,
|
|
57
|
+
orgSource: opts.orgSource,
|
|
58
|
+
}, {
|
|
59
|
+
name: opts.name,
|
|
60
|
+
slug: opts.slug,
|
|
61
|
+
force: opts.force,
|
|
62
|
+
dir: opts.dir,
|
|
63
|
+
skipInstall: opts.skipInstall,
|
|
64
|
+
});
|
|
44
65
|
return { message: lines.join("\n"), exitCode };
|
|
45
66
|
}
|
|
46
67
|
/**
|
|
@@ -171,6 +192,51 @@ function isAlive(pid) {
|
|
|
171
192
|
return false;
|
|
172
193
|
}
|
|
173
194
|
}
|
|
195
|
+
/** The bound the caller is held to: one query, no retry, isRelayResponding's window. */
|
|
196
|
+
const PRESENCE_TIMEOUT_MS = 700;
|
|
197
|
+
/**
|
|
198
|
+
* How many connections are attached, or UNDEFINED when that cannot be
|
|
199
|
+
* established — and undefined is a first-class answer here, not an error.
|
|
200
|
+
*
|
|
201
|
+
* ⚠ THE WRONG SOURCE IS IN THE SAME RESPONSE OBJECT. A `RelayResult` carries
|
|
202
|
+
* `viewers[]` and `testers[]`, and when `liveConnections` is absent those arrays
|
|
203
|
+
* are frequently NON-EMPTY, because they are ever-seen sets the store never
|
|
204
|
+
* prunes (relay/store.ts:14-31). `result.viewers.length` therefore returns a
|
|
205
|
+
* plausible number at exactly the moment the real count is unknown, and printing
|
|
206
|
+
* it claims a phone that closed its tab twenty minutes ago is still watching.
|
|
207
|
+
* There is NO FALLBACK here on purpose: no `?? 0`, no `viewers.length`. Absent
|
|
208
|
+
* means unknown, and unknown prints nothing.
|
|
209
|
+
*/
|
|
210
|
+
async function readLiveConnections(relay) {
|
|
211
|
+
// BOUNDED, not rate-limited: one query, the same window isRelayResponding
|
|
212
|
+
// uses, no retry. The relay has no limiter and this branch reads two in-memory
|
|
213
|
+
// integers; the discipline belongs on the caller.
|
|
214
|
+
const timeout = new Promise((resolve) => setTimeout(() => resolve(undefined), PRESENCE_TIMEOUT_MS).unref?.());
|
|
215
|
+
let result;
|
|
216
|
+
try {
|
|
217
|
+
result = await Promise.race([relay.query({ presence: true, limit: 0 }), timeout]);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
// Refused, non-JSON, a non-2xx — every one of them is "no count".
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
if (result === undefined)
|
|
224
|
+
return undefined;
|
|
225
|
+
// The SHAPE is the identity proof, the same one isRelayResponding applies: a
|
|
226
|
+
// stranger on a reused port does not answer a RelayResult. Only after the
|
|
227
|
+
// envelope is recognised is the count itself read, and it must be a real
|
|
228
|
+
// non-negative integer — a relay that predates the field sends none.
|
|
229
|
+
if (typeof result !== "object" ||
|
|
230
|
+
result === null ||
|
|
231
|
+
!Array.isArray(result.events) ||
|
|
232
|
+
typeof result.latestSeq !== "number") {
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
const n = result.liveConnections;
|
|
236
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n < 0)
|
|
237
|
+
return undefined;
|
|
238
|
+
return n;
|
|
239
|
+
}
|
|
174
240
|
/**
|
|
175
241
|
* Stop a preview started by a *different* process (the common case: `vincentt
|
|
176
242
|
* preview` runs in the background and the agent later wants it down). Reads the
|
|
@@ -183,17 +249,43 @@ function isAlive(pid) {
|
|
|
183
249
|
* closes, the edge sees the tunnel go, and the session drains on its own — so
|
|
184
250
|
* SIGTERM plus clearing the stale relay info is the whole of the cleanup.
|
|
185
251
|
*/
|
|
186
|
-
export async function previewStopSignal(projectCwd) {
|
|
252
|
+
export async function previewStopSignal(projectCwd, deps = {}) {
|
|
187
253
|
const info = await readRelayInfo(projectCwd);
|
|
188
254
|
if (!info) {
|
|
189
255
|
return { stopped: false, message: "No preview is running for this directory." };
|
|
190
256
|
}
|
|
191
257
|
const previewAlive = info.pid !== undefined && isAlive(info.pid);
|
|
192
258
|
if (previewAlive) {
|
|
259
|
+
// PRESENCE IS READ BEFORE THE SIGNAL, and the order is the feature. The
|
|
260
|
+
// creator is about to blank someone's screen, so the count has to describe
|
|
261
|
+
// the moment before that happens; asking after the SIGTERM would race the
|
|
262
|
+
// relay's own teardown and print a number that was true of a preview which
|
|
263
|
+
// no longer exists. A dead pid is never queried at all — the marker is stale
|
|
264
|
+
// and there is nothing on that port to ask.
|
|
265
|
+
const relay = deps.relay ?? httpRelayClient(`http://127.0.0.1:${info.relayPort}`);
|
|
266
|
+
const connected = await readLiveConnections(relay);
|
|
193
267
|
try {
|
|
194
|
-
process.kill(info.pid, "SIGTERM");
|
|
268
|
+
(deps.kill ?? ((pid, sig) => process.kill(pid, sig)))(info.pid, "SIGTERM");
|
|
195
269
|
await clearRelayInfo(projectCwd);
|
|
196
|
-
|
|
270
|
+
// A TRUE ZERO AND AN UNAVAILABLE COUNT PRODUCE THE IDENTICAL BYTES, and
|
|
271
|
+
// that is the decision rather than an accident. `stopping(0)` is the bare
|
|
272
|
+
// `→ Stopping…`, which carries no count — so emitting it on a confirmed
|
|
273
|
+
// zero while omitting it on an unknown would make the two distinguishable
|
|
274
|
+
// by a line that says nothing either way, and a creator could read the
|
|
275
|
+
// difference as meaning something. The line is emitted only when there is
|
|
276
|
+
// a count to state; both silent states collapse.
|
|
277
|
+
//
|
|
278
|
+
// "0 connections open" is the other tempting answer and it is refused for
|
|
279
|
+
// the same reason `stopping()` already refuses it: it would be a claim,
|
|
280
|
+
// and in the unavailable case a false one. Silence claims nothing.
|
|
281
|
+
const line = connected === undefined || connected === 0
|
|
282
|
+
? undefined
|
|
283
|
+
: previewCopy.stopping(connected);
|
|
284
|
+
const lines = [
|
|
285
|
+
...(line === undefined ? [] : [line]),
|
|
286
|
+
"✓ Preview stopped. The link no longer works.",
|
|
287
|
+
];
|
|
288
|
+
return { stopped: true, message: lines.join("\n") };
|
|
197
289
|
}
|
|
198
290
|
catch {
|
|
199
291
|
// Raced with its own exit; the socket is gone either way.
|