@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.
- package/CHANGELOG.md +1104 -4
- package/README.md +27 -0
- package/package.json +31 -7
- package/src/app.ts +147 -3
- package/src/cli.ts +345 -11
- package/src/completions.ts +240 -0
- package/src/lifecycle-source-io.ts +33 -0
- package/src/load-app-mirror.ts +202 -0
- package/src/local-state-io.ts +173 -0
- package/src/mcp-app.ts +42 -0
- package/src/mcp-options.ts +91 -0
- package/src/mcp.ts +8 -0
- package/src/project-writes.ts +377 -0
- package/src/regrade/config.ts +152 -0
- package/src/regrade/history.ts +430 -0
- package/src/regrade/live-api-preserve.ts +8 -0
- package/src/regrade/plan-artifact.ts +285 -0
- package/src/release/bindings.ts +39 -0
- package/src/release/check.ts +844 -0
- package/src/release/config.ts +63 -0
- package/src/release/contract-facts.ts +425 -0
- package/src/release/index.ts +142 -0
- package/src/release/lock-roundtrip-smoke.ts +230 -0
- package/src/release/native-bun-publish.ts +651 -0
- package/src/release/native-bun-registry.ts +765 -0
- package/src/release/notes-cli.ts +171 -0
- package/src/release/notes.ts +390 -0
- package/src/release/pack-coherence.ts +455 -0
- package/src/release/packed-artifacts-smoke.ts +236 -0
- package/src/release/policy.ts +1687 -0
- package/src/release/semver.ts +104 -0
- package/src/release/smoke.ts +56 -0
- package/src/release/wayfinder-dogfood-smoke.ts +760 -0
- package/src/retired-topo-command.ts +36 -0
- package/src/run-adapter-check.ts +76 -0
- package/src/run-collision.ts +126 -0
- package/src/run-completions-install.ts +179 -0
- package/src/run-example.ts +149 -0
- package/src/run-examples.ts +148 -0
- package/src/run-quiet.ts +75 -0
- package/src/run-release-check.ts +74 -0
- package/src/run-schema.ts +74 -0
- package/src/run-trace.ts +273 -0
- package/src/run-warden.ts +39 -0
- package/src/run-watch.ts +432 -0
- package/src/run-wayfind-outline.ts +170 -0
- package/src/scaffold-version-sync.ts +183 -0
- package/src/scaffold-versions.generated.ts +12 -0
- package/src/trails/adapter-check.ts +244 -0
- package/src/trails/add-surface.ts +99 -45
- package/src/trails/add-trail.ts +84 -37
- package/src/trails/add-verify.ts +100 -30
- package/src/trails/compile.ts +58 -0
- package/src/trails/completions-complete.ts +165 -0
- package/src/trails/completions.ts +47 -0
- package/src/trails/create-adapter.ts +785 -0
- package/src/trails/create-scaffold.ts +401 -106
- package/src/trails/create-versions.ts +62 -0
- package/src/trails/create.ts +186 -72
- package/src/trails/deprecate.ts +59 -0
- package/src/trails/dev-clean.ts +82 -0
- package/src/trails/dev-reset.ts +50 -0
- package/src/trails/dev-stats.ts +72 -0
- package/src/trails/dev-support.ts +360 -0
- package/src/trails/doctor.ts +77 -0
- package/src/trails/draft-promote.ts +949 -0
- package/src/trails/guide.ts +70 -68
- package/src/trails/load-app.ts +1123 -15
- package/src/trails/operator-context.ts +66 -0
- package/src/trails/project.ts +17 -3
- package/src/trails/regrade.ts +3091 -0
- package/src/trails/release-check.ts +105 -0
- package/src/trails/release-smoke.ts +49 -0
- package/src/trails/revise.ts +53 -0
- package/src/trails/root-dir.ts +21 -0
- package/src/trails/run-example.ts +475 -0
- package/src/trails/run-examples.ts +129 -0
- package/src/trails/run.ts +434 -0
- package/src/trails/scaffold-json.ts +58 -0
- package/src/trails/survey.ts +877 -214
- package/src/trails/topo-activation.ts +14 -0
- package/src/trails/topo-constants.ts +2 -0
- package/src/trails/topo-history.ts +47 -0
- package/src/trails/topo-output-schemas.ts +259 -0
- package/src/trails/topo-pin.ts +38 -0
- package/src/trails/topo-read-support.ts +368 -0
- package/src/trails/topo-reports.ts +809 -0
- package/src/trails/topo-store-support.ts +325 -0
- package/src/trails/topo-support.ts +220 -0
- package/src/trails/topo-unpin.ts +61 -0
- package/src/trails/topo.ts +92 -0
- package/src/trails/validate.ts +27 -0
- package/src/trails/version-lifecycle-support.ts +936 -0
- package/src/trails/warden-guide.ts +134 -0
- package/src/trails/warden.ts +198 -58
- package/src/trails/wayfind-outline.ts +876 -0
- package/src/trails/wayfind.ts +1053 -0
- package/src/versions.ts +31 -0
- package/.turbo/turbo-build.log +0 -1
- package/.turbo/turbo-lint.log +0 -3
- package/.turbo/turbo-typecheck.log +0 -1
- package/__tests__/examples.test.ts +0 -6
- package/dist/bin/trails.d.ts +0 -3
- package/dist/bin/trails.d.ts.map +0 -1
- package/dist/bin/trails.js +0 -4
- package/dist/bin/trails.js.map +0 -1
- package/dist/src/app.d.ts +0 -2
- package/dist/src/app.d.ts.map +0 -1
- package/dist/src/app.js +0 -11
- package/dist/src/app.js.map +0 -1
- package/dist/src/clack.d.ts +0 -9
- package/dist/src/clack.d.ts.map +0 -1
- package/dist/src/clack.js +0 -84
- package/dist/src/clack.js.map +0 -1
- package/dist/src/cli.d.ts +0 -2
- package/dist/src/cli.d.ts.map +0 -1
- package/dist/src/cli.js +0 -13
- package/dist/src/cli.js.map +0 -1
- package/dist/src/trails/add-surface.d.ts +0 -13
- package/dist/src/trails/add-surface.d.ts.map +0 -1
- package/dist/src/trails/add-surface.js +0 -88
- package/dist/src/trails/add-surface.js.map +0 -1
- package/dist/src/trails/add-trail.d.ts +0 -10
- package/dist/src/trails/add-trail.d.ts.map +0 -1
- package/dist/src/trails/add-trail.js +0 -77
- package/dist/src/trails/add-trail.js.map +0 -1
- package/dist/src/trails/add-verify.d.ts +0 -10
- package/dist/src/trails/add-verify.d.ts.map +0 -1
- package/dist/src/trails/add-verify.js +0 -67
- package/dist/src/trails/add-verify.js.map +0 -1
- package/dist/src/trails/create-scaffold.d.ts +0 -15
- package/dist/src/trails/create-scaffold.d.ts.map +0 -1
- package/dist/src/trails/create-scaffold.js +0 -288
- package/dist/src/trails/create-scaffold.js.map +0 -1
- package/dist/src/trails/create.d.ts +0 -22
- package/dist/src/trails/create.d.ts.map +0 -1
- package/dist/src/trails/create.js +0 -121
- package/dist/src/trails/create.js.map +0 -1
- package/dist/src/trails/guide.d.ts +0 -11
- package/dist/src/trails/guide.d.ts.map +0 -1
- package/dist/src/trails/guide.js +0 -80
- package/dist/src/trails/guide.js.map +0 -1
- package/dist/src/trails/load-app.d.ts +0 -4
- package/dist/src/trails/load-app.d.ts.map +0 -1
- package/dist/src/trails/load-app.js +0 -24
- package/dist/src/trails/load-app.js.map +0 -1
- package/dist/src/trails/project.d.ts +0 -8
- package/dist/src/trails/project.d.ts.map +0 -1
- package/dist/src/trails/project.js +0 -43
- package/dist/src/trails/project.js.map +0 -1
- package/dist/src/trails/survey.d.ts +0 -31
- package/dist/src/trails/survey.d.ts.map +0 -1
- package/dist/src/trails/survey.js +0 -221
- package/dist/src/trails/survey.js.map +0 -1
- package/dist/src/trails/warden.d.ts +0 -19
- package/dist/src/trails/warden.d.ts.map +0 -1
- package/dist/src/trails/warden.js +0 -88
- package/dist/src/trails/warden.js.map +0 -1
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/src/__tests__/create.test.ts +0 -349
- package/src/__tests__/guide.test.ts +0 -91
- package/src/__tests__/load-app.test.ts +0 -15
- package/src/__tests__/survey.test.ts +0 -159
- package/src/__tests__/warden.test.ts +0 -74
- 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
|
+
}
|