@microck/canonfig 2.0.0 → 2.1.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 (57) hide show
  1. package/README.md +1 -1
  2. package/dist/cli/cli.js +1 -1
  3. package/dist/harness-configuration/adapters/amp.js +87 -0
  4. package/dist/harness-configuration/adapters/antigravity.js +50 -0
  5. package/dist/harness-configuration/adapters/claude.js +43 -0
  6. package/dist/harness-configuration/adapters/codex.js +60 -0
  7. package/dist/harness-configuration/adapters/copilot.js +79 -0
  8. package/dist/harness-configuration/adapters/cursor.js +67 -0
  9. package/dist/harness-configuration/adapters/descriptor.js +10 -0
  10. package/dist/harness-configuration/adapters/devin.js +66 -0
  11. package/dist/harness-configuration/adapters/droid.js +32 -0
  12. package/dist/harness-configuration/adapters/grok.js +44 -0
  13. package/dist/harness-configuration/adapters/hermes.js +105 -0
  14. package/dist/harness-configuration/adapters/index.js +50 -0
  15. package/dist/harness-configuration/adapters/kilo.js +18 -0
  16. package/dist/harness-configuration/adapters/kimi.js +148 -0
  17. package/dist/harness-configuration/adapters/omp.js +64 -0
  18. package/dist/harness-configuration/adapters/open-code-family.js +94 -0
  19. package/dist/harness-configuration/adapters/opencode.js +18 -0
  20. package/dist/harness-configuration/adapters/pi.js +93 -0
  21. package/dist/harness-configuration/adapters/qwen.js +164 -0
  22. package/dist/harness-configuration/adapters/shared-common.js +66 -0
  23. package/dist/harness-configuration/adapters/shared-documents.js +117 -0
  24. package/dist/harness-configuration/adapters/shared-hooks.js +186 -0
  25. package/dist/harness-configuration/adapters/shared-mcp.js +214 -0
  26. package/dist/harness-configuration/adapters/shared.js +4 -0
  27. package/dist/harness-configuration/adapters/tools.js +24 -0
  28. package/dist/harness-configuration/cli-arguments.js +105 -0
  29. package/dist/harness-configuration/cli-output.js +77 -0
  30. package/dist/harness-configuration/cli.js +196 -0
  31. package/dist/harness-configuration/core/compiler.js +175 -0
  32. package/dist/harness-configuration/core/config.js +74 -0
  33. package/dist/harness-configuration/core/diff.js +60 -0
  34. package/dist/harness-configuration/core/doctor.js +40 -0
  35. package/dist/harness-configuration/core/errors.js +13 -0
  36. package/dist/harness-configuration/core/filesystem.js +172 -0
  37. package/dist/harness-configuration/core/frontmatter.js +49 -0
  38. package/dist/harness-configuration/core/hash.js +4 -0
  39. package/dist/harness-configuration/core/path.js +50 -0
  40. package/dist/harness-configuration/core/planner.js +255 -0
  41. package/dist/harness-configuration/core/render-cleanup.js +91 -0
  42. package/dist/harness-configuration/core/render-json.js +195 -0
  43. package/dist/harness-configuration/core/render-text.js +134 -0
  44. package/dist/harness-configuration/core/render-utils.js +202 -0
  45. package/dist/harness-configuration/core/render.js +54 -0
  46. package/dist/harness-configuration/core/scaffold.js +103 -0
  47. package/dist/harness-configuration/core/schema-components.js +167 -0
  48. package/dist/harness-configuration/core/schema-config.js +98 -0
  49. package/dist/harness-configuration/core/schema-runtime.js +143 -0
  50. package/dist/harness-configuration/core/schema-types.js +8 -0
  51. package/dist/harness-configuration/core/schema.js +13 -0
  52. package/dist/harness-configuration/core/state.js +42 -0
  53. package/dist/harness-configuration/core/types.js +5 -0
  54. package/dist/harness-configuration/core/validation.js +113 -0
  55. package/dist/harness-configuration/templates/runtime.js +212 -0
  56. package/dist/runtime/main.js +20 -14
  57. package/package.json +5 -5
