@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 company context (with conflict detection) legacy path for
270
- // projects that haven't migrated to the knowledge base yet.
271
- const companyCtxPath = path.resolve(process.cwd(), ".claude/rules/company-context.md");
272
- if (fs.existsSync(companyCtxPath)) {
273
- const content = fs.readFileSync(companyCtxPath, "utf-8");
274
- const hashPath = path.resolve(process.cwd(), ".claude/rules/.context-hash");
275
- const storedHash = fs.existsSync(hashPath) ? fs.readFileSync(hashPath, "utf-8").trim() : null;
276
- try {
277
- // Check if Hub version has changed since we pulled
278
- const checkRes = await fetch(`${baseUrl}/api/projects/${projectSlug}/context`, {
279
- headers: { Authorization: `Bearer ${apiKey}` },
280
- });
281
- const checkData = checkRes.ok ? (await checkRes.json()) : null;
282
- const hubHash = checkData?.context_md_hash || null;
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 (putRes.ok) {
298
- const putData = (await putRes.json());
299
- // Update stored hash
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
- catch {
309
- // Non-fatal
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
- // Interactive prompts for credentials
645
- const apiKey = await prompt("API key (from the Hub):");
646
- if (!apiKey || !apiKey.startsWith("os_")) {
647
- console.error("\n Error: API key should start with os_\n");
648
- process.exit(1);
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-VD-Project-Slug") || "";
671
- const dashboardSlug = res.headers.get("X-VD-Dashboard-Slug") || slug;
672
- const version = res.headers.get("X-VD-Version") || "?";
673
- const commitMsg = res.headers.get("X-VD-Commit-Message") || "";
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 (only overscore configuser adds their own API keys)
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
- // Fetch latest project context from Hub (may be newer than what's in the source zip)
707
- try {
708
- const ctxRes = await fetch(`${baseUrl}/api/projects/${projectSlug}/context`, {
709
- headers: { Authorization: `Bearer ${apiKey}` },
710
- });
711
- if (ctxRes.ok) {
712
- const ctxData = (await ctxRes.json());
713
- if (ctxData.context_md) {
714
- const rulesDir = path.join(targetDir, ".claude", "rules");
715
- fs.mkdirSync(rulesDir, { recursive: true });
716
- fs.writeFileSync(path.join(rulesDir, "company-context.md"), ctxData.context_md);
717
- // Store hash for conflict detection on next deploy
718
- if (ctxData.context_md_hash) {
719
- fs.writeFileSync(path.join(rulesDir, ".context-hash"), ctxData.context_md_hash);
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
- catch {
726
- // Non-fatal the version from the source zip is fine
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
- console.log(" Updated dashboard context from Hub.");
742
+ dashboardContextLoaded = true;
740
743
  }
741
744
  }
742
745
  }
743
746
  catch {
744
747
  // Non-fatal
745
748
  }
746
- // Pull latest platform rules
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
- code .
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 configDir = path.join(process.env.HOME || "", ".overscore");
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 key = cfg.OVERSCORE_API_KEY;
1898
+ const deviceToken = cfg.OVERSCORE_DEVICE_TOKEN;
1769
1899
  const project = cfg.OVERSCORE_PROJECT_SLUG;
1770
- if (key && project) {
1771
- // Verify the key is still valid
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
- Key: ${key.slice(0, 10)}...
1787
- Project: ${data.project_slug || project}
1788
- Config: ~/.overscore/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 auth login' to update your key.
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
- No API key found. Run 'npx @overscore/cli auth login' to set one up.
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
- console.log("\n Set up Overscore CLI authentication.\n");
1836
- const apiKey = await prompt(" API key (from the Hub):");
1837
- if (!apiKey || !apiKey.startsWith("os_")) {
1838
- console.error("\n Error: API key should start with os_\n");
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
- // Auto-resolve project from key
1842
- let projectSlug = null;
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 res = await fetch(`${HUB_URL}/api/projects/verify-key`, {
1845
- method: "POST",
1846
- headers: { "Content-Type": "application/json" },
1847
- body: JSON.stringify({ key: apiKey }),
1848
- });
1849
- if (res.ok) {
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
- console.error("\n Error: API key is invalid or expired.\n");
1858
- process.exit(1);
1981
+ execSync(`xdg-open '${loginUrl}'`);
1859
1982
  }
1860
1983
  }
1861
1984
  catch {
1862
- console.error("\n Error: Could not reach overscore.dev to verify key.\n");
1863
- process.exit(1);
1985
+ // Browser open failed user can follow the printed URL
1864
1986
  }
1865
- if (!projectSlug) {
1866
- projectSlug = await prompt(" Project slug:");
1867
- if (!projectSlug) {
1868
- console.error("\n Error: Project slug is required\n");
1869
- process.exit(1);
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
- fs.writeFileSync(configPath, `OVERSCORE_API_URL=${HUB_URL}/api\nOVERSCORE_API_KEY=${apiKey}\nOVERSCORE_PROJECT_SLUG=${projectSlug}\n`, { mode: 0o600 });
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
- Key: ${apiKey.slice(0, 10)}...
1878
- Project: ${projectSlug}
1879
- Config: ~/.overscore/config
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 === "deploy") {
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 Set up or update your API key
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@overscore/cli",
3
- "version": "0.10.2",
3
+ "version": "0.11.1",
4
4
  "description": "CLI for deploying Overscore dashboards and publishing analyses",
5
5
  "bin": {
6
6
  "overscore": "dist/index.js"
@@ -5,26 +5,32 @@ description: Reviews data insights discovered during dashboard or analysis work
5
5
 
6
6
  # Save Knowledge
7
7
 
8
- Run this skill before deploying a dashboard or when explicitly asked.
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 dashboard code and any exploratory queries you ran.
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
- 2. Read `.claude/knowledge/INDEX.md` to see what's already documented.
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
- 3. For each new insight, decide:
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
- 4. Present proposed additions to the user for approval before writing any files.
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 dashboard-specific
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 this skill before publishing an analysis or when explicitly asked.
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` to see what's already documented.
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
- 3. For each new insight, decide:
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 additions to the user for approval before writing any files.
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)