stackfit 0.0.0-stage → 0.1.1
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 +10 -0
- package/LICENSE +21 -0
- package/README.md +185 -2
- package/bin/stackfit.mjs +125 -0
- package/package.json +43 -4
- package/schemas/project-config.schema.json +71 -0
- package/schemas/skill-sources.schema.json +97 -0
- package/schemas/stackfit.local.schema.json +132 -0
- package/schemas/stackfit.schema.json +110 -0
- package/src/audit.mjs +87 -0
- package/src/catalog.mjs +115 -0
- package/src/detect.mjs +110 -0
- package/src/projects.mjs +117 -0
- package/src/refresh.mjs +64 -0
- package/src/render.mjs +93 -0
- package/src/report.mjs +111 -0
- package/src/scaffold.mjs +88 -0
- package/src/sync.mjs +300 -0
- package/starter/.env.example +10 -0
- package/starter/README.md +12 -0
- package/starter/gitignore +4 -0
- package/starter/rules/defaults.mdc +11 -0
- package/starter/skill-sources.json +315 -0
- package/starter/skills/next-server-security/SKILL.md +63 -0
- package/starter/skills/playwright-e2e-testing/SKILL.md +34 -0
- package/starter/skills/stackfit/SKILL.md +121 -0
- package/starter/skills/stackfit/references/configuration.md +136 -0
- package/starter/skills/stackfit/references/recipes.md +182 -0
- package/starter/skills/stackfit/references/troubleshooting.md +37 -0
- package/starter/stackfit.json +171 -0
- package/starter/stackfit.local.example.json +20 -0
package/src/refresh.mjs
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { cp, mkdtemp, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { loadJson, loadSources } from "./catalog.mjs";
|
|
6
|
+
import { git } from "./sync.mjs";
|
|
7
|
+
|
|
8
|
+
// GITHUB_TOKEN (shell or catalog .env) lets refresh read private repositories.
|
|
9
|
+
// It is passed as a one-off HTTP header, never written into a URL, config, or log.
|
|
10
|
+
function cloneArgs(repository) {
|
|
11
|
+
const token = process.env.GITHUB_TOKEN;
|
|
12
|
+
if (!token || !repository.startsWith("https://github.com/")) return [];
|
|
13
|
+
const basic = Buffer.from(`x-access-token:${token}`).toString("base64");
|
|
14
|
+
return ["-c", `http.https://github.com/.extraheader=AUTHORIZATION: basic ${basic}`];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function refreshSkills(catalog, options) {
|
|
18
|
+
const manifest = loadSources(catalog);
|
|
19
|
+
const selected = options.skill
|
|
20
|
+
? [[options.skill, manifest.skills[options.skill]]]
|
|
21
|
+
: options.all
|
|
22
|
+
? Object.entries(manifest.skills).filter(([, entry]) => entry.source)
|
|
23
|
+
: [];
|
|
24
|
+
if (!selected.length) throw new Error("Choose --skill <name> or --all; only skills with a recorded source can be refreshed");
|
|
25
|
+
for (const [name, entry] of selected) {
|
|
26
|
+
if (!entry) throw new Error(`Unknown skill '${name}' in skill-sources.json`);
|
|
27
|
+
if (!entry.source) throw new Error(`${name} has no recorded upstream source`);
|
|
28
|
+
}
|
|
29
|
+
if (options.dryRun) {
|
|
30
|
+
for (const [name, entry] of selected) console.log(`${name}: would refresh from ${entry.source.repository} ${entry.source.path}`);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const tempRoot = await mkdtemp(resolve(tmpdir(), "stackfit-refresh-"));
|
|
35
|
+
const lock = loadJson(catalog.sourcesLockPath, { schemaVersion: 1, skills: {} });
|
|
36
|
+
const repositories = new Map();
|
|
37
|
+
try {
|
|
38
|
+
for (const [name, entry] of selected) {
|
|
39
|
+
let checkout = repositories.get(entry.source.repository);
|
|
40
|
+
if (!checkout) {
|
|
41
|
+
checkout = resolve(tempRoot, `source-${repositories.size}`);
|
|
42
|
+
const clone = git([...cloneArgs(entry.source.repository), "clone", "--quiet", "--depth", "1", entry.source.repository, checkout], catalog.root, "inherit");
|
|
43
|
+
if (clone.status !== 0) throw new Error(`Could not clone ${entry.source.repository}`);
|
|
44
|
+
repositories.set(entry.source.repository, checkout);
|
|
45
|
+
}
|
|
46
|
+
const source = resolve(checkout, entry.source.path);
|
|
47
|
+
if (!existsSync(resolve(source, "SKILL.md"))) throw new Error(`${name}: upstream path ${entry.source.path} has no SKILL.md`);
|
|
48
|
+
const staging = resolve(catalog.skills, `.${name}.refresh`);
|
|
49
|
+
const destination = resolve(catalog.skills, name);
|
|
50
|
+
await rm(staging, { recursive: true, force: true });
|
|
51
|
+
await cp(source, staging, { recursive: true });
|
|
52
|
+
await rm(destination, { recursive: true, force: true });
|
|
53
|
+
await rename(staging, destination);
|
|
54
|
+
const revision = git(["rev-parse", "HEAD"], checkout).stdout.trim();
|
|
55
|
+
lock.skills[name] = { repository: entry.source.repository, path: entry.source.path, revision, refreshedAt: new Date().toISOString() };
|
|
56
|
+
console.log(`${name}: refreshed at ${revision.slice(0, 12)}`);
|
|
57
|
+
}
|
|
58
|
+
lock.skills = Object.fromEntries(Object.entries(lock.skills).sort(([a], [b]) => a.localeCompare(b)));
|
|
59
|
+
await writeFile(catalog.sourcesLockPath, `${JSON.stringify(lock, null, 2)}\n`);
|
|
60
|
+
} finally {
|
|
61
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
62
|
+
}
|
|
63
|
+
console.log("Review with `git diff`, run `stackfit audit`, then commit the catalog.");
|
|
64
|
+
}
|
package/src/render.mjs
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Text transforms between the catalog format and each agent's expected files.
|
|
2
|
+
|
|
3
|
+
// Minimal YAML frontmatter editing for top-level scalar keys; enough for upstream overrides.
|
|
4
|
+
export function setFrontmatter(content, fields) {
|
|
5
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
|
|
6
|
+
const lines = match ? match[1].split(/\r?\n/) : [];
|
|
7
|
+
const body = match ? content.slice(match[0].length) : content;
|
|
8
|
+
const isKey = (line) => /^[A-Za-z][\w-]*:/.test(line);
|
|
9
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
10
|
+
const start = lines.findIndex((line) => line.startsWith(`${key}:`));
|
|
11
|
+
let end = start + 1;
|
|
12
|
+
if (start !== -1) while (end < lines.length && !isKey(lines[end])) end += 1;
|
|
13
|
+
const rendered = value === null ? [] : [`${key}: ${typeof value === "string" ? JSON.stringify(value) : value}`];
|
|
14
|
+
if (start === -1) lines.push(...rendered);
|
|
15
|
+
else lines.splice(start, end - start, ...rendered);
|
|
16
|
+
}
|
|
17
|
+
return `---\n${lines.join("\n")}\n---\n${body}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Reads top-level keys, joining folded/literal block scalars (`description: >-`).
|
|
21
|
+
export function parseFrontmatter(content) {
|
|
22
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
23
|
+
if (!match) return { fields: {}, body: content };
|
|
24
|
+
const fields = {};
|
|
25
|
+
let current = null;
|
|
26
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
27
|
+
const field = line.match(/^([A-Za-z][\w-]*):\s*(.*)$/);
|
|
28
|
+
if (field) {
|
|
29
|
+
current = field[1];
|
|
30
|
+
fields[current] = field[2].trim().replace(/^['"]|['"]$/g, "");
|
|
31
|
+
} else if (current && /^\s+\S/.test(line)) {
|
|
32
|
+
fields[current] = `${fields[current].replace(/^[>|]-?$/, "")} ${line.trim()}`.trim();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return { fields, body: content.slice(match[0].length).replace(/^\r?\n/, "") };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Split a Cursor `globs` value on commas that are not inside `{...}`.
|
|
39
|
+
export function splitGlobs(value) {
|
|
40
|
+
const globs = [];
|
|
41
|
+
let depth = 0;
|
|
42
|
+
let current = "";
|
|
43
|
+
for (const char of value) {
|
|
44
|
+
if (char === "{") depth += 1;
|
|
45
|
+
if (char === "}") depth -= 1;
|
|
46
|
+
if (char === "," && depth === 0) {
|
|
47
|
+
globs.push(current);
|
|
48
|
+
current = "";
|
|
49
|
+
} else current += char;
|
|
50
|
+
}
|
|
51
|
+
globs.push(current);
|
|
52
|
+
return globs.map((glob) => glob.trim().replace(/^['"]|['"]$/g, "")).filter(Boolean);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Cursor .mdc → Claude Code .claude/rules/*.md. Always-apply rules load every session;
|
|
56
|
+
// glob rules become `paths:`; description-only rules stay routed through AGENTS.md.
|
|
57
|
+
export function claudeRule(content) {
|
|
58
|
+
const { fields, body } = parseFrontmatter(content);
|
|
59
|
+
if (fields.alwaysApply === "true") return body;
|
|
60
|
+
if (fields.globs) {
|
|
61
|
+
const paths = splitGlobs(fields.globs).map((glob) => ` - ${JSON.stringify(glob)}`).join("\n");
|
|
62
|
+
return `---\npaths:\n${paths}\n---\n\n${body}`;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function cursorRule(content, name) {
|
|
68
|
+
if (content.startsWith("---\n")) return content;
|
|
69
|
+
return `---\ndescription: Repository-specific ${name} guidance; read when relevant\nalwaysApply: false\n---\n\n${content}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Claude Code treats commands as skills; keep side-effecting workflows manual-only.
|
|
73
|
+
export function claudeCommand(content) {
|
|
74
|
+
if (/^---\r?\n/.test(content)) return content;
|
|
75
|
+
const summary = content.split(/\r?\n/).map((line) => line.trim()).find((line) => line && !line.startsWith("#")) ?? "";
|
|
76
|
+
return `---\ndescription: ${JSON.stringify(summary.slice(0, 200))}\ndisable-model-invocation: true\n---\n\n${content}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function claudeAgent(content) {
|
|
80
|
+
return content.replace(/^model:\s*.+$/m, "model: inherit").replace(/^readonly:\s*true$/m, "permissionMode: plan");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Effective skill metadata = SKILL.md frontmatter + render overrides from skill-sources.json.
|
|
84
|
+
export function effectiveMetadata(skillContent, overrides = {}) {
|
|
85
|
+
const { fields } = parseFrontmatter(skillContent);
|
|
86
|
+
return { ...fields, ...Object.fromEntries(Object.entries(overrides).map(([key, value]) => [key, String(value)])) };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Characters of a skill that sit in every session: its name + description, unless manual-only.
|
|
90
|
+
export function listingCost(metadata, name) {
|
|
91
|
+
if (!metadata || metadata["disable-model-invocation"] === "true") return 0;
|
|
92
|
+
return (metadata.description?.length ?? 0) + name.length;
|
|
93
|
+
}
|
package/src/report.mjs
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, resolve } from "node:path";
|
|
4
|
+
import { loadSources } from "./catalog.mjs";
|
|
5
|
+
import { effectiveMetadata, listingCost, parseFrontmatter } from "./render.mjs";
|
|
6
|
+
import { createPlan } from "./sync.mjs";
|
|
7
|
+
|
|
8
|
+
// Rough rule of thumb for English prose in current tokenizers.
|
|
9
|
+
export const charsToTokens = (chars) => Math.round(chars / 4);
|
|
10
|
+
|
|
11
|
+
// Folders whose skills load in every repo regardless of stackfit.
|
|
12
|
+
export const GLOBAL_SKILL_DIRS = [".claude/skills", ".agents/skills", ".cursor/skills", ".codex/skills"];
|
|
13
|
+
|
|
14
|
+
export function skillCosts(catalog, registry) {
|
|
15
|
+
const sources = loadSources(catalog).skills;
|
|
16
|
+
const costs = new Map();
|
|
17
|
+
for (const name of registry.skills) {
|
|
18
|
+
const path = resolve(catalog.skills, name, "SKILL.md");
|
|
19
|
+
const metadata = existsSync(path) ? effectiveMetadata(readFileSync(path, "utf8"), sources[name]?.render?.frontmatter) : null;
|
|
20
|
+
costs.set(name, listingCost(metadata, name));
|
|
21
|
+
}
|
|
22
|
+
return costs;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function globalSkills(home = homedir()) {
|
|
26
|
+
const folders = [];
|
|
27
|
+
for (const relativeDir of GLOBAL_SKILL_DIRS) {
|
|
28
|
+
const directory = resolve(home, relativeDir);
|
|
29
|
+
let names;
|
|
30
|
+
try {
|
|
31
|
+
names = readdirSync(directory);
|
|
32
|
+
} catch {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
let count = 0;
|
|
36
|
+
let chars = 0;
|
|
37
|
+
for (const name of names) {
|
|
38
|
+
const skillFile = resolve(directory, name, "SKILL.md");
|
|
39
|
+
try {
|
|
40
|
+
if (!statSync(skillFile).isFile()) continue;
|
|
41
|
+
} catch {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
count += 1;
|
|
45
|
+
chars += listingCost(effectiveMetadata(readFileSync(skillFile, "utf8")), name);
|
|
46
|
+
}
|
|
47
|
+
if (count) folders.push({ path: `~/${relativeDir}`, skills: count, tokens: charsToTokens(chars) });
|
|
48
|
+
}
|
|
49
|
+
return { folders, tokens: folders.reduce((total, folder) => total + folder.tokens, 0) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Uses the same plan as `sync`, so the numbers describe exactly what would be written:
|
|
53
|
+
// catalog and repo-owned skills, overrides applied, and always-on Claude rules.
|
|
54
|
+
export async function projectReport(catalog, registry, project, costs) {
|
|
55
|
+
const plan = await createPlan(catalog, project.path, registry, { scaffold: project.scaffold, selection: project.selection });
|
|
56
|
+
let listingChars = 0;
|
|
57
|
+
let ruleChars = 0;
|
|
58
|
+
let skills = 0;
|
|
59
|
+
for (const [destination, item] of plan.plan) {
|
|
60
|
+
const key = destination.slice(project.path.length + 1).replaceAll("\\", "/");
|
|
61
|
+
const content = item.content.toString("utf8");
|
|
62
|
+
const skill = key.match(/^\.agents\/skills\/([^/]+)\/SKILL\.md$/);
|
|
63
|
+
if (skill) {
|
|
64
|
+
skills += 1;
|
|
65
|
+
listingChars += listingCost(parseFrontmatter(content).fields, skill[1]);
|
|
66
|
+
} else if (/^\.claude\/rules\/.+\.md$/.test(key) && !parseFrontmatter(content).fields.paths && !/^---\r?\npaths:/.test(content)) {
|
|
67
|
+
ruleChars += content.length;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const everything = [...costs.values()].reduce((total, cost) => total + cost, 0);
|
|
71
|
+
const listingTokens = charsToTokens(listingChars);
|
|
72
|
+
const ruleTokens = charsToTokens(ruleChars);
|
|
73
|
+
const tokensIfEverything = charsToTokens(everything) + ruleTokens;
|
|
74
|
+
const tokens = listingTokens + ruleTokens;
|
|
75
|
+
return {
|
|
76
|
+
project: basename(project.path),
|
|
77
|
+
packs: plan.packs,
|
|
78
|
+
skills,
|
|
79
|
+
catalogSkills: registry.skills.length,
|
|
80
|
+
listingTokens,
|
|
81
|
+
ruleTokens,
|
|
82
|
+
tokens,
|
|
83
|
+
tokensIfEverything,
|
|
84
|
+
saved: tokensIfEverything ? Math.round((1 - tokens / tokensIfEverything) * 100) : 0,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const formatTokens = (tokens) => (tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens));
|
|
89
|
+
const tok = (tokens) => `~${formatTokens(tokens)} tok`;
|
|
90
|
+
|
|
91
|
+
export function printReport({ projects: rows, global }) {
|
|
92
|
+
const width = Math.max(7, ...rows.map((row) => row.project.length));
|
|
93
|
+
console.log(`${"project".padEnd(width)} skills skill listings rules per session packs`);
|
|
94
|
+
for (const row of rows) {
|
|
95
|
+
console.log(
|
|
96
|
+
`${row.project.padEnd(width)} ${String(row.skills).padStart(6)} ${tok(row.listingTokens).padStart(14)} ${tok(row.ruleTokens).padStart(8)} `
|
|
97
|
+
+ `${tok(row.tokens).padStart(11)} ${row.packs.join(", ")}`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
const total = rows.reduce((sum, row) => sum + row.tokens, 0);
|
|
101
|
+
const everything = rows.reduce((sum, row) => sum + row.tokensIfEverything, 0);
|
|
102
|
+
console.log(`\nWith stackfit: ~${formatTokens(total)} tokens across ${rows.length} project(s). Every catalog skill in every repo would be ~${formatTokens(everything)}.`);
|
|
103
|
+
if (global.tokens) {
|
|
104
|
+
const where = global.folders.map((folder) => `${folder.path} (${folder.skills})`).join(", ");
|
|
105
|
+
console.log(`Global skills add ~${formatTokens(global.tokens)} tokens to every session in every repo, with or without stackfit: ${where}.`);
|
|
106
|
+
console.log("Skills that also live in the catalog can be removed from those folders once their packs cover the repos that need them.");
|
|
107
|
+
} else {
|
|
108
|
+
console.log("No global skill folders found (~/.claude/skills, ~/.agents/skills, ~/.cursor/skills, ~/.codex/skills).");
|
|
109
|
+
}
|
|
110
|
+
console.log("Estimates: skill names + descriptions and always-on rules, about 4 characters per token. Skill bodies load only when used; manual-only skills cost 0.");
|
|
111
|
+
}
|
package/src/scaffold.mjs
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { cp, mkdir, readdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, relative, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { expandHome } from "./catalog.mjs";
|
|
6
|
+
|
|
7
|
+
const STARTER_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../starter");
|
|
8
|
+
|
|
9
|
+
async function writeMissing(root, files, { dryRun }) {
|
|
10
|
+
const created = [];
|
|
11
|
+
for (const [key, content] of Object.entries(files)) {
|
|
12
|
+
const destination = resolve(root, key);
|
|
13
|
+
if (existsSync(destination)) continue;
|
|
14
|
+
created.push(key);
|
|
15
|
+
if (dryRun) continue;
|
|
16
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
17
|
+
await writeFile(destination, content);
|
|
18
|
+
}
|
|
19
|
+
return created;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
|
|
23
|
+
|
|
24
|
+
// Tracked per-project context. Never overwrites.
|
|
25
|
+
export async function initProject(projectPath, options) {
|
|
26
|
+
if (options.scaffold === false) {
|
|
27
|
+
console.log(`${basename(projectPath)}: skipped scaffold (adapter-only project)`);
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
const created = await writeMissing(projectPath, {
|
|
31
|
+
"AGENTS.md": "# Repository agent instructions\n\nRead `docs/agent/index.md` first, then load only the pages and repo-owned components relevant to the task. Repository instructions take precedence over shared defaults when they conflict.\n",
|
|
32
|
+
"CLAUDE.md": "@AGENTS.md\n",
|
|
33
|
+
"docs/agent/config.json": json({ schemaVersion: 1, packs: [], excludePacks: [], skills: [], overrides: { skills: [], agents: [], commands: [], rules: [] } }),
|
|
34
|
+
"docs/agent/index.md": "# Agent context\n\nRead only the page relevant to the task:\n\n- `architecture.md`: structure, boundaries, and generated code\n- `design-system.md`: UI primitives, tokens, and visual conventions\n- `workflows.md`: package manager, checks, Git, PR, and release policy\n- `rules/*.md`: additional repository constraints, when relevant\n\nRepository-owned components live under `docs/agent/` (`skills/<name>/SKILL.md`, `commands/*.md`, `agents/*.md`, `rules/*.md`). Run `stackfit sync` to render them for each agent.\n",
|
|
35
|
+
"docs/agent/architecture.md": "# Architecture\n\nDocument stable repository boundaries and where new code belongs. Link existing detailed docs instead of duplicating them.\n",
|
|
36
|
+
"docs/agent/design-system.md": "# Design system\n\nDocument the canonical UI primitives, tokens, typography, and accessibility conventions. Remove this page for repositories without a UI.\n",
|
|
37
|
+
"docs/agent/workflows.md": "# Workflows\n\nDocument the package manager, validation commands, generated files, branch/PR policy, and release automation.\n",
|
|
38
|
+
}, options);
|
|
39
|
+
console.log(`${basename(projectPath)}: ${created.length ? `${created.length} tracked docs file(s)` : "already initialized"}`);
|
|
40
|
+
if (!options.quiet) for (const key of created) console.log(` create ${key}`);
|
|
41
|
+
return created.length;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function listFiles(root) {
|
|
45
|
+
const files = [];
|
|
46
|
+
async function walk(directory) {
|
|
47
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
48
|
+
const path = resolve(directory, entry.name);
|
|
49
|
+
if (entry.isDirectory()) await walk(path);
|
|
50
|
+
else files.push(path);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
await walk(root);
|
|
54
|
+
return files;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// A catalog is a plain folder you keep in your own (usually private) Git repository.
|
|
58
|
+
export async function initCatalog(target, options) {
|
|
59
|
+
const root = expandHome(target ?? "~/.stackfit");
|
|
60
|
+
const created = [];
|
|
61
|
+
if (options.starter) {
|
|
62
|
+
for (const source of await listFiles(STARTER_ROOT)) {
|
|
63
|
+
const key = relative(STARTER_ROOT, source);
|
|
64
|
+
const destination = resolve(root, key === "gitignore" ? ".gitignore" : key);
|
|
65
|
+
if (existsSync(destination)) continue;
|
|
66
|
+
created.push(relative(root, destination));
|
|
67
|
+
if (options.dryRun) continue;
|
|
68
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
69
|
+
await cp(source, destination);
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
created.push(...await writeMissing(root, {
|
|
73
|
+
"stackfit.json": json({ $schema: "https://unpkg.com/stackfit/schemas/stackfit.schema.json", schemaVersion: 3, skills: [], packs: { core: { description: "Every repository.", always: true, skills: [] } }, adapters: { agents: [], commands: [], rules: [] } }),
|
|
74
|
+
"skill-sources.json": json({ schemaVersion: 2, skills: {} }),
|
|
75
|
+
"stackfit.local.example.json": json({ schemaVersion: 1, machines: [{ id: "laptop", match: { platform: "darwin" }, roots: ["~/code"] }] }),
|
|
76
|
+
".env.example": "# Copy to .env (gitignored). Values here fill {{env:NAME}} placeholders in commands, rules, and agents.\n# STACKFIT_CATALOG=~/path/to/this/catalog\n# GITHUB_TOKEN=\n",
|
|
77
|
+
".gitignore": "stackfit.local.json\n.env\nskills/.*.refresh/\n.DS_Store\n",
|
|
78
|
+
"skills/.gitkeep": "",
|
|
79
|
+
}, options));
|
|
80
|
+
}
|
|
81
|
+
console.log(`${options.dryRun ? "Would create" : "Created"} catalog at ${root} (${created.length} file(s)).`);
|
|
82
|
+
if (!options.quiet) for (const key of created) console.log(` create ${key}`);
|
|
83
|
+
console.log(options.starter
|
|
84
|
+
? "\nNext:\n stackfit refresh --all # vendor the starter's upstream skills\n stackfit audit\n stackfit report --root ~/code # what each repo would load\n stackfit status --root ~/code # dry run\n stackfit sync --root ~/code"
|
|
85
|
+
: "\nNext: add skills under skills/<name>/SKILL.md, list them in stackfit.json and a pack, record them in skill-sources.json, then:\n stackfit audit\n stackfit report --root ~/code\n stackfit status --root ~/code # dry run\n stackfit sync --root ~/code");
|
|
86
|
+
if (!root.endsWith(".stackfit")) console.log(`\nPoint stackfit at it with --catalog ${target}, STACKFIT_CATALOG, or run commands from inside the folder.`);
|
|
87
|
+
return created.length;
|
|
88
|
+
}
|
package/src/sync.mjs
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, lstatSync, readFileSync, readlinkSync } from "node:fs";
|
|
3
|
+
import { mkdir, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { platform } from "node:os";
|
|
5
|
+
import { basename, dirname, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { expandEnv, loadJson, loadSources } from "./catalog.mjs";
|
|
8
|
+
import { resolveSelection } from "./detect.mjs";
|
|
9
|
+
import { claudeAgent, claudeCommand, claudeRule, cursorRule, setFrontmatter } from "./render.mjs";
|
|
10
|
+
|
|
11
|
+
export const LOCK_NAME = ".stackfit.lock.json";
|
|
12
|
+
// Older names of the lock, from before the tool was called stackfit; read once, then replaced.
|
|
13
|
+
const LEGACY_LOCK_NAMES = [".skillfit.lock.json", ".agent-config.lock.json"];
|
|
14
|
+
const LOCAL_SCAFFOLD_EXCLUDES = ["/CLAUDE.md", "/docs/agent/"];
|
|
15
|
+
const EXCLUDE_BEGIN = "# >>> stackfit: generated files (managed, do not edit)";
|
|
16
|
+
const EXCLUDE_END = "# <<< stackfit";
|
|
17
|
+
// Lines written by older versions, which excluded whole .claude/ and .cursor/ folders.
|
|
18
|
+
const LEGACY_HEADER = /^# Managed locally by (agent-config|skillfit|stackfit)$/;
|
|
19
|
+
const LEGACY_LINES = new Set([".agent-config.lock.json", ".skillfit.lock.json", ".stackfit.lock.json", ".agents/", ".cursor/", ".claude/", "CLAUDE.md", "docs/agent/"]);
|
|
20
|
+
|
|
21
|
+
export function lstatMaybe(path) {
|
|
22
|
+
try {
|
|
23
|
+
return lstatSync(path);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (error.code === "ENOENT") return null;
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const sha256 = (content) => createHash("sha256").update(content).digest("hex");
|
|
31
|
+
export const toPosixPath = (path) => path.replaceAll("\\", "/");
|
|
32
|
+
const projectKey = (projectPath, destination) => toPosixPath(relative(projectPath, destination));
|
|
33
|
+
const normalizeEntries = (entries = {}) => Object.fromEntries(Object.entries(entries).map(([key, value]) => [toPosixPath(key), value]));
|
|
34
|
+
|
|
35
|
+
function loadLock(projectPath) {
|
|
36
|
+
const found = [LOCK_NAME, ...LEGACY_LOCK_NAMES].map((name) => resolve(projectPath, name)).find((path) => existsSync(path));
|
|
37
|
+
const previous = loadJson(found, { files: {}, links: {} }) ?? { files: {}, links: {} };
|
|
38
|
+
return { ...previous, files: normalizeEntries(previous.files), links: normalizeEntries(previous.links) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function git(args, cwd, stdio = "pipe") {
|
|
42
|
+
return spawnSync("git", args, { cwd, encoding: "utf8", stdio });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function currentRevision(cwd) {
|
|
46
|
+
const result = git(["rev-parse", "HEAD"], cwd);
|
|
47
|
+
return result.status === 0 ? result.stdout.trim() : "unknown";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function walkFiles(root) {
|
|
51
|
+
if (!existsSync(root)) return [];
|
|
52
|
+
const files = [];
|
|
53
|
+
async function walk(directory) {
|
|
54
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
55
|
+
const path = resolve(directory, entry.name);
|
|
56
|
+
if (entry.isDirectory()) await walk(path);
|
|
57
|
+
else if (entry.isFile()) files.push(path);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
await walk(root);
|
|
61
|
+
return files.sort();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function addFile(plan, source, destination, transform = undefined) {
|
|
65
|
+
if (!existsSync(source)) throw new Error(`Configured source does not exist: ${source}`);
|
|
66
|
+
const raw = readFileSync(source);
|
|
67
|
+
const content = transform ? Buffer.from(transform(raw.toString("utf8"))) : raw;
|
|
68
|
+
plan.set(destination, { source, content, hash: sha256(content) });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function addTree(plan, sourceRoot, destinationRoot, render = {}) {
|
|
72
|
+
const exclude = new Set(render.exclude ?? []);
|
|
73
|
+
for (const source of await walkFiles(sourceRoot)) {
|
|
74
|
+
const file = toPosixPath(relative(sourceRoot, source));
|
|
75
|
+
if (exclude.has(file) || basename(file) === ".DS_Store") continue;
|
|
76
|
+
const transform = file === "SKILL.md" && render.frontmatter ? (content) => setFrontmatter(content, render.frontmatter) : undefined;
|
|
77
|
+
addFile(plan, source, resolve(destinationRoot, file), transform);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const componentName = (file) => file.replace(/\.(md|mdc)$/, "");
|
|
82
|
+
|
|
83
|
+
function assertLocalComponentAllowed(projectName, projectConfig, kind, name, sharedNames) {
|
|
84
|
+
if (!sharedNames.has(name)) return;
|
|
85
|
+
if (new Set(projectConfig.overrides?.[kind] ?? []).has(name)) return;
|
|
86
|
+
throw new Error(
|
|
87
|
+
`${projectName}: repo-owned ${kind.slice(0, -1)} '${name}' conflicts with shared ${kind}; `
|
|
88
|
+
+ `rename it or add '${name}' to overrides.${kind} in docs/agent/config.json`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function addCommand(plan, projectPath, source, file) {
|
|
93
|
+
const fill = (content) => expandEnv(content, `commands/${file}`);
|
|
94
|
+
addFile(plan, source, resolve(projectPath, ".cursor/commands", file), fill);
|
|
95
|
+
addFile(plan, source, resolve(projectPath, ".claude/commands", file), (content) => claudeCommand(fill(content)));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function addRule(plan, projectPath, source, name, cursorTransform = (content) => content) {
|
|
99
|
+
const cursorContent = expandEnv(cursorTransform(readFileSync(source, "utf8")), `rules/${name}`);
|
|
100
|
+
addFile(plan, source, resolve(projectPath, ".cursor/rules", `${name}.mdc`), () => cursorContent);
|
|
101
|
+
const claudeContent = claudeRule(cursorContent);
|
|
102
|
+
if (claudeContent !== null) addFile(plan, source, resolve(projectPath, ".claude/rules", `${name}.md`), () => claudeContent);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function addAgent(plan, projectPath, source, file) {
|
|
106
|
+
const fill = (content) => expandEnv(content, `agents/${file}`);
|
|
107
|
+
addFile(plan, source, resolve(projectPath, ".cursor/agents", file), fill);
|
|
108
|
+
addFile(plan, source, resolve(projectPath, ".claude/agents", file), (content) => claudeAgent(fill(content)));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function loadProjectConfig(projectPath, registry, scaffold) {
|
|
112
|
+
const configPath = resolve(projectPath, registry.projectConfigPath);
|
|
113
|
+
const projectConfig = scaffold ? loadJson(configPath, { schemaVersion: 1 }) : { schemaVersion: 1 };
|
|
114
|
+
if (projectConfig.schemaVersion !== 1) throw new Error(`${basename(projectPath)}: unsupported ${registry.projectConfigPath} schema`);
|
|
115
|
+
return { projectConfig, configPath };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function createPlan(catalog, projectPath, registry, { scaffold = true, selection: localSelection = {} } = {}) {
|
|
119
|
+
const projectName = basename(projectPath);
|
|
120
|
+
const { projectConfig, configPath } = loadProjectConfig(projectPath, registry, scaffold);
|
|
121
|
+
const selection = resolveSelection(projectPath, registry, projectConfig, localSelection);
|
|
122
|
+
const renderOptions = loadSources(catalog).skills;
|
|
123
|
+
const sharedSkills = new Set(selection.skills);
|
|
124
|
+
const sharedAgents = new Set(registry.adapters.agents.map(componentName));
|
|
125
|
+
const sharedCommands = new Set(registry.adapters.commands.map(componentName));
|
|
126
|
+
const sharedRules = new Set(registry.adapters.rules.map(componentName));
|
|
127
|
+
const plan = new Map();
|
|
128
|
+
const links = new Map([
|
|
129
|
+
[resolve(projectPath, ".claude/skills"), "../.agents/skills"],
|
|
130
|
+
[resolve(projectPath, ".cursor/skills"), "../.agents/skills"],
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
for (const skill of selection.skills) {
|
|
134
|
+
const source = resolve(catalog.skills, skill);
|
|
135
|
+
if (!existsSync(resolve(source, "SKILL.md"))) {
|
|
136
|
+
throw new Error(`Skill '${skill}' is enabled but not in the catalog. Run \`stackfit refresh --skill ${skill}\` or add skills/${skill}/SKILL.md.`);
|
|
137
|
+
}
|
|
138
|
+
await addTree(plan, source, resolve(projectPath, ".agents/skills", skill), renderOptions[skill]?.render);
|
|
139
|
+
}
|
|
140
|
+
for (const file of registry.adapters.agents) addAgent(plan, projectPath, resolve(catalog.agents, file), file);
|
|
141
|
+
for (const file of registry.adapters.commands) addCommand(plan, projectPath, resolve(catalog.commands, file), file);
|
|
142
|
+
for (const file of registry.adapters.rules) addRule(plan, projectPath, resolve(catalog.rules, file), componentName(file));
|
|
143
|
+
|
|
144
|
+
if (scaffold) {
|
|
145
|
+
const localSkills = resolve(projectPath, "docs/agent/skills");
|
|
146
|
+
if (existsSync(localSkills)) {
|
|
147
|
+
for (const entry of await readdir(localSkills, { withFileTypes: true })) {
|
|
148
|
+
if (!entry.isDirectory()) continue;
|
|
149
|
+
const source = resolve(localSkills, entry.name);
|
|
150
|
+
if (!existsSync(resolve(source, "SKILL.md"))) throw new Error(`${projectName}: docs/agent/skills/${entry.name} lacks SKILL.md`);
|
|
151
|
+
assertLocalComponentAllowed(projectName, projectConfig, "skills", entry.name, sharedSkills);
|
|
152
|
+
await addTree(plan, source, resolve(projectPath, ".agents/skills", entry.name));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const localKinds = [
|
|
156
|
+
["agents", "docs/agent/agents", sharedAgents, (source, file) => addAgent(plan, projectPath, source, file)],
|
|
157
|
+
["commands", "docs/agent/commands", sharedCommands, (source, file) => addCommand(plan, projectPath, source, file)],
|
|
158
|
+
["rules", "docs/agent/rules", sharedRules, (source, file) => {
|
|
159
|
+
const name = componentName(file);
|
|
160
|
+
addRule(plan, projectPath, source, name, (content) => cursorRule(content, name));
|
|
161
|
+
}],
|
|
162
|
+
];
|
|
163
|
+
for (const [kind, folder, shared, add] of localKinds) {
|
|
164
|
+
const root = resolve(projectPath, folder);
|
|
165
|
+
for (const source of await walkFiles(root)) {
|
|
166
|
+
if (!source.endsWith(".md")) continue;
|
|
167
|
+
const file = relative(root, source);
|
|
168
|
+
assertLocalComponentAllowed(projectName, projectConfig, kind, componentName(file), shared);
|
|
169
|
+
add(source, file);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return { plan, links, projectName, scaffold, packs: selection.packs, skills: selection.skills, configPath: scaffold ? relative(projectPath, configPath) : null };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Git-exclude exactly what stackfit generates, so a user's own files in .claude/ or .cursor/
|
|
178
|
+
// (settings.json, personal commands) stay visible to git.
|
|
179
|
+
async function ensureLocalGitExclude(projectPath, generatedKeys, { scaffold = true } = {}) {
|
|
180
|
+
const gitPath = git(["rev-parse", "--git-path", "info/exclude"], projectPath);
|
|
181
|
+
const excludePath = gitPath.status === 0 ? resolve(projectPath, gitPath.stdout.trim()) : resolve(projectPath, ".git/info/exclude");
|
|
182
|
+
const existing = existsSync(excludePath) ? await readFile(excludePath, "utf8") : "";
|
|
183
|
+
const kept = [];
|
|
184
|
+
let inBlock = false;
|
|
185
|
+
let inLegacy = false;
|
|
186
|
+
for (const line of existing.split(/\r?\n/)) {
|
|
187
|
+
if (line === EXCLUDE_BEGIN) { inBlock = true; continue; }
|
|
188
|
+
if (inBlock) { if (line === EXCLUDE_END) inBlock = false; continue; }
|
|
189
|
+
if (LEGACY_HEADER.test(line)) { inLegacy = true; continue; }
|
|
190
|
+
if (inLegacy && LEGACY_LINES.has(line)) continue;
|
|
191
|
+
inLegacy = false;
|
|
192
|
+
kept.push(line);
|
|
193
|
+
}
|
|
194
|
+
while (kept.length && kept[kept.length - 1] === "") kept.pop();
|
|
195
|
+
const entries = [
|
|
196
|
+
`/${LOCK_NAME}`,
|
|
197
|
+
"/.agents/",
|
|
198
|
+
...generatedKeys.filter((key) => !key.startsWith(".agents/")).map((key) => `/${key}`).sort(),
|
|
199
|
+
...(scaffold ? [] : LOCAL_SCAFFOLD_EXCLUDES),
|
|
200
|
+
];
|
|
201
|
+
const next = `${kept.length ? `${kept.join("\n")}\n` : ""}${EXCLUDE_BEGIN}\n${[...new Set(entries)].join("\n")}\n${EXCLUDE_END}\n`;
|
|
202
|
+
if (next === existing) return;
|
|
203
|
+
await mkdir(dirname(excludePath), { recursive: true });
|
|
204
|
+
await writeFile(excludePath, next);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// After removing a dropped skill's files, remove its now-empty folders (never the project root).
|
|
208
|
+
async function pruneEmptyParents(projectPath, directory) {
|
|
209
|
+
let current = directory;
|
|
210
|
+
while (current.startsWith(`${projectPath}${sep}`) && !lstatMaybe(current)?.isSymbolicLink()) {
|
|
211
|
+
let entries;
|
|
212
|
+
try {
|
|
213
|
+
entries = await readdir(current);
|
|
214
|
+
} catch {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (entries.length) return;
|
|
218
|
+
await rm(current, { recursive: true, force: true });
|
|
219
|
+
current = dirname(current);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Windows junctions need no admin rights or developer mode; they store absolute targets.
|
|
224
|
+
const LINK_TYPE = platform() === "win32" ? "junction" : "dir";
|
|
225
|
+
const sameLinkTarget = (linkPath, target) => resolve(dirname(linkPath), readlinkSync(linkPath)) === resolve(dirname(linkPath), target);
|
|
226
|
+
|
|
227
|
+
export async function applyPlan(catalog, projectPath, planData, options) {
|
|
228
|
+
const previous = loadLock(projectPath);
|
|
229
|
+
const nextFiles = {};
|
|
230
|
+
const nextLinks = {};
|
|
231
|
+
const actions = [];
|
|
232
|
+
// Managed symlinks this plan no longer wants. Files "found" through them are not
|
|
233
|
+
// real collisions: the link is deleted before writes.
|
|
234
|
+
const retiredLinks = Object.keys(previous.links ?? {})
|
|
235
|
+
.map((key) => resolve(projectPath, key))
|
|
236
|
+
.filter((linkPath) => !planData.links.has(linkPath) && lstatMaybe(linkPath)?.isSymbolicLink());
|
|
237
|
+
const throughRetiredLink = (destination) => retiredLinks.some((linkPath) => destination.startsWith(`${linkPath}${sep}`));
|
|
238
|
+
|
|
239
|
+
for (const [destination, item] of planData.plan) {
|
|
240
|
+
const key = projectKey(projectPath, destination);
|
|
241
|
+
nextFiles[key] = item.hash;
|
|
242
|
+
const existing = existsSync(destination) && !throughRetiredLink(destination) ? await readFile(destination) : null;
|
|
243
|
+
if (existing && sha256(existing) === item.hash) continue;
|
|
244
|
+
if (existing && !previous.files[key] && !options.force) {
|
|
245
|
+
throw new Error(`${planData.projectName}: unmanaged collision at ${key}; review it, then rerun with --force`);
|
|
246
|
+
}
|
|
247
|
+
actions.push({ type: existing ? "update" : "create", key, destination, item });
|
|
248
|
+
}
|
|
249
|
+
for (const key of Object.keys(previous.files)) {
|
|
250
|
+
if (nextFiles[key]) continue;
|
|
251
|
+
const destination = resolve(projectPath, key);
|
|
252
|
+
const underLink = [...planData.links.keys()].find((linkPath) => destination.startsWith(`${linkPath}${sep}`) && lstatMaybe(linkPath)?.isSymbolicLink());
|
|
253
|
+
if (!underLink && lstatMaybe(destination)) actions.push({ type: "delete", key, destination });
|
|
254
|
+
}
|
|
255
|
+
for (const [destination, target] of planData.links) {
|
|
256
|
+
const key = projectKey(projectPath, destination);
|
|
257
|
+
nextLinks[key] = target;
|
|
258
|
+
const stat = lstatMaybe(destination);
|
|
259
|
+
if (stat?.isSymbolicLink() && sameLinkTarget(destination, target)) continue;
|
|
260
|
+
if (stat?.isDirectory()) {
|
|
261
|
+
const unmanaged = (await walkFiles(destination)).map((path) => projectKey(projectPath, path)).filter((path) => !previous.files[path]);
|
|
262
|
+
if (unmanaged.length && !options.force) throw new Error(`${planData.projectName}: unmanaged collision under ${key}: ${unmanaged[0]}`);
|
|
263
|
+
} else if (stat && !previous.links?.[key] && !options.force) {
|
|
264
|
+
throw new Error(`${planData.projectName}: unmanaged collision at ${key}; review it, then rerun with --force`);
|
|
265
|
+
}
|
|
266
|
+
actions.push({ type: stat ? "link" : "create-link", key, destination, target });
|
|
267
|
+
}
|
|
268
|
+
for (const key of Object.keys(previous.links ?? {})) {
|
|
269
|
+
if (!nextLinks[key] && lstatMaybe(resolve(projectPath, key))) actions.push({ type: "delete", key, destination: resolve(projectPath, key) });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (!options.dryRun) {
|
|
273
|
+
await ensureLocalGitExclude(projectPath, [...Object.keys(nextFiles), ...Object.keys(nextLinks)], { scaffold: planData.scaffold !== false });
|
|
274
|
+
for (const action of actions.filter((item) => item.type === "delete")) {
|
|
275
|
+
await rm(action.destination, { recursive: true, force: true });
|
|
276
|
+
await pruneEmptyParents(projectPath, dirname(action.destination));
|
|
277
|
+
}
|
|
278
|
+
for (const action of actions.filter((item) => ["create", "update"].includes(item.type))) {
|
|
279
|
+
await mkdir(dirname(action.destination), { recursive: true });
|
|
280
|
+
await writeFile(action.destination, action.item.content);
|
|
281
|
+
}
|
|
282
|
+
for (const action of actions.filter((item) => ["link", "create-link"].includes(item.type))) {
|
|
283
|
+
await rm(action.destination, { recursive: true, force: true });
|
|
284
|
+
await mkdir(dirname(action.destination), { recursive: true });
|
|
285
|
+
await symlink(action.target, action.destination, LINK_TYPE);
|
|
286
|
+
}
|
|
287
|
+
await writeFile(resolve(projectPath, LOCK_NAME), `${JSON.stringify({
|
|
288
|
+
schemaVersion: 3,
|
|
289
|
+
sourceRevision: currentRevision(catalog.root),
|
|
290
|
+
projectConfig: planData.configPath,
|
|
291
|
+
scaffold: planData.scaffold !== false,
|
|
292
|
+
packs: planData.packs,
|
|
293
|
+
generatedAt: new Date().toISOString(),
|
|
294
|
+
files: nextFiles,
|
|
295
|
+
links: nextLinks,
|
|
296
|
+
}, null, 2)}\n`);
|
|
297
|
+
for (const name of LEGACY_LOCK_NAMES) await rm(resolve(projectPath, name), { force: true });
|
|
298
|
+
}
|
|
299
|
+
return actions;
|
|
300
|
+
}
|