@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.
Files changed (52) hide show
  1. package/README.md +40 -13
  2. package/dist/cli/args.d.ts +12 -0
  3. package/dist/cli/args.js +40 -0
  4. package/dist/cli/index.d.ts +1 -1
  5. package/dist/cli/index.js +308 -95
  6. package/dist/cli/usage-text.d.ts +1 -0
  7. package/dist/cli/usage-text.js +97 -0
  8. package/dist/cli/verb-surface.d.ts +24 -0
  9. package/dist/cli/verb-surface.js +49 -0
  10. package/dist/dx/clusters.d.ts +0 -17
  11. package/dist/dx/clusters.js +50 -1
  12. package/dist/lib/backend.d.ts +11 -0
  13. package/dist/lib/backend.js +23 -0
  14. package/dist/lib/classifyTarget.d.ts +42 -0
  15. package/dist/lib/classifyTarget.js +83 -0
  16. package/dist/lib/feedback.d.ts +46 -5
  17. package/dist/lib/feedback.js +76 -19
  18. package/dist/lib/feedbackCopy.d.ts +70 -0
  19. package/dist/lib/feedbackCopy.js +163 -0
  20. package/dist/lib/initFrom.d.ts +26 -4
  21. package/dist/lib/initFrom.js +65 -41
  22. package/dist/lib/lifecycle.d.ts +28 -3
  23. package/dist/lib/lifecycle.js +99 -7
  24. package/dist/lib/listProjects.d.ts +41 -0
  25. package/dist/lib/listProjects.js +77 -0
  26. package/dist/lib/projects.d.ts +64 -8
  27. package/dist/lib/projects.js +316 -24
  28. package/dist/lib/sourceRefCopy.d.ts +19 -0
  29. package/dist/lib/sourceRefCopy.js +36 -0
  30. package/dist/preview/command.d.ts +11 -0
  31. package/dist/preview/command.js +19 -1
  32. package/dist/preview/copy.d.ts +19 -3
  33. package/dist/preview/copy.js +21 -5
  34. package/dist/preview/runner.d.ts +6 -0
  35. package/dist/preview/runner.js +1 -0
  36. package/dist/preview/stream.d.ts +11 -0
  37. package/dist/preview/stream.js +13 -0
  38. package/dist/relay/annotations.d.ts +28 -4
  39. package/dist/relay/annotations.js +23 -7
  40. package/dist/relay/qa.f12.fixtures.d.ts +154 -0
  41. package/dist/relay/qa.f12.fixtures.js +358 -0
  42. package/dist/relay/server.d.ts +41 -3
  43. package/dist/relay/server.js +90 -32
  44. package/package.json +2 -2
  45. package/dist/library/qa.f5-b32.spec.d.ts +0 -1
  46. package/dist/library/qa.f5-b32.spec.js +0 -202
  47. package/dist/library/qa.f5-cli.spec.d.ts +0 -1
  48. package/dist/library/qa.f5-cli.spec.js +0 -433
  49. package/dist/library/qa.f5-contract.spec.d.ts +0 -1
  50. package/dist/library/qa.f5-contract.spec.js +0 -206
  51. package/dist/relay/injectors.d.ts +0 -29
  52. 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
+ }
@@ -23,13 +23,31 @@ export interface RegisterDeps {
23
23
  message: string;
24
24
  }>;
25
25
  /**
26
- * f2's shipped `createCommand`, injected. Returns the exit code; it owns the
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. Runs ONLY after the scaffold reported success, so the
43
- * source is already on disk in every branch below — which is why every failure
44
- * closes with "Nothing else was written." rather than "Nothing was written."
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. */
@@ -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
- print(` cd ${stripLeadingDotSlash(dirLabel)}`);
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. Runs ONLY after the scaffold reported success, so the
36
- * source is already on disk in every branch below — which is why every failure
37
- * closes with "Nothing else was written." rather than "Nothing was written."
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
- reassure(deps.print, deps.dirLabel);
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(" Nothing else was written.");
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
- deps.print("");
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
- deps.print("");
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
- reassure(deps.print, deps.dirLabel);
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(" Nothing else was written.");
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
- reassure(deps.print, deps.dirLabel);
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(` cd ${stripLeadingDotSlash(deps.dirLabel)}`);
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(" Nothing else was written.");
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
- reassure(deps.print, deps.dirLabel);
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(` cd ${stripLeadingDotSlash(deps.dirLabel)}`);
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(" Nothing else was written.");
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("! That address is taken. Your source is fine — pick another.");
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:");
@@ -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
- * It does NOT scaffold — that is `vincentt init`, which needs no account. See
34
- * `initCommand`.
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;
@@ -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
- * It does NOT scaffold — that is `vincentt init`, which needs no account. See
20
- * `initCommand`.
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(projectCwd, {
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
- }, { name: opts.name, slug: opts.slug, force: opts.force });
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
- return { stopped: true, message: "✓ Preview stopped. The link no longer works." };
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.