gitroots 0.5.0 → 0.6.0

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 CHANGED
@@ -1,6 +1,22 @@
1
1
  # gitroots CLI
2
2
 
3
- Code governance and agent sessions in the terminal. Built from the **gitroots CLI Terminal** design canvas. Each artboard maps to one command, using the same colors, symbols and layout.
3
+ gitroots in your terminal, as a session like Claude Code. Run `gitroots` and you get a welcome box with the gitroots mark and what needs you right now. Type `/` to pick a command from the list, use ↑↓ and Enter, and everything works on your real workspace.
4
+
5
+ ```
6
+ ╭──────────────────────────────────────────────────────────────╮
7
+ │ ██ ██ ██ ██ gitroots v0.6.0 │
8
+ │ ██ ██ ██ ██ code governance and agent sessions │
9
+ │ ██ ██ ██ ██ Shawki Aladdin · Acme ERP (owner) · gitroots.com│
10
+ │ ██ ██ ██ ██ ~/work/erp · acme/erp @ main │
11
+ ╰──────────────────────────────────────────────────────────────╯
12
+ ◐ 1 approval waiting · ◐ 1 agent needs you → /approvals
13
+
14
+ ╭──────────────────────────────────────────────────────────────╮
15
+ │ > /ap │
16
+ ╰──────────────────────────────────────────────────────────────╯
17
+ ❯ /approvals Review merge and access requests · approve or decline
18
+ /attach Open one session live · approve, message, stop, resume
19
+ ```
4
20
 
5
21
  ## Install
6
22
 
@@ -36,85 +52,69 @@ From a checkout: `npm link`.
36
52
 
37
53
  ## Releasing
38
54
 
39
- ```bash
40
- npm test
41
- node scripts/build.mjs # all platforms → dist/
42
- ```
43
- `dist/` is the whole release. Upload it as-is to the download location: `https://gitroots.com/cli` by default, or any static host/bucket. To use another host, set `GITROOTS_DOWNLOAD_BASE` at build time (it goes into the Homebrew formula) and in `src/runtime.js` (`DOWNLOAD_BASE`, used by `update` and `doctor`).
44
- ```
45
- dist/install.sh dist/install.ps1 dist/latest
46
- dist/0.5.0/gitroots-0.5.0-{darwin-arm64,darwin-x64,linux-x64,linux-arm64}.tar.gz
47
- dist/0.5.0/gitroots-0.5.0-win32-x64.zip
48
- dist/0.5.0/gitroots-0.5.0.tgz dist/0.5.0/SHA256SUMS dist/0.5.0/manifest.json
49
- dist/Formula/gitroots.rb
50
- ```
51
- - The binaries are Node 22 LTS single-executable apps, about 35–42 MB compressed. macOS builds are ad-hoc signed. For Gatekeeper-friendly distribution outside the curl installer, sign and notarize them with your Developer ID.
52
- - For npm, run `npm publish` from this folder (the name `gitroots` was free on npm when this was written).
53
- - For a new version, bump `version` in `package.json` and `VERSION` in `src/runtime.js`. The build refuses to run if they differ.
55
+ 1. Bump `version` in `package.json` and `VERSION` in `src/runtime.js`. The build refuses to run if they differ.
56
+ 2. Build and publish:
57
+ ```bash
58
+ npm test
59
+ npm run build # binaries for all platforms, installers, checksums → dist/
60
+ npm run deploy # uploads to the gitroots-cli Railway service; https://gitroots.com/cli serves it
61
+ npm publish # npm asks you to approve with your passkey in the browser
62
+ ```
63
+
64
+ How it's hosted:
65
+ - https://gitroots.com/cli is the install page, in the Django app on `railway-main` (`apps/core/cli_downloads.py`, `templates/landing/cli.html`).
66
+ - Every file under `/cli/` redirects to the `gitroots-cli` service in the GITROOTS Railway project (production). Its server is in `deploy/downloads/`. The redirect target can be changed with `GITROOTS_CLI_DOWNLOADS_URL`.
67
+ - Railway caps uploads at a few hundred MB, so `npm run deploy` uploads only the current release's binaries. Older versions stay installable from npm with `npm install -g gitroots@<version>`.
68
+
69
+ Notes:
70
+ - The binaries are Node 22 LTS single-executable apps, about 35–42 MB compressed each. macOS builds are ad-hoc signed. Sign and notarize them with a Developer ID if you distribute outside the curl installer.
71
+ - The Homebrew formula is written to `dist/Formula/gitroots.rb`. Publish it in a tap repo to enable `brew install`.
54
72
 
55
73
  ## Commands
56
74
 
57
- | Design screen | Command |
75
+ Inside the session, type `/` to see and pick them. Outside it, each one also runs on its own (`gitroots status`, `gitroots repos --json`) for scripts and CI.
76
+
77
+ | | |
58
78
  |---|---|
