@balise.dev/cli 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +19 -0
  2. package/dist/index.js +907 -227
  3. package/package.json +5 -3
package/README.md CHANGED
@@ -61,6 +61,25 @@ Tokens are written to a plaintext JSON file with restrictive permissions:
61
61
 
62
62
  If you need OS-keychain-grade protection, set `BALISE_CREDENTIALS_FILE` to a path on an encrypted volume, or feed `BALISE_TOKEN` from your existing secret manager.
63
63
 
64
+ ## Build-time policy patterns
65
+
66
+ `balise init` ships with two compile-time pattern lists baked into the bundle via tsup `define`:
67
+
68
+ | Env var (build) | Used for | SSOT contract |
69
+ |---|---|---|
70
+ | `BALISE_BAN_PATTERNS` | Directories the extractor will never read (`.git`, `node_modules`, …). Displayed as 🔒 read-only in the `balise init` ignore tree. | **MUST** match the worker-side env var of the same name (read by `balise_mcp/diff_loc.py`). Changing one without the other breaks the SSOT verrou between credit estimate and what the agent sees. |
71
+ | `BALISE_DEFAULT_PATTERNS` | Globs pre-checked-out in the ignore tree as a sensible default (lockfiles, minified assets, snapshots, …). User remains free to scan them — they just pay the tokens. | CLI-only. |
72
+
73
+ Override at build time:
74
+
75
+ ```bash
76
+ BALISE_BAN_PATTERNS=.git,node_modules,dist \
77
+ BALISE_DEFAULT_PATTERNS='*.lock,*.min.js' \
78
+ npm run build
79
+ ```
80
+
81
+ The defaults baked when these env vars are unset are visible in `cli/tsup.config.ts`.
82
+
64
83
  ## Dev
65
84
 
66
85
  ```bash
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(path7) {
552
+ const url = joinUrl(this.opts.apiUrl, path7);
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(path7, payload) {
568
+ const url = joinUrl(this.opts.apiUrl, path7);
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(path7, 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, path7)}${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(path7, opts) {
626
+ await this.ensureFreshToken();
627
+ const qs = new URLSearchParams(opts.query).toString();
628
+ const url = `${joinUrl(this.opts.apiUrl, path7)}${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, path7) {
652
+ return `${base.replace(/\/$/, "")}${path7.startsWith("/") ? path7 : `/${path7}`}`;
619
653
  }
620
654
 
621
655
  // src/config.ts
@@ -700,8 +734,8 @@ account_id : ${me.account_id}
700
734
  }
701
735
 
702
736
  // src/commands/init.ts
703
- import path3 from "path";
704
- import React2 from "react";
737
+ import path5 from "path";
738
+ import React3 from "react";
705
739
  import { render } from "ink";
706
740
 
707
741
  // src/git.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,626 @@ ${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 currentMatches = props.currentRepoId !== void 0 && props.repos.some((r) => r.id === props.currentRepoId);
925
+ const [field, setField] = useState(currentMatches ? "link" : "slug");
926
+ const [slug, setSlug] = useState(normalizeSlug(props.defaultSlug));
927
+ const [ownerId, setOwnerId] = useState(
928
+ props.ownerships[0]?.id
899
929
  );
