agent-skillboard 0.2.17 → 0.3.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 +62 -0
- package/README.md +128 -260
- package/bin/postinstall.mjs +2 -2
- package/docs/adapters.md +37 -113
- package/docs/ai-skill-routing-goal.md +35 -109
- package/docs/capabilities.md +17 -104
- package/docs/install.md +39 -493
- package/docs/policy-model.md +50 -280
- package/docs/positioning.md +19 -86
- package/docs/profiles.md +21 -153
- package/docs/reference.md +117 -356
- package/docs/rollout-runbook.md +23 -25
- package/docs/routing.md +23 -90
- package/docs/user-flow.md +60 -292
- package/docs/value-proof.md +23 -181
- package/docs/variant-lifecycle.md +47 -67
- package/docs/versioning.md +31 -264
- package/examples/v2-multi-source.config.yaml +35 -0
- package/examples/v2-policy-error.config.yaml +6 -0
- package/package.json +1 -1
- package/src/advisor/actions.mjs +102 -6
- package/src/advisor/application-commands.mjs +10 -9
- package/src/advisor/apply-action.mjs +74 -1
- package/src/advisor/guidance.mjs +24 -16
- package/src/advisor/schema.mjs +17 -6
- package/src/advisor/skills.mjs +18 -5
- package/src/advisor.mjs +27 -9
- package/src/agent-integration-cli.mjs +13 -4
- package/src/agent-integration-content.mjs +22 -12
- package/src/agent-integration-files.mjs +1 -1
- package/src/agent-inventory-platforms.mjs +10 -0
- package/src/agent-inventory.mjs +23 -1
- package/src/agent-skill-import.mjs +2 -2
- package/src/audit-paths.mjs +42 -0
- package/src/brief-cli.mjs +3 -2
- package/src/brief-renderer.mjs +1 -0
- package/src/cli.mjs +398 -127
- package/src/compatibility.mjs +24 -0
- package/src/control/can-use-guard.mjs +21 -1
- package/src/control/config-write.mjs +32 -2
- package/src/control/skill-crud.mjs +5 -0
- package/src/control/v2-guard.mjs +175 -0
- package/src/control/v2-skill-crud.mjs +32 -0
- package/src/control/v2-skill-forget.mjs +38 -0
- package/src/control/variant-status.mjs +47 -1
- package/src/control.mjs +55 -0
- package/src/doctor.mjs +65 -6
- package/src/domain/v2-policy.mjs +111 -0
- package/src/hook-plan.mjs +33 -3
- package/src/impact.mjs +52 -29
- package/src/index.mjs +25 -1
- package/src/init.mjs +50 -34
- package/src/inventory-install-units.mjs +63 -0
- package/src/inventory-json.mjs +279 -0
- package/src/inventory-refresh.mjs +163 -18
- package/src/lifecycle-cli.mjs +40 -12
- package/src/lifecycle-content.mjs +52 -67
- package/src/migration/v1-to-v2.mjs +212 -0
- package/src/migration/v2-files.mjs +211 -0
- package/src/migration/v2-journal.mjs +169 -0
- package/src/migration/v2-projection.mjs +108 -0
- package/src/migration/v2-transaction.mjs +205 -0
- package/src/policy.mjs +3 -0
- package/src/reconcile.mjs +139 -111
- package/src/report.mjs +168 -148
- package/src/review.mjs +2 -0
- package/src/route-advisory.mjs +47 -2
- package/src/route-selection.mjs +38 -2
- package/src/route.mjs +62 -2
- package/src/shared-skill.mjs +301 -0
- package/src/source-digest.mjs +42 -0
- package/src/source-profiles.mjs +27 -0
- package/src/source-verification.mjs +32 -48
- package/src/uninstall.mjs +22 -0
- package/src/user-state-paths.mjs +19 -0
- package/src/user-uninstall.mjs +146 -0
- package/src/workspace.mjs +41 -1
|
@@ -2,17 +2,8 @@ export const BRIDGE_START = "<!-- BEGIN SKILLBOARD -->";
|
|
|
2
2
|
export const BRIDGE_END = "<!-- END SKILLBOARD -->";
|
|
3
3
|
|
|
4
4
|
export function defaultConfig() {
|
|
5
|
-
return `version:
|
|
6
|
-
defaults:
|
|
7
|
-
invocation_policy: deny-by-default
|
|
8
|
-
allow_model_invocation: false
|
|
9
|
-
require_explicit_workflow: true
|
|
10
|
-
|
|
5
|
+
return `version: 2
|
|
11
6
|
skills: {}
|
|
12
|
-
capabilities: {}
|
|
13
|
-
harnesses: {}
|
|
14
|
-
workflows: {}
|
|
15
|
-
install_units: {}
|
|
16
7
|
`;
|
|
17
8
|
}
|
|
18
9
|
|
|
@@ -20,40 +11,49 @@ export function bridgeBlock() {
|
|
|
20
11
|
return `${BRIDGE_START}
|
|
21
12
|
# SkillBoard Control Plane
|
|
22
13
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
-
|
|
36
|
-
|
|
37
|
-
-
|
|
38
|
-
|
|
39
|
-
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
14
|
+
SkillBoard keeps one user-level policy. A valid installed skill defaults to
|
|
15
|
+
enabled and agent-local. Users may opt individual skills into cross-agent
|
|
16
|
+
sharing. Optional preference ranks enabled skills installed for the current
|
|
17
|
+
agent and never changes availability or copies files.
|
|
18
|
+
|
|
19
|
+
## Route and use
|
|
20
|
+
|
|
21
|
+
- Work normally unless skills overlap or the user asks
|
|
22
|
+
for a skill decision.
|
|
23
|
+
- Read \`skillboard brief --intent <request> --agent <agent> --json\`.
|
|
24
|
+
- Use the recommended enabled, installed skill and ordered fallbacks. Honor an
|
|
25
|
+
explicit user choice when the guard allows it.
|
|
26
|
+
- Run \`skillboard guard use <skill-id> --agent <agent> --json\` immediately
|
|
27
|
+
before use.
|
|
28
|
+
- Do not ask for another approval when guard allows use. Briefly disclose the
|
|
29
|
+
selected skill as an audit trace.
|
|
30
|
+
- If the routed brief suggests policy memory, finish the work first, then ask
|
|
31
|
+
once whether to remember that intent preference. Do not prompt before use.
|
|
32
|
+
|
|
33
|
+
## Change policy
|
|
34
|
+
|
|
35
|
+
- Policy changes only enable/disable a skill, share/unshare one skill, or
|
|
36
|
+
remember optional ranking preference.
|
|
37
|
+
- Read a fresh brief with \`--include-actions\`, choose one current action id, ask
|
|
38
|
+
for one confirmation, then run \`skillboard apply-action <action-id> --yes
|
|
39
|
+
--json\`.
|
|
40
|
+
- Reread the returned post-apply brief. Never reuse cached action ids or apply
|
|
41
|
+
multiple actions from one confirmation.
|
|
42
|
+
- If inventory no longer observes a permanently removed unshared skill, use its
|
|
43
|
+
current forget action after confirmation. Forget removes policy only and never
|
|
44
|
+
deletes skill files.
|
|
45
|
+
|
|
46
|
+
## Compatibility and boundaries
|
|
47
|
+
|
|
48
|
+
- If the brief reports stale version 1 policy, do not mutate it. Preview
|
|
49
|
+
\`skillboard migrate v2 --config skillboard.config.yaml --json\`, then obtain
|
|
50
|
+
confirmation before applying with \`--yes --json\`. Rollback uses
|
|
51
|
+
\`--rollback <backup> --json\`.
|
|
52
|
+
- Source and provenance observations are optional audit metadata and never
|
|
53
|
+
determine availability.
|
|
54
|
+
- Runtime and action authorization are outside SkillBoard's scope. Follow the
|
|
55
|
+
agent or harness permission boundary for commands, hooks, MCP servers,
|
|
56
|
+
external writes, destructive actions, network access, and secrets.
|
|
57
57
|
|
|
58
58
|
${BRIDGE_END}`;
|
|
59
59
|
}
|
|
@@ -61,39 +61,24 @@ ${BRIDGE_END}`;
|
|
|
61
61
|
export function profileReadme() {
|
|
62
62
|
return `# SkillBoard source profiles
|
|
63
63
|
|
|
64
|
-
Put
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
\`\`\`bash
|
|
70
|
-
skillboard import --profile .skillboard/profiles/example.yaml --source-root /path/to/repo
|
|
71
|
-
\`\`\`
|
|
72
|
-
|
|
73
|
-
The import command emits a YAML fragment with governed \`skills\` and
|
|
74
|
-
\`install_units\`. Review the fragment before merging it into
|
|
75
|
-
\`skillboard.config.yaml\`; imported skills are not automatically active.
|
|
64
|
+
Put discovery profiles here when a skill repository is not
|
|
65
|
+
covered by a built-in profile. Profiles produce inventory observations; they do
|
|
66
|
+
not authorize availability. Newly discovered valid skills default to
|
|
67
|
+
enabled and agent-local while existing user policy is preserved.
|
|
76
68
|
`;
|
|
77
69
|
}
|
|
78
70
|
|
|
79
71
|
export function hookReadme() {
|
|
80
72
|
return `# SkillBoard hooks
|
|
81
73
|
|
|
82
|
-
|
|
74
|
+
Preview a generated guard hook before installation:
|
|
83
75
|
|
|
84
76
|
\`\`\`bash
|
|
85
77
|
skillboard hook install --workflow <name> --config skillboard.config.yaml --skills skills --out .skillboard/hooks/<name>-guard.sh --dry-run --json
|
|
86
|
-
skillboard hook install --workflow <name> --config skillboard.config.yaml --skills skills --out .skillboard/hooks/<name>-guard.sh
|
|
87
78
|
\`\`\`
|
|
88
79
|
|
|
89
|
-
For
|
|
90
|
-
\`skillboard apply-action <action-id> --yes --json\`.
|
|
91
|
-
|
|
92
|
-
\`planned.preview.shell\` before materializing an executable guard hook.
|
|
93
|
-
Generated hooks pin the install-time SkillBoard command, config, skills root,
|
|
94
|
-
and workflow; set those values with hook install options such as
|
|
95
|
-
\`--skillboard-bin\`, not runtime environment overrides. The generated script
|
|
96
|
-
delegates to \`skillboard guard use\` and can be wired into the hook mechanism of
|
|
97
|
-
the active harness.
|
|
80
|
+
For persistent changes prefer one current action card applied with
|
|
81
|
+
\`skillboard apply-action <action-id> --yes --json\`. Runtime hook permission is
|
|
82
|
+
owned by the active harness, not SkillBoard policy.
|
|
98
83
|
`;
|
|
99
84
|
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { EXPOSURE_VALUES, INVOCATION_VALUES, STATUS_VALUES } from "../domain/constants.mjs";
|
|
2
|
+
import { TERMINAL_STATUSES, validateSkillState } from "../domain/skill-state-matrix.mjs";
|
|
3
|
+
|
|
4
|
+
const V1_SKILL_POLICY_FIELDS = new Set(["status", "invocation", "exposure"]);
|
|
5
|
+
const V1_SKILL_LOCATION_FIELDS = new Set(["path", "owner_install_unit"]);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Purely project a parsed v1 config into v2 policy and audit observations.
|
|
9
|
+
* The caller retains ownership of input and may safely hash/reuse it afterward.
|
|
10
|
+
*/
|
|
11
|
+
export function mapV1ConfigToV2(input) {
|
|
12
|
+
const config = requireRecord(input, "v1 config");
|
|
13
|
+
if (config.version !== undefined && config.version !== 1) {
|
|
14
|
+
throw new Error(`Expected version 1 config; got version ${String(config.version)}`);
|
|
15
|
+
}
|
|
16
|
+
const skills = requireRecord(config.skills ?? {}, "v1 config skills");
|
|
17
|
+
const workflows = requireRecord(config.workflows ?? {}, "v1 config workflows");
|
|
18
|
+
const capabilities = requireRecord(config.capabilities ?? {}, "v1 config capabilities");
|
|
19
|
+
const installUnits = requireRecord(config.install_units ?? {}, "v1 config install_units");
|
|
20
|
+
const workflowNames = Object.keys(workflows).sort();
|
|
21
|
+
const warnings = [];
|
|
22
|
+
const losses = collectLosses(config);
|
|
23
|
+
const policySkills = {};
|
|
24
|
+
const inventorySkills = [];
|
|
25
|
+
const uncertainQuarantines = [];
|
|
26
|
+
|
|
27
|
+
for (const [skillId, rawSkill] of Object.entries(skills).sort(byEntryKey)) {
|
|
28
|
+
const skill = requireRecord(rawSkill, `skill ${skillId}`);
|
|
29
|
+
validateState(skillId, skill);
|
|
30
|
+
const enabled = !TERMINAL_STATUSES.has(skill.status);
|
|
31
|
+
const preference = preferenceFor(skillId, capabilities, workflows);
|
|
32
|
+
policySkills[skillId] = {
|
|
33
|
+
enabled,
|
|
34
|
+
shared: false,
|
|
35
|
+
...(preference === null ? {} : { preference })
|
|
36
|
+
};
|
|
37
|
+
if (isReviewOnlyQuarantine(skillId, skill, workflowNames, workflows)) {
|
|
38
|
+
uncertainQuarantines.push(skillId);
|
|
39
|
+
}
|
|
40
|
+
inventorySkills.push(skillObservation(skillId, skill));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const ambiguities = uncertainQuarantines.length === 0 ? [] : [{
|
|
44
|
+
kind: "review_only_quarantine",
|
|
45
|
+
skill_ids: uncertainQuarantines,
|
|
46
|
+
mapped_enabled: true,
|
|
47
|
+
requires_grouped_confirmation: true
|
|
48
|
+
}];
|
|
49
|
+
if (uncertainQuarantines.length > 0) {
|
|
50
|
+
warnings.push(
|
|
51
|
+
`${uncertainQuarantines.length} review-only quarantine state(s) were mapped enabled; confirm them together when applying the migration.`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
policy: {
|
|
57
|
+
version: 2,
|
|
58
|
+
skills: policySkills
|
|
59
|
+
},
|
|
60
|
+
inventory: {
|
|
61
|
+
format_version: 1,
|
|
62
|
+
skills: inventorySkills,
|
|
63
|
+
install_units: Object.entries(installUnits).sort(byEntryKey).map(([id, unit]) => ({
|
|
64
|
+
id,
|
|
65
|
+
observations: clone(requireRecord(unit, `install unit ${id}`))
|
|
66
|
+
}))
|
|
67
|
+
},
|
|
68
|
+
warnings,
|
|
69
|
+
losses,
|
|
70
|
+
ambiguities
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isReviewOnlyQuarantine(skillId, skill, workflowNames, workflows) {
|
|
75
|
+
if (skill.status !== "quarantined" || skill.invocation !== "blocked") return false;
|
|
76
|
+
return !workflowNames.some((name) => stringList(workflows[name]?.blocked_skills).includes(skillId));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function validateState(skillId, skill) {
|
|
80
|
+
if (!STATUS_VALUES.has(skill.status)) {
|
|
81
|
+
throw new Error(`Skill ${skillId} has unsupported status: ${String(skill.status)}`);
|
|
82
|
+
}
|
|
83
|
+
if (!INVOCATION_VALUES.has(skill.invocation)) {
|
|
84
|
+
throw new Error(`Skill ${skillId} has unsupported invocation: ${String(skill.invocation)}`);
|
|
85
|
+
}
|
|
86
|
+
if (!EXPOSURE_VALUES.has(skill.exposure)) {
|
|
87
|
+
throw new Error(`Skill ${skillId} has unsupported exposure: ${String(skill.exposure)}`);
|
|
88
|
+
}
|
|
89
|
+
const diagnostic = validateSkillState(skill.status, skill.invocation, skillId);
|
|
90
|
+
if (typeof diagnostic === "string") throw new Error(diagnostic);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function preferenceFor(skillId, capabilities, workflows) {
|
|
94
|
+
const roles = [];
|
|
95
|
+
for (const [name, rawCapability] of Object.entries(capabilities)) {
|
|
96
|
+
const capability = requireRecord(rawCapability, `capability ${name}`);
|
|
97
|
+
if (capability.canonical === skillId) roles.push({ intent: name, priority: 100 });
|
|
98
|
+
if (stringList(capability.alternatives).includes(skillId)) roles.push({ intent: name, priority: 50 });
|
|
99
|
+
}
|
|
100
|
+
for (const rawWorkflow of Object.values(workflows)) {
|
|
101
|
+
const workflow = requireRecord(rawWorkflow, "workflow");
|
|
102
|
+
const requirements = requireRecord(workflow.required_capabilities ?? {}, "workflow required_capabilities");
|
|
103
|
+
for (const [name, rawRequirement] of Object.entries(requirements)) {
|
|
104
|
+
const requirement = requireRecord(rawRequirement, `workflow capability ${name}`);
|
|
105
|
+
if (requirement.preferred === skillId) roles.push({ intent: name, priority: 100 });
|
|
106
|
+
stringList(requirement.fallback).forEach((candidate, index) => {
|
|
107
|
+
if (candidate === skillId) roles.push({ intent: name, priority: 90 - index });
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (roles.length === 0) return null;
|
|
112
|
+
return {
|
|
113
|
+
intents: [...new Set(roles.map(({ intent }) => intent))].sort(),
|
|
114
|
+
priority: Math.max(...roles.map(({ priority }) => priority))
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function skillObservation(id, skill) {
|
|
119
|
+
const aliases = Array.isArray(skill.source_aliases) ? clone(skill.source_aliases) : [];
|
|
120
|
+
const observations = Object.fromEntries(
|
|
121
|
+
Object.entries(skill)
|
|
122
|
+
.filter(([key]) => !V1_SKILL_POLICY_FIELDS.has(key)
|
|
123
|
+
&& !V1_SKILL_LOCATION_FIELDS.has(key)
|
|
124
|
+
&& key !== "source_aliases")
|
|
125
|
+
.map(([key, value]) => [key, clone(value)])
|
|
126
|
+
);
|
|
127
|
+
return {
|
|
128
|
+
id,
|
|
129
|
+
path: typeof skill.path === "string" && skill.path.length > 0 ? skill.path : id,
|
|
130
|
+
owner_install_unit: typeof skill.owner_install_unit === "string" && skill.owner_install_unit.length > 0
|
|
131
|
+
? skill.owner_install_unit
|
|
132
|
+
: "migration.unowned",
|
|
133
|
+
aliases,
|
|
134
|
+
installed_on: installedAgents(skill),
|
|
135
|
+
observations
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function installedAgents(skill) {
|
|
140
|
+
const units = [skill.owner_install_unit, ...(skill.source_aliases ?? []).map((alias) => alias?.owner_install_unit)];
|
|
141
|
+
return [...new Set(units.map(agentForUnit).filter(Boolean))].sort();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function agentForUnit(unit) {
|
|
145
|
+
if (typeof unit !== "string") return "";
|
|
146
|
+
return ["codex", "claude", "opencode", "hermes"].find((agent) => unit === agent || unit.startsWith(`${agent}.`)) ?? "";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function collectLosses(config) {
|
|
150
|
+
const losses = [];
|
|
151
|
+
visit(config, "", (path, value) => {
|
|
152
|
+
if (path === "/version") return;
|
|
153
|
+
losses.push({ path, value: clone(value), disposition: lossDisposition(path) });
|
|
154
|
+
});
|
|
155
|
+
return losses;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function visit(value, path, add) {
|
|
159
|
+
if (value === null || typeof value !== "object") {
|
|
160
|
+
add(path, value);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (Array.isArray(value)) {
|
|
164
|
+
if (value.length === 0) add(path, []);
|
|
165
|
+
value.forEach((entry, index) => visit(entry, `${path}/${index}`, add));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const entries = Object.entries(value);
|
|
169
|
+
if (entries.length === 0) add(path, {});
|
|
170
|
+
entries.forEach(([key, entry]) => visit(entry, `${path}/${escapePointer(key)}`, add));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function lossDisposition(path) {
|
|
174
|
+
if (/^\/skills\/[^/]+\/(status|invocation|exposure)$/.test(path)) return "mapped_to_v2_policy";
|
|
175
|
+
if (path.startsWith("/skills/") || path.startsWith("/install_units/")) return "preserved_in_inventory_observation";
|
|
176
|
+
if (path.startsWith("/capabilities/") || path.startsWith("/workflows/")) return "mapped_where_applicable";
|
|
177
|
+
return "not_part_of_v2_policy";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function requireRecord(value, label) {
|
|
181
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
182
|
+
throw new Error(`${label} must be an object mapping`);
|
|
183
|
+
}
|
|
184
|
+
return value;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function stringList(value) {
|
|
188
|
+
if (value === undefined) return [];
|
|
189
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
|
|
190
|
+
throw new Error("Expected a list of strings in v1 config");
|
|
191
|
+
}
|
|
192
|
+
return value;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function clone(value) {
|
|
196
|
+
if (Array.isArray(value)) {
|
|
197
|
+
return value.map((entry) => clone(entry));
|
|
198
|
+
}
|
|
199
|
+
if (value !== null && typeof value === "object") {
|
|
200
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, clone(entry)]));
|
|
201
|
+
}
|
|
202
|
+
return value;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** @param {[string, unknown]} left @param {[string, unknown]} right */
|
|
206
|
+
function byEntryKey(left, right) {
|
|
207
|
+
return left[0].localeCompare(right[0]);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function escapePointer(value) {
|
|
211
|
+
return value.split("~").join("~0").split("/").join("~1");
|
|
212
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { chmod, lstat, mkdir, open, readFile, realpath, rename, rm, stat } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
export async function createBackup(configPath, configBytes, inventoryPath, inventoryBytes, label = "v1") {
|
|
6
|
+
const stamp = new Date().toISOString().split(":").join("-");
|
|
7
|
+
const unique = randomUUID().slice(0, 8);
|
|
8
|
+
const configBackupPath = `${configPath}.${label}-${stamp}-${unique}.bak`;
|
|
9
|
+
const inventoryBackupPath = inventoryBytes === null ? null : `${configBackupPath}.inventory`;
|
|
10
|
+
const manifestPath = `${configBackupPath}.manifest.json`;
|
|
11
|
+
const configMode = await fileMode(configPath);
|
|
12
|
+
const inventoryMode = inventoryBytes === null ? null : await fileMode(inventoryPath);
|
|
13
|
+
await durableCreate(configBackupPath, configBytes);
|
|
14
|
+
try {
|
|
15
|
+
if (inventoryBytes !== null) await durableCreate(inventoryBackupPath, inventoryBytes);
|
|
16
|
+
const manifest = {
|
|
17
|
+
format_version: 1,
|
|
18
|
+
source_version: 1,
|
|
19
|
+
target_version: 2,
|
|
20
|
+
policy_projection_version: 2,
|
|
21
|
+
invalidates: ["v1-action-id", "v1-guard-hook", "v1-lock-projection"],
|
|
22
|
+
target_config: basename(configPath),
|
|
23
|
+
target_inventory: relativeInventoryPath(configPath, inventoryPath),
|
|
24
|
+
config_backup: basename(configBackupPath),
|
|
25
|
+
inventory_backup: inventoryBackupPath === null ? null : basename(inventoryBackupPath),
|
|
26
|
+
inventory_existed: inventoryBytes !== null,
|
|
27
|
+
config_mode: configMode,
|
|
28
|
+
inventory_mode: inventoryMode,
|
|
29
|
+
input_sha256: sha256(configBytes),
|
|
30
|
+
inventory_input_sha256: inventoryBytes === null ? null : sha256(inventoryBytes)
|
|
31
|
+
};
|
|
32
|
+
await durableCreate(manifestPath, Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`));
|
|
33
|
+
return {
|
|
34
|
+
configBackupPath,
|
|
35
|
+
inventoryBackupPath,
|
|
36
|
+
inventoryExisted: inventoryBytes !== null,
|
|
37
|
+
configMode,
|
|
38
|
+
inventoryMode,
|
|
39
|
+
manifestPath
|
|
40
|
+
};
|
|
41
|
+
} catch (error) {
|
|
42
|
+
await rm(configBackupPath, { force: true });
|
|
43
|
+
if (inventoryBackupPath !== null) await rm(inventoryBackupPath, { force: true });
|
|
44
|
+
await rm(manifestPath, { force: true });
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function restoreSnapshot(configPath, inventoryPath, snapshot, hooks = {}) {
|
|
50
|
+
await atomicWrite(configPath, await readFile(snapshot.configBackupPath), snapshot.configMode);
|
|
51
|
+
await hooks.afterConfigRename?.();
|
|
52
|
+
if (snapshot.inventoryExisted) {
|
|
53
|
+
await atomicWrite(inventoryPath, await readFile(snapshot.inventoryBackupPath), snapshot.inventoryMode);
|
|
54
|
+
} else {
|
|
55
|
+
await rm(inventoryPath, { force: true });
|
|
56
|
+
await syncDirectory(dirname(inventoryPath));
|
|
57
|
+
}
|
|
58
|
+
await hooks.afterInventoryRename?.();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function removeBackupSet(backup) {
|
|
62
|
+
await rm(backup.configBackupPath, { force: true });
|
|
63
|
+
if (backup.inventoryBackupPath !== null) await rm(backup.inventoryBackupPath, { force: true });
|
|
64
|
+
await rm(backup.manifestPath, { force: true });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function atomicWrite(path, bytes, mode = 0o600) {
|
|
68
|
+
await mkdir(dirname(path), { recursive: true });
|
|
69
|
+
const tempPath = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
|
|
70
|
+
try {
|
|
71
|
+
await durableCreate(tempPath, bytes, mode);
|
|
72
|
+
await rename(tempPath, path);
|
|
73
|
+
await syncDirectory(dirname(path));
|
|
74
|
+
} finally {
|
|
75
|
+
await rm(tempPath, { force: true });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function optionalRead(path) {
|
|
80
|
+
try {
|
|
81
|
+
return await readFile(path);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (error?.code === "ENOENT") return null;
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function removeDurably(path) {
|
|
89
|
+
await rm(path, { force: true });
|
|
90
|
+
await syncDirectory(dirname(path));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function withConfigLock(configPath, operation) {
|
|
94
|
+
const canonicalPath = await realpath(resolve(configPath));
|
|
95
|
+
const lockPath = `${canonicalPath}.migrate.lock`;
|
|
96
|
+
let handle;
|
|
97
|
+
try {
|
|
98
|
+
handle = await open(lockPath, "wx", 0o600);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (error?.code === "EEXIST") {
|
|
101
|
+
throw new Error("Another migration is already using this config. Remove a stale .migrate.lock only after confirming no migration is running.");
|
|
102
|
+
}
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
await handle.writeFile(`${process.pid}\n`);
|
|
107
|
+
await handle.sync();
|
|
108
|
+
return await operation();
|
|
109
|
+
} finally {
|
|
110
|
+
await handle.close();
|
|
111
|
+
await rm(lockPath, { force: true });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function canonicalMigrationPaths(requestedConfigPath, requestedInventoryPath) {
|
|
116
|
+
const requested = resolve(requestedConfigPath);
|
|
117
|
+
const stats = await lstat(requested);
|
|
118
|
+
if (stats.isSymbolicLink()) {
|
|
119
|
+
throw new Error("Migration config path must not be a symbolic link; use its real path explicitly.");
|
|
120
|
+
}
|
|
121
|
+
if (!stats.isFile()) throw new Error("Migration config path must be a regular file.");
|
|
122
|
+
const configPath = await realpath(requested);
|
|
123
|
+
const root = dirname(configPath);
|
|
124
|
+
const requestedInventory = resolve(requestedInventoryPath ?? join(root, ".skillboard", "inventory.json"));
|
|
125
|
+
const inventoryStats = await lstat(requestedInventory).catch(missingOnly);
|
|
126
|
+
if (inventoryStats?.isSymbolicLink()) {
|
|
127
|
+
throw new Error("Migration inventory target must not be a symbolic link.");
|
|
128
|
+
}
|
|
129
|
+
const inventoryDirectory = dirname(requestedInventory);
|
|
130
|
+
const directoryStats = await lstat(inventoryDirectory).catch(missingOnly);
|
|
131
|
+
if (directoryStats?.isSymbolicLink()) {
|
|
132
|
+
throw new Error("Migration inventory directory must not be a symbolic link.");
|
|
133
|
+
}
|
|
134
|
+
const existingParent = await nearestExistingDirectory(inventoryDirectory);
|
|
135
|
+
const realParent = await realpath(existingParent);
|
|
136
|
+
const inventoryPath = resolve(realParent, relative(existingParent, requestedInventory));
|
|
137
|
+
if (!isPathInside(root, realParent)) {
|
|
138
|
+
throw new Error("Migration inventory target resolves outside the config directory.");
|
|
139
|
+
}
|
|
140
|
+
if (!isPathInside(root, inventoryPath)) {
|
|
141
|
+
throw new Error("Migration inventory target must remain inside the config directory.");
|
|
142
|
+
}
|
|
143
|
+
return { configPath, inventoryPath };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function sha256(bytes) {
|
|
147
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function durableCreate(path, bytes, mode = 0o600) {
|
|
151
|
+
await mkdir(dirname(path), { recursive: true });
|
|
152
|
+
const handle = await open(path, "wx", mode);
|
|
153
|
+
try {
|
|
154
|
+
await handle.writeFile(bytes);
|
|
155
|
+
await chmod(path, mode);
|
|
156
|
+
await handle.sync();
|
|
157
|
+
} finally {
|
|
158
|
+
await handle.close();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function syncDirectory(path) {
|
|
163
|
+
if (process.platform === "win32") return;
|
|
164
|
+
let handle;
|
|
165
|
+
try {
|
|
166
|
+
handle = await open(path, "r");
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (error?.code === "ENOENT") return;
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
await handle.sync();
|
|
173
|
+
} finally {
|
|
174
|
+
await handle.close();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function relativeInventoryPath(configPath, inventoryPath) {
|
|
179
|
+
const prefix = `${dirname(configPath)}/`;
|
|
180
|
+
return inventoryPath.startsWith(prefix) ? inventoryPath.slice(prefix.length) : basename(inventoryPath);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function fileMode(path) {
|
|
184
|
+
return (await stat(path)).mode & 0o777;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function nearestExistingDirectory(start) {
|
|
188
|
+
let candidate = start;
|
|
189
|
+
while (true) {
|
|
190
|
+
const stats = await lstat(candidate).catch(missingOnly);
|
|
191
|
+
if (stats !== undefined) {
|
|
192
|
+
if (!stats.isDirectory() && !stats.isSymbolicLink()) {
|
|
193
|
+
throw new Error("Migration inventory parent must be a directory.");
|
|
194
|
+
}
|
|
195
|
+
return candidate;
|
|
196
|
+
}
|
|
197
|
+
const parent = dirname(candidate);
|
|
198
|
+
if (parent === candidate) throw new Error("Migration inventory parent does not exist.");
|
|
199
|
+
candidate = parent;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function missingOnly(error) {
|
|
204
|
+
if (error?.code === "ENOENT") return undefined;
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function isPathInside(root, candidate) {
|
|
209
|
+
const rel = relative(root, candidate);
|
|
210
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
211
|
+
}
|