@jskit-ai/jskit-cli 0.2.41 → 0.2.43

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.
Files changed (26) hide show
  1. package/package.json +4 -3
  2. package/src/server/cliRuntime/completion.js +1177 -0
  3. package/src/server/cliRuntime/descriptorValidation.js +18 -3
  4. package/src/server/cliRuntime/ioAndMigrations.js +2 -2
  5. package/src/server/cliRuntime/mutationApplication.js +1 -1
  6. package/src/server/cliRuntime/mutationWhen.js +2 -0
  7. package/src/server/cliRuntime/mutations/fileMutations.js +188 -143
  8. package/src/server/cliRuntime/mutations/installMigrationMutation.js +11 -38
  9. package/src/server/cliRuntime/mutations/templateContext.js +8 -14
  10. package/src/server/cliRuntime/mutations/textMutations.js +11 -6
  11. package/src/server/cliRuntime/packageInstallFlow.js +36 -21
  12. package/src/server/cliRuntime/packageIntrospection/placementNormalization.js +13 -22
  13. package/src/server/cliRuntime/packageOptions.js +149 -3
  14. package/src/server/cliRuntime/packageRegistries.js +3 -2
  15. package/src/server/commandHandlers/completion.js +129 -0
  16. package/src/server/commandHandlers/list.js +4 -6
  17. package/src/server/commandHandlers/packageCommands/add.js +31 -11
  18. package/src/server/commandHandlers/packageCommands/discoverabilityHelp.js +10 -2
  19. package/src/server/commandHandlers/packageCommands/generate.js +29 -31
  20. package/src/server/commandHandlers/packageCommands/tabLinkItemProvisioning.js +123 -164
  21. package/src/server/commandHandlers/shared.js +23 -3
  22. package/src/server/commandHandlers/show/renderPackageText.js +3 -3
  23. package/src/server/core/argParser.js +12 -2
  24. package/src/server/core/commandCatalog.js +36 -13
  25. package/src/server/core/createCommandHandlers.js +3 -0
  26. package/src/server/shared/optionInterpolation.js +93 -0
@@ -24,12 +24,27 @@ function normalizePackageKind(rawValue, descriptorPath) {
24
24
  return normalized;
25
25
  }
26
26
 
