whydid 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +232 -0
  2. package/dashboard/dist/android-chrome-192x192.png +0 -0
  3. package/dashboard/dist/android-chrome-512x512.png +0 -0
  4. package/dashboard/dist/apple-touch-icon.png +0 -0
  5. package/dashboard/dist/assets/index-DImKqodm.js +69 -0
  6. package/dashboard/dist/assets/index-mHXgqqkj.css +1 -0
  7. package/dashboard/dist/favicon-16x16.png +0 -0
  8. package/dashboard/dist/favicon-32x32.png +0 -0
  9. package/dashboard/dist/favicon.ico +0 -0
  10. package/dashboard/dist/index.html +24 -0
  11. package/dashboard/dist/logo-mark.svg +8 -0
  12. package/dashboard/dist/site.webmanifest +11 -0
  13. package/dist/adapters/claude-code.js +168 -0
  14. package/dist/adapters/hook-events.js +86 -0
  15. package/dist/adapters/sync-claude-code.js +85 -0
  16. package/dist/cli/ai-budget-warning.js +19 -0
  17. package/dist/cli/commands/agent-event.js +80 -0
  18. package/dist/cli/commands/checkpoint.js +36 -0
  19. package/dist/cli/commands/context.js +27 -0
  20. package/dist/cli/commands/daemon.js +139 -0
  21. package/dist/cli/commands/export.js +36 -0
  22. package/dist/cli/commands/init.js +63 -0
  23. package/dist/cli/commands/key.js +65 -0
  24. package/dist/cli/commands/license.js +57 -0
  25. package/dist/cli/commands/milestone.js +64 -0
  26. package/dist/cli/commands/prompt.js +75 -0
  27. package/dist/cli/commands/recap.js +48 -0
  28. package/dist/cli/commands/restore-fork.js +101 -0
  29. package/dist/cli/commands/status.js +26 -0
  30. package/dist/cli/commands/test.js +33 -0
  31. package/dist/cli/commands/timeline.js +23 -0
  32. package/dist/cli/commands/why.js +71 -0
  33. package/dist/cli/context.js +48 -0
  34. package/dist/cli/index.js +77 -0
  35. package/dist/cli/license-gate.js +26 -0
  36. package/dist/cli/preflight.js +28 -0
  37. package/dist/core/agent-context.js +46 -0
  38. package/dist/core/agent-hooks.js +68 -0
  39. package/dist/core/ai-budget.js +27 -0
  40. package/dist/core/chat-retrieval.js +36 -0
  41. package/dist/core/context-tree.js +135 -0
  42. package/dist/core/diff.js +30 -0
  43. package/dist/core/engine.js +321 -0
  44. package/dist/core/export.js +98 -0
  45. package/dist/core/file-history.js +31 -0
  46. package/dist/core/manifest.js +102 -0
  47. package/dist/core/recap.js +49 -0
  48. package/dist/core/test-runner.js +23 -0
  49. package/dist/daemon/listen.js +26 -0
  50. package/dist/daemon/pidfile.js +36 -0
  51. package/dist/daemon/server.js +554 -0
  52. package/dist/db/database.js +631 -0
  53. package/dist/db/schema.js +186 -0
  54. package/dist/git/git.js +22 -0
  55. package/dist/licensing/config.js +28 -0
  56. package/dist/licensing/license-client.js +45 -0
  57. package/dist/licensing/license-manager.js +89 -0
  58. package/dist/licensing/license-payload.js +12 -0
  59. package/dist/licensing/license-store.js +96 -0
  60. package/dist/licensing/machine-id.js +78 -0
  61. package/dist/licensing/secret-store.js +102 -0
  62. package/dist/licensing/verify-license.js +20 -0
  63. package/dist/llm/agent-prompt.js +36 -0
  64. package/dist/llm/annotate-checkpoint.js +67 -0
  65. package/dist/llm/annotate.js +11 -0
  66. package/dist/llm/anthropic-provider.js +97 -0
  67. package/dist/llm/chat.js +35 -0
  68. package/dist/llm/community-pricing.js +75 -0
  69. package/dist/llm/factory.js +17 -0
  70. package/dist/llm/http-error.js +25 -0
  71. package/dist/llm/noop-provider.js +19 -0
  72. package/dist/llm/openai-provider.js +85 -0
  73. package/dist/llm/pricing.js +55 -0
  74. package/dist/llm/prompt.js +103 -0
  75. package/dist/llm/provider.js +2 -0
  76. package/dist/llm/schema.js +49 -0
  77. package/dist/llm/social-post.js +49 -0
  78. package/dist/objects/store.js +31 -0
  79. package/dist/shared/config.js +45 -0
  80. package/dist/shared/redaction.js +18 -0
  81. package/dist/shared/types.js +2 -0
  82. package/dist/watcher/ignore-rules.js +92 -0
  83. package/dist/watcher/watcher.js +249 -0
  84. package/package.json +55 -0
