@overscore/cli 0.13.19 → 0.13.21

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 +241 -17
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -911,9 +911,125 @@ function formatCell(value) {
911
911
  return value.toLocaleString();
912
912
  return String(value);
913
913
  }
914
+ // ── Pull safety: manifest + local-edit detection + backup ───────────────
915
+ // Each pull records a manifest of the files it wrote, so a re-pull can tell whether
916
+ // the folder has un-pulled local edits before it overwrites them. Without this,
917
+ // `pull --force` silently destroys work that isn't in the Hub yet.
918
+ const PULL_MANIFEST = ".overscore-pull.json";
919
+ // Files we never track/hash: dependencies, secrets, VCS metadata, and the manifest itself.
920
+ function isTrackedPullEntry(rel) {
921
+ const top = rel.split(path.sep)[0];
922
+ if (top === "node_modules" || top === ".git")
923
+ return false;
924
+ if (top.startsWith(".env"))
925
+ return false;
926
+ if (rel === PULL_MANIFEST)
927
+ return false;
928
+ return true;
929
+ }
930
+ function listPulledFiles(dir) {
931
+ const out = [];
932
+ const walk = (abs, rel) => {
933
+ for (const entry of fs.readdirSync(abs, { withFileTypes: true })) {
934
+ const childRel = rel ? path.join(rel, entry.name) : entry.name;
935
+ if (!isTrackedPullEntry(childRel))
936
+ continue;
937
+ const childAbs = path.join(abs, entry.name);
938
+ if (entry.isDirectory())
939
+ walk(childAbs, childRel);
940
+ else if (entry.isFile())
941
+ out.push(childRel);
942
+ }
943
+ };
944
+ walk(dir, "");
945
+ return out;
946
+ }
947
+ function hashPulledFile(abs) {
948
+ return createHash("sha256").update(fs.readFileSync(abs)).digest("hex");
949
+ }
950
+ function writePullManifest(dir, version) {
951
+ const files = {};
952
+ for (const rel of listPulledFiles(dir)) {
953
+ try {
954
+ files[rel] = hashPulledFile(path.join(dir, rel));
955
+ }
956
+ catch {
957
+ /* skip unreadable */
958
+ }
959
+ }
960
+ try {
961
+ fs.writeFileSync(path.join(dir, PULL_MANIFEST), JSON.stringify({ version, pulled_at: new Date().toISOString(), files }, null, 2));
962
+ }
963
+ catch {
964
+ /* non-fatal: worst case, the next pull treats the folder as having local edits */
965
+ }
966
+ }
967
+ // True if the folder has edits relative to its last pull — or if we can't tell (no
968
+ // manifest), in which case we err on the side of preserving whatever's there.
969
+ function hasLocalPullEdits(dir) {
970
+ let manifest;
971
+ try {
972
+ manifest = JSON.parse(fs.readFileSync(path.join(dir, PULL_MANIFEST), "utf-8"));
973
+ }
974
+ catch {
975
+ return true; // no/unreadable manifest — can't verify, treat as having local edits
976
+ }
977
+ const recorded = manifest.files || {};
978
+ // A tracked file the last pull didn't write is a local addition.
979
+ for (const rel of listPulledFiles(dir)) {
980
+ if (!(rel in recorded))
981
+ return true;
982
+ }
983
+ // A recorded file that's now missing or changed is a local edit.
984
+ for (const rel of Object.keys(recorded)) {
985
+ const abs = path.join(dir, rel);
986
+ if (!fs.existsSync(abs))
987
+ return true;
988
+ try {
989
+ if (hashPulledFile(abs) !== recorded[rel])
990
+ return true;
991
+ }
992
+ catch {
993
+ return true;
994
+ }
995
+ }
996
+ return false;
997
+ }
998
+ // Copy the current source (minus node_modules/.git) to a sibling backup folder so a
999
+ // forced re-pull never destroys un-pulled work. Returns the backup path.
1000
+ function backupPulledSource(dir) {
1001
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
1002
+ let backup = `${dir}.local-backup-${stamp}`;
1003
+ let n = 1;
1004
+ while (fs.existsSync(backup))
1005
+ backup = `${dir}.local-backup-${stamp}-${n++}`;
1006
+ fs.cpSync(dir, backup, {
1007
+ recursive: true,
1008
+ filter: (src) => {
1009
+ const rel = path.relative(dir, src);
1010
+ if (!rel)
1011
+ return true;
1012
+ // Keep everything except dependencies, VCS, and the manifest; do keep .env* so the backup is complete.
1013
+ const top = rel.split(path.sep)[0];
1014
+ if (top === "node_modules" || top === ".git" || rel === PULL_MANIFEST)
1015
+ return false;
1016
+ return true;
1017
+ },
1018
+ });
1019
+ return backup;
1020
+ }
1021
+ async function promptPullConflict() {
1022
+ const answer = await prompt("This folder has local changes that aren't deployed. [K]eep mine / [r]eplace / [b]ackup then replace? (K/r/b)");
1023
+ const a = answer.trim().toLowerCase();
1024
+ if (a === "r" || a === "replace")
1025
+ return "replace";
1026
+ if (a === "b" || a === "backup")
1027
+ return "backup";
1028
+ return "keep";
1029
+ }
914
1030
  async function pull(slug) {
915
1031
  if (!slug) {
916
- console.error("\n Usage: npx @overscore/cli pull <dashboard-slug> [--version N] [--force]\n");
1032
+ console.error("\n Usage: npx @overscore/cli pull <dashboard-slug> [--project <slug>] [--version N] [--force]\n");
917
1033
  console.error(" Example:");
918
1034
  console.error(" npx @overscore/cli pull my-dashboard\n");
919
1035
  process.exit(1);
@@ -923,10 +1039,18 @@ async function pull(slug) {
923
1039
  const versionParam = versionIndex !== -1 && process.argv[versionIndex + 1]
924
1040
  ? process.argv[versionIndex + 1]
925
1041
  : null;
926
- // --force skips the "directory exists, overwrite?" confirm — needed for non-interactive
927
- // re-pulls (scripted rebuilds / `workspace sync`). Source is versioned in the Hub, and the
928
- // clean step below already preserves node_modules + .env, so overwriting is safe.
1042
+ // --force skips the interactive keep/replace/backup prompt for non-interactive re-pulls
1043
+ // (scripted rebuilds / `workspace sync`). It is NOT a license to destroy: if the folder has
1044
+ // un-pulled local edits, the pull backs them up before overwriting (see below).
929
1045
  const force = process.argv.includes("--force");
1046
+ // --project scopes the api-key mint to the right project. Optional: the dashboard slug is
1047
+ // used as a fallback, correct only when a project has a single same-named dashboard.
1048
+ const projectIndex = process.argv.indexOf("--project");
1049
+ const projectFlag = projectIndex !== -1 &&
1050
+ process.argv[projectIndex + 1] &&
1051
+ !process.argv[projectIndex + 1].startsWith("--")
1052
+ ? process.argv[projectIndex + 1]
1053
+ : null;
930
1054
  // Resolve API key: device token → auto-create os_ key, else the saved project key, else prompt.
931
1055
  let apiKey = "";
932
1056
  const configPath = path.join(process.env.HOME || "", ".overscore", "config");
@@ -935,14 +1059,18 @@ async function pull(slug) {
935
1059
  : {};
936
1060
  const deviceToken = cfg.OVERSCORE_DEVICE_TOKEN;
937
1061
  if (deviceToken) {
1062
+ // Prefer the explicit --project slug; fall back to the dashboard slug (correct only when
1063
+ // a project has a single same-named dashboard). A wrong project slug 404s and leaves
1064
+ // apiKey empty, which we handle explicitly below.
1065
+ const keyProjectSlug = projectFlag || slug;
938
1066
  try {
939
- const keyRes = await fetch(`${HUB_URL}/api/projects/${slug}/api-keys`, {
1067
+ const keyRes = await fetch(`${HUB_URL}/api/projects/${keyProjectSlug}/api-keys`, {
940
1068
  method: "POST",
941
1069
  headers: {
942
1070
  Authorization: `Bearer ${deviceToken}`,
943
1071
  "Content-Type": "application/json",
944
1072
  },
945
- body: JSON.stringify({ name: `${slug} pull key` }),
1073
+ body: JSON.stringify({ name: `${keyProjectSlug} pull key` }),
946
1074
  });
947
1075
  if (keyRes.status === 401 || keyRes.status === 403) {
948
1076
  console.error("\n Error: Device token is invalid or expired. Run 'npx @overscore/cli auth login' to re-authenticate.\n");
@@ -957,14 +1085,26 @@ async function pull(slug) {
957
1085
  // Network error — fall through to the saved key / prompt
958
1086
  }
959
1087
  }
960
- // The mint above targets /api/projects/<slug>, but `slug` is a DASHBOARD slug for any
961
- // project whose name differs from the dashboard (i.e. any multi-dashboard project) that
962
- // 404s and leaves apiKey empty. Fall back to the project API key saved by `auth login`:
963
- // it authorizes every dashboard in the logged-in project, which is the common pull case
964
- // (and what makes non-interactive `pull` / scripted rebuilds work).
1088
+ // Fall back to the project API key saved by `auth login`, if any: it authorizes every
1089
+ // dashboard in the logged-in project.
965
1090
  if (!apiKey && cfg.OVERSCORE_API_KEY) {
966
1091
  apiKey = cfg.OVERSCORE_API_KEY;
967
1092
  }
1093
+ // A logged-in (device-token) user whose mint failed and who has no saved project key gets a
1094
+ // clear, actionable error instead of a raw "paste an API key" prompt they have no key for.
1095
+ // The interactive prompt below is reserved for the pure API-key path (no device token).
1096
+ if (!apiKey && deviceToken) {
1097
+ if (!projectFlag) {
1098
+ console.error(`\n Error: Couldn't resolve the project for dashboard "${slug}".`);
1099
+ console.error(` If it belongs to a multi-dashboard project, pass the project slug:`);
1100
+ console.error(` npx @overscore/cli pull ${slug} --project <project-slug>\n`);
1101
+ }
1102
+ else {
1103
+ console.error(`\n Error: Couldn't create a key for project "${projectFlag}".`);
1104
+ console.error(` Check the project slug and that you have access to it.\n`);
1105
+ }
1106
+ process.exit(1);
1107
+ }
968
1108
  if (!apiKey) {
969
1109
  console.log(" Tip: Run 'npx @overscore/cli auth login' to authenticate once and skip this step.");
970
1110
  apiKey = await prompt("API key (from the Hub):");
@@ -1001,15 +1141,31 @@ async function pull(slug) {
1001
1141
  const zipBuffer = Buffer.from(await res.arrayBuffer());
1002
1142
  // Extract to ./<slug>/
1003
1143
  const targetDir = path.resolve(process.cwd(), slug);
1004
- if (fs.existsSync(targetDir) && !force) {
1005
- const yes = await confirm(`Directory "${slug}" already exists. Overwrite source files?`);
1006
- if (!yes) {
1007
- console.log(" Cancelled.\n");
1008
- return;
1144
+ const dirExists = fs.existsSync(targetDir);
1145
+ // Guard against destroying un-pulled local edits before we overwrite the folder.
1146
+ if (dirExists && hasLocalPullEdits(targetDir)) {
1147
+ if (force) {
1148
+ // Non-interactive re-pull (workspace sync / scripts): never destroy silently —
1149
+ // snapshot the local edits first, then proceed.
1150
+ const backup = backupPulledSource(targetDir);
1151
+ console.log(`\n Local changes detected — backed them up to:\n ${backup}`);
1152
+ }
1153
+ else {
1154
+ const choice = await promptPullConflict();
1155
+ if (choice === "keep") {
1156
+ console.log(" Cancelled — your local files are untouched.\n");
1157
+ return;
1158
+ }
1159
+ if (choice === "backup") {
1160
+ const backup = backupPulledSource(targetDir);
1161
+ console.log(` Backed up your changes to:\n ${backup}`);
1162
+ }
1163
+ // "replace" → fall through to the overwrite
1009
1164
  }
1010
1165
  }
1166
+ // A clean folder (matches its last pull) is safe to overwrite with no prompt.
1011
1167
  // Clean target directory (preserve node_modules and .env)
1012
- if (fs.existsSync(targetDir)) {
1168
+ if (dirExists) {
1013
1169
  for (const entry of fs.readdirSync(targetDir)) {
1014
1170
  if (entry === "node_modules" || entry.startsWith(".env"))
1015
1171
  continue;
@@ -1077,6 +1233,9 @@ VITE_OVERSCORE_API_URL=${HUB_URL}/api
1077
1233
  }
1078
1234
  // Pull latest platform rules (includes save-knowledge skill)
1079
1235
  await syncPlatformRules(targetDir, baseUrl, apiKey);
1236
+ // Record what this pull wrote so a future re-pull can detect local edits before
1237
+ // overwriting them (see hasLocalPullEdits / the --force backup above).
1238
+ writePullManifest(targetDir, version);
1080
1239
  // Print context summary so the developer knows what Claude has access to
1081
1240
  const knowledgeDir = path.join(targetDir, ".claude", "knowledge");
1082
1241
  const knowledgeFiles = fs.existsSync(knowledgeDir)
@@ -1155,6 +1314,7 @@ async function listDashboards() {
1155
1314
  }
1156
1315
  const data = (await res.json());
1157
1316
  console.log(`\n ${data.project} (${data.project_slug})\n`);
1317
+ console.log(" Showing one project — run 'overscore projects' to list them all.\n");
1158
1318
  if (data.dashboards.length === 0) {
1159
1319
  console.log(" No dashboards yet.\n");
1160
1320
  return;
@@ -1173,6 +1333,65 @@ async function listDashboards() {
1173
1333
  console.log();
1174
1334
  }
1175
1335
  }
1336
+ // Account-level project list. Uses the CLI DEVICE token (ocli_) rather than a
1337
+ // project-scoped API key, so it can enumerate EVERY project on the account —
1338
+ // including empty ones a project key could never see.
1339
+ async function listProjects() {
1340
+ const configPath = path.join(process.env.HOME || "", ".overscore", "config");
1341
+ const readToken = () => {
1342
+ const cfg = fs.existsSync(configPath)
1343
+ ? parseEnv(fs.readFileSync(configPath, "utf-8"))
1344
+ : {};
1345
+ return cfg.OVERSCORE_DEVICE_TOKEN;
1346
+ };
1347
+ let deviceToken = readToken();
1348
+ if (!deviceToken) {
1349
+ console.log("\n No session found. Launching browser login...\n");
1350
+ await authLogin();
1351
+ deviceToken = readToken();
1352
+ if (!deviceToken) {
1353
+ console.error("\n Could not authenticate.\n");
1354
+ process.exit(1);
1355
+ }
1356
+ }
1357
+ for (let attempt = 0; attempt < 2; attempt++) {
1358
+ const res = await fetch(`${HUB_URL}/api/projects`, {
1359
+ headers: { Authorization: `Bearer ${deviceToken}` },
1360
+ });
1361
+ if (res.ok) {
1362
+ const { projects } = (await res.json());
1363
+ printProjects(projects);
1364
+ return;
1365
+ }
1366
+ // Device token expired — re-auth once, then retry.
1367
+ if (res.status === 401 && attempt === 0) {
1368
+ console.log("\n Session expired. Launching browser login...\n");
1369
+ await authLogin();
1370
+ deviceToken = readToken();
1371
+ if (!deviceToken)
1372
+ break;
1373
+ continue;
1374
+ }
1375
+ const body = (await res.json().catch(() => ({})));
1376
+ console.error(`\n Error: ${body.error || `HTTP ${res.status}`}\n`);
1377
+ process.exit(1);
1378
+ }
1379
+ console.error("\n Could not authenticate.\n");
1380
+ process.exit(1);
1381
+ }
1382
+ function printProjects(projects) {
1383
+ if (!projects.length) {
1384
+ console.log("\n No projects yet.\n\n Create one: npx create-overscore --dashboard <name> --project <slug>\n");
1385
+ return;
1386
+ }
1387
+ console.log(`\n Your projects (${projects.length})\n`);
1388
+ for (const p of projects) {
1389
+ const count = p.dashboard_count === 1 ? "1 dashboard" : `${p.dashboard_count} dashboards`;
1390
+ console.log(` ${p.name}`);
1391
+ console.log(` slug: ${p.slug} · ${count} · ${p.role}`);
1392
+ console.log();
1393
+ }
1394
+ }
1176
1395
  function collectFiles(dir, prefix) {
1177
1396
  const files = [];
1178
1397
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
@@ -3056,6 +3275,9 @@ else if (command === "versions") {
3056
3275
  else if (command === "list") {
3057
3276
  listDashboards();
3058
3277
  }
3278
+ else if (command === "projects") {
3279
+ listProjects();
3280
+ }
3059
3281
  else if (command === "install-skills") {
3060
3282
  installSkills();
3061
3283
  }
@@ -3220,6 +3442,7 @@ else {
3220
3442
  auth logout Remove saved credentials
3221
3443
  dev Start the dev server (injects auth automatically)
3222
3444
  list List all dashboards in the project
3445
+ projects List every project on your account
3223
3446
  deploy Build and deploy the dashboard
3224
3447
  pull <slug> Pull source code from the hub
3225
3448
  versions List deploy history
@@ -3232,6 +3455,7 @@ else {
3232
3455
  npx @overscore/cli auth login
3233
3456
  npx @overscore/cli dev
3234
3457
  npx @overscore/cli list
3458
+ npx @overscore/cli projects
3235
3459
  npx @overscore/cli deploy --message "description"
3236
3460
  npx @overscore/cli pull <slug>
3237
3461
  npx @overscore/cli pull <slug> --version 3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@overscore/cli",
3
- "version": "0.13.19",
3
+ "version": "0.13.21",
4
4
  "description": "CLI for deploying Overscore dashboards and publishing analyses",
5
5
  "bin": {
6
6
  "overscore": "dist/index.js"