@@ -0,0 +1,175 @@
1
+ import path from "node:path";
2
+ import { BUILTIN_ADAPTERS } from "../adapters/index.js";
3
+ import { commonArtifacts, enabledMcpServerEntries } from "../adapters/shared.js";
4
+ import { configuredTargets, findRepositoryRoot, loadConfig, targetOptions } from "./config.js";
5
+ import { CanonfigError } from "./errors.js";
6
+ import { sha256 } from "./hash.js";
7
+ import { createPlan } from "./planner.js";
8
+ import { validateProject } from "./validation.js";
9
+ const FEATURE_LEVEL_WEIGHT = {
10
+ native: 0,
11
+ portable: 0,
12
+ translated: 1,
13
+ shim: 2,
14
+ lossy: 3,
15
+ unsupported: 4,
16
+ };
17
+ function usedFeatures(config) {
18
+ const used = ["instructions"];
19
+ if (config.instructions.rules.length > 0)
20
+ used.push("rules");
21
+ if (config.skills.roots.length > 0)
22
+ used.push("skills");
23
+ if (Object.keys(config.mcp.servers).length > 0)
24
+ used.push("mcp");
25
+ if (config.hooks.length > 0)
26
+ used.push("hooks");
27
+ if (config.agents.length > 0)
28
+ used.push("agents");
29
+ if (config.commands.length > 0)
30
+ used.push("commands");
31
+ if (config.permissions.rules.length > 0)
32
+ used.push("permissions");
33
+ return used;
34
+ }
35
+ function compatibilityDiagnostics(descriptor, features, strict) {
36
+ return features.flatMap((feature) => {
37
+ const support = descriptor.capabilities[feature];
38
+ if (support === "native" || support === "portable")
39
+ return [];
40
+ const strictFailure = strict && FEATURE_LEVEL_WEIGHT[support] >= FEATURE_LEVEL_WEIGHT.shim;
41
+ const unsupported = support === "unsupported";
42
+ return [{
43
+ level: unsupported || strictFailure ? "error" : support === "translated" ? "info" : "warning",
44
+ code: `FEATURE_${support.toUpperCase()}`,
45
+ target: descriptor.id,
46
+ message: `${descriptor.name}: ${feature} support is ${support}.`,
47
+ }];
48
+ });
49
+ }
50
+ function commonMcpProjectionDiagnostics(context) {
51
+ const diagnostics = [];
52
+ for (const [name, server] of enabledMcpServerEntries(context)) {
53
+ const omitted = [];
54
+ if (server.timeoutMs !== undefined)
55
+ omitted.push("timeoutMs");
56
+ if (server.enabledTools?.length)
57
+ omitted.push("enabledTools");
58
+ if (server.disabledTools?.length)
59
+ omitted.push("disabledTools");
60
+ if (omitted.length > 0) {
61
+ diagnostics.push({
62
+ level: "warning",
63
+ code: "MCP_OPTION_UNSUPPORTED",
64
+ path: ".mcp.json",
65
+ message: `.mcp.json cannot represent ${omitted.join(", ")} for MCP server ${name}; those options were omitted.`,
66
+ });
67
+ }
68
+ }
69
+ return diagnostics;
70
+ }
71
+ function deduplicateExactReplaceArtifacts(artifacts) {
72
+ const seen = new Set();
73
+ const result = [];
74
+ for (const artifact of artifacts) {
75
+ if (artifact.kind !== "replace") {
76
+ result.push(artifact);
77
+ continue;
78
+ }
79
+ const identity = [
80
+ artifact.owner,
81
+ artifact.path,
82
+ artifact.mode ?? "",
83
+ sha256(artifact.content),
84
+ ].join("\0");
85
+ if (seen.has(identity))
86
+ continue;
87
+ seen.add(identity);
88
+ result.push(artifact);
89
+ }
90
+ return result;
91
+ }
92
+ export class AdapterRegistry {
93
+ #adapters = new Map();
94
+ constructor(adapters = []) {
95
+ for (const adapter of adapters)
96
+ this.register(adapter);
97
+ }
98
+ register(adapter) {
99
+ if (this.#adapters.has(adapter.descriptor.id)) {
100
+ throw new CanonfigError("ADAPTER_DUPLICATE", `An adapter is already registered for ${adapter.descriptor.id}.`);
101
+ }
102
+ this.#adapters.set(adapter.descriptor.id, adapter);
103
+ return this;
104
+ }
105
+ replace(adapter) {
106
+ this.#adapters.set(adapter.descriptor.id, adapter);
107
+ return this;
108
+ }
109
+ get(id) {
110
+ const adapter = this.#adapters.get(id);
111
+ if (!adapter)
112
+ throw new CanonfigError("ADAPTER_MISSING", `No adapter is registered for ${id}.`);
113
+ return adapter;
114
+ }
115
+ list() {
116
+ return [...this.#adapters.values()].sort((left, right) => left.descriptor.id.localeCompare(right.descriptor.id));
117
+ }
118
+ }
119
+ export function createDefaultRegistry() {
120
+ return new AdapterRegistry(BUILTIN_ADAPTERS);
121
+ }
122
+ export class HarnessConfigurationCompiler {
123
+ registry;
124
+ constructor(registry = createDefaultRegistry()) {
125
+ this.registry = registry;
126
+ }
127
+ async build(options = {}) {
128
+ const root = options.root
129
+ ? path.resolve(options.root)
130
+ : await findRepositoryRoot(options.cwd ?? process.cwd());
131
+ const loaded = await loadConfig(root);
132
+ const targets = [...new Set(options.targets ?? configuredTargets(loaded.config))];
133
+ if (targets.length === 0)
134
+ throw new CanonfigError("TARGET_EMPTY", "No enabled targets were selected.");
135
+ const diagnostics = await validateProject(root, loaded.config);
136
+ const artifacts = [];
137
+ const commonContext = {
138
+ root,
139
+ canonfigDir: path.join(root, ".canonfig"),
140
+ config: loaded.config,
141
+ target: targets[0],
142
+ targetOptions: {},
143
+ };
144
+ if (options.includeCommon !== false) {
145
+ artifacts.push(...await commonArtifacts(commonContext));
146
+ diagnostics.push(...commonMcpProjectionDiagnostics(commonContext));
147
+ }
148
+ const features = usedFeatures(loaded.config);
149
+ for (const target of targets) {
150
+ const adapter = this.registry.get(target);
151
+ diagnostics.push(...compatibilityDiagnostics(adapter.descriptor, features, options.strict ?? false));
152
+ const context = {
153
+ root,
154
+ canonfigDir: path.join(root, ".canonfig"),
155
+ config: loaded.config,
156
+ target,
157
+ targetOptions: targetOptions(loaded.config, target),
158
+ };
159
+ const result = await adapter.build(context);
160
+ artifacts.push(...result.artifacts);
161
+ diagnostics.push(...result.diagnostics);
162
+ }
163
+ return {
164
+ root,
165
+ configPath: loaded.path,
166
+ targets,
167
+ artifacts: deduplicateExactReplaceArtifacts(artifacts),
168
+ diagnostics,
169
+ };
170
+ }
171
+ async plan(options = {}) {
172
+ const built = await this.build(options);
173
+ return createPlan(built.root, built.targets, built.artifacts, built.diagnostics, { force: options.force ?? false });
174
+ }
175
+ }
@@ -0,0 +1,74 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import YAML from "yaml";
4
+ import { CanonfigConfigSchema } from "./schema.js";
5
+ import { CanonfigError } from "./errors.js";
6
+ import { TARGET_IDS } from "./types.js";
7
+ export const CANONFIG_DIR = ".canonfig";
8
+ export const CONFIG_FILENAMES = ["harness.yaml", "harness.yml", "harness.json"];
9
+ export const STATE_FILENAME = ".harness-state.json";
10
+ export async function findRepositoryRoot(start = process.cwd()) {
11
+ let current = path.resolve(start);
12
+ while (true) {
13
+ for (const filename of CONFIG_FILENAMES) {
14
+ try {
15
+ await fs.access(path.join(current, CANONFIG_DIR, filename));
16
+ return current;
17
+ }
18
+ catch { /* continue */ }
19
+ }
20
+ const parent = path.dirname(current);
21
+ if (parent === current) {
22
+ throw new CanonfigError("CONFIG_NOT_FOUND", `No ${CANONFIG_DIR}/harness.yaml found from ${path.resolve(start)} upward. Run \"canonfig harness init\" first.`);
23
+ }
24
+ current = parent;
25
+ }
26
+ }
27
+ export async function findConfigFile(root) {
28
+ for (const filename of CONFIG_FILENAMES) {
29
+ const candidate = path.join(root, CANONFIG_DIR, filename);
30
+ try {
31
+ await fs.access(candidate);
32
+ return candidate;
33
+ }
34
+ catch { /* continue */ }
35
+ }
36
+ throw new CanonfigError("CONFIG_NOT_FOUND", `Missing ${CANONFIG_DIR}/harness.yaml in ${root}`);
37
+ }
38
+ export async function loadConfig(root) {
39
+ const configPath = await findConfigFile(root);
40
+ const raw = await fs.readFile(configPath, "utf8");
41
+ let parsed;
42
+ try {
43
+ parsed = configPath.endsWith(".json") ? JSON.parse(raw) : YAML.parse(raw);
44
+ }
45
+ catch (error) {
46
+ throw new CanonfigError("CONFIG_PARSE", `Could not parse ${configPath}: ${String(error)}`, error);
47
+ }
48
+ const result = CanonfigConfigSchema.safeParse(parsed);
49
+ if (!result.success) {
50
+ const details = result.error.issues.map((issue) => `${issue.path.join(".") || "<root>"}: ${issue.message}`).join("\n");
51
+ throw new CanonfigError("CONFIG_INVALID", `Invalid ${path.relative(root, configPath)}:\n${details}`, result.error);
52
+ }
53
+ return { config: result.data, path: configPath };
54
+ }
55
+ export function configuredTargets(config) {
56
+ if (Array.isArray(config.targets))
57
+ return [...config.targets];
58
+ const targetMap = config.targets;
59
+ return TARGET_IDS.filter((id) => targetMap[id]?.enabled === true);
60
+ }
61
+ export function targetOptions(config, target) {
62
+ const targetMap = config.targets;
63
+ const direct = Array.isArray(config.targets) ? {} : (targetMap[target]?.options ?? {});
64
+ return { ...direct, ...(config.extensions[target] ?? {}) };
65
+ }
66
+ export function parseTargetList(input) {
67
+ if (!input)
68
+ return undefined;
69
+ const values = input.split(",").map((value) => value.trim()).filter(Boolean);
70
+ const invalid = values.filter((value) => !TARGET_IDS.includes(value));
71
+ if (invalid.length > 0)
72
+ throw new CanonfigError("TARGET_INVALID", `Unknown target(s): ${invalid.join(", ")}`);
73
+ return [...new Set(values)];
74
+ }
@@ -0,0 +1,60 @@
1
+ function lineDiff(before, after) {
2
+ const left = before.split("\n");
3
+ const right = after.split("\n");
4
+ const cells = (left.length + 1) * (right.length + 1);
5
+ if (cells > 1_500_000) {
6
+ return [
7
+ ...left.map((text) => ({ prefix: "-", text })),
8
+ ...right.map((text) => ({ prefix: "+", text })),
9
+ ];
10
+ }
11
+ const table = Array.from({ length: left.length + 1 }, () => new Uint32Array(right.length + 1));
12
+ for (let i = left.length - 1; i >= 0; i -= 1) {
13
+ for (let j = right.length - 1; j >= 0; j -= 1) {
14
+ table[i][j] = left[i] === right[j]
15
+ ? table[i + 1][j + 1] + 1
16
+ : Math.max(table[i + 1][j], table[i][j + 1]);
17
+ }
18
+ }
19
+ const lines = [];
20
+ let i = 0;
21
+ let j = 0;
22
+ while (i < left.length && j < right.length) {
23
+ if (left[i] === right[j]) {
24
+ lines.push({ prefix: " ", text: left[i] });
25
+ i += 1;
26
+ j += 1;
27
+ }
28
+ else if (table[i + 1][j] >= table[i][j + 1]) {
29
+ lines.push({ prefix: "-", text: left[i] });
30
+ i += 1;
31
+ }
32
+ else {
33
+ lines.push({ prefix: "+", text: right[j] });
34
+ j += 1;
35
+ }
36
+ }
37
+ while (i < left.length)
38
+ lines.push({ prefix: "-", text: left[i++] });
39
+ while (j < right.length)
40
+ lines.push({ prefix: "+", text: right[j++] });
41
+ return lines;
42
+ }
43
+ function diffEntry(entry) {
44
+ const header = [`--- a/${entry.path}`, `+++ b/${entry.path}`];
45
+ if (entry.action === "create") {
46
+ return [...header, ...(entry.after ?? "").split("\n").map((line) => `+${line}`)].join("\n");
47
+ }
48
+ if (entry.action === "delete") {
49
+ return [...header, ...(entry.before ?? "").split("\n").map((line) => `-${line}`)].join("\n");
50
+ }
51
+ if (entry.binary)
52
+ return [...header, `Binary file ${entry.action}`].join("\n");
53
+ return [...header, ...lineDiff(entry.before ?? "", entry.after ?? "").map((line) => `${line.prefix}${line.text}`)].join("\n");
54
+ }
55
+ export function formatPlanDiff(plan) {
56
+ return plan.entries
57
+ .filter((entry) => entry.action !== "unchanged")
58
+ .map(diffEntry)
59
+ .join("\n\n");
60
+ }
@@ -0,0 +1,40 @@
1
+ import { spawnSync } from "node:child_process";
2
+ function firstLine(value) {
3
+ const line = value.split(/\r?\n/, 1)[0]?.trim();
4
+ return line ? line : undefined;
5
+ }
6
+ export function doctorTargets(registry, targets) {
7
+ const selected = targets ? new Set(targets) : undefined;
8
+ return registry.list()
9
+ .filter((adapter) => !selected || selected.has(adapter.descriptor.id))
10
+ .map((adapter) => {
11
+ const descriptor = adapter.descriptor;
12
+ let lastError;
13
+ for (const executable of descriptor.executables) {
14
+ const result = spawnSync(executable, ["--version"], {
15
+ encoding: "utf8",
16
+ timeout: 4_000,
17
+ windowsHide: true,
18
+ });
19
+ if (!result.error && result.status === 0) {
20
+ const version = firstLine(result.stdout) ?? firstLine(result.stderr);
21
+ return {
22
+ id: descriptor.id,
23
+ name: descriptor.name,
24
+ found: true,
25
+ executable,
26
+ ...(version ? { version } : {}),
27
+ descriptor,
28
+ };
29
+ }
30
+ lastError = result.error?.message ?? firstLine(result.stderr) ?? `exit ${result.status ?? "unknown"}`;
31
+ }
32
+ return {
33
+ id: descriptor.id,
34
+ name: descriptor.name,
35
+ found: false,
36
+ ...(lastError ? { error: lastError } : {}),
37
+ descriptor,
38
+ };
39
+ });
40
+ }
@@ -0,0 +1,13 @@
1
+ export class CanonfigError extends Error {
2
+ code;
3
+ details;
4
+ constructor(code, message, details) {
5
+ super(message);
6
+ this.name = "CanonfigError";
7
+ this.code = code;
8
+ this.details = details;
9
+ }
10
+ }
11
+ export function errorMessage(error) {
12
+ return error instanceof Error ? error.message : String(error);
13
+ }
@@ -0,0 +1,172 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { CanonfigError } from "./errors.js";
4
+ let temporarySequence = 0;
5
+ function relativeInside(root, candidate) {
6
+ const resolvedRoot = path.resolve(root);
7
+ const resolvedCandidate = path.resolve(candidate);
8
+ const relative = path.relative(resolvedRoot, resolvedCandidate);
9
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
10
+ throw new CanonfigError("PATH_ESCAPE", `Path escapes repository root: ${candidate}`);
11
+ }
12
+ return { root: resolvedRoot, relative };
13
+ }
14
+ async function lstatOptional(filePath) {
15
+ try {
16
+ return await fs.lstat(filePath);
17
+ }
18
+ catch (error) {
19
+ if (error.code === "ENOENT")
20
+ return undefined;
21
+ throw error;
22
+ }
23
+ }
24
+ async function openExclusiveTemporary(filePath, mode) {
25
+ for (;;) {
26
+ const temporary = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.${Date.now()}.${temporarySequence++}.tmp`);
27
+ try {
28
+ return { temporary, handle: await fs.open(temporary, "wx", mode) };
29
+ }
30
+ catch (error) {
31
+ if (error.code !== "EEXIST")
32
+ throw error;
33
+ }
34
+ }
35
+ }
36
+ export async function assertNoSymlinkPathComponents(root, candidate) {
37
+ const confined = relativeInside(root, candidate);
38
+ if (!confined.relative)
39
+ return;
40
+ let current = confined.root;
41
+ for (const component of confined.relative.split(path.sep).filter(Boolean)) {
42
+ current = path.join(current, component);
43
+ const stats = await lstatOptional(current);
44
+ if (stats === undefined)
45
+ return;
46
+ if (stats.isSymbolicLink()) {
47
+ throw new CanonfigError("SYMLINK_ESCAPE", `Path component is a symbolic link: ${current}`);
48
+ }
49
+ }
50
+ }
51
+ export async function ensureDirectoryNoFollow(root, directory) {
52
+ const confined = relativeInside(root, directory);
53
+ if (!confined.relative)
54
+ return;
55
+ let current = confined.root;
56
+ for (const component of confined.relative.split(path.sep).filter(Boolean)) {
57
+ await assertNoSymlinkPathComponents(confined.root, current);
58
+ current = path.join(current, component);
59
+ let stats = await lstatOptional(current);
60
+ if (stats === undefined) {
61
+ try {
62
+ await fs.mkdir(current);
63
+ }
64
+ catch (error) {
65
+ if (error.code !== "EEXIST")
66
+ throw error;
67
+ }
68
+ stats = await lstatOptional(current);
69
+ }
70
+ if (stats === undefined) {
71
+ throw new CanonfigError("PATH_CREATE_FAILED", `Failed to create directory: ${current}`);
72
+ }
73
+ if (stats.isSymbolicLink()) {
74
+ throw new CanonfigError("SYMLINK_ESCAPE", `Path component is a symbolic link: ${current}`);
75
+ }
76
+ if (!stats.isDirectory()) {
77
+ throw new CanonfigError("PATH_COMPONENT_NOT_DIRECTORY", `Path component is not a directory: ${current}`);
78
+ }
79
+ }
80
+ }
81
+ export async function readOptionalFile(filePath) {
82
+ try {
83
+ return await fs.readFile(filePath);
84
+ }
85
+ catch (error) {
86
+ if (error.code === "ENOENT")
87
+ return undefined;
88
+ throw error;
89
+ }
90
+ }
91
+ export async function walkFiles(root) {
92
+ const result = [];
93
+ async function visit(directory) {
94
+ const entries = await fs.readdir(directory, { withFileTypes: true });
95
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
96
+ const absolute = path.join(directory, entry.name);
97
+ if (entry.isDirectory())
98
+ await visit(absolute);
99
+ else if (entry.isFile())
100
+ result.push(path.relative(root, absolute));
101
+ }
102
+ }
103
+ try {
104
+ await visit(root);
105
+ }
106
+ catch (error) {
107
+ if (error.code !== "ENOENT")
108
+ throw error;
109
+ }
110
+ return result;
111
+ }
112
+ export async function atomicWrite(filePath, content, mode, root) {
113
+ if (root === undefined)
114
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
115
+ else
116
+ await ensureDirectoryNoFollow(root, path.dirname(filePath));
117
+ let temporary;
118
+ try {
119
+ if (root !== undefined)
120
+ await assertNoSymlinkPathComponents(root, path.dirname(filePath));
121
+ const opened = await openExclusiveTemporary(filePath, mode);
122
+ temporary = opened.temporary;
123
+ try {
124
+ await opened.handle.writeFile(content);
125
+ if (mode !== undefined)
126
+ await opened.handle.chmod(mode);
127
+ }
128
+ finally {
129
+ await opened.handle.close();
130
+ }
131
+ if (root !== undefined)
132
+ await assertNoSymlinkPathComponents(root, path.dirname(filePath));
133
+ await fs.rename(temporary, filePath);
134
+ temporary = undefined;
135
+ }
136
+ catch (error) {
137
+ if (temporary !== undefined) {
138
+ try {
139
+ if (root !== undefined)
140
+ await assertNoSymlinkPathComponents(root, path.dirname(filePath));
141
+ await fs.rm(temporary, { force: true });
142
+ }
143
+ catch { /* Preserve the original write error. */ }
144
+ }
145
+ throw error;
146
+ }
147
+ }
148
+ export async function removeFileAndEmptyParents(filePath, stopAt) {
149
+ await assertNoSymlinkPathComponents(stopAt, path.dirname(filePath));
150
+ try {
151
+ await fs.unlink(filePath);
152
+ }
153
+ catch (error) {
154
+ if (error.code !== "ENOENT")
155
+ throw error;
156
+ }
157
+ let current = path.dirname(filePath);
158
+ const stop = path.resolve(stopAt);
159
+ while (current !== stop) {
160
+ const relative = path.relative(stop, current);
161
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
162
+ break;
163
+ try {
164
+ await assertNoSymlinkPathComponents(stop, current);
165
+ await fs.rmdir(current);
166
+ }
167
+ catch {
168
+ break;
169
+ }
170
+ current = path.dirname(current);
171
+ }
172
+ }
@@ -0,0 +1,49 @@
1
+ import YAML from "yaml";
2
+ function isRecord(value) {
3
+ return value !== null && typeof value === "object" && !Array.isArray(value);
4
+ }
5
+ export function parseMarkdownDocument(source) {
6
+ const normalized = source.replaceAll("\r\n", "\n");
7
+ if (!normalized.startsWith("---\n"))
8
+ return { data: {}, content: normalized.trim() };
9
+ const body = normalized.slice(4);
10
+ const match = /^---[ \t]*(?:\n|$)/mu.exec(body);
11
+ if (!match)
12
+ throw new Error("Unterminated YAML frontmatter block.");
13
+ const rawData = body.slice(0, match.index);
14
+ const parsed = rawData.trim() === "" ? {} : YAML.parse(rawData);
15
+ if (!isRecord(parsed))
16
+ throw new Error("YAML frontmatter must be an object.");
17
+ return { data: parsed, content: body.slice(match.index + match[0].length).trim() };
18
+ }
19
+ export function parseSkill(source) {
20
+ const parsed = parseMarkdownDocument(source);
21
+ const { data } = parsed;
22
+ if (typeof data.name !== "string" || data.name.length === 0)
23
+ throw new Error("Skill frontmatter requires a non-empty name.");
24
+ if (typeof data.description !== "string" || data.description.length === 0)
25
+ throw new Error("Skill frontmatter requires a non-empty description.");
26
+ if (data.license !== undefined && typeof data.license !== "string")
27
+ throw new Error("Skill license must be a string.");
28
+ if (data.compatibility !== undefined && typeof data.compatibility !== "string")
29
+ throw new Error("Skill compatibility must be a string.");
30
+ if (data.metadata !== undefined && !isRecord(data.metadata))
31
+ throw new Error("Skill metadata must be an object.");
32
+ const allowedTools = data["allowed-tools"];
33
+ if (allowedTools !== undefined && typeof allowedTools !== "string" && !(Array.isArray(allowedTools) && allowedTools.every((item) => typeof item === "string"))) {
34
+ throw new Error("Skill allowed-tools must be a string or an array of strings.");
35
+ }
36
+ const result = {
37
+ name: data.name,
38
+ description: data.description,
39
+ ...(typeof data.license === "string" ? { license: data.license } : {}),
40
+ ...(typeof data.compatibility === "string" ? { compatibility: data.compatibility } : {}),
41
+ ...(isRecord(data.metadata) ? { metadata: data.metadata } : {}),
42
+ ...(typeof allowedTools === "string" || Array.isArray(allowedTools) ? { "allowed-tools": allowedTools } : {}),
43
+ };
44
+ return { data: result, content: parsed.content };
45
+ }
46
+ export function markdownWithFrontmatter(data, content) {
47
+ const frontmatter = YAML.stringify(data, { lineWidth: 0 }).trimEnd();
48
+ return `---\n${frontmatter}\n---\n${content.trim()}\n`;
49
+ }
@@ -0,0 +1,4 @@
1
+ import { createHash } from "node:crypto";
2
+ export function sha256(value) {
3
+ return createHash("sha256").update(value).digest("hex");
4
+ }
@@ -0,0 +1,50 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs/promises";
3
+ import { CanonfigError } from "./errors.js";
4
+ export function toPosix(value) { return value.replaceAll("\\", "/").split(path.sep).join("/"); }
5
+ export function assertSafeRelativePath(value) {
6
+ const normalized = path.posix.normalize(toPosix(value));
7
+ if (normalized === "."
8
+ || normalized === ".."
9
+ || normalized.startsWith("../")
10
+ || normalized.includes("/../")
11
+ || path.posix.isAbsolute(normalized)
12
+ || /^[A-Za-z]:\//u.test(normalized)) {
13
+ throw new CanonfigError("UNSAFE_PATH", `Unsafe repository-relative path: ${value}`);
14
+ }
15
+ return normalized;
16
+ }
17
+ export function resolveInside(root, relativePath) {
18
+ const safe = assertSafeRelativePath(relativePath);
19
+ const resolved = path.resolve(root, safe);
20
+ const relative = path.relative(root, resolved);
21
+ if (relative.startsWith("..") || path.isAbsolute(relative))
22
+ throw new CanonfigError("PATH_ESCAPE", `Path escapes repository root: ${relativePath}`);
23
+ return resolved;
24
+ }
25
+ function assertAbsoluteInside(realRoot, realCandidate, original) {
26
+ const relative = path.relative(realRoot, realCandidate);
27
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
28
+ throw new CanonfigError("SYMLINK_ESCAPE", `Path resolves outside repository root: ${original}`);
29
+ }
30
+ }
31
+ /** Verify an existing path, or its nearest existing ancestor, remains inside root after symlink resolution. */
32
+ export async function assertRealPathInside(root, candidate) {
33
+ const rootReal = await fs.realpath(root);
34
+ let current = candidate;
35
+ while (true) {
36
+ try {
37
+ const resolved = await fs.realpath(current);
38
+ assertAbsoluteInside(rootReal, resolved, candidate);
39
+ return;
40
+ }
41
+ catch (error) {
42
+ if (error.code !== "ENOENT")
43
+ throw error;
44
+ const parent = path.dirname(current);
45
+ if (parent === current)
46
+ throw error;
47
+ current = parent;
48
+ }
49
+ }
50
+ }