@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/README.md +60 -17
- package/dist/agents/claude-code.js +183 -0
- package/dist/commands/configure.js +8 -11
- package/dist/commands/declare.js +50 -9
- package/dist/commands/hook.js +128 -0
- package/dist/commands/init.js +221 -0
- package/dist/commands/link.js +61 -0
- package/dist/commands/login.js +317 -0
- package/dist/commands/observe.js +16 -10
- package/dist/config.js +48 -12
- package/dist/contracts.js +6 -0
- package/dist/diff.js +2 -1
- package/dist/errors.js +10 -8
- package/dist/git.js +17 -0
- package/dist/help.js +81 -14
- package/dist/index.js +12 -0
- package/dist/profiles.js +48 -13
- package/dist/rest.js +49 -0
- package/dist/session.js +2 -1
- package/dist/transport.js +32 -12
- package/package.json +2 -3
- package/recipes/claude-code/README.md +0 -64
- package/recipes/claude-code/hooks/gatebolt-guard.sh +0 -48
- package/recipes/claude-code/hooks/gatebolt-observe.sh +0 -24
- package/recipes/claude-code/hooks/pre-push +0 -23
- package/recipes/claude-code/settings.json +0 -25
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.
|
|
4
|
-
const url = flags.url ?? process.env.
|
|
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
|
|
9
|
+
throw new Error("Missing API key. Set GATEBOLT_KEY, pass --key, or run `gatebolt configure`.");
|
|
10
10
|
}
|
|
11
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
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
|
-
--
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
32
|
-
if (typeof
|
|
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
|
-
|
|
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
|
|
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 ===
|
|
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
|
|
111
|
+
async function readLinkedProfile(root) {
|
|
106
112
|
try {
|
|
107
|
-
|
|
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
|
-
|
|
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.
|
|
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"
|