New:Socket for Asana Is Now Available.Learn more
Get Started

firth

Package Overview
Dependencies
Maintainers
1
Versions
8
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

firth - npm Package Compare versions

Comparing version
0.0.2
to
0.0.3
+46
dist/sync-state.js
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 };
}
+4
-1

@@ -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;
}
+14
-4

@@ -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.
{
"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",