59
- | Welcome and boot | `gitroots` |
60
- | Status | `gitroots status` |
61
- | Repo · branch · clone | `gitroots repo list` · `branch list <repo> [--all]` · `branch rules <repo> <branch>` · `branch restore <repo> <branch>` · `clone <repo\|url> [--url <git-url>]` |
62
- | git push · gitroots checks | `gitroots hooks install` then a normal `git push` · `gitroots check` (dry run) |
63
- | PR list · view | `gitroots pr list [repo]` · `pr view <n>` · `pr diff <n>` |
64
- | PR create | `gitroots pr create [--base staging] [--head branch] [--title "…"] [--draft]` |
65
- | PR approve · merge | `gitroots pr approve <n> -m "…"` · `pr review <n> --request-changes -m "…"` · `pr merge <n> [--yes]` |
66
- | Audit · follow | `gitroots audit [--repo --since 2h --actor --action --decision --pr --csv]` · `audit <ev-id>` · `audit follow` |
67
- | Watch (live dashboard) | `gitroots watch [--machine m --agent codex --repo erp --state running]` (full screen) · `gitroots sessions` (plain list) |
68
- | Attach | `gitroots attach <session>` · `attach <session> --shell` (prefix `ctrl-g`: d detach · b backup · t transfer · f fork · s share · h history) |
69
- | Transfer | `gitroots transfer <session> --to <machine>` |
70
- | Backup · backups | `gitroots backup <session> --label "…"` · `backups [--repo --session --all]` · `backup policy` · `backup policy set --every 10m --store s3://bucket` |
71
- | Restore | `gitroots restore <session> --pick` · `restore <b-id>` · `--point <turn\|b-id>` · `--machine <m>` · `--fork` |
72
- | Tunnel · share | `gitroots tunnel <session> --port 8069` · `tunnel ls` · `tunnel close <t-id>\|--all` · `share <session> --read-only --expires 1h` |
73
- | (account) | `gitroots login [--server URL] [--org slug]` · `whoami` · `logout` |
74
- | (setup) | `gitroots doctor` · `update` · `uninstall [--purge]` · `completion bash\|zsh\|fish` |
75
-
76
- `gitroots help <command>` shows the details for one command.
77
-
78
- ## Terminal design rules (from the canvas)
79
-
80
- - Every state pairs a symbol with a word: `● running`, `◐ waiting`, `○ queued`, `✓ done`, `✕ blocked`.
81
- - Green is used only for success and the one next action. Amber means it needs you. Red means a real block or failure.
82
- - Every screen ends with the next command (`→ gitroots pr view 412`).
83
- - `--json` works on every command. Colors drop when output is piped or `NO_COLOR` is set.
84
- - Motion (spinners, progress bars, live rows) stops when output is piped, with `NO_COLOR`, in `CI`, or when `GITROOTS_REDUCED_MOTION=1`.
85
- - The welcome screen shows the real logo image (`assets/logo.png`) in Kitty, Ghostty, WezTerm and iTerm2. Other terminals, tmux and screen get a plain `gitroots` line. Set `GITROOTS_NO_IMAGES=1` to turn the image off.
86
-
87
- ## What is real and what is sample data
88
-
89
- - **Push checks (real):** `gitroots hooks install` writes a `pre-push` hook. An existing hook is kept and run first with `--force`. On every `git push` the hook checks your actual commits:
90
- - the branch rule
91
- - protected paths
92
- - a secrets scan of added lines (AWS, GitHub, GitLab, Slack, Stripe, Google, Anthropic and OpenAI keys, private keys, hard-coded passwords)
93
- - commit authors and signatures
94
- - migration files
95
-
96
- A blocked push exits non-zero, so nothing leaves the machine. If gitroots itself can't be found, the hook blocks the push rather than skipping the checks.
97
- - **Rules:** rules come from the gitroots org. For any other repo, put a `.gitroots.json` at its root:
79
+ | `/status` | What needs you, workspace posture, agents |
80
+ | `/approvals` | Merge and access requests: pick one, then approve, request changes or decline |
81
+ | `/repos` · `/repo [name]` | Repositories, their branch rules, environments and people |
82
+ | `/pushes [--blocked]` | Recent pushes and the ones gitroots blocked |
83
+ | `/audit [blocked\|merges\|access] [search]` | Who did what, when |
84
+ | `/activity` · `/members` | Team activity, people and roles |
85
+ | `/agents` | Agent sessions on your machines (as the gitroots app reports them) and on this computer (Claude Code, Codex): approve, deny, message, stop, or resume a local one |
86
+ | `/watch` · `/attach` | Full-screen live view of agent sessions (`y` approve, `n` deny, `m` message, `s` stop, `r` resume) |
87
+ | `/machines` | Machines the gitroots app manages |
88
+ | `/hooks [install]` · `/check` | Push checks for the repository you're in |
89
+ | `/login` · `/logout` · `/whoami` · `/workspace` | Account and workspace |
90
+ | `/doctor` · `/update` · `/help` · `/clear` · `/exit` | |
91
+
92
+ Keys: `/` opens the list (type to filter, ↑↓ choose, Tab completes, Enter runs) · ↑↓ history · Esc clears · ctrl-c cancels a running command, twice to exit.
93
+
94
+ ## Where the data comes from
95
+
96
+ All of it is real: nothing is sample data.
97
+
98
+ - **Sign-in:** `/login` shows a code and opens gitroots.com/cli/auth. Approve it there while you're signed in, and the CLI receives your API token, which it keeps in the macOS Keychain (or `~/.gitroots/credentials.json`, mode 600). It works for GitHub-login accounts too.
99
+ - **Workspace:** status, approvals, repositories, branch rules, pushes, audit, activity and members come from the gitroots API (`/api/mobile/gov/…`), the same endpoints the mobile app uses.
100
+ - **Agents:**
101
+ - Your machines: from what the gitroots app reports (`/api/cli/agents`). Approve, deny, message and stop are queued through gitroots, and the app on that machine runs them.
102
+ - This computer: read from Claude Code (`~/.claude/projects`) and Codex (`~/.codex/sessions`) session files. "Resume here" runs `claude --resume <id>` or `codex resume <id>` in the session's folder, and you come back to gitroots when it exits.
103
+ - **Push checks:** `gitroots hooks install` adds a `pre-push` hook. It uses your workspace's branch rules for the GitHub repo you push to: no direct push, leads only, required approvals. The rules are cached so the hook works offline. Repos outside gitroots can use a `.gitroots.json`:
98
104
  ```json
99
105
  { "protected": ["main", "release/*"], "paths": [{ "pattern": "infra/**", "needs": "an ops review" }] }
100
106
  ```
