@overscore/cli 0.13.21 → 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 +281 -14
  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"))
@@ -1053,7 +1054,7 @@ async function pull(slug) {
1053
1054
  : null;
1054
1055
  // Resolve API key: device token → auto-create os_ key, else the saved project key, else prompt.
1055
1056
  let apiKey = "";
1056
- const configPath = path.join(process.env.HOME || "", ".overscore", "config");
1057
+ const configPath = path.join(os.homedir(), ".overscore", "config");
1057
1058
  const cfg = fs.existsSync(configPath)
1058
1059
  ? parseEnv(fs.readFileSync(configPath, "utf-8"))
1059
1060
  : {};
@@ -1337,7 +1338,7 @@ async function listDashboards() {
1337
1338
  // project-scoped API key, so it can enumerate EVERY project on the account —
1338
1339
  // including empty ones a project key could never see.
1339
1340
  async function listProjects() {
1340
- const configPath = path.join(process.env.HOME || "", ".overscore", "config");
1341
+ const configPath = path.join(os.homedir(), ".overscore", "config");
1341
1342
  const readToken = () => {
1342
1343
  const cfg = fs.existsSync(configPath)
1343
1344
  ? parseEnv(fs.readFileSync(configPath, "utf-8"))
@@ -1493,7 +1494,7 @@ async function loadAnalysisConfig() {
1493
1494
  }
1494
1495
  }
1495
1496
  // (2) ~/.overscore/config
1496
- const configDir = path.join(process.env.HOME || "", ".overscore");
1497
+ const configDir = path.join(os.homedir(), ".overscore");
1497
1498
  const configPath = path.join(configDir, "config");
1498
1499
  if (fs.existsSync(configPath)) {
1499
1500
  const cfg = parseEnv(fs.readFileSync(configPath, "utf-8"));
@@ -2881,7 +2882,7 @@ async function dev() {
2881
2882
  // "Invalid API key".
2882
2883
  assertHubMatchesEnv();
2883
2884
  // Get device token from global config
2884
- const configPath = path.join(process.env.HOME || "", ".overscore", "config");
2885
+ const configPath = path.join(os.homedir(), ".overscore", "config");
2885
2886
  if (!fs.existsSync(configPath)) {
2886
2887
  console.error("\n Error: Not authenticated. Run 'npx @overscore/cli auth login' first.\n");
2887
2888
  process.exit(1);
@@ -2978,7 +2979,7 @@ async function dev() {
2978
2979
  }
2979
2980
  // ── auth ────────────────────────────────────────────────────────────
2980
2981
  async function authStatus() {
2981
- const configPath = path.join(process.env.HOME || "", ".overscore", "config");
2982
+ const configPath = path.join(os.homedir(), ".overscore", "config");
2982
2983
  if (fs.existsSync(configPath)) {
2983
2984
  const cfg = parseEnv(fs.readFileSync(configPath, "utf-8"));
2984
2985
  const deviceToken = cfg.OVERSCORE_DEVICE_TOKEN;
@@ -3004,7 +3005,7 @@ async function authStatus() {
3004
3005
  `);
3005
3006
  }
3006
3007
  async function authLogin() {
3007
- const configDir = path.join(process.env.HOME || "", ".overscore");
3008
+ const configDir = path.join(os.homedir(), ".overscore");
3008
3009
  const configPath = path.join(configDir, "config");
3009
3010
  const state = randomBytes(16).toString("hex");
3010
3011
  // Find a free port in range 9876–9886
@@ -3083,11 +3084,13 @@ async function authLogin() {
3083
3084
  const { token: deviceToken, project: projectSlug } = await callbackPromise;
3084
3085
  clearTimeout(timeout);
3085
3086
  server.close();
3086
- // 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
3087
3088
  let existingApiKey = "";
3089
+ let existingTool = "";
3088
3090
  if (fs.existsSync(configPath)) {
3089
3091
  const existing = parseEnv(fs.readFileSync(configPath, "utf-8"));
3090
3092
  existingApiKey = existing.OVERSCORE_API_KEY || "";
3093
+ existingTool = existing.OVERSCORE_TOOL || "";
3091
3094
  }
3092
3095
  fs.mkdirSync(configDir, { recursive: true });
3093
3096
  const lines = [
@@ -3098,6 +3101,8 @@ async function authLogin() {
3098
3101
  lines.push(`OVERSCORE_PROJECT_SLUG=${projectSlug}`);
3099
3102
  if (existingApiKey)
3100
3103
  lines.push(`OVERSCORE_API_KEY=${existingApiKey}`);
3104
+ if (existingTool)
3105
+ lines.push(`OVERSCORE_TOOL=${existingTool}`);
3101
3106
  fs.writeFileSync(configPath, lines.join("\n") + "\n", { mode: 0o600 });
3102
3107
  console.log(`
3103
3108
  Authenticated!
@@ -3108,7 +3113,7 @@ async function authLogin() {
3108
3113
  `);
3109
3114
  }
3110
3115
  async function authLogout() {
3111
- const configPath = path.join(process.env.HOME || "", ".overscore", "config");
3116
+ const configPath = path.join(os.homedir(), ".overscore", "config");
3112
3117
  if (fs.existsSync(configPath)) {
3113
3118
  // Attempt server-side revocation of the device token (non-fatal)
3114
3119
  try {
@@ -3142,7 +3147,7 @@ async function installSkills() {
3142
3147
  console.error(`\n Error: Bundled skills directory not found at ${skillsSrc}\n`);
3143
3148
  process.exit(1);
3144
3149
  }
3145
- const homeSkills = path.join(process.env.HOME || "", ".claude", "skills");
3150
+ const homeSkills = path.join(os.homedir(), ".claude", "skills");
3146
3151
  fs.mkdirSync(homeSkills, { recursive: true });
3147
3152
  const installed = [];
3148
3153
  for (const entry of fs.readdirSync(skillsSrc, { withFileTypes: true })) {
@@ -3189,7 +3194,7 @@ async function templatePull(slug) {
3189
3194
  res = await fetch(url, {
3190
3195
  // Optional: attach device token if available, for attribution in install counting
3191
3196
  headers: (() => {
3192
- const configPath = path.join(process.env.HOME || "", ".overscore", "config");
3197
+ const configPath = path.join(os.homedir(), ".overscore", "config");
3193
3198
  if (fs.existsSync(configPath)) {
3194
3199
  const cfg = parseEnv(fs.readFileSync(configPath, "utf-8"));
3195
3200
  if (cfg.OVERSCORE_DEVICE_TOKEN) {
@@ -3260,6 +3265,263 @@ async function templatePull(slug) {
3260
3265
  // ── Routing ─────────────────────────────────────────────────────────
3261
3266
  const command = process.argv[2];
3262
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
+ }
3263
3525
  if (command === "dev") {
3264
3526
  dev();
3265
3527
  }
@@ -3269,6 +3531,9 @@ else if (command === "deploy") {
3269
3531
  else if (command === "pull") {
3270
3532
  pull(process.argv[3]);
3271
3533
  }
3534
+ else if (command === "open") {
3535
+ open(process.argv[3]);
3536
+ }
3272
3537
  else if (command === "versions") {
3273
3538
  versions();
3274
3539
  }
@@ -3440,6 +3705,7 @@ else {
3440
3705
  auth Check authentication status
3441
3706
  auth login Authenticate via browser (one time per machine)
3442
3707
  auth logout Remove saved credentials
3708
+ open <slug> Set up or fetch a dashboard and open it in your AI tool
3443
3709
  dev Start the dev server (injects auth automatically)
3444
3710
  list List all dashboards in the project
3445
3711
  projects List every project on your account
@@ -3453,6 +3719,7 @@ else {
3453
3719
 
3454
3720
  Usage:
3455
3721
  npx @overscore/cli auth login
3722
+ npx @overscore/cli open <slug> --project <project-slug>
3456
3723
  npx @overscore/cli dev
3457
3724
  npx @overscore/cli list
3458
3725
  npx @overscore/cli projects
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@overscore/cli",
3
- "version": "0.13.21",
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"