whydid 1.0.0 → 1.0.2

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.
@@ -1,10 +1,25 @@
1
1
  import { Command } from "commander";
2
- import { createDefaultLicenseManager, PRICING_URL } from "../../licensing/config.js";
2
+ import { createDefaultLicenseManager } from "../../licensing/config.js";
3
+ import { LicenseClientError } from "../../licensing/license-client.js";
4
+ const SUPPORT_EMAIL = "hello@whydid.dev";
3
5
  export async function runLogin(manager, licenseKey) {
4
6
  await manager.activate(licenseKey);
5
7
  }
6
8
  export async function runLogout(manager) {
7
- await manager.deactivate();
9
+ return manager.deactivate();
10
+ }
11
+ /**
12
+ * Only the device-limit case gets device-management guidance — every other
13
+ * failure (bad key, network down, server down) showing the same hint was
14
+ * noise at best and, since there's no self-serve devices page yet, a dead
15
+ * link at worst. Points to support directly until that page exists.
16
+ */
17
+ export function formatLoginFailureMessage(err) {
18
+ const message = err instanceof Error ? err.message : String(err);
19
+ if (err instanceof LicenseClientError && err.code === "MACHINE_LIMIT_REACHED") {
20
+ return [message, `Already using this license on 2 devices? Email ${SUPPORT_EMAIL} to free up a slot.`];
21
+ }
22
+ return [message];
8
23
  }
9
24
  /** Reads only the locally-stored, already-verified license — no network call, matching whydid's local-first design even for its own status check. */
10
25
  export async function runLicenseStatus(manager) {
@@ -23,8 +38,8 @@ export function createLoginCommand() {
23
38
  console.log("✓ Activated. Run `whydid license` any time to check status.");
24
39
  }
25
40
  catch (err) {
26
- console.error(err instanceof Error ? err.message : String(err));
27
- console.error(`Manage your devices: ${PRICING_URL.replace("/pricing", "/account/devices")}`);
41
+ for (const line of formatLoginFailureMessage(err))
42
+ console.error(line);
28
43
  process.exitCode = 1;
29
44
  }
30
45
  });
@@ -34,8 +49,8 @@ export function createLogoutCommand() {
34
49
  .description("Deactivate this machine")
35
50
  .action(async () => {
36
51
  const manager = await createDefaultLicenseManager();
37
- await runLogout(manager);
38
- console.log("✓ Deactivated this machine.");
52
+ const wasActivated = await runLogout(manager);
53
+ console.log(wasActivated ? "✓ Deactivated this machine." : "Nothing was activated on this machine.");
39
54
  });
40
55
  }
