@ontrails/trails 1.0.0-beta.3 → 1.0.0-beta.30

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 (157) hide show
  1. package/CHANGELOG.md +886 -0
  2. package/README.md +27 -0
  3. package/package.json +30 -7
  4. package/src/app.ts +125 -2
  5. package/src/cli.ts +344 -11
  6. package/src/completions.ts +240 -0
  7. package/src/lifecycle-source-io.ts +33 -0
  8. package/src/load-app-mirror.ts +202 -0
  9. package/src/local-state-io.ts +173 -0
  10. package/src/mcp-app.ts +40 -0
  11. package/src/mcp-options.ts +76 -0
  12. package/src/mcp.ts +8 -0
  13. package/src/project-writes.ts +377 -0
  14. package/src/release/bindings.ts +39 -0
  15. package/src/release/check.ts +844 -0
  16. package/src/release/config.ts +63 -0
  17. package/src/release/contract-facts.ts +425 -0
  18. package/src/release/index.ts +125 -0
  19. package/src/release/native-bun-publish.ts +651 -0
  20. package/src/release/native-bun-registry.ts +754 -0
  21. package/src/release/pack-coherence.ts +457 -0
  22. package/src/release/packed-artifacts-smoke.ts +236 -0
  23. package/src/release/policy.ts +1684 -0
  24. package/src/release/semver.ts +104 -0
  25. package/src/release/smoke.ts +46 -0
  26. package/src/release/wayfinder-dogfood-smoke.ts +760 -0
  27. package/src/retired-topo-command.ts +36 -0
  28. package/src/run-adapter-check.ts +76 -0
  29. package/src/run-collision.ts +126 -0
  30. package/src/run-completions-install.ts +179 -0
  31. package/src/run-example.ts +149 -0
  32. package/src/run-examples.ts +148 -0
  33. package/src/run-quiet.ts +75 -0
  34. package/src/run-release-check.ts +74 -0
  35. package/src/run-schema.ts +41 -0
  36. package/src/run-trace.ts +273 -0
  37. package/src/run-warden.ts +39 -0
  38. package/src/run-watch.ts +432 -0
  39. package/src/run-wayfind-outline.ts +166 -0
  40. package/src/scaffold-version-sync.ts +183 -0
  41. package/src/scaffold-versions.generated.ts +12 -0
  42. package/src/trails/adapter-check.ts +244 -0
  43. package/src/trails/add-surface.ts +94 -40
  44. package/src/trails/add-trail.ts +79 -41
  45. package/src/trails/add-verify.ts +95 -25
  46. package/src/trails/compile.ts +59 -0
  47. package/src/trails/completions-complete.ts +165 -0
  48. package/src/trails/completions.ts +47 -0
  49. package/src/trails/create-adapter.ts +1084 -0
  50. package/src/trails/create-scaffold.ts +399 -104
  51. package/src/trails/create-versions.ts +62 -0
  52. package/src/trails/create.ts +186 -72
  53. package/src/trails/deprecate.ts +59 -0
  54. package/src/trails/dev-clean.ts +82 -0
  55. package/src/trails/dev-reset.ts +50 -0
  56. package/src/trails/dev-stats.ts +72 -0
  57. package/src/trails/dev-support.ts +360 -0
  58. package/src/trails/doctor.ts +77 -0
  59. package/src/trails/draft-promote.ts +949 -0
  60. package/src/trails/guide.ts +71 -69
  61. package/src/trails/load-app.ts +1193 -16
  62. package/src/trails/operator-context.ts +66 -0
  63. package/src/trails/project.ts +17 -3
  64. package/src/trails/regrade.ts +72 -0
  65. package/src/trails/release-check.ts +105 -0
  66. package/src/trails/release-smoke.ts +48 -0
  67. package/src/trails/revise.ts +53 -0
  68. package/src/trails/root-dir.ts +21 -0
  69. package/src/trails/run-example.ts +475 -0
  70. package/src/trails/run-examples.ts +129 -0
  71. package/src/trails/run.ts +434 -0
  72. package/src/trails/scaffold-json.ts +58 -0
  73. package/src/trails/survey.ts +896 -227
  74. package/src/trails/topo-activation.ts +385 -0
  75. package/src/trails/topo-constants.ts +2 -0
  76. package/src/trails/topo-history.ts +47 -0
  77. package/src/trails/topo-output-schemas.ts +259 -0
  78. package/src/trails/topo-pin.ts +38 -0
  79. package/src/trails/topo-read-support.ts +363 -0
  80. package/src/trails/topo-reports.ts +809 -0
  81. package/src/trails/topo-store-support.ts +307 -0
  82. package/src/trails/topo-support.ts +219 -0
  83. package/src/trails/topo-unpin.ts +61 -0
  84. package/src/trails/topo.ts +92 -0
  85. package/src/trails/validate.ts +27 -0
  86. package/src/trails/version-lifecycle-support.ts +935 -0
  87. package/src/trails/warden-guide.ts +129 -0
  88. package/src/trails/warden.ts +183 -58
  89. package/src/trails/wayfind.ts +1001 -0
  90. package/src/versions.ts +31 -0
  91. package/.turbo/turbo-build.log +0 -1
  92. package/.turbo/turbo-lint.log +0 -3
  93. package/.turbo/turbo-typecheck.log +0 -1
  94. package/__tests__/examples.test.ts +0 -6
  95. package/dist/bin/trails.d.ts +0 -3
  96. package/dist/bin/trails.d.ts.map +0 -1
  97. package/dist/bin/trails.js +0 -4
  98. package/dist/bin/trails.js.map +0 -1
  99. package/dist/src/app.d.ts +0 -2
  100. package/dist/src/app.d.ts.map +0 -1
  101. package/dist/src/app.js +0 -11
  102. package/dist/src/app.js.map +0 -1
  103. package/dist/src/clack.d.ts +0 -9
  104. package/dist/src/clack.d.ts.map +0 -1
  105. package/dist/src/clack.js +0 -84
  106. package/dist/src/clack.js.map +0 -1
  107. package/dist/src/cli.d.ts +0 -2
  108. package/dist/src/cli.d.ts.map +0 -1
  109. package/dist/src/cli.js +0 -13
  110. package/dist/src/cli.js.map +0 -1
  111. package/dist/src/trails/add-surface.d.ts +0 -13
  112. package/dist/src/trails/add-surface.d.ts.map +0 -1
  113. package/dist/src/trails/add-surface.js +0 -88
  114. package/dist/src/trails/add-surface.js.map +0 -1
  115. package/dist/src/trails/add-trail.d.ts +0 -11
  116. package/dist/src/trails/add-trail.d.ts.map +0 -1
  117. package/dist/src/trails/add-trail.js +0 -85
  118. package/dist/src/trails/add-trail.js.map +0 -1
  119. package/dist/src/trails/add-verify.d.ts +0 -10
  120. package/dist/src/trails/add-verify.d.ts.map +0 -1
  121. package/dist/src/trails/add-verify.js +0 -67
  122. package/dist/src/trails/add-verify.js.map +0 -1
  123. package/dist/src/trails/create-scaffold.d.ts +0 -15
  124. package/dist/src/trails/create-scaffold.d.ts.map +0 -1
  125. package/dist/src/trails/create-scaffold.js +0 -288
  126. package/dist/src/trails/create-scaffold.js.map +0 -1
  127. package/dist/src/trails/create.d.ts +0 -22
  128. package/dist/src/trails/create.d.ts.map +0 -1
  129. package/dist/src/trails/create.js +0 -121
  130. package/dist/src/trails/create.js.map +0 -1
  131. package/dist/src/trails/guide.d.ts +0 -11
  132. package/dist/src/trails/guide.d.ts.map +0 -1
  133. package/dist/src/trails/guide.js +0 -80
  134. package/dist/src/trails/guide.js.map +0 -1
  135. package/dist/src/trails/load-app.d.ts +0 -4
  136. package/dist/src/trails/load-app.d.ts.map +0 -1
  137. package/dist/src/trails/load-app.js +0 -24
  138. package/dist/src/trails/load-app.js.map +0 -1
  139. package/dist/src/trails/project.d.ts +0 -8
  140. package/dist/src/trails/project.d.ts.map +0 -1
  141. package/dist/src/trails/project.js +0 -43
  142. package/dist/src/trails/project.js.map +0 -1
  143. package/dist/src/trails/survey.d.ts +0 -33
  144. package/dist/src/trails/survey.d.ts.map +0 -1
  145. package/dist/src/trails/survey.js +0 -225
  146. package/dist/src/trails/survey.js.map +0 -1
  147. package/dist/src/trails/warden.d.ts +0 -19
  148. package/dist/src/trails/warden.d.ts.map +0 -1
  149. package/dist/src/trails/warden.js +0 -88
  150. package/dist/src/trails/warden.js.map +0 -1
  151. package/dist/tsconfig.tsbuildinfo +0 -1
  152. package/src/__tests__/create.test.ts +0 -349
  153. package/src/__tests__/guide.test.ts +0 -91
  154. package/src/__tests__/load-app.test.ts +0 -15
  155. package/src/__tests__/survey.test.ts +0 -161
  156. package/src/__tests__/warden.test.ts +0 -74
  157. package/tsconfig.json +0 -9