900
- const [slug, setSlug] = useState(props.defaultSlug);
901
- const [ownerIdx, setOwnerIdx] = useState(0);
902
- const [repoIdx, setRepoIdx] = useState(0);
903
- useInput((input, key) => {
930
+ const [repoId, setRepoId] = useState(
931
+ currentMatches ? props.currentRepoId : props.repos[0]?.id
932
+ );
933
+ const submit = () => {
934
+ if (field === "link") {
935
+ const repo = props.repos.find((r) => r.id === repoId);
936
+ if (!repo) return;
937
+ props.onDone({ action: "link", repo });
938
+ } else {
939
+ const owner = props.ownerships.find((o) => o.id === ownerId);
940
+ if (!owner || !slug) return;
941
+ props.onDone({ action: "create", slug, owner });
942
+ }
943
+ exit();
944
+ };
945
+ useInput((_input, key) => {
904
946
  if (key.escape) {
905
947
  props.onDone({ action: "cancel" });
906
948
  exit();
907
949
  return;
908
950
  }
909
951
  if (key.tab) {
910
- setZone((z) => z === "create" ? "link" : "create");
952
+ setField((f) => {
953
+ if (f === "slug") return "owner";
954
+ if (f === "owner") return props.repos.length > 0 ? "link" : "slug";
955
+ return "slug";
956
+ });
911
957
  return;
912
958
  }
913
959
  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]
960
+ submit();
961
+ }
962
+ });
963
+ const ownerOptions = props.ownerships.map((o) => ({
964
+ label: `${o.login} (${o.type})`,
965
+ value: o.id
966
+ }));
967
+ const repoOptions = props.repos.map((r) => ({
968
+ label: r.id === props.currentRepoId ? `${r.owner_login}/${r.slug} (current)` : `${r.owner_login}/${r.slug}`,
969
+ value: r.id
970
+ }));
971
+ 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(
972
+ TextInput,
973
+ {
974
+ defaultValue: slug,
975
+ placeholder: "repo-slug",
976
+ isDisabled: field !== "slug",
977
+ onChange: (v) => setSlug(normalizeSlug(v))
978
+ }
979
+ ))), /* @__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(
980
+ Select,
981
+ {
982
+ options: ownerOptions,
983
+ defaultValue: ownerId,
984
+ onChange: setOwnerId
985
+ }
986
+ ) : /* @__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(
987
+ Select,
988
+ {
989
+ options: repoOptions,
990
+ defaultValue: repoId,
991
+ onChange: setRepoId
992
+ }
993
+ ) : /* @__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")));
994
+ }
995
+
996
+ // src/scan.ts
997
+ import { isUtf8 } from "buffer";
998
+ import { execFile as execFileCb } from "child_process";
999
+ import { promises as fs3 } from "fs";
1000
+ import path3 from "path";
1001
+ import { promisify } from "util";
1002
+ import picomatch from "picomatch";
1003
+
1004
+ // src/policy.ts
1005
+ var BAN_PATTERNS = [
1006
+ ".git",
1007
+ "node_modules",
1008
+ "__pycache__",
1009
+ ".venv",
1010
+ "venv",
1011
+ "dist",
1012
+ "build",
1013
+ ".next",
1014
+ ".turbo",
1015
+ ".cache",
1016
+ "target"
1017
+ ];
1018
+ var DEFAULT_PATTERNS = [
1019
+ "*.lock",
1020
+ "package-lock.json",
1021
+ "yarn.lock",
1022
+ "pnpm-lock.yaml",
1023
+ "*.min.js",
1024
+ "*.min.css",
1025
+ "*.svg",
1026
+ "*.png",
1027
+ "*.jpg",
1028
+ "*.jpeg",
1029
+ "*.gif",
1030
+ "*.ico",
1031
+ "*.woff",
1032
+ "*.woff2",
1033
+ "*.snap",
1034
+ "CHANGELOG.md"
1035
+ ];
1036
+ var LOC_THRESHOLD = 2500;
1037
+ var MAX_FILE_BYTES = 1e6;
1038
+
1039
+ // src/scan.ts
1040
+ var execFile = promisify(execFileCb);
1041
+ var BAN_DIRS = new Set(BAN_PATTERNS);
1042
+ var matchesDefault = DEFAULT_PATTERNS.length > 0 ? picomatch([...DEFAULT_PATTERNS], { dot: true, matchBase: true }) : () => false;
1043
+ function countLines(buf) {
1044
+ let count = 0;
1045
+ for (let i = 0; i < buf.length; i++) {
1046
+ if (buf[i] === 10) count++;
1047
+ }
1048
+ if (buf.length > 0 && buf[buf.length - 1] !== 10) count++;
1049
+ return count;
1050
+ }
1051
+ async function scanFile(absPath) {
1052
+ let stat;
1053
+ try {
1054
+ stat = await fs3.stat(absPath);
1055
+ } catch {
1056
+ return null;
1057
+ }
1058
+ if (stat.size > MAX_FILE_BYTES) return null;
1059
+ const buf = await fs3.readFile(absPath);
1060
+ if (!isUtf8(buf)) return null;
1061
+ return countLines(buf);
1062
+ }
1063
+ async function listTrackedFiles(cwd) {
1064
+ try {
1065
+ const { stdout } = await execFile("git", ["ls-files", "-z"], {
1066
+ cwd,
1067
+ maxBuffer: 50 * 1024 * 1024
1068
+ });
1069
+ return new Set(stdout.split("\0").filter((l) => l.length > 0));
1070
+ } catch {
1071
+ return null;
1072
+ }
1073
+ }
1074
+ async function walk(absDir, relDir, out, tracked) {
1075
+ let dirents;
1076
+ try {
1077
+ dirents = await fs3.readdir(absDir, { withFileTypes: true });
1078
+ } catch {
1079
+ return;
1080
+ }
1081
+ for (const d of dirents) {
1082
+ const relPath = relDir ? `${relDir}/${d.name}` : d.name;
1083
+ const absPath = path3.join(absDir, d.name);
1084
+ if (d.isDirectory()) {
1085
+ if (BAN_DIRS.has(d.name)) {
1086
+ out.push({
1087
+ path: relPath,
1088
+ locCount: 0,
1089
+ isBan: true,
1090
+ isDefault: false,
1091
+ isLarge: false
920
1092
  });
1093
+ continue;
1094
+ }
1095
+ await walk(absPath, relPath, out, tracked);
1096
+ } else if (d.isFile()) {
1097
+ if (tracked !== null && !tracked.has(relPath)) continue;
1098
+ const locCount = await scanFile(absPath);
1099
+ if (locCount === null) continue;
1100
+ out.push({
1101
+ path: relPath,
1102
+ locCount,
1103
+ isBan: false,
1104
+ isDefault: matchesDefault(relPath),
1105
+ isLarge: locCount > LOC_THRESHOLD
1106
+ });
1107
+ }
1108
+ }
1109
+ }
1110
+ async function scan(cwd) {
1111
+ const tracked = await listTrackedFiles(cwd);
1112
+ const out = [];
1113
+ await walk(cwd, "", out, tracked);
1114
+ return out;
1115
+ }
1116
+
1117
+ // src/baliseignore.ts
1118
+ import { promises as fs4 } from "fs";
1119
+ import path4 from "path";
1120
+ var FILENAME2 = ".baliseignore";
1121
+ var HEADER = [
1122
+ "# Generated by balise init.",
1123
+ "# balise init will overwrite this file on next run.",
1124
+ ""
1125
+ ].join("\n");
1126
+ async function readBaliseignore(cwd) {
1127
+ const file = path4.join(cwd, FILENAME2);
1128
+ let raw;
1129
+ try {
1130
+ raw = await fs4.readFile(file, "utf-8");
1131
+ } catch (err) {
1132
+ if (err.code === "ENOENT") return /* @__PURE__ */ new Set();
1133
+ throw err;
1134
+ }
1135
+ const out = /* @__PURE__ */ new Set();
1136
+ for (const line of raw.split(/\r?\n/)) {
1137
+ const trimmed = line.trim();
1138
+ if (!trimmed || trimmed.startsWith("#")) continue;
1139
+ out.add(trimmed);
1140
+ }
1141
+ return out;
1142
+ }
1143
+ async function writeBaliseignore(cwd, patterns) {
1144
+ const sorted = [...new Set(patterns)].filter((p) => p.length > 0).sort((a, b) => a.localeCompare(b));
1145
+ const body = sorted.length > 0 ? sorted.join("\n") + "\n" : "";
1146
+ await fs4.writeFile(path4.join(cwd, FILENAME2), HEADER + body, "utf-8");
1147
+ }
1148
+
1149
+ // src/ui/IgnoreTree.tsx
1150
+ import React2, { useMemo, useState as useState2 } from "react";
1151
+ import { Box as Box2, Text as Text2, useApp as useApp2, useInput as useInput2 } from "ink";
1152
+ function buildTree(entries) {
1153
+ const root = {
1154
+ path: "",
1155
+ name: ".",
1156
+ isDir: true,
1157
+ isBan: false,
1158
+ locCount: 0,
1159
+ isDefault: false,
1160
+ isLarge: false,
1161
+ children: [],
1162
+ parent: null
1163
+ };
1164
+ const dirMap = /* @__PURE__ */ new Map();
1165
+ dirMap.set("", root);
1166
+ const ensureDir = (dirPath) => {
1167
+ if (!dirPath) return root;
1168
+ const existing = dirMap.get(dirPath);
1169
+ if (existing) return existing;
1170
+ const parts = dirPath.split("/");
1171
+ const name = parts[parts.length - 1];
1172
+ const parentPath = parts.slice(0, -1).join("/");
1173
+ const parent = ensureDir(parentPath);
1174
+ const node = {
1175
+ path: dirPath,
1176
+ name,
1177
+ isDir: true,
1178
+ isBan: false,
1179
+ locCount: 0,
1180
+ isDefault: false,
1181
+ isLarge: false,
1182
+ children: [],
1183
+ parent
1184
+ };
1185
+ parent.children.push(node);
1186
+ dirMap.set(dirPath, node);
1187
+ return node;
1188
+ };
1189
+ const sorted = [...entries].sort((a, b) => a.path.localeCompare(b.path));
1190
+ for (const e of sorted) {
1191
+ const parts = e.path.split("/");
1192
+ const name = parts[parts.length - 1];
1193
+ const parentPath = parts.slice(0, -1).join("/");
1194
+ const parent = ensureDir(parentPath);
1195
+ const node = {
1196
+ path: e.path,
1197
+ name,
1198
+ isDir: e.isBan,
1199
+ // ban entries from scan() are directories by construction
1200
+ isBan: e.isBan,
1201
+ locCount: e.locCount,
1202
+ isDefault: e.isDefault,
1203
+ isLarge: e.isLarge,
1204
+ children: [],
1205
+ parent
1206
+ };
1207
+ parent.children.push(node);
1208
+ if (e.isBan) dirMap.set(e.path, node);
1209
+ }
1210
+ const sortChildren = (n) => {
1211
+ n.children.sort((a, b) => {
1212
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
1213
+ return a.name.localeCompare(b.name);
1214
+ });
1215
+ for (const c of n.children) sortChildren(c);
1216
+ };
1217
+ sortChildren(root);
1218
+ return root;
1219
+ }
1220
+ function collectDescendantFiles(node) {
1221
+ const out = [];
1222
+ const recurse = (n) => {
1223
+ if (!n.isDir && !n.isBan) out.push(n.path);
1224
+ for (const c of n.children) recurse(c);
1225
+ };
1226
+ recurse(node);
1227
+ return out;
1228
+ }
1229
+ function flattenVisible(root, expanded) {
1230
+ const out = [];
1231
+ const recurse = (node, depth) => {
1232
+ if (node !== root) {
1233
+ out.push({ node, depth });
1234
+ }
1235
+ if (node === root || node.isDir && !node.isBan && expanded.has(node.path)) {
1236
+ for (const c of node.children) recurse(c, depth + 1);
1237
+ }
1238
+ };
1239
+ recurse(root, -1);
1240
+ return out;
1241
+ }
1242
+ function dirAllIncluded(node, excluded) {
1243
+ const files = collectDescendantFiles(node);
1244
+ if (files.length === 0) return true;
1245
+ return files.every((f) => !excluded.has(f));
1246
+ }
1247
+ function computeInitialExcluded(scan2, current) {
1248
+ const out = /* @__PURE__ */ new Set();
1249
+ const scanPaths = /* @__PURE__ */ new Set();
1250
+ for (const e of scan2) {
1251
+ if (e.isBan) continue;
1252
+ scanPaths.add(e.path);
1253
+ if (e.isDefault || e.isLarge) out.add(e.path);
1254
+ }
1255
+ for (const p of current) {
1256
+ if (!scanPaths.has(p)) out.add(p);
1257
+ }
1258
+ return out;
1259
+ }
1260
+ function computeDiff(current, excluded) {
1261
+ const all = /* @__PURE__ */ new Set([...current, ...excluded]);
1262
+ return [...all].sort((a, b) => a.localeCompare(b)).map((pattern) => {
1263
+ const inCurrent = current.has(pattern);
1264
+ const inExcluded = excluded.has(pattern);
1265
+ const kind = inCurrent && inExcluded ? "unchanged" : inExcluded ? "added" : "removed";
1266
+ return { pattern, kind };
1267
+ });
1268
+ }
1269
+ var VIEWPORT_HEIGHT = 18;
1270
+ function IgnoreTree(props) {
1271
+ const { exit } = useApp2();
1272
+ const root = useMemo(() => buildTree(props.scan), [props.scan]);
1273
+ const [excluded, setExcluded] = useState2(
1274
+ () => computeInitialExcluded(props.scan, props.currentBaliseignore)
1275
+ );
1276
+ const [expandedDirs, setExpandedDirs] = useState2(/* @__PURE__ */ new Set());
1277
+ const [focus, setFocus] = useState2("tree");
1278
+ const [treeCursor, setTreeCursor] = useState2("");
1279
+ const [diffCursor, setDiffCursor] = useState2(0);
1280
+ const [modalOpen, setModalOpen] = useState2(false);
1281
+ const visible = useMemo(
1282
+ () => flattenVisible(root, expandedDirs),
1283
+ [root, expandedDirs]
1284
+ );
1285
+ const diff = useMemo(
1286
+ () => computeDiff(props.currentBaliseignore, excluded),
1287
+ [props.currentBaliseignore, excluded]
1288
+ );
1289
+ const treeIdx = useMemo(() => {
1290
+ if (visible.length === 0) return -1;
1291
+ const i = visible.findIndex((v) => v.node.path === treeCursor);
1292
+ return i >= 0 ? i : 0;
1293
+ }, [visible, treeCursor]);
1294
+ const safeDiffIdx = diff.length === 0 ? -1 : Math.max(0, Math.min(diffCursor, diff.length - 1));
1295
+ const stats = useMemo(() => {
1296
+ let added = 0;
1297
+ let removed = 0;
1298
+ let unchanged = 0;
1299
+ for (const p of excluded) {
1300
+ if (props.currentBaliseignore.has(p)) unchanged++;
1301
+ else added++;
1302
+ }
1303
+ for (const p of props.currentBaliseignore) {
1304
+ if (!excluded.has(p)) removed++;
1305
+ }
1306
+ return { added, removed, unchanged };
1307
+ }, [excluded, props.currentBaliseignore]);
1308
+ const toggleFile = (path7) => {
1309
+ setExcluded((prev) => {
1310
+ const next = new Set(prev);
1311
+ if (next.has(path7)) next.delete(path7);
1312
+ else next.add(path7);
1313
+ return next;
1314
+ });
1315
+ };
1316
+ const toggleDir = (dirNode) => {
1317
+ const descendants = collectDescendantFiles(dirNode);
1318
+ if (descendants.length === 0) return;
1319
+ const allIncluded = descendants.every((p) => !excluded.has(p));
1320
+ setExcluded((prev) => {
1321
+ const next = new Set(prev);
1322
+ if (allIncluded) {
1323
+ for (const p of descendants) next.add(p);
921
1324
  } else {
922
- if (!props.repos[repoIdx]) return;
923
- props.onDone({ action: "link", repo: props.repos[repoIdx] });
1325
+ for (const p of descendants) next.delete(p);
1326
+ }
1327
+ return next;
1328
+ });
1329
+ };
1330
+ useInput2((input, key) => {
1331
+ if (modalOpen) {
1332
+ if (input === "y" || input === "Y") {
1333
+ props.onDone({
1334
+ action: "save",
1335
+ patterns: [...excluded].sort((a, b) => a.localeCompare(b))
1336
+ });
1337
+ exit();
1338
+ return;
1339
+ }
1340
+ if (input === "n" || input === "N") {
1341
+ setModalOpen(false);
1342
+ return;
1343
+ }
1344
+ if (input === "q" || input === "Q" || key.escape) {
1345
+ props.onDone({ action: "cancel", patterns: [] });
1346
+ exit();
1347
+ return;
924
1348
  }
925
- exit();
926
1349
  return;
927
1350
  }
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());
1351
+ if (key.tab) {
1352
+ setFocus((f) => f === "tree" ? "diff" : "tree");
1353
+ return;
1354
+ }
1355
+ if (key.return) {
1356
+ setModalOpen(true);
1357
+ return;
1358
+ }
1359
+ if (key.escape || input === "q") {
1360
+ setModalOpen(true);
1361
+ return;
1362
+ }
1363
+ if (focus === "tree") {
1364
+ if (key.downArrow) {
1365
+ const i = Math.min(treeIdx + 1, visible.length - 1);
1366
+ if (i >= 0) setTreeCursor(visible[i].node.path);
1367
+ return;
1368
+ }
1369
+ if (key.upArrow) {
1370
+ const i = Math.max(treeIdx - 1, 0);
1371
+ if (i >= 0) setTreeCursor(visible[i].node.path);
1372
+ return;
1373
+ }
1374
+ if (key.rightArrow) {
1375
+ const cur = treeIdx >= 0 ? visible[treeIdx].node : null;
1376
+ if (!cur || !cur.isDir || cur.isBan) return;
1377
+ if (!expandedDirs.has(cur.path)) {
1378
+ setExpandedDirs((s) => {
1379
+ const next = new Set(s);
1380
+ next.add(cur.path);
1381
+ return next;
1382
+ });
1383
+ } else if (cur.children.length > 0) {
1384
+ setTreeCursor(cur.children[0].path);
1385
+ }
1386
+ return;
1387
+ }
1388
+ if (key.leftArrow) {
1389
+ const cur = treeIdx >= 0 ? visible[treeIdx].node : null;
1390
+ if (!cur) return;
1391
+ if (cur.isDir && !cur.isBan && expandedDirs.has(cur.path)) {
1392
+ setExpandedDirs((s) => {
1393
+ const next = new Set(s);
1394
+ next.delete(cur.path);
1395
+ return next;
1396
+ });
1397
+ } else if (cur.parent && cur.parent.path !== "") {
1398
+ setTreeCursor(cur.parent.path);
1399
+ }
1400
+ return;
1401
+ }
1402
+ if (input === " ") {
1403
+ const cur = treeIdx >= 0 ? visible[treeIdx].node : null;
1404
+ if (!cur || cur.isBan) return;
1405
+ if (cur.isDir) toggleDir(cur);
1406
+ else toggleFile(cur.path);
1407
+ return;
1408
+ }
936
1409
  } 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));
