@vincentt-xr/cli 0.16.0 → 0.17.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
@@ -38,7 +38,8 @@ import { httpRelayClient } from "../lib/diagnostics.js";
38
38
  import { readLivePreview, relayInfoDir } from "../lib/relayInfo.js";
39
39
  import { attachedToRunning, mintRateLimited, notSignedIn, previewConflict, startBlock, } from "../preview/copy.js";
40
40
  import { renderTerminalQr, shouldRenderQr } from "../preview/qr.js";
41
- import { PreviewConflictError, PreviewRateLimitedError } from "../preview/api.js";
41
+ import { PreviewConflictError, PreviewLimitError, PreviewRateLimitedError, } from "../preview/api.js";
42
+ import { previewLimitRefused, resolveCountLimit, } from "../shared/countLimit.js";
42
43
  import { fetchNext, readCursor, writeCursor } from "../lib/feedback.js";
43
44
  import * as feedbackCopy from "../lib/feedbackCopy.js";
44
45
  import { record as recordInvocation } from "../dx/journal.js";
@@ -584,7 +585,11 @@ async function preview() {
584
585
  }
585
586
  }
586
587
  catch (err) {
587
- for (const l of previewFailureLines(err))
588
+ // The local org is the FALLBACK only. A count refusal names the org that
589
+ // owns this project, and the wire's name wins over this one wherever both
590
+ // exist — see resolveCountLimit.
591
+ const localOrg = await localOrgIdentity();
592
+ for (const l of previewFailureLines(err, localOrg))
588
593
  console.log(l);
589
594
  process.exitCode = 1;
590
595
  return;
@@ -706,6 +711,18 @@ async function activeOrgName() {
706
711
  (await loadMachineConfig()).activeOrg ??
707
712
  "");
708
713
  }
714
+ /**
715
+ * The org this machine is pointed at, for a refusal whose body named none.
716
+ *
717
+ * `activeOrg` is a SLUG, so it fills the slug slot and not the name slot. The
718
+ * display name is the API's to give; inventing one from the slug would put a
719
+ * fabricated workspace name on a refusal.
720
+ */
721
+ async function localOrgIdentity() {
722
+ const slug = await activeOrgName();
723
+ const { consoleUrl } = await resolveAuthUrls();
724
+ return { slug: slug || undefined, consoleUrl };
725
+ }
709
726
  /** The binding's name is a HINT for display only; the API is the authority. */