@@ -0,0 +1,186 @@
1
+ export const SCHEMA_SQL = `
2
+ CREATE TABLE IF NOT EXISTS projects (
3
+ id TEXT PRIMARY KEY,
4
+ name TEXT NOT NULL,
5
+ root_path TEXT NOT NULL,
6
+ git_root TEXT,
7
+ head_state_id TEXT,
8
+ created_at TEXT NOT NULL,
9
+ updated_at TEXT NOT NULL
10
+ );
11
+
12
+ CREATE TABLE IF NOT EXISTS sessions (
13
+ id TEXT PRIMARY KEY,
14
+ project_id TEXT NOT NULL REFERENCES projects(id),
15
+ agent TEXT NOT NULL,
16
+ agent_session_id TEXT,
17
+ started_at TEXT NOT NULL,
18
+ ended_at TEXT,
19
+ metadata_json TEXT
20
+ );
21
+
22
+ CREATE TABLE IF NOT EXISTS turns (
23
+ id TEXT PRIMARY KEY,
24
+ session_id TEXT NOT NULL REFERENCES sessions(id),
25
+ sequence INTEGER NOT NULL,
26
+ prompt TEXT,
27
+ started_at TEXT NOT NULL,
28
+ ended_at TEXT,
29
+ status TEXT NOT NULL,
30
+ metadata_json TEXT,
31
+ -- 'prompt' (an ordinary real user turn) or 'compact_resume' (a synthetic
32
+ -- turn Claude Code injects when its own context gets auto-compacted and
33
+ -- the session continues) — lets a checkpoint be traced back to "this
34
+ -- happened right after a context compaction" instead of looking ordinary.
35
+ kind TEXT NOT NULL DEFAULT 'prompt'
36
+ );
37
+
38
+ CREATE TABLE IF NOT EXISTS states (
39
+ id TEXT PRIMARY KEY,
40
+ project_id TEXT NOT NULL REFERENCES projects(id),
41
+ parent_state_id TEXT REFERENCES states(id),
42
+ session_id TEXT REFERENCES sessions(id),
43
+ turn_id TEXT REFERENCES turns(id),
44
+ state_type TEXT NOT NULL,
45
+ label TEXT,
46
+ summary TEXT,
47
+ reason TEXT,
48
+ decision_notes TEXT,
49
+ risk_level TEXT,
50
+ created_at TEXT NOT NULL,
51
+ git_commit_sha TEXT,
52
+ working_tree_hash TEXT NOT NULL,
53
+ milestone_label TEXT
54
+ );
55
+
56
+ CREATE INDEX IF NOT EXISTS idx_states_project ON states(project_id, created_at);
57
+
58
+ CREATE TABLE IF NOT EXISTS file_changes (
59
+ id TEXT PRIMARY KEY,
60
+ state_id TEXT NOT NULL REFERENCES states(id),
61
+ file_path TEXT NOT NULL,
62
+ change_type TEXT NOT NULL,
63
+ before_hash TEXT,
64
+ after_hash TEXT,
65
+ lines_added INTEGER NOT NULL DEFAULT 0,
66
+ lines_removed INTEGER NOT NULL DEFAULT 0
67
+ );
68
+
69
+ CREATE INDEX IF NOT EXISTS idx_file_changes_state ON file_changes(state_id);
70
+
71
+ CREATE TABLE IF NOT EXISTS state_manifest (
72
+ state_id TEXT NOT NULL REFERENCES states(id),
73
+ file_path TEXT NOT NULL,
74
+ content_hash TEXT NOT NULL,
75
+ size_bytes INTEGER NOT NULL,
76
+ PRIMARY KEY (state_id, file_path)
77
+ );
78
+
79
+ CREATE TABLE IF NOT EXISTS snapshots (
80
+ id TEXT PRIMARY KEY,
81
+ state_id TEXT NOT NULL REFERENCES states(id),
82
+ manifest_hash TEXT NOT NULL,
83
+ created_at TEXT NOT NULL
84
+ );
85
+
86
+ CREATE TABLE IF NOT EXISTS annotations (
87
+ id TEXT PRIMARY KEY,
88
+ state_id TEXT NOT NULL REFERENCES states(id),
89
+ summary TEXT,
90
+ reason TEXT,
91
+ decisions_json TEXT,
92
+ risk_level TEXT,
93
+ generated_by TEXT NOT NULL,
94
+ model TEXT,
95
+ status TEXT NOT NULL,
96
+ created_at TEXT NOT NULL,
97
+ input_tokens INTEGER,
98
+ output_tokens INTEGER
99
+ );
100
+
101
+ CREATE TABLE IF NOT EXISTS test_runs (
102
+ id TEXT PRIMARY KEY,
103
+ state_id TEXT NOT NULL REFERENCES states(id),
104
+ command TEXT NOT NULL,
105
+ status TEXT NOT NULL,
106
+ exit_code INTEGER,
107
+ duration_ms INTEGER,
108
+ output_path TEXT,
109
+ created_at TEXT NOT NULL
110
+ );
111
+
112
+ CREATE TABLE IF NOT EXISTS forks (
113
+ id TEXT PRIMARY KEY,
114
+ project_id TEXT NOT NULL REFERENCES projects(id),
115
+ source_state_id TEXT NOT NULL REFERENCES states(id),
116
+ name TEXT NOT NULL,
117
+ created_at TEXT NOT NULL
118
+ );
119
+
120
+ CREATE TABLE IF NOT EXISTS events (
121
+ id TEXT PRIMARY KEY,
122
+ project_id TEXT NOT NULL REFERENCES projects(id),
123
+ type TEXT NOT NULL,
124
+ timestamp TEXT NOT NULL,
125
+ payload_json TEXT
126
+ );
127
+
128
+ CREATE TABLE IF NOT EXISTS context_files (
129
+ project_id TEXT NOT NULL REFERENCES projects(id),
130
+ dir_path TEXT NOT NULL,
131
+ file_path TEXT NOT NULL,
132
+ checkpoint_count INTEGER NOT NULL,
133
+ updated_at TEXT NOT NULL,
134
+ PRIMARY KEY (project_id, file_path)
135
+ );
136
+
137
+ CREATE TABLE IF NOT EXISTS social_posts (
138
+ id TEXT PRIMARY KEY,
139
+ project_id TEXT NOT NULL REFERENCES projects(id),
140
+ session_id TEXT NOT NULL REFERENCES sessions(id),
141
+ platform TEXT NOT NULL,
142
+ content TEXT NOT NULL,
143
+ source TEXT NOT NULL,
144
+ pinned INTEGER NOT NULL DEFAULT 0,
145
+ created_at TEXT NOT NULL,
146
+ model TEXT,
147
+ input_tokens INTEGER,
148
+ output_tokens INTEGER
149
+ );
150
+
151
+ CREATE INDEX IF NOT EXISTS idx_social_posts_project ON social_posts(project_id, created_at);
152
+
153
+ CREATE TABLE IF NOT EXISTS chat_conversations (
154
+ id TEXT PRIMARY KEY,
155
+ project_id TEXT NOT NULL REFERENCES projects(id),
156
+ title TEXT NOT NULL,
157
+ created_at TEXT NOT NULL,
158
+ updated_at TEXT NOT NULL
159
+ );
160
+
161
+ CREATE INDEX IF NOT EXISTS idx_chat_conversations_project ON chat_conversations(project_id, updated_at);
162
+
163
+ CREATE TABLE IF NOT EXISTS chat_messages (
164
+ id TEXT PRIMARY KEY,
165
+ conversation_id TEXT NOT NULL REFERENCES chat_conversations(id),
166
+ role TEXT NOT NULL,
167
+ content TEXT NOT NULL,
168
+ cited_state_ids_json TEXT,
169
+ model TEXT,
170
+ input_tokens INTEGER,
171
+ output_tokens INTEGER,
172
+ created_at TEXT NOT NULL
173
+ );
174
+
175
+ CREATE INDEX IF NOT EXISTS idx_chat_messages_conversation ON chat_messages(conversation_id, created_at);
176
+
177
+ -- Full-text index over each annotated state's real content, for the project
178
+ -- chat's grounding/retrieval step. state_id/project_id are UNINDEXED
179
+ -- (retrievable but not tokenized) — only "text" participates in MATCH.
180
+ CREATE VIRTUAL TABLE IF NOT EXISTS annotation_search USING fts5(
181
+ state_id UNINDEXED,
182
+ project_id UNINDEXED,
183
+ text
184
+ );
185
+ `;
186
+ //# sourceMappingURL=schema.js.map
@@ -0,0 +1,22 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const execFileAsync = promisify(execFile);
4
+ async function tryGit(args, cwd) {
5
+ try {
6
+ const { stdout } = await execFileAsync("git", args, { cwd });
7
+ return stdout.trim();
8
+ }
9
+ catch {
10
+ return null;
11
+ }
12
+ }
13
+ export async function detectGit(projectPath) {
14
+ const root = await tryGit(["rev-parse", "--show-toplevel"], projectPath);
15
+ if (!root) {
16
+ return { isRepo: false, root: null, branch: null, commitSha: null };
17
+ }
18
+ const branch = await tryGit(["rev-parse", "--abbrev-ref", "HEAD"], projectPath);
19
+ const commitSha = await tryGit(["rev-parse", "HEAD"], projectPath);
20
+ return { isRepo: true, root, branch, commitSha };
21
+ }
22
+ //# sourceMappingURL=git.js.map
@@ -0,0 +1,28 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { LicenseManager } from "./license-manager.js";
4
+ import { HttpLicenseApiClient } from "./license-client.js";
5
+ import { createLicenseStore } from "./license-store.js";
6
+ import { getMachineId } from "./machine-id.js";
7
+ /**
8
+ * PLACEHOLDER until license-server's real production keypair is generated
9
+ * (license-server/scripts/generate-keypair.ts) — replace with the real
10
+ * public key before this ever ships to a real customer. A build/release
11
+ * check should refuse to publish while this constant is unchanged; add
12
+ * that check as part of actual deployment, not covered by this plan.
13
+ */
14
+ export const PUBLIC_KEY_DER_BASE64 = process.env.WHYDID_LICENSE_PUBLIC_KEY ?? "REPLACE_WITH_REAL_PRODUCTION_PUBLIC_KEY";
15
+ export const LICENSE_API_URL = process.env.WHYDID_LICENSE_API_URL ?? "https://license.whydid.dev";
16
+ export const PRICING_URL = "https://whydid.dev/pricing";
17
+ function globalLicenseDir() {
18
+ return join(homedir(), ".whydid-license");
19
+ }
20
+ export async function createDefaultLicenseManager() {
21
+ const machineId = await getMachineId();
22
+ const store = await createLicenseStore(globalLicenseDir(), machineId);
23
+ const apiClient = new HttpLicenseApiClient(LICENSE_API_URL);
24
+ const manager = new LicenseManager(store, apiClient, PUBLIC_KEY_DER_BASE64);
25
+ await manager.load();
26
+ return manager;
27
+ }
28
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1,45 @@
1
+ export class LicenseClientError extends Error {
2
+ code;
3
+ constructor(code, message) {
4
+ super(message);
5
+ this.code = code;
6
+ this.name = "LicenseClientError";
7
+ }
8
+ }
9
+ export class HttpLicenseApiClient {
10
+ baseUrl;
11
+ fetchImpl;
12
+ constructor(baseUrl, fetchImpl = fetch) {
13
+ this.baseUrl = baseUrl;
14
+ this.fetchImpl = fetchImpl;
15
+ }
16
+ async post(path, body) {
17
+ let res;
18
+ try {
19
+ res = await this.fetchImpl(`${this.baseUrl}${path}`, {
20
+ method: "POST",
21
+ headers: { "content-type": "application/json" },
22
+ body: JSON.stringify(body),
23
+ });
24
+ }
25
+ catch {
26
+ throw new LicenseClientError("SERVER_UNAVAILABLE", "Could not reach the license server.");
27
+ }
28
+ const json = (await res.json());
29
+ if (!res.ok)
30
+ throw new LicenseClientError(json.code ?? "SERVER_UNAVAILABLE", json.message ?? `HTTP ${res.status}`);
31
+ return json;
32
+ }
33
+ async activate(licenseKey, machineId, dodoInstanceId) {
34
+ const body = await this.post("/v1/licenses/activate", dodoInstanceId ? { licenseKey, machineId, dodoInstanceId } : { licenseKey, machineId });
35
+ return body.signedLicense;
36
+ }
37
+ async refresh(licenseKey, machineId, dodoInstanceId) {
38
+ const body = await this.post("/v1/licenses/refresh", { licenseKey, machineId, dodoInstanceId });
39
+ return body.signedLicense;
40
+ }
41
+ async deactivate(licenseKey, machineId, dodoInstanceId) {
42
+ await this.post("/v1/licenses/deactivate", { licenseKey, machineId, dodoInstanceId });
43
+ }
44
+ }
45
+ //# sourceMappingURL=license-client.js.map
@@ -0,0 +1,89 @@
1
+ import { getMachineId } from "./machine-id.js";
2
+ import { verifySignedLicense } from "./verify-license.js";
3
+ const MS_PER_DAY = 86_400_000;
4
+ /**
5
+ * The single centralized licensing chokepoint — every other part of the
6
+ * app (the CLI's entry gate, and anything checking a feature) goes through
7
+ * this, never a scattered boolean or a raw signed-payload field access.
8
+ */
9
+ export class LicenseManager {
10
+ store;
11
+ apiClient;
12
+ publicKeyDerBase64;
13
+ now;
14
+ cached = null;
15
+ constructor(store, apiClient, publicKeyDerBase64, now = () => new Date()) {
16
+ this.store = store;
17
+ this.apiClient = apiClient;
18
+ this.publicKeyDerBase64 = publicKeyDerBase64;
19
+ this.now = now;
20
+ }
21
+ /** Must be called once (e.g. at CLI startup) before the synchronous getters below are meaningful. */
22
+ async load() {
23
+ const signed = await this.store.load();
24
+ this.cached = signed ? verifySignedLicense(signed, this.publicKeyDerBase64) : null;
25
+ }
26
+ isActivated() {
27
+ return this.cached !== null;
28
+ }
29
+ /** Signature already verified in load(); this only checks the time window. */
30
+ isValid() {
31
+ if (!this.cached)
32
+ return false;
33
+ const lastValidatedAt = new Date(this.cached.lastValidatedAt).getTime();
34
+ const nowMs = this.now().getTime();
35
+ // Clock-rollback guard: the wall clock reporting a time before the last
36
+ // known validation is treated as suspicious, not trusted to compute a
37
+ // generous "still within grace" answer.
38
+ if (nowMs < lastValidatedAt)
39
+ return false;
40
+ const windowMs = (this.cached.revalidationDays + this.cached.offlineGraceDays) * MS_PER_DAY;
41
+ return nowMs - lastValidatedAt <= windowMs;
42
+ }
43
+ hasFeature(name) {
44
+ return this.isValid() && (this.cached?.features.includes(name) ?? false);
45
+ }
46
+ needsRefresh() {
47
+ if (!this.cached)
48
+ return false;
49
+ const lastValidatedAt = new Date(this.cached.lastValidatedAt).getTime();
50
+ const revalidationWindowMs = this.cached.revalidationDays * MS_PER_DAY;
51
+ return this.now().getTime() - lastValidatedAt > revalidationWindowMs;
52
+ }
53
+ getLicense() {
54
+ return this.cached;
55
+ }
56
+ async activate(licenseKey) {
57
+ const machineId = await getMachineId();
58
+ const signed = await this.apiClient.activate(licenseKey, machineId, this.cached?.dodoInstanceId);
59
+ await this.persist(signed);
60
+ }
61
+ async deactivate() {
62
+ if (this.cached) {
63
+ await this.apiClient.deactivate(this.cached.licenseId, this.cached.machineId, this.cached.dodoInstanceId).catch(() => undefined);
64
+ }
65
+ await this.store.clear();
66
+ this.cached = null;
67
+ }
68
+ /** Offline-tolerant by design: a failed refresh (no network) leaves the last-known signed license in place, still valid until the grace period actually runs out. */
69
+ async refreshIfNeeded() {
70
+ if (!this.cached || !this.needsRefresh())
71
+ return;
72
+ try {
73
+ const signed = await this.apiClient.refresh(this.cached.licenseId, this.cached.machineId, this.cached.dodoInstanceId);
74
+ await this.persist(signed);
75
+ }
76
+ catch {
77
+ // Offline or the server is unreachable — keep operating on the
78
+ // last-known-good signed payload until the grace period expires.
79
+ }
80
+ }
81
+ async persist(signed) {
82
+ const verified = verifySignedLicense(signed, this.publicKeyDerBase64);
83
+ if (!verified)
84
+ throw new Error("License server returned a signature that failed local verification.");
85
+ await this.store.save(signed);
86
+ this.cached = verified;
87
+ }
88
+ }
89
+ //# sourceMappingURL=license-manager.js.map
@@ -0,0 +1,12 @@
1
+ const FIELD_ORDER = [
2
+ "licenseId", "dodoInstanceId", "machineId", "product", "plan", "features",
3
+ "issuedAt", "lastValidatedAt", "validUntil", "revalidationDays", "offlineGraceDays",
4
+ ];
5
+ /** Deterministic JSON serialization (fixed key order) so signing and verification always operate on identical bytes regardless of how the payload object was constructed. */
6
+ export function canonicalizePayload(payload) {
7
+ const ordered = {};
8
+ for (const key of FIELD_ORDER)
9
+ ordered[key] = payload[key];
10
+ return JSON.stringify(ordered);
11
+ }
12
+ //# sourceMappingURL=license-payload.js.map
@@ -0,0 +1,96 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, chmodSync } from "node:fs";
2
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
3
+ import { dirname, join } from "node:path";
4
+ const SERVICE_NAME = "whydid-license";
5
+ const ACCOUNT_NAME = "license";
6
+ /**
7
+ * OS keychain (macOS Keychain / Windows Credential Manager / Linux Secret
8
+ * Service), via @napi-rs/keyring's keytar-compatible API — the maintained
9
+ * replacement for the archived node-keytar, verified live against this
10
+ * machine's real Keychain during implementation. Preferred storage — falls
11
+ * back to EncryptedFileLicenseStore when the native module can't load on
12
+ * this platform/architecture.
13
+ */
14
+ export class KeychainLicenseStore {
15
+ async save(signed) {
16
+ const { setPassword } = await import("@napi-rs/keyring/keytar.js");
17
+ await setPassword(SERVICE_NAME, ACCOUNT_NAME, JSON.stringify(signed));
18
+ }
19
+ async load() {
20
+ const { getPassword } = await import("@napi-rs/keyring/keytar.js");
21
+ const raw = await getPassword(SERVICE_NAME, ACCOUNT_NAME);
22
+ if (!raw)
23
+ return null;
24
+ try {
25
+ return JSON.parse(raw);
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ async clear() {
32
+ const { deletePassword } = await import("@napi-rs/keyring/keytar.js");
33
+ await deletePassword(SERVICE_NAME, ACCOUNT_NAME).catch(() => false);
34
+ }
35
+ }
36
+ /**
37
+ * Fallback for platforms/architectures where the native keychain module
38
+ * can't load. AES-256-GCM-encrypted local file, keyed by material derived
39
+ * from this machine's own fingerprint — a soft measure ("harder to
40
+ * casually read than plain JSON"), not real security, exactly as the
41
+ * design doc frames it.
42
+ */
43
+ export class EncryptedFileLicenseStore {
44
+ path;
45
+ key;
46
+ constructor(dir, keyMaterial) {
47
+ this.path = join(dir, "license.enc");
48
+ this.key = createHash("sha256").update(keyMaterial).digest(); // 32 bytes, fits AES-256
49
+ }
50
+ async save(signed) {
51
+ mkdirSync(dirname(this.path), { recursive: true });
52
+ const iv = randomBytes(12);
53
+ const cipher = createCipheriv("aes-256-gcm", this.key, iv);
54
+ const encrypted = Buffer.concat([cipher.update(JSON.stringify(signed), "utf-8"), cipher.final()]);
55
+ const authTag = cipher.getAuthTag();
56
+ const onDisk = { iv: iv.toString("base64"), authTag: authTag.toString("base64"), data: encrypted.toString("base64") };
57
+ writeFileSync(this.path, JSON.stringify(onDisk), { mode: 0o600 });
58
+ try {
59
+ chmodSync(this.path, 0o600);
60
+ }
61
+ catch {
62
+ // best-effort, not fatal
63
+ }
64
+ }
65
+ async load() {
66
+ if (!existsSync(this.path))
67
+ return null;
68
+ try {
69
+ const onDisk = JSON.parse(readFileSync(this.path, "utf-8"));
70
+ const decipher = createDecipheriv("aes-256-gcm", this.key, Buffer.from(onDisk.iv, "base64"));
71
+ decipher.setAuthTag(Buffer.from(onDisk.authTag, "base64"));
72
+ const decrypted = Buffer.concat([decipher.update(Buffer.from(onDisk.data, "base64")), decipher.final()]);
73
+ return JSON.parse(decrypted.toString("utf-8"));
74
+ }
75
+ catch {
76
+ // Wrong key material, corrupted file, or tampered ciphertext — fail
77
+ // closed (treat as "no license stored") rather than throw.
78
+ return null;
79
+ }
80
+ }
81
+ async clear() {
82
+ if (existsSync(this.path))
83
+ unlinkSync(this.path);
84
+ }
85
+ }
86
+ /** Tries the OS keychain first; falls back to the encrypted file only if the native module genuinely can't load on this platform. */
87
+ export async function createLicenseStore(globalDir, fallbackKeyMaterial) {
88
+ try {
89
+ await import("@napi-rs/keyring/keytar.js");
90
+ return new KeychainLicenseStore();
91
+ }
92
+ catch {
93
+ return new EncryptedFileLicenseStore(globalDir, fallbackKeyMaterial);
94
+ }
95
+ }
96
+ //# sourceMappingURL=license-store.js.map
@@ -0,0 +1,78 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { randomUUID, createHash } from "node:crypto";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ /**
7
+ * Not a secret — just namespaces the derived hash so it doesn't
8
+ * incidentally match another tool's fingerprint of the same machine.
9
+ */
10
+ const APP_SALT = "whydid-machine-id-v1";
11
+ export function deriveMachineId(rawStableValue) {
12
+ return createHash("sha256").update(rawStableValue + APP_SALT).digest("hex");
13
+ }
14
+ function globalLicenseDir(customDir) {
15
+ return customDir ?? join(homedir(), ".whydid-license");
16
+ }
17
+ /** macOS: the hardware UUID, stable across reboots and reinstalls of the OS. */
18
+ function readMacOsHardwareUuid() {
19
+ try {
20
+ const output = execFileSync("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], { encoding: "utf-8" });
21
+ const match = output.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/);
22
+ return match ? match[1] : null;
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ /** Windows: MachineGuid, generated at OS install and stable until a reinstall. */
29
+ function readWindowsMachineGuid() {
30
+ try {
31
+ const output = execFileSync("reg", ["query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid"], { encoding: "utf-8" });
32
+ const match = output.match(/MachineGuid\s+REG_SZ\s+(\S+)/);
33
+ return match ? match[1] : null;
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ /** Linux: /etc/machine-id (falling back to the dbus copy some distros use instead), a plain file read — no shell-out needed. */
40
+ function readLinuxMachineId() {
41
+ for (const path of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
42
+ try {
43
+ const value = readFileSync(path, "utf-8").trim();
44
+ if (value)
45
+ return value;
46
+ }
47
+ catch {
48
+ // try the next path
49
+ }
50
+ }
51
+ return null;
52
+ }
53
+ async function defaultReadOsStableValue() {
54
+ if (process.platform === "darwin")
55
+ return readMacOsHardwareUuid();
56
+ if (process.platform === "win32")
57
+ return readWindowsMachineGuid();
58
+ if (process.platform === "linux")
59
+ return readLinuxMachineId();
60
+ return null;
61
+ }
62
+ /** Reused when no OS-native identifier is readable at all — weaker (doesn't survive a wiped home directory) but never blocks the customer, matching the design's "don't permanently lock customers out" requirement. */
63
+ function getOrCreatePersistedFallbackId(globalDir) {
64
+ const path = join(globalDir, "fallback-machine-id");
65
+ if (existsSync(path))
66
+ return readFileSync(path, "utf-8").trim();
67
+ mkdirSync(globalDir, { recursive: true });
68
+ const id = randomUUID();
69
+ writeFileSync(path, id, "utf-8");
70
+ return id;
71
+ }
72
+ export async function getMachineId(options = {}) {
73
+ const readOsStableValue = options.readOsStableValue ?? defaultReadOsStableValue;
74
+ const dir = globalLicenseDir(options.globalDir);
75
+ const raw = (await readOsStableValue()) ?? getOrCreatePersistedFallbackId(dir);
76
+ return deriveMachineId(raw);
77
+ }
78
+ //# sourceMappingURL=machine-id.js.map
@@ -0,0 +1,102 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ export class EnvSecretStore {
4
+ async get(provider) {
5
+ const envName = `${provider.toUpperCase()}_API_KEY`;
6
+ return process.env[envName] ?? null;
7
+ }
8
+ async set(_provider, _value) {
9
+ throw new Error("EnvSecretStore is read-only; set the environment variable instead.");
10
+ }
11
+ async delete(_provider) {
12
+ throw new Error("EnvSecretStore is read-only; unset the environment variable instead.");
13
+ }
14
+ }
15
+ /**
16
+ * Per-project API keys saved locally instead of (or on top of) a global
17
+ * environment variable — from the dashboard's Settings page, or
18
+ * `whydid key set <provider> <key>` in the terminal. Stored in
19
+ * .whydid/secrets.json, written 0600; `whydid init` already adds .whydid/
20
+ * to the project's .gitignore, so this never gets committed by accident.
21
+ *
22
+ * A key saved this way always wins over a same-provider environment
23
+ * variable, for this project specifically — that's the point of "instead
24
+ * of setting it globally": one project can override without touching a
25
+ * shell profile (or every other project relying on the same global var).
26
+ */
27
+ export class FileSecretStore {
28
+ path;
29
+ constructor(whydidDir) {
30
+ this.path = join(whydidDir, "secrets.json");
31
+ }
32
+ readAll() {
33
+ if (!existsSync(this.path))
34
+ return {};
35
+ try {
36
+ const parsed = JSON.parse(readFileSync(this.path, "utf-8"));
37
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
38
+ }
39
+ catch {
40
+ return {};
41
+ }
42
+ }
43
+ writeAll(secrets) {
44
+ mkdirSync(dirname(this.path), { recursive: true });
45
+ writeFileSync(this.path, JSON.stringify(secrets, null, 2) + "\n", { mode: 0o600 });
46
+ try {
47
+ // writeFileSync's `mode` option only applies on initial creation, not
48
+ // when overwriting a file that already existed with looser
49
+ // permissions (e.g. carried over from a backup/sync tool) — force it
50
+ // on every write instead of just hoping the first write set it.
51
+ chmodSync(this.path, 0o600);
52
+ }
53
+ catch {
54
+ // Best-effort; not fatal if chmod isn't supported on this filesystem.
55
+ }
56
+ }
57
+ async get(provider) {
58
+ const stored = this.readAll()[provider];
59
+ if (stored)
60
+ return stored;
61
+ const envName = `${provider.toUpperCase()}_API_KEY`;
62
+ return process.env[envName] ?? null;
63
+ }
64
+ async set(provider, value) {
65
+ const secrets = this.readAll();
66
+ secrets[provider] = value;
67
+ this.writeAll(secrets);
68
+ }
69
+ async delete(provider) {
70
+ const secrets = this.readAll();
71
+ delete secrets[provider];
72
+ this.writeAll(secrets);
73
+ }
74
+ /** Whether a key is saved on disk for this provider specifically — not counting an environment variable fallback. For "is this saved locally," without ever exposing the value. */
75
+ hasStoredKey(provider) {
76
+ return !!this.readAll()[provider];
77
+ }
78
+ }
79
+ /** The provider ids whydid's own LLM factory knows how to use — see llm/factory.ts. */
80
+ export const KNOWN_AI_PROVIDERS = ["anthropic", "openai"];
81
+ function maskSecretKey(value) {
82
+ if (value.length <= 8)
83
+ return "•".repeat(value.length);
84
+ return `${value.slice(0, 4)}${"•".repeat(6)}${value.slice(-4)}`;
85
+ }
86
+ /**
87
+ * Status for every known AI provider — shared by the CLI (`whydid key
88
+ * list`) and the dashboard's Settings page, so the two can never disagree
89
+ * about which key is active or where it's coming from. Never returns the
90
+ * raw key itself.
91
+ */
92
+ export async function getSecretStatuses(store) {
93
+ const results = [];
94
+ for (const provider of KNOWN_AI_PROVIDERS) {
95
+ const storedLocally = store.hasStoredKey(provider);
96
+ const resolved = await store.get(provider);
97
+ const source = storedLocally ? "file" : resolved ? "env" : "none";
98
+ results.push({ provider, configured: resolved !== null, source, masked: resolved ? maskSecretKey(resolved) : null });
99
+ }
100
+ return results;
101
+ }
102
+ //# sourceMappingURL=secret-store.js.map
@@ -0,0 +1,20 @@
1
+ import { verify as cryptoVerify, createPublicKey } from "node:crypto";
2
+ import { canonicalizePayload } from "./license-payload.js";
3
+ /**
4
+ * Verifies a signed license against the compiled-in public key. Returns the
5
+ * payload only when the signature is genuine and matches the payload
6
+ * exactly — returns null (never throws) for a tampered payload, a wrong
7
+ * keypair, or malformed input, so callers always get a clean yes/no rather
8
+ * than having to guard against exceptions from crypto internals.
9
+ */
10
+ export function verifySignedLicense(signed, publicKeyDerBase64) {
11
+ try {
12
+ const publicKey = createPublicKey({ key: Buffer.from(publicKeyDerBase64, "base64"), format: "der", type: "spki" });
13
+ const ok = cryptoVerify(null, Buffer.from(canonicalizePayload(signed.payload)), publicKey, Buffer.from(signed.signature, "base64"));
14
+ return ok ? signed.payload : null;
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
20
+ //# sourceMappingURL=verify-license.js.map