Skip to content

Commit 62b08e2

Browse files
committed
feat: add module installer prototype
1 parent d28b031 commit 62b08e2

23 files changed

Lines changed: 560 additions & 18 deletions

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,15 @@ docs/
8585

8686
The next milestone is to build `stackfoundry add` for the first production modules.
8787

88+
Current local prototype:
89+
90+
```bash
91+
node apps/cli/src/cli.mjs list
92+
node apps/cli/src/cli.mjs validate
93+
node apps/cli/src/cli.mjs add api-keys --target ../some-app --dry-run
94+
node apps/cli/src/cli.mjs diff api-keys --target ../some-app
95+
```
96+
8897
## Roadmap
8998

9099
See [`ROADMAP.md`](./ROADMAP.md).

ROADMAP.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22

33
## V1 Golden Path
44

5-
- [ ] `drizzle-postgres`
6-
- [ ] `api-keys`
7-
- [ ] `stripe-billing`
5+
- [x] `drizzle-postgres` manifest and source template
6+
- [x] `api-keys` manifest and source template
7+
- [x] `stripe-billing` manifest and source template
88
- [ ] `next-saas` preset
9-
- [ ] `stackfoundry add <module>` prototype
10-
- [ ] file hash manifest
11-
- [ ] `stackfoundry diff <module>` prototype
9+
- [x] `stackfoundry add <module>` prototype
10+
- [x] file hash manifest
11+
- [x] `stackfoundry diff <module>` prototype
1212

1313
## Wave 2
1414

apps/cli/package.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"name": "stackfoundry-cli",
3+
"version": "0.0.0",
4+
"private": true,
5+
"type": "module",
6+
"bin": {
7+
"stackfoundry": "./src/cli.mjs"
8+
},
9+
"scripts": {
10+
"check": "node --check src/cli.mjs"
11+
}
12+
}

