@ontrails/trails 1.0.0-beta.24 → 1.0.0-beta.29
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 +189 -0
- package/README.md +6 -5
- package/package.json +16 -14
- package/src/app.ts +42 -3
- package/src/cli.ts +17 -3
- package/src/local-state-io.ts +20 -0
- package/src/mcp-app.ts +10 -0
- package/src/mcp-options.ts +2 -3
- package/src/project-writes.ts +11 -11
- package/src/release/check.ts +41 -25
- package/src/release/index.ts +40 -0
- package/src/release/native-bun-registry.ts +282 -19
- package/src/release/pack-coherence.ts +457 -0
- package/src/release/policy.ts +1684 -0
- package/src/release/semver.ts +104 -0
- package/src/release/wayfinder-dogfood-smoke.ts +600 -66
- package/src/run-completions-install.ts +2 -2
- package/src/run-schema.ts +41 -0
- package/src/run-wayfind-outline.ts +166 -0
- package/src/trails/adapter-check.ts +1 -1
- package/src/trails/add-surface.ts +1 -1
- package/src/trails/add-verify.ts +3 -3
- package/src/trails/compile.ts +17 -25
- package/src/trails/create-scaffold.ts +14 -14
- package/src/trails/create.ts +5 -5
- package/src/trails/dev-support.ts +44 -24
- package/src/trails/doctor.ts +23 -2
- package/src/trails/draft-promote.ts +7 -7
- package/src/trails/guide.ts +15 -19
- package/src/trails/load-app.ts +58 -9
- package/src/trails/operator-context.ts +66 -0
- package/src/trails/regrade.ts +72 -0
- package/src/trails/release-check.ts +1 -0
- package/src/trails/run-example.ts +21 -37
- package/src/trails/run-examples.ts +16 -32
- package/src/trails/run.ts +31 -44
- package/src/trails/survey.ts +76 -62
- package/src/trails/topo-output-schemas.ts +11 -0
- package/src/trails/topo-pin.ts +6 -20
- package/src/trails/topo-read-support.ts +91 -41
- package/src/trails/topo-reports.ts +4 -2
- package/src/trails/topo-store-support.ts +172 -39
- package/src/trails/topo-support.ts +1 -2
- package/src/trails/topo.ts +5 -19
- package/src/trails/validate.ts +9 -20
- package/src/trails/version-lifecycle-support.ts +10 -20
- package/src/trails/warden.ts +18 -0
- package/src/trails/wayfind.ts +1001 -0
package/src/release/check.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { readdir } from 'node:fs/promises';
|
|
3
3
|
import { join, relative, resolve } from 'node:path';
|
|
4
|
-
|
|
4
|
+
|
|
5
|
+
import { loadTrailsConfigValue } from '@ontrails/config';
|
|
5
6
|
|
|
6
7
|
import { defaultReleaseConfig, releaseConfigSchema } from './config.js';
|
|
7
8
|
import type { ReleaseConfigInput, ReleaseFactType } from './config.js';
|
|
@@ -34,6 +35,7 @@ export interface ReleaseCheckInput {
|
|
|
34
35
|
|
|
35
36
|
export interface ReleaseCheckResult {
|
|
36
37
|
readonly affectedPackages: readonly string[];
|
|
38
|
+
readonly activePackageChangesetsWithoutReleaseFacts: readonly string[];
|
|
37
39
|
readonly changedChangesets: readonly string[];
|
|
38
40
|
readonly contractFacts: readonly ContractReleaseFact[];
|
|
39
41
|
readonly coveredPackages: readonly string[];
|
|
@@ -94,13 +96,6 @@ const VERSION_RELEASE_WORKSPACE_FILES = new Set([
|
|
|
94
96
|
'CHANGELOG.md',
|
|
95
97
|
'package.json',
|
|
96
98
|
]);
|
|
97
|
-
const CONFIG_CANDIDATES = [
|
|
98
|
-
'trails.config.ts',
|
|
99
|
-
'trails.config.mts',
|
|
100
|
-
'trails.config.js',
|
|
101
|
-
'trails.config.mjs',
|
|
102
|
-
] as const;
|
|
103
|
-
|
|
104
99
|
const normalizePath = (path: string): string =>
|
|
105
100
|
path.replaceAll('\\', '/').replace(/^\.\//u, '');
|
|
106
101
|
|
|
@@ -315,6 +310,14 @@ const findChangedChangesetPaths = (
|
|
|
315
310
|
.map(normalizePath)
|
|
316
311
|
.filter((path) => CHANGESET_PATH_PATTERN.test(path));
|
|
317
312
|
|
|
313
|
+
const findActiveChangedChangesetPaths = (
|
|
314
|
+
changedFiles: readonly string[],
|
|
315
|
+
repoRoot: string
|
|
316
|
+
): readonly string[] =>
|
|
317
|
+
findChangedChangesetPaths(changedFiles).filter((path) =>
|
|
318
|
+
existsSync(join(repoRoot, path))
|
|
319
|
+
);
|
|
320
|
+
|
|
318
321
|
const findChangedChangesets = (
|
|
319
322
|
changedChangesetPaths: readonly string[],
|
|
320
323
|
repoRoot: string
|
|
@@ -387,6 +390,10 @@ export const checkReleaseRules = (
|
|
|
387
390
|
input.workspaces
|
|
388
391
|
);
|
|
389
392
|
const changedChangesets = findChangedChangesetPaths(input.changedFiles);
|
|
393
|
+
const activeChangedChangesets = findActiveChangedChangesetPaths(
|
|
394
|
+
input.changedFiles,
|
|
395
|
+
input.repoRoot
|
|
396
|
+
);
|
|
390
397
|
const changesets = findChangedChangesets(changedChangesets, input.repoRoot);
|
|
391
398
|
const coveredPackages = [
|
|
392
399
|
...new Set(changesets.flatMap((changeset) => changeset.packages)),
|
|
@@ -403,6 +410,12 @@ export const checkReleaseRules = (
|
|
|
403
410
|
const uncoveredContractFacts = contractFacts.filter(
|
|
404
411
|
(fact) => !isContractFactCovered(fact, coveredPackages)
|
|
405
412
|
);
|
|
413
|
+
const hasReleaseFacts =
|
|
414
|
+
affectedPackages.length > 0 || contractFacts.length > 0 || versionRelease;
|
|
415
|
+
const activePackageChangesetsWithoutReleaseFacts =
|
|
416
|
+
activeChangedChangesets.length > 0 && !hasReleaseFacts
|
|
417
|
+
? activeChangedChangesets
|
|
418
|
+
: [];
|
|
406
419
|
const matchedRuleIds = findMatchedRuleIds(input);
|
|
407
420
|
const requiresPackageIntent = ruleMatchesFactType(input, 'package-content');
|
|
408
421
|
const requiresPublicContractIntent = ruleMatchesFactType(
|
|
@@ -417,6 +430,12 @@ export const checkReleaseRules = (
|
|
|
417
430
|
);
|
|
418
431
|
}
|
|
419
432
|
|
|
433
|
+
if (activePackageChangesetsWithoutReleaseFacts.length > 0) {
|
|
434
|
+
errors.push(
|
|
435
|
+
`Active changesets require a matching package or release fact on this branch. Remove ${activePackageChangesetsWithoutReleaseFacts.join(', ')} or include the package-facing change here.`
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
420
439
|
if (
|
|
421
440
|
!noReleaseOverride &&
|
|
422
441
|
!versionRelease &&
|
|
@@ -442,6 +461,7 @@ export const checkReleaseRules = (
|
|
|
442
461
|
}
|
|
443
462
|
|
|
444
463
|
return {
|
|
464
|
+
activePackageChangesetsWithoutReleaseFacts,
|
|
445
465
|
affectedPackages,
|
|
446
466
|
changedChangesets,
|
|
447
467
|
contractFacts,
|
|
@@ -536,9 +556,7 @@ const findConfigPath = (
|
|
|
536
556
|
return resolvedPath;
|
|
537
557
|
}
|
|
538
558
|
|
|
539
|
-
return
|
|
540
|
-
(entry) => existsSync(entry)
|
|
541
|
-
);
|
|
559
|
+
return undefined;
|
|
542
560
|
};
|
|
543
561
|
|
|
544
562
|
const extractReleaseConfig = (value: unknown): ReleaseConfigInput | undefined =>
|
|
@@ -546,14 +564,6 @@ const extractReleaseConfig = (value: unknown): ReleaseConfigInput | undefined =>
|
|
|
546
564
|
? (value['release'] as ReleaseConfigInput)
|
|
547
565
|
: undefined;
|
|
548
566
|
|
|
549
|
-
const importConfigModule = async (
|
|
550
|
-
configPath: string
|
|
551
|
-
): Promise<Record<string, unknown>> => {
|
|
552
|
-
const url = pathToFileURL(configPath);
|
|
553
|
-
url.searchParams.set('t', Date.now().toString());
|
|
554
|
-
return (await import(url.href)) as Record<string, unknown>;
|
|
555
|
-
};
|
|
556
|
-
|
|
557
567
|
export const loadReleaseConfig = async ({
|
|
558
568
|
configPath,
|
|
559
569
|
env = {},
|
|
@@ -564,13 +574,19 @@ export const loadReleaseConfig = async ({
|
|
|
564
574
|
readonly repoRoot: string;
|
|
565
575
|
}): Promise<ReleaseConfigLoadResult> => {
|
|
566
576
|
const locatedConfigPath = findConfigPath(repoRoot, configPath);
|
|
567
|
-
if (locatedConfigPath === undefined) {
|
|
577
|
+
if (configPath !== undefined && locatedConfigPath === undefined) {
|
|
568
578
|
return {};
|
|
569
579
|
}
|
|
570
580
|
|
|
571
581
|
try {
|
|
572
|
-
const
|
|
573
|
-
|
|
582
|
+
const loaded = await loadTrailsConfigValue({
|
|
583
|
+
configPath,
|
|
584
|
+
rootDir: repoRoot,
|
|
585
|
+
});
|
|
586
|
+
const exported = loaded.value;
|
|
587
|
+
if (exported === undefined) {
|
|
588
|
+
return {};
|
|
589
|
+
}
|
|
574
590
|
|
|
575
591
|
if (isResolvableConfig(exported)) {
|
|
576
592
|
const resolved = await exported.resolve({ cwd: repoRoot, env });
|
|
@@ -578,7 +594,7 @@ export const loadReleaseConfig = async ({
|
|
|
578
594
|
if (resolved.isOk()) {
|
|
579
595
|
return {
|
|
580
596
|
config: extractReleaseConfig(resolved.value),
|
|
581
|
-
configPath:
|
|
597
|
+
configPath: loaded.configPath,
|
|
582
598
|
};
|
|
583
599
|
}
|
|
584
600
|
throw new Error(
|
|
@@ -588,13 +604,13 @@ export const loadReleaseConfig = async ({
|
|
|
588
604
|
|
|
589
605
|
return {
|
|
590
606
|
config: extractReleaseConfig(resolved),
|
|
591
|
-
configPath:
|
|
607
|
+
configPath: loaded.configPath,
|
|
592
608
|
};
|
|
593
609
|
}
|
|
594
610
|
|
|
595
611
|
return {
|
|
596
612
|
config: extractReleaseConfig(exported),
|
|
597
|
-
configPath:
|
|
613
|
+
configPath: loaded.configPath,
|
|
598
614
|
};
|
|
599
615
|
} catch (error) {
|
|
600
616
|
throw new Error(`Failed to load release config: ${errorMessage(error)}`, {
|
package/src/release/index.ts
CHANGED
|
@@ -56,18 +56,58 @@ export {
|
|
|
56
56
|
type NativeBunPublishPackageJson,
|
|
57
57
|
type NativeBunPublishWorkspace,
|
|
58
58
|
} from './native-bun-publish.js';
|
|
59
|
+
export {
|
|
60
|
+
findLockfileWorkspaceMetadataMismatches,
|
|
61
|
+
isReleasePackCoherenceFile,
|
|
62
|
+
parseReleasePackCoherenceArgs,
|
|
63
|
+
runReleasePackCoherenceCli,
|
|
64
|
+
shouldRunReleasePackCoherenceCheck,
|
|
65
|
+
syncLockfileWorkspaceMetadataText,
|
|
66
|
+
type ReleasePackCoherenceLockfileInput,
|
|
67
|
+
type ReleasePackCoherenceLockfileSyncResult,
|
|
68
|
+
type ReleasePackCoherenceLockfileWorkspace,
|
|
69
|
+
type ReleasePackCoherenceInput,
|
|
70
|
+
type ReleasePackCoherenceParsedArgs,
|
|
71
|
+
type ReleasePackCoherenceWorkspace,
|
|
72
|
+
} from './pack-coherence.js';
|
|
59
73
|
export {
|
|
60
74
|
checkRegistryPosture,
|
|
75
|
+
classifyPackageRegistryState,
|
|
61
76
|
discoverRegistryWorkspaces,
|
|
62
77
|
formatDistTagSummary,
|
|
78
|
+
npmRegistryVersionView,
|
|
79
|
+
npmRegistryView,
|
|
63
80
|
registryPostureErrors,
|
|
64
81
|
runRegistryPreflight,
|
|
65
82
|
runRegistryPreflightCli,
|
|
83
|
+
type PackageRegistryFacts,
|
|
84
|
+
type PackageRegistryState,
|
|
85
|
+
type RegistryCheckPhase,
|
|
66
86
|
type RegistryPreflightOptions,
|
|
67
87
|
type RegistryResult,
|
|
88
|
+
type RegistryVersionView,
|
|
68
89
|
type RegistryView,
|
|
69
90
|
type RegistryWorkspace,
|
|
70
91
|
} from './native-bun-registry.js';
|
|
92
|
+
export {
|
|
93
|
+
channelIntentForDistTag,
|
|
94
|
+
evaluateReleasePolicy,
|
|
95
|
+
labelsForReleasePullRequest,
|
|
96
|
+
releaseIntentForVersionDelta,
|
|
97
|
+
runReleasePolicyCli,
|
|
98
|
+
type ChannelIntent,
|
|
99
|
+
type PublishIntent,
|
|
100
|
+
type ReleaseIntent,
|
|
101
|
+
type ReleasePolicyChangedFile,
|
|
102
|
+
type ReleasePolicyCommit,
|
|
103
|
+
type ReleasePolicyDecision,
|
|
104
|
+
type ReleasePolicyInput,
|
|
105
|
+
type ReleasePolicyPullRequest,
|
|
106
|
+
type ReleasePolicyRegistryPackage,
|
|
107
|
+
type ReleasePolicyReport,
|
|
108
|
+
type ReleasePolicySourcePullRequest,
|
|
109
|
+
type StackIntent,
|
|
110
|
+
} from './policy.js';
|
|
71
111
|
export {
|
|
72
112
|
releaseSmokeCheckValues,
|
|
73
113
|
runReleaseSmoke,
|
|
@@ -2,8 +2,15 @@
|
|
|
2
2
|
import { readdir } from 'node:fs/promises';
|
|
3
3
|
import { join, relative, resolve } from 'node:path';
|
|
4
4
|
|
|
5
|
+
import { compareSemver } from './semver.js';
|
|
6
|
+
|
|
5
7
|
const REPO_ROOT = resolve(process.cwd());
|
|
6
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';
|
|
7
14
|
|
|
8
15
|
export interface RegistryPreflightOptions {
|
|
9
16
|
readonly requirePublished: boolean;
|
|
@@ -35,6 +42,7 @@ export type RegistryResult =
|
|
|
35
42
|
readonly name: string;
|
|
36
43
|
readonly status: 'published';
|
|
37
44
|
readonly version: string;
|
|
45
|
+
readonly versionPublished: boolean | undefined;
|
|
38
46
|
readonly workspaceVersion: string;
|
|
39
47
|
}
|
|
40
48
|
| {
|
|
@@ -49,6 +57,106 @@ export type RegistryResult =
|
|
|
49
57
|
readonly workspaceVersion: string;
|
|
50
58
|
};
|
|
51
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
|
+
|
|
52
160
|
const USAGE = `Usage: bun scripts/check-registry-preflight.ts [options]
|
|
53
161
|
|
|
54
162
|
Read-only npm registry preflight for public @ontrails/* workspaces.
|
|
@@ -217,9 +325,66 @@ export const npmRegistryView: RegistryView = async (name) => {
|
|
|
217
325
|
throw new Error(stderr.trim() || `npm view failed for ${name}`);
|
|
218
326
|
};
|
|
219
327
|
|
|
328
|
+
/** Probe whether an exact `name@version` is published. The missing fact that
|
|
329
|
+
* a tag/version summary alone cannot answer. */
|
|
330
|
+
export type RegistryVersionView = (
|
|
331
|
+
name: string,
|
|
332
|
+
version: string
|
|
333
|
+
) => Promise<boolean | undefined>;
|
|
334
|
+
|
|
335
|
+
const UNKNOWN_REGISTRY_VERSION_STATE: { readonly published?: boolean } = {};
|
|
336
|
+
const unknownRegistryVersionView: RegistryVersionView = async () =>
|
|
337
|
+
UNKNOWN_REGISTRY_VERSION_STATE.published;
|
|
338
|
+
|
|
339
|
+
export const npmRegistryVersionView: RegistryVersionView = async (
|
|
340
|
+
name,
|
|
341
|
+
version
|
|
342
|
+
) => {
|
|
343
|
+
const proc = Bun.spawn(
|
|
344
|
+
['npm', 'view', `${name}@${version}`, 'version', '--json'],
|
|
345
|
+
{ stderr: 'pipe', stdin: 'ignore', stdout: 'pipe' }
|
|
346
|
+
);
|
|
347
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
348
|
+
new Response(proc.stdout).text(),
|
|
349
|
+
new Response(proc.stderr).text(),
|
|
350
|
+
proc.exited,
|
|
351
|
+
]);
|
|
352
|
+
|
|
353
|
+
if (exitCode === 0) {
|
|
354
|
+
return JSON.parse(stdout.trim()) === version;
|
|
355
|
+
}
|
|
356
|
+
const combined = `${stdout}\n${stderr}`;
|
|
357
|
+
if (combined.includes('E404') || combined.includes('404 Not Found')) {
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
throw new Error(stderr.trim() || `npm view failed for ${name}@${version}`);
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
/** Run async tasks with a bounded number in flight, preserving input order. */
|
|
364
|
+
const mapBounded = async <T, R>(
|
|
365
|
+
items: readonly T[],
|
|
366
|
+
limit: number,
|
|
367
|
+
task: (item: T) => Promise<R>
|
|
368
|
+
): Promise<R[]> => {
|
|
369
|
+
const results: R[] = [];
|
|
370
|
+
let cursor = 0;
|
|
371
|
+
const worker = async (): Promise<void> => {
|
|
372
|
+
while (cursor < items.length) {
|
|
373
|
+
const index = cursor;
|
|
374
|
+
cursor += 1;
|
|
375
|
+
results[index] = await task(items[index] as T);
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
await Promise.all(
|
|
379
|
+
Array.from({ length: Math.min(limit, items.length) }, worker)
|
|
380
|
+
);
|
|
381
|
+
return results;
|
|
382
|
+
};
|
|
383
|
+
|
|
220
384
|
const checkWorkspaceRegistryPosture = async (
|
|
221
385
|
workspace: RegistryWorkspace,
|
|
222
386
|
view: RegistryView,
|
|
387
|
+
versionView: RegistryVersionView,
|
|
223
388
|
expectedTag: string
|
|
224
389
|
): Promise<RegistryResult> => {
|
|
225
390
|
try {
|
|
@@ -238,6 +403,7 @@ const checkWorkspaceRegistryPosture = async (
|
|
|
238
403
|
name: workspace.name,
|
|
239
404
|
status: 'published',
|
|
240
405
|
version: registry.version ?? '(unknown)',
|
|
406
|
+
versionPublished: await versionView(workspace.name, workspace.version),
|
|
241
407
|
workspaceVersion: workspace.version,
|
|
242
408
|
};
|
|
243
409
|
} catch (error) {
|
|
@@ -250,33 +416,97 @@ const checkWorkspaceRegistryPosture = async (
|
|
|
250
416
|
}
|
|
251
417
|
};
|
|
252
418
|
|
|
253
|
-
|
|
419
|
+
type CheckRegistryPostureArgs =
|
|
420
|
+
| readonly [versionView: RegistryVersionView, expectedTag: string]
|
|
421
|
+
| readonly [expectedTag: string];
|
|
422
|
+
|
|
423
|
+
const normalizeCheckRegistryPostureArgs = (
|
|
424
|
+
args: CheckRegistryPostureArgs
|
|
425
|
+
): {
|
|
426
|
+
readonly expectedTag: string;
|
|
427
|
+
readonly versionView: RegistryVersionView;
|
|
428
|
+
} => {
|
|
429
|
+
if (args.length === 1) {
|
|
430
|
+
return { expectedTag: args[0], versionView: unknownRegistryVersionView };
|
|
431
|
+
}
|
|
432
|
+
return { expectedTag: args[1], versionView: args[0] };
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
export function checkRegistryPosture(
|
|
254
436
|
workspaces: readonly RegistryWorkspace[],
|
|
255
437
|
view: RegistryView,
|
|
256
438
|
expectedTag: string
|
|
257
|
-
): Promise<RegistryResult[]
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
439
|
+
): Promise<RegistryResult[]>;
|
|
440
|
+
export function checkRegistryPosture(
|
|
441
|
+
workspaces: readonly RegistryWorkspace[],
|
|
442
|
+
view: RegistryView,
|
|
443
|
+
versionView: RegistryVersionView,
|
|
444
|
+
expectedTag: string
|
|
445
|
+
): Promise<RegistryResult[]>;
|
|
446
|
+
export async function checkRegistryPosture(
|
|
447
|
+
workspaces: readonly RegistryWorkspace[],
|
|
448
|
+
view: RegistryView,
|
|
449
|
+
...args: CheckRegistryPostureArgs
|
|
450
|
+
): Promise<RegistryResult[]> {
|
|
451
|
+
const { expectedTag, versionView } = normalizeCheckRegistryPostureArgs(args);
|
|
452
|
+
return mapBounded(workspaces, PROBE_CONCURRENCY, (workspace) =>
|
|
453
|
+
checkWorkspaceRegistryPosture(workspace, view, versionView, expectedTag)
|
|
262
454
|
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Phase-aware registry errors, derived from the shared classifier.
|
|
459
|
+
*
|
|
460
|
+
* `ready` (pre-publish): only a tag pointing *ahead* of the target or an
|
|
461
|
+
* inaccessible registry is an error. A behind tag or an unpublished target is
|
|
462
|
+
* the expected state before publish runs, not a failure.
|
|
463
|
+
*
|
|
464
|
+
* `published` (post-publish): every package must be `complete`.
|
|
465
|
+
*/
|
|
466
|
+
const normalizeRegistryCheckPhase = (
|
|
467
|
+
phaseOrRequirePublished: boolean | RegistryCheckPhase
|
|
468
|
+
): RegistryCheckPhase => {
|
|
469
|
+
if (typeof phaseOrRequirePublished !== 'boolean') {
|
|
470
|
+
return phaseOrRequirePublished;
|
|
471
|
+
}
|
|
472
|
+
return phaseOrRequirePublished ? 'published' : 'ready';
|
|
473
|
+
};
|
|
263
474
|
|
|
264
475
|
export const registryPostureErrors = (
|
|
265
476
|
results: readonly RegistryResult[],
|
|
266
477
|
expectedTag: string,
|
|
267
|
-
|
|
478
|
+
phaseOrRequirePublished: boolean | RegistryCheckPhase
|
|
268
479
|
): string[] => {
|
|
480
|
+
const phase = normalizeRegistryCheckPhase(phaseOrRequirePublished);
|
|
269
481
|
const errors: string[] = [];
|
|
270
482
|
for (const result of results) {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
483
|
+
const state = classifyPackageRegistryState(factsFromResult(result));
|
|
484
|
+
if (state.kind === 'registry-inaccessible') {
|
|
485
|
+
errors.push(`${result.name}: registry probe failed: ${state.error}`);
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
if (state.kind === 'tag-points-ahead') {
|
|
489
|
+
errors.push(
|
|
490
|
+
`${result.name}: dist-tag ${expectedTag} points to ${state.currentTagVersion}, which is newer than target ${result.workspaceVersion}`
|
|
491
|
+
);
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
if (phase === 'ready' || state.kind === 'complete') {
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
if (state.kind === 'first-time-package') {
|
|
498
|
+
errors.push(`${result.name}: package is missing from the registry`);
|
|
499
|
+
} else if (state.kind === 'needs-publish') {
|
|
500
|
+
const targetState =
|
|
501
|
+
result.status === 'published' && result.versionPublished === undefined
|
|
502
|
+
? 'publish state was not probed'
|
|
503
|
+
: 'is not published';
|
|
278
504
|
errors.push(
|
|
279
|
-
`${result.name}:
|
|
505
|
+
`${result.name}: target version ${result.workspaceVersion} ${targetState}`
|
|
506
|
+
);
|
|
507
|
+
} else if (state.kind === 'needs-tag-repair') {
|
|
508
|
+
errors.push(
|
|
509
|
+
`${result.name}: needs dist-tag update — ${expectedTag} points to ${state.currentTagVersion ?? '(missing)'}, target ${result.workspaceVersion}`
|
|
280
510
|
);
|
|
281
511
|
}
|
|
282
512
|
}
|
|
@@ -290,6 +520,18 @@ export const formatDistTagSummary = (
|
|
|
290
520
|
', '
|
|
291
521
|
);
|
|
292
522
|
|
|
523
|
+
const formatTargetVersionStatus = (
|
|
524
|
+
versionPublished: boolean | undefined
|
|
525
|
+
): string => {
|
|
526
|
+
if (versionPublished === true) {
|
|
527
|
+
return 'target version published';
|
|
528
|
+
}
|
|
529
|
+
if (versionPublished === false) {
|
|
530
|
+
return 'target version not published yet';
|
|
531
|
+
}
|
|
532
|
+
return 'target version publish state unknown';
|
|
533
|
+
};
|
|
534
|
+
|
|
293
535
|
const printResults = (
|
|
294
536
|
results: readonly RegistryResult[],
|
|
295
537
|
expectedTag: string
|
|
@@ -297,8 +539,9 @@ const printResults = (
|
|
|
297
539
|
console.log(`Registry preflight for dist-tag "${expectedTag}"`);
|
|
298
540
|
for (const result of results) {
|
|
299
541
|
if (result.status === 'published') {
|
|
542
|
+
const targetStatus = formatTargetVersionStatus(result.versionPublished);
|
|
300
543
|
console.log(
|
|
301
|
-
`✓ ${result.name}@${result.workspaceVersion}:
|
|
544
|
+
`✓ ${result.name}@${result.workspaceVersion}: package exists, ${targetStatus} (registry version ${result.version}, expected ${expectedTag}=${result.expectedTagVersion ?? 'missing'}, tags ${formatDistTagSummary(result.distTags)})`
|
|
302
545
|
);
|
|
303
546
|
} else if (result.status === 'missing') {
|
|
304
547
|
console.log(
|
|
@@ -310,18 +553,38 @@ const printResults = (
|
|
|
310
553
|
}
|
|
311
554
|
};
|
|
312
555
|
|
|
556
|
+
const normalizeRegistryPreflightViews = (
|
|
557
|
+
view: RegistryView | undefined,
|
|
558
|
+
versionView: RegistryVersionView | undefined
|
|
559
|
+
): {
|
|
560
|
+
readonly versionView: RegistryVersionView;
|
|
561
|
+
readonly view: RegistryView;
|
|
562
|
+
} => {
|
|
563
|
+
if (view === undefined) {
|
|
564
|
+
return { versionView: npmRegistryVersionView, view: npmRegistryView };
|
|
565
|
+
}
|
|
566
|
+
return { versionView: versionView ?? unknownRegistryVersionView, view };
|
|
567
|
+
};
|
|
568
|
+
|
|
313
569
|
export const runRegistryPreflight = async (
|
|
314
570
|
options: RegistryPreflightOptions,
|
|
315
|
-
view
|
|
571
|
+
view?: RegistryView,
|
|
572
|
+
versionView?: RegistryVersionView
|
|
316
573
|
): Promise<number> => {
|
|
574
|
+
const registryViews = normalizeRegistryPreflightViews(view, versionView);
|
|
317
575
|
const expectedTag = options.tag ?? (await resolveDefaultTag());
|
|
318
576
|
const workspaces = await discoverRegistryWorkspaces();
|
|
319
|
-
const results = await checkRegistryPosture(
|
|
577
|
+
const results = await checkRegistryPosture(
|
|
578
|
+
workspaces,
|
|
579
|
+
registryViews.view,
|
|
580
|
+
registryViews.versionView,
|
|
581
|
+
expectedTag
|
|
582
|
+
);
|
|
320
583
|
printResults(results, expectedTag);
|
|
321
584
|
const errors = registryPostureErrors(
|
|
322
585
|
results,
|
|
323
586
|
expectedTag,
|
|
324
|
-
options.requirePublished
|
|
587
|
+
options.requirePublished ? 'published' : 'ready'
|
|
325
588
|
);
|
|
326
589
|
if (errors.length > 0) {
|
|
327
590
|
console.error('\nRegistry preflight failed:');
|