@klhapp/skillmux 0.4.4 → 0.5.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/CHANGELOG.md +29 -0
- package/README.md +57 -11
- package/docs/configuration.md +50 -9
- package/package.json +1 -1
- package/src/cli.ts +524 -44
- package/src/completions.ts +33 -2
- package/src/config.ts +7 -3
- package/src/init-clients.ts +189 -0
- package/src/init-instructions.ts +173 -0
- package/src/init.ts +280 -17
- package/src/manifest.ts +24 -13
- package/src/setup.ts +145 -0
- package/src/sync.ts +136 -19
package/src/completions.ts
CHANGED
|
@@ -20,7 +20,7 @@ _skillmux_completions() {
|
|
|
20
20
|
COMPREPLY=( $(compgen -W "add list current use remove" -- "$cur") )
|
|
21
21
|
;;
|
|
22
22
|
config)
|
|
23
|
-
COMPREPLY=( $(compgen -W "show get validate diff set status" -- "$cur") )
|
|
23
|
+
COMPREPLY=( $(compgen -W "init show get validate diff set status" -- "$cur") )
|
|
24
24
|
;;
|
|
25
25
|
calibrate)
|
|
26
26
|
COMPREPLY=( $(compgen -W "run list show apply generate-dataset" -- "$cur") )
|
|
@@ -34,7 +34,16 @@ _skillmux_completions() {
|
|
|
34
34
|
local-vault)
|
|
35
35
|
COMPREPLY=( $(compgen -W "init" -- "$cur") )
|
|
36
36
|
;;
|
|
37
|
+
--client)
|
|
38
|
+
COMPREPLY=( $(compgen -W "claude-code codex gemini-cli opencode github-copilot windsurf antigravity goose hermes skillmux-mcp" -- "$cur") )
|
|
39
|
+
;;
|
|
40
|
+
--target)
|
|
41
|
+
COMPREPLY=( $(compgen -W "agent-skills claude-code codex custom" -- "$cur") )
|
|
42
|
+
;;
|
|
37
43
|
esac
|
|
44
|
+
if [ "\${COMP_WORDS[1]}" = "init" ] && [ "\${#COMPREPLY[@]}" -eq 0 ]; then
|
|
45
|
+
COMPREPLY=( $(compgen -W "--client --target --path --vault --core --migrate-full-vault --yes --dry-run --json" -- "$cur") )
|
|
46
|
+
fi
|
|
38
47
|
}
|
|
39
48
|
complete -F _skillmux_completions skillmux
|
|
40
49
|
`;
|
|
@@ -63,7 +72,20 @@ _skillmux() {
|
|
|
63
72
|
'models:Manage local models'
|
|
64
73
|
'completions:Generate shell completions'
|
|
65
74
|
)
|
|
66
|
-
|
|
75
|
+
if (( CURRENT == 2 )); then
|
|
76
|
+
_describe -t commands 'skillmux command' commands
|
|
77
|
+
elif [[ "$words[2]" == "init" ]]; then
|
|
78
|
+
_arguments \
|
|
79
|
+
'*--client[select a client]:client:(claude-code codex gemini-cli opencode github-copilot windsurf antigravity goose hermes skillmux-mcp)' \
|
|
80
|
+
'*--target[select a target]:target:(agent-skills claude-code codex custom)' \
|
|
81
|
+
'--path[custom target directory]:directory:_directories' \
|
|
82
|
+
'--vault[vault directory]:directory:_directories' \
|
|
83
|
+
'*--core[seed a core skill]:skill id:' \
|
|
84
|
+
'--migrate-full-vault[convert a full-vault symlink to managed pins]' \
|
|
85
|
+
'--yes[apply without prompts]' \
|
|
86
|
+
'--dry-run[print the plan without writing]' \
|
|
87
|
+
'--json[emit a JSON envelope]'
|
|
88
|
+
fi
|
|
67
89
|
}
|
|
68
90
|
_skillmux "$@"
|
|
69
91
|
`;
|
|
@@ -77,6 +99,15 @@ complete -c skillmux -n "__fish_use_subcommand" -a config -d "Manage configurati
|
|
|
77
99
|
complete -c skillmux -n "__fish_use_subcommand" -a calibrate -d "Manage policy calibration"
|
|
78
100
|
complete -c skillmux -n "__fish_use_subcommand" -a serve -d "Start MCP server"
|
|
79
101
|
complete -c skillmux -n "__fish_use_subcommand" -a completions -d "Generate shell completions"
|
|
102
|
+
complete -c skillmux -n "__fish_seen_subcommand_from init" -l client -x -a "claude-code codex gemini-cli opencode github-copilot windsurf antigravity goose hermes skillmux-mcp" -d "Select a client"
|
|
103
|
+
complete -c skillmux -n "__fish_seen_subcommand_from init" -l target -x -a "agent-skills claude-code codex custom" -d "Select a target"
|
|
104
|
+
complete -c skillmux -n "__fish_seen_subcommand_from init" -l path -r -d "Custom target directory"
|
|
105
|
+
complete -c skillmux -n "__fish_seen_subcommand_from init" -l vault -r -d "Vault directory"
|
|
106
|
+
complete -c skillmux -n "__fish_seen_subcommand_from init" -l core -x -d "Seed a core skill"
|
|
107
|
+
complete -c skillmux -n "__fish_seen_subcommand_from init" -l migrate-full-vault -d "Convert a full-vault symlink"
|
|
108
|
+
complete -c skillmux -n "__fish_seen_subcommand_from init" -l yes -d "Apply without prompts"
|
|
109
|
+
complete -c skillmux -n "__fish_seen_subcommand_from init" -l dry-run -d "Print the plan without writing"
|
|
110
|
+
complete -c skillmux -n "__fish_seen_subcommand_from init" -l json -d "Emit a JSON envelope"
|
|
80
111
|
`;
|
|
81
112
|
}
|
|
82
113
|
|
package/src/config.ts
CHANGED
|
@@ -169,8 +169,7 @@ export function migrateLegacyPaths(): void {
|
|
|
169
169
|
migrateLegacyDir("~/.cache/skill-router", "~/.cache/skillmux");
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
-
export
|
|
173
|
-
migrateLegacyPaths();
|
|
172
|
+
export function resolveConfigPath(path?: string): string {
|
|
174
173
|
let configEnv = process.env.SKILLMUX_CONFIG;
|
|
175
174
|
if (configEnv === undefined && process.env.SKILL_ROUTER_CONFIG !== undefined) {
|
|
176
175
|
if (!warnedEnv.has("SKILL_ROUTER_CONFIG")) {
|
|
@@ -179,7 +178,12 @@ export async function loadConfig(path?: string): Promise<Config> {
|
|
|
179
178
|
}
|
|
180
179
|
configEnv = process.env.SKILL_ROUTER_CONFIG;
|
|
181
180
|
}
|
|
182
|
-
|
|
181
|
+
return expandHome(path ?? configEnv ?? DEFAULT_CONFIG_PATH);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function loadConfig(path?: string): Promise<Config> {
|
|
185
|
+
migrateLegacyPaths();
|
|
186
|
+
const configPath = resolveConfigPath(path);
|
|
183
187
|
const file = Bun.file(expandHome(configPath));
|
|
184
188
|
|
|
185
189
|
const baseConfig = structuredClone(DEFAULTS);
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export const SUPPORTED_CLIENT_IDS = [
|
|
5
|
+
"claude-code",
|
|
6
|
+
"codex",
|
|
7
|
+
"gemini-cli",
|
|
8
|
+
"opencode",
|
|
9
|
+
"github-copilot",
|
|
10
|
+
"windsurf",
|
|
11
|
+
"antigravity",
|
|
12
|
+
"goose",
|
|
13
|
+
"hermes",
|
|
14
|
+
"skillmux-mcp",
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
export type ClientId = (typeof SUPPORTED_CLIENT_IDS)[number];
|
|
18
|
+
export type DeliveryMode = "managed-pins" | "full-vault" | "mcp";
|
|
19
|
+
|
|
20
|
+
interface ClientDefinition {
|
|
21
|
+
id: ClientId;
|
|
22
|
+
surfaceId?: "agent-skills" | "claude-code" | "codex" | "antigravity";
|
|
23
|
+
deliveryMode: DeliveryMode;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PlannedClientSurface {
|
|
27
|
+
id: string;
|
|
28
|
+
targetName: string;
|
|
29
|
+
path: string;
|
|
30
|
+
deliveryMode: "managed-pins";
|
|
31
|
+
clients: ClientId[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ClientSurfacePlan {
|
|
35
|
+
clients: ClientDefinition[];
|
|
36
|
+
surfaces: PlannedClientSurface[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type ReadinessStatus = "ready" | "planned" | "manual" | "not-applicable";
|
|
40
|
+
|
|
41
|
+
export interface ReadinessAxis {
|
|
42
|
+
status: ReadinessStatus;
|
|
43
|
+
detail: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ClientReadiness {
|
|
47
|
+
client: ClientId;
|
|
48
|
+
skillSurface: ReadinessAxis;
|
|
49
|
+
mcpRegistration: ReadinessAxis;
|
|
50
|
+
instructionSetup: ReadinessAxis;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ResolvedBuiltInTarget {
|
|
54
|
+
targetName: string;
|
|
55
|
+
path: string;
|
|
56
|
+
warning?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const CLIENTS: Record<ClientId, ClientDefinition> = {
|
|
60
|
+
"claude-code": { id: "claude-code", surfaceId: "claude-code", deliveryMode: "managed-pins" },
|
|
61
|
+
codex: { id: "codex", surfaceId: "codex", deliveryMode: "managed-pins" },
|
|
62
|
+
"gemini-cli": { id: "gemini-cli", surfaceId: "agent-skills", deliveryMode: "managed-pins" },
|
|
63
|
+
opencode: { id: "opencode", surfaceId: "agent-skills", deliveryMode: "managed-pins" },
|
|
64
|
+
"github-copilot": { id: "github-copilot", surfaceId: "agent-skills", deliveryMode: "managed-pins" },
|
|
65
|
+
windsurf: { id: "windsurf", surfaceId: "agent-skills", deliveryMode: "managed-pins" },
|
|
66
|
+
antigravity: { id: "antigravity", surfaceId: "antigravity", deliveryMode: "managed-pins" },
|
|
67
|
+
goose: { id: "goose", deliveryMode: "full-vault" },
|
|
68
|
+
hermes: { id: "hermes", deliveryMode: "full-vault" },
|
|
69
|
+
"skillmux-mcp": { id: "skillmux-mcp", deliveryMode: "mcp" },
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function surfacePath(
|
|
73
|
+
surfaceId: NonNullable<ClientDefinition["surfaceId"]>,
|
|
74
|
+
options: { home: string; codexHome?: string },
|
|
75
|
+
): string {
|
|
76
|
+
if (surfaceId === "agent-skills") return join(options.home, ".agents", "skills");
|
|
77
|
+
if (surfaceId === "claude-code") return join(options.home, ".claude", "skills");
|
|
78
|
+
if (surfaceId === "codex") return join(options.codexHome ?? join(options.home, ".codex"), "skills");
|
|
79
|
+
return join(options.home, ".gemini", "config", "skills");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function resolveBuiltInTarget(
|
|
83
|
+
name: string,
|
|
84
|
+
options: { home?: string; codexHome?: string; customPath?: string } = {},
|
|
85
|
+
): ResolvedBuiltInTarget {
|
|
86
|
+
const home = options.home ?? homedir();
|
|
87
|
+
if (name === "custom") {
|
|
88
|
+
if (!options.customPath) throw new Error("--target custom requires --path <dir>");
|
|
89
|
+
return { targetName: name, path: options.customPath };
|
|
90
|
+
}
|
|
91
|
+
if (name === "agent-skills" || name === "agents") {
|
|
92
|
+
return {
|
|
93
|
+
targetName: name,
|
|
94
|
+
path: surfacePath("agent-skills", { home }),
|
|
95
|
+
...(name === "agents"
|
|
96
|
+
? { warning: "--target agents is deprecated; use --target agent-skills" }
|
|
97
|
+
: {}),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (name === "claude-code" || name === "claude") {
|
|
101
|
+
return {
|
|
102
|
+
targetName: name,
|
|
103
|
+
path: surfacePath("claude-code", { home }),
|
|
104
|
+
...(name === "claude"
|
|
105
|
+
? { warning: "--target claude is deprecated; use --target claude-code" }
|
|
106
|
+
: {}),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
if (name === "codex") {
|
|
110
|
+
return {
|
|
111
|
+
targetName: name,
|
|
112
|
+
path: surfacePath("codex", { home, codexHome: options.codexHome }),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
throw new Error(
|
|
116
|
+
`unknown --target "${name}"; supported targets: agent-skills, claude-code, codex, custom`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function planClientSurfaces(
|
|
121
|
+
requestedClients: readonly string[],
|
|
122
|
+
options: { home?: string; codexHome?: string } = {},
|
|
123
|
+
): ClientSurfacePlan {
|
|
124
|
+
const clients = [...new Set(requestedClients)].map((id) => {
|
|
125
|
+
if (!SUPPORTED_CLIENT_IDS.includes(id as ClientId)) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`unsupported client "${id}"; supported clients: ${SUPPORTED_CLIENT_IDS.join(", ")}`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return CLIENTS[id as ClientId];
|
|
131
|
+
});
|
|
132
|
+
const home = options.home ?? homedir();
|
|
133
|
+
const surfaces = new Map<string, PlannedClientSurface>();
|
|
134
|
+
|
|
135
|
+
for (const client of clients) {
|
|
136
|
+
if (!client.surfaceId) continue;
|
|
137
|
+
const path = surfacePath(client.surfaceId, { home, codexHome: options.codexHome });
|
|
138
|
+
const existing = surfaces.get(path);
|
|
139
|
+
if (existing) {
|
|
140
|
+
if (!existing.clients.includes(client.id)) existing.clients.push(client.id);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
surfaces.set(path, {
|
|
144
|
+
id: client.surfaceId,
|
|
145
|
+
targetName: client.surfaceId,
|
|
146
|
+
path,
|
|
147
|
+
deliveryMode: "managed-pins",
|
|
148
|
+
clients: [client.id],
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { clients, surfaces: [...surfaces.values()] };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function assessClientReadiness(
|
|
156
|
+
plan: ClientSurfacePlan,
|
|
157
|
+
instructionReadiness: Partial<Record<ClientId, ReadinessAxis>> = {},
|
|
158
|
+
): ClientReadiness[] {
|
|
159
|
+
return plan.clients.map((client) => {
|
|
160
|
+
const surface = plan.surfaces.find((candidate) => candidate.clients.includes(client.id));
|
|
161
|
+
let skillSurface: ReadinessAxis;
|
|
162
|
+
if (surface) {
|
|
163
|
+
skillSurface = { status: "planned", detail: surface.path };
|
|
164
|
+
} else if (client.id === "goose") {
|
|
165
|
+
skillSurface = { status: "manual", detail: "configure the full vault in Goose" };
|
|
166
|
+
} else if (client.id === "hermes") {
|
|
167
|
+
skillSurface = { status: "manual", detail: "configure the full vault in Hermes external_dirs" };
|
|
168
|
+
} else {
|
|
169
|
+
skillSurface = {
|
|
170
|
+
status: "not-applicable",
|
|
171
|
+
detail: "skills resolve through Skillmux MCP",
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const mcpRegistration: ReadinessAxis = client.deliveryMode === "mcp"
|
|
176
|
+
? { status: "manual", detail: "register the Skillmux MCP server" }
|
|
177
|
+
: { status: "not-applicable", detail: "native skill loading" };
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
client: client.id,
|
|
181
|
+
skillSurface,
|
|
182
|
+
mcpRegistration,
|
|
183
|
+
instructionSetup: instructionReadiness[client.id] ?? {
|
|
184
|
+
status: "manual",
|
|
185
|
+
detail: "instruction adapter not applied",
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
});
|
|
189
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import {
|
|
2
|
+
lstatSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
renameSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
statSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { DISCOVERY_PARAGRAPH } from "./init";
|
|
12
|
+
import type { ClientId } from "./init-clients";
|
|
13
|
+
|
|
14
|
+
export const INSTRUCTION_BLOCK_START = "<!-- skillmux:discovery:start -->";
|
|
15
|
+
export const INSTRUCTION_BLOCK_END = "<!-- skillmux:discovery:end -->";
|
|
16
|
+
|
|
17
|
+
const MANAGED_BLOCK = [
|
|
18
|
+
INSTRUCTION_BLOCK_START,
|
|
19
|
+
DISCOVERY_PARAGRAPH,
|
|
20
|
+
INSTRUCTION_BLOCK_END,
|
|
21
|
+
].join("\n");
|
|
22
|
+
|
|
23
|
+
export interface InstructionChange {
|
|
24
|
+
path: string;
|
|
25
|
+
clients: ClientId[];
|
|
26
|
+
status: "create" | "update" | "unchanged";
|
|
27
|
+
before: string | null;
|
|
28
|
+
after: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface InstructionPlan {
|
|
32
|
+
changes: InstructionChange[];
|
|
33
|
+
manual: Array<{ client: ClientId; reason: string }>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface InstructionPlanOptions {
|
|
37
|
+
home?: string;
|
|
38
|
+
codexHome?: string;
|
|
39
|
+
claudeConfigDir?: string;
|
|
40
|
+
readFile?: (path: string) => string | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function instructionPath(
|
|
44
|
+
client: ClientId,
|
|
45
|
+
options: Required<Pick<InstructionPlanOptions, "home" | "codexHome" | "claudeConfigDir">>,
|
|
46
|
+
): string | undefined {
|
|
47
|
+
if (client === "claude-code") return join(options.claudeConfigDir, "CLAUDE.md");
|
|
48
|
+
if (client === "codex") return join(options.codexHome, "AGENTS.md");
|
|
49
|
+
if (client === "gemini-cli" || client === "antigravity") {
|
|
50
|
+
return join(options.home, ".gemini", "GEMINI.md");
|
|
51
|
+
}
|
|
52
|
+
if (client === "opencode") return join(options.home, ".config", "opencode", "AGENTS.md");
|
|
53
|
+
if (client === "goose") return join(options.home, ".config", "goose", ".goosehints");
|
|
54
|
+
if (client === "hermes") return join(options.home, ".hermes.md");
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readInstructionFile(path: string): string | null {
|
|
59
|
+
try {
|
|
60
|
+
const stat = lstatSync(path);
|
|
61
|
+
if (stat.isSymbolicLink()) {
|
|
62
|
+
throw new Error(`instruction file is a symbolic link and will not be modified: ${path}`);
|
|
63
|
+
}
|
|
64
|
+
if (!stat.isFile()) throw new Error(`instruction path is not a file: ${path}`);
|
|
65
|
+
return readFileSync(path, "utf8");
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function withManagedBlock(existing: string | null, path: string): string {
|
|
73
|
+
if (existing === null || existing.length === 0) return `${MANAGED_BLOCK}\n`;
|
|
74
|
+
|
|
75
|
+
const starts = existing.split(INSTRUCTION_BLOCK_START).length - 1;
|
|
76
|
+
const ends = existing.split(INSTRUCTION_BLOCK_END).length - 1;
|
|
77
|
+
if (starts === 0 && ends === 0) return `${existing.trimEnd()}\n\n${MANAGED_BLOCK}\n`;
|
|
78
|
+
if (starts !== 1 || ends !== 1) {
|
|
79
|
+
throw new Error(`instruction file has a malformed skillmux managed block: ${path}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const start = existing.indexOf(INSTRUCTION_BLOCK_START);
|
|
83
|
+
const end = existing.indexOf(INSTRUCTION_BLOCK_END, start);
|
|
84
|
+
if (end < start) throw new Error(`instruction file has a malformed skillmux managed block: ${path}`);
|
|
85
|
+
return `${existing.slice(0, start)}${MANAGED_BLOCK}${existing.slice(end + INSTRUCTION_BLOCK_END.length)}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function planInstructionSetup(
|
|
89
|
+
requestedClients: readonly ClientId[],
|
|
90
|
+
options: InstructionPlanOptions = {},
|
|
91
|
+
): InstructionPlan {
|
|
92
|
+
const home = options.home ?? process.env.HOME ?? "";
|
|
93
|
+
const resolvedOptions = {
|
|
94
|
+
home,
|
|
95
|
+
codexHome: options.codexHome ?? process.env.CODEX_HOME ?? join(home, ".codex"),
|
|
96
|
+
claudeConfigDir: options.claudeConfigDir ?? process.env.CLAUDE_CONFIG_DIR ?? join(home, ".claude"),
|
|
97
|
+
};
|
|
98
|
+
const readFile = options.readFile ?? readInstructionFile;
|
|
99
|
+
const changesByPath = new Map<string, InstructionChange>();
|
|
100
|
+
const manual: InstructionPlan["manual"] = [];
|
|
101
|
+
|
|
102
|
+
for (const client of [...new Set(requestedClients)]) {
|
|
103
|
+
const path = instructionPath(client, resolvedOptions);
|
|
104
|
+
if (!path) {
|
|
105
|
+
manual.push({ client, reason: "no safe durable user instruction file is known" });
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const existingChange = changesByPath.get(path);
|
|
109
|
+
if (existingChange) {
|
|
110
|
+
existingChange.clients.push(client);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const before = readFile(path);
|
|
115
|
+
const after = withManagedBlock(before, path);
|
|
116
|
+
changesByPath.set(path, {
|
|
117
|
+
path,
|
|
118
|
+
clients: [client],
|
|
119
|
+
status: before === null ? "create" : before === after ? "unchanged" : "update",
|
|
120
|
+
before,
|
|
121
|
+
after,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { changes: [...changesByPath.values()], manual };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function atomicWrite(path: string, content: string, mode: number): void {
|
|
129
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
130
|
+
const temporaryPath = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
131
|
+
try {
|
|
132
|
+
writeFileSync(temporaryPath, content, { encoding: "utf8", mode });
|
|
133
|
+
renameSync(temporaryPath, path);
|
|
134
|
+
} finally {
|
|
135
|
+
rmSync(temporaryPath, { force: true });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function applyInstructionPlan(plan: InstructionPlan): void {
|
|
140
|
+
for (const change of plan.changes) {
|
|
141
|
+
if (change.status === "unchanged") continue;
|
|
142
|
+
if (readInstructionFile(change.path) !== change.before) {
|
|
143
|
+
throw new Error(`instruction file changed after planning: ${change.path}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const applied: InstructionChange[] = [];
|
|
148
|
+
try {
|
|
149
|
+
for (const change of plan.changes) {
|
|
150
|
+
if (change.status === "unchanged") continue;
|
|
151
|
+
const mode = change.before === null ? 0o600 : statSync(change.path).mode;
|
|
152
|
+
atomicWrite(change.path, change.after, mode);
|
|
153
|
+
applied.push(change);
|
|
154
|
+
}
|
|
155
|
+
} catch (error) {
|
|
156
|
+
rollbackInstructionChanges(applied);
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function rollbackInstructionChanges(changes: InstructionChange[]): void {
|
|
162
|
+
for (const change of [...changes].reverse()) {
|
|
163
|
+
if (change.before === null) {
|
|
164
|
+
rmSync(change.path, { force: true });
|
|
165
|
+
} else {
|
|
166
|
+
atomicWrite(change.path, change.before, statSync(change.path).mode);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function rollbackInstructionPlan(plan: InstructionPlan): void {
|
|
172
|
+
rollbackInstructionChanges(plan.changes.filter((change) => change.status !== "unchanged"));
|
|
173
|
+
}
|