apps/cli/src/cli.mjs

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
#!/usr/bin/env node
2+
import { createHash } from "node:crypto";
3+
import { existsSync } from "node:fs";
4+
import { mkdir, readFile, readdir, stat, writeFile, copyFile } from "node:fs/promises";
5+
import path from "node:path";
6+
import { fileURLToPath } from "node:url";
7+
8+
const __filename = fileURLToPath(import.meta.url);
9+
const repoRoot = path.resolve(path.dirname(__filename), "../../..");
10+
const registryRoot = path.join(repoRoot, "registry");
11+
const modulesRoot = path.join(registryRoot, "modules");
12+
13+
const requiredModuleFields = ["name", "type", "category", "title", "description", "files", "status"];
14+
15+
function usage() {
16+
console.log(`StackFoundry
17+
18+
Usage:
19+
stackfoundry list
20+
stackfoundry validate
21+
stackfoundry add <module> [--target <dir>] [--dry-run] [--force]
22+
stackfoundry diff <module> [--target <dir>]
23+
`);
24+
}
25+
26+
function parseArgs(argv) {
27+
const [command, moduleName, ...rest] = argv;
28+
const flags = { target: process.cwd(), dryRun: false, force: false };
29+
30+
for (let i = 0; i < rest.length; i += 1) {
31+
const arg = rest[i];
32+
if (arg === "--target") {
33+
flags.target = path.resolve(rest[++i]);
34+
} else if (arg === "--dry-run") {
35+
flags.dryRun = true;
36+
} else if (arg === "--force") {
37+
flags.force = true;
38+
} else {
39+
throw new Error(`Unknown option: ${arg}`);
40+
}
41+
}
42+
43+
return { command, moduleName, flags };
44+
}
45+
46+
async function readJson(filePath) {
47+
return JSON.parse(await readFile(filePath, "utf8"));
48+
}
49+
50+
async function hashFile(filePath) {
51+
const content = await readFile(filePath);
52+
return createHash("sha256").update(content).digest("hex");
53+
}
54+
55+
async function listFiles(dir) {
56+
if (!existsSync(dir)) return [];
57+
const entries = await readdir(dir);
58+
const files = [];
59+
60+
for (const entry of entries) {
61+
const full = path.join(dir, entry);
62+
const info = await stat(full);
63+
if (info.isDirectory()) {
64+
files.push(...(await listFiles(full)));
65+
} else {
66+
files.push(full);
67+
}
68+
}
69+
70+
return files;
71+
}
72+
73+
async function getModule(name) {
74+
const modulePath = path.join(modulesRoot, name, "module.json");
75+
if (!existsSync(modulePath)) {
76+
throw new Error(`Unknown module: ${name}`);
77+
}
78+
return {
79+
dir: path.dirname(modulePath),
80+
manifest: await readJson(modulePath),
81+
};
82+
}
83+
84+
async function validateModule(moduleDir) {
85+
const manifestPath = path.join(moduleDir, "module.json");
86+
const manifest = await readJson(manifestPath);
87+
const errors = [];
88+
89+
for (const field of requiredModuleFields) {
90+
if (!(field in manifest)) errors.push(`missing field: ${field}`);
91+
}
92+
if (!Array.isArray(manifest.files)) errors.push("files must be an array");
93+
if (!Array.isArray(manifest.dependencies)) errors.push("dependencies must be an array");
94+
if (!Array.isArray(manifest.devDependencies)) errors.push("devDependencies must be an array");
95+
if (!Array.isArray(manifest.registryDependencies)) errors.push("registryDependencies must be an array");
96+
if (!Array.isArray(manifest.env)) errors.push("env must be an array");
97+
98+
const filesDir = path.join(moduleDir, "files");
99+
const sourceFiles = await listFiles(filesDir);
100+
const relativeFiles = sourceFiles.map((file) => path.relative(filesDir, file).split(path.sep).join("/"));
101+
const declaredFiles = new Set(manifest.files.map((file) => file.path));
102+
103+
for (const file of relativeFiles) {
104+
if (!declaredFiles.has(file)) errors.push(`files/ contains undeclared file: ${file}`);
105+
}
106+
for (const file of manifest.files) {
107+
if (!existsSync(path.join(filesDir, file.path))) errors.push(`declared file does not exist: ${file.path}`);
108+
}
109+
110+
return { manifest, errors };
111+
}
112+
113+
async function validateRegistry() {
114+
const registry = await readJson(path.join(repoRoot, "registry.json"));
115+
const itemNames = new Set(registry.items.map((item) => item.name));
116+
const moduleDirs = (await readdir(modulesRoot)).map((name) => path.join(modulesRoot, name));
117+
const errors = [];
118+
119+
for (const moduleDir of moduleDirs) {
120+
const { manifest, errors: moduleErrors } = await validateModule(moduleDir);
121+
if (!itemNames.has(manifest.name)) {
122+
errors.push(`${manifest.name}: missing from registry.json`);
123+
}
124+
for (const dependency of manifest.registryDependencies) {
125+
if (!existsSync(path.join(modulesRoot, dependency, "module.json"))) {
126+
errors.push(`${manifest.name}: unknown registry dependency ${dependency}`);
127+
}
128+
}
129+
for (const error of moduleErrors) {
130+
errors.push(`${manifest.name}: ${error}`);
131+
}
132+
}
133+
134+
return errors;
135+
}
136+
137+
async function listModules() {
138+
const names = await readdir(modulesRoot);
139+
for (const name of names.sort()) {
140+
const { manifest } = await getModule(name);
141+
console.log(`${manifest.name.padEnd(18)} ${manifest.status.padEnd(12)} ${manifest.description}`);
142+
}
143+
}
144+
145+
async function loadInstallManifest(target) {
146+
const filePath = path.join(target, ".stackfoundry", "installed.json");
147+
if (!existsSync(filePath)) return { modules: {} };
148+
return readJson(filePath);
149+
}
150+
151+
async function saveInstallManifest(target, manifest) {
152+
const dir = path.join(target, ".stackfoundry");
153+
await mkdir(dir, { recursive: true });
154+
await writeFile(path.join(dir, "installed.json"), `${JSON.stringify(manifest, null, 2)}\n`);
155+
}
156+
157+
async function addModule(name, flags) {
158+
const { dir, manifest } = await getModule(name);
159+
const filesDir = path.join(dir, "files");
160+
const sourceFiles = await listFiles(filesDir);
161+
const installed = await loadInstallManifest(flags.target);
162+
const installedFiles = {};
163+
164+
for (const source of sourceFiles) {
165+
const relative = path.relative(filesDir, source);
166+
const dest = path.join(flags.target, relative);
167+
const destExists = existsSync(dest);
168+
169+
if (destExists) {
170+
const same = (await hashFile(source)) === (await hashFile(dest));
171+
if (!same && !flags.force) {
172+
throw new Error(`Refusing to overwrite modified file: ${relative}. Use --force or inspect with diff.`);
173+
}
174+
}
175+
176+
if (!flags.dryRun) {
177+
await mkdir(path.dirname(dest), { recursive: true });
178+
if (destExists && flags.force) {
179+
await copyFile(dest, `${dest}.stackfoundry.bak`);
180+
}
181+
await copyFile(source, dest);
182+
}
183+
184+
installedFiles[relative.split(path.sep).join("/")] = await hashFile(source);
185+
console.log(`${flags.dryRun ? "would write" : "wrote"} ${relative}`);
186+
}
187+
188+
const skillPath = path.join(dir, "skill", "SKILL.md");
189+
if (existsSync(skillPath)) {
190+
const dest = path.join(flags.target, ".agents", "skills", name, "SKILL.md");
191+
if (!flags.dryRun) {
192+
await mkdir(path.dirname(dest), { recursive: true });
193+
await copyFile(skillPath, dest);
194+
}
195+
installedFiles[".agents/skills/" + name + "/SKILL.md"] = await hashFile(skillPath);
196+
console.log(`${flags.dryRun ? "would write" : "wrote"} .agents/skills/${name}/SKILL.md`);
197+
}
198+
199+
if (manifest.env.length > 0) {
200+
const envPath = path.join(flags.target, `.env.stackfoundry.${name}.example`);
201+
const content = manifest.env.map((key) => `${key}=`).join("\n") + "\n";
202+
if (!flags.dryRun) await writeFile(envPath, content);
203+
installedFiles[`.env.stackfoundry.${name}.example`] = createHash("sha256").update(content).digest("hex");
204+
console.log(`${flags.dryRun ? "would write" : "wrote"} .env.stackfoundry.${name}.example`);
205+
}
206+
207+
if (!flags.dryRun) {
208+
installed.modules[name] = {
209+
installedAt: new Date().toISOString(),
210+
files: installedFiles,
211+
dependencies: manifest.dependencies,
212+
devDependencies: manifest.devDependencies,
213+
env: manifest.env,
214+
};
215+
await saveInstallManifest(flags.target, installed);
216+
}
217+
}
218+
219+
async function diffModule(name, flags) {
220+
const { dir } = await getModule(name);
221+
const filesDir = path.join(dir, "files");
222+
const sourceFiles = await listFiles(filesDir);
223+
let changes = 0;
224+
225+
for (const source of sourceFiles) {
226+
const relative = path.relative(filesDir, source);
227+
const dest = path.join(flags.target, relative);
228+
if (!existsSync(dest)) {
229+
console.log(`missing ${relative}`);
230+
changes += 1;
231+
continue;
232+
}
233+
if ((await hashFile(source)) !== (await hashFile(dest))) {
234+
console.log(`changed ${relative}`);
235+
changes += 1;
236+
} else {
237+
console.log(`same ${relative}`);
238+
}
239+
}
240+
241+
process.exitCode = changes > 0 ? 1 : 0;
242+
}
243+
244+
async function main() {
245+
const { command, moduleName, flags } = parseArgs(process.argv.slice(2));
246+
247+
if (!command || command === "help" || command === "--help") {
248+
usage();
249+
return;
250+
}
251+
252+
if (command === "list") return listModules();
253+
if (command === "validate") {
254+
const errors = await validateRegistry();
255+
if (errors.length > 0) {
256+
for (const error of errors) console.error(`error: ${error}`);
257+
process.exitCode = 1;
258+
} else {
259+
console.log("registry ok");
260+
}
261+
return;
262+
}
263+
264+
if (!moduleName) throw new Error(`${command} requires a module name`);
265+
if (command === "add") return addModule(moduleName, flags);
266+
if (command === "diff") return diffModule(moduleName, flags);
267+
268+
throw new Error(`Unknown command: ${command}`);
269+
}
270+
271+
main().catch((error) => {
272+
console.error(`error: ${error.message}`);
273+
process.exit(1);
274+
});

