@ontrails/trails 1.0.0-beta.2 → 1.0.0-beta.22
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 +647 -0
- package/README.md +26 -0
- package/package.json +28 -7
- package/src/app.ts +86 -2
- package/src/clack.ts +22 -0
- package/src/cli.ts +330 -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 +153 -0
- package/src/mcp-app.ts +30 -0
- package/src/mcp-options.ts +77 -0
- package/src/mcp.ts +8 -0
- package/src/project-writes.ts +377 -0
- package/src/release/bindings.ts +39 -0
- package/src/release/check.ts +818 -0
- package/src/release/config.ts +63 -0
- package/src/release/contract-facts.ts +425 -0
- package/src/release/index.ts +85 -0
- package/src/release/native-bun-publish.ts +651 -0
- package/src/release/native-bun-registry.ts +350 -0
- package/src/release/packed-artifacts-smoke.ts +236 -0
- package/src/release/smoke.ts +46 -0
- package/src/release/wayfinder-dogfood-smoke.ts +226 -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-trace.ts +273 -0
- package/src/run-warden.ts +39 -0
- package/src/run-watch.ts +432 -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 +94 -40
- package/src/trails/add-trail.ts +79 -41
- package/src/trails/add-verify.ts +95 -25
- package/src/trails/compile.ts +67 -0
- package/src/trails/completions-complete.ts +165 -0
- package/src/trails/completions.ts +47 -0
- package/src/trails/create-adapter.ts +1084 -0
- package/src/trails/create-scaffold.ts +399 -104
- package/src/trails/create-versions.ts +62 -0
- package/src/trails/create.ts +185 -71
- 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 +340 -0
- package/src/trails/doctor.ts +56 -0
- package/src/trails/draft-promote.ts +949 -0
- package/src/trails/guide.ts +74 -68
- package/src/trails/load-app.ts +1143 -15
- package/src/trails/project.ts +17 -3
- package/src/trails/release-check.ts +104 -0
- package/src/trails/release-smoke.ts +48 -0
- package/src/trails/revise.ts +53 -0
- package/src/trails/root-dir.ts +21 -0
- package/src/trails/run-example.ts +491 -0
- package/src/trails/run-examples.ts +145 -0
- package/src/trails/run.ts +410 -0
- package/src/trails/scaffold-json.ts +58 -0
- package/src/trails/survey.ts +881 -226
- package/src/trails/topo-activation.ts +385 -0
- package/src/trails/topo-constants.ts +2 -0
- package/src/trails/topo-history.ts +47 -0
- package/src/trails/topo-output-schemas.ts +248 -0
- package/src/trails/topo-pin.ts +52 -0
- package/src/trails/topo-read-support.ts +313 -0
- package/src/trails/topo-reports.ts +807 -0
- package/src/trails/topo-store-support.ts +174 -0
- package/src/trails/topo-support.ts +220 -0
- package/src/trails/topo-unpin.ts +61 -0
- package/src/trails/topo.ts +106 -0
- package/src/trails/validate.ts +38 -0
- package/src/trails/version-lifecycle-support.ts +945 -0
- package/src/trails/warden-guide.ts +129 -0
- package/src/trails/warden.ts +165 -58
- 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 -62
- 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 -11
- package/dist/src/trails/add-trail.d.ts.map +0 -1
- package/dist/src/trails/add-trail.js +0 -85
- 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 -33
- package/dist/src/trails/survey.d.ts.map +0 -1
- package/dist/src/trails/survey.js +0 -225
- 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 -161
- package/src/__tests__/warden.test.ts +0 -74
- package/tsconfig.json +0 -9
|
@@ -0,0 +1,949 @@
|
|
|
1
|
+
import { existsSync, statSync } from 'node:fs';
|
|
2
|
+
import {
|
|
3
|
+
basename,
|
|
4
|
+
dirname,
|
|
5
|
+
isAbsolute,
|
|
6
|
+
join,
|
|
7
|
+
relative,
|
|
8
|
+
resolve,
|
|
9
|
+
} from 'node:path';
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
Result,
|
|
13
|
+
ValidationError,
|
|
14
|
+
deriveDraftReport,
|
|
15
|
+
isDraftId,
|
|
16
|
+
trail,
|
|
17
|
+
} from '@ontrails/core';
|
|
18
|
+
import type { Topo } from '@ontrails/core';
|
|
19
|
+
import {
|
|
20
|
+
DRAFT_FILE_PREFIX,
|
|
21
|
+
isDraftMarkedFile,
|
|
22
|
+
stripDraftFileMarkers,
|
|
23
|
+
} from '@ontrails/warden';
|
|
24
|
+
import { findStringLiterals, parse } from '@ontrails/warden/ast';
|
|
25
|
+
import { z } from 'zod';
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
applyProjectOperations,
|
|
29
|
+
planProjectOperations,
|
|
30
|
+
} from '../project-writes.js';
|
|
31
|
+
import type {
|
|
32
|
+
PlannedProjectOperation,
|
|
33
|
+
ProjectWriteOperation,
|
|
34
|
+
} from '../project-writes.js';
|
|
35
|
+
import type { FreshAppLease } from './load-app.js';
|
|
36
|
+
import { tryLoadFreshAppLease } from './load-app.js';
|
|
37
|
+
import { findTopoPath } from './project.js';
|
|
38
|
+
import { resolveTrailRootDir } from './root-dir.js';
|
|
39
|
+
|
|
40
|
+
interface PromotionEdit {
|
|
41
|
+
readonly end: number;
|
|
42
|
+
readonly replacement: string;
|
|
43
|
+
readonly start: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface FileRename {
|
|
47
|
+
readonly from: string;
|
|
48
|
+
readonly to: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const isManagedSourceFile = (match: string): boolean =>
|
|
52
|
+
!match.endsWith('.d.ts') &&
|
|
53
|
+
!match.startsWith('node_modules/') &&
|
|
54
|
+
!match.startsWith('dist/') &&
|
|
55
|
+
!match.startsWith('.git/');
|
|
56
|
+
|
|
57
|
+
const collectTsFiles = (rootDir: string): string[] => {
|
|
58
|
+
const files: string[] = [];
|
|
59
|
+
for (const match of new Bun.Glob('**/*.ts').scanSync({
|
|
60
|
+
cwd: rootDir,
|
|
61
|
+
dot: false,
|
|
62
|
+
onlyFiles: true,
|
|
63
|
+
})) {
|
|
64
|
+
if (isManagedSourceFile(match)) {
|
|
65
|
+
files.push(join(rootDir, match));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return files.toSorted();
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const applyEdits = (
|
|
72
|
+
sourceCode: string,
|
|
73
|
+
edits: readonly PromotionEdit[]
|
|
74
|
+
): string => {
|
|
75
|
+
let updated = sourceCode;
|
|
76
|
+
for (const edit of [...edits].toSorted((a, b) => b.start - a.start)) {
|
|
77
|
+
updated =
|
|
78
|
+
updated.slice(0, edit.start) + edit.replacement + updated.slice(edit.end);
|
|
79
|
+
}
|
|
80
|
+
return updated;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const literalQuote = (raw: string): '"' | "'" | '`' => {
|
|
84
|
+
const [first] = raw;
|
|
85
|
+
return first === '"' || first === '`' ? first : "'";
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const replaceQuotedLiteral = (
|
|
89
|
+
sourceCode: string,
|
|
90
|
+
start: number,
|
|
91
|
+
end: number,
|
|
92
|
+
nextValue: string
|
|
93
|
+
): string => {
|
|
94
|
+
const quote = literalQuote(sourceCode.slice(start, end));
|
|
95
|
+
return `${quote}${nextValue}${quote}`;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const replaceIdLiterals = (
|
|
99
|
+
sourceCode: string,
|
|
100
|
+
filePath: string,
|
|
101
|
+
fromId: string,
|
|
102
|
+
toId: string
|
|
103
|
+
): { readonly changed: boolean; readonly nextSource: string } => {
|
|
104
|
+
const ast = parse(filePath, sourceCode);
|
|
105
|
+
if (!ast) {
|
|
106
|
+
return { changed: false, nextSource: sourceCode };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const edits = findStringLiterals(ast, (value) => value === fromId).map(
|
|
110
|
+
(match) => ({
|
|
111
|
+
end: match.end,
|
|
112
|
+
replacement: replaceQuotedLiteral(
|
|
113
|
+
sourceCode,
|
|
114
|
+
match.start,
|
|
115
|
+
match.end,
|
|
116
|
+
toId
|
|
117
|
+
),
|
|
118
|
+
start: match.start,
|
|
119
|
+
})
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
if (edits.length === 0) {
|
|
123
|
+
return { changed: false, nextSource: sourceCode };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
changed: true,
|
|
128
|
+
nextSource: applyEdits(sourceCode, edits),
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const hasDraftIds = (sourceCode: string, filePath: string): boolean => {
|
|
133
|
+
const ast = parse(filePath, sourceCode);
|
|
134
|
+
if (!ast) {
|
|
135
|
+
return sourceCode.includes(DRAFT_FILE_PREFIX);
|
|
136
|
+
}
|
|
137
|
+
return findStringLiterals(ast, (value) => isDraftId(value)).length > 0;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const toJsPath = (filePath: string): string => filePath.replace(/\.ts$/, '.js');
|
|
141
|
+
|
|
142
|
+
const toRelativeModulePath = (fromFile: string, toFile: string): string => {
|
|
143
|
+
const rel = relative(dirname(fromFile), toJsPath(toFile)).replaceAll(
|
|
144
|
+
'\\',
|
|
145
|
+
'/'
|
|
146
|
+
);
|
|
147
|
+
return rel.startsWith('.') ? rel : `./${rel}`;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const replaceLiteralValue = (
|
|
151
|
+
sourceCode: string,
|
|
152
|
+
filePath: string,
|
|
153
|
+
currentValue: string,
|
|
154
|
+
nextValue: string
|
|
155
|
+
): { readonly changed: boolean; readonly nextSource: string } => {
|
|
156
|
+
const ast = parse(filePath, sourceCode);
|
|
157
|
+
if (!ast) {
|
|
158
|
+
return { changed: false, nextSource: sourceCode };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const edits = findStringLiterals(ast, (value) => value === currentValue).map(
|
|
162
|
+
(match) => ({
|
|
163
|
+
end: match.end,
|
|
164
|
+
replacement: replaceQuotedLiteral(
|
|
165
|
+
sourceCode,
|
|
166
|
+
match.start,
|
|
167
|
+
match.end,
|
|
168
|
+
nextValue
|
|
169
|
+
),
|
|
170
|
+
start: match.start,
|
|
171
|
+
})
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
if (edits.length === 0) {
|
|
175
|
+
return { changed: false, nextSource: sourceCode };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
changed: true,
|
|
180
|
+
nextSource: applyEdits(sourceCode, edits),
|
|
181
|
+
};
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const collectOutputId = (app: Topo, id: string) =>
|
|
185
|
+
app.get(id) ?? app.signals.get(id) ?? app.getResource(id);
|
|
186
|
+
|
|
187
|
+
const toRelativeOutputPath = (rootDir: string, filePath: string): string =>
|
|
188
|
+
relative(rootDir, filePath).replaceAll('\\', '/');
|
|
189
|
+
|
|
190
|
+
const toProjectModulePath = (sourceImport: string): string =>
|
|
191
|
+
sourceImport.startsWith('./')
|
|
192
|
+
? `./src/${sourceImport.slice(2)}`
|
|
193
|
+
: sourceImport;
|
|
194
|
+
|
|
195
|
+
interface PromotionRewriteState {
|
|
196
|
+
readonly plannedOperations: PlannedProjectOperation[];
|
|
197
|
+
readonly renames: FileRename[];
|
|
198
|
+
readonly updatedSourceFiles: Set<string>;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
interface PromotionLoadState {
|
|
202
|
+
readonly appModule: string | null;
|
|
203
|
+
readonly loadError: string | null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const validatePromotionInput = (input: {
|
|
207
|
+
readonly fromId: string;
|
|
208
|
+
readonly toId: string;
|
|
209
|
+
}): Result<void, ValidationError> => {
|
|
210
|
+
if (!isDraftId(input.fromId)) {
|
|
211
|
+
return Result.err(
|
|
212
|
+
new ValidationError(
|
|
213
|
+
`fromId must use the reserved draft prefix: "${input.fromId}"`
|
|
214
|
+
)
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (isDraftId(input.toId)) {
|
|
219
|
+
return Result.err(
|
|
220
|
+
new ValidationError(
|
|
221
|
+
`toId must be established, not draft: "${input.toId}"`
|
|
222
|
+
)
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return Result.ok();
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const validatePromotionRoot = (
|
|
230
|
+
rootDir: string
|
|
231
|
+
): Result<void, ValidationError> => {
|
|
232
|
+
if (!existsSync(rootDir)) {
|
|
233
|
+
return Result.err(
|
|
234
|
+
new ValidationError(`rootDir does not exist: "${rootDir}"`)
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (!statSync(rootDir).isDirectory()) {
|
|
239
|
+
return Result.err(
|
|
240
|
+
new ValidationError(`rootDir must be a directory: "${rootDir}"`)
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return Result.ok();
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const resolveValidatedPromotionRoot = (
|
|
248
|
+
input: {
|
|
249
|
+
readonly fromId: string;
|
|
250
|
+
readonly rootDir?: string | undefined;
|
|
251
|
+
readonly toId: string;
|
|
252
|
+
},
|
|
253
|
+
ctx: { readonly cwd?: string | undefined }
|
|
254
|
+
): Result<string, ValidationError> => {
|
|
255
|
+
const validation = validatePromotionInput(input);
|
|
256
|
+
if (validation.isErr()) {
|
|
257
|
+
return validation;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
let rootDir: string;
|
|
261
|
+
if (input.rootDir === undefined) {
|
|
262
|
+
const cwdResult = resolveTrailRootDir(undefined, ctx.cwd);
|
|
263
|
+
if (cwdResult.isErr()) {
|
|
264
|
+
return cwdResult;
|
|
265
|
+
}
|
|
266
|
+
rootDir = resolve(cwdResult.value);
|
|
267
|
+
} else if (isAbsolute(input.rootDir)) {
|
|
268
|
+
rootDir = resolve(input.rootDir);
|
|
269
|
+
} else {
|
|
270
|
+
const cwdResult = resolveTrailRootDir(undefined, ctx.cwd);
|
|
271
|
+
if (cwdResult.isErr()) {
|
|
272
|
+
return cwdResult;
|
|
273
|
+
}
|
|
274
|
+
rootDir = resolve(cwdResult.value, input.rootDir);
|
|
275
|
+
}
|
|
276
|
+
const rootValidation = validatePromotionRoot(rootDir);
|
|
277
|
+
if (rootValidation.isErr()) {
|
|
278
|
+
return rootValidation;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return Result.ok(rootDir);
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
type SourceFileMap = Map<string, string>;
|
|
285
|
+
type WriteProjectOperation = Extract<
|
|
286
|
+
ProjectWriteOperation,
|
|
287
|
+
{ readonly kind: 'write' }
|
|
288
|
+
>;
|
|
289
|
+
|
|
290
|
+
const pushWriteOperation = (
|
|
291
|
+
operations: ProjectWriteOperation[],
|
|
292
|
+
operation: WriteProjectOperation
|
|
293
|
+
): void => {
|
|
294
|
+
for (let index = operations.length - 1; index >= 0; index -= 1) {
|
|
295
|
+
const existing = operations[index];
|
|
296
|
+
if (
|
|
297
|
+
existing?.kind === 'rename' &&
|
|
298
|
+
(existing.from === operation.path || existing.to === operation.path)
|
|
299
|
+
) {
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
if (existing?.kind === 'write' && existing.path === operation.path) {
|
|
303
|
+
operations[index] = operation;
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
operations.push(operation);
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const readSourceFiles = async (
|
|
312
|
+
filePaths: readonly string[]
|
|
313
|
+
): Promise<Result<SourceFileMap, Error>> => {
|
|
314
|
+
const sources = new Map<string, string>();
|
|
315
|
+
for (const filePath of filePaths) {
|
|
316
|
+
try {
|
|
317
|
+
sources.set(filePath, await Bun.file(filePath).text());
|
|
318
|
+
} catch (error) {
|
|
319
|
+
return Result.err(
|
|
320
|
+
new ValidationError(
|
|
321
|
+
`Cannot read source file "${filePath}"`,
|
|
322
|
+
error instanceof Error ? { cause: error } : undefined
|
|
323
|
+
)
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return Result.ok(sources);
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const planPromotedSourceFiles = (
|
|
331
|
+
filePaths: readonly string[],
|
|
332
|
+
sources: SourceFileMap,
|
|
333
|
+
fromId: string,
|
|
334
|
+
toId: string,
|
|
335
|
+
updatedSourceFiles: Set<string>,
|
|
336
|
+
operations: ProjectWriteOperation[]
|
|
337
|
+
): Result<void, Error> => {
|
|
338
|
+
for (const filePath of filePaths) {
|
|
339
|
+
const sourceCode = sources.get(filePath);
|
|
340
|
+
if (sourceCode === undefined) {
|
|
341
|
+
return Result.err(
|
|
342
|
+
new ValidationError(`Cannot read source file "${filePath}"`)
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const replaced = replaceIdLiterals(sourceCode, filePath, fromId, toId);
|
|
347
|
+
if (!replaced.changed) {
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
sources.set(filePath, replaced.nextSource);
|
|
352
|
+
pushWriteOperation(operations, {
|
|
353
|
+
content: replaced.nextSource,
|
|
354
|
+
kind: 'write',
|
|
355
|
+
path: filePath,
|
|
356
|
+
});
|
|
357
|
+
updatedSourceFiles.add(filePath);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return Result.ok();
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const buildPromotableFileRename = (
|
|
364
|
+
filePath: string,
|
|
365
|
+
fileName: string
|
|
366
|
+
): Result<FileRename | null, Error> => {
|
|
367
|
+
const nextFileName = stripDraftFileMarkers(fileName);
|
|
368
|
+
if (nextFileName === fileName) {
|
|
369
|
+
return Result.ok(null);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const nextPath = join(dirname(filePath), nextFileName);
|
|
373
|
+
if (nextPath !== filePath && existsSync(nextPath)) {
|
|
374
|
+
return Result.err(
|
|
375
|
+
new ValidationError(
|
|
376
|
+
`Cannot promote draft file "${filePath}" because "${nextPath}" already exists.`
|
|
377
|
+
)
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return Result.ok({ from: filePath, to: nextPath });
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const collectPromotableFileRename = (
|
|
385
|
+
filePath: string,
|
|
386
|
+
sourceCode: string
|
|
387
|
+
): Result<FileRename | null, Error> => {
|
|
388
|
+
if (!isDraftMarkedFile(filePath)) {
|
|
389
|
+
return Result.ok(null);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const fileName = basename(filePath);
|
|
393
|
+
if (!fileName) {
|
|
394
|
+
return Result.ok(null);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (hasDraftIds(sourceCode, filePath)) {
|
|
398
|
+
return Result.ok(null);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return buildPromotableFileRename(filePath, fileName);
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
/** Validate that no two renames target the same path and no target already exists. */
|
|
405
|
+
const validateRenameTargets = (
|
|
406
|
+
renames: readonly FileRename[]
|
|
407
|
+
): Result<void, Error> => {
|
|
408
|
+
const targets = new Set<string>();
|
|
409
|
+
for (const r of renames) {
|
|
410
|
+
if (targets.has(r.to)) {
|
|
411
|
+
return Result.err(
|
|
412
|
+
new ValidationError(
|
|
413
|
+
`Duplicate rename target "${r.to}" — multiple draft files would be renamed to the same path`
|
|
414
|
+
)
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
if (existsSync(r.to)) {
|
|
418
|
+
return Result.err(
|
|
419
|
+
new ValidationError(
|
|
420
|
+
`Rename target "${r.to}" already exists — cannot overwrite`
|
|
421
|
+
)
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
targets.add(r.to);
|
|
425
|
+
}
|
|
426
|
+
return Result.ok();
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const collectFileRenames = (
|
|
430
|
+
filePaths: readonly string[],
|
|
431
|
+
sources: SourceFileMap
|
|
432
|
+
): Result<FileRename[], Error> => {
|
|
433
|
+
const renames: FileRename[] = [];
|
|
434
|
+
for (const filePath of filePaths) {
|
|
435
|
+
const sourceCode = sources.get(filePath);
|
|
436
|
+
if (sourceCode === undefined) {
|
|
437
|
+
return Result.err(
|
|
438
|
+
new ValidationError(`Cannot read source file "${filePath}"`)
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
const renameResult = collectPromotableFileRename(filePath, sourceCode);
|
|
442
|
+
if (renameResult.isErr()) {
|
|
443
|
+
return renameResult;
|
|
444
|
+
}
|
|
445
|
+
if (renameResult.value !== null) {
|
|
446
|
+
renames.push(renameResult.value);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return Result.ok(renames);
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
const applyRenameEffects = (
|
|
453
|
+
updatedSourceFiles: Set<string>,
|
|
454
|
+
renames: readonly FileRename[]
|
|
455
|
+
): void => {
|
|
456
|
+
for (const rename of renames) {
|
|
457
|
+
if (updatedSourceFiles.delete(rename.from)) {
|
|
458
|
+
updatedSourceFiles.add(rename.to);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
const applySourceRenameEffects = (
|
|
464
|
+
sources: SourceFileMap,
|
|
465
|
+
renames: readonly FileRename[]
|
|
466
|
+
): void => {
|
|
467
|
+
for (const rename of renames) {
|
|
468
|
+
const sourceCode = sources.get(rename.from);
|
|
469
|
+
if (sourceCode === undefined) {
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
sources.delete(rename.from);
|
|
473
|
+
sources.set(rename.to, sourceCode);
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
const applyFilePathRenames = (
|
|
478
|
+
filePaths: readonly string[],
|
|
479
|
+
renames: readonly FileRename[]
|
|
480
|
+
): string[] => {
|
|
481
|
+
const renamedBySource = new Map(
|
|
482
|
+
renames.map((rename) => [rename.from, rename.to])
|
|
483
|
+
);
|
|
484
|
+
return filePaths.map((filePath) => renamedBySource.get(filePath) ?? filePath);
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
const applyRelativeImportRename = (
|
|
488
|
+
sourceCode: string,
|
|
489
|
+
filePath: string,
|
|
490
|
+
rename: FileRename
|
|
491
|
+
): { readonly changed: boolean; readonly sourceCode: string } => {
|
|
492
|
+
const currentValue = toRelativeModulePath(filePath, rename.from);
|
|
493
|
+
const nextValue = toRelativeModulePath(filePath, rename.to);
|
|
494
|
+
if (currentValue === nextValue) {
|
|
495
|
+
return { changed: false, sourceCode };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const replaced = replaceLiteralValue(
|
|
499
|
+
sourceCode,
|
|
500
|
+
filePath,
|
|
501
|
+
currentValue,
|
|
502
|
+
nextValue
|
|
503
|
+
);
|
|
504
|
+
if (!replaced.changed) {
|
|
505
|
+
return { changed: false, sourceCode };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return { changed: true, sourceCode: replaced.nextSource };
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
const rewriteRelativeImportsForFile = (
|
|
512
|
+
filePath: string,
|
|
513
|
+
renames: readonly FileRename[],
|
|
514
|
+
sourceCode: string
|
|
515
|
+
): { readonly changed: boolean; readonly sourceCode: string } => {
|
|
516
|
+
let nextSourceCode = sourceCode;
|
|
517
|
+
let changed = false;
|
|
518
|
+
|
|
519
|
+
for (const rename of renames) {
|
|
520
|
+
const updated = applyRelativeImportRename(nextSourceCode, filePath, rename);
|
|
521
|
+
if (!updated.changed) {
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
nextSourceCode = updated.sourceCode;
|
|
526
|
+
changed = true;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
return { changed, sourceCode: nextSourceCode };
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
const planRelativeImportsForFile = (
|
|
533
|
+
filePath: string,
|
|
534
|
+
renames: readonly FileRename[],
|
|
535
|
+
sources: SourceFileMap,
|
|
536
|
+
operations: ProjectWriteOperation[]
|
|
537
|
+
): Result<boolean, Error> => {
|
|
538
|
+
const sourceCode = sources.get(filePath);
|
|
539
|
+
if (sourceCode === undefined) {
|
|
540
|
+
return Result.err(
|
|
541
|
+
new ValidationError(`Cannot read source file "${filePath}"`)
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const updated = rewriteRelativeImportsForFile(filePath, renames, sourceCode);
|
|
546
|
+
if (updated.changed) {
|
|
547
|
+
sources.set(filePath, updated.sourceCode);
|
|
548
|
+
pushWriteOperation(operations, {
|
|
549
|
+
content: updated.sourceCode,
|
|
550
|
+
kind: 'write',
|
|
551
|
+
path: filePath,
|
|
552
|
+
});
|
|
553
|
+
return Result.ok(true);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return Result.ok(false);
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
const planRelativeImports = (
|
|
560
|
+
filePaths: readonly string[],
|
|
561
|
+
renames: readonly FileRename[],
|
|
562
|
+
sources: SourceFileMap,
|
|
563
|
+
updatedSourceFiles: Set<string>,
|
|
564
|
+
operations: ProjectWriteOperation[]
|
|
565
|
+
): Result<void, Error> => {
|
|
566
|
+
for (const filePath of filePaths) {
|
|
567
|
+
const changed = planRelativeImportsForFile(
|
|
568
|
+
filePath,
|
|
569
|
+
renames,
|
|
570
|
+
sources,
|
|
571
|
+
operations
|
|
572
|
+
);
|
|
573
|
+
if (changed.isErr()) {
|
|
574
|
+
return Result.err(changed.error);
|
|
575
|
+
}
|
|
576
|
+
if (changed.value) {
|
|
577
|
+
updatedSourceFiles.add(filePath);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
return Result.ok();
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
const rewritePromotionState = async (
|
|
585
|
+
rootDir: string,
|
|
586
|
+
input: {
|
|
587
|
+
readonly dryRun?: boolean | undefined;
|
|
588
|
+
readonly fromId: string;
|
|
589
|
+
readonly renameFiles: boolean;
|
|
590
|
+
readonly toId: string;
|
|
591
|
+
}
|
|
592
|
+
): Promise<Result<PromotionRewriteState, Error>> => {
|
|
593
|
+
const initialFiles = collectTsFiles(rootDir);
|
|
594
|
+
const sourcesResult = await readSourceFiles(initialFiles);
|
|
595
|
+
if (sourcesResult.isErr()) {
|
|
596
|
+
return Result.err(sourcesResult.error);
|
|
597
|
+
}
|
|
598
|
+
const sources = sourcesResult.value;
|
|
599
|
+
const operations: ProjectWriteOperation[] = [];
|
|
600
|
+
const updatedSourceFiles = new Set<string>();
|
|
601
|
+
|
|
602
|
+
const rewritten = planPromotedSourceFiles(
|
|
603
|
+
initialFiles,
|
|
604
|
+
sources,
|
|
605
|
+
input.fromId,
|
|
606
|
+
input.toId,
|
|
607
|
+
updatedSourceFiles,
|
|
608
|
+
operations
|
|
609
|
+
);
|
|
610
|
+
if (rewritten.isErr()) {
|
|
611
|
+
return Result.err(rewritten.error);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const renamesResult = input.renameFiles
|
|
615
|
+
? collectFileRenames(initialFiles, sources)
|
|
616
|
+
: Result.ok([] as FileRename[]);
|
|
617
|
+
if (renamesResult.isErr()) {
|
|
618
|
+
return Result.err(renamesResult.error);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const valid = validateRenameTargets(renamesResult.value);
|
|
622
|
+
if (valid.isErr()) {
|
|
623
|
+
return valid;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
for (const rename of renamesResult.value) {
|
|
627
|
+
operations.push({ from: rename.from, kind: 'rename', to: rename.to });
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
applyRenameEffects(updatedSourceFiles, renamesResult.value);
|
|
631
|
+
applySourceRenameEffects(sources, renamesResult.value);
|
|
632
|
+
const importUpdates = planRelativeImports(
|
|
633
|
+
applyFilePathRenames(initialFiles, renamesResult.value),
|
|
634
|
+
renamesResult.value,
|
|
635
|
+
sources,
|
|
636
|
+
updatedSourceFiles,
|
|
637
|
+
operations
|
|
638
|
+
);
|
|
639
|
+
if (importUpdates.isErr()) {
|
|
640
|
+
return Result.err(importUpdates.error);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const plannedOperations = input.dryRun
|
|
644
|
+
? planProjectOperations(rootDir, operations)
|
|
645
|
+
: await applyProjectOperations(rootDir, operations);
|
|
646
|
+
if (plannedOperations.isErr()) {
|
|
647
|
+
return Result.err(plannedOperations.error);
|
|
648
|
+
}
|
|
649
|
+
return Result.ok({
|
|
650
|
+
plannedOperations: plannedOperations.value,
|
|
651
|
+
renames: renamesResult.value,
|
|
652
|
+
updatedSourceFiles,
|
|
653
|
+
});
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
const resolvePromotionAppModule = async (
|
|
657
|
+
input: {
|
|
658
|
+
readonly appModule?: string | undefined;
|
|
659
|
+
},
|
|
660
|
+
rootDir: string
|
|
661
|
+
): Promise<string | null> => {
|
|
662
|
+
const discoveredAppModule = await findTopoPath(rootDir);
|
|
663
|
+
return (
|
|
664
|
+
input.appModule ??
|
|
665
|
+
(discoveredAppModule === null
|
|
666
|
+
? null
|
|
667
|
+
: toProjectModulePath(discoveredAppModule))
|
|
668
|
+
);
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Run `consume` while holding a fresh-load lease on `appModule`.
|
|
673
|
+
*
|
|
674
|
+
* @remarks
|
|
675
|
+
* The lease deletes its on-disk mirror on release. Any Topo consumption that
|
|
676
|
+
* may trigger deferred filesystem imports (for example inside a trail's
|
|
677
|
+
* `blaze` or a lazy relative `import()`) must run before the lease is
|
|
678
|
+
* released, otherwise those resolutions race the mirror teardown. Collapsing
|
|
679
|
+
* consumption into the leased critical section keeps that contract
|
|
680
|
+
* structural rather than relying on the caller to discover it.
|
|
681
|
+
*/
|
|
682
|
+
type LeaseAttempt =
|
|
683
|
+
| {
|
|
684
|
+
readonly ok: true;
|
|
685
|
+
readonly lease: FreshAppLease;
|
|
686
|
+
}
|
|
687
|
+
| { readonly ok: false; readonly loadError: string };
|
|
688
|
+
|
|
689
|
+
const tryAcquireLease = async (
|
|
690
|
+
appModule: string,
|
|
691
|
+
rootDir: string
|
|
692
|
+
): Promise<LeaseAttempt> => {
|
|
693
|
+
const loaded = await tryLoadFreshAppLease(appModule, rootDir);
|
|
694
|
+
if (loaded.isErr()) {
|
|
695
|
+
return { loadError: loaded.error.message, ok: false };
|
|
696
|
+
}
|
|
697
|
+
return { lease: loaded.value, ok: true };
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
const withVerifiedApp = async <T>(
|
|
701
|
+
appModule: string | null,
|
|
702
|
+
rootDir: string,
|
|
703
|
+
consume: (app: Topo) => T | Promise<T>
|
|
704
|
+
): Promise<{ readonly load: PromotionLoadState; readonly value: T | null }> => {
|
|
705
|
+
if (appModule === null) {
|
|
706
|
+
return { load: { appModule, loadError: null }, value: null };
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const attempt = await tryAcquireLease(appModule, rootDir);
|
|
710
|
+
if (!attempt.ok) {
|
|
711
|
+
return { load: { appModule, loadError: attempt.loadError }, value: null };
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
try {
|
|
715
|
+
const value = await consume(attempt.lease.app);
|
|
716
|
+
return { load: { appModule, loadError: null }, value };
|
|
717
|
+
} finally {
|
|
718
|
+
attempt.lease.release();
|
|
719
|
+
}
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
const toRenamedFiles = (rootDir: string, renames: readonly FileRename[]) =>
|
|
723
|
+
renames.map((rename) => ({
|
|
724
|
+
from: toRelativeOutputPath(rootDir, rename.from),
|
|
725
|
+
to: toRelativeOutputPath(rootDir, rename.to),
|
|
726
|
+
}));
|
|
727
|
+
|
|
728
|
+
const toUpdatedFiles = (rootDir: string, updatedSourceFiles: Set<string>) =>
|
|
729
|
+
[...updatedSourceFiles]
|
|
730
|
+
.toSorted()
|
|
731
|
+
.map((filePath) => toRelativeOutputPath(rootDir, filePath));
|
|
732
|
+
|
|
733
|
+
const buildUnverifiedPromotionMessage = (
|
|
734
|
+
loadError: string | null,
|
|
735
|
+
dryRun: boolean
|
|
736
|
+
): string => {
|
|
737
|
+
if (dryRun) {
|
|
738
|
+
return 'Promotion plan is valid. Re-run without dryRun to apply it.';
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
return loadError === null
|
|
742
|
+
? 'Promotion rewrote source files, but no topo entrypoint could be loaded for verification.'
|
|
743
|
+
: `Promotion rewrote source files, but verification failed: ${loadError}`;
|
|
744
|
+
};
|
|
745
|
+
|
|
746
|
+
const buildUnverifiedPromotionResult = (
|
|
747
|
+
rootDir: string,
|
|
748
|
+
loadError: string | null,
|
|
749
|
+
renames: readonly FileRename[],
|
|
750
|
+
updatedSourceFiles: Set<string>,
|
|
751
|
+
appModule: string | null,
|
|
752
|
+
plannedOperations: readonly PlannedProjectOperation[],
|
|
753
|
+
dryRun: boolean
|
|
754
|
+
) =>
|
|
755
|
+
Result.ok({
|
|
756
|
+
appModule,
|
|
757
|
+
dryRun,
|
|
758
|
+
message: buildUnverifiedPromotionMessage(loadError, dryRun),
|
|
759
|
+
plannedOperations,
|
|
760
|
+
promotedEstablished: false,
|
|
761
|
+
remainingDraftIds: [],
|
|
762
|
+
renamedFiles: toRenamedFiles(rootDir, renames),
|
|
763
|
+
updatedFiles: toUpdatedFiles(rootDir, updatedSourceFiles),
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
const buildVerifiedPromotionResult = (
|
|
767
|
+
rootDir: string,
|
|
768
|
+
analysis: ReturnType<typeof deriveDraftReport>,
|
|
769
|
+
promotedEstablished: boolean,
|
|
770
|
+
renames: readonly FileRename[],
|
|
771
|
+
updatedSourceFiles: Set<string>,
|
|
772
|
+
appModule: string | null,
|
|
773
|
+
plannedOperations: readonly PlannedProjectOperation[],
|
|
774
|
+
toId: string
|
|
775
|
+
) => {
|
|
776
|
+
const blockingFinding = analysis.findings.find(
|
|
777
|
+
(finding) => finding.id === toId
|
|
778
|
+
);
|
|
779
|
+
|
|
780
|
+
return Result.ok({
|
|
781
|
+
appModule,
|
|
782
|
+
dryRun: false,
|
|
783
|
+
message:
|
|
784
|
+
blockingFinding?.message ??
|
|
785
|
+
(promotedEstablished
|
|
786
|
+
? `Promoted "${toId}" is now established.`
|
|
787
|
+
: `Promoted "${toId}" could not be verified as established.`),
|
|
788
|
+
plannedOperations,
|
|
789
|
+
promotedEstablished,
|
|
790
|
+
remainingDraftIds: [...analysis.declaredDraftIds].toSorted(),
|
|
791
|
+
renamedFiles: toRenamedFiles(rootDir, renames),
|
|
792
|
+
updatedFiles: toUpdatedFiles(rootDir, updatedSourceFiles),
|
|
793
|
+
});
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
const buildVerifiedPromotionResultFromApp = (
|
|
797
|
+
rootDir: string,
|
|
798
|
+
loadedApp: Topo,
|
|
799
|
+
renames: readonly FileRename[],
|
|
800
|
+
updatedSourceFiles: Set<string>,
|
|
801
|
+
appModule: string | null,
|
|
802
|
+
plannedOperations: readonly PlannedProjectOperation[],
|
|
803
|
+
toId: string
|
|
804
|
+
) => {
|
|
805
|
+
const analysis = deriveDraftReport(loadedApp);
|
|
806
|
+
const promotedNode = collectOutputId(loadedApp, toId);
|
|
807
|
+
const promotedEstablished =
|
|
808
|
+
promotedNode !== undefined && !analysis.contaminatedIds.has(toId);
|
|
809
|
+
|
|
810
|
+
return buildVerifiedPromotionResult(
|
|
811
|
+
rootDir,
|
|
812
|
+
analysis,
|
|
813
|
+
promotedEstablished,
|
|
814
|
+
renames,
|
|
815
|
+
updatedSourceFiles,
|
|
816
|
+
appModule,
|
|
817
|
+
plannedOperations,
|
|
818
|
+
toId
|
|
819
|
+
);
|
|
820
|
+
};
|
|
821
|
+
|
|
822
|
+
const promoteDraftState = async (
|
|
823
|
+
input: {
|
|
824
|
+
readonly appModule?: string | undefined;
|
|
825
|
+
readonly dryRun?: boolean | undefined;
|
|
826
|
+
readonly fromId: string;
|
|
827
|
+
readonly renameFiles: boolean;
|
|
828
|
+
readonly rootDir?: string | undefined;
|
|
829
|
+
readonly toId: string;
|
|
830
|
+
},
|
|
831
|
+
ctx: { readonly cwd?: string | undefined }
|
|
832
|
+
) => {
|
|
833
|
+
const rootDirResult = resolveValidatedPromotionRoot(input, ctx);
|
|
834
|
+
if (rootDirResult.isErr()) {
|
|
835
|
+
return rootDirResult;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const rewriteResult = await rewritePromotionState(rootDirResult.value, input);
|
|
839
|
+
if (rewriteResult.isErr()) {
|
|
840
|
+
return Result.err(rewriteResult.error);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const { renames, updatedSourceFiles } = rewriteResult.value;
|
|
844
|
+
const appModule = await resolvePromotionAppModule(input, rootDirResult.value);
|
|
845
|
+
if (input.dryRun === true) {
|
|
846
|
+
return buildUnverifiedPromotionResult(
|
|
847
|
+
rootDirResult.value,
|
|
848
|
+
null,
|
|
849
|
+
renames,
|
|
850
|
+
updatedSourceFiles,
|
|
851
|
+
appModule,
|
|
852
|
+
rewriteResult.value.plannedOperations,
|
|
853
|
+
true
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const { load, value } = await withVerifiedApp(
|
|
858
|
+
appModule,
|
|
859
|
+
rootDirResult.value,
|
|
860
|
+
(loadedApp) =>
|
|
861
|
+
buildVerifiedPromotionResultFromApp(
|
|
862
|
+
rootDirResult.value,
|
|
863
|
+
loadedApp,
|
|
864
|
+
renames,
|
|
865
|
+
updatedSourceFiles,
|
|
866
|
+
appModule,
|
|
867
|
+
rewriteResult.value.plannedOperations,
|
|
868
|
+
input.toId
|
|
869
|
+
)
|
|
870
|
+
);
|
|
871
|
+
|
|
872
|
+
return (
|
|
873
|
+
value ??
|
|
874
|
+
buildUnverifiedPromotionResult(
|
|
875
|
+
rootDirResult.value,
|
|
876
|
+
load.loadError,
|
|
877
|
+
renames,
|
|
878
|
+
updatedSourceFiles,
|
|
879
|
+
appModule,
|
|
880
|
+
rewriteResult.value.plannedOperations,
|
|
881
|
+
false
|
|
882
|
+
)
|
|
883
|
+
);
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
export const draftPromoteTrail = trail('draft.promote', {
|
|
887
|
+
blaze: promoteDraftState,
|
|
888
|
+
description:
|
|
889
|
+
'Promote a draft id to an established id, rewrite inbound references, and verify the result against a fresh topo load.',
|
|
890
|
+
examples: [
|
|
891
|
+
{
|
|
892
|
+
error: 'ValidationError',
|
|
893
|
+
input: {
|
|
894
|
+
// warden-ignore-next-line
|
|
895
|
+
fromId: '_draft.entity.prepare',
|
|
896
|
+
renameFiles: true,
|
|
897
|
+
rootDir: './__does_not_exist__/draft-promote-example',
|
|
898
|
+
toId: 'entity.prepare',
|
|
899
|
+
},
|
|
900
|
+
name: 'Rejects a missing project root before any rewrite begins',
|
|
901
|
+
},
|
|
902
|
+
],
|
|
903
|
+
input: z.object({
|
|
904
|
+
appModule: z
|
|
905
|
+
.string()
|
|
906
|
+
.optional()
|
|
907
|
+
.describe('Optional app module to verify after promotion'),
|
|
908
|
+
dryRun: z
|
|
909
|
+
.boolean()
|
|
910
|
+
.default(false)
|
|
911
|
+
.describe('Plan promotion rewrites without touching source files'),
|
|
912
|
+
fromId: z.string().describe('Draft id to promote'),
|
|
913
|
+
renameFiles: z
|
|
914
|
+
.boolean()
|
|
915
|
+
.default(true)
|
|
916
|
+
.describe('Rename draft-marked files that no longer contain draft ids'),
|
|
917
|
+
rootDir: z.string().optional().describe('Project root directory'),
|
|
918
|
+
toId: z
|
|
919
|
+
.string()
|
|
920
|
+
.describe('Established id to write in place of the draft id'),
|
|
921
|
+
}),
|
|
922
|
+
intent: 'write',
|
|
923
|
+
output: z.object({
|
|
924
|
+
appModule: z.string().nullable(),
|
|
925
|
+
dryRun: z.boolean(),
|
|
926
|
+
message: z.string(),
|
|
927
|
+
plannedOperations: z.array(
|
|
928
|
+
z.discriminatedUnion('kind', [
|
|
929
|
+
z.object({ kind: z.literal('mkdir'), path: z.string() }),
|
|
930
|
+
z.object({
|
|
931
|
+
from: z.string(),
|
|
932
|
+
kind: z.literal('rename'),
|
|
933
|
+
to: z.string(),
|
|
934
|
+
}),
|
|
935
|
+
z.object({ kind: z.literal('write'), path: z.string() }),
|
|
936
|
+
])
|
|
937
|
+
),
|
|
938
|
+
promotedEstablished: z.boolean(),
|
|
939
|
+
remainingDraftIds: z.array(z.string()),
|
|
940
|
+
renamedFiles: z.array(
|
|
941
|
+
z.object({
|
|
942
|
+
from: z.string(),
|
|
943
|
+
to: z.string(),
|
|
944
|
+
})
|
|
945
|
+
),
|
|
946
|
+
updatedFiles: z.array(z.string()),
|
|
947
|
+
}),
|
|
948
|
+
permit: { scopes: ['version:write'] },
|
|
949
|
+
});
|