@balise.dev/cli 0.2.0 → 0.3.1

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 +571 -207
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -913,84 +913,297 @@ ${credentialsHelpMessage()}
913
913
  }
914
914
 
915
915
  // src/ui/InitPicker.tsx
916
- import React, { useState } from "react";
916
+ import React, { useMemo, useState } from "react";
917
917
  import { Box, Text, useApp, useInput } from "ink";
918
- import { Select, TextInput } from "@inkjs/ui";
918
+ var ACCENT = "#E8A947";
919
+ var OK = "#9db89a";
920
+ var WIDTH = 56;
921
+ var RESERVED_SLUGS = /* @__PURE__ */ new Set(["settings"]);
919
922
  function normalizeSlug(raw) {
920
- return raw.toLowerCase().replace(/[^a-z0-9-]/g, "");
923
+ return raw.toLowerCase().replace(/[^a-z0-9_-]/g, "");
921
924
  }
925
+ function slugStatus(slug, ownerId, repos) {
926
+ if (slug.length === 0) return "empty";
927
+ if (slug.length < 2) return "too-short";
928
+ if (slug.length > 40) return "too-long";
929
+ if (RESERVED_SLUGS.has(slug)) return "reserved";
930
+ if (ownerId && repos.some((r) => r.owner_id === ownerId && r.slug === slug)) {
931
+ return "taken";
932
+ }
933
+ return "ok";
934
+ }
935
+ var FILTER_CHAR = /^[\w./-]$/;
922
936
  function InitPicker(props) {
923
937
  const { exit } = useApp();
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
929
- );
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 });
938
+ const { repos, ownerships } = props;
939
+ const initialCursor = useMemo(() => {
940
+ if (props.currentRepoId) {
941
+ const i = repos.findIndex((r) => r.id === props.currentRepoId);
942
+ if (i >= 0) return i + 1;
943
+ }
944
+ return 0;
945
+ }, [repos, props.currentRepoId]);
946
+ const [mode, setMode] = useState("list");
947
+ const [filter, setFilter] = useState("");
948
+ const [listCursor, setListCursor] = useState(initialCursor);
949
+ const [ownerCursor, setOwnerCursor] = useState(0);
950
+ const [owner, setOwner] = useState(void 0);
951
+ const [name, setName] = useState(() => normalizeSlug(props.defaultSlug));
952
+ const [visibility, setVisibility] = useState("private");
953
+ const [field, setField] = useState("name");
954
+ const visibleRepos = useMemo(() => {
955
+ const f = filter.toLowerCase();
956
+ if (!f) return repos;
957
+ return repos.filter(
958
+ (r) => `${r.owner_login}/${r.slug}`.toLowerCase().includes(f)
959
+ );
960
+ }, [repos, filter]);
961
+ const maxListIdx = visibleRepos.length;
962
+ const safeCursor = Math.max(0, Math.min(listCursor, maxListIdx));
963
+ const status = slugStatus(name, owner?.id, repos);
964
+ useInput((input, key) => {
965
+ if (mode === "list") {
966
+ if (key.upArrow) {
967
+ setListCursor((c) => Math.max(0, Math.min(c, maxListIdx) - 1));
968
+ return;
969
+ }
970
+ if (key.downArrow) {
971
+ setListCursor((c) => Math.min(maxListIdx, c + 1));
972
+ return;
973
+ }
974
+ if (key.return) {
975
+ if (safeCursor === 0) {
976
+ setMode("owner");
977
+ setOwnerCursor(0);
978
+ } else {
979
+ const repo = visibleRepos[safeCursor - 1];
980
+ if (repo) {
981
+ props.onDone({ action: "link", repo });
982
+ exit();
983
+ }
984
+ }
985
+ return;
986
+ }
987
+ if (key.escape) {
988
+ if (filter) {
989
+ setFilter("");
990
+ setListCursor(0);
991
+ } else {
992
+ props.onDone({ action: "cancel" });
993
+ exit();
994
+ }
995
+ return;
996
+ }
997
+ if ((key.backspace || key.delete) && filter) {
998
+ setFilter((f) => f.slice(0, -1));
999
+ setListCursor(0);
1000
+ return;
1001
+ }
1002
+ if (!key.ctrl && !key.meta && input && FILTER_CHAR.test(input)) {
1003
+ setFilter((f) => f + input);
1004
+ setListCursor(0);
1005
+ }
1006
+ return;
1007
+ }
1008
+ if (mode === "owner") {
1009
+ if (key.upArrow) {
1010
+ setOwnerCursor((c) => Math.max(0, c - 1));
1011
+ return;
1012
+ }
1013
+ if (key.downArrow) {
1014
+ setOwnerCursor((c) => Math.min(ownerships.length - 1, c + 1));
1015
+ return;
1016
+ }
1017
+ if (key.return) {
1018
+ const chosen = ownerships[ownerCursor];
1019
+ if (chosen) {
1020
+ setOwner(chosen);
1021
+ setField("name");
1022
+ setMode("detail");
1023
+ }
1024
+ return;
1025
+ }
1026
+ if (key.escape) {
1027
+ setMode("list");
1028
+ }
1029
+ return;
942
1030
  }
943
- exit();
944
- };
945
- useInput((_input, key) => {
946
1031
  if (key.escape) {
947
- props.onDone({ action: "cancel" });
948
- exit();
1032
+ if (field === "visibility") setField("name");
1033
+ else setMode("owner");
949
1034
  return;
950
1035
  }
951
- if (key.tab) {
952
- setField((f) => {
953
- if (f === "slug") return "owner";
954
- if (f === "owner") return props.repos.length > 0 ? "link" : "slug";
955
- return "slug";
956
- });
1036
+ if (field === "name") {
1037
+ if (key.return) {
1038
+ if (status === "ok") setField("visibility");
1039
+ return;
1040
+ }
1041
+ if (key.backspace || key.delete) {
1042
+ setName((n) => n.slice(0, -1));
1043
+ return;
1044
+ }
1045
+ if (!key.ctrl && !key.meta && input) {
1046
+ const accepted = input.replace(/[^a-z0-9_-]/g, "");
1047
+ if (accepted) setName((n) => n + accepted);
1048
+ }
1049
+ return;
1050
+ }
1051
+ if (key.leftArrow || key.rightArrow || input === " ") {
1052
+ setVisibility((v) => v === "private" ? "public" : "private");
1053
+ return;
1054
+ }
1055
+ if (key.upArrow) {
1056
+ setField("name");
957
1057
  return;
958
1058
  }
959
1059
  if (key.return) {
960
- submit();
1060
+ if (status !== "ok") {
1061
+ setField("name");
1062
+ return;
1063
+ }
1064
+ props.onDone({
1065
+ action: "create",
1066
+ slug: name,
1067
+ owner,
1068
+ visibility
1069
+ });
1070
+ exit();
961
1071
  }
962
1072
  });
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,
1073
+ if (mode === "owner") {
1074
+ return /* @__PURE__ */ React.createElement(OwnerStep, { ownerships, cursor: ownerCursor });
1075
+ }
1076
+ if (mode === "detail" && owner) {
1077
+ return /* @__PURE__ */ React.createElement(
1078
+ DetailStep,
1079
+ {
1080
+ owner,
1081
+ name,
1082
+ visibility,
1083
+ field,
1084
+ status
1085
+ }
1086
+ );
1087
+ }
1088
+ return /* @__PURE__ */ React.createElement(
1089
+ ListStep,
1090
+ {
1091
+ repos: visibleRepos,
1092
+ total: repos.length,
1093
+ filter,
1094
+ cursor: safeCursor,
1095
+ currentRepoId: props.currentRepoId
1096
+ }
1097
+ );
1098
+ }
1099
+ function ListStep(props) {
1100
+ const { repos, total, filter, cursor } = props;
1101
+ const sub = filter ? `${repos.length}/${total} repos \xB7 filter: ${filter}` : `${total} repos \xB7 type to filter`;
1102
+ return /* @__PURE__ */ React.createElement(Box, { flexDirection: "column", width: WIDTH, paddingX: 1 }, /* @__PURE__ */ React.createElement(Text, { bold: true }, "Select a repository"), /* @__PURE__ */ React.createElement(Text, { dimColor: true }, sub), /* @__PURE__ */ React.createElement(
1103
+ Row,
973
1104
  {
974
- defaultValue: slug,
975
- placeholder: "repo-slug",
976
- isDisabled: field !== "slug",
977
- onChange: (v) => setSlug(normalizeSlug(v))
1105
+ selected: cursor === 0,
1106
+ label: "+ Initialize new repository",
1107
+ labelColor: "white",
1108
+ kind: "here"
978
1109
  }
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,
1110
+ ), repos.length > 0 ? /* @__PURE__ */ React.createElement(Text, { color: "gray" }, "\u2500".repeat(WIDTH - 4)) : null, repos.map((r, i) => /* @__PURE__ */ React.createElement(
1111
+ Row,
981
1112
  {
982
- options: ownerOptions,
983
- defaultValue: ownerId,
984
- onChange: setOwnerId
1113
+ key: r.id,
1114
+ selected: cursor === i + 1,
1115
+ label: `${r.owner_login}/${r.slug}`,
1116
+ suffix: r.id === props.currentRepoId ? " (current)" : void 0,
1117
+ vis: r.visibility,
1118
+ kind: r.owner_type
985
1119
  }
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,
1120
+ )), /* @__PURE__ */ React.createElement(Box, { marginTop: 1 }, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "\u2191\u2193 move \xB7 \u23CE select \xB7 type to filter \xB7 esc ", filter ? "clear" : "quit")));
1121
+ }
1122
+ function OwnerStep(props) {
1123
+ return /* @__PURE__ */ React.createElement(Box, { flexDirection: "column", width: WIDTH, paddingX: 1 }, /* @__PURE__ */ React.createElement(Text, { bold: true }, "New repository \xB7 choose an owner"), /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "step 1 of 3 \xB7 owner \u2192 name \u2192 visibility"), /* @__PURE__ */ React.createElement(
1124
+ Box,
1125
+ {
1126
+ flexDirection: "column",
1127
+ borderStyle: "round",
1128
+ borderColor: ACCENT,
1129
+ paddingX: 1,
1130
+ marginTop: 1
1131
+ },
1132
+ props.ownerships.length === 0 ? /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "(no owners available)") : props.ownerships.map((o, i) => /* @__PURE__ */ React.createElement(
1133
+ Row,
1134
+ {
1135
+ key: o.id,
1136
+ selected: props.cursor === i,
1137
+ label: o.login,
1138
+ kind: o.type
1139
+ }
1140
+ ))
1141
+ ), /* @__PURE__ */ React.createElement(Box, { marginTop: 1 }, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "\u2191\u2193 owner \xB7 \u23CE confirm & continue \xB7 esc back to list")));
1142
+ }
1143
+ function DetailStep(props) {
1144
+ const { owner, name, visibility, field, status } = props;
1145
+ const nameActive = field === "name";
1146
+ const taken = status === "taken";
1147
+ return /* @__PURE__ */ React.createElement(Box, { flexDirection: "column", width: WIDTH, paddingX: 1 }, /* @__PURE__ */ React.createElement(Text, { bold: true }, "New repository"), /* @__PURE__ */ React.createElement(Text, { dimColor: true }, owner.login, " \xB7 ", owner.type), /* @__PURE__ */ React.createElement(
1148
+ Box,
1149
+ {
1150
+ flexDirection: "column",
1151
+ borderStyle: "round",
1152
+ borderColor: ACCENT,
1153
+ paddingX: 1,
1154
+ marginTop: 1
1155
+ },
1156
+ /* @__PURE__ */ React.createElement(FieldRow, { state: "done", label: "owner" }, /* @__PURE__ */ React.createElement(Text, null, owner.login, " ", /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "\xB7 ", owner.type))),
1157
+ /* @__PURE__ */ React.createElement(FieldRow, { state: nameActive ? "active" : "done", label: "name" }, /* @__PURE__ */ React.createElement(Text, null, name, nameActive ? /* @__PURE__ */ React.createElement(Text, { color: ACCENT }, "\u2588") : null, nameActive && taken ? /* @__PURE__ */ React.createElement(Text, { color: ACCENT }, " \u26A0 existe d\xE9j\xE0") : null)),
1158
+ /* @__PURE__ */ React.createElement(
1159
+ FieldRow,
1160
+ {
1161
+ state: field === "visibility" ? "active" : "pending",
1162
+ label: "visibility"
1163
+ },
1164
+ /* @__PURE__ */ React.createElement(VisibilityPick, { value: visibility, dim: field !== "visibility" })
1165
+ )
1166
+ ), /* @__PURE__ */ React.createElement(
1167
+ StatusLine,
988
1168
  {
989
- options: repoOptions,
990
- defaultValue: repoId,
991
- onChange: setRepoId
1169
+ owner: owner.login,
1170
+ name,
1171
+ visibility,
1172
+ status
992
1173
  }
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")));
1174
+ ), /* @__PURE__ */ React.createElement(Box, { marginTop: 1 }, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "\u23CE next field \xB7 on visibility \u2190/\u2192 toggle \xB7 last \u23CE create \xB7 esc back")));
1175
+ }
1176
+ function Row(props) {
1177
+ const { selected } = props;
1178
+ return /* @__PURE__ */ React.createElement(Box, null, /* @__PURE__ */ React.createElement(Box, { width: 2, flexShrink: 0 }, /* @__PURE__ */ React.createElement(Text, { color: selected ? ACCENT : void 0 }, selected ? "\u276F " : " ")), /* @__PURE__ */ React.createElement(Box, { flexGrow: 1 }, /* @__PURE__ */ React.createElement(Text, { color: selected ? ACCENT : props.labelColor, wrap: "truncate-end" }, props.label, props.suffix ? /* @__PURE__ */ React.createElement(Text, { dimColor: true }, props.suffix) : null)), /* @__PURE__ */ React.createElement(Box, { width: 9, flexShrink: 0, justifyContent: "flex-end" }, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, props.vis ?? "")), /* @__PURE__ */ React.createElement(Box, { width: 5, flexShrink: 0, justifyContent: "flex-end" }, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, props.kind ?? "")));
1179
+ }
1180
+ function FieldRow(props) {
1181
+ const { state } = props;
1182
+ const marker = state === "done" ? "\u2713" : state === "active" ? "\u276F" : "\xB7";
1183
+ const markerColor = state === "done" ? OK : state === "active" ? ACCENT : "gray";
1184
+ return /* @__PURE__ */ React.createElement(Box, null, /* @__PURE__ */ React.createElement(Box, { width: 2, flexShrink: 0 }, /* @__PURE__ */ React.createElement(Text, { color: markerColor }, marker, " ")), /* @__PURE__ */ React.createElement(Box, { width: 11, flexShrink: 0 }, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, props.label)), /* @__PURE__ */ React.createElement(Box, { flexGrow: 1 }, props.children));
1185
+ }
1186
+ function VisibilityPick(props) {
1187
+ const opt = (v, label) => {
1188
+ const on = props.value === v;
1189
+ const color = props.dim ? void 0 : on ? ACCENT : "gray";
1190
+ return /* @__PURE__ */ React.createElement(Text, { color, dimColor: props.dim }, on ? "(\u2022)" : "( )", " ", label);
1191
+ };
1192
+ return /* @__PURE__ */ React.createElement(Box, null, opt("private", "private"), /* @__PURE__ */ React.createElement(Text, null, " "), opt("public", "public"));
1193
+ }
1194
+ function StatusLine(props) {
1195
+ const { owner, name, visibility, status } = props;
1196
+ if (status === "ok") {
1197
+ return /* @__PURE__ */ React.createElement(Text, { color: OK }, "\u2192 ", owner, "/", name, " \xB7 ", visibility, " \xB7 disponible");
1198
+ }
1199
+ if (status === "taken") {
1200
+ return /* @__PURE__ */ React.createElement(Text, { color: ACCENT }, "\u26A0 ", owner, "/", name, " existe d\xE9j\xE0 \u2014 change le nom pour continuer");
1201
+ }
1202
+ if (status === "reserved") {
1203
+ return /* @__PURE__ */ React.createElement(Text, { color: ACCENT }, "\u26A0 \xAB ", name, " \xBB est un nom r\xE9serv\xE9");
1204
+ }
1205
+ const hint = status === "empty" ? "saisis un nom de repo" : status === "too-short" ? "2 caract\xE8res minimum" : "40 caract\xE8res maximum";
1206
+ return /* @__PURE__ */ React.createElement(Text, { dimColor: true }, hint);
994
1207
  }
