@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.
Files changed (64) hide show
  1. package/README.md +40 -13
  2. package/dist/cli/args.d.ts +22 -0
  3. package/dist/cli/args.js +71 -0
  4. package/dist/cli/index.d.ts +1 -1
  5. package/dist/cli/index.js +359 -91
  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/dx/skeleton.js +1 -0
  13. package/dist/lib/backend.d.ts +11 -0
  14. package/dist/lib/backend.js +23 -0
  15. package/dist/lib/classifyTarget.d.ts +42 -0
  16. package/dist/lib/classifyTarget.js +83 -0
  17. package/dist/lib/feedback.d.ts +46 -5
  18. package/dist/lib/feedback.js +76 -19
  19. package/dist/lib/feedbackCopy.d.ts +70 -0
  20. package/dist/lib/feedbackCopy.js +163 -0
  21. package/dist/lib/initCommand.d.ts +28 -1
  22. package/dist/lib/initCommand.js +91 -16
  23. package/dist/lib/initFrom.d.ts +70 -0
  24. package/dist/lib/initFrom.js +222 -0
  25. package/dist/lib/lifecycle.d.ts +28 -3
  26. package/dist/lib/lifecycle.js +99 -7
  27. package/dist/lib/listProjects.d.ts +41 -0
  28. package/dist/lib/listProjects.js +77 -0
  29. package/dist/lib/projects.d.ts +64 -8
  30. package/dist/lib/projects.js +316 -24
  31. package/dist/lib/sourceRef.d.ts +46 -0
  32. package/dist/lib/sourceRef.js +107 -0
  33. package/dist/lib/sourceRefCopy.d.ts +30 -0
  34. package/dist/lib/sourceRefCopy.js +116 -0
  35. package/dist/preview/command.d.ts +11 -0
  36. package/dist/preview/command.js +19 -1
  37. package/dist/preview/copy.d.ts +7 -0
  38. package/dist/preview/copy.js +8 -1
  39. package/dist/preview/runner.d.ts +6 -0
  40. package/dist/preview/runner.js +1 -0
  41. package/dist/preview/stream.d.ts +11 -0
  42. package/dist/preview/stream.js +13 -0
  43. package/dist/publish/api.d.ts +20 -0
  44. package/dist/publish/command.d.ts +3 -0
  45. package/dist/publish/command.js +39 -4
  46. package/dist/publish/copy.d.ts +29 -0
  47. package/dist/publish/copy.js +86 -0
  48. package/dist/relay/annotations.d.ts +28 -4
  49. package/dist/relay/annotations.js +23 -7
  50. package/dist/relay/qa.f12.fixtures.d.ts +154 -0
  51. package/dist/relay/qa.f12.fixtures.js +358 -0
  52. package/dist/relay/server.d.ts +41 -3
  53. package/dist/relay/server.js +90 -32
  54. package/dist/scaffold/index.d.ts +9 -0
  55. package/dist/scaffold/index.js +13 -1
  56. package/package.json +2 -2
  57. package/dist/library/qa.f5-b32.spec.d.ts +0 -1
  58. package/dist/library/qa.f5-b32.spec.js +0 -202
  59. package/dist/library/qa.f5-cli.spec.d.ts +0 -1
  60. package/dist/library/qa.f5-cli.spec.js +0 -433
  61. package/dist/library/qa.f5-contract.spec.d.ts +0 -1
  62. package/dist/library/qa.f5-contract.spec.js +0 -206
  63. package/dist/relay/injectors.d.ts +0 -29
  64. package/dist/relay/injectors.js +0 -72
