whydid 1.0.1 → 1.0.3

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,12 +1,30 @@
1
1
  import { existsSync, readdirSync, statSync, openSync, closeSync, readSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { join } from "node:path";
3
+ import { join, basename as nodeBasename } from "node:path";
4
4
  export function claudeCodeProjectSlug(projectRoot) {
5
5
  return projectRoot.replace(/[^a-zA-Z0-9]+/g, "-");
6
6
  }
7
+ /**
8
+ * Claude Code keys its ~/.claude/projects/<slug> folder on the full
9
+ * absolute path on macOS (verified directly against a real local install —
10
+ * see claudeCodeProjectSlug's test). But a real Windows install was
11
+ * observed naming the folder from the project directory's own basename
12
+ * only — no drive letter, no parent segments at all (e.g. "enrollverse-
13
+ * landing" for C:\Users\...\enrollverse_landing). This is an undocumented
14
+ * format that evidently differs by platform or version, so
15
+ * findClaudeCodeSessionDir tries both rather than assuming one.
16
+ */
17
+ export function claudeCodeBasenameSlug(projectRoot, basenameImpl = nodeBasename) {
18
+ return claudeCodeProjectSlug(basenameImpl(projectRoot));
19
+ }
7
20
  export function findClaudeCodeSessionDir(projectRoot, claudeProjectsDir = join(homedir(), ".claude", "projects")) {
8
- const dir = join(claudeProjectsDir, claudeCodeProjectSlug(projectRoot));
9
- return existsSync(dir) ? dir : null;
21
+ const fullPathDir = join(claudeProjectsDir, claudeCodeProjectSlug(projectRoot));
22
+ if (existsSync(fullPathDir))
23
+ return fullPathDir;
24
+ const basenameDir = join(claudeProjectsDir, claudeCodeBasenameSlug(projectRoot));
25
+ if (existsSync(basenameDir))
26
+ return basenameDir;
27
+ return null;
10
28
  }
11
29
  export function findLatestSessionFile(sessionDir) {
12
30
  let latestFile = null;
@@ -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() {
@@ -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() {
@@ -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.1",
3
+ "version": "1.0.3",
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.",