@overscore/cli 0.13.20 → 0.13.22

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/dist/homes.js +134 -0
  2. package/dist/index.js +457 -31
  3. package/package.json +1 -1
package/dist/homes.js ADDED
@@ -0,0 +1,134 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import os from "os";
4
+ const SCHEMA_VERSION = 1;
5
+ const CONFIG_DIR = path.join(os.homedir(), ".overscore");
6
+ const HOMES_PATH = path.join(CONFIG_DIR, "homes.json");
7
+ const LOCK_PATH = HOMES_PATH + ".lock";
8
+ const LOCK_STALE_MS = 10000;
9
+ // Managed workspace root. Project-nested from day one so that graduating a project to a
10
+ // dbt workspace is purely additive: the workspace wrapper drops in at the project root
11
+ // and `overscore/<slug>/` never has to move.
12
+ export function managedRoot() {
13
+ return path.join(os.homedir(), "Overscore");
14
+ }
15
+ export function managedProjectDir(projectSlug) {
16
+ return path.join(managedRoot(), projectSlug);
17
+ }
18
+ export function managedOverscoreDir(projectSlug) {
19
+ return path.join(managedProjectDir(projectSlug), "overscore");
20
+ }
21
+ export function managedDashboardDir(projectSlug, dashboardSlug) {
22
+ return path.join(managedOverscoreDir(projectSlug), dashboardSlug);
23
+ }
24
+ function readHomesFile() {
25
+ try {
26
+ const parsed = JSON.parse(fs.readFileSync(HOMES_PATH, "utf-8"));
27
+ if (parsed && typeof parsed === "object" && parsed.homes) {
28
+ return { schema_version: parsed.schema_version ?? SCHEMA_VERSION, homes: parsed.homes };
29
+ }
30
+ }
31
+ catch {
32
+ // Missing or corrupt. Atomic rename means a reader never sees a torn write, so a parse
33
+ // failure means no file yet (or a truly hand-mangled one) — start fresh.
34
+ }
35
+ return { schema_version: SCHEMA_VERSION, homes: {} };
36
+ }
37
+ // Reads are lock-free: writes rename atomically, so a reader sees either the old or the new
38
+ // complete file, never a partial one. Returns null if the project has no home recorded yet.
39
+ export function getHome(projectSlug) {
40
+ return readHomesFile().homes[projectSlug] ?? null;
41
+ }
42
+ // A cross-process advisory lock so a read-modify-write from `overscore open` can't lose an
43
+ // update raced against a concurrent `workspace` write. Exclusive-create the lockfile; if
44
+ // it's held, wait, and reclaim it only if it's gone stale.
45
+ function withLock(fn) {
46
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
47
+ const start = Date.now();
48
+ // Small synchronous sleep without pulling in a dependency.
49
+ const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
50
+ for (;;) {
51
+ try {
52
+ const fd = fs.openSync(LOCK_PATH, "wx");
53
+ fs.writeSync(fd, String(process.pid));
54
+ fs.closeSync(fd);
55
+ break;
56
+ }
57
+ catch (err) {
58
+ if (err.code !== "EEXIST")
59
+ throw err;
60
+ let age = Infinity;
61
+ try {
62
+ age = Date.now() - fs.statSync(LOCK_PATH).mtimeMs;
63
+ }
64
+ catch {
65
+ continue; // lock vanished between openSync and statSync — retry immediately
66
+ }
67
+ if (age > LOCK_STALE_MS) {
68
+ try {
69
+ fs.rmSync(LOCK_PATH, { force: true });
70
+ }
71
+ catch {
72
+ /* someone else reclaimed it */
73
+ }
74
+ continue;
75
+ }
76
+ if (Date.now() - start > LOCK_STALE_MS) {
77
+ throw new Error("Timed out waiting for the homes.json lock");
78
+ }
79
+ sleep(50);
80
+ }
81
+ }
82
+ try {
83
+ return fn();
84
+ }
85
+ finally {
86
+ try {
87
+ fs.rmSync(LOCK_PATH, { force: true });
88
+ }
89
+ catch {
90
+ /* best effort */
91
+ }
92
+ }
93
+ }
94
+ function writeAtomic(data) {
95
+ const tmp = `${HOMES_PATH}.tmp-${process.pid}`;
96
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
97
+ // On Windows a rename can transiently fail with EPERM/EBUSY if a lock-free reader has the
98
+ // file open; retry briefly rather than crash after side effects.
99
+ for (let attempt = 0;; attempt++) {
100
+ try {
101
+ fs.renameSync(tmp, HOMES_PATH);
102
+ return;
103
+ }
104
+ catch (err) {
105
+ const code = err.code;
106
+ if (process.platform === "win32" && (code === "EPERM" || code === "EBUSY") && attempt < 5) {
107
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
108
+ continue;
109
+ }
110
+ try {
111
+ fs.rmSync(tmp, { force: true });
112
+ }
113
+ catch {
114
+ /* best effort */
115
+ }
116
+ throw err;
117
+ }
118
+ }
119
+ }
120
+ // Record (or update) where a project lives: locked read-modify-write + atomic replace. Refuses
121
+ // to write over a homes.json written by a newer CLI, so an old binary can't downgrade/corrupt it.
122
+ export function setHome(projectSlug, entry) {
123
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
124
+ withLock(() => {
125
+ const file = readHomesFile();
126
+ if (file.schema_version > SCHEMA_VERSION) {
127
+ throw new Error(`~/.overscore/homes.json is version ${file.schema_version}, but this Overscore CLI only ` +
128
+ `understands version ${SCHEMA_VERSION}. Update the CLI (npm i -g @overscore/cli).`);
129
+ }
130
+ file.schema_version = SCHEMA_VERSION;
131
+ file.homes[projectSlug] = entry;
132
+ writeAtomic(file);
133
+ });
134
+ }
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ import { createInterface } from "readline";
9
9
  import { createHash, randomBytes } from "crypto";