@@ -0,0 +1,222 @@
1
+ // `vincentt init <dir> --from owner/repo@tag` — the combined clone path (f6).
2
+ //
3
+ // One command, TWO failure domains. Everything above the seam is on disk and
4
+ // permanent; everything below it is a network attempt. The transcript's shape is
5
+ // what lets a creator answer "did I lose my work?" without reading the error.
6
+ //
7
+ // This file is an ORCHESTRATOR plus the bounded re-ask loop plus the transition
8
+ // copy, and nothing else. `ensureLoggedIn` and `createCommand` are called
9
+ // verbatim — a second implementation of f2's rules is a second place for them to
10
+ // be wrong.
11
+ import path from "node:path";
12
+ import { SlugTakenError } from "./backend.js";
13
+ import { slugify } from "./slug.js";
14
+ import { formatSourceRef } from "./sourceRef.js";
15
+ /** How many times the address prompt re-asks before falling back to the printed block. */
16
+ export const ADDRESS_REASK_LIMIT = 3;
17
+ const ORG_SOURCE_LABEL = {
18
+ active: "from your active workspace",
19
+ env: "from VINCENTT_ORG",
20
+ flag: "from --org",
21
+ };
22
+ /** The one sentence every registration-half failure opens with. Byte-identical across arms. */
23
+ function reassure(print, dirLabel) {
24
+ print(` Your source is fine — ${dirLabel} is on disk and it's yours.`);
25
+ }
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)}`);
30
+ print(` ${command}`);
31
+ }
32
+ function stripLeadingDotSlash(label) {
33
+ return label.startsWith("./") ? label.slice(2) : label;
34
+ }
35
+ /**
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.
43
+ */
44
+ export async function registerAfterScaffold(deps, opts = {}) {
45
+ const defaultName = opts.name ?? path.basename(path.resolve(deps.targetDir));
46
+ const defaultSlug = opts.slug ?? slugify(defaultName);
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
+ };
67
+ // TRAP 2 inverted deliberately: the session is checked AFTER the scaffold, and
68
+ // `ensureLoggedIn` is only reachable on a TTY because it opens a browser.
69
+ const token = await deps.currentToken();
70
+ if (!token) {
71
+ if (!deps.ask) {
72
+ deps.print("");
73
+ deps.print("✗ Not signed in, and signing in needs a browser.");
74
+ deps.print("");
75
+ if (scaffolded)
76
+ reassure(deps.print, deps.dirLabel);
77
+ deps.print(" No address is reserved yet.");
78
+ deps.print("");
79
+ deps.print(" On a machine with a browser:");
80
+ deps.print(" vincentt login");
81
+ deps.print("");
82
+ deps.print(" Then finish:");
83
+ resume(deps.print, deps.dirLabel, resumeCommand, needsCd);
84
+ deps.print("");
85
+ deps.print(closing);
86
+ return 1;
87
+ }
88
+ deps.print("");
89
+ deps.print("→ Now registering the project so it gets an address.");
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
+ }
105
+ try {
106
+ await deps.runLogin();
107
+ }
108
+ catch {
109
+ printSignInAbandoned();
110
+ return 1;
111
+ }
112
+ if (!(await deps.currentToken())) {
113
+ printSignInAbandoned();
114
+ return 1;
115
+ }
116
+ }
117
+ // A non-TTY caller must supply both, and the tool never invents an address.
118
+ if (!deps.ask && (opts.name === undefined || opts.slug === undefined)) {
119
+ deps.print("");
120
+ deps.print("✗ Can't register the project without a name and an address, and I can't ask.");
121
+ deps.print("");
122
+ if (scaffolded)
123
+ reassure(deps.print, deps.dirLabel);
124
+ deps.print(" No address is reserved yet.");
125
+ deps.print("");
126
+ deps.print(" Finish it:");
127
+ resume(deps.print, deps.dirLabel, resumeCommand, needsCd);
128
+ deps.print("");
129
+ deps.print(closing);
130
+ return 1;
131
+ }
132
+ const org = await deps.resolveOrg();
133
+ if (!org.ok) {
134
+ deps.print("");
135
+ deps.print(org.message);
136
+ deps.print("");
137
+ if (scaffolded)
138
+ reassure(deps.print, deps.dirLabel);
139
+ deps.print(" No address is reserved yet.");
140
+ deps.print("");
141
+ deps.print(" Check who you are: vincentt whoami");
142
+ deps.print(" Then finish:");
143
+ resume(deps.print, deps.dirLabel, "vincentt create --org <workspace>", needsCd);
144
+ deps.print("");
145
+ deps.print(closing);
146
+ return 1;
147
+ }
148
+ // AF-3: name the org AND where it came from, before the address prompt. The
149
+ // card's command carries no --org, so a stale active workspace would otherwise
150
+ // claim an address in a team org silently.
151
+ deps.print("");
152
+ deps.print(`→ Registering in ${org.name} (${ORG_SOURCE_LABEL[org.source]})`);
153
+ let name = opts.name;
154
+ let slug = opts.slug;
155
+ if (deps.ask) {
156
+ if (name === undefined) {
157
+ const answer = await deps.ask(`? Project name: (${defaultName}) `, defaultName);
158
+ name = answer.trim() === "" ? defaultName : answer.trim();
159
+ }
160
+ if (slug === undefined) {
161
+ const fallback = slugify(name);
162
+ const answer = await deps.ask(`? Address: (${fallback}) `, fallback);
163
+ slug = answer.trim() === "" ? fallback : answer.trim();
164
+ }
165
+ }
166
+ let attempts = 0;
167
+ for (;;) {
168
+ try {
169
+ return await deps.create({ name: name, slug: slug });
170
+ }
171
+ catch (err) {
172
+ if (!(err instanceof SlugTakenError))
173
+ throw err;
174
+ attempts++;
175
+ // TRAP 3: never auto-pick suggestions[0]. On a non-TTY this refuses; on a
176
+ // TTY it re-asks with NO default, because there is no safe default left.
177
+ const canReask = Boolean(deps.ask) && attempts < ADDRESS_REASK_LIMIT;
178
+ if (!canReask) {
179
+ deps.print("");
180
+ deps.print("✗ That address is taken.");
181
+ deps.print("");
182
+ if (scaffolded)
183
+ reassure(deps.print, deps.dirLabel);
184
+ deps.print(" No address is reserved yet.");
185
+ if (err.suggestions.length > 0) {
186
+ deps.print("");
187
+ deps.print(" Available alternatives:");
188
+ for (const s of err.suggestions)
189
+ deps.print(` ${s}`);
190
+ }
191
+ deps.print("");
192
+ deps.print(deps.ask
193
+ ? " Finish with an address of your own:"
194
+ : " Finish with one of those, or your own:");
195
+ resume(deps.print, deps.dirLabel, `vincentt create --name "${name}" --slug <address>`, needsCd);
196
+ deps.print("");
197
+ deps.print(closing);
198
+ return 1;
199
+ }
200
+ // A routine, expected collision mid-flow: `!` and a re-ask, not `✗` and an
201
+ // exit. The command DOES proceed, so a `✗` would announce a stop that is
202
+ // not happening.
203
+ deps.print("");
204
+ deps.print(scaffolded
205
+ ? "! That address is taken. Your source is fine — pick another."
206
+ : "! That address is taken. Pick another.");
207
+ if (err.suggestions.length > 0) {
208
+ deps.print("");
209
+ deps.print(" Available alternatives:");
210
+ for (const s of err.suggestions)
211
+ deps.print(` ${s}`);
212
+ }
213
+ deps.print("");
214
+ const answer = await deps.ask("? Address: ", "");
215
+ slug = answer.trim();
216
+ }
217
+ }
218
+ }
219
+ /** The `· from owner/repo@tag` bullet in the start block. */
220
+ export function sourceBullet(ref) {
221
+ return ` · from ${formatSourceRef(ref)}`;
222
+ }
@@ -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.
@@ -0,0 +1,41 @@
1
+ import type { ProjectView } from "./backend.js";
2
+ import type { ResolvedConfig } from "../shared/config.js";
3
+ export interface ListProjectsDeps {
4
+ print: (line: string) => void;
5
+ /** The machine's API host + PAT, or undefined when nothing is signed in. */
6
+ config: () => Promise<ResolvedConfig | undefined>;
7
+ /** The workspace to list. Resolved by the caller's own precedence chain. */
8
+ org: () => Promise<{
9
+ ok: true;
10
+ org: {
11
+ slug: string;
12
+ name: string;
13
+ };
14
+ } | {
15
+ ok: false;
16
+ message: string;
17
+ }>;
18
+ list: (cfg: ResolvedConfig, orgSlug: string) => Promise<ProjectView[]>;
19
+ }
20
+ export interface ListProjectsOptions {
21
+ json?: boolean;
22
+ }
23
+ /**
24
+ * The columns are `id`, `slug`, `name` and where it stands.
25
+ *
26
+ * THE ID IS FIRST AND IS NEVER ELIDED. It is the whole reason the verb exists:
27
+ * the next command a reader runs is `vincentt link <project-id>`, and a listing
28
+ * that truncated the id would send them to the console for the one value the
29
+ * command needs — which is the gap this closes.
30
+ */
31
+ export declare function listProjectsCommand(deps: ListProjectsDeps, opts?: ListProjectsOptions): Promise<number>;
32
+ /**
33
+ * Aligned on the widest id and slug so the block reads as a table in a terminal
34
+ * and stays greppable in an agent's transcript.
35
+ *
36
+ * The state column reads from `publishState` and NEVER computes a URL from the
37
+ * slug: `liveUrl` is server-computed because the apex differs per environment,
38
+ * so a client that assembled `<slug>.vincentt.app` itself would print the wrong
39
+ * address on staging and dev.
40
+ */
41
+ export declare function projectRows(projects: ProjectView[]): string[];
@@ -0,0 +1,77 @@
1
+ // `vincentt projects` — which projects does this workspace have?
2
+ //
3
+ // A READ-ONLY verb, and an APPEND to the verb surface (B-F3-8 permits additions;
4
+ // nothing is renamed or removed). It is NEUTRAL in the sense the feature
5
+ // requires: a human typing it gets exactly what an agent running it gets, and
6
+ // nothing about it exists for a plugin.
7
+ //
8
+ // It answers the one question the neutral surface could not. An agent asked to
9
+ // resume a project in an UNBOUND folder previously had three options and all
10
+ // were bad: guess an id, tell the creator to open the console and copy one, or
11
+ // fail. `link` needs an id, and until now nothing printed one.
12
+ //
13
+ // The IO boundary (print, backend, org) is injected, so every branch is testable
14
+ // with no API, no HOME, and no network.
15
+ /**
16
+ * The columns are `id`, `slug`, `name` and where it stands.
17
+ *
18
+ * THE ID IS FIRST AND IS NEVER ELIDED. It is the whole reason the verb exists:
19
+ * the next command a reader runs is `vincentt link <project-id>`, and a listing
20
+ * that truncated the id would send them to the console for the one value the
21
+ * command needs — which is the gap this closes.
22
+ */
23
+ export async function listProjectsCommand(deps, opts = {}) {
24
+ const cfg = await deps.config();
25
+ if (!cfg) {
26
+ deps.print("Not signed in. Run `vincentt login`.");
27
+ return 1;
28
+ }
29
+ const org = await deps.org();
30
+ if (!org.ok) {
31
+ deps.print(org.message);
32
+ return 1;
33
+ }
34
+ const projects = await deps.list(cfg, org.org.slug);
35
+ if (opts.json) {
36
+ // `{projects: [...]}` — the server's own envelope, holding the SAME
37
+ // `ProjectView` every other verb in this CLI parses a project into. It is a
38
+ // projection of the wire row (the API also sends version/timestamp fields),
39
+ // and it is deliberately the shared one rather than a listing-specific
40
+ // shape: a second projection here would be a second thing that can drift.
41
+ deps.print(JSON.stringify({ projects }, null, 2));
42
+ return 0;
43
+ }
44
+ if (projects.length === 0) {
45
+ // Not a failure, so no ✗ glyph: an empty workspace is a fact. The remedy is
46
+ // named because a reader who ran this verb was looking for something to act
47
+ // on and found nothing.
48
+ deps.print(`No projects in ${org.org.name}.`);
49
+ deps.print("");
50
+ deps.print(" vincentt create <name> Register one for a new folder");
51
+ return 0;
52
+ }
53
+ deps.print(`${projects.length} project${projects.length === 1 ? "" : "s"} in ${org.org.name}`);
54
+ deps.print("");
55
+ for (const line of projectRows(projects))
56
+ deps.print(line);
57
+ deps.print("");
58
+ deps.print(" Link a folder to one: vincentt link <project-id>");
59
+ return 0;
60
+ }
61
+ /**
62
+ * Aligned on the widest id and slug so the block reads as a table in a terminal
63
+ * and stays greppable in an agent's transcript.
64
+ *
65
+ * The state column reads from `publishState` and NEVER computes a URL from the
66
+ * slug: `liveUrl` is server-computed because the apex differs per environment,
67
+ * so a client that assembled `<slug>.vincentt.app` itself would print the wrong
68
+ * address on staging and dev.
69
+ */
70
+ export function projectRows(projects) {
71
+ const idWidth = Math.max(...projects.map((p) => p.id.length));
72
+ const slugWidth = Math.max(...projects.map((p) => p.slug.length));
73
+ return projects.map((p) => {
74
+ const state = p.publishState === "live" && p.liveUrl ? p.liveUrl : p.publishState;
75
+ return ` ${p.id.padEnd(idWidth)} ${p.slug.padEnd(slugWidth)} ${p.name} ${state}`;
76
+ });
77
+ }
@@ -1,3 +1,7 @@
1
+ import { type FolderCase } from "./classifyTarget.js";
2
+ import { type OrgSource } from "./initFrom.js";
3
+ import type { ScaffoldCall } from "./initCommand.js";
4
+ import type { Ask } from "../publish/prompt.js";
1
5
  import { type ResolvedConfig } from "../shared/config.js";
2
6
  export interface ProjectCommandDeps {
3
7
  /** Print a transcript line. */
@@ -23,22 +27,70 @@ export interface CreateCommandDeps extends ProjectCommandDeps {
23
27
  ok: false;
24
28
  message: string;
25
29
  }>;
30
+ /** Which of the three folder cases the target is in. Defaults to `classifyTarget`. */
31
+ classify?: (dir: string) => Promise<FolderCase>;
32
+ /** Create the target directory. See the writer's guards below. */
33
+ mkdir?: (dir: string) => Promise<void>;
34
+ /** Resolve a path through its symlinks, for the classify→write re-check. */
35
+ realpath?: (dir: string) => Promise<string>;
36
+ scaffold?: (dir: string, opts: ScaffoldCall) => Promise<void>;
37
+ install?: (dir: string) => Promise<string>;
38
+ detectPm?: (dir: string) => Promise<string>;
39
+ /** Free-text prompt. Absent → not a TTY, so nothing may be asked. */
40
+ ask?: Ask;
41
+ /** The PAT that would authenticate a call, if any. Never triggers a login itself. */
42
+ currentToken?: () => Promise<string | undefined>;
43
+ /** The interactive browser login. Only ever reached on a TTY. */
44
+ runLogin?: () => Promise<void>;
45
+ /** Where the resolved org came from, so a surprising value is diagnosable (AF-3). */
46
+ orgSource?: () => OrgSource;
26
47
  }
27
48
  export interface CreateCommandOptions {
28
49
  name?: string;
29
50
  slug?: string;
30
51
  /** Replace a binding this folder already has. */
31
52
  force?: boolean;
53
+ /** The positional, already through `sanitizeAppName`. Resolves against `cwd`. */
54
+ dir?: string;
55
+ /** `--no-install`, the same meaning it has on `init`. */
56
+ skipInstall?: boolean;
57
+ /**
58
+ * Let a `409 slug_taken` propagate instead of printing here. Set by the callers
59
+ * that own their own taken-address copy (`registerAfterScaffold`, which has the
60
+ * bounded re-ask). See the throw site.
61
+ */
62
+ rethrowSlugTaken?: boolean;
32
63
  }
33
64
  /**
34
- * `vincentt create` — register a project and link this folder to it.
65
+ * `vincentt create` — a TWO-HALF verb with one seam.
66
+ *
67
+ * Above the seam is local and permanent (a directory, source on disk); below it is
68
+ * a network attempt (a session, an org, a slug claim, a binding). Which half
69
+ * failed is readable from the transcript's shape, which is what lets a creator
70
+ * answer "did I lose my work?" without reading the error twice.
71
+ *
72
+ * In an EMPTY folder it scaffolds the standard starter, installs, and then
73
+ * registers. In a folder that is already a Vincentt app it only registers. In any
74
+ * other non-empty folder it REFUSES and names `vincentt init .`, which is the
75
+ * escape hatch with its own confirm.
35
76
  *
36
- * It does NOT scaffold. An unscaffolded folder gets a `!` advisory and the command
37
- * PROCEEDS: the server accepts a create from any folder or none, and blocking here
38
- * would put the open template behind the auth gate, which is the one thing the
39
- * init/create split exists to prevent.
77
+ * `requireLogin()` is deliberately NOT called above this: the scaffold half must
78
+ * never depend on a session or on our availability, and a session check at the top
79
+ * of the verb would put the open template behind the auth gate. A "cleanup" that
80
+ * restores it silently reverts the whole design — `link()` three lines below in
81
+ * cli/index.ts does exactly that, which is what a dev writes by copying a
82
+ * neighbour.
40
83
  */
41
84
  export declare function createCommand(deps: CreateCommandDeps, opts?: CreateCommandOptions): Promise<number>;
85
+ /**
86
+ * The registration half — the binding guard's remainder, the slug derivation, the
87
+ * round trip, and the binding write. Extracted from `createCommand` so
88
+ * `registerAfterScaffold` can call it without a cycle (that orchestrator IS the
89
+ * below-the-seam half of `create`, and it calls this as its `create` dep).
90
+ *
91
+ * Pure refactor of what shipped: no behavior change on f6's path.
92
+ */
93
+ export declare function registerProject(deps: CreateCommandDeps, opts?: CreateCommandOptions): Promise<number>;
42
94
  /**
43
95
  * `vincentt link <project-id>` — one verb for four situations (console-first
44
96
  * birth, a fresh clone, a folder that lost its binding, a re-link after
@@ -70,9 +122,13 @@ export declare function printUnreachable(print: (line: string) => void, message:
70
122
  */
71
123
  export declare function unlinkCommand(deps: ProjectCommandDeps): Promise<number>;
72
124
  /**
73
- * The advisory's trigger: a package.json that depends on @vincentt-xr/sdk. It is a
74
- * HINT only — create proceeds either way — so a false negative costs one extra
75
- * line of output, never a refused command.
125
+ * A package.json that depends on `@vincentt-xr/*`.
126
+ *
127
+ * This is a GATE, not a hint. It is `classifyTarget`'s first and unconditional
128
+ * question, and it decides whether `create` registers only (true) or goes on to ask
129
+ * whether the folder is empty (false). A false negative therefore costs a refused
130
+ * command on a non-empty folder and a SCAFFOLD on an empty one — never one extra
131
+ * line of output, which is what this used to say and is no longer true.
76
132
  */
77
133
  export declare function looksLikeVincenttApp(cwd: string): Promise<boolean>;
78
134
  /** Resolve the bound project for a project-scoped verb, or explain why not. */