@arcadeai/hypotenuse 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1594 @@
1
+ #!/usr/bin/env bun
2
+ // @bun
3
+
4
+ // src/output/early-env.ts
5
+ function applyEarlyEnv(argv, env) {
6
+ const has = (flag) => argv.includes(flag);
7
+ const plain = has("--no-color") || has("--json") || has("-j");
8
+ if (plain) {
9
+ if (!env.NO_COLOR)
10
+ env.NO_COLOR = "1";
11
+ delete env.FORCE_COLOR;
12
+ }
13
+ if (has("--force-color") && !plain) {
14
+ env.FORCE_COLOR = "1";
15
+ delete env.NO_COLOR;
16
+ }
17
+ return env;
18
+ }
19
+ applyEarlyEnv(process.argv.slice(2), process.env);
20
+
21
+ // src/output/logger.ts
22
+ import { createSpinner } from "nanospinner";
23
+ // ../design/tokens.json
24
+ var tokens_default = {
25
+ $comment: "Generated by design/tokens.ts \u2014 do not edit. Change INPUTS there and run `bun run --cwd frontend theme:build`.",
26
+ light: {
27
+ "bg-app": "#f8f9fc",
28
+ "bg-panel": "#ffffff",
29
+ "bg-raised": "#ffffff",
30
+ "bg-sunken": "#eff1f3",
31
+ "bg-hover": "#eceef1",
32
+ "bg-selected": "#d7edec",
33
+ "border-subtle": "#e1e3e5",
34
+ "border-default": "#d0d2d4",
35
+ "border-strong": "#afb1b3",
36
+ "text-primary": "#27282a",
37
+ "text-secondary": "#545658",
38
+ "text-tertiary": "#707174",
39
+ "text-disabled": "#8f9194",
40
+ accent: "#007f7d",
41
+ "accent-hover": "#007271",
42
+ "accent-subtle": "#d8f4f2",
43
+ "text-on-accent": "#ffffff",
44
+ "focus-ring": "#009c9b",
45
+ "status-pending": "#707174",
46
+ "status-active": "#008ecc",
47
+ "status-waiting": "#ae7805",
48
+ "status-complete": "#38955a",
49
+ "status-error": "#dd5455",
50
+ "status-stale": "#8f9194",
51
+ "status-stopping": "#c16c35",
52
+ "runner-online": "#289655",
53
+ "runner-offline": "#8f9194",
54
+ "runner-draining": "#ae7805",
55
+ "role-manager": "#8f78c8",
56
+ "role-worker": "#009492",
57
+ "diff-add": "#208149",
58
+ "diff-remove": "#c94146"
59
+ },
60
+ dark: {
61
+ "bg-app": "#181a1c",
62
+ "bg-panel": "#1e2022",
63
+ "bg-raised": "#242628",
64
+ "bg-sunken": "#121316",
65
+ "bg-hover": "#292a2d",
66
+ "bg-selected": "#243635",
67
+ "border-subtle": "#313335",
68
+ "border-default": "#414245",
69
+ "border-strong": "#5f6164",
70
+ "text-primary": "#eceef1",
71
+ "text-secondary": "#a8aaad",
72
+ "text-tertiary": "#86888c",
73
+ "text-disabled": "#686a6d",
74
+ accent: "#39bebc",
75
+ "accent-hover": "#4ececb",
76
+ "accent-subtle": "#123635",
77
+ "text-on-accent": "#16181a",
78
+ "focus-ring": "#39bebc",
79
+ "status-pending": "#86888c",
80
+ "status-active": "#3fb6fd",
81
+ "status-waiting": "#da9f38",
82
+ "status-complete": "#62be80",
83
+ "status-error": "#ff8681",
84
+ "status-stale": "#686a6d",
85
+ "status-stopping": "#ee935a",
86
+ "runner-online": "#57bf7b",
87
+ "runner-offline": "#686a6d",
88
+ "runner-draining": "#da9f38",
89
+ "role-manager": "#b89ff3",
90
+ "role-worker": "#39bebc",
91
+ "diff-add": "#62be80",
92
+ "diff-remove": "#ff8681"
93
+ }
94
+ };
95
+
96
+ // src/output/tty.ts
97
+ function isTruthyEnv(value) {
98
+ if (!value)
99
+ return false;
100
+ const lower = value.toLowerCase();
101
+ return lower !== "0" && lower !== "false";
102
+ }
103
+ function detectColorDepth(term) {
104
+ const { env } = term;
105
+ const argv = term.argv ?? [];
106
+ const flagPlain = argv.includes("--no-color") || argv.includes("--json") || argv.includes("-j");
107
+ if (flagPlain)
108
+ return "none";
109
+ const force = argv.includes("--force-color") || isTruthyEnv(env.FORCE_COLOR);
110
+ if (!force && (Boolean(env.NO_COLOR) || !term.isTTY))
111
+ return "none";
112
+ const colorterm = (env.COLORTERM ?? "").toLowerCase();
113
+ if (colorterm === "truecolor" || colorterm === "24bit")
114
+ return "truecolor";
115
+ return "256";
116
+ }
117
+ function detectUnicode(env) {
118
+ const locale = env.LC_ALL || env.LC_CTYPE || env.LANG;
119
+ if (!locale)
120
+ return true;
121
+ return /utf-?8/i.test(locale);
122
+ }
123
+ function detectLightBackground(env) {
124
+ const bg = env.COLORFGBG?.split(";").at(-1);
125
+ return bg === "7" || bg === "15";
126
+ }
127
+ function detectMode(term) {
128
+ const argv = term.argv ?? [];
129
+ const json = argv.includes("--json") || argv.includes("-j");
130
+ return {
131
+ interactive: term.isTTY && !json && !isTruthyEnv(term.env.CI),
132
+ depth: detectColorDepth(term),
133
+ json,
134
+ unicode: detectUnicode(term.env),
135
+ lightBackground: detectLightBackground(term.env)
136
+ };
137
+ }
138
+ var current = null;
139
+ function getMode() {
140
+ current ??= detectMode({
141
+ env: process.env,
142
+ isTTY: Boolean(process.stdout.isTTY || process.stderr.isTTY),
143
+ argv: process.argv.slice(2)
144
+ });
145
+ return current;
146
+ }
147
+ function setMode(mode) {
148
+ current = mode;
149
+ }
150
+
151
+ // src/output/theme.ts
152
+ var GLYPHS = {
153
+ pending: { unicode: "\u25CC", ascii: "o", token: "status-pending" },
154
+ active: { unicode: "\u25D4", ascii: "*", token: "status-active" },
155
+ awaitingInput: { unicode: "\u25C9", ascii: "?", token: "status-waiting" },
156
+ complete: { unicode: "\u2713", ascii: "v", token: "status-complete" },
157
+ error: { unicode: "\u2715", ascii: "x", token: "status-error" },
158
+ stale: { unicode: "\u25CC", ascii: "o", token: "status-stale", dim: true },
159
+ stopping: { unicode: "\u25D8", ascii: "#", token: "status-stopping" },
160
+ online: { unicode: "\u25CF", ascii: "+", token: "runner-online" },
161
+ offline: { unicode: "\u25CB", ascii: "-", token: "runner-offline" },
162
+ draining: { unicode: "\u25D0", ascii: "~", token: "runner-draining" }
163
+ };
164
+ var ESC = "\x1B[";
165
+ var RESET = `${ESC}0m`;
166
+ var ANSI = /\x1b\[[0-9;]*m/g;
167
+ function rgb(hex) {
168
+ const n = Number.parseInt(hex.slice(1), 16);
169
+ return [n >> 16 & 255, n >> 8 & 255, n & 255];
170
+ }
171
+ function nearestXterm256(hex) {
172
+ const [r, g, b] = rgb(hex);
173
+ const steps = [0, 95, 135, 175, 215, 255];
174
+ const nearestStep = (v) => steps.reduce((best, s, i) => Math.abs(s - v) < Math.abs((steps[best] ?? 0) - v) ? i : best, 0);
175
+ const [ri, gi, bi] = [nearestStep(r), nearestStep(g), nearestStep(b)];
176
+ const cube = [
177
+ steps[ri] ?? 0,
178
+ steps[gi] ?? 0,
179
+ steps[bi] ?? 0
180
+ ];
181
+ const grayIndex = Math.max(0, Math.min(23, Math.round(((r + g + b) / 3 - 8) / 10)));
182
+ const gray = 8 + grayIndex * 10;
183
+ const dist = (c) => (c[0] - r) ** 2 + (c[1] - g) ** 2 + (c[2] - b) ** 2;
184
+ return dist(cube) <= dist([gray, gray, gray]) ? 16 + 36 * ri + 6 * gi + bi : 232 + grayIndex;
185
+ }
186
+ function visibleWidth(text) {
187
+ return [...text.replace(ANSI, "")].length;
188
+ }
189
+ function truncateMiddle(text, width, ellipsis = "\u2026") {
190
+ const chars = [...text];
191
+ if (chars.length <= width)
192
+ return text;
193
+ const mark = [...ellipsis].length;
194
+ if (width <= mark)
195
+ return chars.slice(0, width).join("");
196
+ const keep = width - mark;
197
+ const head = Math.ceil(keep / 2);
198
+ const tail = keep - head;
199
+ return chars.slice(0, head).join("") + ellipsis + (tail > 0 ? chars.slice(-tail).join("") : "");
200
+ }
201
+ function createTheme(mode) {
202
+ const palette = mode.lightBackground ? tokens_default.light : tokens_default.dark;
203
+ const color = (token, text) => {
204
+ if (mode.depth === "none")
205
+ return text;
206
+ const hex = palette[token];
207
+ if (!hex)
208
+ return text;
209
+ if (mode.depth === "truecolor") {
210
+ const [r, g, b] = rgb(hex);
211
+ return `${ESC}38;2;${r};${g};${b}m${text}${RESET}`;
212
+ }
213
+ return `${ESC}38;5;${nearestXterm256(hex)}m${text}${RESET}`;
214
+ };
215
+ const dim = (text) => mode.depth === "none" ? text : `${ESC}2m${text}${RESET}`;
216
+ const glyph = (name) => {
217
+ const g = GLYPHS[name];
218
+ const painted = color(g.token, mode.unicode ? g.unicode : g.ascii);
219
+ return g.dim ? dim(painted) : painted;
220
+ };
221
+ const table = (rows, columns) => {
222
+ const ellipsis = mode.unicode ? "\u2026" : "...";
223
+ const cells = rows.map((row) => columns.map((col) => {
224
+ const raw = row[col.key];
225
+ const text = raw === undefined || raw === null ? "" : String(raw);
226
+ return col.maxWidth ? truncateMiddle(text, col.maxWidth, ellipsis) : text;
227
+ }));
228
+ const headers = columns.map((col) => col.header ?? col.key.toUpperCase());
229
+ const align = columns.map((col, i) => {
230
+ if (col.align)
231
+ return col.align;
232
+ const numeric = rows.length > 0 && cells.every((r) => /^-?\d+(\.\d+)?%?$/.test(r[i] ?? ""));
233
+ return numeric ? "right" : "left";
234
+ });
235
+ const widths = columns.map((_, i) => Math.max(visibleWidth(headers[i] ?? ""), ...cells.map((r) => visibleWidth(r[i] ?? ""))));
236
+ const pad = (text, i) => {
237
+ const gap = " ".repeat((widths[i] ?? 0) - visibleWidth(text));
238
+ return align[i] === "right" ? gap + text : text + gap;
239
+ };
240
+ const line = (values, paint = (s) => s) => values.map((v, i) => paint(pad(v, i))).join(" ").trimEnd();
241
+ return [line(headers, dim), ...cells.map((r) => line(r))].join(`
242
+ `);
243
+ };
244
+ const banner = (version) => {
245
+ const name = `hypotenuse ${dim(version)}`;
246
+ return mode.unicode ? `${color("accent", "\u25E2")} ${name}` : name;
247
+ };
248
+ return { color, dim, glyph, table, banner };
249
+ }
250
+ function color(token, text) {
251
+ return createTheme(getMode()).color(token, text);
252
+ }
253
+ function dim(text) {
254
+ return createTheme(getMode()).dim(text);
255
+ }
256
+ function glyph(name) {
257
+ return createTheme(getMode()).glyph(name);
258
+ }
259
+ function table(rows, columns) {
260
+ return createTheme(getMode()).table(rows, columns);
261
+ }
262
+ function banner(version) {
263
+ return createTheme(getMode()).banner(version);
264
+ }
265
+
266
+ // src/output/logger.ts
267
+ var verbose = false;
268
+ var activeSpinner = null;
269
+ function quiet() {
270
+ return getMode().json || !(process.stderr.isTTY ?? false);
271
+ }
272
+ function write(line) {
273
+ if (activeSpinner) {
274
+ activeSpinner.clear();
275
+ process.stderr.write(`${line}
276
+ `);
277
+ activeSpinner.render();
278
+ } else {
279
+ process.stderr.write(`${line}
280
+ `);
281
+ }
282
+ }
283
+ var logger = {
284
+ setVerbose(on) {
285
+ verbose = on;
286
+ },
287
+ info(msg) {
288
+ if (!quiet())
289
+ write(dim(msg));
290
+ },
291
+ success(msg) {
292
+ if (!quiet())
293
+ write(`${glyph("complete")} ${msg}`);
294
+ },
295
+ warn(msg) {
296
+ if (!quiet())
297
+ write(color("status-waiting", msg));
298
+ },
299
+ error(msg) {
300
+ write(`${glyph("error")} ${color("status-error", msg)}`);
301
+ },
302
+ tell(msg) {
303
+ write(msg);
304
+ },
305
+ debug(msg) {
306
+ if (verbose)
307
+ write(dim(msg));
308
+ },
309
+ writeRaw(text) {
310
+ process.stderr.write(text);
311
+ },
312
+ spinner(text) {
313
+ if (quiet() || !getMode().interactive) {
314
+ return { update() {}, success() {}, error() {}, stop() {} };
315
+ }
316
+ const spinner = createSpinner(text, { stream: process.stderr }).start();
317
+ activeSpinner = spinner;
318
+ const done = () => {
319
+ activeSpinner = null;
320
+ };
321
+ return {
322
+ update: (t) => {
323
+ spinner.update({ text: t });
324
+ },
325
+ success: (t) => {
326
+ spinner.success({ text: t });
327
+ done();
328
+ },
329
+ error: (t) => {
330
+ spinner.error({ text: t });
331
+ done();
332
+ },
333
+ stop: () => {
334
+ spinner.stop();
335
+ done();
336
+ }
337
+ };
338
+ }
339
+ };
340
+
341
+ // src/program.ts
342
+ import { Command } from "commander";
343
+ // package.json
344
+ var package_default = {
345
+ name: "@arcadeai/hypotenuse",
346
+ version: "0.2.0",
347
+ description: "Hypotenuse \u2014 run coding agents on your machine for Linear agent sessions",
348
+ type: "module",
349
+ license: "Apache-2.0",
350
+ bin: {
351
+ hypotenuse: "./dist/hypotenuse.js"
352
+ },
353
+ files: [
354
+ "dist/hypotenuse.js",
355
+ "skills",
356
+ "README.md",
357
+ "LICENSE"
358
+ ],
359
+ repository: {
360
+ type: "git",
361
+ url: "https://github.com/arcadeai-labs/hypotenuse.git"
362
+ },
363
+ homepage: "https://github.com/arcadeai-labs/hypotenuse#readme",
364
+ bugs: {
365
+ url: "https://github.com/arcadeai-labs/hypotenuse/issues"
366
+ },
367
+ publishConfig: {
368
+ access: "public"
369
+ },
370
+ engines: {
371
+ bun: ">=1.2"
372
+ },
373
+ scripts: {
374
+ hypotenuse: "bun run src/cli.ts",
375
+ dev: "bun run src/cli.ts",
376
+ test: "bun test",
377
+ lint: "tsc && biome check .",
378
+ format: "tsc && biome check --write .",
379
+ bundle: "bun build ./src/cli.ts --target bun --packages external --outfile dist/hypotenuse.js",
380
+ build: "bun build --compile --minify --sourcemap ./src/cli.ts --outfile dist/hypotenuse",
381
+ prepack: "bun run bundle"
382
+ },
383
+ dependencies: {
384
+ commander: "^14.0.3",
385
+ nanospinner: "^1.2.2",
386
+ upgradr: "^0.4.0",
387
+ zod: "^4.3.6"
388
+ },
389
+ devDependencies: {
390
+ "@biomejs/biome": "^2.4.8",
391
+ "@types/bun": "latest",
392
+ typescript: "^7.0.2"
393
+ }
394
+ };
395
+
396
+ // src/api/client.ts
397
+ class ApiError extends Error {
398
+ status;
399
+ type;
400
+ constructor(message, status, type) {
401
+ super(message);
402
+ this.status = status;
403
+ this.type = type;
404
+ this.name = "ApiError";
405
+ }
406
+ }
407
+
408
+ class ApiClient {
409
+ origin;
410
+ token;
411
+ debug;
412
+ constructor(origin, token, debug = () => {}) {
413
+ this.origin = origin;
414
+ this.token = token;
415
+ this.debug = debug;
416
+ }
417
+ async request(method, path, options = {}) {
418
+ const url = new URL(`${this.origin}/api${path}`);
419
+ for (const [key, value] of Object.entries(options.query ?? {})) {
420
+ if (value !== undefined)
421
+ url.searchParams.set(key, String(value));
422
+ }
423
+ const headers = { Accept: "application/json" };
424
+ if (this.token && !options.anonymous) {
425
+ headers.Authorization = `Bearer ${this.token}`;
426
+ }
427
+ if (options.body !== undefined) {
428
+ headers["Content-Type"] = "application/json";
429
+ }
430
+ this.debug(`${method} ${url.pathname}${url.search}`);
431
+ let response;
432
+ try {
433
+ response = await fetch(url, {
434
+ method,
435
+ headers,
436
+ body: options.body === undefined ? undefined : JSON.stringify(options.body)
437
+ });
438
+ } catch (error) {
439
+ throw new ApiError(`Could not reach ${this.origin}: ${error instanceof Error ? error.message : String(error)}`, 0);
440
+ }
441
+ this.debug(`\u2190 ${response.status}`);
442
+ const text = await response.text();
443
+ let payload = null;
444
+ if (text.length > 0) {
445
+ try {
446
+ payload = JSON.parse(text);
447
+ } catch {
448
+ payload = { error: { message: text.slice(0, 500) } };
449
+ }
450
+ }
451
+ const envelope = payload;
452
+ if (envelope?.error) {
453
+ const error = envelope.error;
454
+ throw typeof error === "string" ? new ApiError(error, response.status) : new ApiError(error.message || `HTTP ${response.status}`, response.status, error.type);
455
+ }
456
+ if (!response.ok) {
457
+ throw new ApiError(`HTTP ${response.status} ${response.statusText}`, response.status);
458
+ }
459
+ return payload;
460
+ }
461
+ }
462
+
463
+ // src/api/credentials.ts
464
+ import {
465
+ chmodSync,
466
+ existsSync,
467
+ mkdirSync,
468
+ readFileSync,
469
+ renameSync,
470
+ writeFileSync
471
+ } from "fs";
472
+ import { dirname } from "path";
473
+ import { z } from "zod";
474
+
475
+ // src/paths.ts
476
+ import { homedir } from "os";
477
+ import { join } from "path";
478
+ function configDir(env = process.env) {
479
+ const base = env.XDG_CONFIG_HOME || join(env.HOME || homedir(), ".config");
480
+ return join(base, "hypotenuse");
481
+ }
482
+ function credentialsPath(env = process.env) {
483
+ return join(configDir(env), "credentials.json");
484
+ }
485
+ function defaultConfigPath(env = process.env) {
486
+ return join(configDir(env), "config.toml");
487
+ }
488
+
489
+ // src/api/credentials.ts
490
+ function credentialsPathFor(env) {
491
+ return credentialsPath(env);
492
+ }
493
+ var LoginSchema = z.object({
494
+ token: z.string(),
495
+ cliTokenId: z.number(),
496
+ device: z.string(),
497
+ email: z.string().optional(),
498
+ expiresAt: z.number().optional()
499
+ });
500
+ var ServerEntrySchema = z.object({
501
+ login: LoginSchema.nullable().default(null),
502
+ project: z.number().nullable().default(null),
503
+ runner: z.unknown().nullable().default(null)
504
+ });
505
+ var CredentialsSchema = z.object({
506
+ version: z.literal(1),
507
+ current: z.string().nullable().default(null),
508
+ servers: z.record(z.string(), ServerEntrySchema).default({})
509
+ });
510
+ function emptyCredentials() {
511
+ return { version: 1, current: null, servers: {} };
512
+ }
513
+ function loadCredentials(path = credentialsPath()) {
514
+ if (!existsSync(path))
515
+ return emptyCredentials();
516
+ let raw;
517
+ try {
518
+ raw = JSON.parse(readFileSync(path, "utf8"));
519
+ } catch {
520
+ throw new Error(`${path} is not valid JSON`);
521
+ }
522
+ const parsed = CredentialsSchema.safeParse(raw);
523
+ if (!parsed.success) {
524
+ throw new Error(`${path} is not a hypotenuse credentials file`);
525
+ }
526
+ return parsed.data;
527
+ }
528
+ function saveCredentials(credentials, path = credentialsPath()) {
529
+ const dir = dirname(path);
530
+ mkdirSync(dir, { recursive: true, mode: 448 });
531
+ chmodSync(dir, 448);
532
+ const temp = `${path}.${process.pid}.tmp`;
533
+ writeFileSync(temp, `${JSON.stringify(credentials, null, 2)}
534
+ `, {
535
+ mode: 384
536
+ });
537
+ chmodSync(temp, 384);
538
+ renameSync(temp, path);
539
+ }
540
+ function originOf(url) {
541
+ let parsed;
542
+ try {
543
+ parsed = new URL(url);
544
+ } catch {
545
+ throw new Error(`Not a URL: ${url}`);
546
+ }
547
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
548
+ throw new Error(`Not an http(s) URL: ${url}`);
549
+ }
550
+ return parsed.origin;
551
+ }
552
+ function serverEntry(credentials, origin) {
553
+ credentials.servers[origin] ??= { login: null, project: null, runner: null };
554
+ return credentials.servers[origin];
555
+ }
556
+
557
+ // src/api/resolve.ts
558
+ async function resolveProjectId(client, selector) {
559
+ if (/^\d+$/.test(selector))
560
+ return Number(selector);
561
+ for (let page = 1;; page++) {
562
+ const { projects, pagination } = await client.request("GET", "/projects", { query: { page, limit: 100 } });
563
+ const match = projects.find((p) => p.slug === selector);
564
+ if (match)
565
+ return match.id;
566
+ if (page >= pagination.pages) {
567
+ throw new Error(`You have no project with the slug "${selector}"`);
568
+ }
569
+ }
570
+ }
571
+
572
+ // src/api/url.ts
573
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
574
+ var DEFAULT_SERVER_URL = "https://api.hypotenuse.cloud";
575
+ function configServerUrl(path) {
576
+ if (!existsSync2(path))
577
+ return;
578
+ let parsed;
579
+ try {
580
+ parsed = Bun.TOML.parse(readFileSync2(path, "utf8"));
581
+ } catch (error) {
582
+ throw new Error(`${path} is not valid TOML: ${error instanceof Error ? error.message : String(error)}`);
583
+ }
584
+ const url = parsed.server?.url;
585
+ return typeof url === "string" ? url : undefined;
586
+ }
587
+ function resolveServerUrl(sources) {
588
+ if (sources.flag)
589
+ return { origin: originOf(sources.flag), source: "flag" };
590
+ if (sources.env.HYPOTENUSE_URL) {
591
+ return { origin: originOf(sources.env.HYPOTENUSE_URL), source: "env" };
592
+ }
593
+ const { current, servers } = sources.credentials;
594
+ const known = Object.keys(servers);
595
+ if (current && servers[current]) {
596
+ return { origin: current, source: "credentials" };
597
+ }
598
+ if (known.length === 1 && known[0]) {
599
+ return { origin: known[0], source: "credentials" };
600
+ }
601
+ const configured = configServerUrl(sources.configPath);
602
+ if (configured)
603
+ return { origin: originOf(configured), source: "config" };
604
+ return { origin: DEFAULT_SERVER_URL, source: "default" };
605
+ }
606
+
607
+ // src/context.ts
608
+ class CliContext {
609
+ options;
610
+ env;
611
+ cachedCredentials;
612
+ cachedServer;
613
+ constructor(options, env = process.env) {
614
+ this.options = options;
615
+ this.env = env;
616
+ }
617
+ get json() {
618
+ return Boolean(this.options.json) || getMode().json;
619
+ }
620
+ get credentialsPath() {
621
+ return credentialsPathFor(this.env);
622
+ }
623
+ get configPath() {
624
+ return this.options.config ?? defaultConfigPath(this.env);
625
+ }
626
+ get credentials() {
627
+ this.cachedCredentials ??= loadCredentials(this.credentialsPath);
628
+ return this.cachedCredentials;
629
+ }
630
+ get server() {
631
+ this.cachedServer ??= resolveServerUrl({
632
+ flag: this.options.url,
633
+ env: this.env,
634
+ credentials: this.credentials,
635
+ configPath: this.configPath
636
+ });
637
+ return this.cachedServer;
638
+ }
639
+ get entry() {
640
+ return serverEntry(this.credentials, this.server.origin);
641
+ }
642
+ get login() {
643
+ return this.credentials.servers[this.server.origin]?.login ?? null;
644
+ }
645
+ save() {
646
+ saveCredentials(this.credentials, this.credentialsPath);
647
+ }
648
+ client() {
649
+ return this.clientWith(this.login?.token);
650
+ }
651
+ clientWith(token) {
652
+ return new ApiClient(this.server.origin, token, (line) => logger.debug(line));
653
+ }
654
+ signedInClient() {
655
+ if (!this.login) {
656
+ throw new Error(`Not signed in to ${this.server.origin}. Run \`hypotenuse login\`.`);
657
+ }
658
+ return this.client();
659
+ }
660
+ async projectId() {
661
+ const selector = this.options.project ?? this.env.HYPOTENUSE_PROJECT ?? (this.credentials.servers[this.server.origin]?.project != null ? String(this.credentials.servers[this.server.origin]?.project) : undefined);
662
+ if (!selector) {
663
+ throw new Error("No project selected. Pass --project <id|slug> or run `hypotenuse project use <id|slug>`.");
664
+ }
665
+ return resolveProjectId(this.signedInClient(), selector);
666
+ }
667
+ }
668
+ function contextFor(command) {
669
+ const options = command.optsWithGlobals();
670
+ logger.setVerbose(Boolean(options.verbose));
671
+ return new CliContext(options);
672
+ }
673
+ async function runCommand(command, fn) {
674
+ let ctx;
675
+ try {
676
+ ctx = contextFor(command);
677
+ await fn(ctx);
678
+ } catch (error) {
679
+ const message = error instanceof Error ? error.message : String(error);
680
+ logger.error(message);
681
+ if (error instanceof ApiError && error.status === 401 && ctx && !/hypotenuse login/.test(message)) {
682
+ logger.error(`Run \`hypotenuse login\` to sign in to ${ctx.server.origin}.`);
683
+ }
684
+ process.exitCode = 1;
685
+ }
686
+ }
687
+ function emit(ctx, value, human) {
688
+ if (ctx.json) {
689
+ console.log(JSON.stringify(value, null, 2));
690
+ } else {
691
+ const text = human();
692
+ if (text)
693
+ console.log(text);
694
+ }
695
+ }
696
+ function when(ms) {
697
+ if (ms == null)
698
+ return "-";
699
+ const d = new Date(ms);
700
+ const pad = (n) => String(n).padStart(2, "0");
701
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
702
+ }
703
+ function withPaging(command) {
704
+ return command.option("--page <n>", "page number, from 1").option("--limit <n>", "rows per page (default 25)");
705
+ }
706
+ function pageQuery(command) {
707
+ const o = command.opts();
708
+ return {
709
+ page: o.page ? Number(o.page) : undefined,
710
+ limit: o.limit ? Number(o.limit) : undefined
711
+ };
712
+ }
713
+ function pageFooter(pagination) {
714
+ return pagination.pages > 1 ? `
715
+ page ${pagination.page} of ${pagination.pages} (${pagination.total} total)` : "";
716
+ }
717
+
718
+ // src/commands/audit.ts
719
+ var DEFAULT_WINDOW_DAYS = 30;
720
+ function parseTime(raw, now = Date.now()) {
721
+ if (/^\d+$/.test(raw))
722
+ return Number(raw);
723
+ const relative = /^(\d+)([dhm])$/.exec(raw);
724
+ if (relative) {
725
+ const unit = { d: 86400000, h: 3600000, m: 60000 }[relative[2]];
726
+ return now - Number(relative[1]) * unit;
727
+ }
728
+ const parsed = Date.parse(raw);
729
+ if (Number.isNaN(parsed)) {
730
+ throw new Error(`Not a time: ${raw} (use ISO-8601, epoch ms, or 7d / 12h / 30m)`);
731
+ }
732
+ return parsed;
733
+ }
734
+ function registerAuditCommands(program) {
735
+ const audit = program.command("audit").description("The selected project's audit log (admin)");
736
+ withPaging(audit.command("list").description("List who changed what, newest first").option("--since <time>", `start of the range: ISO-8601, epoch ms, or 7d / 12h / 30m (default ${DEFAULT_WINDOW_DAYS}d)`).option("--until <time>", "end of the range (default now)").option("--action <name>", "only this action, e.g. tag:edit")).action(async function() {
737
+ await runCommand(this, async (ctx) => {
738
+ const opts = this.opts();
739
+ const projectId = await ctx.projectId();
740
+ const result = await ctx.signedInClient().request("GET", "/audit-logs", {
741
+ query: {
742
+ projectId,
743
+ since: parseTime(opts.since ?? `${DEFAULT_WINDOW_DAYS}d`),
744
+ until: opts.until ? parseTime(opts.until) : undefined,
745
+ action: opts.action,
746
+ ...pageQuery(this)
747
+ }
748
+ });
749
+ emit(ctx, result, () => table(result.auditLogs.map((a) => ({
750
+ when: when(a.createdAt),
751
+ user: a.userId ?? "-",
752
+ action: a.action,
753
+ target: [a.targetType, a.targetPath].filter(Boolean).join(" ")
754
+ })), [
755
+ { key: "when" },
756
+ { key: "user" },
757
+ { key: "action" },
758
+ { key: "target", maxWidth: 50 }
759
+ ]) + pageFooter(result.pagination));
760
+ });
761
+ });
762
+ }
763
+
764
+ // src/commands/auth.ts
765
+ import { hostname } from "os";
766
+
767
+ // src/shutdown.ts
768
+ class ExitError extends Error {
769
+ code;
770
+ constructor(code, message) {
771
+ super(message);
772
+ this.code = code;
773
+ this.name = "ExitError";
774
+ }
775
+ }
776
+ var registry = new Set;
777
+ var shuttingDown = false;
778
+ var installed = false;
779
+ var HARD_TIMEOUT_MS = 8000;
780
+ function register(c) {
781
+ registry.add(c);
782
+ }
783
+ function unregister(c) {
784
+ registry.delete(c);
785
+ }
786
+ async function runShutdown(exitCode) {
787
+ if (shuttingDown)
788
+ process.exit(exitCode);
789
+ shuttingDown = true;
790
+ const closeAll = Promise.allSettled([...registry].map((c) => c.close()));
791
+ await Promise.race([
792
+ closeAll,
793
+ new Promise((resolve) => setTimeout(resolve, HARD_TIMEOUT_MS).unref())
794
+ ]);
795
+ process.exit(exitCode);
796
+ }
797
+ function installSignalHandlers() {
798
+ if (installed)
799
+ return;
800
+ installed = true;
801
+ process.on("SIGINT", () => void runShutdown(130));
802
+ process.on("SIGTERM", () => void runShutdown(143));
803
+ process.on("SIGHUP", () => void runShutdown(129));
804
+ process.on("uncaughtException", (err) => {
805
+ logger.error(`uncaught exception: ${err?.stack ?? String(err)}`);
806
+ runShutdown(1);
807
+ });
808
+ process.on("unhandledRejection", (reason) => {
809
+ const detail = reason instanceof Error ? reason.stack ?? reason.message : String(reason);
810
+ logger.error(`unhandled rejection: ${detail}`);
811
+ runShutdown(1);
812
+ });
813
+ }
814
+
815
+ // src/api/oauth.ts
816
+ var SIGN_IN_TIMEOUT_MS = 10 * 60 * 1000;
817
+ function randomBase64Url(bytes) {
818
+ return Buffer.from(crypto.getRandomValues(new Uint8Array(bytes))).toString("base64url");
819
+ }
820
+ function s256(verifier) {
821
+ return new Bun.CryptoHasher("sha256").update(verifier).digest("base64url");
822
+ }
823
+ function openBrowser(url, env = process.env) {
824
+ if (env.BROWSER === "none")
825
+ return false;
826
+ const cmd = env.BROWSER ? [env.BROWSER, url] : process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? ["cmd", "/c", "start", "", url] : ["xdg-open", url];
827
+ try {
828
+ const proc = Bun.spawn(cmd, {
829
+ stdin: "ignore",
830
+ stdout: "ignore",
831
+ stderr: "ignore"
832
+ });
833
+ proc.unref();
834
+ return true;
835
+ } catch {
836
+ return false;
837
+ }
838
+ }
839
+ async function loopbackSignIn(options) {
840
+ const verifier = randomBase64Url(48);
841
+ const state = randomBase64Url(16);
842
+ const dashboard = options.dashboardUrl.replace(/\/$/, "");
843
+ let settle;
844
+ const codeArrived = new Promise((resolve, reject) => {
845
+ settle = { resolve, reject };
846
+ });
847
+ const doneUrl = (status) => `${dashboard}/cli/authorize?status=${status}`;
848
+ const server = Bun.serve({
849
+ hostname: "127.0.0.1",
850
+ port: 0,
851
+ fetch(req) {
852
+ const url = new URL(req.url);
853
+ if (url.pathname !== "/callback") {
854
+ return new Response("Not found", { status: 404 });
855
+ }
856
+ if (url.searchParams.get("state") !== state) {
857
+ settle.reject(new Error("The sign-in callback did not match this login; try again."));
858
+ return Response.redirect(doneUrl("error"), 302);
859
+ }
860
+ const error = url.searchParams.get("error");
861
+ const code = url.searchParams.get("code");
862
+ if (error || !code) {
863
+ settle.reject(new Error("Sign-in was cancelled in the browser."));
864
+ return Response.redirect(doneUrl("cancelled"), 302);
865
+ }
866
+ settle.resolve(code);
867
+ return Response.redirect(doneUrl("done"), 302);
868
+ }
869
+ });
870
+ const closeable = { close: async () => void server.stop(true) };
871
+ register(closeable);
872
+ const authorizeUrl = new URL(`${dashboard}/cli/authorize`);
873
+ authorizeUrl.searchParams.set("port", String(server.port));
874
+ authorizeUrl.searchParams.set("state", state);
875
+ authorizeUrl.searchParams.set("code_challenge", s256(verifier));
876
+ authorizeUrl.searchParams.set("device", options.device);
877
+ const opened = (options.open ?? openBrowser)(authorizeUrl.toString());
878
+ options.tell(opened ? "Opening your browser to approve this CLI. If it did not open, visit:" : "Open this URL in a browser to approve this CLI:");
879
+ options.tell(` ${authorizeUrl.toString()}`);
880
+ const timeout = setTimeout(() => settle.reject(new Error("Timed out waiting for the browser.")), options.timeoutMs ?? SIGN_IN_TIMEOUT_MS);
881
+ try {
882
+ const code = await codeArrived;
883
+ const exchanged = await options.client.request("POST", "/cli-auth/exchange", { body: { code, codeVerifier: verifier }, anonymous: true });
884
+ return approved(exchanged);
885
+ } finally {
886
+ clearTimeout(timeout);
887
+ setTimeout(() => server.stop(true), 250).unref();
888
+ unregister(closeable);
889
+ }
890
+ }
891
+ async function deviceSignIn(options) {
892
+ const started = await options.client.request("PUT", "/cli-auth/device", {
893
+ body: { device: options.device },
894
+ anonymous: true
895
+ });
896
+ options.tell(`On any device, open ${started.verificationUrl}`);
897
+ options.tell(`and enter the code: ${started.userCode}`);
898
+ const sleep = options.sleep ?? Bun.sleep;
899
+ const deadline = Date.now() + started.expiresIn * 1000;
900
+ while (Date.now() < deadline) {
901
+ await sleep(started.interval * 1000);
902
+ const polled = await options.client.request("POST", "/cli-auth/exchange", { body: { deviceCode: started.deviceCode }, anonymous: true });
903
+ if (polled.status === "approved")
904
+ return approved(polled);
905
+ }
906
+ throw new Error("The code expired before it was approved. Run login again.");
907
+ }
908
+ function approved(response) {
909
+ if (response.status !== "approved" || !response.token || !response.cliToken) {
910
+ throw new Error("The server did not issue a token.");
911
+ }
912
+ return { token: response.token, cliToken: response.cliToken };
913
+ }
914
+
915
+ // src/commands/auth.ts
916
+ function registerAuthCommands(program) {
917
+ program.command("login").description("Sign this CLI in through the dashboard in your browser").option("--no-browser", "sign in with a code typed on any device (for SSH and headless machines)").option("--device <name>", "the name this machine shows in the dashboard").action(async function() {
918
+ await runCommand(this, async (ctx) => {
919
+ const opts = this.opts();
920
+ const anonymous = ctx.clientWith(undefined);
921
+ const device = opts.device ?? hostname();
922
+ logger.info(`Signing in to ${ctx.server.origin} as ${device}`);
923
+ let signedIn;
924
+ if (opts.browser) {
925
+ const status = await anonymous.request("GET", "/status", { anonymous: true });
926
+ if (!status.dashboardUrl) {
927
+ throw new Error(`${ctx.server.origin} did not say where its dashboard is; try \`hypotenuse login --no-browser\`.`);
928
+ }
929
+ const spinner = logger.spinner("Waiting for approval in the browser");
930
+ try {
931
+ signedIn = await loopbackSignIn({
932
+ client: anonymous,
933
+ dashboardUrl: status.dashboardUrl,
934
+ device,
935
+ tell: (line) => logger.tell(line)
936
+ });
937
+ } finally {
938
+ spinner.stop();
939
+ }
940
+ } else {
941
+ signedIn = await deviceSignIn({
942
+ client: anonymous,
943
+ device,
944
+ tell: (line) => logger.tell(line)
945
+ });
946
+ }
947
+ const previous = ctx.login;
948
+ if (previous && previous.token !== signedIn.token) {
949
+ await ctx.clientWith(previous.token).request("DELETE", "/cli-token", { body: {} }).catch(() => {});
950
+ }
951
+ const { user } = await ctx.clientWith(signedIn.token).request("GET", "/me");
952
+ ctx.entry.login = {
953
+ token: signedIn.token,
954
+ cliTokenId: signedIn.cliToken.id,
955
+ device: signedIn.cliToken.device,
956
+ email: user.email,
957
+ expiresAt: signedIn.cliToken.expiresAt
958
+ };
959
+ ctx.credentials.current = ctx.server.origin;
960
+ ctx.save();
961
+ emit(ctx, {
962
+ server: ctx.server.origin,
963
+ user,
964
+ cliToken: signedIn.cliToken
965
+ }, () => `Signed in to ${ctx.server.origin} as ${user.email}`);
966
+ });
967
+ });
968
+ program.command("logout").description("Revoke this CLI's sign-in and forget it").action(async function() {
969
+ await runCommand(this, async (ctx) => {
970
+ const login = ctx.login;
971
+ if (!login) {
972
+ emit(ctx, { server: ctx.server.origin, revoked: false }, () => `Not signed in to ${ctx.server.origin}`);
973
+ return;
974
+ }
975
+ let revoked = true;
976
+ try {
977
+ await ctx.client().request("DELETE", "/cli-token", { body: {} });
978
+ } catch (error) {
979
+ if (!(error instanceof ApiError && error.status === 401))
980
+ throw error;
981
+ revoked = false;
982
+ }
983
+ ctx.entry.login = null;
984
+ ctx.save();
985
+ emit(ctx, { server: ctx.server.origin, revoked }, () => `Signed out of ${ctx.server.origin}`);
986
+ });
987
+ });
988
+ program.command("whoami").description("Show who this CLI is signed in as, and where").action(async function() {
989
+ await runCommand(this, async (ctx) => {
990
+ const { user } = await ctx.signedInClient().request("GET", "/me");
991
+ emit(ctx, { server: ctx.server.origin, user, device: ctx.login?.device }, () => [
992
+ `${user.name} <${user.email}>`,
993
+ `server ${ctx.server.origin}`,
994
+ `device ${ctx.login?.device ?? "-"}`
995
+ ].join(`
996
+ `));
997
+ });
998
+ });
999
+ }
1000
+
1001
+ // src/update/updater.ts
1002
+ import { createUpdater } from "upgradr";
1003
+ var GITHUB_REPO = package_default.repository.url.replace(/^https:\/\/github\.com\//, "").replace(/\.git$/, "");
1004
+ var BINARY_NAME = Object.keys(package_default.bin)[0];
1005
+ var NO_UPDATE_CHECK_ENV = "HYPOTENUSE_NO_UPDATE_CHECK";
1006
+ var BACKGROUND_SKIP_COMMANDS = [
1007
+ "check-update",
1008
+ "upgrade",
1009
+ "daemon",
1010
+ "mcp"
1011
+ ];
1012
+ function updaterConfig(overrides = {}) {
1013
+ return {
1014
+ currentVersion: package_default.version,
1015
+ packageName: package_default.name,
1016
+ repo: GITHUB_REPO,
1017
+ binaryName: BINARY_NAME,
1018
+ cacheDir: configDir(),
1019
+ cliName: BINARY_NAME,
1020
+ noUpdateCheckEnv: NO_UPDATE_CHECK_ENV,
1021
+ checkIntervalMs: 24 * 60 * 60 * 1000,
1022
+ timeoutMs: 5000,
1023
+ backgroundSkipCommands: BACKGROUND_SKIP_COMMANDS,
1024
+ ...overrides
1025
+ };
1026
+ }
1027
+ var updater = createUpdater(updaterConfig());
1028
+
1029
+ // src/commands/check-update.ts
1030
+ function registerCheckUpdateCommand(program) {
1031
+ program.command("check-update").description("Check for a newer release of hypotenuse").action(async function() {
1032
+ await runCommand(this, async (ctx) => {
1033
+ const spinner = logger.spinner("Checking for updates");
1034
+ let info;
1035
+ try {
1036
+ info = await updater.checkForUpdate();
1037
+ } finally {
1038
+ spinner.stop();
1039
+ }
1040
+ if (!info.error) {
1041
+ await updater.saveCache({
1042
+ lastCheckAt: new Date().toISOString(),
1043
+ latestVersion: info.latestVersion,
1044
+ hasUpdate: info.hasUpdate,
1045
+ changelog: info.changelog
1046
+ });
1047
+ }
1048
+ emit(ctx, info, () => {
1049
+ if (info.error)
1050
+ return `Could not check for updates: ${info.error}`;
1051
+ if (!info.hasUpdate) {
1052
+ return info.aheadOfLatest ? `hypotenuse v${info.currentVersion} is ahead of the latest release (v${info.latestVersion})` : `hypotenuse is up to date (v${info.currentVersion})`;
1053
+ }
1054
+ return [
1055
+ `Update available: ${info.currentVersion} \u2192 ${info.latestVersion}`,
1056
+ ...info.changelog ? ["", dim(info.changelog)] : [],
1057
+ "",
1058
+ "Run `hypotenuse upgrade` to update"
1059
+ ].join(`
1060
+ `);
1061
+ });
1062
+ });
1063
+ });
1064
+ }
1065
+
1066
+ // src/commands/invite.ts
1067
+ function inviteTable(invites) {
1068
+ return table(invites.map((i) => ({
1069
+ id: i.id,
1070
+ project: i.projectName,
1071
+ invitee: i.inviteeEmail,
1072
+ from: i.inviterEmail,
1073
+ status: i.status,
1074
+ expires: when(i.expiresAt)
1075
+ })), [
1076
+ { key: "id" },
1077
+ { key: "project", maxWidth: 30 },
1078
+ { key: "invitee", maxWidth: 40 },
1079
+ { key: "from", maxWidth: 40 },
1080
+ { key: "status" },
1081
+ { key: "expires" }
1082
+ ]);
1083
+ }
1084
+ function tagIds(raw) {
1085
+ return (raw ?? []).flatMap((v) => v.split(",")).map((v) => v.trim()).filter(Boolean).map((v) => {
1086
+ if (!/^\d+$/.test(v))
1087
+ throw new Error(`Not a tag id: ${v}`);
1088
+ return Number(v);
1089
+ });
1090
+ }
1091
+ function registerInviteCommands(program) {
1092
+ const invite = program.command("invite").description("Project invitations");
1093
+ withPaging(invite.command("list").description("The selected project's invites (admin)")).action(async function() {
1094
+ await runCommand(this, async (ctx) => {
1095
+ const projectId = await ctx.projectId();
1096
+ const result = await ctx.signedInClient().request("GET", "/invites", {
1097
+ query: { projectId, ...pageQuery(this) }
1098
+ });
1099
+ emit(ctx, result, () => inviteTable(result.invites) + pageFooter(result.pagination));
1100
+ });
1101
+ });
1102
+ withPaging(invite.command("pending").description("Invites addressed to you")).action(async function() {
1103
+ await runCommand(this, async (ctx) => {
1104
+ const result = await ctx.signedInClient().request("GET", "/invites/pending", {
1105
+ query: pageQuery(this)
1106
+ });
1107
+ emit(ctx, result, () => inviteTable(result.invites) + pageFooter(result.pagination));
1108
+ });
1109
+ });
1110
+ invite.command("create").description("Invite someone to the selected project by email (admin)").requiredOption("--email <email>", "who to invite").option("--tag <id>", "a tag id to grant on acceptance (repeatable, or comma-separated)", (value, previous = []) => [...previous, value]).action(async function() {
1111
+ await runCommand(this, async (ctx) => {
1112
+ const opts = this.opts();
1113
+ const projectId = await ctx.projectId();
1114
+ const result = await ctx.signedInClient().request("PUT", "/invite", {
1115
+ body: {
1116
+ projectId,
1117
+ inviteeEmail: opts.email,
1118
+ tagIds: tagIds(opts.tag)
1119
+ }
1120
+ });
1121
+ emit(ctx, result, () => `Invited ${result.invite.inviteeEmail} (invite ${result.invite.id})`);
1122
+ });
1123
+ });
1124
+ for (const verb of ["accept", "reject"]) {
1125
+ invite.command(verb).description(`${verb === "accept" ? "Accept" : "Reject"} an invite addressed to you`).argument("<inviteId>", "the invite's id (see `invite pending`)").action(async function(inviteId) {
1126
+ await runCommand(this, async (ctx) => {
1127
+ const result = await ctx.signedInClient().request("POST", `/invite/${verb}`, {
1128
+ body: { inviteId: Number(inviteId) }
1129
+ });
1130
+ emit(ctx, result, () => `${verb === "accept" ? "Accepted" : "Rejected"} invite ${inviteId}`);
1131
+ });
1132
+ });
1133
+ }
1134
+ }
1135
+
1136
+ // src/commands/linear.ts
1137
+ var POLL_INTERVAL_MS = 2000;
1138
+ var DEFAULT_TIMEOUT_SECONDS = 300;
1139
+ var STATUS_GLYPH = {
1140
+ active: "online",
1141
+ error: "error",
1142
+ revoked: "offline"
1143
+ };
1144
+ function connectFinished(current, baseline) {
1145
+ return current?.status === "active" && current.connectedAt !== baseline;
1146
+ }
1147
+ function describeInstallation(response) {
1148
+ const i = response.installation;
1149
+ if (!i || i.status === "revoked") {
1150
+ const lines = [
1151
+ `${glyph("offline")} Not connected to Linear. Run \`hypotenuse linear connect\` (admin).`
1152
+ ];
1153
+ if (!response.configured) {
1154
+ lines.push(dim("This server has no Linear app configured."));
1155
+ }
1156
+ return lines.join(`
1157
+ `);
1158
+ }
1159
+ const limit = i.rateLimit;
1160
+ const lines = [
1161
+ `${glyph(STATUS_GLYPH[i.status])} ${i.organizationName} (${i.urlKey}) ${i.status}`,
1162
+ `app user ${i.appUserName} (${i.appUserId})`,
1163
+ `scopes ${i.scopes.join(", ")}`,
1164
+ `teams ${i.teamIds.length}`,
1165
+ `token \u2026${i.accessTokenLastFour || "----"}, expires ${when(i.accessTokenExpiresAt)}`,
1166
+ `refreshed ${when(i.lastRefreshedAt)}`
1167
+ ];
1168
+ if (limit?.requestsRemaining != null) {
1169
+ lines.push(`rate limit ${limit.requestsRemaining}${limit.requestsLimit != null ? `/${limit.requestsLimit}` : ""} requests left`);
1170
+ }
1171
+ if (i.refreshError)
1172
+ lines.push(`error ${i.refreshError}`);
1173
+ return lines.join(`
1174
+ `);
1175
+ }
1176
+ async function confirm(question) {
1177
+ process.stderr.write(`${question} [y/N] `);
1178
+ for await (const line of console) {
1179
+ return /^y(es)?$/i.test(line.trim());
1180
+ }
1181
+ return false;
1182
+ }
1183
+ function registerLinearCommands(program) {
1184
+ const linear = program.command("linear").description("The selected project's Linear workspace connection");
1185
+ linear.command("status").description("Show which Linear workspace the project is connected to").action(async function() {
1186
+ await runCommand(this, async (ctx) => {
1187
+ const projectId = await ctx.projectId();
1188
+ const result = await ctx.signedInClient().request("GET", "/linear/installation", {
1189
+ query: { projectId }
1190
+ });
1191
+ emit(ctx, result, () => describeInstallation(result));
1192
+ });
1193
+ });
1194
+ linear.command("connect").description("Connect the project to a Linear workspace (admin; a Linear admin approves in a browser)").option("--no-browser", "print the consent URL instead of opening it").option("--timeout <seconds>", "how long to wait for the approval", String(DEFAULT_TIMEOUT_SECONDS)).action(async function() {
1195
+ await runCommand(this, async (ctx) => {
1196
+ const { browser, timeout } = this.opts();
1197
+ const client = ctx.signedInClient();
1198
+ const projectId = await ctx.projectId();
1199
+ const read = () => client.request("GET", "/linear/installation", {
1200
+ query: { projectId }
1201
+ });
1202
+ const baseline = (await read()).installation?.connectedAt ?? null;
1203
+ const { authorizeUrl } = await client.request("GET", "/linear/connect", { query: { projectId } });
1204
+ logger.tell("Approve Hypotenuse on Linear's consent screen:");
1205
+ logger.tell(authorizeUrl);
1206
+ if (browser)
1207
+ openBrowser(authorizeUrl);
1208
+ const spinner = logger.spinner("Waiting for a Linear admin to approve");
1209
+ const deadline = Date.now() + Number(timeout) * 1000;
1210
+ try {
1211
+ for (;; ) {
1212
+ const now = await read();
1213
+ if (connectFinished(now.installation, baseline)) {
1214
+ spinner.success(`Connected to ${now.installation.organizationName}`);
1215
+ emit(ctx, now, () => describeInstallation(now));
1216
+ return;
1217
+ }
1218
+ if (Date.now() >= deadline) {
1219
+ throw new Error("Timed out waiting for the approval. If Linear redirected to the dashboard with an error, it says why there.");
1220
+ }
1221
+ await Bun.sleep(POLL_INTERVAL_MS);
1222
+ }
1223
+ } catch (error) {
1224
+ spinner.stop();
1225
+ throw error;
1226
+ }
1227
+ });
1228
+ });
1229
+ linear.command("disconnect").description("Revoke the project's Linear tokens and disconnect the workspace (admin)").option("--yes", "do not ask for confirmation").action(async function() {
1230
+ await runCommand(this, async (ctx) => {
1231
+ const { yes } = this.opts();
1232
+ const client = ctx.signedInClient();
1233
+ const projectId = await ctx.projectId();
1234
+ if (!yes) {
1235
+ if (!process.stdin.isTTY || ctx.json) {
1236
+ throw new Error("Disconnecting stops @hypotenuse in Linear at once. Re-run with --yes to disconnect.");
1237
+ }
1238
+ if (!await confirm("Disconnect Linear? @hypotenuse stops answering in that workspace at once.")) {
1239
+ throw new Error("Not disconnected.");
1240
+ }
1241
+ }
1242
+ const result = await client.request("DELETE", "/linear/installation", { body: { projectId } });
1243
+ emit(ctx, result, () => `Disconnected ${result.installation.organizationName}`);
1244
+ });
1245
+ });
1246
+ }
1247
+
1248
+ // src/commands/member.ts
1249
+ function registerMemberCommands(program) {
1250
+ const member = program.command("member").description("The selected project's members");
1251
+ withPaging(member.command("list").description("List members")).action(async function() {
1252
+ await runCommand(this, async (ctx) => {
1253
+ const projectId = await ctx.projectId();
1254
+ const result = await ctx.signedInClient().request("GET", "/memberships", {
1255
+ query: { projectId, ...pageQuery(this) }
1256
+ });
1257
+ emit(ctx, result, () => table(result.memberships.map((m) => ({
1258
+ user: m.userId,
1259
+ name: m.user.name,
1260
+ email: m.user.email,
1261
+ tags: m.tags.map((t) => t.name).join(", "),
1262
+ joined: when(m.createdAt)
1263
+ })), [
1264
+ { key: "user" },
1265
+ { key: "name", maxWidth: 30 },
1266
+ { key: "email", maxWidth: 40 },
1267
+ { key: "tags", maxWidth: 40 },
1268
+ { key: "joined" }
1269
+ ]) + pageFooter(result.pagination));
1270
+ });
1271
+ });
1272
+ member.command("add").description("Add an existing user by email (admin)").requiredOption("--email <email>", "the user's email").action(async function() {
1273
+ await runCommand(this, async (ctx) => {
1274
+ const { email } = this.opts();
1275
+ const projectId = await ctx.projectId();
1276
+ const result = await ctx.signedInClient().request("PUT", "/membership", { body: { projectId, email } });
1277
+ emit(ctx, result, () => `Added ${email}`);
1278
+ });
1279
+ });
1280
+ member.command("remove").description("Remove a member by user id (admin); revokes their runners too").argument("<userId>", "the member's user id").action(async function(userId) {
1281
+ await runCommand(this, async (ctx) => {
1282
+ const projectId = await ctx.projectId();
1283
+ const result = await ctx.signedInClient().request("DELETE", "/membership", {
1284
+ body: { projectId, userId: Number(userId) }
1285
+ });
1286
+ emit(ctx, result, () => `Removed user ${userId}`);
1287
+ });
1288
+ });
1289
+ }
1290
+
1291
+ // src/commands/project.ts
1292
+ function projectTable(projects, selected) {
1293
+ return table(projects.map((p) => ({
1294
+ " ": p.id === selected ? "*" : "",
1295
+ id: p.id,
1296
+ slug: p.slug,
1297
+ name: p.name,
1298
+ created: when(p.createdAt)
1299
+ })), [
1300
+ { key: " " },
1301
+ { key: "id" },
1302
+ { key: "slug" },
1303
+ { key: "name", maxWidth: 40 },
1304
+ { key: "created" }
1305
+ ]);
1306
+ }
1307
+ function registerProjectCommands(program) {
1308
+ const project = program.command("project").description("Your projects, and which one commands act on");
1309
+ withPaging(project.command("list").description("List the projects you belong to")).action(async function() {
1310
+ await runCommand(this, async (ctx) => {
1311
+ const result = await ctx.signedInClient().request("GET", "/projects", { query: pageQuery(this) });
1312
+ emit(ctx, result, () => projectTable(result.projects, ctx.entry.project) + pageFooter(result.pagination));
1313
+ });
1314
+ });
1315
+ project.command("view").description("Show a project and your standing in it").argument("[project]", "id or slug (default: the selected project)").action(async function(selector) {
1316
+ await runCommand(this, async (ctx) => {
1317
+ const client = ctx.signedInClient();
1318
+ const projectId = selector ? await resolveProjectId(client, selector) : await ctx.projectId();
1319
+ const result = await client.request("GET", "/project", { query: { projectId } });
1320
+ emit(ctx, result, () => [
1321
+ `${result.project.name} (${result.project.slug}, id ${result.project.id})`,
1322
+ `standing ${result.standing.isAdmin ? "admin" : "member"}`,
1323
+ `tags ${result.callerTags.map((t) => t.name).join(", ") || "-"}`,
1324
+ `created ${when(result.project.createdAt)}`
1325
+ ].join(`
1326
+ `));
1327
+ });
1328
+ });
1329
+ project.command("create").description("Create a project; you become its admin").requiredOption("--name <name>", "the project's display name").action(async function() {
1330
+ await runCommand(this, async (ctx) => {
1331
+ const { name } = this.opts();
1332
+ const result = await ctx.signedInClient().request("PUT", "/project", {
1333
+ body: { name }
1334
+ });
1335
+ emit(ctx, result, () => `Created ${result.project.name} (${result.project.slug}, id ${result.project.id})`);
1336
+ });
1337
+ });
1338
+ project.command("edit").description("Rename a project (admin)").argument("<project>", "id or slug").requiredOption("--name <name>", "the new display name").action(async function(selector) {
1339
+ await runCommand(this, async (ctx) => {
1340
+ const { name } = this.opts();
1341
+ const client = ctx.signedInClient();
1342
+ const projectId = await resolveProjectId(client, selector);
1343
+ const result = await client.request("POST", "/project", { body: { projectId, name } });
1344
+ emit(ctx, result, () => `Renamed project ${projectId} to ${name}`);
1345
+ });
1346
+ });
1347
+ project.command("delete").description("Delete a project and everything in it (admin)").argument("<project>", "id or slug").option("--yes", "confirm; required, because this cannot be undone").action(async function(selector) {
1348
+ await runCommand(this, async (ctx) => {
1349
+ const { yes } = this.opts();
1350
+ if (!yes) {
1351
+ throw new Error(`Deleting a project cannot be undone. Re-run with --yes to delete ${selector}.`);
1352
+ }
1353
+ const client = ctx.signedInClient();
1354
+ const projectId = await resolveProjectId(client, selector);
1355
+ const result = await client.request("DELETE", "/project", {
1356
+ body: { projectId }
1357
+ });
1358
+ if (ctx.entry.project === projectId) {
1359
+ ctx.entry.project = null;
1360
+ ctx.save();
1361
+ }
1362
+ emit(ctx, result, () => `Deleted project ${selector}`);
1363
+ });
1364
+ });
1365
+ project.command("use").description("Select the project later commands act on").argument("<project>", "id or slug").action(async function(selector) {
1366
+ await runCommand(this, async (ctx) => {
1367
+ const client = ctx.signedInClient();
1368
+ const projectId = await resolveProjectId(client, selector);
1369
+ const { project: selected } = await client.request("GET", "/project", { query: { projectId } });
1370
+ ctx.entry.project = selected.id;
1371
+ ctx.save();
1372
+ emit(ctx, { project: selected }, () => `Using ${selected.name} (${selected.slug}, id ${selected.id})`);
1373
+ });
1374
+ });
1375
+ }
1376
+
1377
+ // src/commands/tag.ts
1378
+ function registerTagCommands(program) {
1379
+ const tag = program.command("tag").description("The selected project's permission tags");
1380
+ withPaging(tag.command("list").description("List tags")).action(async function() {
1381
+ await runCommand(this, async (ctx) => {
1382
+ const projectId = await ctx.projectId();
1383
+ const result = await ctx.signedInClient().request("GET", "/tags", { query: { projectId, ...pageQuery(this) } });
1384
+ emit(ctx, result, () => table(result.tags.map((t) => ({
1385
+ id: t.id,
1386
+ name: t.name,
1387
+ reserved: t.reserved ? "yes" : "",
1388
+ created: when(t.createdAt)
1389
+ })), [
1390
+ { key: "id" },
1391
+ { key: "name", maxWidth: 40 },
1392
+ { key: "reserved" },
1393
+ { key: "created" }
1394
+ ]) + pageFooter(result.pagination));
1395
+ });
1396
+ });
1397
+ tag.command("create").description("Create a tag (admin)").argument("<name>", "the tag's name").action(async function(name) {
1398
+ await runCommand(this, async (ctx) => {
1399
+ const projectId = await ctx.projectId();
1400
+ const result = await ctx.signedInClient().request("PUT", "/tag", { body: { projectId, name } });
1401
+ emit(ctx, result, () => `Created tag ${result.tag.name} (id ${result.tag.id})`);
1402
+ });
1403
+ });
1404
+ tag.command("edit").description("Rename a tag (admin)").argument("<tagId>", "the tag's id").requiredOption("--name <name>", "the new name").action(async function(tagId) {
1405
+ await runCommand(this, async (ctx) => {
1406
+ const { name } = this.opts();
1407
+ const projectId = await ctx.projectId();
1408
+ const result = await ctx.signedInClient().request("POST", "/tag", {
1409
+ body: { projectId, tagId: Number(tagId), name }
1410
+ });
1411
+ emit(ctx, result, () => `Renamed tag ${tagId} to ${name}`);
1412
+ });
1413
+ });
1414
+ tag.command("delete").description("Delete a tag (admin)").argument("<tagId>", "the tag's id").action(async function(tagId) {
1415
+ await runCommand(this, async (ctx) => {
1416
+ const projectId = await ctx.projectId();
1417
+ const result = await ctx.signedInClient().request("DELETE", "/tag", {
1418
+ body: { projectId, tagId: Number(tagId) }
1419
+ });
1420
+ emit(ctx, result, () => `Deleted tag ${tagId}`);
1421
+ });
1422
+ });
1423
+ for (const [verb, method, done] of [
1424
+ ["assign", "PUT", "Granted"],
1425
+ ["unassign", "DELETE", "Revoked"]
1426
+ ]) {
1427
+ tag.command(verb).description(verb === "assign" ? "Grant a tag to a member (admin)" : "Revoke a tag from a member (admin)").argument("<tagId>", "the tag's id").requiredOption("--user <userId>", "the member's user id").action(async function(tagId) {
1428
+ await runCommand(this, async (ctx) => {
1429
+ const { user } = this.opts();
1430
+ const projectId = await ctx.projectId();
1431
+ const result = await ctx.signedInClient().request(method, "/user-tag", {
1432
+ body: { projectId, userId: Number(user), tagId: Number(tagId) }
1433
+ });
1434
+ emit(ctx, result, () => `${done} tag ${tagId} for user ${user}`);
1435
+ });
1436
+ });
1437
+ }
1438
+ }
1439
+
1440
+ // src/commands/upgrade.ts
1441
+ var WINDOWS_REINSTALL = "irm https://raw.githubusercontent.com/arcadeai-labs/hypotenuse/main/install.ps1 | iex";
1442
+ async function performUpgrade(up, platform = process.platform) {
1443
+ const method = up.detectInstallMethod();
1444
+ let result;
1445
+ if (platform === "win32" && method === "binary") {
1446
+ const info = await up.checkForUpdate();
1447
+ result = {
1448
+ method,
1449
+ from: info.currentVersion,
1450
+ to: info.latestVersion,
1451
+ hasUpdate: info.hasUpdate,
1452
+ performed: false,
1453
+ success: !info.error && !info.hasUpdate,
1454
+ ...info.error ? { error: info.error } : {},
1455
+ ...info.hasUpdate ? { reinstall: WINDOWS_REINSTALL } : {}
1456
+ };
1457
+ if (info.hasUpdate) {
1458
+ return {
1459
+ result,
1460
+ message: `hypotenuse v${result.to} is available. Windows cannot replace a running hypotenuse.exe; reinstall with:
1461
+ ${WINDOWS_REINSTALL}`,
1462
+ failed: false,
1463
+ exitCode: 0
1464
+ };
1465
+ }
1466
+ } else {
1467
+ result = await up.upgrade();
1468
+ }
1469
+ const exitCode = result.performed && !result.success ? 1 : 0;
1470
+ if (result.error && !result.performed) {
1471
+ return {
1472
+ result,
1473
+ message: `Could not check for updates: ${result.error}`,
1474
+ failed: true,
1475
+ exitCode
1476
+ };
1477
+ }
1478
+ if (!result.hasUpdate) {
1479
+ return {
1480
+ result,
1481
+ message: `hypotenuse is already up to date (v${result.from})`,
1482
+ failed: false,
1483
+ exitCode
1484
+ };
1485
+ }
1486
+ if (result.method === "local-dev") {
1487
+ return {
1488
+ result,
1489
+ message: "Running from source. Use `git pull && bun install` to update.",
1490
+ failed: false,
1491
+ exitCode
1492
+ };
1493
+ }
1494
+ if (result.success) {
1495
+ return {
1496
+ result,
1497
+ message: `Upgraded hypotenuse v${result.from} \u2192 v${result.to}`,
1498
+ failed: false,
1499
+ exitCode
1500
+ };
1501
+ }
1502
+ return {
1503
+ result,
1504
+ message: `Upgrade failed${result.error ? `: ${result.error}` : "."}`,
1505
+ failed: true,
1506
+ exitCode
1507
+ };
1508
+ }
1509
+ function registerUpgradeCommand(program) {
1510
+ program.command("upgrade").description("Upgrade hypotenuse to the latest release").action(async function() {
1511
+ await runCommand(this, async (ctx) => {
1512
+ const spinner = logger.spinner("Checking for updates");
1513
+ let outcome;
1514
+ try {
1515
+ outcome = await performUpgrade(updater);
1516
+ } finally {
1517
+ spinner.stop();
1518
+ }
1519
+ if (ctx.json) {
1520
+ emit(ctx, outcome.result, () => "");
1521
+ } else if (outcome.failed) {
1522
+ logger.error(outcome.message);
1523
+ } else {
1524
+ console.log(outcome.message);
1525
+ }
1526
+ if (outcome.exitCode !== 0)
1527
+ process.exitCode = outcome.exitCode;
1528
+ });
1529
+ });
1530
+ }
1531
+
1532
+ // src/program.ts
1533
+ var VALUE_OPTIONS = ["-c", "--config", "--url", "--project"];
1534
+ function buildProgram() {
1535
+ const program = new Command().name("hypotenuse").description(package_default.description).version(package_default.version, "-V, --version", "print the version").option("-c, --config <path>", "the daemon config file").option("--url <origin>", "the Hypotenuse server (default: where you last logged in)").option("--project <id|slug>", "the project to act on (default: `project use`)").option("-j, --json", "print machine-readable JSON on stdout").option("-v, --verbose", "print each request on stderr").option("--no-color", "disable colored output").option("--force-color", "color output even when it is piped").showHelpAfterError().action(() => {
1536
+ console.log(banner(package_default.version));
1537
+ console.log("");
1538
+ program.outputHelp();
1539
+ });
1540
+ registerAuthCommands(program);
1541
+ registerProjectCommands(program);
1542
+ registerMemberCommands(program);
1543
+ registerInviteCommands(program);
1544
+ registerTagCommands(program);
1545
+ registerAuditCommands(program);
1546
+ registerLinearCommands(program);
1547
+ registerCheckUpdateCommand(program);
1548
+ registerUpgradeCommand(program);
1549
+ return program;
1550
+ }
1551
+ function firstCommandWord(argv) {
1552
+ for (let i = 0;i < argv.length; i++) {
1553
+ const arg = argv[i];
1554
+ if (VALUE_OPTIONS.includes(arg)) {
1555
+ i++;
1556
+ continue;
1557
+ }
1558
+ if (arg.startsWith("-"))
1559
+ continue;
1560
+ return arg;
1561
+ }
1562
+ return;
1563
+ }
1564
+
1565
+ // src/cli.ts
1566
+ installSignalHandlers();
1567
+ var argv = process.argv.slice(2);
1568
+ setMode(detectMode({
1569
+ env: process.env,
1570
+ isTTY: Boolean(process.stdout.isTTY || process.stderr.isTTY),
1571
+ argv
1572
+ }));
1573
+ var program = buildProgram();
1574
+ var command = firstCommandWord(argv);
1575
+ var known = new Set(program.commands.map((c) => c.name()));
1576
+ if (command && command !== "help" && !known.has(command)) {
1577
+ logger.error(`unknown command '${command}'. See 'hypotenuse --help'.`);
1578
+ process.exit(1);
1579
+ }
1580
+ var json = argv.includes("--json") || argv.includes("-j");
1581
+ var updateNotice = updater.maybeBackgroundNotice(json ? { isTTY: false } : undefined);
1582
+ try {
1583
+ await program.parseAsync(process.argv);
1584
+ } catch (error) {
1585
+ if (error instanceof ExitError)
1586
+ process.exit(error.code);
1587
+ logger.error(error instanceof Error ? error.message : String(error));
1588
+ process.exit(1);
1589
+ }
1590
+ process.once("beforeExit", async () => {
1591
+ const notice = await updateNotice;
1592
+ if (notice)
1593
+ logger.writeRaw(notice);
1594
+ });