@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.
- package/dist/homes.js +134 -0
- package/dist/index.js +457 -31
- 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"))
|
|
@@ -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
|
|
927
|
-
//
|
|
928
|
-
//
|
|
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(
|
|
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/${
|
|
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: `${
|
|
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
|
-
//
|
|
961
|
-
//
|
|
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
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
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 (
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|