firth 0.0.2 → 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/dist/commands/deploy.js +4 -1
- package/dist/commands/observe.js +32 -8
- package/dist/ensure-skills.js +14 -4
- package/dist/skills/firth/SKILL.md +52 -6
- package/dist/sync-state.js +46 -0
- package/package.json +1 -1
package/dist/commands/deploy.js
CHANGED
|
@@ -15,7 +15,10 @@ export async function deploy(argv, deps) {
|
|
|
15
15
|
return 1;
|
|
16
16
|
}
|
|
17
17
|
const out = await apiFromDeps(deps).deploy(link.projectId, {
|
|
18
|
-
image: values.image,
|
|
18
|
+
image: values.image,
|
|
19
|
+
from: values.from,
|
|
20
|
+
branch: link.branch?.id ?? link.branch?.name,
|
|
21
|
+
port: values.port ? Number(values.port) : undefined,
|
|
19
22
|
});
|
|
20
23
|
deps.print(`deployed machine ${out.machineId} → ${out.url}`);
|
|
21
24
|
return 0;
|
package/dist/commands/observe.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
3
|
import { join } from 'node:path';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
3
5
|
import { readProjectLink } from '../config.js';
|
|
6
|
+
import { readAuditOffset, writeAuditOffset, readNewAuditLines } from '../sync-state.js';
|
|
4
7
|
import { apiFromDeps } from './project.js';
|
|
5
|
-
|
|
8
|
+
const BATCH = 500;
|
|
9
|
+
export async function observeSync(argv, deps) {
|
|
10
|
+
const { values } = parseArgs({ args: argv, options: { all: { type: 'boolean' } }, allowPositionals: true });
|
|
6
11
|
const link = readProjectLink(deps.cwd);
|
|
7
12
|
if (!link) {
|
|
8
13
|
deps.print('this directory is not linked — run `firth project link <id>`');
|
|
@@ -13,7 +18,15 @@ export async function observeSync(_argv, deps) {
|
|
|
13
18
|
deps.print('no audit log found at .firth/audit.jsonl (is the observe hook installed?)');
|
|
14
19
|
return 0;
|
|
15
20
|
}
|
|
16
|
-
const
|
|
21
|
+
const content = readFileSync(path, 'utf8');
|
|
22
|
+
const offset = values.all ? 0 : readAuditOffset(deps.cwd);
|
|
23
|
+
const { lines, ends } = readNewAuditLines(content, offset);
|
|
24
|
+
if (lines.length === 0) {
|
|
25
|
+
deps.print('nothing new to sync');
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
const api = apiFromDeps(deps);
|
|
29
|
+
const events = lines.map((line) => {
|
|
17
30
|
let parsed = {};
|
|
18
31
|
try {
|
|
19
32
|
parsed = JSON.parse(line);
|
|
@@ -21,13 +34,24 @@ export async function observeSync(_argv, deps) {
|
|
|
21
34
|
catch {
|
|
22
35
|
parsed = { raw: line };
|
|
23
36
|
}
|
|
24
|
-
return {
|
|
37
|
+
return {
|
|
38
|
+
source: 'agent',
|
|
39
|
+
kind: `agent.${parsed.sink ?? parsed.kind ?? 'action'}`,
|
|
40
|
+
payload: parsed,
|
|
41
|
+
dedup_key: createHash('sha256').update(line).digest('hex'),
|
|
42
|
+
};
|
|
25
43
|
});
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
44
|
+
let recorded = 0, skipped = 0;
|
|
45
|
+
for (let i = 0; i < events.length; i += BATCH) {
|
|
46
|
+
const batch = events.slice(i, i + BATCH);
|
|
47
|
+
const res = await api.postEvents(link.projectId, batch);
|
|
48
|
+
recorded += res.recorded;
|
|
49
|
+
skipped += res.skipped ?? 0;
|
|
50
|
+
writeAuditOffset(deps.cwd, ends[i + batch.length - 1], new Date().toISOString());
|
|
29
51
|
}
|
|
30
|
-
|
|
31
|
-
|
|
52
|
+
let msg = `synced ${recorded} new finding(s)`;
|
|
53
|
+
if (skipped > 0)
|
|
54
|
+
msg += ` (${skipped} already uploaded)`;
|
|
55
|
+
deps.print(msg);
|
|
32
56
|
return 0;
|
|
33
57
|
}
|
package/dist/ensure-skills.js
CHANGED
|
@@ -3,11 +3,21 @@ import { readProjectLink, markSkillsInstalled, ensureGitignore } from './config.
|
|
|
3
3
|
// These are regenerable agent context, not the developer's source — keep them out of git.
|
|
4
4
|
const SKILL_DIRS = ['.claude/skills/', '.agents/skills/', '.github/skills/'];
|
|
5
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)
|
|
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'];
|
|
7
13
|
const SKILLS = [
|
|
8
|
-
{ label: 'neon-postgres', args: ['skills', 'add', 'neondatabase/agent-skills', '-s', 'neon-postgres'] },
|
|
9
|
-
{ label: 'tigris', args: ['skills', 'add', 'tigrisdata/skills'
|
|
10
|
-
|
|
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] },
|
|
11
21
|
];
|
|
12
22
|
// Install the related agent skills once per linked project. No-op unless deps.run is set
|
|
13
23
|
// (production wires the real runner; tests inject a fake; other command tests omit it → no-op,
|
|
@@ -25,18 +25,64 @@ Every project automatically gets three base resources — build your app directl
|
|
|
25
25
|
- `firth project create <name>` — provisions DB + storage + compute.
|
|
26
26
|
- `firth project link <id>` — link an existing project.
|
|
27
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
|
|
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
29
|
4. `firth events` — the action ↔ resource-side-effect timeline (`--branch`, `--limit`).
|
|
30
30
|
|
|
31
31
|
## Database & migrations
|
|
32
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
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
|
+
|
|
34
80
|
## Branching — isolate risky changes
|
|
35
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`.
|
|
36
82
|
|
|
37
|
-
- `firth branch create <name>`
|
|
38
|
-
-
|
|
39
|
-
- `firth branch switch <name>` then `firth secrets` → `./.env` now has the branch's `DATABASE_URL`. Run your migrations against the branch DB
|
|
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.
|
|
40
86
|
|
|
41
87
|
### Merging a branch back to main
|
|
42
88
|
Firth does **not** auto-merge — you do it in your local code repo:
|
|
@@ -47,10 +93,10 @@ Firth does **not** auto-merge — you do it in your local code repo:
|
|
|
47
93
|
|
|
48
94
|
## Delete (destructive — require `--yes`)
|
|
49
95
|
- `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
|
|
96
|
+
- `firth branch delete <name> --yes` — tears down the branch's Neon branch **and its Fly app**. The default branch can't be deleted.
|
|
51
97
|
|
|
52
98
|
## Rules for agents
|
|
53
99
|
- 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`
|
|
100
|
+
- `DATABASE_URL` **and compute** are isolated **per branch**; storage credentials (`AWS_*` / `BUCKET_NAME`) are **shared** across branches.
|
|
55
101
|
- Track all DB schema changes as files under `migrations/` so they can be replayed on a branch DB and on `main` after merge.
|
|
56
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "firth",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
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
6
|
"bin": { "firth": "dist/index.js" },
|