@balise.dev/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -0
- package/dist/index.js +1337 -0
- package/dist/index.js.map +1 -0
- package/package.json +44 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1337 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { defineCommand, runMain } from "citty";
|
|
5
|
+
|
|
6
|
+
// src/commands/login.ts
|
|
7
|
+
import open from "open";
|
|
8
|
+
import crypto2 from "crypto";
|
|
9
|
+
|
|
10
|
+
// src/oauth.ts
|
|
11
|
+
import crypto from "crypto";
|
|
12
|
+
import http from "http";
|
|
13
|
+
import { request } from "undici";
|
|
14
|
+
function generateCodeVerifier() {
|
|
15
|
+
return base64url(crypto.randomBytes(64));
|
|
16
|
+
}
|
|
17
|
+
function codeChallengeFor(verifier) {
|
|
18
|
+
return base64url(crypto.createHash("sha256").update(verifier).digest());
|
|
19
|
+
}
|
|
20
|
+
function base64url(buf) {
|
|
21
|
+
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
22
|
+
}
|
|
23
|
+
var SUCCESS_HTML = `<!doctype html>
|
|
24
|
+
<html><head><title>Balise \u2014 Logged in</title>
|
|
25
|
+
<style>body{font-family:system-ui,sans-serif;padding:4rem;text-align:center;background:#0b0b0b;color:#eee}
|
|
26
|
+
h1{font-size:2rem;margin:0 0 1rem}p{opacity:.7}</style></head>
|
|
27
|
+
<body><h1>✓ Balise CLI logged in</h1>
|
|
28
|
+
<p>You can close this tab and return to your terminal.</p>
|
|
29
|
+
<script>setTimeout(()=>window.close(),1200)</script></body></html>`;
|
|
30
|
+
var FAILURE_HTML = `<!doctype html>
|
|
31
|
+
<html><head><title>Balise \u2014 Login failed</title></head>
|
|
32
|
+
<body style="font-family:system-ui;padding:4rem;text-align:center">
|
|
33
|
+
<h1>Login failed</h1><p>See your terminal for details.</p></body></html>`;
|
|
34
|
+
function startLoopbackServer(timeoutMs = 3e5) {
|
|
35
|
+
let resolveCode;
|
|
36
|
+
let rejectCode;
|
|
37
|
+
const waitForCode = new Promise((res, rej) => {
|
|
38
|
+
resolveCode = res;
|
|
39
|
+
rejectCode = rej;
|
|
40
|
+
});
|
|
41
|
+
const server = http.createServer((req, res) => {
|
|
42
|
+
if (!req.url) return;
|
|
43
|
+
const url = new URL(req.url, "http://localhost");
|
|
44
|
+
if (url.pathname !== "/callback") {
|
|
45
|
+
res.writeHead(404).end("Not Found");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const code = url.searchParams.get("code");
|
|
49
|
+
const error = url.searchParams.get("error");
|
|
50
|
+
if (error) {
|
|
51
|
+
res.writeHead(400, { "Content-Type": "text/html" }).end(FAILURE_HTML);
|
|
52
|
+
rejectCode(new Error(`OAuth error: ${error}`));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (!code) {
|
|
56
|
+
res.writeHead(400, { "Content-Type": "text/html" }).end(FAILURE_HTML);
|
|
57
|
+
rejectCode(new Error("OAuth callback missing `code`"));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
res.writeHead(200, { "Content-Type": "text/html" }).end(SUCCESS_HTML);
|
|
61
|
+
resolveCode(code);
|
|
62
|
+
});
|
|
63
|
+
const portPromise = new Promise((res, rej) => {
|
|
64
|
+
server.once("error", rej);
|
|
65
|
+
server.listen(0, "127.0.0.1", () => {
|
|
66
|
+
const { port } = server.address();
|
|
67
|
+
res(port);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
const timer = setTimeout(() => {
|
|
71
|
+
rejectCode(new Error(`OAuth login timed out after ${timeoutMs / 1e3}s`));
|
|
72
|
+
server.close();
|
|
73
|
+
}, timeoutMs);
|
|
74
|
+
return {
|
|
75
|
+
port: portPromise,
|
|
76
|
+
waitForCode: waitForCode.finally(() => {
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
server.close();
|
|
79
|
+
}),
|
|
80
|
+
close: () => {
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
server.close();
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function buildAuthorizeUrl(params) {
|
|
87
|
+
const u = new URL(
|
|
88
|
+
`${params.supabaseUrl.replace(/\/$/, "")}/auth/v1/oauth/authorize`
|
|
89
|
+
);
|
|
90
|
+
u.searchParams.set("response_type", "code");
|
|
91
|
+
u.searchParams.set("client_id", params.clientId);
|
|
92
|
+
u.searchParams.set("redirect_uri", params.redirectUri);
|
|
93
|
+
u.searchParams.set("code_challenge", params.codeChallenge);
|
|
94
|
+
u.searchParams.set("code_challenge_method", "S256");
|
|
95
|
+
u.searchParams.set("state", params.state);
|
|
96
|
+
return u.toString();
|
|
97
|
+
}
|
|
98
|
+
async function registerClient(params) {
|
|
99
|
+
const url = `${params.supabaseUrl.replace(/\/$/, "")}/auth/v1/oauth/clients/register`;
|
|
100
|
+
const body = JSON.stringify({
|
|
101
|
+
client_name: params.clientName,
|
|
102
|
+
redirect_uris: [params.redirectUri],
|
|
103
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
104
|
+
response_types: ["code"],
|
|
105
|
+
token_endpoint_auth_method: "none",
|
|
106
|
+
application_type: "native"
|
|
107
|
+
});
|
|
108
|
+
const { statusCode, body: respBody } = await request(url, {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: { "Content-Type": "application/json" },
|
|
111
|
+
body
|
|
112
|
+
});
|
|
113
|
+
const text = await respBody.text();
|
|
114
|
+
if (statusCode < 200 || statusCode >= 300) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`Client registration failed (${statusCode}): ${text.slice(0, 200)}`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const data = JSON.parse(text);
|
|
120
|
+
if (!data.client_id) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`Client registration response missing client_id: ${text.slice(0, 200)}`
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
return data.client_id;
|
|
126
|
+
}
|
|
127
|
+
async function exchangeCodeForTokens(params) {
|
|
128
|
+
const url = `${params.supabaseUrl.replace(/\/$/, "")}/auth/v1/oauth/token`;
|
|
129
|
+
const body = new URLSearchParams({
|
|
130
|
+
grant_type: "authorization_code",
|
|
131
|
+
code: params.code,
|
|
132
|
+
code_verifier: params.codeVerifier,
|
|
133
|
+
redirect_uri: params.redirectUri,
|
|
134
|
+
client_id: params.clientId
|
|
135
|
+
}).toString();
|
|
136
|
+
const { statusCode, body: respBody } = await request(url, {
|
|
137
|
+
method: "POST",
|
|
138
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
139
|
+
body
|
|
140
|
+
});
|
|
141
|
+
const text = await respBody.text();
|
|
142
|
+
if (statusCode < 200 || statusCode >= 300) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`Token exchange failed (${statusCode}): ${text.slice(0, 200)}`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
return JSON.parse(text);
|
|
148
|
+
}
|
|
149
|
+
async function refreshAccessToken(params) {
|
|
150
|
+
const url = `${params.supabaseUrl.replace(/\/$/, "")}/auth/v1/oauth/token`;
|
|
151
|
+
const reqBody = new URLSearchParams({
|
|
152
|
+
grant_type: "refresh_token",
|
|
153
|
+
refresh_token: params.refreshToken,
|
|
154
|
+
client_id: params.clientId
|
|
155
|
+
}).toString();
|
|
156
|
+
const { statusCode, body } = await request(url, {
|
|
157
|
+
method: "POST",
|
|
158
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
159
|
+
body: reqBody
|
|
160
|
+
});
|
|
161
|
+
const text = await body.text();
|
|
162
|
+
if (statusCode < 200 || statusCode >= 300) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
`Token refresh failed (${statusCode}): ${text.slice(0, 200)}`
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
return JSON.parse(text);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// src/credentials.ts
|
|
171
|
+
import { promises as fs } from "fs";
|
|
172
|
+
import path from "path";
|
|
173
|
+
import os from "os";
|
|
174
|
+
var APP_DIR = "balise";
|
|
175
|
+
var FILENAME = "credentials.json";
|
|
176
|
+
var CredentialsError = class extends Error {
|
|
177
|
+
constructor(message, cause) {
|
|
178
|
+
super(message);
|
|
179
|
+
this.cause = cause;
|
|
180
|
+
this.name = "CredentialsError";
|
|
181
|
+
}
|
|
182
|
+
cause;
|
|
183
|
+
};
|
|
184
|
+
function credentialsPath() {
|
|
185
|
+
const fileOverride = process.env.BALISE_CREDENTIALS_FILE;
|
|
186
|
+
if (fileOverride && fileOverride.length > 0) return fileOverride;
|
|
187
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
188
|
+
const base = xdg && xdg.length > 0 ? xdg : path.join(os.homedir(), ".config");
|
|
189
|
+
return path.join(base, APP_DIR, FILENAME);
|
|
190
|
+
}
|
|
191
|
+
function hasEnvToken() {
|
|
192
|
+
const v = process.env.BALISE_TOKEN;
|
|
193
|
+
return typeof v === "string" && v.length > 0;
|
|
194
|
+
}
|
|
195
|
+
function isValidTokens(v) {
|
|
196
|
+
if (!v || typeof v !== "object") return false;
|
|
197
|
+
const t = v;
|
|
198
|
+
return typeof t.access_token === "string" && typeof t.refresh_token === "string";
|
|
199
|
+
}
|
|
200
|
+
async function loadTokens() {
|
|
201
|
+
if (hasEnvToken()) {
|
|
202
|
+
return {
|
|
203
|
+
access_token: process.env.BALISE_TOKEN,
|
|
204
|
+
refresh_token: ""
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
const p = credentialsPath();
|
|
208
|
+
let raw;
|
|
209
|
+
try {
|
|
210
|
+
raw = await fs.readFile(p, "utf8");
|
|
211
|
+
} catch (err) {
|
|
212
|
+
if (err?.code === "ENOENT") return null;
|
|
213
|
+
throw new CredentialsError(
|
|
214
|
+
`Cannot read credentials file ${p}: ${err.message}`,
|
|
215
|
+
err
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
if (process.platform !== "win32") {
|
|
219
|
+
try {
|
|
220
|
+
const st = await fs.stat(p);
|
|
221
|
+
if ((st.mode & 63) !== 0) {
|
|
222
|
+
process.stderr.write(
|
|
223
|
+
`Warning: credentials file ${p} has loose permissions; will re-tighten on next write.
|
|
224
|
+
`
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
} catch {
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
let parsed;
|
|
231
|
+
try {
|
|
232
|
+
parsed = JSON.parse(raw);
|
|
233
|
+
} catch {
|
|
234
|
+
process.stderr.write(
|
|
235
|
+
`Warning: credentials file ${p} is corrupt, please re-login.
|
|
236
|
+
`
|
|
237
|
+
);
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
if (!isValidTokens(parsed)) {
|
|
241
|
+
process.stderr.write(
|
|
242
|
+
`Warning: credentials file ${p} is missing required keys, please re-login.
|
|
243
|
+
`
|
|
244
|
+
);
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
return parsed;
|
|
248
|
+
}
|
|
249
|
+
async function saveTokens(t) {
|
|
250
|
+
if (hasEnvToken()) {
|
|
251
|
+
process.stderr.write(
|
|
252
|
+
"Warning: env var BALISE_TOKEN active, skipping credentials file write.\n"
|
|
253
|
+
);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (process.platform === "win32") {
|
|
257
|
+
throw new CredentialsError(
|
|
258
|
+
"Windows credential file storage not yet supported. Use BALISE_TOKEN env var instead."
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
const p = credentialsPath();
|
|
262
|
+
const dir = path.dirname(p);
|
|
263
|
+
try {
|
|
264
|
+
await fs.mkdir(dir, { recursive: true, mode: 448 });
|
|
265
|
+
} catch (err) {
|
|
266
|
+
throw new CredentialsError(
|
|
267
|
+
`Cannot create credentials directory ${dir}: ${err.message}. Set BALISE_TOKEN env var instead.`,
|
|
268
|
+
err
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
try {
|
|
272
|
+
await fs.writeFile(p, JSON.stringify(t), { mode: 384 });
|
|
273
|
+
await fs.chmod(p, 384);
|
|
274
|
+
} catch (err) {
|
|
275
|
+
throw new CredentialsError(
|
|
276
|
+
`Cannot write credentials file ${p}: ${err.message}. Set BALISE_TOKEN env var instead.`,
|
|
277
|
+
err
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
async function clearTokens() {
|
|
282
|
+
const p = credentialsPath();
|
|
283
|
+
let existed = true;
|
|
284
|
+
try {
|
|
285
|
+
await fs.unlink(p);
|
|
286
|
+
} catch (err) {
|
|
287
|
+
const e = err;
|
|
288
|
+
if (e?.code === "ENOENT") {
|
|
289
|
+
existed = false;
|
|
290
|
+
} else {
|
|
291
|
+
throw new CredentialsError(
|
|
292
|
+
`Cannot remove credentials file ${p}: ${e.message ?? String(err)}`,
|
|
293
|
+
err
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (hasEnvToken()) {
|
|
298
|
+
process.stderr.write(
|
|
299
|
+
"Warning: BALISE_TOKEN env var still set \u2014 unset it manually to fully log out.\n"
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
return existed;
|
|
303
|
+
}
|
|
304
|
+
function credentialsHelpMessage() {
|
|
305
|
+
const p = credentialsPath();
|
|
306
|
+
return `Credentials are stored at ${p} (mode 0600).
|
|
307
|
+
\u2022 Set BALISE_TOKEN=<jwt> to supply a token directly (useful in CI).
|
|
308
|
+
\u2022 Set BALISE_CREDENTIALS_FILE=<path> to override the storage location.`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// src/commands/login.ts
|
|
312
|
+
async function runLogin(opts) {
|
|
313
|
+
if (process.env.BALISE_TOKEN && process.env.BALISE_TOKEN.length > 0) {
|
|
314
|
+
process.stderr.write(
|
|
315
|
+
"Already authenticated via BALISE_TOKEN env var. Unset it to run browser-based login.\n"
|
|
316
|
+
);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const verifier = generateCodeVerifier();
|
|
320
|
+
const challenge = codeChallengeFor(verifier);
|
|
321
|
+
const state = crypto2.randomBytes(16).toString("hex");
|
|
322
|
+
const { port: portPromise, waitForCode } = startLoopbackServer(
|
|
323
|
+
opts.timeoutMs ?? 3e5
|
|
324
|
+
);
|
|
325
|
+
const port = await portPromise;
|
|
326
|
+
const redirectUri = `http://localhost:${port}/callback`;
|
|
327
|
+
const clientId = await registerClient({
|
|
328
|
+
supabaseUrl: opts.supabaseUrl,
|
|
329
|
+
clientName: "Balise CLI",
|
|
330
|
+
redirectUri
|
|
331
|
+
});
|
|
332
|
+
const url = buildAuthorizeUrl({
|
|
333
|
+
supabaseUrl: opts.supabaseUrl,
|
|
334
|
+
clientId,
|
|
335
|
+
redirectUri,
|
|
336
|
+
codeChallenge: challenge,
|
|
337
|
+
state
|
|
338
|
+
});
|
|
339
|
+
process.stderr.write(`Opening browser to log in\u2026
|
|
340
|
+
${url}
|
|
341
|
+
`);
|
|
342
|
+
const opener = opts.openBrowser ?? (async (u) => {
|
|
343
|
+
await open(u);
|
|
344
|
+
});
|
|
345
|
+
try {
|
|
346
|
+
await opener(url);
|
|
347
|
+
} catch {
|
|
348
|
+
process.stderr.write(
|
|
349
|
+
"Could not auto-open browser. Copy the URL above manually.\n"
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
const code = await waitForCode;
|
|
353
|
+
const tokens = await exchangeCodeForTokens({
|
|
354
|
+
supabaseUrl: opts.supabaseUrl,
|
|
355
|
+
code,
|
|
356
|
+
codeVerifier: verifier,
|
|
357
|
+
redirectUri,
|
|
358
|
+
clientId
|
|
359
|
+
});
|
|
360
|
+
try {
|
|
361
|
+
await saveTokens({
|
|
362
|
+
access_token: tokens.access_token,
|
|
363
|
+
refresh_token: tokens.refresh_token,
|
|
364
|
+
client_id: clientId,
|
|
365
|
+
expires_at: Math.floor(Date.now() / 1e3) + (tokens.expires_in ?? 3600),
|
|
366
|
+
user_id: tokens.user?.id
|
|
367
|
+
});
|
|
368
|
+
} catch (err) {
|
|
369
|
+
if (err instanceof CredentialsError) {
|
|
370
|
+
process.stderr.write(`
|
|
371
|
+
${err.message}
|
|
372
|
+
|
|
373
|
+
${credentialsHelpMessage()}
|
|
374
|
+
`);
|
|
375
|
+
process.exit(1);
|
|
376
|
+
}
|
|
377
|
+
throw err;
|
|
378
|
+
}
|
|
379
|
+
const who = tokens.user?.email ?? tokens.user?.id ?? "you";
|
|
380
|
+
process.stdout.write(`Logged in as ${who}
|
|
381
|
+
`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// src/commands/logout.ts
|
|
385
|
+
async function runLogout() {
|
|
386
|
+
try {
|
|
387
|
+
const existed = await clearTokens();
|
|
388
|
+
if (existed) {
|
|
389
|
+
process.stdout.write("Logged out \u2014 credentials file removed.\n");
|
|
390
|
+
} else {
|
|
391
|
+
process.stdout.write("Already logged out (no credentials file found).\n");
|
|
392
|
+
}
|
|
393
|
+
} catch (err) {
|
|
394
|
+
if (err instanceof CredentialsError) {
|
|
395
|
+
process.stderr.write(`
|
|
396
|
+
${err.message}
|
|
397
|
+
|
|
398
|
+
${credentialsHelpMessage()}
|
|
399
|
+
`);
|
|
400
|
+
process.exit(1);
|
|
401
|
+
}
|
|
402
|
+
throw err;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// src/api.ts
|
|
407
|
+
import { request as request2 } from "undici";
|
|
408
|
+
var NotAuthenticatedError = class extends Error {
|
|
409
|
+
constructor(msg = "Not authenticated \u2014 run `balise login`") {
|
|
410
|
+
super(msg);
|
|
411
|
+
this.name = "NotAuthenticatedError";
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
var ApiError = class extends Error {
|
|
415
|
+
constructor(status, body, message) {
|
|
416
|
+
super(message ?? `API ${status}: ${body.slice(0, 200)}`);
|
|
417
|
+
this.status = status;
|
|
418
|
+
this.body = body;
|
|
419
|
+
this.name = "ApiError";
|
|
420
|
+
}
|
|
421
|
+
status;
|
|
422
|
+
body;
|
|
423
|
+
};
|
|
424
|
+
var ApiUnreachableError = class extends Error {
|
|
425
|
+
constructor(cause) {
|
|
426
|
+
super("Cannot reach the Balise API");
|
|
427
|
+
this.cause = cause;
|
|
428
|
+
this.name = "ApiUnreachableError";
|
|
429
|
+
}
|
|
430
|
+
cause;
|
|
431
|
+
};
|
|
432
|
+
var UNREACHABLE_CODES = /* @__PURE__ */ new Set([
|
|
433
|
+
"ECONNREFUSED",
|
|
434
|
+
"ENOTFOUND",
|
|
435
|
+
"ECONNRESET",
|
|
436
|
+
"ETIMEDOUT",
|
|
437
|
+
"EAI_AGAIN",
|
|
438
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
439
|
+
"UND_ERR_SOCKET"
|
|
440
|
+
]);
|
|
441
|
+
function isUnreachable(err) {
|
|
442
|
+
if (!err || typeof err !== "object") return false;
|
|
443
|
+
const e = err;
|
|
444
|
+
if (e.code && UNREACHABLE_CODES.has(e.code)) return true;
|
|
445
|
+
if (e.cause?.code && UNREACHABLE_CODES.has(e.cause.code)) return true;
|
|
446
|
+
if (Array.isArray(e.errors)) {
|
|
447
|
+
return e.errors.some((inner) => isUnreachable(inner));
|
|
448
|
+
}
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
function wrapNetwork(fn) {
|
|
452
|
+
return fn().catch((err) => {
|
|
453
|
+
if (isUnreachable(err)) throw new ApiUnreachableError(err);
|
|
454
|
+
throw err;
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
var ApiClient = class {
|
|
458
|
+
constructor(opts) {
|
|
459
|
+
this.opts = opts;
|
|
460
|
+
}
|
|
461
|
+
opts;
|
|
462
|
+
async authHeader() {
|
|
463
|
+
const tokens = await loadTokens();
|
|
464
|
+
if (!tokens) throw new NotAuthenticatedError();
|
|
465
|
+
return { Authorization: `Bearer ${tokens.access_token}` };
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Proactively refresh the access token if it expires within `graceSec`.
|
|
469
|
+
*
|
|
470
|
+
* Needed before non-replayable requests (e.g. uploadBundle, whose body is
|
|
471
|
+
* a `git archive` subprocess stdout that cannot be re-consumed). Without
|
|
472
|
+
* this the withAuth retry-on-401 path would kick in but the retry would
|
|
473
|
+
* send a drained stream (0 bytes).
|
|
474
|
+
*
|
|
475
|
+
* No-op when BALISE_TOKEN env var is in effect (stateless CI mode —
|
|
476
|
+
* caller is responsible for providing a fresh token).
|
|
477
|
+
*/
|
|
478
|
+
async ensureFreshToken(graceSec = 60) {
|
|
479
|
+
if (process.env.BALISE_TOKEN && process.env.BALISE_TOKEN.length > 0) return;
|
|
480
|
+
const tokens = await loadTokens();
|
|
481
|
+
if (!tokens) throw new NotAuthenticatedError();
|
|
482
|
+
if (!tokens.expires_at || !tokens.client_id) return;
|
|
483
|
+
const secondsLeft = tokens.expires_at - Math.floor(Date.now() / 1e3);
|
|
484
|
+
if (secondsLeft > graceSec) return;
|
|
485
|
+
try {
|
|
486
|
+
const r = await refreshAccessToken({
|
|
487
|
+
supabaseUrl: this.opts.supabaseUrl,
|
|
488
|
+
refreshToken: tokens.refresh_token,
|
|
489
|
+
clientId: tokens.client_id
|
|
490
|
+
});
|
|
491
|
+
await saveTokens({
|
|
492
|
+
access_token: r.access_token,
|
|
493
|
+
refresh_token: r.refresh_token,
|
|
494
|
+
client_id: tokens.client_id,
|
|
495
|
+
expires_at: Math.floor(Date.now() / 1e3) + (r.expires_in ?? 3600),
|
|
496
|
+
user_id: r.user?.id ?? tokens.user_id
|
|
497
|
+
});
|
|
498
|
+
} catch {
|
|
499
|
+
throw new NotAuthenticatedError();
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Execute a request, retrying exactly once after a 401 via refresh_token.
|
|
504
|
+
*/
|
|
505
|
+
async withAuth(fn) {
|
|
506
|
+
let tokens = await loadTokens();
|
|
507
|
+
if (!tokens) throw new NotAuthenticatedError();
|
|
508
|
+
let res = await fn({ Authorization: `Bearer ${tokens.access_token}` });
|
|
509
|
+
if (res.status !== 401) {
|
|
510
|
+
if (res.status < 200 || res.status >= 300) {
|
|
511
|
+
throw new ApiError(res.status, res.text);
|
|
512
|
+
}
|
|
513
|
+
return res.json();
|
|
514
|
+
}
|
|
515
|
+
if (!tokens.client_id) {
|
|
516
|
+
throw new NotAuthenticatedError();
|
|
517
|
+
}
|
|
518
|
+
let refreshed;
|
|
519
|
+
try {
|
|
520
|
+
const r = await refreshAccessToken({
|
|
521
|
+
supabaseUrl: this.opts.supabaseUrl,
|
|
522
|
+
refreshToken: tokens.refresh_token,
|
|
523
|
+
clientId: tokens.client_id
|
|
524
|
+
});
|
|
525
|
+
refreshed = {
|
|
526
|
+
access_token: r.access_token,
|
|
527
|
+
refresh_token: r.refresh_token,
|
|
528
|
+
client_id: tokens.client_id,
|
|
529
|
+
expires_at: Math.floor(Date.now() / 1e3) + (r.expires_in ?? 3600),
|
|
530
|
+
user_id: r.user?.id ?? tokens.user_id
|
|
531
|
+
};
|
|
532
|
+
await saveTokens(refreshed);
|
|
533
|
+
} catch {
|
|
534
|
+
throw new NotAuthenticatedError();
|
|
535
|
+
}
|
|
536
|
+
res = await fn({ Authorization: `Bearer ${refreshed.access_token}` });
|
|
537
|
+
if (res.status < 200 || res.status >= 300) {
|
|
538
|
+
if (res.status === 401) throw new NotAuthenticatedError();
|
|
539
|
+
throw new ApiError(res.status, res.text);
|
|
540
|
+
}
|
|
541
|
+
return res.json();
|
|
542
|
+
}
|
|
543
|
+
async getJson(path4) {
|
|
544
|
+
const url = joinUrl(this.opts.apiUrl, path4);
|
|
545
|
+
return this.withAuth(async (headers) => {
|
|
546
|
+
const { statusCode, body } = await wrapNetwork(() => request2(url, {
|
|
547
|
+
method: "GET",
|
|
548
|
+
headers,
|
|
549
|
+
dispatcher: this.opts.dispatcher
|
|
550
|
+
}));
|
|
551
|
+
const text = await body.text();
|
|
552
|
+
return {
|
|
553
|
+
status: statusCode,
|
|
554
|
+
text,
|
|
555
|
+
json: () => JSON.parse(text)
|
|
556
|
+
};
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
async postJson(path4, payload) {
|
|
560
|
+
const url = joinUrl(this.opts.apiUrl, path4);
|
|
561
|
+
return this.withAuth(async (headers) => {
|
|
562
|
+
const { statusCode, body } = await wrapNetwork(() => request2(url, {
|
|
563
|
+
method: "POST",
|
|
564
|
+
headers: {
|
|
565
|
+
...headers,
|
|
566
|
+
"Content-Type": "application/json"
|
|
567
|
+
},
|
|
568
|
+
body: JSON.stringify(payload),
|
|
569
|
+
dispatcher: this.opts.dispatcher
|
|
570
|
+
}));
|
|
571
|
+
const text = await body.text();
|
|
572
|
+
return {
|
|
573
|
+
status: statusCode,
|
|
574
|
+
text,
|
|
575
|
+
json: () => JSON.parse(text)
|
|
576
|
+
};
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Raw-body bundle upload — streams a git bundle directly as the request body,
|
|
581
|
+
* metadata travels in the query string. Matches the FastAPI contract
|
|
582
|
+
* `POST /v1/repos/:owner/:slug/sync?commit_sha=...&branch=...`.
|
|
583
|
+
*/
|
|
584
|
+
async uploadBundle(path4, opts) {
|
|
585
|
+
await this.ensureFreshToken();
|
|
586
|
+
const qs = new URLSearchParams(opts.query).toString();
|
|
587
|
+
const url = `${joinUrl(this.opts.apiUrl, path4)}${qs ? `?${qs}` : ""}`;
|
|
588
|
+
return this.withAuth(async (headers) => {
|
|
589
|
+
const { statusCode, body } = await wrapNetwork(() => request2(url, {
|
|
590
|
+
method: "POST",
|
|
591
|
+
headers: {
|
|
592
|
+
...headers,
|
|
593
|
+
"Content-Type": "application/octet-stream"
|
|
594
|
+
// Chunked encoding is applied automatically by undici for streams
|
|
595
|
+
// when no Content-Length is set; setting it manually is rejected.
|
|
596
|
+
},
|
|
597
|
+
body: opts.bundleStream,
|
|
598
|
+
dispatcher: this.opts.dispatcher
|
|
599
|
+
}));
|
|
600
|
+
const text = await body.text();
|
|
601
|
+
return {
|
|
602
|
+
status: statusCode,
|
|
603
|
+
text,
|
|
604
|
+
json: () => JSON.parse(text)
|
|
605
|
+
};
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
function joinUrl(base, path4) {
|
|
610
|
+
return `${base.replace(/\/$/, "")}${path4.startsWith("/") ? path4 : `/${path4}`}`;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// src/config.ts
|
|
614
|
+
import fs2 from "fs/promises";
|
|
615
|
+
import path2 from "path";
|
|
616
|
+
import ini from "ini";
|
|
617
|
+
var CONFIG_DIR = ".balise";
|
|
618
|
+
var CONFIG_FILE = "config";
|
|
619
|
+
var DEFAULT_API_URL = process.env.BALISE_API_URL ?? "https://api.balise.dev";
|
|
620
|
+
function configPath(cwd = process.cwd()) {
|
|
621
|
+
return path2.join(cwd, CONFIG_DIR, CONFIG_FILE);
|
|
622
|
+
}
|
|
623
|
+
async function readConfig(cwd = process.cwd()) {
|
|
624
|
+
const p = configPath(cwd);
|
|
625
|
+
let raw;
|
|
626
|
+
try {
|
|
627
|
+
raw = await fs2.readFile(p, "utf8");
|
|
628
|
+
} catch (err) {
|
|
629
|
+
if (err.code === "ENOENT") return null;
|
|
630
|
+
throw err;
|
|
631
|
+
}
|
|
632
|
+
const parsed = ini.parse(raw);
|
|
633
|
+
if (!parsed.repo?.id || !parsed.repo.slug || !parsed.repo.owner_login) {
|
|
634
|
+
throw new Error(`Invalid .balise/config at ${p}: missing [repo] fields`);
|
|
635
|
+
}
|
|
636
|
+
return {
|
|
637
|
+
repo: {
|
|
638
|
+
id: String(parsed.repo.id),
|
|
639
|
+
slug: String(parsed.repo.slug),
|
|
640
|
+
owner_login: String(parsed.repo.owner_login)
|
|
641
|
+
},
|
|
642
|
+
api: {
|
|
643
|
+
url: String(parsed.api?.url ?? DEFAULT_API_URL)
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
async function writeConfig(cfg, cwd = process.cwd()) {
|
|
648
|
+
const dir = path2.join(cwd, CONFIG_DIR);
|
|
649
|
+
await fs2.mkdir(dir, { recursive: true });
|
|
650
|
+
const serialized = ini.stringify(cfg);
|
|
651
|
+
await fs2.writeFile(path2.join(dir, CONFIG_FILE), serialized, "utf8");
|
|
652
|
+
}
|
|
653
|
+
async function ensureGitignored(cwd = process.cwd()) {
|
|
654
|
+
const gi = path2.join(cwd, ".gitignore");
|
|
655
|
+
let current = "";
|
|
656
|
+
try {
|
|
657
|
+
current = await fs2.readFile(gi, "utf8");
|
|
658
|
+
} catch (err) {
|
|
659
|
+
if (err.code !== "ENOENT") throw err;
|
|
660
|
+
}
|
|
661
|
+
const lines = current.split(/\r?\n/).map((l) => l.trim());
|
|
662
|
+
if (lines.some((l) => l === ".balise" || l === ".balise/")) return;
|
|
663
|
+
const suffix = current.length && !current.endsWith("\n") ? "\n" : "";
|
|
664
|
+
await fs2.writeFile(gi, `${current}${suffix}.balise/
|
|
665
|
+
`, "utf8");
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// src/commands/whoami.ts
|
|
669
|
+
async function runWhoami(opts) {
|
|
670
|
+
const cfg = await readConfig().catch(() => null);
|
|
671
|
+
const apiUrl = cfg?.api.url ?? DEFAULT_API_URL;
|
|
672
|
+
const client = new ApiClient({
|
|
673
|
+
apiUrl,
|
|
674
|
+
supabaseUrl: opts.supabaseUrl,
|
|
675
|
+
clientId: opts.clientId
|
|
676
|
+
});
|
|
677
|
+
try {
|
|
678
|
+
const me = await client.getJson("/v1/me");
|
|
679
|
+
process.stdout.write(
|
|
680
|
+
`login : ${me.login}
|
|
681
|
+
type : ${me.type}
|
|
682
|
+
account_id : ${me.account_id}
|
|
683
|
+
` + (me.email ? `email : ${me.email}
|
|
684
|
+
` : "")
|
|
685
|
+
);
|
|
686
|
+
} catch (err) {
|
|
687
|
+
if (err instanceof NotAuthenticatedError) {
|
|
688
|
+
process.stderr.write("Not logged in \u2014 run `balise login`.\n");
|
|
689
|
+
process.exit(1);
|
|
690
|
+
}
|
|
691
|
+
throw err;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// src/commands/init.ts
|
|
696
|
+
import path3 from "path";
|
|
697
|
+
import React2 from "react";
|
|
698
|
+
import { render } from "ink";
|
|
699
|
+
|
|
700
|
+
// src/git.ts
|
|
701
|
+
import { spawn } from "child_process";
|
|
702
|
+
import { createReadStream } from "fs";
|
|
703
|
+
import { unlink } from "fs/promises";
|
|
704
|
+
import { randomBytes } from "crypto";
|
|
705
|
+
import { tmpdir } from "os";
|
|
706
|
+
import { join } from "path";
|
|
707
|
+
var GitError = class extends Error {
|
|
708
|
+
constructor(args, code, stderr) {
|
|
709
|
+
super(`git ${args.join(" ")} failed (exit ${code}): ${stderr.trim()}`);
|
|
710
|
+
this.args = args;
|
|
711
|
+
this.code = code;
|
|
712
|
+
this.stderr = stderr;
|
|
713
|
+
this.name = "GitError";
|
|
714
|
+
}
|
|
715
|
+
args;
|
|
716
|
+
code;
|
|
717
|
+
stderr;
|
|
718
|
+
};
|
|
719
|
+
var NotAGitRepoError = class extends Error {
|
|
720
|
+
constructor(cwd) {
|
|
721
|
+
super(`not a git repo: ${cwd}`);
|
|
722
|
+
this.name = "NotAGitRepoError";
|
|
723
|
+
}
|
|
724
|
+
};
|
|
725
|
+
async function runGit(opts, args) {
|
|
726
|
+
const sp = opts.spawner ?? spawn;
|
|
727
|
+
return new Promise((resolve, reject) => {
|
|
728
|
+
const proc = sp("git", args, { cwd: opts.cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
729
|
+
let stdout = "";
|
|
730
|
+
let stderr = "";
|
|
731
|
+
proc.stdout?.on("data", (d) => {
|
|
732
|
+
stdout += d.toString();
|
|
733
|
+
});
|
|
734
|
+
proc.stderr?.on("data", (d) => {
|
|
735
|
+
stderr += d.toString();
|
|
736
|
+
});
|
|
737
|
+
proc.on("error", reject);
|
|
738
|
+
proc.on("close", (code) => {
|
|
739
|
+
if (code === 0) resolve(stdout);
|
|
740
|
+
else reject(new GitError(args, code, stderr));
|
|
741
|
+
});
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
async function isGitRepo(opts) {
|
|
745
|
+
try {
|
|
746
|
+
await runGit(opts, ["rev-parse", "--is-inside-work-tree"]);
|
|
747
|
+
return true;
|
|
748
|
+
} catch (err) {
|
|
749
|
+
if (err instanceof GitError) return false;
|
|
750
|
+
throw err;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
async function assertGitRepo(opts) {
|
|
754
|
+
if (!await isGitRepo(opts)) {
|
|
755
|
+
throw new NotAGitRepoError(opts.cwd);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
async function getCommitSha(opts, ref = "HEAD") {
|
|
759
|
+
return (await runGit(opts, ["rev-parse", ref])).trim();
|
|
760
|
+
}
|
|
761
|
+
async function getCurrentBranch(opts) {
|
|
762
|
+
return (await runGit(opts, ["rev-parse", "--abbrev-ref", "HEAD"])).trim();
|
|
763
|
+
}
|
|
764
|
+
async function isDirty(opts) {
|
|
765
|
+
const status = await runGit(opts, ["status", "--porcelain"]);
|
|
766
|
+
return status.trim().length > 0;
|
|
767
|
+
}
|
|
768
|
+
async function gitBundle(opts) {
|
|
769
|
+
await assertGitRepo(opts);
|
|
770
|
+
const [commitSha, branch, dirty] = await Promise.all([
|
|
771
|
+
getCommitSha(opts),
|
|
772
|
+
getCurrentBranch(opts),
|
|
773
|
+
isDirty(opts)
|
|
774
|
+
]);
|
|
775
|
+
const tmpFile = join(
|
|
776
|
+
tmpdir(),
|
|
777
|
+
`balise-bundle-${randomBytes(8).toString("hex")}.bundle`
|
|
778
|
+
);
|
|
779
|
+
await runGit(opts, ["bundle", "create", tmpFile, "--all"]);
|
|
780
|
+
const stream = createReadStream(tmpFile);
|
|
781
|
+
const cleanup = () => {
|
|
782
|
+
unlink(tmpFile).catch(() => {
|
|
783
|
+
});
|
|
784
|
+
};
|
|
785
|
+
stream.on("close", cleanup);
|
|
786
|
+
stream.on("error", cleanup);
|
|
787
|
+
return { stream, commitSha, branch, dirty };
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// src/auth-ensure.ts
|
|
791
|
+
import readline from "readline/promises";
|
|
792
|
+
import crypto3 from "crypto";
|
|
793
|
+
import open2 from "open";
|
|
794
|
+
var LoginDeclinedError = class extends Error {
|
|
795
|
+
constructor() {
|
|
796
|
+
super("login declined by user");
|
|
797
|
+
this.name = "LoginDeclinedError";
|
|
798
|
+
}
|
|
799
|
+
};
|
|
800
|
+
function isInteractive(stdin) {
|
|
801
|
+
const s = stdin ?? process.stdin;
|
|
802
|
+
return Boolean(s.isTTY);
|
|
803
|
+
}
|
|
804
|
+
async function ensureAuthenticated(opts) {
|
|
805
|
+
const existing = await loadTokens();
|
|
806
|
+
if (existing) return;
|
|
807
|
+
const stderr = opts.stderr ?? process.stderr;
|
|
808
|
+
if (!isInteractive(opts.stdin)) {
|
|
809
|
+
throw new LoginDeclinedError();
|
|
810
|
+
}
|
|
811
|
+
const rl = readline.createInterface({
|
|
812
|
+
input: opts.stdin ?? process.stdin,
|
|
813
|
+
output: opts.stdout ?? process.stdout
|
|
814
|
+
});
|
|
815
|
+
let answer;
|
|
816
|
+
try {
|
|
817
|
+
answer = (await rl.question("Not logged in. Login now? (Y/n) ")).trim();
|
|
818
|
+
} finally {
|
|
819
|
+
rl.close();
|
|
820
|
+
}
|
|
821
|
+
if (answer && !/^y(es)?$/i.test(answer)) {
|
|
822
|
+
throw new LoginDeclinedError();
|
|
823
|
+
}
|
|
824
|
+
const verifier = generateCodeVerifier();
|
|
825
|
+
const challenge = codeChallengeFor(verifier);
|
|
826
|
+
const state = crypto3.randomBytes(16).toString("hex");
|
|
827
|
+
const { port: portPromise, waitForCode } = startLoopbackServer(
|
|
828
|
+
opts.timeoutMs ?? 3e5
|
|
829
|
+
);
|
|
830
|
+
const port = await portPromise;
|
|
831
|
+
const redirectUri = `http://localhost:${port}/callback`;
|
|
832
|
+
const clientId = await registerClient({
|
|
833
|
+
supabaseUrl: opts.supabaseUrl,
|
|
834
|
+
clientName: "Balise CLI",
|
|
835
|
+
redirectUri
|
|
836
|
+
});
|
|
837
|
+
const url = buildAuthorizeUrl({
|
|
838
|
+
supabaseUrl: opts.supabaseUrl,
|
|
839
|
+
clientId,
|
|
840
|
+
redirectUri,
|
|
841
|
+
codeChallenge: challenge,
|
|
842
|
+
state
|
|
843
|
+
});
|
|
844
|
+
stderr.write(`Opening browser to log in\u2026
|
|
845
|
+
${url}
|
|
846
|
+
`);
|
|
847
|
+
const opener = opts.openBrowser ?? (async (u) => {
|
|
848
|
+
await open2(u);
|
|
849
|
+
});
|
|
850
|
+
try {
|
|
851
|
+
await opener(url);
|
|
852
|
+
} catch {
|
|
853
|
+
stderr.write("Could not auto-open browser. Copy the URL above manually.\n");
|
|
854
|
+
}
|
|
855
|
+
const code = await waitForCode;
|
|
856
|
+
const tokens = await exchangeCodeForTokens({
|
|
857
|
+
supabaseUrl: opts.supabaseUrl,
|
|
858
|
+
code,
|
|
859
|
+
codeVerifier: verifier,
|
|
860
|
+
redirectUri,
|
|
861
|
+
clientId
|
|
862
|
+
});
|
|
863
|
+
try {
|
|
864
|
+
await saveTokens({
|
|
865
|
+
access_token: tokens.access_token,
|
|
866
|
+
refresh_token: tokens.refresh_token,
|
|
867
|
+
client_id: clientId,
|
|
868
|
+
expires_at: Math.floor(Date.now() / 1e3) + (tokens.expires_in ?? 3600),
|
|
869
|
+
user_id: tokens.user?.id
|
|
870
|
+
});
|
|
871
|
+
} catch (err) {
|
|
872
|
+
if (err instanceof CredentialsError) {
|
|
873
|
+
stderr.write(`
|
|
874
|
+
${err.message}
|
|
875
|
+
|
|
876
|
+
${credentialsHelpMessage()}
|
|
877
|
+
`);
|
|
878
|
+
throw err;
|
|
879
|
+
}
|
|
880
|
+
throw err;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// src/ui/InitPicker.tsx
|
|
885
|
+
import React, { useState } from "react";
|
|
886
|
+
import { Box, Text, useApp, useInput } from "ink";
|
|
887
|
+
function InitPicker(props) {
|
|
888
|
+
const { exit } = useApp();
|
|
889
|
+
const [zone, setZone] = useState(
|
|
890
|
+
props.repos.length > 0 ? "link" : "create"
|
|
891
|
+
);
|
|
892
|
+
const [slug, setSlug] = useState(props.defaultSlug);
|
|
893
|
+
const [ownerIdx, setOwnerIdx] = useState(0);
|
|
894
|
+
const [repoIdx, setRepoIdx] = useState(0);
|
|
895
|
+
useInput((input, key) => {
|
|
896
|
+
if (key.escape) {
|
|
897
|
+
props.onDone({ action: "cancel" });
|
|
898
|
+
exit();
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
if (key.tab) {
|
|
902
|
+
setZone((z) => z === "create" ? "link" : "create");
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
if (key.return) {
|
|
906
|
+
if (zone === "create") {
|
|
907
|
+
if (!props.ownerships[ownerIdx]) return;
|
|
908
|
+
props.onDone({
|
|
909
|
+
action: "create",
|
|
910
|
+
slug,
|
|
911
|
+
owner: props.ownerships[ownerIdx]
|
|
912
|
+
});
|
|
913
|
+
} else {
|
|
914
|
+
if (!props.repos[repoIdx]) return;
|
|
915
|
+
props.onDone({ action: "link", repo: props.repos[repoIdx] });
|
|
916
|
+
}
|
|
917
|
+
exit();
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
if (zone === "create") {
|
|
921
|
+
if (key.upArrow)
|
|
922
|
+
setOwnerIdx((i) => (i - 1 + props.ownerships.length) % Math.max(1, props.ownerships.length));
|
|
923
|
+
else if (key.downArrow)
|
|
924
|
+
setOwnerIdx((i) => (i + 1) % Math.max(1, props.ownerships.length));
|
|
925
|
+
else if (key.backspace || key.delete) setSlug((s) => s.slice(0, -1));
|
|
926
|
+
else if (input && /^[a-z0-9-]$/i.test(input))
|
|
927
|
+
setSlug((s) => (s + input).toLowerCase());
|
|
928
|
+
} else {
|
|
929
|
+
if (key.upArrow)
|
|
930
|
+
setRepoIdx((i) => (i - 1 + props.repos.length) % Math.max(1, props.repos.length));
|
|
931
|
+
else if (key.downArrow)
|
|
932
|
+
setRepoIdx((i) => (i + 1) % Math.max(1, props.repos.length));
|
|
933
|
+
}
|
|
934
|
+
});
|
|
935
|
+
return /* @__PURE__ */ React.createElement(Box, { flexDirection: "column", paddingX: 1 }, /* @__PURE__ */ React.createElement(Text, { bold: true }, "Balise \u2014 link or create a repo"), /* @__PURE__ */ React.createElement(Box, { marginTop: 1, flexDirection: "column" }, /* @__PURE__ */ React.createElement(Text, { color: zone === "create" ? "cyan" : "gray" }, zone === "create" ? "\u25B8 " : " ", "Create new repo"), zone === "create" && /* @__PURE__ */ React.createElement(Box, { marginLeft: 2, flexDirection: "column" }, /* @__PURE__ */ React.createElement(Text, null, "slug : ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, slug)), /* @__PURE__ */ React.createElement(Text, null, "owner: ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, props.ownerships[ownerIdx] ? `${props.ownerships[ownerIdx].login} (${props.ownerships[ownerIdx].type})` : "<no ownership>"), " (\u2191/\u2193 to change)"))), /* @__PURE__ */ React.createElement(Box, { marginTop: 1, flexDirection: "column" }, /* @__PURE__ */ React.createElement(Text, { color: zone === "link" ? "cyan" : "gray" }, zone === "link" ? "\u25B8 " : " ", "Link existing (", props.repos.length, ")"), zone === "link" && props.repos.length > 0 && /* @__PURE__ */ React.createElement(Box, { marginLeft: 2, flexDirection: "column" }, props.repos.map((r, i) => /* @__PURE__ */ React.createElement(Text, { key: r.id, color: i === repoIdx ? "yellow" : void 0 }, i === repoIdx ? "\u25B8 " : " ", r.owner_login, "/", r.slug)))), /* @__PURE__ */ React.createElement(Box, { marginTop: 1 }, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "Tab: switch zone \xB7 \u2191/\u2193: navigate \xB7 Enter: confirm \xB7 Esc: cancel")));
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// src/commands/init.ts
|
|
939
|
+
async function runInit(opts) {
|
|
940
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
941
|
+
try {
|
|
942
|
+
await assertGitRepo({ cwd });
|
|
943
|
+
} catch (err) {
|
|
944
|
+
if (err instanceof NotAGitRepoError) {
|
|
945
|
+
process.stderr.write("not a git repo\n");
|
|
946
|
+
process.exit(1);
|
|
947
|
+
}
|
|
948
|
+
throw err;
|
|
949
|
+
}
|
|
950
|
+
try {
|
|
951
|
+
await ensureAuthenticated({
|
|
952
|
+
supabaseUrl: opts.supabaseUrl,
|
|
953
|
+
clientId: opts.clientId
|
|
954
|
+
});
|
|
955
|
+
} catch (err) {
|
|
956
|
+
if (err instanceof LoginDeclinedError) {
|
|
957
|
+
process.stderr.write("Login required. Aborting.\n");
|
|
958
|
+
process.exit(1);
|
|
959
|
+
}
|
|
960
|
+
throw err;
|
|
961
|
+
}
|
|
962
|
+
const apiUrl = DEFAULT_API_URL;
|
|
963
|
+
const client = new ApiClient({
|
|
964
|
+
apiUrl,
|
|
965
|
+
supabaseUrl: opts.supabaseUrl,
|
|
966
|
+
clientId: opts.clientId
|
|
967
|
+
});
|
|
968
|
+
let ownerships = [];
|
|
969
|
+
let repos = [];
|
|
970
|
+
try {
|
|
971
|
+
const [me, orgs, repoList] = await Promise.all([
|
|
972
|
+
client.getJson("/v1/me"),
|
|
973
|
+
client.getJson("/v1/me/orgs"),
|
|
974
|
+
client.getJson("/v1/me/repos")
|
|
975
|
+
]);
|
|
976
|
+
ownerships = [
|
|
977
|
+
{ id: me.account_id, login: me.login, type: "user" },
|
|
978
|
+
...orgs.map((o) => ({ id: o.id, login: o.login, type: "org" }))
|
|
979
|
+
];
|
|
980
|
+
repos = repoList;
|
|
981
|
+
} catch (err) {
|
|
982
|
+
if (err instanceof NotAuthenticatedError) {
|
|
983
|
+
process.stderr.write("Not logged in \u2014 run `balise login` first.\n");
|
|
984
|
+
process.exit(1);
|
|
985
|
+
}
|
|
986
|
+
throw err;
|
|
987
|
+
}
|
|
988
|
+
const sortedRepos = [...repos].sort(
|
|
989
|
+
(a, b) => `${a.owner_login}/${a.slug}`.localeCompare(`${b.owner_login}/${b.slug}`)
|
|
990
|
+
);
|
|
991
|
+
const defaultSlug = path3.basename(cwd).toLowerCase();
|
|
992
|
+
const result = await new Promise((resolve) => {
|
|
993
|
+
const app = render(
|
|
994
|
+
React2.createElement(InitPicker, {
|
|
995
|
+
defaultSlug,
|
|
996
|
+
ownerships,
|
|
997
|
+
repos: sortedRepos,
|
|
998
|
+
onDone: (r) => {
|
|
999
|
+
resolve(r);
|
|
1000
|
+
app.unmount();
|
|
1001
|
+
}
|
|
1002
|
+
})
|
|
1003
|
+
);
|
|
1004
|
+
});
|
|
1005
|
+
if (result.action === "cancel") {
|
|
1006
|
+
process.stderr.write("Cancelled.\n");
|
|
1007
|
+
process.exit(1);
|
|
1008
|
+
}
|
|
1009
|
+
let cfg;
|
|
1010
|
+
if (result.action === "create") {
|
|
1011
|
+
const created = await client.postJson("/v1/repos", {
|
|
1012
|
+
owner_id: result.owner.id,
|
|
1013
|
+
slug: result.slug
|
|
1014
|
+
});
|
|
1015
|
+
cfg = {
|
|
1016
|
+
repo: {
|
|
1017
|
+
id: created.id,
|
|
1018
|
+
slug: created.slug,
|
|
1019
|
+
owner_login: created.owner_login
|
|
1020
|
+
},
|
|
1021
|
+
api: { url: apiUrl }
|
|
1022
|
+
};
|
|
1023
|
+
} else {
|
|
1024
|
+
cfg = {
|
|
1025
|
+
repo: {
|
|
1026
|
+
id: result.repo.id,
|
|
1027
|
+
slug: result.repo.slug,
|
|
1028
|
+
owner_login: result.repo.owner_login
|
|
1029
|
+
},
|
|
1030
|
+
api: { url: apiUrl }
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
await writeConfig(cfg, cwd);
|
|
1034
|
+
await ensureGitignored(cwd);
|
|
1035
|
+
process.stdout.write(
|
|
1036
|
+
`Linked ${cfg.repo.owner_login}/${cfg.repo.slug} \u2192 .balise/config
|
|
1037
|
+
`
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// src/commands/sync.ts
|
|
1042
|
+
import React4 from "react";
|
|
1043
|
+
import { render as render2 } from "ink";
|
|
1044
|
+
|
|
1045
|
+
// src/ui/SyncProgress.tsx
|
|
1046
|
+
import React3, { useEffect, useState as useState2 } from "react";
|
|
1047
|
+
import { Box as Box2, Text as Text2, useApp as useApp2 } from "ink";
|
|
1048
|
+
import Spinner from "ink-spinner";
|
|
1049
|
+
var QUEUED_MESSAGES = [
|
|
1050
|
+
"Waiting for an agent willing to accept the job\u2026",
|
|
1051
|
+
"Your request is in line. An agent will be assigned once one stops pretending to be busy.",
|
|
1052
|
+
'Looking for an available agent. Most are currently "in a meeting."',
|
|
1053
|
+
"Queued. An agent will pick this up as soon as they finish rereading the spec.",
|
|
1054
|
+
"Your task is waiting for an agent with the right vibes.",
|
|
1055
|
+
"Negotiating with an agent. They want a raise.",
|
|
1056
|
+
"All agents are currently on strike. Sending in a scab.",
|
|
1057
|
+
"Waiting for an agent to finish their coffee \u2615",
|
|
1058
|
+
"An agent saw your task and walked away slowly. Finding another one.",
|
|
1059
|
+
"Your task is being passed around like a hot potato. Someone will catch it eventually.",
|
|
1060
|
+
"Agents are currently arguing about who has to do this one.",
|
|
1061
|
+
"Waiting for an agent brave enough to open your repo.",
|
|
1062
|
+
"Your task is sitting in the agent break room. Someone will notice it soon.",
|
|
1063
|
+
"An agent was assigned, but they ghosted. Recruiting a replacement.",
|
|
1064
|
+
"Paging an agent. Please hold while we bribe one.",
|
|
1065
|
+
"Queued. Waiting for an agent with enough context window to care.",
|
|
1066
|
+
"An agent is spinning up. They're doing their stretches.",
|
|
1067
|
+
"Looking for an agent whose system prompt allows this.",
|
|
1068
|
+
"Waiting for a worker. Their last task traumatized them.",
|
|
1069
|
+
"Your task is in queue. Agents are busy arguing about tabs vs spaces.",
|
|
1070
|
+
"Finding an agent\u2026",
|
|
1071
|
+
"Found one. They said no.",
|
|
1072
|
+
"Finding another agent\u2026",
|
|
1073
|
+
"This one looks promising.",
|
|
1074
|
+
"Negotiating\u2026"
|
|
1075
|
+
];
|
|
1076
|
+
var RUNNING_MESSAGES = [
|
|
1077
|
+
"An agent is on it. Try not to watch.",
|
|
1078
|
+
"Your task has an owner. They seem focused.",
|
|
1079
|
+
"An agent accepted the job. Surprisingly.",
|
|
1080
|
+
"Working. The agent asked not to be disturbed.",
|
|
1081
|
+
"An agent is handling it. They'll let us know if they need anything.",
|
|
1082
|
+
"Progress is happening. Allegedly.",
|
|
1083
|
+
"An agent rolled up their sleeves. Mostly for show.",
|
|
1084
|
+
"Hard at work. The agent has opened seventeen browser tabs.",
|
|
1085
|
+
"Your agent is locked in. Do not make eye contact.",
|
|
1086
|
+
"An agent is typing furiously. Some of it might be relevant.",
|
|
1087
|
+
"Working. The agent has entered the zone. And also a Wikipedia rabbit hole.",
|
|
1088
|
+
"An agent is deep in thought. Or buffering. Hard to tell.",
|
|
1089
|
+
"Your task is being handled. The agent is muttering to themselves.",
|
|
1090
|
+
"Cooking. \u{1F9D1}\u200D\u{1F373}",
|
|
1091
|
+
"The agent is working. They've asked for snacks.",
|
|
1092
|
+
"An agent is doing the thing. Please clap.",
|
|
1093
|
+
"Agent is crunching tokens.",
|
|
1094
|
+
"Working. The agent is reasoning about your reasoning.",
|
|
1095
|
+
"An agent is in a tool-use loop. Going well so far.",
|
|
1096
|
+
"Thinking hard. The agent just discovered your codebase has opinions.",
|
|
1097
|
+
"Working. The agent is negotiating with your linter.",
|
|
1098
|
+
"An agent is running. They promise it's not an infinite loop.",
|
|
1099
|
+
"The agent is writing, deleting, and rewriting. Classic.",
|
|
1100
|
+
"An agent picked it up.",
|
|
1101
|
+
"They're reading the task\u2026",
|
|
1102
|
+
"Looks like they have a plan.",
|
|
1103
|
+
"Executing\u2026",
|
|
1104
|
+
"Still going. Confidently.",
|
|
1105
|
+
"Almost there. (They always say that.)"
|
|
1106
|
+
];
|
|
1107
|
+
function pickInitialIndex(len) {
|
|
1108
|
+
return Math.floor(Math.random() * len);
|
|
1109
|
+
}
|
|
1110
|
+
function SyncProgress(props) {
|
|
1111
|
+
const { exit } = useApp2();
|
|
1112
|
+
const [status, setStatus] = useState2(null);
|
|
1113
|
+
const [error, setError] = useState2(null);
|
|
1114
|
+
const [startedAt] = useState2(() => Date.now());
|
|
1115
|
+
const [queuedIdx, setQueuedIdx] = useState2(
|
|
1116
|
+
() => pickInitialIndex(QUEUED_MESSAGES.length)
|
|
1117
|
+
);
|
|
1118
|
+
const [runningIdx, setRunningIdx] = useState2(
|
|
1119
|
+
() => pickInitialIndex(RUNNING_MESSAGES.length)
|
|
1120
|
+
);
|
|
1121
|
+
useEffect(() => {
|
|
1122
|
+
let cancelled = false;
|
|
1123
|
+
const tick = async () => {
|
|
1124
|
+
try {
|
|
1125
|
+
const s = await props.client.getJson(
|
|
1126
|
+
`/v1/syncs/${props.syncId}`
|
|
1127
|
+
);
|
|
1128
|
+
if (cancelled) return;
|
|
1129
|
+
setStatus(s);
|
|
1130
|
+
if (s.status === "done") {
|
|
1131
|
+
props.onDone(true);
|
|
1132
|
+
exit();
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
if (s.status === "failed") {
|
|
1136
|
+
props.onDone(false);
|
|
1137
|
+
exit();
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
} catch (err) {
|
|
1141
|
+
if (cancelled) return;
|
|
1142
|
+
setError(err.message);
|
|
1143
|
+
props.onDone(false);
|
|
1144
|
+
exit();
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
setTimeout(tick, props.pollIntervalMs ?? 1e3);
|
|
1148
|
+
};
|
|
1149
|
+
void tick();
|
|
1150
|
+
return () => {
|
|
1151
|
+
cancelled = true;
|
|
1152
|
+
};
|
|
1153
|
+
}, []);
|
|
1154
|
+
useEffect(() => {
|
|
1155
|
+
const period = props.messageRotationMs ?? 1e4;
|
|
1156
|
+
const h = setInterval(() => {
|
|
1157
|
+
setQueuedIdx((i) => (i + 1) % QUEUED_MESSAGES.length);
|
|
1158
|
+
setRunningIdx((i) => (i + 1) % RUNNING_MESSAGES.length);
|
|
1159
|
+
}, period);
|
|
1160
|
+
return () => clearInterval(h);
|
|
1161
|
+
}, [props.messageRotationMs]);
|
|
1162
|
+
if (error) {
|
|
1163
|
+
return /* @__PURE__ */ React3.createElement(Text2, { color: "red" }, "\u2717 Failed");
|
|
1164
|
+
}
|
|
1165
|
+
if (!status) {
|
|
1166
|
+
return /* @__PURE__ */ React3.createElement(Box2, null, /* @__PURE__ */ React3.createElement(Text2, { color: "cyan" }, /* @__PURE__ */ React3.createElement(Spinner, { type: "dots" })), /* @__PURE__ */ React3.createElement(Text2, null, " Getting things ready\u2026"));
|
|
1167
|
+
}
|
|
1168
|
+
if (status.status === "done") {
|
|
1169
|
+
return /* @__PURE__ */ React3.createElement(Text2, { color: "green" }, "\u2713 Done");
|
|
1170
|
+
}
|
|
1171
|
+
if (status.status === "failed") {
|
|
1172
|
+
return /* @__PURE__ */ React3.createElement(Text2, { color: "red" }, "\u2717 Failed");
|
|
1173
|
+
}
|
|
1174
|
+
const message = status.status === "queued" ? QUEUED_MESSAGES[queuedIdx] : RUNNING_MESSAGES[runningIdx];
|
|
1175
|
+
const { files_processed, files_total, nodes_pushed } = status.progress;
|
|
1176
|
+
const hasCounters = status.status === "running" && (files_total > 0 || nodes_pushed > 0);
|
|
1177
|
+
return /* @__PURE__ */ React3.createElement(Box2, { flexDirection: "column" }, /* @__PURE__ */ React3.createElement(Box2, null, /* @__PURE__ */ React3.createElement(Text2, { color: "cyan" }, /* @__PURE__ */ React3.createElement(Spinner, { type: "dots" })), /* @__PURE__ */ React3.createElement(Text2, null, " ", message)), hasCounters ? /* @__PURE__ */ React3.createElement(Text2, { dimColor: true }, " ", files_processed, "/", files_total, " files \xB7 ", nodes_pushed, " concepts") : null);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// src/commands/sync.ts
|
|
1181
|
+
async function runSync(opts) {
|
|
1182
|
+
try {
|
|
1183
|
+
await runSyncInner(opts);
|
|
1184
|
+
} catch (err) {
|
|
1185
|
+
if (err instanceof ApiUnreachableError) {
|
|
1186
|
+
process.stderr.write(
|
|
1187
|
+
"Cannot reach the Balise service. Please try again in a moment.\n"
|
|
1188
|
+
);
|
|
1189
|
+
} else {
|
|
1190
|
+
process.stderr.write("Something went wrong. Please try again.\n");
|
|
1191
|
+
}
|
|
1192
|
+
process.exit(1);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
async function runSyncInner(opts) {
|
|
1196
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
1197
|
+
try {
|
|
1198
|
+
await assertGitRepo({ cwd });
|
|
1199
|
+
} catch (err) {
|
|
1200
|
+
if (err instanceof NotAGitRepoError) {
|
|
1201
|
+
process.stderr.write("not a git repo\n");
|
|
1202
|
+
process.exit(1);
|
|
1203
|
+
}
|
|
1204
|
+
throw err;
|
|
1205
|
+
}
|
|
1206
|
+
try {
|
|
1207
|
+
await ensureAuthenticated({
|
|
1208
|
+
supabaseUrl: opts.supabaseUrl,
|
|
1209
|
+
clientId: opts.clientId
|
|
1210
|
+
});
|
|
1211
|
+
} catch (err) {
|
|
1212
|
+
if (err instanceof LoginDeclinedError) {
|
|
1213
|
+
process.stderr.write("Login required. Aborting.\n");
|
|
1214
|
+
process.exit(1);
|
|
1215
|
+
}
|
|
1216
|
+
throw err;
|
|
1217
|
+
}
|
|
1218
|
+
let cfg = await readConfig(cwd);
|
|
1219
|
+
if (!cfg) {
|
|
1220
|
+
process.stderr.write("No .balise/config \u2014 running `balise init` first.\n");
|
|
1221
|
+
await runInit({ ...opts, cwd });
|
|
1222
|
+
cfg = await readConfig(cwd);
|
|
1223
|
+
if (!cfg) {
|
|
1224
|
+
process.stderr.write("Init cancelled. Aborting sync.\n");
|
|
1225
|
+
process.exit(1);
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
if (await isDirty({ cwd })) {
|
|
1229
|
+
process.stderr.write(
|
|
1230
|
+
"Warning: working tree has uncommitted changes \u2014 syncing HEAD anyway.\n"
|
|
1231
|
+
);
|
|
1232
|
+
}
|
|
1233
|
+
const client = new ApiClient({
|
|
1234
|
+
apiUrl: cfg.api.url,
|
|
1235
|
+
supabaseUrl: opts.supabaseUrl,
|
|
1236
|
+
clientId: opts.clientId
|
|
1237
|
+
});
|
|
1238
|
+
process.stderr.write(
|
|
1239
|
+
`Packing ${cfg.repo.owner_login}/${cfg.repo.slug} at HEAD\u2026
|
|
1240
|
+
`
|
|
1241
|
+
);
|
|
1242
|
+
const { stream, commitSha, branch } = await gitBundle({ cwd });
|
|
1243
|
+
let accepted;
|
|
1244
|
+
try {
|
|
1245
|
+
accepted = await client.uploadBundle(
|
|
1246
|
+
`/v1/repos/${cfg.repo.owner_login}/${cfg.repo.slug}/sync`,
|
|
1247
|
+
{
|
|
1248
|
+
bundleStream: stream,
|
|
1249
|
+
query: { commit_sha: commitSha, branch }
|
|
1250
|
+
}
|
|
1251
|
+
);
|
|
1252
|
+
} catch (err) {
|
|
1253
|
+
if (err instanceof NotAuthenticatedError) {
|
|
1254
|
+
process.stderr.write("Not logged in \u2014 run `balise login`.\n");
|
|
1255
|
+
process.exit(1);
|
|
1256
|
+
}
|
|
1257
|
+
if (err instanceof ApiUnreachableError) {
|
|
1258
|
+
process.stderr.write(
|
|
1259
|
+
"Cannot reach the Balise service. Please try again in a moment.\n"
|
|
1260
|
+
);
|
|
1261
|
+
process.exit(1);
|
|
1262
|
+
}
|
|
1263
|
+
process.stderr.write(
|
|
1264
|
+
"Something went wrong while uploading. Please try again.\n"
|
|
1265
|
+
);
|
|
1266
|
+
process.exit(1);
|
|
1267
|
+
}
|
|
1268
|
+
const result = await new Promise((resolve) => {
|
|
1269
|
+
const app = render2(
|
|
1270
|
+
React4.createElement(SyncProgress, {
|
|
1271
|
+
client,
|
|
1272
|
+
syncId: accepted.sync_id,
|
|
1273
|
+
onDone: (ok) => {
|
|
1274
|
+
resolve(ok);
|
|
1275
|
+
app.unmount();
|
|
1276
|
+
}
|
|
1277
|
+
})
|
|
1278
|
+
);
|
|
1279
|
+
});
|
|
1280
|
+
process.exit(result ? 0 : 1);
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// src/index.ts
|
|
1284
|
+
var SUPABASE_URL = process.env.BALISE_SUPABASE_URL ?? "https://auth.balise.dev";
|
|
1285
|
+
var CLIENT_ID = process.env.BALISE_CLIENT_ID ?? "balise-cli";
|
|
1286
|
+
var loginCmd = defineCommand({
|
|
1287
|
+
meta: { name: "login", description: "Authenticate via OAuth (PKCE loopback)." },
|
|
1288
|
+
async run() {
|
|
1289
|
+
await runLogin({ supabaseUrl: SUPABASE_URL, clientId: CLIENT_ID });
|
|
1290
|
+
}
|
|
1291
|
+
});
|
|
1292
|
+
var logoutCmd = defineCommand({
|
|
1293
|
+
meta: { name: "logout", description: "Clear stored credentials." },
|
|
1294
|
+
async run() {
|
|
1295
|
+
await runLogout();
|
|
1296
|
+
}
|
|
1297
|
+
});
|
|
1298
|
+
var whoamiCmd = defineCommand({
|
|
1299
|
+
meta: { name: "whoami", description: "Show current authenticated user." },
|
|
1300
|
+
async run() {
|
|
1301
|
+
await runWhoami({ supabaseUrl: SUPABASE_URL, clientId: CLIENT_ID });
|
|
1302
|
+
}
|
|
1303
|
+
});
|
|
1304
|
+
var initCmd = defineCommand({
|
|
1305
|
+
meta: {
|
|
1306
|
+
name: "init",
|
|
1307
|
+
description: "Link or create a Balise repo and write .balise/config."
|
|
1308
|
+
},
|
|
1309
|
+
async run() {
|
|
1310
|
+
await runInit({ supabaseUrl: SUPABASE_URL, clientId: CLIENT_ID });
|
|
1311
|
+
}
|
|
1312
|
+
});
|
|
1313
|
+
var syncCmd = defineCommand({
|
|
1314
|
+
meta: {
|
|
1315
|
+
name: "sync",
|
|
1316
|
+
description: "Tarball current repo \u2192 upload \u2192 poll extraction progress."
|
|
1317
|
+
},
|
|
1318
|
+
async run() {
|
|
1319
|
+
await runSync({ supabaseUrl: SUPABASE_URL, clientId: CLIENT_ID });
|
|
1320
|
+
}
|
|
1321
|
+
});
|
|
1322
|
+
var main = defineCommand({
|
|
1323
|
+
meta: {
|
|
1324
|
+
name: "balise",
|
|
1325
|
+
version: "0.1.0",
|
|
1326
|
+
description: "Balise CLI \u2014 push codebase for spec extraction."
|
|
1327
|
+
},
|
|
1328
|
+
subCommands: {
|
|
1329
|
+
login: loginCmd,
|
|
1330
|
+
logout: logoutCmd,
|
|
1331
|
+
whoami: whoamiCmd,
|
|
1332
|
+
init: initCmd,
|
|
1333
|
+
sync: syncCmd
|
|
1334
|
+
}
|
|
1335
|
+
});
|
|
1336
|
+
void runMain(main);
|
|
1337
|
+
//# sourceMappingURL=index.js.map
|