@jskit-ai/jskit-cli 0.2.115 → 0.2.117

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/jskit-cli",
3
- "version": "0.2.115",
3
+ "version": "0.2.117",
4
4
  "description": "Bundle and package orchestration CLI for JSKIT apps.",
5
5
  "type": "module",
6
6
  "files": [
@@ -20,9 +20,9 @@
20
20
  "test": "node --test"
21
21
  },
22
22
  "dependencies": {
23
- "@jskit-ai/jskit-catalog": "0.1.110",
24
- "@jskit-ai/kernel": "0.1.103",
25
- "@jskit-ai/shell-web": "0.1.102",
23
+ "@jskit-ai/jskit-catalog": "0.1.112",
24
+ "@jskit-ai/kernel": "0.1.105",
25
+ "@jskit-ai/shell-web": "0.1.104",
26
26
  "@vue/compiler-sfc": "^3.5.29",
27
27
  "ts-morph": "^28.0.0"
28
28
  },
@@ -61,6 +61,62 @@ function validateFileMutationShape(descriptor, descriptorPath) {
61
61
  }
62
62
  }
63
63
 
64
+ function validateSourceMutationShape(descriptor, descriptorPath) {
65
+ const packageId = String(ensureObject(descriptor).packageId || "").trim() || "unknown-package";
66
+ const mutations = ensureObject(ensureObject(descriptor).mutations);
67
+ const sourceMutations = ensureArray(mutations.source);
68
+ const supportedOps = new Set([
69
+ "ensure-assignment",
70
+ "ensure-call",
71
+ "ensure-export-const",
72
+ "ensure-import"
73
+ ]);
74
+
75
+ for (const rawMutation of sourceMutations) {
76
+ const mutation = ensureObject(rawMutation);
77
+ const operation = String(mutation.op || "").trim();
78
+ const file = String(mutation.file || "").trim();
79
+ if (!supportedOps.has(operation)) {
80
+ throw createCliError(
81
+ `Invalid package descriptor at ${descriptorPath}: source mutation in ${packageId} has unsupported op "${operation}".`
82
+ );
83
+ }
84
+ if (!file) {
85
+ throw createCliError(
86
+ `Invalid package descriptor at ${descriptorPath}: source mutation in ${packageId} requires "file".`
87
+ );
88
+ }
89
+ if (operation === "ensure-import") {
90
+ const hasImportBinding =
91
+ String(mutation.defaultImport || "").trim() ||
92
+ String(mutation.namespaceImport || "").trim() ||
93
+ ensureArray(mutation.namedImports).length > 0;
94
+ if (!String(mutation.from || "").trim() || !hasImportBinding) {
95
+ throw createCliError(
96
+ `Invalid package descriptor at ${descriptorPath}: ensure-import in ${packageId} requires "from" and an import binding.`
97
+ );
98
+ }
99
+ }
100
+ if (operation === "ensure-call" && !String(mutation.callee || "").trim()) {
101
+ throw createCliError(
102
+ `Invalid package descriptor at ${descriptorPath}: ensure-call in ${packageId} requires "callee".`
103
+ );
104
+ }
105
+ if (operation === "ensure-assignment") {
106
+ if (!String(mutation.target || "").trim() || !String(mutation.value || "").trim()) {
107
+ throw createCliError(
108
+ `Invalid package descriptor at ${descriptorPath}: ensure-assignment in ${packageId} requires "target" and "value".`
109
+ );
110
+ }
111
+ }
112
+ if (operation === "ensure-export-const" && !String(mutation.name || "").trim()) {
113
+ throw createCliError(
114
+ `Invalid package descriptor at ${descriptorPath}: ensure-export-const in ${packageId} requires "name".`
115
+ );
116
+ }
117
+ }
118
+ }
119
+
64
120
  function validateLifecycleHookSpec(spec = {}, descriptorPath, label = "lifecycle hook") {
65
121
  const normalized = ensureObject(spec);
66
122
  if (Object.keys(normalized).length < 1) {
@@ -133,6 +189,7 @@ function validatePackageDescriptorShape(descriptor, descriptorPath) {
133
189
  }
134
190
 
135
191
  validateFileMutationShape(normalized, descriptorPath);
192
+ validateSourceMutationShape(normalized, descriptorPath);
136
193
  const lifecycle = validateLifecycleShape(normalized, descriptorPath);
137
194
 
138
195
  return {
@@ -165,6 +222,7 @@ function validateAppLocalPackageDescriptorShape(descriptor, descriptorPath, { ex
165
222
  }
166
223
 
167
224
  validateFileMutationShape(normalized, descriptorPath);
225
+ validateSourceMutationShape(normalized, descriptorPath);
168
226
  const lifecycle = validateLifecycleShape(normalized, descriptorPath);
169
227
 
170
228
  return {
@@ -8,3 +8,9 @@ export {
8
8
  partitionPreFileConfigTextMutations,
9
9
  resolvePositioningMutations
10
10
  } from "./mutations/textMutations.js";
11
+
12
+ export {
13
+ applySourceMutations,
14
+ partitionPreFileConfigSourceMutations,
15
+ resolvePositioningSourceMutations
16
+ } from "./mutations/sourceMutations.js";
@@ -0,0 +1,458 @@
1
+ import {
2
+ mkdir,
3
+ writeFile
4
+ } from "node:fs/promises";
5
+ import path from "node:path";
6
+ import {
7
+ Project,
8
+ SyntaxKind
9
+ } from "ts-morph";
10
+ import { createCliError } from "../../shared/cliError.js";
11
+ import {
12
+ ensureArray,
13
+ ensureObject
14
+ } from "../../shared/collectionUtils.js";
15
+ import {
16
+ interpolateOptionValue
17
+ } from "../../shared/optionInterpolation.js";
18
+ import {
19
+ normalizeMutationWhen,
20
+ shouldApplyMutationWhen
21
+ } from "../mutationWhen.js";
22
+ import {
23
+ loadMutationWhenConfigContext,
24
+ normalizeRelativePath,
25
+ readFileBufferIfExists
26
+ } from "../ioAndMigrations.js";
27
+ import { normalizeMutationRelativeFilePath } from "./mutationPathUtils.js";
28
+
29
+ const PRE_FILE_CONFIG_MUTATION_TARGETS = new Set([
30
+ "config/public.js",
31
+ "config/server.js"
32
+ ]);
33
+
34
+ function createSourceFile(relativeFile, sourceText) {
35
+ const project = new Project({
36
+ useInMemoryFileSystem: true,
37
+ skipAddingFilesFromTsConfig: true
38
+ });
39
+ return project.createSourceFile(`/${relativeFile}`, sourceText, { overwrite: true });
40
+ }
41
+
42
+ function normalizeSourceText(value = "") {
43
+ return String(value || "").replace(/\r\n/g, "\n");
44
+ }
45
+
46
+ function insertText(sourceText, index, text) {
47
+ return `${sourceText.slice(0, index)}${text}${sourceText.slice(index)}`;
48
+ }
49
+
50
+ function appendBlock(sourceText, block) {
51
+ const normalized = normalizeSourceText(sourceText);
52
+ const trimmed = normalized.replace(/\s*$/u, "");
53
+ if (!trimmed) {
54
+ return `${block.trim()}\n`;
55
+ }
56
+ return `${trimmed}\n\n${block.trim()}\n`;
57
+ }
58
+
59
+ function renderImportMutation(mutation, options, packageId, mutationId) {
60
+ const defaultImport = interpolateOptionValue(
61
+ mutation.defaultImport || "",
62
+ options,
63
+ packageId,
64
+ `${mutationId}.defaultImport`
65
+ ).trim();
66
+ const namespaceImport = interpolateOptionValue(
67
+ mutation.namespaceImport || "",
68
+ options,
69
+ packageId,
70
+ `${mutationId}.namespaceImport`
71
+ ).trim();
72
+ const namedImports = ensureArray(mutation.namedImports)
73
+ .map((entry, index) =>
74
+ interpolateOptionValue(entry, options, packageId, `${mutationId}.namedImports.${index}`).trim()
75
+ )
76
+ .filter(Boolean);
77
+ const from = interpolateOptionValue(mutation.from || "", options, packageId, `${mutationId}.from`).trim();
78
+
79
+ if (!from) {
80
+ throw createCliError(`Invalid ensure-import source mutation in ${packageId}: "from" is required.`);
81
+ }
82
+ if (!defaultImport && !namespaceImport && namedImports.length < 1) {
83
+ throw createCliError(
84
+ `Invalid ensure-import source mutation in ${packageId}: defaultImport, namespaceImport, or namedImports is required.`
85
+ );
86
+ }
87
+
88
+ let importClause = "";
89
+ if (namespaceImport) {
90
+ importClause = `* as ${namespaceImport}`;
91
+ } else {
92
+ const namedClause = namedImports.length > 0 ? `{ ${namedImports.join(", ")} }` : "";
93
+ importClause = [defaultImport, namedClause].filter(Boolean).join(", ");
94
+ }
95
+
96
+ return {
97
+ defaultImport,
98
+ namespaceImport,
99
+ namedImports,
100
+ from,
101
+ statement: `import ${importClause} from "${from}";`
102
+ };
103
+ }
104
+
105
+ function sourceAlreadyHasImport(sourceFile, rendered) {
106
+ return sourceFile.getImportDeclarations().some((importDeclaration) => {
107
+ if (importDeclaration.getModuleSpecifierValue() !== rendered.from) {
108
+ return false;
109
+ }
110
+
111
+ const defaultImport = importDeclaration.getDefaultImport()?.getText() || "";
112
+ if (rendered.defaultImport && defaultImport !== rendered.defaultImport) {
113
+ return false;
114
+ }
115
+
116
+ const namespaceImport = importDeclaration.getNamespaceImport()?.getText() || "";
117
+ if (rendered.namespaceImport && namespaceImport !== rendered.namespaceImport) {
118
+ return false;
119
+ }
120
+
121
+ const existingNamedImports = new Set(
122
+ importDeclaration.getNamedImports().map((namedImport) => namedImport.getName())
123
+ );
124
+ return rendered.namedImports.every((name) => existingNamedImports.has(name));
125
+ });
126
+ }
127
+
128
+ function ensureImport(sourceText, relativeFile, mutation, options, packageId, mutationId) {
129
+ const sourceFile = createSourceFile(relativeFile, sourceText);
130
+ const rendered = renderImportMutation(mutation, options, packageId, mutationId);
131
+
132
+ if (sourceAlreadyHasImport(sourceFile, rendered)) {
133
+ return {
134
+ changed: false,
135
+ content: sourceText
136
+ };
137
+ }
138
+
139
+ const importDeclarations = sourceFile.getImportDeclarations();
140
+ if (importDeclarations.length > 0) {
141
+ const lastImport = importDeclarations[importDeclarations.length - 1];
142
+ return {
143
+ changed: true,
144
+ content: insertText(sourceText, lastImport.getEnd(), `\n${rendered.statement}`)
145
+ };
146
+ }
147
+
148
+ const normalized = normalizeSourceText(sourceText);
149
+ return {
150
+ changed: true,
151
+ content: normalized.trim() ? `${rendered.statement}\n\n${normalized}` : `${rendered.statement}\n`
152
+ };
153
+ }
154
+
155
+ function normalizeSourceArg(value = "") {
156
+ const raw = String(value || "").trim();
157
+ const quotedMatch = raw.match(/^(['"])(.*)\1$/u);
158
+ if (!quotedMatch) {
159
+ return raw;
160
+ }
161
+ try {
162
+ return JSON.parse(`"${quotedMatch[2].replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`);
163
+ } catch {
164
+ return quotedMatch[2];
165
+ }
166
+ }
167
+
168
+ function callExpressionMatches(callExpression, callee, expectedArgs, uniqueArgIndex) {
169
+ if (callExpression.getExpression().getText() !== callee) {
170
+ return false;
171
+ }
172
+
173
+ const args = callExpression.getArguments();
174
+ if (uniqueArgIndex < 0 || uniqueArgIndex >= expectedArgs.length) {
175
+ return true;
176
+ }
177
+ if (uniqueArgIndex >= args.length) {
178
+ return false;
179
+ }
180
+
181
+ return normalizeSourceArg(args[uniqueArgIndex].getText()) === normalizeSourceArg(expectedArgs[uniqueArgIndex]);
182
+ }
183
+
184
+ function renderCallMutation(mutation, options, packageId, mutationId) {
185
+ const callee = interpolateOptionValue(mutation.callee || "", options, packageId, `${mutationId}.callee`).trim();
186
+ const args = ensureArray(mutation.args)
187
+ .map((entry, index) => interpolateOptionValue(entry, options, packageId, `${mutationId}.args.${index}`).trim())
188
+ .filter(Boolean);
189
+
190
+ if (!callee) {
191
+ throw createCliError(`Invalid ensure-call source mutation in ${packageId}: "callee" is required.`);
192
+ }
193
+
194
+ return {
195
+ callee,
196
+ args,
197
+ statement: `${callee}(${args.join(", ")});`
198
+ };
199
+ }
200
+
201
+ function ensureCall(sourceText, relativeFile, mutation, options, packageId, mutationId) {
202
+ const sourceFile = createSourceFile(relativeFile, sourceText);
203
+ const rendered = renderCallMutation(mutation, options, packageId, mutationId);
204
+ const uniqueArgIndex = Number.isInteger(mutation.uniqueArgIndex)
205
+ ? mutation.uniqueArgIndex
206
+ : 0;
207
+ const callExpressions = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression);
208
+ if (callExpressions.some((callExpression) =>
209
+ callExpressionMatches(callExpression, rendered.callee, rendered.args, uniqueArgIndex)
210
+ )) {
211
+ return {
212
+ changed: false,
213
+ content: sourceText
214
+ };
215
+ }
216
+
217
+ const beforeClass = String(mutation.beforeClass || "").trim();
218
+ if (beforeClass) {
219
+ const classDeclaration = sourceFile.getClass(beforeClass);
220
+ if (!classDeclaration) {
221
+ throw createCliError(
222
+ `Invalid ensure-call source mutation in ${packageId}: beforeClass "${beforeClass}" was not found in ${relativeFile}.`
223
+ );
224
+ }
225
+ return {
226
+ changed: true,
227
+ content: insertText(sourceText, classDeclaration.getStart(), `${rendered.statement}\n\n`)
228
+ };
229
+ }
230
+
231
+ return {
232
+ changed: true,
233
+ content: appendBlock(sourceText, rendered.statement)
234
+ };
235
+ }
236
+
237
+ function sourceHasAssignmentToTarget(sourceFile, target) {
238
+ return sourceFile
239
+ .getDescendantsOfKind(SyntaxKind.BinaryExpression)
240
+ .some((expression) => expression.getLeft().getText() === target);
241
+ }
242
+
243
+ function renderAssignmentStatement(target, value) {
244
+ return `${target} = ${value};`;
245
+ }
246
+
247
+ function ensureAssignment(sourceText, relativeFile, mutation, options, packageId, mutationId) {
248
+ const sourceFile = createSourceFile(relativeFile, sourceText);
249
+ const target = interpolateOptionValue(mutation.target || "", options, packageId, `${mutationId}.target`).trim();
250
+ const value = interpolateOptionValue(mutation.value || "", options, packageId, `${mutationId}.value`).trim();
251
+ if (!target) {
252
+ throw createCliError(`Invalid ensure-assignment source mutation in ${packageId}: "target" is required.`);
253
+ }
254
+ if (!value) {
255
+ throw createCliError(`Invalid ensure-assignment source mutation in ${packageId}: "value" is required.`);
256
+ }
257
+
258
+ const statements = [];
259
+ for (const rawEnsureTarget of ensureArray(mutation.ensureObjects)) {
260
+ const ensureTarget = interpolateOptionValue(
261
+ rawEnsureTarget,
262
+ options,
263
+ packageId,
264
+ `${mutationId}.ensureObjects`
265
+ ).trim();
266
+ if (!ensureTarget || sourceHasAssignmentToTarget(sourceFile, ensureTarget)) {
267
+ continue;
268
+ }
269
+ statements.push(`${ensureTarget} ||= {};`);
270
+ }
271
+
272
+ if (!sourceHasAssignmentToTarget(sourceFile, target)) {
273
+ statements.push(renderAssignmentStatement(target, value));
274
+ }
275
+
276
+ if (statements.length < 1) {
277
+ return {
278
+ changed: false,
279
+ content: sourceText
280
+ };
281
+ }
282
+
283
+ return {
284
+ changed: true,
285
+ content: appendBlock(sourceText, statements.join("\n"))
286
+ };
287
+ }
288
+
289
+ function ensureExportConst(sourceText, relativeFile, mutation, options, packageId, mutationId) {
290
+ const sourceFile = createSourceFile(relativeFile, sourceText);
291
+ const name = interpolateOptionValue(mutation.name || "", options, packageId, `${mutationId}.name`).trim();
292
+ const value = interpolateOptionValue(mutation.value || "{}", options, packageId, `${mutationId}.value`).trim() || "{}";
293
+ if (!name) {
294
+ throw createCliError(`Invalid ensure-export-const source mutation in ${packageId}: "name" is required.`);
295
+ }
296
+
297
+ if (sourceFile.getVariableDeclaration(name)) {
298
+ return {
299
+ changed: false,
300
+ content: sourceText
301
+ };
302
+ }
303
+
304
+ const statement = `export const ${name} = ${value};`;
305
+ const importDeclarations = sourceFile.getImportDeclarations();
306
+ if (importDeclarations.length > 0) {
307
+ const lastImport = importDeclarations[importDeclarations.length - 1];
308
+ return {
309
+ changed: true,
310
+ content: insertText(sourceText, lastImport.getEnd(), `\n\n${statement}`)
311
+ };
312
+ }
313
+
314
+ const normalized = normalizeSourceText(sourceText);
315
+ return {
316
+ changed: true,
317
+ content: normalized.trim() ? `${statement}\n\n${normalized}` : `${statement}\n`
318
+ };
319
+ }
320
+
321
+ function applySourceMutationToContent(sourceText, relativeFile, mutation, options, packageId) {
322
+ const operation = String(mutation?.op || "").trim();
323
+ const mutationId = String(mutation?.id || "").trim() || operation || "source";
324
+
325
+ if (operation === "ensure-import") {
326
+ return ensureImport(sourceText, relativeFile, mutation, options, packageId, mutationId);
327
+ }
328
+ if (operation === "ensure-call") {
329
+ return ensureCall(sourceText, relativeFile, mutation, options, packageId, mutationId);
330
+ }
331
+ if (operation === "ensure-assignment") {
332
+ return ensureAssignment(sourceText, relativeFile, mutation, options, packageId, mutationId);
333
+ }
334
+ if (operation === "ensure-export-const") {
335
+ return ensureExportConst(sourceText, relativeFile, mutation, options, packageId, mutationId);
336
+ }
337
+
338
+ throw createCliError(`Unsupported source mutation op "${operation}" in ${packageId}.`);
339
+ }
340
+
341
+ function createManagedSourceRecord(relativeFile, mutation) {
342
+ const mutationId = String(mutation?.id || "").trim() || String(mutation?.op || "source").trim();
343
+ const recordKey = `${relativeFile}::${mutationId}`;
344
+ return {
345
+ recordKey,
346
+ record: {
347
+ file: relativeFile,
348
+ op: String(mutation?.op || "").trim(),
349
+ id: String(mutation?.id || ""),
350
+ reason: String(mutation?.reason || ""),
351
+ category: String(mutation?.category || "")
352
+ }
353
+ };
354
+ }
355
+
356
+ async function applySourceMutations(
357
+ packageEntry,
358
+ appRoot,
359
+ sourceMutations,
360
+ options,
361
+ managedSource,
362
+ touchedFiles,
363
+ { dryRun = false } = {}
364
+ ) {
365
+ for (const mutation of sourceMutations) {
366
+ const when = normalizeMutationWhen(mutation?.when);
367
+ const configContext = when?.config ? await loadMutationWhenConfigContext(appRoot) : {};
368
+ if (
369
+ !shouldApplyMutationWhen(when, {
370
+ options,
371
+ configContext,
372
+ packageId: packageEntry.packageId,
373
+ mutationContext: "source mutation"
374
+ })
375
+ ) {
376
+ continue;
377
+ }
378
+
379
+ const relativeFile = normalizeMutationRelativeFilePath(mutation?.file || "");
380
+ if (!relativeFile) {
381
+ throw createCliError(`Invalid source mutation in ${packageEntry.packageId}: "file" is required.`);
382
+ }
383
+
384
+ const absoluteFile = path.join(appRoot, relativeFile);
385
+ const previous = await readFileBufferIfExists(absoluteFile);
386
+ const previousContent = previous.exists ? previous.buffer.toString("utf8") : "";
387
+ const applied = applySourceMutationToContent(
388
+ normalizeSourceText(previousContent),
389
+ relativeFile,
390
+ mutation,
391
+ options,
392
+ packageEntry.packageId
393
+ );
394
+ const {
395
+ recordKey,
396
+ record
397
+ } = createManagedSourceRecord(relativeFile, mutation);
398
+ if (!applied.changed) {
399
+ managedSource[recordKey] = record;
400
+ continue;
401
+ }
402
+
403
+ if (!dryRun) {
404
+ await mkdir(path.dirname(absoluteFile), { recursive: true });
405
+ await writeFile(absoluteFile, applied.content, "utf8");
406
+ }
407
+
408
+ managedSource[recordKey] = record;
409
+ touchedFiles.add(normalizeRelativePath(appRoot, absoluteFile));
410
+ }
411
+ }
412
+
413
+ function isPositioningSourceMutation(value = {}) {
414
+ const mutation = ensureObject(value);
415
+ const operation = String(mutation.op || "").trim();
416
+ if (operation !== "ensure-call") {
417
+ return false;
418
+ }
419
+ return normalizeMutationRelativeFilePath(mutation.file) === "src/placement.js";
420
+ }
421
+
422
+ function isPreFileConfigSourceMutation(value = {}) {
423
+ const mutation = ensureObject(value);
424
+ const operation = String(mutation.op || "").trim();
425
+ if (operation !== "ensure-assignment" && operation !== "ensure-export-const") {
426
+ return false;
427
+ }
428
+ return PRE_FILE_CONFIG_MUTATION_TARGETS.has(normalizeMutationRelativeFilePath(mutation.file));
429
+ }
430
+
431
+ function partitionPreFileConfigSourceMutations(sourceMutations = []) {
432
+ const preFileSourceMutations = [];
433
+ const postFileSourceMutations = [];
434
+
435
+ for (const mutation of ensureArray(sourceMutations)) {
436
+ if (isPreFileConfigSourceMutation(mutation)) {
437
+ preFileSourceMutations.push(mutation);
438
+ continue;
439
+ }
440
+ postFileSourceMutations.push(mutation);
441
+ }
442
+
443
+ return {
444
+ preFileSourceMutations,
445
+ postFileSourceMutations
446
+ };
447
+ }
448
+
449
+ function resolvePositioningSourceMutations(descriptorMutations = {}) {
450
+ const mutations = ensureObject(descriptorMutations);
451
+ return ensureArray(mutations.source).filter((mutationValue) => isPositioningSourceMutation(mutationValue));
452
+ }
453
+
454
+ export {
455
+ applySourceMutations,
456
+ partitionPreFileConfigSourceMutations,
457
+ resolvePositioningSourceMutations
458
+ };
@@ -36,10 +36,13 @@ import {
36
36
  } from "./packageTemplateResolution.js";
37
37
  import {
38
38
  applyFileMutations,
39
+ applySourceMutations,
39
40
  applyTextMutations,
41
+ partitionPreFileConfigSourceMutations,
40
42
  partitionPreFileConfigTextMutations,
41
43
  prepareFileMutations,
42
- resolvePositioningMutations
44
+ resolvePositioningMutations,
45
+ resolvePositioningSourceMutations
43
46
  } from "./mutationApplication.js";
44
47
  function createManagedRecordBase(packageEntry, options) {
45
48
  const sourceRecord = {
@@ -61,6 +64,7 @@ function createManagedRecordBase(packageEntry, options) {
61
64
  scripts: {}
62
65
  },
63
66
  text: {},
67
+ source: {},
64
68
  vite: {},
65
69
  files: [],
66
70
  migrations: []
@@ -172,6 +176,7 @@ async function applyPackagePositioning({
172
176
  scripts: cloneManagedMap(existingPackageJsonManaged.scripts)
173
177
  },
174
178
  text: cloneManagedMap(existingManaged.text),
179
+ source: cloneManagedMap(existingManaged.source),
175
180
  vite: cloneManagedMap(existingManaged.vite),
176
181
  files: cloneManagedArray(existingManaged.files),
177
182
  migrations: cloneManagedArray(existingManaged.migrations)
@@ -188,8 +193,10 @@ async function applyPackagePositioning({
188
193
 
189
194
  const mutations = ensureObject(packageEntry.descriptor.mutations);
190
195
  const positioningMutations = resolvePositioningMutations(mutations);
196
+ const positioningSourceMutations = resolvePositioningSourceMutations(mutations);
191
197
  const appliedManagedFiles = [];
192
198
  const appliedManagedText = {};
199
+ const appliedManagedSource = {};
193
200
  const preparedFileMutations = await prepareFileMutations(
194
201
  packageEntryForMutations,
195
202
  packageOptions,
@@ -225,6 +232,19 @@ async function applyPackagePositioning({
225
232
  }
226
233
  );
227
234
  }
235
+ if (positioningSourceMutations.length > 0) {
236
+ await applySourceMutations(
237
+ packageEntryForMutations,
238
+ appRoot,
239
+ positioningSourceMutations,
240
+ packageOptions,
241
+ appliedManagedSource,
242
+ touchedFiles,
243
+ {
244
+ dryRun
245
+ }
246
+ );
247
+ }
228
248
 
229
249
  if (appliedManagedFiles.length > 0) {
230
250
  const replacedPaths = new Set(
@@ -245,6 +265,12 @@ async function applyPackagePositioning({
245
265
  ...appliedManagedText
246
266
  };
247
267
  }
268
+ if (Object.keys(appliedManagedSource).length > 0) {
269
+ nextManaged.source = {
270
+ ...nextManaged.source,
271
+ ...appliedManagedSource
272
+ };
273
+ }
248
274
 
249
275
  const managedRecord = {
250
276
  ...existingInstall,
@@ -283,6 +309,7 @@ async function applyPackageMigrationsOnly({
283
309
  scripts: cloneManagedMap(existingPackageJsonManaged.scripts)
284
310
  },
285
311
  text: cloneManagedMap(existingManaged.text),
312
+ source: cloneManagedMap(existingManaged.source),
286
313
  vite: cloneManagedMap(existingManaged.vite),
287
314
  files: cloneManagedArray(existingManaged.files),
288
315
  migrations: cloneManagedArray(existingManaged.migrations)
@@ -363,6 +390,7 @@ async function applyPackageInstall({
363
390
  const mutations = ensureObject(packageEntry.descriptor.mutations);
364
391
  const fileMutations = ensureArray(mutations.files);
365
392
  const textMutations = ensureArray(mutations.text);
393
+ const sourceMutations = ensureArray(mutations.source);
366
394
  const hasSurfaceTargetedFileMutations = fileMutations.some((mutationValue) =>
367
395
  Boolean(normalizeFileMutationRecord(mutationValue).toSurface)
368
396
  );
@@ -375,6 +403,15 @@ async function applyPackageInstall({
375
403
  preFileTextMutations: [],
376
404
  postFileTextMutations: textMutations
377
405
  };
406
+ const {
407
+ preFileSourceMutations,
408
+ postFileSourceMutations
409
+ } = hasSurfaceTargetedFileMutations
410
+ ? partitionPreFileConfigSourceMutations(sourceMutations)
411
+ : {
412
+ preFileSourceMutations: [],
413
+ postFileSourceMutations: sourceMutations
414
+ };
378
415
  const templateRoot = await resolvePackageTemplateRoot({
379
416
  packageEntry,
380
417
  appRoot,
@@ -403,6 +440,19 @@ async function applyPackageInstall({
403
440
  }
404
441
  );
405
442
  }
443
+ if (preFileSourceMutations.length > 0) {
444
+ await applySourceMutations(
445
+ packageEntryForMutations,
446
+ appRoot,
447
+ preFileSourceMutations,
448
+ packageOptions,
449
+ managedRecord.managed.source,
450
+ touchedFiles,
451
+ {
452
+ dryRun
453
+ }
454
+ );
455
+ }
406
456
 
407
457
  const preparedFileMutations = await prepareFileMutations(
408
458
  packageEntryForMutations,
@@ -576,6 +626,18 @@ async function applyPackageInstall({
576
626
  }
577
627
  );
578
628
 
629
+ await applySourceMutations(
630
+ packageEntryForMutations,
631
+ appRoot,
632
+ postFileSourceMutations,
633
+ packageOptions,
634
+ managedRecord.managed.source,
635
+ touchedFiles,
636
+ {
637
+ dryRun
638
+ }
639
+ );
640
+
579
641
  await applyViteMutations(
580
642
  packageEntryForMutations,
581
643
  appRoot,
@@ -10,6 +10,7 @@ import {
10
10
  } from "./appCommandCatalog.js";
11
11
  import { runAppAdoptManagedScriptsCommand } from "./appCommands/adoptManagedScripts.js";
12
12
  import { runAppLinkLocalPackagesCommand } from "./appCommands/linkLocalPackages.js";
13
+ import { runAppMigrateSourceMutationsCommand } from "./appCommands/migrateSourceMutations.js";
13
14
  import { runAppPreparePreviewUserCommand } from "./appCommands/preparePreviewUser.js";
14
15
  import { runAppReleaseCommand } from "./appCommands/release.js";
15
16
  import { runAppUpdatePackagesCommand } from "./appCommands/updatePackages.js";
@@ -152,6 +153,9 @@ function createAppCommands(ctx = {}) {
152
153
  if (definition.name === "adopt-managed-scripts") {
153
154
  return runAppAdoptManagedScriptsCommand(ctx, { appRoot, options, stdout, stderr });
154
155
  }
156
+ if (definition.name === "migrate-source-mutations") {
157
+ return runAppMigrateSourceMutationsCommand(ctx, { appRoot, options, stdout, stderr });
158
+ }
155
159
  if (definition.name === "prepare-preview-user") {
156
160
  return runAppPreparePreviewUserCommand(ctx, { appRoot, options, stdout, stderr });
157
161
  }
@@ -188,6 +188,22 @@ const APP_COMMAND_DEFINITIONS = Object.freeze({
188
188
  "Customized script values are reported and left alone unless --force is used.",
189
189
  "This command is for apps that still carry copied JSKIT maintenance scripts."
190
190
  ])
191
+ }),
192
+ "migrate-source-mutations": Object.freeze({
193
+ name: "migrate-source-mutations",
194
+ summary: "Rewrite legacy append-text source edits into the current source-mutation layout.",
195
+ usage: "jskit app migrate-source-mutations [--dry-run]",
196
+ options: Object.freeze([
197
+ Object.freeze({
198
+ label: "--dry-run",
199
+ description: "Preview source rewrites without writing app files."
200
+ })
201
+ ]),
202
+ defaults: Object.freeze([
203
+ "Moves legacy MainClientProvider component registrations before the MainClientProvider class.",
204
+ "Leaves apps unchanged when they already use the current source-mutation layout.",
205
+ "Run this after updating JSKIT packages in older apps that were installed before source mutations."
206
+ ])
191
207
  })
192
208
  });
193
209
 
@@ -217,6 +233,7 @@ function buildAppCommandOptionMeta(subcommandName = "") {
217
233
  if (
218
234
  definition.name === "update-packages" ||
219
235
  definition.name === "adopt-managed-scripts" ||
236
+ definition.name === "migrate-source-mutations" ||
220
237
  definition.name === "release"
221
238
  ) {
222
239
  optionMeta["dry-run"] = { inputType: "flag" };
@@ -0,0 +1,538 @@
1
+ import { readFile, readdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import {
4
+ Node,
5
+ Project
6
+ } from "ts-morph";
7
+ import { fileExists } from "./shared.js";
8
+
9
+ const MAIN_CLIENT_PROVIDER_FILE = "packages/main/src/client/providers/MainClientProvider.js";
10
+ const CRUD_FORM_FIELD_ARRAY_NAMES = Object.freeze([
11
+ "UI_CREATE_FORM_FIELDS",
12
+ "UI_EDIT_FORM_FIELDS"
13
+ ]);
14
+ const CRUD_FORM_FIELD_MARKERS_BY_ARRAY = Object.freeze({
15
+ UI_CREATE_FORM_FIELDS: "// jskit:crud-ui-form-fields:new",
16
+ UI_EDIT_FORM_FIELDS: "// jskit:crud-ui-form-fields:edit"
17
+ });
18
+ const SOURCE_FILE_EXTENSIONS = new Set([".js", ".mjs", ".cjs", ".vue"]);
19
+ const SOURCE_SCAN_SKIP_DIRS = new Set([
20
+ ".git",
21
+ ".jskit",
22
+ "build",
23
+ "coverage",
24
+ "dist",
25
+ "node_modules"
26
+ ]);
27
+
28
+ function createSourceFile(sourceText = "") {
29
+ const project = new Project({
30
+ useInMemoryFileSystem: true,
31
+ skipAddingFilesFromTsConfig: true
32
+ });
33
+ return project.createSourceFile("/MainClientProvider.js", String(sourceText || ""), { overwrite: true });
34
+ }
35
+
36
+ function readStringLiteralValue(node) {
37
+ if (!node) {
38
+ return "";
39
+ }
40
+ if (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node)) {
41
+ return node.getLiteralText();
42
+ }
43
+ return "";
44
+ }
45
+
46
+ function readRegisterMainClientComponentToken(statement) {
47
+ if (!Node.isExpressionStatement(statement)) {
48
+ return "";
49
+ }
50
+ const expression = statement.getExpression();
51
+ if (!Node.isCallExpression(expression)) {
52
+ return "";
53
+ }
54
+ if (expression.getExpression().getText() !== "registerMainClientComponent") {
55
+ return "";
56
+ }
57
+ return readStringLiteralValue(expression.getArguments()[0]);
58
+ }
59
+
60
+ function buildMainClientProviderMigration(sourceText = "") {
61
+ const sourceFile = createSourceFile(sourceText);
62
+ const classDeclaration = sourceFile.getClass("MainClientProvider");
63
+ if (!classDeclaration) {
64
+ return {
65
+ changed: false,
66
+ content: sourceText,
67
+ moved: 0,
68
+ deduped: 0,
69
+ reason: "missing-class"
70
+ };
71
+ }
72
+
73
+ const classStart = classDeclaration.getStart();
74
+ const registerRecords = sourceFile
75
+ .getStatements()
76
+ .map((statement, index) => ({
77
+ statement,
78
+ index,
79
+ token: readRegisterMainClientComponentToken(statement),
80
+ text: statement.getText(),
81
+ beforeClass: statement.getStart() < classStart
82
+ }))
83
+ .filter((record) => record.token);
84
+
85
+ const movedRecords = registerRecords.filter((record) => !record.beforeClass);
86
+ const seenTokens = new Set();
87
+ const desiredRecords = [];
88
+ const duplicates = [];
89
+ for (const record of [
90
+ ...registerRecords.filter((entry) => entry.beforeClass),
91
+ ...movedRecords
92
+ ]) {
93
+ if (seenTokens.has(record.token)) {
94
+ duplicates.push(record);
95
+ continue;
96
+ }
97
+ seenTokens.add(record.token);
98
+ desiredRecords.push(record);
99
+ }
100
+
101
+ if (movedRecords.length < 1 && duplicates.length < 1) {
102
+ return {
103
+ changed: false,
104
+ content: sourceText,
105
+ moved: 0,
106
+ deduped: 0,
107
+ reason: "already-current"
108
+ };
109
+ }
110
+
111
+ for (const record of [...registerRecords].sort((left, right) => right.index - left.index)) {
112
+ record.statement.remove();
113
+ }
114
+
115
+ const nextClassIndex = sourceFile.getStatements().findIndex((statement) =>
116
+ Node.isClassDeclaration(statement) && statement.getName() === "MainClientProvider"
117
+ );
118
+ if (nextClassIndex < 0) {
119
+ return {
120
+ changed: false,
121
+ content: sourceText,
122
+ moved: 0,
123
+ deduped: 0,
124
+ reason: "missing-class"
125
+ };
126
+ }
127
+
128
+ sourceFile.insertStatements(nextClassIndex, desiredRecords.map((record) => record.text));
129
+ return {
130
+ changed: true,
131
+ content: sourceFile.getFullText(),
132
+ moved: movedRecords.length,
133
+ deduped: duplicates.length,
134
+ reason: "rewritten"
135
+ };
136
+ }
137
+
138
+ function escapeRegExp(value = "") {
139
+ return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
140
+ }
141
+
142
+ function findMatchingDelimiter(sourceText = "", openIndex = -1, openChar = "(", closeChar = ")") {
143
+ const source = String(sourceText || "");
144
+ if (openIndex < 0 || source[openIndex] !== openChar) {
145
+ return -1;
146
+ }
147
+
148
+ let depth = 0;
149
+ let quote = "";
150
+ let inLineComment = false;
151
+ let inBlockComment = false;
152
+ for (let index = openIndex; index < source.length; index += 1) {
153
+ const char = source[index];
154
+ const nextChar = source[index + 1] || "";
155
+ const previousChar = source[index - 1] || "";
156
+
157
+ if (inLineComment) {
158
+ if (char === "\n") {
159
+ inLineComment = false;
160
+ }
161
+ continue;
162
+ }
163
+ if (inBlockComment) {
164
+ if (char === "*" && nextChar === "/") {
165
+ inBlockComment = false;
166
+ index += 1;
167
+ }
168
+ continue;
169
+ }
170
+ if (quote) {
171
+ if (char === "\\" && quote !== "`") {
172
+ index += 1;
173
+ continue;
174
+ }
175
+ if (char === quote && previousChar !== "\\") {
176
+ quote = "";
177
+ }
178
+ continue;
179
+ }
180
+
181
+ if (char === "/" && nextChar === "/") {
182
+ inLineComment = true;
183
+ index += 1;
184
+ continue;
185
+ }
186
+ if (char === "/" && nextChar === "*") {
187
+ inBlockComment = true;
188
+ index += 1;
189
+ continue;
190
+ }
191
+ if (char === "\"" || char === "'" || char === "`") {
192
+ quote = char;
193
+ continue;
194
+ }
195
+ if (char === openChar) {
196
+ depth += 1;
197
+ continue;
198
+ }
199
+ if (char === closeChar) {
200
+ depth -= 1;
201
+ if (depth === 0) {
202
+ return index;
203
+ }
204
+ }
205
+ }
206
+
207
+ return -1;
208
+ }
209
+
210
+ function readLineIndent(sourceText = "", index = 0) {
211
+ const source = String(sourceText || "");
212
+ const lineStart = source.lastIndexOf("\n", Math.max(0, index - 1)) + 1;
213
+ const linePrefix = source.slice(lineStart, index);
214
+ const match = linePrefix.match(/^[ \t]*/);
215
+ return match?.[0] || "";
216
+ }
217
+
218
+ function readFormFieldKey(sourceText = "") {
219
+ const match = String(sourceText || "").match(/(?:^|[,{]\s*)(?:"key"|key)\s*:\s*["']([^"']+)["']/);
220
+ return String(match?.[1] || "");
221
+ }
222
+
223
+ function readFormFieldKeys(sourceText = "") {
224
+ const keys = new Set();
225
+ const keyPattern = /(?:^|[,{]\s*)(?:"key"|key)\s*:\s*["']([^"']+)["']/g;
226
+ let match = null;
227
+ while ((match = keyPattern.exec(String(sourceText || ""))) != null) {
228
+ if (match[1]) {
229
+ keys.add(String(match[1]));
230
+ }
231
+ }
232
+ return keys;
233
+ }
234
+
235
+ function formatArrayEntry(entrySource = "", indent = " ") {
236
+ return String(entrySource || "")
237
+ .trim()
238
+ .split(/\r?\n/)
239
+ .map((line) => `${indent}${line}`)
240
+ .join("\n");
241
+ }
242
+
243
+ function findFormFieldArrayDeclaration(sourceText = "", arrayName = "") {
244
+ const source = String(sourceText || "");
245
+ const declarationPattern = new RegExp(`\\b(?:const|let)\\s+${escapeRegExp(arrayName)}\\s*=\\s*\\[`, "g");
246
+ const match = declarationPattern.exec(source);
247
+ if (!match) {
248
+ return null;
249
+ }
250
+
251
+ const openIndex = source.indexOf("[", match.index);
252
+ const closeIndex = findMatchingDelimiter(source, openIndex, "[", "]");
253
+ if (closeIndex < 0) {
254
+ return null;
255
+ }
256
+
257
+ return {
258
+ openIndex,
259
+ closeIndex,
260
+ indent: readLineIndent(source, match.index),
261
+ content: source.slice(openIndex + 1, closeIndex)
262
+ };
263
+ }
264
+
265
+ function findLineRangeContaining(sourceText = "", searchText = "", { afterIndex = -1 } = {}) {
266
+ const source = String(sourceText || "");
267
+ const target = String(searchText || "");
268
+ if (!target) {
269
+ return null;
270
+ }
271
+
272
+ const startSearchIndex = Number.isInteger(afterIndex) && afterIndex >= 0 ? afterIndex : 0;
273
+ const matchIndex = source.indexOf(target, startSearchIndex);
274
+ if (matchIndex < 0) {
275
+ return null;
276
+ }
277
+
278
+ const lineStart = source.lastIndexOf("\n", Math.max(0, matchIndex - 1)) + 1;
279
+ let lineEnd = source.indexOf("\n", matchIndex);
280
+ if (lineEnd < 0) {
281
+ lineEnd = source.length;
282
+ } else {
283
+ lineEnd += 1;
284
+ }
285
+
286
+ return {
287
+ startIndex: lineStart,
288
+ endIndex: lineEnd,
289
+ text: source.slice(lineStart, lineEnd)
290
+ };
291
+ }
292
+
293
+ function findFormFieldPushCalls(sourceText = "", arrayName = "") {
294
+ const source = String(sourceText || "");
295
+ const callPattern = new RegExp(`^[ \\t]*${escapeRegExp(arrayName)}\\.push\\s*\\(`, "gm");
296
+ const calls = [];
297
+ let match = null;
298
+ while ((match = callPattern.exec(source)) != null) {
299
+ const openIndex = source.indexOf("(", match.index);
300
+ const closeIndex = findMatchingDelimiter(source, openIndex, "(", ")");
301
+ if (closeIndex < 0) {
302
+ continue;
303
+ }
304
+
305
+ let endIndex = closeIndex + 1;
306
+ while (endIndex < source.length && /[ \t]/.test(source[endIndex])) {
307
+ endIndex += 1;
308
+ }
309
+ if (source[endIndex] === ";") {
310
+ endIndex += 1;
311
+ }
312
+ while (endIndex < source.length && /[ \t]/.test(source[endIndex])) {
313
+ endIndex += 1;
314
+ }
315
+ if (source.slice(endIndex, endIndex + 2) === "\r\n") {
316
+ endIndex += 2;
317
+ } else if (source[endIndex] === "\n") {
318
+ endIndex += 1;
319
+ }
320
+
321
+ const argumentSource = source.slice(openIndex + 1, closeIndex).trim();
322
+ if (!argumentSource.startsWith("{") || !argumentSource.endsWith("}")) {
323
+ continue;
324
+ }
325
+
326
+ calls.push({
327
+ startIndex: match.index,
328
+ endIndex,
329
+ argumentSource,
330
+ key: readFormFieldKey(argumentSource)
331
+ });
332
+ }
333
+
334
+ return calls;
335
+ }
336
+
337
+ function buildArrayContentWithAddedEntries(existingContent = "", entries = [], indent = "", { marker = "" } = {}) {
338
+ const entriesBlock = entries
339
+ .map((entry) => formatArrayEntry(entry, `${indent} `))
340
+ .join(",\n");
341
+ const markerLine = String(marker || "").trim() ? `${indent} ${String(marker || "").trim()}` : "";
342
+ const normalizedExistingContent = markerLine
343
+ ? String(existingContent || "").replace(new RegExp(`^[ \\t]*${escapeRegExp(marker)}[ \\t]*\\r?\\n?`, "m"), "")
344
+ : String(existingContent || "");
345
+ if (!entriesBlock && !markerLine) {
346
+ return normalizedExistingContent;
347
+ }
348
+
349
+ const trimmedExisting = normalizedExistingContent.trim();
350
+ if (!trimmedExisting) {
351
+ const lines = [entriesBlock, markerLine].filter(Boolean);
352
+ return `\n${lines.join("\n")}\n${indent}`;
353
+ }
354
+
355
+ const existingWithoutTrailingWhitespace = normalizedExistingContent.trimEnd();
356
+ const existingWithComma = existingWithoutTrailingWhitespace.endsWith(",")
357
+ ? existingWithoutTrailingWhitespace
358
+ : `${existingWithoutTrailingWhitespace},`;
359
+ const appendedLines = [entriesBlock, markerLine].filter(Boolean).join("\n");
360
+ return `${existingWithComma}\n${appendedLines}\n${indent}`;
361
+ }
362
+
363
+ function applyTextReplacements(sourceText = "", replacements = []) {
364
+ let content = String(sourceText || "");
365
+ for (const replacement of [...replacements].sort((left, right) => right.start - left.start)) {
366
+ content = `${content.slice(0, replacement.start)}${replacement.text}${content.slice(replacement.end)}`;
367
+ }
368
+ return content;
369
+ }
370
+
371
+ function buildCrudFormFieldPushMigration(sourceText = "") {
372
+ const source = String(sourceText || "");
373
+ if (!source.includes("UI_")) {
374
+ return {
375
+ changed: false,
376
+ content: source,
377
+ folded: 0,
378
+ movedMarkers: 0,
379
+ reason: "no-form-field-pushes"
380
+ };
381
+ }
382
+
383
+ const replacements = [];
384
+ let folded = 0;
385
+ let movedMarkers = 0;
386
+ for (const arrayName of CRUD_FORM_FIELD_ARRAY_NAMES) {
387
+ const declaration = findFormFieldArrayDeclaration(source, arrayName);
388
+ const calls = findFormFieldPushCalls(source, arrayName);
389
+ const marker = CRUD_FORM_FIELD_MARKERS_BY_ARRAY[arrayName] || "";
390
+ const markerInArray = marker ? declaration?.content?.includes(marker) === true : true;
391
+ const markerLineRange = marker && !markerInArray && declaration
392
+ ? findLineRangeContaining(source, marker, { afterIndex: declaration.closeIndex })
393
+ : null;
394
+ if (!declaration || (calls.length < 1 && !markerLineRange)) {
395
+ continue;
396
+ }
397
+
398
+ const knownKeys = readFormFieldKeys(declaration.content);
399
+ const entriesToAdd = [];
400
+ for (const call of calls) {
401
+ const callKey = call.key;
402
+ if (callKey && knownKeys.has(callKey)) {
403
+ replacements.push({ start: call.startIndex, end: call.endIndex, text: "" });
404
+ folded += 1;
405
+ continue;
406
+ }
407
+ if (callKey) {
408
+ knownKeys.add(callKey);
409
+ }
410
+ entriesToAdd.push(call.argumentSource);
411
+ replacements.push({ start: call.startIndex, end: call.endIndex, text: "" });
412
+ folded += 1;
413
+ }
414
+
415
+ if (markerLineRange) {
416
+ replacements.push({ start: markerLineRange.startIndex, end: markerLineRange.endIndex, text: "" });
417
+ movedMarkers += 1;
418
+ }
419
+
420
+ if (entriesToAdd.length > 0 || markerLineRange) {
421
+ const shouldAddMarker = marker && !markerInArray;
422
+ replacements.push({
423
+ start: declaration.openIndex + 1,
424
+ end: declaration.closeIndex,
425
+ text: buildArrayContentWithAddedEntries(declaration.content, entriesToAdd, declaration.indent, {
426
+ marker: shouldAddMarker ? marker : ""
427
+ })
428
+ });
429
+ }
430
+ }
431
+
432
+ if (folded < 1 && movedMarkers < 1) {
433
+ return {
434
+ changed: false,
435
+ content: source,
436
+ folded: 0,
437
+ movedMarkers: 0,
438
+ reason: "no-foldable-form-field-pushes"
439
+ };
440
+ }
441
+
442
+ return {
443
+ changed: true,
444
+ content: applyTextReplacements(source, replacements),
445
+ folded,
446
+ movedMarkers,
447
+ reason: "rewritten"
448
+ };
449
+ }
450
+
451
+ async function collectCrudFormFieldCandidateFiles(appRoot = "") {
452
+ const pagesRoot = path.join(appRoot, "src", "pages");
453
+ if (!(await fileExists(pagesRoot))) {
454
+ return [];
455
+ }
456
+
457
+ const files = [];
458
+ async function visit(directory) {
459
+ const entries = await readdir(directory, { withFileTypes: true });
460
+ for (const entry of entries) {
461
+ if (entry.name.startsWith(".") || SOURCE_SCAN_SKIP_DIRS.has(entry.name)) {
462
+ continue;
463
+ }
464
+ const absolutePath = path.join(directory, entry.name);
465
+ if (entry.isDirectory()) {
466
+ await visit(absolutePath);
467
+ continue;
468
+ }
469
+ if (!entry.isFile() || !SOURCE_FILE_EXTENSIONS.has(path.extname(entry.name))) {
470
+ continue;
471
+ }
472
+ files.push(absolutePath);
473
+ }
474
+ }
475
+
476
+ await visit(pagesRoot);
477
+ return files.sort();
478
+ }
479
+
480
+ async function runAppMigrateSourceMutationsCommand(_ctx = {}, { appRoot = "", options = {}, stdout }) {
481
+ const dryRun = options?.dryRun === true;
482
+ let changedCount = 0;
483
+ const absolutePath = path.join(appRoot, MAIN_CLIENT_PROVIDER_FILE);
484
+ if (await fileExists(absolutePath)) {
485
+ const previousContent = await readFile(absolutePath, "utf8");
486
+ const migrated = buildMainClientProviderMigration(previousContent);
487
+ if (migrated.changed) {
488
+ if (!dryRun) {
489
+ await writeFile(absolutePath, migrated.content, "utf8");
490
+ }
491
+
492
+ changedCount += 1;
493
+ const action = dryRun ? "would rewrite" : "rewrote";
494
+ const movedSuffix = migrated.moved === 1 ? "1 component registration" : `${migrated.moved} component registrations`;
495
+ const dedupeSuffix = migrated.deduped > 0
496
+ ? ` and removed ${migrated.deduped} duplicate registration${migrated.deduped === 1 ? "" : "s"}`
497
+ : "";
498
+ stdout.write(
499
+ `[migrate-source-mutations] ${action} ${MAIN_CLIENT_PROVIDER_FILE}: moved ${movedSuffix} before MainClientProvider${dedupeSuffix}.\n`
500
+ );
501
+ }
502
+ }
503
+
504
+ const crudFormFieldFiles = await collectCrudFormFieldCandidateFiles(appRoot);
505
+ for (const filePath of crudFormFieldFiles) {
506
+ const previousContent = await readFile(filePath, "utf8");
507
+ const migrated = buildCrudFormFieldPushMigration(previousContent);
508
+ if (!migrated.changed) {
509
+ continue;
510
+ }
511
+ if (!dryRun) {
512
+ await writeFile(filePath, migrated.content, "utf8");
513
+ }
514
+
515
+ changedCount += 1;
516
+ const relativePath = path.relative(appRoot, filePath).replaceAll(path.sep, "/");
517
+ const action = dryRun ? "would rewrite" : "rewrote";
518
+ const foldedSuffix = migrated.folded === 1 ? "1 form field push" : `${migrated.folded} form field pushes`;
519
+ const markerSuffix = migrated.movedMarkers > 0
520
+ ? ` and moved ${migrated.movedMarkers} form-field marker${migrated.movedMarkers === 1 ? "" : "s"} into array literals`
521
+ : "";
522
+ stdout.write(
523
+ `[migrate-source-mutations] ${action} ${relativePath}: folded ${foldedSuffix} into array literals${markerSuffix}.\n`
524
+ );
525
+ }
526
+
527
+ if (changedCount < 1) {
528
+ stdout.write("[migrate-source-mutations] source files are already current.\n");
529
+ }
530
+
531
+ return 0;
532
+ }
533
+
534
+ export {
535
+ buildCrudFormFieldPushMigration,
536
+ buildMainClientProviderMigration,
537
+ runAppMigrateSourceMutationsCommand
538
+ };
@@ -78,6 +78,7 @@ function renderPackagePayloadText({
78
78
  const devMutations = ensureObject(ensureObject(payload.mutations).dependencies).dev || {};
79
79
  const scriptMutations = ensureObject(ensureObject(payload.mutations).packageJson).scripts || {};
80
80
  const textMutations = ensureArray(ensureObject(payload.mutations).text);
81
+ const sourceMutations = ensureArray(ensureObject(payload.mutations).source);
81
82
  const runtimeMutationEntries = Object.entries(ensureObject(runtimeMutations));
82
83
  const devMutationEntries = Object.entries(ensureObject(devMutations));
83
84
  const scriptMutationEntries = Object.entries(ensureObject(scriptMutations));
@@ -371,6 +372,23 @@ function renderPackagePayloadText({
371
372
  }
372
373
  }
373
374
 
375
+ if (sourceMutations.length > 0) {
376
+ stdout.write(`${color.heading(`Source mutations (${sourceMutations.length}):`)}\n`);
377
+ for (const mutation of sourceMutations) {
378
+ const record = ensureObject(mutation);
379
+ const op = String(record.op || "").trim();
380
+ const file = String(record.file || "").trim();
381
+ const reason = String(record.reason || "").trim();
382
+ const reasonSuffix = reason ? `: ${reason}` : "";
383
+ let target = String(record.target || record.callee || record.name || record.from || "").trim();
384
+ if (op === "ensure-import") {
385
+ target = String(record.from || "").trim();
386
+ }
387
+ const mutationLabel = `${op} ${file} ${target}`.trim();
388
+ stdout.write(`- ${color.item(mutationLabel)}${reasonSuffix}\n`);
389
+ }
390
+ }
391
+
374
392
  if (payload.fileWritePlan.fileCount > 0) {
375
393
  stdout.write(`${color.heading(`File writes (${payload.fileWritePlan.fileCount}):`)}\n`);
376
394
  for (const group of payload.fileWritePlan.groups) {
@@ -136,13 +136,13 @@ const COMMAND_DESCRIPTORS = Object.freeze({
136
136
  parameters: Object.freeze([
137
137
  Object.freeze({
138
138
  name: "<subcommand>",
139
- description: "verify | update-packages | link-local-packages | release | adopt-managed-scripts."
139
+ description: "verify | update-packages | link-local-packages | release | adopt-managed-scripts | migrate-source-mutations."
140
140
  })
141
141
  ]),
142
142
  defaults: Object.freeze([
143
143
  "The scaffold keeps npm run shortcuts such as verify and jskit:update, but their maintained behavior lives under jskit app.",
144
144
  "Use jskit app <subcommand> help for subcommand-specific usage.",
145
- "--dry-run is accepted by update-packages, adopt-managed-scripts, and release."
145
+ "--dry-run is accepted by update-packages, adopt-managed-scripts, migrate-source-mutations, and release."
146
146
  ]),
147
147
  examples: Object.freeze([
148
148
  Object.freeze({
@@ -156,7 +156,9 @@ const COMMAND_DESCRIPTORS = Object.freeze({
156
156
  label: "Existing app migration",
157
157
  lines: Object.freeze([
158
158
  "jskit app adopt-managed-scripts --dry-run",
159
- "jskit app adopt-managed-scripts --force"
159
+ "jskit app adopt-managed-scripts --force",
160
+ "jskit app migrate-source-mutations --dry-run",
161
+ "jskit app migrate-source-mutations"
160
162
  ])
161
163
  })
162
164
  ]),