@vibe-cafe/vibe-usage 0.14.0 → 0.14.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "scripts": {
package/src/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, writeFileSync, chmodSync, mkdirSync, existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
+ import { moveDirectoryOutOfFilePath } from './path-repair.js';
4
5
 
5
6
  // VIBE_USAGE_CONFIG_DIR overrides the dir (test hook).
6
7
  const CONFIG_DIR = process.env.VIBE_USAGE_CONFIG_DIR?.trim() || join(homedir(), '.vibe-usage');
@@ -22,6 +23,9 @@ export function loadConfig() {
22
23
 
23
24
  export function saveConfig(config) {
24
25
  mkdirSync(CONFIG_DIR, { recursive: true });
26
+ // A directory sitting where the config file belongs would make this write
27
+ // throw EISDIR forever; move it aside so the run can save.
28
+ moveDirectoryOutOfFilePath(CONFIG_FILE);
25
29
  // The file holds the vbu_ API key — never leave it group/world-readable.
26
30
  // mode only applies at file creation, so chmod explicitly for pre-existing
27
31
  // files written before this hardening.
package/src/init.js CHANGED
@@ -21,7 +21,16 @@ function prompt(question) {
21
21
  }
22
22
 
23
23
  function openBrowser(url) {
24
- const cmds = { darwin: 'open', linux: 'xdg-open', win32: 'start' };
24
+ // `start` is a cmd.exe builtin, not an executable: execFile('start', …) fails
25
+ // outright on Windows, so the device-flow login never opened a tab and the
26
+ // user was left staring at the code with no browser to type it into. Go
27
+ // through cmd, and escape a bare `&` (present in verificationUriComplete's
28
+ // query string) or cmd would read it as a command separator.
29
+ if (platform() === 'win32') {
30
+ execFile('cmd', ['/c', 'start', '', url.replace(/&/g, '^&')], { windowsHide: true }, () => {});
31
+ return;
32
+ }
33
+ const cmds = { darwin: 'open', linux: 'xdg-open' };
25
34
  const cmd = cmds[platform()] || cmds.linux;
26
35
  // Use execFile with args array to avoid shell injection via VIBE_USAGE_API_URL
27
36
  execFile(cmd, [url], () => {});
@@ -24,10 +24,21 @@ export function openCodeStore(root) {
24
24
  return null;
25
25
  }
26
26
 
27
+ // XDG first, then the Windows per-user store. A root that does not resolve is
28
+ // skipped by the caller, so probing costs nothing when it is absent.
29
+ function defaultOpenCodeRoots() {
30
+ const xdg = join(homedir(), '.local', 'share', 'opencode');
31
+ const localAppData = process.env.LOCALAPPDATA?.trim();
32
+ if (process.platform === 'win32' && localAppData) {
33
+ return [xdg, join(localAppData, 'opencode')];
34
+ }
35
+ return [xdg];
36
+ }
37
+
27
38
  export function getOpenCodeStores({ extraRoots = [], onWarning = () => {} } = {}) {
28
39
  const override = process.env.VIBE_USAGE_OPENCODE_DIRS?.trim();
29
40
  const defaults = override ? override.split(delimiter).map(p => p.trim()).filter(Boolean)
30
- : [join(homedir(), '.local', 'share', 'opencode')];
41
+ : defaultOpenCodeRoots();
31
42
  const seen = new Set(), stores = [];
32
43
  for (const root of [...defaults, ...extraRoots]) {
33
44
  try {
@@ -3,9 +3,17 @@ import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { aggregateToBuckets, extractSessions } from './aggregate.js';
5
5
 
6
- function resolveThreadsDir() {
6
+ /** Threads directory, XDG first and %LOCALAPPDATA% on Windows. Exported so the
7
+ * Windows branch can be exercised from a POSIX test host. */
8
+ export function resolveThreadsDir() {
7
9
  if (process.env.AMP_DATA_DIR) return process.env.AMP_DATA_DIR;
8
10
  if (process.env.XDG_DATA_HOME) return join(process.env.XDG_DATA_HOME, 'amp', 'threads');
11
+ // Amp on Windows keeps its threads under %LOCALAPPDATA%; without this the
12
+ // source silently collected nothing there.
13
+ if (process.platform === 'win32' && process.env.LOCALAPPDATA) {
14
+ const winDir = join(process.env.LOCALAPPDATA, 'amp', 'threads');
15
+ if (existsSync(winDir)) return winDir;
16
+ }
9
17
  return join(homedir(), '.local', 'share', 'amp', 'threads');
10
18
  }
11
19
 
@@ -104,7 +104,9 @@ function extractProject(meta) {
104
104
  const match = meta.git.repository_url.match(/([^/]+\/[^/]+?)(?:\.git)?$/);
105
105
  if (match) return match[1];
106
106
  }
107
- if (meta.cwd) return meta.cwd.split('/').pop() || 'unknown';
107
+ // Windows reports cwd with backslashes, so splitting on '/' alone yields the
108
+ // whole path as the "project".
109
+ if (meta.cwd) return meta.cwd.split(/[\\/]/).pop() || 'unknown';
108
110
  return 'unknown';
109
111
  }
110
112
 
@@ -40,7 +40,9 @@ function findSessionFiles(baseDir) {
40
40
 
41
41
  function extractProject(cwd, filePath) {
42
42
  if (cwd) {
43
- const parts = cwd.split('/').filter(Boolean);
43
+ // Windows reports cwd with backslashes; split on both separators so the
44
+ // project is the last path segment rather than the whole path.
45
+ const parts = cwd.split(/[\\/]/).filter(Boolean);
44
46
  if (parts.length > 0) return parts[parts.length - 1];
45
47
  }
46
48
  const tmpPrefix = QWEN_TMP_DIR + sep;
@@ -0,0 +1,27 @@
1
+ import { statSync, renameSync } from 'node:fs';
2
+
3
+ /**
4
+ * Move a directory that is occupying a path we need as a file.
5
+ *
6
+ * Both the config file and the state file are written by this CLI, and a
7
+ * customer hit a layout where the *file* path had become a directory (the store
8
+ * was created by a different tool, or an earlier run was interrupted between
9
+ * mkdir and open). `writeFileSync` then fails with EISDIR and the CLI is stuck:
10
+ * every later run reads nothing, and the save that would fix it is the one that
11
+ * throws.
12
+ *
13
+ * Renaming it aside keeps whatever the user's data was — never delete it, the
14
+ * directory may hold real content — while letting the current run write a fresh
15
+ * file. Absence is not a repair, so ENOENT stays silent; anything else (a
16
+ * permission error, a read-only volume) is the caller's to report rather than
17
+ * something to swallow.
18
+ */
19
+ export function moveDirectoryOutOfFilePath(path) {
20
+ try {
21
+ if (statSync(path).isDirectory()) {
22
+ renameSync(path, `${path}.directory-backup-${Date.now()}`);
23
+ }
24
+ } catch (err) {
25
+ if (err?.code !== 'ENOENT') throw err;
26
+ }
27
+ }
package/src/state.js CHANGED
@@ -2,6 +2,7 @@ import { readFileSync, writeFileSync, unlinkSync, mkdirSync, existsSync, renameS
2
2
  import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { createHash, randomBytes } from 'node:crypto';
5
+ import { moveDirectoryOutOfFilePath } from './path-repair.js';
5
6
 
6
7
  // Persisted sync state, kept next to config.js's files (same dir + dev split).
7
8
  // Maps a stable item key -> hash of its mutable fields, recording what we have
@@ -79,6 +80,9 @@ export function loadState(identity) {
79
80
 
80
81
  export function saveState(state, identity) {
81
82
  mkdirSync(STATE_DIR, { recursive: true });
83
+ // Same EISDIR repair as config: a directory where the state file belongs
84
+ // would block every save, and with it every incremental sync.
85
+ moveDirectoryOutOfFilePath(STATE_FILE);
82
86
  // Only the durable fields are written: `identityChanged` is loadState()'s
83
87
  // one-run signal, not state. A CLI older than 0.11.1 reads just
84
88
  // buckets/sessions, so the extra top-level `identity` key is ignored there —