@boundless.network/setup 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,61 @@
1
+ import { join } from "node:path";
2
+ import { parse, stringify } from "smol-toml";
3
+ import { KEY_ENV } from "../lib/config.js";
4
+ import { codexHome } from "../lib/env.js";
5
+ import { installed } from "../lib/files.js";
6
+ import { backup, readText, tilde, writePrivate } from "../lib/files.js";
7
+ export const codex = {
8
+ backgroundDefault: null,
9
+ defaultModel: "qwen3.6",
10
+ detect: () => installed("codex"),
11
+ id: "codex",
12
+ name: "Codex CLI",
13
+ plan: (input) => {
14
+ const path = join(codexHome(), "config.toml");
15
+ return Promise.resolve({
16
+ changes: [
17
+ {
18
+ apply: async () => {
19
+ const current = parse((await readText(path)) ?? "");
20
+ const providers = (current.model_providers ?? {});
21
+ const next = {
22
+ ...current,
23
+ model: input.primary,
24
+ model_provider: "boundless",
25
+ model_providers: {
26
+ ...providers,
27
+ boundless: {
28
+ base_url: `${input.gatewayUrl}/v1`,
29
+ env_key: KEY_ENV,
30
+ name: "Boundless",
31
+ wire_api: "responses",
32
+ },
33
+ },
34
+ model_reasoning_effort: "low",
35
+ model_reasoning_summary: "none",
36
+ };
37
+ const saved = await backup(path);
38
+ await writePrivate(path, stringify(next));
39
+ return { backup: saved };
40
+ },
41
+ path,
42
+ summary: `model ${input.primary}, provider boundless → ${input.gatewayUrl}/v1 (responses), key read from ${KEY_ENV}`,
43
+ },
44
+ ],
45
+ manual: [],
46
+ notes: [
47
+ `Codex keeps one configuration in ${tilde(path)}; it reads the key from ${KEY_ENV} in your shell environment.`,
48
+ ...(input.scope === "project" && input.keyFile
49
+ ? [`Project scope: export ${KEY_ENV}="$(cat ${tilde(input.keyFile)})" before running codex in this project.`]
50
+ : []),
51
+ "Comments in an existing config.toml are not preserved; the backup keeps the original.",
52
+ ],
53
+ });
54
+ },
55
+ smoke: (input) => ({
56
+ args: ["exec", "--skip-git-repo-check", "-m", input.primary, "Reply with exactly the word: boundless"],
57
+ command: "codex",
58
+ expect: /boundless/i,
59
+ }),
60
+ wire: "openai",
61
+ };
@@ -0,0 +1,43 @@
1
+ import { anyExists, home } from "../lib/files.js";
2
+ import { readExpression } from "../lib/store.js";
3
+ function reveal(input) {
4
+ if (input.keyFile) {
5
+ return `cat "${input.keyFile}"`;
6
+ }
7
+ return input.store ? readExpression(input.store) : "echo $BOUNDLESS_API_KEY";
8
+ }
9
+ export const cursor = {
10
+ backgroundDefault: null,
11
+ defaultModel: "glm-5.2",
12
+ detect: () => anyExists(["/Applications/Cursor.app", home(".cursor")]),
13
+ id: "cursor",
14
+ name: "Cursor",
15
+ plan: (input) => Promise.resolve({
16
+ changes: [],
17
+ manual: [
18
+ "Open Cursor → Settings → Models.",
19
+ `Set "Override OpenAI Base URL" to ${input.gatewayUrl}/v1.`,
20
+ `Paste your key into "OpenAI API Key". Reveal it with: ${reveal(input)}`,
21
+ `Add ${input.primary} under "Custom models" and select it explicitly (Auto stays on Cursor's own models).`,
22
+ ],
23
+ notes: ["Tab completion keeps using a Cursor model; only chat and the custom model route through Boundless."],
24
+ }),
25
+ wire: "openai",
26
+ };
27
+ export const copilot = {
28
+ backgroundDefault: null,
29
+ defaultModel: "glm-5.2",
30
+ detect: () => anyExists([home(".vscode"), home(".copilot")]),
31
+ id: "copilot",
32
+ name: "GitHub Copilot",
33
+ plan: (input) => Promise.resolve({
34
+ changes: [],
35
+ manual: [
36
+ 'In VS Code run "Chat: Manage Language Models", choose Custom Endpoint.',
37
+ `Base URL ${input.gatewayUrl}/v1, key revealed with: ${reveal(input)}`,
38
+ `For the Copilot CLI export COPILOT_PROVIDER_BASE_URL="${input.gatewayUrl}/v1", COPILOT_PROVIDER_API_KEY="$BOUNDLESS_API_KEY" and COPILOT_MODEL="${input.primary}".`,
39
+ ],
40
+ notes: ["BYOK covers chat only; inline suggestions keep using Copilot's models."],
41
+ }),
42
+ wire: "openai",
43
+ };
@@ -0,0 +1,46 @@
1
+ import { Document, parseDocument } from "yaml";
2
+ import { KEY_ENV } from "../lib/config.js";
3
+ import { installed } from "../lib/files.js";
4
+ import { backup, home, readText, writePrivate } from "../lib/files.js";
5
+ export const hermes = {
6
+ backgroundDefault: null,
7
+ defaultModel: "qwen3.6",
8
+ detect: () => installed("hermes"),
9
+ id: "hermes",
10
+ name: "Hermes",
11
+ plan: (input) => {
12
+ const path = home(".hermes", "config.yaml");
13
+ return Promise.resolve({
14
+ changes: [
15
+ {
16
+ apply: async () => {
17
+ const text = (await readText(path))?.trim();
18
+ const doc = text ? parseDocument(text) : new Document({});
19
+ const context = input.catalog.find((model) => model.id === input.primary)?.contextTokens ?? 262_144;
20
+ doc.setIn(["providers", "boundless"], {
21
+ api: `${input.gatewayUrl}/v1`,
22
+ key_env: KEY_ENV,
23
+ models: { [input.primary]: { context_length: context } },
24
+ transport: "chat_completions",
25
+ });
26
+ doc.setIn(["model", "provider"], "custom:boundless");
27
+ doc.setIn(["model", "default"], input.primary);
28
+ const saved = await backup(path);
29
+ await writePrivate(path, doc.toString());
30
+ return { backup: saved };
31
+ },
32
+ path,
33
+ summary: `provider boundless → ${input.gatewayUrl}/v1, default model ${input.primary}, key read from ${KEY_ENV}`,
34
+ },
35
+ ],
36
+ manual: [],
37
+ notes: [`Hermes reads the key from ${KEY_ENV} in your shell environment.`],
38
+ });
39
+ },
40
+ smoke: () => ({
41
+ args: ["chat", "--toolsets", "terminal", "-q", "Use the terminal tool to run printf boundless, then report the output."],
42
+ command: "hermes",
43
+ expect: /boundless/i,
44
+ }),
45
+ wire: "openai",
46
+ };
@@ -0,0 +1,7 @@
1
+ import { claudeCode } from "./claude-code.js";
2
+ import { codex } from "./codex.js";
3
+ import { copilot, cursor } from "./gui.js";
4
+ import { hermes } from "./hermes.js";
5
+ import { omp } from "./omp.js";
6
+ import { opencode } from "./opencode.js";
7
+ export const HARNESSES = [claudeCode, codex, opencode, hermes, omp, cursor, copilot];
@@ -0,0 +1,41 @@
1
+ import { Document, parseDocument } from "yaml";
2
+ import { KEY_ENV } from "../lib/config.js";
3
+ import { installed } from "../lib/files.js";
4
+ import { backup, home, readText, writePrivate } from "../lib/files.js";
5
+ export const omp = {
6
+ backgroundDefault: "dsv4",
7
+ defaultModel: "glm-5.2",
8
+ detect: () => installed("omp"),
9
+ id: "omp",
10
+ name: "omp (Oh My Pi)",
11
+ plan: (input) => {
12
+ const path = home(".omp", "agent", "models.yml");
13
+ return Promise.resolve({
14
+ changes: [
15
+ {
16
+ apply: async () => {
17
+ const text = (await readText(path))?.trim();
18
+ const doc = text ? parseDocument(text) : new Document({});
19
+ doc.setIn(["providers", "boundless"], {
20
+ api: "openai-completions",
21
+ apiKey: KEY_ENV,
22
+ baseUrl: `${input.gatewayUrl}/v1`,
23
+ compat: { reasoningContentField: "reasoning_content" },
24
+ discovery: { type: "litellm" },
25
+ });
26
+ doc.setIn(["modelRoles", "default"], `boundless/${input.primary}`);
27
+ doc.setIn(["modelRoles", "tiny"], `boundless/${input.background}`);
28
+ const saved = await backup(path);
29
+ await writePrivate(path, doc.toString());
30
+ return { backup: saved };
31
+ },
32
+ path,
33
+ summary: `provider boundless → ${input.gatewayUrl}/v1 with discovery, default boundless/${input.primary}, tiny boundless/${input.background}`,
34
+ },
35
+ ],
36
+ manual: [],
37
+ notes: [`omp reads the key from ${KEY_ENV}; models and prices are discovered from the gateway at start.`],
38
+ });
39
+ },
40
+ wire: "openai",
41
+ };
@@ -0,0 +1,66 @@
1
+ import { join } from "node:path";
2
+ import { KEY_ENV } from "../lib/config.js";
3
+ import { installed } from "../lib/files.js";
4
+ import { backup, home, merge, readJson, writePrivate } from "../lib/files.js";
5
+ function modelEntry(model) {
6
+ const inputs = ["text", ...["image", "audio", "video"].filter((kind) => model.supports.includes(`${kind} input`))];
7
+ return {
8
+ limit: { context: model.contextTokens, output: 32_000 },
9
+ name: model.name,
10
+ reasoning: model.supports.includes("reasoning"),
11
+ tool_call: model.supports.includes("tool-use"),
12
+ ...(inputs.length > 1 ? { modalities: { input: inputs } } : {}),
13
+ };
14
+ }
15
+ function configPath(input) {
16
+ if (input.scope === "project") {
17
+ return join(input.env.cwd, "opencode.json");
18
+ }
19
+ return join(process.env.XDG_CONFIG_HOME ?? home(".config"), "opencode", "opencode.json");
20
+ }
21
+ export const opencode = {
22
+ backgroundDefault: "dsv4",
23
+ defaultModel: "qwen3.6",
24
+ detect: () => installed("opencode"),
25
+ id: "opencode",
26
+ name: "OpenCode",
27
+ plan: (input) => {
28
+ const path = configPath(input);
29
+ const apiKey = input.scope === "project" && input.keyFile ? `{file:${input.keyFile}}` : `{env:${KEY_ENV}}`;
30
+ return Promise.resolve({
31
+ changes: [
32
+ {
33
+ apply: async () => {
34
+ const current = await readJson(path);
35
+ const next = merge(current, {
36
+ $schema: "https://opencode.ai/config.json",
37
+ model: `boundless/${input.primary}`,
38
+ provider: {
39
+ boundless: {
40
+ models: Object.fromEntries(input.catalog.map((model) => [model.id, modelEntry(model)])),
41
+ name: "Boundless",
42
+ npm: "@ai-sdk/openai-compatible",
43
+ options: { apiKey, baseURL: `${input.gatewayUrl}/v1` },
44
+ },
45
+ },
46
+ small_model: `boundless/${input.background}`,
47
+ });
48
+ const saved = await backup(path);
49
+ await writePrivate(path, `${JSON.stringify(next, null, 2)}\n`);
50
+ return { backup: saved };
51
+ },
52
+ path,
53
+ summary: `provider boundless with ${input.catalog.length} models, model boundless/${input.primary}, small_model boundless/${input.background}, apiKey ${apiKey}`,
54
+ },
55
+ ],
56
+ manual: [],
57
+ notes: [],
58
+ });
59
+ },
60
+ smoke: (input) => ({
61
+ args: ["run", "--model", `boundless/${input.primary}`, "Use the bash tool to run printf boundless, then report the output."],
62
+ command: "opencode",
63
+ expect: /boundless/i,
64
+ }),
65
+ wire: "openai",
66
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,21 @@
1
+ import { spawn } from "node:child_process";
2
+ export function openBrowser(url) {
3
+ const [command, args] = process.platform === "darwin"
4
+ ? ["open", [url]]
5
+ : process.platform === "win32"
6
+ ? ["cmd", ["/c", "start", "", url.replaceAll("&", "^&")]]
7
+ : ["xdg-open", [url]];
8
+ return new Promise((resolve) => {
9
+ try {
10
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
11
+ child.once("error", () => resolve(false));
12
+ child.once("spawn", () => {
13
+ child.unref();
14
+ resolve(true);
15
+ });
16
+ }
17
+ catch {
18
+ resolve(false);
19
+ }
20
+ });
21
+ }
@@ -0,0 +1,29 @@
1
+ import { CONSOLE, request, SetupError } from "./config.js";
2
+ export async function listModels(gatewayUrl, key) {
3
+ const [catalog, served] = await Promise.all([
4
+ request(`${CONSOLE}/api/catalog`),
5
+ request(`${gatewayUrl}/v1/models`, { headers: { authorization: `Bearer ${key}` } }),
6
+ ]);
7
+ if (!catalog.ok) {
8
+ throw new SetupError(`The model catalog answered HTTP ${catalog.status}.`, "catalog_failed");
9
+ }
10
+ if (!served.ok) {
11
+ throw new SetupError(`${gatewayUrl}/v1/models answered HTTP ${served.status} with the new key.`, "models_failed");
12
+ }
13
+ const { data } = (await catalog.json());
14
+ const ids = new Set((await served.json()).data.map((model) => model.id));
15
+ return data.filter((model) => model.serving && ids.has(model.id));
16
+ }
17
+ export async function keyWorks(gatewayUrl, key) {
18
+ try {
19
+ const response = await request(`${gatewayUrl}/v1/models`, { headers: { authorization: `Bearer ${key}` } });
20
+ return response.ok;
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
26
+ /** Exact USD, never rounded away: trailing zeros trimmed, up to twelve decimals. */
27
+ export function usd(amount) {
28
+ return `$${amount.toFixed(12).replace(/0+$/, "").replace(/\.$/, "")}`;
29
+ }
@@ -0,0 +1,42 @@
1
+ import { VERSION } from "../version.js";
2
+ export const CONSOLE = (process.env.BOUNDLESS_CONSOLE ?? "https://inference.boundless.network").replace(/\/+$/, "");
3
+ export const USER_AGENT = `boundless-setup/${VERSION} (+https://boundless.md)`;
4
+ export const KEY_ENV = "BOUNDLESS_API_KEY";
5
+ export const KEYS_URL = `${CONSOLE}/api-keys`;
6
+ export class SetupError extends Error {
7
+ code;
8
+ constructor(message, code) {
9
+ super(message);
10
+ this.code = code;
11
+ this.name = "SetupError";
12
+ }
13
+ }
14
+ /** Plain HTTP is tolerated only for a console running on this machine. */
15
+ export const isLoopback = (url) => url.hostname === "localhost" || url.hostname === "127.0.0.1";
16
+ export function request(url, init = {}) {
17
+ const headers = new Headers(init.headers);
18
+ headers.set("user-agent", USER_AGENT);
19
+ return fetch(url, { ...init, headers });
20
+ }
21
+ export async function discoverSetup() {
22
+ const response = await request(`${CONSOLE}/api/setup`);
23
+ if (response.status === 503) {
24
+ throw new SetupError(`Automatic setup is not enabled on ${CONSOLE}. Create a key at ${KEYS_URL} instead.`, "setup_unavailable");
25
+ }
26
+ if (!response.ok) {
27
+ throw new SetupError(`${CONSOLE}/api/setup answered HTTP ${response.status}.`, "discovery_failed");
28
+ }
29
+ const config = (await response.json());
30
+ const issuer = new URL(config.issuer);
31
+ if (issuer.protocol !== "https:" && !isLoopback(issuer)) {
32
+ throw new SetupError("The authorization issuer must use HTTPS.", "insecure_issuer");
33
+ }
34
+ return { ...config, gatewayUrl: config.gatewayUrl.replace(/\/+$/, ""), issuer: issuer.origin };
35
+ }
36
+ export const sleep = (ms, signal) => new Promise((resolve, reject) => {
37
+ const timer = setTimeout(resolve, ms);
38
+ signal?.addEventListener("abort", () => {
39
+ clearTimeout(timer);
40
+ reject(new SetupError("Cancelled.", "cancelled"));
41
+ });
42
+ });
@@ -0,0 +1,35 @@
1
+ import { arch, platform, release } from "node:os";
2
+ import { basename } from "node:path";
3
+ import { exists, home } from "./files.js";
4
+ function shell() {
5
+ if (process.platform === "win32") {
6
+ return { shell: "powershell", shellLabel: "PowerShell" };
7
+ }
8
+ const name = basename(process.env.SHELL ?? "");
9
+ if (name === "zsh" || name === "bash" || name === "fish") {
10
+ return { shell: name, shellLabel: name };
11
+ }
12
+ return { shell: "other", shellLabel: name || "unknown" };
13
+ }
14
+ async function remote() {
15
+ if (process.env.SSH_CONNECTION || process.env.SSH_TTY || process.env.SSH_CLIENT) {
16
+ return "SSH session";
17
+ }
18
+ if (process.env.CODESPACES) {
19
+ return "GitHub Codespace";
20
+ }
21
+ if ((await exists("/.dockerenv")) || (await exists("/run/.containerenv"))) {
22
+ return "container";
23
+ }
24
+ if (process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
25
+ return "headless Linux";
26
+ }
27
+ return null;
28
+ }
29
+ export async function inspectEnvironment() {
30
+ const os = process.platform === "darwin" ? "macos" : process.platform === "win32" ? "windows" : "linux";
31
+ const osLabel = os === "macos" ? `macOS ${release()}` : os === "windows" ? `Windows ${release()}` : `${platform()} ${release()}`;
32
+ return { arch: arch(), cwd: process.cwd(), os, osLabel, remote: await remote(), ...shell() };
33
+ }
34
+ export const claudeHome = () => process.env.CLAUDE_CONFIG_DIR ?? home(".claude");
35
+ export const codexHome = () => process.env.CODEX_HOME ?? home(".codex");
@@ -0,0 +1,55 @@
1
+ import { CONSOLE, KEYS_URL, request, SetupError, sleep } from "./config.js";
2
+ const MESSAGES = {
3
+ key_limit_reached: `Your account has reached its key limit. Revoke one at ${KEYS_URL} and run setup again.`,
4
+ member_suspended: `This account is suspended. See ${CONSOLE}/ or contact support.`,
5
+ not_approved: `Your account is waiting for approval. Check ${CONSOLE}/ for its status.`,
6
+ team_conflict: "Your Team is being changed right now. Run setup again in a moment.",
7
+ team_not_provisioned: `Your Team is not set up for API access yet. See ${CONSOLE}/.`,
8
+ team_suspended: `Your Team is suspended. See ${CONSOLE}/ or contact support.`,
9
+ unauthorized: "The sign-in token was rejected. Run setup again to sign in afresh.",
10
+ };
11
+ export class RecoveryRequired extends SetupError {
12
+ keyName;
13
+ constructor(keyName) {
14
+ super(`A key named ${keyName} was already created for this sign-in and its secret cannot be shown again. Open ${KEYS_URL}, revoke that key, then run setup again.`, "setup_recovery_required");
15
+ this.keyName = keyName;
16
+ }
17
+ }
18
+ export async function exchangeForKey(accessToken, signal) {
19
+ const deadline = Date.now() + 60_000;
20
+ let backoff = 2_000;
21
+ for (;;) {
22
+ let response;
23
+ try {
24
+ response = await request(`${CONSOLE}/api/setup/key`, {
25
+ headers: { authorization: `Bearer ${accessToken}` },
26
+ method: "POST",
27
+ signal,
28
+ });
29
+ }
30
+ catch (error) {
31
+ if (signal.aborted || Date.now() + backoff > deadline) {
32
+ throw new SetupError(`Could not reach ${CONSOLE}: ${error.message}`, "network");
33
+ }
34
+ await sleep(backoff, signal);
35
+ backoff = Math.min(backoff * 2, 15_000);
36
+ continue;
37
+ }
38
+ if (response.ok) {
39
+ return (await response.json());
40
+ }
41
+ const body = (await response.json().catch(() => ({})));
42
+ const retryAfter = Number(response.headers.get("retry-after") ?? "0") * 1000;
43
+ const transient = body.error === "account_settling" || response.status === 502 || response.status === 503;
44
+ if ((transient || response.status === 429) && Date.now() + Math.max(retryAfter, backoff) <= deadline) {
45
+ await sleep(Math.max(retryAfter, backoff), signal);
46
+ backoff = Math.min(backoff * 2, 15_000);
47
+ continue;
48
+ }
49
+ if (body.error === "setup_recovery_required" && body.name) {
50
+ throw new RecoveryRequired(body.name);
51
+ }
52
+ const code = body.error ?? `http_${response.status}`;
53
+ throw new SetupError(MESSAGES[code] ?? `Creating the key failed (${code}). See ${KEYS_URL}.`, code);
54
+ }
55
+ }
@@ -0,0 +1,116 @@
1
+ import { execFile } from "node:child_process";
2
+ import { chmod, copyFile, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { promisify } from "node:util";
6
+ export const run = promisify(execFile);
7
+ export function home(...parts) {
8
+ return join(homedir(), ...parts);
9
+ }
10
+ export function tilde(path) {
11
+ const root = homedir();
12
+ return path.startsWith(root) ? `~${path.slice(root.length)}` : path;
13
+ }
14
+ export async function exists(path) {
15
+ try {
16
+ await stat(path);
17
+ return true;
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
23
+ export async function readText(path) {
24
+ try {
25
+ return await readFile(path, "utf8");
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ const stamp = () => new Date().toISOString().replace(/[:.]/g, "-");
32
+ /** Copies a file next to itself before it is replaced. Returns the backup path, or null when there was nothing to back up. */
33
+ export async function backup(path) {
34
+ if (!(await exists(path))) {
35
+ return null;
36
+ }
37
+ const target = `${path}.boundless-backup-${stamp()}`;
38
+ await copyFile(path, target);
39
+ await chmod(target, 0o600).catch(() => undefined);
40
+ return target;
41
+ }
42
+ async function writeAtomic(path, content, mode, directoryMode) {
43
+ await mkdir(dirname(path), { mode: directoryMode, recursive: true });
44
+ const temporary = `${path}.${process.pid}.tmp`;
45
+ await writeFile(temporary, content, { mode });
46
+ await rename(temporary, path);
47
+ await chmod(path, mode).catch(() => undefined);
48
+ }
49
+ /** Writes with private permissions: 0700 directory, 0600 file. */
50
+ export const writePrivate = (path, content) => writeAtomic(path, content, 0o600, 0o700);
51
+ /** Rewrites a file in place, keeping its existing mode (a shell profile stays 0644). */
52
+ export async function writeKeepingMode(path, content) {
53
+ const mode = await stat(path).then((info) => info.mode & 0o777).catch(() => 0o644);
54
+ await writeAtomic(path, content, mode, 0o755);
55
+ }
56
+ export async function which(binary) {
57
+ try {
58
+ const { stdout } = await run(process.platform === "win32" ? "where" : "which", [binary]);
59
+ return stdout.trim().split(/\r?\n/)[0] || null;
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ }
65
+ export const installed = async (binary) => (await which(binary)) !== null;
66
+ export async function anyExists(paths) {
67
+ for (const path of paths) {
68
+ if (await exists(path)) {
69
+ return true;
70
+ }
71
+ }
72
+ return false;
73
+ }
74
+ function isObject(value) {
75
+ return typeof value === "object" && value !== null && !Array.isArray(value);
76
+ }
77
+ /** Deep-merges `patch` into `base`; objects merge recursively, everything else is replaced. */
78
+ export function merge(base, patch) {
79
+ if (isObject(base) && isObject(patch)) {
80
+ const result = { ...base };
81
+ for (const [key, value] of Object.entries(patch)) {
82
+ result[key] = merge(base[key], value);
83
+ }
84
+ return result;
85
+ }
86
+ return patch;
87
+ }
88
+ export async function readJson(path) {
89
+ const text = await readText(path);
90
+ if (text === null || text.trim() === "") {
91
+ return {};
92
+ }
93
+ const parsed = JSON.parse(text);
94
+ if (!isObject(parsed)) {
95
+ throw new Error(`${path} is not a JSON object`);
96
+ }
97
+ return parsed;
98
+ }
99
+ export async function gitTracked(path, cwd) {
100
+ try {
101
+ await run("git", ["ls-files", "--error-unmatch", path], { cwd });
102
+ return true;
103
+ }
104
+ catch {
105
+ return false;
106
+ }
107
+ }
108
+ export async function ensureIgnored(cwd, pattern) {
109
+ const path = join(cwd, ".gitignore");
110
+ const current = (await readText(path)) ?? "";
111
+ if (current.split(/\r?\n/).some((line) => line.trim() === pattern)) {
112
+ return false;
113
+ }
114
+ await writeFile(path, `${current}${current.endsWith("\n") || current === "" ? "" : "\n"}${pattern}\n`);
115
+ return true;
116
+ }
@@ -0,0 +1,89 @@
1
+ import { CONSOLE, isLoopback, request, SetupError, sleep } from "./config.js";
2
+ function endpoint(issuer, value, name) {
3
+ if (!value) {
4
+ throw new SetupError(`The authorization server advertises no ${name}.`, "no_device_grant");
5
+ }
6
+ const url = new URL(value);
7
+ if ((url.protocol !== "https:" && !isLoopback(url)) || url.origin !== issuer) {
8
+ throw new SetupError(`The advertised ${name} is not on ${issuer}.`, "foreign_endpoint");
9
+ }
10
+ return url.toString();
11
+ }
12
+ export async function startDeviceGrant(config) {
13
+ const discovery = await request(`${config.issuer}/.well-known/oauth-authorization-server`);
14
+ if (!discovery.ok) {
15
+ throw new SetupError(`Authorization server discovery answered HTTP ${discovery.status}.`, "discovery_failed");
16
+ }
17
+ const metadata = (await discovery.json());
18
+ const deviceEndpoint = endpoint(config.issuer, metadata.device_authorization_endpoint, "device authorization endpoint");
19
+ const tokenEndpoint = endpoint(config.issuer, metadata.token_endpoint, "token endpoint");
20
+ const response = await request(deviceEndpoint, {
21
+ body: new URLSearchParams({ client_id: config.clientId, scope: "profile" }),
22
+ method: "POST",
23
+ });
24
+ if (!response.ok) {
25
+ throw new SetupError(`Starting browser sign-in failed with HTTP ${response.status}.`, "device_start_failed");
26
+ }
27
+ const grant = (await response.json());
28
+ const verificationUriComplete = grant.verification_uri_complete ?? grant.verification_uri;
29
+ return {
30
+ deviceCode: grant.device_code,
31
+ expiresAt: Date.now() + (grant.expires_in ?? 600) * 1000,
32
+ interval: Math.max(grant.interval ?? 5, 1),
33
+ tokenEndpoint,
34
+ userCode: grant.user_code,
35
+ verificationUri: grant.verification_uri,
36
+ verificationUriComplete,
37
+ };
38
+ }
39
+ /** The console sign-in page that lands on the approval page with the code already applied. */
40
+ export function approvalUrl(grant) {
41
+ return `${CONSOLE}/authorize?redirect_url=${encodeURIComponent(grant.verificationUriComplete)}`;
42
+ }
43
+ export async function waitForAccessToken(config, grant, signal) {
44
+ let interval = grant.interval;
45
+ let failures = 0;
46
+ while (Date.now() < grant.expiresAt) {
47
+ await sleep(interval * 1000, signal);
48
+ let response;
49
+ try {
50
+ response = await request(grant.tokenEndpoint, {
51
+ body: new URLSearchParams({
52
+ client_id: config.clientId,
53
+ device_code: grant.deviceCode,
54
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
55
+ }),
56
+ method: "POST",
57
+ signal,
58
+ });
59
+ }
60
+ catch (error) {
61
+ if (signal.aborted) {
62
+ throw new SetupError("Cancelled.", "cancelled");
63
+ }
64
+ failures += 1;
65
+ if (failures > 6) {
66
+ throw new SetupError(`Could not reach the authorization server: ${error.message}`, "network");
67
+ }
68
+ continue;
69
+ }
70
+ const body = (await response.json().catch(() => ({})));
71
+ if (response.ok && body.access_token) {
72
+ return body.access_token;
73
+ }
74
+ switch (body.error) {
75
+ case "authorization_pending":
76
+ continue;
77
+ case "slow_down":
78
+ interval += 5;
79
+ continue;
80
+ case "access_denied":
81
+ throw new SetupError("You declined the sign-in in the browser.", "access_denied");
82
+ case "expired_token":
83
+ throw new SetupError("The sign-in request expired before it was approved.", "expired");
84
+ default:
85
+ throw new SetupError(`Sign-in failed: ${body.error ?? `HTTP ${response.status}`}.`, body.error ?? "oauth_error");
86
+ }
87
+ }
88
+ throw new SetupError("The sign-in request expired before it was approved.", "expired");
89
+ }