27
- function validateInstallMigrationMutationShape(descriptor, descriptorPath) {
27
+ function validateFileMutationShape(descriptor, descriptorPath) {
28
28
  const packageId = String(ensureObject(descriptor).packageId || "").trim() || "unknown-package";
29
29
  const mutations = ensureObject(ensureObject(descriptor).mutations);
30
30
  const files = ensureArray(mutations.files);
31
31
  for (const fileMutation of files) {
32
32
  const normalized = normalizeFileMutationRecord(fileMutation);
33
+ if (normalized.ownership !== "package" && normalized.ownership !== "app") {
34
+ throw createCliError(
35
+ `Invalid package descriptor at ${descriptorPath}: files mutation in ${packageId} has unsupported ownership "${normalized.ownership}". Expected "package" or "app".`
36
+ );
37
+ }
38
+ if (normalized.expectedExistingFrom && normalized.op !== "copy-file") {
39
+ throw createCliError(
40
+ `Invalid package descriptor at ${descriptorPath}: files mutation in ${packageId} can only use "expectedExistingFrom" with copy-file.`
41
+ );
42
+ }
43
+ if (normalized.expectedExistingFrom && normalized.ownership !== "app") {
44
+ throw createCliError(
45
+ `Invalid package descriptor at ${descriptorPath}: files mutation in ${packageId} can only use "expectedExistingFrom" when ownership is "app".`
46
+ );
47
+ }
33
48
  if (normalized.op !== "install-migration") {
34
49
  continue;
35
50
  }
@@ -69,7 +84,7 @@ function validatePackageDescriptorShape(descriptor, descriptorPath) {
69
84
  );
70
85
  }
71
86
 
72
- validateInstallMigrationMutationShape(normalized, descriptorPath);
87
+ validateFileMutationShape(normalized, descriptorPath);
73
88
 
74
89
  return {
75
90
  ...normalized,
@@ -99,7 +114,7 @@ function validateAppLocalPackageDescriptorShape(descriptor, descriptorPath, { ex
99
114
  throw createCliError(`Invalid app-local package descriptor at ${descriptorPath}: missing version.`);
100
115
  }
101
116
 
102
- validateInstallMigrationMutationShape(normalized, descriptorPath);
117
+ validateFileMutationShape(normalized, descriptorPath);
103
118
 
104
119
  return {
105
120
  ...normalized,
@@ -8,7 +8,7 @@ import {
8
8
  writeFile
9
9
  } from "node:fs/promises";
10
10
  import path from "node:path";
11
- import { pathToFileURL } from "node:url";
11
+ import { importFreshModuleFromAbsolutePath } from "@jskit-ai/kernel/server/support";
12
12
  import { createCliError } from "../shared/cliError.js";
13
13
  import {
14
14
  ensureArray,
@@ -325,7 +325,7 @@ async function loadAppConfigModuleConfig(appRoot, relativePath) {
325
325
 
326
326
  let moduleNamespace = null;
327
327
  try {
328
- moduleNamespace = await import(`${pathToFileURL(absolutePath).href}?t=${Date.now()}_${Math.random()}`);
328
+ moduleNamespace = await importFreshModuleFromAbsolutePath(absolutePath);
329
329
  } catch (error) {
330
330
  throw createCliError(
331
331
  `Unable to load ${relativePath}: ${String(error?.message || error || "unknown error")}`
@@ -1,6 +1,6 @@
1
1
  export {
2
2
  applyFileMutations,
3
- preflightFileMutationTemplateContexts
3
+ prepareFileMutations
4
4
  } from "./mutations/fileMutations.js";
5
5
 
6
6
  export {
@@ -41,6 +41,8 @@ function normalizeFileMutationRecord(value) {
41
41
  toSurfaceRoot: record.toSurfaceRoot === true,
42
42
  toDir: String(record.toDir || "").trim(),
43
43
  extension: normalizeMutationExtension(record.extension),
44
+ ownership: String(record.ownership || "").trim().toLowerCase() || "package",
45
+ expectedExistingFrom: String(record.expectedExistingFrom || "").trim(),
44
46
  preserveOnRemove: record.preserveOnRemove === true,
45
47
  id: String(record.id || "").trim(),
46
48
  category: String(record.category || "").trim(),
@@ -1,5 +1,6 @@
1
1
  import {
2
- readFile
2
+ mkdir,
3
+ writeFile
3
4
  } from "node:fs/promises";
4
5
  import path from "node:path";
5
6
  import { createCliError } from "../../shared/cliError.js";
@@ -20,151 +21,32 @@ import {
20
21
  resolveAppRelativePathWithinRoot
21
22
  } from "../ioAndMigrations.js";
22
23
  import {
23
- copyTemplateFile,
24
24
  interpolateFileMutationRecord,
25
+ renderTemplateFile,
25
26
  resolveTemplateContextReplacementsForMutation
26
27
  } from "./templateContext.js";
27
28
  import { resolveSurfaceTargetPathsForMutation } from "./surfaceTargets.js";
28
29
  import { applyInstallMigrationMutation } from "./installMigrationMutation.js";
29
30
 
30
- async function applyFileMutations(
31
+ async function prepareFileMutations(
31
32
  packageEntry,
32
33
  options,
33
34
  appRoot,
34
35
  fileMutations,
35
- managedFiles,
36
- managedMigrations,
37
- touchedFiles,
38
- warnings = [],
39
- precomputedTemplateContextByMutationIndex = null
36
+ existingManagedFiles = []
40
37
  ) {
41
38
  const mutationList = ensureArray(fileMutations);
42
- const managedMigrationById = new Map();
43
- for (const managedMigrationValue of ensureArray(managedMigrations)) {
44
- const managedMigration = ensureObject(managedMigrationValue);
45
- const migrationId = String(managedMigration.id || "").trim();
46
- if (!migrationId) {
47
- continue;
48
- }
49
- managedMigrationById.set(migrationId, managedMigration);
50
- }
51
-
52
- for (const [mutationIndex, mutationValue] of mutationList.entries()) {
53
- const normalizedMutation = normalizeFileMutationRecord(mutationValue);
54
- const requiresConfigContext = Boolean(normalizedMutation.when?.config || normalizedMutation.toSurface);
55
- const configContext = requiresConfigContext ? await loadMutationWhenConfigContext(appRoot) : {};
56
- if (
57
- !shouldApplyMutationWhen(normalizedMutation.when, {
58
- options,
59
- configContext,
60
- packageId: packageEntry.packageId,
61
- mutationContext: "files mutation"
62
- })
63
- ) {
64
- continue;
65
- }
66
-
67
- const mutation = interpolateFileMutationRecord(normalizedMutation, options, packageEntry.packageId);
68
- const operation = mutation.op || "copy-file";
69
-
70
- if (operation === "install-migration") {
71
- await applyInstallMigrationMutation({
72
- packageEntry,
73
- mutation,
74
- rawMutation: mutationValue,
75
- mutationIndex,
76
- options,
77
- appRoot,
78
- managedMigrations,
79
- managedMigrationById,
80
- touchedFiles,
81
- warnings,
82
- precomputedTemplateContextByMutationIndex
83
- });
39
+ const existingManagedFilesByPath = new Map();
40
+ for (const managedFileValue of ensureArray(existingManagedFiles)) {
41
+ const managedFile = ensureObject(managedFileValue);
42
+ const managedPath = String(managedFile.path || "").trim();
43
+ if (!managedPath) {
84
44
  continue;
85
45
  }
86
-
87
- if (operation !== "copy-file") {
88
- throw createCliError(`Unsupported files mutation op \"${operation}\" in ${packageEntry.packageId}.`);
89
- }
90
-
91
- const from = mutation.from;
92
- const to = mutation.to;
93
- const toSurface = mutation.toSurface;
94
- if (to && toSurface) {
95
- throw createCliError(
96
- `Invalid files mutation in ${packageEntry.packageId}: "to" and "toSurface" cannot both be set.`
97
- );
98
- }
99
- if (!from || (!to && !toSurface)) {
100
- throw createCliError(
101
- `Invalid files mutation in ${packageEntry.packageId}: "from" plus one destination ("to" or "toSurface") are required.`
102
- );
103
- }
104
-
105
- const sourcePath = path.join(packageEntry.rootDir, from);
106
- if (!(await fileExists(sourcePath))) {
107
- throw createCliError(`Missing template source ${sourcePath} for ${packageEntry.packageId}.`);
108
- }
109
-
110
- const targetPaths = toSurface
111
- ? resolveSurfaceTargetPathsForMutation({
112
- appRoot,
113
- packageId: packageEntry.packageId,
114
- mutation,
115
- configContext
116
- })
117
- : [resolveAppRelativePathWithinRoot(appRoot, to, `${packageEntry.packageId} files mutation.to`).absolutePath];
118
- const hasPrecomputedTemplateContext =
119
- precomputedTemplateContextByMutationIndex instanceof Map &&
120
- precomputedTemplateContextByMutationIndex.has(mutationIndex);
121
- const templateContextReplacements = hasPrecomputedTemplateContext
122
- ? precomputedTemplateContextByMutationIndex.get(mutationIndex)
123
- : await resolveTemplateContextReplacementsForMutation({
124
- packageEntry,
125
- mutation,
126
- options,
127
- appRoot,
128
- sourcePath,
129
- targetPaths
130
- });
131
-
132
- for (const targetPath of targetPaths) {
133
- const previous = await readFileBufferIfExists(targetPath);
134
- await copyTemplateFile(
135
- sourcePath,
136
- targetPath,
137
- options,
138
- packageEntry.packageId,
139
- `${mutation.id || to || from}.source`,
140
- templateContextReplacements
141
- );
142
- const nextBuffer = await readFile(targetPath);
143
-
144
- managedFiles.push({
145
- path: normalizeRelativePath(appRoot, targetPath),
146
- hash: hashBuffer(nextBuffer),
147
- hadPrevious: previous.exists,
148
- previousContentBase64: previous.exists ? previous.buffer.toString("base64") : "",
149
- preserveOnRemove: mutation.preserveOnRemove,
150
- reason: mutation.reason,
151
- category: mutation.category,
152
- id: mutation.id
153
- });
154
- touchedFiles.add(normalizeRelativePath(appRoot, targetPath));
155
- }
46
+ existingManagedFilesByPath.set(managedPath, managedFile);
156
47
  }
157
- }
158
-
159
- async function preflightFileMutationTemplateContexts(
160
- packageEntry,
161
- options,
162
- appRoot,
163
- fileMutations
164
- ) {
165
- const mutationList = ensureArray(fileMutations);
166
- const replacementsByMutationIndex = new Map();
167
48
 
49
+ const preparedMutations = [];
168
50
  for (const [mutationIndex, mutationValue] of mutationList.entries()) {
169
51
  const normalizedMutation = normalizeFileMutationRecord(mutationValue);
170
52
  const requiresConfigContext = Boolean(normalizedMutation.when?.config || normalizedMutation.toSurface);
@@ -181,23 +63,16 @@ async function preflightFileMutationTemplateContexts(
181
63
  }
182
64
 
183
65
  const mutation = interpolateFileMutationRecord(normalizedMutation, options, packageEntry.packageId);
184
- const templateContext = ensureObject(mutation.templateContext);
185
- if (Object.keys(templateContext).length < 1) {
186
- continue;
187
- }
188
-
189
66
  const operation = mutation.op || "copy-file";
190
67
  if (operation !== "copy-file" && operation !== "install-migration") {
191
- continue;
68
+ throw createCliError(`Unsupported files mutation op "${operation}" in ${packageEntry.packageId}.`);
192
69
  }
193
70
 
194
71
  const from = mutation.from;
195
72
  const to = mutation.to;
196
73
  const toSurface = mutation.toSurface;
197
74
  if (!from) {
198
- throw createCliError(
199
- `Invalid files mutation in ${packageEntry.packageId}: "from" is required.`
200
- );
75
+ throw createCliError(`Invalid files mutation in ${packageEntry.packageId}: "from" is required.`);
201
76
  }
202
77
  if (operation === "copy-file") {
203
78
  if (to && toSurface) {
@@ -227,7 +102,7 @@ async function preflightFileMutationTemplateContexts(
227
102
  })
228
103
  : [resolveAppRelativePathWithinRoot(appRoot, to, `${packageEntry.packageId} files mutation.to`).absolutePath]
229
104
  : [path.join(appRoot, mutation.toDir || "migrations")];
230
- const replacements = await resolveTemplateContextReplacementsForMutation({
105
+ const templateContextReplacements = await resolveTemplateContextReplacementsForMutation({
231
106
  packageEntry,
232
107
  mutation,
233
108
  options,
@@ -236,13 +111,183 @@ async function preflightFileMutationTemplateContexts(
236
111
  targetPaths,
237
112
  mutationContext: "files mutation"
238
113
  });
239
- replacementsByMutationIndex.set(mutationIndex, replacements);
114
+ const interpolationKey = `${mutation.id || to || from}.source`;
115
+ const renderedSourceContent = await renderTemplateFile(
116
+ sourcePath,
117
+ options,
118
+ packageEntry.packageId,
119
+ interpolationKey,
120
+ templateContextReplacements
121
+ );
122
+
123
+ if (operation === "copy-file" && mutation.ownership === "app") {
124
+ const renderedSourceBuffer = Buffer.from(renderedSourceContent, "utf8");
125
+ const renderedSourceHash = hashBuffer(renderedSourceBuffer);
126
+ let expectedExistingHash = "";
127
+ if (mutation.expectedExistingFrom) {
128
+ const expectedExistingPath = path.join(packageEntry.rootDir, mutation.expectedExistingFrom);
129
+ if (!(await fileExists(expectedExistingPath))) {
130
+ throw createCliError(
131
+ `Missing expected existing source ${expectedExistingPath} for ${packageEntry.packageId}.`
132
+ );
133
+ }
134
+ const expectedExistingContent = await renderTemplateFile(
135
+ expectedExistingPath,
136
+ options,
137
+ packageEntry.packageId,
138
+ `${mutation.id || to || from}.expectedExisting`,
139
+ templateContextReplacements
140
+ );
141
+ expectedExistingHash = hashBuffer(Buffer.from(expectedExistingContent, "utf8"));
142
+ }
143
+
144
+ for (const targetPath of targetPaths) {
145
+ const relativeTargetPath = normalizeRelativePath(appRoot, targetPath);
146
+ if (existingManagedFilesByPath.has(relativeTargetPath)) {
147
+ const existing = await readFileBufferIfExists(targetPath);
148
+ if (!existing.exists) {
149
+ throw createCliError(
150
+ `${packageEntry.packageId}: app-owned file ${relativeTargetPath} is managed in lock but missing on disk. Restore it before updating, or remove and re-add the package intentionally.`
151
+ );
152
+ }
153
+ continue;
154
+ }
155
+
156
+ const existing = await readFileBufferIfExists(targetPath);
157
+ if (!existing.exists) {
158
+ continue;
159
+ }
160
+
161
+ const existingHash = hashBuffer(existing.buffer);
162
+ if (existingHash === renderedSourceHash) {
163
+ continue;
164
+ }
165
+ if (expectedExistingHash && existingHash === expectedExistingHash) {
166
+ continue;
167
+ }
168
+
169
+ const expectedSourceLabel = mutation.expectedExistingFrom
170
+ ? ` or match ${mutation.expectedExistingFrom}`
171
+ : "";
172
+ throw createCliError(
173
+ `${packageEntry.packageId}: app-owned file ${relativeTargetPath} already exists and cannot be claimed. It must already match the rendered scaffold${expectedSourceLabel}.`
174
+ );
175
+ }
176
+ }
177
+
178
+ preparedMutations.push({
179
+ mutationIndex,
180
+ mutation,
181
+ operation,
182
+ sourcePath,
183
+ targetPaths,
184
+ renderedSourceContent
185
+ });
186
+ }
187
+
188
+ return preparedMutations;
189
+ }
190
+
191
+ async function applyFileMutations(
192
+ packageEntry,
193
+ appRoot,
194
+ preparedMutations,
195
+ managedFiles,
196
+ managedMigrations,
197
+ touchedFiles,
198
+ warnings = [],
199
+ existingManagedFiles = []
200
+ ) {
201
+ const existingManagedFilesByPath = new Map();
202
+ for (const managedFileValue of ensureArray(existingManagedFiles)) {
203
+ const managedFile = ensureObject(managedFileValue);
204
+ const managedPath = String(managedFile.path || "").trim();
205
+ if (!managedPath) {
206
+ continue;
207
+ }
208
+ existingManagedFilesByPath.set(managedPath, managedFile);
209
+ }
210
+
211
+ const managedMigrationById = new Map();
212
+ for (const managedMigrationValue of ensureArray(managedMigrations)) {
213
+ const managedMigration = ensureObject(managedMigrationValue);
214
+ const migrationId = String(managedMigration.id || "").trim();
215
+ if (!migrationId) {
216
+ continue;
217
+ }
218
+ managedMigrationById.set(migrationId, managedMigration);
240
219
  }
241
220
 
242
- return replacementsByMutationIndex;
221
+ for (const preparedMutation of ensureArray(preparedMutations)) {
222
+ const mutation = ensureObject(preparedMutation.mutation);
223
+ const operation = String(preparedMutation.operation || "").trim() || "copy-file";
224
+ if (operation === "install-migration") {
225
+ await applyInstallMigrationMutation({
226
+ packageEntry,
227
+ preparedMutation,
228
+ appRoot,
229
+ managedMigrations,
230
+ managedMigrationById,
231
+ touchedFiles,
232
+ warnings
233
+ });
234
+ continue;
235
+ }
236
+
237
+ const renderedSourceContent = String(preparedMutation.renderedSourceContent || "");
238
+ const renderedSourceBuffer = Buffer.from(renderedSourceContent, "utf8");
239
+ const renderedSourceHash = hashBuffer(renderedSourceBuffer);
240
+
241
+ for (const targetPath of ensureArray(preparedMutation.targetPaths)) {
242
+ const relativeTargetPath = normalizeRelativePath(appRoot, targetPath);
243
+ const previous = await readFileBufferIfExists(targetPath);
244
+ const existingManaged = existingManagedFilesByPath.get(relativeTargetPath);
245
+
246
+ if (mutation.ownership === "app" && existingManaged) {
247
+ managedFiles.push({
248
+ ...existingManaged,
249
+ path: relativeTargetPath,
250
+ preserveOnRemove: mutation.preserveOnRemove,
251
+ reason: mutation.reason || String(existingManaged.reason || ""),
252
+ category: mutation.category || String(existingManaged.category || ""),
253
+ id: mutation.id || String(existingManaged.id || "")
254
+ });
255
+ continue;
256
+ }
257
+
258
+ if (mutation.ownership === "app" && previous.exists && hashBuffer(previous.buffer) === renderedSourceHash) {
259
+ managedFiles.push({
260
+ path: relativeTargetPath,
261
+ hash: renderedSourceHash,
262
+ hadPrevious: true,
263
+ previousContentBase64: previous.buffer.toString("base64"),
264
+ preserveOnRemove: mutation.preserveOnRemove,
265
+ reason: mutation.reason,
266
+ category: mutation.category,
267
+ id: mutation.id
268
+ });
269
+ continue;
270
+ }
271
+
272
+ await mkdir(path.dirname(targetPath), { recursive: true });
273
+ await writeFile(targetPath, renderedSourceContent, "utf8");
274
+
275
+ managedFiles.push({
276
+ path: relativeTargetPath,
277
+ hash: renderedSourceHash,
278
+ hadPrevious: previous.exists,
279
+ previousContentBase64: previous.exists ? previous.buffer.toString("base64") : "",
280
+ preserveOnRemove: mutation.preserveOnRemove,
281
+ reason: mutation.reason,
282
+ category: mutation.category,
283
+ id: mutation.id
284
+ });
285
+ touchedFiles.add(relativeTargetPath);
286
+ }
287
+ }
243
288
  }
244
289
 
245
290
  export {
246
291
  applyFileMutations,
247
- preflightFileMutationTemplateContexts
292
+ prepareFileMutations
248
293
  };
@@ -6,7 +6,6 @@ import {
6
6
  import path from "node:path";
7
7
  import { createCliError } from "../../shared/cliError.js";
8
8
  import { ensureObject } from "../../shared/collectionUtils.js";
9
- import { interpolateOptionValue } from "../../shared/optionInterpolation.js";
10
9
  import {
11
10
  hashBuffer,
12
11
  normalizeMigrationExtension,
@@ -19,25 +18,18 @@ import {
19
18
  fileExists
20
19
  } from "../ioAndMigrations.js";
21
20
  import { normalizeRelativePosixPath } from "../localPackageSupport.js";
22
- import {
23
- applyTemplateContextReplacements,
24
- resolveTemplateContextReplacementsForMutation
25
- } from "./templateContext.js";
26
21
 
27
22
  async function applyInstallMigrationMutation({
28
23
  packageEntry,
29
- mutation,
30
- rawMutation,
31
- mutationIndex,
32
- options,
24
+ preparedMutation,
33
25
  appRoot,
34
26
  managedMigrations,
35
27
  managedMigrationById,
36
28
  touchedFiles,
37
- warnings,
38
- precomputedTemplateContextByMutationIndex
29
+ warnings
39
30
  } = {}) {
40
- if (Object.hasOwn(ensureObject(rawMutation), "preserveOnRemove")) {
31
+ const mutation = ensureObject(preparedMutation?.mutation);
32
+ if (mutation.preserveOnRemove === true) {
41
33
  warnings.push(
42
34
  `${packageEntry.packageId}: install-migration ignores preserveOnRemove (migrations are always preserved on remove).`
43
35
  );
@@ -45,36 +37,17 @@ async function applyInstallMigrationMutation({
45
37
 
46
38
  const from = mutation.from;
47
39
  const toDir = mutation.toDir || "migrations";
40
+ const sourcePath = String(preparedMutation?.sourcePath || "").trim();
41
+ const renderedSourceContent = preparedMutation?.renderedSourceContent;
48
42
  if (!from) {
49
43
  throw createCliError(`Invalid install-migration mutation in ${packageEntry.packageId}: \"from\" is required.`);
50
44
  }
51
- const migrationId = normalizeMigrationId(mutation.id, packageEntry.packageId);
52
-
53
- const sourcePath = path.join(packageEntry.rootDir, from);
54
- if (!(await fileExists(sourcePath))) {
55
- throw createCliError(`Missing migration template source ${sourcePath} for ${packageEntry.packageId}.`);
45
+ if (!sourcePath) {
46
+ throw createCliError(`Invalid install-migration mutation in ${packageEntry.packageId}: missing prepared source path.`);
56
47
  }
57
-
58
- const hasPrecomputedTemplateContext =
59
- precomputedTemplateContextByMutationIndex instanceof Map &&
60
- precomputedTemplateContextByMutationIndex.has(mutationIndex);
61
- const templateContextReplacements = hasPrecomputedTemplateContext
62
- ? precomputedTemplateContextByMutationIndex.get(mutationIndex)
63
- : await resolveTemplateContextReplacementsForMutation({
64
- packageEntry,
65
- mutation,
66
- options,
67
- appRoot,
68
- sourcePath,
69
- targetPaths: [path.join(appRoot, toDir)]
70
- });
71
-
72
- const sourceContent = await readFile(sourcePath, "utf8");
73
- let renderedSourceContent = sourceContent.includes("${")
74
- ? interpolateOptionValue(sourceContent, options, packageEntry.packageId, `${mutation.id || from}.source`)
75
- : sourceContent;
76
- if (templateContextReplacements) {
77
- renderedSourceContent = applyTemplateContextReplacements(renderedSourceContent, templateContextReplacements);
48
+ const migrationId = normalizeMigrationId(mutation.id, packageEntry.packageId);
49
+ if (typeof renderedSourceContent !== "string") {
50
+ throw createCliError(`Invalid install-migration mutation in ${packageEntry.packageId}: missing rendered migration source.`);
78
51
  }
79
52
  const sourceExtension = normalizeMigrationExtension(path.extname(from), ".cjs");
80
53
  const extension = normalizeMigrationExtension(mutation.extension, sourceExtension);
@@ -1,10 +1,5 @@
1
- import {
2
- mkdir,
3
- readFile,
4
- writeFile
5
- } from "node:fs/promises";
6
- import path from "node:path";
7
- import { pathToFileURL } from "node:url";
1
+ import { readFile } from "node:fs/promises";
2
+ import { importFreshModuleFromAbsolutePath } from "@jskit-ai/kernel/server/support";
8
3
  import { createCliError } from "../../shared/cliError.js";
9
4
  import {
10
5
  ensureArray,
@@ -31,6 +26,8 @@ function interpolateFileMutationRecord(mutation, options, packageId) {
31
26
  toSurfacePath: interpolate(mutation.toSurfacePath, "toSurfacePath"),
32
27
  toDir: interpolate(mutation.toDir, "toDir"),
33
28
  extension: interpolate(mutation.extension, "extension"),
29
+ ownership: interpolate(mutation.ownership, "ownership"),
30
+ expectedExistingFrom: interpolate(mutation.expectedExistingFrom, "expectedExistingFrom"),
34
31
  id: interpolate(mutation.id, "id"),
35
32
  category: interpolate(mutation.category, "category"),
36
33
  reason: interpolate(mutation.reason, "reason"),
@@ -55,9 +52,8 @@ function applyTemplateContextReplacements(sourceContent, replacements) {
55
52
  return output;
56
53
  }
57
54
 
58
- async function copyTemplateFile(
55
+ async function renderTemplateFile(
59
56
  sourcePath,
60
- targetPath,
61
57
  options,
62
58
  packageId,
63
59
  interpolationKey,
@@ -70,9 +66,7 @@ async function copyTemplateFile(
70
66
  if (templateContextReplacements) {
71
67
  renderedContent = applyTemplateContextReplacements(renderedContent, templateContextReplacements);
72
68
  }
73
-
74
- await mkdir(path.dirname(targetPath), { recursive: true });
75
- await writeFile(targetPath, renderedContent, "utf8");
69
+ return renderedContent;
76
70
  }
77
71
 
78
72
  async function resolveTemplateContextReplacementsForMutation({
@@ -111,7 +105,7 @@ async function resolveTemplateContextReplacementsForMutation({
111
105
 
112
106
  let moduleNamespace = null;
113
107
  try {
114
- moduleNamespace = await import(`${pathToFileURL(absoluteEntrypointPath).href}?t=${Date.now()}_${Math.random()}`);
108
+ moduleNamespace = await importFreshModuleFromAbsolutePath(absoluteEntrypointPath);
115
109
  } catch (error) {
116
110
  throw createCliError(
117
111
  `Unable to load templateContext entrypoint ${entrypoint} for ${packageEntry.packageId}: ${String(error?.message || error || "unknown error")}`
@@ -165,7 +159,7 @@ async function resolveTemplateContextReplacementsForMutation({
165
159
 
166
160
  export {
167
161
  applyTemplateContextReplacements,
168
- copyTemplateFile,
169
162
  interpolateFileMutationRecord,
163
+ renderTemplateFile,
170
164
  resolveTemplateContextReplacementsForMutation
171
165
  };
@@ -118,25 +118,30 @@ async function applyTextMutations(packageEntry, appRoot, textMutations, options,
118
118
  const operation = String(mutation?.op || "").trim();
119
119
  if (operation === "upsert-env") {
120
120
  const relativeFile = String(mutation?.file || "").trim();
121
- const key = String(mutation?.key || "").trim();
122
- if (!relativeFile || !key) {
121
+ const rawKey = String(mutation?.key || "").trim();
122
+ if (!relativeFile || !rawKey) {
123
123
  throw createCliError(`Invalid upsert-env mutation in ${packageEntry.packageId}: "file" and "key" are required.`);
124
124
  }
125
125
 
126
+ const resolvedKey = interpolateOptionValue(rawKey, options, packageEntry.packageId, `${rawKey}.key`).trim();
127
+ if (!resolvedKey) {
128
+ throw createCliError(`Invalid upsert-env mutation in ${packageEntry.packageId}: resolved key is empty.`);
129
+ }
130
+
126
131
  const absoluteFile = path.join(appRoot, relativeFile);
127
132
  const previous = await readFileBufferIfExists(absoluteFile);
128
133
  const previousContent = previous.exists ? previous.buffer.toString("utf8") : "";
129
- const resolvedValue = interpolateOptionValue(mutation?.value || "", options, packageEntry.packageId, key);
130
- const upserted = upsertEnvValue(previousContent, key, resolvedValue);
134
+ const resolvedValue = interpolateOptionValue(mutation?.value || "", options, packageEntry.packageId, resolvedKey);
135
+ const upserted = upsertEnvValue(previousContent, resolvedKey, resolvedValue);
131
136
 
132
137
  await mkdir(path.dirname(absoluteFile), { recursive: true });
133
138
  await writeFile(absoluteFile, upserted.content, "utf8");
134
139
 
135
- const recordKey = `${relativeFile}::${String(mutation?.id || key)}`;
140
+ const recordKey = `${relativeFile}::${String(mutation?.id || resolvedKey)}`;
136
141
  managedText[recordKey] = {
137
142
  file: relativeFile,
138
143
  op: "upsert-env",
139
- key,
144
+ key: resolvedKey,
140
145
  value: resolvedValue,
141
146
  hadPrevious: upserted.hadPrevious,
142
147
  previousValue: upserted.previousValue,