@vincentt-xr/cli 0.15.2 → 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.
Files changed (83) hide show
  1. package/dist/cli/args.js +1 -0
  2. package/dist/cli/index.js +198 -3
  3. package/dist/cli/usage-text.js +27 -0
  4. package/dist/cli/verb-surface.d.ts +3 -1
  5. package/dist/cli/verb-surface.js +12 -0
  6. package/dist/domain/api.d.ts +51 -0
  7. package/dist/domain/api.js +145 -0
  8. package/dist/domain/command.d.ts +75 -0
  9. package/dist/domain/command.js +467 -0
  10. package/dist/domain/copy.d.ts +334 -0
  11. package/dist/domain/copy.js +952 -0
  12. package/dist/domain/hostname.d.ts +57 -0
  13. package/dist/domain/hostname.js +116 -0
  14. package/dist/domain/names.d.ts +78 -0
  15. package/dist/domain/names.js +113 -0
  16. package/dist/domain/routedVerbs.d.ts +10 -0
  17. package/dist/domain/routedVerbs.js +30 -0
  18. package/dist/domain/wire.d.ts +140 -0
  19. package/dist/domain/wire.js +174 -0
  20. package/dist/dx/command.d.ts +13 -4
  21. package/dist/dx/command.js +26 -5
  22. package/dist/dx/report.d.ts +39 -7
  23. package/dist/dx/report.js +71 -20
  24. package/dist/dx/send.d.ts +22 -4
  25. package/dist/dx/send.js +29 -8
  26. package/dist/dx/skeleton.js +19 -0
  27. package/dist/dx/unbiddenVerbs.d.ts +25 -0
  28. package/dist/dx/unbiddenVerbs.js +39 -0
  29. package/dist/lib/initCommand.d.ts +3 -1
  30. package/dist/lib/initCommand.js +10 -0
  31. package/dist/lib/projects.d.ts +8 -1
  32. package/dist/lib/projects.js +12 -2
  33. package/dist/platform/allowlist.d.ts +36 -0
  34. package/dist/platform/allowlist.js +34 -0
  35. package/dist/platform/behind.d.ts +41 -0
  36. package/dist/platform/behind.js +82 -0
  37. package/dist/platform/command.d.ts +16 -0
  38. package/dist/platform/command.js +90 -0
  39. package/dist/platform/footer.d.ts +60 -0
  40. package/dist/platform/footer.js +101 -0
  41. package/dist/platform/notice.d.ts +29 -0
  42. package/dist/platform/notice.js +66 -0
  43. package/dist/platform/offSwitch.d.ts +19 -0
  44. package/dist/platform/offSwitch.js +21 -0
  45. package/dist/platform/outdated.d.ts +25 -0
  46. package/dist/platform/outdated.js +246 -0
  47. package/dist/platform/releaseCache.d.ts +69 -0
  48. package/dist/platform/releaseCache.js +140 -0
  49. package/dist/platform/releaseFetch.d.ts +51 -0
  50. package/dist/platform/releaseFetch.js +150 -0
  51. package/dist/platform/resolve.d.ts +30 -0
  52. package/dist/platform/resolve.js +128 -0
  53. package/dist/platform/templateUpgrade.d.ts +56 -0
  54. package/dist/platform/templateUpgrade.js +476 -0
  55. package/dist/platform/upgradeReport.d.ts +132 -0
  56. package/dist/platform/upgradeReport.js +258 -0
  57. package/dist/platform/versionSummary.d.ts +82 -0
  58. package/dist/platform/versionSummary.js +181 -0
  59. package/dist/platform/zonePath.d.ts +154 -0
  60. package/dist/platform/zonePath.js +286 -0
  61. package/dist/platform/zones.d.ts +64 -0
  62. package/dist/platform/zones.js +79 -0
  63. package/dist/preview/api.d.ts +19 -0
  64. package/dist/preview/api.js +29 -0
  65. package/dist/publish/api.d.ts +32 -5
  66. package/dist/publish/api.js +19 -5
  67. package/dist/publish/command.d.ts +10 -0
  68. package/dist/publish/command.js +30 -2
  69. package/dist/publish/copy.d.ts +14 -1
  70. package/dist/publish/copy.js +32 -3
  71. package/dist/scaffold/index.d.ts +50 -3
  72. package/dist/scaffold/index.js +61 -1
  73. package/dist/shared/config.d.ts +16 -0
  74. package/dist/shared/config.js +9 -1
  75. package/dist/shared/countLimit.d.ts +109 -0
  76. package/dist/shared/countLimit.js +169 -0
  77. package/package.json +1 -1
  78. package/dist/dx/qa.f7.fixtures.d.ts +0 -73
  79. package/dist/dx/qa.f7.fixtures.js +0 -58
  80. package/dist/publish/qa.f4.fixtures.d.ts +0 -25
  81. package/dist/publish/qa.f4.fixtures.js +0 -140
  82. package/dist/relay/qa.f12.fixtures.d.ts +0 -154
  83. package/dist/relay/qa.f12.fixtures.js +0 -358
