@gatebolt/cli 0.4.0 → 0.5.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/dist/config.js CHANGED
@@ -1,32 +1,68 @@
1
1
  export const DEFAULT_BASE_URL = "https://api.gatebolt.com";
2
2
  export async function resolveConnection(flags, loadProfile) {
3
- const key = flags.key ?? process.env.GBOLT_KEY;
4
- const url = flags.url ?? process.env.GBOLT_URL;
3
+ const key = flags.key ?? process.env.GATEBOLT_KEY;
4
+ const url = flags.url ?? process.env.GATEBOLT_URL;
5
5
  const profile = key && url ? null : await loadProfile();
6
6
  const resolvedKey = key ?? profile?.key;
7
7
  const baseUrl = (url ?? profile?.url ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
8
8
  if (!resolvedKey) {
9
- throw new Error("Missing API key. Set GBOLT_KEY, pass --key, or run `gatebolt configure`.");
9
+ throw new Error("Missing API key. Set GATEBOLT_KEY, pass --key, or run `gatebolt configure`.");
10
10
  }
11
- return { key: resolvedKey, baseUrl };
11
+ const connection = { key: resolvedKey, baseUrl };
12
+ const profileName = connectionProfileName(key, profile);
13
+ if (profileName)
14
+ connection.profileName = profileName;
15
+ return connection;
12
16
  }
13
- export async function resolveRepoSlug(flags, loadProfile) {
14
- const raw = flags.repo ?? process.env.GBOLT_REPO ?? (await loadProfile())?.repo;
15
- if (!raw) {
16
- throw new Error("Missing repository slug. Set GBOLT_REPO, pass --repo, or run `gatebolt configure`.");
17
- }
18
- return slugifyRepo(raw);
17
+ function connectionProfileName(key, profile) {
18
+ if (key || !profile)
19
+ return undefined;
20
+ return profile.name;
19
21
  }
20
22
  const SLUG_MAX_LENGTH = 48;
21
- export function slugifyRepo(value) {
23
+ function toSlug(value) {
22
24
  const slug = value
23
25
  .toLowerCase()
24
26
  .trim()
25
27
  .replace(/[^a-z0-9]+/g, "-")
26
- .slice(0, SLUG_MAX_LENGTH)
27
28
  .replace(/^-+|-+$/g, "");
28
29
  if (!slug) {
29
30
  throw new Error(`Could not derive a repository slug from "${value}".`);
30
31
  }
32
+ if (slug.length > SLUG_MAX_LENGTH) {
33
+ throw new Error(`"${value}" makes a ${slug.length}-character slug, over the ${SLUG_MAX_LENGTH} limit. ` +
34
+ "Run `gatebolt link --name <shorter-name>` to name this repository yourself.");
35
+ }
31
36
  return slug;
32
37
  }
38
+ export function repoIdentityFromName(name) {
39
+ return { slug: toSlug(name), name };
40
+ }
41
+ const REMOTE_SCHEMES = new Set(["ssh:", "https:", "http:", "git:"]);
42
+ const HAS_SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i;
43
+ const SCP_LIKE = /^(?:[^@]+@)?[^:/]{2,}:(.+)$/;
44
+ function remotePath(url) {
45
+ const trimmed = url.trim();
46
+ if (!HAS_SCHEME.test(trimmed)) {
47
+ return SCP_LIKE.exec(trimmed)?.[1] ?? null;
48
+ }
49
+ let parsed;
50
+ try {
51
+ parsed = new URL(trimmed);
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ return REMOTE_SCHEMES.has(parsed.protocol) ? parsed.pathname : null;
57
+ }
58
+ export function parseRemoteUrl(url) {
59
+ const path = remotePath(url);
60
+ if (!path)
61
+ return null;
62
+ const segments = path.split("/").filter(Boolean);
63
+ const repo = segments.pop()?.replace(/\.git$/i, "");
64
+ if (!repo || segments.length === 0)
65
+ return null;
66
+ const name = `${segments.join("/")}/${repo}`;
67
+ return { slug: toSlug(name), name };
68
+ }
package/dist/contracts.js CHANGED
@@ -12,3 +12,9 @@ export const AGENT_VENDORS = [
12
12
  export function isAgentVendor(value) {
13
13
  return AGENT_VENDORS.includes(value);
14
14
  }
15
+ // Mirrors z.uuid() in @gatebolt/validators; keep in sync by hand (the CLI
16
+ // takes no dependency on the validators package).
17
+ const SERVER_UUID = /^(?:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/;
18
+ export function isUuid(value) {
19
+ return SERVER_UUID.test(value);
20
+ }
package/dist/diff.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { REPO_LINK_FILE } from "./profiles.js";
1
2
  const PARSER_BUG_HINT = "This is likely a GateBolt bug — please report it.";
2
3
  export function observedFilesFromGit(nameStatusOutput, numstatOutput, untrackedOutput) {
3
4
  const counts = parseNumstat(numstatOutput);
@@ -18,7 +19,7 @@ export function observedFilesFromGit(nameStatusOutput, numstatOutput, untrackedO
18
19
  continue;
19
20
  observed.push({ path, action: "added" });
20
21
  }
21
- return observed;
22
+ return observed.filter((file) => file.path !== REPO_LINK_FILE);
22
23
  }
23
24
  function parseNameStatus(output) {
24
25
  const reader = createReader(splitFields(output));
package/dist/errors.js CHANGED
@@ -3,7 +3,13 @@ const STATUS_BAD_REQUEST = 400;
3
3
  const STATUS_UNAUTHORIZED = 401;
4
4
  const STATUS_NOT_FOUND = 404;
5
5
  const STATUS_CONFLICT = 409;
6
- const STATUS_RATE_LIMITED = 429;
6
+ const WRONG_WIRE_MESSAGE = "Not found — no GateBolt agent wire answered. Check the URL points at your GateBolt instance.";
7
+ const NOT_FOUND_MESSAGE = {
8
+ "change.declare": "Not found — the repository id in .gatebolt is not one this API key can record against. The repository may have been deleted, or the file may have come from another workspace. Run `gatebolt link` from the repo root to re-establish it, then commit the .gatebolt it writes.",
9
+ "change.observe": "Not found — no change matches this API key. `observe` must use the same key that ran `declare`; if the key was rotated in between, re-run `gatebolt declare`.",
10
+ "repository.link": WRONG_WIRE_MESSAGE,
11
+ "agentKey.create": WRONG_WIRE_MESSAGE,
12
+ };
7
13
  export function formatError(error) {
8
14
  if (error instanceof RequestError) {
9
15
  return messageForStatus(error);
@@ -16,17 +22,13 @@ export function formatError(error) {
16
22
  export function messageForStatus(error) {
17
23
  switch (error.status) {
18
24
  case STATUS_BAD_REQUEST:
19
- return "Invalid request — check the declared values and try again.";
20
- // The server collapses a missing/invalid key and a spent rate limit into one
21
- // 401, so this cannot name which without guessing.
25
+ return `Invalid request — ${error.fieldErrors.join(" ") || "check the declared values and try again."}`;
22
26
  case STATUS_UNAUTHORIZED:
23
- return "Unauthorized — the API key is invalid or has hit its rate limit.";
27
+ return "Unauthorized — check the API key. Run `gatebolt configure` to set a valid key for this repo.";
24
28
  case STATUS_NOT_FOUND:
25
- return "Not found — no change matches this API key. `observe` must use the same key that ran `declare`; if the key was rotated in between, re-run `gatebolt declare`.";
29
+ return NOT_FOUND_MESSAGE[error.procedure];
26
30
  case STATUS_CONFLICT:
27
31
  return "Conflict — this change was already observed with different files.";
28
- case STATUS_RATE_LIMITED:
29
- return "Rate limited — try again later.";
30
32
  default:
31
33
  return error.message;
32
34
  }
package/dist/git.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { resolve } from "node:path";
2
3
  import { promisify } from "node:util";
3
4
  const run = promisify(execFile);
4
5
  const MAX_GIT_OUTPUT_BYTES = 67_108_864;
@@ -28,6 +29,22 @@ export async function repoContext() {
28
29
  return null;
29
30
  return { gitDir: dir, root };
30
31
  }
32
+ // --git-path resolves the shared hooks dir — correct even inside a linked worktree.
33
+ export async function hooksPath() {
34
+ const output = await git(["rev-parse", "--git-path", "hooks"]);
35
+ return resolve(output.trim());
36
+ }
37
+ export async function remoteOriginUrl() {
38
+ try {
39
+ const output = await git(["remote", "get-url", "origin"]);
40
+ return output.trim() || null;
41
+ }
42
+ catch (error) {
43
+ if (gitExitedNonZero(error))
44
+ return null;
45
+ throw gitFailure(error);
46
+ }
47
+ }
31
48
  export async function currentHead() {
32
49
  const output = await git(["rev-parse", "HEAD"]);
33
50
  return output.trim();
package/dist/help.js CHANGED
@@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises";
2
2
  import { DEFAULT_BASE_URL } from "./config.js";
3
3
  import { AGENT_VENDORS } from "./contracts.js";
4
4
  import { isRecord } from "./json.js";
5
- export const USAGE = "Usage: gatebolt <declare|observe|configure> [options]";
5
+ export const USAGE = "Usage: gatebolt <login|init|declare|observe|configure|link> [options]";
6
6
  const ROOT_HELP = `gatebolt — the GateBolt agent client. An agent declares the files it intends to
7
7
  touch before it edits, then observes the actual diff when the work lands;
8
8
  GateBolt reconciles the two and scores the drift.
@@ -10,9 +10,13 @@ GateBolt reconciles the two and scores the drift.
10
10
  ${USAGE}
11
11
 
12
12
  Commands:
13
+ login Sign in through your browser and save an agent key for this machine.
14
+ init Set up GateBolt in a repository: link it and wire the agent hooks.
13
15
  declare Record the files the agent intends to touch, before it edits.
14
16
  observe Derive the diff from git and reconcile it against the declaration.
15
- configure Save a per-repo profile (url, repo, key) to ~/.gatebolt/config.json.
17
+ configure Save a profile (url, key) to ~/.gatebolt/config.json.
18
+ link Record which repository this checkout is, in a committable file.
19
+ hook Internal — the declare/observe hook commands gatebolt init wires up.
16
20
 
17
21
  Options:
18
22
  -h, --help Show this help, or the help of a command.
@@ -20,12 +24,28 @@ Options:
20
24
 
21
25
  Run \`gatebolt <command> --help\` for the options of a command.
22
26
  `;
27
+ const LOGIN_HELP = `Usage: gatebolt login [options]
28
+
29
+ Signs in through your browser and saves an agent API key for this machine.
30
+ Opens a GateBolt page to approve the device, then mints an org-scoped key and
31
+ writes a profile to ~/.gatebolt/config.json. Run once per machine; the profile
32
+ is named after the organization you pick.
33
+
34
+ Options:
35
+ --key-name <name> Label for the key in the dashboard. Defaults to
36
+ "gatebolt-cli (<hostname>)".
37
+ --url <url> GateBolt URL. Defaults to ${DEFAULT_BASE_URL}.
38
+ -h, --help Show this help.
39
+ `;
23
40
  const DECLARE_HELP = `Usage: gatebolt declare --task <text> --vendor <vendor> --name <name>
24
41
  --model <id> --file <path> [--file <path> ...] [options]
42
+ gatebolt declare --abort
25
43
 
26
- Records the files the agent intends to touch, before it edits. Snapshots the
27
- current commit as the base and writes the change id under <git-dir>/gatebolt/
28
- for \`gatebolt observe\` to pick up. The repository must have at least one commit.
44
+ Records the files the agent intends to touch, before it edits. Reads the
45
+ repository id from .gatebolt, or establishes it from the \`origin\` remote when
46
+ the file is absent. Snapshots the current commit as the base and writes the
47
+ change id under <git-dir>/gatebolt/ for \`gatebolt observe\` to pick up. The
48
+ repository must have at least one commit.
29
49
 
30
50
  Required:
31
51
  --task <text> What the agent is doing.
@@ -37,10 +57,12 @@ Required:
37
57
  Options:
38
58
  --agent-version <ver> Version of the agent — not of this CLI.
39
59
  --launched-by <email> Email of the person who launched the agent.
40
- --profile <name> Profile to read url, repo and key from.
60
+ --profile <name> Profile to read url and key from.
41
61
  --key <key> API key. Overrides the profile.
42
62
  --url <url> GateBolt URL. Overrides the profile.
43
- --repo <slug> Repo slug. Overrides the profile.
63
+ --abort Clear the open declaration for this repo (local only;
64
+ use it to unstick a declaration that can no longer be
65
+ reconciled), then declare again. Ignores other flags.
44
66
  -h, --help Show this help.
45
67
  `;
46
68
  const OBSERVE_HELP = `Usage: gatebolt observe [options]
@@ -57,27 +79,72 @@ Options:
57
79
  --url <url> GateBolt URL. Overrides the profile.
58
80
  -h, --help Show this help.
59
81
  `;
60
- const CONFIGURE_HELP = `Usage: gatebolt configure --repo <slug> [options]
82
+ const CONFIGURE_HELP = `Usage: gatebolt configure [options]
61
83
 
62
84
  Saves a profile to ~/.gatebolt/config.json, owner-readable only. Prompts for the
63
- API key unless --key is given; what you type is not echoed.
64
-
65
- Required:
66
- --repo <slug> Slug of the repo this agent works in, created on the first
67
- declare. Normalized to lowercase-hyphens.
85
+ API key unless --key is given; what you type is not echoed. Run \`gatebolt link\`
86
+ in a repo to record which repository it is.
68
87
 
69
88
  Options:
70
- --profile <name> Profile name. Defaults to the repo slug.
89
+ --profile <name> Profile name. Defaults to "default".
71
90
  --url <url> GateBolt URL. Defaults to ${DEFAULT_BASE_URL}.
72
91
  --key <key> API key. Skips the prompt, for scripting.
73
92
  -h, --help Show this help.
74
93
 
75
94
  Set GATEBOLT_CONFIG to keep the config somewhere other than the default path.
76
95
  `;
96
+ const LINK_HELP = `Usage: gatebolt link [options]
97
+
98
+ Records which repository this checkout is, so every clone, worktree and CI job
99
+ resolves the same one. Derives the name from the \`origin\` remote unless --name
100
+ is given, then writes the repository id to a .gatebolt file at the repo root —
101
+ commit that file.
102
+
103
+ Options:
104
+ --name <name> Name this repository yourself. Required when there is no
105
+ \`origin\` remote to derive from.
106
+ --profile <name> Profile to read url and key from. Recorded in .gatebolt.
107
+ --key <key> API key. Overrides the profile.
108
+ --url <url> GateBolt URL. Overrides the profile.
109
+ -h, --help Show this help.
110
+ `;
111
+ const INIT_HELP = `Usage: gatebolt init [options]
112
+
113
+ Sets up GateBolt in this repository: links it (writing a committable .gatebolt),
114
+ wires your agent's declare → observe hooks into the repo, and installs a pre-push
115
+ backstop. Reuses a saved profile, so run \`gatebolt login\` or \`gatebolt configure\`
116
+ first. Safe to re-run.
117
+
118
+ Options:
119
+ --name <name> Name this repository yourself. Required when there is no
120
+ \`origin\` remote to derive it from.
121
+ --enforce <mode> \`strict\` blocks edits until you declare; \`lenient\` keeps
122
+ it advisory. Omit to leave a repo's current mode unchanged
123
+ (a fresh repo stays advisory).
124
+ --no-hooks Only link the repository; don't wire the agent hooks.
125
+ --no-pre-push Don't install the pre-push backstop.
126
+ --force Replace an existing, non-GateBolt pre-push hook.
127
+ --profile <name> Profile to read url and key from.
128
+ -h, --help Show this help.
129
+ `;
130
+ const HOOK_HELP = `Usage: gatebolt hook <guard|post-turn|pre-push>
131
+
132
+ Internal subcommands the hooks installed by \`gatebolt init\` invoke — you don't run
133
+ these by hand. \`guard\` denies an edit until a declaration exists (strict mode only),
134
+ \`post-turn\` reconciles the diff when a turn ends, and \`pre-push\` reconciles an open
135
+ declaration before a push.
136
+
137
+ Options:
138
+ -h, --help Show this help.
139
+ `;
77
140
  const COMMAND_HELP = new Map([
141
+ ["login", LOGIN_HELP],
142
+ ["init", INIT_HELP],
78
143
  ["declare", DECLARE_HELP],
79
144
  ["observe", OBSERVE_HELP],
80
145
  ["configure", CONFIGURE_HELP],
146
+ ["link", LINK_HELP],
147
+ ["hook", HOOK_HELP],
81
148
  ]);
82
149
  export function isCommand(command) {
83
150
  return COMMAND_HELP.has(command);
package/dist/index.js CHANGED
@@ -1,6 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { runConfigure } from "./commands/configure.js";
3
3
  import { runDeclare } from "./commands/declare.js";
4
+ import { runHook } from "./commands/hook.js";
5
+ import { runInit } from "./commands/init.js";
6
+ import { runLink } from "./commands/link.js";
7
+ import { runLogin } from "./commands/login.js";
4
8
  import { runObserve } from "./commands/observe.js";
5
9
  import { formatError } from "./errors.js";
6
10
  import { cliVersion, helpFor, isCommand, USAGE } from "./help.js";
@@ -26,12 +30,20 @@ async function dispatch(command, argv) {
26
30
  return;
27
31
  }
28
32
  switch (command) {
33
+ case "login":
34
+ return runLogin(argv);
29
35
  case "declare":
30
36
  return runDeclare(argv);
31
37
  case "observe":
32
38
  return runObserve(argv);
33
39
  case "configure":
34
40
  return runConfigure(argv);
41
+ case "link":
42
+ return runLink(argv);
43
+ case "init":
44
+ return runInit(argv);
45
+ case "hook":
46
+ return runHook(argv);
35
47
  default:
36
48
  process.stderr.write(`${USAGE}\nRun \`gatebolt --help\` for details.\n`);
37
49
  process.exitCode = 1;
package/dist/profiles.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
+ import { isUuid } from "./contracts.js";
4
5
  import { isRecord } from "./json.js";
5
- const POINTER_FILE = ".gatebolt";
6
+ export const REPO_LINK_FILE = ".gatebolt";
6
7
  const CONFIG_FILE_MODE = 0o600;
7
8
  function configPath() {
8
9
  return (nonEmpty(process.env.GATEBOLT_CONFIG) ??
@@ -28,14 +29,15 @@ export function parseConfig(raw) {
28
29
  function parseProfile(value) {
29
30
  if (!isRecord(value))
30
31
  return null;
31
- const { url, repo, key } = value;
32
- if (typeof repo !== "string" || typeof key !== "string") {
32
+ const { url, key } = value;
33
+ if (typeof key !== "string")
33
34
  return null;
34
- }
35
- if (url !== undefined && typeof url !== "string") {
35
+ if (url !== undefined && typeof url !== "string")
36
36
  return null;
37
- }
38
- return url === undefined ? { repo, key } : { url, repo, key };
37
+ const profile = { key };
38
+ if (url !== undefined)
39
+ profile.url = url;
40
+ return profile;
39
41
  }
40
42
  export function selectProfileName(config, inputs) {
41
43
  const explicit = nonEmpty(inputs.name) ?? nonEmpty(inputs.env) ?? nonEmpty(inputs.pointer);
@@ -51,21 +53,25 @@ export async function resolveProfile(opts) {
51
53
  if (!config)
52
54
  return null;
53
55
  const explicit = nonEmpty(opts.name) ?? nonEmpty(process.env.GATEBOLT_PROFILE);
54
- const pointer = await readPointer(opts.root);
56
+ const pointer = await readLinkedProfile(opts.root);
57
+ const preference = configured(config, nonEmpty(opts.preferred)) ?? pointer;
55
58
  const name = selectProfileName(config, {
56
59
  name: opts.name,
57
60
  env: process.env.GATEBOLT_PROFILE,
58
- pointer,
61
+ pointer: preference,
59
62
  });
60
63
  if (!name)
61
64
  return null;
62
65
  const profile = config.profiles[name];
63
66
  if (profile)
64
- return profile;
65
- if (!explicit && name === pointer)
67
+ return { name, ...profile };
68
+ if (!explicit && name === preference)
66
69
  return null;
67
70
  throw new Error(`Profile "${name}" is not in ${configPath()}. Run \`gatebolt configure --profile ${name}\`.`);
68
71
  }
72
+ function configured(config, name) {
73
+ return name && config.profiles[name] ? name : undefined;
74
+ }
69
75
  export function profileLoader(opts) {
70
76
  let cached;
71
77
  return () => (cached ??= resolveProfile(opts));
@@ -102,14 +108,43 @@ async function loadConfig() {
102
108
  }
103
109
  return config;
104
110
  }
105
- async function readPointer(root) {
111
+ async function readLinkedProfile(root) {
106
112
  try {
107
- return nonEmpty((await readFile(join(root, POINTER_FILE), "utf8")).trim());
113
+ const link = await readRepoLink(root);
114
+ return link?.profile;
108
115
  }
109
116
  catch {
110
117
  return undefined;
111
118
  }
112
119
  }
120
+ export async function readRepoLink(root) {
121
+ let raw;
122
+ try {
123
+ raw = await readFile(join(root, REPO_LINK_FILE), "utf8");
124
+ }
125
+ catch (error) {
126
+ if (isNotFound(error))
127
+ return null;
128
+ throw error;
129
+ }
130
+ const data = safeJsonParse(raw);
131
+ if (!isRecord(data) ||
132
+ typeof data.repositoryId !== "string" ||
133
+ !isUuid(data.repositoryId)) {
134
+ throw new Error(`${join(root, REPO_LINK_FILE)} is malformed — it should hold {"repositoryId": "<uuid>"}. ` +
135
+ "Run `gatebolt link` from the repo root to rewrite it, then commit it.");
136
+ }
137
+ const link = { repositoryId: data.repositoryId };
138
+ if (typeof data.profile === "string") {
139
+ link.profile = data.profile;
140
+ }
141
+ return link;
142
+ }
143
+ export async function writeRepoLink(root, link) {
144
+ const path = join(root, REPO_LINK_FILE);
145
+ await writeFile(path, JSON.stringify(link, null, 2) + "\n", "utf8");
146
+ return path;
147
+ }
113
148
  function safeJsonParse(raw) {
114
149
  try {
115
150
  return JSON.parse(raw);
package/dist/rest.js ADDED
@@ -0,0 +1,49 @@
1
+ const AUTH_BASE_PATH = "/api/auth";
2
+ const REQUEST_TIMEOUT_MS = 30_000;
3
+ export async function authFetch(baseUrl, path, request) {
4
+ let response;
5
+ try {
6
+ response = await fetch(buildUrl(baseUrl, path, request.query), {
7
+ method: request.method,
8
+ headers: buildHeaders(request),
9
+ body: request.body === undefined ? undefined : JSON.stringify(request.body),
10
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
11
+ });
12
+ }
13
+ catch (error) {
14
+ throw connectionError(baseUrl, error);
15
+ }
16
+ return { status: response.status, json: await readJson(response) };
17
+ }
18
+ function buildUrl(baseUrl, path, query) {
19
+ const url = new URL(`${baseUrl}${AUTH_BASE_PATH}${path}`);
20
+ for (const [name, value] of Object.entries(query ?? {})) {
21
+ url.searchParams.set(name, value);
22
+ }
23
+ return url.toString();
24
+ }
25
+ function buildHeaders(request) {
26
+ const headers = {};
27
+ if (request.body !== undefined)
28
+ headers["content-type"] = "application/json";
29
+ if (request.token)
30
+ headers.authorization = `Bearer ${request.token}`;
31
+ return headers;
32
+ }
33
+ async function readJson(response) {
34
+ const text = await response.text();
35
+ if (!text)
36
+ return null;
37
+ try {
38
+ return JSON.parse(text);
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ function connectionError(baseUrl, error) {
45
+ if (error instanceof Error && error.name === "TimeoutError") {
46
+ return new Error(`Timed out waiting for ${baseUrl}.`);
47
+ }
48
+ return new Error(`Could not reach ${baseUrl}.`);
49
+ }
package/dist/session.js CHANGED
@@ -26,11 +26,12 @@ export function writeSession(gitDir, session) {
26
26
  return writeJson(gitDir, SESSION_FILE, session);
27
27
  }
28
28
  export function readSession(gitDir) {
29
- return readJson(gitDir, SESSION_FILE, ({ changeId, baseSha, branch }) => typeof changeId === "string" && typeof baseSha === "string"
29
+ return readJson(gitDir, SESSION_FILE, ({ changeId, baseSha, branch, profile }) => typeof changeId === "string" && typeof baseSha === "string"
30
30
  ? {
31
31
  changeId,
32
32
  baseSha,
33
33
  branch: typeof branch === "string" ? branch : undefined,
34
+ profile: typeof profile === "string" ? profile : undefined,
34
35
  }
35
36
  : null);
36
37
  }
package/dist/transport.js CHANGED
@@ -3,11 +3,15 @@ const REQUEST_TIMEOUT_MS = 30_000;
3
3
  export class RequestError extends Error {
4
4
  status;
5
5
  code;
6
- constructor(message, status, code) {
6
+ procedure;
7
+ fieldErrors;
8
+ constructor(message, status, code, details) {
7
9
  super(message);
8
10
  this.status = status;
9
11
  this.code = code;
10
12
  this.name = "RequestError";
13
+ this.procedure = details.procedure;
14
+ this.fieldErrors = details.fieldErrors ?? [];
11
15
  }
12
16
  }
13
17
  export async function postChange(connection, procedure, input) {
@@ -24,7 +28,7 @@ export async function postChange(connection, procedure, input) {
24
28
  });
25
29
  }
26
30
  catch (error) {
27
- throw connectionError(connection.baseUrl, error);
31
+ throw connectionError(connection.baseUrl, error, procedure);
28
32
  }
29
33
  let body;
30
34
  try {
@@ -32,32 +36,38 @@ export async function postChange(connection, procedure, input) {
32
36
  }
33
37
  catch (error) {
34
38
  if (!(error instanceof SyntaxError)) {
35
- throw connectionError(connection.baseUrl, error);
39
+ throw connectionError(connection.baseUrl, error, procedure);
36
40
  }
37
41
  body = null;
38
42
  }
39
43
  if (!response.ok) {
40
- throw toRequestError(response.status, body);
44
+ throw toRequestError(response.status, body, procedure);
41
45
  }
42
- return extractResult(body);
46
+ return extractResult(body, procedure);
43
47
  }
44
- function connectionError(baseUrl, error) {
48
+ function connectionError(baseUrl, error, procedure) {
45
49
  if (error instanceof Error && error.name === "TimeoutError") {
46
- return new RequestError(`Timed out waiting for ${baseUrl}.`, 0, "TIMEOUT");
50
+ return new RequestError(`Timed out waiting for ${baseUrl}.`, 0, "TIMEOUT", {
51
+ procedure,
52
+ });
47
53
  }
48
- return new RequestError(`Could not reach ${baseUrl}.`, 0, "NETWORK");
54
+ return new RequestError(`Could not reach ${baseUrl}.`, 0, "NETWORK", {
55
+ procedure,
56
+ });
49
57
  }
50
- function toRequestError(status, body) {
58
+ function toRequestError(status, body, procedure) {
51
59
  const parsed = parseTrpcError(body);
52
- return new RequestError(parsed?.message ?? `Request failed with status ${status}.`, status, parsed?.code ?? "UNKNOWN");
60
+ return new RequestError(parsed?.message ?? `Request failed with status ${status}.`, status, parsed?.code ?? "UNKNOWN", { procedure, fieldErrors: parsed?.fieldErrors ?? [] });
53
61
  }
54
- export function extractResult(body) {
62
+ export function extractResult(body, procedure) {
55
63
  const result = isRecord(body) ? body.result : undefined;
56
64
  const data = isRecord(result) ? result.data : undefined;
57
65
  if (isRecord(data) && data.json !== undefined) {
58
66
  return data.json;
59
67
  }
60
- throw new RequestError("Unexpected response from server.", 0, "MALFORMED");
68
+ throw new RequestError("Unexpected response from server.", 0, "MALFORMED", {
69
+ procedure,
70
+ });
61
71
  }
62
72
  function parseTrpcError(body) {
63
73
  const error = isRecord(body) ? body.error : undefined;
@@ -68,5 +78,15 @@ function parseTrpcError(body) {
68
78
  return {
69
79
  message: typeof json.message === "string" ? json.message : undefined,
70
80
  code: isRecord(data) && typeof data.code === "string" ? data.code : undefined,
81
+ fieldErrors: parseFieldErrors(data),
71
82
  };
72
83
  }
84
+ function parseFieldErrors(data) {
85
+ const zodError = isRecord(data) ? data.zodError : undefined;
86
+ const fieldErrors = isRecord(zodError) ? zodError.fieldErrors : undefined;
87
+ if (!isRecord(fieldErrors))
88
+ return [];
89
+ return Object.values(fieldErrors)
90
+ .flat()
91
+ .filter((message) => typeof message === "string");
92
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gatebolt/cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Declare-then-observe agent client for GateBolt — records the files an AI agent intends to touch, then reconciles the actual git diff.",
5
5
  "keywords": [
6
6
  "gatebolt",
@@ -22,8 +22,7 @@
22
22
  "gatebolt": "./dist/index.js"
23
23
  },
24
24
  "files": [
25
- "dist",
26
- "recipes"
25
+ "dist"
27
26
  ],
28
27
  "engines": {
29
28
  "node": ">=20"