101
- - **Signed in (real):** `gitroots login` signs in to your gitroots server (gitroots_core) with its Basic auth. The password is stored in the macOS Keychain, or in `~/.gitroots/credentials.json` (mode 600) elsewhere. After that, these use the real API:
102
- - `whoami` and the `status` header
103
- - `repo list` and `branch list` (GitHub repos synced in the dashboard)
104
- - `audit`, `audit <ev-id>` and `audit follow` (which polls the API)
105
- - the welcome screen's sign-in and repository checks
106
- - **Sample data:** PRs, agent sessions, machines, backups and tunnels aren't in the gitroots API yet. They run on the design's sample org, and screens say so when you're signed in. Your changes to them are saved in `~/.gitroots/state.json`, so commands build on each other. `gitroots demo reset` starts over. When the API grows those endpoints, add them in `src/connected.js` the same way repos and audit work.
107
- - **Shells:** `attach --shell` opens a real shell for sessions on this machine. A remote shell would need an agentd on that machine, which doesn't exist yet.
107
+ Every push also gets a secrets scan, an author check and migration flags. A blocked push never leaves the machine.
108
108
 
109
109
  ## Environment
110
110
 
111
111
  | Variable | Effect |
112
112
  |---|---|
113
113
  | `GITROOTS_HOME` | where state, config and credentials live (default `~/.gitroots`) |
114
- | `GITROOTS_SERVER` | default server for `gitroots login` (default `https://gitroots.com`) |
114
+ | `GITROOTS_SERVER` | gitroots server (default `https://gitroots.com`) |
115
+ | `GITROOTS_TOKEN` | use this API token instead of the stored sign-in (CI) |
115
116
  | `GITROOTS_DOWNLOAD_BASE` | where `update` and the installers fetch releases |
116
117
  | `GITROOTS_NO_KEYCHAIN` | store the password in credentials.json even on macOS |
117
- | `GITROOTS_RELAY_DOMAIN` | domain used for tunnel and share links (default `<relay>.relay.gitroots.com`) |
118
118
  | `NO_COLOR` / `FORCE_COLOR` | turn colors off or force them on |
119
119
  | `GITROOTS_REDUCED_MOTION` | no spinners or animation |
120
120
  | `GITROOTS_NO_IMAGES` | no inline logo image |
@@ -124,15 +124,15 @@ dist/Formula/gitroots.rb
124
124
 
125
125
  ```
126
126
  bin/gitroots.js entry
127
- src/cli.js argument parsing, dispatch, help
128
- src/runtime.js version, npm vs standalone binary, install paths
129
- src/api.js gitroots API client, credentials
130
- src/connected.js real data when signed in
131
- src/installer.js download, verify, install (gitroots update)
132
- scripts/ build.mjs (release), install.sh, install.ps1
133
- src/ui/ colors (design tokens), steps/spinners/prompts, full-screen helpers, inline images
134
- src/commands/ one file per design screen
135
- src/git/checks.js the real push checks
136
- src/data/ sample org, persisted store, live activity streams
137
- test/ node --test (includes a real git push against a scratch repo)
127
+ src/cli.js one-shot commands and plumbing (hook, completion, uninstall)
128
+ src/session/ the session: repl.js (input, / menu, history), logo.js, registry.js (every command),
129
+ workspace.js, agents.js (incl. /watch and /attach), account.js, setup.js
130
+ src/api.js gitroots API client (token auth)
131
+ src/config.js settings, Keychain/credentials, caches
132
+ src/agents/local.js Claude Code and Codex sessions on this computer
133
+ src/rules.js branch rules for the push hook
134
+ src/commands/push.js the pre-push hook and checks · update.js · completion.js
135
+ src/git/checks.js secrets scan, authors, migrations
136
+ scripts/ build.mjs (release), deploy.sh, install.sh, install.ps1
137
+ test/ node --test (includes a mock gitroots API and real git pushes)
138
138
  ```
