@microck/canonfig 2.0.0

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 (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +263 -0
  3. package/dist/agent/agent-resolution.errors.js +42 -0
  4. package/dist/agent/agent-resolution.layer.js +204 -0
  5. package/dist/agent/agent-resolution.service.js +2259 -0
  6. package/dist/agent/agent-resolution.types.js +1 -0
  7. package/dist/agent/controlled-executor.js +704 -0
  8. package/dist/agent/harness-adapters.js +85 -0
  9. package/dist/cli/cli.js +618 -0
  10. package/dist/cli/exit-codes.js +28 -0
  11. package/dist/cli/follower-commands.js +3 -0
  12. package/dist/cli/render.js +56 -0
  13. package/dist/cli/source-commands.js +5 -0
  14. package/dist/domain/brand.js +29 -0
  15. package/dist/domain/identity.js +31 -0
  16. package/dist/domain/npm-package-spec.js +186 -0
  17. package/dist/domain/profile.js +950 -0
  18. package/dist/domain/recipe-versions.js +297 -0
  19. package/dist/domain/resource.js +259 -0
  20. package/dist/domain/synchronization.js +346 -0
  21. package/dist/enrollment/enrollment.errors.js +43 -0
  22. package/dist/enrollment/enrollment.layer.js +724 -0
  23. package/dist/enrollment/enrollment.service.js +3 -0
  24. package/dist/enrollment/enrollment.types.js +59 -0
  25. package/dist/enrollment/follower-client.js +585 -0
  26. package/dist/enrollment/source-server.js +313 -0
  27. package/dist/machine/linux.layer.js +1183 -0
  28. package/dist/machine/machine-state.errors.js +52 -0
  29. package/dist/machine/machine-state.service.js +3 -0
  30. package/dist/machine/machine-state.types.js +1 -0
  31. package/dist/machine/macos.layer.js +470 -0
  32. package/dist/machine/windows.layer.js +879 -0
  33. package/dist/profile/discovery.js +740 -0
  34. package/dist/profile/profile-catalog.errors.js +50 -0
  35. package/dist/profile/profile-catalog.layer.js +20 -0
  36. package/dist/profile/profile-catalog.service.js +7 -0
  37. package/dist/profile/profile-codec.js +153 -0
  38. package/dist/profile/publication.js +298 -0
  39. package/dist/profile/tool-catalog.js +384 -0
  40. package/dist/runtime/doctor.js +306 -0
  41. package/dist/runtime/layers.js +706 -0
  42. package/dist/runtime/main.js +38 -0
  43. package/dist/schedule/linux-schedule.js +24 -0
  44. package/dist/schedule/macos-schedule.js +25 -0
  45. package/dist/schedule/schedule-manager.errors.js +17 -0
  46. package/dist/schedule/schedule-manager.layer.js +205 -0
  47. package/dist/schedule/schedule-manager.service.js +3 -0
  48. package/dist/schedule/schedule-manager.types.js +114 -0
  49. package/dist/schedule/windows-schedule.js +25 -0
  50. package/dist/state/state-repository.errors.js +55 -0
  51. package/dist/state/state-repository.layer.js +1507 -0
  52. package/dist/state/state-repository.service.js +3 -0
  53. package/dist/state/state-repository.types.js +1 -0
  54. package/dist/state/state-schema.js +298 -0
  55. package/dist/synchronization/config-codec.js +97 -0
  56. package/dist/synchronization/executor.js +700 -0
  57. package/dist/synchronization/follower-orchestration.js +939 -0
  58. package/dist/synchronization/follower-sync-config.js +81 -0
  59. package/dist/synchronization/npm-artifact.js +670 -0
  60. package/dist/synchronization/planner.js +378 -0
  61. package/dist/synchronization/recovery.js +397 -0
  62. package/dist/synchronization/resource-executors.js +1198 -0
  63. package/dist/synchronization/resource-plans.js +645 -0
  64. package/dist/synchronization/synchronization.errors.js +102 -0
  65. package/dist/synchronization/synchronization.layer.js +97 -0
  66. package/dist/synchronization/synchronization.service.js +11 -0
  67. package/dist/synchronization/synchronization.types.js +1 -0
  68. package/package.json +66 -0
@@ -0,0 +1,645 @@
1
+ import { Schema } from "effect";
2
+ import { AgentTaskId, ContentDigest as ContentDigestSchema, } from "../domain/brand.js";
3
+ import { isNestedCommandLauncher } from "../agent/agent-resolution.service.js";
4
+ import { sha256Hex } from "../profile/profile-codec.js";
5
+ import { isMissingAutomaticRecipeVersion, recipeSourceDetails, } from "../domain/recipe-versions.js";
6
+ import { InvalidObservedStateError } from "./synchronization.errors.js";
7
+ const compareText = (left, right) => {
8
+ if (left < right)
9
+ return -1;
10
+ if (left > right)
11
+ return 1;
12
+ return 0;
13
+ };
14
+ const sortedUnique = (values) => [...new Set(values)].sort(compareText);
15
+ const pendingAgentTaskId = Schema.decodeUnknownSync(AgentTaskId)("pending");
16
+ const filesDigest = (files) => {
17
+ const encoded = [...files]
18
+ .sort((left, right) => compareText(left.path, right.path))
19
+ .map((file) => `${file.path}\0${file.digest}\0${file.executable === true ? "x" : "-"}`)
20
+ .join("\n");
21
+ return sha256Hex(encoded);
22
+ };
23
+ const presentObservedFiles = (files) => files.flatMap((file) => "state" in file || file.digest === undefined
24
+ ? []
25
+ : [{ path: file.path, digest: file.digest, executable: file.executable }]);
26
+ const fileSignature = (file) => `${file.digest}\0${file.executable === true ? "x" : "-"}`;
27
+ export const desiredResourceDigest = (desired) => {
28
+ switch (desired.kind) {
29
+ case "file":
30
+ case "config":
31
+ case "skill":
32
+ case "schedule":
33
+ return desired.digest;
34
+ case "directory":
35
+ return filesDigest(desired.files);
36
+ case "tool":
37
+ case "credential":
38
+ return undefined;
39
+ }
40
+ };
41
+ const observedDigest = (context) => {
42
+ switch (context.observed.state) {
43
+ case "absent":
44
+ case "unverifiable":
45
+ return undefined;
46
+ case "present":
47
+ return Schema.decodeUnknownSync(ContentDigestSchema)(context.observed.digest);
48
+ case "directory":
49
+ return filesDigest(presentObservedFiles(context.observed.files));
50
+ }
51
+ };
52
+ export const detectSkillDrift = (input) => {
53
+ const observed = input.observedDigest;
54
+ const applied = input.lastAppliedDigest;
55
+ const observedMatchesDesired = observed === input.desiredDigest
56
+ && (input.desiredExecutable === undefined
57
+ || input.observedExecutable === input.desiredExecutable);
58
+ const observedMatchesApplied = observed === applied
59
+ && (input.lastAppliedExecutable === undefined
60
+ || input.observedExecutable === input.lastAppliedExecutable);
61
+ const desiredMatchesApplied = input.desiredDigest === applied
62
+ && (input.desiredExecutable === undefined
63
+ || input.lastAppliedExecutable === input.desiredExecutable);
64
+ if (observedMatchesDesired) {
65
+ return desiredMatchesApplied ? "unchanged" : "converged";
66
+ }
67
+ if (applied === undefined) {
68
+ return observed === undefined ? "remote-only" : "conflicting";
69
+ }
70
+ if (observedMatchesApplied)
71
+ return "remote-only";
72
+ if (desiredMatchesApplied)
73
+ return "local-only";
74
+ return "conflicting";
75
+ };
76
+ const noOp = () => ({
77
+ kind: "no-op",
78
+ detail: { kind: "no-op" },
79
+ });
80
+ const observedMatchesDesired = (desired, observed) => {
81
+ if (observed.state === "absent" || observed.state === "unverifiable")
82
+ return false;
83
+ const observedObjectKind = observed.state === "present" || observed.state === "directory"
84
+ ? observed.objectKind
85
+ : undefined;
86
+ switch (desired.kind) {
87
+ case "file":
88
+ return observed.state === "present"
89
+ && observed.digest === desired.digest
90
+ && observed.executable === desired.executable
91
+ && (desired.symlinkTo === undefined
92
+ ? observed.symlinkTo === undefined
93
+ && (observedObjectKind === undefined || observedObjectKind === "regular")
94
+ : observed.symlinkTo !== undefined
95
+ && (observedObjectKind === undefined || observedObjectKind === "symlink"));
96
+ case "config":
97
+ case "schedule":
98
+ return observed.state === "present"
99
+ && observed.digest === desired.digest
100
+ && (observedObjectKind === undefined || observedObjectKind === "regular");
101
+ case "directory":
102
+ case "skill":
103
+ return observed.state === "directory"
104
+ && (observed.objectKind === undefined || observed.objectKind === "directory")
105
+ && observed.files.every((file) => !("state" in file))
106
+ && filesDigest(presentObservedFiles(observed.files)) === desiredResourceDigest(desired);
107
+ case "tool":
108
+ case "credential":
109
+ return observed.state === "present";
110
+ }
111
+ };
112
+ const observedMatchesApplied = (context) => {
113
+ const applied = context.applied;
114
+ if (applied === undefined || context.observed.state === "absent")
115
+ return true;
116
+ if ((context.resource.kind === "directory" || context.resource.kind === "skill")
117
+ && context.observed.state === "directory"
118
+ && applied.ownedFiles !== undefined) {
119
+ const ownedByPath = new Map(applied.ownedFiles.map((file) => [file.path, fileSignature(file)]));
120
+ return context.observed.files.every((file) => "state" in file
121
+ || ownedByPath.get(file.path) === undefined
122
+ || ownedByPath.get(file.path) === fileSignature(file));
123
+ }
124
+ const currentDigest = observedDigest(context);
125
+ if (currentDigest !== applied.digest)
126
+ return false;
127
+ if (context.resource.kind === "file"
128
+ && context.observed.state === "present"
129
+ && (applied.executable !== undefined
130
+ && context.observed.executable !== applied.executable
131
+ || applied.symlinkTo === undefined
132
+ && context.observed.objectKind !== undefined
133
+ && context.observed.objectKind !== "regular"
134
+ || applied.symlinkTo !== undefined
135
+ && context.observed.objectKind !== undefined
136
+ && context.observed.objectKind !== "symlink")) {
137
+ return false;
138
+ }
139
+ if (context.resource.kind === "file"
140
+ && context.observed.state === "present"
141
+ && applied.symlinkTo === undefined
142
+ && context.observed.symlinkTo !== undefined) {
143
+ return false;
144
+ }
145
+ return true;
146
+ };
147
+ const writeFile = (context, digest, executable) => {
148
+ const detail = {
149
+ kind: "write-file",
150
+ target: context.resource.target,
151
+ digest,
152
+ };
153
+ if (executable !== undefined)
154
+ detail.executable = executable;
155
+ return { kind: "write-file", detail };
156
+ };
157
+ const replaceDirectory = (context, desired) => {
158
+ const observedFiles = context.observed.state === "directory"
159
+ ? presentObservedFiles(context.observed.files)
160
+ : [];
161
+ const observedByPath = new Map(observedFiles.map((file) => [
162
+ file.path,
163
+ `${file.digest}\0${file.executable === true ? "x" : "-"}`,
164
+ ]));
165
+ const desiredPaths = new Set(desired.files.map((file) => file.path));
166
+ return {
167
+ kind: "mirror-directory",
168
+ detail: {
169
+ kind: "mirror-directory",
170
+ target: context.resource.target,
171
+ adds: desired.files
172
+ .filter((file) => observedByPath.get(file.path)
173
+ !== `${file.digest}\0${file.executable ? "x" : "-"}`)
174
+ .map((file) => file.path)
175
+ .sort(compareText),
176
+ removes: observedFiles
177
+ .filter((file) => !desiredPaths.has(file.path))
178
+ .map((file) => file.path)
179
+ .sort(compareText),
180
+ },
181
+ };
182
+ };
183
+ const driftConflict = (context, desiredDigest, currentDigest, desiredExecutable, observedExecutable) => {
184
+ const detail = {
185
+ kind: "drift-conflict",
186
+ target: context.resource.target,
187
+ desiredDigest,
188
+ observedDigest: currentDigest,
189
+ };
190
+ if (desiredExecutable !== undefined)
191
+ detail.desiredExecutable = desiredExecutable;
192
+ if (observedExecutable !== undefined)
193
+ detail.observedExecutable = observedExecutable;
194
+ return { kind: "drift-conflict", detail };
195
+ };
196
+ const directoryRootConflict = (context) => {
197
+ if (context.desired.kind !== "directory" && context.desired.kind !== "skill") {
198
+ return undefined;
199
+ }
200
+ if (context.observed.state === "directory"
201
+ && (context.observed.objectKind === undefined
202
+ || context.observed.objectKind === "directory")) {
203
+ return undefined;
204
+ }
205
+ if (context.observed.state === "present" && context.observed.objectKind === undefined) {
206
+ return undefined;
207
+ }
208
+ if (context.observed.state === "absent")
209
+ return undefined;
210
+ const observedDigest = observedDigestForDirectoryRoot(context);
211
+ return driftConflict(context, desiredResourceDigest(context.desired), observedDigest);
212
+ };
213
+ const observedDigestForDirectoryRoot = (context) => context.observed.state === "present"
214
+ ? Schema.decodeUnknownSync(ContentDigestSchema)(context.observed.digest)
215
+ : sha256Hex("canonfig:unverifiable-directory-root");
216
+ const unresolvedAgentTask = (context, summary) => {
217
+ const tool = context.desired.kind === "tool" ? context.desired : undefined;
218
+ const allowedExecutables = tool === undefined
219
+ ? []
220
+ : sortedUnique([
221
+ tool.toolId,
222
+ ...tool.recipes.map((recipe) => recipe.method),
223
+ ]);
224
+ // Verification stays on the tool itself; package-manager recipe methods can
225
+ // be launcher-class names (`make`, `npx`, ...), so they are never granted an
226
+ // execution model. The bounded agent remains free to propose them, but any
227
+ // launcher-class action fails closed at authorization time.
228
+ const verifiable = tool === undefined || isNestedCommandLauncher(tool.toolId)
229
+ ? undefined
230
+ : tool;
231
+ return {
232
+ kind: "agent-task",
233
+ detail: {
234
+ kind: "agent-task",
235
+ taskId: pendingAgentTaskId,
236
+ summary,
237
+ },
238
+ task: {
239
+ resource: context.resource.id,
240
+ summary,
241
+ desiredOutcome: `Converge ${context.resource.kind} ${context.resource.id}`,
242
+ observedEvidence: [`Observed state: ${context.observed.state}`],
243
+ allowedPaths: [context.resource.target],
244
+ allowedExecutables,
245
+ executableAuthorizations: verifiable === undefined
246
+ ? []
247
+ : [{ executable: verifiable.toolId, behavior: "leaf" }],
248
+ allowedOrigins: [],
249
+ forbidden: ["elevation", "login", "restart", "reboot"],
250
+ timeLimitSeconds: 300,
251
+ outputLimitBytes: 65_536,
252
+ verification: {
253
+ command: verifiable === undefined ? [] : [verifiable.toolId, "--version"],
254
+ },
255
+ },
256
+ };
257
+ };
258
+ const planReplace = (context) => {
259
+ const desiredDigest = desiredResourceDigest(context.desired);
260
+ if (desiredDigest === undefined) {
261
+ return [unresolvedAgentTask(context, `Resolve ${context.resource.kind} ${context.resource.id}`)];
262
+ }
263
+ if (observedMatchesDesired(context.desired, context.observed))
264
+ return [noOp()];
265
+ if (context.desired.kind === "directory" || context.desired.kind === "skill") {
266
+ return [replaceDirectory(context, context.desired)];
267
+ }
268
+ return [
269
+ writeFile(context, desiredDigest, context.desired.kind === "file" ? context.desired.executable : undefined),
270
+ ];
271
+ };
272
+ const planMerge = (context) => {
273
+ if (context.desired.kind !== "config") {
274
+ const desiredDigest = desiredResourceDigest(context.desired);
275
+ if (desiredDigest !== undefined
276
+ && observedMatchesDesired(context.desired, context.observed))
277
+ return [noOp()];
278
+ return [{
279
+ kind: "write-config",
280
+ detail: {
281
+ kind: "write-config",
282
+ target: context.resource.target,
283
+ keys: [],
284
+ },
285
+ }];
286
+ }
287
+ const conflicts = context.desired.keys.filter((key) => context.overlayKeys.includes(key));
288
+ if (conflicts.length > 0) {
289
+ const keys = sortedUnique(conflicts);
290
+ return [{
291
+ kind: "human-action",
292
+ detail: {
293
+ kind: "human-action",
294
+ reason: `Local Overlay conflicts with canonical keys: ${keys.join(", ")}`,
295
+ instructions: `Remove or rename the conflicting Local Overlay keys for ${context.resource.id}, then rerun synchronization.`,
296
+ },
297
+ }];
298
+ }
299
+ if (observedMatchesDesired(context.desired, context.observed))
300
+ return [noOp()];
301
+ return [{
302
+ kind: "write-config",
303
+ detail: {
304
+ kind: "write-config",
305
+ target: context.resource.target,
306
+ keys: sortedUnique(context.desired.keys),
307
+ },
308
+ }];
309
+ };
310
+ const planMirror = (context) => {
311
+ const desiredFiles = context.desired.kind === "directory" || context.desired.kind === "skill"
312
+ ? context.desired.files
313
+ : [{ path: context.resource.target, digest: desiredResourceDigest(context.desired) }];
314
+ const observedFiles = context.observed.state === "directory"
315
+ ? presentObservedFiles(context.observed.files)
316
+ : [];
317
+ const currentByPath = new Map(observedFiles.map((file) => [
318
+ file.path,
319
+ `${file.digest}\0${file.executable === true ? "x" : "-"}`,
320
+ ]));
321
+ const desiredPaths = new Set(desiredFiles.map((file) => file.path));
322
+ const ownedByPath = new Map((context.applied?.ownedFiles ?? []).map((file) => [
323
+ file.path,
324
+ `${file.digest}\0${file.executable === true ? "x" : "-"}`,
325
+ ]));
326
+ const adds = desiredFiles
327
+ .filter((file) => file.digest !== undefined
328
+ && currentByPath.get(file.path)
329
+ !== `${file.digest}\0${"executable" in file && file.executable ? "x" : "-"}`)
330
+ .map((file) => file.path)
331
+ .sort(compareText);
332
+ const removes = observedFiles
333
+ .filter((file) => !desiredPaths.has(file.path))
334
+ .filter((file) => ownedByPath.get(file.path)
335
+ === `${file.digest}\0${file.executable === true ? "x" : "-"}`)
336
+ .map((file) => file.path)
337
+ .sort(compareText);
338
+ if (adds.length === 0
339
+ && removes.length === 0
340
+ && context.observed.state !== "absent") {
341
+ return [noOp()];
342
+ }
343
+ return [{
344
+ kind: "mirror-directory",
345
+ detail: {
346
+ kind: "mirror-directory",
347
+ target: context.resource.target,
348
+ adds,
349
+ removes,
350
+ },
351
+ }];
352
+ };
353
+ const planReplaceIfUnmodified = (context) => {
354
+ const desiredDigest = desiredResourceDigest(context.desired);
355
+ if (desiredDigest === undefined) {
356
+ return [unresolvedAgentTask(context, `Resolve ${context.resource.kind} ${context.resource.id}`)];
357
+ }
358
+ if (context.observed.state === "unverifiable"
359
+ || (context.applied !== undefined
360
+ && context.observed.state === "absent")) {
361
+ return [{
362
+ kind: "human-action",
363
+ detail: {
364
+ kind: "human-action",
365
+ reason: `Previously applied ${context.resource.kind} ${context.resource.id} is missing or unverifiable`,
366
+ instructions: `Restore ${context.resource.target} or explicitly review and remove the local change, then rerun synchronization. Canonfig will not rewrite this target under replace-if-unmodified.`,
367
+ },
368
+ }];
369
+ }
370
+ const currentDigest = observedDigest(context);
371
+ const observedStateMatchesDesired = observedMatchesDesired(context.desired, context.observed)
372
+ || (context.desired.kind === "skill"
373
+ && context.observed.state === "present"
374
+ && context.observed.objectKind === undefined);
375
+ const drift = detectSkillDrift({
376
+ desiredDigest,
377
+ observedDigest: currentDigest,
378
+ lastAppliedDigest: context.applied === undefined
379
+ ? undefined
380
+ : Schema.decodeUnknownSync(ContentDigestSchema)(context.applied.digest),
381
+ desiredExecutable: context.desired.kind === "file"
382
+ ? context.desired.executable
383
+ : undefined,
384
+ observedExecutable: context.observed.state === "present"
385
+ ? context.observed.executable
386
+ : undefined,
387
+ lastAppliedExecutable: context.applied?.executable,
388
+ });
389
+ switch (drift) {
390
+ case "unchanged":
391
+ case "converged":
392
+ return observedStateMatchesDesired
393
+ ? [noOp()]
394
+ : [driftConflict(context, desiredDigest, currentDigest ?? sha256Hex("canonfig:unverifiable-observed-state"), context.desired.kind === "file" ? context.desired.executable : undefined, context.observed.state === "present"
395
+ ? context.observed.executable
396
+ : undefined)];
397
+ case "remote-only":
398
+ if (context.desired.kind === "skill") {
399
+ return [replaceDirectory(context, context.desired)];
400
+ }
401
+ return [
402
+ writeFile(context, desiredDigest, context.desired.kind === "file" ? context.desired.executable : undefined),
403
+ ];
404
+ case "local-only":
405
+ case "conflicting":
406
+ if (currentDigest === undefined) {
407
+ return [
408
+ writeFile(context, desiredDigest, context.desired.kind === "file" ? context.desired.executable : undefined),
409
+ ];
410
+ }
411
+ return [driftConflict(context, desiredDigest, currentDigest, context.desired.kind === "file" ? context.desired.executable : undefined, context.observed.state === "present" ? context.observed.executable : undefined)];
412
+ }
413
+ };
414
+ const planEnsure = (context) => {
415
+ if (context.desired.kind !== "tool") {
416
+ throw new InvalidObservedStateError({
417
+ resource: context.resource.id,
418
+ kind: context.resource.kind,
419
+ observedState: context.observed.state,
420
+ });
421
+ }
422
+ if (context.observed.state === "present")
423
+ return [noOp()];
424
+ if (context.observed.state === "unverifiable") {
425
+ return [unresolvedAgentTask(context, `Verify or install tool ${context.desired.toolId}`)];
426
+ }
427
+ const recipe = [...context.desired.recipes]
428
+ .filter((candidate) => candidate.platform === context.platform)
429
+ .sort((left, right) => compareText(`${left.method}\0${left.package}\0${left.version ?? ""}\0${JSON.stringify(left.source)}`, `${right.method}\0${right.package}\0${right.version ?? ""}\0${JSON.stringify(right.source)}`))[0];
430
+ if (recipe === undefined) {
431
+ return [unresolvedAgentTask(context, `Find an installation recipe for ${context.desired.toolId}`)];
432
+ }
433
+ if (isMissingAutomaticRecipeVersion(recipe)) {
434
+ return [{
435
+ kind: "human-action",
436
+ detail: {
437
+ kind: "human-action",
438
+ reason: `Installing ${context.desired.toolId} requires a deterministic ${recipe.method} version`,
439
+ instructions: `The reviewed ${recipe.method} recipe for ${recipe.package} has no exact version. Add a deterministic version to the profile, or install the tool manually, then rerun synchronization.`,
440
+ },
441
+ }];
442
+ }
443
+ if (recipe.method === "source") {
444
+ return [{
445
+ kind: "human-action",
446
+ detail: {
447
+ kind: "human-action",
448
+ reason: `Installing ${context.desired.toolId} from source requires Human Action Required`,
449
+ instructions: `Canonfig preserves the reviewed source recipe at revision ${recipe.version ?? "unknown"}, but it cannot automatically check out or build source code. Apply the bounded source build manually, then rerun synchronization.`,
450
+ },
451
+ }];
452
+ }
453
+ if (recipe.method === "cargo"
454
+ && (recipe.buildPolicy?.mode ?? "scripts-disabled") === "scripts-disabled") {
455
+ return [{
456
+ kind: "human-action",
457
+ detail: {
458
+ kind: "human-action",
459
+ reason: `Installing ${context.desired.toolId} with Cargo requires Human Action Required`,
460
+ instructions: `Cargo may execute build.rs and procedural macros for ${recipe.package}, but Cargo has no disable-scripts mode. Review and apply this recipe with a separately bounded builder policy, then rerun synchronization.`,
461
+ },
462
+ }];
463
+ }
464
+ const sourceDetails = recipeSourceDetails(recipe.source);
465
+ if ((recipe.method === "npm" || recipe.method === "pnpm" || recipe.method === "bun")
466
+ && sourceDetails.source?.startsWith("https://") === true
467
+ && sourceDetails.integrity === undefined) {
468
+ return [{
469
+ kind: "human-action",
470
+ detail: {
471
+ kind: "human-action",
472
+ reason: `Installing ${context.desired.toolId} requires a reviewed npm artifact integrity`,
473
+ instructions: `The reviewed ${recipe.method} tarball for ${recipe.package}@${recipe.version ?? "the declared version"} has no supported sha256 or sha512 SRI value. Add a lockfile integrity value, or install the package manually and rerun synchronization.`,
474
+ },
475
+ }];
476
+ }
477
+ const detail = {
478
+ kind: "install-tool",
479
+ toolId: context.desired.toolId,
480
+ method: recipe.method,
481
+ package: recipe.package,
482
+ };
483
+ if (recipe.version !== undefined)
484
+ detail.version = recipe.version;
485
+ if (recipe.indexPolicy !== undefined)
486
+ detail.indexPolicy = recipe.indexPolicy;
487
+ if (recipe.source !== undefined)
488
+ detail.source = recipe.source;
489
+ if (recipe.buildPolicy !== undefined)
490
+ detail.buildPolicy = recipe.buildPolicy;
491
+ if (detail.buildPolicy?.mode === "required") {
492
+ return [{
493
+ kind: "human-action",
494
+ detail: {
495
+ kind: "human-action",
496
+ reason: `Installing ${context.desired.toolId} requires reviewed build hooks`,
497
+ instructions: `The ${recipe.method} recipe has a reviewed build policy, but Canonfig's current process executor cannot confine lifecycle descendants. Run the bounded build plan manually within its declared executable, path, origin, and capability bounds, then rerun synchronization.`,
498
+ },
499
+ }];
500
+ }
501
+ const actions = [{
502
+ kind: "install-tool",
503
+ detail,
504
+ }];
505
+ if (context.desired.loginRequired) {
506
+ actions.push({
507
+ kind: "human-action",
508
+ detail: {
509
+ kind: "human-action",
510
+ reason: `${context.desired.toolId} requires a local login`,
511
+ instructions: context.desired.loginInstructions ?? `Log in to ${context.desired.toolId}, then rerun synchronization.`,
512
+ },
513
+ });
514
+ }
515
+ return actions;
516
+ };
517
+ const planRequireLocal = (context) => {
518
+ if (context.desired.kind !== "credential") {
519
+ throw new InvalidObservedStateError({
520
+ resource: context.resource.id,
521
+ kind: context.resource.kind,
522
+ observedState: context.observed.state,
523
+ });
524
+ }
525
+ if (context.observed.state === "present")
526
+ return [noOp()];
527
+ return [{
528
+ kind: "human-action",
529
+ detail: {
530
+ kind: "human-action",
531
+ reason: `Local credential ${context.desired.reference} is unavailable`,
532
+ instructions: context.desired.instructions,
533
+ },
534
+ }];
535
+ };
536
+ const planRemovedResource = (context) => {
537
+ const applied = context.applied;
538
+ if (applied === undefined
539
+ || applied.kind !== context.resource.kind
540
+ || applied.policy !== context.resource.policy
541
+ || applied.target === undefined) {
542
+ return [];
543
+ }
544
+ if (!observedMatchesApplied(context))
545
+ return [];
546
+ switch (context.resource.kind) {
547
+ case "file":
548
+ if (context.resource.policy !== "replace"
549
+ && context.resource.policy !== "replace-if-unmodified") {
550
+ return [];
551
+ }
552
+ return [{
553
+ kind: "remove-resource",
554
+ detail: {
555
+ kind: "remove-resource",
556
+ target: applied.target,
557
+ paths: [],
558
+ keys: [],
559
+ },
560
+ }];
561
+ case "config":
562
+ if (context.resource.policy === "replace") {
563
+ return [{
564
+ kind: "remove-resource",
565
+ detail: {
566
+ kind: "remove-resource",
567
+ target: applied.target,
568
+ paths: [],
569
+ keys: [],
570
+ },
571
+ }];
572
+ }
573
+ if (context.resource.policy !== "merge"
574
+ || applied.ownedKeys === undefined
575
+ || applied.configFormat === undefined) {
576
+ return [];
577
+ }
578
+ return [{
579
+ kind: "remove-resource",
580
+ detail: {
581
+ kind: "remove-resource",
582
+ target: applied.target,
583
+ paths: [],
584
+ keys: sortedUnique(applied.ownedKeys),
585
+ },
586
+ }];
587
+ case "directory":
588
+ case "skill":
589
+ if ((context.resource.policy !== "mirror-owned"
590
+ && context.resource.policy !== "replace"
591
+ && context.resource.policy !== "replace-if-unmodified")
592
+ || applied.ownedFiles === undefined) {
593
+ return [];
594
+ }
595
+ return [{
596
+ kind: "remove-resource",
597
+ detail: {
598
+ kind: "remove-resource",
599
+ target: applied.target,
600
+ paths: sortedUnique(applied.ownedFiles.map((file) => file.path)),
601
+ keys: [],
602
+ },
603
+ }];
604
+ case "schedule":
605
+ if (context.resource.policy !== "replace" || applied.schedule === undefined) {
606
+ return [];
607
+ }
608
+ return [{
609
+ kind: "remove-resource",
610
+ detail: {
611
+ kind: "remove-resource",
612
+ target: applied.target,
613
+ paths: [],
614
+ keys: [],
615
+ schedule: applied.schedule,
616
+ },
617
+ }];
618
+ case "tool":
619
+ case "credential":
620
+ // Ensure and require-local intentionally do not claim ownership of
621
+ // follower software or secrets, so removal is never automatic.
622
+ return [];
623
+ }
624
+ };
625
+ /** Exhaustive Apply Policy dispatch. Transfer planning is intentionally absent. */
626
+ export const planResource = (context) => {
627
+ const rootConflict = directoryRootConflict(context);
628
+ if (rootConflict !== undefined)
629
+ return [rootConflict];
630
+ switch (context.resource.policy) {
631
+ case "replace":
632
+ return planReplace(context);
633
+ case "mirror-owned":
634
+ return planMirror(context);
635
+ case "merge":
636
+ return planMerge(context);
637
+ case "replace-if-unmodified":
638
+ return planReplaceIfUnmodified(context);
639
+ case "ensure":
640
+ return planEnsure(context);
641
+ case "require-local":
642
+ return planRequireLocal(context);
643
+ }
644
+ };
645
+ export const planRemoved = planRemovedResource;