@overscore/cli 0.10.2 → 0.11.1
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/index.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from "fs";
|
|
3
3
|
import path from "path";
|
|
4
|
+
import http from "http";
|
|
5
|
+
import net from "net";
|
|
4
6
|
import { execSync } from "child_process";
|
|
5
7
|
import { createInterface } from "readline";
|
|
6
|
-
import { createHash } from "crypto";
|
|
8
|
+
import { createHash, randomBytes } from "crypto";
|
|
7
9
|
import yauzl from "yauzl";
|
|
8
10
|
// ── Shared helpers ──────────────────────────────────────────────────
|
|
9
11
|
function parseEnv(content) {
|
|
@@ -266,27 +268,20 @@ async function deploy() {
|
|
|
266
268
|
const baseUrl = apiUrl.replace(/\/api$/, "");
|
|
267
269
|
// Upload knowledge base (project-level, shared across all artifacts)
|
|
268
270
|
await uploadKnowledgeBase(process.cwd(), baseUrl, projectSlug, apiKey);
|
|
269
|
-
// Sync
|
|
270
|
-
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
if (storedHash && hubHash && storedHash !== hubHash) {
|
|
284
|
-
console.log(" ⚠ Company context was updated in the Hub since you pulled.");
|
|
285
|
-
console.log(` Review at: ${baseUrl}/projects/${projectSlug}/context`);
|
|
286
|
-
console.log(" Your local changes were NOT uploaded to avoid overwriting.");
|
|
287
|
-
}
|
|
288
|
-
else {
|
|
289
|
-
const putRes = await fetch(`${baseUrl}/api/projects/${projectSlug}/context`, {
|
|
271
|
+
// Sync dashboard context — skip stub files (they have no real content)
|
|
272
|
+
const dashCtxPath = path.resolve(process.cwd(), ".claude/rules/this-dashboard.md");
|
|
273
|
+
const legacyDashCtxPath = path.resolve(process.cwd(), ".claude/rules/dashboard-context.md");
|
|
274
|
+
const activeDashCtxPath = fs.existsSync(dashCtxPath)
|
|
275
|
+
? dashCtxPath
|
|
276
|
+
: fs.existsSync(legacyDashCtxPath)
|
|
277
|
+
? legacyDashCtxPath
|
|
278
|
+
: null;
|
|
279
|
+
if (activeDashCtxPath) {
|
|
280
|
+
const content = fs.readFileSync(activeDashCtxPath, "utf-8").trim();
|
|
281
|
+
const isStub = !content || content.startsWith("<!--") || content === `# ${dashboardSlug}`;
|
|
282
|
+
if (!isStub) {
|
|
283
|
+
try {
|
|
284
|
+
const res = await fetch(`${baseUrl}/api/dashboards/${dashboardSlug}/context`, {
|
|
290
285
|
method: "PUT",
|
|
291
286
|
headers: {
|
|
292
287
|
Authorization: `Bearer ${apiKey}`,
|
|
@@ -294,40 +289,17 @@ async function deploy() {
|
|
|
294
289
|
},
|
|
295
290
|
body: JSON.stringify({ context_md: content }),
|
|
296
291
|
});
|
|
297
|
-
if (
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
if (putData.context_md_hash) {
|
|
301
|
-
fs.mkdirSync(path.dirname(hashPath), { recursive: true });
|
|
302
|
-
fs.writeFileSync(hashPath, putData.context_md_hash);
|
|
303
|
-
}
|
|
304
|
-
console.log(" Company context synced.");
|
|
292
|
+
if (res.ok) {
|
|
293
|
+
const lines = content.split("\n").length;
|
|
294
|
+
console.log(` Dashboard context synced (${lines} lines).`);
|
|
305
295
|
}
|
|
306
296
|
}
|
|
297
|
+
catch {
|
|
298
|
+
// Non-fatal
|
|
299
|
+
}
|
|
307
300
|
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
// Sync dashboard context (no conflict detection needed — per-dashboard)
|
|
313
|
-
const dashCtxPath = path.resolve(process.cwd(), ".claude/rules/this-dashboard.md");
|
|
314
|
-
const legacyDashCtxPath = path.resolve(process.cwd(), ".claude/rules/dashboard-context.md");
|
|
315
|
-
const activeDashCtxPath = fs.existsSync(dashCtxPath) ? dashCtxPath : fs.existsSync(legacyDashCtxPath) ? legacyDashCtxPath : null;
|
|
316
|
-
if (activeDashCtxPath) {
|
|
317
|
-
const content = fs.readFileSync(activeDashCtxPath, "utf-8");
|
|
318
|
-
try {
|
|
319
|
-
await fetch(`${baseUrl}/api/dashboards/${dashboardSlug}/context`, {
|
|
320
|
-
method: "PUT",
|
|
321
|
-
headers: {
|
|
322
|
-
Authorization: `Bearer ${apiKey}`,
|
|
323
|
-
"Content-Type": "application/json",
|
|
324
|
-
},
|
|
325
|
-
body: JSON.stringify({ context_md: content }),
|
|
326
|
-
});
|
|
327
|
-
console.log(" Dashboard context synced.");
|
|
328
|
-
}
|
|
329
|
-
catch {
|
|
330
|
-
// Non-fatal
|
|
301
|
+
else {
|
|
302
|
+
console.log(" Dashboard context: stub, not synced — run save-knowledge to populate it.");
|
|
331
303
|
}
|
|
332
304
|
}
|
|
333
305
|
// Pull latest platform rules so the local project stays up to date
|
|
@@ -641,11 +613,43 @@ async function pull(slug) {
|
|
|
641
613
|
const versionParam = versionIndex !== -1 && process.argv[versionIndex + 1]
|
|
642
614
|
? process.argv[versionIndex + 1]
|
|
643
615
|
: null;
|
|
644
|
-
//
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
616
|
+
// Resolve API key: device token → auto-create os_ key, else prompt
|
|
617
|
+
let apiKey = "";
|
|
618
|
+
const configPath = path.join(process.env.HOME || "", ".overscore", "config");
|
|
619
|
+
if (fs.existsSync(configPath)) {
|
|
620
|
+
const cfg = parseEnv(fs.readFileSync(configPath, "utf-8"));
|
|
621
|
+
const deviceToken = cfg.OVERSCORE_DEVICE_TOKEN;
|
|
622
|
+
if (deviceToken) {
|
|
623
|
+
try {
|
|
624
|
+
const keyRes = await fetch(`${HUB_URL}/api/projects/${slug}/api-keys`, {
|
|
625
|
+
method: "POST",
|
|
626
|
+
headers: {
|
|
627
|
+
Authorization: `Bearer ${deviceToken}`,
|
|
628
|
+
"Content-Type": "application/json",
|
|
629
|
+
},
|
|
630
|
+
body: JSON.stringify({ name: `${slug} pull key` }),
|
|
631
|
+
});
|
|
632
|
+
if (keyRes.status === 401 || keyRes.status === 403) {
|
|
633
|
+
console.error("\n Error: Device token is invalid or expired. Run 'npx @overscore/cli auth login' to re-authenticate.\n");
|
|
634
|
+
process.exit(1);
|
|
635
|
+
}
|
|
636
|
+
if (keyRes.ok) {
|
|
637
|
+
const keyData = (await keyRes.json());
|
|
638
|
+
apiKey = keyData.raw_key || "";
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
catch {
|
|
642
|
+
// Network error — fall through to prompt
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
if (!apiKey) {
|
|
647
|
+
console.log(" Tip: Run 'npx @overscore/cli auth login' to authenticate once and skip this step.");
|
|
648
|
+
apiKey = await prompt("API key (from the Hub):");
|
|
649
|
+
if (!apiKey || !apiKey.startsWith("os_")) {
|
|
650
|
+
console.error("\n Error: API key should start with os_\n");
|
|
651
|
+
process.exit(1);
|
|
652
|
+
}
|
|
649
653
|
}
|
|
650
654
|
const apiUrl = "https://overscore.dev/api";
|
|
651
655
|
const baseUrl = apiUrl.replace(/\/api$/, "");
|
|
@@ -667,10 +671,10 @@ async function pull(slug) {
|
|
|
667
671
|
console.error(`\n Error: ${errorMsg}\n`);
|
|
668
672
|
process.exit(1);
|
|
669
673
|
}
|
|
670
|
-
const projectSlug = res.headers.get("X-
|
|
671
|
-
const dashboardSlug = res.headers.get("X-
|
|
672
|
-
const version = res.headers.get("X-
|
|
673
|
-
const commitMsg = res.headers.get("X-
|
|
674
|
+
const projectSlug = res.headers.get("X-OS-Project-Slug") || "";
|
|
675
|
+
const dashboardSlug = res.headers.get("X-OS-Dashboard-Slug") || slug;
|
|
676
|
+
const version = res.headers.get("X-OS-Version") || "?";
|
|
677
|
+
const commitMsg = res.headers.get("X-OS-Commit-Message") || "";
|
|
674
678
|
// Download the zip
|
|
675
679
|
const zipBuffer = Buffer.from(await res.arrayBuffer());
|
|
676
680
|
// Extract to ./<slug>/
|
|
@@ -695,68 +699,97 @@ async function pull(slug) {
|
|
|
695
699
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
696
700
|
}
|
|
697
701
|
await extractZip(zipBuffer, targetDir);
|
|
698
|
-
// Generate .env (
|
|
702
|
+
// Generate .env (no API key — 'npx @overscore/cli dev' injects it at runtime)
|
|
699
703
|
const envContent = `# Overscore Configuration
|
|
700
704
|
VITE_OVERSCORE_PROJECT_SLUG=${projectSlug}
|
|
701
705
|
VITE_OVERSCORE_DASHBOARD_SLUG=${dashboardSlug}
|
|
702
|
-
VITE_OVERSCORE_API_KEY=${apiKey}
|
|
703
706
|
VITE_OVERSCORE_API_URL=https://overscore.dev/api
|
|
704
707
|
`;
|
|
705
708
|
fs.writeFileSync(path.join(targetDir, ".env"), envContent, { mode: 0o600 });
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
const
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
709
|
+
const rulesDir = path.join(targetDir, ".claude", "rules");
|
|
710
|
+
fs.mkdirSync(rulesDir, { recursive: true });
|
|
711
|
+
// Sync project knowledge base (preferred) — or fall back to legacy company-context.md
|
|
712
|
+
const hasKnowledge = await syncKnowledgeBase(targetDir, baseUrl, projectSlug, apiKey);
|
|
713
|
+
if (!hasKnowledge) {
|
|
714
|
+
try {
|
|
715
|
+
const ctxRes = await fetch(`${baseUrl}/api/projects/${projectSlug}/context`, {
|
|
716
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
717
|
+
});
|
|
718
|
+
if (ctxRes.ok) {
|
|
719
|
+
const ctxData = (await ctxRes.json());
|
|
720
|
+
if (ctxData.context_md) {
|
|
721
|
+
fs.writeFileSync(path.join(rulesDir, "company-context.md"), ctxData.context_md);
|
|
722
|
+
if (ctxData.context_md_hash) {
|
|
723
|
+
fs.writeFileSync(path.join(rulesDir, ".context-hash"), ctxData.context_md_hash);
|
|
724
|
+
}
|
|
720
725
|
}
|
|
721
|
-
console.log(" Updated company context from Hub.");
|
|
722
726
|
}
|
|
723
727
|
}
|
|
728
|
+
catch {
|
|
729
|
+
// Non-fatal
|
|
730
|
+
}
|
|
724
731
|
}
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
}
|
|
728
|
-
// Pull dashboard context from Hub (may be newer than the source zip)
|
|
732
|
+
// Pull dashboard context from Hub
|
|
733
|
+
let dashboardContextLoaded = false;
|
|
729
734
|
try {
|
|
730
735
|
const dashCtxRes = await fetch(`${baseUrl}/api/dashboards/${dashboardSlug}/context`, {
|
|
731
736
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
732
737
|
});
|
|
733
738
|
if (dashCtxRes.ok) {
|
|
734
739
|
const dashCtxData = (await dashCtxRes.json());
|
|
735
|
-
if (dashCtxData.context_md) {
|
|
736
|
-
const rulesDir = path.join(targetDir, ".claude", "rules");
|
|
737
|
-
fs.mkdirSync(rulesDir, { recursive: true });
|
|
740
|
+
if (dashCtxData.context_md && dashCtxData.context_md.trim()) {
|
|
738
741
|
fs.writeFileSync(path.join(rulesDir, "this-dashboard.md"), dashCtxData.context_md);
|
|
739
|
-
|
|
742
|
+
dashboardContextLoaded = true;
|
|
740
743
|
}
|
|
741
744
|
}
|
|
742
745
|
}
|
|
743
746
|
catch {
|
|
744
747
|
// Non-fatal
|
|
745
748
|
}
|
|
746
|
-
//
|
|
749
|
+
// Create a stub this-dashboard.md if none came from the Hub so Claude knows to fill it in
|
|
750
|
+
if (!dashboardContextLoaded) {
|
|
751
|
+
const stubPath = path.join(rulesDir, "this-dashboard.md");
|
|
752
|
+
if (!fs.existsSync(stubPath) || fs.readFileSync(stubPath, "utf-8").trim() === "") {
|
|
753
|
+
fs.writeFileSync(stubPath, `# ${slug}\n\n<!-- Claude: fill this in as you build. What does this dashboard answer? Who is the audience? Which queries power which charts? Any non-obvious filter logic or design decisions? -->\n`);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
// Pull latest platform rules (includes save-knowledge skill)
|
|
747
757
|
await syncPlatformRules(targetDir, baseUrl, apiKey);
|
|
758
|
+
// Print context summary so the developer knows what Claude has access to
|
|
759
|
+
const knowledgeDir = path.join(targetDir, ".claude", "knowledge");
|
|
760
|
+
const knowledgeFiles = fs.existsSync(knowledgeDir)
|
|
761
|
+
? fs.readdirSync(knowledgeDir).filter((f) => f.endsWith(".md") && f !== "INDEX.md")
|
|
762
|
+
: [];
|
|
763
|
+
const dashCtxPath = path.join(rulesDir, "this-dashboard.md");
|
|
764
|
+
const dashCtxContent = fs.existsSync(dashCtxPath) ? fs.readFileSync(dashCtxPath, "utf-8").trim() : "";
|
|
765
|
+
const dashCtxIsStub = dashCtxContent.startsWith("<!--") || dashCtxContent === `# ${slug}`;
|
|
766
|
+
const sqlStylePath = path.join(rulesDir, "sql-style.md");
|
|
748
767
|
console.log(`
|
|
749
768
|
Pulled v${version}${commitMsg ? ` — "${commitMsg}"` : ""}
|
|
750
769
|
|
|
770
|
+
Context loaded:`);
|
|
771
|
+
if (knowledgeFiles.length > 0) {
|
|
772
|
+
console.log(` Knowledge base: ${knowledgeFiles.length} file${knowledgeFiles.length !== 1 ? "s" : ""} (${knowledgeFiles.join(", ")})`);
|
|
773
|
+
}
|
|
774
|
+
else {
|
|
775
|
+
console.log(` Knowledge base: empty — Claude will build it as you work`);
|
|
776
|
+
}
|
|
777
|
+
if (dashCtxIsStub || !dashCtxContent) {
|
|
778
|
+
console.log(` Dashboard context: none yet — stub created, Claude will fill it in`);
|
|
779
|
+
}
|
|
780
|
+
else {
|
|
781
|
+
const lines = dashCtxContent.split("\n").length;
|
|
782
|
+
console.log(` Dashboard context: this-dashboard.md (${lines} lines)`);
|
|
783
|
+
}
|
|
784
|
+
if (fs.existsSync(sqlStylePath)) {
|
|
785
|
+
console.log(` SQL style: project override active`);
|
|
786
|
+
}
|
|
787
|
+
console.log(`
|
|
751
788
|
Next steps:
|
|
752
789
|
|
|
753
790
|
cd ${slug}
|
|
754
791
|
npm install
|
|
755
|
-
|
|
756
|
-
claude
|
|
757
|
-
|
|
758
|
-
Note: If this dashboard uses third-party APIs, add your
|
|
759
|
-
API keys to .env before running.
|
|
792
|
+
npx @overscore/cli dev # starts dev server with auth injected
|
|
760
793
|
|
|
761
794
|
When ready to deploy:
|
|
762
795
|
|
|
@@ -1758,67 +1791,122 @@ function analysisPreview() {
|
|
|
1758
1791
|
process.exit(1);
|
|
1759
1792
|
}
|
|
1760
1793
|
}
|
|
1794
|
+
// ── dev ─────────────────────────────────────────────────────────────
|
|
1795
|
+
async function dev() {
|
|
1796
|
+
// Must be run from inside a dashboard project folder
|
|
1797
|
+
const envPath = path.resolve(process.cwd(), ".env");
|
|
1798
|
+
if (!fs.existsSync(envPath)) {
|
|
1799
|
+
console.error("\n Error: No .env file found. Run this from inside a dashboard project folder.\n");
|
|
1800
|
+
process.exit(1);
|
|
1801
|
+
}
|
|
1802
|
+
const localEnv = parseEnv(fs.readFileSync(envPath, "utf-8"));
|
|
1803
|
+
const projectSlug = localEnv.VITE_OVERSCORE_PROJECT_SLUG;
|
|
1804
|
+
if (!projectSlug) {
|
|
1805
|
+
console.error("\n Error: VITE_OVERSCORE_PROJECT_SLUG not set in .env.\n");
|
|
1806
|
+
process.exit(1);
|
|
1807
|
+
}
|
|
1808
|
+
// Get device token from global config
|
|
1809
|
+
const configPath = path.join(process.env.HOME || "", ".overscore", "config");
|
|
1810
|
+
if (!fs.existsSync(configPath)) {
|
|
1811
|
+
console.error("\n Error: Not authenticated. Run 'npx @overscore/cli auth login' first.\n");
|
|
1812
|
+
process.exit(1);
|
|
1813
|
+
}
|
|
1814
|
+
const cfg = parseEnv(fs.readFileSync(configPath, "utf-8"));
|
|
1815
|
+
const deviceToken = cfg.OVERSCORE_DEVICE_TOKEN;
|
|
1816
|
+
if (!deviceToken) {
|
|
1817
|
+
console.error("\n Error: No device token found. Run 'npx @overscore/cli auth login' first.\n");
|
|
1818
|
+
process.exit(1);
|
|
1819
|
+
}
|
|
1820
|
+
// Create a temporary dev session key
|
|
1821
|
+
console.log("\n Creating dev session key...");
|
|
1822
|
+
let keyId = null;
|
|
1823
|
+
let apiKey = null;
|
|
1824
|
+
try {
|
|
1825
|
+
const keyRes = await fetch(`${HUB_URL}/api/projects/${projectSlug}/api-keys`, {
|
|
1826
|
+
method: "POST",
|
|
1827
|
+
headers: {
|
|
1828
|
+
Authorization: `Bearer ${deviceToken}`,
|
|
1829
|
+
"Content-Type": "application/json",
|
|
1830
|
+
},
|
|
1831
|
+
body: JSON.stringify({ name: "dev session (auto-cleanup)" }),
|
|
1832
|
+
});
|
|
1833
|
+
if (keyRes.status === 401 || keyRes.status === 403) {
|
|
1834
|
+
console.error("\n Error: Device token is invalid or expired. Run 'npx @overscore/cli auth login' to re-authenticate.\n");
|
|
1835
|
+
process.exit(1);
|
|
1836
|
+
}
|
|
1837
|
+
if (!keyRes.ok) {
|
|
1838
|
+
const errData = await keyRes.json().catch(() => ({}));
|
|
1839
|
+
console.error(`\n Error: ${errData.error || `HTTP ${keyRes.status}`}\n`);
|
|
1840
|
+
process.exit(1);
|
|
1841
|
+
}
|
|
1842
|
+
const keyData = (await keyRes.json());
|
|
1843
|
+
keyId = keyData.id ?? null;
|
|
1844
|
+
apiKey = keyData.raw_key ?? null;
|
|
1845
|
+
}
|
|
1846
|
+
catch {
|
|
1847
|
+
console.error("\n Error: Could not reach overscore.dev to create dev session key.\n");
|
|
1848
|
+
process.exit(1);
|
|
1849
|
+
}
|
|
1850
|
+
if (!apiKey) {
|
|
1851
|
+
console.error("\n Error: Failed to get API key for dev session.\n");
|
|
1852
|
+
process.exit(1);
|
|
1853
|
+
}
|
|
1854
|
+
// Best-effort cleanup: revoke the session key on exit
|
|
1855
|
+
let cleaned = false;
|
|
1856
|
+
async function cleanup() {
|
|
1857
|
+
if (cleaned || !keyId)
|
|
1858
|
+
return;
|
|
1859
|
+
cleaned = true;
|
|
1860
|
+
try {
|
|
1861
|
+
await fetch(`${HUB_URL}/api/projects/${projectSlug}/api-keys`, {
|
|
1862
|
+
method: "DELETE",
|
|
1863
|
+
headers: {
|
|
1864
|
+
Authorization: `Bearer ${deviceToken}`,
|
|
1865
|
+
"Content-Type": "application/json",
|
|
1866
|
+
},
|
|
1867
|
+
body: JSON.stringify({ id: keyId }),
|
|
1868
|
+
});
|
|
1869
|
+
}
|
|
1870
|
+
catch {
|
|
1871
|
+
// Best effort — key will remain but that's acceptable
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
// Spawn npm run dev with the key injected into the environment
|
|
1875
|
+
const { spawn } = await import("child_process");
|
|
1876
|
+
const child = spawn("npm", ["run", "dev"], {
|
|
1877
|
+
stdio: "inherit",
|
|
1878
|
+
env: { ...process.env, VITE_OVERSCORE_API_KEY: apiKey },
|
|
1879
|
+
});
|
|
1880
|
+
child.on("exit", async (code) => {
|
|
1881
|
+
await cleanup();
|
|
1882
|
+
process.exit(code ?? 0);
|
|
1883
|
+
});
|
|
1884
|
+
process.on("SIGINT", async () => {
|
|
1885
|
+
await cleanup();
|
|
1886
|
+
process.exit(0);
|
|
1887
|
+
});
|
|
1888
|
+
process.on("SIGTERM", async () => {
|
|
1889
|
+
await cleanup();
|
|
1890
|
+
process.exit(0);
|
|
1891
|
+
});
|
|
1892
|
+
}
|
|
1761
1893
|
// ── auth ────────────────────────────────────────────────────────────
|
|
1762
1894
|
async function authStatus() {
|
|
1763
|
-
const
|
|
1764
|
-
const configPath = path.join(configDir, "config");
|
|
1765
|
-
// Check global config
|
|
1895
|
+
const configPath = path.join(process.env.HOME || "", ".overscore", "config");
|
|
1766
1896
|
if (fs.existsSync(configPath)) {
|
|
1767
1897
|
const cfg = parseEnv(fs.readFileSync(configPath, "utf-8"));
|
|
1768
|
-
const
|
|
1898
|
+
const deviceToken = cfg.OVERSCORE_DEVICE_TOKEN;
|
|
1769
1899
|
const project = cfg.OVERSCORE_PROJECT_SLUG;
|
|
1770
|
-
if (
|
|
1771
|
-
|
|
1772
|
-
let valid = false;
|
|
1773
|
-
try {
|
|
1774
|
-
const hubUrl = cfg.OVERSCORE_API_URL?.replace(/\/api$/, "") || HUB_URL;
|
|
1775
|
-
const res = await fetch(`${hubUrl}/api/projects/verify-key`, {
|
|
1776
|
-
method: "POST",
|
|
1777
|
-
headers: { "Content-Type": "application/json" },
|
|
1778
|
-
body: JSON.stringify({ key }),
|
|
1779
|
-
});
|
|
1780
|
-
if (res.ok) {
|
|
1781
|
-
const data = (await res.json());
|
|
1782
|
-
valid = true;
|
|
1783
|
-
console.log(`
|
|
1900
|
+
if (deviceToken) {
|
|
1901
|
+
console.log(`
|
|
1784
1902
|
Authenticated
|
|
1785
1903
|
|
|
1786
|
-
|
|
1787
|
-
Project:
|
|
1788
|
-
Config:
|
|
1789
|
-
Status: Valid
|
|
1790
|
-
`);
|
|
1791
|
-
}
|
|
1792
|
-
}
|
|
1793
|
-
catch {
|
|
1794
|
-
// network error
|
|
1795
|
-
}
|
|
1796
|
-
if (!valid) {
|
|
1797
|
-
console.log(`
|
|
1798
|
-
Authentication problem
|
|
1799
|
-
|
|
1800
|
-
Key: ${key.slice(0, 10)}...
|
|
1801
|
-
Project: ${project}
|
|
1802
|
-
Config: ~/.overscore/config
|
|
1803
|
-
Status: Invalid or expired
|
|
1904
|
+
Device token: ${deviceToken.slice(0, 12)}...
|
|
1905
|
+
Project: ${project || "—"}
|
|
1906
|
+
Config: ~/.overscore/config
|
|
1804
1907
|
|
|
1805
|
-
Run 'npx @overscore/cli
|
|
1806
|
-
|
|
1807
|
-
}
|
|
1808
|
-
return;
|
|
1809
|
-
}
|
|
1810
|
-
}
|
|
1811
|
-
// Check local .env
|
|
1812
|
-
const envPath = path.resolve(process.cwd(), ".env");
|
|
1813
|
-
if (fs.existsSync(envPath)) {
|
|
1814
|
-
const env = parseEnv(fs.readFileSync(envPath, "utf-8"));
|
|
1815
|
-
if (env.VITE_OVERSCORE_API_KEY) {
|
|
1816
|
-
console.log(`
|
|
1817
|
-
Using local .env (this project only)
|
|
1818
|
-
|
|
1819
|
-
Key: ${env.VITE_OVERSCORE_API_KEY.slice(0, 10)}...
|
|
1820
|
-
Project: ${env.VITE_OVERSCORE_PROJECT_SLUG || "—"}
|
|
1821
|
-
Dashboard: ${env.VITE_OVERSCORE_DASHBOARD_SLUG || "—"}
|
|
1908
|
+
Run 'npx @overscore/cli dev' to start the dev server.
|
|
1909
|
+
Run 'npx @overscore/cli auth logout' to sign out.
|
|
1822
1910
|
`);
|
|
1823
1911
|
return;
|
|
1824
1912
|
}
|
|
@@ -1826,64 +1914,126 @@ async function authStatus() {
|
|
|
1826
1914
|
console.log(`
|
|
1827
1915
|
Not authenticated
|
|
1828
1916
|
|
|
1829
|
-
|
|
1917
|
+
Run 'npx @overscore/cli auth login' to authenticate.
|
|
1830
1918
|
`);
|
|
1831
1919
|
}
|
|
1832
1920
|
async function authLogin() {
|
|
1833
1921
|
const configDir = path.join(process.env.HOME || "", ".overscore");
|
|
1834
1922
|
const configPath = path.join(configDir, "config");
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1923
|
+
const state = randomBytes(16).toString("hex");
|
|
1924
|
+
// Find a free port in range 9876–9886
|
|
1925
|
+
let chosenPort = null;
|
|
1926
|
+
for (let p = 9876; p <= 9886; p++) {
|
|
1927
|
+
const free = await new Promise((resolve) => {
|
|
1928
|
+
const srv = net.createServer();
|
|
1929
|
+
srv.once("error", () => resolve(false));
|
|
1930
|
+
srv.once("listening", () => { srv.close(() => resolve(true)); });
|
|
1931
|
+
srv.listen(p, "127.0.0.1");
|
|
1932
|
+
});
|
|
1933
|
+
if (free) {
|
|
1934
|
+
chosenPort = p;
|
|
1935
|
+
break;
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
if (!chosenPort) {
|
|
1939
|
+
console.error("\n Error: No free port found in range 9876–9886.\n");
|
|
1839
1940
|
process.exit(1);
|
|
1840
1941
|
}
|
|
1841
|
-
//
|
|
1842
|
-
let
|
|
1942
|
+
// Start local callback server
|
|
1943
|
+
let resolveCallback;
|
|
1944
|
+
const callbackPromise = new Promise((resolve) => {
|
|
1945
|
+
resolveCallback = resolve;
|
|
1946
|
+
});
|
|
1947
|
+
const server = http.createServer((req, res) => {
|
|
1948
|
+
const reqUrl = new URL(req.url ?? "/", `http://localhost:${chosenPort}`);
|
|
1949
|
+
if (reqUrl.pathname !== "/callback") {
|
|
1950
|
+
res.writeHead(404);
|
|
1951
|
+
res.end();
|
|
1952
|
+
return;
|
|
1953
|
+
}
|
|
1954
|
+
const token = reqUrl.searchParams.get("token") ?? "";
|
|
1955
|
+
const returnedState = reqUrl.searchParams.get("state") ?? "";
|
|
1956
|
+
const project = reqUrl.searchParams.get("project") ?? undefined;
|
|
1957
|
+
if (returnedState !== state || !token.startsWith("ocli_")) {
|
|
1958
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
1959
|
+
res.end("Invalid callback");
|
|
1960
|
+
return;
|
|
1961
|
+
}
|
|
1962
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
1963
|
+
res.end(`<!DOCTYPE html><html><head><title>Overscore CLI</title></head>` +
|
|
1964
|
+
`<body style="font-family:sans-serif;text-align:center;padding:3rem">` +
|
|
1965
|
+
`<h2>Authenticated! You can close this tab.</h2></body></html>`);
|
|
1966
|
+
resolveCallback({ token, project });
|
|
1967
|
+
});
|
|
1968
|
+
server.listen(chosenPort, "127.0.0.1");
|
|
1969
|
+
const loginUrl = `${HUB_URL}/login?next=${encodeURIComponent(`/api/cli/auth/finalize?port=${chosenPort}&state=${state}`)}&mode=signin`;
|
|
1970
|
+
console.log(`\n Opening browser to authenticate...`);
|
|
1971
|
+
console.log(`\n If your browser doesn't open automatically, visit:\n ${loginUrl}\n`);
|
|
1843
1972
|
try {
|
|
1844
|
-
const
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
const data = (await res.json());
|
|
1851
|
-
projectSlug = data.project_slug || null;
|
|
1852
|
-
if (projectSlug) {
|
|
1853
|
-
console.log(`\n Detected project: ${projectSlug}`);
|
|
1854
|
-
}
|
|
1973
|
+
const platform = process.platform;
|
|
1974
|
+
if (platform === "darwin") {
|
|
1975
|
+
execSync(`open '${loginUrl}'`);
|
|
1976
|
+
}
|
|
1977
|
+
else if (platform === "win32") {
|
|
1978
|
+
execSync(`start "" "${loginUrl}"`);
|
|
1855
1979
|
}
|
|
1856
1980
|
else {
|
|
1857
|
-
|
|
1858
|
-
process.exit(1);
|
|
1981
|
+
execSync(`xdg-open '${loginUrl}'`);
|
|
1859
1982
|
}
|
|
1860
1983
|
}
|
|
1861
1984
|
catch {
|
|
1862
|
-
|
|
1863
|
-
process.exit(1);
|
|
1985
|
+
// Browser open failed — user can follow the printed URL
|
|
1864
1986
|
}
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1987
|
+
// Wait up to 120 seconds for the callback
|
|
1988
|
+
const timeout = setTimeout(() => {
|
|
1989
|
+
console.error("\n Error: Timed out waiting for browser login (120s).\n");
|
|
1990
|
+
server.close();
|
|
1991
|
+
process.exit(1);
|
|
1992
|
+
}, 120000);
|
|
1993
|
+
const { token: deviceToken, project: projectSlug } = await callbackPromise;
|
|
1994
|
+
clearTimeout(timeout);
|
|
1995
|
+
server.close();
|
|
1996
|
+
// Preserve any existing os_ API key so dashboard .env fallback still works
|
|
1997
|
+
let existingApiKey = "";
|
|
1998
|
+
if (fs.existsSync(configPath)) {
|
|
1999
|
+
const existing = parseEnv(fs.readFileSync(configPath, "utf-8"));
|
|
2000
|
+
existingApiKey = existing.OVERSCORE_API_KEY || "";
|
|
1871
2001
|
}
|
|
1872
2002
|
fs.mkdirSync(configDir, { recursive: true });
|
|
1873
|
-
|
|
2003
|
+
const lines = [
|
|
2004
|
+
`OVERSCORE_API_URL=${HUB_URL}/api`,
|
|
2005
|
+
`OVERSCORE_DEVICE_TOKEN=${deviceToken}`,
|
|
2006
|
+
];
|
|
2007
|
+
if (projectSlug)
|
|
2008
|
+
lines.push(`OVERSCORE_PROJECT_SLUG=${projectSlug}`);
|
|
2009
|
+
if (existingApiKey)
|
|
2010
|
+
lines.push(`OVERSCORE_API_KEY=${existingApiKey}`);
|
|
2011
|
+
fs.writeFileSync(configPath, lines.join("\n") + "\n", { mode: 0o600 });
|
|
1874
2012
|
console.log(`
|
|
1875
2013
|
Authenticated!
|
|
1876
2014
|
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
You're ready to use the CLI.
|
|
2015
|
+
Device token saved to ~/.overscore/config.
|
|
2016
|
+
You won't need to copy API keys manually — create-overscore and pull
|
|
2017
|
+
will authenticate automatically from now on.
|
|
1882
2018
|
`);
|
|
1883
2019
|
}
|
|
1884
2020
|
async function authLogout() {
|
|
1885
2021
|
const configPath = path.join(process.env.HOME || "", ".overscore", "config");
|
|
1886
2022
|
if (fs.existsSync(configPath)) {
|
|
2023
|
+
// Attempt server-side revocation of the device token (non-fatal)
|
|
2024
|
+
try {
|
|
2025
|
+
const cfg = parseEnv(fs.readFileSync(configPath, "utf-8"));
|
|
2026
|
+
const deviceToken = cfg.OVERSCORE_DEVICE_TOKEN;
|
|
2027
|
+
if (deviceToken) {
|
|
2028
|
+
await fetch(`${HUB_URL}/api/cli/auth/revoke`, {
|
|
2029
|
+
method: "POST",
|
|
2030
|
+
headers: { Authorization: `Bearer ${deviceToken}` },
|
|
2031
|
+
});
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
catch {
|
|
2035
|
+
// Ignore — Hub may be unreachable
|
|
2036
|
+
}
|
|
1887
2037
|
fs.unlinkSync(configPath);
|
|
1888
2038
|
console.log("\n Logged out. Global config removed.\n");
|
|
1889
2039
|
}
|
|
@@ -1930,7 +2080,10 @@ ${installed.map((s) => ` - ${s}`).join("\n")}
|
|
|
1930
2080
|
// ── Routing ─────────────────────────────────────────────────────────
|
|
1931
2081
|
const command = process.argv[2];
|
|
1932
2082
|
const subcommand = process.argv[3];
|
|
1933
|
-
if (command === "
|
|
2083
|
+
if (command === "dev") {
|
|
2084
|
+
dev();
|
|
2085
|
+
}
|
|
2086
|
+
else if (command === "deploy") {
|
|
1934
2087
|
deploy();
|
|
1935
2088
|
}
|
|
1936
2089
|
else if (command === "pull") {
|
|
@@ -2039,8 +2192,9 @@ else {
|
|
|
2039
2192
|
|
|
2040
2193
|
Commands:
|
|
2041
2194
|
auth Check authentication status
|
|
2042
|
-
auth login
|
|
2195
|
+
auth login Authenticate via browser (one time per machine)
|
|
2043
2196
|
auth logout Remove saved credentials
|
|
2197
|
+
dev Start the dev server (injects auth automatically)
|
|
2044
2198
|
list List all dashboards in the project
|
|
2045
2199
|
deploy Build and deploy the dashboard
|
|
2046
2200
|
pull <slug> Pull source code from the hub
|
|
@@ -2050,6 +2204,8 @@ else {
|
|
|
2050
2204
|
install-skills Install the Overscore skill library into ~/.claude/skills/
|
|
2051
2205
|
|
|
2052
2206
|
Usage:
|
|
2207
|
+
npx @overscore/cli auth login
|
|
2208
|
+
npx @overscore/cli dev
|
|
2053
2209
|
npx @overscore/cli list
|
|
2054
2210
|
npx @overscore/cli deploy --message "description"
|
|
2055
2211
|
npx @overscore/cli pull <slug>
|
package/package.json
CHANGED
|
@@ -5,26 +5,32 @@ description: Reviews data insights discovered during dashboard or analysis work
|
|
|
5
5
|
|
|
6
6
|
# Save Knowledge
|
|
7
7
|
|
|
8
|
-
Run
|
|
9
|
-
|
|
10
|
-
## When to invoke
|
|
11
|
-
- Before `npx @overscore/cli deploy`
|
|
12
|
-
- When the user says "save what you learned" or similar
|
|
8
|
+
Run before deploying a dashboard, before publishing an analysis, or whenever you discover something non-obvious about the data.
|
|
13
9
|
|
|
14
10
|
## Process
|
|
15
11
|
|
|
16
|
-
1. Review the
|
|
12
|
+
1. Review the code you've written and queries you've run this session.
|
|
13
|
+
|
|
14
|
+
2. **Read the existing files before proposing anything.** Read `.claude/knowledge/INDEX.md`, then read the specific knowledge files relevant to what you learned. Read `.claude/rules/this-dashboard.md` (or `analysis-log.md` for analyses) in full. You need to know what's already there before you can know what's new.
|
|
15
|
+
|
|
16
|
+
3. For each insight from this session, decide what action to take:
|
|
17
|
+
|
|
18
|
+
| Situation | Action |
|
|
19
|
+
|---|---|
|
|
20
|
+
| New topic, no existing file covers it | Create a new `.claude/knowledge/{topic}.md`; add entry to INDEX.md |
|
|
21
|
+
| New fact that belongs in an existing knowledge file | Append a new line or section to that file — do not rewrite what's there |
|
|
22
|
+
| Fact that refines or corrects something already documented | Edit that specific line/section only — do not duplicate it |
|
|
23
|
+
| Already documented accurately | Skip entirely |
|
|
24
|
+
| Dashboard/analysis-specific insight not yet captured | Append to `this-dashboard.md` or `analysis-log.md` |
|
|
25
|
+
| Insight that updates something already there | Edit that specific part only |
|
|
17
26
|
|
|
18
|
-
|
|
27
|
+
**Never overwrite a file wholesale. Never duplicate a fact that's already accurate. Only write what's genuinely new or changed.**
|
|
19
28
|
|
|
20
|
-
|
|
21
|
-
- **Project knowledge** (helps ALL future artifacts) → propose addition to `.claude/knowledge/`
|
|
22
|
-
- **Dashboard-specific** (design decision, query note) → append to `.claude/rules/this-dashboard.md`
|
|
23
|
-
- **Already known** → skip
|
|
29
|
+
4. Present proposed changes to the user. Be specific — show the exact text you'd add or the exact edit you'd make. "I should note something about X" is not acceptable — show the line.
|
|
24
30
|
|
|
25
|
-
|
|
31
|
+
5. After the user approves, write the files. If you created a new knowledge file, update INDEX.md.
|
|
26
32
|
|
|
27
|
-
## What qualifies as project knowledge vs
|
|
33
|
+
## What qualifies as project knowledge vs artifact-specific
|
|
28
34
|
- "dates are UTC-7 no DST" → project knowledge (helps all artifacts)
|
|
29
35
|
- "affiliate_id = 0 is unattributed" → project knowledge
|
|
30
36
|
- "Revenue chart excludes refunds per Finance definition" → dashboard-specific
|
|
@@ -5,11 +5,7 @@ description: Reviews data insights discovered during analysis work and proposes
|
|
|
5
5
|
|
|
6
6
|
# Save Knowledge
|
|
7
7
|
|
|
8
|
-
Run
|
|
9
|
-
|
|
10
|
-
## When to invoke
|
|
11
|
-
- Before `analysis publish` (review what you learned first)
|
|
12
|
-
- When the user says "save what you learned" or similar
|
|
8
|
+
Run before publishing an analysis or whenever you discover something non-obvious about the data.
|
|
13
9
|
|
|
14
10
|
## Process
|
|
15
11
|
|
|
@@ -19,14 +15,21 @@ Run this skill before publishing an analysis or when explicitly asked.
|
|
|
19
15
|
- Metric definitions or business rules
|
|
20
16
|
- Data quality issues or known gaps
|
|
21
17
|
|
|
22
|
-
2. Read `.claude/knowledge/INDEX.md
|
|
18
|
+
2. **Read the existing knowledge files before proposing anything.** Read `.claude/knowledge/INDEX.md`, then read the specific files relevant to what you discovered. You need to know what's already there before you can know what's new.
|
|
19
|
+
|
|
20
|
+
3. For each insight, decide what action to take:
|
|
21
|
+
|
|
22
|
+
| Situation | Action |
|
|
23
|
+
|---|---|
|
|
24
|
+
| New topic, no existing file covers it | Create a new `.claude/knowledge/{topic}.md`; add entry to INDEX.md |
|
|
25
|
+
| New fact that belongs in an existing knowledge file | Append a new line or section to that file — do not rewrite what's there |
|
|
26
|
+
| Fact that refines or corrects something already documented | Edit that specific line/section only — do not duplicate it |
|
|
27
|
+
| Already documented accurately | Skip entirely |
|
|
28
|
+
| Analysis-specific (only relevant to this investigation) | Leave in analysis-log.md — don't promote to project knowledge |
|
|
23
29
|
|
|
24
|
-
|
|
25
|
-
- **Project knowledge** (helps ALL future artifacts) → propose a new file or addition to `.claude/knowledge/`
|
|
26
|
-
- **Already known** (in an existing knowledge file) → skip
|
|
27
|
-
- **Analysis-specific** (only relevant to this investigation) → leave in analysis-log.md
|
|
30
|
+
**Never overwrite a file wholesale. Never duplicate a fact that's already accurate. Only write what's genuinely new or changed.**
|
|
28
31
|
|
|
29
|
-
4. Present proposed
|
|
32
|
+
4. Present proposed changes to the user. Show the exact text you'd add or the exact edit you'd make — not a summary of what you intend to write.
|
|
30
33
|
|
|
31
34
|
5. After approval, write the files and update INDEX.md.
|
|
32
35
|
|
|
@@ -34,5 +37,5 @@ Run this skill before publishing an analysis or when explicitly asked.
|
|
|
34
37
|
- "unique_page_views is already COUNT(DISTINCT domain_userid)" → YES
|
|
35
38
|
- "date_az is America/Phoenix UTC-7, no DST" → YES
|
|
36
39
|
- "affiliate_id = 0 is unattributed, usually exclude" → YES
|
|
37
|
-
- "Q1 churn spiked because of the pricing change" → NO (analysis conclusion, not data fact)
|
|
40
|
+
- "Q1 churn spiked because of the pricing change" → NO (analysis conclusion, not a data fact)
|
|
38
41
|
- "I used a 90-day window for this analysis" → NO (analysis-specific decision)
|