995
1208
 
996
1209
  // src/scan.ts
@@ -999,47 +1212,12 @@ import { execFile as execFileCb } from "child_process";
999
1212
  import { promises as fs3 } from "fs";
1000
1213
  import path3 from "path";
1001
1214
  import { promisify } from "util";
1002
- import picomatch from "picomatch";
1003
1215
 
1004
1216
  // 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
1217
  var LOC_THRESHOLD = 2500;
1037
- var MAX_FILE_BYTES = 1e6;
1038
1218
 
1039
1219
  // src/scan.ts
1040
1220
  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
1221
  function countLines(buf) {
1044
1222
  let count = 0;
1045
1223
  for (let i = 0; i < buf.length; i++) {
@@ -1049,29 +1227,30 @@ function countLines(buf) {
1049
1227
  return count;
1050
1228
  }
1051
1229
  async function scanFile(absPath) {
1052
- let stat;
1230
+ let buf;
1053
1231
  try {
1054
- stat = await fs3.stat(absPath);
1232
+ buf = await fs3.readFile(absPath);
1055
1233
  } catch {
1056
1234
  return null;
1057
1235
  }
1058
- if (stat.size > MAX_FILE_BYTES) return null;
1059
- const buf = await fs3.readFile(absPath);
1060
- if (!isUtf8(buf)) return null;
1236
+ if (!isUtf8(buf)) return 0;
1061
1237
  return countLines(buf);
1062
1238
  }
1239
+ function toEntry(rel, locCount) {
1240
+ return { path: rel, locCount, isLarge: locCount > LOC_THRESHOLD };
1241
+ }
1063
1242
  async function listTrackedFiles(cwd) {
1064
1243
  try {
1065
1244
  const { stdout } = await execFile("git", ["ls-files", "-z"], {
1066
1245
  cwd,
1067
1246
  maxBuffer: 50 * 1024 * 1024
1068
1247
  });
1069
- return new Set(stdout.split("\0").filter((l) => l.length > 0));
1248
+ return stdout.split("\0").filter((l) => l.length > 0);
1070
1249
  } catch {
1071
1250
  return null;
1072
1251
  }
1073
1252
  }
1074
- async function walk(absDir, relDir, out, tracked) {
1253
+ async function walkAll(absDir, relDir, out) {
1075
1254
  let dirents;
1076
1255
  try {
1077
1256
  dirents = await fs3.readdir(absDir, { withFileTypes: true });
@@ -1082,35 +1261,26 @@ async function walk(absDir, relDir, out, tracked) {
1082
1261
  const relPath = relDir ? `${relDir}/${d.name}` : d.name;
1083
1262
  const absPath = path3.join(absDir, d.name);
1084
1263
  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
1092
- });
1093
- continue;
1094
- }
1095
- await walk(absPath, relPath, out, tracked);
1264
+ if (d.name === ".git") continue;
1265
+ await walkAll(absPath, relPath, out);
1096
1266
  } 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
- });
1267
+ const loc = await scanFile(absPath);
1268
+ if (loc !== null) out.push(toEntry(relPath, loc));
1107
1269
  }