docs/registry.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,12 @@ Each module has:
2727
## Safety
2828

2929
Installers must not overwrite modified user files silently. The installer should track file hashes and support diff/update flows.
30+
31+
## Local Prototype
32+
33+
```bash
34+
node apps/cli/src/cli.mjs list
35+
node apps/cli/src/cli.mjs validate
36+
node apps/cli/src/cli.mjs add drizzle-postgres --target /path/to/app
37+
node apps/cli/src/cli.mjs diff drizzle-postgres --target /path/to/app
38+
```

package.json

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,20 @@
44
"private": true,
55
"description": "Source registry for production SaaS and AI modules.",
66
"type": "module",
7+
"bin": {
8+
"stackfoundry": "./apps/cli/src/cli.mjs"
9+
},
710
"packageManager": "pnpm@10.11.0",
811
"engines": {
912
"node": ">=22.0.0",
1013
"pnpm": ">=10.11.0"
1114
},
1215
"scripts": {
13-
"check": "pnpm lint && pnpm test",
14-
"lint": "echo \"lint placeholder\"",
15-
"test": "echo \"test placeholder\"",
16-
"build": "echo \"build placeholder\""
16+
"check": "pnpm validate && pnpm lint && pnpm test",
17+
"validate": "node apps/cli/src/cli.mjs validate",
18+
"lint": "node --check apps/cli/src/cli.mjs",
19+
"test": "rm -rf /tmp/stackfoundry-test && mkdir -p /tmp/stackfoundry-test && node apps/cli/src/cli.mjs list >/dev/null && node apps/cli/src/cli.mjs add api-keys --target /tmp/stackfoundry-test >/dev/null && node apps/cli/src/cli.mjs diff api-keys --target /tmp/stackfoundry-test >/dev/null",
20+
"build": "pnpm validate"
1721
},
1822
"keywords": [
1923
"nextjs",

pnpm-workspace.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
packages:
2+
- apps/*
3+
- packages/*
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export default function ApiKeysPage() {
2+
return (
3+
<main className="flex flex-col gap-4 p-6">
4+
<div>
5+
<h1 className="text-2xl font-semibold">API Keys</h1>
6+
<p className="text-muted-foreground">Create, scope, rotate, and revoke API keys.</p>
7+
</div>
8+
</main>
9+
);
10+
}

0 commit comments

Comments
 (0)