@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.
- package/dist/homes.js +134 -0
- package/dist/index.js +281 -14
- 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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|