| import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; | ||
| import { join } from 'node:path'; | ||
| const statePath = (cwd) => join(cwd, '.firth', 'sync-state.json'); | ||
| export function readAuditOffset(cwd) { | ||
| const p = statePath(cwd); | ||
| if (!existsSync(p)) | ||
| return 0; | ||
| try { | ||
| const s = JSON.parse(readFileSync(p, 'utf8')); | ||
| return typeof s?.audit?.offset === 'number' ? s.audit.offset : 0; | ||
| } | ||
| catch { | ||
| return 0; | ||
| } | ||
| } | ||
| export function writeAuditOffset(cwd, offset, now) { | ||
| mkdirSync(join(cwd, '.firth'), { recursive: true }); | ||
| writeFileSync(statePath(cwd), JSON.stringify({ audit: { offset, syncedAt: now } }, null, 2)); | ||
| } | ||
| // Complete (non-blank) lines from `offset` to the last newline boundary. | ||
| // `ends[i]` = byte offset just past `lines[i]`; `newOffset` = byte offset past | ||
| // the last complete line (incl. any blank lines). A trailing partial line is | ||
| // excluded. `offset > byteLength(content)` (truncation) restarts from 0. | ||
| export function readNewAuditLines(content, offset) { | ||
| const byteLen = Buffer.byteLength(content, 'utf8'); | ||
| const start = offset > byteLen ? 0 : offset; | ||
| const tail = Buffer.from(content, 'utf8').subarray(start).toString('utf8'); | ||
| const lastNl = tail.lastIndexOf('\n'); | ||
| if (lastNl < 0) | ||
| return { lines: [], ends: [], newOffset: start }; | ||
| const block = tail.slice(0, lastNl + 1); // complete lines incl. trailing newline | ||
| const raw = block.split('\n'); | ||
| if (raw[raw.length - 1] === '') | ||
| raw.pop(); // drop empty tail after the final '\n' | ||
| const lines = []; | ||
| const ends = []; | ||
| let cursor = start; | ||
| for (const l of raw) { | ||
| cursor += Buffer.byteLength(l, 'utf8') + 1; // + the newline | ||
| if (l.trim()) { | ||
| lines.push(l); | ||
| ends.push(cursor); | ||
| } | ||
| } | ||
| return { lines, ends, newOffset: cursor }; | ||
| } |
@@ -18,3 +18,6 @@ import { parseArgs } from 'node:util'; | ||
| const out = await apiFromDeps(deps).deploy(link.projectId, { | ||
| image: values.image, from: values.from, port: values.port ? Number(values.port) : undefined, | ||
| image: values.image, | ||
| from: values.from, | ||
| branch: link.branch?.id ?? link.branch?.name, | ||
| port: values.port ? Number(values.port) : undefined, | ||
| }); | ||
@@ -21,0 +24,0 @@ deps.print(`deployed machine ${out.machineId} → ${out.url}`); |
@@ -0,6 +1,11 @@ | ||
| import { createHash } from 'node:crypto'; | ||
| import { existsSync, readFileSync } from 'node:fs'; | ||
| import { join } from 'node:path'; | ||
| import { parseArgs } from 'node:util'; | ||
| import { readProjectLink } from '../config.js'; | ||
| import { readAuditOffset, writeAuditOffset, readNewAuditLines } from '../sync-state.js'; | ||
| import { apiFromDeps } from './project.js'; | ||
| export async function observeSync(_argv, deps) { | ||
| const BATCH = 500; | ||
| export async function observeSync(argv, deps) { | ||
| const { values } = parseArgs({ args: argv, options: { all: { type: 'boolean' } }, allowPositionals: true }); | ||
| const link = readProjectLink(deps.cwd); | ||
@@ -16,3 +21,11 @@ if (!link) { | ||
| } | ||
| const events = readFileSync(path, 'utf8').split('\n').filter((l) => l.trim()).map((line) => { | ||
| const content = readFileSync(path, 'utf8'); | ||
| const offset = values.all ? 0 : readAuditOffset(deps.cwd); | ||
| const { lines, ends } = readNewAuditLines(content, offset); | ||
| if (lines.length === 0) { | ||
| deps.print('nothing new to sync'); | ||
| return 0; | ||
| } | ||
| const api = apiFromDeps(deps); | ||
| const events = lines.map((line) => { | ||
| let parsed = {}; | ||
@@ -25,11 +38,22 @@ try { | ||
| } | ||
| return { source: 'agent', kind: `agent.${parsed.sink ?? parsed.kind ?? 'action'}`, payload: parsed }; | ||
| return { | ||
| source: 'agent', | ||
| kind: `agent.${parsed.sink ?? parsed.kind ?? 'action'}`, | ||
| payload: parsed, | ||
| dedup_key: createHash('sha256').update(line).digest('hex'), | ||
| }; | ||
| }); | ||
| if (events.length === 0) { | ||
| deps.print('audit log is empty — nothing to sync'); | ||
| return 0; | ||
| let recorded = 0, skipped = 0; | ||
| for (let i = 0; i < events.length; i += BATCH) { | ||
| const batch = events.slice(i, i + BATCH); | ||
| const res = await api.postEvents(link.projectId, batch); | ||
| recorded += res.recorded; | ||
| skipped += res.skipped ?? 0; | ||
| writeAuditOffset(deps.cwd, ends[i + batch.length - 1], new Date().toISOString()); | ||
| } | ||
| const res = await apiFromDeps(deps).postEvents(link.projectId, events); | ||
| deps.print(`synced ${res.recorded} agent events to the timeline`); | ||
| let msg = `synced ${recorded} new finding(s)`; | ||
| if (skipped > 0) | ||
| msg += ` (${skipped} already uploaded)`; | ||
| deps.print(msg); | ||
| return 0; | ||
| } |
@@ -6,7 +6,17 @@ import { readProjectLink, markSkillsInstalled, ensureGitignore } from './config.js'; | ||
| // Agent skills installed once per linked project so the developer's agent has | ||
| // Neon / Tigris / Firth context. Run via `npx skills add` (vercel-labs/skills). | ||
| // Neon / Tigris / Firth context. Run via `npx skills add` (vercel-labs/skills), | ||
| // fully non-interactively: pin the agents (Claude Code → `.claude/skills/`, Codex | ||
| // → `.agents/skills/`; both already gitignored) so there's no agent prompt and the | ||
| // tool doesn't fan out to ~13–72 agent dirs; explicit `-s` skill names (no skill | ||
| // prompt); `-y` (no scope/confirm prompt); `--copy` (real files, not symlinks into | ||
| // a transient package cache). | ||
| const AGENT_FLAGS = ['-a', 'claude-code', '-a', 'codex', '-y', '--copy']; | ||
| const SKILLS = [ | ||
| { label: 'neon-postgres', args: ['skills', 'add', 'neondatabase/agent-skills', '-s', 'neon-postgres'] }, | ||
| { label: 'tigris', args: ['skills', 'add', 'tigrisdata/skills'] }, | ||
| { label: 'firth', args: ['skills', 'add', 'firthstack/firth', '--skill', 'firth'] }, | ||
| { label: 'neon-postgres', args: ['skills', 'add', 'neondatabase/agent-skills', '-s', 'neon-postgres', ...AGENT_FLAGS] }, | ||
| { label: 'tigris', args: ['skills', 'add', 'tigrisdata/skills', | ||
| '-s', 'tigris-object-operations', '-s', 'file-storage', '-s', 'tigris-sdk-guide', | ||
| '-s', 'tigris-security-access-control', '-s', 'tigris-image-optimization', | ||
| '-s', 'tigris-s3-migration', '-s', 'tigris-static-assets', '-s', 'tigris-agent-kit', | ||
| ...AGENT_FLAGS] }, | ||
| { label: 'firth', args: ['skills', 'add', 'firthstack/firth', '-s', 'firth', ...AGENT_FLAGS] }, | ||
| ]; | ||
@@ -13,0 +23,0 @@ // Install the related agent skills once per linked project. No-op unless deps.run is set |
@@ -28,3 +28,3 @@ --- | ||
| 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.) | ||
| 3. `firth deploy --image <url>` — deploy a container image to the project's compute (`--port`, `--from`). | ||
| 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. | ||
| 4. `firth events` — the action ↔ resource-side-effect timeline (`--branch`, `--limit`). | ||
@@ -35,8 +35,54 @@ | ||
| ## Deploying your app (frontend & backend) | ||
| 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: | ||
| ``` | ||
| firth deploy --image <registry/image:tag> --port <n> # runs it on the CURRENT branch's compute | ||
| # --from <branch> targets a specific branch; the URL is printed on success | ||
| ``` | ||
| **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). | ||
| ### Backend | ||
| Containerize your server so it listens on the port you pass to `--port` and reads credentials from the environment: | ||
| ```dockerfile | ||
| FROM node:20-alpine | ||
| WORKDIR /app | ||
| COPY package*.json ./ | ||
| RUN npm ci --omit=dev | ||
| COPY . . | ||
| EXPOSE 8080 | ||
| CMD ["node", "server.js"] # reads process.env.DATABASE_URL etc. | ||
| ``` | ||
| `firth deploy --image <registry>/api:tag --port 8080` | ||
| ### Frontend (SPA) | ||
| Build the static assets, then serve them from a tiny static-server container. Rewrite unknown paths to `index.html` so client-side routing works: | ||
| ```dockerfile | ||
| FROM node:20-alpine AS build | ||
| WORKDIR /app | ||
| COPY package*.json ./ | ||
| RUN npm ci | ||
| COPY . . | ||
| RUN npm run build # produces ./dist | ||
| FROM caddy:alpine | ||
| COPY --from=build /app/dist /srv | ||
| # Caddyfile: `:80 { root * /srv; try_files {path} /index.html; file_server }` | ||
| COPY Caddyfile /etc/caddy/Caddyfile | ||
| EXPOSE 80 | ||
| ``` | ||
| `firth deploy --image <registry>/web:tag --port 80` | ||
| ### Full-stack — one branch = one container + one port | ||
| 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. | ||
| ## Branching — isolate risky changes | ||
| Before a high-risk change (schema migration, data backfill, risky refactor), do the work on a **branch**, verify it, then merge back to `main`. | ||
| - `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`. | ||
| - **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. | ||
| - `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. | ||
| - `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. | ||
| - 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). | ||
| - `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. | ||
@@ -52,8 +98,8 @@ ### Merging a branch back to main | ||
| - `firth project delete --yes` — tears down ALL resources (Neon DB, Fly app, Tigris bucket) and unlinks the directory. | ||
| - `firth branch delete <name> --yes` — tears down the branch's Neon branch. The default branch can't be deleted. | ||
| - `firth branch delete <name> --yes` — tears down the branch's Neon branch **and its Fly app**. The default branch can't be deleted. | ||
| ## Rules for agents | ||
| - Treat `./.env` as the **only** source of resource credentials — run `firth secrets` to populate it; never hardcode credential values or print them. | ||
| - `DATABASE_URL` is isolated **per branch**; storage credentials (`AWS_*` / `BUCKET_NAME`) are **shared** across branches. | ||
| - `DATABASE_URL` **and compute** are isolated **per branch**; storage credentials (`AWS_*` / `BUCKET_NAME`) are **shared** across branches. | ||
| - Track all DB schema changes as files under `migrations/` so they can be replayed on a branch DB and on `main` after merge. | ||
| - The CLI auto-installs `flyctl` (via Homebrew) when missing during project/branch commands, so the Fly app is manageable directly if needed. |
+1
-1
| { | ||
| "name": "firth", | ||
| "version": "0.0.2", | ||
| "version": "0.0.3", | ||
| "description": "The Firth CLI — provision and govern a project's cloud resources (Neon Postgres, Tigris storage, Fly.io compute) behind one credential seam.", | ||
@@ -5,0 +5,0 @@ "type": "module", |
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
46916
15.2%20
5.26%753
12.39%2
-33.33%11
10%