@ontrails/trails 1.0.0-beta.4 → 1.0.0-beta.41

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 (165) hide show
  1. package/CHANGELOG.md +1104 -4
  2. package/README.md +27 -0
  3. package/package.json +31 -7
  4. package/src/app.ts +147 -3
  5. package/src/cli.ts +345 -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 +42 -0
  11. package/src/mcp-options.ts +91 -0
  12. package/src/mcp.ts +8 -0
  13. package/src/project-writes.ts +377 -0
  14. package/src/regrade/config.ts +152 -0
  15. package/src/regrade/history.ts +430 -0
  16. package/src/regrade/live-api-preserve.ts +8 -0
  17. package/src/regrade/plan-artifact.ts +285 -0
  18. package/src/release/bindings.ts +39 -0
  19. package/src/release/check.ts +844 -0
  20. package/src/release/config.ts +63 -0
  21. package/src/release/contract-facts.ts +425 -0
  22. package/src/release/index.ts +142 -0
  23. package/src/release/lock-roundtrip-smoke.ts +230 -0
  24. package/src/release/native-bun-publish.ts +651 -0
  25. package/src/release/native-bun-registry.ts +765 -0
  26. package/src/release/notes-cli.ts +171 -0
  27. package/src/release/notes.ts +390 -0
  28. package/src/release/pack-coherence.ts +455 -0
  29. package/src/release/packed-artifacts-smoke.ts +236 -0
  30. package/src/release/policy.ts +1687 -0
  31. package/src/release/semver.ts +104 -0
  32. package/src/release/smoke.ts +56 -0
  33. package/src/release/wayfinder-dogfood-smoke.ts +760 -0
  34. package/src/retired-topo-command.ts +36 -0
  35. package/src/run-adapter-check.ts +76 -0
  36. package/src/run-collision.ts +126 -0
  37. package/src/run-completions-install.ts +179 -0
  38. package/src/run-example.ts +149 -0
  39. package/src/run-examples.ts +148 -0
  40. package/src/run-quiet.ts +75 -0
  41. package/src/run-release-check.ts +74 -0
  42. package/src/run-schema.ts +74 -0
  43. package/src/run-trace.ts +273 -0
  44. package/src/run-warden.ts +39 -0
  45. package/src/run-watch.ts +432 -0
  46. package/src/run-wayfind-outline.ts +170 -0
  47. package/src/scaffold-version-sync.ts +183 -0
  48. package/src/scaffold-versions.generated.ts +12 -0
  49. package/src/trails/adapter-check.ts +244 -0
  50. package/src/trails/add-surface.ts +99 -45
  51. package/src/trails/add-trail.ts +84 -37
  52. package/src/trails/add-verify.ts +100 -30
  53. package/src/trails/compile.ts +58 -0
  54. package/src/trails/completions-complete.ts +165 -0
  55. package/src/trails/completions.ts +47 -0
  56. package/src/trails/create-adapter.ts +785 -0
  57. package/src/trails/create-scaffold.ts +401 -106
  58. package/src/trails/create-versions.ts +62 -0
  59. package/src/trails/create.ts +186 -72
  60. package/src/trails/deprecate.ts +59 -0
  61. package/src/trails/dev-clean.ts +82 -0
  62. package/src/trails/dev-reset.ts +50 -0
  63. package/src/trails/dev-stats.ts +72 -0
  64. package/src/trails/dev-support.ts +360 -0
  65. package/src/trails/doctor.ts +77 -0
  66. package/src/trails/draft-promote.ts +949 -0
  67. package/src/trails/guide.ts +70 -68
  68. package/src/trails/load-app.ts +1123 -15
  69. package/src/trails/operator-context.ts +66 -0
  70. package/src/trails/project.ts +17 -3
  71. package/src/trails/regrade.ts +3091 -0
  72. package/src/trails/release-check.ts +105 -0
  73. package/src/trails/release-smoke.ts +49 -0
  74. package/src/trails/revise.ts +53 -0
  75. package/src/trails/root-dir.ts +21 -0
  76. package/src/trails/run-example.ts +475 -0
  77. package/src/trails/run-examples.ts +129 -0
  78. package/src/trails/run.ts +434 -0
  79. package/src/trails/scaffold-json.ts +58 -0
  80. package/src/trails/survey.ts +877 -214
  81. package/src/trails/topo-activation.ts +14 -0
  82. package/src/trails/topo-constants.ts +2 -0
  83. package/src/trails/topo-history.ts +47 -0
  84. package/src/trails/topo-output-schemas.ts +259 -0
  85. package/src/trails/topo-pin.ts +38 -0
  86. package/src/trails/topo-read-support.ts +368 -0
  87. package/src/trails/topo-reports.ts +809 -0
  88. package/src/trails/topo-store-support.ts +325 -0
  89. package/src/trails/topo-support.ts +220 -0
  90. package/src/trails/topo-unpin.ts +61 -0
  91. package/src/trails/topo.ts +92 -0
  92. package/src/trails/validate.ts +27 -0
  93. package/src/trails/version-lifecycle-support.ts +936 -0
  94. package/src/trails/warden-guide.ts +134 -0
  95. package/src/trails/warden.ts +198 -58
  96. package/src/trails/wayfind-outline.ts +876 -0
  97. package/src/trails/wayfind.ts +1053 -0
  98. package/src/versions.ts +31 -0
  99. package/.turbo/turbo-build.log +0 -1
  100. package/.turbo/turbo-lint.log +0 -3
  101. package/.turbo/turbo-typecheck.log +0 -1
  102. package/__tests__/examples.test.ts +0 -6
  103. package/dist/bin/trails.d.ts +0 -3
  104. package/dist/bin/trails.d.ts.map +0 -1
  105. package/dist/bin/trails.js +0 -4
  106. package/dist/bin/trails.js.map +0 -1
  107. package/dist/src/app.d.ts +0 -2
  108. package/dist/src/app.d.ts.map +0 -1
  109. package/dist/src/app.js +0 -11
  110. package/dist/src/app.js.map +0 -1
  111. package/dist/src/clack.d.ts +0 -9
  112. package/dist/src/clack.d.ts.map +0 -1
  113. package/dist/src/clack.js +0 -84
  114. package/dist/src/clack.js.map +0 -1
  115. package/dist/src/cli.d.ts +0 -2
  116. package/dist/src/cli.d.ts.map +0 -1
  117. package/dist/src/cli.js +0 -13
  118. package/dist/src/cli.js.map +0 -1
  119. package/dist/src/trails/add-surface.d.ts +0 -13
  120. package/dist/src/trails/add-surface.d.ts.map +0 -1
  121. package/dist/src/trails/add-surface.js +0 -88
  122. package/dist/src/trails/add-surface.js.map +0 -1
  123. package/dist/src/trails/add-trail.d.ts +0 -10
  124. package/dist/src/trails/add-trail.d.ts.map +0 -1
  125. package/dist/src/trails/add-trail.js +0 -77
  126. package/dist/src/trails/add-trail.js.map +0 -1
  127. package/dist/src/trails/add-verify.d.ts +0 -10
  128. package/dist/src/trails/add-verify.d.ts.map +0 -1
  129. package/dist/src/trails/add-verify.js +0 -67
  130. package/dist/src/trails/add-verify.js.map +0 -1
  131. package/dist/src/trails/create-scaffold.d.ts +0 -15
  132. package/dist/src/trails/create-scaffold.d.ts.map +0 -1
  133. package/dist/src/trails/create-scaffold.js +0 -288
  134. package/dist/src/trails/create-scaffold.js.map +0 -1
  135. package/dist/src/trails/create.d.ts +0 -22
  136. package/dist/src/trails/create.d.ts.map +0 -1
  137. package/dist/src/trails/create.js +0 -121
  138. package/dist/src/trails/create.js.map +0 -1
  139. package/dist/src/trails/guide.d.ts +0 -11
  140. package/dist/src/trails/guide.d.ts.map +0 -1
  141. package/dist/src/trails/guide.js +0 -80
  142. package/dist/src/trails/guide.js.map +0 -1
  143. package/dist/src/trails/load-app.d.ts +0 -4
  144. package/dist/src/trails/load-app.d.ts.map +0 -1
  145. package/dist/src/trails/load-app.js +0 -24
  146. package/dist/src/trails/load-app.js.map +0 -1
  147. package/dist/src/trails/project.d.ts +0 -8
  148. package/dist/src/trails/project.d.ts.map +0 -1
  149. package/dist/src/trails/project.js +0 -43
  150. package/dist/src/trails/project.js.map +0 -1
  151. package/dist/src/trails/survey.d.ts +0 -31
  152. package/dist/src/trails/survey.d.ts.map +0 -1
  153. package/dist/src/trails/survey.js +0 -221
  154. package/dist/src/trails/survey.js.map +0 -1
  155. package/dist/src/trails/warden.d.ts +0 -19
  156. package/dist/src/trails/warden.d.ts.map +0 -1
  157. package/dist/src/trails/warden.js +0 -88
  158. package/dist/src/trails/warden.js.map +0 -1
  159. package/dist/tsconfig.tsbuildinfo +0 -1
  160. package/src/__tests__/create.test.ts +0 -349
  161. package/src/__tests__/guide.test.ts +0 -91
  162. package/src/__tests__/load-app.test.ts +0 -15
  163. package/src/__tests__/survey.test.ts +0 -159
  164. package/src/__tests__/warden.test.ts +0 -74
  165. package/tsconfig.json +0 -9