710
727
  async function projectDisplayName(cwd) {
711
728
  const located = await locateProjectBinding(cwd);
@@ -744,9 +761,15 @@ async function projectConsoleUrl(cwd, sessionId) {
744
761
  * creator never has to read a second, and none of them names the edge, the API
745
762
  * or an outage — a creator cannot act on that.
746
763
  */
747
- function previewFailureLines(err) {
764
+ function previewFailureLines(err, local) {
748
765
  if (err instanceof PreviewRateLimitedError)
749
766
  return mintRateLimited(err.retryAfterSec);
767
+ // The ceiling is the second 409 on the mint route, and it is a DIFFERENT
768
+ // creator situation from the conflict below: nothing is running, nothing was
769
+ // taken down, and the organization is full.
770
+ if (err instanceof PreviewLimitError) {
771
+ return previewLimitRefused(resolveCountLimit(err.detail, local));
772
+ }
750
773
  if (err instanceof PreviewConflictError)
751
774
  return previewConflict();
752
775
  if (err instanceof ProjectUnreachableError) {
@@ -1,4 +1,5 @@
1
1
  import type { ResolvedConfig } from "../shared/config.js";
2
+ import type { CountLimitDetail } from "../shared/countLimit.js";
2
3
  import { BackendError } from "../lib/backend.js";
3
4
  /** The mint response. Its key set is the cross-repo pin (contract table 2.11). */
4
5
  export interface MintedPreview {
@@ -27,6 +28,24 @@ export declare class PreviewRateLimitedError extends BackendError {
27
28
  export declare class PreviewConflictError extends BackendError {
28
29
  constructor(message: string);
29
30
  }
31
+ /**
32
+ * The org is at its concurrent-live-preview ceiling.
33
+ *
34
+ * IT IS A SECOND 409 ON THE MINT ROUTE and it is told from the first by its
35
+ * CODE, never by its status. The two mean opposite things to a creator: a
36
+ * conflict says a preview for this folder is already running and to go watch it;
37
+ * the ceiling says no preview started and the organization is full. Collapsing
38
+ * them would tell a creator at their ceiling to watch a preview that does not
39
+ * exist.
40
+ *
41
+ * It carries the whole detail because the refusal IS the notice: the figures and
42
+ * the org name are the only things that make it actionable, and the CLI cannot
43
+ * obtain either anywhere else.
44
+ */
45
+ export declare class PreviewLimitError extends BackendError {
46
+ readonly detail: CountLimitDetail;
47
+ constructor(message: string, detail: CountLimitDetail);
48
+ }
30
49
  /**
31
50
  * The session is gone — `preview_session_ended`, the CLI's terminal branch.
32
51
  *
@@ -23,6 +23,28 @@ export class PreviewConflictError extends BackendError {
23
23
  this.name = "PreviewConflictError";
24
24
  }
25
25
  }
26
+ /**
27
+ * The org is at its concurrent-live-preview ceiling.
28
+ *
29
+ * IT IS A SECOND 409 ON THE MINT ROUTE and it is told from the first by its
30
+ * CODE, never by its status. The two mean opposite things to a creator: a
31
+ * conflict says a preview for this folder is already running and to go watch it;
32
+ * the ceiling says no preview started and the organization is full. Collapsing
33
+ * them would tell a creator at their ceiling to watch a preview that does not
34
+ * exist.
35
+ *
36
+ * It carries the whole detail because the refusal IS the notice: the figures and
37
+ * the org name are the only things that make it actionable, and the CLI cannot
38
+ * obtain either anywhere else.
39
+ */
40
+ export class PreviewLimitError extends BackendError {
41
+ detail;
42
+ constructor(message, detail) {
43
+ super(message, 409);
44
+ this.detail = detail;
45
+ this.name = "PreviewLimitError";
46
+ }
47
+ }
26
48
  /**
27
49
  * The session is gone — `preview_session_ended`, the CLI's terminal branch.
28
50
  *
@@ -104,6 +126,13 @@ export async function mintPreviewSession(cfg, projectId, key, fetchImpl = fetch)
104
126
  throw new ProjectUnreachableError(body.message ?? "This project is not available to you.");
105
127
  }
106
128
  if (res.status === 409) {
129
+ // TWO 409s NOW LAND HERE and the CODE is what separates them. Reading the
130
+ // status alone would render the ceiling refusal as "a preview is already
131
+ // running for this folder" — a sentence that is false, that names a
132
+ // preview the creator does not have, and that sends them to watch it.
133
+ if (body.code === "preview_limit") {
134
+ throw new PreviewLimitError(body.message ?? "this organization is at its preview limit", body.detail ?? {});
135
+ }
107
136
  throw new PreviewConflictError(body.message ?? "a preview is already running for this folder");
108
137
  }
109
138
  throw new BackendError(body.message ?? `could not start a preview (${res.status})`, res.status);
@@ -41,7 +41,15 @@ export interface CommitResult {
41
41
  releaseNote?: string;
42
42
  }
43
43
  export interface ApiErrorDetail {
44
- limit?: number;
44
+ /**
45
+ * ⚠ `limit` CARRIES TWO DIFFERENT TYPES on two different codes, and the api
46
+ * owns both. On `publish_too_large` and f16's domain ceiling it is a
47
+ * MAGNITUDE (bytes, a count); on the three count refusals it is the AXIS NAME
48
+ * ("seats" | "published" | "previews" | "storage"). The two never appear on
49
+ * the same body, so the union is read at the arm that knows which code it is
50
+ * handling and never generically.
51
+ */
52
+ limit?: number | string;
45
53
  observed?: number;
46
54
  missing?: number;
47
55
  mismatched?: number;
@@ -67,6 +75,14 @@ export interface ApiErrorDetail {
67
75
  */
68
76
  orgName?: string;
69
77
  orgSlug?: string;
78
+ /**
79
+ * The count refusals' two figures — the org's current occupancy on the axis,
80
+ * and the plan's ceiling for it. Both are required for the refusal to
81
+ * discharge B-F10-2: a refusal naming neither is a refusal with no notice in
82
+ * it.
83
+ */
84
+ count?: number;
85
+ ceiling?: number;
70
86
  }
71
87
  export declare class PublishApiError extends Error {
72
88
  readonly status: number;
@@ -17,6 +17,7 @@ import { classifyDist } from "./classify.js";
17
17
  import { createProgress } from "./progress.js";
18
18
  import { UploadFailed, uploadAll } from "./upload.js";
19
19
  import * as copy from "./copy.js";
20
+ import { publishedLimitRefused, resolveCountLimit, storageCeilingRefused, } from "../shared/countLimit.js";
20
21
  import { resolveBuiltWith } from "../platform/resolve.js";
21
22
  const COMMIT_RETRIES = 3;
22
23
  const COMMIT_RETRY_DELAY_MS = 2000;
@@ -318,8 +319,20 @@ function openErrorLines(err, address, liveVersion, org) {
318
319
  if (err.code === "unavailable_plan_limit") {
319
320
  return planLimitLines(err, org);
320
321
  }
322
+ // The two count/ceiling refusals of this change. Both land at OPEN — before a
323
+ // presigned URL is minted and before a byte moves — which is why they are here
324
+ // and not in commitErrorLines: neither is reachable from commit.
325
+ if (err.code === "published_limit") {
326
+ return publishedLimitRefused(resolveCountLimit(err.detail, org));
327
+ }
328
+ if (err.code === "storage_ceiling") {
329
+ // It takes no detail. The body is `{"limit":"storage"}` and names nothing
330
+ // else by design — the ceiling is an abuse ceiling, not a plan limit.
331
+ return storageCeilingRefused();
332
+ }
321
333
  if (err.code === "publish_too_large") {
322
- return copy.tooLargeFromServer(err.detail.observed ?? 0, err.detail.limit ?? 0);
334
+ // `limit` is the MAGNITUDE on this code. See ApiErrorDetail.limit.
335
+ return copy.tooLargeFromServer(err.detail.observed ?? 0, numericLimit(err.detail.limit));
323
336
  }
324
337
  if (err.code === "rate_limited") {
325
338
  return copy.rateLimited(err.retryAfterSec ?? 60);
@@ -339,6 +352,15 @@ function openErrorLines(err, address, liveVersion, org) {
339
352
  "",
340
353
  ];
341
354
  }
355
+ /**
356
+ * `detail.limit` on a code whose `limit` is a MAGNITUDE. A string there is the
357
+ * axis-name meaning arriving on a body that should not carry it, and it reads as
358
+ * 0 rather than as `NaN` — a bound printed as "NaN MB" is worse than one printed
359
+ * as a figure the copy already handles.
360
+ */
361
+ function numericLimit(limit) {
362
+ return typeof limit === "number" ? limit : 0;
363
+ }
342
364
  function manifestErrorLines(err, distDir) {
343
365
  if (err.reason === "no_dist")
344
366
  return copy.noDist(distDir);
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The detail every count refusal carries. Pinned to the api's `Decision.Err`
3
+ * renderer: `{limit, count, ceiling, orgName, orgSlug, planName}`.
4
+ *
5
+ * On THESE codes `limit` is the AXIS NAME — "seats" | "published" | "previews" |
6
+ * "storage" — and not a magnitude. It is typed as the transport's union rather
7
+ * than narrowed to `string`, because the same detail bag carries the byte-valued
8
+ * `limit` of `publish_too_large`, and a type that disagreed with the transport
9
+ * would push the mismatch to a cast at the call site.
10
+ *
11
+ * NOTHING BELOW READS IT. The axis is already known from the code that selected
12
+ * the renderer, so reading it back would be the renderer asking the wire to
13
+ * confirm which function it is. It is declared so the shape is complete and so a
14
+ * reader can see the field was considered.
15
+ */
16
+ export interface CountLimitDetail {
17
+ limit?: number | string;
18
+ count?: number;
19
+ ceiling?: number;
20
+ orgName?: string;
21
+ orgSlug?: string;
22
+ planName?: string;
23
+ }
24
+ /**
25
+ * The org as the CLI can otherwise name it — the machine's configured active
26
+ * org. It is the FALLBACK and never the authority: see `resolveOrg`.
27
+ */
28
+ export interface LocalOrgIdentity {
29
+ name?: string;
30
+ slug?: string;
31
+ consoleUrl?: string;
32
+ }
33
+ export interface ResolvedCountLimit {
34
+ orgName?: string;
35
+ orgSlug?: string;
36
+ planName?: string;
37
+ count: number;
38
+ ceiling: number;
39
+ consoleUrl?: string;
40
+ }
41
+ /**
42
+ * THE WIRE WINS over the machine's configured active org.
43
+ *
44
+ * The API names the org that owns the project being acted on; local config names
45
+ * whichever org the CLI was last pointed at, and on a machine with more than one
46
+ * workspace they are routinely different. Naming the local one would print the
47
+ * wrong workspace on the exact screen that is refusing the creator — and the
48
+ * refusal's figures are about the org the wire named, so a mismatched heading
49
+ * makes the numbers read as wrong rather than the name.
50
+ *
51
+ * The local values remain as a fallback for a body that carried neither, which
52
+ * is a refusal from an older api rather than a shape this client accepts.
53
+ */
54
+ export declare function resolveCountLimit(detail: CountLimitDetail, local?: LocalOrgIdentity): ResolvedCountLimit;
55
+ /**
56
+ * `Acme (acme)` — name and slug when they differ, one of them when they do not,
57
+ * and an empty string when the wire named neither. Matches f10's shipped
58
+ * plan-limit heading so the four plan refusals read as one family.
59
+ */
60
+ export declare function orgLabel(name?: string, slug?: string): string;
61
+ /**
62
+ * The figures, STACKED AND ALIGNED rather than inline. They are a comparison —
63
+ * the count against the ceiling — and f2's convention puts two values a creator
64
+ * must judge on their own aligned lines. Inline they are a sentence to parse;
65
+ * stacked, an agent and a human read them the same way.
66
+ */
67
+ export declare function countRows(i: ResolvedCountLimit, axisLabel: string, planSuffix: string): string[];
68
+ /**
69
+ * f4's rule: where a verb does not exist because the act belongs to another
70
+ * surface, say where it lives and link to it — and if the link cannot be built,
71
+ * drop the link and keep the explanation.
72
+ *
73
+ * `path` is the org-scoped page that shows the axis being refused.
74
+ */
75
+ export declare function consoleLines(i: ResolvedCountLimit, path: string): string[];
76
+ /**
77
+ * The published-project ceiling, refused at open-publish.
78
+ *
79
+ * "One published project" means ONE LIVE ADDRESS AT A TIME, and the copy says
80
+ * so, because the other reading — one project ever published — is what makes a
81
+ * creator believe they have permanently spent their slot on a mistake. Taking a
82
+ * project offline frees it, and that is the sentence that distinguishes the two.
83
+ *
84
+ * It does NOT say the project being published is excluded from the count, even
85
+ * though it is: a creator refused here is publishing a SECOND project, so the
86
+ * exclusion never applies on the screen that would explain it.
87
+ */
88
+ export declare function publishedLimitRefused(i: ResolvedCountLimit): string[];
89
+ /**
90
+ * The concurrent-preview ceiling, refused at mint.
91
+ *
92
+ * "Still running" is the load-bearing claim and it is stated first among the
93
+ * facts. A creator whose `vincentt preview` just refused has every reason to
94
+ * believe it took something down — the command that failed is the command that
95
+ * starts previews — and B-F10-3's guarantee (a count limit refuses growth and
96
+ * removes nothing) is invisible unless the refusal says it.
97
+ */
98
+ export declare function previewLimitRefused(i: ResolvedCountLimit): string[];
99
+ /**
100
+ * The storage ceiling. It NAMES NO FIGURE, and the asymmetry with the three
101
+ * count refusals above is deliberate rather than an omission: the ceiling is an
102
+ * abuse ceiling, not a tier limit. It is flat on every plan, never sold, never
103
+ * displayed, and its only audience is an operator. A number here would be the
104
+ * product printing a magnitude a creator cannot act on and was never offered.
105
+ *
106
+ * Support is therefore the whole remedy — there is no console page showing this
107
+ * and no plan that raises it.
108
+ */
109
+ export declare function storageCeilingRefused(): string[];
@@ -0,0 +1,169 @@
1
+ // The three count-limit refusals — seats, published projects, concurrent live
2
+ // previews — as they reach a creator standing at a ceiling.
3
+ //
4
+ // THE REFUSAL IS THE NOTICE. A count limit is evaluated in the same request as
5
+ // the act it refuses, so no earlier moment exists at which a warning could have
6
+ // been delivered (B-F10-2, as amended). The obligation is discharged by the
7
+ // refusal naming the axis, the current count and the ceiling — which means a
8
+ // refusal rendered without its figures does not discharge it at all. That is the
9
+ // whole reason these arms exist rather than the generic `✗ {message}` default.
10
+ //
11
+ // Two prohibitions govern every string below:
12
+ // - NO PRICE, and no plan other than the one in force. f16's inherited rule: a
13
+ // surface tells a creator where they stand, never what to buy.
14
+ // - NO COMMAND that resolves the refusal. An agent's default response to a
15
+ // failure is to run the next thing it is handed, and there is no CLI verb
16
+ // that frees a seat, unpublishes a project or ends someone else's preview.
17
+ // The copy names the act as a human one, in the console.
18
+ const SUPPORT_EMAIL = "hello@vincentt.studio";
19
+ const DEFAULT_CONSOLE_URL = "https://console.vincentt.studio";
20
+ /**
21
+ * THE WIRE WINS over the machine's configured active org.
22
+ *
23
+ * The API names the org that owns the project being acted on; local config names
24
+ * whichever org the CLI was last pointed at, and on a machine with more than one
25
+ * workspace they are routinely different. Naming the local one would print the
26
+ * wrong workspace on the exact screen that is refusing the creator — and the
27
+ * refusal's figures are about the org the wire named, so a mismatched heading
28
+ * makes the numbers read as wrong rather than the name.
29
+ *
30
+ * The local values remain as a fallback for a body that carried neither, which
31
+ * is a refusal from an older api rather than a shape this client accepts.
32
+ */
33
+ export function resolveCountLimit(detail, local) {
34
+ return {
35
+ orgName: detail.orgName ?? local?.name,
36
+ orgSlug: detail.orgSlug ?? local?.slug,
37
+ planName: detail.planName,
38
+ count: detail.count ?? 0,
39
+ ceiling: detail.ceiling ?? 0,
40
+ consoleUrl: local?.consoleUrl,
41
+ };
42
+ }
43
+ /**
44
+ * `Acme (acme)` — name and slug when they differ, one of them when they do not,
45
+ * and an empty string when the wire named neither. Matches f10's shipped
46
+ * plan-limit heading so the four plan refusals read as one family.
47
+ */
48
+ export function orgLabel(name, slug) {
49
+ if (name && slug && name !== slug)
50
+ return `${name} (${slug})`;
51
+ return name || slug || "";
52
+ }
53
+ /**
54
+ * The figures, STACKED AND ALIGNED rather than inline. They are a comparison —
55
+ * the count against the ceiling — and f2's convention puts two values a creator
56
+ * must judge on their own aligned lines. Inline they are a sentence to parse;
57
+ * stacked, an agent and a human read them the same way.
58
+ */
59
+ export function countRows(i, axisLabel, planSuffix) {
60
+ const rows = [];
61
+ if (i.orgName || i.orgSlug)
62
+ rows.push(["Organization", orgLabel(i.orgName, i.orgSlug)]);
63
+ if (i.planName)
64
+ rows.push(["Plan", `${i.planName} — ${planSuffix}`]);
65
+ rows.push([axisLabel, `${i.count} of ${i.ceiling} used`]);
66
+ return alignedRows(rows);
67
+ }
68
+ function alignedRows(rows) {
69
+ const width = Math.max(...rows.map((r) => r[0].length));
70
+ return rows.map(([label, value]) => ` ${label.padEnd(width)} ${value}`);
71
+ }
72
+ /**
73
+ * f4's rule: where a verb does not exist because the act belongs to another
74
+ * surface, say where it lives and link to it — and if the link cannot be built,
75
+ * drop the link and keep the explanation.
76
+ *
77
+ * `path` is the org-scoped page that shows the axis being refused.
78
+ */
79
+ export function consoleLines(i, path) {
80
+ if (!i.orgSlug)
81
+ return [` Email ${SUPPORT_EMAIL}.`, ""];
82
+ const base = (i.consoleUrl ?? DEFAULT_CONSOLE_URL).replace(/\/$/, "");
83
+ return [` See ${base}/orgs/${i.orgSlug}/${path}, or email`, ` ${SUPPORT_EMAIL}.`, ""];
84
+ }
85
+ // THERE IS NO SEAT ARM HERE, AND THE ABSENCE IS THE DESIGN.
86
+ //
87
+ // `seat_limit` is refused on `POST /orgs/{slug}/invites`, which the CLI does not
88
+ // call and has no verb for: inviting a person is an act whose authorized actor
89
+ // is a human, so it lives in the console (f15's convention). The seat refusal's
90
+ // copy — including the two ways to free a seat — is the console's to render.
91
+ //
92
+ // A renderer for it added here would be an export with no caller, which is worse
93
+ // than nothing: it reads as coverage and cannot fail.
94
+ /**
95
+ * The published-project ceiling, refused at open-publish.
96
+ *
97
+ * "One published project" means ONE LIVE ADDRESS AT A TIME, and the copy says
98
+ * so, because the other reading — one project ever published — is what makes a
99
+ * creator believe they have permanently spent their slot on a mistake. Taking a
100
+ * project offline frees it, and that is the sentence that distinguishes the two.
101
+ *
102
+ * It does NOT say the project being published is excluded from the count, even
103
+ * though it is: a creator refused here is publishing a SECOND project, so the
104
+ * exclusion never applies on the screen that would explain it.
105
+ */
106
+ export function publishedLimitRefused(i) {
107
+ return [
108
+ "",
109
+ "✗ This organization is already publishing as many projects as its plan allows.",
110
+ "",
111
+ ...countRows(i, "Published", "published projects are counted per organization"),
112
+ "",
113
+ " The count is projects with a live address right now, not projects that have",
114
+ " ever been published. Taking one offline in the console frees the slot, and",
115
+ " everything already published stays exactly as it is.",
116
+ "",
117
+ ...consoleLines(i, "projects"),
118
+ " Nothing was uploaded.",
119
+ "",
120
+ ];
121
+ }
122
+ /**
123
+ * The concurrent-preview ceiling, refused at mint.
124
+ *
125
+ * "Still running" is the load-bearing claim and it is stated first among the
126
+ * facts. A creator whose `vincentt preview` just refused has every reason to
127
+ * believe it took something down — the command that failed is the command that
128
+ * starts previews — and B-F10-3's guarantee (a count limit refuses growth and
129
+ * removes nothing) is invisible unless the refusal says it.
130
+ */
131
+ export function previewLimitRefused(i) {
132
+ return [
133
+ "",
134
+ "✗ This organization has as many previews running as its plan allows.",
135
+ "",
136
+ ...countRows(i, "Previews", "previews are counted per organization"),
137
+ "",
138
+ " Every preview that was running is still running — this one did not start.",
139
+ " A preview stops when whoever started it stops it, or when it reaches its",
140
+ " 12-hour limit.",
141
+ "",
142
+ ...consoleLines(i, "projects"),
143
+ " Nothing was started.",
144
+ "",
145
+ ];
146
+ }
147
+ /**
148
+ * The storage ceiling. It NAMES NO FIGURE, and the asymmetry with the three
149
+ * count refusals above is deliberate rather than an omission: the ceiling is an
150
+ * abuse ceiling, not a tier limit. It is flat on every plan, never sold, never
151
+ * displayed, and its only audience is an operator. A number here would be the
152
+ * product printing a magnitude a creator cannot act on and was never offered.
153
+ *
154
+ * Support is therefore the whole remedy — there is no console page showing this
155
+ * and no plan that raises it.
156
+ */
157
+ export function storageCeilingRefused() {
158
+ return [
159
+ "",
160
+ "✗ This organization cannot upload more files right now.",
161
+ "",
162
+ " This is not a plan limit and taking a different plan will not change it.",
163
+ "",
164
+ ` Email ${SUPPORT_EMAIL} and it can be looked at.`,
165
+ "",
166
+ " Nothing was uploaded.",
167
+ "",
168
+ ];
169
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincentt-xr/cli",
3
- "version": "0.16.0",
3
+ "version": "0.17.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",