@agent-flow-board/bridge 0.0.0-stage → 0.2.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.
@@ -0,0 +1,79 @@
1
+ // CALLING THE SERVER FROM A HOOK. A HOOK MUST BE QUICK AND MUST NEVER BREAK THE TOOL, SO EVERY FAILURE BECOMES AN EMPTY ANSWER.
2
+ // -- DEP-MODULES -- //
3
+ import { createHash } from 'node:crypto';
4
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { configDir } from '../tokenStore.js';
7
+ import { installedModVersion } from './modVersion.js';
8
+ // -- CONSTANTS -- //
9
+ const TIMEOUT_MS = 4000;
10
+ // -- FUNCTIONS -- //
11
+ /** The slug of the org the token belongs to, remembered in the bridge's folder so each hook costs one request. */
12
+ async function orgOf(server, token, fetchImpl) {
13
+ // Named by the token too, so signing in again as another organisation does not keep sending to the old one
14
+ const who = createHash('sha256').update(token).digest('hex').slice(0, 12);
15
+ const file = join(configDir(), `org-${new URL(server).host.replace(/[^A-Za-z0-9.-]/g, '_')}-${who}.txt`);
16
+ try {
17
+ const cached = readFileSync(file, 'utf8').trim();
18
+ if (cached)
19
+ return cached;
20
+ }
21
+ catch {
22
+ /* Not remembered yet */
23
+ }
24
+ const res = await fetchImpl(`${server}/api/v1/me`, {
25
+ headers: { authorization: `Bearer ${token}` },
26
+ signal: AbortSignal.timeout(TIMEOUT_MS)
27
+ });
28
+ if (!res.ok)
29
+ return null;
30
+ const me = (await res.json());
31
+ const slug = me.orgs?.[0]?.slug ?? null;
32
+ if (slug) {
33
+ mkdirSync(configDir(), { recursive: true, mode: 0o700 });
34
+ writeFileSync(file, slug, { mode: 0o600 });
35
+ }
36
+ return slug;
37
+ }
38
+ // -- STATES -- //
39
+ let warned = false;
40
+ // -- FUNCTIONS -- //
41
+ /** A hook still answers empty when it cannot reach the board, but says why once on stderr so the failure is not silent. */
42
+ function warnOnce(reason) {
43
+ if (warned)
44
+ return;
45
+ warned = true;
46
+ process.stderr.write(`agent-flow-board hook: ${reason}
47
+ `);
48
+ }
49
+ /** Sends one event and returns the answer, or null on any problem (no token, offline, a refusal, a slow server). */
50
+ export async function callHook(o, event) {
51
+ if (!o.token) {
52
+ warnOnce('no sign-in found, so no progress reminder or claim release will reach the board. Run: agent-flow-board login');
53
+ return null;
54
+ }
55
+ try {
56
+ const org = await orgOf(o.server, o.token, o.fetch);
57
+ if (!org) {
58
+ warnOnce(`the server ${o.server} did not give an organisation for this sign-in.`);
59
+ return null;
60
+ }
61
+ // A Claude Code start tells the board which editor status mod is installed, so it can say when a newer one is served
62
+ const modVersion = event.tool === 'claude-code' && event.event === 'start'
63
+ ? installedModVersion(o.home)
64
+ : undefined;
65
+ const res = await o.fetch(`${o.server}/api/v1/orgs/${org}/hooks`, {
66
+ method: 'POST',
67
+ headers: { authorization: `Bearer ${o.token}`, 'content-type': 'application/json' },
68
+ body: JSON.stringify(modVersion ? { ...event, modVersion } : event),
69
+ signal: AbortSignal.timeout(TIMEOUT_MS)
70
+ });
71
+ if (!res.ok)
72
+ warnOnce(`the server answered ${res.status} to a hook event.`);
73
+ return res.ok ? (await res.json()) : null;
74
+ }
75
+ catch {
76
+ warnOnce(`the server ${o.server} could not be reached, so the board is not told about this session.`);
77
+ return null;
78
+ }
79
+ }
@@ -0,0 +1,56 @@
1
+ // THE LOCAL GIT HOOK: A POST-COMMIT WARNING WHEN A COMMIT NAMES NO BOARD ITEM. WARN ONLY, IT NEVER BLOCKS A COMMIT (ADR-014)
2
+ // The hook is a short block between markers, so it can sit beside a hook the person already has and be removed cleanly.
3
+ // -- DEP-MODULES -- //
4
+ import { execFileSync } from 'node:child_process';
5
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { dirname, resolve } from 'node:path';
7
+ // -- CONSTANTS -- //
8
+ const START = '# >>> agent-flow-board (warn only) >>>';
9
+ const END = '# <<< agent-flow-board <<<';
10
+ const BLOCK = `${START}
11
+ # Warns when a commit names no board item. It never blocks, and it does nothing when the tool is not installed.
12
+ if command -v agent-flow-board >/dev/null 2>&1; then agent-flow-board git-hook check || true; fi
13
+ ${END}`;
14
+ const TRAILER = /^(?:board-item|items?|refs?|closes|fixes)\s*:\s*\S/im;
15
+ const SUBJECT_REF = /\([A-Za-z][A-Za-z0-9]{1,9}-[A-Za-z0-9][A-Za-z0-9-]{0,30}\)\s*$/;
16
+ // -- FUNCTIONS -- //
17
+ function hookPath(repo) {
18
+ const rel = execFileSync('git', ['-C', repo, 'rev-parse', '--git-path', 'hooks/post-commit'], {
19
+ encoding: 'utf8'
20
+ }).trim();
21
+ return resolve(repo, rel);
22
+ }
23
+ /** Adds the warning to the repository's post-commit hook, next to anything already there. Returns the file. */
24
+ export function installGitHook(repo) {
25
+ const path = hookPath(repo);
26
+ const existing = existsSync(path) ? readFileSync(path, 'utf8') : null;
27
+ if (existing?.includes(START))
28
+ return { path, created: false };
29
+ mkdirSync(dirname(path), { recursive: true });
30
+ const head = existing ?? '#!/bin/sh\n';
31
+ writeFileSync(path, `${head.replace(/\n*$/, '\n')}\n${BLOCK}\n`);
32
+ chmodSync(path, 0o755);
33
+ return { path, created: existing === null };
34
+ }
35
+ /** Removes only the block this tool wrote. A hook that held nothing else is left empty of it. */
36
+ export function uninstallGitHook(repo) {
37
+ const path = hookPath(repo);
38
+ if (!existsSync(path))
39
+ return false;
40
+ const text = readFileSync(path, 'utf8');
41
+ if (!text.includes(START))
42
+ return false;
43
+ const start = text.indexOf(START);
44
+ const end = text.indexOf(END, start);
45
+ writeFileSync(path, (text.slice(0, start) + text.slice(end + END.length)).replace(/\n{3,}/g, '\n\n'));
46
+ return true;
47
+ }
48
+ /** The warning for a commit message, or null when it names an item. */
49
+ export function checkCommit(message) {
50
+ if (TRAILER.test(message) || SUBJECT_REF.test((message.split('\n')[0] ?? '').trim()))
51
+ return null;
52
+ return 'This commit names no board item. Add a line such as "Item: CKT-20261017-0950" to the message, or ignore this when the work belongs to no item. The board only warns.';
53
+ }
54
+ export function lastCommitMessage(repo) {
55
+ return execFileSync('git', ['-C', repo, 'log', '-1', '--format=%B'], { encoding: 'utf8' });
56
+ }
@@ -0,0 +1,26 @@
1
+ // THE VERSION OF THE EDITOR STATUS MOD THIS PERSON HAS INSTALLED (AFB-20261108-0856)
2
+ // The setup prompt writes the mod to ~/.claude/skills/afb-flow-status. The hook sends the version on a start so the board can say
3
+ // when a newer one is served. A hook must never fail or slow down because of this file, so every problem means "no version".
4
+ // -- DEP-MODULES -- //
5
+ import { readFileSync } from 'node:fs';
6
+ import { homedir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ // -- CONSTANTS -- //
9
+ const MOD_FOLDER = ['.claude', 'skills', 'afb-flow-status'];
10
+ const VERSION = /^\d{1,6}\.\d{1,6}\.\d{1,6}$/;
11
+ /** A manifest is a few hundred bytes: a file far bigger than that is not read. */
12
+ const MAX_BYTES = 64 * 1024;
13
+ // -- FUNCTIONS -- //
14
+ /** The installed mod's plain major.minor.patch version, or undefined when there is no mod, no readable manifest or no plain version. */
15
+ export function installedModVersion(home = homedir()) {
16
+ try {
17
+ const text = readFileSync(join(home, ...MOD_FOLDER, '.claude-plugin', 'plugin.json'), 'utf8');
18
+ if (text.length > MAX_BYTES)
19
+ return undefined;
20
+ const version = JSON.parse(text).version;
21
+ return typeof version === 'string' && VERSION.test(version) ? version : undefined;
22
+ }
23
+ catch {
24
+ return undefined;
25
+ }
26
+ }
@@ -0,0 +1,49 @@
1
+ // THE HOOK SETTINGS EACH TOOL NEEDS, PRINTED FOR THE PERSON TO PASTE. THE BRIDGE NEVER EDITS A TOOL'S SETTINGS BY ITSELF.
2
+ // Hook commands carry no quotes around their paths, so they work where a shell strips them (cmd /c on Windows).
3
+ // -- FUNCTIONS -- //
4
+ /** The settings block for one tool, or null for a name that is not known. */
5
+ export function hookSettings(tool, server, org) {
6
+ // With an organisation the hook reads that organisation's sign in
7
+ const bin = (adapter, event = '') => `agent-flow-board hook ${adapter}${event ? ` ${event}` : ''} --server ${server}${org ? ` --org ${org}` : ''}`;
8
+ if (tool === 'claude' || tool === 'claude-code') {
9
+ const command = (timeout) => [{ type: 'command', command: bin('claude'), timeout }];
10
+ return JSON.stringify({
11
+ hooks: {
12
+ SessionStart: [{ hooks: command(15) }],
13
+ UserPromptSubmit: [{ hooks: command(10) }],
14
+ PreToolUse: [{ matcher: '^mcp__.*__(item_status|flow_set_status)$', hooks: command(10) }],
15
+ PostToolUse: [{ matcher: '.*', hooks: command(10) }],
16
+ Stop: [{ hooks: command(20) }],
17
+ // The conversation ended: the server lets go of this session's claim
18
+ SessionEnd: [{ hooks: command(10) }]
19
+ }
20
+ }, null, 2);
21
+ }
22
+ if (tool === 'antigravity' || tool === 'agy') {
23
+ const at = (event, timeout) => ({
24
+ type: 'command',
25
+ command: bin('antigravity', event),
26
+ timeout
27
+ });
28
+ return JSON.stringify({
29
+ 'agent-flow-board': {
30
+ PostToolUse: [{ matcher: '*', hooks: [at('PostToolUse', 15)] }],
31
+ PreInvocation: [at('PreInvocation', 25)],
32
+ Stop: [at('Stop', 25)]
33
+ }
34
+ }, null, 2);
35
+ }
36
+ if (tool === 'gemini') {
37
+ const command = (timeout) => [{ type: 'command', command: bin('gemini'), timeout }];
38
+ return JSON.stringify({
39
+ hooks: {
40
+ SessionStart: [{ hooks: command(15000) }],
41
+ BeforeAgent: [{ hooks: command(10000) }],
42
+ BeforeTool: [{ matcher: 'mcp_.*(item_status|flow_set_status)', hooks: command(10000) }],
43
+ AfterTool: [{ matcher: '.*', hooks: command(10000) }],
44
+ AfterAgent: [{ hooks: command(20000) }]
45
+ }
46
+ }, null, 2);
47
+ }
48
+ return null;
49
+ }
@@ -0,0 +1,124 @@
1
+ // THE SETUP COMMAND: PUT THE BRIDGE'S CLAUDE CODE HOOKS INTO A REPO'S SETTINGS FILE WITHOUT TOUCHING ANYTHING ELSE IN IT.
2
+ // An earlier rule said the bridge never edits a tool's settings by itself (settings.ts). This command is the one place that does, and
3
+ // only when the person or an agent runs it on purpose: it merges, it keeps every hook that is not the bridge's, it backs the file up
4
+ // outside the repo first, and it refuses a file it cannot read as plain JSON instead of guessing.
5
+ // -- DEP-MODULES -- //
6
+ import { createHash } from 'node:crypto';
7
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
8
+ import { dirname, join, resolve } from 'node:path';
9
+ import { hookSettings } from './settings.js';
10
+ // -- CONSTANTS -- //
11
+ /** A command of the bridge's Claude hook, whatever its server, organisation or timeout. */
12
+ const BRIDGE_HOOK = /(^|[\\/\s"'])agent-flow-board(\.cmd|\.exe)?["']?\s+hook\s+claude\b/;
13
+ // -- FUNCTIONS -- //
14
+ const isBridge = (entry) => Array.isArray(entry.hooks) &&
15
+ entry.hooks.length > 0 &&
16
+ entry.hooks.every((h) => typeof h.command === 'string' && BRIDGE_HOOK.test(h.command));
17
+ /**
18
+ * What the settings file should hold after setup. `existing` is the file's text, or null when there is none. Hooks of the bridge are
19
+ * replaced (so a run with another organisation changes them, and a second run changes nothing); every other hook, key and entry stays.
20
+ */
21
+ export function planSetup(existing, server, org) {
22
+ const generated = hookSettings('claude', server, org);
23
+ if (!generated)
24
+ return { ok: false, error: 'No hook settings for Claude Code.' };
25
+ const wanted = JSON.parse(generated).hooks ?? {};
26
+ let settings = {};
27
+ if (existing !== null && existing.trim() !== '') {
28
+ try {
29
+ const parsed = JSON.parse(existing);
30
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
31
+ return { ok: false, error: 'The settings file is not a JSON object.' };
32
+ settings = parsed;
33
+ }
34
+ catch {
35
+ return {
36
+ ok: false,
37
+ error: 'The settings file is not plain JSON (a comment or a trailing comma?), so it was not changed. Add the hooks by hand: agent-flow-board hooks print claude --server <address> --org <slug>'
38
+ };
39
+ }
40
+ }
41
+ if (settings.hooks !== undefined &&
42
+ (typeof settings.hooks !== 'object' || Array.isArray(settings.hooks)))
43
+ return { ok: false, error: 'The "hooks" key of the settings file is not an object.' };
44
+ const hooks = { ...(settings.hooks ?? {}) };
45
+ let added = 0;
46
+ let replaced = 0;
47
+ let kept = 0;
48
+ for (const [event, entries] of Object.entries(wanted)) {
49
+ const current = Array.isArray(hooks[event]) ? hooks[event] : [];
50
+ const others = current.filter((e) => !isBridge(e));
51
+ const ours = current.filter(isBridge);
52
+ kept += others.length;
53
+ // The bridge's entries go last and in a fixed order, so running it again gives the same file
54
+ hooks[event] = [...others, ...entries];
55
+ const same = JSON.stringify(ours) === JSON.stringify(entries);
56
+ if (ours.length === 0)
57
+ added += entries.length;
58
+ else if (!same)
59
+ replaced += entries.length;
60
+ }
61
+ // Events the file has that the bridge does not use stay as they are
62
+ for (const [event, entries] of Object.entries(hooks))
63
+ if (!(event in wanted) && Array.isArray(entries))
64
+ kept += entries.length;
65
+ const next = { ...settings, hooks };
66
+ // Compared as data, so a file that only differs in how it is spaced is left as the person wrote it
67
+ const changed = existing === null || JSON.stringify(settings) !== JSON.stringify(next);
68
+ return {
69
+ ok: true,
70
+ text: JSON.stringify(next, null, '\t') + '\n',
71
+ added,
72
+ replaced,
73
+ kept,
74
+ changed
75
+ };
76
+ }
77
+ /** Where the backup of a repo's settings file goes: the bridge's own folder, never the repo, so it cannot be committed by accident. */
78
+ export function backupPath(configDir, file, now = Date.now()) {
79
+ const key = createHash('sha256').update(resolve(file)).digest('hex').slice(0, 12);
80
+ const stamp = new Date(now).toISOString().replace(/[:.]/g, '-');
81
+ return join(configDir, 'backups', `${key}-${stamp}.bak`);
82
+ }
83
+ /** Runs the setup for one repo folder. Writes the file atomically; backs the old one up first. Never throws. */
84
+ export function runSetup(options) {
85
+ try {
86
+ const file = join(options.repo, '.claude', options.shared ? 'settings.json' : 'settings.local.json');
87
+ const existing = existsSync(file) ? readFileSync(file, 'utf8') : null;
88
+ const plan = planSetup(existing, options.server, options.org);
89
+ if (!plan.ok)
90
+ return { ok: false, error: `${file}: ${plan.error}` };
91
+ if (!plan.changed)
92
+ return {
93
+ ok: true,
94
+ file,
95
+ changed: false,
96
+ added: 0,
97
+ replaced: 0,
98
+ kept: plan.kept,
99
+ backup: null
100
+ };
101
+ let backup = null;
102
+ if (existing !== null) {
103
+ backup = backupPath(options.configDir, file, options.now);
104
+ mkdirSync(dirname(backup), { recursive: true, mode: 0o700 });
105
+ writeFileSync(backup, existing, { mode: 0o600 });
106
+ }
107
+ mkdirSync(dirname(file), { recursive: true });
108
+ const temp = `${file}.${process.pid}.tmp`;
109
+ writeFileSync(temp, plan.text);
110
+ renameSync(temp, file);
111
+ return {
112
+ ok: true,
113
+ file,
114
+ changed: true,
115
+ added: plan.added,
116
+ replaced: plan.replaced,
117
+ kept: plan.kept,
118
+ backup
119
+ };
120
+ }
121
+ catch (error) {
122
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
123
+ }
124
+ }
@@ -0,0 +1,116 @@
1
+ // THE STATUS FILE OF A CONVERSATION: WHAT THE BOARD SAYS IT WORKS ON, KEPT BESIDE THE BRIDGE FOR AN EDITOR STATUS LINE.
2
+ // The hook answer carries the status when the bridge asks for it. The bridge writes it to <config dir>/status/<conversation id>.json
3
+ // so the editor mod reads a local file and needs no token. The file holds the status fields only, never a token or a verifier.
4
+ // A hook must never fail or slow down because of this file, so every function here swallows its errors.
5
+ // -- DEP-MODULES -- //
6
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ // -- CONSTANTS -- //
9
+ /** Files older than this are removed when a conversation starts. */
10
+ export const PURGE_AFTER_MS = 2 * 24 * 60 * 60 * 1000;
11
+ const ENDED = '.ended';
12
+ const TEMP = '.tmp';
13
+ /** A rename over a file another process holds can fail for a moment on Windows (antivirus, a parallel hook). */
14
+ const RENAME_TRIES = 4;
15
+ // -- FUNCTIONS -- //
16
+ /**
17
+ * The file name of a conversation, or null when its id is not plain. An id comes from a tool's payload, so it is never used as a
18
+ * path unless it is made only of letters, digits, dots, dashes and underscores and does not start with a dot.
19
+ */
20
+ export function statusName(externalId) {
21
+ return /^[A-Za-z0-9_-][A-Za-z0-9._-]{7,99}$/.test(externalId) ? externalId : null;
22
+ }
23
+ /** Waits a few milliseconds without spinning, for the retry of a rename. */
24
+ function pause(ms) {
25
+ try {
26
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
27
+ }
28
+ catch {
29
+ /* No wait available: the retry runs at once */
30
+ }
31
+ }
32
+ function writeAtomic(dir, name, body) {
33
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
34
+ const temp = join(dir, `${name}.${process.pid}${TEMP}`);
35
+ writeFileSync(temp, body, { mode: 0o600 });
36
+ for (let i = 0; i < RENAME_TRIES; i++) {
37
+ try {
38
+ renameSync(temp, join(dir, `${name}.json`));
39
+ return;
40
+ }
41
+ catch (error) {
42
+ if (i === RENAME_TRIES - 1) {
43
+ rmSync(temp, { force: true });
44
+ throw error;
45
+ }
46
+ pause(15 * (i + 1));
47
+ }
48
+ }
49
+ }
50
+ /** The time the file in place was asked for, or 0 when there is none or it cannot be read. */
51
+ function askedAtOf(dir, name) {
52
+ try {
53
+ const parsed = JSON.parse(readFileSync(join(dir, `${name}.json`), 'utf8'));
54
+ return typeof parsed.asOf === 'number' ? parsed.asOf : 0;
55
+ }
56
+ catch {
57
+ return 0;
58
+ }
59
+ }
60
+ /** Removes the files nobody asked for in two days: finished conversations, crashed ones and leftovers of a failed write. */
61
+ export function purgeStatus(dir, now = Date.now()) {
62
+ try {
63
+ for (const file of readdirSync(dir)) {
64
+ const path = join(dir, file);
65
+ try {
66
+ if (now - statSync(path).mtimeMs > PURGE_AFTER_MS)
67
+ rmSync(path, { force: true });
68
+ }
69
+ catch {
70
+ /* A file that vanished meanwhile is gone, which is the point */
71
+ }
72
+ }
73
+ }
74
+ catch {
75
+ /* No folder yet */
76
+ }
77
+ }
78
+ /**
79
+ * Applies one hook event to the files. `asked` is the time the request left, so two answers that come back in the wrong order never
80
+ * replace a newer file with an older one. It never throws and never prints.
81
+ */
82
+ export function applyStatusFile(dir, event, answer, asked, now = Date.now()) {
83
+ try {
84
+ const name = statusName(event.externalId);
85
+ if (!name)
86
+ return;
87
+ if (event.event === 'end') {
88
+ // A last hook of the same conversation may still be on its way: the marker keeps it from writing the file again
89
+ rmSync(join(dir, `${name}.json`), { force: true });
90
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
91
+ writeFileSync(join(dir, `${name}${ENDED}`), String(now), { mode: 0o600 });
92
+ return;
93
+ }
94
+ if (event.event === 'start') {
95
+ // A resumed conversation starts again under the same id
96
+ rmSync(join(dir, `${name}${ENDED}`), { force: true });
97
+ purgeStatus(dir, now);
98
+ }
99
+ if (!event.status)
100
+ return;
101
+ if (existsSync(join(dir, `${name}${ENDED}`)))
102
+ return;
103
+ if (!answer?.status) {
104
+ // The board did not answer a call that may have changed the claim: an old file would show a wrong item
105
+ if (event.event === 'tool')
106
+ rmSync(join(dir, `${name}.json`), { force: true });
107
+ return;
108
+ }
109
+ if (askedAtOf(dir, name) > asked)
110
+ return;
111
+ writeAtomic(dir, name, JSON.stringify({ v: 1, asOf: asked, writtenAt: now, status: answer.status }));
112
+ }
113
+ catch {
114
+ /* A failed write is never a failed hook */
115
+ }
116
+ }
package/dist/mockup.js ADDED
@@ -0,0 +1,145 @@
1
+ // agent-flow-board mockup push <file.html>: UPLOADS ONE MOCKUP FILE TO THE BOARD OVER /api/v1 WITH THE BRIDGE'S OWN SIGN IN
2
+ // It reads the file by path from the agent's own folder, so the HTML never passes through a model's output. It is never queued, never
3
+ // goes through the MCP forwarder and changes nothing in the bridge protocol: a failed push says so and the agent runs it again.
4
+ // -- DEP-MODULES -- //
5
+ import { randomBytes } from 'node:crypto';
6
+ import { lstatSync, readFileSync } from 'node:fs';
7
+ import { extname } from 'node:path';
8
+ // -- CONSTANTS -- //
9
+ /** The server's own cap for one version (50 MB). The server checks it again, this only stops a hopeless upload early. */
10
+ export const MAX_MOCKUP_BYTES = 50 * 1024 * 1024;
11
+ export const MAX_SCREENSHOT_BYTES = 5 * 1024 * 1024;
12
+ const TRIES = 3;
13
+ /** A kit's summary travels in the address, so it is kept short here. */
14
+ const MAX_KIT_SUMMARY = 4000;
15
+ // -- FUNCTIONS -- //
16
+ /** The message of a failed call: the server's own words when it sent them. */
17
+ async function failure(res) {
18
+ try {
19
+ const j = (await res.json());
20
+ if (j.error?.message)
21
+ return `${j.error.message} (${res.status})`;
22
+ }
23
+ catch {
24
+ /* Not JSON */
25
+ }
26
+ return `The server answered ${res.status}.`;
27
+ }
28
+ /** Only a plain HTML or PNG file of a sane size is read: never a link, a folder or some other file an agent was talked into naming. */
29
+ function readFileChecked(path, kinds, max, what) {
30
+ const st = lstatSync(path, { throwIfNoEntry: false });
31
+ if (!st)
32
+ throw new Error(`The ${what} file ${path} does not exist.`);
33
+ if (!st.isFile())
34
+ throw new Error(`The ${what} path ${path} is not a plain file.`);
35
+ if (!kinds.includes(extname(path).toLowerCase()))
36
+ throw new Error(`The ${what} must be a ${kinds.join(' or ')} file.`);
37
+ if (st.size === 0)
38
+ throw new Error(`The ${what} file is empty.`);
39
+ if (st.size > max)
40
+ throw new Error(`The ${what} is ${Math.ceil(st.size / 1024 / 1024)} MB, more than the ${Math.floor(max / 1024 / 1024)} MB limit.`);
41
+ return new Uint8Array(readFileSync(path));
42
+ }
43
+ /** One call, tried again on a network failure or a 5xx with the same request id, so a repeat can never publish twice. */
44
+ async function call(o, method, path, body, headers, requestId) {
45
+ let last = 'The server could not be reached.';
46
+ for (let i = 0; i < TRIES; i++) {
47
+ try {
48
+ const res = await o.fetch(`${o.server}/api/v1${path}`, {
49
+ method,
50
+ body,
51
+ headers: {
52
+ authorization: `Bearer ${o.token}`,
53
+ 'idempotency-key': requestId,
54
+ ...headers
55
+ }
56
+ });
57
+ if (res.status < 500)
58
+ return res;
59
+ last = await failure(res);
60
+ }
61
+ catch (e) {
62
+ last = e instanceof Error ? e.message : String(e);
63
+ }
64
+ if (i < TRIES - 1)
65
+ await (o.wait ?? ((ms) => new Promise((r) => setTimeout(r, ms))))(500 * (i + 1));
66
+ }
67
+ throw new Error(last);
68
+ }
69
+ export async function pushMockup(o) {
70
+ if (!o.token)
71
+ throw new Error('Not signed in. Run: agent-flow-board login --server <url> --org <slug>');
72
+ if (!o.item && !o.project && !o.mockup)
73
+ throw new Error('Name the plan (--item), a project (--project) or the mockup (--mockup).');
74
+ const html = readFileChecked(o.file, ['.html', '.htm'], MAX_MOCKUP_BYTES, 'mockup');
75
+ const shot = o.screenshot
76
+ ? readFileChecked(o.screenshot, ['.png'], MAX_SCREENSHOT_BYTES, 'screenshot')
77
+ : null;
78
+ const id = randomBytes(8).toString('hex');
79
+ const base = `/orgs/${encodeURIComponent(o.org)}`;
80
+ const q = new URLSearchParams();
81
+ for (const [k, v] of [
82
+ ['item', o.item],
83
+ ['project', o.project],
84
+ ['mockup', o.mockup],
85
+ ['title', o.title]
86
+ ])
87
+ if (v)
88
+ q.set(k, v);
89
+ if (o.another)
90
+ q.set('another', 'true');
91
+ const res = await call(o, 'POST', `${base}/mockups?${q}`, html, { 'content-type': 'text/html' }, `mockup-push-${id}`);
92
+ if (!res.ok)
93
+ throw new Error(await failure(res));
94
+ const made = (await res.json());
95
+ const at = `${base}/mockups/${made.mockup.id}/versions/${made.version.number}`;
96
+ let notes = false;
97
+ if (o.keepNotes || o.freeNotes) {
98
+ const n = await call(o, 'PUT', `${at}/notes`, JSON.stringify({ keepNotes: o.keepNotes ?? '', freeNotes: o.freeNotes ?? '' }), { 'content-type': 'application/json' }, `mockup-notes-${id}`);
99
+ if (!n.ok)
100
+ o.log(`The mockup is published, but its notes were not saved: ${await failure(n)}`);
101
+ else
102
+ notes = true;
103
+ }
104
+ let screenshot = false;
105
+ if (shot) {
106
+ const s = await call(o, 'PUT', `${at}/screenshot`, shot, { 'content-type': 'image/png' }, `mockup-shot-${id}`);
107
+ if (!s.ok)
108
+ o.log(`The mockup is published, but its screenshot was not saved: ${await failure(s)}`);
109
+ else
110
+ screenshot = true;
111
+ }
112
+ const warnings = made.warnings ?? [];
113
+ for (const w of warnings)
114
+ o.log(`Warning: ${w}`);
115
+ return {
116
+ mockupId: made.mockup.id,
117
+ version: made.version.number,
118
+ url: `${o.server}/${o.org}/${made.projectKey}/mockups/${made.mockup.id}`,
119
+ notes,
120
+ screenshot,
121
+ warnings
122
+ };
123
+ }
124
+ /** Uploads the project's kit file (colours, fonts, icons) by path, the same way. The warnings are printed and never change the exit code. */
125
+ export async function pushKit(o) {
126
+ if (!o.token)
127
+ throw new Error('Not signed in. Run: agent-flow-board login --server <url> --org <slug>');
128
+ const html = readFileChecked(o.file, ['.html', '.htm'], MAX_MOCKUP_BYTES, 'kit');
129
+ const q = new URLSearchParams();
130
+ if (o.commit)
131
+ q.set('builtFromCommit', o.commit.slice(0, 80));
132
+ if (o.summary) {
133
+ if (o.summary.length > MAX_KIT_SUMMARY)
134
+ throw new Error(`The summary is ${o.summary.length.toLocaleString('en-US')} characters; the bridge sends at most ${MAX_KIT_SUMMARY.toLocaleString('en-US')}. Shorten it.`);
135
+ q.set('summary', o.summary);
136
+ }
137
+ const res = await call(o, 'PUT', `/orgs/${encodeURIComponent(o.org)}/projects/${encodeURIComponent(o.project)}/mockup-kit?${q}`, html, { 'content-type': 'text/html' }, `mockup-kit-${randomBytes(8).toString('hex')}`);
138
+ if (!res.ok)
139
+ throw new Error(await failure(res));
140
+ const made = (await res.json());
141
+ const warnings = made.warnings ?? [];
142
+ for (const w of warnings)
143
+ o.log(`Warning: ${w}`);
144
+ return { number: made.number, warnings };
145
+ }