@kisev/skills-opencode 2.4.1 → 3.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.
package/dist/doctor.js CHANGED
@@ -328,7 +328,6 @@ function reconcileProjection(plan) {
328
328
  "current",
329
329
  "renamed",
330
330
  "retired",
331
- "archive-pending",
332
331
  "modified_managed",
333
332
  "user_owned",
334
333
  "unknown",
package/dist/index.d.ts CHANGED
@@ -49,74 +49,6 @@ declare const plugin: (input: PluginInput) => Promise<{
49
49
  decision?: any;
50
50
  }, context: import("@opencode-ai/plugin").ToolContext): Promise<import("@opencode-ai/plugin").ToolResult>;
51
51
  };
52
- capabilities: {
53
- description: string;
54
- args: {};
55
- execute(args: Record<string, never>, context: import("@opencode-ai/plugin").ToolContext): Promise<import("@opencode-ai/plugin").ToolResult>;
56
- };
57
- doctor: {
58
- description: string;
59
- args: {
60
- scope: import("zod").ZodDefault<import("zod").ZodEnum<{
61
- global: "global";
62
- project: "project";
63
- }>>;
64
- };
65
- execute(args: {
66
- scope: "global" | "project";
67
- }, context: import("@opencode-ai/plugin").ToolContext): Promise<import("@opencode-ai/plugin").ToolResult>;
68
- };
69
- agent_profiles: {
70
- description: string;
71
- args: {
72
- action: import("zod").ZodEnum<{
73
- list: "list";
74
- model_set: "model_set";
75
- critic_add: "critic_add";
76
- critic_remove: "critic_remove";
77
- }>;
78
- phase: import("zod").ZodOptional<import("zod").ZodEnum<{
79
- preview: "preview";
80
- apply: "apply";
81
- }>>;
82
- scope: import("zod").ZodEnum<{
83
- global: "global";
84
- project: "project";
85
- }>;
86
- name: import("zod").ZodOptional<import("zod").ZodString>;
87
- model: import("zod").ZodOptional<import("zod").ZodString>;
88
- variant: import("zod").ZodOptional<import("zod").ZodNullable<import("zod").ZodString>>;
89
- confirmation_digest: import("zod").ZodOptional<import("zod").ZodString>;
90
- };
91
- execute(args: {
92
- action: "list" | "model_set" | "critic_add" | "critic_remove";
93
- scope: "global" | "project";
94
- phase?: "preview" | "apply" | undefined;
95
- name?: string | undefined;
96
- model?: string | undefined;
97
- variant?: string | null | undefined;
98
- confirmation_digest?: string | undefined;
99
- }, context: import("@opencode-ai/plugin").ToolContext): Promise<import("@opencode-ai/plugin").ToolResult>;
100
- };
101
- reconcile: {
102
- description: string;
103
- args: {
104
- phase: import("zod").ZodEnum<{
105
- preview: "preview";
106
- apply: "apply";
107
- }>;
108
- scope: import("zod").ZodEnum<{
109
- global: "global";
110
- project: "project";
111
- }>;
112
- confirmation_digest: import("zod").ZodOptional<import("zod").ZodString>;
113
- };
114
- execute(args: {
115
- phase: "preview" | "apply";
116
- scope: "global" | "project";
117
- confirmation_digest?: string | undefined;
118
- }, context: import("@opencode-ai/plugin").ToolContext): Promise<import("@opencode-ai/plugin").ToolResult>;
119
- };
120
52
  };
121
53
  "tool.execute.before": (input: {
122
54
  tool: string;
@@ -173,74 +105,6 @@ export declare const server: (input: PluginInput) => Promise<{
173
105
  decision?: any;
174
106
  }, context: import("@opencode-ai/plugin").ToolContext): Promise<import("@opencode-ai/plugin").ToolResult>;
175
107
  };
