@gatebolt/cli 0.3.1 → 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 +68 -19
- package/dist/agents/claude-code.js +183 -0
- package/dist/commands/configure.js +30 -13
- package/dist/commands/declare.js +54 -12
- 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 +46 -14
- package/dist/config.js +48 -12
- package/dist/contracts.js +6 -0
- package/dist/diff.js +6 -4
- package/dist/errors.js +35 -0
- package/dist/git.js +26 -1
- package/dist/help.js +161 -0
- package/dist/index.js +42 -43
- 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 +3 -4
- 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
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { delimiter, dirname, isAbsolute, join, relative } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
import { claudeCodeAdapter } from "../agents/claude-code.js";
|
|
7
|
+
import { repoIdentityFromName, resolveConnection } from "../config.js";
|
|
8
|
+
import { hooksPath, repoContext } from "../git.js";
|
|
9
|
+
import { isRecord } from "../json.js";
|
|
10
|
+
import { profileLoader, readRepoLink } from "../profiles.js";
|
|
11
|
+
import { deriveIdentity, linkRepository } from "./link.js";
|
|
12
|
+
const ADAPTERS = [claudeCodeAdapter];
|
|
13
|
+
const ENFORCE_VALUES = ["lenient", "strict"];
|
|
14
|
+
const PRE_PUSH_FILE = "pre-push";
|
|
15
|
+
const PRE_PUSH_MARKER = "GateBolt pre-push backstop";
|
|
16
|
+
const HOOK_MODE = 0o755;
|
|
17
|
+
const ID_PREFIX_LEN = 8;
|
|
18
|
+
const GITIGNORE_ENTRY = ".claude/settings.local.json";
|
|
19
|
+
const NO_REMOTE = "GateBolt cannot derive this repository's identity from its `origin` remote. Re-run with --name <a-name-for-this-repo>.";
|
|
20
|
+
const NO_KEY = "No GateBolt key found. Run `gatebolt login` (or `gatebolt configure`) first, then re-run `gatebolt init`.";
|
|
21
|
+
const EPHEMERAL_CLI = "GateBolt is running from a temporary npx/dlx cache, so the hooks would break when the package manager prunes it. Install it durably first — `npm i -g @gatebolt/cli`, or add it as a project dependency so `gatebolt` is on PATH — then re-run `gatebolt init`. To link only, pass --no-hooks --no-pre-push.";
|
|
22
|
+
export async function runInit(argv) {
|
|
23
|
+
const { values } = parseArgs({
|
|
24
|
+
args: argv,
|
|
25
|
+
options: {
|
|
26
|
+
profile: { type: "string" },
|
|
27
|
+
name: { type: "string" },
|
|
28
|
+
enforce: { type: "string" },
|
|
29
|
+
"no-hooks": { type: "boolean" },
|
|
30
|
+
"no-pre-push": { type: "boolean" },
|
|
31
|
+
force: { type: "boolean" },
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
const enforce = parseEnforce(values.enforce);
|
|
35
|
+
const repo = await repoContext();
|
|
36
|
+
if (!repo) {
|
|
37
|
+
throw new Error("Not inside a git repository.");
|
|
38
|
+
}
|
|
39
|
+
const loadProfile = profileLoader({ name: values.profile, root: repo.root });
|
|
40
|
+
const connection = await connect(loadProfile);
|
|
41
|
+
const link = await ensureLink(connection, repo.root, values.name);
|
|
42
|
+
const linkOnly = Boolean(values["no-hooks"] && values["no-pre-push"]);
|
|
43
|
+
const command = linkOnly ? "" : resolveCommand(repo.root);
|
|
44
|
+
const wired = values["no-hooks"]
|
|
45
|
+
? []
|
|
46
|
+
: await wireAgents(repo.root, command, enforce);
|
|
47
|
+
const prePush = values["no-pre-push"]
|
|
48
|
+
? null
|
|
49
|
+
: await installPrePush(command, Boolean(values.force));
|
|
50
|
+
process.stdout.write(summary({
|
|
51
|
+
root: repo.root,
|
|
52
|
+
link,
|
|
53
|
+
wired,
|
|
54
|
+
prePush,
|
|
55
|
+
enforce: wired[0]?.result.enforce ?? enforce ?? "lenient",
|
|
56
|
+
noHooks: Boolean(values["no-hooks"]),
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
export function parseEnforce(value) {
|
|
60
|
+
if (value === undefined)
|
|
61
|
+
return undefined;
|
|
62
|
+
if (ENFORCE_VALUES.includes(value)) {
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
throw new Error(`Invalid --enforce '${value}'. Expected one of: ${ENFORCE_VALUES.join(", ")}.`);
|
|
66
|
+
}
|
|
67
|
+
async function connect(loadProfile) {
|
|
68
|
+
try {
|
|
69
|
+
return await resolveConnection({}, loadProfile);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (error instanceof Error && error.message.startsWith("Missing API key")) {
|
|
73
|
+
throw new Error(NO_KEY);
|
|
74
|
+
}
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async function ensureLink(connection, root, name) {
|
|
79
|
+
const existing = await readRepoLink(root);
|
|
80
|
+
if (existing) {
|
|
81
|
+
return { repositoryId: existing.repositoryId, existed: true };
|
|
82
|
+
}
|
|
83
|
+
const identity = name ? repoIdentityFromName(name) : await deriveIdentity();
|
|
84
|
+
if (!identity) {
|
|
85
|
+
throw new Error(NO_REMOTE);
|
|
86
|
+
}
|
|
87
|
+
const result = await linkRepository(connection, root, identity);
|
|
88
|
+
return {
|
|
89
|
+
repositoryId: result.repositoryId,
|
|
90
|
+
existed: false,
|
|
91
|
+
name: result.name,
|
|
92
|
+
slug: result.slug,
|
|
93
|
+
created: result.created,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function resolveCommand(root) {
|
|
97
|
+
const cli = fileURLToPath(new URL("../index.js", import.meta.url));
|
|
98
|
+
if (isEphemeralPath(cli)) {
|
|
99
|
+
throw new Error(EPHEMERAL_CLI);
|
|
100
|
+
}
|
|
101
|
+
if (onDurablePath("gatebolt", root))
|
|
102
|
+
return "gatebolt";
|
|
103
|
+
return `${quote(process.execPath)} ${quote(cli)}`;
|
|
104
|
+
}
|
|
105
|
+
// npx/dlx run from throwaway caches that get pruned, breaking any hook wired
|
|
106
|
+
// here: npm marks them with an `_npx` segment, pnpm with `dlx`, yarn with `xfs-…`.
|
|
107
|
+
export function isEphemeralPath(cliPath) {
|
|
108
|
+
return cliPath
|
|
109
|
+
.split(/[/\\]/)
|
|
110
|
+
.some((segment) => segment === "_npx" || segment === "dlx" || segment.startsWith("xfs-"));
|
|
111
|
+
}
|
|
112
|
+
export function onDurablePath(bin, root) {
|
|
113
|
+
const names = process.platform === "win32" ? [`${bin}.cmd`, `${bin}.exe`, bin] : [bin];
|
|
114
|
+
return (process.env.PATH ?? "")
|
|
115
|
+
.split(delimiter)
|
|
116
|
+
.filter(Boolean)
|
|
117
|
+
.filter((dir) => !isInsideRoot(dir, root))
|
|
118
|
+
.some((dir) => names.some((name) => existsSync(join(dir, name))));
|
|
119
|
+
}
|
|
120
|
+
function isInsideRoot(dir, root) {
|
|
121
|
+
const rel = relative(root, dir);
|
|
122
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
123
|
+
}
|
|
124
|
+
function quote(value) {
|
|
125
|
+
return /\s/.test(value) ? `"${value}"` : value;
|
|
126
|
+
}
|
|
127
|
+
async function wireAgents(root, command, enforce) {
|
|
128
|
+
const wired = [];
|
|
129
|
+
for (const adapter of ADAPTERS) {
|
|
130
|
+
const result = await adapter.wire(root, { command, enforce });
|
|
131
|
+
wired.push({ displayName: adapter.displayName, result });
|
|
132
|
+
}
|
|
133
|
+
return wired;
|
|
134
|
+
}
|
|
135
|
+
async function installPrePush(command, force) {
|
|
136
|
+
const path = join(await hooksPath(), PRE_PUSH_FILE);
|
|
137
|
+
const existing = await readIfExists(path);
|
|
138
|
+
const foreign = existing !== null && !existing.includes(PRE_PUSH_MARKER);
|
|
139
|
+
if (foreign && !force) {
|
|
140
|
+
return { path, status: "skipped" };
|
|
141
|
+
}
|
|
142
|
+
const script = prePushScript(command);
|
|
143
|
+
if (existing === script) {
|
|
144
|
+
return { path, status: "unchanged" };
|
|
145
|
+
}
|
|
146
|
+
await mkdir(dirname(path), { recursive: true });
|
|
147
|
+
await writeFile(path, script, "utf8");
|
|
148
|
+
await chmod(path, HOOK_MODE);
|
|
149
|
+
return { path, status: existing === null ? "installed" : "updated" };
|
|
150
|
+
}
|
|
151
|
+
export function prePushScript(command) {
|
|
152
|
+
return [
|
|
153
|
+
"#!/usr/bin/env sh",
|
|
154
|
+
`# ${PRE_PUSH_MARKER} — reconciles an open declaration before a push.`,
|
|
155
|
+
`exec ${command} hook ${PRE_PUSH_FILE}`,
|
|
156
|
+
"",
|
|
157
|
+
].join("\n");
|
|
158
|
+
}
|
|
159
|
+
async function readIfExists(path) {
|
|
160
|
+
try {
|
|
161
|
+
return await readFile(path, "utf8");
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
if (isRecord(error) && error.code === "ENOENT")
|
|
165
|
+
return null;
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function summary(parts) {
|
|
170
|
+
const lines = [
|
|
171
|
+
linkLine(parts.link),
|
|
172
|
+
...hookLines(parts.root, parts.wired, parts.noHooks),
|
|
173
|
+
prePushLine(parts.root, parts.prePush),
|
|
174
|
+
"",
|
|
175
|
+
enforceLine(parts.enforce),
|
|
176
|
+
];
|
|
177
|
+
if (!parts.link.existed) {
|
|
178
|
+
lines.push("Commit .gatebolt so every clone and CI job resolves the same repository.");
|
|
179
|
+
}
|
|
180
|
+
return lines.join("\n") + "\n";
|
|
181
|
+
}
|
|
182
|
+
function linkLine(link) {
|
|
183
|
+
if (link.existed) {
|
|
184
|
+
return `Already linked (repository ${link.repositoryId.slice(0, ID_PREFIX_LEN)}…).`;
|
|
185
|
+
}
|
|
186
|
+
const verb = link.created ? "Created" : "Linked to existing";
|
|
187
|
+
return `${verb} repository ${link.name} (${link.slug}).`;
|
|
188
|
+
}
|
|
189
|
+
function hookLines(root, wired, noHooks) {
|
|
190
|
+
if (noHooks)
|
|
191
|
+
return ["Skipped agent hooks (--no-hooks)."];
|
|
192
|
+
const lines = [];
|
|
193
|
+
for (const { displayName, result } of wired) {
|
|
194
|
+
const verb = result.settingsChanged ? "wired" : "already wired";
|
|
195
|
+
lines.push(`${displayName}: ${verb} → ${relative(root, result.settingsPath)}`);
|
|
196
|
+
if (result.gitignoreChanged) {
|
|
197
|
+
lines.push(` ignored ${GITIGNORE_ENTRY} in .gitignore`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return lines;
|
|
201
|
+
}
|
|
202
|
+
function prePushLine(root, prePush) {
|
|
203
|
+
if (prePush === null)
|
|
204
|
+
return "Skipped pre-push backstop (--no-pre-push).";
|
|
205
|
+
const rel = relative(root, prePush.path);
|
|
206
|
+
switch (prePush.status) {
|
|
207
|
+
case "installed":
|
|
208
|
+
return `Installed pre-push backstop → ${rel}`;
|
|
209
|
+
case "updated":
|
|
210
|
+
return `Updated pre-push backstop → ${rel}`;
|
|
211
|
+
case "unchanged":
|
|
212
|
+
return `Pre-push backstop already installed → ${rel}`;
|
|
213
|
+
case "skipped":
|
|
214
|
+
return `Left the existing pre-push hook untouched (${rel}); pass --force to replace it.`;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function enforceLine(enforce) {
|
|
218
|
+
return enforce === "strict"
|
|
219
|
+
? "Enforcement: strict — edits are blocked until you declare."
|
|
220
|
+
: "Enforcement: lenient — declaring is advisory. Pass `--enforce strict` to block edits until declared.";
|
|
221
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
import { parseRemoteUrl, repoIdentityFromName, resolveConnection, } from "../config.js";
|
|
3
|
+
import { remoteOriginUrl, repoContext } from "../git.js";
|
|
4
|
+
import { isRecord } from "../json.js";
|
|
5
|
+
import { profileLoader, writeRepoLink } from "../profiles.js";
|
|
6
|
+
import { postChange } from "../transport.js";
|
|
7
|
+
export async function runLink(argv) {
|
|
8
|
+
const { values } = parseArgs({
|
|
9
|
+
args: argv,
|
|
10
|
+
options: {
|
|
11
|
+
key: { type: "string" },
|
|
12
|
+
url: { type: "string" },
|
|
13
|
+
profile: { type: "string" },
|
|
14
|
+
name: { type: "string" },
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
const repo = await repoContext();
|
|
18
|
+
if (!repo) {
|
|
19
|
+
throw new Error("Not inside a git repository.");
|
|
20
|
+
}
|
|
21
|
+
const { root } = repo;
|
|
22
|
+
const loadProfile = profileLoader({ name: values.profile, root });
|
|
23
|
+
const connection = await resolveConnection({ key: values.key, url: values.url }, loadProfile);
|
|
24
|
+
const identity = values.name
|
|
25
|
+
? repoIdentityFromName(values.name)
|
|
26
|
+
: ((await deriveIdentity()) ?? raiseNoRemote());
|
|
27
|
+
const result = await linkRepository(connection, root, identity);
|
|
28
|
+
const verb = result.created ? "Created" : "Linked to existing";
|
|
29
|
+
process.stdout.write(`${verb} repository ${result.name} (${result.slug}).\n` +
|
|
30
|
+
"Wrote .gatebolt — commit it so every clone and CI job resolves the same repository.\n");
|
|
31
|
+
}
|
|
32
|
+
export async function deriveIdentity() {
|
|
33
|
+
const url = await remoteOriginUrl();
|
|
34
|
+
return url ? parseRemoteUrl(url) : null;
|
|
35
|
+
}
|
|
36
|
+
export async function linkRepository(connection, root, identity) {
|
|
37
|
+
const input = {
|
|
38
|
+
slug: identity.slug,
|
|
39
|
+
name: identity.name,
|
|
40
|
+
};
|
|
41
|
+
const result = await postChange(connection, "repository.link", input);
|
|
42
|
+
if (!isLinkResult(result)) {
|
|
43
|
+
throw new Error("Unexpected response from server.");
|
|
44
|
+
}
|
|
45
|
+
await writeRepoLink(root, {
|
|
46
|
+
repositoryId: result.repositoryId,
|
|
47
|
+
...(connection.profileName ? { profile: connection.profileName } : {}),
|
|
48
|
+
});
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
function raiseNoRemote() {
|
|
52
|
+
throw new Error("GateBolt cannot derive this repository's identity from its `origin` remote. " +
|
|
53
|
+
"Pass --name <a-name-for-this-repo> to name it yourself.");
|
|
54
|
+
}
|
|
55
|
+
export function isLinkResult(value) {
|
|
56
|
+
return (isRecord(value) &&
|
|
57
|
+
typeof value.repositoryId === "string" &&
|
|
58
|
+
typeof value.slug === "string" &&
|
|
59
|
+
typeof value.name === "string" &&
|
|
60
|
+
typeof value.created === "boolean");
|
|
61
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { hostname } from "node:os";
|
|
3
|
+
import { stdin, stdout } from "node:process";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
import { DEFAULT_BASE_URL } from "../config.js";
|
|
7
|
+
import { isRecord } from "../json.js";
|
|
8
|
+
import { readConfigForWrite, writeConfig } from "../profiles.js";
|
|
9
|
+
import { authFetch } from "../rest.js";
|
|
10
|
+
import { postChange, RequestError } from "../transport.js";
|
|
11
|
+
const DEVICE_CLIENT_ID = "gatebolt-cli";
|
|
12
|
+
const DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
|
|
13
|
+
const SLOW_DOWN_INCREMENT_MS = 5_000;
|
|
14
|
+
const MS_PER_SECOND = 1_000;
|
|
15
|
+
const HTTP_OK = 200;
|
|
16
|
+
const HTTP_SERVER_ERROR = 500;
|
|
17
|
+
const MAX_UNREACHABLE_POLLS = 5;
|
|
18
|
+
export async function runLogin(argv) {
|
|
19
|
+
const { values } = parseArgs({
|
|
20
|
+
args: argv,
|
|
21
|
+
options: {
|
|
22
|
+
url: { type: "string" },
|
|
23
|
+
"key-name": { type: "string" },
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
const baseUrl = resolveBaseUrl(values.url);
|
|
27
|
+
const requestedName = values["key-name"]?.trim();
|
|
28
|
+
const keyName = requestedName !== undefined && requestedName.length > 0
|
|
29
|
+
? requestedName
|
|
30
|
+
: defaultKeyName();
|
|
31
|
+
const code = await requestDeviceCode(baseUrl);
|
|
32
|
+
announce(code);
|
|
33
|
+
openBrowser(code.verification_uri_complete);
|
|
34
|
+
const token = await pollForToken(baseUrl, code);
|
|
35
|
+
const orgs = await listOrganizations(baseUrl, token);
|
|
36
|
+
const { org, key } = await mintForSelectedOrg(baseUrl, token, orgs, keyName);
|
|
37
|
+
const path = await saveLoginProfile(org.slug, baseUrl, key);
|
|
38
|
+
stdout.write(`\nSaved profile "${org.slug}" to ${path}.\n` +
|
|
39
|
+
`GateBolt is connected to ${org.name}. In each repository, run \`gatebolt link\` once ` +
|
|
40
|
+
`and commit the .gatebolt it writes, so declare and observe record against it.\n`);
|
|
41
|
+
}
|
|
42
|
+
function resolveBaseUrl(flag) {
|
|
43
|
+
const url = flag ?? process.env.GATEBOLT_URL ?? DEFAULT_BASE_URL;
|
|
44
|
+
return url.replace(/\/+$/, "");
|
|
45
|
+
}
|
|
46
|
+
function defaultKeyName() {
|
|
47
|
+
const [label] = hostname().split(".");
|
|
48
|
+
const host = label && label.length > 0 ? label : "device";
|
|
49
|
+
return `gatebolt-cli ${host}`;
|
|
50
|
+
}
|
|
51
|
+
async function requestDeviceCode(baseUrl) {
|
|
52
|
+
const result = await authFetch(baseUrl, "/device/code", {
|
|
53
|
+
method: "POST",
|
|
54
|
+
body: { client_id: DEVICE_CLIENT_ID },
|
|
55
|
+
});
|
|
56
|
+
const code = parseDeviceCode(result.json);
|
|
57
|
+
if (result.status !== HTTP_OK || !code) {
|
|
58
|
+
throw new Error(`Could not start the login (status ${result.status}).`);
|
|
59
|
+
}
|
|
60
|
+
return code;
|
|
61
|
+
}
|
|
62
|
+
function announce(code) {
|
|
63
|
+
stdout.write(`\nTo authorize the GateBolt CLI, open:\n ${code.verification_uri_complete}\n\n` +
|
|
64
|
+
`Your code: ${code.user_code}\n` +
|
|
65
|
+
`(If your browser doesn't open, visit ${code.verification_uri} and enter the code.)\n\n` +
|
|
66
|
+
"Waiting for you to approve in the browser…\n");
|
|
67
|
+
}
|
|
68
|
+
async function pollForToken(baseUrl, code) {
|
|
69
|
+
let intervalMs = code.interval * MS_PER_SECOND;
|
|
70
|
+
const deadline = Date.now() + code.expires_in * MS_PER_SECOND;
|
|
71
|
+
let unreachablePolls = 0;
|
|
72
|
+
while (Date.now() < deadline) {
|
|
73
|
+
await sleep(intervalMs);
|
|
74
|
+
const outcome = await pollOnce(baseUrl, code.device_code);
|
|
75
|
+
if (outcome.kind === "token")
|
|
76
|
+
return outcome.token;
|
|
77
|
+
if (outcome.kind === "fatal")
|
|
78
|
+
throw new Error(outcome.message);
|
|
79
|
+
if (outcome.kind === "unreachable") {
|
|
80
|
+
unreachablePolls += 1;
|
|
81
|
+
if (unreachablePolls >= MAX_UNREACHABLE_POLLS)
|
|
82
|
+
throw new Error(outcome.message);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
unreachablePolls = 0;
|
|
86
|
+
if (outcome.kind === "slow_down")
|
|
87
|
+
intervalMs += SLOW_DOWN_INCREMENT_MS;
|
|
88
|
+
}
|
|
89
|
+
throw new Error("Timed out waiting for approval. Run `gatebolt login` again when you're ready.");
|
|
90
|
+
}
|
|
91
|
+
async function pollOnce(baseUrl, deviceCode) {
|
|
92
|
+
try {
|
|
93
|
+
return interpretToken(await requestToken(baseUrl, deviceCode));
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
97
|
+
return { kind: "unreachable", message };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function requestToken(baseUrl, deviceCode) {
|
|
101
|
+
return authFetch(baseUrl, "/device/token", {
|
|
102
|
+
method: "POST",
|
|
103
|
+
body: {
|
|
104
|
+
grant_type: DEVICE_GRANT_TYPE,
|
|
105
|
+
device_code: deviceCode,
|
|
106
|
+
client_id: DEVICE_CLIENT_ID,
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
function interpretToken(result) {
|
|
111
|
+
if (result.status === HTTP_OK) {
|
|
112
|
+
const token = accessToken(result.json);
|
|
113
|
+
return token
|
|
114
|
+
? { kind: "token", token }
|
|
115
|
+
: {
|
|
116
|
+
kind: "fatal",
|
|
117
|
+
message: "The server returned an unexpected response.",
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
if (result.status >= HTTP_SERVER_ERROR) {
|
|
121
|
+
return {
|
|
122
|
+
kind: "unreachable",
|
|
123
|
+
message: `The server is temporarily unavailable (status ${result.status}).`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
switch (tokenError(result.json)) {
|
|
127
|
+
case "authorization_pending":
|
|
128
|
+
return { kind: "pending" };
|
|
129
|
+
case "slow_down":
|
|
130
|
+
return { kind: "slow_down" };
|
|
131
|
+
case "access_denied":
|
|
132
|
+
return {
|
|
133
|
+
kind: "fatal",
|
|
134
|
+
message: "Authorization was denied in the browser.",
|
|
135
|
+
};
|
|
136
|
+
case "expired_token":
|
|
137
|
+
return {
|
|
138
|
+
kind: "fatal",
|
|
139
|
+
message: "The code expired before it was approved. Run `gatebolt login` again.",
|
|
140
|
+
};
|
|
141
|
+
default:
|
|
142
|
+
return {
|
|
143
|
+
kind: "fatal",
|
|
144
|
+
message: `Authorization failed (status ${result.status}).`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
async function listOrganizations(baseUrl, token) {
|
|
149
|
+
const result = await authFetch(baseUrl, "/organization/list", {
|
|
150
|
+
method: "GET",
|
|
151
|
+
token,
|
|
152
|
+
});
|
|
153
|
+
const orgs = parseOrganizations(result.json);
|
|
154
|
+
if (result.status !== HTTP_OK || !orgs) {
|
|
155
|
+
throw new Error(`Could not list your organizations (status ${result.status}).`);
|
|
156
|
+
}
|
|
157
|
+
if (orgs.length === 0) {
|
|
158
|
+
throw new Error("Your account has no organization yet. Create one in the dashboard, then run `gatebolt login` again.");
|
|
159
|
+
}
|
|
160
|
+
return orgs;
|
|
161
|
+
}
|
|
162
|
+
async function mintForSelectedOrg(baseUrl, token, orgs, keyName) {
|
|
163
|
+
const org = await activateChoice(baseUrl, token, orgs);
|
|
164
|
+
const first = await tryMint(baseUrl, token, keyName);
|
|
165
|
+
if (first.kind === "key")
|
|
166
|
+
return { org, key: first.key };
|
|
167
|
+
if (first.kind === "error")
|
|
168
|
+
throw first.error;
|
|
169
|
+
if (orgs.length === 1)
|
|
170
|
+
throw notAdminError(org);
|
|
171
|
+
stdout.write(`\nYou're not an admin of ${org.slug}, so a key can't be created there.\n`);
|
|
172
|
+
const retryOrg = await activateChoice(baseUrl, token, orgs);
|
|
173
|
+
const second = await tryMint(baseUrl, token, keyName);
|
|
174
|
+
if (second.kind === "key")
|
|
175
|
+
return { org: retryOrg, key: second.key };
|
|
176
|
+
if (second.kind === "error")
|
|
177
|
+
throw second.error;
|
|
178
|
+
throw notAdminError(retryOrg);
|
|
179
|
+
}
|
|
180
|
+
async function activateChoice(baseUrl, token, orgs) {
|
|
181
|
+
const org = await chooseOrg(orgs);
|
|
182
|
+
if (orgs.length > 1)
|
|
183
|
+
await setActiveOrg(baseUrl, token, org.id);
|
|
184
|
+
return org;
|
|
185
|
+
}
|
|
186
|
+
async function chooseOrg(orgs) {
|
|
187
|
+
const [first, ...rest] = orgs;
|
|
188
|
+
if (!first)
|
|
189
|
+
throw new Error("No organizations to choose from.");
|
|
190
|
+
if (rest.length === 0)
|
|
191
|
+
return first;
|
|
192
|
+
stdout.write("\nYou belong to more than one organization:\n");
|
|
193
|
+
orgs.forEach((org, index) => {
|
|
194
|
+
stdout.write(` ${index + 1}. ${org.name} (${org.slug})\n`);
|
|
195
|
+
});
|
|
196
|
+
const answer = await prompt("Select an organization [1]: ");
|
|
197
|
+
const org = orgs[answer === "" ? 0 : Number(answer) - 1];
|
|
198
|
+
if (!org) {
|
|
199
|
+
throw new Error(`"${answer}" is not one of the listed organizations.`);
|
|
200
|
+
}
|
|
201
|
+
return org;
|
|
202
|
+
}
|
|
203
|
+
async function setActiveOrg(baseUrl, token, organizationId) {
|
|
204
|
+
const result = await authFetch(baseUrl, "/organization/set-active", {
|
|
205
|
+
method: "POST",
|
|
206
|
+
token,
|
|
207
|
+
body: { organizationId },
|
|
208
|
+
});
|
|
209
|
+
if (result.status !== HTTP_OK) {
|
|
210
|
+
throw new Error(`Could not select the organization (status ${result.status}).`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
async function tryMint(baseUrl, token, name) {
|
|
214
|
+
try {
|
|
215
|
+
const input = { name };
|
|
216
|
+
const result = await postChange({ key: token, baseUrl }, "agentKey.create", input);
|
|
217
|
+
return { kind: "key", key: result.key };
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
if (error instanceof RequestError && error.code === "FORBIDDEN") {
|
|
221
|
+
return { kind: "forbidden" };
|
|
222
|
+
}
|
|
223
|
+
return { kind: "error", error };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function notAdminError(org) {
|
|
227
|
+
return new Error(`You're not an admin of ${org.name}, so you can't create an agent key there. ` +
|
|
228
|
+
"Ask an owner or admin to run `gatebolt login`, or pick an organization where you're an admin.");
|
|
229
|
+
}
|
|
230
|
+
async function saveLoginProfile(slug, baseUrl, key) {
|
|
231
|
+
const config = await readConfigForWrite();
|
|
232
|
+
config.profiles[slug] =
|
|
233
|
+
baseUrl === DEFAULT_BASE_URL ? { key } : { url: baseUrl, key };
|
|
234
|
+
config.default = slug;
|
|
235
|
+
return writeConfig(config);
|
|
236
|
+
}
|
|
237
|
+
function openBrowser(url) {
|
|
238
|
+
const opener = browserOpener();
|
|
239
|
+
try {
|
|
240
|
+
const child = spawn(opener.command, [...opener.args, url], {
|
|
241
|
+
stdio: "ignore",
|
|
242
|
+
detached: true,
|
|
243
|
+
});
|
|
244
|
+
child.on("error", noop);
|
|
245
|
+
child.unref();
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
// The URL is printed above, so a failed auto-open is not fatal.
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
function browserOpener() {
|
|
252
|
+
if (process.platform === "darwin")
|
|
253
|
+
return { command: "open", args: [] };
|
|
254
|
+
if (process.platform === "win32")
|
|
255
|
+
return { command: "cmd", args: ["/c", "start", ""] };
|
|
256
|
+
return { command: "xdg-open", args: [] };
|
|
257
|
+
}
|
|
258
|
+
async function prompt(question) {
|
|
259
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
260
|
+
try {
|
|
261
|
+
return (await rl.question(question)).trim();
|
|
262
|
+
}
|
|
263
|
+
finally {
|
|
264
|
+
rl.close();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
function sleep(ms) {
|
|
268
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
269
|
+
}
|
|
270
|
+
function noop() {
|
|
271
|
+
// Intentionally empty: a browser-open failure is swallowed.
|
|
272
|
+
}
|
|
273
|
+
function parseDeviceCode(json) {
|
|
274
|
+
if (!isRecord(json))
|
|
275
|
+
return null;
|
|
276
|
+
if (typeof json.device_code !== "string" ||
|
|
277
|
+
typeof json.user_code !== "string" ||
|
|
278
|
+
typeof json.verification_uri !== "string" ||
|
|
279
|
+
typeof json.verification_uri_complete !== "string" ||
|
|
280
|
+
typeof json.expires_in !== "number" ||
|
|
281
|
+
typeof json.interval !== "number") {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
return {
|
|
285
|
+
device_code: json.device_code,
|
|
286
|
+
user_code: json.user_code,
|
|
287
|
+
verification_uri: json.verification_uri,
|
|
288
|
+
verification_uri_complete: json.verification_uri_complete,
|
|
289
|
+
expires_in: json.expires_in,
|
|
290
|
+
interval: json.interval,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
function parseOrganizations(json) {
|
|
294
|
+
if (!Array.isArray(json))
|
|
295
|
+
return null;
|
|
296
|
+
const orgs = [];
|
|
297
|
+
for (const item of json) {
|
|
298
|
+
if (!isRecord(item))
|
|
299
|
+
return null;
|
|
300
|
+
const { id, name, slug } = item;
|
|
301
|
+
if (typeof id !== "string" ||
|
|
302
|
+
typeof name !== "string" ||
|
|
303
|
+
typeof slug !== "string") {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
orgs.push({ id, name, slug });
|
|
307
|
+
}
|
|
308
|
+
return orgs;
|
|
309
|
+
}
|
|
310
|
+
function accessToken(json) {
|
|
311
|
+
return isRecord(json) && typeof json.access_token === "string"
|
|
312
|
+
? json.access_token
|
|
313
|
+
: null;
|
|
314
|
+
}
|
|
315
|
+
function tokenError(json) {
|
|
316
|
+
return isRecord(json) && typeof json.error === "string" ? json.error : "";
|
|
317
|
+
}
|