gcf-board 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Oleg (VirtualMaestro)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,75 @@
1
- # Temporary Holding Version
1
+ # gcf-board
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A local web board for game projects on [GCF](../gcf-aif-ext): the items by status, the phase, WIP, the inbox, the playtest session, what was accepted and what was killed — and buttons that run the same `ai-factory gcf` commands an operator runs in a console. The board is a view: it stores nothing, and without it the work goes on in the console unchanged (Methodology §11).
4
+
5
+ ## Requirements
6
+
7
+ - Node.js ≥ 22.18
8
+ - [`ai-factory`](https://www.npmjs.com/package/ai-factory) installed globally, and in each project [`gcf-aif-ext`](../gcf-aif-ext) ≥ 0.6 (`gcf board --json` with `schema: 1`)
9
+ - for runs: [`gcf-harness-aif-ext`](../gcf-harness-aif-ext) ≥ 0.7 and Claude Code (`claude -p`)
10
+
11
+ ## Run
12
+
13
+ ```bash
14
+ npm i -g gcf-board
15
+ gcf-board # http://127.0.0.1:4317
16
+ gcf-board --port 4400
17
+ ```
18
+
19
+ It prints `http://127.0.0.1:4317/?t=<token>`: open that URL once. It sets a cookie, and the plain address works after that. Add a project by its folder (one that has `gcf/`) under **Projects**.
20
+
21
+ ## What it does
22
+
23
+ - **Board**: columns ready, running, blocked, awaiting-verdict, awaiting-decision, unplayed, parked. A card shows the type, the gate, the plan progress (`2/5`), the severity, `verified`, the model.
24
+ - **An item**: its file, its plan and its run log as it grows, and the moves the core offers for it (`items[].moves`, Methodology §4.1): verdict, decide, block, unblock, drop, close, submit. The board keeps only each move's form; the core still checks the rest itself, and the board shows its answer.
25
+ - **Drag**:
26
+ - a ready card into **running** starts its run;
27
+ - a blocked or parked card into **ready** unblocks it;
28
+ - a card onto **graveyard** drops it.
29
+
30
+ Every other move is a button.
31
+ - **Run**: **run** on an item, or select ready cards and **Run selected**. It starts `ai-factory gcf-harness run <ids> --json`: one worktree and one agent session per item, then the integrator. A run asks first: it spends the subscription.
32
+ - **New item**: `gcf item new … --commit`. The item's `model` and `effort` go to its run.
33
+ - **Inbox**: add, erase, or turn an entry into an item. Open questions are listed there too.
34
+ - **Playtest**: `gcf playtest session` with the checklist grouped by subsystem; keep, tune or kill each item from the list. **Build** runs `dod.build`. A note goes to the inbox as `--source playtest`.
35
+ - **Ended**: history (accepted) and graveyard (killed, dropped, won't-fix).
36
+
37
+ The page updates by itself: the server watches each project's `gcf/` and `.gcf-session/runs/`.
38
+
39
+ ## Security
40
+
41
+ The board starts agents that write code and commit: whoever can talk to it runs code on this machine.
42
+
43
+ - It listens on `127.0.0.1` only.
44
+ - Every request needs this machine's token, kept in `~/.gcf/board-token`; delete that file to make a new one.
45
+ - A request must also name host `127.0.0.1` or `localhost` with the board's port.
46
+ - A change must come from the board's own page (`Origin`). This blocks another site in the same browser.
47
+ - Commands run without a shell: the operator's text reaches `ai-factory` as one argument, never as a command line.
48
+ - The API shows only item files, plans and run logs inside a project.
49
+
50
+ Do not put the port on a network. Remote and phone access is planned for v2, behind its own authentication.
51
+
52
+ ## Files
53
+
54
+ - `~/.gcf/projects.json`: the projects on the board, `{projects: [{name, path}]}`.
55
+ - `~/.gcf/board-token`: the token.
56
+ - `GCF_BOARD_HOME` moves both; `GCF_BOARD_AIF` points at another `ai-factory` entry script.
57
+
58
+ ## Roadmap (v2)
59
+
60
+ - **Chat with Claude in a project**:
61
+ - `claude -p --output-format stream-json`;
62
+ - a choice of model and effort;
63
+ - permission prompts answered from the board through a `PermissionRequest` hook;
64
+ - resume a session.
65
+ - **Phone and remote access**: Tailscale or Claude Remote Control, with their own authentication; push notifications when an item blocks or waits for a verdict.
66
+
67
+ ## Develop
68
+
69
+ ```bash
70
+ npm install
71
+ npm test # server and web tests on Node's type stripping
72
+ npm run typecheck
73
+ npm run build # vite → dist/web, tsc → dist/server
74
+ node dist/server/main.js --port 4318
75
+ ```
@@ -0,0 +1,220 @@
1
+ // The board's HTTP server: the page, and an API that is a thin layer over `ai-factory gcf`.
2
+ // Reads: projects, the board, a shown file, a run log, the playtest session. Writes: a project
3
+ // on the board, a move, a run. Every write is a command of the framework.
4
+ import { createServer } from 'node:http';
5
+ import { existsSync } from 'node:fs';
6
+ import { readFile } from 'node:fs/promises';
7
+ import path from 'node:path';
8
+ import { BOARD_SCHEMA } from '../shared/board.js';
9
+ import { admit, COOKIE, hostAllowed, tokenInUrl } from './auth.js';
10
+ import { briefError, runCli } from './cli.js';
11
+ import { broadcast, subscribe, watchProject } from './events.js';
12
+ import { followLog, readShown } from './files.js';
13
+ import { HttpError } from './http-error.js';
14
+ import { moveArgs, runIds } from './moves.js';
15
+ import { addProject, listProjects, projectOf, removeProject } from './registry.js';
16
+ import { activeRuns, startRun } from './runs.js';
17
+ const send = (res, status, body) => {
18
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
19
+ res.end(JSON.stringify(body));
20
+ };
21
+ async function bodyOf(req) {
22
+ // Bytes first, text once: a letter cut between two chunks stays whole.
23
+ const chunks = [];
24
+ let size = 0;
25
+ for await (const chunk of req) {
26
+ size += chunk.length;
27
+ if (size > 1_000_000)
28
+ throw new HttpError(413, 'request too large');
29
+ chunks.push(chunk);
30
+ }
31
+ const raw = Buffer.concat(chunks).toString('utf8');
32
+ try {
33
+ const v = raw ? JSON.parse(raw) : {};
34
+ if (typeof v !== 'object' || v === null || Array.isArray(v))
35
+ throw new Error('not an object');
36
+ return v;
37
+ }
38
+ catch {
39
+ throw new HttpError(400, 'the body must be a JSON object');
40
+ }
41
+ }
42
+ /** Parse the JSON a command printed, or say what it printed instead. */
43
+ function jsonOf(r, what) {
44
+ try {
45
+ return JSON.parse(r.out);
46
+ }
47
+ catch {
48
+ throw new HttpError(502, `ai-factory ${what} gave no report: ${(r.err || r.out).trim()}`);
49
+ }
50
+ }
51
+ const TYPES = {
52
+ '.html': 'text/html; charset=utf-8',
53
+ '.js': 'text/javascript; charset=utf-8',
54
+ '.css': 'text/css; charset=utf-8',
55
+ '.svg': 'image/svg+xml',
56
+ '.json': 'application/json',
57
+ '.ico': 'image/x-icon',
58
+ '.woff2': 'font/woff2',
59
+ };
60
+ /** A file of the built page; any other path is the page itself (the page routes). */
61
+ async function sendPage(res, web, pathname) {
62
+ let decoded = '';
63
+ try {
64
+ decoded = decodeURIComponent(pathname);
65
+ }
66
+ catch {
67
+ throw new HttpError(400, 'a path that is not valid percent-encoding');
68
+ }
69
+ const rel = path.normalize(decoded).replace(/^([/\\])+/, '');
70
+ let file = path.join(web, rel);
71
+ if (!file.startsWith(web) || !existsSync(file) || rel === '')
72
+ file = path.join(web, 'index.html');
73
+ if (!existsSync(file))
74
+ throw new HttpError(500, `the page is not built: ${web} has no index.html`);
75
+ res.writeHead(200, { 'content-type': TYPES[path.extname(file)] ?? 'application/octet-stream' });
76
+ res.end(await readFile(file));
77
+ }
78
+ export function createApp(o) {
79
+ const watchers = new Map();
80
+ const watchAll = async () => {
81
+ for (const p of await listProjects())
82
+ if (!watchers.has(p.name))
83
+ watchers.set(p.name, watchProject(p));
84
+ };
85
+ const cli = (p, args, timeout) => runCli(o.entry, p.path, args, timeout);
86
+ const routes = [
87
+ ['GET', /^\/api\/events$/, async (_req, res) => subscribe(res)],
88
+ ['GET', /^\/api\/projects$/, async (_req, res) => send(res, 200, await listProjects())],
89
+ [
90
+ 'POST',
91
+ /^\/api\/projects$/,
92
+ async (req, res) => {
93
+ const b = await bodyOf(req);
94
+ if (typeof b.path !== 'string')
95
+ throw new HttpError(400, 'path is required');
96
+ const name = typeof b.name === 'string' && b.name ? b.name : undefined;
97
+ const p = await addProject(b.path, name);
98
+ await watchAll();
99
+ send(res, 201, p);
100
+ },
101
+ ],
102
+ [
103
+ 'DELETE',
104
+ /^\/api\/projects\/([^/]+)$/,
105
+ async (_req, res, m) => {
106
+ await removeProject(m[1]);
107
+ watchers.get(m[1])?.();
108
+ watchers.delete(m[1]);
109
+ send(res, 200, { ok: true });
110
+ },
111
+ ],
112
+ [
113
+ 'GET',
114
+ /^\/api\/p\/([^/]+)\/board$/,
115
+ async (_req, res, m) => {
116
+ const board = jsonOf(await cli(await projectOf(m[1]), ['gcf', 'board', '--json']), 'gcf board');
117
+ if (board.schema !== BOARD_SCHEMA)
118
+ throw new HttpError(502, `gcf board gives schema ${board.schema ?? 'none'}, this board reads ${BOARD_SCHEMA}: ` +
119
+ 'update gcf-aif-ext and gcf-board together');
120
+ send(res, 200, board);
121
+ },
122
+ ],
123
+ [
124
+ 'GET',
125
+ /^\/api\/p\/([^/]+)\/file$/,
126
+ async (_req, res, m, url) => {
127
+ const p = await projectOf(m[1]);
128
+ send(res, 200, { text: await readShown(p.path, url.searchParams.get('path') ?? '') });
129
+ },
130
+ ],
131
+ [
132
+ 'GET',
133
+ /^\/api\/p\/([^/]+)\/log\/([a-z0-9][a-z0-9-]*)$/,
134
+ async (_req, res, m) => {
135
+ followLog((await projectOf(m[1])).path, m[2], res);
136
+ },
137
+ ],
138
+ [
139
+ 'GET',
140
+ /^\/api\/p\/([^/]+)\/playtest$/,
141
+ async (_req, res, m) => {
142
+ const r = await cli(await projectOf(m[1]), ['gcf', 'playtest', 'session', '--json']);
143
+ send(res, 200, jsonOf(r, 'gcf playtest session'));
144
+ },
145
+ ],
146
+ [
147
+ // A build runs the project's own command: a write, so a POST with its Origin checked.
148
+ 'POST',
149
+ /^\/api\/p\/([^/]+)\/playtest\/build$/,
150
+ async (_req, res, m) => {
151
+ const args = ['gcf', 'playtest', 'session', '--json', '--build'];
152
+ // A build of a real game takes minutes.
153
+ const r = await cli(await projectOf(m[1]), args, 30 * 60_000);
154
+ send(res, 200, jsonOf(r, 'gcf playtest session --build'));
155
+ },
156
+ ],
157
+ [
158
+ 'POST',
159
+ /^\/api\/p\/([^/]+)\/move$/,
160
+ async (req, res, m) => {
161
+ const p = await projectOf(m[1]);
162
+ const r = await cli(p, moveArgs(await bodyOf(req)));
163
+ broadcast({ type: 'board', project: p.name });
164
+ send(res, 200, { ok: r.ok, out: r.out.trim(), err: briefError(r.err) });
165
+ },
166
+ ],
167
+ [
168
+ 'GET',
169
+ /^\/api\/p\/([^/]+)\/runs$/,
170
+ async (_req, res, m) => send(res, 200, activeRuns((await projectOf(m[1])).name)),
171
+ ],
172
+ [
173
+ 'POST',
174
+ /^\/api\/p\/([^/]+)\/run$/,
175
+ async (req, res, m) => {
176
+ const p = await projectOf(m[1]);
177
+ send(res, 202, startRun(o.entry, p, runIds(await bodyOf(req))));
178
+ },
179
+ ],
180
+ ];
181
+ const handle = async (req, res) => {
182
+ const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
183
+ const visit = req.method === 'GET' && !url.pathname.startsWith('/api/');
184
+ if (visit && hostAllowed(req, o.port) && tokenInUrl(url, o.token)) {
185
+ res.writeHead(302, {
186
+ 'set-cookie': `${COOKIE}=${o.token}; HttpOnly; SameSite=Strict; Path=/`,
187
+ // Always the root: a path like `//elsewhere` would send the browser off the board.
188
+ location: '/',
189
+ });
190
+ res.end();
191
+ return;
192
+ }
193
+ const gate = admit(req, o.port, o.token);
194
+ if (!gate.ok) {
195
+ send(res, gate.status, { error: gate.message });
196
+ return;
197
+ }
198
+ for (const [method, re, route] of routes) {
199
+ const m = url.pathname.match(re);
200
+ if (m && req.method === method)
201
+ return route(req, res, m, url);
202
+ }
203
+ if (url.pathname.startsWith('/api/'))
204
+ throw new HttpError(404, `no ${req.method} ${url.pathname}`);
205
+ await sendPage(res, o.web, url.pathname);
206
+ };
207
+ const server = createServer((req, res) => {
208
+ handle(req, res).catch((e) => {
209
+ if (res.headersSent)
210
+ return res.end();
211
+ send(res, e instanceof HttpError ? e.status : 500, { error: e.message });
212
+ });
213
+ });
214
+ server.on('listening', () => void watchAll());
215
+ server.on('close', () => {
216
+ for (const stop of watchers.values())
217
+ stop();
218
+ });
219
+ return server;
220
+ }
@@ -0,0 +1,7 @@
1
+ import { rename, writeFile } from 'node:fs/promises';
2
+ /** Write a file whole or not at all: a crash mid-write leaves the old one, never a torn one. */
3
+ export async function writeAtomic(file, text, mode) {
4
+ const tmp = `${file}.${process.pid}.tmp`;
5
+ await writeFile(tmp, text, { mode });
6
+ await rename(tmp, file);
7
+ }
@@ -0,0 +1,52 @@
1
+ // The board starts agents that commit and push: whoever talks to it runs code on this machine.
2
+ // It listens on 127.0.0.1 only, and every request needs the token printed at start (kept in a
3
+ // cookie after the first visit). Host and Origin are checked too: a page in another tab can
4
+ // reach 127.0.0.1 (DNS rebinding, a cross-site POST), but it cannot fake those headers.
5
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
6
+ import { existsSync } from 'node:fs';
7
+ import { mkdir, readFile } from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import { writeAtomic } from './atomic.js';
10
+ import { homeDir } from './registry.js';
11
+ export const COOKIE = 'gcf_board';
12
+ // An empty or short token would let everyone in: such a file is replaced.
13
+ const STRONG = /^[0-9a-f]{48}$/;
14
+ /** The token of this machine's board: made once, kept in `~/.gcf/board-token`. */
15
+ export async function boardToken() {
16
+ const file = path.join(homeDir(), 'board-token');
17
+ const kept = existsSync(file) ? (await readFile(file, 'utf8')).trim() : '';
18
+ if (STRONG.test(kept))
19
+ return kept;
20
+ const token = randomBytes(24).toString('hex');
21
+ await mkdir(homeDir(), { recursive: true });
22
+ await writeAtomic(file, `${token}\n`, 0o600);
23
+ return token;
24
+ }
25
+ const same = (a, b) => b.length > 0 && a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
26
+ function cookieOf(req) {
27
+ for (const part of (req.headers.cookie ?? '').split(';')) {
28
+ const [k, ...v] = part.trim().split('=');
29
+ if (k === COOKIE)
30
+ return v.join('=');
31
+ }
32
+ return '';
33
+ }
34
+ const hostsOf = (port) => [`127.0.0.1:${port}`, `localhost:${port}`];
35
+ export const hostAllowed = (req, port) => hostsOf(port).includes(req.headers.host ?? '');
36
+ /** Whether a request may go on: its Host, its Origin on a write, its token. */
37
+ export function admit(req, port, token) {
38
+ const hosts = hostsOf(port);
39
+ if (!hostAllowed(req, port))
40
+ return { ok: false, status: 403, message: 'the board answers 127.0.0.1 and localhost only' };
41
+ const write = !['GET', 'HEAD'].includes(req.method ?? 'GET');
42
+ const origin = req.headers.origin;
43
+ if (write && !(origin && hosts.some((h) => origin === `http://${h}`)))
44
+ return { ok: false, status: 403, message: 'a change comes from the board page only' };
45
+ if (!same(cookieOf(req), token))
46
+ return { ok: false, status: 401, message: 'open the URL gcf-board printed at start' };
47
+ return { ok: true };
48
+ }
49
+ /** `?t=<token>` on a page: the visit that sets the cookie. */
50
+ export function tokenInUrl(url, token) {
51
+ return same(url.searchParams.get('t') ?? '', token);
52
+ }
@@ -0,0 +1,61 @@
1
+ // The board runs the same `ai-factory` the operator runs, never a shell: the arguments carry the
2
+ // operator's own words (a reason, a note), and node gets them one by one.
3
+ import { execSync, spawn, spawnSync } from 'node:child_process';
4
+ import { readFileSync } from 'node:fs';
5
+ import path from 'node:path';
6
+ /**
7
+ * The JS entry of the `ai-factory` CLI: GCF_BOARD_AIF, or the bin of the global package. The
8
+ * global one is found through `npm root -g`, a fixed command line: the shell Windows needs for
9
+ * npm.cmd sees nothing of the operator's.
10
+ */
11
+ export function aiFactoryEntry() {
12
+ if (process.env.GCF_BOARD_AIF)
13
+ return process.env.GCF_BOARD_AIF;
14
+ const root = execSync('npm root -g', { encoding: 'utf8' }).trim();
15
+ const dir = path.join(root, 'ai-factory');
16
+ const pkg = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8'));
17
+ const bin = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.['ai-factory'];
18
+ if (!bin)
19
+ throw new Error(`${dir} has no ai-factory bin: install it with npm i -g ai-factory`);
20
+ return path.join(dir, bin);
21
+ }
22
+ /**
23
+ * The line of a refusal an operator reads: the core throws, and node prints the stack under
24
+ * an `Error: <message>` line. Anything else is shown as it came.
25
+ */
26
+ export function briefError(err) {
27
+ return err.match(/^\w*Error: (.+)$/m)?.[1].trim() ?? err.trim();
28
+ }
29
+ /** Run `ai-factory <args>` in a project; a move can wait up to a minute on gcf's main lock. */
30
+ export function runCli(entry, cwd, args, timeoutMs = 120_000) {
31
+ return new Promise((resolve) => {
32
+ const child = spawn(process.execPath, [entry, ...args], {
33
+ cwd,
34
+ shell: false,
35
+ stdio: ['ignore', 'pipe', 'pipe'],
36
+ });
37
+ // A decoder per stream: a Cyrillic letter cut between two chunks stays one letter.
38
+ child.stdout.setEncoding('utf8');
39
+ child.stderr.setEncoding('utf8');
40
+ let out = '';
41
+ let err = '';
42
+ child.stdout.on('data', (d) => (out += d));
43
+ child.stderr.on('data', (d) => (err += d));
44
+ const timer = setTimeout(() => {
45
+ err += `\nstopped after ${timeoutMs / 1000} s`;
46
+ // On Windows a kill stops node alone: a build it started (Unity) would go on.
47
+ if (process.platform === 'win32' && child.pid)
48
+ spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
49
+ else
50
+ child.kill();
51
+ }, timeoutMs);
52
+ child.on('error', (e) => {
53
+ clearTimeout(timer);
54
+ resolve({ ok: false, code: null, out, err: e.message });
55
+ });
56
+ child.on('close', (code) => {
57
+ clearTimeout(timer);
58
+ resolve({ ok: code === 0, code, out, err });
59
+ });
60
+ });
61
+ }
@@ -0,0 +1,56 @@
1
+ // Server-sent events: a project's `gcf/` or its run locks changed, a run the board started ended.
2
+ // The page reads the board again on each one; the event carries no state of its own.
3
+ import { watch, watchFile, unwatchFile } from 'node:fs';
4
+ import path from 'node:path';
5
+ const clients = new Set();
6
+ /** Open an event stream on `res`. */
7
+ export function openStream(res) {
8
+ res.writeHead(200, {
9
+ 'content-type': 'text/event-stream',
10
+ 'cache-control': 'no-cache',
11
+ connection: 'keep-alive',
12
+ });
13
+ res.write(': open\n\n');
14
+ // A proxy or a sleeping laptop drops an idle stream: a comment line keeps it.
15
+ const ping = setInterval(() => res.write(': ping\n\n'), 25_000);
16
+ res.on('close', () => clearInterval(ping));
17
+ return res;
18
+ }
19
+ export function subscribe(res) {
20
+ clients.add(openStream(res));
21
+ res.on('close', () => clients.delete(res));
22
+ }
23
+ export function broadcast(event) {
24
+ const line = `data: ${JSON.stringify(event)}\n\n`;
25
+ for (const c of clients)
26
+ c.write(line);
27
+ }
28
+ /**
29
+ * Watch a project: `gcf/` (every move writes there) and `.gcf-session/runs` (a run's lock comes
30
+ * and goes). The runs directory may not exist yet, so it is polled, not watched. Bursts of
31
+ * writes make one event. Returns the stop.
32
+ */
33
+ export function watchProject(p, debounceMs = 300) {
34
+ let timer = null;
35
+ const fire = () => {
36
+ if (timer)
37
+ clearTimeout(timer);
38
+ timer = setTimeout(() => broadcast({ type: 'board', project: p.name }), debounceMs);
39
+ };
40
+ let gcf = null;
41
+ try {
42
+ gcf = watch(path.join(p.path, 'gcf'), { recursive: true }, fire);
43
+ gcf.on('error', () => gcf?.close());
44
+ }
45
+ catch {
46
+ // gcf/ is gone: the board read shows the error, nothing to watch.
47
+ }
48
+ const runs = path.join(p.path, '.gcf-session', 'runs');
49
+ watchFile(runs, { interval: 1500 }, fire);
50
+ return () => {
51
+ gcf?.close();
52
+ unwatchFile(runs, fire);
53
+ if (timer)
54
+ clearTimeout(timer);
55
+ };
56
+ }
@@ -0,0 +1,97 @@
1
+ // The files a board shows: an item, its plan, its run log. Only those shapes, only inside the
2
+ // project, symlinks resolved: the API is not a way to read the disk.
3
+ import { realpath, readFile, stat, open } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { StringDecoder } from 'node:string_decoder';
6
+ import { HttpError } from './http-error.js';
7
+ import { openStream } from './events.js';
8
+ const SHOWN = [
9
+ /^gcf\/[\w./-]+\.md$/,
10
+ /^\.ai-factory\/(archive\/)?plans\/[\w.-]+\.md$/,
11
+ /^\.gcf-session\/runs\/[\w.-]+\.log$/,
12
+ ];
13
+ const MAX = 1024 * 1024;
14
+ /** The absolute path of a shown file, or a 403. */
15
+ export async function shownFile(root, rel) {
16
+ if (!SHOWN.some((r) => r.test(rel)) || rel.split('/').includes('..'))
17
+ throw new HttpError(403, `${rel} is not a file the board shows`);
18
+ const file = path.join(root, rel);
19
+ const real = await realpath(file).catch(() => {
20
+ throw new HttpError(404, `${rel} does not exist`);
21
+ });
22
+ const inside = path.relative(await realpath(root), real);
23
+ if (inside.startsWith('..') || path.isAbsolute(inside))
24
+ throw new HttpError(403, `${rel} leads outside the project`);
25
+ return real;
26
+ }
27
+ /** The text of a shown file; a large one is cut to its last megabyte. */
28
+ export async function readShown(root, rel) {
29
+ const file = await shownFile(root, rel);
30
+ const { size } = await stat(file);
31
+ if (size <= MAX)
32
+ return readFile(file, 'utf8');
33
+ const tail = (await readBytes(file, size - MAX, size)).toString('utf8');
34
+ return `…(first ${size - MAX} bytes cut)\n${tail}`;
35
+ }
36
+ async function readBytes(file, from, to) {
37
+ const fh = await open(file, 'r');
38
+ try {
39
+ const buf = Buffer.alloc(to - from);
40
+ const { bytesRead } = await fh.read(buf, 0, buf.length, from);
41
+ return buf.subarray(0, bytesRead);
42
+ }
43
+ finally {
44
+ await fh.close();
45
+ }
46
+ }
47
+ /** Whether a file that exists resolves inside the project: a log may be a symlink. */
48
+ async function inside(root, file) {
49
+ const real = await realpath(file).catch(() => null);
50
+ if (!real)
51
+ return false;
52
+ const rel = path.relative(await realpath(root), real);
53
+ return !rel.startsWith('..') && !path.isAbsolute(rel);
54
+ }
55
+ /**
56
+ * Follow the run log of `id` as it grows: its last 256 KB first, then every new chunk, polled
57
+ * each second (a log on another disk does not always fire a watch). A log that shrank was
58
+ * started again: a `reset` event tells the page to drop what it holds, then it is read from its
59
+ * start. One decoder for the stream: a letter cut between two reads stays whole.
60
+ */
61
+ export function followLog(root, id, res) {
62
+ const file = path.join(root, '.gcf-session', 'runs', `${id}.log`);
63
+ openStream(res);
64
+ let pos = -1;
65
+ let decoder = new StringDecoder('utf8');
66
+ const pump = async () => {
67
+ if (!(await inside(root, file)))
68
+ return;
69
+ const { size } = await stat(file);
70
+ if (pos < 0)
71
+ pos = Math.max(0, size - 256 * 1024);
72
+ if (size < pos) {
73
+ pos = 0;
74
+ decoder = new StringDecoder('utf8');
75
+ res.write('event: reset\ndata: ""\n\n');
76
+ }
77
+ if (size === pos)
78
+ return;
79
+ const chunk = decoder.write(await readBytes(file, pos, size));
80
+ pos = size;
81
+ if (chunk)
82
+ res.write(`data: ${JSON.stringify(chunk)}\n\n`);
83
+ };
84
+ let busy = false;
85
+ const tick = async () => {
86
+ if (busy)
87
+ return;
88
+ busy = true;
89
+ // A log removed between two reads is read again on the next tick.
90
+ await pump()
91
+ .catch(() => { })
92
+ .finally(() => (busy = false));
93
+ };
94
+ void tick();
95
+ const timer = setInterval(tick, 1000);
96
+ res.on('close', () => clearInterval(timer));
97
+ }
@@ -0,0 +1,8 @@
1
+ /** An error the server answers with its own status, its message shown to the operator. */
2
+ export class HttpError extends Error {
3
+ status;
4
+ constructor(status, message) {
5
+ super(message);
6
+ this.status = status;
7
+ }
8
+ }
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ // `gcf-board [--port <n>]`: the board on http://127.0.0.1:<port>, 4317 by default. It prints the
3
+ // URL with this machine's token; open that one, the plain address answers 401.
4
+ import { existsSync } from 'node:fs';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { parseArgs } from 'node:util';
8
+ import { boardToken } from './auth.js';
9
+ import { aiFactoryEntry } from './cli.js';
10
+ import { createApp } from './app.js';
11
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
12
+ /**
13
+ * The built page: `dist/web` beside `server/` when run from the source (the source `web/` is
14
+ * not built), `web/` beside `dist/server/` in the package.
15
+ */
16
+ function webDir() {
17
+ const fromSource = path.resolve(HERE, '..', 'dist', 'web');
18
+ return existsSync(path.join(fromSource, 'index.html'))
19
+ ? fromSource
20
+ : path.resolve(HERE, '..', 'web');
21
+ }
22
+ async function main() {
23
+ const { values } = parseArgs({ options: { port: { type: 'string', default: '4317' } } });
24
+ const port = Number(values.port);
25
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
26
+ throw new Error('--port is a number from 1 to 65535');
27
+ const token = await boardToken();
28
+ const app = createApp({ entry: aiFactoryEntry(), token, port, web: webDir() });
29
+ app.listen(port, '127.0.0.1', () => {
30
+ console.log(`gcf-board: http://127.0.0.1:${port}/?t=${token}`);
31
+ });
32
+ }
33
+ main().catch((e) => {
34
+ console.error(`gcf-board: ${e.message}`);
35
+ process.exit(1);
36
+ });