176
- capabilities: {
177
- description: string;
178
- args: {};
179
- execute(args: Record<string, never>, context: import("@opencode-ai/plugin").ToolContext): Promise<import("@opencode-ai/plugin").ToolResult>;
180
- };
181
- doctor: {
182
- description: string;
183
- args: {
184
- scope: import("zod").ZodDefault<import("zod").ZodEnum<{
185
- global: "global";
186
- project: "project";
187
- }>>;
188
- };
189
- execute(args: {
190
- scope: "global" | "project";
191
- }, context: import("@opencode-ai/plugin").ToolContext): Promise<import("@opencode-ai/plugin").ToolResult>;
192
- };
193
- agent_profiles: {
194
- description: string;
195
- args: {
196
- action: import("zod").ZodEnum<{
197
- list: "list";
198
- model_set: "model_set";
199
- critic_add: "critic_add";
200
- critic_remove: "critic_remove";
201
- }>;
202
- phase: import("zod").ZodOptional<import("zod").ZodEnum<{
203
- preview: "preview";
204
- apply: "apply";
205
- }>>;
206
- scope: import("zod").ZodEnum<{
207
- global: "global";
208
- project: "project";
209
- }>;
210
- name: import("zod").ZodOptional<import("zod").ZodString>;
211
- model: import("zod").ZodOptional<import("zod").ZodString>;
212
- variant: import("zod").ZodOptional<import("zod").ZodNullable<import("zod").ZodString>>;
213
- confirmation_digest: import("zod").ZodOptional<import("zod").ZodString>;
214
- };
215
- execute(args: {
216
- action: "list" | "model_set" | "critic_add" | "critic_remove";
217
- scope: "global" | "project";
218
- phase?: "preview" | "apply" | undefined;
219
- name?: string | undefined;
220
- model?: string | undefined;
221
- variant?: string | null | undefined;
222
- confirmation_digest?: string | undefined;
223
- }, context: import("@opencode-ai/plugin").ToolContext): Promise<import("@opencode-ai/plugin").ToolResult>;
224
- };
225
- reconcile: {
226
- description: string;
227
- args: {
228
- phase: import("zod").ZodEnum<{
229
- preview: "preview";
230
- apply: "apply";
231
- }>;
232
- scope: import("zod").ZodEnum<{
233
- global: "global";
234
- project: "project";
235
- }>;
236
- confirmation_digest: import("zod").ZodOptional<import("zod").ZodString>;
237
- };
238
- execute(args: {
239
- phase: "preview" | "apply";
240
- scope: "global" | "project";
241
- confirmation_digest?: string | undefined;
242
- }, context: import("@opencode-ai/plugin").ToolContext): Promise<import("@opencode-ai/plugin").ToolResult>;
243
- };
244
108
  };