@@ -0,0 +1,754 @@
1
+ /* oxlint-disable max-statements -- release preflight CLI with explicit reporting */
2
+ import { readdir } from 'node:fs/promises';
3
+ import { join, relative, resolve } from 'node:path';
4
+
5
+ import { compareSemver } from './semver.js';
6
+
7
+ const REPO_ROOT = resolve(process.cwd());
8
+ const SUMMARY_DIST_TAGS = ['latest', 'beta'] as const;
9
+ /** Bound concurrent npm probes so release checks stay responsive on large workspaces. */
10
+ const PROBE_CONCURRENCY = 8;
11
+
12
+ /** Phase of a registry check: pre-publish readiness vs post-publish verification. */
13
+ export type RegistryCheckPhase = 'published' | 'ready';
14
+
15
+ export interface RegistryPreflightOptions {
16
+ readonly requirePublished: boolean;
17
+ readonly tag: string | undefined;
18
+ }
19
+
20
+ interface PackageJson {
21
+ readonly name?: string;
22
+ readonly private?: boolean;
23
+ readonly version?: string;
24
+ }
25
+
26
+ export interface RegistryWorkspace {
27
+ readonly name: string;
28
+ readonly path: string;
29
+ readonly version: string;
30
+ }
31
+
32
+ interface NpmView {
33
+ readonly name?: string;
34
+ readonly version?: string;
35
+ readonly 'dist-tags'?: Record<string, string>;
36
+ }
37
+
38
+ export type RegistryResult =
39
+ | {
40
+ readonly distTags: Record<string, string>;
41
+ readonly expectedTagVersion: string | undefined;
42
+ readonly name: string;
43
+ readonly status: 'published';
44
+ readonly version: string;
45
+ readonly versionPublished: boolean | undefined;
46
+ readonly workspaceVersion: string;
47
+ }
48
+ | {
49
+ readonly name: string;
50
+ readonly status: 'missing';
51
+ readonly workspaceVersion: string;
52
+ }
53
+ | {
54
+ readonly error: string;
55
+ readonly name: string;
56
+ readonly status: 'inaccessible';
57
+ readonly workspaceVersion: string;
58
+ };
59
+
60
+ /**
61
+ * The single source of truth for what a package's registry state means for a
62
+ * release. Both the release policy engine and the registry preflight derive
63
+ * verdicts from this, so they cannot drift.
64
+ */
65
+ export type PackageRegistryState =
66
+ | { readonly kind: 'complete' }
67
+ | { readonly kind: 'needs-publish' }
68
+ | { readonly kind: 'first-time-package' }
69
+ | {
70
+ readonly kind: 'needs-tag-repair';
71
+ readonly currentTagVersion: string | undefined;
72
+ }
73
+ | { readonly kind: 'tag-points-ahead'; readonly currentTagVersion: string }
74
+ | { readonly kind: 'registry-inaccessible'; readonly error: string };
75
+
76
+ /** Minimal facts the classifier needs, mappable from any registry probe shape. */
77
+ export interface PackageRegistryFacts {
78
+ readonly status: 'inaccessible' | 'missing' | 'published';
79
+ readonly targetVersion: string;
80
+ readonly expectedTagVersion: string | undefined;
81
+ readonly versionPublished: boolean | undefined;
82
+ readonly error?: string | undefined;
83
+ }
84
+
85
+ /**
86
+ * Classify a package's registry state from two orthogonal facts — whether the
87
+ * target version is published, and where the dist-tag points relative to it —
88
+ * plus reachability. Members are mutually exclusive by construction.
89
+ *
90
+ * `versionPublished` is read strictly only in the behind-tag case: a behind tag
91
+ * becomes a `needs-tag-repair` only when the target is known published (`true`);
92
+ * `undefined` or `false` route to `needs-publish`, preserving the conservative
93
+ * release-policy behavior. When the tag already points at the target, an
94
+ * unprobed (`undefined`) state counts as published and yields `complete`, which
95
+ * matches the policy `versionPublished ?? true` default. `undefined` means the
96
+ * exact-version probe was not run, including policy inputs and compatibility
97
+ * callers that supply an injected registry view without a version probe.
98
+ */
99
+ export const classifyPackageRegistryState = (
100
+ facts: PackageRegistryFacts
101
+ ): PackageRegistryState => {
102
+ if (facts.status === 'inaccessible') {
103
+ return {
104
+ error: facts.error ?? 'registry probe failed',
105
+ kind: 'registry-inaccessible',
106
+ };
107
+ }
108
+ if (facts.status === 'missing') {
109
+ return { kind: 'first-time-package' };
110
+ }
111
+
112
+ const { expectedTagVersion, targetVersion, versionPublished } = facts;
113
+ const tagAtTarget = expectedTagVersion === targetVersion;
114
+ const tagAhead =
115
+ expectedTagVersion !== undefined &&
116
+ !tagAtTarget &&
117
+ compareSemver(expectedTagVersion, targetVersion) > 0;
118
+
119
+ if (tagAhead) {
120
+ return { currentTagVersion: expectedTagVersion, kind: 'tag-points-ahead' };
121
+ }
122
+ if (tagAtTarget) {
123
+ return versionPublished === false
124
+ ? { kind: 'needs-publish' }
125
+ : { kind: 'complete' };
126
+ }
127
+ if (versionPublished === true) {
128
+ return { currentTagVersion: expectedTagVersion, kind: 'needs-tag-repair' };
129
+ }
130
+ return { kind: 'needs-publish' };
131
+ };
132
+
133
+ /** Map a registry probe result into the classifier's fact shape. */
134
+ const factsFromResult = (result: RegistryResult): PackageRegistryFacts => {
135
+ if (result.status === 'published') {
136
+ return {
137
+ expectedTagVersion: result.expectedTagVersion,
138
+ status: 'published',
139
+ targetVersion: result.workspaceVersion,
140
+ versionPublished: result.versionPublished,
141
+ };
142
+ }
143
+ if (result.status === 'inaccessible') {
144
+ return {
145
+ error: result.error,
146
+ expectedTagVersion: undefined,
147
+ status: 'inaccessible',
148
+ targetVersion: result.workspaceVersion,
149
+ versionPublished: false,
150
+ };
151
+ }
152
+ return {
153
+ expectedTagVersion: undefined,
154
+ status: 'missing',
155
+ targetVersion: result.workspaceVersion,
156
+ versionPublished: false,
157
+ };
158
+ };
159
+
160
+ const USAGE = `Usage: bun scripts/check-registry-preflight.ts [options]
161
+
162
+ Read-only npm registry preflight for public @ontrails/* workspaces.
163
+
164
+ Options:
165
+ --tag <tag> Expected npm dist-tag. Defaults to .changeset/pre.json
166
+ tag while in prerelease mode, otherwise "latest".
167
+ --require-published Fail when any workspace package is missing from npm.
168
+ Use after publication to verify every package exists.
169
+ -h, --help Show this help and exit.
170
+
171
+ Exit codes: 0 success, 1 registry posture failure, 2 arg-parse error.`;
172
+
173
+ const parseArgs = (argv: readonly string[]): RegistryPreflightOptions => {
174
+ let requirePublished = false;
175
+ let tag: string | undefined;
176
+
177
+ const needsValue = (flag: string, value: string | undefined): string => {
178
+ if (value === undefined || value.startsWith('--')) {
179
+ console.error(`${flag} requires a value`);
180
+ console.error(USAGE);
181
+ process.exit(2);
182
+ }
183
+ return value;
184
+ };
185
+
186
+ let i = 0;
187
+ while (i < argv.length) {
188
+ const arg = argv[i] as string;
189
+ if (arg === '--require-published') {
190
+ requirePublished = true;
191
+ } else if (arg === '--tag') {
192
+ i += 1;
193
+ tag = needsValue('--tag', argv[i]);
194
+ } else if (arg === '-h' || arg === '--help') {
195
+ console.log(USAGE);
196
+ process.exit(0);
197
+ } else {
198
+ console.error(`Unknown argument: ${arg}`);
199
+ console.error(USAGE);
200
+ process.exit(2);
201
+ }
202
+ i += 1;
203
+ }
204
+
205
+ return { requirePublished, tag };
206
+ };
207
+
208
+ const readJson = async <T>(path: string): Promise<T> => {
209
+ const file = Bun.file(path);
210
+ if (!(await file.exists())) {
211
+ throw new Error(`File not found: ${path}`);
212
+ }
213
+ return (await file.json()) as T;
214
+ };
215
+
216
+ const errorCode = (error: unknown): string | undefined => {
217
+ if (typeof error !== 'object' || error === null || !('code' in error)) {
218
+ return undefined;
219
+ }
220
+ const { code } = error as { readonly code?: unknown };
221
+ return typeof code === 'string' ? code : undefined;
222
+ };
223
+
224
+ const resolveDefaultTag = async (): Promise<string> => {
225
+ const prePath = join(REPO_ROOT, '.changeset', 'pre.json');
226
+ if (!(await Bun.file(prePath).exists())) {
227
+ return 'latest';
228
+ }
229
+ const pre = await readJson<{ mode?: string; tag?: string }>(prePath);
230
+ if (pre.mode !== 'pre') {
231
+ return 'latest';
232
+ }
233
+ if (typeof pre.tag === 'string' && pre.tag.length > 0) {
234
+ return pre.tag;
235
+ }
236
+ throw new Error(`${prePath} is in prerelease mode but has no tag`);
237
+ };
238
+
239
+ const discoverWorkspaceDirs = async (
240
+ repoRoot: string,
241
+ patterns: readonly string[]
242
+ ): Promise<string[]> => {
243
+ const dirs: string[] = [];
244
+ for (const pattern of patterns) {
245
+ if (pattern.endsWith('/*')) {
246
+ const parent = join(repoRoot, pattern.slice(0, -2));
247
+ let names: string[] = [];
248
+ try {
249
+ const entries = await readdir(parent, { withFileTypes: true });
250
+ names = entries.filter((e) => e.isDirectory()).map((e) => e.name);
251
+ } catch (error) {
252
+ if (errorCode(error) === 'ENOENT') {
253
+ continue;
254
+ }
255
+ throw new Error(
256
+ `Unable to read workspace directory ${relative(repoRoot, parent)}: ${error instanceof Error ? error.message : String(error)}`,
257
+ { cause: error }
258
+ );
259
+ }
260
+ for (const name of names) {
261
+ const dir = join(parent, name);
262
+ if (await Bun.file(join(dir, 'package.json')).exists()) {
263
+ dirs.push(dir);
264
+ }
265
+ }
266
+ } else {
267
+ const dir = join(repoRoot, pattern);
268
+ if (await Bun.file(join(dir, 'package.json')).exists()) {
269
+ dirs.push(dir);
270
+ }
271
+ }
272
+ }
273
+ return dirs;
274
+ };
275
+
276
+ export const discoverRegistryWorkspaces = async (
277
+ repoRoot = REPO_ROOT
278
+ ): Promise<RegistryWorkspace[]> => {
279
+ const root = await readJson<{ workspaces?: string[] }>(
280
+ join(repoRoot, 'package.json')
281
+ );
282
+ const dirs = await discoverWorkspaceDirs(repoRoot, root.workspaces ?? []);
283
+ const workspaces: RegistryWorkspace[] = [];
284
+
285
+ for (const dir of dirs) {
286
+ const pkg = await readJson<PackageJson>(join(dir, 'package.json'));
287
+ if (
288
+ pkg.private === true ||
289
+ typeof pkg.name !== 'string' ||
290
+ !pkg.name.startsWith('@ontrails/') ||
291
+ typeof pkg.version !== 'string'
292
+ ) {
293
+ continue;
294
+ }
295
+ workspaces.push({
296
+ name: pkg.name,
297
+ path: relative(repoRoot, dir),
298
+ version: pkg.version,
299
+ });
300
+ }
301
+
302
+ return workspaces.toSorted((a, b) => a.name.localeCompare(b.name));
303
+ };
304
+
305
+ export type RegistryView = (name: string) => Promise<NpmView | null>;
306
+
307
+ export interface NpmCommandResult {
308
+ readonly exitCode: number;
309
+ readonly stderr: string;
310
+ readonly stdout: string;
311
+ }
312
+
313
+ export type NpmCommandRunner = (
314
+ args: readonly string[]
315
+ ) => Promise<NpmCommandResult>;
316
+
317
+ const readSpawnResult = async (
318
+ proc: Bun.Subprocess<'ignore', 'pipe', 'pipe'>
319
+ ): Promise<NpmCommandResult> => {
320
+ const [stdout, stderr, exitCode] = await Promise.all([
321
+ new Response(proc.stdout).text(),
322
+ new Response(proc.stderr).text(),
323
+ proc.exited,
324
+ ]);
325
+ return { exitCode, stderr, stdout };
326
+ };
327
+
328
+ const runNpmCommand: NpmCommandRunner = async (args) =>
329
+ readSpawnResult(
330
+ Bun.spawn(['npm', ...args], {
331
+ stderr: 'pipe',
332
+ stdin: 'ignore',
333
+ stdout: 'pipe',
334
+ })
335
+ );
336
+
337
+ const isNpmNotFoundOutput = (stdout: string, stderr: string): boolean => {
338
+ const combined = `${stdout}\n${stderr}`;
339
+ return combined.includes('E404') || combined.includes('404 Not Found');
340
+ };
341
+
342
+ const isNpmExactVersionMissingOutput = (
343
+ stdout: string,
344
+ stderr: string
345
+ ): boolean => {
346
+ const combined = `${stdout}\n${stderr}`;
347
+ return (
348
+ combined.includes('ETARGET') ||
349
+ combined.includes('No matching version found')
350
+ );
351
+ };
352
+
353
+ export const parseNpmDistTagListOutput = (
354
+ stdout: string
355
+ ): Record<string, string> => {
356
+ const distTags: Record<string, string> = {};
357
+ for (const line of stdout.split(/\r?\n/)) {
358
+ const trimmed = line.trim();
359
+ if (trimmed.length === 0) {
360
+ continue;
361
+ }
362
+ const separator = trimmed.indexOf(':');
363
+ if (separator < 1) {
364
+ continue;
365
+ }
366
+ const tag = trimmed.slice(0, separator).trim();
367
+ const version = trimmed.slice(separator + 1).trim();
368
+ if (tag.length > 0 && version.length > 0) {
369
+ distTags[tag] = version;
370
+ }
371
+ }
372
+ return distTags;
373
+ };
374
+
375
+ export const parseNpmPackDryRunPublishedVersion = (
376
+ stdout: string,
377
+ name: string,
378
+ version: string
379
+ ): boolean => {
380
+ const parsed = JSON.parse(stdout.trim()) as unknown;
381
+ if (!Array.isArray(parsed)) {
382
+ return false;
383
+ }
384
+ return parsed.some((entry) => {
385
+ if (typeof entry !== 'object' || entry === null) {
386
+ return false;
387
+ }
388
+ const candidate = entry as {
389
+ readonly id?: unknown;
390
+ readonly name?: unknown;
391
+ readonly version?: unknown;
392
+ };
393
+ return (
394
+ candidate.id === `${name}@${version}` ||
395
+ (candidate.name === name && candidate.version === version)
396
+ );
397
+ });
398
+ };
399
+
400
+ const npmDistTagRegistryView = async (
401
+ name: string,
402
+ runNpm: NpmCommandRunner
403
+ ): Promise<NpmView | null> => {
404
+ const { exitCode, stderr, stdout } = await runNpm(['dist-tag', 'ls', name]);
405
+ if (exitCode !== 0) {
406
+ if (isNpmNotFoundOutput(stdout, stderr)) {
407
+ return null;
408
+ }
409
+ throw new Error(stderr.trim() || `npm dist-tag ls failed for ${name}`);
410
+ }
411
+
412
+ const distTags = parseNpmDistTagListOutput(stdout);
413
+ const version =
414
+ distTags['latest'] ?? distTags['beta'] ?? Object.values(distTags)[0];
415
+ if (version === undefined) {
416
+ return null;
417
+ }
418
+ return { 'dist-tags': distTags, name, version };
419
+ };
420
+
421
+ export const createNpmRegistryView =
422
+ (runNpm: NpmCommandRunner = runNpmCommand): RegistryView =>
423
+ async (name) => {
424
+ const { exitCode, stderr, stdout } = await runNpm([
425
+ 'view',
426
+ name,
427
+ 'name',
428
+ 'version',
429
+ 'dist-tags',
430
+ '--json',
431
+ ]);
432
+
433
+ if (exitCode === 0) {
434
+ return JSON.parse(stdout) as NpmView;
435
+ }
436
+ if (isNpmNotFoundOutput(stdout, stderr)) {
437
+ return npmDistTagRegistryView(name, runNpm);
438
+ }
439
+ throw new Error(stderr.trim() || `npm view failed for ${name}`);
440
+ };
441
+
442
+ export const npmRegistryView: RegistryView = createNpmRegistryView();
443
+
444
+ /** Probe whether an exact `name@version` is published. The missing fact that
445
+ * a tag/version summary alone cannot answer. */
446
+ export type RegistryVersionView = (
447
+ name: string,
448
+ version: string
449
+ ) => Promise<boolean | undefined>;
450
+
451
+ const UNKNOWN_REGISTRY_VERSION_STATE: { readonly published?: boolean } = {};
452
+ const unknownRegistryVersionView: RegistryVersionView = async () =>
453
+ UNKNOWN_REGISTRY_VERSION_STATE.published;
454
+
455
+ export const createNpmRegistryVersionView =
456
+ (runNpm: NpmCommandRunner = runNpmCommand): RegistryVersionView =>
457
+ async (name, version) => {
458
+ const { exitCode, stderr, stdout } = await runNpm([
459
+ 'view',
460
+ `${name}@${version}`,
461
+ 'version',
462
+ '--json',
463
+ ]);
464
+
465
+ if (exitCode === 0) {
466
+ return JSON.parse(stdout.trim()) === version;
467
+ }
468
+ if (isNpmExactVersionMissingOutput(stdout, stderr)) {
469
+ return false;
470
+ }
471
+ if (!isNpmNotFoundOutput(stdout, stderr)) {
472
+ throw new Error(
473
+ stderr.trim() || `npm view failed for ${name}@${version}`
474
+ );
475
+ }
476
+
477
+ const packResult = await runNpm([
478
+ 'pack',
479
+ `${name}@${version}`,
480
+ '--dry-run',
481
+ '--json',
482
+ ]);
483
+ if (packResult.exitCode === 0) {
484
+ return parseNpmPackDryRunPublishedVersion(
485
+ packResult.stdout,
486
+ name,
487
+ version
488
+ );
489
+ }
490
+ if (isNpmExactVersionMissingOutput(packResult.stdout, packResult.stderr)) {
491
+ return false;
492
+ }
493
+ if (isNpmNotFoundOutput(packResult.stdout, packResult.stderr)) {
494
+ return false;
495
+ }
496
+ throw new Error(
497
+ packResult.stderr.trim() || `npm pack failed for ${name}@${version}`
498
+ );
499
+ };
500
+
501
+ export const npmRegistryVersionView: RegistryVersionView =
502
+ createNpmRegistryVersionView();
503
+
504
+ /** Run async tasks with a bounded number in flight, preserving input order. */
505
+ const mapBounded = async <T, R>(
506
+ items: readonly T[],
507
+ limit: number,
508
+ task: (item: T) => Promise<R>
509
+ ): Promise<R[]> => {
510
+ const results: R[] = [];
511
+ let cursor = 0;
512
+ const worker = async (): Promise<void> => {
513
+ while (cursor < items.length) {
514
+ const index = cursor;
515
+ cursor += 1;
516
+ results[index] = await task(items[index] as T);
517
+ }
518
+ };
519
+ await Promise.all(
520
+ Array.from({ length: Math.min(limit, items.length) }, worker)
521
+ );
522
+ return results;
523
+ };
524
+
525
+ const checkWorkspaceRegistryPosture = async (
526
+ workspace: RegistryWorkspace,
527
+ view: RegistryView,
528
+ versionView: RegistryVersionView,
529
+ expectedTag: string
530
+ ): Promise<RegistryResult> => {
531
+ try {
532
+ const registry = await view(workspace.name);
533
+ if (!registry) {
534
+ return {
535
+ name: workspace.name,
536
+ status: 'missing',
537
+ workspaceVersion: workspace.version,
538
+ };
539
+ }
540
+ const distTags = registry['dist-tags'] ?? {};
541
+ return {
542
+ distTags,
543
+ expectedTagVersion: distTags[expectedTag],
544
+ name: workspace.name,
545
+ status: 'published',
546
+ version: registry.version ?? '(unknown)',
547
+ versionPublished: await versionView(workspace.name, workspace.version),
548
+ workspaceVersion: workspace.version,
549
+ };
550
+ } catch (error) {
551
+ return {
552
+ error: error instanceof Error ? error.message : String(error),
553
+ name: workspace.name,
554
+ status: 'inaccessible',
555
+ workspaceVersion: workspace.version,
556
+ };
557
+ }
558
+ };
559
+
560
+ type CheckRegistryPostureArgs =
561
+ | readonly [versionView: RegistryVersionView, expectedTag: string]
562
+ | readonly [expectedTag: string];
563
+
564
+ const normalizeCheckRegistryPostureArgs = (
565
+ args: CheckRegistryPostureArgs
566
+ ): {
567
+ readonly expectedTag: string;
568
+ readonly versionView: RegistryVersionView;
569
+ } => {
570
+ if (args.length === 1) {
571
+ return { expectedTag: args[0], versionView: unknownRegistryVersionView };
572
+ }
573
+ return { expectedTag: args[1], versionView: args[0] };
574
+ };
575
+
576
+ export function checkRegistryPosture(
577
+ workspaces: readonly RegistryWorkspace[],
578
+ view: RegistryView,
579
+ expectedTag: string
580
+ ): Promise<RegistryResult[]>;
581
+ export function checkRegistryPosture(
582
+ workspaces: readonly RegistryWorkspace[],
583
+ view: RegistryView,
584
+ versionView: RegistryVersionView,
585
+ expectedTag: string
586
+ ): Promise<RegistryResult[]>;
587
+ export async function checkRegistryPosture(
588
+ workspaces: readonly RegistryWorkspace[],
589
+ view: RegistryView,
590
+ ...args: CheckRegistryPostureArgs
591
+ ): Promise<RegistryResult[]> {
592
+ const { expectedTag, versionView } = normalizeCheckRegistryPostureArgs(args);
593
+ return mapBounded(workspaces, PROBE_CONCURRENCY, (workspace) =>
594
+ checkWorkspaceRegistryPosture(workspace, view, versionView, expectedTag)
595
+ );
596
+ }
597
+
598
+ /**
599
+ * Phase-aware registry errors, derived from the shared classifier.
600
+ *
601
+ * `ready` (pre-publish): only a tag pointing *ahead* of the target or an
602
+ * inaccessible registry is an error. A behind tag or an unpublished target is
603
+ * the expected state before publish runs, not a failure.
604
+ *
605
+ * `published` (post-publish): every package must be `complete`.
606
+ */
607
+ const normalizeRegistryCheckPhase = (
608
+ phaseOrRequirePublished: boolean | RegistryCheckPhase
609
+ ): RegistryCheckPhase => {
610
+ if (typeof phaseOrRequirePublished !== 'boolean') {
611
+ return phaseOrRequirePublished;
612
+ }
613
+ return phaseOrRequirePublished ? 'published' : 'ready';
614
+ };
615
+
616
+ export const registryPostureErrors = (
617
+ results: readonly RegistryResult[],
618
+ expectedTag: string,
619
+ phaseOrRequirePublished: boolean | RegistryCheckPhase
620
+ ): string[] => {
621
+ const phase = normalizeRegistryCheckPhase(phaseOrRequirePublished);
622
+ const errors: string[] = [];
623
+ for (const result of results) {
624
+ const state = classifyPackageRegistryState(factsFromResult(result));
625
+ if (state.kind === 'registry-inaccessible') {
626
+ errors.push(`${result.name}: registry probe failed: ${state.error}`);
627
+ continue;
628
+ }
629
+ if (state.kind === 'tag-points-ahead') {
630
+ errors.push(
631
+ `${result.name}: dist-tag ${expectedTag} points to ${state.currentTagVersion}, which is newer than target ${result.workspaceVersion}`
632
+ );
633
+ continue;
634
+ }
635
+ if (phase === 'ready' || state.kind === 'complete') {
636
+ continue;
637
+ }
638
+ if (state.kind === 'first-time-package') {
639
+ errors.push(`${result.name}: package is missing from the registry`);
640
+ } else if (state.kind === 'needs-publish') {
641
+ const targetState =
642
+ result.status === 'published' && result.versionPublished === undefined
643
+ ? 'publish state was not probed'
644
+ : 'is not published';
645
+ errors.push(
646
+ `${result.name}: target version ${result.workspaceVersion} ${targetState}`
647
+ );
648
+ } else if (state.kind === 'needs-tag-repair') {
649
+ errors.push(
650
+ `${result.name}: needs dist-tag update — ${expectedTag} points to ${state.currentTagVersion ?? '(missing)'}, target ${result.workspaceVersion}`
651
+ );
652
+ }
653
+ }
654
+ return errors;
655
+ };
656
+
657
+ export const formatDistTagSummary = (
658
+ distTags: Readonly<Record<string, string>>
659
+ ): string =>
660
+ SUMMARY_DIST_TAGS.map((tag) => `${tag}=${distTags[tag] ?? 'missing'}`).join(
661
+ ', '
662
+ );
663
+
664
+ const formatTargetVersionStatus = (
665
+ versionPublished: boolean | undefined
666
+ ): string => {
667
+ if (versionPublished === true) {
668
+ return 'target version published';
669
+ }
670
+ if (versionPublished === false) {
671
+ return 'target version not published yet';
672
+ }
673
+ return 'target version publish state unknown';
674
+ };
675
+
676
+ const printResults = (
677
+ results: readonly RegistryResult[],
678
+ expectedTag: string
679
+ ): void => {
680
+ console.log(`Registry preflight for dist-tag "${expectedTag}"`);
681
+ for (const result of results) {
682
+ if (result.status === 'published') {
683
+ const targetStatus = formatTargetVersionStatus(result.versionPublished);
684
+ console.log(
685
+ `✓ ${result.name}@${result.workspaceVersion}: package exists, ${targetStatus} (registry version ${result.version}, expected ${expectedTag}=${result.expectedTagVersion ?? 'missing'}, tags ${formatDistTagSummary(result.distTags)})`
686
+ );
687
+ } else if (result.status === 'missing') {
688
+ console.log(
689
+ `• ${result.name}@${result.workspaceVersion}: first-time package candidate (not found on registry)`
690
+ );
691
+ } else {
692
+ console.log(`✗ ${result.name}: registry probe failed: ${result.error}`);
693
+ }
694
+ }
695
+ };
696
+
697
+ const normalizeRegistryPreflightViews = (
698
+ view: RegistryView | undefined,
699
+ versionView: RegistryVersionView | undefined
700
+ ): {
701
+ readonly versionView: RegistryVersionView;
702
+ readonly view: RegistryView;
703
+ } => {
704
+ if (view === undefined) {
705
+ return { versionView: npmRegistryVersionView, view: npmRegistryView };
706
+ }
707
+ return { versionView: versionView ?? unknownRegistryVersionView, view };
708
+ };
709
+
710
+ export const runRegistryPreflight = async (
711
+ options: RegistryPreflightOptions,
712
+ view?: RegistryView,
713
+ versionView?: RegistryVersionView
714
+ ): Promise<number> => {
715
+ const registryViews = normalizeRegistryPreflightViews(view, versionView);
716
+ const expectedTag = options.tag ?? (await resolveDefaultTag());
717
+ const workspaces = await discoverRegistryWorkspaces();
718
+ const results = await checkRegistryPosture(
719
+ workspaces,
720
+ registryViews.view,
721
+ registryViews.versionView,
722
+ expectedTag
723
+ );
724
+ printResults(results, expectedTag);
725
+ const errors = registryPostureErrors(
726
+ results,
727
+ expectedTag,
728
+ options.requirePublished ? 'published' : 'ready'
729
+ );
730
+ if (errors.length > 0) {
731
+ console.error('\nRegistry preflight failed:');
732
+ for (const error of errors) {
733
+ console.error(`- ${error}`);
734
+ }
735
+ return 1;
736
+ }
737
+ console.log('\nRegistry preflight passed.');
738
+ return 0;
739
+ };
740
+
741
+ export const runRegistryPreflightCli = async (
742
+ args: readonly string[] = process.argv.slice(2)
743
+ ): Promise<number> => {
744
+ try {
745
+ return await runRegistryPreflight(parseArgs(args));
746
+ } catch (error) {
747
+ console.error(error instanceof Error ? error.message : String(error));
748
+ return 1;
749
+ }
750
+ };
751
+
752
+ if (import.meta.main) {
753
+ process.exit(await runRegistryPreflightCli(process.argv.slice(2)));
754
+ }