1410
+ if (key.downArrow) {
1411
+ setDiffCursor((c) => Math.min(c + 1, Math.max(diff.length - 1, 0)));
1412
+ return;
1413
+ }
1414
+ if (key.upArrow) {
1415
+ setDiffCursor((c) => Math.max(c - 1, 0));
1416
+ return;
1417
+ }
1418
+ if (input === " ") {
1419
+ if (safeDiffIdx < 0) return;
1420
+ toggleFile(diff[safeDiffIdx].pattern);
1421
+ return;
1422
+ }
941
1423
  }
942
1424
  });
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")));
1425
+ if (modalOpen) {
1426
+ return /* @__PURE__ */ React2.createElement(ConfirmModal, { stats, excludedSize: excluded.size });
1427
+ }
1428
+ const treeStart = Math.max(
1429
+ 0,
1430
+ Math.min(
1431
+ treeIdx - Math.floor(VIEWPORT_HEIGHT / 2),
1432
+ visible.length - VIEWPORT_HEIGHT
1433
+ )
1434
+ );
1435
+ const treeWindow = visible.slice(
1436
+ Math.max(treeStart, 0),
1437
+ Math.max(treeStart, 0) + VIEWPORT_HEIGHT
1438
+ );
1439
+ const diffStart = Math.max(
1440
+ 0,
1441
+ Math.min(
1442
+ safeDiffIdx - Math.floor(VIEWPORT_HEIGHT / 2),
1443
+ diff.length - VIEWPORT_HEIGHT
1444
+ )
1445
+ );
1446
+ const diffWindow = diff.slice(
1447
+ Math.max(diffStart, 0),
1448
+ Math.max(diffStart, 0) + VIEWPORT_HEIGHT
1449
+ );
1450
+ const treeWindowStart = Math.max(treeStart, 0);
1451
+ const diffWindowStart = Math.max(diffStart, 0);
1452
+ return /* @__PURE__ */ React2.createElement(Box2, { flexDirection: "column", paddingX: 1 }, /* @__PURE__ */ React2.createElement(Text2, { bold: true }, "Balise \u2014 configure .baliseignore"), /* @__PURE__ */ React2.createElement(Text2, { dimColor: true }, "Tab: panel \xB7 \u2191/\u2193: nav \xB7 \u2192/\u2190: open/close \xB7 Space: toggle \xB7 Enter: save \xB7 Esc: cancel"), /* @__PURE__ */ React2.createElement(Box2, { marginTop: 1 }, /* @__PURE__ */ React2.createElement(
1453
+ Box2,
1454
+ {
1455
+ flexDirection: "column",
1456
+ width: "50%",
1457
+ paddingRight: 1,
1458
+ borderStyle: "round",
1459
+ borderColor: focus === "tree" ? "cyan" : "gray"
1460
+ },
1461
+ /* @__PURE__ */ React2.createElement(Text2, { bold: true, color: focus === "tree" ? "cyan" : void 0 }, "Files"),
1462
+ treeWindow.length === 0 ? /* @__PURE__ */ React2.createElement(Text2, { dimColor: true }, "(no files)") : treeWindow.map((v, i) => /* @__PURE__ */ React2.createElement(
1463
+ TreeRow,
1464
+ {
1465
+ key: v.node.path,
1466
+ node: v.node,
1467
+ depth: v.depth,
1468
+ expanded: expandedDirs.has(v.node.path),
1469
+ excluded,
1470
+ isFocused: focus === "tree" && treeWindowStart + i === treeIdx
1471
+ }
1472
+ ))
1473
+ ), /* @__PURE__ */ React2.createElement(
1474
+ Box2,
1475
+ {
1476
+ flexDirection: "column",
1477
+ width: "50%",
1478
+ paddingLeft: 1,
1479
+ borderStyle: "round",
1480
+ borderColor: focus === "diff" ? "cyan" : "gray"
1481
+ },
1482
+ /* @__PURE__ */ React2.createElement(Text2, { bold: true, color: focus === "diff" ? "cyan" : void 0 }, "Diff vs .baliseignore"),
1483
+ diff.length === 0 ? /* @__PURE__ */ React2.createElement(Text2, { dimColor: true }, "(no changes)") : diffWindow.map((d, i) => /* @__PURE__ */ React2.createElement(
1484
+ DiffRow,
1485
+ {
1486
+ key: d.pattern,
1487
+ entry: d,
1488
+ isFocused: focus === "diff" && diffWindowStart + i === safeDiffIdx
1489
+ }
1490
+ ))
1491
+ )), /* @__PURE__ */ React2.createElement(Box2, { marginTop: 1 }, /* @__PURE__ */ React2.createElement(Text2, { dimColor: true }, stats.added > 0 || stats.removed > 0 ? `${stats.added} additions, ${stats.removed} removals` : "(no changes vs current .baliseignore)")));
1492
+ }
1493
+ function TreeRow(props) {
1494
+ const { node, depth, expanded, excluded, isFocused } = props;
1495
+ const indent = " ".repeat(Math.max(depth, 0));
1496
+ let label;
1497
+ let dim = false;
1498
+ if (node.isBan) {
1499
+ label = `\u{1F512} ${node.name} [ban]`;
1500
+ dim = true;
1501
+ } else if (node.isDir) {
1502
+ const allIncluded = dirAllIncluded(node, excluded);
1503
+ const arrow = expanded ? "\u25BE" : "\u25B8";
1504
+ const box = allIncluded ? "\u2611" : "\u2610";
1505
+ label = `${arrow} ${box} ${node.name}/`;
1506
+ } else {
1507
+ const box = excluded.has(node.path) ? "\u2610" : "\u2611";
1508
+ const loc = node.locCount > 0 ? ` (${node.locCount} LoC)` : "";
1509
+ const flags = [];
1510
+ if (node.isLarge) flags.push("large");
1511
+ if (node.isDefault) flags.push("default");
1512
+ const flagStr = flags.length > 0 ? ` [${flags.join(",")}]` : "";
1513
+ label = `${box} ${node.name}${loc}${flagStr}`;
1514
+ }
1515
+ return /* @__PURE__ */ React2.createElement(Text2, { color: isFocused ? "cyan" : void 0, dimColor: dim }, isFocused ? "\u25B8 " : " ", indent, label);
1516
+ }
1517
+ function DiffRow(props) {
1518
+ const { entry, isFocused } = props;
1519
+ const sigil = entry.kind === "added" ? "+" : entry.kind === "removed" ? "-" : " ";
1520
+ const color = isFocused ? "cyan" : entry.kind === "added" ? "green" : entry.kind === "removed" ? "red" : void 0;
1521
+ return /* @__PURE__ */ React2.createElement(Text2, { color }, isFocused ? "\u25B8 " : " ", sigil, " ", entry.pattern);
1522
+ }
1523
+ function ConfirmModal(props) {
1524
+ const { stats, excludedSize } = props;
1525
+ return /* @__PURE__ */ React2.createElement(
1526
+ Box2,
1527
+ {
1528
+ flexDirection: "column",
1529
+ padding: 1,
1530
+ borderStyle: "double",
1531
+ borderColor: "cyan"
1532
+ },
1533
+ /* @__PURE__ */ React2.createElement(Text2, { bold: true }, "Save changes to .baliseignore?"),
1534
+ /* @__PURE__ */ React2.createElement(Box2, { marginTop: 1, flexDirection: "row", gap: 2 }, /* @__PURE__ */ React2.createElement(Text2, { color: "green" }, "+", stats.added, " added"), /* @__PURE__ */ React2.createElement(Text2, { color: "red" }, "-", stats.removed, " removed"), /* @__PURE__ */ React2.createElement(Text2, { dimColor: true }, stats.unchanged, " unchanged")),
1535
+ /* @__PURE__ */ React2.createElement(Box2, { marginTop: 1 }, /* @__PURE__ */ React2.createElement(Text2, { dimColor: true }, excludedSize, " total patterns in resulting file")),
1536
+ /* @__PURE__ */ React2.createElement(Box2, { marginTop: 1 }, /* @__PURE__ */ React2.createElement(Text2, null, "[y] Confirm [n] Back to edit [q/Esc] Cancel without saving"))
1537
+ );
944
1538
  }