245
109
  "tool.execute.before": (input: {
246
110
  tool: string;
package/dist/index.js CHANGED
@@ -3,10 +3,6 @@ import { CATEGORIES, RoutingGate, } from "./routing.js";
3
3
  import rulesInjector from "./plugins/rules-injector.js";
4
4
  import rtk from "./plugins/rtk.js";
5
5
  import zedBell from "./plugins/zed-bell.js";
6
- import { applyAgentProfileChange, listAgentProfiles, previewAgentProfileChange, } from "./agent-profiles.js";
7
- import { applyReconcile, previewReconcile } from "./reconcile.js";
8
- import { collectDoctorFacts } from "./doctor.js";
9
- import { CATALOG } from "./catalog.js";
10
6
  import { digest } from "./lifecycle.js";
11
7
  export { rulesInjector, rtk, zedBell };
12
8
  const plugin = (async (input) => {
@@ -91,98 +87,8 @@ const plugin = (async (input) => {
91
87
  return JSON.stringify({ decision, receipt, status: "routed" });
92
88
  },
93
89
  });
94
- const capabilities = tool({
95
- description: "Show the bundled OpenCode capability catalog without installing or changing anything.",
96
- args: {},
97
- async execute() {
98
- return JSON.stringify({ schema_version: 1, status: "ok", ...CATALOG });
99
- },
100
- });
101
- const doctor = tool({
102
- description: "Read package health and opt-in defaults without installing or repairing anything.",
103
- args: { scope: tool.schema.enum(["global", "project"]).default("project") },
104
- async execute(args, context) {
105
- const host = input.client
106
- ? {
107
- config: async () => (await input.client.config.get({ query: { directory: context.directory } })).data,
108
- lsp: async () => (await input.client.lsp.status({ query: { directory: context.directory } })).data,
109
- }
110
- : undefined;
111
- return JSON.stringify(await collectDoctorFacts(args.scope ?? "project", context.directory, undefined, host));
112
- },
113
- });
114
- const agentProfiles = tool({
115
- description: "List, preview, or apply package-owned OpenCode agent profile configuration without editing opencode.json.",
116
- args: {
117
- action: tool.schema.enum(["list", "model_set", "critic_add", "critic_remove"]),
118
- phase: tool.schema.enum(["preview", "apply"]).optional(),
119
- scope: tool.schema.enum(["global", "project"]),
120
- name: tool.schema.string().optional(),
121
- model: tool.schema.string().optional(),
122
- variant: tool.schema.string().nullable().optional(),
123
- confirmation_digest: tool.schema.string().optional(),
124
- },
125
- async execute(args) {
126
- const cwd = input.directory ?? process.cwd();
127
- if (args.action === "list") {
128
- if (args.phase ||
129
- args.name ||
130
- args.model ||
131
- args.variant !== undefined ||
132
- args.confirmation_digest)
133
- throw new Error("agent_profiles list accepts only scope");
134
- return JSON.stringify({
135
- status: "ok",
136
- inventory: await listAgentProfiles(args.scope, cwd),
137
- });
138
- }
139
- if (!args.phase)
140
- throw new Error("agent_profiles mutation requires preview or apply phase");
141
- const action = {
142
- model_set: "model-set",
143
- critic_add: "critic-add",
144
- critic_remove: "critic-remove",
145
- }[args.action];
146
- const request = {
147
- action,
148
- ...(args.name ? { name: args.name } : {}),
149
- ...(args.model ? { model: args.model } : {}),
150
- ...(args.variant !== undefined ? { variant: args.variant } : {}),
151
- };
152
- if (args.phase === "preview")
153
- return JSON.stringify({
154
- status: "ok",
155
- applied: false,
156
- requires_restart: false,
157
- plan: await previewAgentProfileChange(request, args.scope, cwd),
158
- });
159
- if (!args.confirmation_digest)
160
- throw new Error("agent_profiles apply requires confirmation_digest");
161
- return JSON.stringify(await applyAgentProfileChange(request, args.scope, args.confirmation_digest, cwd));
162
- },
163
- });
164
- const reconcile = tool({
165
- description: "Preview or apply scope-isolated removal of retired public assets without touching unknown sources or runtime state.",
166
- args: {
167
- phase: tool.schema.enum(["preview", "apply"]),
168
- scope: tool.schema.enum(["global", "project"]),
169
- confirmation_digest: tool.schema.string().optional(),
170
- },
171
- async execute(args) {
172
- const cwd = input.directory ?? process.cwd();
173
- if (args.phase === "preview")
174
- return JSON.stringify({
175
- status: "ok",
176
- applied: false,
177
- plan: await previewReconcile(args.scope, cwd),
178
- });
179
- if (!args.confirmation_digest)
180
- throw new Error("reconcile apply requires confirmation_digest");
181
- return JSON.stringify(await applyReconcile(args.scope, args.confirmation_digest, cwd));
182
- },
183
- });
184
90
  return {
185
- tool: { route, capabilities, doctor, agent_profiles: agentProfiles, reconcile },
91
+ tool: { route },
186
92
  "tool.execute.before": async (input, output) => {
187
93
  if (input.tool !== "task")
188
94
  return;
@@ -45,7 +45,6 @@ export type ArchiveCandidate = {
45
45
  kind: "command" | "agent" | "plugin" | "state";
46
46
  };
47
47
  export declare const SELECTABLE_PLUGINS: SelectablePlugin[];
48
- export declare const PACKAGE_COMMANDS: string[];
49
48
  export declare const SKILL_COMMANDS: string[];
50
49
  export declare function defaultSelection(): InstallerSelection;
51
50
  export declare function normalizeSelection(value?: Partial<InstallerSelection>): InstallerSelection;
package/dist/installer.js CHANGED
@@ -20,18 +20,17 @@ function retiredAssetPaths() {
20
20
  ]);
21
21
  }
22
22
  export const SELECTABLE_PLUGINS = [...CATALOG.plugins];
23
- export const PACKAGE_COMMANDS = [...CATALOG.package_commands];
24
23
  export const SKILL_COMMANDS = [...CATALOG.skills];
25
24
  export function defaultSelection() {
26
25
  return {
27
- commands: [...SKILL_COMMANDS, ...PACKAGE_COMMANDS].sort(),
26
+ commands: [...SKILL_COMMANDS].sort(),
28
27
  agents: [...FIXED_AGENT_ROLES],
29
28
  plugins: [],
30
29
  core_activation: true,
31
30
  };
32
31
  }
33
32
  export function normalizeSelection(value = {}) {
34
- const allCommands = new Set([...SKILL_COMMANDS, ...PACKAGE_COMMANDS]);
33
+ const allCommands = new Set(SKILL_COMMANDS);
35
34
  const commands = [...new Set(value.commands ?? defaultSelection().commands)].sort();
36
35
  const agents = [...new Set(value.agents ?? FIXED_AGENT_ROLES)];
37
36
  const plugins = [...new Set(value.plugins ?? [])];
@@ -45,8 +44,7 @@ export function normalizeSelection(value = {}) {
45
44
  commands,
46
45
  agents: agents.sort(),
47
46
  plugins: plugins.sort(),
48
- core_activation: Boolean(value.core_activation ??
49
- (commands.some((name) => PACKAGE_COMMANDS.includes(name)) || agents.length)),
47
+ core_activation: Boolean(value.core_activation ?? agents.length),
50
48
  };
51
49
  }
52
50
  export class InstallerError extends LifecycleError {
@@ -214,15 +212,20 @@ export async function archiveMutations(candidates, scope, cwd, home, sourceVersi
214
212
  }
215
213
  const known = new Set(entries.map((entry) => `${String(entry.original_path)}:${String(entry.digest)}`));
216
214
  const mutations = [];
215
+ const queuedObjects = new Set();
217
216
  const now = new Date(0).toISOString();
218
217
  for (const candidate of candidates) {
219
218
  const digestValue = sha256(candidate.content);
220
219
  const key = `${candidate.path}:${digestValue}`;
221
- if (known.has(key))
222
- continue;
223
220
  const objectPath = `objects/${digestValue}`;
224
221
  const object = await readRegular(destination(root, objectPath));
225
- if (!object) {
222
+ if (known.has(key)) {
223
+ if ((!object && !queuedObjects.has(digestValue)) ||
224
+ (object && sha256(object) !== digestValue))
225
+ throw new InstallerError("invalid_archive", `Archive index references an invalid object: ${digestValue}`);
226
+ continue;
227
+ }
228
+ if (!object && !queuedObjects.has(digestValue)) {
226
229
  mutations.push({
227
230
  root,
228
231
  path: objectPath,
@@ -231,8 +234,9 @@ export async function archiveMutations(candidates, scope, cwd, home, sourceVersi
231
234
  mode: candidate.record.mode,
232
235
  expected: { absent: true },
233
236
  });
237
+ queuedObjects.add(digestValue);
234
238
  }
235
- else if (sha256(object) !== digestValue) {
239
+ else if (object && sha256(object) !== digestValue) {
236
240
  throw new InstallerError("archive_collision", `Archive object collision: ${digestValue}`);
237
241
  }
238
242
  entries.push({
@@ -605,7 +609,7 @@ export async function preview(action, scope, cwd = process.cwd(), home = homedir
605
609
  const root = deploymentRoot(scope, cwd, home);
606
610
  try {
607
611
  return await withLifecycleLock(stateRoot, async () => {
608
- if (await recoverTransaction(root, stateRoot))
612
+ if (await recoverTransaction(root, stateRoot, [archiveRoot(scope, cwd, home)]))
609
613
  throw new InstallerError("recovered_transaction", "Recovered an interrupted transaction; request a fresh plan");
610
614
  const built = await build(action, scope, cwd, home, selection);
611
615
  const receipt = await saveReceipt(stateRoot, `installer:${action}`, scope, root, { plan_digest: built.plan.digest }, Date.now(), built.plan.digest);
@@ -632,7 +636,7 @@ export async function apply(action, scope, confirmationDigest, cwd = process.cwd
632
636
  const root = deploymentRoot(scope, cwd, home);
633
637
  try {
634
638
  return await withLifecycleLock(stateRoot, async () => {
635
- if (await recoverTransaction(root, stateRoot))
639
+ if (await recoverTransaction(root, stateRoot, [archiveRoot(scope, cwd, home)]))
636
640
  throw new InstallerError("recovered_transaction", "Recovered an interrupted transaction; request a fresh plan");
637
641
  const receipt = (await consumeReceipt(stateRoot, {
638
642
  digest: confirmationDigest,
@@ -20,6 +20,18 @@ export type FileMutation = {
20
20
  root?: string;
21
21
  operation: "remove";
22
22
  expected: FileExpectation;
23
+ } | {
24
+ path: string;
25
+ root?: string;
26
+ operation: "external-write";
27
+ content: Buffer;
28
+ mode: number;
29
+ expected: FileExpectation;
30
+ } | {
31
+ path: string;
32
+ root?: string;
33
+ operation: "external-remove";
34
+ expected: FileExpectation;
23
35
  };
24
36
  export type SupersededPlan = {
25
37
  kind: string;
@@ -56,10 +68,11 @@ export declare function consumeReceipt(stateRoot: string, expected: {
56
68
  }, now?: number): Promise<unknown>;
57
69
  /** Invalidate an active receipt when a preview is blocked before a replacement exists. */
58
70
  export declare function supersedeReceipt(stateRoot: string, now?: number): Promise<void>;
59
- export declare function recoverTransaction(root: string, stateRoot: string): Promise<boolean>;
71
+ export declare function recoverTransaction(root: string, stateRoot: string, allowedRoots?: readonly string[]): Promise<boolean>;
60
72
  export type TransactionOptions = {
61
73
  beforePublish?: (index: number) => void;
62
74
  afterPublish?: (published: number) => "continue" | "fail" | "interrupt";
75
+ applyExternal?: () => Promise<void>;
63
76
  validateFinal?: () => Promise<void>;
64
77
  };
65
78
  export declare function applyTransaction(root: string, stateRoot: string, mutations: readonly FileMutation[], options?: TransactionOptions): Promise<void>;
package/dist/lifecycle.js CHANGED
@@ -105,8 +105,17 @@ async function ensureDirectory(path, mode) {
105
105
  throw new LifecycleError("unsafe_path", `Unsafe directory: ${current}`);
106
106
  continue;
107
107
  }
108
- await mkdir(current, { mode });
109
- created.push(current);
108
+ try {
109
+ await mkdir(current, { mode });
110
+ created.push(current);
111
+ }
112
+ catch (error) {
113
+ if (error.code !== "EEXIST")
114
+ throw error;
115
+ const raced = await lstat(current);
116
+ if (!raced.isDirectory() || raced.isSymbolicLink())
117
+ throw new LifecycleError("unsafe_path", `Unsafe directory: ${current}`);
118
+ }
110
119
  }
111
120
  return created;
112
121
  }
@@ -155,7 +164,8 @@ export function archiveRoot(scope, cwd = process.cwd(), home = homedir()) {
155
164
  const base = process.env.XDG_DATA_HOME && home === homedir()
156
165
  ? resolve(process.env.XDG_DATA_HOME)
157
166
  : resolve(home, ".local", "share");
158
- return join(base, "opencode", "skills-opencode", "archive", scope);
167
+ const suffix = scope === "global" ? "global" : join("project", sha256(resolve(cwd)));
168
+ return join(base, "opencode", "skills-opencode", "archive", suffix);
159
169
  }
160
170
  async function processAlive(pid) {
161
171
  if (!Number.isSafeInteger(pid) || pid <= 0)
@@ -476,11 +486,17 @@ async function restore(root, journal) {
476
486
  const currentMode = current ? (await lstat(target)).mode & 0o777 : undefined;
477
487
  const beforeHash = item.content === null ? undefined : sha256(Buffer.from(item.content, "base64"));
478
488
  const beforeMode = item.mode ?? undefined;
479
- const intendedHash = operation.operation === "write" && operation.content
489
+ const intendedHash = (operation.operation === "write" || operation.operation === "external-write") &&
490
+ operation.content
480
491
  ? sha256(Buffer.from(operation.content, "base64"))
481
492
  : undefined;
482
- const intendedMode = operation.operation === "write" ? operation.mode : undefined;
483
- const touched = index < journal.published || index === journal.applying;
493
+ const intendedMode = operation.operation === "write" || operation.operation === "external-write"
494
+ ? operation.mode
495
+ : undefined;
496
+ const external = operation.operation === "external-write" || operation.operation === "external-remove";
497
+ const touched = external
498
+ ? journal.external_started === true
499
+ : index < journal.published || index === journal.applying;
484
500
  if (!touched) {
485
501
  if (currentHash !== beforeHash || currentMode !== beforeMode)
486
502
  conflicts.push(item.path);
@@ -521,7 +537,7 @@ async function restore(root, journal) {
521
537
  if (conflicts.length)
522
538
  throw new LifecycleError("recovery_conflict", `Transaction targets changed outside the lifecycle lock: ${conflicts.join(", ")}`);
523
539
  }
524
- function parseJournal(raw, expectedRoot) {
540
+ function parseJournal(raw, expectedRoot, allowedRoots) {
525
541
  let value;
526
542
  try {
527
543
  value = JSON.parse(raw.toString("utf8"));
@@ -530,6 +546,7 @@ function parseJournal(raw, expectedRoot) {
530
546
  throw new LifecycleError("invalid_journal", "Transaction journal is not valid JSON");
531
547
  }
532
548
  const journal = value;
549
+ const roots = new Set([expectedRoot, ...allowedRoots].map((value) => resolve(value)));
533
550
  if (journal.schema_version !== 1 ||
534
551
  journal.root !== expectedRoot ||
535
552
  !Array.isArray(journal.operations) ||
@@ -540,6 +557,7 @@ function parseJournal(raw, expectedRoot) {
540
557
  !Number.isSafeInteger(journal.published) ||
541
558
  journal.published < 0 ||
542
559
  journal.published > journal.operations.length ||
560
+ (journal.external_started !== undefined && typeof journal.external_started !== "boolean") ||
543
561
  (journal.applying !== null &&
544
562
  (typeof journal.applying !== "number" ||
545
563
  !Number.isSafeInteger(journal.applying) ||
@@ -550,8 +568,8 @@ function parseJournal(raw, expectedRoot) {
550
568
  }
551
569
  for (const item of journal.snapshots) {
552
570
  assertSafeRelative(item.path);
553
- if (item.root !== undefined && !isAbsolute(item.root))
554
- throw new LifecycleError("invalid_journal", "Transaction snapshot root must be absolute");
571
+ if (item.root !== undefined && (!isAbsolute(item.root) || !roots.has(resolve(item.root))))
572
+ throw new LifecycleError("invalid_journal", "Transaction snapshot root is not allowed");
555
573
  }
556
574
  for (const item of journal.snapshots) {
557
575
  if ((item.content !== null &&
@@ -564,11 +582,14 @@ function parseJournal(raw, expectedRoot) {
564
582
  }
565
583
  for (const item of journal.operations) {
566
584
  assertSafeRelative(item.path);
567
- if (item.root !== undefined && !isAbsolute(item.root))
568
- throw new LifecycleError("invalid_journal", "Transaction operation root must be absolute");
569
- if (item.operation !== "write" && item.operation !== "remove")
585
+ if (item.root !== undefined && (!isAbsolute(item.root) || !roots.has(resolve(item.root))))
586
+ throw new LifecycleError("invalid_journal", "Transaction operation root is not allowed");
587
+ if (item.operation !== "write" &&
588
+ item.operation !== "remove" &&
589
+ item.operation !== "external-write" &&
590
+ item.operation !== "external-remove")
570
591
  throw new LifecycleError("invalid_journal", "Transaction journal contains an invalid operation");
571
- if (item.operation === "write" &&
592
+ if ((item.operation === "write" || item.operation === "external-write") &&
572
593
  (typeof item.content !== "string" ||
573
594
  !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(item.content) ||
574
595
  typeof item.mode !== "number" ||
@@ -577,10 +598,17 @@ function parseJournal(raw, expectedRoot) {
577
598
  item.mode > 0o777))
578
599
  throw new LifecycleError("invalid_journal", "Transaction journal contains invalid write content");
579
600
  }
601
+ for (let index = 0; index < journal.operations.length; index += 1) {
602
+ const operation = journal.operations[index];
603
+ const snapshot = journal.snapshots[index];
604
+ if (operation.path !== snapshot.path ||
605
+ resolve(operation.root ?? expectedRoot) !== resolve(snapshot.root ?? expectedRoot))
606
+ throw new LifecycleError("invalid_journal", "Transaction journal operation and snapshot targets differ");
607
+ }
580
608
  if (!journal.created_directories.every((item) => item &&
581
609
  typeof item.path === "string" &&
582
610
  (inside(resolve(expectedRoot), resolve(item.path)) ||
583
- (journal.operations ?? []).some((operation) => operation.root && inside(resolve(operation.root), resolve(item.path)))) &&
611
+ [...roots].some((root) => inside(root, resolve(item.path)))) &&
584
612
  Number.isSafeInteger(item.device) &&
585
613
  item.device >= 0 &&
586
614
  Number.isSafeInteger(item.inode) &&
@@ -592,11 +620,11 @@ function parseJournal(raw, expectedRoot) {
592
620
  snapshots: journal.snapshots.map((item) => ({ ...item, root: item.root ?? expectedRoot })),
593
621
  };
594
622
  }
595
- export async function recoverTransaction(root, stateRoot) {
623
+ export async function recoverTransaction(root, stateRoot, allowedRoots = []) {
596
624
  const raw = await readRegular(journalPath(stateRoot));
597
625
  if (!raw)
598
626
  return false;
599
- const journal = parseJournal(raw, resolve(root));
627
+ const journal = parseJournal(raw, resolve(root), allowedRoots);
600
628
  await restore(root, journal);
601
629
  await unlink(journalPath(stateRoot));
602
630
  return true;
@@ -640,20 +668,24 @@ async function ensureTransactionDirectories(root, mutation, stateRoot, journal)
640
668
  export async function applyTransaction(root, stateRoot, mutations, options = {}) {
641
669
  if (await readRegular(journalPath(stateRoot)))
642
670
  throw new LifecycleError("recovery_required", "An interrupted transaction must be recovered before apply");
671
+ const ordered = [
672
+ ...mutations.filter((mutation) => mutation.operation !== "external-write" && mutation.operation !== "external-remove"),
673
+ ...mutations.filter((mutation) => mutation.operation === "external-write" || mutation.operation === "external-remove"),
674
+ ];
643
675
  const unique = new Set();
644
- for (const mutation of mutations) {
676
+ for (const mutation of ordered) {
645
677
  assertSafeRelative(mutation.path);
646
- const key = `${mutationRoot(root, mutation)}:${mutation.path}`;
678
+ const key = destination(mutationRoot(root, mutation), mutation.path);
647
679
  if (unique.has(key))
648
680
  throw new LifecycleError("invalid_plan", `Duplicate transaction target: ${mutation.path}`);
649
681
  unique.add(key);
650
682
  await validateExpectation(root, mutation);
651
683
  }
652
- const snapshots = await Promise.all(mutations.map((mutation) => snapshot(root, mutation)));
684
+ const snapshots = await Promise.all(ordered.map((mutation) => snapshot(root, mutation)));
653
685
  const journal = {
654
686
  schema_version: 1,
655
687
  root: resolve(root),
656
- operations: mutations.map((mutation) => mutation.operation === "write"
688
+ operations: ordered.map((mutation) => mutation.operation === "write" || mutation.operation === "external-write"
657
689
  ? {
658
690
  path: mutation.path,
659
691
  ...(mutation.root ? { root: resolve(mutation.root) } : {}),
@@ -672,10 +704,13 @@ export async function applyTransaction(root, stateRoot, mutations, options = {})
672
704
  created_directories: [],
673
705
  published: 0,
674
706
  applying: null,
707
+ external_started: false,
675
708
  };
676
709
  await writeJournal(stateRoot, journal);
677
710
  try {
678
- for (const mutation of mutations) {
711
+ const native = ordered.filter((mutation) => mutation.operation !== "external-write" && mutation.operation !== "external-remove");
712
+ const external = ordered.slice(native.length);
713
+ for (const mutation of native) {
679
714
  journal.applying = journal.published;
680
715
  await writeJournal(stateRoot, journal);
681
716
  options.beforePublish?.(journal.published);
@@ -696,11 +731,19 @@ export async function applyTransaction(root, stateRoot, mutations, options = {})
696
731
  if (injected === "fail")
697
732
  throw new LifecycleError("test_failure", "Injected transaction failure");
698
733
  }
699
- for (const mutation of mutations) {
734
+ if (external.length) {
735
+ for (const mutation of external)
736
+ await validateExpectation(root, mutation);
737
+ journal.external_started = true;
738
+ await writeJournal(stateRoot, journal);
739
+ await options.applyExternal?.();
740
+ }
741
+ for (const mutation of ordered) {
700
742
  const target = destination(mutationRoot(root, mutation), mutation.path);
701
743
  const current = await readRegular(target);
702
- if ((mutation.operation === "remove" && current) ||
703
- (mutation.operation === "write" &&
744
+ if (((mutation.operation === "remove" || mutation.operation === "external-remove") &&
745
+ current) ||
746
+ ((mutation.operation === "write" || mutation.operation === "external-write") &&
704
747
  (!current ||
705
748
  !current.equals(mutation.content) ||
706
749
  ((await stat(target)).mode & 0o777) !== mutation.mode))) {