@vincentt-xr/cli 0.4.0 → 0.5.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/dist/cli/index.js CHANGED
@@ -35,7 +35,7 @@ import { mintRateLimited, notSignedIn, previewConflict, startBlock, } from "../p
35
35
  import { renderTerminalQr, shouldRenderQr } from "../preview/qr.js";
36
36
  import { PreviewConflictError, PreviewRateLimitedError } from "../preview/api.js";
37
37
  import { fetchNext, readCursor, writeCursor } from "../lib/feedback.js";
38
- import { isHelpInvocation, isVersionInvocation, positionalArgs, refusedFlag } from "./args.js";
38
+ import { isHelpInvocation, isVersionInvocation, positionalArgs, refusedFlag, } from "./args.js";
39
39
  // The console origin serves /cli-auth (the browser target for `vincentt login`). VINCENTT_
40
40
  // CONSOLE_URL overrides for dev/dogfood; VINCENTT_EDITOR_URL is honored as a legacy alias.
41
41
  const DEFAULT_CONSOLE_URL = "https://vincentt.app";
@@ -62,18 +62,20 @@ function positional(n) {
62
62
  return positionalArgs(argv)[n];
63
63
  }
64
64
  // ---- auth -------------------------------------------------------------------
65
- function resolveAuthUrls() {
65
+ async function resolveAuthUrls() {
66
+ const machine = await loadMachineConfig();
66
67
  const consoleUrl = flag("--console") ??
67
68
  flag("--editor") ??
68
69
  process.env.VINCENTT_CONSOLE_URL ??
69
70
  process.env.VINCENTT_EDITOR_URL ??
71
+ machine.consoleUrl ??
70
72
  DEFAULT_CONSOLE_URL;
71
- const apiUrl = flag("--api") ?? process.env.VINCENTT_API_URL ?? DEFAULT_API_URL;
73
+ const apiUrl = flag("--api") ?? process.env.VINCENTT_API_URL ?? machine.apiUrl ?? DEFAULT_API_URL;
72
74
  return { consoleUrl, apiUrl };
73
75
  }
74
76
  /** The PKCE loopback + code-exchange login (f1). The raw PAT never rides a redirect query. */
75
77
  async function runLoginFlow() {
76
- const { consoleUrl, apiUrl } = resolveAuthUrls();
78
+ const { consoleUrl, apiUrl } = await resolveAuthUrls();
77
79
  return runPkceLogin({
78
80
  consoleUrl,
79
81
  apiUrl,
@@ -82,7 +84,7 @@ async function runLoginFlow() {
82
84
  }
83
85
  /** `vincentt login` [--force] — the L1/L2/L4 transcripts. */
84
86
  async function login() {
85
- const { apiUrl } = resolveAuthUrls();
87
+ const { apiUrl } = await resolveAuthUrls();
86
88
  const code = await loginCommand({
87
89
  print: (line) => console.log(line),
88
90
  currentToken,
@@ -317,6 +319,14 @@ async function preview() {
317
319
  onViewer: (label, uaClass) => ui.viewerSeen(label, uaClass),
318
320
  onViewerGone: (label) => ui.viewerGone(label),
319
321
  onEvents: (events, tags) => ui.events(events, tags),
322
+ onAttribute: (tags) => {
323
+ // The hello resolved a tester on connect. Same guard the events path
324
+ // uses: a connection with no viewer label has nothing to attribute in
325
+ // the roster (the roster is keyed by label).
326
+ if (tags.viewer !== undefined && tags.tester !== undefined) {
327
+ ui.testerSeen(tags.viewer, tags.tester);
328
+ }
329
+ },
320
330
  onCapReached: () => ui.capReached(),
321
331
  onTunnelState: (state) => ui.tunnel(state),
322
332
  onOriginError: () => ui.originError(),
@@ -375,7 +385,7 @@ async function preview() {
375
385
  await done;
376
386
  clearInterval(ceiling);
377
387
  if (ending !== null) {
378
- ui.endedBy(ending, (await activeOrgName()) || "your organization");
388
+ ui.endedBy(ending, (await activeOrgName()) || "your workspace");
379
389
  await previewStop();
380
390
  process.exitCode = 1;
381
391
  }
@@ -407,7 +417,11 @@ async function followPreview(info, ui, filterTester, filterViewer) {
407
417
  });
408
418
  while (!stop) {
409
419
  try {
410
- const result = await relay.query({ since, tester: filterTester, viewer: filterViewer });
420
+ const result = await relay.query({
421
+ since,
422
+ tester: filterTester,
423
+ viewer: filterViewer,
424
+ });
411
425
  // An unknown id is a FACT, not an error — and the remedy goes on the same
412
426
  // line. Said once rather than on every poll, or a two-second loop becomes
413
427
  // a wall of identical advisories.
@@ -563,6 +577,7 @@ function usage() {
563
577
  ` Full guide: npm view @vincentt-xr/cli readme\n\n` +
564
578
  `Auth\n` +
565
579
  ` login Sign in; save an access token to ~/.vincentt/config.json\n` +
580
+ ` [--api <url>] [--console <url>] to target a non-prod env\n` +
566
581
  ` logout Remove the saved access token\n` +
567
582
  ` whoami Show the signed-in account\n\n` +
568
583
  `Project\n` +
@@ -64,8 +64,8 @@ export async function resolveOrgForCommand(cfg, input) {
64
64
  return {
65
65
  ok: false,
66
66
  message: asked
67
- ? `✗ You are not an active member of "${asked.slug}".\n\n Run \`vincentt whoami\` to see which organizations you belong to.`
68
- : "✗ No active organization.\n\n Run `vincentt whoami` to see which organizations you belong to,\n then re-run with `--org <slug>`.",
67
+ ? `✗ You are not an active member of "${asked.slug}".\n\n Run \`vincentt whoami\` to see which workspaces you belong to.`
68
+ : "✗ No active workspace.\n\n Run `vincentt whoami` to see which workspaces you belong to,\n then re-run with `--org <slug>`.",
69
69
  };
70
70
  }
71
71
  /**
@@ -161,7 +161,7 @@ export function printUnreachable(print, message) {
161
161
  print(`✗ ${asSentence(message)}`);
162
162
  print("");
163
163
  print(" This folder is linked to a project that can't be reached with your current");
164
- print(" account. It may have been deleted, or it may belong to an organization you're");
164
+ print(" account. It may have been deleted, or it may belong to a workspace you're");
165
165
  print(" not a member of.");
166
166
  print("");
167
167
  print(" Check who you are: vincentt whoami");
@@ -31,8 +31,8 @@ export async function whoamiCommand(deps) {
31
31
  // Printed even when there is no active org: an absent line is indistinguishable
32
32
  // from a bug on exactly the path where the creator is already confused.
33
33
  deps.print(org
34
- ? ` Active org: ${org.name} (${org.slug})`
35
- : " Active org: none — pass --org <slug>");
34
+ ? ` Active workspace: ${org.name} (${org.slug})`
35
+ : " Active workspace: none — pass --org <slug>");
36
36
  deps.print(` API: ${cfg.apiUrl}`);
37
37
  return 0;
38
38
  }
@@ -27,7 +27,7 @@ export function orgSummaryLine(identity) {
27
27
  .filter((m) => m !== active)
28
28
  .map((m) => m.orgName)
29
29
  .filter(Boolean);
30
- const base = ` Active org: ${activeName}`;
30
+ const base = ` Active workspace: ${activeName}`;
31
31
  return others.length ? `${base} · also a member of: ${others.join(", ")}` : base;
32
32
  }
33
33
  /**
@@ -70,6 +70,14 @@ export interface StartPreviewOptions {
70
70
  * itself, so the first events of a load are routinely un-attributed.
71
71
  */
72
72
  onEvents?: (events: DiagEvent[], tags: EventTags) => void;
73
+ /**
74
+ * A connection introduced its tester on the `hello`, at connect, before any
75
+ * event. This is what makes `tester-N connected` print for a SILENT app and
76
+ * for one that crashes during mount — the key introduced itself before the
77
+ * crash. Fires once per connection; a client with no hello (an old harness)
78
+ * still resolves its tester on the first batch via `onEvents`.
79
+ */
80
+ onAttribute?: (tags: EventTags) => void;
73
81
  /**
74
82
  * The tester cap was reached, once per refused session. The CLI prints one
75
83
  * line; the connection's events are still stored, just un-attributed.
@@ -76,6 +76,7 @@ export async function startPreview(opts) {
76
76
  projectDir: projectCwd,
77
77
  onLog: (m) => onLog(`[relay] ${m}`),
78
78
  onEvents: opts.onEvents,
79
+ onAttribute: opts.onAttribute,
79
80
  onCapReached: opts.onCapReached,
80
81
  });
81
82
  front = createServer((req, res) => {
@@ -24,6 +24,16 @@ export interface RelayOptions {
24
24
  * replacement, and a missing handler changes nothing.
25
25
  */
26
26
  onEvents?: (events: DiagEvent[], tags: EventTags) => void;
27
+ /**
28
+ * A connection resolved its tester — on the `hello` at WS-open, before any
29
+ * batch. This is what lets the CLI print `tester-N connected` on connect, so a
30
+ * SILENT app (no events at all) and an app that crashes during mount are both
31
+ * attributed. Fires ONCE per connection, the moment the tester is known; a
32
+ * connection whose key misses the grammar resolves to no tester and never
33
+ * fires this. The events path still carries `tester` for un-helloed clients
34
+ * (an old harness), so attribution is not lost if the hello never arrives.
35
+ */
36
+ onAttribute?: (tags: EventTags) => void;
27
37
  /**
28
38
  * Fires ONCE per refused session when the tester cap is reached, so the CLI
29
39
  * can print the one warning line. Once per session rather than once per batch:
@@ -27,8 +27,7 @@ export function startRelay(opts = {}) {
27
27
  let annotations;
28
28
  let dispatcher;
29
29
  if (opts.projectDir || opts.annotationStore) {
30
- annotations =
31
- opts.annotationStore ?? new AnnotationStore(opts.projectDir);
30
+ annotations = opts.annotationStore ?? new AnnotationStore(opts.projectDir);
32
31
  const queuePath = path.join(opts.projectDir ? annotationsDir(opts.projectDir) : ".", "queue.ndjson");
33
32
  dispatcher =
34
33
  opts.dispatcher ??
@@ -57,22 +56,41 @@ export function startRelay(opts = {}) {
57
56
  // skips.
58
57
  const viewer = readViewerLabel(req.headers[VIEWER_STAMP_HEADER]);
59
58
  log(viewer ? `client connected · ${viewer}` : "client connected");
60
- // The tester is resolved ONCE per connection, from the first batch's key.
61
- // Re-resolving per batch would let one socket's events land under two ids if
62
- // a client changed its key mid-stream.
59
+ // The tester is resolved ONCE per connection, from the FIRST key the
60
+ // connection presents — the `hello` at WS-open, or (for an old harness with
61
+ // no hello) the first batch. Re-resolving per message would let one socket's
62
+ // events land under two ids if a client changed its key mid-stream.
63
+ //
64
+ // `attribute` grammar-guards the key against CLIENT_SESSION_ID_RE with no
65
+ // sanitize-and-keep branch: a malformed key resolves to `undefined` (the
66
+ // connection stays live and un-keyed), and the raw key is only ever a map
67
+ // lookup inside the registry — it reaches no sink, exactly as the batch key
68
+ // and the readViewerLabel discipline require.
63
69
  let tester;
64
70
  let resolved = false;
71
+ const resolveTester = (sessionId) => {
72
+ if (resolved)
73
+ return;
74
+ resolved = true;
75
+ const attribution = testers.attribute(sessionId);
76
+ tester = attribution.tester;
77
+ if (attribution.warnCapReached)
78
+ opts.onCapReached?.();
79
+ };
65
80
  ws.on("message", (data) => {
66
81
  try {
67
82
  const msg = JSON.parse(String(data));
83
+ if (msg.type === "hello") {
84
+ // Resolve on connect, before any batch — the whole point of the hello.
85
+ // A well-formed key mints/looks-up the tester and announces it; a
86
+ // malformed one resolves to undefined and announces nothing.
87
+ resolveTester(msg.sessionId);
88
+ if (tester !== undefined)
89
+ opts.onAttribute?.({ viewer, tester });
90
+ return;
91
+ }
68
92
  if (msg.type === "events" && Array.isArray(msg.events)) {
69
- if (!resolved) {
70
- resolved = true;
71
- const attribution = testers.attribute(msg.sessionId);
72
- tester = attribution.tester;
73
- if (attribution.warnCapReached)
74
- opts.onCapReached?.();
75
- }
93
+ resolveTester(msg.sessionId);
76
94
  store.ingest(msg.events, { viewer, tester });
77
95
  opts.onEvents?.(msg.events, { viewer, tester });
78
96
  }
@@ -129,7 +147,9 @@ function handleQuery(req, res, url, ctx) {
129
147
  readQuery(req, url)
130
148
  .then((q) => {
131
149
  const result = ctx.store.query(q);
132
- res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(result));
150
+ res
151
+ .writeHead(200, { "content-type": "application/json" })
152
+ .end(JSON.stringify(result));
133
153
  })
134
154
  .catch((err) => {
135
155
  ctx.log(`query error: ${err}`);
@@ -145,7 +165,9 @@ function handleAnnotationPost(req, res, ctx) {
145
165
  readBody(req, MAX_ANNOTATION_BYTES)
146
166
  .then(async (body) => {
147
167
  const input = JSON.parse(body);
148
- if (!input || typeof input.message !== "string" || typeof input.screenshot !== "string") {
168
+ if (!input ||
169
+ typeof input.message !== "string" ||
170
+ typeof input.screenshot !== "string") {
149
171
  res.writeHead(400).end("annotation requires message + screenshot");
150
172
  return;
151
173
  }
@@ -1,6 +1,8 @@
1
1
  export interface MachineConfig {
2
2
  /** Base URL of the Vincentt backend API (e.g. https://api.vincentt.studio). */
3
3
  apiUrl: string;
4
+ /** Console origin that serves /cli-auth (e.g. https://vincentt.app); used by `login`. */
5
+ consoleUrl: string;
4
6
  /** Personal access token (bearer) for machine auth. */
5
7
  pat: string;
6
8
  /** Org slug commands act in when no --org flag or VINCENTT_ORG env is given. */
@@ -1,7 +1,7 @@
1
1
  // Config the lifecycle MCP verbs (project_create / project_publish) read to reach
2
2
  // a creator's Vincentt backend. Two homes, by sensitivity:
3
- // • ~/.vincentt/config.json — machine-global { apiUrl, pat }. The PAT is a
4
- // bearer credential, so it lives OUTSIDE any repo (never committed).
3
+ // • ~/.vincentt/config.json — machine-global { apiUrl, consoleUrl, pat }. The
4
+ // PAT is a bearer credential, so it lives OUTSIDE any repo (never committed).
5
5
  // • <project>/.vincentt/project.json — the per-tree binding { projectId, slug }.
6
6
  // Gitignored: `git archive HEAD` (the remix path) must NOT carry it, or a
7
7
  // remixer would inherit — and publish over — the original's project.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincentt-xr/cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Vincentt CLI — the vincentt command-line dev loop (login, create, publish, preview, diagnostics, feedback, relay)",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -24,7 +24,7 @@
24
24
  "qrcode": "^1.5.4",
25
25
  "ws": "^8.18.0",
26
26
  "zod": "^3.23.8",
27
- "@vincentt-xr/harness": "1.1.0"
27
+ "@vincentt-xr/harness": "1.2.0"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@types/qrcode": "^1.5.5",