@jskit-ai/jskit-cli 0.2.129 → 0.2.130
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/package.json +5 -4
- package/src/server/cliRuntime/appState.js +5 -2
- package/src/server/cliRuntime/ci/composer.js +171 -0
- package/src/server/cliRuntime/ci/contract.js +218 -0
- package/src/server/cliRuntime/ci/githubWorkflow.js +136 -0
- package/src/server/cliRuntime/ci/managedWorkflow.js +549 -0
- package/src/server/cliRuntime/descriptorValidation.js +11 -0
- package/src/server/commandHandlers/app.js +4 -0
- package/src/server/commandHandlers/appCommandCatalog.js +20 -1
- package/src/server/commandHandlers/appCommands/syncCi.js +40 -0
- package/src/server/commandHandlers/appCommands/updatePackages.js +9 -1
- package/src/server/commandHandlers/appCommands/verify.js +12 -1
- package/src/server/commandHandlers/health.js +8 -0
- package/src/server/commandHandlers/packageCommands/add.js +28 -0
- package/src/server/commandHandlers/packageCommands/remove.js +13 -0
- package/src/server/core/buildCommandDeps.js +7 -0
- package/src/server/core/createCliRunner.js +16 -0
- package/src/server/index.js +11 -0
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdir,
|
|
3
|
+
rm,
|
|
4
|
+
writeFile
|
|
5
|
+
} from "node:fs/promises";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { createCliError } from "../../shared/cliError.js";
|
|
8
|
+
import { ensureObject } from "../../shared/collectionUtils.js";
|
|
9
|
+
import {
|
|
10
|
+
directoryLooksLikeJskitAppRoot,
|
|
11
|
+
loadLockFile
|
|
12
|
+
} from "../appState.js";
|
|
13
|
+
import {
|
|
14
|
+
fileExists,
|
|
15
|
+
hashBuffer,
|
|
16
|
+
readFileBufferIfExists,
|
|
17
|
+
writeJsonFile
|
|
18
|
+
} from "../ioAndMigrations.js";
|
|
19
|
+
import {
|
|
20
|
+
hydratePackageRegistryFromInstalledNodeModules,
|
|
21
|
+
loadAppLocalPackageRegistry,
|
|
22
|
+
loadPackageRegistry,
|
|
23
|
+
mergePackageRegistries
|
|
24
|
+
} from "../packageRegistries.js";
|
|
25
|
+
import {
|
|
26
|
+
CiCompositionError,
|
|
27
|
+
composeCiContributions
|
|
28
|
+
} from "./composer.js";
|
|
29
|
+
import {
|
|
30
|
+
JSKIT_CI_WORKFLOW_RELATIVE_PATH,
|
|
31
|
+
LEGACY_CI_WORKFLOW_RELATIVE_PATH,
|
|
32
|
+
LEGACY_VERIFY_WORKFLOW_HASH,
|
|
33
|
+
parseGithubWorkflow,
|
|
34
|
+
renderGithubServiceOptions,
|
|
35
|
+
renderGithubWorkflow
|
|
36
|
+
} from "./githubWorkflow.js";
|
|
37
|
+
|
|
38
|
+
function contentHash(content = "") {
|
|
39
|
+
return hashBuffer(Buffer.from(String(content || ""), "utf8"));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function collectInstalledPackageEntries({ lock, packageRegistry, installedPackageIds = null }) {
|
|
43
|
+
const installed = ensureObject(lock?.installedPackages);
|
|
44
|
+
const packageIds = Array.isArray(installedPackageIds)
|
|
45
|
+
? installedPackageIds
|
|
46
|
+
: Object.keys(installed);
|
|
47
|
+
return [...new Set(packageIds.map((value) => String(value || "").trim()).filter(Boolean))]
|
|
48
|
+
.sort((left, right) => left.localeCompare(right))
|
|
49
|
+
.map((packageId) => {
|
|
50
|
+
const packageEntry = packageRegistry?.get?.(packageId);
|
|
51
|
+
if (!packageEntry) {
|
|
52
|
+
throw new CiCompositionError(
|
|
53
|
+
"descriptor-missing",
|
|
54
|
+
`Installed package descriptor not found for ${packageId}. Restore the package in node_modules or the JSKIT catalog before synchronizing CI.`,
|
|
55
|
+
{ packageId }
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return packageEntry;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function composeInstalledPackageCi({ lock, packageRegistry, installedPackageIds = null }) {
|
|
63
|
+
return composeCiContributions(
|
|
64
|
+
collectInstalledPackageEntries({ lock, packageRegistry, installedPackageIds })
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function managedCiRecord(lock = {}) {
|
|
69
|
+
return ensureObject(ensureObject(lock.managed).ciWorkflow);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function setManagedCiRecord(lock, record) {
|
|
73
|
+
lock.managed = {
|
|
74
|
+
...ensureObject(lock.managed),
|
|
75
|
+
ciWorkflow: {
|
|
76
|
+
path: String(record.path || JSKIT_CI_WORKFLOW_RELATIVE_PATH),
|
|
77
|
+
hash: String(record.hash || "")
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function recoveryError(message, { force = false } = {}) {
|
|
83
|
+
const command = force ? "npx jskit app sync-ci --force" : "npx jskit app sync-ci";
|
|
84
|
+
return createCliError(`${message} Run: ${command}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function inspectWorkflowOwnership({ appRoot, lock }) {
|
|
88
|
+
const record = managedCiRecord(lock);
|
|
89
|
+
const targetPath = path.join(appRoot, JSKIT_CI_WORKFLOW_RELATIVE_PATH);
|
|
90
|
+
const legacyPath = path.join(appRoot, LEGACY_CI_WORKFLOW_RELATIVE_PATH);
|
|
91
|
+
const target = await readFileBufferIfExists(targetPath);
|
|
92
|
+
const legacy = await readFileBufferIfExists(legacyPath);
|
|
93
|
+
return {
|
|
94
|
+
record,
|
|
95
|
+
targetPath,
|
|
96
|
+
target,
|
|
97
|
+
legacyPath,
|
|
98
|
+
legacy,
|
|
99
|
+
targetHash: target.exists ? hashBuffer(target.buffer) : "",
|
|
100
|
+
legacyIsGenerated:
|
|
101
|
+
legacy.exists && hashBuffer(legacy.buffer) === LEGACY_VERIFY_WORKFLOW_HASH
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function assertManagedCiWorkflowUnmodified({
|
|
106
|
+
appRoot,
|
|
107
|
+
lock,
|
|
108
|
+
allowManagedOverwrite = false
|
|
109
|
+
}) {
|
|
110
|
+
const ownership = await inspectWorkflowOwnership({ appRoot, lock });
|
|
111
|
+
const recordPath = String(ownership.record.path || "").trim();
|
|
112
|
+
const recordHash = String(ownership.record.hash || "").trim();
|
|
113
|
+
|
|
114
|
+
if (recordPath && recordPath !== JSKIT_CI_WORKFLOW_RELATIVE_PATH) {
|
|
115
|
+
throw createCliError(
|
|
116
|
+
`[ci:workflow-ownership-invalid] .jskit/lock.json records the managed CI workflow at ${recordPath}, but JSKIT owns ${JSKIT_CI_WORKFLOW_RELATIVE_PATH}. Restore the recorded path before synchronizing CI.`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (recordHash && !allowManagedOverwrite) {
|
|
121
|
+
if (!ownership.target.exists) {
|
|
122
|
+
throw recoveryError(
|
|
123
|
+
`[ci:workflow-modified] The JSKIT-managed CI workflow is missing: ${JSKIT_CI_WORKFLOW_RELATIVE_PATH}.`,
|
|
124
|
+
{ force: true }
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
if (ownership.targetHash !== recordHash) {
|
|
128
|
+
throw recoveryError(
|
|
129
|
+
`[ci:workflow-modified] ${JSKIT_CI_WORKFLOW_RELATIVE_PATH} differs from the generated version recorded in .jskit/lock.json. Move application-specific CI to a separate workflow before regenerating.`,
|
|
130
|
+
{ force: true }
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!recordHash && ownership.target.exists) {
|
|
136
|
+
throw createCliError(
|
|
137
|
+
`[ci:workflow-unowned] ${JSKIT_CI_WORKFLOW_RELATIVE_PATH} already exists without JSKIT ownership in .jskit/lock.json. Rename or remove it, then run: npx jskit app sync-ci`
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (ownership.legacy.exists && !ownership.legacyIsGenerated) {
|
|
142
|
+
throw createCliError(
|
|
143
|
+
`[ci:legacy-workflow-conflict] ${LEGACY_CI_WORKFLOW_RELATIVE_PATH} is not the original JSKIT scaffold and will not be removed. Move application-specific CI to a separate workflow, remove the duplicate verification workflow, then run: npx jskit app sync-ci`
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return ownership;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function synchronizeManagedCiWorkflow({
|
|
151
|
+
appRoot,
|
|
152
|
+
lock,
|
|
153
|
+
packageRegistry,
|
|
154
|
+
installedPackageIds = null,
|
|
155
|
+
touchedFiles = null,
|
|
156
|
+
dryRun = false,
|
|
157
|
+
allowManagedOverwrite = false
|
|
158
|
+
}) {
|
|
159
|
+
const model = composeInstalledPackageCi({
|
|
160
|
+
lock,
|
|
161
|
+
packageRegistry,
|
|
162
|
+
installedPackageIds
|
|
163
|
+
});
|
|
164
|
+
const content = renderGithubWorkflow(model);
|
|
165
|
+
const hash = contentHash(content);
|
|
166
|
+
const ownership = await assertManagedCiWorkflowUnmodified({
|
|
167
|
+
appRoot,
|
|
168
|
+
lock,
|
|
169
|
+
allowManagedOverwrite
|
|
170
|
+
});
|
|
171
|
+
const currentContent = ownership.target.exists ? ownership.target.buffer.toString("utf8") : "";
|
|
172
|
+
const workflowChanged = currentContent !== content;
|
|
173
|
+
const removedLegacyWorkflow = ownership.legacyIsGenerated;
|
|
174
|
+
const replacedModifiedWorkflow =
|
|
175
|
+
allowManagedOverwrite &&
|
|
176
|
+
Boolean(ownership.record.hash) &&
|
|
177
|
+
ownership.targetHash !== String(ownership.record.hash || "");
|
|
178
|
+
|
|
179
|
+
if (!dryRun) {
|
|
180
|
+
if (workflowChanged) {
|
|
181
|
+
await mkdir(path.dirname(ownership.targetPath), { recursive: true });
|
|
182
|
+
await writeFile(ownership.targetPath, content, "utf8");
|
|
183
|
+
}
|
|
184
|
+
if (removedLegacyWorkflow) {
|
|
185
|
+
await rm(ownership.legacyPath, { force: true });
|
|
186
|
+
}
|
|
187
|
+
setManagedCiRecord(lock, {
|
|
188
|
+
path: JSKIT_CI_WORKFLOW_RELATIVE_PATH,
|
|
189
|
+
hash
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (workflowChanged) {
|
|
194
|
+
touchedFiles?.add?.(JSKIT_CI_WORKFLOW_RELATIVE_PATH);
|
|
195
|
+
}
|
|
196
|
+
if (removedLegacyWorkflow) {
|
|
197
|
+
touchedFiles?.add?.(LEGACY_CI_WORKFLOW_RELATIVE_PATH);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
applicable: true,
|
|
202
|
+
path: JSKIT_CI_WORKFLOW_RELATIVE_PATH,
|
|
203
|
+
hash,
|
|
204
|
+
content,
|
|
205
|
+
model,
|
|
206
|
+
changed: workflowChanged || removedLegacyWorkflow,
|
|
207
|
+
workflowChanged,
|
|
208
|
+
removedLegacyWorkflow,
|
|
209
|
+
replacedModifiedWorkflow
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function createValidationIssue(code, message, details = {}) {
|
|
214
|
+
return {
|
|
215
|
+
code: `ci:${code}`,
|
|
216
|
+
message: `[ci:${code}] ${message}`,
|
|
217
|
+
...details
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function formatRequirementPackages(packageIds = []) {
|
|
222
|
+
return packageIds.join(", ");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function describeCiRequirements(model = {}) {
|
|
226
|
+
const descriptions = [];
|
|
227
|
+
for (const service of model.services || []) {
|
|
228
|
+
descriptions.push(
|
|
229
|
+
`${formatRequirementPackages(model.sources?.services?.[service.id] || [])} requires the ${service.id} service`
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
if (Object.prototype.hasOwnProperty.call(model.environment || {}, "DB_CLIENT")) {
|
|
233
|
+
descriptions.push(
|
|
234
|
+
`${formatRequirementPackages(model.sources?.environment?.DB_CLIENT || [])} requires DB_CLIENT=${model.environment.DB_CLIENT}`
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
for (const step of model.steps || []) {
|
|
238
|
+
descriptions.push(
|
|
239
|
+
`${formatRequirementPackages(model.sources?.steps?.[step.id] || [])} requires ${step.label} before verification`
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
return descriptions.filter((value) => !value.startsWith(" requires")).join("; ");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function normalizeActualEnvironment(value) {
|
|
246
|
+
const source = value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
247
|
+
return Object.fromEntries(
|
|
248
|
+
Object.entries(source)
|
|
249
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
250
|
+
.map(([key, entry]) => [key, String(entry)])
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function collectEnvironmentIssues({ expected, actual, sources, issues }) {
|
|
255
|
+
for (const [key, value] of Object.entries(expected)) {
|
|
256
|
+
const packageIds = sources[key] || [];
|
|
257
|
+
if (!Object.prototype.hasOwnProperty.call(actual, key)) {
|
|
258
|
+
issues.push(createValidationIssue(
|
|
259
|
+
"environment-missing",
|
|
260
|
+
`${formatRequirementPackages(packageIds)} requires ${key}=${value} in the verify job environment.`
|
|
261
|
+
));
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (actual[key] !== value) {
|
|
265
|
+
issues.push(createValidationIssue(
|
|
266
|
+
"environment-incorrect",
|
|
267
|
+
`${formatRequirementPackages(packageIds)} requires ${key}=${value}, but the managed workflow contains ${key}=${actual[key]}.`
|
|
268
|
+
));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function collectServiceIssues({ model, verifyJob, issues }) {
|
|
274
|
+
const actualServices = ensureObject(verifyJob.services);
|
|
275
|
+
for (const service of model.services || []) {
|
|
276
|
+
const packageIds = model.sources?.services?.[service.id] || [];
|
|
277
|
+
const actual = actualServices[service.id];
|
|
278
|
+
if (!actual || typeof actual !== "object") {
|
|
279
|
+
issues.push(createValidationIssue(
|
|
280
|
+
"service-missing",
|
|
281
|
+
`${formatRequirementPackages(packageIds)} requires the ${service.id} service.`
|
|
282
|
+
));
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
const expectedOptions = renderGithubServiceOptions(service);
|
|
286
|
+
const actualPorts = Array.isArray(actual.ports) ? actual.ports.map(String) : [];
|
|
287
|
+
const serviceMatches =
|
|
288
|
+
String(actual.image || "") === service.image &&
|
|
289
|
+
JSON.stringify(normalizeActualEnvironment(actual.env)) === JSON.stringify(service.environment) &&
|
|
290
|
+
JSON.stringify(actualPorts) === JSON.stringify(service.ports) &&
|
|
291
|
+
String(actual.options || "") === expectedOptions;
|
|
292
|
+
if (!serviceMatches) {
|
|
293
|
+
issues.push(createValidationIssue(
|
|
294
|
+
"service-incorrect",
|
|
295
|
+
`${formatRequirementPackages(packageIds)} requires the generated ${service.id} service image, environment, ports, and health check to remain unchanged.`
|
|
296
|
+
));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function collectStepIssues({ model, verifyJob, issues }) {
|
|
302
|
+
const actualSteps = Array.isArray(verifyJob.steps) ? verifyJob.steps : [];
|
|
303
|
+
const stepIndexById = new Map();
|
|
304
|
+
for (const [index, step] of actualSteps.entries()) {
|
|
305
|
+
const id = String(step?.id || "").trim();
|
|
306
|
+
if (id && !stepIndexById.has(id)) {
|
|
307
|
+
stepIndexById.set(id, index);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
for (const step of model.steps || []) {
|
|
312
|
+
const packageIds = model.sources?.steps?.[step.id] || [];
|
|
313
|
+
const index = stepIndexById.get(step.id);
|
|
314
|
+
if (typeof index === "undefined") {
|
|
315
|
+
issues.push(createValidationIssue(
|
|
316
|
+
"step-missing",
|
|
317
|
+
`${formatRequirementPackages(packageIds)} requires the "${step.label}" step (${step.id}).`
|
|
318
|
+
));
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
const actual = actualSteps[index];
|
|
322
|
+
if (String(actual.name || "") !== step.label || String(actual.run || "") !== step.command) {
|
|
323
|
+
issues.push(createValidationIssue(
|
|
324
|
+
"step-incorrect",
|
|
325
|
+
`${formatRequirementPackages(packageIds)} requires ${step.id} to run ${step.command}.`
|
|
326
|
+
));
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const expectedOrder = [
|
|
331
|
+
"checkout",
|
|
332
|
+
"setup-node",
|
|
333
|
+
"install-dependencies",
|
|
334
|
+
...(model.steps || []).map((step) => step.id),
|
|
335
|
+
"verify"
|
|
336
|
+
];
|
|
337
|
+
const actualIndexes = expectedOrder.map((id) => stepIndexById.get(id));
|
|
338
|
+
if (
|
|
339
|
+
actualIndexes.every((index) => typeof index !== "undefined") &&
|
|
340
|
+
actualIndexes.some((index, position) => position > 0 && index <= actualIndexes[position - 1])
|
|
341
|
+
) {
|
|
342
|
+
issues.push(createValidationIssue(
|
|
343
|
+
"step-order-incorrect",
|
|
344
|
+
`Managed preparation steps must run after npm ci and before npm run verify in this order: ${expectedOrder.join(" -> ")}.`
|
|
345
|
+
));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function validateManagedCiWorkflow({ appRoot, lock, packageRegistry }) {
|
|
350
|
+
let model = null;
|
|
351
|
+
try {
|
|
352
|
+
model = composeInstalledPackageCi({ lock, packageRegistry });
|
|
353
|
+
} catch (error) {
|
|
354
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
355
|
+
return {
|
|
356
|
+
applicable: true,
|
|
357
|
+
valid: false,
|
|
358
|
+
model: null,
|
|
359
|
+
issues: [createValidationIssue(
|
|
360
|
+
"contribution-conflict",
|
|
361
|
+
`Installed package CI contributions could not be composed: ${message}`
|
|
362
|
+
)]
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const record = managedCiRecord(lock);
|
|
367
|
+
const workflowPath = path.join(appRoot, JSKIT_CI_WORKFLOW_RELATIVE_PATH);
|
|
368
|
+
const legacyPath = path.join(appRoot, LEGACY_CI_WORKFLOW_RELATIVE_PATH);
|
|
369
|
+
const workflow = await readFileBufferIfExists(workflowPath);
|
|
370
|
+
const hasLegacyWorkflow = await fileExists(legacyPath);
|
|
371
|
+
const applicable =
|
|
372
|
+
Boolean(record.hash || record.path) ||
|
|
373
|
+
workflow.exists ||
|
|
374
|
+
hasLegacyWorkflow ||
|
|
375
|
+
model.packages.length > 0;
|
|
376
|
+
if (!applicable) {
|
|
377
|
+
return {
|
|
378
|
+
applicable: false,
|
|
379
|
+
valid: true,
|
|
380
|
+
model,
|
|
381
|
+
issues: []
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const expectedContent = renderGithubWorkflow(model);
|
|
386
|
+
const expectedHash = contentHash(expectedContent);
|
|
387
|
+
const requirementSummary = describeCiRequirements(model);
|
|
388
|
+
const requirementSuffix = requirementSummary ? ` ${requirementSummary}.` : "";
|
|
389
|
+
const recovery = " Run: npx jskit app sync-ci";
|
|
390
|
+
const forcedRecovery = " Run: npx jskit app sync-ci --force";
|
|
391
|
+
const issues = [];
|
|
392
|
+
const recordPath = String(record.path || "").trim();
|
|
393
|
+
const recordHash = String(record.hash || "").trim();
|
|
394
|
+
|
|
395
|
+
if (recordPath !== JSKIT_CI_WORKFLOW_RELATIVE_PATH || !recordHash) {
|
|
396
|
+
issues.push(createValidationIssue(
|
|
397
|
+
"workflow-ownership-missing",
|
|
398
|
+
`The generated workflow is not recorded as a managed projection in .jskit/lock.json.${recovery}`
|
|
399
|
+
));
|
|
400
|
+
}
|
|
401
|
+
if (!workflow.exists) {
|
|
402
|
+
const missingWorkflowRecovery = recordHash ? forcedRecovery : recovery;
|
|
403
|
+
issues.push(createValidationIssue(
|
|
404
|
+
"workflow-missing",
|
|
405
|
+
`The managed CI workflow is missing at ${JSKIT_CI_WORKFLOW_RELATIVE_PATH}.${missingWorkflowRecovery}`
|
|
406
|
+
));
|
|
407
|
+
issues.push(createValidationIssue(
|
|
408
|
+
"workflow-out-of-date",
|
|
409
|
+
`The managed CI workflow does not match the installed packages.${requirementSuffix}${missingWorkflowRecovery}`
|
|
410
|
+
));
|
|
411
|
+
return {
|
|
412
|
+
applicable,
|
|
413
|
+
valid: false,
|
|
414
|
+
model,
|
|
415
|
+
expectedContent,
|
|
416
|
+
expectedHash,
|
|
417
|
+
issues
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const actualContent = workflow.buffer.toString("utf8");
|
|
422
|
+
const actualHash = hashBuffer(workflow.buffer);
|
|
423
|
+
const workflowWasModified = Boolean(recordHash && actualHash !== recordHash);
|
|
424
|
+
const workflowRecovery = workflowWasModified ? forcedRecovery : recovery;
|
|
425
|
+
if (workflowWasModified) {
|
|
426
|
+
issues.push(createValidationIssue(
|
|
427
|
+
"workflow-modified",
|
|
428
|
+
`${JSKIT_CI_WORKFLOW_RELATIVE_PATH} differs from the generated version recorded in .jskit/lock.json. Move application-specific CI to a separate workflow.${forcedRecovery}`
|
|
429
|
+
));
|
|
430
|
+
}
|
|
431
|
+
if (actualHash !== expectedHash || recordHash !== expectedHash) {
|
|
432
|
+
issues.push(createValidationIssue(
|
|
433
|
+
"workflow-out-of-date",
|
|
434
|
+
`The managed CI workflow does not match the installed packages.${requirementSuffix}${workflowRecovery}`
|
|
435
|
+
));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
let document = null;
|
|
439
|
+
try {
|
|
440
|
+
document = parseGithubWorkflow(actualContent);
|
|
441
|
+
} catch (error) {
|
|
442
|
+
issues.push(createValidationIssue(
|
|
443
|
+
"workflow-invalid-yaml",
|
|
444
|
+
`${JSKIT_CI_WORKFLOW_RELATIVE_PATH} is not valid YAML: ${error instanceof Error ? error.message : String(error)}.${workflowRecovery}`
|
|
445
|
+
));
|
|
446
|
+
return {
|
|
447
|
+
applicable,
|
|
448
|
+
valid: false,
|
|
449
|
+
model,
|
|
450
|
+
expectedContent,
|
|
451
|
+
expectedHash,
|
|
452
|
+
actualHash,
|
|
453
|
+
issues
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const verifyJob = ensureObject(ensureObject(ensureObject(document).jobs).verify);
|
|
458
|
+
collectEnvironmentIssues({
|
|
459
|
+
expected: model.environment,
|
|
460
|
+
actual: normalizeActualEnvironment(verifyJob.env),
|
|
461
|
+
sources: model.sources.environment,
|
|
462
|
+
issues
|
|
463
|
+
});
|
|
464
|
+
collectServiceIssues({ model, verifyJob, issues });
|
|
465
|
+
collectStepIssues({ model, verifyJob, issues });
|
|
466
|
+
|
|
467
|
+
return {
|
|
468
|
+
applicable,
|
|
469
|
+
valid: issues.length === 0,
|
|
470
|
+
model,
|
|
471
|
+
expectedContent,
|
|
472
|
+
expectedHash,
|
|
473
|
+
actualHash,
|
|
474
|
+
issues
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async function loadAppCiContext(appRoot) {
|
|
479
|
+
const { lockPath, lock } = await loadLockFile(appRoot);
|
|
480
|
+
const catalogRegistry = await loadPackageRegistry();
|
|
481
|
+
const appLocalRegistry = await loadAppLocalPackageRegistry(appRoot);
|
|
482
|
+
const packageRegistry = mergePackageRegistries(catalogRegistry, appLocalRegistry);
|
|
483
|
+
await hydratePackageRegistryFromInstalledNodeModules({
|
|
484
|
+
appRoot,
|
|
485
|
+
packageRegistry,
|
|
486
|
+
seedPackageIds: Object.keys(ensureObject(lock.installedPackages))
|
|
487
|
+
});
|
|
488
|
+
return {
|
|
489
|
+
lockPath,
|
|
490
|
+
lock,
|
|
491
|
+
packageRegistry
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async function assertAppManagedCiWorkflowUnmodified({ appRoot }) {
|
|
496
|
+
if (!(await directoryLooksLikeJskitAppRoot(appRoot))) {
|
|
497
|
+
return { applicable: false };
|
|
498
|
+
}
|
|
499
|
+
const { lock } = await loadLockFile(appRoot);
|
|
500
|
+
await assertManagedCiWorkflowUnmodified({ appRoot, lock });
|
|
501
|
+
return { applicable: true };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async function synchronizeAppCiWorkflow({
|
|
505
|
+
appRoot,
|
|
506
|
+
allowManagedOverwrite = false,
|
|
507
|
+
dryRun = false
|
|
508
|
+
}) {
|
|
509
|
+
if (!(await directoryLooksLikeJskitAppRoot(appRoot))) {
|
|
510
|
+
return { applicable: false, changed: false };
|
|
511
|
+
}
|
|
512
|
+
const { lockPath, lock, packageRegistry } = await loadAppCiContext(appRoot);
|
|
513
|
+
const result = await synchronizeManagedCiWorkflow({
|
|
514
|
+
appRoot,
|
|
515
|
+
lock,
|
|
516
|
+
packageRegistry,
|
|
517
|
+
dryRun,
|
|
518
|
+
allowManagedOverwrite
|
|
519
|
+
});
|
|
520
|
+
if (!dryRun) {
|
|
521
|
+
await writeJsonFile(lockPath, lock);
|
|
522
|
+
}
|
|
523
|
+
return {
|
|
524
|
+
...result,
|
|
525
|
+
lockPath
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function validateAppCiWorkflow({ appRoot }) {
|
|
530
|
+
if (!(await directoryLooksLikeJskitAppRoot(appRoot))) {
|
|
531
|
+
return { applicable: false, valid: true, issues: [] };
|
|
532
|
+
}
|
|
533
|
+
const { lock, packageRegistry } = await loadAppCiContext(appRoot);
|
|
534
|
+
return validateManagedCiWorkflow({
|
|
535
|
+
appRoot,
|
|
536
|
+
lock,
|
|
537
|
+
packageRegistry
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
export {
|
|
542
|
+
assertAppManagedCiWorkflowUnmodified,
|
|
543
|
+
assertManagedCiWorkflowUnmodified,
|
|
544
|
+
composeInstalledPackageCi,
|
|
545
|
+
synchronizeAppCiWorkflow,
|
|
546
|
+
synchronizeManagedCiWorkflow,
|
|
547
|
+
validateAppCiWorkflow,
|
|
548
|
+
validateManagedCiWorkflow
|
|
549
|
+
};
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
ensureObject
|
|
5
5
|
} from "../shared/collectionUtils.js";
|
|
6
6
|
import { normalizeFileMutationRecord } from "./mutationWhen.js";
|
|
7
|
+
import { normalizeCiContribution } from "./ci/contract.js";
|
|
7
8
|
|
|
8
9
|
const PACKAGE_KIND_RUNTIME = "runtime";
|
|
9
10
|
const PACKAGE_KIND_GENERATOR = "generator";
|
|
@@ -191,9 +192,14 @@ function validatePackageDescriptorShape(descriptor, descriptorPath) {
|
|
|
191
192
|
validateFileMutationShape(normalized, descriptorPath);
|
|
192
193
|
validateSourceMutationShape(normalized, descriptorPath);
|
|
193
194
|
const lifecycle = validateLifecycleShape(normalized, descriptorPath);
|
|
195
|
+
const ci = normalizeCiContribution(normalized.ci, {
|
|
196
|
+
descriptorPath,
|
|
197
|
+
packageId
|
|
198
|
+
});
|
|
194
199
|
|
|
195
200
|
return {
|
|
196
201
|
...normalized,
|
|
202
|
+
ci,
|
|
197
203
|
lifecycle,
|
|
198
204
|
kind: normalizePackageKind(normalized.kind, descriptorPath)
|
|
199
205
|
};
|
|
@@ -224,11 +230,16 @@ function validateAppLocalPackageDescriptorShape(descriptor, descriptorPath, { ex
|
|
|
224
230
|
validateFileMutationShape(normalized, descriptorPath);
|
|
225
231
|
validateSourceMutationShape(normalized, descriptorPath);
|
|
226
232
|
const lifecycle = validateLifecycleShape(normalized, descriptorPath);
|
|
233
|
+
const ci = normalizeCiContribution(normalized.ci, {
|
|
234
|
+
descriptorPath,
|
|
235
|
+
packageId
|
|
236
|
+
});
|
|
227
237
|
|
|
228
238
|
return {
|
|
229
239
|
...normalized,
|
|
230
240
|
packageId,
|
|
231
241
|
version,
|
|
242
|
+
ci,
|
|
232
243
|
lifecycle,
|
|
233
244
|
kind: normalizePackageKind(normalized.kind, descriptorPath)
|
|
234
245
|
};
|
|
@@ -12,6 +12,7 @@ import { runAppAdoptManagedScriptsCommand } from "./appCommands/adoptManagedScri
|
|
|
12
12
|
import { runAppLinkLocalPackagesCommand } from "./appCommands/linkLocalPackages.js";
|
|
13
13
|
import { runAppMigrateSourceMutationsCommand } from "./appCommands/migrateSourceMutations.js";
|
|
14
14
|
import { runAppReleaseCommand } from "./appCommands/release.js";
|
|
15
|
+
import { runAppSyncCiCommand } from "./appCommands/syncCi.js";
|
|
15
16
|
import { runAppUpdatePackagesCommand } from "./appCommands/updatePackages.js";
|
|
16
17
|
import { runAppVerifyCommand } from "./appCommands/verify.js";
|
|
17
18
|
import { runAppVerifyUiCommand } from "./appCommands/verifyUi.js";
|
|
@@ -143,6 +144,9 @@ function createAppCommands(ctx = {}) {
|
|
|
143
144
|
if (definition.name === "update-packages") {
|
|
144
145
|
return runAppUpdatePackagesCommand(ctx, { appRoot, options, stdout, stderr });
|
|
145
146
|
}
|
|
147
|
+
if (definition.name === "sync-ci") {
|
|
148
|
+
return runAppSyncCiCommand(ctx, { appRoot, options, stdout, stderr });
|
|
149
|
+
}
|
|
146
150
|
if (definition.name === "link-local-packages") {
|
|
147
151
|
return runAppLinkLocalPackagesCommand(ctx, { appRoot, options, stdout, stderr });
|
|
148
152
|
}
|
|
@@ -90,7 +90,23 @@ const APP_COMMAND_DEFINITIONS = Object.freeze({
|
|
|
90
90
|
defaults: Object.freeze([
|
|
91
91
|
"Runtime and dev @jskit-ai dependencies are updated separately so package.json sections stay correct.",
|
|
92
92
|
"Each package is moved to the latest available major.x range and npm saves the resolved exact version.",
|
|
93
|
-
"Managed migrations are refreshed afterwards unless --dry-run is used."
|
|
93
|
+
"Managed migrations and the composed CI workflow are refreshed afterwards unless --dry-run is used."
|
|
94
|
+
])
|
|
95
|
+
}),
|
|
96
|
+
"sync-ci": Object.freeze({
|
|
97
|
+
name: "sync-ci",
|
|
98
|
+
summary: "Regenerate the JSKIT-managed GitHub verification workflow from installed package contracts.",
|
|
99
|
+
usage: "jskit app sync-ci [--force]",
|
|
100
|
+
options: Object.freeze([
|
|
101
|
+
Object.freeze({
|
|
102
|
+
label: "--force",
|
|
103
|
+
description: "Replace a modified workflow that is already recorded as JSKIT-managed."
|
|
104
|
+
})
|
|
105
|
+
]),
|
|
106
|
+
defaults: Object.freeze([
|
|
107
|
+
"Recomposes CI requirements from installed package descriptors and records the generated content hash in .jskit/lock.json.",
|
|
108
|
+
"Refuses to replace a modified workflow unless --force is explicit; application-specific CI belongs in a separate workflow.",
|
|
109
|
+
"Never claims an unrecorded jskit-verify.yml or removes a customized legacy verify.yml."
|
|
94
110
|
])
|
|
95
111
|
}),
|
|
96
112
|
"link-local-packages": Object.freeze({
|
|
@@ -201,6 +217,9 @@ function buildAppCommandOptionMeta(subcommandName = "") {
|
|
|
201
217
|
if (definition.name === "adopt-managed-scripts") {
|
|
202
218
|
optionMeta.force = { inputType: "flag" };
|
|
203
219
|
}
|
|
220
|
+
if (definition.name === "sync-ci") {
|
|
221
|
+
optionMeta.force = { inputType: "flag" };
|
|
222
|
+
}
|
|
204
223
|
if (definition.name === "update-packages" || definition.name === "release") {
|
|
205
224
|
optionMeta.registry = { inputType: "text" };
|
|
206
225
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
async function runAppSyncCiCommand(ctx = {}, { appRoot = "", options = {}, stdout }) {
|
|
2
|
+
const {
|
|
3
|
+
createCliError,
|
|
4
|
+
synchronizeAppCiWorkflow
|
|
5
|
+
} = ctx;
|
|
6
|
+
const force = String(options?.inlineOptions?.force || "").trim().toLowerCase() === "true";
|
|
7
|
+
const result = await synchronizeAppCiWorkflow({
|
|
8
|
+
appRoot,
|
|
9
|
+
allowManagedOverwrite: force
|
|
10
|
+
});
|
|
11
|
+
if (!result.applicable) {
|
|
12
|
+
throw createCliError("jskit app sync-ci only works in a JSKIT app root.");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (options.json) {
|
|
16
|
+
stdout.write(`${JSON.stringify({
|
|
17
|
+
path: result.path,
|
|
18
|
+
hash: result.hash,
|
|
19
|
+
changed: result.changed,
|
|
20
|
+
removedLegacyWorkflow: result.removedLegacyWorkflow,
|
|
21
|
+
replacedModifiedWorkflow: result.replacedModifiedWorkflow
|
|
22
|
+
}, null, 2)}\n`);
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (result.replacedModifiedWorkflow) {
|
|
27
|
+
stdout.write(`Replaced the modified JSKIT-managed CI workflow: ${result.path}\n`);
|
|
28
|
+
} else if (result.changed) {
|
|
29
|
+
stdout.write(`Synchronized the JSKIT-managed CI workflow: ${result.path}\n`);
|
|
30
|
+
} else {
|
|
31
|
+
stdout.write(`The JSKIT-managed CI workflow is already current: ${result.path}\n`);
|
|
32
|
+
}
|
|
33
|
+
if (result.removedLegacyWorkflow) {
|
|
34
|
+
stdout.write("Removed the superseded .github/workflows/verify.yml scaffold.\n");
|
|
35
|
+
}
|
|
36
|
+
stdout.write(`Recorded content hash: ${result.hash}\n`);
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export { runAppSyncCiCommand };
|