embertime 0.1.3 → 0.1.4

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,5 +1,5 @@
1
1
  {
2
2
  "name": "ember",
3
3
  "description": "Ember coding-time tracker: records Claude Code sessions, prompts and file edits as anonymised Ember heartbeats (no prompt text, file contents or paths are sent).",
4
- "version": "0.1.3"
4
+ "version": "0.1.4"
5
5
  }
package/cli.mjs CHANGED
@@ -193,6 +193,7 @@ function uninstall(o) {
193
193
  const HELP = `embertime: track coding time across editors and AI agents
194
194
 
195
195
  npx embertime setup --key <ember_key> [--api-url <url>] [--vsix <file>] [--skip-editors]
196
+ npx embertime setup re-run with the key you already saved
196
197
  npx embertime status
197
198
  npx embertime uninstall [--purge]
198
199
 
package/ember-hook.mjs CHANGED
@@ -9,7 +9,8 @@
9
9
  // * never writes to stdout for Claude Code / Codex (they may interpret it); for
10
10
  // Cursor it prints the minimal JSON reply Cursor expects. Always exits 0
11
11
  // * never sends file contents, raw paths or prompt text: files are reduced
12
- // to sha1(absolute path) and the project to the git root's basename
12
+ // to an HMAC of the absolute path, keyed with a random per-machine secret
13
+ // (~/.ember/entity.key), and the project to the git root's basename
13
14
  // * heartbeats are appended to ~/.ember/queue.jsonl before any network I/O,
14
15
  // so nothing is lost when the server is down
15
16
  //
@@ -21,7 +22,7 @@ import path from 'node:path';
21
22
  import crypto from 'node:crypto';
22
23
  import { fileURLToPath } from 'node:url';
23
24
 
24
- export const VERSION = '0.1.3';
25
+ export const VERSION = '0.1.4';
25
26
  export const AGENTS = ['claude-code', 'codex', 'cursor-agent'];
26
27
 
27
28
  /** Events whose heartbeats are throttled: they fire on every tool call or file edit. */
@@ -59,6 +60,7 @@ function paths(home) {
59
60
  state: path.join(home, 'hook-state.json'),
60
61
  log: path.join(home, 'hook.log'),
61
62
  lastError: path.join(home, 'last_error'),
63
+ entityKey: path.join(home, 'entity.key'),
62
64
  };
63
65
  }
64
66
 
@@ -409,8 +411,57 @@ export function extractFilePath(toolInput, cwd) {
409
411
  return path.resolve(cwd || process.cwd(), p);
410
412
  }
411
413
 
412
- export function sha1(text) {
413
- return crypto.createHash('sha1').update(text).digest('hex');
414
+ /**
415
+ * File identity sent as `entity`: HMAC-SHA256 of the absolute path under the
416
+ * per-machine key, cut to the 40 hex characters the server accepts. A plain hash
417
+ * of a path could be reversed by guessing likely paths; without the key it can't.
418
+ */
419
+ export function entityHash(key, file) {
420
+ return crypto.createHmac('sha256', key).update(file).digest('hex').slice(0, 40);
421
+ }
422
+
423
+ const ENTITY_KEY_RE = /^[a-f0-9]{64}$/;
424
+
425
+ function readEntityKey(file) {
426
+ try {
427
+ const key = fs.readFileSync(file, 'utf8').trim();
428
+ return ENTITY_KEY_RE.test(key) ? key : undefined;
429
+ } catch {
430
+ return undefined;
431
+ }
432
+ }
433
+
434
+ /**
435
+ * The random key shared by every collector on this machine (mode 0600), created
436
+ * on first use. Concurrent first runs agree on one key: the first hard link wins.
437
+ * If it can't be stored, a one-off key is used: entities then differ between runs,
438
+ * which only weakens de-duplication, never privacy.
439
+ */
440
+ export function loadEntityKey(home) {
441
+ const file = paths(home).entityKey;
442
+ const existing = readEntityKey(file);
443
+ if (existing) return existing;
444
+ const key = crypto.randomBytes(32).toString('hex');
445
+ const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
446
+ try {
447
+ fs.mkdirSync(home, { recursive: true, mode: 0o700 });
448
+ fs.writeFileSync(tmp, `${key}\n`, { mode: 0o600 });
449
+ try {
450
+ fs.linkSync(tmp, file);
451
+ return key;
452
+ } catch (e) {
453
+ if (e.code !== 'EEXIST') throw e;
454
+ const winner = readEntityKey(file);
455
+ if (winner) return winner;
456
+ fs.renameSync(tmp, file); // existing file is corrupt: replace it
457
+ return key;
458
+ }
459
+ } catch (e) {
460
+ log(home, 'warn', `could not store entity key: ${e.message}`);
461
+ return key;
462
+ } finally {
463
+ fs.rmSync(tmp, { force: true });
464
+ }
414
465
  }
415
466
 
416
467
  export function parsePayload(text) {
@@ -427,7 +478,7 @@ export function parsePayload(text) {
427
478
  * Build exactly one heartbeat from a hook payload. Only derived, non-sensitive
428
479
  * fields are copied: never prompt text, tool output, file contents or paths.
429
480
  */
430
- export function buildHeartbeat(payload, { agent, now = Date.now() / 1000, env = process.env } = {}) {
481
+ export function buildHeartbeat(payload, { agent, now = Date.now() / 1000, env = process.env, entityKey } = {}) {
431
482
  // Claude Code and Codex send `cwd`; Cursor sends `workspace_roots` (and sets CURSOR_PROJECT_DIR).
432
483
  const roots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots.filter((r) => typeof r === 'string' && r) : [];
433
484
  const cwd =
@@ -450,7 +501,7 @@ export function buildHeartbeat(payload, { agent, now = Date.now() / 1000, env =
450
501
  extractFilePath(payload.tool_input, cwd) ||
451
502
  (typeof payload.file_path === 'string' && payload.file_path.trim() ? path.resolve(cwd, payload.file_path.trim()) : undefined);
452
503
  if (file) {
453
- hb.entity = sha1(file);
504
+ hb.entity = entityHash(entityKey ?? loadEntityKey(emberHome(env)), file);
454
505
  const language = languageForPath(file);
455
506
  if (language) hb.language = language;
456
507
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "embertime",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Connect VS Code, Cursor, Windsurf, Claude Code and Codex to Ember in one command. Tracks coding time, never code or prompts.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,7 @@
9
9
  "files": [
10
10
  "cli.mjs",
11
11
  "editors.mjs",
12
+ "postinstall.mjs",
12
13
  "ember-hook.mjs",
13
14
  "install.mjs",
14
15
  "hooks",
@@ -30,7 +31,8 @@
30
31
  "heatmap"
31
32
  ],
32
33
  "scripts": {
33
- "test": "node --test test/*.test.mjs"
34
+ "test": "node --test test/*.test.mjs",
35
+ "postinstall": "node postinstall.mjs"
34
36
  },
35
37
  "repository": {
36
38
  "type": "git",
@@ -0,0 +1,20 @@
1
+ // Runs after `npm install -g embertime` (and upgrades). If Ember was already set
2
+ // up on this machine, refresh the hook copy in ~/.ember/agent-hook so agents run
3
+ // the new version straight away. Your key in ~/.ember/config.json is untouched.
4
+ // Best effort: it never fails the install and does nothing on a fresh machine.
5
+
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ try {
12
+ const here = path.dirname(fileURLToPath(import.meta.url));
13
+ const dest = path.join(process.env.EMBER_HOME || path.join(os.homedir(), '.ember'), 'agent-hook');
14
+ if (fs.existsSync(path.join(dest, 'ember-hook.mjs'))) {
15
+ for (const f of ['ember-hook.mjs', 'install.mjs', 'package.json']) fs.copyFileSync(path.join(here, f), path.join(dest, f));
16
+ console.log('embertime: updated the hook in ~/.ember/agent-hook (your key is unchanged).');
17
+ }
18
+ } catch {
19
+ /* never block an install */
20
+ }