package/bin/gitroots.js CHANGED
@@ -1,6 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { main } from '../src/cli.js';
3
3
 
4
+ // `gitroots status | head` closes the pipe early: stop quietly instead of crashing.
5
+ for (const stream of [process.stdout, process.stderr]) {
6
+ stream.on('error', (err) => {
7
+ if (err && err.code === 'EPIPE') process.exit(0);
8
+ throw err;
9
+ });
10
+ }
11
+
4
12
  main(process.argv.slice(2)).then(
5
13
  (code) => { process.exitCode = code ?? 0; },
6
14
  (err) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitroots",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "gitroots CLI: code governance and agent sessions.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,6 +16,7 @@
16
16
  "test": "node --test test/",
17
17
  "build": "node scripts/build.mjs",
18
18
  "build:npm": "node scripts/build.mjs --no-binaries",
19
+ "deploy": "bash scripts/deploy.sh",
19
20
  "prepublishOnly": "node --test test/"
20
21
  },
21
22
  "keywords": [
@@ -0,0 +1,219 @@
1
+ // Agent sessions on this machine, read from the agents' own session files.
2
+ // Claude Code ~/.claude/projects/<folder>/<session-id>.jsonl resume: claude --resume <id>
3
+ // Codex ~/.codex/sessions/YYYY/MM/DD/rollout-…-<id>.jsonl resume: codex resume <id>
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+
8
+ const TAIL = 192 * 1024;
9
+ const LIVE_MS = 2 * 60 * 1000;
10
+ const RECENT_MS = 14 * 24 * 3600 * 1000;
11
+
12
+ function readSlice(file, start, length) {
13
+ const fd = fs.openSync(file, 'r');
14
+ try {
15
+ const buf = Buffer.alloc(length);
16
+ const n = fs.readSync(fd, buf, 0, length, start);
17
+ return buf.subarray(0, n).toString('utf8');
18
+ } finally { fs.closeSync(fd); }
19
+ }
20
+
21
+ // Walk the start of a file line by line, 128 KB at a time, until visit() returns true (max 2 MB).
22
+ function scanHead(file, size, visit) {
23
+ const CHUNK = 128 * 1024;
24
+ let pos = 0;
25
+ let carry = '';
26
+ while (pos < Math.min(size, 2 * 1024 * 1024)) {
27
+ const text = carry + readSlice(file, pos, CHUNK);
28
+ pos += CHUNK;
29
+ const lines = text.split('\n');
30
+ carry = pos < size ? lines.pop() : '';
31
+ for (const l of lines) {
32
+ if (!l.trim()) continue;
33
+ let d;
34
+ try { d = JSON.parse(l); } catch { continue; }
35
+ if (visit(d)) return;
36
+ }
37
+ }
38
+ }
39
+
40
+ function jsonLines(text, dropFirst) {
41
+ const lines = text.split('\n');
42
+ if (dropFirst) lines.shift();
43
+ const out = [];
44
+ for (const l of lines) { if (!l.trim()) continue; try { out.push(JSON.parse(l)); } catch { /* partial line */ } }
45
+ return out;
46
+ }
47
+
48
+ function textOf(content) {
49
+ if (typeof content === 'string') return content;
50
+ if (!Array.isArray(content)) return '';
51
+ return content.map((c) => (c && (c.type === 'text' || c.type === 'input_text' || c.type === 'output_text') ? c.text : '')).filter(Boolean).join(' ');
52
+ }
53
+
54
+ const clean = (s) => String(s || '').replace(/\s+/g, ' ').trim();
55
+ const isNoise = (t) => !t || t.startsWith('<') || t.startsWith('Caveat:') || /^\[Request interrupted/.test(t);
56
+
57
+ function state(mtime) {
58
+ const age = Date.now() - mtime;
59
+ return age < LIVE_MS ? 'live' : 'idle';
60
+ }
61
+
62
+ // ---------------------------------------------------------------- Claude Code
63
+
64
+ function claudeSession(file, st) {
65
+ const size = st.size;
66
+ const tail = jsonLines(readSlice(file, Math.max(0, size - TAIL), Math.min(size, TAIL)), size > TAIL);
67
+ let title = '';
68
+ let cwd = '';
69
+ let branch = '';
70
+ let id = path.basename(file, '.jsonl');
71
+ scanHead(file, size, (d) => {
72
+ if (d.cwd && !cwd) cwd = d.cwd;
73
+ if (d.sessionId) id = d.sessionId;
74
+ if (d.type === 'summary' && d.summary && !title) title = d.summary;
75
+ if (!title && d.type === 'user' && d.message && d.message.role === 'user') {
76
+ const t = clean(textOf(d.message.content));
77
+ if (!isNoise(t)) title = t;
78
+ }
79
+ return Boolean(title && cwd);
80
+ });
81
+ const log = [];
82
+ let lastUser = '';
83
+ for (const d of tail) {
84
+ if (d.cwd) cwd = d.cwd;
85
+ if (d.gitBranch) branch = d.gitBranch;
86
+ const m = d.message;
87
+ if (!m) continue;
88
+ if (d.type === 'user' && m.role === 'user') {
89
+ const t = clean(textOf(m.content));
90
+ if (!isNoise(t)) { lastUser = t; log.push({ who: 'you', text: t, at: d.timestamp }); }
91
+ } else if (d.type === 'assistant' && Array.isArray(m.content)) {
92
+ for (const c of m.content) {
93
+ if (c.type === 'text' && clean(c.text)) log.push({ who: 'agent', text: clean(c.text), at: d.timestamp });
94
+ if (c.type === 'tool_use') log.push({ who: 'tool', text: `${c.name}${c.input && (c.input.command || c.input.file_path || c.input.pattern) ? ` ${clean(c.input.command || c.input.file_path || c.input.pattern)}` : ''}`, at: d.timestamp });
95
+ }
96
+ }
97
+ }
98
+ if (!cwd) return null;
99
+ return {
100
+ source: 'local', agent: 'claude', agentName: 'Claude Code', id, file, cwd, branch: branch === 'HEAD' ? '' : branch,
101
+ title: title || lastUser || '(no prompt yet)', lastPrompt: lastUser, mtime: st.mtimeMs, status: state(st.mtimeMs),
102
+ log: log.slice(-60), resume: { cmd: 'claude', args: ['--resume', id] },
103
+ };
104
+ }
105
+
106
+ function findClaude(root) {
107
+ const out = [];
108
+ let dirs = [];
109
+ try { dirs = fs.readdirSync(root); } catch { return out; }
110
+ for (const d of dirs) {
111
+ let files = [];
112
+ try { files = fs.readdirSync(path.join(root, d)); } catch { continue; }
113
+ for (const f of files) {
114
+ if (!f.endsWith('.jsonl')) continue;
115
+ const p = path.join(root, d, f);
116
+ try { const st = fs.statSync(p); if (Date.now() - st.mtimeMs < RECENT_MS && st.size > 0) out.push([p, st]); } catch { /* gone */ }
117
+ }
118
+ }
119
+ return out;
120
+ }
121
+
122
+ // ---------------------------------------------------------------- Codex
123
+
124
+ function codexSession(file, st) {
125
+ const size = st.size;
126
+ const tail = jsonLines(readSlice(file, Math.max(0, size - TAIL), Math.min(size, TAIL)), size > TAIL);
127
+ let id = (/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/.exec(file) || [])[1] || path.basename(file);
128
+ let cwd = '';
129
+ let title = '';
130
+ let branch = '';
131
+ scanHead(file, size, (d) => {
132
+ const p = d.payload || {};
133
+ if (d.type === 'session_meta') { id = p.id || id; cwd = p.cwd || cwd; if (p.git && p.git.branch) branch = p.git.branch; }
134
+ if (!title && d.type === 'response_item' && p.type === 'message' && p.role === 'user') {
135
+ const t = clean(textOf(p.content));
136
+ if (!isNoise(t)) title = t;
137
+ }
138
+ return Boolean(title && cwd);
139
+ });
140
+ const log = [];
141
+ let lastUser = '';
142
+ for (const d of tail) {
143
+ const p = d.payload || {};
144
+ if (d.type === 'turn_context' && p.cwd) cwd = p.cwd;
145
+ if (d.type !== 'response_item') continue;
146
+ if (p.type === 'message' && (p.role === 'user' || p.role === 'assistant')) {
147
+ const t = clean(textOf(p.content));
148
+ if (isNoise(t)) continue;
149
+ if (p.role === 'user') lastUser = t;
150
+ log.push({ who: p.role === 'user' ? 'you' : 'agent', text: t, at: d.timestamp });
151
+ } else if (p.type === 'function_call' || p.type === 'local_shell_call' || p.type === 'custom_tool_call') {
152
+ let args = '';
153
+ try { const a = JSON.parse(p.arguments || '{}'); args = Array.isArray(a.command) ? a.command.join(' ') : a.command || a.cmd || ''; } catch { args = ''; }
154
+ log.push({ who: 'tool', text: `${p.name || 'shell'}${args ? ` ${clean(args)}` : ''}`, at: d.timestamp });
155
+ }
156
+ }
157
+ if (!cwd) return null;
158
+ return {
159
+ source: 'local', agent: 'codex', agentName: 'Codex', id, file, cwd, branch,
160
+ title: title || lastUser || '(no prompt yet)', lastPrompt: lastUser, mtime: st.mtimeMs, status: state(st.mtimeMs),
161
+ log: log.slice(-60), resume: { cmd: 'codex', args: ['resume', id] },
162
+ };
163
+ }
164
+
165
+ function findCodex(root) {
166
+ const out = [];
167
+ const walk = (dir, depth) => {
168
+ let entries = [];
169
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
170
+ for (const e of entries) {
171
+ const p = path.join(dir, e.name);
172
+ if (e.isDirectory() && depth < 3) walk(p, depth + 1);
173
+ else if (e.isFile() && e.name.endsWith('.jsonl')) {
174
+ try { const st = fs.statSync(p); if (Date.now() - st.mtimeMs < RECENT_MS && st.size > 0) out.push([p, st]); } catch { /* gone */ }
175
+ }
176
+ }
177
+ };
178
+ walk(root, 0);
179
+ return out;
180
+ }
181
+
182
+ // ---------------------------------------------------------------- public
183
+
184
+ export function localSessions({ limit = 40, homeDir = os.homedir() } = {}) {
185
+ const claudeRoot = process.env.CLAUDE_CONFIG_DIR ? path.join(process.env.CLAUDE_CONFIG_DIR, 'projects') : path.join(homeDir, '.claude', 'projects');
186
+ const codexRoot = path.join(process.env.CODEX_HOME || path.join(homeDir, '.codex'), 'sessions');
187
+ const files = [
188
+ ...findClaude(claudeRoot).map(([p, st]) => ['claude', p, st]),
189
+ ...findCodex(codexRoot).map(([p, st]) => ['codex', p, st]),
190
+ ].sort((a, b) => b[2].mtimeMs - a[2].mtimeMs).slice(0, limit);
191
+ const out = [];
192
+ for (const [kind, p, st] of files) {
193
+ try {
194
+ const s = kind === 'claude' ? claudeSession(p, st) : codexSession(p, st);
195
+ if (s) out.push(s);
196
+ } catch { /* unreadable */ }
197
+ }
198
+ return out;
199
+ }
200
+
201
+ export function refreshLocal(s) {
202
+ try {
203
+ const st = fs.statSync(s.file);
204
+ return s.agent === 'claude' ? claudeSession(s.file, st) : codexSession(s.file, st);
205
+ } catch { return s; }
206
+ }
207
+
208
+ export function shortPath(p) {
209
+ const h = os.homedir();
210
+ return p && p.startsWith(h) ? `~${p.slice(h.length)}` : p || '';
211
+ }
212
+
213
+ export function ago(ms) {
214
+ const s = Math.max(0, Math.round((Date.now() - ms) / 1000));
215
+ if (s < 60) return `${s}s ago`;
216
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
217
+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
218
+ return `${Math.floor(s / 86400)}d ago`;
219
+ }
package/src/api.js CHANGED
@@ -1,121 +1,84 @@
1
- // gitroots API client (gitroots_core, Django REST framework).
2
- // Sign-in uses the API's Basic auth; the password is kept in the macOS Keychain,
3
- // or in $GITROOTS_HOME/credentials.json (mode 600) elsewhere.
4
- import fs from 'node:fs';
5
- import path from 'node:path';
6
- import { spawnSync } from 'node:child_process';
7
- import { home } from './data/store.js';
8
-
9
- export const DEFAULT_SERVER = process.env.GITROOTS_SERVER || 'https://gitroots.com';
10
-
11
- const configFile = () => path.join(home(), 'config.json');
12
- const credFile = () => path.join(home(), 'credentials.json');
13
-
14
- export function readConfig() {
15
- try { return JSON.parse(fs.readFileSync(configFile(), 'utf8')); } catch { return {}; }
16
- }
17
-
18
- export function writeConfig(cfg) {
19
- fs.mkdirSync(home(), { recursive: true });
20
- fs.writeFileSync(configFile(), JSON.stringify(cfg, null, 2), { mode: 0o600 });
21
- }
22
-
23
- const service = (server) => `gitroots:${server}`;
24
- const useKeychain = () => process.platform === 'darwin' && !process.env.GITROOTS_NO_KEYCHAIN;
25
-
26
- export function savePassword(server, username, password) {
27
- if (useKeychain()) {
28
- const r = spawnSync('security', ['add-generic-password', '-U', '-a', username, '-s', service(server), '-w', password], { stdio: 'ignore' });
29
- if (r.status === 0) return 'keychain';
30
- }
31
- fs.mkdirSync(home(), { recursive: true });
32
- let all = {};
33
- try { all = JSON.parse(fs.readFileSync(credFile(), 'utf8')); } catch { /* none */ }
34
- all[`${username}@${server}`] = password;
35
- fs.writeFileSync(credFile(), JSON.stringify(all, null, 2), { mode: 0o600 });
36
- fs.chmodSync(credFile(), 0o600);
37
- return 'file';
38
- }
39
-
40
- export function loadPassword(server, username) {
41
- if (process.env.GITROOTS_PASSWORD) return process.env.GITROOTS_PASSWORD;
42
- if (useKeychain()) {
43
- const r = spawnSync('security', ['find-generic-password', '-a', username, '-s', service(server), '-w'], { encoding: 'utf8' });
44
- if (r.status === 0) return r.stdout.replace(/\n$/, '');
45
- }
46
- try { return JSON.parse(fs.readFileSync(credFile(), 'utf8'))[`${username}@${server}`] || null; } catch { return null; }
47
- }
48
-
49
- export function forgetPassword(server, username) {
50
- if (process.platform === 'darwin') spawnSync('security', ['delete-generic-password', '-a', username, '-s', service(server)], { stdio: 'ignore' });
51
- try {
52
- const all = JSON.parse(fs.readFileSync(credFile(), 'utf8'));
53
- delete all[`${username}@${server}`];
54
- fs.writeFileSync(credFile(), JSON.stringify(all, null, 2), { mode: 0o600 });
55
- } catch { /* none */ }
56
- }
1
+ // gitroots API client. Uses the same token as the gitroots mobile app.
2
+ import { DEFAULT_SERVER, readConfig, loadToken } from './config.js';
57
3
 
58
4
  export class ApiError extends Error {
59
- constructor(message, status) { super(message); this.status = status; }
5
+ constructor(message, status, body) { super(message); this.status = status; this.body = body; }
60
6
  }
61
7
 
62
8
  export class Api {
63
- constructor({ server, username, password }) {
9
+ constructor({ server = DEFAULT_SERVER, token = null, orgId = null } = {}) {
64
10
  this.server = server.replace(/\/+$/, '');
65
- this.username = username;
66
- this.auth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
11
+ this.token = token;
12
+ this.orgId = orgId;
67
13
  }
68
14
 
69
15
  static fromConfig() {
70
16
  const cfg = readConfig();
71
- if (!cfg.server || !cfg.username) return null;
72
- const password = loadPassword(cfg.server, cfg.username);
73
- if (!password) return null;
74
- return new Api({ server: cfg.server, username: cfg.username, password });
17
+ const server = process.env.GITROOTS_SERVER || cfg.server || DEFAULT_SERVER;
18
+ const token = loadToken(server, cfg.username);
19
+ return new Api({ server, token, orgId: cfg.org?.id ?? null });
75
20
  }
76
21
 
77
- async get(p, query = {}) {
22
+ get signedIn() { return Boolean(this.token); }
23
+ get host() { return this.server.replace(/^https?:\/\//, ''); }
24
+
25
+ async request(method, p, { query = {}, body, signal, auth = true, timeout = 20000, okStatuses = [] } = {}) {
78
26
  const url = new URL(`${this.server}/api/${p.replace(/^\/+/, '')}`);
79
- for (const [k, v] of Object.entries(query)) if (v !== undefined && v !== null) url.searchParams.set(k, v);
27
+ for (const [k, v] of Object.entries(query)) if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, v);
28
+ const headers = { Accept: 'application/json', 'User-Agent': 'gitroots-cli' };
29
+ if (auth) {
30
+ if (!this.token) throw new ApiError('not signed in · /login', 401);
31
+ headers.Authorization = `Token ${this.token}`;
32
+ }
33
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
34
+ const signals = [AbortSignal.timeout(timeout)];
35
+ if (signal) signals.push(signal);
80
36
  let res;
81
37
  try {
82
- res = await fetch(url, { headers: { Authorization: this.auth, Accept: 'application/json', 'User-Agent': 'gitroots-cli' }, signal: AbortSignal.timeout(15000) });
38
+ res = await fetch(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.any ? AbortSignal.any(signals) : signals[0] });
83
39
  } catch (e) {
84
- throw new ApiError(`can't reach ${this.server} (${e.cause?.code || e.name})`, 0);
85
- }
86
- if (res.status === 401 || res.status === 403) {
87
- let detail = '';
88
- try { detail = (await res.json()).detail || ''; } catch { /* not json */ }
89
- throw new ApiError(res.status === 401 ? 'not signed in, or the password changed · gitroots login' : `not allowed${detail ? `: ${detail}` : ''}`, res.status);
90
- }
91
- if (!res.ok) {
92
- let detail = '';
93
- try { detail = (await res.json()).detail || ''; } catch { /* not json */ }
94
- throw new ApiError(`${res.status} from ${url.pathname}${detail ? `: ${detail}` : ''}`, res.status);
40
+ if (signal?.aborted) throw new ApiError('cancelled', 0);
41
+ throw new ApiError(`can't reach ${this.host} (${e.cause?.code || e.name})`, 0);
95
42
  }
43
+ let data = null;
96
44
  const ct = res.headers.get('content-type') || '';
97
- if (!ct.includes('json')) throw new ApiError(`${url.pathname} did not return JSON · is ${this.server} a gitroots server?`, res.status);
98
- return res.json();
45
+ if (ct.includes('json')) { try { data = await res.json(); } catch { data = null; } }
46
+ if (res.ok || okStatuses.includes(res.status)) return { status: res.status, data };
47
+ const detail = (data && (data.detail || data.error)) || '';
48
+ if (res.status === 401) throw new ApiError('your sign-in expired or was revoked · /login', 401, data);
49
+ if (res.status === 403) throw new ApiError(detail || 'not allowed for your role in this workspace', 403, data);
50
+ if (res.status === 404) throw new ApiError(detail || `not found (${url.pathname})`, 404, data);
51
+ if (!ct.includes('json')) throw new ApiError(`${this.host} answered ${res.status} · is it a gitroots server?`, res.status);
52
+ throw new ApiError(detail || `${res.status} from ${url.pathname}`, res.status, data);
99
53
  }
100
54
 
101
- // DRF list: a plain array, or paginated { results, next }.
102
- async list(p, query = {}) {
103
- let data = await this.get(p, query);
104
- if (Array.isArray(data)) return data;
105
- const out = [...(data.results || [])];
106
- let pages = 0;
107
- while (data.next && pages++ < 50) {
108
- const next = new URL(data.next);
109
- data = await this.get(next.pathname.replace(/^\/api\//, ''), Object.fromEntries(next.searchParams));
110
- out.push(...(data.results || []));
111
- }
112
- return out;
55
+ async get(p, query, opts = {}) { return (await this.request('GET', p, { ...opts, query: { org_id: this.orgId, ...query } })).data; }
56
+ async post(p, body, opts = {}) { return (await this.request('POST', p, { ...opts, body, query: { org_id: this.orgId, ...(opts.query || {}) } })).data; }
57
+
58
+ // ---- sign-in (no token yet)
59
+ authStart(clientName) { return this.request('POST', 'cli/auth/start', { auth: false, body: { client_name: clientName } }).then((r) => r.data); }
60
+ authPoll(deviceCode, signal) {
61
+ return this.request('POST', 'cli/auth/poll', { auth: false, body: { device_code: deviceCode }, signal, okStatuses: [202, 403, 404, 410] });
113
62
  }
114
63
 
115
- me() { return this.list('users/').then((us) => us.find((u) => u.username === this.username || u.email === this.username) || null); }
116
- orgs() { return this.list('orgs/'); }
117
- memberships() { return this.list('memberships/'); }
118
- auditEvents() { return this.list('audit/events/'); }
119
- repos(orgId) { return this.get('integrations/github/org-repos', { org_id: orgId, limit: 200 }).then((d) => d.repos || []); }
120
- branches(orgId, fullName) { return this.get('integrations/github/org-branches', { org_id: orgId, full_name: fullName }).then((d) => d.branches || []); }
64
+ // ---- account & workspace
65
+ me(opts) { return this.get('mobile/me', {}, opts); }
66
+
67
+ // ---- governance
68
+ home(opts) { return this.get('mobile/gov/home', {}, opts); }
69
+ repos(opts) { return this.get('mobile/gov/repos', {}, opts); }
70
+ repo(fullName, opts) { return this.get('mobile/gov/repos/detail', { repo: fullName }, opts); }
71
+ approvals(state = 'waiting', opts) { return this.get('mobile/gov/approvals', { state }, opts); }
72
+ approveSync(id, note, opts) { return this.request('POST', `provision/sync-requests/${id}/approve/`, { ...opts, body: { note: note || '' } }).then((r) => r.data); }
73
+ declineSync(id, mode, reason, opts) { return this.post(`mobile/gov/sync-requests/${id}/decline`, { mode, reason }, opts); }
74
+ decideAccess(id, decision, reason, opts) { return this.post(`mobile/gov/access-requests/${id}/decide`, { decision, reason }, opts); }
75
+ pushEvents(blocked, opts) { return this.get('mobile/gov/push-events', { blocked: blocked ? 1 : undefined }, opts); }
76
+ audit(category, q, opts) { return this.get('mobile/gov/audit', { category, q }, opts); }
77
+ teamActivity(opts) { return this.get('mobile/gov/team-activity', {}, opts); }
78
+ members(opts) { return this.get('mobile/gov/members', {}, opts); }
79
+
80
+ // ---- agents
81
+ agents(opts) { return this.get('cli/agents', {}, opts); }
82
+ agentSession(id, opts) { return this.get(`cli/agents/sessions/${id}`, {}, opts); }
83
+ agentAction(id, action, text, opts) { return this.post(`cli/agents/sessions/${id}/action`, { action, text }, opts); }
121
84
  }