@@ -0,0 +1,765 @@
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 isNpmUnauthorizedOutput = (stdout: string, stderr: string): boolean => {
343
+ const combined = `${stdout}\n${stderr}`;
344
+ return combined.includes('E401') || combined.includes('401 Unauthorized');
345
+ };
346
+
347
+ const isNpmExactVersionMissingOutput = (
348
+ stdout: string,
349
+ stderr: string
350
+ ): boolean => {
351
+ const combined = `${stdout}\n${stderr}`;
352
+ return (
353
+ combined.includes('ETARGET') ||
354
+ combined.includes('No matching version found')
355
+ );
356
+ };
357
+
358
+ export const parseNpmDistTagListOutput = (
359
+ stdout: string
360
+ ): Record<string, string> => {
361
+ const distTags: Record<string, string> = {};
362
+ for (const line of stdout.split(/\r?\n/)) {
363
+ const trimmed = line.trim();
364
+ if (trimmed.length === 0) {
365
+ continue;
366
+ }
367
+ const separator = trimmed.indexOf(':');
368
+ if (separator < 1) {
369
+ continue;
370
+ }
371
+ const tag = trimmed.slice(0, separator).trim();
372
+ const version = trimmed.slice(separator + 1).trim();
373
+ if (tag.length > 0 && version.length > 0) {
374
+ distTags[tag] = version;
375
+ }
376
+ }
377
+ return distTags;
378
+ };
379
+
380
+ export const parseNpmPackDryRunPublishedVersion = (
381
+ stdout: string,
382
+ name: string,
383
+ version: string
384
+ ): boolean => {
385
+ const parsed = JSON.parse(stdout.trim()) as unknown;
386
+ if (!Array.isArray(parsed)) {
387
+ return false;
388
+ }
389
+ return parsed.some((entry) => {
390
+ if (typeof entry !== 'object' || entry === null) {
391
+ return false;
392
+ }
393
+ const candidate = entry as {
394
+ readonly id?: unknown;
395
+ readonly name?: unknown;
396
+ readonly version?: unknown;
397
+ };
398
+ return (
399
+ candidate.id === `${name}@${version}` ||
400
+ (candidate.name === name && candidate.version === version)
401
+ );
402
+ });
403
+ };
404
+
405
+ const npmDistTagRegistryView = async (
406
+ name: string,
407
+ runNpm: NpmCommandRunner
408
+ ): Promise<NpmView | null> => {
409
+ const { exitCode, stderr, stdout } = await runNpm(['dist-tag', 'ls', name]);
410
+ if (exitCode !== 0) {
411
+ // npm returns E401 for the dist-tag endpoint of an unpublished scoped
412
+ // package, even though the preceding package view returned E404. At this
413
+ // fallback boundary both responses mean the package does not exist yet.
414
+ if (
415
+ isNpmNotFoundOutput(stdout, stderr) ||
416
+ isNpmUnauthorizedOutput(stdout, stderr)
417
+ ) {
418
+ return null;
419
+ }
420
+ throw new Error(stderr.trim() || `npm dist-tag ls failed for ${name}`);
421
+ }
422
+
423
+ const distTags = parseNpmDistTagListOutput(stdout);
424
+ const version =
425
+ distTags['latest'] ?? distTags['beta'] ?? Object.values(distTags)[0];
426
+ if (version === undefined) {
427
+ return null;
428
+ }
429
+ return { 'dist-tags': distTags, name, version };
430
+ };
431
+
432
+ export const createNpmRegistryView =
433
+ (runNpm: NpmCommandRunner = runNpmCommand): RegistryView =>
434
+ async (name) => {
435
+ const { exitCode, stderr, stdout } = await runNpm([
436
+ 'view',
437
+ name,
438
+ 'name',
439
+ 'version',
440
+ 'dist-tags',
441
+ '--json',
442
+ ]);
443
+
444
+ if (exitCode === 0) {
445
+ return JSON.parse(stdout) as NpmView;
446
+ }
447
+ if (isNpmNotFoundOutput(stdout, stderr)) {
448
+ return npmDistTagRegistryView(name, runNpm);
449
+ }
450
+ throw new Error(stderr.trim() || `npm view failed for ${name}`);
451
+ };
452
+
453
+ export const npmRegistryView: RegistryView = createNpmRegistryView();
454
+
455
+ /** Probe whether an exact `name@version` is published. The missing fact that
456
+ * a tag/version summary alone cannot answer. */
457
+ export type RegistryVersionView = (
458
+ name: string,
459
+ version: string
460
+ ) => Promise<boolean | undefined>;
461
+
462
+ const UNKNOWN_REGISTRY_VERSION_STATE: { readonly published?: boolean } = {};
463
+ const unknownRegistryVersionView: RegistryVersionView = async () =>
464
+ UNKNOWN_REGISTRY_VERSION_STATE.published;
465
+
466
+ export const createNpmRegistryVersionView =
467
+ (runNpm: NpmCommandRunner = runNpmCommand): RegistryVersionView =>
468
+ async (name, version) => {
469
+ const { exitCode, stderr, stdout } = await runNpm([
470
+ 'view',
471
+ `${name}@${version}`,
472
+ 'version',
473
+ '--json',
474
+ ]);
475
+
476
+ if (exitCode === 0) {
477
+ return JSON.parse(stdout.trim()) === version;
478
+ }
479
+ if (isNpmExactVersionMissingOutput(stdout, stderr)) {
480
+ return false;
481
+ }
482
+ if (!isNpmNotFoundOutput(stdout, stderr)) {
483
+ throw new Error(
484
+ stderr.trim() || `npm view failed for ${name}@${version}`
485
+ );
486
+ }
487
+
488
+ const packResult = await runNpm([
489
+ 'pack',
490
+ `${name}@${version}`,
491
+ '--dry-run',
492
+ '--json',
493
+ ]);
494
+ if (packResult.exitCode === 0) {
495
+ return parseNpmPackDryRunPublishedVersion(
496
+ packResult.stdout,
497
+ name,
498
+ version
499
+ );
500
+ }
501
+ if (isNpmExactVersionMissingOutput(packResult.stdout, packResult.stderr)) {
502
+ return false;
503
+ }
504
+ if (isNpmNotFoundOutput(packResult.stdout, packResult.stderr)) {
505
+ return false;
506
+ }
507
+ throw new Error(
508
+ packResult.stderr.trim() || `npm pack failed for ${name}@${version}`
509
+ );
510
+ };
511
+
512
+ export const npmRegistryVersionView: RegistryVersionView =
513
+ createNpmRegistryVersionView();
514
+
515
+ /** Run async tasks with a bounded number in flight, preserving input order. */
516
+ const mapBounded = async <T, R>(
517
+ items: readonly T[],
518
+ limit: number,
519
+ task: (item: T) => Promise<R>
520
+ ): Promise<R[]> => {
521
+ const results: R[] = [];
522
+ let cursor = 0;
523
+ const worker = async (): Promise<void> => {
524
+ while (cursor < items.length) {
525
+ const index = cursor;
526
+ cursor += 1;
527
+ results[index] = await task(items[index] as T);
528
+ }
529
+ };
530
+ await Promise.all(
531
+ Array.from({ length: Math.min(limit, items.length) }, worker)
532
+ );
533
+ return results;
534
+ };
535
+
536
+ const checkWorkspaceRegistryPosture = async (
537
+ workspace: RegistryWorkspace,
538
+ view: RegistryView,
539
+ versionView: RegistryVersionView,
540
+ expectedTag: string
541
+ ): Promise<RegistryResult> => {
542
+ try {
543
+ const registry = await view(workspace.name);
544
+ if (!registry) {
545
+ return {
546
+ name: workspace.name,
547
+ status: 'missing',
548
+ workspaceVersion: workspace.version,
549
+ };
550
+ }
551
+ const distTags = registry['dist-tags'] ?? {};
552
+ return {
553
+ distTags,
554
+ expectedTagVersion: distTags[expectedTag],
555
+ name: workspace.name,
556
+ status: 'published',
557
+ version: registry.version ?? '(unknown)',
558
+ versionPublished: await versionView(workspace.name, workspace.version),
559
+ workspaceVersion: workspace.version,
560
+ };
561
+ } catch (error) {
562
+ return {
563
+ error: error instanceof Error ? error.message : String(error),
564
+ name: workspace.name,
565
+ status: 'inaccessible',
566
+ workspaceVersion: workspace.version,
567
+ };
568
+ }
569
+ };
570
+
571
+ type CheckRegistryPostureArgs =
572
+ | readonly [versionView: RegistryVersionView, expectedTag: string]
573
+ | readonly [expectedTag: string];
574
+
575
+ const normalizeCheckRegistryPostureArgs = (
576
+ args: CheckRegistryPostureArgs
577
+ ): {
578
+ readonly expectedTag: string;
579
+ readonly versionView: RegistryVersionView;
580
+ } => {
581
+ if (args.length === 1) {
582
+ return { expectedTag: args[0], versionView: unknownRegistryVersionView };
583
+ }
584
+ return { expectedTag: args[1], versionView: args[0] };
585
+ };
586
+
587
+ export function checkRegistryPosture(
588
+ workspaces: readonly RegistryWorkspace[],
589
+ view: RegistryView,
590
+ expectedTag: string
591
+ ): Promise<RegistryResult[]>;
592
+ export function checkRegistryPosture(
593
+ workspaces: readonly RegistryWorkspace[],
594
+ view: RegistryView,
595
+ versionView: RegistryVersionView,
596
+ expectedTag: string
597
+ ): Promise<RegistryResult[]>;
598
+ export async function checkRegistryPosture(
599
+ workspaces: readonly RegistryWorkspace[],
600
+ view: RegistryView,
601
+ ...args: CheckRegistryPostureArgs
602
+ ): Promise<RegistryResult[]> {
603
+ const { expectedTag, versionView } = normalizeCheckRegistryPostureArgs(args);
604
+ return mapBounded(workspaces, PROBE_CONCURRENCY, (workspace) =>
605
+ checkWorkspaceRegistryPosture(workspace, view, versionView, expectedTag)
606
+ );
607
+ }
608
+
609
+ /**
610
+ * Phase-aware registry errors, derived from the shared classifier.
611
+ *
612
+ * `ready` (pre-publish): only a tag pointing *ahead* of the target or an
613
+ * inaccessible registry is an error. A behind tag or an unpublished target is
614
+ * the expected state before publish runs, not a failure.
615
+ *
616
+ * `published` (post-publish): every package must be `complete`.
617
+ */
618
+ const normalizeRegistryCheckPhase = (
619
+ phaseOrRequirePublished: boolean | RegistryCheckPhase
620
+ ): RegistryCheckPhase => {
621
+ if (typeof phaseOrRequirePublished !== 'boolean') {
622
+ return phaseOrRequirePublished;
623
+ }
624
+ return phaseOrRequirePublished ? 'published' : 'ready';
625
+ };
626
+
627
+ export const registryPostureErrors = (
628
+ results: readonly RegistryResult[],
629
+ expectedTag: string,
630
+ phaseOrRequirePublished: boolean | RegistryCheckPhase
631
+ ): string[] => {
632
+ const phase = normalizeRegistryCheckPhase(phaseOrRequirePublished);
633
+ const errors: string[] = [];
634
+ for (const result of results) {
635
+ const state = classifyPackageRegistryState(factsFromResult(result));
636
+ if (state.kind === 'registry-inaccessible') {
637
+ errors.push(`${result.name}: registry probe failed: ${state.error}`);
638
+ continue;
639
+ }
640
+ if (state.kind === 'tag-points-ahead') {
641
+ errors.push(
642
+ `${result.name}: dist-tag ${expectedTag} points to ${state.currentTagVersion}, which is newer than target ${result.workspaceVersion}`
643
+ );
644
+ continue;
645
+ }
646
+ if (phase === 'ready' || state.kind === 'complete') {
647
+ continue;
648
+ }
649
+ if (state.kind === 'first-time-package') {
650
+ errors.push(`${result.name}: package is missing from the registry`);
651
+ } else if (state.kind === 'needs-publish') {
652
+ const targetState =
653
+ result.status === 'published' && result.versionPublished === undefined
654
+ ? 'publish state was not probed'
655
+ : 'is not published';
656
+ errors.push(
657
+ `${result.name}: target version ${result.workspaceVersion} ${targetState}`
658
+ );
659
+ } else if (state.kind === 'needs-tag-repair') {
660
+ errors.push(
661
+ `${result.name}: needs dist-tag update — ${expectedTag} points to ${state.currentTagVersion ?? '(missing)'}, target ${result.workspaceVersion}`
662
+ );
663
+ }
664
+ }
665
+ return errors;
666
+ };
667
+
668
+ export const formatDistTagSummary = (
669
+ distTags: Readonly<Record<string, string>>
670
+ ): string =>
671
+ SUMMARY_DIST_TAGS.map((tag) => `${tag}=${distTags[tag] ?? 'missing'}`).join(
672
+ ', '
673
+ );
674
+
675
+ const formatTargetVersionStatus = (
676
+ versionPublished: boolean | undefined
677
+ ): string => {
678
+ if (versionPublished === true) {
679
+ return 'target version published';
680
+ }
681
+ if (versionPublished === false) {
682
+ return 'target version not published yet';
683
+ }
684
+ return 'target version publish state unknown';
685
+ };
686
+
687
+ const printResults = (
688
+ results: readonly RegistryResult[],
689
+ expectedTag: string
690
+ ): void => {
691
+ console.log(`Registry preflight for dist-tag "${expectedTag}"`);
692
+ for (const result of results) {
693
+ if (result.status === 'published') {
694
+ const targetStatus = formatTargetVersionStatus(result.versionPublished);
695
+ console.log(
696
+ `✓ ${result.name}@${result.workspaceVersion}: package exists, ${targetStatus} (registry version ${result.version}, expected ${expectedTag}=${result.expectedTagVersion ?? 'missing'}, tags ${formatDistTagSummary(result.distTags)})`
697
+ );
698
+ } else if (result.status === 'missing') {
699
+ console.log(
700
+ `• ${result.name}@${result.workspaceVersion}: first-time package candidate (not found on registry)`
701
+ );
702
+ } else {
703
+ console.log(`✗ ${result.name}: registry probe failed: ${result.error}`);
704
+ }
705
+ }
706
+ };
707
+
708
+ const normalizeRegistryPreflightViews = (
709
+ view: RegistryView | undefined,
710
+ versionView: RegistryVersionView | undefined
711
+ ): {
712
+ readonly versionView: RegistryVersionView;
713
+ readonly view: RegistryView;
714
+ } => {
715
+ if (view === undefined) {
716
+ return { versionView: npmRegistryVersionView, view: npmRegistryView };
717
+ }
718
+ return { versionView: versionView ?? unknownRegistryVersionView, view };
719
+ };
720
+
721
+ export const runRegistryPreflight = async (
722
+ options: RegistryPreflightOptions,
723
+ view?: RegistryView,
724
+ versionView?: RegistryVersionView
725
+ ): Promise<number> => {
726
+ const registryViews = normalizeRegistryPreflightViews(view, versionView);
727
+ const expectedTag = options.tag ?? (await resolveDefaultTag());
728
+ const workspaces = await discoverRegistryWorkspaces();
729
+ const results = await checkRegistryPosture(
730
+ workspaces,
731
+ registryViews.view,
732
+ registryViews.versionView,
733
+ expectedTag
734
+ );
735
+ printResults(results, expectedTag);
736
+ const errors = registryPostureErrors(
737
+ results,
738
+ expectedTag,
739
+ options.requirePublished ? 'published' : 'ready'
740
+ );
741
+ if (errors.length > 0) {
742
+ console.error('\nRegistry preflight failed:');
743
+ for (const error of errors) {
744
+ console.error(`- ${error}`);
745
+ }
746
+ return 1;
747
+ }
748
+ console.log('\nRegistry preflight passed.');
749
+ return 0;
750
+ };
751
+
752
+ export const runRegistryPreflightCli = async (
753
+ args: readonly string[] = process.argv.slice(2)
754
+ ): Promise<number> => {
755
+ try {
756
+ return await runRegistryPreflight(parseArgs(args));
757
+ } catch (error) {
758
+ console.error(error instanceof Error ? error.message : String(error));
759
+ return 1;
760
+ }
761
+ };
762
+
763
+ if (import.meta.main) {
764
+ process.exit(await runRegistryPreflightCli(process.argv.slice(2)));
765
+ }