@hraness/dawg 0.0.0-stage → 0.3.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/CHANGELOG.md +126 -0
- package/DAWG.md +327 -0
- package/LICENSE +21 -0
- package/README.md +213 -2
- package/core/diff.ts +249 -0
- package/core/drums.ts +102 -0
- package/core/key.ts +43 -0
- package/core/loop.ts +78 -0
- package/core/pitch.ts +60 -0
- package/core/score.ts +1388 -0
- package/core/sdk/eval-child.ts +113 -0
- package/core/sdk/eval.ts +257 -0
- package/core/sdk/print.ts +393 -0
- package/core/sdk/v1.ts +954 -0
- package/core/slug.ts +19 -0
- package/package.json +45 -4
- package/src/agent/agent.ts +853 -0
- package/src/agent/brief.ts +160 -0
- package/src/agent/gateway.ts +441 -0
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +157 -0
- package/src/agent/planner.ts +259 -0
- package/src/agent/provider.ts +454 -0
- package/src/agent/sse.ts +114 -0
- package/src/agent/tools.ts +1373 -0
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +262 -0
- package/src/agent/xcb.ts +579 -0
- package/src/audio/click.ts +125 -0
- package/src/audio/clock.ts +68 -0
- package/src/audio/engine.ts +841 -0
- package/src/audio/live.ts +152 -0
- package/src/audio/lock.ts +57 -0
- package/src/audio/player.ts +134 -0
- package/src/audio/render-worker.ts +68 -0
- package/src/audio/renderer.ts +174 -0
- package/src/audio/sampler.ts +292 -0
- package/src/audio/samples.ts +683 -0
- package/src/audio/wav.ts +861 -0
- package/src/auth/cli.ts +231 -0
- package/src/auth/credentials.ts +411 -0
- package/src/auth/discover.ts +481 -0
- package/src/auth/login.ts +1191 -0
- package/src/auth/openrouter.ts +206 -0
- package/src/auth/picker.ts +282 -0
- package/src/auth/runner.ts +207 -0
- package/src/auth/tui.ts +107 -0
- package/src/commands/edit.ts +170 -0
- package/src/commands/help.ts +247 -0
- package/src/commands/history.ts +69 -0
- package/src/commands/music.ts +461 -0
- package/src/commands/sample.ts +302 -0
- package/src/daemon.ts +31 -0
- package/src/main.ts +2209 -0
- package/src/media/analyze.ts +364 -0
- package/src/media/backend.ts +253 -0
- package/src/media/cli.ts +173 -0
- package/src/media/download.ts +281 -0
- package/src/media/dsp.ts +281 -0
- package/src/media/import.ts +130 -0
- package/src/media/lyrics.ts +201 -0
- package/src/media/notes.ts +363 -0
- package/src/media/paths.ts +168 -0
- package/src/media/process.ts +226 -0
- package/src/media/registry.ts +9 -0
- package/src/media/sidecar.ts +72 -0
- package/src/media/stemdeck.ts +254 -0
- package/src/media/stems.ts +173 -0
- package/src/media/tools.ts +292 -0
- package/src/media/types.ts +92 -0
- package/src/media/vendor/basic-pitch.ts +261 -0
- package/src/media/vendor/drums.ts +817 -0
- package/src/media/vendor/grid.ts +203 -0
- package/src/media/vendor/util.ts +139 -0
- package/src/media/vendor/wav.ts +233 -0
- package/src/project/check.ts +80 -0
- package/src/project/init.ts +253 -0
- package/src/project/sync.ts +432 -0
- package/src/project/typecheck.ts +149 -0
- package/src/render.ts +121 -0
- package/src/session/attach.ts +181 -0
- package/src/session/client.ts +498 -0
- package/src/session/daemon.ts +740 -0
- package/src/session/delta.ts +249 -0
- package/src/session/list.ts +180 -0
- package/src/session/lock.ts +92 -0
- package/src/session/meta.ts +253 -0
- package/src/session/naming.ts +430 -0
- package/src/session/port.ts +481 -0
- package/src/session/presence.ts +159 -0
- package/src/session/protocol.ts +618 -0
- package/src/session/rebase.ts +168 -0
- package/src/session/store.ts +581 -0
- package/src/tui/menu.ts +1083 -0
- package/src/tui/play-mode.ts +442 -0
- package/src/tui/play-session.ts +636 -0
- package/src/web/fetch.ts +340 -0
- package/src/web/http.ts +137 -0
- package/src/web/search.ts +681 -0
- package/tui/activity.ts +364 -0
- package/tui/app.ts +1372 -0
- package/tui/drums.ts +65 -0
- package/tui/highway.ts +921 -0
- package/tui/input.ts +63 -0
- package/tui/keys.ts +102 -0
- package/tui/layers.ts +80 -0
- package/tui/play-strip.ts +143 -0
- package/tui/prompt.ts +609 -0
- package/tui/render.ts +124 -0
- package/tui/screen.ts +247 -0
- package/tui/text.ts +72 -0
- package/tui/theme.ts +451 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { isValidKey } from "./credentials.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* OpenRouter's OAuth PKCE flow for apps
|
|
6
|
+
* (https://openrouter.ai/docs/use-cases/oauth-pkce): open
|
|
7
|
+
* `https://openrouter.ai/auth?callback_url=…&code_challenge=…&code_challenge_method=S256`,
|
|
8
|
+
* receive `?code=` on a localhost callback (any port is allowed), then
|
|
9
|
+
* exchange it with `POST /api/v1/auth/keys` for a user-controlled key.
|
|
10
|
+
*/
|
|
11
|
+
export const OPENROUTER_AUTH_URL = "https://openrouter.ai/auth";
|
|
12
|
+
export const OPENROUTER_API_URL = "https://openrouter.ai/api/v1";
|
|
13
|
+
export const OPENROUTER_KEYS_URL = "https://openrouter.ai/settings/keys";
|
|
14
|
+
export const OAUTH_TIMEOUT_MS = 5 * 60_000;
|
|
15
|
+
const MAX_EXCHANGE_BYTES = 16 * 1024;
|
|
16
|
+
const CODE_PATTERN = /^[A-Za-z0-9._~-]{8,512}$/;
|
|
17
|
+
|
|
18
|
+
type Fetcher = (
|
|
19
|
+
input: RequestInfo | URL,
|
|
20
|
+
init?: RequestInit,
|
|
21
|
+
) => Promise<Response>;
|
|
22
|
+
|
|
23
|
+
export type Pkce = Readonly<{
|
|
24
|
+
verifier: string;
|
|
25
|
+
challenge: string;
|
|
26
|
+
state: string;
|
|
27
|
+
}>;
|
|
28
|
+
|
|
29
|
+
function base64url(bytes: Buffer): string {
|
|
30
|
+
return bytes
|
|
31
|
+
.toString("base64")
|
|
32
|
+
.replace(/\+/g, "-")
|
|
33
|
+
.replace(/\//g, "_")
|
|
34
|
+
.replace(/=+$/, "");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A 43-character verifier, its S256 challenge, and a CSRF state. */
|
|
38
|
+
export function createPkce(
|
|
39
|
+
random: (size: number) => Buffer = randomBytes,
|
|
40
|
+
): Pkce {
|
|
41
|
+
const verifier = base64url(random(32));
|
|
42
|
+
const challenge = base64url(createHash("sha256").update(verifier).digest());
|
|
43
|
+
return { verifier, challenge, state: base64url(random(16)) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function authorizeUrl(
|
|
47
|
+
callbackUrl: string,
|
|
48
|
+
pkce: Pkce,
|
|
49
|
+
base = OPENROUTER_AUTH_URL,
|
|
50
|
+
): string {
|
|
51
|
+
const url = new URL(base);
|
|
52
|
+
url.searchParams.set("callback_url", callbackUrl);
|
|
53
|
+
url.searchParams.set("code_challenge", pkce.challenge);
|
|
54
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
55
|
+
url.searchParams.set("key_label", "dawg");
|
|
56
|
+
url.searchParams.set("state", pkce.state);
|
|
57
|
+
return url.toString();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function sameSecret(a: string, b: string): boolean {
|
|
61
|
+
const left = Buffer.from(a);
|
|
62
|
+
const right = Buffer.from(b);
|
|
63
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const PAGE = (title: string, body: string) =>
|
|
67
|
+
`<!doctype html><meta charset="utf-8"><title>${title}</title><body style="font:16px system-ui;margin:3rem;max-width:32rem"><h1 style="font-size:1.4rem">${title}</h1><p>${body}</p></body>`;
|
|
68
|
+
|
|
69
|
+
export type CallbackServer = Readonly<{
|
|
70
|
+
/** `http://127.0.0.1:<port>/callback`. */
|
|
71
|
+
url: string;
|
|
72
|
+
/** Resolves with the code once a request carries the right state. */
|
|
73
|
+
code: Promise<string>;
|
|
74
|
+
stop(): void;
|
|
75
|
+
}>;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A one-shot callback listener on 127.0.0.1 and an ephemeral port. Requests
|
|
79
|
+
* with a missing or wrong `state` are refused and do not end the wait.
|
|
80
|
+
*/
|
|
81
|
+
export function startCallbackServer(
|
|
82
|
+
state: string,
|
|
83
|
+
options: { signal?: AbortSignal; timeoutMs?: number } = {},
|
|
84
|
+
): CallbackServer {
|
|
85
|
+
let settle: { resolve(code: string): void; reject(error: Error): void };
|
|
86
|
+
const code = new Promise<string>((resolve, reject) => {
|
|
87
|
+
settle = { resolve, reject };
|
|
88
|
+
});
|
|
89
|
+
code.catch(() => undefined);
|
|
90
|
+
const server = Bun.serve({
|
|
91
|
+
hostname: "127.0.0.1",
|
|
92
|
+
port: 0,
|
|
93
|
+
fetch(request) {
|
|
94
|
+
const url = new URL(request.url);
|
|
95
|
+
if (url.pathname !== "/callback")
|
|
96
|
+
return new Response("not found", { status: 404 });
|
|
97
|
+
const html = (status: number, title: string, body: string) =>
|
|
98
|
+
new Response(PAGE(title, body), {
|
|
99
|
+
status,
|
|
100
|
+
headers: {
|
|
101
|
+
"content-type": "text/html; charset=utf-8",
|
|
102
|
+
"cache-control": "no-store",
|
|
103
|
+
"referrer-policy": "no-referrer",
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
const got = url.searchParams.get("state") ?? "";
|
|
107
|
+
const value = url.searchParams.get("code") ?? "";
|
|
108
|
+
if (!sameSecret(got, state))
|
|
109
|
+
return html(
|
|
110
|
+
400,
|
|
111
|
+
"Sign-in not recognized",
|
|
112
|
+
"This link does not match the sign-in dawg started. Return to the terminal and try again.",
|
|
113
|
+
);
|
|
114
|
+
if (!CODE_PATTERN.test(value)) {
|
|
115
|
+
settle.reject(
|
|
116
|
+
new Error("OpenRouter did not return a code (access denied?)"),
|
|
117
|
+
);
|
|
118
|
+
return html(
|
|
119
|
+
400,
|
|
120
|
+
"Sign-in cancelled",
|
|
121
|
+
"No authorization code came back. You can close this tab.",
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
settle.resolve(value);
|
|
125
|
+
return html(
|
|
126
|
+
200,
|
|
127
|
+
"dawg is connected to OpenRouter",
|
|
128
|
+
"You can close this tab and return to the terminal.",
|
|
129
|
+
);
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
const timer = setTimeout(
|
|
133
|
+
() => settle.reject(new Error("timed out waiting for the browser")),
|
|
134
|
+
options.timeoutMs ?? OAUTH_TIMEOUT_MS,
|
|
135
|
+
);
|
|
136
|
+
const onAbort = () => settle.reject(new Error("sign-in cancelled"));
|
|
137
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
138
|
+
const stop = () => {
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
141
|
+
// Graceful first so the browser still gets the "connected" page, then
|
|
142
|
+
// force-close any idle keep-alive sockets.
|
|
143
|
+
void server.stop(false);
|
|
144
|
+
setTimeout(() => void server.stop(true), 1_000).unref?.();
|
|
145
|
+
};
|
|
146
|
+
void code.then(stop, stop);
|
|
147
|
+
return { url: `http://127.0.0.1:${server.port}/callback`, code, stop };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** `POST /api/v1/auth/keys` → the user's new key. */
|
|
151
|
+
export async function exchangeCode(
|
|
152
|
+
code: string,
|
|
153
|
+
pkce: Pick<Pkce, "verifier">,
|
|
154
|
+
options: { fetcher?: Fetcher; baseUrl?: string } = {},
|
|
155
|
+
): Promise<string> {
|
|
156
|
+
const fetcher = options.fetcher ?? fetch;
|
|
157
|
+
const base = (options.baseUrl ?? OPENROUTER_API_URL).replace(/\/$/, "");
|
|
158
|
+
const response = await fetcher(`${base}/auth/keys`, {
|
|
159
|
+
method: "POST",
|
|
160
|
+
headers: { "content-type": "application/json" },
|
|
161
|
+
body: JSON.stringify({
|
|
162
|
+
code,
|
|
163
|
+
code_verifier: pkce.verifier,
|
|
164
|
+
code_challenge_method: "S256",
|
|
165
|
+
}),
|
|
166
|
+
signal: AbortSignal.timeout(20_000),
|
|
167
|
+
});
|
|
168
|
+
const text = (await response.text()).slice(0, MAX_EXCHANGE_BYTES);
|
|
169
|
+
if (!response.ok)
|
|
170
|
+
throw new Error(
|
|
171
|
+
response.status === 403
|
|
172
|
+
? "OpenRouter refused the code (expired or already used)"
|
|
173
|
+
: `OpenRouter key exchange failed (${response.status})`,
|
|
174
|
+
);
|
|
175
|
+
let key: unknown;
|
|
176
|
+
try {
|
|
177
|
+
key = (JSON.parse(text) as { key?: unknown }).key;
|
|
178
|
+
} catch {
|
|
179
|
+
key = undefined;
|
|
180
|
+
}
|
|
181
|
+
if (!isValidKey(key)) throw new Error("OpenRouter returned no usable key");
|
|
182
|
+
return key;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export type KeyCheck = "valid" | "rejected" | "unverified";
|
|
186
|
+
|
|
187
|
+
/** `GET /api/v1/key`: cheap, authenticated, and spends nothing. */
|
|
188
|
+
export async function checkOpenRouterKey(
|
|
189
|
+
key: string,
|
|
190
|
+
options: { fetcher?: Fetcher; baseUrl?: string } = {},
|
|
191
|
+
): Promise<KeyCheck> {
|
|
192
|
+
const fetcher = options.fetcher ?? fetch;
|
|
193
|
+
const base = (options.baseUrl ?? OPENROUTER_API_URL).replace(/\/$/, "");
|
|
194
|
+
try {
|
|
195
|
+
const response = await fetcher(`${base}/key`, {
|
|
196
|
+
headers: { authorization: `Bearer ${key}` },
|
|
197
|
+
signal: AbortSignal.timeout(8_000),
|
|
198
|
+
});
|
|
199
|
+
await response.body?.cancel().catch(() => undefined);
|
|
200
|
+
if (response.ok) return "valid";
|
|
201
|
+
if (response.status === 401 || response.status === 403) return "rejected";
|
|
202
|
+
return "unverified";
|
|
203
|
+
} catch {
|
|
204
|
+
return "unverified";
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { stdin, stdout } from "node:process";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A small inline picker for the shell (`dawg login`, `dawg model`), in the
|
|
5
|
+
* style of the Codex CLI sign-in: ↑/↓ or j/k move, Enter chooses, 1-9 jump
|
|
6
|
+
* and choose, typing filters (when enabled), Esc or Ctrl-C cancels. It draws
|
|
7
|
+
* below the cursor and erases itself afterwards, so the scrollback keeps only
|
|
8
|
+
* the one-line result the caller prints.
|
|
9
|
+
*/
|
|
10
|
+
export type ChoiceItem = Readonly<{
|
|
11
|
+
label: string;
|
|
12
|
+
/** Right-hand muted text: a status, a price. */
|
|
13
|
+
detail?: string;
|
|
14
|
+
/** Section heading shown above the first row of each group. */
|
|
15
|
+
group?: string;
|
|
16
|
+
/** Rows that cannot be chosen still show (e.g. "not set up"). */
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
/** Marked with ● (the current model). */
|
|
19
|
+
current?: boolean;
|
|
20
|
+
}>;
|
|
21
|
+
|
|
22
|
+
export type ChooseOptions = Readonly<{
|
|
23
|
+
initial?: number;
|
|
24
|
+
/** Type-to-filter on label and detail. */
|
|
25
|
+
filter?: boolean;
|
|
26
|
+
/** Footer hint override. */
|
|
27
|
+
hint?: string;
|
|
28
|
+
}>;
|
|
29
|
+
|
|
30
|
+
export type PickerKey =
|
|
31
|
+
| { type: "up" }
|
|
32
|
+
| { type: "down" }
|
|
33
|
+
| { type: "enter" }
|
|
34
|
+
| { type: "cancel" }
|
|
35
|
+
| { type: "digit"; value: number }
|
|
36
|
+
| { type: "backspace" }
|
|
37
|
+
| { type: "text"; text: string };
|
|
38
|
+
|
|
39
|
+
/** Split one raw stdin read into picker keys. */
|
|
40
|
+
export function parsePickerKeys(data: string): PickerKey[] {
|
|
41
|
+
const keys: PickerKey[] = [];
|
|
42
|
+
let rest = data.replace(/\u001b\[20[01]~/g, "");
|
|
43
|
+
while (rest.length > 0) {
|
|
44
|
+
if (rest.startsWith("\u001b[A") || rest.startsWith("\u001bOA")) {
|
|
45
|
+
keys.push({ type: "up" });
|
|
46
|
+
rest = rest.slice(3);
|
|
47
|
+
} else if (rest.startsWith("\u001b[B") || rest.startsWith("\u001bOB")) {
|
|
48
|
+
keys.push({ type: "down" });
|
|
49
|
+
rest = rest.slice(3);
|
|
50
|
+
} else if (rest.startsWith("\u001b[")) {
|
|
51
|
+
// Other CSI sequences (left/right, home, …) are ignored.
|
|
52
|
+
const match = /^\u001b\[[0-9;]*[A-Za-z~]/.exec(rest);
|
|
53
|
+
rest = rest.slice(match ? match[0].length : 2);
|
|
54
|
+
} else {
|
|
55
|
+
const char = rest[0]!;
|
|
56
|
+
rest = rest.slice(1);
|
|
57
|
+
if (char === "\r" || char === "\n") keys.push({ type: "enter" });
|
|
58
|
+
else if (char === "\u001b" || char === "\u0003")
|
|
59
|
+
keys.push({ type: "cancel" });
|
|
60
|
+
else if (char === "\u007f" || char === "\b")
|
|
61
|
+
keys.push({ type: "backspace" });
|
|
62
|
+
else if (char === "\u0010") keys.push({ type: "up" });
|
|
63
|
+
else if (char === "\u000e") keys.push({ type: "down" });
|
|
64
|
+
else if (char >= " ") keys.push({ type: "text", text: char });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return keys;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The picker's pure state; `step` applies one key. */
|
|
71
|
+
export class PickerModel {
|
|
72
|
+
index: number;
|
|
73
|
+
query = "";
|
|
74
|
+
constructor(
|
|
75
|
+
readonly items: readonly ChoiceItem[],
|
|
76
|
+
readonly options: ChooseOptions = {},
|
|
77
|
+
) {
|
|
78
|
+
const visible = this.visible();
|
|
79
|
+
const initial = options.initial ?? 0;
|
|
80
|
+
const start = visible.indexOf(initial) >= 0 ? initial : (visible[0] ?? 0);
|
|
81
|
+
this.index = start;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Indices of rows matching the filter, in order. */
|
|
85
|
+
visible(): number[] {
|
|
86
|
+
const query = this.query.toLowerCase();
|
|
87
|
+
return this.items
|
|
88
|
+
.map((item, index) => ({ item, index }))
|
|
89
|
+
.filter(
|
|
90
|
+
({ item }) =>
|
|
91
|
+
!query ||
|
|
92
|
+
item.label.toLowerCase().includes(query) ||
|
|
93
|
+
(item.detail ?? "").toLowerCase().includes(query) ||
|
|
94
|
+
(item.group ?? "").toLowerCase().includes(query),
|
|
95
|
+
)
|
|
96
|
+
.map(({ index }) => index);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** `undefined` while picking, a row index when chosen, `null` on cancel. */
|
|
100
|
+
step(key: PickerKey): number | null | undefined {
|
|
101
|
+
const visible = this.visible();
|
|
102
|
+
const position = Math.max(0, visible.indexOf(this.index));
|
|
103
|
+
const move = (delta: number) => {
|
|
104
|
+
if (visible.length === 0) return;
|
|
105
|
+
for (let offset = 1; offset <= visible.length; offset += 1) {
|
|
106
|
+
const n = visible.length;
|
|
107
|
+
const next = visible[(((position + delta * offset) % n) + n) % n]!;
|
|
108
|
+
if (!this.items[next]?.disabled) {
|
|
109
|
+
this.index = next;
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
switch (key.type) {
|
|
115
|
+
case "up":
|
|
116
|
+
move(-1);
|
|
117
|
+
return undefined;
|
|
118
|
+
case "down":
|
|
119
|
+
move(1);
|
|
120
|
+
return undefined;
|
|
121
|
+
case "enter":
|
|
122
|
+
return visible.includes(this.index) && !this.items[this.index]?.disabled
|
|
123
|
+
? this.index
|
|
124
|
+
: undefined;
|
|
125
|
+
case "cancel":
|
|
126
|
+
if (this.query) {
|
|
127
|
+
this.query = "";
|
|
128
|
+
this.refocus();
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
case "backspace":
|
|
133
|
+
this.query = this.query.slice(0, -1);
|
|
134
|
+
this.refocus();
|
|
135
|
+
return undefined;
|
|
136
|
+
case "digit": {
|
|
137
|
+
const target = visible[key.value - 1];
|
|
138
|
+
if (target !== undefined && !this.items[target]?.disabled)
|
|
139
|
+
return target;
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
case "text":
|
|
143
|
+
if (/^[1-9]$/.test(key.text) && !this.query)
|
|
144
|
+
return this.step({ type: "digit", value: Number(key.text) });
|
|
145
|
+
if (!this.options.filter) {
|
|
146
|
+
if (key.text === "k") move(-1);
|
|
147
|
+
if (key.text === "j") move(1);
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
if (this.query.length < 40) this.query += key.text;
|
|
151
|
+
this.refocus();
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private refocus(): void {
|
|
157
|
+
const visible = this.visible().filter(
|
|
158
|
+
(index) => !this.items[index]?.disabled,
|
|
159
|
+
);
|
|
160
|
+
if (!visible.includes(this.index) && visible.length > 0)
|
|
161
|
+
this.index = visible[0]!;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Plain lines (no colour); the renderer styles them. */
|
|
165
|
+
lines(
|
|
166
|
+
title: string,
|
|
167
|
+
maxRows = 14,
|
|
168
|
+
): {
|
|
169
|
+
text: string;
|
|
170
|
+
kind: "title" | "group" | "row" | "selected" | "muted" | "hint";
|
|
171
|
+
}[] {
|
|
172
|
+
const out: {
|
|
173
|
+
text: string;
|
|
174
|
+
kind: "title" | "group" | "row" | "selected" | "muted" | "hint";
|
|
175
|
+
}[] = [
|
|
176
|
+
{
|
|
177
|
+
text: title + (this.query ? ` filter: ${this.query}` : ""),
|
|
178
|
+
kind: "title",
|
|
179
|
+
},
|
|
180
|
+
];
|
|
181
|
+
const visible = this.visible();
|
|
182
|
+
if (visible.length === 0) out.push({ text: " no matches", kind: "muted" });
|
|
183
|
+
const position = Math.max(0, visible.indexOf(this.index));
|
|
184
|
+
const first = Math.max(
|
|
185
|
+
0,
|
|
186
|
+
Math.min(visible.length - maxRows, position - Math.floor(maxRows / 2)),
|
|
187
|
+
);
|
|
188
|
+
let lastGroup: string | undefined;
|
|
189
|
+
visible.slice(first, first + maxRows).forEach((index, offset) => {
|
|
190
|
+
const item = this.items[index]!;
|
|
191
|
+
if (item.group && item.group !== lastGroup) {
|
|
192
|
+
out.push({ text: ` ${item.group}`, kind: "group" });
|
|
193
|
+
}
|
|
194
|
+
lastGroup = item.group;
|
|
195
|
+
const selected = index === this.index;
|
|
196
|
+
const number =
|
|
197
|
+
first + offset < 9 && !this.query ? `${first + offset + 1}.` : " ";
|
|
198
|
+
const mark = item.current ? "●" : " ";
|
|
199
|
+
out.push({
|
|
200
|
+
text: `${selected ? "›" : " "} ${number} ${mark} ${item.label}${item.detail ? ` ${item.detail}` : ""}`,
|
|
201
|
+
kind: selected ? "selected" : item.disabled ? "muted" : "row",
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
out.push({
|
|
205
|
+
text:
|
|
206
|
+
this.options.hint ??
|
|
207
|
+
` ↑/↓ move · enter choose · 1-9 pick${this.options.filter ? " · type to filter" : ""} · esc cancel`,
|
|
208
|
+
kind: "hint",
|
|
209
|
+
});
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const ESC = "\u001b[";
|
|
215
|
+
|
|
216
|
+
function style(kind: string, text: string, color: boolean): string {
|
|
217
|
+
if (!color) return text;
|
|
218
|
+
if (kind === "title") return `${ESC}1m${text}${ESC}0m`;
|
|
219
|
+
if (kind === "selected") return `${ESC}1;36m${text}${ESC}0m`;
|
|
220
|
+
if (kind === "group") return `${ESC}2;4m${text}${ESC}0m`;
|
|
221
|
+
if (kind === "muted" || kind === "hint") return `${ESC}2m${text}${ESC}0m`;
|
|
222
|
+
return text;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Run a picker on the real terminal. Resolves the chosen index, or
|
|
227
|
+
* `undefined` on Esc / Ctrl-C. Requires a TTY on stdin and stdout.
|
|
228
|
+
*/
|
|
229
|
+
export function chooseOnTerminal(
|
|
230
|
+
title: string,
|
|
231
|
+
items: readonly ChoiceItem[],
|
|
232
|
+
options: ChooseOptions = {},
|
|
233
|
+
): Promise<number | undefined> {
|
|
234
|
+
const model = new PickerModel(items, options);
|
|
235
|
+
const color = !process.env.NO_COLOR && process.env.TERM !== "dumb";
|
|
236
|
+
const width = () => Math.max(20, (stdout.columns ?? 80) - 1);
|
|
237
|
+
let drawn = 0;
|
|
238
|
+
const draw = () => {
|
|
239
|
+
const lines = model.lines(
|
|
240
|
+
title,
|
|
241
|
+
Math.max(4, Math.min(14, (stdout.rows ?? 24) - 6)),
|
|
242
|
+
);
|
|
243
|
+
let out = drawn > 0 ? `\r${ESC}${drawn - 1}A` : "\r";
|
|
244
|
+
out += `${ESC}J`;
|
|
245
|
+
out += lines
|
|
246
|
+
.map((line) => style(line.kind, truncate(line.text, width()), color))
|
|
247
|
+
.join("\r\n");
|
|
248
|
+
drawn = lines.length;
|
|
249
|
+
stdout.write(`${ESC}?25l${out}`);
|
|
250
|
+
};
|
|
251
|
+
const erase = () => {
|
|
252
|
+
if (drawn > 0) stdout.write(`\r${ESC}${drawn - 1}A${ESC}J`);
|
|
253
|
+
stdout.write(`${ESC}?25h`);
|
|
254
|
+
};
|
|
255
|
+
return new Promise((resolve) => {
|
|
256
|
+
const wasRaw = stdin.isRaw;
|
|
257
|
+
stdin.setRawMode?.(true);
|
|
258
|
+
stdin.resume();
|
|
259
|
+
const finish = (result: number | undefined) => {
|
|
260
|
+
stdin.off("data", onData);
|
|
261
|
+
stdin.setRawMode?.(wasRaw);
|
|
262
|
+
stdin.pause();
|
|
263
|
+
erase();
|
|
264
|
+
resolve(result);
|
|
265
|
+
};
|
|
266
|
+
const onData = (chunk: Buffer) => {
|
|
267
|
+
for (const key of parsePickerKeys(chunk.toString("utf8"))) {
|
|
268
|
+
const result = model.step(key);
|
|
269
|
+
if (result === null) return finish(undefined);
|
|
270
|
+
if (result !== undefined) return finish(result);
|
|
271
|
+
}
|
|
272
|
+
draw();
|
|
273
|
+
};
|
|
274
|
+
stdin.on("data", onData);
|
|
275
|
+
draw();
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function truncate(text: string, width: number): string {
|
|
280
|
+
const chars = [...text];
|
|
281
|
+
return chars.length > width ? `${chars.slice(0, width - 1).join("")}…` : text;
|
|
282
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place dawg starts helper CLIs (`vercel`, `security`, `xcb`).
|
|
3
|
+
* Everything above it takes a `CommandRunner`, so tests script the
|
|
4
|
+
* subprocesses instead of touching real accounts or the keychain.
|
|
5
|
+
*/
|
|
6
|
+
export type RunOptions = Readonly<{
|
|
7
|
+
/** Written to the child's stdin, then stdin is closed. */
|
|
8
|
+
stdin?: string;
|
|
9
|
+
/** Hand the terminal to the child (for `vercel login`). Output is not captured. */
|
|
10
|
+
inherit?: boolean;
|
|
11
|
+
timeoutMs?: number;
|
|
12
|
+
/** Aborting sends SIGTERM and still waits for the child to exit. */
|
|
13
|
+
signal?: AbortSignal;
|
|
14
|
+
/** Bound on captured stdout/stderr; excess is dropped. */
|
|
15
|
+
maxOutputBytes?: number;
|
|
16
|
+
env?: Readonly<Record<string, string | undefined>>;
|
|
17
|
+
/**
|
|
18
|
+
* Called with each decoded chunk as it arrives (progress parsing). Chunks
|
|
19
|
+
* keep flowing past `maxOutputBytes`; only the captured text is bounded.
|
|
20
|
+
*/
|
|
21
|
+
onOutput?: (stream: "stdout" | "stderr", chunk: string) => void;
|
|
22
|
+
}>;
|
|
23
|
+
|
|
24
|
+
export type RunResult = Readonly<{
|
|
25
|
+
code: number;
|
|
26
|
+
stdout: string;
|
|
27
|
+
stderr: string;
|
|
28
|
+
/** True when the child was stopped by `signal` or `timeoutMs`. */
|
|
29
|
+
killed: boolean;
|
|
30
|
+
}>;
|
|
31
|
+
|
|
32
|
+
export type CommandRunner = Readonly<{
|
|
33
|
+
run(
|
|
34
|
+
command: string,
|
|
35
|
+
args: readonly string[],
|
|
36
|
+
options?: RunOptions,
|
|
37
|
+
): Promise<RunResult>;
|
|
38
|
+
/** Absolute path of an executable on PATH, or undefined. */
|
|
39
|
+
which(command: string): string | undefined;
|
|
40
|
+
}>;
|
|
41
|
+
|
|
42
|
+
const DEFAULT_MAX_OUTPUT = 1024 * 1024;
|
|
43
|
+
const KILL_GRACE_MS = 15_000;
|
|
44
|
+
|
|
45
|
+
export const systemRunner: CommandRunner = {
|
|
46
|
+
which: (command) => Bun.which(command) ?? undefined,
|
|
47
|
+
async run(command, args, options = {}) {
|
|
48
|
+
const maxBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT;
|
|
49
|
+
const env = options.env ? { ...process.env, ...options.env } : process.env;
|
|
50
|
+
if (options.inherit) {
|
|
51
|
+
const child = Bun.spawn([command, ...args], {
|
|
52
|
+
stdin: "inherit",
|
|
53
|
+
stdout: "inherit",
|
|
54
|
+
stderr: "inherit",
|
|
55
|
+
env,
|
|
56
|
+
});
|
|
57
|
+
const stop = watchKill(child, options);
|
|
58
|
+
try {
|
|
59
|
+
const code = await child.exited;
|
|
60
|
+
return { code, stdout: "", stderr: "", killed: stop.killed() };
|
|
61
|
+
} finally {
|
|
62
|
+
stop.dispose();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const child = Bun.spawn([command, ...args], {
|
|
66
|
+
stdin: options.stdin === undefined ? "ignore" : "pipe",
|
|
67
|
+
stdout: "pipe",
|
|
68
|
+
stderr: "pipe",
|
|
69
|
+
env,
|
|
70
|
+
});
|
|
71
|
+
const stop = watchKill(child, options);
|
|
72
|
+
try {
|
|
73
|
+
if (options.stdin !== undefined && child.stdin) {
|
|
74
|
+
try {
|
|
75
|
+
child.stdin.write(options.stdin);
|
|
76
|
+
await child.stdin.end();
|
|
77
|
+
} catch {
|
|
78
|
+
// The child may exit before reading its input; its exit code reports why.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const tap = options.onOutput;
|
|
82
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
83
|
+
readBounded(
|
|
84
|
+
child.stdout,
|
|
85
|
+
maxBytes,
|
|
86
|
+
tap && ((chunk) => tap("stdout", chunk)),
|
|
87
|
+
),
|
|
88
|
+
readBounded(
|
|
89
|
+
child.stderr,
|
|
90
|
+
maxBytes,
|
|
91
|
+
tap && ((chunk) => tap("stderr", chunk)),
|
|
92
|
+
),
|
|
93
|
+
child.exited,
|
|
94
|
+
]);
|
|
95
|
+
return { code, stdout, stderr, killed: stop.killed() };
|
|
96
|
+
} finally {
|
|
97
|
+
stop.dispose();
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
function watchKill(
|
|
103
|
+
child: { kill(signal?: number | NodeJS.Signals): void },
|
|
104
|
+
options: RunOptions,
|
|
105
|
+
): { killed(): boolean; dispose(): void } {
|
|
106
|
+
let killed = false;
|
|
107
|
+
let escalate: ReturnType<typeof setTimeout> | undefined;
|
|
108
|
+
const kill = () => {
|
|
109
|
+
if (killed) return;
|
|
110
|
+
killed = true;
|
|
111
|
+
try {
|
|
112
|
+
child.kill("SIGTERM");
|
|
113
|
+
} catch {
|
|
114
|
+
// Already exited.
|
|
115
|
+
}
|
|
116
|
+
// Callers still await exit; a child that ignores SIGTERM gets SIGKILL.
|
|
117
|
+
escalate = setTimeout(() => {
|
|
118
|
+
try {
|
|
119
|
+
child.kill("SIGKILL");
|
|
120
|
+
} catch {
|
|
121
|
+
// Already exited.
|
|
122
|
+
}
|
|
123
|
+
}, KILL_GRACE_MS);
|
|
124
|
+
};
|
|
125
|
+
const timer =
|
|
126
|
+
options.timeoutMs !== undefined
|
|
127
|
+
? setTimeout(kill, options.timeoutMs)
|
|
128
|
+
: undefined;
|
|
129
|
+
if (options.signal?.aborted) kill();
|
|
130
|
+
options.signal?.addEventListener("abort", kill, { once: true });
|
|
131
|
+
return {
|
|
132
|
+
killed: () => killed,
|
|
133
|
+
dispose() {
|
|
134
|
+
if (timer) clearTimeout(timer);
|
|
135
|
+
if (escalate) clearTimeout(escalate);
|
|
136
|
+
options.signal?.removeEventListener("abort", kill);
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function readBounded(
|
|
142
|
+
stream: ReadableStream<Uint8Array> | undefined | null,
|
|
143
|
+
maxBytes: number,
|
|
144
|
+
tap?: (chunk: string) => void,
|
|
145
|
+
): Promise<string> {
|
|
146
|
+
if (!stream) return "";
|
|
147
|
+
const decoder = new TextDecoder();
|
|
148
|
+
const tapDecoder = tap ? new TextDecoder() : undefined;
|
|
149
|
+
let text = "";
|
|
150
|
+
let bytes = 0;
|
|
151
|
+
for await (const chunk of stream) {
|
|
152
|
+
if (tap && tapDecoder) {
|
|
153
|
+
try {
|
|
154
|
+
tap(tapDecoder.decode(chunk, { stream: true }));
|
|
155
|
+
} catch {
|
|
156
|
+
// A progress parser must never stop the drain.
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (bytes >= maxBytes) continue; // Keep draining so the child never blocks.
|
|
160
|
+
const room = maxBytes - bytes;
|
|
161
|
+
const part = chunk.byteLength > room ? chunk.subarray(0, room) : chunk;
|
|
162
|
+
bytes += part.byteLength;
|
|
163
|
+
text += decoder.decode(part, { stream: true });
|
|
164
|
+
}
|
|
165
|
+
return text + decoder.decode();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** A scripted runner for tests: each call is matched in order by a predicate. */
|
|
169
|
+
export type ScriptedCall = Readonly<{
|
|
170
|
+
match: (command: string, args: readonly string[]) => boolean;
|
|
171
|
+
result?: Partial<RunResult>;
|
|
172
|
+
respond?: (
|
|
173
|
+
command: string,
|
|
174
|
+
args: readonly string[],
|
|
175
|
+
options: RunOptions,
|
|
176
|
+
) => Promise<Partial<RunResult>> | Partial<RunResult>;
|
|
177
|
+
}>;
|
|
178
|
+
|
|
179
|
+
export function scriptedRunner(
|
|
180
|
+
script: readonly ScriptedCall[],
|
|
181
|
+
installed: readonly string[] = [],
|
|
182
|
+
): CommandRunner & {
|
|
183
|
+
calls: { command: string; args: readonly string[]; options: RunOptions }[];
|
|
184
|
+
} {
|
|
185
|
+
const calls: {
|
|
186
|
+
command: string;
|
|
187
|
+
args: readonly string[];
|
|
188
|
+
options: RunOptions;
|
|
189
|
+
}[] = [];
|
|
190
|
+
const remaining = [...script];
|
|
191
|
+
return {
|
|
192
|
+
calls,
|
|
193
|
+
which: (command) =>
|
|
194
|
+
installed.includes(command) ? `/usr/local/bin/${command}` : undefined,
|
|
195
|
+
async run(command, args, options = {}) {
|
|
196
|
+
calls.push({ command, args, options });
|
|
197
|
+
const index = remaining.findIndex((step) => step.match(command, args));
|
|
198
|
+
if (index < 0)
|
|
199
|
+
throw new Error(`unexpected command: ${command} ${args.join(" ")}`);
|
|
200
|
+
const [step] = remaining.splice(index, 1);
|
|
201
|
+
const partial = step!.respond
|
|
202
|
+
? await step!.respond(command, args, options)
|
|
203
|
+
: (step!.result ?? {});
|
|
204
|
+
return { code: 0, stdout: "", stderr: "", killed: false, ...partial };
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|