firth 0.0.1 → 0.0.3
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 +55 -56
- package/dist/api.js +60 -0
- package/dist/commands/auth.js +35 -0
- package/dist/commands/branch.js +107 -0
- package/dist/commands/deploy.js +25 -0
- package/dist/commands/events.js +20 -0
- package/dist/commands/observe.js +57 -0
- package/dist/commands/project.js +84 -0
- package/dist/commands/secrets.js +44 -0
- package/dist/commands/skills.js +18 -0
- package/dist/commands/status.js +11 -0
- package/dist/commands/util.js +11 -0
- package/dist/config.js +66 -0
- package/dist/ensure-skills.js +47 -0
- package/dist/fly.js +25 -0
- package/dist/index.js +98 -0
- package/dist/skills/README.md +58 -0
- package/dist/skills/firth/SKILL.md +102 -0
- package/dist/sync-state.js +46 -0
- package/package.json +13 -38
- package/dist/cli.js +0 -183
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,47 @@
|
|
|
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
|
+
// fully non-interactively: pin the agents (Claude Code → `.claude/skills/`, Codex
|
|
8
|
+
// → `.agents/skills/`; both already gitignored) so there's no agent prompt and the
|
|
9
|
+
// tool doesn't fan out to ~13–72 agent dirs; explicit `-s` skill names (no skill
|
|
10
|
+
// prompt); `-y` (no scope/confirm prompt); `--copy` (real files, not symlinks into
|
|
11
|
+
// a transient package cache).
|
|
12
|
+
const AGENT_FLAGS = ['-a', 'claude-code', '-a', 'codex', '-y', '--copy'];
|
|
13
|
+
const SKILLS = [
|
|
14
|
+
{ label: 'neon-postgres', args: ['skills', 'add', 'neondatabase/agent-skills', '-s', 'neon-postgres', ...AGENT_FLAGS] },
|
|
15
|
+
{ label: 'tigris', args: ['skills', 'add', 'tigrisdata/skills',
|
|
16
|
+
'-s', 'tigris-object-operations', '-s', 'file-storage', '-s', 'tigris-sdk-guide',
|
|
17
|
+
'-s', 'tigris-security-access-control', '-s', 'tigris-image-optimization',
|
|
18
|
+
'-s', 'tigris-s3-migration', '-s', 'tigris-static-assets', '-s', 'tigris-agent-kit',
|
|
19
|
+
...AGENT_FLAGS] },
|
|
20
|
+
{ label: 'firth', args: ['skills', 'add', 'firthstack/firth', '-s', 'firth', ...AGENT_FLAGS] },
|
|
21
|
+
];
|
|
22
|
+
// Install the related agent skills once per linked project. No-op unless deps.run is set
|
|
23
|
+
// (production wires the real runner; tests inject a fake; other command tests omit it → no-op,
|
|
24
|
+
// so nothing is spawned). Convenience only — never blocks or fails the host command.
|
|
25
|
+
export async function ensureSkills(deps) {
|
|
26
|
+
const run = deps.run;
|
|
27
|
+
if (!run)
|
|
28
|
+
return;
|
|
29
|
+
try {
|
|
30
|
+
const link = readProjectLink(deps.cwd);
|
|
31
|
+
if (!link || link.skillsInstalled)
|
|
32
|
+
return; // not linked yet, or already done
|
|
33
|
+
deps.print('installing related agent skills (neon, tigris, firth) via `npx skills add` …');
|
|
34
|
+
for (const s of SKILLS) {
|
|
35
|
+
const r = await run('npx', s.args, true); // streamed so the user sees progress
|
|
36
|
+
deps.print(r.ok ? ` ${s.label} ✓` : ` ${s.label} failed — add manually: npx ${s.args.join(' ')}`);
|
|
37
|
+
}
|
|
38
|
+
// keep the installed (regenerable) skill folders out of the developer's git history
|
|
39
|
+
const added = ensureGitignore(deps.cwd, SKILL_DIRS);
|
|
40
|
+
if (added.length)
|
|
41
|
+
deps.print(` .gitignore += ${added.join(', ')}`);
|
|
42
|
+
markSkillsInstalled(deps.cwd); // mark done even on partial failure so we don't re-run every command
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
/* convenience only — never block the command */
|
|
46
|
+
}
|
|
47
|
+
}
|
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,102 @@
|
|
|
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 **current branch's** compute (`--port`; `--from <branch>` targets a specific branch's app instead). See **Deploying your app** for frontend/backend patterns.
|
|
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
|
+
## Deploying your app (frontend & backend)
|
|
35
|
+
Firth compute is a **container** running on Fly.io — **one container per branch**, exposed over HTTPS at `https://<app>.fly.dev` on the single port you choose. Deploy is **image-based**: build a container image, push it to a registry your runtime can pull, then:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
firth deploy --image <registry/image:tag> --port <n> # runs it on the CURRENT branch's compute
|
|
39
|
+
# --from <branch> targets a specific branch; the URL is printed on success
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**Secrets are injected for you.** At deploy time Firth decrypts the branch's credentials (`DATABASE_URL`, `AWS_*`, `BUCKET_NAME`, …) and passes them into the container as **environment variables**. Read them from the process environment in production — do **not** bake `./.env` into the image (that file, from `firth secrets`, is for *local* development only).
|
|
43
|
+
|
|
44
|
+
### Backend
|
|
45
|
+
Containerize your server so it listens on the port you pass to `--port` and reads credentials from the environment:
|
|
46
|
+
|
|
47
|
+
```dockerfile
|
|
48
|
+
FROM node:20-alpine
|
|
49
|
+
WORKDIR /app
|
|
50
|
+
COPY package*.json ./
|
|
51
|
+
RUN npm ci --omit=dev
|
|
52
|
+
COPY . .
|
|
53
|
+
EXPOSE 8080
|
|
54
|
+
CMD ["node", "server.js"] # reads process.env.DATABASE_URL etc.
|
|
55
|
+
```
|
|
56
|
+
`firth deploy --image <registry>/api:tag --port 8080`
|
|
57
|
+
|
|
58
|
+
### Frontend (SPA)
|
|
59
|
+
Build the static assets, then serve them from a tiny static-server container. Rewrite unknown paths to `index.html` so client-side routing works:
|
|
60
|
+
|
|
61
|
+
```dockerfile
|
|
62
|
+
FROM node:20-alpine AS build
|
|
63
|
+
WORKDIR /app
|
|
64
|
+
COPY package*.json ./
|
|
65
|
+
RUN npm ci
|
|
66
|
+
COPY . .
|
|
67
|
+
RUN npm run build # produces ./dist
|
|
68
|
+
|
|
69
|
+
FROM caddy:alpine
|
|
70
|
+
COPY --from=build /app/dist /srv
|
|
71
|
+
# Caddyfile: `:80 { root * /srv; try_files {path} /index.html; file_server }`
|
|
72
|
+
COPY Caddyfile /etc/caddy/Caddyfile
|
|
73
|
+
EXPOSE 80
|
|
74
|
+
```
|
|
75
|
+
`firth deploy --image <registry>/web:tag --port 80`
|
|
76
|
+
|
|
77
|
+
### Full-stack — one branch = one container + one port
|
|
78
|
+
A branch's compute serves **one app on one port**, so ship frontend + backend as a **single image**: have your backend serve the built frontend's static files (framework SSR, or copy the SPA's `dist/` into the server's static directory). One `firth deploy`, one URL, and the frontend can call the backend at the same origin. If you genuinely need separate frontend and backend services, host the static frontend on a dedicated static/CDN host and deploy only the backend container to Firth compute.
|
|
79
|
+
|
|
80
|
+
## Branching — isolate risky changes
|
|
81
|
+
Before a high-risk change (schema migration, data backfill, risky refactor), do the work on a **branch**, verify it, then merge back to `main`.
|
|
82
|
+
|
|
83
|
+
- `firth branch create <name>` provisions an **isolated environment** for the branch: a new **Neon DB branch** (a full copy of the parent's data, isolated from `main`, with its own `DATABASE_URL`) **and a new dedicated compute** (its own Fly app). **Only the storage bucket is shared** across branches.
|
|
84
|
+
- Each branch has its own compute app, so multiple branches' environments run **in parallel** — working on one branch never disturbs another's. To rebuild the branch's running environment, **redeploy your branch's code** to its app (`firth deploy` targets the current branch's compute).
|
|
85
|
+
- `firth branch switch <name>` then `firth secrets` → `./.env` now has the branch's `DATABASE_URL`. Run your migrations against the branch DB, then `firth deploy` → the branch's isolated compute runs your branch code, an environment to validate the change.
|
|
86
|
+
|
|
87
|
+
### Merging a branch back to main
|
|
88
|
+
Firth does **not** auto-merge — you do it in your local code repo:
|
|
89
|
+
1. Merge the branch's frontend + backend **code** into `main`.
|
|
90
|
+
2. Merge the branch's **migration files** into `main`'s `migrations/`.
|
|
91
|
+
3. Switch to main: `firth branch switch main` → `firth secrets`.
|
|
92
|
+
4. Re-run the DB **migrations** against `main`'s database, then **re-deploy** compute: `firth deploy`.
|
|
93
|
+
|
|
94
|
+
## Delete (destructive — require `--yes`)
|
|
95
|
+
- `firth project delete --yes` — tears down ALL resources (Neon DB, Fly app, Tigris bucket) and unlinks the directory.
|
|
96
|
+
- `firth branch delete <name> --yes` — tears down the branch's Neon branch **and its Fly app**. The default branch can't be deleted.
|
|
97
|
+
|
|
98
|
+
## Rules for agents
|
|
99
|
+
- Treat `./.env` as the **only** source of resource credentials — run `firth secrets` to populate it; never hardcode credential values or print them.
|
|
100
|
+
- `DATABASE_URL` **and compute** are isolated **per branch**; storage credentials (`AWS_*` / `BUCKET_NAME`) are **shared** across branches.
|
|
101
|
+
- Track all DB schema changes as files under `migrations/` so they can be replayed on a branch DB and on `main` after merge.
|
|
102
|
+
- The CLI auto-installs `flyctl` (via Homebrew) when missing during project/branch commands, so the Fly app is manageable directly if needed.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
const statePath = (cwd) => join(cwd, '.firth', 'sync-state.json');
|
|
4
|
+
export function readAuditOffset(cwd) {
|
|
5
|
+
const p = statePath(cwd);
|
|
6
|
+
if (!existsSync(p))
|
|
7
|
+
return 0;
|
|
8
|
+
try {
|
|
9
|
+
const s = JSON.parse(readFileSync(p, 'utf8'));
|
|
10
|
+
return typeof s?.audit?.offset === 'number' ? s.audit.offset : 0;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return 0;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function writeAuditOffset(cwd, offset, now) {
|
|
17
|
+
mkdirSync(join(cwd, '.firth'), { recursive: true });
|
|
18
|
+
writeFileSync(statePath(cwd), JSON.stringify({ audit: { offset, syncedAt: now } }, null, 2));
|
|
19
|
+
}
|
|
20
|
+
// Complete (non-blank) lines from `offset` to the last newline boundary.
|
|
21
|
+
// `ends[i]` = byte offset just past `lines[i]`; `newOffset` = byte offset past
|
|
22
|
+
// the last complete line (incl. any blank lines). A trailing partial line is
|
|
23
|
+
// excluded. `offset > byteLength(content)` (truncation) restarts from 0.
|
|
24
|
+
export function readNewAuditLines(content, offset) {
|
|
25
|
+
const byteLen = Buffer.byteLength(content, 'utf8');
|
|
26
|
+
const start = offset > byteLen ? 0 : offset;
|
|
27
|
+
const tail = Buffer.from(content, 'utf8').subarray(start).toString('utf8');
|
|
28
|
+
const lastNl = tail.lastIndexOf('\n');
|
|
29
|
+
if (lastNl < 0)
|
|
30
|
+
return { lines: [], ends: [], newOffset: start };
|
|
31
|
+
const block = tail.slice(0, lastNl + 1); // complete lines incl. trailing newline
|
|
32
|
+
const raw = block.split('\n');
|
|
33
|
+
if (raw[raw.length - 1] === '')
|
|
34
|
+
raw.pop(); // drop empty tail after the final '\n'
|
|
35
|
+
const lines = [];
|
|
36
|
+
const ends = [];
|
|
37
|
+
let cursor = start;
|
|
38
|
+
for (const l of raw) {
|
|
39
|
+
cursor += Buffer.byteLength(l, 'utf8') + 1; // + the newline
|
|
40
|
+
if (l.trim()) {
|
|
41
|
+
lines.push(l);
|
|
42
|
+
ends.push(cursor);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return { lines, ends, newOffset: cursor };
|
|
46
|
+
}
|
package/package.json
CHANGED
|
@@ -1,45 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "firth",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.0.3",
|
|
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
|
-
|
|
8
|
-
},
|
|
9
|
-
"
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
39
|
-
|
|
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
|
}
|