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.
@@ -0,0 +1,132 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "stackfit machine profiles (stackfit.local.json, gitignored)",
4
+ "type": "object",
5
+ "required": [
6
+ "schemaVersion",
7
+ "machines"
8
+ ],
9
+ "properties": {
10
+ "schemaVersion": {
11
+ "const": 1
12
+ },
13
+ "machines": {
14
+ "type": "array",
15
+ "minItems": 1,
16
+ "items": {
17
+ "type": "object",
18
+ "required": [
19
+ "id",
20
+ "match",
21
+ "roots"
22
+ ],
23
+ "properties": {
24
+ "id": {
25
+ "type": "string",
26
+ "minLength": 1,
27
+ "description": "Label printed when this machine profile is selected."
28
+ },
29
+ "match": {
30
+ "type": "object",
31
+ "required": [
32
+ "platform"
33
+ ],
34
+ "properties": {
35
+ "platform": {
36
+ "type": "string",
37
+ "enum": [
38
+ "win32",
39
+ "darwin",
40
+ "linux"
41
+ ],
42
+ "description": "Node.js process.platform value."
43
+ },
44
+ "hostname": {
45
+ "type": "string",
46
+ "minLength": 1,
47
+ "description": "Optional exact hostname match (os.hostname())."
48
+ }
49
+ },
50
+ "additionalProperties": false
51
+ },
52
+ "roots": {
53
+ "type": "array",
54
+ "minItems": 1,
55
+ "items": {
56
+ "type": "string",
57
+ "minLength": 1
58
+ },
59
+ "description": "Absolute or ~-relative directories containing Git projects to sync with --all."
60
+ },
61
+ "projects": {
62
+ "type": "array",
63
+ "items": {
64
+ "$ref": "#/$defs/projectEntry"
65
+ },
66
+ "description": "Optional projects to sync under roots. Omit to sync every Git repo in roots. Use { name, scaffold: false } for adapter-only local sync without docs/agent scaffolding."
67
+ }
68
+ },
69
+ "additionalProperties": false
70
+ }
71
+ },
72
+ "$schema": {
73
+ "type": "string"
74
+ }
75
+ },
76
+ "$defs": {
77
+ "projectEntry": {
78
+ "oneOf": [
79
+ {
80
+ "type": "string",
81
+ "minLength": 1
82
+ },
83
+ {
84
+ "type": "object",
85
+ "required": [
86
+ "name"
87
+ ],
88
+ "properties": {
89
+ "name": {
90
+ "type": "string",
91
+ "minLength": 1
92
+ },
93
+ "scaffold": {
94
+ "type": "boolean",
95
+ "default": true,
96
+ "description": "When false, sync shared skills/adapters only and keep CLAUDE.md + docs/agent/ local-gitignored. init is skipped."
97
+ },
98
+ "packs": {
99
+ "type": "array",
100
+ "items": {
101
+ "type": "string",
102
+ "minLength": 1
103
+ },
104
+ "uniqueItems": true,
105
+ "description": "Extra packs for this project on this machine. Useful for scaffold:false repos whose docs/agent/config.json is not read."
106
+ },
107
+ "excludePacks": {
108
+ "type": "array",
109
+ "items": {
110
+ "type": "string",
111
+ "minLength": 1
112
+ },
113
+ "uniqueItems": true,
114
+ "description": "Auto-detected packs to skip for this project on this machine."
115
+ },
116
+ "skills": {
117
+ "type": "array",
118
+ "items": {
119
+ "type": "string",
120
+ "minLength": 1
121
+ },
122
+ "uniqueItems": true,
123
+ "description": "Individual catalog skills to add for this project on this machine."
124
+ }
125
+ },
126
+ "additionalProperties": false
127
+ }
128
+ ]
129
+ }
130
+ },
131
+ "additionalProperties": false
132
+ }
@@ -0,0 +1,110 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "stackfit catalog registry (stackfit.json)",
4
+ "type": "object",
5
+ "required": [
6
+ "schemaVersion",
7
+ "skills",
8
+ "packs"
9
+ ],
10
+ "properties": {
11
+ "schemaVersion": {
12
+ "const": 3
13
+ },
14
+ "projectConfigPath": {
15
+ "type": "string",
16
+ "description": "Where projects keep their config. Default docs/agent/config.json."
17
+ },
18
+ "skills": {
19
+ "type": "array",
20
+ "items": {
21
+ "type": "string"
22
+ },
23
+ "uniqueItems": true,
24
+ "description": "Enabled catalog. A skill reaches a project only through a pack or an explicit project `skills` entry."
25
+ },
26
+ "packs": {
27
+ "type": "object",
28
+ "description": "Named skill groups. `always` packs go to every project; `detect` packs are auto-selected from package.json dependencies or root/subfolder files; packs with neither are opt-in only.",
29
+ "additionalProperties": {
30
+ "type": "object",
31
+ "required": [
32
+ "skills"
33
+ ],
34
+ "properties": {
35
+ "description": {
36
+ "type": "string"
37
+ },
38
+ "always": {
39
+ "type": "boolean"
40
+ },
41
+ "detect": {
42
+ "type": "object",
43
+ "properties": {
44
+ "dependencies": {
45
+ "type": "array",
46
+ "items": {
47
+ "type": "string"
48
+ },
49
+ "uniqueItems": true
50
+ },
51
+ "files": {
52
+ "type": "array",
53
+ "items": {
54
+ "type": "string"
55
+ },
56
+ "uniqueItems": true
57
+ }
58
+ },
59
+ "additionalProperties": false
60
+ },
61
+ "skills": {
62
+ "type": "array",
63
+ "items": {
64
+ "type": "string"
65
+ },
66
+ "uniqueItems": true
67
+ }
68
+ },
69
+ "additionalProperties": false
70
+ }
71
+ },
72
+ "adapters": {
73
+ "type": "object",
74
+ "required": [
75
+ "agents",
76
+ "commands",
77
+ "rules"
78
+ ],
79
+ "properties": {
80
+ "agents": {
81
+ "type": "array",
82
+ "items": {
83
+ "type": "string"
84
+ },
85
+ "uniqueItems": true
86
+ },
87
+ "commands": {
88
+ "type": "array",
89
+ "items": {
90
+ "type": "string"
91
+ },
92
+ "uniqueItems": true
93
+ },
94
+ "rules": {
95
+ "type": "array",
96
+ "items": {
97
+ "type": "string"
98
+ },
99
+ "uniqueItems": true
100
+ }
101
+ },
102
+ "additionalProperties": false
103
+ },
104
+ "$schema": {
105
+ "type": "string"
106
+ }
107
+ },
108
+ "additionalProperties": false,
109
+ "$id": "https://unpkg.com/stackfit/schemas/stackfit.schema.json"
110
+ }
package/src/audit.mjs ADDED
@@ -0,0 +1,87 @@
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { loadRegistry, loadSources } from "./catalog.mjs";
4
+ import { effectiveMetadata, listingCost } from "./render.mjs";
5
+ import { charsToTokens } from "./report.mjs";
6
+
7
+ const STALE_DAYS = 180;
8
+ const DESCRIPTION_LIMIT = 1024;
9
+
10
+ export function auditCatalog(catalog, { now = Date.now() } = {}) {
11
+ const registry = loadRegistry(catalog);
12
+ const sources = loadSources(catalog);
13
+ const errors = [];
14
+ const warnings = [];
15
+ const directories = existsSync(catalog.skills)
16
+ ? readdirSync(catalog.skills, { withFileTypes: true })
17
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith("_") && !entry.name.startsWith("."))
18
+ .map((entry) => entry.name)
19
+ .sort()
20
+ : [];
21
+
22
+ const metadataByName = new Map();
23
+ for (const name of directories) {
24
+ const skillPath = resolve(catalog.skills, name, "SKILL.md");
25
+ if (!existsSync(skillPath)) {
26
+ errors.push(`${name}: missing SKILL.md`);
27
+ continue;
28
+ }
29
+ const content = readFileSync(skillPath, "utf8");
30
+ const metadata = effectiveMetadata(content, sources.skills[name]?.render?.frontmatter);
31
+ metadataByName.set(name, metadata);
32
+ if (!metadata.name || !metadata.description) errors.push(`${name}: frontmatter needs name and description`);
33
+ if (metadata.name && metadata.name !== name) errors.push(`${name}: frontmatter name is '${metadata.name}' (directory name must match, or set render.frontmatter.name)`);
34
+ if (metadata.description?.length > DESCRIPTION_LIMIT) warnings.push(`${name}: description is ${metadata.description.length} chars (claude.ai rejects > ${DESCRIPTION_LIMIT})`);
35
+ if (content.split("\n").length > 500) warnings.push(`${name}: SKILL.md over 500 lines; move details into referenced files`);
36
+ }
37
+
38
+ for (const [name, provenance] of Object.entries(sources.skills)) {
39
+ if (!registry.skills.includes(name) && !directories.includes(name)) warnings.push(`skill-sources.json: '${name}' is neither enabled nor in skills/`);
40
+ if (provenance.kind === "external" && !provenance.source) warnings.push(`${name}: external origin is not recorded; refresh is disabled`);
41
+ if (!provenance.reviewed) warnings.push(`${name}: no 'reviewed' date in skill-sources.json`);
42
+ else {
43
+ const age = (now - Date.parse(provenance.reviewed)) / 86_400_000;
44
+ if (age > STALE_DAYS) warnings.push(`${name}: last reviewed ${provenance.reviewed} (${Math.floor(age)} days ago); check it is still current and worth its context`);
45
+ }
46
+ }
47
+
48
+ for (const name of registry.skills) {
49
+ if (directories.includes(name)) {
50
+ if (!sources.skills[name]) warnings.push(`${name}: no skill-sources.json entry (add kind: custom or external)`);
51
+ continue;
52
+ }
53
+ if (sources.skills[name]?.source) errors.push(`${name}: enabled but not vendored yet; run \`stackfit refresh --skill ${name}\``);
54
+ else errors.push(`stackfit.json: unknown skill '${name}'`);
55
+ }
56
+ for (const name of directories) {
57
+ if (!registry.skills.includes(name)) warnings.push(`${name}: in skills/ but not enabled in stackfit.json`);
58
+ }
59
+
60
+ const packed = new Set();
61
+ for (const [packName, pack] of Object.entries(registry.packs)) {
62
+ for (const name of pack.skills) {
63
+ if (!registry.skills.includes(name)) errors.push(`pack '${packName}': skill '${name}' is not enabled in stackfit.json`);
64
+ packed.add(name);
65
+ }
66
+ if (pack.always && pack.detect) warnings.push(`pack '${packName}': 'always' makes 'detect' redundant`);
67
+ }
68
+ for (const name of registry.skills) {
69
+ if (!packed.has(name)) warnings.push(`${name}: enabled but in no pack; it only reaches projects that list it under 'skills'`);
70
+ }
71
+ for (const [kind, files] of Object.entries(registry.adapters)) {
72
+ for (const file of files) if (!existsSync(resolve(catalog.root, kind, file))) errors.push(`unknown adapter ${kind}/${file}`);
73
+ }
74
+
75
+ const cost = (names) => names.reduce((total, name) => total + listingCost(metadataByName.get(name), name), 0);
76
+ const packCosts = Object.entries(registry.packs).map(([name, pack]) => ({ name, always: Boolean(pack.always), tokens: charsToTokens(cost(pack.skills)) }));
77
+ return { errors, warnings, skills: directories.length, packs: packCosts, adapters: Object.values(registry.adapters).flat().length, totalTokens: charsToTokens(cost([...packed])) };
78
+ }
79
+
80
+ export function printAudit(result) {
81
+ for (const warning of result.warnings) console.warn(`warning: ${warning}`);
82
+ for (const error of result.errors) console.error(`error: ${error}`);
83
+ console.log(`Audited ${result.skills} skills, ${result.packs.length} packs, and ${result.adapters} adapters.`);
84
+ console.log("Always-loaded description cost per pack (approx. tokens; manual-only skills cost 0):");
85
+ for (const pack of result.packs) console.log(` ${pack.name.padEnd(18)} ${String(pack.tokens).padStart(5)}${pack.always ? " (every project)" : ""}`);
86
+ console.log(` ${"all packs".padEnd(18)} ${String(result.totalTokens).padStart(5)}`);
87
+ }
@@ -0,0 +1,115 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, isAbsolute, resolve } from "node:path";
4
+
5
+ export const REGISTRY_FILE = "stackfit.json";
6
+ export const LOCAL_FILE = "stackfit.local.json";
7
+ export const SOURCES_FILE = "skill-sources.json";
8
+ export const SOURCES_LOCK_FILE = "skill-sources.lock.json";
9
+
10
+ export function loadJson(path, fallback = undefined) {
11
+ if (!existsSync(path)) return fallback;
12
+ try {
13
+ return JSON.parse(readFileSync(path, "utf8"));
14
+ } catch (error) {
15
+ throw new Error(`Invalid JSON in ${path}: ${error.message}`);
16
+ }
17
+ }
18
+
19
+ // `.env` files never override variables already set in the shell.
20
+ export function loadEnvFile(path) {
21
+ if (!existsSync(path)) return;
22
+ for (const line of readFileSync(path, "utf8").split(/\r?\n/)) {
23
+ const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
24
+ if (!match || line.trim().startsWith("#")) continue;
25
+ const [, key, raw] = match;
26
+ if (process.env[key] !== undefined) continue;
27
+ process.env[key] = raw.replace(/^(['"])(.*)\1$/, "$2");
28
+ }
29
+ }
30
+
31
+ export function expandHome(pathValue, base = process.cwd()) {
32
+ const trimmed = pathValue.trim();
33
+ if (!trimmed) throw new Error("Empty path");
34
+ if (trimmed === "~") return homedir();
35
+ if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) return resolve(homedir(), trimmed.slice(2));
36
+ return isAbsolute(trimmed) ? resolve(trimmed) : resolve(base, trimmed);
37
+ }
38
+
39
+ function findUp(start, file) {
40
+ let current = resolve(start);
41
+ while (true) {
42
+ if (existsSync(resolve(current, file))) return current;
43
+ const parent = dirname(current);
44
+ if (parent === current) return null;
45
+ current = parent;
46
+ }
47
+ }
48
+
49
+ // Catalog = the folder holding stackfit.json, skills/, rules/, commands/, agents/.
50
+ // Resolution: --catalog, then STACKFIT_CATALOG (shell or ./.env), then the nearest
51
+ // stackfit.json above the working directory, then ~/.stackfit.
52
+ export function resolveCatalog(explicit) {
53
+ loadEnvFile(resolve(process.cwd(), ".env"));
54
+ const candidates = [
55
+ explicit && expandHome(explicit),
56
+ process.env.STACKFIT_CATALOG && expandHome(process.env.STACKFIT_CATALOG),
57
+ findUp(process.cwd(), REGISTRY_FILE),
58
+ resolve(homedir(), ".stackfit"),
59
+ ].filter(Boolean);
60
+ const root = candidates.find((path) => existsSync(resolve(path, REGISTRY_FILE)));
61
+ if (!root) {
62
+ const tried = explicit || process.env.STACKFIT_CATALOG;
63
+ throw new Error(
64
+ tried
65
+ ? `No ${REGISTRY_FILE} in ${expandHome(tried)}`
66
+ : `No catalog found. Create one with \`stackfit catalog init\` (your own skills) or \`stackfit catalog init --starter\` (curated packs), or point to one with STACKFIT_CATALOG / --catalog.`,
67
+ );
68
+ }
69
+ loadEnvFile(resolve(root, ".env"));
70
+ return catalogPaths(root);
71
+ }
72
+
73
+ export function catalogPaths(root) {
74
+ return {
75
+ root,
76
+ registryPath: resolve(root, REGISTRY_FILE),
77
+ localPath: process.env.STACKFIT_LOCAL_CONFIG ? resolve(process.env.STACKFIT_LOCAL_CONFIG) : resolve(root, LOCAL_FILE),
78
+ sourcesPath: resolve(root, SOURCES_FILE),
79
+ sourcesLockPath: resolve(root, SOURCES_LOCK_FILE),
80
+ skills: resolve(root, "skills"),
81
+ agents: resolve(root, "agents"),
82
+ commands: resolve(root, "commands"),
83
+ rules: resolve(root, "rules"),
84
+ };
85
+ }
86
+
87
+ export function loadRegistry(catalog) {
88
+ const registry = loadJson(catalog.registryPath);
89
+ if (registry.schemaVersion !== 3) throw new Error(`${REGISTRY_FILE}: unsupported schemaVersion ${registry.schemaVersion} (expected 3)`);
90
+ return {
91
+ projectConfigPath: "docs/agent/config.json",
92
+ packs: {},
93
+ ...registry,
94
+ adapters: { agents: [], commands: [], rules: [], ...(registry.adapters ?? {}) },
95
+ };
96
+ }
97
+
98
+ export function loadSources(catalog) {
99
+ return loadJson(catalog.sourcesPath, { schemaVersion: 2, skills: {} });
100
+ }
101
+
102
+ const warned = new Set();
103
+
104
+ // `{{env:NAME}}` in commands, rules, and agents is filled from the environment at sync time,
105
+ // so personal values (emails, org names) live in the catalog's gitignored .env.
106
+ export function expandEnv(content, origin) {
107
+ return content.replace(/\{\{env:([A-Za-z_][A-Za-z0-9_]*)\}\}/g, (placeholder, name) => {
108
+ if (process.env[name] !== undefined) return process.env[name];
109
+ if (!warned.has(name)) {
110
+ warned.add(name);
111
+ console.warn(`warning: ${origin} uses {{env:${name}}} but ${name} is not set; left as-is`);
112
+ }
113
+ return placeholder;
114
+ });
115
+ }
package/src/detect.mjs ADDED
@@ -0,0 +1,110 @@
1
+ import { readFileSync, readdirSync } from "node:fs";
2
+ import { basename, resolve } from "node:path";
3
+
4
+ const SCAN_SKIP = new Set(["node_modules", "dist", "build", "out", "coverage", "target", "vendor", "venv", "__pycache__"]);
5
+
6
+ // Root, every top-level folder, and one level below it: covers `web/`, `apps/*`, `packages/*`.
7
+ export function candidateDirectories(projectPath) {
8
+ const children = (directory) => {
9
+ try {
10
+ return readdirSync(directory, { withFileTypes: true })
11
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith(".") && !SCAN_SKIP.has(entry.name))
12
+ .map((entry) => resolve(directory, entry.name));
13
+ } catch {
14
+ return [];
15
+ }
16
+ };
17
+ const level1 = children(projectPath);
18
+ return [projectPath, ...level1, ...level1.flatMap(children)];
19
+ }
20
+
21
+ const normalize = (name) => name.trim().toLowerCase();
22
+
23
+ // Manifest readers return dependency names. Regex-based on purpose: detection only needs names.
24
+ const MANIFESTS = {
25
+ "package.json": (text) => {
26
+ const manifest = JSON.parse(text);
27
+ return ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]
28
+ .flatMap((field) => Object.keys(manifest[field] ?? {}));
29
+ },
30
+ "requirements.txt": (text) => text.split(/\r?\n/)
31
+ .map((line) => line.replace(/#.*/, "").trim())
32
+ .filter((line) => line && !line.startsWith("-"))
33
+ .map((line) => line.split(/[\s<>=!~;[]/)[0]),
34
+ "pyproject.toml": (text) => [...text.matchAll(/^\s*["']([A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:[<>=!~;[\s"']|$)/gm)].map((match) => match[1])
35
+ .concat([...text.matchAll(/^\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*=\s*(?:["'{^~*\d])/gm)].map((match) => match[1])),
36
+ "go.mod": (text) => [...text.matchAll(/^\s*(?:require\s+)?([a-z0-9.-]+\.[a-z]{2,}\/[^\s]+)\s+v[\d.]/gm)].map((match) => match[1]),
37
+ "Cargo.toml": (text) => {
38
+ const names = [];
39
+ let inDeps = false;
40
+ for (const line of text.split(/\r?\n/)) {
41
+ const section = line.match(/^\s*\[([^\]]+)\]/);
42
+ if (section) inDeps = /(^|\.)(dev-|build-)?dependencies$/.test(section[1]);
43
+ else if (inDeps) {
44
+ const key = line.match(/^\s*([A-Za-z0-9_-]+)\s*=/);
45
+ if (key) names.push(key[1]);
46
+ }
47
+ }
48
+ return names;
49
+ },
50
+ "composer.json": (text) => {
51
+ const manifest = JSON.parse(text);
52
+ return [...Object.keys(manifest.require ?? {}), ...Object.keys(manifest["require-dev"] ?? {})];
53
+ },
54
+ };
55
+
56
+ export function detectionSignals(projectPath) {
57
+ const dependencies = new Set();
58
+ const files = new Set();
59
+ for (const directory of candidateDirectories(projectPath)) {
60
+ let entries;
61
+ try {
62
+ entries = readdirSync(directory);
63
+ } catch {
64
+ continue;
65
+ }
66
+ for (const name of entries) {
67
+ files.add(name);
68
+ const reader = MANIFESTS[name] ?? (/^requirements[-.\w]*\.txt$/.test(name) ? MANIFESTS["requirements.txt"] : null);
69
+ if (!reader) continue;
70
+ try {
71
+ for (const dependency of reader(readFileSync(resolve(directory, name), "utf8"))) dependencies.add(normalize(dependency));
72
+ } catch {
73
+ // An unreadable manifest simply contributes no signals.
74
+ }
75
+ }
76
+ }
77
+ return { dependencies, files };
78
+ }
79
+
80
+ export function packMatches(pack, signals) {
81
+ const detect = pack.detect ?? {};
82
+ return (detect.dependencies ?? []).some((name) => signals.dependencies.has(normalize(name)))
83
+ || (detect.files ?? []).some((name) => signals.files.has(name));
84
+ }
85
+
86
+ // Packs are selected by: always-on, explicit request, or auto-detection (packs without
87
+ // `detect` are opt-in only). Explicit skills are added, disabledSharedSkills removed last.
88
+ export function resolveSelection(projectPath, registry, projectConfig = {}, localSelection = {}) {
89
+ const packs = registry.packs ?? {};
90
+ const known = new Set(registry.skills);
91
+ const requested = new Set([...(projectConfig.packs ?? []), ...(localSelection.packs ?? [])]);
92
+ const excluded = new Set([...(projectConfig.excludePacks ?? []), ...(localSelection.excludePacks ?? [])]);
93
+ for (const name of [...requested, ...excluded]) {
94
+ if (!packs[name]) throw new Error(`${basename(projectPath)}: unknown pack '${name}'`);
95
+ }
96
+ const autodetect = projectConfig.detect !== false;
97
+ const signals = autodetect ? detectionSignals(projectPath) : null;
98
+ const selected = Object.entries(packs)
99
+ .filter(([name, pack]) => pack.always
100
+ || requested.has(name)
101
+ || (autodetect && pack.detect && !excluded.has(name) && packMatches(pack, signals)))
102
+ .map(([name]) => name);
103
+ const skills = new Set(selected.flatMap((name) => packs[name].skills));
104
+ for (const name of [...(projectConfig.skills ?? []), ...(localSelection.skills ?? [])]) {
105
+ if (!known.has(name)) throw new Error(`${basename(projectPath)}: unknown shared skill '${name}'`);
106
+ skills.add(name);
107
+ }
108
+ for (const name of projectConfig.disabledSharedSkills ?? []) skills.delete(name);
109
+ return { packs: selected, skills: [...skills].filter((name) => known.has(name)).sort() };
110
+ }
@@ -0,0 +1,117 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { hostname, platform } from "node:os";
3
+ import { basename, dirname, resolve } from "node:path";
4
+ import { expandHome, loadJson } from "./catalog.mjs";
5
+
6
+ export function currentMachineIdentity() {
7
+ return {
8
+ platform: process.env.STACKFIT_PLATFORM || platform(),
9
+ hostname: process.env.STACKFIT_HOSTNAME || hostname(),
10
+ };
11
+ }
12
+
13
+ function scoreMachineMatch(entry, identity) {
14
+ const match = entry.match ?? {};
15
+ if (match.platform !== identity.platform) return -1;
16
+ if (match.hostname) return match.hostname.toLowerCase() === identity.hostname.toLowerCase() ? 2 : -1;
17
+ return 1;
18
+ }
19
+
20
+ export function resolveMachineProfile(localConfig, identity = currentMachineIdentity()) {
21
+ if (!localConfig) return null;
22
+ if (localConfig.schemaVersion !== 1) throw new Error("Unsupported stackfit.local.json schemaVersion");
23
+ if (!Array.isArray(localConfig.machines) || !localConfig.machines.length) {
24
+ throw new Error("stackfit.local.json must declare at least one machine");
25
+ }
26
+ let best = null;
27
+ let bestScore = -1;
28
+ for (const entry of localConfig.machines) {
29
+ const score = scoreMachineMatch(entry, identity);
30
+ if (score > bestScore) {
31
+ best = entry;
32
+ bestScore = score;
33
+ }
34
+ }
35
+ if (!best) {
36
+ throw new Error(
37
+ `No stackfit.local.json machine matches ${identity.platform} hostname=${identity.hostname}.`
38
+ + " Update the local file or pass --project/--root.",
39
+ );
40
+ }
41
+ return best;
42
+ }
43
+
44
+ function discoverProjects(root, catalogRoot) {
45
+ if (!existsSync(root)) return [];
46
+ return readdirSync(root, { withFileTypes: true })
47
+ .filter((entry) => entry.isDirectory())
48
+ .map((entry) => resolve(root, entry.name))
49
+ .filter((path) => path !== catalogRoot && existsSync(resolve(path, ".git")))
50
+ .sort();
51
+ }
52
+
53
+ function normalizeProjectEntry(entry) {
54
+ if (typeof entry === "string") return { name: entry, scaffold: true, selection: {} };
55
+ if (entry && typeof entry === "object" && typeof entry.name === "string") {
56
+ return {
57
+ name: entry.name,
58
+ scaffold: entry.scaffold !== false,
59
+ selection: { packs: entry.packs, excludePacks: entry.excludePacks, skills: entry.skills },
60
+ };
61
+ }
62
+ throw new Error('Invalid projects entry in stackfit.local.json; use a string name or { "name": "...", "packs": [] }');
63
+ }
64
+
65
+ function resolveNamedProjects(roots, entries) {
66
+ return entries.map(normalizeProjectEntry).map((entry) => {
67
+ const matches = roots.map((root) => resolve(root, entry.name)).filter((path) => existsSync(resolve(path, ".git")));
68
+ if (!matches.length) {
69
+ throw new Error(
70
+ `Configured project '${entry.name}' not found under machine roots: ${roots.join(", ")}.`
71
+ + " Remove it from stackfit.local.json or add the folder that holds it to roots.",
72
+ );
73
+ }
74
+ if (matches.length > 1) throw new Error(`Configured project '${entry.name}' matches multiple roots: ${matches.join(", ")}`);
75
+ return { path: matches[0], scaffold: entry.scaffold, selection: entry.selection };
76
+ });
77
+ }
78
+
79
+ function lookupProjectEntry(catalog, projectPath) {
80
+ const fallback = { scaffold: true, selection: {} };
81
+ try {
82
+ const machine = resolveMachineProfile(loadJson(catalog.localPath, null));
83
+ const entry = machine?.projects?.map(normalizeProjectEntry).find((item) => item.name === basename(projectPath));
84
+ return entry ? { scaffold: entry.scaffold, selection: entry.selection } : fallback;
85
+ } catch {
86
+ return fallback;
87
+ }
88
+ }
89
+
90
+ const asTargets = (paths) => paths.map((path) => ({ path, scaffold: true, selection: {} }));
91
+
92
+ export function selectedProjects(catalog, options) {
93
+ if (options.project) {
94
+ const path = resolve(options.project);
95
+ return { projects: [{ path, ...lookupProjectEntry(catalog, path) }] };
96
+ }
97
+ if (options.root) {
98
+ const roots = options.root.split(",").map((root) => expandHome(root));
99
+ const projects = [...new Set(roots.flatMap((root) => discoverProjects(root, catalog.root)))].sort();
100
+ if (!projects.length) throw new Error(`No Git repositories directly under ${roots.join(", ")}`);
101
+ return { projects: asTargets(projects) };
102
+ }
103
+ if (!options.all) throw new Error("Choose --project <path>, --root <dir>, or --all (this machine's stackfit.local.json)");
104
+ const machine = resolveMachineProfile(loadJson(catalog.localPath, null));
105
+ if (!machine) return { projects: asTargets(discoverProjects(dirname(catalog.root), catalog.root)) };
106
+ const roots = machine.roots.map((root) => expandHome(root, catalog.root));
107
+ const missing = roots.filter((root) => !existsSync(root));
108
+ if (missing.length) {
109
+ throw new Error(`Machine '${machine.id}' roots do not exist: ${missing.join(", ")}. Update stackfit.local.json for this machine.`);
110
+ }
111
+ const identity = currentMachineIdentity();
112
+ const resolvedMachine = { id: machine.id, platform: identity.platform, hostname: identity.hostname, roots };
113
+ const projects = machine.projects?.length
114
+ ? resolveNamedProjects(roots, machine.projects)
115
+ : asTargets([...new Set(roots.flatMap((root) => discoverProjects(root, catalog.root)))].sort());
116
+ return { projects, machine: resolvedMachine };
117
+ }