41
56
  export function createLicenseCommand() {
package/dist/cli/index.js CHANGED
@@ -49,8 +49,9 @@ async function main() {
49
49
  const program = new Command("whydid").description("A local time machine for AI-assisted coding").version(PACKAGE_VERSION, "-V, --version");
50
50
  const rawArg = process.argv[2];
51
51
  const commandArg = rawArg === "doctor" ? "recap" : rawArg; // doctor is an alias registered on the recap Command itself
52
+ const subCommandArg = process.argv[3];
52
53
  const { commandNeedsLicense, printLicenseRequiredMessage } = await import("./license-gate.js");
53
- if (commandNeedsLicense(commandArg)) {
54
+ if (commandNeedsLicense(commandArg, subCommandArg)) {
54
55
  const { createDefaultLicenseManager } = await import("../licensing/config.js");
55
56
  const manager = await createDefaultLicenseManager();
56
57
  await manager.refreshIfNeeded().catch(() => undefined); // best-effort, offline-tolerant
@@ -1,13 +1,17 @@
1
1
  import { PRICING_URL } from "../licensing/config.js";
2
2
  /** Never gated — a customer must be able to authenticate/check status/see this message without already having a valid license. */
3
3
  const EXEMPT_COMMANDS = new Set(["login", "logout", "license"]);
4
- export function commandNeedsLicense(commandArg) {
4
+ /** Stopping or checking an already-running daemon shouldn't require a valid license — only starting new work (or any other command) does. */
5
+ const EXEMPT_DAEMON_SUBCOMMANDS = new Set(["stop", "status"]);
6
+ export function commandNeedsLicense(commandArg, subCommandArg) {
5
7
  if (!commandArg)
6
8
  return false; // no args -> shows help
7
9
  if (commandArg === "--help" || commandArg === "-h")
8
10
  return false;
9
11
  if (commandArg === "--version" || commandArg === "-V")
10
12
  return false;
13
+ if (commandArg === "daemon" && subCommandArg && EXEMPT_DAEMON_SUBCOMMANDS.has(subCommandArg))
14
+ return false;
11
15
  return !EXEMPT_COMMANDS.has(commandArg);
12
16
  }
13
17
  export function printLicenseRequiredMessage() {
@@ -5,13 +5,13 @@ import { HttpLicenseApiClient } from "./license-client.js";
5
5
  import { createLicenseStore } from "./license-store.js";
6
6
  import { getMachineId } from "./machine-id.js";
7
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.
8
+ * The real production Ed25519 public key, matching the private key held
9
+ * only in license-server's deployed .env (LICENSE_ED25519_PRIVATE_KEY) —
10
+ * generated once via license-server/scripts/generate-keypair.ts. Every
11
+ * signed license this public key verifies was signed by that exact
12
+ * deployment; swapping it invalidates every previously-issued license.
13
13
  */
14
- export const PUBLIC_KEY_DER_BASE64 = process.env.WHYDID_LICENSE_PUBLIC_KEY ?? "REPLACE_WITH_REAL_PRODUCTION_PUBLIC_KEY";
14
+ export const PUBLIC_KEY_DER_BASE64 = process.env.WHYDID_LICENSE_PUBLIC_KEY ?? "MCowBQYDK2VwAyEA2CpLqf2+q26q6q1k77bbJZYvQ1otG4E6iY19e+KNogc=";
15
15
  export const LICENSE_API_URL = process.env.WHYDID_LICENSE_API_URL ?? "https://license.whydid.dev";
16
16
  export const PRICING_URL = "https://whydid.dev/pricing";
17
17
  function globalLicenseDir() {
@@ -58,12 +58,15 @@ export class LicenseManager {
58
58
  const signed = await this.apiClient.activate(licenseKey, machineId, this.cached?.dodoInstanceId);
59
59
  await this.persist(signed);
60
60
  }
61
+ /** Returns whether anything was actually activated before this call, so the CLI can give an accurate message rather than always reporting success. */
61
62
  async deactivate() {
63
+ const wasActivated = this.cached !== null;
62
64
  if (this.cached) {
63
65
  await this.apiClient.deactivate(this.cached.licenseId, this.cached.machineId, this.cached.dodoInstanceId).catch(() => undefined);
64
66
  }
65
67
  await this.store.clear();
66
68
  this.cached = null;
69
+ return wasActivated;
67
70
  }
68
71
  /** 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
72
  async refreshIfNeeded() {
@@ -3,6 +3,24 @@ import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:
3
3
  import { dirname, join } from "node:path";
4
4
  const SERVICE_NAME = "whydid-license";
5
5
  const ACCOUNT_NAME = "license";
6
+ async function loadRealKeytar() {
7
+ return import("@napi-rs/keyring/keytar.js");
8
+ }
9
+ /**
10
+ * The OS keychain prompt (e.g. macOS's "wants to use your confidential
11
+ * information") asks for the user's own Mac login password, not anything
12
+ * whydid-specific — but denying/canceling it throws a raw native error
13
+ * ("User canceled the operation.") straight out of @napi-rs/keyring. Left
14
+ * unwrapped, that string reaches the user verbatim with no context. This
15
+ * wraps ANY keychain-access failure (denied, canceled, or an unexpected
16
+ * platform error) in one clear, actionable message.
17
+ */
18
+ function wrapKeychainError(err) {
19
+ const raw = err instanceof Error ? err.message : String(err);
20
+ return new Error(`whydid couldn't access your saved license in the system keychain (${raw}). ` +
21
+ `This is asking for your Mac's own login password, not a whydid password — ` +
22
+ `run the command again and click "Allow" (or "Always Allow") when prompted.`);
23
+ }
6
24
  /**
7
25
  * OS keychain (macOS Keychain / Windows Credential Manager / Linux Secret
8
26
  * Service), via @napi-rs/keyring's keytar-compatible API — the maintained
@@ -12,13 +30,28 @@ const ACCOUNT_NAME = "license";
12
30
  * this platform/architecture.
13
31
  */
14
32
  export class KeychainLicenseStore {
33
+ loadKeytar;
34
+ constructor(loadKeytar = loadRealKeytar) {
35
+ this.loadKeytar = loadKeytar;
36
+ }
15
37
  async save(signed) {
16
- const { setPassword } = await import("@napi-rs/keyring/keytar.js");
17
- await setPassword(SERVICE_NAME, ACCOUNT_NAME, JSON.stringify(signed));
38
+ const { setPassword } = await this.loadKeytar();
39
+ try {
40
+ await setPassword(SERVICE_NAME, ACCOUNT_NAME, JSON.stringify(signed));
41
+ }
42
+ catch (err) {
43
+ throw wrapKeychainError(err);
44
+ }
18
45
  }
19
46
  async load() {
20
- const { getPassword } = await import("@napi-rs/keyring/keytar.js");
21
- const raw = await getPassword(SERVICE_NAME, ACCOUNT_NAME);
47
+ const { getPassword } = await this.loadKeytar();
48
+ let raw;
49
+ try {
50
+ raw = await getPassword(SERVICE_NAME, ACCOUNT_NAME);
51
+ }
52
+ catch (err) {
53
+ throw wrapKeychainError(err);
54
+ }
22
55
  if (!raw)
23
56
  return null;
24
57
  try {
@@ -29,7 +62,7 @@ export class KeychainLicenseStore {
29
62
  }
30
63
  }
31
64
  async clear() {
32
- const { deletePassword } = await import("@napi-rs/keyring/keytar.js");
65
+ const { deletePassword } = await this.loadKeytar();
33
66
  await deletePassword(SERVICE_NAME, ACCOUNT_NAME).catch(() => false);
34
67
  }
35
68
  }
@@ -1,4 +1,5 @@
1
1
  import chokidar from "chokidar";
2
+ import { relative as nodeRelative } from "node:path";
2
3
  import { createCheckpoint } from "../core/engine.js";
3
4
  import { annotateCheckpoint } from "../llm/annotate-checkpoint.js";
4
5
  import { updateContextTree } from "../core/context-tree.js";
@@ -6,6 +7,19 @@ import { generateAndSaveSocialPost } from "../llm/social-post.js";
6
7
  import { NoopProvider } from "../llm/noop-provider.js";
7
8
  import { syncClaudeCodeTurns } from "../adapters/sync-claude-code.js";
8
9
  const TURN_POLL_MS = 250;
10
+ /**
11
+ * Chokidar normalizes the paths it hands to `ignored` to forward slashes on
12
+ * every platform, but a project root resolved via Node's own path APIs is
13
+ * OS-native (backslashes on Windows) — a naive string-prefix strip silently
14
+ * fails to match there, passing the full absolute path straight through to
15
+ * IgnoreRules, which then throws (the `ignore` package requires a relative
16
+ * path). `path.relative()` correctly handles drive letters, casing, and
17
+ * slash style regardless of platform; `relativeImpl` is overridable only so
18
+ * tests can exercise Windows semantics (`path.win32.relative`) on any host OS.
19
+ */
20
+ export function toProjectRelativePath(projectRoot, absolutePath, relativeImpl = nodeRelative) {
21
+ return relativeImpl(projectRoot, absolutePath);
22
+ }
9
23
  export class ProjectWatcher {
10
24
  ctx;
11
25
  projectId;
@@ -90,7 +104,7 @@ export class ProjectWatcher {
90
104
  this.pollingMode = usePolling;
91
105
  const watcher = chokidar.watch(this.ctx.projectRoot, {
92
106
  ignored: (path) => {
93
- const rel = path.replace(this.ctx.projectRoot, "").replace(/^[\\/]/, "");
107
+ const rel = toProjectRelativePath(this.ctx.projectRoot, path);
94
108
  return rel !== "" && this.ctx.ignoreRules.isIgnored(rel);
95
109
  },
96
110
  ignoreInitial: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whydid",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "description": "A local-first AI coding time machine — captures meaningful changes, reasoning, and session context so you can see what happened across your coding sessions, even days later.",