945
1539
 
946
1540
  // src/commands/init.ts
@@ -966,6 +1560,13 @@ async function runInit(opts) {
966
1560
  }
967
1561
  throw err;
968
1562
  }
1563
+ const existingConfig = await readConfig(cwd);
1564
+ if (existingConfig) {
1565
+ process.stdout.write(
1566
+ `Re-configuring ${existingConfig.repo.owner_login}/${existingConfig.repo.slug}.
1567
+ `
1568
+ );
1569
+ }
969
1570
  const apiUrl = DEFAULT_API_URL;
970
1571
  const client = new ApiClient({
971
1572
  apiUrl,
@@ -994,13 +1595,14 @@ async function runInit(opts) {
994
1595
  const sortedRepos = [...repos].sort(
995
1596
  (a, b) => `${a.owner_login}/${a.slug}`.localeCompare(`${b.owner_login}/${b.slug}`)
996
1597
  );
997
- const defaultSlug = path3.basename(cwd).toLowerCase();
1598
+ const defaultSlug = path5.basename(cwd).toLowerCase();
998
1599
  const result = await new Promise((resolve) => {
999
1600
  const app = render(
1000
- React2.createElement(InitPicker, {
1601
+ React3.createElement(InitPicker, {
1001
1602
  defaultSlug,
1002
1603
  ownerships,
1003
1604
  repos: sortedRepos,
1605
+ currentRepoId: existingConfig?.repo.id,
1004
1606
  onDone: (r) => {
1005
1607
  resolve(r);
1006
1608
  app.unmount();
@@ -1040,160 +1642,143 @@ async function runInit(opts) {
1040
1642
  await ensureGitignored(cwd);
1041
1643
  process.stdout.write(
1042
1644
  `Linked ${cfg.repo.owner_login}/${cfg.repo.slug} \u2192 .balise/config
1645
+ `
1646
+ );
1647
+ const [scanResult, currentBaliseignore] = await Promise.all([
1648
+ scan(cwd),
1649
+ readBaliseignore(cwd)
1650
+ ]);
1651
+ const treeResult = await new Promise((resolve) => {
1652
+ const app = render(
1653
+ React3.createElement(IgnoreTree, {
1654
+ scan: scanResult,
1655
+ currentBaliseignore,
1656
+ onDone: (r) => {
1657
+ resolve(r);
1658
+ app.unmount();
1659
+ }
1660
+ })
1661
+ );
1662
+ });
1663
+ if (treeResult.action === "cancel") {
1664
+ process.stderr.write(
1665
+ "Ignore configuration cancelled. .baliseignore unchanged.\n"
1666
+ );
1667
+ process.exit(1);
1668
+ }
1669
+ await writeBaliseignore(cwd, treeResult.patterns);
1670
+ process.stdout.write(
1671
+ `Wrote ${treeResult.patterns.length} patterns to .baliseignore
1043
1672
  `
1044
1673
  );
1045
1674
  }
1046
1675
 
1047
1676
  // src/commands/sync.ts
1048
- import React4 from "react";
1049
- import { render as render2 } from "ink";
1677
+ import readline from "readline/promises";
1050
1678
 
1051
- // src/ui/SyncProgress.tsx
1052
- import React3, { useEffect, useState as useState2 } from "react";
1053
- import { Box as Box2, Text as Text2, useApp as useApp2 } from "ink";
1054
- import Spinner from "ink-spinner";
1055
- var QUEUED_MESSAGES = [
1056
- "Waiting for an agent willing to accept the job\u2026",
1057
- "Your request is in line. An agent will be assigned once one stops pretending to be busy.",
1058
- 'Looking for an available agent. Most are currently "in a meeting."',
1059
- "Queued. An agent will pick this up as soon as they finish rereading the spec.",
1060
- "Your task is waiting for an agent with the right vibes.",
1061
- "Negotiating with an agent. They want a raise.",
1062
- "All agents are currently on strike. Sending in a scab.",
1063
- "Waiting for an agent to finish their coffee \u2615",
1064
- "An agent saw your task and walked away slowly. Finding another one.",
1065
- "Your task is being passed around like a hot potato. Someone will catch it eventually.",
1066
- "Agents are currently arguing about who has to do this one.",
1067
- "Waiting for an agent brave enough to open your repo.",
1068
- "Your task is sitting in the agent break room. Someone will notice it soon.",
1069
- "An agent was assigned, but they ghosted. Recruiting a replacement.",
1070
- "Paging an agent. Please hold while we bribe one.",
1071
- "Queued. Waiting for an agent with enough context window to care.",
1072
- "An agent is spinning up. They're doing their stretches.",
1073
- "Looking for an agent whose system prompt allows this.",
1074
- "Waiting for a worker. Their last task traumatized them.",
1075
- "Your task is in queue. Agents are busy arguing about tabs vs spaces.",
1076
- "Finding an agent\u2026",
1077
- "Found one. They said no.",
1078
- "Finding another agent\u2026",
1079
- "This one looks promising.",
1080
- "Negotiating\u2026"
1081
- ];
1082
- var RUNNING_MESSAGES = [
1083
- "An agent is on it. Try not to watch.",
1084
- "Your task has an owner. They seem focused.",
1085
- "An agent accepted the job. Surprisingly.",
1086
- "Working. The agent asked not to be disturbed.",
1087
- "An agent is handling it. They'll let us know if they need anything.",
1088
- "Progress is happening. Allegedly.",
1089
- "An agent rolled up their sleeves. Mostly for show.",
1090
- "Hard at work. The agent has opened seventeen browser tabs.",
1091
- "Your agent is locked in. Do not make eye contact.",
1092
- "An agent is typing furiously. Some of it might be relevant.",
1093
- "Working. The agent has entered the zone. And also a Wikipedia rabbit hole.",
1094
- "An agent is deep in thought. Or buffering. Hard to tell.",
1095
- "Your task is being handled. The agent is muttering to themselves.",
1096
- "Cooking. \u{1F9D1}\u200D\u{1F373}",
1097
- "The agent is working. They've asked for snacks.",
1098
- "An agent is doing the thing. Please clap.",
1099
- "Agent is crunching tokens.",
1100
- "Working. The agent is reasoning about your reasoning.",
1101
- "An agent is in a tool-use loop. Going well so far.",
1102
- "Thinking hard. The agent just discovered your codebase has opinions.",
1103
- "Working. The agent is negotiating with your linter.",
1104
- "An agent is running. They promise it's not an infinite loop.",
1105
- "The agent is writing, deleting, and rewriting. Classic.",
1106
- "An agent picked it up.",
1107
- "They're reading the task\u2026",
1108
- "Looks like they have a plan.",
1109
- "Executing\u2026",
1110
- "Still going. Confidently.",
1111
- "Almost there. (They always say that.)"
1112
- ];
1113
- function pickInitialIndex(len) {
1114
- return Math.floor(Math.random() * len);
1679
+ // src/logger.ts
1680
+ import { promises as fs5 } from "fs";
1681
+ import path6 from "path";
1682
+ import os2 from "os";
1683
+ var APP_DIR2 = ".balise";
1684
+ var FILENAME3 = "balise.log";
1685
+ function logPath() {
1686
+ const override = process.env.BALISE_LOG_FILE;
1687
+ if (override && override.length > 0) return override;
1688
+ return path6.join(os2.homedir(), APP_DIR2, FILENAME3);
1115
1689
  }
1116
- function SyncProgress(props) {
1117
- const { exit } = useApp2();
1118
- const [status, setStatus] = useState2(null);
1119
- const [error, setError] = useState2(null);
1120
- const [startedAt] = useState2(() => Date.now());
1121
- const [queuedIdx, setQueuedIdx] = useState2(
1122
- () => pickInitialIndex(QUEUED_MESSAGES.length)
1123
- );
1124
- const [runningIdx, setRunningIdx] = useState2(
1125
- () => pickInitialIndex(RUNNING_MESSAGES.length)
1126
- );
1127
- useEffect(() => {
1128
- let cancelled = false;
1129
- const tick = async () => {
1130
- try {
1131
- const s = await props.client.getJson(
1132
- `/v1/syncs/${props.syncId}`
1133
- );
1134
- if (cancelled) return;
1135
- setStatus(s);
1136
- if (s.status === "done") {
1137
- props.onDone(true);
1138
- exit();
1139
- return;
1140
- }
1141
- if (s.status === "failed") {
1142
- props.onDone(false);
1143
- exit();
1144
- return;
1145
- }
1146
- } catch (err) {
1147
- if (cancelled) return;
1148
- setError(err.message);
1149
- props.onDone(false);
1150
- exit();
1151
- return;
1152
- }
1153
- setTimeout(tick, props.pollIntervalMs ?? 1e3);
1154
- };
1155
- void tick();
1156
- return () => {
1157
- cancelled = true;
1158
- };
1159
- }, []);
1160
- useEffect(() => {
1161
- const period = props.messageRotationMs ?? 1e4;
1162
- const h = setInterval(() => {
1163
- setQueuedIdx((i) => (i + 1) % QUEUED_MESSAGES.length);
1164
- setRunningIdx((i) => (i + 1) % RUNNING_MESSAGES.length);
1165
- }, period);
1166
- return () => clearInterval(h);
1167
- }, [props.messageRotationMs]);
1168
- if (error) {
1169
- return /* @__PURE__ */ React3.createElement(Text2, { color: "red" }, "\u2717 Failed");
1170
- }
1171
- 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"));
1173
- }
1174
- if (status.status === "done") {
1175
- return /* @__PURE__ */ React3.createElement(Text2, { color: "green" }, "\u2713 Done");
1176
- }
1177
- if (status.status === "failed") {
1178
- return /* @__PURE__ */ React3.createElement(Text2, { color: "red" }, "\u2717 Failed");
1179
- }
1180
- const message = status.status === "queued" ? QUEUED_MESSAGES[queuedIdx] : RUNNING_MESSAGES[runningIdx];
1181
- 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);
1690
+ function formatError(err) {
1691
+ if (err instanceof Error) {
1692
+ const stack = err.stack ?? `${err.name}: ${err.message}`;
1693
+ const extras = [];
1694
+ const cause = err.cause;
1695
+ if (cause) extras.push(` caused by: ${formatError(cause)}`);
1696
+ return extras.length ? `${stack}
1697
+ ${extras.join("\n")}` : stack;
1698
+ }
1699
+ if (typeof err === "string") return err;
1700
+ try {
1701
+ return JSON.stringify(err);
1702
+ } catch {
1703
+ return String(err);
1704
+ }
1705
+ }
1706
+ async function logError(context, err) {
1707
+ try {
1708
+ const p = logPath();
1709
+ await fs5.mkdir(path6.dirname(p), { recursive: true, mode: 448 });
1710
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
1711
+ const line = `[${ts}] ERROR ${context}
1712
+ ${formatError(err)}
1713
+
1714
+ `;
1715
+ await fs5.appendFile(p, line, { mode: 384 });
1716
+ } catch {
1717
+ }
1184
1718
  }
1185
1719
 
1186
1720
  // src/commands/sync.ts
1721
+ function buildSyncQuery(opts) {
1722
+ const q = { commit_sha: opts.commitSha };
1723
+ if (opts.branch) q.branch = opts.branch;
1724
+ q.force = opts.force ? "true" : "false";
1725
+ q.dry_run = opts.dryRun ? "true" : "false";
1726
+ if (opts.base) q.base = opts.base;
1727
+ return q;
1728
+ }
1729
+ var INIT_AUTOLAUNCH_MESSAGE = "Repo not initialized, launching balise init...";
1730
+ function drainStdinBuffer(stdin) {
1731
+ if (stdin !== process.stdin) return;
1732
+ const s = stdin;
1733
+ if (typeof s.read !== "function") return;
1734
+ while (s.read() !== null) {
1735
+ }
1736
+ }
1737
+ async function confirmRetryWithForce(opts) {
1738
+ const stderr = opts.stderr ?? process.stderr;
1739
+ const synced = opts.body.synced_at ?? "unknown time";
1740
+ stderr.write(
1741
+ `commit already synced at tag ${opts.body.tag} (synced at ${synced}), re-run ?
1742
+ `
1743
+ );
1744
+ if (opts.autoConfirm) {
1745
+ return false;
1746
+ }
1747
+ const input = opts.stdin ?? process.stdin;
1748
+ drainStdinBuffer(input);
1749
+ const rl = readline.createInterface({
1750
+ input,
1751
+ output: opts.stdout ?? process.stdout
1752
+ });
1753
+ let answer;
1754
+ try {
1755
+ answer = (await rl.question("re-run ? (y/N) ")).trim();
1756
+ } finally {
1757
+ rl.close();
1758
+ }
1759
+ return /^y(es)?$/i.test(answer);
1760
+ }
1761
+ function formatDryRunSummary(body) {
1762
+ 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}` : "");
1763
+ return `Sync would use: base=${body.base_dolt_ref} (${body.base_kind}) / ${fromDiff}`;
1764
+ }
1765
+ function formatAcceptedSummary(body) {
1766
+ return `Sync queued. Track it here: ${body.web_url}`;
1767
+ }
1187
1768
  async function runSync(opts) {
1188
1769
  try {
1189
1770
  await runSyncInner(opts);
1190
1771
  } catch (err) {
1772
+ await logError("sync", err);
1191
1773
  if (err instanceof ApiUnreachableError) {
1192
1774
  process.stderr.write(
1193
1775
  "Cannot reach the Balise service. Please try again in a moment.\n"
1194
1776
  );
1195
1777
  } else {
1196
- process.stderr.write("Something went wrong. Please try again.\n");
1778
+ process.stderr.write(
1779
+ `Something went wrong. Please try again. (details: ${logPath()})
1780
+ `
1781
+ );
1197
1782
  }
1198
1783
  process.exit(1);
1199
1784
  }
@@ -1222,7 +1807,8 @@ async function runSyncInner(opts) {
1222
1807
  }
1223
1808
  let cfg = await readConfig(cwd);
1224
1809
  if (!cfg) {
1225
- process.stderr.write("No .balise/config \u2014 running `balise init` first.\n");
1810
+ process.stderr.write(`${INIT_AUTOLAUNCH_MESSAGE}
1811
+ `);
1226
1812
  await runInit({ ...opts, cwd });
1227
1813
  cfg = await readConfig(cwd);
1228
1814
  if (!cfg) {
@@ -1243,65 +1829,131 @@ async function runSyncInner(opts) {
1243
1829
  `Packing ${cfg.repo.owner_login}/${cfg.repo.slug} at HEAD\u2026
1244
1830
  `
1245
1831
  );
1246
- const { stream, commitSha, branch } = await gitBundle({ cwd });
1247
- let accepted;
1832
+ const submit = async (force) => {
1833
+ const { stream, commitSha, branch } = await gitBundle({ cwd });
1834
+ const query = buildSyncQuery({
1835
+ commitSha,
1836
+ branch,
1837
+ force,
1838
+ base: opts.base,
1839
+ dryRun: opts.dryRun ?? false
1840
+ });
1841
+ return uploadOnce(client, cfg.repo.owner_login, cfg.repo.slug, stream, query);
1842
+ };
1843
+ let outcome;
1248
1844
  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
- );
1845
+ outcome = await submit(opts.force ?? false);
1256
1846
  } catch (err) {
1257
- if (err instanceof NotAuthenticatedError) {
1258
- process.stderr.write("Not logged in \u2014 run `balise login`.\n");
1847
+ handleUploadError(err);
1848
+ process.exit(1);
1849
+ }
1850
+ if (outcome.kind === "conflict") {
1851
+ const proceedForce = await confirmRetryWithForce({
1852
+ body: outcome.body,
1853
+ autoConfirm: opts.autoConfirm ?? false
1854
+ });
1855
+ if (!proceedForce) {
1259
1856
  process.exit(1);
1260
1857
  }
1261
- if (err instanceof ApiUnreachableError) {
1262
- process.stderr.write(
1263
- "Cannot reach the Balise service. Please try again in a moment.\n"
1264
- );
1858
+ try {
1859
+ outcome = await submit(true);
1860
+ } catch (err) {
1861
+ handleUploadError(err);
1265
1862
  process.exit(1);
1266
1863
  }
1267
- process.stderr.write(
1268
- "Something went wrong while uploading. Please try again.\n"
1269
- );
1864
+ }
1865
+ if (outcome.kind === "invalid_base") {
1866
+ process.stderr.write(`invalid base ref: ${opts.base}
1867
+ `);
1270
1868
  process.exit(1);
1271
1869
  }
1272
- const result = await new Promise((resolve) => {
1273
- const app = render2(
1274
- React4.createElement(SyncProgress, {
1275
- client,
1276
- syncId: accepted.sync_id,
1277
- onDone: (ok) => {
1278
- resolve(ok);
1279
- app.unmount();
1280
- }
1281
- })
1870
+ if (outcome.kind === "dry_run") {
1871
+ process.stdout.write(formatDryRunSummary(outcome.body) + "\n");
1872
+ process.exit(0);
1873
+ }
1874
+ if (outcome.kind !== "accepted") {
1875
+ process.exit(1);
1876
+ }
1877
+ process.stdout.write(formatAcceptedSummary(outcome.body) + "\n");
1878
+ process.exit(0);
1879
+ }
1880
+ async function uploadOnce(client, owner, slug, stream, query) {
1881
+ const raw = await client.uploadBundleRaw(
1882
+ `/v1/repos/${owner}/${slug}/sync`,
1883
+ {
1884
+ bundleStream: stream,
1885
+ query
1886
+ }
1887
+ );
1888
+ if (raw.status === 202) {
1889
+ return { kind: "accepted", body: JSON.parse(raw.text) };
1890
+ }
1891
+ if (raw.status === 200) {
1892
+ return { kind: "dry_run", body: JSON.parse(raw.text) };
1893
+ }
1894
+ if (raw.status === 409) {
1895
+ const parsed = JSON.parse(raw.text);
1896
+ const body = "detail" in parsed && parsed.detail ? parsed.detail : parsed;
1897
+ return { kind: "conflict", body };
1898
+ }
1899
+ if (raw.status === 422) {
1900
+ let detail;
1901
+ try {
1902
+ const parsed = JSON.parse(raw.text);
1903
+ if (typeof parsed.detail === "string") detail = parsed.detail;
1904
+ } catch {
1905
+ }
1906
+ if (detail === "invalid_base_ref") return { kind: "invalid_base" };
1907
+ throw new ApiError(raw.status, raw.text);
1908
+ }
1909
+ throw new ApiError(raw.status, raw.text);
1910
+ }
1911
+ function handleUploadError(err) {
1912
+ if (err instanceof NotAuthenticatedError) {
1913
+ process.stderr.write("Not logged in \u2014 run `balise login`.\n");
1914
+ return;
1915
+ }
1916
+ if (err instanceof ApiUnreachableError) {
1917
+ process.stderr.write(
1918
+ "Cannot reach the Balise service. Please try again in a moment.\n"
1282
1919
  );
1283
- });
1284
- process.exit(result ? 0 : 1);
1920
+ return;
1921
+ }
1922
+ process.stderr.write(
1923
+ "Something went wrong while uploading. Please try again.\n"
1924
+ );
1285
1925
  }
1286
1926
 
1287
1927
  // src/index.ts
1928
+ async function withLog(name, fn) {
1929
+ try {
1930
+ await fn();
1931
+ } catch (err) {
1932
+ await logError(name, err);
1933
+ process.stderr.write(
1934
+ `Something went wrong (${name}). Details: ${logPath()}
1935
+ `
1936
+ );
1937
+ process.exit(1);
1938
+ }
1939
+ }
1288
1940
  var SUPABASE_URL = process.env.BALISE_SUPABASE_URL ?? "https://api.balise.dev";
1289
1941
  var loginCmd = defineCommand({
1290
1942
  meta: { name: "login", description: "Authenticate via OAuth (PKCE loopback)." },
1291
1943
  async run() {
1292
- await runLogin({ supabaseUrl: SUPABASE_URL });
1944
+ await withLog("login", () => runLogin({ supabaseUrl: SUPABASE_URL }));
1293
1945
  }
1294
1946
  });
1295
1947
  var logoutCmd = defineCommand({
1296
1948
  meta: { name: "logout", description: "Clear stored credentials." },
1297
1949
  async run() {
1298
- await runLogout();
1950
+ await withLog("logout", () => runLogout());
1299
1951
  }
1300
1952
  });
1301
1953
  var whoamiCmd = defineCommand({
1302
1954
  meta: { name: "whoami", description: "Show current authenticated user." },
1303
1955
  async run() {
1304
- await runWhoami({ supabaseUrl: SUPABASE_URL });
1956
+ await withLog("whoami", () => runWhoami({ supabaseUrl: SUPABASE_URL }));
1305
1957
  }
1306
1958
  });
1307
1959
  var initCmd = defineCommand({
@@ -1310,7 +1962,7 @@ var initCmd = defineCommand({
1310
1962
  description: "Link or create a Balise repo and write .balise/config."
1311
1963
  },
1312
1964
  async run() {
1313
- await runInit({ supabaseUrl: SUPABASE_URL });
1965
+ await withLog("init", () => runInit({ supabaseUrl: SUPABASE_URL }));
1314
1966
  }
1315
1967
  });
1316
1968
  var syncCmd = defineCommand({
@@ -1318,8 +1970,36 @@ var syncCmd = defineCommand({
1318
1970
  name: "sync",
1319
1971
  description: "Tarball current repo \u2192 upload \u2192 poll extraction progress."
1320
1972
  },
1321
- async run() {
1322
- await runSync({ supabaseUrl: SUPABASE_URL });
1973
+ args: {
1974
+ yes: {
1975
+ type: "boolean",
1976
+ alias: "y",
1977
+ description: "Skip sync confirmations (warning prompt + future 409 retry).",
1978
+ default: false
1979
+ },
1980
+ force: {
1981
+ type: "boolean",
1982
+ description: "Bypass idempotency 409 + resume-reuse: re-tag from scratch and re-resolve base.",
1983
+ default: false
1984
+ },
1985
+ base: {
1986
+ type: "string",
1987
+ description: "Explicit base balise ref (branch/tag/commit) to start from. Validated server-side."
1988
+ },
1989
+ "dry-run": {
1990
+ type: "boolean",
1991
+ description: "Preview the resolution without uploading the bundle or enqueuing the worker.",
1992
+ default: false
1993
+ }
1994
+ },
1995
+ async run({ args }) {
1996
+ await runSync({
1997
+ supabaseUrl: SUPABASE_URL,
1998
+ autoConfirm: Boolean(args.yes),
1999
+ force: Boolean(args.force),
2000
+ base: typeof args.base === "string" && args.base.length > 0 ? args.base : void 0,
2001
+ dryRun: Boolean(args["dry-run"])
2002
+ });
1323
2003
  }
1324
2004
  });
1325
2005
  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.2.0",
4
4
  "description": "Balise CLI — push codebase to Balise backend for spec extraction.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,12 +22,12 @@
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",
30
+ "picomatch": "^4.0.2",
31
31
  "react": "^18.3.1",
32
32
  "tar": "^7.4.3",
33
33
  "undici": "^6.19.8"
@@ -35,8 +35,10 @@
35
35
  "devDependencies": {
36
36
  "@types/ini": "^4.1.1",
37
37
  "@types/node": "^20.14.0",
38
+ "@types/picomatch": "^3.0.2",
38
39
  "@types/react": "^18.3.3",
39
40
  "@types/tar": "^6.1.13",
41
+ "ink-testing-library": "^4.0.0",
40
42
  "tsup": "^8.2.4",
41
43
  "typescript": "^5.5.4",
42
44
  "vitest": "^2.0.5"