10
10
  import yauzl from "yauzl";
11
11
  import { templatePush } from "./template-push.js";
12
+ import { getHome, setHome, managedProjectDir, managedOverscoreDir, managedDashboardDir, } from "./homes.js";
12
13
  // ── Shared helpers ──────────────────────────────────────────────────
13
14
  // Name CLI-minted API keys after the machine + date so stale keys are
14
15
  // recognizable and revocable in the Hub UI (mitigates key sprawl). Keys are
@@ -75,7 +76,7 @@ async function loadEnv() {
75
76
  }
76
77
  // .env has project context but no API key — generate one via device token
77
78
  if (projectSlug) {
78
- const globalCfgPath = path.join(process.env.HOME || "", ".overscore", "config");
79
+ const globalCfgPath = path.join(os.homedir(), ".overscore", "config");
79
80
  const globalCfg = fs.existsSync(globalCfgPath)
80
81
  ? parseEnv(fs.readFileSync(globalCfgPath, "utf-8"))
81
82
  : {};
@@ -98,7 +99,7 @@ async function loadEnv() {
98
99
  }
99
100
  }
100
101
  // (2) Global config (works from analysis folders or anywhere)
101
- const configPath = path.join(process.env.HOME || "", ".overscore", "config");
102
+ const configPath = path.join(os.homedir(), ".overscore", "config");
102
103
  if (fs.existsSync(configPath)) {
103
104
  const cfg = parseEnv(fs.readFileSync(configPath, "utf-8"));
104
105
  if (cfg.OVERSCORE_API_KEY) {
@@ -125,7 +126,7 @@ async function loadEnv() {
125
126
  // No creds — trigger browser auth inline, then re-check
126
127
  console.log("\n No Overscore credentials found. Launching browser login...\n");
127
128
  await authLogin();
128
- const configPath2 = path.join(process.env.HOME || "", ".overscore", "config");
129
+ const configPath2 = path.join(os.homedir(), ".overscore", "config");
129
130
  if (fs.existsSync(configPath2)) {
130
131
  const cfg2 = parseEnv(fs.readFileSync(configPath2, "utf-8"));
131
132
  if (cfg2.OVERSCORE_API_KEY) {
@@ -229,7 +230,7 @@ function prompt(message) {
229
230
  });
230
231
  }
231
232
  async function refreshApiKey(projectSlug) {
232
- const configDir = path.join(process.env.HOME || "", ".overscore");
233
+ const configDir = path.join(os.homedir(), ".overscore");
233
234
  const configPath = path.join(configDir, "config");
234
235
  let cfg = fs.existsSync(configPath)
235
236
  ? parseEnv(fs.readFileSync(configPath, "utf-8"))
@@ -911,9 +912,125 @@ function formatCell(value) {
911
912
  return value.toLocaleString();
912
913
  return String(value);
913
914
  }
915
+ // ── Pull safety: manifest + local-edit detection + backup ───────────────
916
+ // Each pull records a manifest of the files it wrote, so a re-pull can tell whether
917
+ // the folder has un-pulled local edits before it overwrites them. Without this,
918
+ // `pull --force` silently destroys work that isn't in the Hub yet.
919
+ const PULL_MANIFEST = ".overscore-pull.json";
920
+ // Files we never track/hash: dependencies, secrets, VCS metadata, and the manifest itself.
921
+ function isTrackedPullEntry(rel) {
922
+ const top = rel.split(path.sep)[0];
923
+ if (top === "node_modules" || top === ".git")
924
+ return false;
925
+ if (top.startsWith(".env"))
926
+ return false;
927
+ if (rel === PULL_MANIFEST)
928
+ return false;
929
+ return true;
930
+ }
931
+ function listPulledFiles(dir) {
932
+ const out = [];
933
+ const walk = (abs, rel) => {
934
+ for (const entry of fs.readdirSync(abs, { withFileTypes: true })) {
935
+ const childRel = rel ? path.join(rel, entry.name) : entry.name;
936
+ if (!isTrackedPullEntry(childRel))
937
+ continue;
938
+ const childAbs = path.join(abs, entry.name);
939
+ if (entry.isDirectory())
940
+ walk(childAbs, childRel);
941
+ else if (entry.isFile())
942
+ out.push(childRel);
943
+ }
944
+ };
945
+ walk(dir, "");
946
+ return out;
947
+ }
948
+ function hashPulledFile(abs) {
949
+ return createHash("sha256").update(fs.readFileSync(abs)).digest("hex");
950
+ }
951
+ function writePullManifest(dir, version) {
952
+ const files = {};
953
+ for (const rel of listPulledFiles(dir)) {
954
+ try {
955
+ files[rel] = hashPulledFile(path.join(dir, rel));
956
+ }
957
+ catch {
958
+ /* skip unreadable */
959
+ }
960
+ }
961
+ try {
962
+ fs.writeFileSync(path.join(dir, PULL_MANIFEST), JSON.stringify({ version, pulled_at: new Date().toISOString(), files }, null, 2));
963
+ }
964
+ catch {
965
+ /* non-fatal: worst case, the next pull treats the folder as having local edits */
966
+ }
967
+ }
968
+ // True if the folder has edits relative to its last pull — or if we can't tell (no
969
+ // manifest), in which case we err on the side of preserving whatever's there.
970
+ function hasLocalPullEdits(dir) {
971
+ let manifest;
972
+ try {
973
+ manifest = JSON.parse(fs.readFileSync(path.join(dir, PULL_MANIFEST), "utf-8"));
974
+ }
975
+ catch {
976
+ return true; // no/unreadable manifest — can't verify, treat as having local edits
977
+ }
978
+ const recorded = manifest.files || {};
979
+ // A tracked file the last pull didn't write is a local addition.
980
+ for (const rel of listPulledFiles(dir)) {
981
+ if (!(rel in recorded))
982
+ return true;
983
+ }
984
+ // A recorded file that's now missing or changed is a local edit.
985
+ for (const rel of Object.keys(recorded)) {
986
+ const abs = path.join(dir, rel);
987
+ if (!fs.existsSync(abs))
988
+ return true;
989
+ try {
990
+ if (hashPulledFile(abs) !== recorded[rel])
991
+ return true;
992
+ }
993
+ catch {
994
+ return true;
995
+ }
996
+ }
997
+ return false;
998
+ }
999
+ // Copy the current source (minus node_modules/.git) to a sibling backup folder so a
1000
+ // forced re-pull never destroys un-pulled work. Returns the backup path.
1001
+ function backupPulledSource(dir) {
1002
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
1003
+ let backup = `${dir}.local-backup-${stamp}`;
1004
+ let n = 1;
1005
+ while (fs.existsSync(backup))
1006
+ backup = `${dir}.local-backup-${stamp}-${n++}`;
1007
+ fs.cpSync(dir, backup, {
1008
+ recursive: true,
1009
+ filter: (src) => {
1010
+ const rel = path.relative(dir, src);
1011
+ if (!rel)
1012
+ return true;
1013
+ // Keep everything except dependencies, VCS, and the manifest; do keep .env* so the backup is complete.
1014
+ const top = rel.split(path.sep)[0];
1015
+ if (top === "node_modules" || top === ".git" || rel === PULL_MANIFEST)
1016
+ return false;
1017
+ return true;
1018
+ },
1019
+ });
1020
+ return backup;
1021
+ }
1022
+ async function promptPullConflict() {
1023
+ 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)");
1024
+ const a = answer.trim().toLowerCase();
1025
+ if (a === "r" || a === "replace")
1026
+ return "replace";
1027
+ if (a === "b" || a === "backup")
1028
+ return "backup";
1029
+ return "keep";
1030
+ }
914
1031
  async function pull(slug) {
915
1032
  if (!slug) {
916
- console.error("\n Usage: npx @overscore/cli pull <dashboard-slug> [--version N] [--force]\n");
1033
+ console.error("\n Usage: npx @overscore/cli pull <dashboard-slug> [--project <slug>] [--version N] [--force]\n");
917
1034
  console.error(" Example:");
918
1035
  console.error(" npx @overscore/cli pull my-dashboard\n");
919
1036
  process.exit(1);
@@ -923,26 +1040,38 @@ async function pull(slug) {
923
1040
  const versionParam = versionIndex !== -1 && process.argv[versionIndex + 1]
924
1041
  ? process.argv[versionIndex + 1]
925
1042
  : 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.
1043
+ // --force skips the interactive keep/replace/backup prompt for non-interactive re-pulls
1044
+ // (scripted rebuilds / `workspace sync`). It is NOT a license to destroy: if the folder has
1045
+ // un-pulled local edits, the pull backs them up before overwriting (see below).
929
1046
  const force = process.argv.includes("--force");
1047
+ // --project scopes the api-key mint to the right project. Optional: the dashboard slug is
1048
+ // used as a fallback, correct only when a project has a single same-named dashboard.
1049
+ const projectIndex = process.argv.indexOf("--project");
1050
+ const projectFlag = projectIndex !== -1 &&
1051
+ process.argv[projectIndex + 1] &&
1052
+ !process.argv[projectIndex + 1].startsWith("--")
1053
+ ? process.argv[projectIndex + 1]
1054
+ : null;
930
1055
  // Resolve API key: device token → auto-create os_ key, else the saved project key, else prompt.
931
1056
  let apiKey = "";
932
- const configPath = path.join(process.env.HOME || "", ".overscore", "config");
1057
+ const configPath = path.join(os.homedir(), ".overscore", "config");
933
1058
  const cfg = fs.existsSync(configPath)
934
1059
  ? parseEnv(fs.readFileSync(configPath, "utf-8"))
935
1060
  : {};
936
1061
  const deviceToken = cfg.OVERSCORE_DEVICE_TOKEN;
937
1062
  if (deviceToken) {
1063
+ // Prefer the explicit --project slug; fall back to the dashboard slug (correct only when
1064
+ // a project has a single same-named dashboard). A wrong project slug 404s and leaves
1065
+ // apiKey empty, which we handle explicitly below.
1066
+ const keyProjectSlug = projectFlag || slug;
938
1067
  try {
939
- const keyRes = await fetch(`${HUB_URL}/api/projects/${slug}/api-keys`, {
1068
+ const keyRes = await fetch(`${HUB_URL}/api/projects/${keyProjectSlug}/api-keys`, {
940
1069
  method: "POST",
941
1070
  headers: {
942
1071
  Authorization: `Bearer ${deviceToken}`,
943
1072
  "Content-Type": "application/json",
944
1073
  },
945
- body: JSON.stringify({ name: `${slug} pull key` }),
1074
+ body: JSON.stringify({ name: `${keyProjectSlug} pull key` }),
946
1075
  });
947
1076
  if (keyRes.status === 401 || keyRes.status === 403) {
948
1077
  console.error("\n Error: Device token is invalid or expired. Run 'npx @overscore/cli auth login' to re-authenticate.\n");
@@ -957,14 +1086,26 @@ async function pull(slug) {
957
1086
  // Network error — fall through to the saved key / prompt
958
1087
  }
959
1088
  }
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).
1089
+ // Fall back to the project API key saved by `auth login`, if any: it authorizes every
1090
+ // dashboard in the logged-in project.
965
1091
  if (!apiKey && cfg.OVERSCORE_API_KEY) {
966
1092
  apiKey = cfg.OVERSCORE_API_KEY;
967
1093
  }
1094
+ // A logged-in (device-token) user whose mint failed and who has no saved project key gets a
1095
+ // clear, actionable error instead of a raw "paste an API key" prompt they have no key for.
1096
+ // The interactive prompt below is reserved for the pure API-key path (no device token).
1097
+ if (!apiKey && deviceToken) {
1098
+ if (!projectFlag) {
1099
+ console.error(`\n Error: Couldn't resolve the project for dashboard "${slug}".`);
1100
+ console.error(` If it belongs to a multi-dashboard project, pass the project slug:`);
1101
+ console.error(` npx @overscore/cli pull ${slug} --project <project-slug>\n`);
1102
+ }
1103
+ else {
1104
+ console.error(`\n Error: Couldn't create a key for project "${projectFlag}".`);
1105
+ console.error(` Check the project slug and that you have access to it.\n`);
1106
+ }
1107
+ process.exit(1);
1108
+ }
968
1109
  if (!apiKey) {
969
1110
  console.log(" Tip: Run 'npx @overscore/cli auth login' to authenticate once and skip this step.");
970
1111
  apiKey = await prompt("API key (from the Hub):");
@@ -1001,15 +1142,31 @@ async function pull(slug) {
1001
1142
  const zipBuffer = Buffer.from(await res.arrayBuffer());
1002
1143
  // Extract to ./<slug>/
1003
1144
  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;
1145
+ const dirExists = fs.existsSync(targetDir);
1146
+ // Guard against destroying un-pulled local edits before we overwrite the folder.
1147
+ if (dirExists && hasLocalPullEdits(targetDir)) {
1148
+ if (force) {
1149
+ // Non-interactive re-pull (workspace sync / scripts): never destroy silently —
1150
+ // snapshot the local edits first, then proceed.
1151
+ const backup = backupPulledSource(targetDir);
1152
+ console.log(`\n Local changes detected — backed them up to:\n ${backup}`);
1153
+ }
1154
+ else {
1155
+ const choice = await promptPullConflict();
1156
+ if (choice === "keep") {
1157
+ console.log(" Cancelled — your local files are untouched.\n");
1158
+ return;
1159
+ }
1160
+ if (choice === "backup") {
1161
+ const backup = backupPulledSource(targetDir);
1162
+ console.log(` Backed up your changes to:\n ${backup}`);
1163
+ }
1164
+ // "replace" → fall through to the overwrite
1009
1165
  }
1010
1166
  }
1167
+ // A clean folder (matches its last pull) is safe to overwrite with no prompt.
1011
1168
  // Clean target directory (preserve node_modules and .env)
1012
- if (fs.existsSync(targetDir)) {
1169
+ if (dirExists) {
1013
1170
  for (const entry of fs.readdirSync(targetDir)) {
1014
1171
  if (entry === "node_modules" || entry.startsWith(".env"))
1015
1172
  continue;
@@ -1077,6 +1234,9 @@ VITE_OVERSCORE_API_URL=${HUB_URL}/api
1077
1234
  }
1078
1235
  // Pull latest platform rules (includes save-knowledge skill)
1079
1236
  await syncPlatformRules(targetDir, baseUrl, apiKey);
1237
+ // Record what this pull wrote so a future re-pull can detect local edits before
1238
+ // overwriting them (see hasLocalPullEdits / the --force backup above).
1239
+ writePullManifest(targetDir, version);
1080
1240
  // Print context summary so the developer knows what Claude has access to
1081
1241
  const knowledgeDir = path.join(targetDir, ".claude", "knowledge");
1082
1242
  const knowledgeFiles = fs.existsSync(knowledgeDir)
@@ -1178,7 +1338,7 @@ async function listDashboards() {
1178
1338
  // project-scoped API key, so it can enumerate EVERY project on the account —
1179
1339
  // including empty ones a project key could never see.
1180
1340
  async function listProjects() {
1181
- const configPath = path.join(process.env.HOME || "", ".overscore", "config");
1341
+ const configPath = path.join(os.homedir(), ".overscore", "config");
1182
1342
  const readToken = () => {
1183
1343
  const cfg = fs.existsSync(configPath)
1184
1344
  ? parseEnv(fs.readFileSync(configPath, "utf-8"))
@@ -1334,7 +1494,7 @@ async function loadAnalysisConfig() {
1334
1494
  }
1335
1495
  }
1336
1496
  // (2) ~/.overscore/config
1337
- const configDir = path.join(process.env.HOME || "", ".overscore");
1497
+ const configDir = path.join(os.homedir(), ".overscore");
1338
1498
  const configPath = path.join(configDir, "config");
1339
1499
  if (fs.existsSync(configPath)) {
1340
1500
  const cfg = parseEnv(fs.readFileSync(configPath, "utf-8"));
@@ -2722,7 +2882,7 @@ async function dev() {
2722
2882
  // "Invalid API key".
2723
2883
  assertHubMatchesEnv();
2724
2884
  // Get device token from global config
2725
- const configPath = path.join(process.env.HOME || "", ".overscore", "config");
2885
+ const configPath = path.join(os.homedir(), ".overscore", "config");
2726
2886
  if (!fs.existsSync(configPath)) {
2727
2887
  console.error("\n Error: Not authenticated. Run 'npx @overscore/cli auth login' first.\n");
2728
2888
  process.exit(1);
@@ -2819,7 +2979,7 @@ async function dev() {
2819
2979
  }
2820
2980
  // ── auth ────────────────────────────────────────────────────────────
2821
2981
  async function authStatus() {
2822
- const configPath = path.join(process.env.HOME || "", ".overscore", "config");
2982
+ const configPath = path.join(os.homedir(), ".overscore", "config");
2823
2983
  if (fs.existsSync(configPath)) {
2824
2984
  const cfg = parseEnv(fs.readFileSync(configPath, "utf-8"));
2825
2985
  const deviceToken = cfg.OVERSCORE_DEVICE_TOKEN;
@@ -2845,7 +3005,7 @@ async function authStatus() {
2845
3005
  `);
2846
3006
  }
2847
3007
  async function authLogin() {
2848
- const configDir = path.join(process.env.HOME || "", ".overscore");
3008
+ const configDir = path.join(os.homedir(), ".overscore");
2849
3009
  const configPath = path.join(configDir, "config");
2850
3010
  const state = randomBytes(16).toString("hex");
2851
3011
  // Find a free port in range 9876–9886
@@ -2924,11 +3084,13 @@ async function authLogin() {
2924
3084
  const { token: deviceToken, project: projectSlug } = await callbackPromise;
2925
3085
  clearTimeout(timeout);
2926
3086
  server.close();
2927
- // Preserve any existing os_ API key so dashboard .env fallback still works
3087
+ // Preserve any existing os_ API key + saved tool preference so re-login doesn't drop them
2928
3088
  let existingApiKey = "";
3089
+ let existingTool = "";
2929
3090
  if (fs.existsSync(configPath)) {
2930
3091
  const existing = parseEnv(fs.readFileSync(configPath, "utf-8"));
2931
3092
  existingApiKey = existing.OVERSCORE_API_KEY || "";
3093
+ existingTool = existing.OVERSCORE_TOOL || "";
2932
3094
  }
2933
3095
  fs.mkdirSync(configDir, { recursive: true });
2934
3096
  const lines = [
@@ -2939,6 +3101,8 @@ async function authLogin() {
2939
3101
  lines.push(`OVERSCORE_PROJECT_SLUG=${projectSlug}`);
2940
3102
  if (existingApiKey)
2941
3103
  lines.push(`OVERSCORE_API_KEY=${existingApiKey}`);
3104
+ if (existingTool)
3105
+ lines.push(`OVERSCORE_TOOL=${existingTool}`);
2942
3106
  fs.writeFileSync(configPath, lines.join("\n") + "\n", { mode: 0o600 });
2943
3107
  console.log(`
2944
3108
  Authenticated!
@@ -2949,7 +3113,7 @@ async function authLogin() {
2949
3113
  `);
2950
3114
  }
2951
3115
  async function authLogout() {
2952
- const configPath = path.join(process.env.HOME || "", ".overscore", "config");
3116
+ const configPath = path.join(os.homedir(), ".overscore", "config");
2953
3117
  if (fs.existsSync(configPath)) {
2954
3118
  // Attempt server-side revocation of the device token (non-fatal)
2955
3119
  try {
@@ -2983,7 +3147,7 @@ async function installSkills() {
2983
3147
  console.error(`\n Error: Bundled skills directory not found at ${skillsSrc}\n`);
2984
3148
  process.exit(1);
2985
3149
  }
2986
- const homeSkills = path.join(process.env.HOME || "", ".claude", "skills");
3150
+ const homeSkills = path.join(os.homedir(), ".claude", "skills");
2987
3151
  fs.mkdirSync(homeSkills, { recursive: true });
2988
3152
  const installed = [];
2989
3153
  for (const entry of fs.readdirSync(skillsSrc, { withFileTypes: true })) {
@@ -3030,7 +3194,7 @@ async function templatePull(slug) {
3030
3194
  res = await fetch(url, {
3031
3195
  // Optional: attach device token if available, for attribution in install counting
3032
3196
  headers: (() => {
3033
- const configPath = path.join(process.env.HOME || "", ".overscore", "config");
3197
+ const configPath = path.join(os.homedir(), ".overscore", "config");
3034
3198
  if (fs.existsSync(configPath)) {
3035
3199
  const cfg = parseEnv(fs.readFileSync(configPath, "utf-8"));
3036
3200
  if (cfg.OVERSCORE_DEVICE_TOKEN) {
@@ -3101,6 +3265,263 @@ async function templatePull(slug) {
3101
3265
  // ── Routing ─────────────────────────────────────────────────────────
3102
3266
  const command = process.argv[2];
3103
3267
  const subcommand = process.argv[3];
3268
+ const OPEN_TOOL_LABELS = {
3269
+ code: "VS Code",
3270
+ cursor: "Cursor",
3271
+ claude: "Claude Code (terminal)",
3272
+ codex: "Codex",
3273
+ };
3274
+ const OPEN_TOOLS = ["code", "cursor", "claude", "codex"];
3275
+ const DASHBOARD_STARTER = "Build me a professional dashboard for this data.";
3276
+ const WORKSPACE_STARTER = "Read the catalog first, then help me build or update a dashboard for this client.";
3277
+ function openCmdOnPath(cmd) {
3278
+ try {
3279
+ execSync(process.platform === "win32" ? `where ${cmd}` : `command -v ${cmd}`, {
3280
+ stdio: "ignore",
3281
+ });
3282
+ return true;
3283
+ }
3284
+ catch {
3285
+ return false;
3286
+ }
3287
+ }
3288
+ function detectOpenTools() {
3289
+ return OPEN_TOOLS.filter((t) => openCmdOnPath(t));
3290
+ }
3291
+ function saveConfigValue(key, value) {
3292
+ const dir = path.join(os.homedir(), ".overscore");
3293
+ const configPath = path.join(dir, "config");
3294
+ const cfg = fs.existsSync(configPath)
3295
+ ? parseEnv(fs.readFileSync(configPath, "utf-8"))
3296
+ : {};
3297
+ cfg[key] = value;
3298
+ fs.mkdirSync(dir, { recursive: true });
3299
+ fs.writeFileSync(configPath, Object.entries(cfg)
3300
+ .map(([k, v]) => `${k}=${v}`)
3301
+ .join("\n") + "\n", { mode: 0o600 });
3302
+ }
3303
+ // Which tool to open in: an explicit --tool wins, then a remembered choice, then detection
3304
+ // (one installed → use it; several → ask once and remember). Null if nothing is installed.
3305
+ async function chooseOpenTool(explicit) {
3306
+ if (explicit && OPEN_TOOLS.includes(explicit))
3307
+ return explicit;
3308
+ const configPath = path.join(os.homedir(), ".overscore", "config");
3309
+ const cfg = fs.existsSync(configPath)
3310
+ ? parseEnv(fs.readFileSync(configPath, "utf-8"))
3311
+ : {};
3312
+ const saved = cfg.OVERSCORE_TOOL;
3313
+ if (saved && OPEN_TOOLS.includes(saved))
3314
+ return saved;
3315
+ const available = detectOpenTools();
3316
+ if (available.length === 0)
3317
+ return null;
3318
+ let chosen;
3319
+ if (available.length === 1) {
3320
+ chosen = available[0];
3321
+ }
3322
+ else {
3323
+ console.log("\n Which tool do you build with?");
3324
+ available.forEach((t, i) => console.log(` ${i + 1}) ${OPEN_TOOL_LABELS[t]}`));
3325
+ const ans = await prompt(`Choose 1-${available.length}:`);
3326
+ chosen = available[parseInt(ans, 10) - 1] ?? available[0];
3327
+ }
3328
+ saveConfigValue("OVERSCORE_TOOL", chosen);
3329
+ return chosen;
3330
+ }
3331
+ function launchUrl(url) {
3332
+ try {
3333
+ if (process.platform === "darwin")
3334
+ execSync(`open '${url}'`);
3335
+ else if (process.platform === "win32")
3336
+ execSync(`start "" "${url}"`);
3337
+ else
3338
+ execSync(`xdg-open '${url}'`);
3339
+ }
3340
+ catch {
3341
+ /* best effort */
3342
+ }
3343
+ }
3344
+ // Open a folder in the chosen tool. GUI editors open cleanly by spawning their launcher;
3345
+ // terminal agents can't be launched cleanly everywhere, so we hand back a copy-paste hint.
3346
+ function openFolderInTool(tool, dir, starter) {
3347
+ try {
3348
+ if (tool === "code") {
3349
+ execSync(`code -r "${dir}"`, { stdio: "ignore" });
3350
+ return { opened: true, hint: "" };
3351
+ }
3352
+ if (tool === "cursor") {
3353
+ execSync(`cursor "${dir}"`, { stdio: "ignore" });
3354
+ return { opened: true, hint: "" };
3355
+ }
3356
+ if (tool === "claude") {
3357
+ // Opens a terminal running claude in the folder (pre-fills, does not send, the prompt).
3358
+ // The handler only registers after the first interactive claude session, so also give
3359
+ // the manual fallback.
3360
+ launchUrl(`claude-cli://open?cwd=${encodeURIComponent(dir)}&q=${encodeURIComponent(starter)}`);
3361
+ return { opened: true, hint: `If Claude didn't open, run: cd "${dir}" && claude` };
3362
+ }
3363
+ // codex: no folder deep link — hand over the exact command.
3364
+ return { opened: false, hint: `cd "${dir}" && codex` };
3365
+ }
3366
+ catch {
3367
+ return { opened: false, hint: `Open "${dir}" in ${OPEN_TOOL_LABELS[tool]} to start building.` };
3368
+ }
3369
+ }
3370
+ function runOpenChild(cmd, cwd) {
3371
+ try {
3372
+ execSync(cmd, { cwd, stdio: "inherit" });
3373
+ }
3374
+ catch {
3375
+ console.error(`\n Error: setup step failed:\n ${cmd}\n`);
3376
+ process.exit(1);
3377
+ }
3378
+ }
3379
+ async function probeDashboard(deviceToken, projectSlug, dashboardSlug) {
3380
+ let apiKey = "";
3381
+ try {
3382
+ const keyRes = await fetch(`${HUB_URL}/api/projects/${projectSlug}/api-keys`, {
3383
+ method: "POST",
3384
+ headers: { Authorization: `Bearer ${deviceToken}`, "Content-Type": "application/json" },
3385
+ body: JSON.stringify({ name: `${projectSlug} open key` }),
3386
+ });
3387
+ if (keyRes.status === 401 || keyRes.status === 403)
3388
+ return { state: "unauthorized", apiKey };
3389
+ if (!keyRes.ok)
3390
+ return { state: "error", apiKey };
3391
+ apiKey = (await keyRes.json()).raw_key || "";
3392
+ }
3393
+ catch {
3394
+ return { state: "error", apiKey };
3395
+ }
3396
+ if (!apiKey)
3397
+ return { state: "error", apiKey };
3398
+ try {
3399
+ const vRes = await fetch(`${HUB_URL}/api/dashboards/${dashboardSlug}/versions`, {
3400
+ headers: { Authorization: `Bearer ${apiKey}` },
3401
+ });
3402
+ if (vRes.status === 401 || vRes.status === 403)
3403
+ return { state: "unauthorized", apiKey };
3404
+ if (vRes.status === 404)
3405
+ return { state: "new", apiKey };
3406
+ if (!vRes.ok)
3407
+ return { state: "error", apiKey };
3408
+ const data = (await vRes.json());
3409
+ const deployed = Array.isArray(data.versions) && data.versions.length > 0;
3410
+ return { state: deployed ? "deployed" : "new", apiKey };
3411
+ }
3412
+ catch {
3413
+ return { state: "error", apiKey };
3414
+ }
3415
+ }
3416
+ function finishOpen(res, dir) {
3417
+ console.log(res.opened ? `\n Opened in your tool:\n ${dir}` : `\n Ready at:\n ${dir}`);
3418
+ if (res.hint)
3419
+ console.log(`\n ${res.hint}`);
3420
+ console.log(`\n Tell your AI tool what to build. When it looks right, say "deploy it".\n`);
3421
+ }
3422
+ async function open(dashboardSlug) {
3423
+ if (!dashboardSlug || dashboardSlug.startsWith("--")) {
3424
+ console.error("\n Usage: npx @overscore/cli open <dashboard-slug> --project <slug> [--tool code|cursor|claude|codex]\n");
3425
+ process.exit(1);
3426
+ }
3427
+ const projIdx = process.argv.indexOf("--project");
3428
+ const projectSlug = projIdx !== -1 && process.argv[projIdx + 1] && !process.argv[projIdx + 1].startsWith("--")
3429
+ ? process.argv[projIdx + 1]
3430
+ : null;
3431
+ if (!projectSlug) {
3432
+ console.error(`\n Error: --project <slug> is required.`);
3433
+ console.error(` npx @overscore/cli open ${dashboardSlug} --project <project-slug>\n`);
3434
+ process.exit(1);
3435
+ }
3436
+ // Slugs get interpolated into shell commands and filesystem paths below, so validate them
3437
+ // strictly (lowercase alphanumeric + hyphens) — this also blocks command injection.
3438
+ const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
3439
+ if (!SLUG_RE.test(dashboardSlug) || !SLUG_RE.test(projectSlug)) {
3440
+ console.error("\n Error: project and dashboard slugs must be lowercase letters, numbers, and hyphens.\n");
3441
+ process.exit(1);
3442
+ }
3443
+ const toolIdx = process.argv.indexOf("--tool");
3444
+ const toolFlag = toolIdx !== -1 ? process.argv[toolIdx + 1] : undefined;
3445
+ // 1. Ensure a device token (browser login if needed) — the same file every tool reads.
3446
+ const configPath = path.join(os.homedir(), ".overscore", "config");
3447
+ const readToken = () => (fs.existsSync(configPath) ? parseEnv(fs.readFileSync(configPath, "utf-8")) : {})
3448
+ .OVERSCORE_DEVICE_TOKEN;
3449
+ let deviceToken = readToken();
3450
+ if (!deviceToken) {
3451
+ console.log("\n No session found. Signing you in...\n");
3452
+ await authLogin();
3453
+ deviceToken = readToken();
3454
+ if (!deviceToken) {
3455
+ console.error("\n Could not authenticate.\n");
3456
+ process.exit(1);
3457
+ }
3458
+ }
3459
+ // 2. Coexistence: if a dbt workspace already owns this project, open it there instead of
3460
+ // making a second, divergent copy.
3461
+ const existing = getHome(projectSlug);
3462
+ if (existing?.mode === "workspace") {
3463
+ console.log(`\n Project "${projectSlug}" is managed by a workspace at:\n ${existing.path}`);
3464
+ const tool = await chooseOpenTool(toolFlag);
3465
+ if (!tool) {
3466
+ console.log(`\n Install an AI coding tool, then open that folder to build.\n`);
3467
+ return;
3468
+ }
3469
+ finishOpen(openFolderInTool(tool, existing.path, WORKSPACE_STARTER), existing.path);
3470
+ return;
3471
+ }
3472
+ const dashDir = managedDashboardDir(projectSlug, dashboardSlug);
3473
+ const parentDir = managedOverscoreDir(projectSlug);
3474
+ // A folder counts as "ready" only if a complete scaffold/pull left a package.json — a dir
3475
+ // that exists without one is a half-finished setup (Ctrl-C, crash, lost network) we redo.
3476
+ const isReady = (d) => fs.existsSync(path.join(d, "package.json"));
3477
+ // 3. Make sure the source is present locally.
3478
+ if (isReady(dashDir)) {
3479
+ console.log(`\n Opening your local copy of "${dashboardSlug}"...`);
3480
+ }
3481
+ else {
3482
+ // Clear away any half-written folder left by a previous interrupted run.
3483
+ if (fs.existsSync(dashDir))
3484
+ fs.rmSync(dashDir, { recursive: true, force: true });
3485
+ fs.mkdirSync(parentDir, { recursive: true });
3486
+ let probe = await probeDashboard(deviceToken, projectSlug, dashboardSlug);
3487
+ if (probe.state === "unauthorized") {
3488
+ console.log("\n Your session expired. Signing you in again...\n");
3489
+ await authLogin();
3490
+ deviceToken = readToken() || deviceToken;
3491
+ probe = await probeDashboard(deviceToken, projectSlug, dashboardSlug);
3492
+ }
3493
+ if (probe.state === "error" || probe.state === "unauthorized") {
3494
+ console.error(`\n Error: couldn't reach the Hub for project "${projectSlug}".`);
3495
+ console.error(` Check the project slug and your access, then try again.\n`);
3496
+ process.exit(1);
3497
+ }
3498
+ const selfCli = process.argv[1];
3499
+ if (probe.state === "deployed") {
3500
+ console.log(`\n Fetching "${dashboardSlug}" from the Hub...`);
3501
+ runOpenChild(`node "${selfCli}" pull ${dashboardSlug} --project ${projectSlug} --force`, parentDir);
3502
+ }
3503
+ else {
3504
+ console.log(`\n Setting up a new dashboard "${dashboardSlug}"...`);
3505
+ // Reuse the key we already minted so the scaffold doesn't mint a second one.
3506
+ const keyArg = probe.apiKey ? ` --key ${probe.apiKey}` : "";
3507
+ runOpenChild(`npx --yes create-overscore --dashboard ${dashboardSlug} --project ${projectSlug}${keyArg}`, parentDir);
3508
+ }
3509
+ if (!isReady(dashDir)) {
3510
+ console.error(`\n Error: setup did not complete for:\n ${dashDir}\n`);
3511
+ process.exit(1);
3512
+ }
3513
+ }
3514
+ // 4. Record the project's home so re-opens and workspace graduation stay coherent.
3515
+ setHome(projectSlug, { path: managedProjectDir(projectSlug), mode: "flat" });
3516
+ // 5. Open it in the user's tool.
3517
+ const tool = await chooseOpenTool(toolFlag);
3518
+ if (!tool) {
3519
+ console.log(`\n Your dashboard is ready at:\n ${dashDir}`);
3520
+ console.log(`\n Install Claude Code, Codex, VS Code, or Cursor, then open that folder to build.\n`);
3521
+ return;
3522
+ }
3523
+ finishOpen(openFolderInTool(tool, dashDir, DASHBOARD_STARTER), dashDir);
3524
+ }
3104
3525
  if (command === "dev") {
3105
3526
  dev();
3106
3527
  }
@@ -3110,6 +3531,9 @@ else if (command === "deploy") {
3110
3531
  else if (command === "pull") {
3111
3532
  pull(process.argv[3]);
3112
3533
  }
3534
+ else if (command === "open") {
3535
+ open(process.argv[3]);
3536
+ }
3113
3537
  else if (command === "versions") {
3114
3538
  versions();
3115
3539
  }
@@ -3281,6 +3705,7 @@ else {
3281
3705
  auth Check authentication status
3282
3706
  auth login Authenticate via browser (one time per machine)
3283
3707
  auth logout Remove saved credentials
3708
+ open <slug> Set up or fetch a dashboard and open it in your AI tool
3284
3709
  dev Start the dev server (injects auth automatically)
3285
3710
  list List all dashboards in the project
3286
3711
  projects List every project on your account
@@ -3294,6 +3719,7 @@ else {
3294
3719
 
3295
3720
  Usage:
3296
3721
  npx @overscore/cli auth login
3722
+ npx @overscore/cli open <slug> --project <project-slug>
3297
3723
  npx @overscore/cli dev
3298
3724
  npx @overscore/cli list
3299
3725
  npx @overscore/cli projects
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@overscore/cli",
3
- "version": "0.13.20",
3
+ "version": "0.13.22",
4
4
  "description": "CLI for deploying Overscore dashboards and publishing analyses",
5
5
  "bin": {
6
6
  "overscore": "dist/index.js"