@@ -1,5 +1,6 @@
1
1
  import type { ResolvedConfig } from "../shared/config.js";
2
2
  import type { ManifestEntry } from "./manifest.js";
3
+ import type { BuiltWith } from "../platform/resolve.js";
3
4
  export interface PublishProject {
4
5
  id: string;
5
6
  slug: string;
@@ -40,7 +41,15 @@ export interface CommitResult {
40
41
  releaseNote?: string;
41
42
  }
42
43
  export interface ApiErrorDetail {
43
- 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;
44
53
  observed?: number;
45
54
  missing?: number;
46
55
  mismatched?: number;
@@ -66,6 +75,14 @@ export interface ApiErrorDetail {
66
75
  */
67
76
  orgName?: string;
68
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;
69
86
  }
70
87
  export declare class PublishApiError extends Error {
71
88
  readonly status: number;
@@ -77,11 +94,21 @@ export declare class PublishApiError extends Error {
77
94
  /** The ONE call before the checklist. liveVersion + livePublishedAt ride it, by design. */
78
95
  export declare function readProject(cfg: ResolvedConfig, projectId: string, fetchImpl?: typeof fetch): Promise<PublishProject>;
79
96
  /**
80
- * Open an upload. The request body carries `files` (path/size/sha256) and an
81
- * optional `note`, AND NOTHING ELSE — there is no field for a MIME type and no
82
- * field a parsed title, description or image URL could land in.
97
+ * Open an upload. The request body carries `files` (path/size/sha256), an
98
+ * optional `note`, and an optional `builtWith`, AND NOTHING ELSE — there is no
99
+ * field for a MIME type and no field a parsed title, description or image URL
100
+ * could land in.
101
+ *
102
+ * `builtWith` CANNOT FAIL A PUBLISH. It is evidence and never authorization: it
103
+ * is computed on a machine the platform does not control, so nothing may branch
104
+ * on it, and the API validates it in its own pass after the manifest, dropping
105
+ * over-bound entries rather than refusing. An instrument that can break the
106
+ * product's most important write path is worse than an instrument with a gap —
107
+ * which is why it is omitted entirely when nothing resolved rather than sent as
108
+ * an empty object. `{}` means "resolution ran and found nothing", a different
109
+ * and specific fact.
83
110
  */
84
- export declare function openUpload(cfg: ResolvedConfig, projectId: string, files: ManifestEntry[], note: string | undefined, fetchImpl?: typeof fetch): Promise<OpenedUpload>;
111
+ export declare function openUpload(cfg: ResolvedConfig, projectId: string, files: ManifestEntry[], note: string | undefined, fetchImpl?: typeof fetch, builtWith?: BuiltWith): Promise<OpenedUpload>;
85
112
  export declare function commitUpload(cfg: ResolvedConfig, projectId: string, uploadId: string, fetchImpl?: typeof fetch): Promise<CommitResult>;
86
113
  /** Abandon an open upload. Idempotent server-side; a failure here must never mask the real error. */
87
114
  export declare function abandonUpload(cfg: ResolvedConfig, projectId: string, uploadId: string, fetchImpl?: typeof fetch): Promise<void>;
@@ -58,15 +58,29 @@ export async function readProject(cfg, projectId, fetchImpl = fetch) {
58
58
  };
59
59
  }
60
60
  /**
61
- * Open an upload. The request body carries `files` (path/size/sha256) and an
62
- * optional `note`, AND NOTHING ELSE — there is no field for a MIME type and no
63
- * field a parsed title, description or image URL could land in.
61
+ * Open an upload. The request body carries `files` (path/size/sha256), an
62
+ * optional `note`, and an optional `builtWith`, AND NOTHING ELSE — there is no
63
+ * field for a MIME type and no field a parsed title, description or image URL
64
+ * could land in.
65
+ *
66
+ * `builtWith` CANNOT FAIL A PUBLISH. It is evidence and never authorization: it
67
+ * is computed on a machine the platform does not control, so nothing may branch
68
+ * on it, and the API validates it in its own pass after the manifest, dropping
69
+ * over-bound entries rather than refusing. An instrument that can break the
70
+ * product's most important write path is worse than an instrument with a gap —
71
+ * which is why it is omitted entirely when nothing resolved rather than sent as
72
+ * an empty object. `{}` means "resolution ran and found nothing", a different
73
+ * and specific fact.
64
74
  */
65
- export async function openUpload(cfg, projectId, files, note, fetchImpl = fetch) {
75
+ export async function openUpload(cfg, projectId, files, note, fetchImpl = fetch, builtWith) {
66
76
  const res = await fetchImpl(`${cfg.apiUrl}/projects/${encodeURIComponent(projectId)}/publishes`, {
67
77
  method: "POST",
68
78
  headers: { ...authHeaders(cfg), "content-type": "application/json" },
69
- body: JSON.stringify({ files, ...(note ? { note } : {}) }),
79
+ body: JSON.stringify({
80
+ files,
81
+ ...(note ? { note } : {}),
82
+ ...(builtWith ? { builtWith } : {}),
83
+ }),
70
84
  });
71
85
  if (!res.ok)
72
86
  throw await readError(res);
@@ -1,4 +1,5 @@
1
1
  import type { ResolvedConfig } from "../shared/config.js";
2
+ import { type BuiltWith } from "../platform/resolve.js";
2
3
  export interface PublishDeps {
3
4
  print: (line: string) => void;
4
5
  /** Raw write, for the redrawn progress line only. */
@@ -21,6 +22,15 @@ export interface PublishDeps {
21
22
  /** The active org's slug, for the usage deep link on a plan-limit refusal. */
22
23
  orgSlug?: () => Promise<string | undefined>;
23
24
  consoleUrl?: string;
25
+ /**
26
+ * What this project resolved on this machine, attached to the version row.
27
+ *
28
+ * Injected so the publish path can be tested without a `node_modules` tree,
29
+ * and defaulted to f17's SHARED resolver — the same one the report path calls,
30
+ * because two payloads carrying byte-identical values for one project is the
31
+ * mechanical proof there is one resolver and not two that drifted.
32
+ */
33
+ builtWith?: () => Promise<BuiltWith | undefined>;
24
34
  }
25
35
  export interface PublishOptions {
26
36
  distDir?: string;
@@ -17,6 +17,8 @@ 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";
21
+ import { resolveBuiltWith } from "../platform/resolve.js";
20
22
  const COMMIT_RETRIES = 3;
21
23
  const COMMIT_RETRY_DELAY_MS = 2000;
22
24
  export async function publishCommand(deps, opts = {}) {
@@ -140,6 +142,10 @@ export async function publishCommand(deps, opts = {}) {
140
142
  return 0;
141
143
  }
142
144
  }
145
+ // Resolved AFTER the confirmation and never before it: a refused publish must
146
+ // read nothing about the creator's installed packages. A fault here yields
147
+ // undefined and the field is omitted — it can never fail a publish.
148
+ const builtWith = await (deps.builtWith ?? (() => resolveBuiltWith(deps.cwd)))().catch(() => undefined);
143
149
  return runUpload(deps, cfg, {
144
150
  projectId: binding.projectId,
145
151
  distDir,
@@ -148,6 +154,7 @@ export async function publishCommand(deps, opts = {}) {
148
154
  address,
149
155
  liveVersion: project.liveVersion ?? undefined,
150
156
  fetchImpl,
157
+ builtWith,
151
158
  org: {
152
159
  name: orgName || undefined,
153
160
  slug: (await deps.orgSlug?.()) || undefined,
@@ -158,7 +165,7 @@ export async function publishCommand(deps, opts = {}) {
158
165
  async function runUpload(deps, cfg, i) {
159
166
  let opened;
160
167
  try {
161
- opened = await openUpload(cfg, i.projectId, i.manifest.files, i.note, i.fetchImpl);
168
+ opened = await openUpload(cfg, i.projectId, i.manifest.files, i.note, i.fetchImpl, i.builtWith);
162
169
  }
163
170
  catch (err) {
164
171
  for (const l of openErrorLines(err, i.address, i.liveVersion, i.org))
@@ -312,8 +319,20 @@ function openErrorLines(err, address, liveVersion, org) {
312
319
  if (err.code === "unavailable_plan_limit") {
313
320
  return planLimitLines(err, org);
314
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
+ }
315
333
  if (err.code === "publish_too_large") {
316
- 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));
317
336
  }
318
337
  if (err.code === "rate_limited") {
319
338
  return copy.rateLimited(err.retryAfterSec ?? 60);
@@ -333,6 +352,15 @@ function openErrorLines(err, address, liveVersion, org) {
333
352
  "",
334
353
  ];
335
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
+ }
336
364
  function manifestErrorLines(err, distDir) {
337
365
  if (err.reason === "no_dist")
338
366
  return copy.noDist(distDir);
@@ -205,5 +205,18 @@ export declare function rateLimited(retryAfterSec: number): string[];
205
205
  * none.
206
206
  */
207
207
  export declare function consoleVerb(verb: string, versionsUrl?: string): string[];
208
- /** The six verbs that land on that block. */
208
+ /**
209
+ * The verbs that land on that block — f4's and f10's six, plus f15's five.
210
+ *
211
+ * NONE OF THESE GAINS A HANDLER. They are routed to an answer precisely because
212
+ * they do not exist: f15 deliberately gives the CLI no checkout verb, since the
213
+ * authorized actor is a human reading consequence copy in a browser and a verb
214
+ * whose whole contribution is printing a URL is a browser flow with a shell in
215
+ * front of it.
216
+ *
217
+ * Routing them rather than ignoring them is about f15's actual actor. A human
218
+ * who types `vincentt upgrade` and gets "unrecognised command" shrugs and reads
219
+ * the help. AN AGENT RETRIES, or guesses a neighbouring verb, and neither
220
+ * terminates.
221
+ */
209
222
  export declare const CONSOLE_ONLY_VERBS: readonly string[];
@@ -476,9 +476,19 @@ export function planLimitRefused(i) {
476
476
  "",
477
477
  ...alignedRows(rows),
478
478
  "",
479
+ // f15 FALSIFIED THE OLD SENTENCE. It said restoring was "a manual action by a
480
+ // Vincentt operator" — true until a customer could pay for it themselves, and
481
+ // false the moment checkout shipped.
482
+ //
483
+ // "cannot be done from the CLI" STAYS, and is the load-bearing half: an agent
484
+ // told only that publishing failed hunts for a flag, and naming the remedy as
485
+ // something outside this tool is what stops the loop.
486
+ //
487
+ // The restore is deliberately not promised as instant — it is about two
488
+ // minutes, and copy that promises instant is copy that lies on a slow tick.
479
489
  ` Published addresses were taken offline${takenOn} because the organization`,
480
- " went over its monthly traffic allowance. Restoring this is a manual action",
481
- " by a Vincentt operator and cannot be done from the CLI.",
490
+ " went over its monthly traffic allowance. Taking a paid plan restores them",
491
+ " automatically, in the console — it cannot be done from the CLI.",
482
492
  "",
483
493
  " Every version is still stored and the address is still yours.",
484
494
  "",
@@ -681,7 +691,20 @@ export function consoleVerb(verb, versionsUrl) {
681
691
  }
682
692
  return out;
683
693
  }
684
- /** The six verbs that land on that block. */
694
+ /**
695
+ * The verbs that land on that block — f4's and f10's six, plus f15's five.
696
+ *
697
+ * NONE OF THESE GAINS A HANDLER. They are routed to an answer precisely because
698
+ * they do not exist: f15 deliberately gives the CLI no checkout verb, since the
699
+ * authorized actor is a human reading consequence copy in a browser and a verb
700
+ * whose whole contribution is printing a URL is a browser flow with a shell in
701
+ * front of it.
702
+ *
703
+ * Routing them rather than ignoring them is about f15's actual actor. A human
704
+ * who types `vincentt upgrade` and gets "unrecognised command" shrugs and reads
705
+ * the help. AN AGENT RETRIES, or guesses a neighbouring verb, and neither
706
+ * terminates.
707
+ */
685
708
  export const CONSOLE_ONLY_VERBS = [
686
709
  "rollback",
687
710
  "unpublish",
@@ -689,4 +712,10 @@ export const CONSOLE_ONLY_VERBS = [
689
712
  "versions",
690
713
  "revert",
691
714
  "take-offline",
715
+ // f15 — checkout is console-only, by design.
716
+ "upgrade",
717
+ "billing",
718
+ "plan",
719
+ "subscribe",
720
+ "pay",
692
721
  ];
@@ -11,8 +11,14 @@ export declare const TEMPLATE_REPO = "https://github.com/vincentt-xr/v2-template
11
11
  * immutable, which is what makes that an escape hatch rather than a coin flip.
12
12
  */
13
13
  export declare const DEFAULT_TEMPLATE_REF = "latest";
14
- /** Injectable git runner (real git by default; a fake in tests avoids the network). */
15
- export type GitRunner = (args: string[]) => Promise<void>;
14
+ /**
15
+ * Injectable git runner (real git by default; a fake in tests avoids the network).
16
+ *
17
+ * Resolves to git's stdout. A fake that returns nothing still satisfies the type,
18
+ * so the tag resolution below treats a void result the same as empty output —
19
+ * unresolved rather than a crash.
20
+ */
21
+ export type GitRunner = (args: string[]) => Promise<string | void>;
16
22
  /**
17
23
  * True when `dir` has no app yet (no package.json) → safe to scaffold into.
18
24
  * A directory that already holds an app is left untouched (project_create just binds).
@@ -46,12 +52,53 @@ export interface ScaffoldOptions {
46
52
  /** Test seam — defaults to spawning real git. */
47
53
  runGit?: GitRunner;
48
54
  }
55
+ /**
56
+ * The concrete version tag the clone actually landed on, or `undefined` when the
57
+ * commit carries none.
58
+ *
59
+ * `git describe --tags` is the obvious call and it is WRONG here: against a clone
60
+ * of the moving tag it answers `latest`, which is the exact non-information this
61
+ * resolution exists to replace. `tag --points-at HEAD` lists every tag on the
62
+ * cloned commit instead — `latest` alongside the version tag it was force-moved
63
+ * onto — and the version tag is selected by shape.
64
+ *
65
+ * When several concrete versions share a commit (a re-tagged release), the
66
+ * HIGHEST is recorded: the tags name the same tree, so any is a correct baseline,
67
+ * and picking by precedence rather than by git's output order keeps the recorded
68
+ * value stable across clones.
69
+ */
70
+ export declare function pickConcreteVersionTag(tagOutput: string | void): string | undefined;
71
+ /** What a scaffold resolved. `templateVersion` absent = it could not be established. */
72
+ export interface ScaffoldResult {
73
+ /**
74
+ * The concrete version tag the clone landed on. ABSENT rather than `latest`
75
+ * when unresolvable: a moving tag recorded as a baseline is worse than no
76
+ * baseline, because a later upgrade would trust it and widen what it replaces.
77
+ */
78
+ templateVersion?: string;
79
+ /** The ref that was ASKED for — `latest` on the default path. Diagnostic only. */
80
+ requestedRef?: string;
81
+ }
82
+ /**
83
+ * The shape an INJECTED scaffold may return. `void` is permitted, and reads as
84
+ * "this fake resolved no version" — which is the no-baseline state, already a
85
+ * first-class value here.
86
+ *
87
+ * Deliberately looser than what `scaffoldFromTemplate` itself returns: the real
88
+ * function always reports, while a caller's test double has nothing to report
89
+ * and must not be forced to invent a version to satisfy a type.
90
+ */
91
+ export type ScaffoldOutcome = ScaffoldResult | void;
49
92
  /**
50
93
  * Scaffold the template into `targetDir`. Clones to a temp dir, strips `.git`,
51
94
  * copies the files in (never overwriting anything already there), then inits a
52
95
  * fresh repo. Throws a clear error if the clone fails (e.g. no git access).
96
+ *
97
+ * Returns what it resolved. The requested ref is not the answer: `latest` is
98
+ * force-updated by the template's release workflow, so recording it preserves
99
+ * nothing — every project from any date would report the same string.
53
100
  */
54
- export declare function scaffoldFromTemplate(targetDir: string, opts?: ScaffoldOptions): Promise<void>;
101
+ export declare function scaffoldFromTemplate(targetDir: string, opts?: ScaffoldOptions): Promise<ScaffoldResult>;
55
102
  export type PackageManager = "pnpm" | "yarn" | "npm";
56
103
  /**
57
104
  * Pick the package manager the scaffolded template is set up for, by its lockfile.
@@ -23,7 +23,8 @@ export const TEMPLATE_REPO = "https://github.com/vincentt-xr/v2-template.git";
23
23
  */
24
24
  export const DEFAULT_TEMPLATE_REF = "latest";
25
25
  const realGit = async (args) => {
26
- await execFileAsync("git", args);
26
+ const { stdout } = await execFileAsync("git", args);
27
+ return stdout;
27
28
  };
28
29
  /**
29
30
  * True when `dir` has no app yet (no package.json) → safe to scaffold into.
@@ -71,10 +72,57 @@ export class ScaffoldRefError extends Error {
71
72
  this.name = "ScaffoldRefError";
72
73
  }
73
74
  }
75
+ /**
76
+ * A concrete `MAJOR.MINOR.PATCH` version tag, anchored at both ends.
77
+ *
78
+ * Anchored because a template repo carries tags a version compare cannot use:
79
+ * `latest` itself, and anything merely version-SHAPED (`1.4.3-extra`). Recording
80
+ * one of those is the defect this whole resolution exists to remove.
81
+ */
82
+ const CONCRETE_VERSION_TAG = /^[0-9]+\.[0-9]+\.[0-9]+$/;
83
+ /**
84
+ * The concrete version tag the clone actually landed on, or `undefined` when the
85
+ * commit carries none.
86
+ *
87
+ * `git describe --tags` is the obvious call and it is WRONG here: against a clone
88
+ * of the moving tag it answers `latest`, which is the exact non-information this
89
+ * resolution exists to replace. `tag --points-at HEAD` lists every tag on the
90
+ * cloned commit instead — `latest` alongside the version tag it was force-moved
91
+ * onto — and the version tag is selected by shape.
92
+ *
93
+ * When several concrete versions share a commit (a re-tagged release), the
94
+ * HIGHEST is recorded: the tags name the same tree, so any is a correct baseline,
95
+ * and picking by precedence rather than by git's output order keeps the recorded
96
+ * value stable across clones.
97
+ */
98
+ export function pickConcreteVersionTag(tagOutput) {
99
+ if (!tagOutput)
100
+ return undefined;
101
+ const versions = tagOutput
102
+ .split(/\r?\n/)
103
+ .map((line) => line.trim())
104
+ .filter((line) => CONCRETE_VERSION_TAG.test(line));
105
+ if (versions.length === 0)
106
+ return undefined;
107
+ return versions.sort(compareVersionDesc)[0];
108
+ }
109
+ function compareVersionDesc(a, b) {
110
+ const pa = a.split(".").map(Number);
111
+ const pb = b.split(".").map(Number);
112
+ for (let i = 0; i < 3; i++) {
113
+ if (pa[i] !== pb[i])
114
+ return pb[i] - pa[i];
115
+ }
116
+ return 0;
117
+ }
74
118
  /**
75
119
  * Scaffold the template into `targetDir`. Clones to a temp dir, strips `.git`,
76
120
  * copies the files in (never overwriting anything already there), then inits a
77
121
  * fresh repo. Throws a clear error if the clone fails (e.g. no git access).
122
+ *
123
+ * Returns what it resolved. The requested ref is not the answer: `latest` is
124
+ * force-updated by the template's release workflow, so recording it preserves
125
+ * nothing — every project from any date would report the same string.
78
126
  */
79
127
  export async function scaffoldFromTemplate(targetDir, opts = {}) {
80
128
  const repo = opts.repo ?? TEMPLATE_REPO;
@@ -97,6 +145,17 @@ export async function scaffoldFromTemplate(targetDir, opts = {}) {
97
145
  }
98
146
  throw new Error(`Could not clone the ${repo} template (check your git access): ${msg}`);
99
147
  }
148
+ // BEFORE the strip below — `.git` is the only place the concrete tag is
149
+ // readable, and the copy into the target deliberately does not carry it.
150
+ // A failure here must not fail the scaffold: the files are correct either
151
+ // way, and the honest outcome is a missing baseline, not a missing app.
152
+ let templateVersion;
153
+ try {
154
+ templateVersion = pickConcreteVersionTag(await runGit(["-C", tmp, "tag", "--points-at", "HEAD"]));
155
+ }
156
+ catch {
157
+ templateVersion = undefined;
158
+ }
100
159
  await fs.rm(path.join(tmp, ".git"), { recursive: true, force: true });
101
160
  // Copy template contents in without clobbering anything already in the target.
102
161
  for (const entry of await fs.readdir(tmp)) {
@@ -110,6 +169,7 @@ export async function scaffoldFromTemplate(targetDir, opts = {}) {
110
169
  if (!(await isGitRepo(targetDir))) {
111
170
  await runGit(["-C", targetDir, "init"]);
112
171
  }
172
+ return { templateVersion, requestedRef: ref };
113
173
  }
114
174
  finally {
115
175
  await fs.rm(tmp, { recursive: true, force: true });
@@ -18,6 +18,20 @@ export interface MachineConfig {
18
18
  */
19
19
  dxSendEnabledAt?: string;
20
20
  }
21
+ /**
22
+ * What the project was scaffolded from — the drift baseline a later upgrade
23
+ * compares against.
24
+ *
25
+ * The whole object is ABSENT when the scaffold could not resolve a concrete
26
+ * version, and absent is a first-class state every reader must handle. The
27
+ * alternative — recording the moving `latest` tag — is strictly worse than
28
+ * absence: a reader cannot tell it apart from a real baseline, so it would be
29
+ * trusted, and an upgrade trusting a wrong baseline widens what it overwrites.
30
+ */
31
+ export interface TemplateProvenance {
32
+ /** A concrete `MAJOR.MINOR.PATCH` tag. Never `latest`, never a branch. */
33
+ version: string;
34
+ }
21
35
  export interface ProjectBinding {
22
36
  /** The backend Project id this working tree publishes to. */
23
37
  projectId: string;
@@ -25,6 +39,8 @@ export interface ProjectBinding {
25
39
  slug: string;
26
40
  /** Optional per-project API override; else the machine config's apiUrl. */
27
41
  apiUrl?: string;
42
+ /** Absent = no recorded baseline. See TemplateProvenance. */
43
+ template?: TemplateProvenance;
28
44
  }
29
45
  export declare const MACHINE_CONFIG_PATH: string;
30
46
  export declare function projectBindingPath(cwd: string): string;
@@ -176,7 +176,15 @@ async function isGitRoot(dir) {
176
176
  export async function writeProjectBinding(cwd, binding) {
177
177
  await fs.mkdir(path.join(cwd, BINDING_DIR), { recursive: true });
178
178
  const p = projectBindingPath(cwd);
179
- await fs.writeFile(p, JSON.stringify(binding, null, 2) + "\n", "utf8");
179
+ // A caller that writes a whole binding without a `template` (`link`, a re-bind)
180
+ // must not DROP a baseline the scaffold recorded: the tree it describes is
181
+ // unchanged by a re-bind, and a lost baseline downgrades the next upgrade to
182
+ // the reporting path with nothing on screen to explain why.
183
+ const existing = binding.template
184
+ ? undefined
185
+ : (await readJsonIfExists(p))?.template;
186
+ const next = existing ? { ...binding, template: existing } : binding;
187
+ await fs.writeFile(p, JSON.stringify(next, null, 2) + "\n", "utf8");
180
188
  await ensureGitignored(cwd, `${BINDING_DIR}/`);
181
189
  // `create` and `link` WRITE a binding rather than resolve one, so they never
182
190
  // pass through the resolver's hook. Between the two, every way a folder comes
@@ -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[];