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.
@@ -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, from: values.from, port: values.port ? Number(values.port) : undefined,
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;
@@ -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
- export async function observeSync(_argv, deps) {
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 events = readFileSync(path, 'utf8').split('\n').filter((l) => l.trim()).map((line) => {
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 { source: 'agent', kind: `agent.${parsed.sink ?? parsed.kind ?? 'action'}`, payload: parsed };
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
- if (events.length === 0) {
27
- deps.print('audit log is empty nothing to sync');
28
- return 0;
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
- const res = await apiFromDeps(deps).postEvents(link.projectId, events);
31
- deps.print(`synced ${res.recorded} agent events to the timeline`);
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
  }
@@ -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
- { label: 'firth', args: ['skills', 'add', 'firthstack/firth', '--skill', 'firth'] },
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 project's compute (`--port`, `--from`).
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>` 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.
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. The default branch can't be deleted.
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` is isolated **per branch**; storage credentials (`AWS_*` / `BUCKET_NAME`) are **shared** across branches.
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.2",
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" },