@balise.dev/cli 0.1.2 → 0.1.3

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.
Files changed (2) hide show
  1. package/dist/index.js +335 -87
  2. package/package.json +2 -3
package/dist/index.js CHANGED
@@ -548,8 +548,8 @@ var ApiClient = class {
548
548
  }
549
549
  return res.json();
550
550
  }
551
- async getJson(path4) {
552
- const url = joinUrl(this.opts.apiUrl, path4);
551
+ async getJson(path5) {
552
+ const url = joinUrl(this.opts.apiUrl, path5);
553
553
  return this.withAuth(async (headers) => {
554
554
  const { statusCode, body } = await wrapNetwork(() => request2(url, {
555
555
  method: "GET",
@@ -564,8 +564,8 @@ var ApiClient = class {
564
564
  };
565
565
  });
566
566
  }
567
- async postJson(path4, payload) {
568
- const url = joinUrl(this.opts.apiUrl, path4);
567
+ async postJson(path5, payload) {
568
+ const url = joinUrl(this.opts.apiUrl, path5);
569
569
  return this.withAuth(async (headers) => {
570
570
  const { statusCode, body } = await wrapNetwork(() => request2(url, {
571
571
  method: "POST",
@@ -589,10 +589,10 @@ var ApiClient = class {
589
589
  * metadata travels in the query string. Matches the FastAPI contract
590
590
  * `POST /v1/repos/:owner/:slug/sync?commit_sha=...&branch=...`.
591
591
  */
592
- async uploadBundle(path4, opts) {
592
+ async uploadBundle(path5, opts) {
593
593
  await this.ensureFreshToken();
594
594
  const qs = new URLSearchParams(opts.query).toString();
595
- const url = `${joinUrl(this.opts.apiUrl, path4)}${qs ? `?${qs}` : ""}`;
595
+ const url = `${joinUrl(this.opts.apiUrl, path5)}${qs ? `?${qs}` : ""}`;
596
596
  return this.withAuth(async (headers) => {
597
597
  const { statusCode, body } = await wrapNetwork(() => request2(url, {
598
598
  method: "POST",
@@ -613,9 +613,43 @@ var ApiClient = class {
613
613
  };
614
614
  });
615
615
  }
616
+ /**
617
+ * Same wire as `uploadBundle` but returns the raw `(status, text)` pair so
618
+ * callers can branch on response shape (200 dry-run, 202 happy, 409
619
+ * idempotency, 422 invalid base) without `ApiError` swallowing the body.
620
+ *
621
+ * Token refresh and auth attachment behave identically; we still rely on
622
+ * `withAuth` for the 401-then-retry plumbing. Non-401 statuses are
623
+ * returned to the caller verbatim — `withAuth` only treats 401 specially.
624
+ */
625
+ async uploadBundleRaw(path5, opts) {
626
+ await this.ensureFreshToken();
627
+ const qs = new URLSearchParams(opts.query).toString();
628
+ const url = `${joinUrl(this.opts.apiUrl, path5)}${qs ? `?${qs}` : ""}`;
629
+ let tokens = await loadTokens();
630
+ if (!tokens) throw new NotAuthenticatedError();
631
+ const exec = async (token) => {
632
+ const { statusCode, body } = await wrapNetwork(
633
+ () => request2(url, {
634
+ method: "POST",
635
+ headers: {
636
+ Authorization: `Bearer ${token}`,
637
+ "Content-Type": "application/octet-stream"
638
+ },
639
+ body: opts.bundleStream,
640
+ dispatcher: this.opts.dispatcher
641
+ })
642
+ );
643
+ const text = await body.text();
644
+ return { status: statusCode, text };
645
+ };
646
+ const first = await exec(tokens.access_token);
647
+ if (first.status !== 401) return first;
648
+ throw new NotAuthenticatedError();
649
+ }
616
650
  };
617
- function joinUrl(base, path4) {
618
- return `${base.replace(/\/$/, "")}${path4.startsWith("/") ? path4 : `/${path4}`}`;
651
+ function joinUrl(base, path5) {
652
+ return `${base.replace(/\/$/, "")}${path5.startsWith("/") ? path5 : `/${path5}`}`;
619
653
  }
620
654
 
621
655
  // src/config.ts
@@ -795,7 +829,6 @@ async function gitBundle(opts) {
795
829
  }
796
830
 
797
831
  // src/auth-ensure.ts
798
- import readline from "readline/promises";
799
832
  import crypto3 from "crypto";
800
833
  import open2 from "open";
801
834
  var LoginDeclinedError = class extends Error {
@@ -808,6 +841,7 @@ function isInteractive(stdin) {
808
841
  const s = stdin ?? process.stdin;
809
842
  return Boolean(s.isTTY);
810
843
  }
844
+ var LOGIN_AUTOLAUNCH_MESSAGE = "Not logged in, launching balise login...";
811
845
  async function ensureAuthenticated(opts) {
812
846
  const existing = await loadTokens();
813
847
  if (existing) return;
@@ -815,19 +849,8 @@ async function ensureAuthenticated(opts) {
815
849
  if (!isInteractive(opts.stdin)) {
816
850
  throw new LoginDeclinedError();
817
851
  }
818
- const rl = readline.createInterface({
819
- input: opts.stdin ?? process.stdin,
820
- output: opts.stdout ?? process.stdout
821
- });
822
- let answer;
823
- try {
824
- answer = (await rl.question("Not logged in. Login now? (Y/n) ")).trim();
825
- } finally {
826
- rl.close();
827
- }
828
- if (answer && !/^y(es)?$/i.test(answer)) {
829
- throw new LoginDeclinedError();
830
- }
852
+ stderr.write(`${LOGIN_AUTOLAUNCH_MESSAGE}
853
+ `);
831
854
  const verifier = generateCodeVerifier();
832
855
  const challenge = codeChallengeFor(verifier);
833
856
  const state = crypto3.randomBytes(16).toString("hex");
@@ -892,55 +915,81 @@ ${credentialsHelpMessage()}
892
915
  // src/ui/InitPicker.tsx
893
916
  import React, { useState } from "react";
894
917
  import { Box, Text, useApp, useInput } from "ink";
918
+ import { Select, TextInput } from "@inkjs/ui";
919
+ function normalizeSlug(raw) {
920
+ return raw.toLowerCase().replace(/[^a-z0-9-]/g, "");
921
+ }
895
922
  function InitPicker(props) {
896
923
  const { exit } = useApp();
897
- const [zone, setZone] = useState(
898
- props.repos.length > 0 ? "link" : "create"
924
+ const [field, setField] = useState("slug");
925
+ const [slug, setSlug] = useState(normalizeSlug(props.defaultSlug));
926
+ const [ownerId, setOwnerId] = useState(
927
+ props.ownerships[0]?.id
899
928
  );
900
- const [slug, setSlug] = useState(props.defaultSlug);
901
- const [ownerIdx, setOwnerIdx] = useState(0);
902
- const [repoIdx, setRepoIdx] = useState(0);
903
- useInput((input, key) => {
929
+ const [repoId, setRepoId] = useState(
930
+ props.repos[0]?.id
931
+ );
932
+ const submit = () => {
933
+ if (field === "link") {
934
+ const repo = props.repos.find((r) => r.id === repoId);
935
+ if (!repo) return;
936
+ props.onDone({ action: "link", repo });
937
+ } else {
938
+ const owner = props.ownerships.find((o) => o.id === ownerId);
939
+ if (!owner || !slug) return;
940
+ props.onDone({ action: "create", slug, owner });
941
+ }
942
+ exit();
943
+ };
944
+ useInput((_input, key) => {
904
945
  if (key.escape) {
905
946
  props.onDone({ action: "cancel" });
906
947
  exit();
907
948
  return;
908
949
  }
909
950
  if (key.tab) {
910
- setZone((z) => z === "create" ? "link" : "create");
951
+ setField((f) => {
952
+ if (f === "slug") return "owner";
953
+ if (f === "owner") return props.repos.length > 0 ? "link" : "slug";
954
+ return "slug";
955
+ });
911
956
  return;
912
957
  }
913
958
  if (key.return) {
914
- if (zone === "create") {
915
- if (!props.ownerships[ownerIdx]) return;
916
- props.onDone({
917
- action: "create",
918
- slug,
919
- owner: props.ownerships[ownerIdx]
920
- });
921
- } else {
922
- if (!props.repos[repoIdx]) return;
923
- props.onDone({ action: "link", repo: props.repos[repoIdx] });
924
- }
925
- exit();
926
- return;
927
- }
928
- if (zone === "create") {
929
- if (key.upArrow)
930
- setOwnerIdx((i) => (i - 1 + props.ownerships.length) % Math.max(1, props.ownerships.length));
931
- else if (key.downArrow)
932
- setOwnerIdx((i) => (i + 1) % Math.max(1, props.ownerships.length));
933
- else if (key.backspace || key.delete) setSlug((s) => s.slice(0, -1));
934
- else if (input && /^[a-z0-9-]$/i.test(input))
935
- setSlug((s) => (s + input).toLowerCase());
936
- } else {
937
- if (key.upArrow)
938
- setRepoIdx((i) => (i - 1 + props.repos.length) % Math.max(1, props.repos.length));
939
- else if (key.downArrow)
940
- setRepoIdx((i) => (i + 1) % Math.max(1, props.repos.length));
959
+ submit();
941
960
  }
942
961
  });
943
- 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")));
962
+ const ownerOptions = props.ownerships.map((o) => ({
963
+ label: `${o.login} (${o.type})`,
964
+ value: o.id
965
+ }));
966
+ const repoOptions = props.repos.map((r) => ({
967
+ label: `${r.owner_login}/${r.slug}`,
968
+ value: r.id
969
+ }));
970
+ 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: field === "slug" ? "cyan" : "gray" }, field === "slug" ? "\u25B8 " : " ", "New repo slug"), /* @__PURE__ */ React.createElement(Box, { marginLeft: 2 }, /* @__PURE__ */ React.createElement(
971
+ TextInput,
972
+ {
973
+ defaultValue: slug,
974
+ placeholder: "repo-slug",
975
+ isDisabled: field !== "slug",
976
+ onChange: (v) => setSlug(normalizeSlug(v))
977
+ }
978
+ ))), /* @__PURE__ */ React.createElement(Box, { marginTop: 1, flexDirection: "column" }, /* @__PURE__ */ React.createElement(Text, { color: field === "owner" ? "cyan" : "gray" }, field === "owner" ? "\u25B8 " : " ", "Owner"), /* @__PURE__ */ React.createElement(Box, { marginLeft: 2 }, field === "owner" ? /* @__PURE__ */ React.createElement(
979
+ Select,
980
+ {
981
+ options: ownerOptions,
982
+ defaultValue: ownerId,
983
+ onChange: setOwnerId
984
+ }
985
+ ) : /* @__PURE__ */ React.createElement(Text, { dimColor: true }, ownerOptions.find((o) => o.value === ownerId)?.label ?? "\u2014"))), props.repos.length > 0 && /* @__PURE__ */ React.createElement(Box, { marginTop: 1, flexDirection: "column" }, /* @__PURE__ */ React.createElement(Text, { color: field === "link" ? "cyan" : "gray" }, field === "link" ? "\u25B8 " : " ", "Link existing (", props.repos.length, ")"), /* @__PURE__ */ React.createElement(Box, { marginLeft: 2 }, field === "link" ? /* @__PURE__ */ React.createElement(
986
+ Select,
987
+ {
988
+ options: repoOptions,
989
+ defaultValue: repoId,
990
+ onChange: setRepoId
991
+ }
992
+ ) : /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "tab to browse"))), /* @__PURE__ */ React.createElement(Box, { marginTop: 1 }, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "Tab: next field \xB7 \u2191/\u2193: navigate \xB7 Enter: confirm \xB7 Esc: cancel")));
944
993
  }
945
994
 
946
995
  // src/commands/init.ts
@@ -1045,13 +1094,55 @@ async function runInit(opts) {
1045
1094
  }
1046
1095
 
1047
1096
  // src/commands/sync.ts
1097
+ import readline from "readline/promises";
1048
1098
  import React4 from "react";
1049
1099
  import { render as render2 } from "ink";
1050
1100
 
1101
+ // src/logger.ts
1102
+ import { promises as fs3 } from "fs";
1103
+ import path4 from "path";
1104
+ import os2 from "os";
1105
+ var APP_DIR2 = ".balise";
1106
+ var FILENAME2 = "balise.log";
1107
+ function logPath() {
1108
+ const override = process.env.BALISE_LOG_FILE;
1109
+ if (override && override.length > 0) return override;
1110
+ return path4.join(os2.homedir(), APP_DIR2, FILENAME2);
1111
+ }
1112
+ function formatError(err) {
1113
+ if (err instanceof Error) {
1114
+ const stack = err.stack ?? `${err.name}: ${err.message}`;
1115
+ const extras = [];
1116
+ const cause = err.cause;
1117
+ if (cause) extras.push(` caused by: ${formatError(cause)}`);
1118
+ return extras.length ? `${stack}
1119
+ ${extras.join("\n")}` : stack;
1120
+ }
1121
+ if (typeof err === "string") return err;
1122
+ try {
1123
+ return JSON.stringify(err);
1124
+ } catch {
1125
+ return String(err);
1126
+ }
1127
+ }
1128
+ async function logError(context, err) {
1129
+ try {
1130
+ const p = logPath();
1131
+ await fs3.mkdir(path4.dirname(p), { recursive: true, mode: 448 });
1132
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
1133
+ const line = `[${ts}] ERROR ${context}
1134
+ ${formatError(err)}
1135
+
1136
+ `;
1137
+ await fs3.appendFile(p, line, { mode: 384 });
1138
+ } catch {
1139
+ }
1140
+ }
1141
+
1051
1142
  // src/ui/SyncProgress.tsx
1052
1143
  import React3, { useEffect, useState as useState2 } from "react";
1053
1144
  import { Box as Box2, Text as Text2, useApp as useApp2 } from "ink";
1054
- import Spinner from "ink-spinner";
1145
+ import { Spinner } from "@inkjs/ui";
1055
1146
  var QUEUED_MESSAGES = [
1056
1147
  "Waiting for an agent willing to accept the job\u2026",
1057
1148
  "Your request is in line. An agent will be assigned once one stops pretending to be busy.",
@@ -1169,7 +1260,7 @@ function SyncProgress(props) {
1169
1260
  return /* @__PURE__ */ React3.createElement(Text2, { color: "red" }, "\u2717 Failed");
1170
1261
  }
1171
1262
  if (!status) {
1172
- 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"));
1263
+ return /* @__PURE__ */ React3.createElement(Spinner, { label: "Getting things ready\u2026" });
1173
1264
  }
1174
1265
  if (status.status === "done") {
1175
1266
  return /* @__PURE__ */ React3.createElement(Text2, { color: "green" }, "\u2713 Done");
@@ -1179,21 +1270,71 @@ function SyncProgress(props) {
1179
1270
  }
1180
1271
  const message = status.status === "queued" ? QUEUED_MESSAGES[queuedIdx] : RUNNING_MESSAGES[runningIdx];
1181
1272
  const { files_processed, files_total, nodes_pushed } = status.progress;
1182
- const hasCounters = status.status === "running" && (files_total > 0 || nodes_pushed > 0);
1183
- 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);
1273
+ const isRunning = status.status === "running";
1274
+ const showFiles = isRunning && files_total > 0;
1275
+ const showNodes = isRunning && nodes_pushed > 0;
1276
+ return /* @__PURE__ */ React3.createElement(Box2, { flexDirection: "column" }, /* @__PURE__ */ React3.createElement(Spinner, { label: message }), showFiles || showNodes ? /* @__PURE__ */ React3.createElement(Text2, { dimColor: true }, " ", showFiles ? `${files_processed}/${files_total} files` : null, showFiles && showNodes ? " \xB7 " : null, showNodes ? `${nodes_pushed} concepts` : null) : null);
1184
1277
  }
1185
1278
 
1186
1279
  // src/commands/sync.ts
1280
+ function buildSyncQuery(opts) {
1281
+ const q = { commit_sha: opts.commitSha };
1282
+ if (opts.branch) q.branch = opts.branch;
1283
+ q.force = opts.force ? "true" : "false";
1284
+ q.dry_run = opts.dryRun ? "true" : "false";
1285
+ if (opts.base) q.base = opts.base;
1286
+ return q;
1287
+ }
1288
+ var INIT_AUTOLAUNCH_MESSAGE = "Repo not initialized, launching balise init...";
1289
+ function drainStdinBuffer(stdin) {
1290
+ if (stdin !== process.stdin) return;
1291
+ const s = stdin;
1292
+ if (typeof s.read !== "function") return;
1293
+ while (s.read() !== null) {
1294
+ }
1295
+ }
1296
+ async function confirmRetryWithForce(opts) {
1297
+ const stderr = opts.stderr ?? process.stderr;
1298
+ const synced = opts.body.synced_at ?? "unknown time";
1299
+ stderr.write(
1300
+ `commit already synced at tag ${opts.body.tag} (synced at ${synced}), re-run ?
1301
+ `
1302
+ );
1303
+ if (opts.autoConfirm) {
1304
+ return false;
1305
+ }
1306
+ const input = opts.stdin ?? process.stdin;
1307
+ drainStdinBuffer(input);
1308
+ const rl = readline.createInterface({
1309
+ input,
1310
+ output: opts.stdout ?? process.stdout
1311
+ });
1312
+ let answer;
1313
+ try {
1314
+ answer = (await rl.question("re-run ? (y/N) ")).trim();
1315
+ } finally {
1316
+ rl.close();
1317
+ }
1318
+ return /^y(es)?$/i.test(answer);
1319
+ }
1320
+ function formatDryRunSummary(body) {
1321
+ const fromDiff = body.would_be_cold_start ? "cold-start (no prior tag)" : `from-commit=${body.from_commit ?? "?"}` + (body.from_commit_distance !== null ? ` distance=${body.from_commit_distance}` : "");
1322
+ return `Sync would use: base=${body.base_dolt_ref} (${body.base_kind}) / ${fromDiff}`;
1323
+ }
1187
1324
  async function runSync(opts) {
1188
1325
  try {
1189
1326
  await runSyncInner(opts);
1190
1327
  } catch (err) {
1328
+ await logError("sync", err);
1191
1329
  if (err instanceof ApiUnreachableError) {
1192
1330
  process.stderr.write(
1193
1331
  "Cannot reach the Balise service. Please try again in a moment.\n"
1194
1332
  );
1195
1333
  } else {
1196
- process.stderr.write("Something went wrong. Please try again.\n");
1334
+ process.stderr.write(
1335
+ `Something went wrong. Please try again. (details: ${logPath()})
1336
+ `
1337
+ );
1197
1338
  }
1198
1339
  process.exit(1);
1199
1340
  }
@@ -1222,7 +1363,8 @@ async function runSyncInner(opts) {
1222
1363
  }
1223
1364
  let cfg = await readConfig(cwd);
1224
1365
  if (!cfg) {
1225
- process.stderr.write("No .balise/config \u2014 running `balise init` first.\n");
1366
+ process.stderr.write(`${INIT_AUTOLAUNCH_MESSAGE}
1367
+ `);
1226
1368
  await runInit({ ...opts, cwd });
1227
1369
  cfg = await readConfig(cwd);
1228
1370
  if (!cfg) {
@@ -1243,32 +1385,52 @@ async function runSyncInner(opts) {
1243
1385
  `Packing ${cfg.repo.owner_login}/${cfg.repo.slug} at HEAD\u2026
1244
1386
  `
1245
1387
  );
1246
- const { stream, commitSha, branch } = await gitBundle({ cwd });
1247
- let accepted;
1388
+ const submit = async (force) => {
1389
+ const { stream, commitSha, branch } = await gitBundle({ cwd });
1390
+ const query = buildSyncQuery({
1391
+ commitSha,
1392
+ branch,
1393
+ force,
1394
+ base: opts.base,
1395
+ dryRun: opts.dryRun ?? false
1396
+ });
1397
+ return uploadOnce(client, cfg.repo.owner_login, cfg.repo.slug, stream, query);
1398
+ };
1399
+ let outcome;
1248
1400
  try {
1249
- accepted = await client.uploadBundle(
1250
- `/v1/repos/${cfg.repo.owner_login}/${cfg.repo.slug}/sync`,
1251
- {
1252
- bundleStream: stream,
1253
- query: { commit_sha: commitSha, branch }
1254
- }
1255
- );
1401
+ outcome = await submit(opts.force ?? false);
1256
1402
  } catch (err) {
1257
- if (err instanceof NotAuthenticatedError) {
1258
- process.stderr.write("Not logged in \u2014 run `balise login`.\n");
1403
+ handleUploadError(err);
1404
+ process.exit(1);
1405
+ }
1406
+ if (outcome.kind === "conflict") {
1407
+ const proceedForce = await confirmRetryWithForce({
1408
+ body: outcome.body,
1409
+ autoConfirm: opts.autoConfirm ?? false
1410
+ });
1411
+ if (!proceedForce) {
1259
1412
  process.exit(1);
1260
1413
  }
1261
- if (err instanceof ApiUnreachableError) {
1262
- process.stderr.write(
1263
- "Cannot reach the Balise service. Please try again in a moment.\n"
1264
- );
1414
+ try {
1415
+ outcome = await submit(true);
1416
+ } catch (err) {
1417
+ handleUploadError(err);
1265
1418
  process.exit(1);
1266
1419
  }
1267
- process.stderr.write(
1268
- "Something went wrong while uploading. Please try again.\n"
1269
- );
1420
+ }
1421
+ if (outcome.kind === "invalid_base") {
1422
+ process.stderr.write(`invalid base ref: ${opts.base}
1423
+ `);
1424
+ process.exit(1);
1425
+ }
1426
+ if (outcome.kind === "dry_run") {
1427
+ process.stdout.write(formatDryRunSummary(outcome.body) + "\n");
1428
+ process.exit(0);
1429
+ }
1430
+ if (outcome.kind !== "accepted") {
1270
1431
  process.exit(1);
1271
1432
  }
1433
+ const accepted = outcome.body;
1272
1434
  const result = await new Promise((resolve) => {
1273
1435
  const app = render2(
1274
1436
  React4.createElement(SyncProgress, {
@@ -1283,25 +1445,83 @@ async function runSyncInner(opts) {
1283
1445
  });
1284
1446
  process.exit(result ? 0 : 1);
1285
1447
  }
1448
+ async function uploadOnce(client, owner, slug, stream, query) {
1449
+ const raw = await client.uploadBundleRaw(
1450
+ `/v1/repos/${owner}/${slug}/sync`,
1451
+ {
1452
+ bundleStream: stream,
1453
+ query
1454
+ }
1455
+ );
1456
+ if (raw.status === 202) {
1457
+ return { kind: "accepted", body: JSON.parse(raw.text) };
1458
+ }
1459
+ if (raw.status === 200) {
1460
+ return { kind: "dry_run", body: JSON.parse(raw.text) };
1461
+ }
1462
+ if (raw.status === 409) {
1463
+ const parsed = JSON.parse(raw.text);
1464
+ const body = "detail" in parsed && parsed.detail ? parsed.detail : parsed;
1465
+ return { kind: "conflict", body };
1466
+ }
1467
+ if (raw.status === 422) {
1468
+ let detail;
1469
+ try {
1470
+ const parsed = JSON.parse(raw.text);
1471
+ if (typeof parsed.detail === "string") detail = parsed.detail;
1472
+ } catch {
1473
+ }
1474
+ if (detail === "invalid_base_ref") return { kind: "invalid_base" };
1475
+ throw new ApiError(raw.status, raw.text);
1476
+ }
1477
+ throw new ApiError(raw.status, raw.text);
1478
+ }
1479
+ function handleUploadError(err) {
1480
+ if (err instanceof NotAuthenticatedError) {
1481
+ process.stderr.write("Not logged in \u2014 run `balise login`.\n");
1482
+ return;
1483
+ }
1484
+ if (err instanceof ApiUnreachableError) {
1485
+ process.stderr.write(
1486
+ "Cannot reach the Balise service. Please try again in a moment.\n"
1487
+ );
1488
+ return;
1489
+ }
1490
+ process.stderr.write(
1491
+ "Something went wrong while uploading. Please try again.\n"
1492
+ );
1493
+ }
1286
1494
 
1287
1495
  // src/index.ts
1496
+ async function withLog(name, fn) {
1497
+ try {
1498
+ await fn();
1499
+ } catch (err) {
1500
+ await logError(name, err);
1501
+ process.stderr.write(
1502
+ `Something went wrong (${name}). Details: ${logPath()}
1503
+ `
1504
+ );
1505
+ process.exit(1);
1506
+ }
1507
+ }
1288
1508
  var SUPABASE_URL = process.env.BALISE_SUPABASE_URL ?? "https://api.balise.dev";
1289
1509
  var loginCmd = defineCommand({
1290
1510
  meta: { name: "login", description: "Authenticate via OAuth (PKCE loopback)." },
1291
1511
  async run() {
1292
- await runLogin({ supabaseUrl: SUPABASE_URL });
1512
+ await withLog("login", () => runLogin({ supabaseUrl: SUPABASE_URL }));
1293
1513
  }
1294
1514
  });
1295
1515
  var logoutCmd = defineCommand({
1296
1516
  meta: { name: "logout", description: "Clear stored credentials." },
1297
1517
  async run() {
1298
- await runLogout();
1518
+ await withLog("logout", () => runLogout());
1299
1519
  }
1300
1520
  });
1301
1521
  var whoamiCmd = defineCommand({
1302
1522
  meta: { name: "whoami", description: "Show current authenticated user." },
1303
1523
  async run() {
1304
- await runWhoami({ supabaseUrl: SUPABASE_URL });
1524
+ await withLog("whoami", () => runWhoami({ supabaseUrl: SUPABASE_URL }));
1305
1525
  }
1306
1526
  });
1307
1527
  var initCmd = defineCommand({
@@ -1310,7 +1530,7 @@ var initCmd = defineCommand({
1310
1530
  description: "Link or create a Balise repo and write .balise/config."
1311
1531
  },
1312
1532
  async run() {
1313
- await runInit({ supabaseUrl: SUPABASE_URL });
1533
+ await withLog("init", () => runInit({ supabaseUrl: SUPABASE_URL }));
1314
1534
  }
1315
1535
  });
1316
1536
  var syncCmd = defineCommand({
@@ -1318,8 +1538,36 @@ var syncCmd = defineCommand({
1318
1538
  name: "sync",
1319
1539
  description: "Tarball current repo \u2192 upload \u2192 poll extraction progress."
1320
1540
  },
1321
- async run() {
1322
- await runSync({ supabaseUrl: SUPABASE_URL });
1541
+ args: {
1542
+ yes: {
1543
+ type: "boolean",
1544
+ alias: "y",
1545
+ description: "Skip sync confirmations (warning prompt + future 409 retry).",
1546
+ default: false
1547
+ },
1548
+ force: {
1549
+ type: "boolean",
1550
+ description: "Bypass idempotency 409 + resume-reuse: re-tag from scratch and re-resolve base.",
1551
+ default: false
1552
+ },
1553
+ base: {
1554
+ type: "string",
1555
+ description: "Explicit base balise ref (branch/tag/commit) to start from. Validated server-side."
1556
+ },
1557
+ "dry-run": {
1558
+ type: "boolean",
1559
+ description: "Preview the resolution without uploading the bundle or enqueuing the worker.",
1560
+ default: false
1561
+ }
1562
+ },
1563
+ async run({ args }) {
1564
+ await runSync({
1565
+ supabaseUrl: SUPABASE_URL,
1566
+ autoConfirm: Boolean(args.yes),
1567
+ force: Boolean(args.force),
1568
+ base: typeof args.base === "string" && args.base.length > 0 ? args.base : void 0,
1569
+ dryRun: Boolean(args["dry-run"])
1570
+ });
1323
1571
  }
1324
1572
  });
1325
1573
  var main = defineCommand({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@balise.dev/cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Balise CLI — push codebase to Balise backend for spec extraction.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,11 +22,10 @@
22
22
  "node": ">=20"
23
23
  },
24
24
  "dependencies": {
25
+ "@inkjs/ui": "^2.0.0",
25
26
  "citty": "^0.2.2",
26
27
  "ini": "^5.0.0",
27
28
  "ink": "^5.0.1",
28
- "ink-progress-bar": "^3.0.0",
29
- "ink-spinner": "^5.0.0",
30
29
  "open": "^10.1.0",
31
30
  "react": "^18.3.1",
32
31
  "tar": "^7.4.3",