@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.
@@ -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 };