1108
1270
  }
1109
1271
  }
1110
1272
  async function scan(cwd) {
1111
1273
  const tracked = await listTrackedFiles(cwd);
1274
+ if (tracked !== null) {
1275
+ const out2 = [];
1276
+ for (const rel of tracked) {
1277
+ const loc = await scanFile(path3.join(cwd, rel));
1278
+ if (loc !== null) out2.push(toEntry(rel, loc));
1279
+ }
1280
+ return out2;
1281
+ }
1112
1282
  const out = [];
1113
- await walk(cwd, "", out, tracked);
1283
+ await walkAll(cwd, "", out);
1114
1284
  return out;
1115
1285
  }
1116
1286
 
@@ -1147,16 +1317,14 @@ async function writeBaliseignore(cwd, patterns) {
1147
1317
  }
1148
1318
 
1149
1319
  // src/ui/IgnoreTree.tsx
1150
- import React2, { useMemo, useState as useState2 } from "react";
1320
+ import React2, { useMemo as useMemo2, useState as useState2 } from "react";
1151
1321
  import { Box as Box2, Text as Text2, useApp as useApp2, useInput as useInput2 } from "ink";
1152
1322
  function buildTree(entries) {
1153
1323
  const root = {
1154
1324
  path: "",
1155
1325
  name: ".",
1156
1326
  isDir: true,
1157
- isBan: false,
1158
1327
  locCount: 0,
1159
- isDefault: false,
1160
1328
  isLarge: false,
1161
1329
  children: [],
1162
1330
  parent: null
@@ -1175,9 +1343,7 @@ function buildTree(entries) {
1175
1343
  path: dirPath,
1176
1344
  name,
1177
1345
  isDir: true,
1178
- isBan: false,
1179
1346
  locCount: 0,
1180
- isDefault: false,
1181
1347
  isLarge: false,
1182
1348
  children: [],
1183
1349
  parent
@@ -1195,17 +1361,13 @@ function buildTree(entries) {
1195
1361
  const node = {
1196
1362
  path: e.path,
1197
1363
  name,
1198
- isDir: e.isBan,
1199
- // ban entries from scan() are directories by construction
1200
- isBan: e.isBan,
1364
+ isDir: false,
1201
1365
  locCount: e.locCount,
1202
- isDefault: e.isDefault,
1203
1366
  isLarge: e.isLarge,
1204
1367
  children: [],
1205
1368
  parent
1206
1369
  };
1207
1370
  parent.children.push(node);
1208
- if (e.isBan) dirMap.set(e.path, node);
1209
1371
  }
1210
1372
  const sortChildren = (n) => {
1211
1373
  n.children.sort((a, b) => {
@@ -1220,7 +1382,7 @@ function buildTree(entries) {
1220
1382
  function collectDescendantFiles(node) {
1221
1383
  const out = [];
1222
1384
  const recurse = (n) => {
1223
- if (!n.isDir && !n.isBan) out.push(n.path);
1385
+ if (!n.isDir) out.push(n.path);
1224
1386
  for (const c of n.children) recurse(c);
1225
1387
  };
1226
1388
  recurse(node);
@@ -1232,7 +1394,7 @@ function flattenVisible(root, expanded) {
1232
1394
  if (node !== root) {
1233
1395
  out.push({ node, depth });
1234
1396
  }
1235
- if (node === root || node.isDir && !node.isBan && expanded.has(node.path)) {
1397
+ if (node === root || node.isDir && expanded.has(node.path)) {
1236
1398
  for (const c of node.children) recurse(c, depth + 1);
1237
1399
  }
1238
1400
  };
@@ -1244,13 +1406,17 @@ function dirAllIncluded(node, excluded) {
1244
1406
  if (files.length === 0) return true;
1245
1407
  return files.every((f) => !excluded.has(f));
1246
1408
  }
1409
+ function buildFileList(entries) {
1410
+ return [...entries].sort(
1411
+ (a, b) => b.locCount - a.locCount || a.path.localeCompare(b.path)
1412
+ );
1413
+ }
1247
1414
  function computeInitialExcluded(scan2, current) {
1248
1415
  const out = /* @__PURE__ */ new Set();
1249
1416
  const scanPaths = /* @__PURE__ */ new Set();
1250
1417
  for (const e of scan2) {
1251
- if (e.isBan) continue;
1252
1418
  scanPaths.add(e.path);
1253
- if (e.isDefault || e.isLarge) out.add(e.path);
1419
+ if (e.isLarge) out.add(e.path);
1254
1420
  }
1255
1421
  for (const p of current) {
1256
1422
  if (!scanPaths.has(p)) out.add(p);
@@ -1269,30 +1435,34 @@ function computeDiff(current, excluded) {
1269
1435
  var VIEWPORT_HEIGHT = 18;
1270
1436
  function IgnoreTree(props) {
1271
1437
  const { exit } = useApp2();
1272
- const root = useMemo(() => buildTree(props.scan), [props.scan]);
1438
+ const root = useMemo2(() => buildTree(props.scan), [props.scan]);
1439
+ const fileList = useMemo2(() => buildFileList(props.scan), [props.scan]);
1273
1440
  const [excluded, setExcluded] = useState2(
1274
1441
  () => computeInitialExcluded(props.scan, props.currentBaliseignore)
1275
1442
  );
1276
1443
  const [expandedDirs, setExpandedDirs] = useState2(/* @__PURE__ */ new Set());
1277
- const [focus, setFocus] = useState2("tree");
1444
+ const [leftView, setLeftView] = useState2("list");
1445
+ const [focus, setFocus] = useState2("left");
1278
1446
  const [treeCursor, setTreeCursor] = useState2("");
1447
+ const [listCursor, setListCursor] = useState2(0);
1279
1448
  const [diffCursor, setDiffCursor] = useState2(0);
1280
1449
  const [modalOpen, setModalOpen] = useState2(false);
1281
- const visible = useMemo(
1450
+ const visible = useMemo2(
1282
1451
  () => flattenVisible(root, expandedDirs),
1283
1452
  [root, expandedDirs]
1284
1453
  );
1285
- const diff = useMemo(
1454
+ const diff = useMemo2(
1286
1455
  () => computeDiff(props.currentBaliseignore, excluded),
1287
1456
  [props.currentBaliseignore, excluded]
1288
1457
  );
1289
- const treeIdx = useMemo(() => {
1458
+ const treeIdx = useMemo2(() => {
1290
1459
  if (visible.length === 0) return -1;
1291
1460
  const i = visible.findIndex((v) => v.node.path === treeCursor);
1292
1461
  return i >= 0 ? i : 0;
1293
1462
  }, [visible, treeCursor]);
1463
+ const safeListIdx = fileList.length === 0 ? -1 : Math.max(0, Math.min(listCursor, fileList.length - 1));
1294
1464
  const safeDiffIdx = diff.length === 0 ? -1 : Math.max(0, Math.min(diffCursor, diff.length - 1));
1295
- const stats = useMemo(() => {
1465
+ const stats = useMemo2(() => {
1296
1466
  let added = 0;
1297
1467
  let removed = 0;
1298
1468
  let unchanged = 0;
@@ -1349,7 +1519,11 @@ function IgnoreTree(props) {
1349
1519
  return;
1350
1520
  }
1351
1521
  if (key.tab) {
1352
- setFocus((f) => f === "tree" ? "diff" : "tree");
1522
+ setFocus((f) => f === "left" ? "diff" : "left");
1523
+ return;
1524
+ }
1525
+ if ((input === "v" || input === "V") && focus === "left") {
1526
+ setLeftView((vw) => vw === "list" ? "tree" : "list");
1353
1527
  return;
1354
1528
  }
1355
1529
  if (key.return) {
@@ -1360,7 +1534,21 @@ function IgnoreTree(props) {
1360
1534
  setModalOpen(true);
1361
1535
  return;
1362
1536
  }
1363
- if (focus === "tree") {
1537
+ if (focus === "left" && leftView === "list") {
1538
+ if (key.downArrow) {
1539
+ setListCursor((c) => Math.min(c + 1, Math.max(fileList.length - 1, 0)));
1540
+ return;
1541
+ }
1542
+ if (key.upArrow) {
1543
+ setListCursor((c) => Math.max(c - 1, 0));
1544
+ return;
1545
+ }
1546
+ if (input === " ") {
1547
+ if (safeListIdx < 0) return;
1548
+ toggleFile(fileList[safeListIdx].path);
1549
+ return;
1550
+ }
1551
+ } else if (focus === "left") {
1364
1552
  if (key.downArrow) {
1365
1553
  const i = Math.min(treeIdx + 1, visible.length - 1);
1366
1554
  if (i >= 0) setTreeCursor(visible[i].node.path);
@@ -1373,7 +1561,7 @@ function IgnoreTree(props) {
1373
1561
  }
1374
1562
  if (key.rightArrow) {
1375
1563
  const cur = treeIdx >= 0 ? visible[treeIdx].node : null;
1376
- if (!cur || !cur.isDir || cur.isBan) return;
1564
+ if (!cur || !cur.isDir) return;
1377
1565
  if (!expandedDirs.has(cur.path)) {
1378
1566
  setExpandedDirs((s) => {
1379
1567
  const next = new Set(s);
@@ -1388,7 +1576,7 @@ function IgnoreTree(props) {
1388
1576
  if (key.leftArrow) {
1389
1577
  const cur = treeIdx >= 0 ? visible[treeIdx].node : null;
1390
1578
  if (!cur) return;
1391
- if (cur.isDir && !cur.isBan && expandedDirs.has(cur.path)) {
1579
+ if (cur.isDir && expandedDirs.has(cur.path)) {
1392
1580
  setExpandedDirs((s) => {
1393
1581
  const next = new Set(s);
1394
1582
  next.delete(cur.path);
@@ -1401,7 +1589,7 @@ function IgnoreTree(props) {
1401
1589
  }
1402
1590
  if (input === " ") {
1403
1591
  const cur = treeIdx >= 0 ? visible[treeIdx].node : null;
1404
- if (!cur || cur.isBan) return;
1592
+ if (!cur) return;
1405
1593
  if (cur.isDir) toggleDir(cur);
1406
1594
  else toggleFile(cur.path);
1407
1595
  return;
@@ -1436,6 +1624,19 @@ function IgnoreTree(props) {
1436
1624
  Math.max(treeStart, 0),
1437
1625
  Math.max(treeStart, 0) + VIEWPORT_HEIGHT
1438
1626
  );
1627
+ const treeWindowStart = Math.max(treeStart, 0);
1628
+ const listStart = Math.max(
1629
+ 0,
1630
+ Math.min(
1631
+ safeListIdx - Math.floor(VIEWPORT_HEIGHT / 2),
1632
+ fileList.length - VIEWPORT_HEIGHT
1633
+ )
1634
+ );
1635
+ const listWindow = fileList.slice(
1636
+ Math.max(listStart, 0),
1637
+ Math.max(listStart, 0) + VIEWPORT_HEIGHT
1638
+ );
1639
+ const listWindowStart = Math.max(listStart, 0);
1439
1640
  const diffStart = Math.max(
1440
1641
  0,
1441
1642
  Math.min(
@@ -1447,19 +1648,27 @@ function IgnoreTree(props) {
1447
1648
  Math.max(diffStart, 0),
1448
1649
  Math.max(diffStart, 0) + VIEWPORT_HEIGHT
1449
1650
  );
1450
- const treeWindowStart = Math.max(treeStart, 0);
1451
1651
  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(
1652
+ const leftTitle = leftView === "list" ? "Files \u2014 by LoC" : "Files \u2014 tree";
1653
+ 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 v: list/tree \xB7 \u2191/\u2193: nav", leftView === "tree" ? " \xB7 \u2192/\u2190: open/close" : "", " \xB7 Space: toggle \xB7 Enter: save \xB7 Esc: cancel"), /* @__PURE__ */ React2.createElement(Box2, { marginTop: 1 }, /* @__PURE__ */ React2.createElement(
1453
1654
  Box2,
1454
1655
  {
1455
1656
  flexDirection: "column",
1456
1657
  width: "50%",
1457
1658
  paddingRight: 1,
1458
1659
  borderStyle: "round",
1459
- borderColor: focus === "tree" ? "cyan" : "gray"
1660
+ borderColor: focus === "left" ? "cyan" : "gray"
1460
1661
  },
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(
1662
+ /* @__PURE__ */ React2.createElement(Text2, { bold: true, color: focus === "left" ? "cyan" : void 0 }, leftTitle),
1663
+ leftView === "list" ? listWindow.length === 0 ? /* @__PURE__ */ React2.createElement(Text2, { dimColor: true }, "(no files)") : listWindow.map((e, i) => /* @__PURE__ */ React2.createElement(
1664
+ ListRow,
1665
+ {
1666
+ key: e.path,
1667
+ entry: e,
1668
+ excluded: excluded.has(e.path),
1669
+ isFocused: focus === "left" && listWindowStart + i === safeListIdx
1670
+ }
1671
+ )) : treeWindow.length === 0 ? /* @__PURE__ */ React2.createElement(Text2, { dimColor: true }, "(no files)") : treeWindow.map((v, i) => /* @__PURE__ */ React2.createElement(
1463
1672
  TreeRow,
1464
1673
  {
1465
1674
  key: v.node.path,
@@ -1467,7 +1676,7 @@ function IgnoreTree(props) {
1467
1676
  depth: v.depth,
1468
1677
  expanded: expandedDirs.has(v.node.path),
1469
1678
  excluded,
1470
- isFocused: focus === "tree" && treeWindowStart + i === treeIdx
1679
+ isFocused: focus === "left" && treeWindowStart + i === treeIdx
1471
1680
  }
1472
1681
  ))
1473
1682
  ), /* @__PURE__ */ React2.createElement(
@@ -1490,15 +1699,18 @@ function IgnoreTree(props) {
1490
1699
  ))
1491
1700
  )), /* @__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
1701
  }
1702
+ function ListRow(props) {
1703
+ const { entry, excluded, isFocused } = props;
1704
+ const box = excluded ? "\u2610" : "\u2611";
1705
+ const loc = entry.locCount > 0 ? ` (${entry.locCount} LoC)` : "";
1706
+ const large = entry.isLarge ? " [large]" : "";
1707
+ return /* @__PURE__ */ React2.createElement(Text2, { color: isFocused ? "cyan" : void 0 }, isFocused ? "\u25B8 " : " ", box, " ", entry.path, loc, large);
1708
+ }
1493
1709
  function TreeRow(props) {
1494
1710
  const { node, depth, expanded, excluded, isFocused } = props;
1495
1711
  const indent = " ".repeat(Math.max(depth, 0));
1496
1712
  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) {
1713
+ if (node.isDir) {
1502
1714
  const allIncluded = dirAllIncluded(node, excluded);
1503
1715
  const arrow = expanded ? "\u25BE" : "\u25B8";
1504
1716
  const box = allIncluded ? "\u2611" : "\u2610";
@@ -1506,13 +1718,10 @@ function TreeRow(props) {
1506
1718
  } else {
1507
1719
  const box = excluded.has(node.path) ? "\u2610" : "\u2611";
1508
1720
  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}`;
1721
+ const large = node.isLarge ? " [large]" : "";
1722
+ label = `${box} ${node.name}${loc}${large}`;
1514
1723
  }
1515
- return /* @__PURE__ */ React2.createElement(Text2, { color: isFocused ? "cyan" : void 0, dimColor: dim }, isFocused ? "\u25B8 " : " ", indent, label);
1724
+ return /* @__PURE__ */ React2.createElement(Text2, { color: isFocused ? "cyan" : void 0 }, isFocused ? "\u25B8 " : " ", indent, label);
1516
1725
  }
1517
1726
  function DiffRow(props) {
1518
1727
  const { entry, isFocused } = props;
@@ -1618,7 +1827,8 @@ async function runInit(opts) {
1618
1827
  if (result.action === "create") {
1619
1828
  const created = await client.postJson("/v1/repos", {
1620
1829
  owner_id: result.owner.id,
1621
- slug: result.slug
1830
+ slug: result.slug,
1831
+ visibility: result.visibility
1622
1832
  });
1623
1833
  cfg = {
1624
1834
  repo: {
@@ -1671,6 +1881,9 @@ async function runInit(opts) {
1671
1881
  `Wrote ${treeResult.patterns.length} patterns to .baliseignore
1672
1882
  `
1673
1883
  );
1884
+ process.stdout.write(
1885
+ '\n\u26A0 .baliseignore takes effect only once committed (it\'s read per commit\n server-side). Commit it before your next `balise sync`:\n git add .baliseignore && git commit -m "chore: update .baliseignore"\n'
1886
+ );
1674
1887
  }
1675
1888
 
1676
1889
  // src/commands/sync.ts
@@ -1717,6 +1930,67 @@ ${formatError(err)}
1717
1930
  }
1718
1931
  }
1719
1932
 
1933
+ // src/ui/SyncDashboard.tsx
1934
+ import React4 from "react";
1935
+ import { Box as Box3, render as render2, Text as Text3, useApp as useApp3, useInput as useInput3 } from "ink";
1936
+
1937
+ // src/format.ts
1938
+ function fmtLoc(n) {
1939
+ return n >= 1e3 ? `${(n / 1e3).toFixed(1).replace(/\.0$/, "")}k` : String(n);
1940
+ }
1941
+ var CREDIT_FMT = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 });
1942
+ function fmtCredits(micro) {
1943
+ return CREDIT_FMT.format(Math.floor(micro / 1e6));
1944
+ }
1945
+ function fmtEstimate(micro) {
1946
+ if (micro > 0 && micro < 1e6) return "<1";
1947
+ return fmtCredits(micro);
1948
+ }
1949
+
1950
+ // src/ui/SyncDashboard.tsx
1951
+ function Row2(props) {
1952
+ return /* @__PURE__ */ React4.createElement(Box3, { flexDirection: "row" }, /* @__PURE__ */ React4.createElement(Box3, { width: 8, flexShrink: 0 }, /* @__PURE__ */ React4.createElement(Text3, { dimColor: true }, props.label)), /* @__PURE__ */ React4.createElement(Text3, null, props.children));
1953
+ }
1954
+ function describeBase(body) {
1955
+ if (body.would_be_cold_start) {
1956
+ return `${body.base_dolt_ref} \xB7 cold-start (no prior sync)`;
1957
+ }
1958
+ const dist = body.from_commit_distance !== null ? ` \xB7 ${body.from_commit_distance} commit${body.from_commit_distance === 1 ? "" : "s"} ahead` : "";
1959
+ return `${body.base_dolt_ref} \xB7 ${body.base_kind}${dist}`;
1960
+ }
1961
+ function SyncDashboard(props) {
1962
+ const { exit } = useApp3();
1963
+ const { body } = props;
1964
+ useInput3((input, key) => {
1965
+ if (key.return) {
1966
+ props.onDone(true);
1967
+ exit();
1968
+ return;
1969
+ }
1970
+ if (key.escape || input === "q" || input === "Q" || key.ctrl && input === "c") {
1971
+ props.onDone(false);
1972
+ exit();
1973
+ return;
1974
+ }
1975
+ });
1976
+ const loc = body.loc !== null ? `${fmtLoc(body.loc)} LOC` : "n/a";
1977
+ const cost = `~${fmtEstimate(body.estimate)} cr`;
1978
+ return /* @__PURE__ */ React4.createElement(Box3, { flexDirection: "column", paddingX: 1, borderStyle: "round", borderColor: "cyan" }, /* @__PURE__ */ React4.createElement(Text3, { bold: true }, "balise sync \xB7 ", props.owner, "/", props.slug), /* @__PURE__ */ React4.createElement(Box3, { marginTop: 1, flexDirection: "column" }, /* @__PURE__ */ React4.createElement(Row2, { label: "commit" }, props.commitSha.slice(0, 7), props.branch ? /* @__PURE__ */ React4.createElement(Text3, { dimColor: true }, " \xB7 ", props.branch) : null), /* @__PURE__ */ React4.createElement(Row2, { label: "base" }, describeBase(body)), /* @__PURE__ */ React4.createElement(Row2, { label: "diff" }, /* @__PURE__ */ React4.createElement(Text3, { bold: true }, loc)), /* @__PURE__ */ React4.createElement(Row2, { label: "cost" }, /* @__PURE__ */ React4.createElement(Text3, { bold: true }, cost))), props.dirty ? /* @__PURE__ */ React4.createElement(Box3, { marginTop: 1 }, /* @__PURE__ */ React4.createElement(Text3, { color: "yellow" }, "\u26A0 working tree dirty \u2014 syncing HEAD")) : null, /* @__PURE__ */ React4.createElement(Box3, { marginTop: 1 }, /* @__PURE__ */ React4.createElement(Text3, { dimColor: true }, "[Enter] sync \xB7 [Esc] cancel")));
1979
+ }
1980
+ function runSyncDashboard(props) {
1981
+ return new Promise((resolve) => {
1982
+ const app = render2(
1983
+ React4.createElement(SyncDashboard, {
1984
+ ...props,
1985
+ onDone: (confirmed) => {
1986
+ resolve(confirmed);
1987
+ app.unmount();
1988
+ }
1989
+ })
1990
+ );
1991
+ });
1992
+ }
1993
+
1720
1994
  // src/commands/sync.ts
1721
1995
  function buildSyncQuery(opts) {
1722
1996
  const q = { commit_sha: opts.commitSha };
@@ -1734,6 +2008,9 @@ function drainStdinBuffer(stdin) {
1734
2008
  while (s.read() !== null) {
1735
2009
  }
1736
2010
  }
2011
+ function isInteractive2() {
2012
+ return Boolean(process.stdin.isTTY);
2013
+ }
1737
2014
  async function confirmRetryWithForce(opts) {
1738
2015
  const stderr = opts.stderr ?? process.stderr;
1739
2016
  const synced = opts.body.synced_at ?? "unknown time";
@@ -1760,10 +2037,37 @@ async function confirmRetryWithForce(opts) {
1760
2037
  }
1761
2038
  function formatDryRunSummary(body) {
1762
2039
  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}`;
2040
+ const loc = body.loc !== null ? `${body.loc} LOC` : "LOC n/a";
2041
+ return `Sync would use: base=${body.base_dolt_ref} (${body.base_kind}) / ${fromDiff} \xB7 ${loc} \xB7 ~${fmtEstimate(body.estimate)} cr`;
1764
2042
  }
1765
- function formatAcceptedSummary(body) {
1766
- return `Sync queued. Track it here: ${body.web_url}`;
2043
+ function resolveWebUrl(webUrl, frontUrl) {
2044
+ try {
2045
+ return new URL(webUrl, frontUrl).toString();
2046
+ } catch {
2047
+ return webUrl;
2048
+ }
2049
+ }
2050
+ function formatAcceptedSummary(body, frontUrl) {
2051
+ return `Sync queued. Track it here: ${resolveWebUrl(body.web_url, frontUrl)}`;
2052
+ }
2053
+ function formatInsufficientCredits(rawBody) {
2054
+ let estimate;
2055
+ let available;
2056
+ try {
2057
+ const detail = JSON.parse(rawBody).detail;
2058
+ if (detail?.error === "insufficient_credits") {
2059
+ if (typeof detail.estimate === "number") estimate = detail.estimate;
2060
+ if (typeof detail.available === "number") available = detail.available;
2061
+ }
2062
+ } catch {
2063
+ }
2064
+ const topUp = "Top up your balance, then run `balise sync` again.\n";
2065
+ if (estimate !== void 0 && available !== void 0) {
2066
+ return `Not enough credits \u2014 this sync needs ~${fmtEstimate(estimate)} cr but only ${fmtCredits(available)} cr available.
2067
+ ${topUp}`;
2068
+ }
2069
+ return `Not enough credits to run this sync.
2070
+ ${topUp}`;
1767
2071
  }
1768
2072
  async function runSync(opts) {
1769
2073
  try {
@@ -1816,11 +2120,7 @@ async function runSyncInner(opts) {
1816
2120
  process.exit(1);
1817
2121
  }
1818
2122
  }
1819
- if (await isDirty({ cwd })) {
1820
- process.stderr.write(
1821
- "Warning: working tree has uncommitted changes \u2014 syncing HEAD anyway.\n"
1822
- );
1823
- }
2123
+ const dirty = await isDirty({ cwd });
1824
2124
  const client = new ApiClient({
1825
2125
  apiUrl: cfg.api.url,
1826
2126
  supabaseUrl: opts.supabaseUrl
@@ -1829,53 +2129,117 @@ async function runSyncInner(opts) {
1829
2129
  `Packing ${cfg.repo.owner_login}/${cfg.repo.slug} at HEAD\u2026
1830
2130
  `
1831
2131
  );
1832
- const submit = async (force) => {
2132
+ const submit = async (force, dryRun) => {
1833
2133
  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);
2134
+ const query = buildSyncQuery({ commitSha, branch, force, base: opts.base, dryRun });
2135
+ const outcome = await uploadOnce(
2136
+ client,
2137
+ cfg.repo.owner_login,
2138
+ cfg.repo.slug,
2139
+ stream,
2140
+ query
2141
+ );
2142
+ return { outcome, commitSha, branch };
2143
+ };
2144
+ const finishReal = (outcome) => {
2145
+ if (outcome.kind === "invalid_base") {
2146
+ process.stderr.write(`invalid base ref: ${opts.base}
2147
+ `);
2148
+ process.exit(1);
2149
+ }
2150
+ if (outcome.kind === "accepted") {
2151
+ process.stdout.write(
2152
+ formatAcceptedSummary(outcome.body, opts.frontUrl) + "\n"
2153
+ );
2154
+ process.exit(0);
2155
+ }
2156
+ process.exit(1);
1842
2157
  };
1843
- let outcome;
2158
+ if (opts.autoConfirm) {
2159
+ if (dirty) {
2160
+ process.stderr.write(
2161
+ "Warning: working tree has uncommitted changes \u2014 syncing HEAD anyway.\n"
2162
+ );
2163
+ }
2164
+ let outcome;
2165
+ try {
2166
+ outcome = (await submit(opts.force ?? false, false)).outcome;
2167
+ } catch (err) {
2168
+ handleUploadError(err);
2169
+ process.exit(1);
2170
+ }
2171
+ if (outcome.kind === "conflict") {
2172
+ const proceed = await confirmRetryWithForce({
2173
+ body: outcome.body,
2174
+ autoConfirm: true
2175
+ });
2176
+ if (!proceed) process.exit(1);
2177
+ try {
2178
+ outcome = (await submit(true, false)).outcome;
2179
+ } catch (err) {
2180
+ handleUploadError(err);
2181
+ process.exit(1);
2182
+ }
2183
+ }
2184
+ finishReal(outcome);
2185
+ }
2186
+ let dry;
1844
2187
  try {
1845
- outcome = await submit(opts.force ?? false);
2188
+ dry = await submit(opts.force ?? false, true);
1846
2189
  } catch (err) {
1847
2190
  handleUploadError(err);
1848
2191
  process.exit(1);
1849
2192
  }
1850
- if (outcome.kind === "conflict") {
1851
- const proceedForce = await confirmRetryWithForce({
1852
- body: outcome.body,
1853
- autoConfirm: opts.autoConfirm ?? false
2193
+ if (dry.outcome.kind === "conflict") {
2194
+ const proceed = await confirmRetryWithForce({
2195
+ body: dry.outcome.body,
2196
+ autoConfirm: false
1854
2197
  });
1855
- if (!proceedForce) {
1856
- process.exit(1);
1857
- }
2198
+ if (!proceed) process.exit(1);
2199
+ let forced;
1858
2200
  try {
1859
- outcome = await submit(true);
2201
+ forced = (await submit(true, false)).outcome;
1860
2202
  } catch (err) {
1861
2203
  handleUploadError(err);
1862
2204
  process.exit(1);
1863
2205
  }
2206
+ finishReal(forced);
1864
2207
  }
1865
- if (outcome.kind === "invalid_base") {
2208
+ if (dry.outcome.kind === "invalid_base") {
1866
2209
  process.stderr.write(`invalid base ref: ${opts.base}
1867
2210
  `);
1868
2211
  process.exit(1);
1869
2212
  }
1870
- if (outcome.kind === "dry_run") {
1871
- process.stdout.write(formatDryRunSummary(outcome.body) + "\n");
2213
+ if (dry.outcome.kind !== "dry_run") {
2214
+ process.exit(1);
2215
+ }
2216
+ if (!isInteractive2()) {
2217
+ process.stdout.write(formatDryRunSummary(dry.outcome.body) + "\n");
2218
+ process.stderr.write(
2219
+ "Non-interactive terminal \u2014 re-run with -y to confirm and sync.\n"
2220
+ );
2221
+ process.exit(1);
2222
+ }
2223
+ const confirmed = await runSyncDashboard({
2224
+ owner: cfg.repo.owner_login,
2225
+ slug: cfg.repo.slug,
2226
+ commitSha: dry.commitSha,
2227
+ branch: dry.branch,
2228
+ dirty,
2229
+ body: dry.outcome.body
2230
+ });
2231
+ if (!confirmed) {
2232
+ process.stderr.write("Sync cancelled.\n");
1872
2233
  process.exit(0);
1873
2234
  }
1874
- if (outcome.kind !== "accepted") {
2235
+ let real;
2236
+ try {
2237
+ real = (await submit(opts.force ?? false, false)).outcome;
2238
+ } catch (err) {
2239
+ handleUploadError(err);
1875
2240
  process.exit(1);
1876
2241
  }
1877
- process.stdout.write(formatAcceptedSummary(outcome.body) + "\n");
1878
- process.exit(0);
2242
+ finishReal(real);
1879
2243
  }
1880
2244
  async function uploadOnce(client, owner, slug, stream, query) {
1881
2245
  const raw = await client.uploadBundleRaw(
@@ -1919,6 +2283,10 @@ function handleUploadError(err) {
1919
2283
  );
1920
2284
  return;
1921
2285
  }
2286
+ if (err instanceof ApiError && err.status === 402) {
2287
+ process.stderr.write(formatInsufficientCredits(err.body));
2288
+ return;
2289
+ }
1922
2290
  process.stderr.write(
1923
2291
  "Something went wrong while uploading. Please try again.\n"
1924
2292
  );
@@ -1938,6 +2306,7 @@ async function withLog(name, fn) {
1938
2306
  }
1939
2307
  }
1940
2308
  var SUPABASE_URL = process.env.BALISE_SUPABASE_URL ?? "https://api.balise.dev";
2309
+ var FRONT_URL = process.env.BALISE_FRONT_URL ?? "https://balise.dev";
1941
2310
  var loginCmd = defineCommand({
1942
2311
  meta: { name: "login", description: "Authenticate via OAuth (PKCE loopback)." },
1943
2312
  async run() {
@@ -1974,7 +2343,7 @@ var syncCmd = defineCommand({
1974
2343
  yes: {
1975
2344
  type: "boolean",
1976
2345
  alias: "y",
1977
- description: "Skip sync confirmations (warning prompt + future 409 retry).",
2346
+ description: "Auto-validate: skip the pre-run dashboard and sync directly.",
1978
2347
  default: false
1979
2348
  },
1980
2349
  force: {
@@ -1985,27 +2354,22 @@ var syncCmd = defineCommand({
1985
2354
  base: {
1986
2355
  type: "string",
1987
2356
  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
2357
  }
1994
2358
  },
1995
2359
  async run({ args }) {
1996
2360
  await runSync({
1997
2361
  supabaseUrl: SUPABASE_URL,
2362
+ frontUrl: FRONT_URL,
1998
2363
  autoConfirm: Boolean(args.yes),
1999
2364
  force: Boolean(args.force),
2000
- base: typeof args.base === "string" && args.base.length > 0 ? args.base : void 0,
2001
- dryRun: Boolean(args["dry-run"])
2365
+ base: typeof args.base === "string" && args.base.length > 0 ? args.base : void 0
2002
2366
  });
2003
2367
  }
2004
2368
  });
2005
2369
  var main = defineCommand({
2006
2370
  meta: {
2007
2371
  name: "balise",
2008
- version: "0.1.0",
2372
+ version: "0.3.1",
2009
2373
  description: "Balise CLI \u2014 push codebase for spec extraction."
2010
2374
  },
2011
2375
  subCommands: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@balise.dev/cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Balise CLI — push codebase to Balise backend for spec extraction.",
5
5
  "type": "module",
6
6
  "bin": {