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/README.md
CHANGED
|
@@ -1,76 +1,75 @@
|
|
|
1
|
-
# firth
|
|
1
|
+
# firth
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The **Firth CLI** — provision and govern a project's cloud resources (Neon Postgres,
|
|
4
|
+
Tigris storage, Fly.io compute) behind one CLI and one credential seam. It is a thin
|
|
5
|
+
client of the Firth control-plane API; you sign in, create a project (which provisions
|
|
6
|
+
the resources under Firth's own provider accounts), and pull a complete `.env` of
|
|
7
|
+
scoped, encrypted-at-rest credentials.
|
|
4
8
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
This repo is the **L2 / CLI layer** of the Firth project. The companion repo [`firth`](https://github.com/firthdev/firth) holds the L1 / Knowledge layer (Skills, templates, runbooks, ARCHITECTURE.md).
|
|
8
|
-
|
|
9
|
-
For the project's overall design and rationale, see [`firth/ARCHITECTURE.md`](https://github.com/firthdev/firth/blob/main/ARCHITECTURE.md). This README is just for the CLI itself.
|
|
10
|
-
|
|
11
|
-
## Local development
|
|
9
|
+
## Install
|
|
12
10
|
|
|
13
11
|
```bash
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
# run the CLI in dev (no build step)
|
|
18
|
-
npm run dev -- init
|
|
19
|
-
|
|
20
|
-
# typecheck
|
|
21
|
-
npm run typecheck
|
|
22
|
-
|
|
23
|
-
# tests
|
|
24
|
-
npm test
|
|
25
|
-
|
|
26
|
-
# build a distributable
|
|
27
|
-
npm run build
|
|
28
|
-
|
|
29
|
-
# link into your shell so `firth` works globally during dev
|
|
30
|
-
npm link
|
|
31
|
-
firth init my-test-app
|
|
12
|
+
npm install -g firth
|
|
13
|
+
# or run without installing
|
|
14
|
+
npx firth --help
|
|
32
15
|
```
|
|
33
16
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
### `firth init [name]`
|
|
17
|
+
Requires Node ≥ 20.
|
|
37
18
|
|
|
38
|
-
|
|
19
|
+
## Quickstart
|
|
39
20
|
|
|
40
21
|
```bash
|
|
41
|
-
#
|
|
42
|
-
firth
|
|
22
|
+
firth login # sign in (email/password)
|
|
23
|
+
firth project create my-app # provision DB + storage + compute; links ./.firth/project.json
|
|
24
|
+
firth branch create dev # fork an isolated DB branch (storage shared; compute redeploys)
|
|
25
|
+
firth branch switch dev # make dev the current branch
|
|
26
|
+
firth secrets # write the project's credentials into ./.env
|
|
27
|
+
firth deploy --image <url> # deploy a container image to the project's compute
|
|
28
|
+
firth events # the action ↔ side-effect timeline
|
|
29
|
+
```
|
|
43
30
|
|
|
44
|
-
|
|
45
|
-
|
|
31
|
+
> **Connectivity:** the CLI talks to the Firth control-plane API. Set `FIRTH_API_URL`
|
|
32
|
+
> (default `http://localhost:8080`) to point at your control plane. While Firth is
|
|
33
|
+
> pre-release, run the control plane locally and target `http://localhost:8080`.
|
|
46
34
|
|
|
47
|
-
|
|
48
|
-
firth init my-app --yes
|
|
35
|
+
## Commands
|
|
49
36
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
37
|
+
```
|
|
38
|
+
login Sign in (email/password)
|
|
39
|
+
logout Clear stored credentials
|
|
40
|
+
status Show login, linked project, and current branch
|
|
41
|
+
|
|
42
|
+
project create <name> Create + link a project (provisions DB/storage/compute)
|
|
43
|
+
project link <id> Link this directory to a project
|
|
44
|
+
project list List your projects
|
|
45
|
+
project delete Delete the linked project + all resources (--yes)
|
|
46
|
+
|
|
47
|
+
branch create <name> Create a branch (--from <parent>, default main)
|
|
48
|
+
branch list List the linked project's branches
|
|
49
|
+
branch switch <name> Set the current branch (secrets/events default to it)
|
|
50
|
+
branch delete <name> Delete a branch + its Neon branch (--yes)
|
|
51
|
+
|
|
52
|
+
secrets Fetch the linked project's secrets into .env (--branch <id>)
|
|
53
|
+
deploy Deploy --image <url> to the project's compute (--from, --port)
|
|
54
|
+
events Show the action ↔ side-effect timeline (--branch, --limit)
|
|
55
|
+
observe sync Upload local observe-hook findings (.firth/audit.jsonl) to the timeline
|
|
56
|
+
skills pull Install the firth skill into ./.claude/skills
|
|
53
57
|
```
|
|
54
58
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
## Commands (planned)
|
|
58
|
-
|
|
59
|
-
- `firth deploy` — provision resources and push code across the stack.
|
|
60
|
-
- `firth secrets set/get/list` — sync secrets across providers.
|
|
61
|
-
- `firth logs [--service]` — tail logs.
|
|
62
|
-
- `firth status` — current deployment + resource state.
|
|
63
|
-
- `firth handoff` — generate a context dump for a fresh agent session.
|
|
64
|
-
- `firth db migrate / db reset` — database lifecycle.
|
|
59
|
+
## Configuration & state
|
|
65
60
|
|
|
66
|
-
|
|
61
|
+
- **Global** — `~/.firth/config.json`: API URL, InsForge auth endpoint, and your access token.
|
|
62
|
+
- **Per-project** — `./.firth/project.json`: the linked project id and the current branch
|
|
63
|
+
(set by `firth branch switch`). `secrets` and `events` default to the current branch.
|
|
64
|
+
- **Override** — `FIRTH_API_URL` selects which control plane to talk to.
|
|
67
65
|
|
|
68
|
-
|
|
69
|
-
- **Agent-friendly errors.** Failures emit `ERROR / LIKELY CAUSE / SUGGESTED ACTIONS` so an agent loop can recover.
|
|
70
|
-
- **Local state lives in the project.** `firth.config.ts` (declarative, hand-edited) + `firth.lock.json` (generated, holds resource IDs) — both committed.
|
|
66
|
+
## Credentials
|
|
71
67
|
|
|
72
|
-
|
|
68
|
+
Treat `./.env` as the only source of resource credentials — never hardcode them or copy
|
|
69
|
+
them elsewhere. A branch's `DATABASE_URL` is isolated per branch; storage (`AWS_*`) is
|
|
70
|
+
shared across branches. Re-run `firth secrets` after `firth branch switch` to refresh
|
|
71
|
+
`DATABASE_URL`.
|
|
73
72
|
|
|
74
73
|
## License
|
|
75
74
|
|
|
76
|
-
MIT
|
|
75
|
+
MIT
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
const realFetcher = async (url, init) => {
|
|
2
|
+
const res = await fetch(url, { method: init.method, headers: init.headers, body: init.body });
|
|
3
|
+
return { status: res.status, json: () => res.json(), text: () => res.text() };
|
|
4
|
+
};
|
|
5
|
+
export class FirthApi {
|
|
6
|
+
apiUrl;
|
|
7
|
+
token;
|
|
8
|
+
fetcher;
|
|
9
|
+
constructor(apiUrl, token, fetcher = realFetcher) {
|
|
10
|
+
this.apiUrl = apiUrl;
|
|
11
|
+
this.token = token;
|
|
12
|
+
this.fetcher = fetcher;
|
|
13
|
+
}
|
|
14
|
+
async req(method, path, body) {
|
|
15
|
+
const res = await this.fetcher(`${this.apiUrl}${path}`, {
|
|
16
|
+
method,
|
|
17
|
+
headers: { Authorization: `Bearer ${this.token}`, 'Content-Type': 'application/json' },
|
|
18
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
19
|
+
});
|
|
20
|
+
if (res.status < 200 || res.status >= 300) {
|
|
21
|
+
let msg = '';
|
|
22
|
+
try {
|
|
23
|
+
msg = (await res.json())?.error ?? '';
|
|
24
|
+
}
|
|
25
|
+
catch { /* ignore */ }
|
|
26
|
+
throw new Error(`request failed: ${res.status}${msg ? ` ${msg}` : ''}`);
|
|
27
|
+
}
|
|
28
|
+
return res.json();
|
|
29
|
+
}
|
|
30
|
+
createProject(name) { return this.req('POST', '/projects', { name }); }
|
|
31
|
+
listProjects() { return this.req('GET', '/projects').then((r) => r.projects); }
|
|
32
|
+
createBranch(projectId, name, from) {
|
|
33
|
+
return this.req('POST', `/projects/${projectId}/branches`, { name, from });
|
|
34
|
+
}
|
|
35
|
+
listBranches(projectId) { return this.req('GET', `/projects/${projectId}/branches`).then((r) => r.branches); }
|
|
36
|
+
getSecrets(projectId, branch) {
|
|
37
|
+
const q = branch ? `?branch=${encodeURIComponent(branch)}` : '';
|
|
38
|
+
return this.req('GET', `/projects/${projectId}/secrets${q}`).then((r) => r.secrets);
|
|
39
|
+
}
|
|
40
|
+
deploy(projectId, opts) {
|
|
41
|
+
return this.req('POST', `/projects/${projectId}/deploy`, opts);
|
|
42
|
+
}
|
|
43
|
+
listEvents(projectId, opts = {}) {
|
|
44
|
+
const qs = new URLSearchParams();
|
|
45
|
+
if (opts.branch)
|
|
46
|
+
qs.set('branch', opts.branch);
|
|
47
|
+
if (opts.limit)
|
|
48
|
+
qs.set('limit', String(opts.limit));
|
|
49
|
+
const q = qs.toString();
|
|
50
|
+
return this.req('GET', `/projects/${projectId}/events${q ? `?${q}` : ''}`).then((r) => r.events);
|
|
51
|
+
}
|
|
52
|
+
postEvents(projectId, events) {
|
|
53
|
+
return this.req('POST', `/projects/${projectId}/events`, { events });
|
|
54
|
+
}
|
|
55
|
+
deleteProject(id) { return this.req('DELETE', `/projects/${id}`); }
|
|
56
|
+
deleteBranch(projectId, branchId) { return this.req('DELETE', `/projects/${projectId}/branches/${branchId}`); }
|
|
57
|
+
login(email, password) {
|
|
58
|
+
return this.req('POST', '/auth/login', { email, password });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { FirthApi } from '../api.js';
|
|
3
|
+
import { readConfig, writeConfig } from '../config.js';
|
|
4
|
+
export async function login(argv, deps) {
|
|
5
|
+
const { values } = parseArgs({ args: argv, options: { email: { type: 'string' }, password: { type: 'string' }, 'api-url': { type: 'string' } }, allowPositionals: false });
|
|
6
|
+
const email = values.email ?? deps.env.FIRTH_EMAIL;
|
|
7
|
+
const password = values.password ?? deps.env.FIRTH_PASSWORD;
|
|
8
|
+
if (!email || !password) {
|
|
9
|
+
deps.print('login requires --email and --password (or FIRTH_EMAIL/FIRTH_PASSWORD)');
|
|
10
|
+
return 1;
|
|
11
|
+
}
|
|
12
|
+
const cfg = readConfig(deps.home, deps.env);
|
|
13
|
+
// --api-url sets the control-plane host for this login and persists it for later commands
|
|
14
|
+
// (a token is host-specific, so switching hosts means logging in there). Falls back to the
|
|
15
|
+
// existing config / FIRTH_API_URL env / the built-in default (the deployed control plane).
|
|
16
|
+
const apiUrl = values['api-url'] ?? cfg.apiUrl;
|
|
17
|
+
const api = deps.makeApi ? deps.makeApi() : new FirthApi(apiUrl, '');
|
|
18
|
+
try {
|
|
19
|
+
const { token } = await api.login(email, password);
|
|
20
|
+
writeConfig({ ...cfg, apiUrl, token }, deps.home);
|
|
21
|
+
deps.print(`signed in as ${email} (control plane: ${apiUrl})`);
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
deps.print(`login failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
26
|
+
return 1;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export async function logout(_argv, deps) {
|
|
30
|
+
const cfg = readConfig(deps.home, deps.env);
|
|
31
|
+
delete cfg.token;
|
|
32
|
+
writeConfig(cfg, deps.home);
|
|
33
|
+
deps.print('signed out');
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { readProjectLink, setCurrentBranch } from '../config.js';
|
|
3
|
+
import { apiFromDeps } from './project.js';
|
|
4
|
+
import { formatTeardown } from './util.js';
|
|
5
|
+
import { ensureFlyctl } from '../fly.js';
|
|
6
|
+
import { ensureSkills } from '../ensure-skills.js';
|
|
7
|
+
function linkedProject(deps) {
|
|
8
|
+
const link = readProjectLink(deps.cwd);
|
|
9
|
+
if (!link)
|
|
10
|
+
throw new Error('this directory is not linked — run `firth project link <id>` or `firth project create`');
|
|
11
|
+
return link.projectId;
|
|
12
|
+
}
|
|
13
|
+
export async function branchCreate(argv, deps) {
|
|
14
|
+
await ensureFlyctl(deps);
|
|
15
|
+
try {
|
|
16
|
+
const { values, positionals } = parseArgs({ args: argv, options: { from: { type: 'string' } }, allowPositionals: true });
|
|
17
|
+
const name = positionals[0];
|
|
18
|
+
if (!name) {
|
|
19
|
+
deps.print('usage: firth branch create <name> [--from <parent>]');
|
|
20
|
+
return 1;
|
|
21
|
+
}
|
|
22
|
+
const projectId = linkedProject(deps);
|
|
23
|
+
const out = await apiFromDeps(deps).createBranch(projectId, name, values.from ?? 'main');
|
|
24
|
+
deps.print(`created branch ${out.branch.name} (${out.branch.id})`);
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
catch (e) {
|
|
28
|
+
deps.print(`error: ${e instanceof Error ? e.message : String(e)}`);
|
|
29
|
+
return 1;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export async function branchList(_argv, deps) {
|
|
33
|
+
try {
|
|
34
|
+
const projectId = linkedProject(deps);
|
|
35
|
+
const branches = await apiFromDeps(deps).listBranches(projectId);
|
|
36
|
+
for (const b of branches)
|
|
37
|
+
deps.print(`${b.id} ${b.name}`);
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
deps.print(`error: ${e instanceof Error ? e.message : String(e)}`);
|
|
42
|
+
return 1;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export async function branchSwitch(argv, deps) {
|
|
46
|
+
await ensureFlyctl(deps);
|
|
47
|
+
try {
|
|
48
|
+
const { positionals } = parseArgs({ args: argv, options: {}, allowPositionals: true });
|
|
49
|
+
const name = positionals[0];
|
|
50
|
+
if (!name) {
|
|
51
|
+
deps.print('usage: firth branch switch <name>');
|
|
52
|
+
return 1;
|
|
53
|
+
}
|
|
54
|
+
const projectId = linkedProject(deps);
|
|
55
|
+
const branches = await apiFromDeps(deps).listBranches(projectId);
|
|
56
|
+
const target = branches.find((b) => b.name === name || b.id === name);
|
|
57
|
+
if (!target) {
|
|
58
|
+
deps.print(`branch "${name}" not found; available: ${branches.map((b) => b.name).join(', ')}`);
|
|
59
|
+
return 1;
|
|
60
|
+
}
|
|
61
|
+
setCurrentBranch({ id: target.id, name: target.name }, deps.cwd);
|
|
62
|
+
deps.print(`switched to branch ${target.name} (${target.id}) — run \`firth secrets\` to refresh .env`);
|
|
63
|
+
await ensureSkills(deps);
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
deps.print(`error: ${e instanceof Error ? e.message : String(e)}`);
|
|
68
|
+
return 1;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export async function branchDelete(argv, deps) {
|
|
72
|
+
try {
|
|
73
|
+
const { values, positionals } = parseArgs({ args: argv, options: { yes: { type: 'boolean' } }, allowPositionals: true });
|
|
74
|
+
const name = positionals[0];
|
|
75
|
+
if (!name) {
|
|
76
|
+
deps.print('usage: firth branch delete <name>');
|
|
77
|
+
return 1;
|
|
78
|
+
}
|
|
79
|
+
const projectId = linkedProject(deps);
|
|
80
|
+
const branches = await apiFromDeps(deps).listBranches(projectId);
|
|
81
|
+
const target = branches.find((b) => b.name === name || b.id === name);
|
|
82
|
+
if (!target) {
|
|
83
|
+
deps.print(`branch "${name}" not found; available: ${branches.map((b) => b.name).join(', ')}`);
|
|
84
|
+
return 1;
|
|
85
|
+
}
|
|
86
|
+
if (target.is_default) {
|
|
87
|
+
deps.print('cannot delete the default branch');
|
|
88
|
+
return 1;
|
|
89
|
+
}
|
|
90
|
+
if (!values.yes) {
|
|
91
|
+
deps.print(`this destroys branch "${name}" (its Neon branch). re-run with --yes to confirm.`);
|
|
92
|
+
return 1;
|
|
93
|
+
}
|
|
94
|
+
const out = await apiFromDeps(deps).deleteBranch(projectId, target.id);
|
|
95
|
+
// If the deleted branch is the current one, clear it
|
|
96
|
+
const link = readProjectLink(deps.cwd);
|
|
97
|
+
if (link?.branch?.id === target.id) {
|
|
98
|
+
setCurrentBranch(null, deps.cwd);
|
|
99
|
+
}
|
|
100
|
+
deps.print(`deleted branch ${target.name}${formatTeardown(out.teardown ?? {})}`);
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
deps.print(`error: ${e instanceof Error ? e.message : String(e)}`);
|
|
105
|
+
return 1;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { readProjectLink } from '../config.js';
|
|
3
|
+
import { apiFromDeps } from './project.js';
|
|
4
|
+
export async function deploy(argv, deps) {
|
|
5
|
+
const { values } = parseArgs({ args: argv, options: {
|
|
6
|
+
image: { type: 'string' }, from: { type: 'string' }, port: { type: 'string' },
|
|
7
|
+
}, allowPositionals: false });
|
|
8
|
+
if (!values.image) {
|
|
9
|
+
deps.print('usage: firth deploy --image <url> [--from <branch>] [--port <n>]');
|
|
10
|
+
return 1;
|
|
11
|
+
}
|
|
12
|
+
const link = readProjectLink(deps.cwd);
|
|
13
|
+
if (!link) {
|
|
14
|
+
deps.print('this directory is not linked — run `firth project link <id>`');
|
|
15
|
+
return 1;
|
|
16
|
+
}
|
|
17
|
+
const out = await apiFromDeps(deps).deploy(link.projectId, {
|
|
18
|
+
image: values.image,
|
|
19
|
+
from: values.from,
|
|
20
|
+
branch: link.branch?.id ?? link.branch?.name,
|
|
21
|
+
port: values.port ? Number(values.port) : undefined,
|
|
22
|
+
});
|
|
23
|
+
deps.print(`deployed machine ${out.machineId} → ${out.url}`);
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { readProjectLink } from '../config.js';
|
|
3
|
+
import { apiFromDeps } from './project.js';
|
|
4
|
+
export async function events(argv, deps) {
|
|
5
|
+
const { values } = parseArgs({ args: argv, options: { branch: { type: 'string' }, limit: { type: 'string' } }, allowPositionals: false });
|
|
6
|
+
const link = readProjectLink(deps.cwd);
|
|
7
|
+
if (!link) {
|
|
8
|
+
deps.print('this directory is not linked — run `firth project link <id>`');
|
|
9
|
+
return 1;
|
|
10
|
+
}
|
|
11
|
+
const effectiveBranch = values.branch ?? link.branch?.id;
|
|
12
|
+
const rows = await apiFromDeps(deps).listEvents(link.projectId, { branch: effectiveBranch, limit: values.limit ? Number(values.limit) : undefined });
|
|
13
|
+
if (rows.length === 0)
|
|
14
|
+
deps.print('(no events yet)');
|
|
15
|
+
for (const e of rows) {
|
|
16
|
+
const summary = e.payload?.url ?? e.payload?.name ?? e.payload?.machineId ?? '';
|
|
17
|
+
deps.print(`${e.created_at} ${e.source.padEnd(8)} ${e.kind}${summary ? ` ${summary}` : ''}`);
|
|
18
|
+
}
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
import { readProjectLink } from '../config.js';
|
|
6
|
+
import { readAuditOffset, writeAuditOffset, readNewAuditLines } from '../sync-state.js';
|
|
7
|
+
import { apiFromDeps } from './project.js';
|
|
8
|
+
const BATCH = 500;
|
|
9
|
+
export async function observeSync(argv, deps) {
|
|
10
|
+
const { values } = parseArgs({ args: argv, options: { all: { type: 'boolean' } }, allowPositionals: true });
|
|
11
|
+
const link = readProjectLink(deps.cwd);
|
|
12
|
+
if (!link) {
|
|
13
|
+
deps.print('this directory is not linked — run `firth project link <id>`');
|
|
14
|
+
return 1;
|
|
15
|
+
}
|
|
16
|
+
const path = join(deps.cwd, '.firth', 'audit.jsonl');
|
|
17
|
+
if (!existsSync(path)) {
|
|
18
|
+
deps.print('no audit log found at .firth/audit.jsonl (is the observe hook installed?)');
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
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) => {
|
|
30
|
+
let parsed = {};
|
|
31
|
+
try {
|
|
32
|
+
parsed = JSON.parse(line);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
parsed = { raw: line };
|
|
36
|
+
}
|
|
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
|
+
};
|
|
43
|
+
});
|
|
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());
|
|
51
|
+
}
|
|
52
|
+
let msg = `synced ${recorded} new finding(s)`;
|
|
53
|
+
if (skipped > 0)
|
|
54
|
+
msg += ` (${skipped} already uploaded)`;
|
|
55
|
+
deps.print(msg);
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { readConfig, writeProjectLink, readProjectLink, clearProjectLink, setCurrentBranch } from '../config.js';
|
|
3
|
+
import { FirthApi } from '../api.js';
|
|
4
|
+
import { formatTeardown } from './util.js';
|
|
5
|
+
import { ensureFlyctl } from '../fly.js';
|
|
6
|
+
import { ensureSkills } from '../ensure-skills.js';
|
|
7
|
+
// Build a FirthApi from stored config; tests can override via deps.makeApi.
|
|
8
|
+
export function apiFromDeps(deps) {
|
|
9
|
+
if (deps.makeApi)
|
|
10
|
+
return deps.makeApi();
|
|
11
|
+
const cfg = readConfig(deps.home, deps.env);
|
|
12
|
+
if (!cfg.token)
|
|
13
|
+
throw new Error('not logged in — run `firth login`');
|
|
14
|
+
return new FirthApi(cfg.apiUrl, cfg.token);
|
|
15
|
+
}
|
|
16
|
+
export async function projectCreate(argv, deps) {
|
|
17
|
+
await ensureFlyctl(deps);
|
|
18
|
+
const name = argv[0];
|
|
19
|
+
if (!name) {
|
|
20
|
+
deps.print('usage: firth project create <name>');
|
|
21
|
+
return 1;
|
|
22
|
+
}
|
|
23
|
+
const out = await apiFromDeps(deps).createProject(name);
|
|
24
|
+
writeProjectLink(out.project.id, deps.cwd);
|
|
25
|
+
// start on the project's default branch so later commands target it without a manual `branch switch`
|
|
26
|
+
setCurrentBranch({ id: out.defaultBranch.id, name: out.defaultBranch.name }, deps.cwd);
|
|
27
|
+
deps.print(`created project ${out.project.name} (${out.project.id}); linked + on branch ${out.defaultBranch.name}`);
|
|
28
|
+
await ensureSkills(deps);
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
export async function projectLink(argv, deps) {
|
|
32
|
+
await ensureFlyctl(deps);
|
|
33
|
+
const id = argv[0];
|
|
34
|
+
if (!id) {
|
|
35
|
+
deps.print('usage: firth project link <id>');
|
|
36
|
+
return 1;
|
|
37
|
+
}
|
|
38
|
+
writeProjectLink(id, deps.cwd);
|
|
39
|
+
// best-effort: switch to the project's default branch so later commands target main without a manual
|
|
40
|
+
// `branch switch`. If we can't reach the API (not logged in / offline / no access), the id is still linked.
|
|
41
|
+
let on = '';
|
|
42
|
+
try {
|
|
43
|
+
const branches = await apiFromDeps(deps).listBranches(id);
|
|
44
|
+
const def = branches.find((b) => b.is_default) ?? branches[0];
|
|
45
|
+
if (def) {
|
|
46
|
+
setCurrentBranch({ id: def.id, name: def.name }, deps.cwd);
|
|
47
|
+
on = ` on branch ${def.name}`;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch { /* the id is still linked; secrets/events fall back to the default branch */ }
|
|
51
|
+
deps.print(`linked this directory to project ${id}${on}`);
|
|
52
|
+
await ensureSkills(deps);
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
export async function projectList(_argv, deps) {
|
|
56
|
+
const projects = await apiFromDeps(deps).listProjects();
|
|
57
|
+
if (projects.length === 0)
|
|
58
|
+
deps.print('(no projects)');
|
|
59
|
+
for (const p of projects)
|
|
60
|
+
deps.print(`${p.id} ${p.name}`);
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
export async function projectDelete(argv, deps) {
|
|
64
|
+
try {
|
|
65
|
+
const { values } = parseArgs({ args: argv, options: { yes: { type: 'boolean' } }, allowPositionals: false });
|
|
66
|
+
const link = readProjectLink(deps.cwd);
|
|
67
|
+
if (!link) {
|
|
68
|
+
deps.print('this directory is not linked — run `firth project link <id>` or `firth project create`');
|
|
69
|
+
return 1;
|
|
70
|
+
}
|
|
71
|
+
if (!values.yes) {
|
|
72
|
+
deps.print(`this permanently destroys the project's Neon DB, Fly app, and storage bucket. re-run with --yes to confirm.`);
|
|
73
|
+
return 1;
|
|
74
|
+
}
|
|
75
|
+
const out = await apiFromDeps(deps).deleteProject(link.projectId);
|
|
76
|
+
clearProjectLink(deps.cwd);
|
|
77
|
+
deps.print(`deleted project ${link.projectId}${formatTeardown(out.teardown ?? {})}; unlinked ./.firth/project.json`);
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
deps.print(`error: ${e instanceof Error ? e.message : String(e)}`);
|
|
82
|
+
return 1;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { parseArgs } from 'node:util';
|
|
4
|
+
import { readProjectLink } from '../config.js';
|
|
5
|
+
import { apiFromDeps } from './project.js';
|
|
6
|
+
export async function secrets(argv, deps) {
|
|
7
|
+
const { values } = parseArgs({ args: argv, options: { branch: { type: 'string' } }, allowPositionals: false });
|
|
8
|
+
const link = readProjectLink(deps.cwd);
|
|
9
|
+
if (!link) {
|
|
10
|
+
deps.print('this directory is not linked — run `firth project link <id>`');
|
|
11
|
+
return 1;
|
|
12
|
+
}
|
|
13
|
+
const api = apiFromDeps(deps);
|
|
14
|
+
// Resolve the target branch (by name/id, default = the project's default branch).
|
|
15
|
+
const branches = await api.listBranches(link.projectId);
|
|
16
|
+
const target = values.branch
|
|
17
|
+
? branches.find((b) => b.name === values.branch || b.id === values.branch)
|
|
18
|
+
: link.branch
|
|
19
|
+
? branches.find((b) => b.id === link.branch.id)
|
|
20
|
+
: (branches.find((b) => b.is_default) ?? branches[0]);
|
|
21
|
+
if (!target) {
|
|
22
|
+
deps.print(`branch "${values.branch ?? '(default)'}" not found`);
|
|
23
|
+
return 1;
|
|
24
|
+
}
|
|
25
|
+
// The seam returns EITHER project-scoped (no branch) OR branch-scoped; merge both for a complete .env.
|
|
26
|
+
const project = await api.getSecrets(link.projectId);
|
|
27
|
+
const branch = await api.getSecrets(link.projectId, target.id);
|
|
28
|
+
const bundle = { ...project, ...branch };
|
|
29
|
+
// Merge Firth-managed keys into any existing .env, preserving user-added lines/comments.
|
|
30
|
+
const path = join(deps.cwd, '.env');
|
|
31
|
+
const firthKeys = new Set(Object.keys(bundle));
|
|
32
|
+
const existing = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
33
|
+
const kept = existing.split('\n').filter((line) => {
|
|
34
|
+
const key = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=/)?.[1];
|
|
35
|
+
return !(key && firthKeys.has(key)); // drop stale Firth keys; keep user vars/comments/blanks
|
|
36
|
+
});
|
|
37
|
+
while (kept.length && kept[kept.length - 1].trim() === '')
|
|
38
|
+
kept.pop(); // trim trailing blanks
|
|
39
|
+
const firthLines = Object.entries(bundle).map(([k, v]) => `${k}=${v}`);
|
|
40
|
+
const merged = [...kept, ...firthLines];
|
|
41
|
+
writeFileSync(path, merged.length ? merged.join('\n') + '\n' : '');
|
|
42
|
+
deps.print(`wrote ${firthLines.length} secrets to ${path}`); // values never printed
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { copyFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
export async function skillsPull(_argv, deps) {
|
|
5
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
// built run: cli/dist/commands -> cli/dist/skills (build copies the top-level skills/ in)
|
|
7
|
+
// source run: cli/src/commands -> <repo-root>/skills
|
|
8
|
+
const candidates = [
|
|
9
|
+
join(here, '..', 'skills', 'firth', 'SKILL.md'),
|
|
10
|
+
join(here, '..', '..', '..', 'skills', 'firth', 'SKILL.md'),
|
|
11
|
+
];
|
|
12
|
+
const src = candidates.find((p) => existsSync(p)) ?? candidates[candidates.length - 1];
|
|
13
|
+
const destDir = join(deps.cwd, '.claude', 'skills', 'firth');
|
|
14
|
+
mkdirSync(destDir, { recursive: true });
|
|
15
|
+
copyFileSync(src, join(destDir, 'SKILL.md'));
|
|
16
|
+
deps.print(`installed firth skill → ${join('.claude', 'skills', 'firth', 'SKILL.md')}`);
|
|
17
|
+
return 0;
|
|
18
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { readConfig, readProjectLink } from '../config.js';
|
|
2
|
+
export async function status(_argv, deps) {
|
|
3
|
+
const cfg = readConfig(deps.home, deps.env);
|
|
4
|
+
const link = readProjectLink(deps.cwd);
|
|
5
|
+
deps.print(`api: ${cfg.apiUrl}`);
|
|
6
|
+
deps.print(`auth: ${cfg.token ? 'signed in' : 'not signed in (run `firth login`)'}`);
|
|
7
|
+
deps.print(`project: ${link ? link.projectId : '(not linked)'}`);
|
|
8
|
+
const branchLabel = link?.branch ? `${link.branch.name} (${link.branch.id})` : '(default)';
|
|
9
|
+
deps.print(`branch: ${branchLabel}`);
|
|
10
|
+
return 0;
|
|
11
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function formatTeardown(t) {
|
|
2
|
+
let result = '';
|
|
3
|
+
if (t.destroyed && t.destroyed.length > 0) {
|
|
4
|
+
result += ` (destroyed: ${t.destroyed.join(', ')})`;
|
|
5
|
+
}
|
|
6
|
+
if (t.failed && t.failed.length > 0) {
|
|
7
|
+
const failedKinds = t.failed.map(f => f.kind).join(', ');
|
|
8
|
+
result += result ? `; FAILED: ${failedKinds}` : ` FAILED: ${failedKinds}`;
|
|
9
|
+
}
|
|
10
|
+
return result;
|
|
11
|
+
}
|