firth 0.0.1 → 0.0.2

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/README.md CHANGED
@@ -1,76 +1,75 @@
1
- # firth-cli
1
+ # firth
2
2
 
3
- > The runtime CLI for [Firth](https://github.com/firthdev/firth) the cloud platform SDK for AI coding agents.
3
+ The **Firth CLI** provision and govern a project's cloud resources (Neon Postgres,
4
+ Tigris storage, Fly.io compute) behind one CLI and one credential seam. It is a thin
5
+ client of the Firth control-plane API; you sign in, create a project (which provisions
6
+ the resources under Firth's own provider accounts), and pull a complete `.env` of
7
+ scoped, encrypted-at-rest credentials.
4
8
 
5
- **Status:** Pre-alpha. v0.0.1 ships only `firth init` (project scaffolding).
6
-
7
- This repo is the **L2 / CLI layer** of the Firth project. The companion repo [`firth`](https://github.com/firthdev/firth) holds the L1 / Knowledge layer (Skills, templates, runbooks, ARCHITECTURE.md).
8
-
9
- For the project's overall design and rationale, see [`firth/ARCHITECTURE.md`](https://github.com/firthdev/firth/blob/main/ARCHITECTURE.md). This README is just for the CLI itself.
10
-
11
- ## Local development
9
+ ## Install
12
10
 
13
11
  ```bash
14
- # from this directory
15
- npm install
16
-
17
- # run the CLI in dev (no build step)
18
- npm run dev -- init
19
-
20
- # typecheck
21
- npm run typecheck
22
-
23
- # tests
24
- npm test
25
-
26
- # build a distributable
27
- npm run build
28
-
29
- # link into your shell so `firth` works globally during dev
30
- npm link
31
- firth init my-test-app
12
+ npm install -g firth
13
+ # or run without installing
14
+ npx firth --help
32
15
  ```
33
16
 
34
- ## Commands (current)
35
-
36
- ### `firth init [name]`
17
+ Requires Node ≥ 20.
37
18
 
38
- Scaffold a Firth project. Generates `firth.config.ts` and `firth.lock.json`.
19
+ ## Quickstart
39
20
 
40
21
  ```bash
41
- # interactive
42
- firth init my-app
22
+ firth login # sign in (email/password)
23
+ firth project create my-app # provision DB + storage + compute; links ./.firth/project.json
24
+ firth branch create dev # fork an isolated DB branch (storage shared; compute redeploys)
25
+ firth branch switch dev # make dev the current branch
26
+ firth secrets # write the project's credentials into ./.env
27
+ firth deploy --image <url> # deploy a container image to the project's compute
28
+ firth events # the action ↔ side-effect timeline
29
+ ```
43
30
 
44
- # in current directory
45
- firth init .
31
+ > **Connectivity:** the CLI talks to the Firth control-plane API. Set `FIRTH_API_URL`
32
+ > (default `http://localhost:8080`) to point at your control plane. While Firth is
33
+ > pre-release, run the control plane locally and target `http://localhost:8080`.
46
34
 
47
- # non-interactive (agent-friendly): use defaults
48
- firth init my-app --yes
35
+ ## Commands
49
36
 
50
- # non-interactive with explicit overrides
51
- firth init my-app --frontend=nextjs --backend=hono --db=neon \
52
- --frontend-host=vercel --backend-host=railway --yes
37
+ ```
38
+ login Sign in (email/password)
39
+ logout Clear stored credentials
40
+ status Show login, linked project, and current branch
41
+
42
+ project create <name> Create + link a project (provisions DB/storage/compute)
43
+ project link <id> Link this directory to a project
44
+ project list List your projects
45
+ project delete Delete the linked project + all resources (--yes)
46
+
47
+ branch create <name> Create a branch (--from <parent>, default main)
48
+ branch list List the linked project's branches
49
+ branch switch <name> Set the current branch (secrets/events default to it)
50
+ branch delete <name> Delete a branch + its Neon branch (--yes)
51
+
52
+ secrets Fetch the linked project's secrets into .env (--branch <id>)
53
+ deploy Deploy --image <url> to the project's compute (--from, --port)
54
+ events Show the action ↔ side-effect timeline (--branch, --limit)
55
+ observe sync Upload local observe-hook findings (.firth/audit.jsonl) to the timeline
56
+ skills pull Install the firth skill into ./.claude/skills
53
57
  ```
54
58
 
55
- Defaults (when `--yes` is passed): Next.js + Hono + Neon Postgres + Vercel + Railway.
56
-
57
- ## Commands (planned)
58
-
59
- - `firth deploy` — provision resources and push code across the stack.
60
- - `firth secrets set/get/list` — sync secrets across providers.
61
- - `firth logs [--service]` — tail logs.
62
- - `firth status` — current deployment + resource state.
63
- - `firth handoff` — generate a context dump for a fresh agent session.
64
- - `firth db migrate / db reset` — database lifecycle.
59
+ ## Configuration & state
65
60
 
66
- ## Design notes
61
+ - **Global** — `~/.firth/config.json`: API URL, InsForge auth endpoint, and your access token.
62
+ - **Per-project** — `./.firth/project.json`: the linked project id and the current branch
63
+ (set by `firth branch switch`). `secrets` and `events` default to the current branch.
64
+ - **Override** — `FIRTH_API_URL` selects which control plane to talk to.
67
65
 
68
- - **Thin orchestrator, not a wrapper.** Every command shells out to the official provider CLI/API; we never re-implement provider features.
69
- - **Agent-friendly errors.** Failures emit `ERROR / LIKELY CAUSE / SUGGESTED ACTIONS` so an agent loop can recover.
70
- - **Local state lives in the project.** `firth.config.ts` (declarative, hand-edited) + `firth.lock.json` (generated, holds resource IDs) — both committed.
66
+ ## Credentials
71
67
 
72
- See [`firth/ARCHITECTURE.md`](https://github.com/firthdev/firth/blob/main/ARCHITECTURE.md) for the full rationale.
68
+ Treat `./.env` as the only source of resource credentials — never hardcode them or copy
69
+ them elsewhere. A branch's `DATABASE_URL` is isolated per branch; storage (`AWS_*`) is
70
+ shared across branches. Re-run `firth secrets` after `firth branch switch` to refresh
71
+ `DATABASE_URL`.
73
72
 
74
73
  ## License
75
74
 
76
- MIT (planned).
75
+ MIT
package/dist/api.js ADDED
@@ -0,0 +1,60 @@
1
+ const realFetcher = async (url, init) => {
2
+ const res = await fetch(url, { method: init.method, headers: init.headers, body: init.body });
3
+ return { status: res.status, json: () => res.json(), text: () => res.text() };
4
+ };
5
+ export class FirthApi {
6
+ apiUrl;
7
+ token;
8
+ fetcher;
9
+ constructor(apiUrl, token, fetcher = realFetcher) {
10
+ this.apiUrl = apiUrl;
11
+ this.token = token;
12
+ this.fetcher = fetcher;
13
+ }
14
+ async req(method, path, body) {
15
+ const res = await this.fetcher(`${this.apiUrl}${path}`, {
16
+ method,
17
+ headers: { Authorization: `Bearer ${this.token}`, 'Content-Type': 'application/json' },
18
+ body: body === undefined ? undefined : JSON.stringify(body),
19
+ });
20
+ if (res.status < 200 || res.status >= 300) {
21
+ let msg = '';
22
+ try {
23
+ msg = (await res.json())?.error ?? '';
24
+ }
25
+ catch { /* ignore */ }
26
+ throw new Error(`request failed: ${res.status}${msg ? ` ${msg}` : ''}`);
27
+ }
28
+ return res.json();
29
+ }
30
+ createProject(name) { return this.req('POST', '/projects', { name }); }
31
+ listProjects() { return this.req('GET', '/projects').then((r) => r.projects); }
32
+ createBranch(projectId, name, from) {
33
+ return this.req('POST', `/projects/${projectId}/branches`, { name, from });
34
+ }
35
+ listBranches(projectId) { return this.req('GET', `/projects/${projectId}/branches`).then((r) => r.branches); }
36
+ getSecrets(projectId, branch) {
37
+ const q = branch ? `?branch=${encodeURIComponent(branch)}` : '';
38
+ return this.req('GET', `/projects/${projectId}/secrets${q}`).then((r) => r.secrets);
39
+ }
40
+ deploy(projectId, opts) {
41
+ return this.req('POST', `/projects/${projectId}/deploy`, opts);
42
+ }
43
+ listEvents(projectId, opts = {}) {
44
+ const qs = new URLSearchParams();
45
+ if (opts.branch)
46
+ qs.set('branch', opts.branch);
47
+ if (opts.limit)
48
+ qs.set('limit', String(opts.limit));
49
+ const q = qs.toString();
50
+ return this.req('GET', `/projects/${projectId}/events${q ? `?${q}` : ''}`).then((r) => r.events);
51
+ }
52
+ postEvents(projectId, events) {
53
+ return this.req('POST', `/projects/${projectId}/events`, { events });
54
+ }
55
+ deleteProject(id) { return this.req('DELETE', `/projects/${id}`); }
56
+ deleteBranch(projectId, branchId) { return this.req('DELETE', `/projects/${projectId}/branches/${branchId}`); }
57
+ login(email, password) {
58
+ return this.req('POST', '/auth/login', { email, password });
59
+ }
60
+ }
@@ -0,0 +1,35 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { FirthApi } from '../api.js';
3
+ import { readConfig, writeConfig } from '../config.js';
4
+ export async function login(argv, deps) {
5
+ const { values } = parseArgs({ args: argv, options: { email: { type: 'string' }, password: { type: 'string' }, 'api-url': { type: 'string' } }, allowPositionals: false });
6
+ const email = values.email ?? deps.env.FIRTH_EMAIL;
7
+ const password = values.password ?? deps.env.FIRTH_PASSWORD;
8
+ if (!email || !password) {
9
+ deps.print('login requires --email and --password (or FIRTH_EMAIL/FIRTH_PASSWORD)');
10
+ return 1;
11
+ }
12
+ const cfg = readConfig(deps.home, deps.env);
13
+ // --api-url sets the control-plane host for this login and persists it for later commands
14
+ // (a token is host-specific, so switching hosts means logging in there). Falls back to the
15
+ // existing config / FIRTH_API_URL env / the built-in default (the deployed control plane).
16
+ const apiUrl = values['api-url'] ?? cfg.apiUrl;
17
+ const api = deps.makeApi ? deps.makeApi() : new FirthApi(apiUrl, '');
18
+ try {
19
+ const { token } = await api.login(email, password);
20
+ writeConfig({ ...cfg, apiUrl, token }, deps.home);
21
+ deps.print(`signed in as ${email} (control plane: ${apiUrl})`);
22
+ return 0;
23
+ }
24
+ catch (e) {
25
+ deps.print(`login failed: ${e instanceof Error ? e.message : String(e)}`);
26
+ return 1;
27
+ }
28
+ }
29
+ export async function logout(_argv, deps) {
30
+ const cfg = readConfig(deps.home, deps.env);
31
+ delete cfg.token;
32
+ writeConfig(cfg, deps.home);
33
+ deps.print('signed out');
34
+ return 0;
35
+ }
@@ -0,0 +1,107 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { readProjectLink, setCurrentBranch } from '../config.js';
3
+ import { apiFromDeps } from './project.js';
4
+ import { formatTeardown } from './util.js';
5
+ import { ensureFlyctl } from '../fly.js';
6
+ import { ensureSkills } from '../ensure-skills.js';
7
+ function linkedProject(deps) {
8
+ const link = readProjectLink(deps.cwd);
9
+ if (!link)
10
+ throw new Error('this directory is not linked — run `firth project link <id>` or `firth project create`');
11
+ return link.projectId;
12
+ }
13
+ export async function branchCreate(argv, deps) {
14
+ await ensureFlyctl(deps);
15
+ try {
16
+ const { values, positionals } = parseArgs({ args: argv, options: { from: { type: 'string' } }, allowPositionals: true });
17
+ const name = positionals[0];
18
+ if (!name) {
19
+ deps.print('usage: firth branch create <name> [--from <parent>]');
20
+ return 1;
21
+ }
22
+ const projectId = linkedProject(deps);
23
+ const out = await apiFromDeps(deps).createBranch(projectId, name, values.from ?? 'main');
24
+ deps.print(`created branch ${out.branch.name} (${out.branch.id})`);
25
+ return 0;
26
+ }
27
+ catch (e) {
28
+ deps.print(`error: ${e instanceof Error ? e.message : String(e)}`);
29
+ return 1;
30
+ }
31
+ }
32
+ export async function branchList(_argv, deps) {
33
+ try {
34
+ const projectId = linkedProject(deps);
35
+ const branches = await apiFromDeps(deps).listBranches(projectId);
36
+ for (const b of branches)
37
+ deps.print(`${b.id} ${b.name}`);
38
+ return 0;
39
+ }
40
+ catch (e) {
41
+ deps.print(`error: ${e instanceof Error ? e.message : String(e)}`);
42
+ return 1;
43
+ }
44
+ }
45
+ export async function branchSwitch(argv, deps) {
46
+ await ensureFlyctl(deps);
47
+ try {
48
+ const { positionals } = parseArgs({ args: argv, options: {}, allowPositionals: true });
49
+ const name = positionals[0];
50
+ if (!name) {
51
+ deps.print('usage: firth branch switch <name>');
52
+ return 1;
53
+ }
54
+ const projectId = linkedProject(deps);
55
+ const branches = await apiFromDeps(deps).listBranches(projectId);
56
+ const target = branches.find((b) => b.name === name || b.id === name);
57
+ if (!target) {
58
+ deps.print(`branch "${name}" not found; available: ${branches.map((b) => b.name).join(', ')}`);
59
+ return 1;
60
+ }
61
+ setCurrentBranch({ id: target.id, name: target.name }, deps.cwd);
62
+ deps.print(`switched to branch ${target.name} (${target.id}) — run \`firth secrets\` to refresh .env`);
63
+ await ensureSkills(deps);
64
+ return 0;
65
+ }
66
+ catch (e) {
67
+ deps.print(`error: ${e instanceof Error ? e.message : String(e)}`);
68
+ return 1;
69
+ }
70
+ }
71
+ export async function branchDelete(argv, deps) {
72
+ try {
73
+ const { values, positionals } = parseArgs({ args: argv, options: { yes: { type: 'boolean' } }, allowPositionals: true });
74
+ const name = positionals[0];
75
+ if (!name) {
76
+ deps.print('usage: firth branch delete <name>');
77
+ return 1;
78
+ }
79
+ const projectId = linkedProject(deps);
80
+ const branches = await apiFromDeps(deps).listBranches(projectId);
81
+ const target = branches.find((b) => b.name === name || b.id === name);
82
+ if (!target) {
83
+ deps.print(`branch "${name}" not found; available: ${branches.map((b) => b.name).join(', ')}`);
84
+ return 1;
85
+ }
86
+ if (target.is_default) {
87
+ deps.print('cannot delete the default branch');
88
+ return 1;
89
+ }
90
+ if (!values.yes) {
91
+ deps.print(`this destroys branch "${name}" (its Neon branch). re-run with --yes to confirm.`);
92
+ return 1;
93
+ }
94
+ const out = await apiFromDeps(deps).deleteBranch(projectId, target.id);
95
+ // If the deleted branch is the current one, clear it
96
+ const link = readProjectLink(deps.cwd);
97
+ if (link?.branch?.id === target.id) {
98
+ setCurrentBranch(null, deps.cwd);
99
+ }
100
+ deps.print(`deleted branch ${target.name}${formatTeardown(out.teardown ?? {})}`);
101
+ return 0;
102
+ }
103
+ catch (e) {
104
+ deps.print(`error: ${e instanceof Error ? e.message : String(e)}`);
105
+ return 1;
106
+ }
107
+ }
@@ -0,0 +1,22 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { readProjectLink } from '../config.js';
3
+ import { apiFromDeps } from './project.js';
4
+ export async function deploy(argv, deps) {
5
+ const { values } = parseArgs({ args: argv, options: {
6
+ image: { type: 'string' }, from: { type: 'string' }, port: { type: 'string' },
7
+ }, allowPositionals: false });
8
+ if (!values.image) {
9
+ deps.print('usage: firth deploy --image <url> [--from <branch>] [--port <n>]');
10
+ return 1;
11
+ }
12
+ const link = readProjectLink(deps.cwd);
13
+ if (!link) {
14
+ deps.print('this directory is not linked — run `firth project link <id>`');
15
+ return 1;
16
+ }
17
+ const out = await apiFromDeps(deps).deploy(link.projectId, {
18
+ image: values.image, from: values.from, port: values.port ? Number(values.port) : undefined,
19
+ });
20
+ deps.print(`deployed machine ${out.machineId} → ${out.url}`);
21
+ return 0;
22
+ }
@@ -0,0 +1,20 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { readProjectLink } from '../config.js';
3
+ import { apiFromDeps } from './project.js';
4
+ export async function events(argv, deps) {
5
+ const { values } = parseArgs({ args: argv, options: { branch: { type: 'string' }, limit: { type: 'string' } }, allowPositionals: false });
6
+ const link = readProjectLink(deps.cwd);
7
+ if (!link) {
8
+ deps.print('this directory is not linked — run `firth project link <id>`');
9
+ return 1;
10
+ }
11
+ const effectiveBranch = values.branch ?? link.branch?.id;
12
+ const rows = await apiFromDeps(deps).listEvents(link.projectId, { branch: effectiveBranch, limit: values.limit ? Number(values.limit) : undefined });
13
+ if (rows.length === 0)
14
+ deps.print('(no events yet)');
15
+ for (const e of rows) {
16
+ const summary = e.payload?.url ?? e.payload?.name ?? e.payload?.machineId ?? '';
17
+ deps.print(`${e.created_at} ${e.source.padEnd(8)} ${e.kind}${summary ? ` ${summary}` : ''}`);
18
+ }
19
+ return 0;
20
+ }
@@ -0,0 +1,33 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { readProjectLink } from '../config.js';
4
+ import { apiFromDeps } from './project.js';
5
+ export async function observeSync(_argv, deps) {
6
+ const link = readProjectLink(deps.cwd);
7
+ if (!link) {
8
+ deps.print('this directory is not linked — run `firth project link <id>`');
9
+ return 1;
10
+ }
11
+ const path = join(deps.cwd, '.firth', 'audit.jsonl');
12
+ if (!existsSync(path)) {
13
+ deps.print('no audit log found at .firth/audit.jsonl (is the observe hook installed?)');
14
+ return 0;
15
+ }
16
+ const events = readFileSync(path, 'utf8').split('\n').filter((l) => l.trim()).map((line) => {
17
+ let parsed = {};
18
+ try {
19
+ parsed = JSON.parse(line);
20
+ }
21
+ catch {
22
+ parsed = { raw: line };
23
+ }
24
+ return { source: 'agent', kind: `agent.${parsed.sink ?? parsed.kind ?? 'action'}`, payload: parsed };
25
+ });
26
+ if (events.length === 0) {
27
+ deps.print('audit log is empty — nothing to sync');
28
+ return 0;
29
+ }
30
+ const res = await apiFromDeps(deps).postEvents(link.projectId, events);
31
+ deps.print(`synced ${res.recorded} agent events to the timeline`);
32
+ return 0;
33
+ }
@@ -0,0 +1,84 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { readConfig, writeProjectLink, readProjectLink, clearProjectLink, setCurrentBranch } from '../config.js';
3
+ import { FirthApi } from '../api.js';
4
+ import { formatTeardown } from './util.js';
5
+ import { ensureFlyctl } from '../fly.js';
6
+ import { ensureSkills } from '../ensure-skills.js';
7
+ // Build a FirthApi from stored config; tests can override via deps.makeApi.
8
+ export function apiFromDeps(deps) {
9
+ if (deps.makeApi)
10
+ return deps.makeApi();
11
+ const cfg = readConfig(deps.home, deps.env);
12
+ if (!cfg.token)
13
+ throw new Error('not logged in — run `firth login`');
14
+ return new FirthApi(cfg.apiUrl, cfg.token);
15
+ }
16
+ export async function projectCreate(argv, deps) {
17
+ await ensureFlyctl(deps);
18
+ const name = argv[0];
19
+ if (!name) {
20
+ deps.print('usage: firth project create <name>');
21
+ return 1;
22
+ }
23
+ const out = await apiFromDeps(deps).createProject(name);
24
+ writeProjectLink(out.project.id, deps.cwd);
25
+ // start on the project's default branch so later commands target it without a manual `branch switch`
26
+ setCurrentBranch({ id: out.defaultBranch.id, name: out.defaultBranch.name }, deps.cwd);
27
+ deps.print(`created project ${out.project.name} (${out.project.id}); linked + on branch ${out.defaultBranch.name}`);
28
+ await ensureSkills(deps);
29
+ return 0;
30
+ }
31
+ export async function projectLink(argv, deps) {
32
+ await ensureFlyctl(deps);
33
+ const id = argv[0];
34
+ if (!id) {
35
+ deps.print('usage: firth project link <id>');
36
+ return 1;
37
+ }
38
+ writeProjectLink(id, deps.cwd);
39
+ // best-effort: switch to the project's default branch so later commands target main without a manual
40
+ // `branch switch`. If we can't reach the API (not logged in / offline / no access), the id is still linked.
41
+ let on = '';
42
+ try {
43
+ const branches = await apiFromDeps(deps).listBranches(id);
44
+ const def = branches.find((b) => b.is_default) ?? branches[0];
45
+ if (def) {
46
+ setCurrentBranch({ id: def.id, name: def.name }, deps.cwd);
47
+ on = ` on branch ${def.name}`;
48
+ }
49
+ }
50
+ catch { /* the id is still linked; secrets/events fall back to the default branch */ }
51
+ deps.print(`linked this directory to project ${id}${on}`);
52
+ await ensureSkills(deps);
53
+ return 0;
54
+ }
55
+ export async function projectList(_argv, deps) {
56
+ const projects = await apiFromDeps(deps).listProjects();
57
+ if (projects.length === 0)
58
+ deps.print('(no projects)');
59
+ for (const p of projects)
60
+ deps.print(`${p.id} ${p.name}`);
61
+ return 0;
62
+ }
63
+ export async function projectDelete(argv, deps) {
64
+ try {
65
+ const { values } = parseArgs({ args: argv, options: { yes: { type: 'boolean' } }, allowPositionals: false });
66
+ const link = readProjectLink(deps.cwd);
67
+ if (!link) {
68
+ deps.print('this directory is not linked — run `firth project link <id>` or `firth project create`');
69
+ return 1;
70
+ }
71
+ if (!values.yes) {
72
+ deps.print(`this permanently destroys the project's Neon DB, Fly app, and storage bucket. re-run with --yes to confirm.`);
73
+ return 1;
74
+ }
75
+ const out = await apiFromDeps(deps).deleteProject(link.projectId);
76
+ clearProjectLink(deps.cwd);
77
+ deps.print(`deleted project ${link.projectId}${formatTeardown(out.teardown ?? {})}; unlinked ./.firth/project.json`);
78
+ return 0;
79
+ }
80
+ catch (e) {
81
+ deps.print(`error: ${e instanceof Error ? e.message : String(e)}`);
82
+ return 1;
83
+ }
84
+ }
@@ -0,0 +1,44 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { parseArgs } from 'node:util';
4
+ import { readProjectLink } from '../config.js';
5
+ import { apiFromDeps } from './project.js';
6
+ export async function secrets(argv, deps) {
7
+ const { values } = parseArgs({ args: argv, options: { branch: { type: 'string' } }, allowPositionals: false });
8
+ const link = readProjectLink(deps.cwd);
9
+ if (!link) {
10
+ deps.print('this directory is not linked — run `firth project link <id>`');
11
+ return 1;
12
+ }
13
+ const api = apiFromDeps(deps);
14
+ // Resolve the target branch (by name/id, default = the project's default branch).
15
+ const branches = await api.listBranches(link.projectId);
16
+ const target = values.branch
17
+ ? branches.find((b) => b.name === values.branch || b.id === values.branch)
18
+ : link.branch
19
+ ? branches.find((b) => b.id === link.branch.id)
20
+ : (branches.find((b) => b.is_default) ?? branches[0]);
21
+ if (!target) {
22
+ deps.print(`branch "${values.branch ?? '(default)'}" not found`);
23
+ return 1;
24
+ }
25
+ // The seam returns EITHER project-scoped (no branch) OR branch-scoped; merge both for a complete .env.
26
+ const project = await api.getSecrets(link.projectId);
27
+ const branch = await api.getSecrets(link.projectId, target.id);
28
+ const bundle = { ...project, ...branch };
29
+ // Merge Firth-managed keys into any existing .env, preserving user-added lines/comments.
30
+ const path = join(deps.cwd, '.env');
31
+ const firthKeys = new Set(Object.keys(bundle));
32
+ const existing = existsSync(path) ? readFileSync(path, 'utf8') : '';
33
+ const kept = existing.split('\n').filter((line) => {
34
+ const key = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=/)?.[1];
35
+ return !(key && firthKeys.has(key)); // drop stale Firth keys; keep user vars/comments/blanks
36
+ });
37
+ while (kept.length && kept[kept.length - 1].trim() === '')
38
+ kept.pop(); // trim trailing blanks
39
+ const firthLines = Object.entries(bundle).map(([k, v]) => `${k}=${v}`);
40
+ const merged = [...kept, ...firthLines];
41
+ writeFileSync(path, merged.length ? merged.join('\n') + '\n' : '');
42
+ deps.print(`wrote ${firthLines.length} secrets to ${path}`); // values never printed
43
+ return 0;
44
+ }
@@ -0,0 +1,18 @@
1
+ import { copyFileSync, mkdirSync, existsSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ export async function skillsPull(_argv, deps) {
5
+ const here = dirname(fileURLToPath(import.meta.url));
6
+ // built run: cli/dist/commands -> cli/dist/skills (build copies the top-level skills/ in)
7
+ // source run: cli/src/commands -> <repo-root>/skills
8
+ const candidates = [
9
+ join(here, '..', 'skills', 'firth', 'SKILL.md'),
10
+ join(here, '..', '..', '..', 'skills', 'firth', 'SKILL.md'),
11
+ ];
12
+ const src = candidates.find((p) => existsSync(p)) ?? candidates[candidates.length - 1];
13
+ const destDir = join(deps.cwd, '.claude', 'skills', 'firth');
14
+ mkdirSync(destDir, { recursive: true });
15
+ copyFileSync(src, join(destDir, 'SKILL.md'));
16
+ deps.print(`installed firth skill → ${join('.claude', 'skills', 'firth', 'SKILL.md')}`);
17
+ return 0;
18
+ }
@@ -0,0 +1,11 @@
1
+ import { readConfig, readProjectLink } from '../config.js';
2
+ export async function status(_argv, deps) {
3
+ const cfg = readConfig(deps.home, deps.env);
4
+ const link = readProjectLink(deps.cwd);
5
+ deps.print(`api: ${cfg.apiUrl}`);
6
+ deps.print(`auth: ${cfg.token ? 'signed in' : 'not signed in (run `firth login`)'}`);
7
+ deps.print(`project: ${link ? link.projectId : '(not linked)'}`);
8
+ const branchLabel = link?.branch ? `${link.branch.name} (${link.branch.id})` : '(default)';
9
+ deps.print(`branch: ${branchLabel}`);
10
+ return 0;
11
+ }
@@ -0,0 +1,11 @@
1
+ export function formatTeardown(t) {
2
+ let result = '';
3
+ if (t.destroyed && t.destroyed.length > 0) {
4
+ result += ` (destroyed: ${t.destroyed.join(', ')})`;
5
+ }
6
+ if (t.failed && t.failed.length > 0) {
7
+ const failedKinds = t.failed.map(f => f.kind).join(', ');
8
+ result += result ? `; FAILED: ${failedKinds}` : ` FAILED: ${failedKinds}`;
9
+ }
10
+ return result;
11
+ }
package/dist/config.js ADDED
@@ -0,0 +1,66 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
4
+ // Production control plane (InsForge compute). Override with `firth login --api-url`
5
+ // or FIRTH_API_URL=… for local dev against http://localhost:8080.
6
+ const DEFAULT_API = 'https://firth-control-plane-0662c2ef-202a-4feb-8267-5501b3b60037.fly.dev';
7
+ const gpath = (home) => join(home, '.firth', 'config.json');
8
+ const lpath = (cwd) => join(cwd, '.firth', 'project.json');
9
+ export function readConfig(home = homedir(), env = process.env) {
10
+ let file = {};
11
+ const p = gpath(home);
12
+ if (existsSync(p))
13
+ file = JSON.parse(readFileSync(p, 'utf8'));
14
+ return { ...file, apiUrl: env.FIRTH_API_URL ?? file.apiUrl ?? DEFAULT_API };
15
+ }
16
+ export function writeConfig(cfg, home = homedir()) {
17
+ mkdirSync(join(home, '.firth'), { recursive: true });
18
+ writeFileSync(gpath(home), JSON.stringify(cfg, null, 2));
19
+ }
20
+ export function readProjectLink(cwd = process.cwd()) {
21
+ const p = lpath(cwd);
22
+ return existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : null;
23
+ }
24
+ // One-time marker so related-skill installation runs once per linked project, not on every command.
25
+ export function markSkillsInstalled(cwd = process.cwd()) {
26
+ const link = readProjectLink(cwd);
27
+ if (!link)
28
+ return;
29
+ link.skillsInstalled = true;
30
+ writeFileSync(lpath(cwd), JSON.stringify(link, null, 2));
31
+ }
32
+ export function writeProjectLink(projectId, cwd = process.cwd()) {
33
+ mkdirSync(join(cwd, '.firth'), { recursive: true });
34
+ writeFileSync(lpath(cwd), JSON.stringify({ projectId }, null, 2));
35
+ }
36
+ export function setCurrentBranch(branch, cwd = process.cwd()) {
37
+ const link = readProjectLink(cwd);
38
+ if (!link)
39
+ throw new Error('not linked');
40
+ if (branch !== null) {
41
+ link.branch = branch;
42
+ }
43
+ else {
44
+ delete link.branch;
45
+ }
46
+ writeFileSync(lpath(cwd), JSON.stringify(link, null, 2));
47
+ }
48
+ export function clearProjectLink(cwd = process.cwd()) {
49
+ const p = lpath(cwd);
50
+ if (existsSync(p))
51
+ unlinkSync(p);
52
+ }
53
+ // Append any missing entries to the project's ./.gitignore (creating it if absent).
54
+ // Idempotent: entries already present are left alone. Returns the entries it added.
55
+ export function ensureGitignore(cwd, entries) {
56
+ const p = join(cwd, '.gitignore');
57
+ const existing = existsSync(p) ? readFileSync(p, 'utf8') : '';
58
+ const have = new Set(existing.split('\n').map((l) => l.trim()));
59
+ const missing = entries.filter((e) => !have.has(e));
60
+ if (missing.length === 0)
61
+ return [];
62
+ const prefix = existing && !existing.endsWith('\n') ? '\n' : '';
63
+ const block = `${prefix}\n# Firth: agent skills installed by \`firth\` / \`npx skills add\` (regenerable, not source)\n${missing.join('\n')}\n`;
64
+ writeFileSync(p, existing + block);
65
+ return missing;
66
+ }
@@ -0,0 +1,37 @@
1
+ import { readProjectLink, markSkillsInstalled, ensureGitignore } from './config.js';
2
+ // Where `npx skills add` / `firth skills pull` drop skills, per detected agent.
3
+ // These are regenerable agent context, not the developer's source — keep them out of git.
4
+ const SKILL_DIRS = ['.claude/skills/', '.agents/skills/', '.github/skills/'];
5
+ // Agent skills installed once per linked project so the developer's agent has
6
+ // Neon / Tigris / Firth context. Run via `npx skills add` (vercel-labs/skills).
7
+ const SKILLS = [
8
+ { label: 'neon-postgres', args: ['skills', 'add', 'neondatabase/agent-skills', '-s', 'neon-postgres'] },
9
+ { label: 'tigris', args: ['skills', 'add', 'tigrisdata/skills'] },
10
+ { label: 'firth', args: ['skills', 'add', 'firthstack/firth', '--skill', 'firth'] },
11
+ ];
12
+ // Install the related agent skills once per linked project. No-op unless deps.run is set
13
+ // (production wires the real runner; tests inject a fake; other command tests omit it → no-op,
14
+ // so nothing is spawned). Convenience only — never blocks or fails the host command.
15
+ export async function ensureSkills(deps) {
16
+ const run = deps.run;
17
+ if (!run)
18
+ return;
19
+ try {
20
+ const link = readProjectLink(deps.cwd);
21
+ if (!link || link.skillsInstalled)
22
+ return; // not linked yet, or already done
23
+ deps.print('installing related agent skills (neon, tigris, firth) via `npx skills add` …');
24
+ for (const s of SKILLS) {
25
+ const r = await run('npx', s.args, true); // streamed so the user sees progress
26
+ deps.print(r.ok ? ` ${s.label} ✓` : ` ${s.label} failed — add manually: npx ${s.args.join(' ')}`);
27
+ }
28
+ // keep the installed (regenerable) skill folders out of the developer's git history
29
+ const added = ensureGitignore(deps.cwd, SKILL_DIRS);
30
+ if (added.length)
31
+ deps.print(` .gitignore += ${added.join(', ')}`);
32
+ markSkillsInstalled(deps.cwd); // mark done even on partial failure so we don't re-run every command
33
+ }
34
+ catch {
35
+ /* convenience only — never block the command */
36
+ }
37
+ }
package/dist/fly.js ADDED
@@ -0,0 +1,25 @@
1
+ import { spawn } from 'node:child_process';
2
+ export const defaultRunner = (cmd, args, inherit = false) => new Promise((resolve) => {
3
+ const p = spawn(cmd, args, { stdio: inherit ? 'inherit' : 'ignore' });
4
+ p.on('error', () => resolve({ ok: false })); // ENOENT etc.
5
+ p.on('close', (code) => resolve({ ok: code === 0 }));
6
+ });
7
+ // Ensure flyctl is installed. No-op unless deps.run is set (production wires defaultRunner;
8
+ // tests inject a fake; other command tests omit it → this is a no-op so nothing is spawned).
9
+ export async function ensureFlyctl(deps) {
10
+ const run = deps.run;
11
+ if (!run)
12
+ return;
13
+ try {
14
+ if ((await run('flyctl', ['version'])).ok)
15
+ return; // already installed
16
+ if (!(await run('brew', ['--version'])).ok) { // no Homebrew → just hint
17
+ deps.print('note: flyctl (fly CLI) not found and Homebrew is unavailable — install it: https://fly.io/docs/flyctl/install/');
18
+ return;
19
+ }
20
+ deps.print('flyctl not found — installing with `brew install flyctl` (one-time)…');
21
+ const r = await run('brew', ['install', 'flyctl'], true);
22
+ deps.print(r.ok ? 'flyctl installed ✓' : 'flyctl install failed — install manually: https://fly.io/docs/flyctl/install/');
23
+ }
24
+ catch { /* convenience only — never block the command */ }
25
+ }
package/dist/index.js ADDED
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { homedir } from 'node:os';
6
+ import { login, logout } from './commands/auth.js';
7
+ import { projectCreate, projectLink, projectList, projectDelete } from './commands/project.js';
8
+ import { branchCreate, branchList, branchSwitch, branchDelete } from './commands/branch.js';
9
+ import { secrets } from './commands/secrets.js';
10
+ import { skillsPull } from './commands/skills.js';
11
+ import { deploy } from './commands/deploy.js';
12
+ import { events } from './commands/events.js';
13
+ import { observeSync } from './commands/observe.js';
14
+ import { status } from './commands/status.js';
15
+ import { defaultRunner } from './fly.js';
16
+ function readVersion() {
17
+ try {
18
+ const here = dirname(fileURLToPath(import.meta.url));
19
+ return JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8')).version ?? '0.0.0';
20
+ }
21
+ catch {
22
+ return '0.0.0';
23
+ }
24
+ }
25
+ const USAGE = `firth <command>
26
+
27
+ Commands:
28
+ login Sign in (--email, --password; --api-url <url> sets the control-plane host)
29
+ logout Clear stored credentials
30
+ project create <name> Create + link a project
31
+ project link <id> Link this directory to a project
32
+ project list List your projects
33
+ branch create <name> Create a branch (--from <parent>, default main)
34
+ branch list List the linked project's branches
35
+ secrets Fetch the linked project's secrets into .env (--branch <id>)
36
+ skills pull Install the firth skill into ./.claude/skills
37
+ deploy Deploy --image <url> to the project's compute (--from, --port)
38
+ events Show the project's action↔side-effect timeline (--branch, --limit)
39
+ observe sync Upload local observe-hook findings (.firth/audit.jsonl) to the timeline
40
+ status Show login, linked project, and current branch
41
+ project delete Delete the linked project + all resources (--yes)
42
+ branch switch <name> Set the current branch (secrets/events default to it)
43
+ branch delete <name> Delete a branch + its Neon branch (--yes)
44
+ --help Show this help
45
+ --version, -v Print the CLI version`;
46
+ // Command handlers registered by later tasks. Each: (argv, deps) => Promise<number>.
47
+ export const COMMANDS = {};
48
+ COMMANDS['login'] = login;
49
+ COMMANDS['logout'] = logout;
50
+ COMMANDS['project create'] = projectCreate;
51
+ COMMANDS['project link'] = projectLink;
52
+ COMMANDS['project list'] = projectList;
53
+ COMMANDS['branch create'] = branchCreate;
54
+ COMMANDS['branch list'] = branchList;
55
+ COMMANDS['secrets'] = secrets;
56
+ COMMANDS['skills pull'] = skillsPull;
57
+ COMMANDS['deploy'] = deploy;
58
+ COMMANDS['events'] = events;
59
+ COMMANDS['observe sync'] = observeSync;
60
+ COMMANDS['branch switch'] = branchSwitch;
61
+ COMMANDS['branch delete'] = branchDelete;
62
+ COMMANDS['project delete'] = projectDelete;
63
+ COMMANDS['status'] = status;
64
+ export async function route(argv, deps) {
65
+ if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
66
+ deps.print(USAGE);
67
+ return 0;
68
+ }
69
+ if (argv[0] === '--version' || argv[0] === '-v') {
70
+ deps.print(readVersion());
71
+ return 0;
72
+ }
73
+ // Support two-word commands ("project create") and one-word ("login").
74
+ const key2 = argv.length >= 2 ? `${argv[0]} ${argv[1]}` : '';
75
+ const handler = COMMANDS[key2] ?? COMMANDS[argv[0]];
76
+ if (!handler) {
77
+ deps.print(`unknown command: ${argv.join(' ')}\n\n${USAGE}`);
78
+ return 1;
79
+ }
80
+ const rest = COMMANDS[key2] ? argv.slice(2) : argv.slice(1);
81
+ try {
82
+ return await handler(rest, deps);
83
+ }
84
+ catch (e) {
85
+ deps.print(`error: ${e instanceof Error ? e.message : String(e)}`);
86
+ return 1;
87
+ }
88
+ }
89
+ export async function main() {
90
+ const code = await route(process.argv.slice(2), {
91
+ print: (s) => console.log(s), home: homedir(), cwd: process.cwd(), env: process.env,
92
+ run: defaultRunner,
93
+ });
94
+ process.exit(code);
95
+ }
96
+ if (process.env.NODE_ENV !== 'test') {
97
+ void main();
98
+ }
@@ -0,0 +1,58 @@
1
+ # Firth Skills
2
+
3
+ This directory holds the **Skills bundle** for Firth — Markdown files in the [Anthropic Skills format](https://docs.claude.com) that teach AI coding agents how to operate cloud platforms in the context of a Firth project.
4
+
5
+ ## Layout (planned)
6
+
7
+ ```
8
+ skills/
9
+ ├── README.md ← you are here
10
+ ├── stack-overview/
11
+ │ └── SKILL.md ← what stack this project uses, why
12
+ ├── deploy-flow/
13
+ │ └── SKILL.md ← how `firth deploy` actually works
14
+ ├── debug-runbook/
15
+ │ └── SKILL.md ← what to do when a deploy / migration / build fails
16
+ ├── cost-and-scaling/
17
+ │ └── SKILL.md ← free-tier limits, when to scale up, how
18
+ ├── handoff/
19
+ │ └── SKILL.md ← how to produce a context dump for a fresh agent
20
+
21
+ ├── neon/ ← provider-specific Skills
22
+ │ ├── connection-pooling/
23
+ │ │ └── SKILL.md
24
+ │ └── ...
25
+ ├── vercel/
26
+ │ └── ...
27
+ ├── railway/
28
+ │ └── ...
29
+ └── ...
30
+ ```
31
+
32
+ ## Skill format
33
+
34
+ Each Skill is a single `SKILL.md` file with YAML frontmatter:
35
+
36
+ ```markdown
37
+ ---
38
+ name: <skill-name>
39
+ description: <one-line description used by the agent to decide whether to invoke this Skill>
40
+ ---
41
+
42
+ # <Skill title>
43
+
44
+ <Markdown body — instructions, examples, gotchas, references to firth CLI commands>
45
+ ```
46
+
47
+ See the Anthropic Skills documentation for full schema details.
48
+
49
+ ## Authoring guidelines (early draft)
50
+
51
+ - **One Skill, one capability.** Don't write a 2,000-line "everything about Vercel" Skill. Split by task: `vercel-deploy`, `vercel-env-vars`, `vercel-debug-build`.
52
+ - **Reference the Firth CLI.** Skills should tell the agent which `firth` command to invoke, not how to call provider APIs directly. Provider-specific knowledge lives behind the CLI.
53
+ - **Include known failure modes.** A Skill is most valuable when it teaches the agent what can go wrong and how to recover.
54
+ - **Keep examples runnable.** Code blocks should be copy-pasteable; agents will execute them.
55
+
56
+ ## Status
57
+
58
+ Empty for now. The first batch of Skills will land alongside the first golden path (Next.js + Hono + Neon + Vercel + Railway).
@@ -0,0 +1,56 @@
1
+ ---
2
+ name: firth
3
+ description: Use when working in a project managed by Firth — provisioning DB/storage/compute, creating/switching/merging branches, deploying, checking status, or wiring app secrets via the firth CLI.
4
+ ---
5
+
6
+ # Firth
7
+
8
+ Firth provisions and governs a project's cloud resources behind one CLI and one credential seam. The CLI talks **only** to the Firth control plane; you never configure a cloud backend directly.
9
+
10
+ ## Resources Firth provisions
11
+ Every project automatically gets three base resources — build your app directly against them:
12
+ - **Postgres database** (Neon) — your app's relational DB.
13
+ - **S3-compatible storage** (Tigris) — object/blob storage.
14
+ - **Compute** (Fly.io) — where your container runs.
15
+
16
+ `firth secrets` writes the connection credentials for all three into `./.env`.
17
+
18
+ ## Setup
19
+ - `firth login --email <e> --password <p>` — sign in. Add `--api-url <url>` to target a non-local control plane; it persists for later commands.
20
+ - `firth status` — login state, linked project, current branch.
21
+ - `firth --version`.
22
+
23
+ ## Workflow
24
+ 1. **Create or link a project** — you end up linked (`./.firth/project.json`) and on the default branch `main`:
25
+ - `firth project create <name>` — provisions DB + storage + compute.
26
+ - `firth project link <id>` — link an existing project.
27
+ 2. `firth secrets` — write the current branch's credentials into `./.env`. **This is how an agent gets DB/storage access.** (`--branch <id>` targets a specific branch.)
28
+ 3. `firth deploy --image <url>` — deploy a container image to the project's compute (`--port`, `--from`).
29
+ 4. `firth events` — the action ↔ resource-side-effect timeline (`--branch`, `--limit`).
30
+
31
+ ## Database & migrations
32
+ You connect **directly** to the Postgres database (the `DATABASE_URL` from `firth secrets` — there is no ORM/abstraction in front of it). Keep a **`migrations/` directory at the project root** to store and track your database migration files, so schema changes are versioned and reproducible — this is essential for applying the same schema to a branch DB and re-applying it on `main` after a merge.
33
+
34
+ ## Branching — isolate risky changes
35
+ Before a high-risk change (schema migration, data backfill, risky refactor), do the work on a **branch**, verify it, then merge back to `main`.
36
+
37
+ - `firth branch create <name>` creates an **isolated Neon DB branch** — a full copy of the parent's data, isolated from `main` — and gives it its own `DATABASE_URL`.
38
+ - **Storage and compute are NOT branched.** The storage bucket is **shared** across branches. Compute is the project's **single shared app** (redeploy-to-restore) — to bring up the branch's environment, **redeploy your branch's code** to it (`firth deploy`). Because the compute is shared, deploying a branch redeploys that one app to the branch's code, so only one branch's app runs at a time.
39
+ - `firth branch switch <name>` then `firth secrets` → `./.env` now has the branch's `DATABASE_URL`. Run your migrations against the branch DB and deploy → an isolated branch environment to validate the change.
40
+
41
+ ### Merging a branch back to main
42
+ Firth does **not** auto-merge — you do it in your local code repo:
43
+ 1. Merge the branch's frontend + backend **code** into `main`.
44
+ 2. Merge the branch's **migration files** into `main`'s `migrations/`.
45
+ 3. Switch to main: `firth branch switch main` → `firth secrets`.
46
+ 4. Re-run the DB **migrations** against `main`'s database, then **re-deploy** compute: `firth deploy`.
47
+
48
+ ## Delete (destructive — require `--yes`)
49
+ - `firth project delete --yes` — tears down ALL resources (Neon DB, Fly app, Tigris bucket) and unlinks the directory.
50
+ - `firth branch delete <name> --yes` — tears down the branch's Neon branch. The default branch can't be deleted.
51
+
52
+ ## Rules for agents
53
+ - Treat `./.env` as the **only** source of resource credentials — run `firth secrets` to populate it; never hardcode credential values or print them.
54
+ - `DATABASE_URL` is isolated **per branch**; storage credentials (`AWS_*` / `BUCKET_NAME`) are **shared** across branches.
55
+ - Track all DB schema changes as files under `migrations/` so they can be replayed on a branch DB and on `main` after merge.
56
+ - The CLI auto-installs `flyctl` (via Homebrew) when missing during project/branch commands, so the Fly app is manageable directly if needed.
package/package.json CHANGED
@@ -1,45 +1,20 @@
1
1
  {
2
2
  "name": "firth",
3
- "version": "0.0.1",
4
- "description": "Cloud platform SDK for AI coding agents scaffold, deploy, and operate cloud stacks alongside your AI coding agent.",
3
+ "version": "0.0.2",
4
+ "description": "The Firth CLI provision and govern a project's cloud resources (Neon Postgres, Tigris storage, Fly.io compute) behind one credential seam.",
5
5
  "type": "module",
6
- "bin": {
7
- "firth": "dist/cli.js"
8
- },
9
- "files": [
10
- "dist",
11
- "README.md"
12
- ],
6
+ "bin": { "firth": "dist/index.js" },
7
+ "files": ["dist", "README.md"],
8
+ "engines": { "node": ">=20" },
9
+ "license": "MIT",
10
+ "keywords": ["firth", "cli", "ai-agent", "neon", "tigris", "fly", "deploy", "secrets", "branching"],
11
+ "repository": { "type": "git", "url": "git+https://github.com/firthstack/firth.git", "directory": "cli" },
13
12
  "scripts": {
14
- "dev": "tsx src/cli.ts",
15
- "build": "tsup",
16
- "typecheck": "tsc --noEmit",
13
+ "build": "tsc -p tsconfig.json && node -e \"require('node:fs').cpSync('../skills','dist/skills',{recursive:true})\"",
17
14
  "test": "vitest run",
18
- "test:watch": "vitest"
19
- },
20
- "keywords": [
21
- "cli",
22
- "ai-agent",
23
- "deploy",
24
- "scaffold",
25
- "vercel",
26
- "railway",
27
- "neon",
28
- "firth"
29
- ],
30
- "license": "MIT",
31
- "engines": {
32
- "node": ">=20"
33
- },
34
- "dependencies": {
35
- "@clack/prompts": "^0.7.0",
36
- "citty": "^0.1.6"
15
+ "dev": "tsx src/index.ts",
16
+ "prepublishOnly": "npm run build"
37
17
  },
38
- "devDependencies": {
39
- "@types/node": "^20.11.0",
40
- "tsup": "^8.0.2",
41
- "tsx": "^4.7.1",
42
- "typescript": "^5.4.0",
43
- "vitest": "^1.4.0"
44
- }
18
+ "dependencies": { "@insforge/sdk": "^1.4.2" },
19
+ "devDependencies": { "@types/node": "^20.14.0", "tsx": "^4.16.0", "typescript": "^5.5.0", "vitest": "^2.0.0" }
45
20
  }
package/dist/cli.js DELETED
@@ -1,183 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- // src/cli.ts
4
- import { defineCommand as defineCommand2, runMain } from "citty";
5
-
6
- // src/commands/init.ts
7
- import { defineCommand } from "citty";
8
- import * as p from "@clack/prompts";
9
- import { writeFile, mkdir } from "fs/promises";
10
- import { existsSync } from "fs";
11
- import { resolve, basename } from "path";
12
- var initCommand = defineCommand({
13
- meta: {
14
- name: "init",
15
- description: "Scaffold Firth project files (firth.config.ts + firth.lock.json) in the target directory."
16
- },
17
- args: {
18
- name: {
19
- type: "positional",
20
- description: "Project directory (use '.' for current directory)",
21
- required: false,
22
- default: "."
23
- },
24
- yes: {
25
- type: "boolean",
26
- alias: "y",
27
- description: "Skip all prompts; use defaults (Next.js + Hono + Neon + Vercel + Railway). Safe for non-interactive agent runs.",
28
- default: false
29
- },
30
- frontend: {
31
- type: "string",
32
- description: "Frontend framework override (nextjs)"
33
- },
34
- backend: {
35
- type: "string",
36
- description: "Backend framework override (hono | express | none)"
37
- },
38
- db: {
39
- type: "string",
40
- description: "Database provider override (neon | none)"
41
- },
42
- "frontend-host": {
43
- type: "string",
44
- description: "Frontend host override (vercel | none)"
45
- },
46
- "backend-host": {
47
- type: "string",
48
- description: "Backend host override (railway | none)"
49
- }
50
- },
51
- async run({ args }) {
52
- const rawName = String(args.name);
53
- const targetDir = resolve(process.cwd(), rawName);
54
- const projectName = rawName === "." ? basename(targetDir) : basename(targetDir);
55
- p.intro("firth init");
56
- const configPath = resolve(targetDir, "firth.config.ts");
57
- if (existsSync(configPath)) {
58
- p.cancel(
59
- [
60
- "ERROR: firth.config.ts already exists.",
61
- `LOCATION: ${configPath}`,
62
- "SUGGESTED ACTIONS:",
63
- " 1. Edit firth.config.ts by hand to change the stack.",
64
- " 2. Or delete firth.config.ts and re-run `firth init`."
65
- ].join("\n")
66
- );
67
- process.exit(1);
68
- }
69
- const stack = args.yes ? defaultStack() : await promptStack(args);
70
- if (!stack) {
71
- p.cancel("Cancelled.");
72
- process.exit(0);
73
- }
74
- const config = { project: projectName, stack };
75
- const lock = { version: 1, resources: {} };
76
- if (!existsSync(targetDir)) {
77
- await mkdir(targetDir, { recursive: true });
78
- }
79
- await writeFile(configPath, renderConfig(config), "utf8");
80
- await writeFile(
81
- resolve(targetDir, "firth.lock.json"),
82
- JSON.stringify(lock, null, 2) + "\n",
83
- "utf8"
84
- );
85
- p.outro(
86
- [
87
- `OK: wrote firth.config.ts and firth.lock.json to ${targetDir}`,
88
- "",
89
- "NEXT STEPS:",
90
- " 1. Review firth.config.ts and adjust the stack if needed.",
91
- " 2. Run `firth deploy` to provision and ship (coming soon)."
92
- ].join("\n")
93
- );
94
- }
95
- });
96
- function defaultStack() {
97
- return {
98
- frontend: "nextjs",
99
- backend: "hono",
100
- db: "neon",
101
- frontendHost: "vercel",
102
- backendHost: "railway"
103
- };
104
- }
105
- async function promptStack(args) {
106
- const frontend = args.frontend ?? await p.select({
107
- message: "Frontend framework?",
108
- options: [{ value: "nextjs", label: "Next.js" }],
109
- initialValue: "nextjs"
110
- });
111
- if (p.isCancel(frontend)) return null;
112
- const backend = args.backend ?? await p.select({
113
- message: "Backend framework?",
114
- options: [
115
- { value: "hono", label: "Hono (recommended)" },
116
- { value: "express", label: "Express" },
117
- { value: "none", label: "None (frontend-only)" }
118
- ],
119
- initialValue: "hono"
120
- });
121
- if (p.isCancel(backend)) return null;
122
- const db = args.db ?? await p.select({
123
- message: "Database?",
124
- options: [
125
- { value: "neon", label: "Neon Postgres" },
126
- { value: "none", label: "None" }
127
- ],
128
- initialValue: "neon"
129
- });
130
- if (p.isCancel(db)) return null;
131
- const frontendHost = args["frontend-host"] ?? await p.select({
132
- message: "Frontend hosting?",
133
- options: [
134
- { value: "vercel", label: "Vercel" },
135
- { value: "none", label: "Not yet" }
136
- ],
137
- initialValue: "vercel"
138
- });
139
- if (p.isCancel(frontendHost)) return null;
140
- let backendHost;
141
- if (backend === "none") {
142
- backendHost = "none";
143
- } else {
144
- const result = args["backend-host"] ?? await p.select({
145
- message: "Backend hosting?",
146
- options: [
147
- { value: "railway", label: "Railway" },
148
- { value: "none", label: "Not yet" }
149
- ],
150
- initialValue: "railway"
151
- });
152
- if (p.isCancel(result)) return null;
153
- backendHost = String(result);
154
- }
155
- return {
156
- frontend: String(frontend),
157
- backend: String(backend),
158
- db: String(db),
159
- frontendHost: String(frontendHost),
160
- backendHost
161
- };
162
- }
163
- function renderConfig(config) {
164
- return `// firth.config.ts
165
- // Generated by \`firth init\`. Source-of-truth for this project's stack.
166
- // Hand-edit, then run \`firth deploy\` to apply changes.
167
-
168
- export default ${JSON.stringify(config, null, 2)};
169
- `;
170
- }
171
-
172
- // src/cli.ts
173
- var main = defineCommand2({
174
- meta: {
175
- name: "firth",
176
- version: "0.0.1",
177
- description: "Cloud platform SDK for AI coding agents."
178
- },
179
- subCommands: {
180
- init: initCommand
181
- }
182
- });
183
- runMain(main);