@prohost/cli 0.10.0 → 0.10.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.
- package/CHANGELOG.md +48 -0
- package/README.md +27 -16
- package/dist/agent/base-url.d.ts +43 -0
- package/dist/agent/base-url.js +72 -0
- package/dist/agent/command.js +21 -6
- package/dist/agent/credentials.d.ts +9 -0
- package/dist/agent/credentials.js +1 -1
- package/dist/agent/pair.js +41 -20
- package/dist/index.js +11 -3
- package/dist/usage/command.d.ts +3 -0
- package/dist/usage/command.js +88 -13
- package/dist/usage/key-input.d.ts +36 -0
- package/dist/usage/key-input.js +79 -0
- package/dist/usage/project.d.ts +30 -0
- package/dist/usage/project.js +138 -0
- package/dist/usage/scanner.d.ts +49 -8
- package/dist/usage/scanner.js +115 -26
- package/dist/usage/service.d.ts +30 -1
- package/dist/usage/service.js +105 -10
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/usage/command.js
CHANGED
|
@@ -11,12 +11,14 @@ import process from 'node:process';
|
|
|
11
11
|
import { existsSync } from 'node:fs';
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
13
|
import { DEFAULT_ACCOUNT_LABEL, loadAccounts, runtimeName } from '../agent/accounts.js';
|
|
14
|
+
import { baseUrlProblem } from '../agent/base-url.js';
|
|
14
15
|
import { daemonEnvironment } from '../agent/command.js';
|
|
15
16
|
import { maskSecret, prohostHome } from '../agent/credentials.js';
|
|
16
17
|
import { buildSystemdUnit, installLaunchdDaemon, launchdLabel, launchdPlistPath, uninstallLaunchdDaemon, } from '../agent/daemon.js';
|
|
17
18
|
import { acquireRunLock } from '../agent/lock.js';
|
|
18
19
|
import { ensurePrivateDir, resolveUsageKey, saveUsageCredentials, usageStateDir } from './config.js';
|
|
19
20
|
import { accountConfigDir, discoverAccounts } from './discovery.js';
|
|
21
|
+
import { PipedKeyTooLongError, promptHiddenKey, readPipedKey } from './key-input.js';
|
|
20
22
|
import { RETENTION_DAYS, dayKey, loadScanState, localTotals, pendingDays } from './scanner.js';
|
|
21
23
|
import { UsageService, loadStatus, runUsageLoop, scanStatePath } from './service.js';
|
|
22
24
|
const defaultIO = {
|
|
@@ -83,21 +85,77 @@ export function renderDiscovery(result, env, dryRun) {
|
|
|
83
85
|
lines.push(row('!', s.label, s.runtime, s.dir, `skipped: ${s.reason}`));
|
|
84
86
|
return lines;
|
|
85
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* The key `connect` should store, in order: `--key -` (stdin, or a hidden
|
|
90
|
+
* prompt at a terminal), a literal `--key <value>` (warned about), then
|
|
91
|
+
* `$PROHOST_API_KEY`, then piped stdin, then — at a terminal, with no
|
|
92
|
+
* credential to fall back on — a hidden prompt. `undefined` means "none given";
|
|
93
|
+
* `null` means a usage error that has already been printed.
|
|
94
|
+
*/
|
|
95
|
+
async function connectKey(flags, io, env) {
|
|
96
|
+
const stdin = io.stdin ?? process.stdin;
|
|
97
|
+
const prompt = () => promptHiddenKey(stdin, (text) => process.stderr.write(text));
|
|
98
|
+
const literal = flagString(flags, 'key');
|
|
99
|
+
if (literal === '-') {
|
|
100
|
+
const key = stdin.isTTY ? await prompt() : await readPipedKey(stdin);
|
|
101
|
+
if (!key)
|
|
102
|
+
io.err('✗ --key - got no key. Pipe one in (pbpaste | prohost usage connect --key -) or type it at the prompt.');
|
|
103
|
+
return key ?? null;
|
|
104
|
+
}
|
|
105
|
+
if (literal) {
|
|
106
|
+
io.err('! --key <value> leaves the key in your shell history and the process list. ' +
|
|
107
|
+
'Next time pipe it instead: pbpaste | prohost usage connect --key -');
|
|
108
|
+
return literal.trim();
|
|
109
|
+
}
|
|
110
|
+
if (env.PROHOST_API_KEY)
|
|
111
|
+
return env.PROHOST_API_KEY.trim();
|
|
112
|
+
if (!stdin.isTTY)
|
|
113
|
+
return readPipedKey(stdin);
|
|
114
|
+
// At a terminal: prompt only when there is nothing to fall back on, so a
|
|
115
|
+
// connected machine (or one with a paired agent) is not asked again.
|
|
116
|
+
if (resolveUsageKey(env))
|
|
117
|
+
return undefined;
|
|
118
|
+
return prompt();
|
|
119
|
+
}
|
|
86
120
|
async function connectCommand(flags, io) {
|
|
87
121
|
const env = io.env ?? process.env;
|
|
88
122
|
if (flags.key === true) {
|
|
89
|
-
io.err('--key needs a value.
|
|
123
|
+
io.err('--key needs a value. Use --key - to read it from stdin (pbpaste | prohost usage connect --key -), ' +
|
|
124
|
+
'or leave --key off to be prompted. Create a key with the usage:write scope in ProhostAI → Settings → API keys.');
|
|
125
|
+
return 1;
|
|
126
|
+
}
|
|
127
|
+
if (flags['base-url'] === true) {
|
|
128
|
+
io.err('--base-url needs a value, e.g. --base-url https://connect.prohost.ai');
|
|
129
|
+
return 1;
|
|
130
|
+
}
|
|
131
|
+
// Checked before any key is read or stored: the key goes to this URL.
|
|
132
|
+
const baseUrl = flagString(flags, 'base-url');
|
|
133
|
+
const problem = baseUrl ? baseUrlProblem(baseUrl) : undefined;
|
|
134
|
+
if (problem) {
|
|
135
|
+
io.err(`✗ --base-url rejected: ${problem}`);
|
|
136
|
+
return 1;
|
|
137
|
+
}
|
|
138
|
+
let key;
|
|
139
|
+
try {
|
|
140
|
+
key = await connectKey(flags, io, env);
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
if (!(err instanceof PipedKeyTooLongError))
|
|
144
|
+
throw err;
|
|
145
|
+
io.err('✗ The piped input is not an API key (no line ended within 4096 characters). Nothing was saved.');
|
|
90
146
|
return 1;
|
|
91
147
|
}
|
|
92
|
-
|
|
148
|
+
if (key === null)
|
|
149
|
+
return 1;
|
|
93
150
|
if (key) {
|
|
94
|
-
const file = saveUsageCredentials({ apiKey: key
|
|
95
|
-
io.out(`✓ Saved key ${maskSecret(key
|
|
151
|
+
const file = saveUsageCredentials({ apiKey: key, baseUrl }, env);
|
|
152
|
+
io.out(`✓ Saved key ${maskSecret(key)} to ${file} (owner-only).`);
|
|
96
153
|
}
|
|
97
154
|
else {
|
|
98
155
|
const existing = resolveUsageKey(env);
|
|
99
156
|
if (!existing) {
|
|
100
|
-
io.err('✗ No API key.
|
|
157
|
+
io.err('✗ No API key. Run prohost usage connect at a terminal to be prompted, pipe one in with --key -, ' +
|
|
158
|
+
'or set $PROHOST_API_KEY (scope usage:write). ' +
|
|
101
159
|
'A machine with a paired agent can skip this — its credential is used.');
|
|
102
160
|
return 1;
|
|
103
161
|
}
|
|
@@ -112,6 +170,26 @@ async function connectCommand(flags, io) {
|
|
|
112
170
|
io.out('Report now and every 5 minutes: prohost usage start (or keep it running: prohost usage install-daemon)');
|
|
113
171
|
return 0;
|
|
114
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* The stored key, when it can be sent: present, and its base URL is https://
|
|
175
|
+
* (or loopback http://). Prints why not and returns `null` otherwise.
|
|
176
|
+
*/
|
|
177
|
+
function usableKey(env, io) {
|
|
178
|
+
const key = resolveUsageKey(env);
|
|
179
|
+
if (!key) {
|
|
180
|
+
io.err('✗ Not connected. Run: prohost usage connect');
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
const problem = baseUrlProblem(key.baseUrl);
|
|
184
|
+
if (problem) {
|
|
185
|
+
io.err(`✗ Refusing to send the API key: the stored API base URL is unusable. ${problem} ` +
|
|
186
|
+
(key.source === 'agent'
|
|
187
|
+
? 'Re-pair the agent with an https:// --base-url.'
|
|
188
|
+
: 'Reconnect: prohost usage connect --key - --base-url https://…'));
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
return key;
|
|
192
|
+
}
|
|
115
193
|
function discoverCommand(flags, io) {
|
|
116
194
|
const env = io.env ?? process.env;
|
|
117
195
|
const dryRun = flags['dry-run'] === true;
|
|
@@ -121,10 +199,8 @@ function discoverCommand(flags, io) {
|
|
|
121
199
|
}
|
|
122
200
|
async function startCommand(flags, io) {
|
|
123
201
|
const env = io.env ?? process.env;
|
|
124
|
-
if (!
|
|
125
|
-
io.err('✗ Not connected. Run: prohost usage connect --key <api key>');
|
|
202
|
+
if (!usableKey(env, io))
|
|
126
203
|
return 1;
|
|
127
|
-
}
|
|
128
204
|
// Its own lock, under $PROHOST_HOME/usage — `agent run` holds the home's.
|
|
129
205
|
const stateDir = ensurePrivateDir(usageStateDir(env));
|
|
130
206
|
const lock = acquireRunLock({ ...env, PROHOST_HOME: stateDir });
|
|
@@ -163,10 +239,8 @@ function cliEntrypoint() {
|
|
|
163
239
|
}
|
|
164
240
|
function installCommand(flags, io) {
|
|
165
241
|
const env = io.env ?? process.env;
|
|
166
|
-
if (!
|
|
167
|
-
io.err('✗ Not connected. Run: prohost usage connect --key <api key>');
|
|
242
|
+
if (!usableKey(env, io))
|
|
168
243
|
return 1;
|
|
169
|
-
}
|
|
170
244
|
const workdir = ensurePrivateDir(usageStateDir(env));
|
|
171
245
|
const spec = {
|
|
172
246
|
label: usageLaunchdLabel(prohostHome(env)),
|
|
@@ -233,8 +307,9 @@ function statusCommand(io) {
|
|
|
233
307
|
const env = io.env ?? process.env;
|
|
234
308
|
const key = resolveUsageKey(env);
|
|
235
309
|
io.out(key
|
|
236
|
-
? `Connected ${maskSecret(key.apiKey)} → ${key.baseUrl}${key.source === 'agent' ? ' (paired agent credential)' : ''}`
|
|
237
|
-
|
|
310
|
+
? `Connected ${maskSecret(key.apiKey)} → ${key.baseUrl}${key.source === 'agent' ? ' (paired agent credential)' : ''}` +
|
|
311
|
+
`${baseUrlProblem(key.baseUrl) ? ' (not https:// — nothing is sent)' : ''}`
|
|
312
|
+
: 'Connected no — run: prohost usage connect');
|
|
238
313
|
const plist = launchdPlistPath(usageLaunchdLabel(prohostHome(env)));
|
|
239
314
|
io.out(`Daemon ${os.platform() === 'darwin' ? (existsSync(plist) ? `installed (${tildify(plist, env)})` : 'not installed') : 'see systemd'}`);
|
|
240
315
|
const status = loadStatus(env);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading an API key for `prohost usage connect` without putting it on the
|
|
3
|
+
* command line, where it would land in shell history and the process list.
|
|
4
|
+
*
|
|
5
|
+
* Two safe paths: piped stdin (`pbpaste | prohost usage connect --key -`) and,
|
|
6
|
+
* at a terminal, a prompt that does not echo what is typed or pasted.
|
|
7
|
+
*/
|
|
8
|
+
/** The slice of `process.stdin` this module uses, so tests can pass a stream. */
|
|
9
|
+
export interface KeyStdin extends AsyncIterable<string | Buffer> {
|
|
10
|
+
isTTY?: boolean;
|
|
11
|
+
setRawMode?(mode: boolean): unknown;
|
|
12
|
+
setEncoding(encoding: BufferEncoding): unknown;
|
|
13
|
+
on(event: 'data', listener: (chunk: string | Buffer) => void): unknown;
|
|
14
|
+
removeListener(event: 'data', listener: (chunk: string | Buffer) => void): unknown;
|
|
15
|
+
resume(): unknown;
|
|
16
|
+
pause(): unknown;
|
|
17
|
+
}
|
|
18
|
+
/** Longer than any ProhostAI key; a pipe that runs past it is not a key. */
|
|
19
|
+
export declare const MAX_PIPED_KEY_CHARS = 4096;
|
|
20
|
+
export declare class PipedKeyTooLongError extends Error {
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The first non-empty line of piped stdin, trimmed; `undefined` when there is
|
|
24
|
+
* none. Returns as soon as that line ends, so a producer that keeps the pipe
|
|
25
|
+
* open does not hang `connect`, and stops reading past
|
|
26
|
+
* {@link MAX_PIPED_KEY_CHARS}.
|
|
27
|
+
*
|
|
28
|
+
* @throws PipedKeyTooLongError when no line ends within the limit.
|
|
29
|
+
*/
|
|
30
|
+
export declare function readPipedKey(stdin: KeyStdin): Promise<string | undefined>;
|
|
31
|
+
/**
|
|
32
|
+
* Prompt for a key at a terminal without echoing it. Resolves with the
|
|
33
|
+
* trimmed key, or `undefined` when nothing was entered or the user pressed
|
|
34
|
+
* Ctrl-C. The prompt goes to `write` (stderr), keeping stdout clean.
|
|
35
|
+
*/
|
|
36
|
+
export declare function promptHiddenKey(stdin: KeyStdin, write: (text: string) => void, prompt?: string): Promise<string | undefined>;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading an API key for `prohost usage connect` without putting it on the
|
|
3
|
+
* command line, where it would land in shell history and the process list.
|
|
4
|
+
*
|
|
5
|
+
* Two safe paths: piped stdin (`pbpaste | prohost usage connect --key -`) and,
|
|
6
|
+
* at a terminal, a prompt that does not echo what is typed or pasted.
|
|
7
|
+
*/
|
|
8
|
+
/** Longer than any ProhostAI key; a pipe that runs past it is not a key. */
|
|
9
|
+
export const MAX_PIPED_KEY_CHARS = 4096;
|
|
10
|
+
export class PipedKeyTooLongError extends Error {
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The first non-empty line of piped stdin, trimmed; `undefined` when there is
|
|
14
|
+
* none. Returns as soon as that line ends, so a producer that keeps the pipe
|
|
15
|
+
* open does not hang `connect`, and stops reading past
|
|
16
|
+
* {@link MAX_PIPED_KEY_CHARS}.
|
|
17
|
+
*
|
|
18
|
+
* @throws PipedKeyTooLongError when no line ends within the limit.
|
|
19
|
+
*/
|
|
20
|
+
export async function readPipedKey(stdin) {
|
|
21
|
+
let pending = '';
|
|
22
|
+
for await (const chunk of stdin) {
|
|
23
|
+
pending += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
24
|
+
let newline = pending.search(/\r?\n/);
|
|
25
|
+
while (newline !== -1) {
|
|
26
|
+
// Measured before trimming: padding counts toward the bound too.
|
|
27
|
+
const raw = pending.slice(0, newline);
|
|
28
|
+
if (raw.length > MAX_PIPED_KEY_CHARS)
|
|
29
|
+
throw new PipedKeyTooLongError('piped input is not a key');
|
|
30
|
+
const line = raw.trim();
|
|
31
|
+
if (line)
|
|
32
|
+
return line;
|
|
33
|
+
pending = pending.slice(newline).replace(/^\r?\n/, '');
|
|
34
|
+
newline = pending.search(/\r?\n/);
|
|
35
|
+
}
|
|
36
|
+
if (pending.length > MAX_PIPED_KEY_CHARS)
|
|
37
|
+
throw new PipedKeyTooLongError('piped input is not a key');
|
|
38
|
+
}
|
|
39
|
+
if (pending.length > MAX_PIPED_KEY_CHARS)
|
|
40
|
+
throw new PipedKeyTooLongError('piped input is not a key');
|
|
41
|
+
return pending.trim() || undefined;
|
|
42
|
+
}
|
|
43
|
+
const ENTER = new Set(['\r', '\n', '\u0004']); // Return, or Ctrl-D
|
|
44
|
+
const CTRL_C = '\u0003';
|
|
45
|
+
const BACKSPACE = new Set(['\u007f', '\b']);
|
|
46
|
+
/**
|
|
47
|
+
* Prompt for a key at a terminal without echoing it. Resolves with the
|
|
48
|
+
* trimmed key, or `undefined` when nothing was entered or the user pressed
|
|
49
|
+
* Ctrl-C. The prompt goes to `write` (stderr), keeping stdout clean.
|
|
50
|
+
*/
|
|
51
|
+
export function promptHiddenKey(stdin, write, prompt = 'API key (usage:write scope; input hidden): ') {
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
let value = '';
|
|
54
|
+
const finish = (result) => {
|
|
55
|
+
stdin.removeListener('data', onData);
|
|
56
|
+
stdin.setRawMode?.(false);
|
|
57
|
+
stdin.pause();
|
|
58
|
+
write('\n');
|
|
59
|
+
resolve(result);
|
|
60
|
+
};
|
|
61
|
+
const onData = (chunk) => {
|
|
62
|
+
for (const ch of typeof chunk === 'string' ? chunk : chunk.toString('utf8')) {
|
|
63
|
+
if (ENTER.has(ch))
|
|
64
|
+
return finish(value.trim() || undefined);
|
|
65
|
+
if (ch === CTRL_C)
|
|
66
|
+
return finish(undefined);
|
|
67
|
+
if (BACKSPACE.has(ch))
|
|
68
|
+
value = value.slice(0, -1);
|
|
69
|
+
else if (ch >= ' ')
|
|
70
|
+
value += ch;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
write(prompt);
|
|
74
|
+
stdin.setRawMode?.(true);
|
|
75
|
+
stdin.setEncoding('utf8');
|
|
76
|
+
stdin.on('data', onData);
|
|
77
|
+
stdin.resume();
|
|
78
|
+
});
|
|
79
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The project a session's working directory belongs to: its repository's name.
|
|
3
|
+
*
|
|
4
|
+
* A session can run in the repository root, a subdirectory (`iosApp`,
|
|
5
|
+
* `Sources`) or a git worktree with a branch-like name
|
|
6
|
+
* (`.worktrees/backend-service/fix-agent-delete-memory-aware`). All of them
|
|
7
|
+
* are the one repository, so the project is the basename of the MAIN
|
|
8
|
+
* repository's root:
|
|
9
|
+
*
|
|
10
|
+
* 1. Walk up from the directory to the nearest ancestor holding `.git`.
|
|
11
|
+
* 2. A `.git` directory: that ancestor is the root.
|
|
12
|
+
* 3. A `.git` file is a linked worktree (or a submodule): its `gitdir:` names
|
|
13
|
+
* `<repo>/.git/worktrees/<name>`, whose `commondir` leads back to
|
|
14
|
+
* `<repo>/.git`. A submodule's gitdir has neither, so it is its own project.
|
|
15
|
+
* 4. A directory that no longer exists (a removed worktree) is recognised by
|
|
16
|
+
* its layout — `…/.worktrees/<repo>/<name>` or `…/<repo>/.claude/worktrees/<name>`
|
|
17
|
+
* — and is otherwise named by its basename.
|
|
18
|
+
*
|
|
19
|
+
* Only the basename ever leaves this module, never the path. Results are
|
|
20
|
+
* cached per directory: the scanner asks once per log line, and the answer is
|
|
21
|
+
* a few `stat`s the first time and a map lookup after — never a `git` child
|
|
22
|
+
* process.
|
|
23
|
+
*
|
|
24
|
+
* The walk does what `git` itself does, so a directory under a dotfiles
|
|
25
|
+
* repository at `~` is attributed to it.
|
|
26
|
+
*/
|
|
27
|
+
/** Forget every cached answer (tests; a long-running process never needs it). */
|
|
28
|
+
export declare function clearProjectCache(): void;
|
|
29
|
+
/** The repository name of a session's working directory, or `null` when it names none. */
|
|
30
|
+
export declare function projectName(cwd: unknown): string | null;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The project a session's working directory belongs to: its repository's name.
|
|
3
|
+
*
|
|
4
|
+
* A session can run in the repository root, a subdirectory (`iosApp`,
|
|
5
|
+
* `Sources`) or a git worktree with a branch-like name
|
|
6
|
+
* (`.worktrees/backend-service/fix-agent-delete-memory-aware`). All of them
|
|
7
|
+
* are the one repository, so the project is the basename of the MAIN
|
|
8
|
+
* repository's root:
|
|
9
|
+
*
|
|
10
|
+
* 1. Walk up from the directory to the nearest ancestor holding `.git`.
|
|
11
|
+
* 2. A `.git` directory: that ancestor is the root.
|
|
12
|
+
* 3. A `.git` file is a linked worktree (or a submodule): its `gitdir:` names
|
|
13
|
+
* `<repo>/.git/worktrees/<name>`, whose `commondir` leads back to
|
|
14
|
+
* `<repo>/.git`. A submodule's gitdir has neither, so it is its own project.
|
|
15
|
+
* 4. A directory that no longer exists (a removed worktree) is recognised by
|
|
16
|
+
* its layout — `…/.worktrees/<repo>/<name>` or `…/<repo>/.claude/worktrees/<name>`
|
|
17
|
+
* — and is otherwise named by its basename.
|
|
18
|
+
*
|
|
19
|
+
* Only the basename ever leaves this module, never the path. Results are
|
|
20
|
+
* cached per directory: the scanner asks once per log line, and the answer is
|
|
21
|
+
* a few `stat`s the first time and a map lookup after — never a `git` child
|
|
22
|
+
* process.
|
|
23
|
+
*
|
|
24
|
+
* The walk does what `git` itself does, so a directory under a dotfiles
|
|
25
|
+
* repository at `~` is attributed to it.
|
|
26
|
+
*/
|
|
27
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
const MAX_PROJECT_CHARS = 120;
|
|
30
|
+
/** Directories remembered; far more than one machine's sessions ever name. */
|
|
31
|
+
const MAX_CACHED = 5000;
|
|
32
|
+
/** Deepest walk up from a directory looking for `.git`. */
|
|
33
|
+
const MAX_ANCESTORS = 64;
|
|
34
|
+
const cache = new Map();
|
|
35
|
+
/** Forget every cached answer (tests; a long-running process never needs it). */
|
|
36
|
+
export function clearProjectCache() {
|
|
37
|
+
cache.clear();
|
|
38
|
+
}
|
|
39
|
+
/** The repository name of a session's working directory, or `null` when it names none. */
|
|
40
|
+
export function projectName(cwd) {
|
|
41
|
+
if (typeof cwd !== 'string' || !cwd.trim())
|
|
42
|
+
return null;
|
|
43
|
+
const dir = cwd.trim().replace(/[\\/]+$/, '');
|
|
44
|
+
if (!dir)
|
|
45
|
+
return null;
|
|
46
|
+
const cached = cache.get(dir);
|
|
47
|
+
if (cached !== undefined)
|
|
48
|
+
return cached;
|
|
49
|
+
const base = path.basename(resolveProjectRoot(dir));
|
|
50
|
+
const name = base ? base.slice(0, MAX_PROJECT_CHARS) : null;
|
|
51
|
+
if (cache.size >= MAX_CACHED)
|
|
52
|
+
cache.clear();
|
|
53
|
+
cache.set(dir, name);
|
|
54
|
+
return name;
|
|
55
|
+
}
|
|
56
|
+
/** The directory whose basename names the project. */
|
|
57
|
+
function resolveProjectRoot(dir) {
|
|
58
|
+
if (!isDirectory(dir))
|
|
59
|
+
return removedWorktreeRepo(dir) ?? dir;
|
|
60
|
+
let current = dir;
|
|
61
|
+
for (let depth = 0; depth < MAX_ANCESTORS; depth += 1) {
|
|
62
|
+
const root = repoRootAt(current);
|
|
63
|
+
if (root)
|
|
64
|
+
return root;
|
|
65
|
+
const parent = path.dirname(current);
|
|
66
|
+
if (parent === current)
|
|
67
|
+
break;
|
|
68
|
+
current = parent;
|
|
69
|
+
}
|
|
70
|
+
return dir;
|
|
71
|
+
}
|
|
72
|
+
/** The main repository root when `dir` holds a `.git`; `undefined` when it holds none. */
|
|
73
|
+
function repoRootAt(dir) {
|
|
74
|
+
const dotGit = path.join(dir, '.git');
|
|
75
|
+
let stat;
|
|
76
|
+
try {
|
|
77
|
+
stat = statSync(dotGit);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
if (stat.isDirectory())
|
|
83
|
+
return dir;
|
|
84
|
+
if (!stat.isFile())
|
|
85
|
+
return undefined;
|
|
86
|
+
const gitDir = readGitDir(dotGit);
|
|
87
|
+
return (gitDir && mainRepoOf(gitDir)) ?? dir;
|
|
88
|
+
}
|
|
89
|
+
/** The `gitdir:` a `.git` file points at, resolved against the file's directory. */
|
|
90
|
+
function readGitDir(dotGitFile) {
|
|
91
|
+
try {
|
|
92
|
+
const match = /^gitdir:\s*(.+?)\s*$/m.exec(readFileSync(dotGitFile, 'utf8'));
|
|
93
|
+
return match?.[1] ? path.resolve(path.dirname(dotGitFile), match[1]) : undefined;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** A linked worktree's main repository root, from its gitdir; `undefined` for a submodule. */
|
|
100
|
+
function mainRepoOf(gitDir) {
|
|
101
|
+
let common;
|
|
102
|
+
try {
|
|
103
|
+
const relative = readFileSync(path.join(gitDir, 'commondir'), 'utf8').trim();
|
|
104
|
+
if (relative)
|
|
105
|
+
common = path.resolve(gitDir, relative);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// No commondir: read the conventional `<repo>/.git/worktrees/<name>` layout.
|
|
109
|
+
const match = /^(.*)[\\/]\.git[\\/]worktrees[\\/][^\\/]+$/.exec(gitDir);
|
|
110
|
+
if (match?.[1])
|
|
111
|
+
return match[1];
|
|
112
|
+
}
|
|
113
|
+
if (!common)
|
|
114
|
+
return undefined;
|
|
115
|
+
// `<repo>/.git` → `<repo>`; a bare `<repo>.git` common dir names the repo itself.
|
|
116
|
+
if (path.basename(common) === '.git')
|
|
117
|
+
return path.dirname(common);
|
|
118
|
+
return common.replace(/\.git$/, '') || common;
|
|
119
|
+
}
|
|
120
|
+
/** The repository a removed worktree path belonged to, from the two layouts agents use. */
|
|
121
|
+
function removedWorktreeRepo(dir) {
|
|
122
|
+
const sep = '[\\\\/]';
|
|
123
|
+
const claude = new RegExp(`^(.*?)${sep}\\.claude${sep}worktrees${sep}[^\\\\/]+(?:${sep}.*)?$`).exec(dir);
|
|
124
|
+
if (claude?.[1])
|
|
125
|
+
return claude[1];
|
|
126
|
+
const sibling = new RegExp(`${sep}\\.worktrees${sep}([^\\\\/]+)${sep}[^\\\\/]+(?:${sep}.*)?$`).exec(dir);
|
|
127
|
+
if (sibling?.[1])
|
|
128
|
+
return sibling[1];
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
function isDirectory(dir) {
|
|
132
|
+
try {
|
|
133
|
+
return statSync(dir).isDirectory();
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
package/dist/usage/scanner.d.ts
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
* Token totals from the session logs Claude Code and Codex already write.
|
|
3
3
|
*
|
|
4
4
|
* Aggregates only: tokens per (local day, account, runtime, model, project),
|
|
5
|
-
* where the project is the
|
|
5
|
+
* where the project is the name of the repository the log's working directory
|
|
6
|
+
* belongs to — a worktree or a subdirectory counts as its repository (see
|
|
7
|
+
* `project.ts`).
|
|
6
8
|
* No prompt, reply, tool call or file path ever leaves this module — lines are
|
|
7
9
|
* parsed for their usage counters and nothing else is kept.
|
|
8
10
|
*
|
|
@@ -21,9 +23,19 @@
|
|
|
21
23
|
* Incremental: a cursor per file (byte offset, size, mtime) lives in the scan
|
|
22
24
|
* state, so each pass reads only what was appended. The first pass looks back
|
|
23
25
|
* {@link RETENTION_DAYS} days. Every (day, account) a pass changes is marked dirty and is
|
|
24
|
-
* re-sent whole
|
|
26
|
+
* re-sent whole, and the upload names it as complete: the server replaces that
|
|
27
|
+
* day's rows for the account and deletes any it no longer carries, so a resend
|
|
28
|
+
* is harmless and a renamed project leaves no stale row behind.
|
|
29
|
+
*
|
|
30
|
+
* State version 2 changed project attribution (repository, not directory). A
|
|
31
|
+
* version-1 state is discarded and every log in the window read again; its
|
|
32
|
+
* per-(day, account) totals are kept as {@link ScanState.legacy}. Those pairs'
|
|
33
|
+
* server rows still carry per-directory projects, so the uploader sends them
|
|
34
|
+
* only to a server that replaces a day whole, and only when the rebuild
|
|
35
|
+
* found at least what version 1 had counted (see `service.ts`).
|
|
25
36
|
*/
|
|
26
37
|
import type { AccountRuntime } from '../agent/accounts.js';
|
|
38
|
+
export { projectName } from './project.js';
|
|
27
39
|
/** How far back the first scan looks, and how long totals are kept locally. */
|
|
28
40
|
export declare const RETENTION_DAYS = 30;
|
|
29
41
|
/** Most rows in one `POST /v1/usage/token-totals`. */
|
|
@@ -54,7 +66,7 @@ interface CodexCursor {
|
|
|
54
66
|
}
|
|
55
67
|
type Cursor = ClaudeCursor | CodexCursor;
|
|
56
68
|
export interface ScanState {
|
|
57
|
-
version:
|
|
69
|
+
version: 2;
|
|
58
70
|
files: Record<string, Cursor>;
|
|
59
71
|
/** Bucket key ({@link bucketKey}) → totals. */
|
|
60
72
|
buckets: Record<string, Totals>;
|
|
@@ -64,11 +76,19 @@ export interface ScanState {
|
|
|
64
76
|
dirty: string[];
|
|
65
77
|
/** Pending pair → consecutive uploads the server only partly accepted. */
|
|
66
78
|
strikes: Record<string, number>;
|
|
79
|
+
/**
|
|
80
|
+
* Pair → what the version-1 state had counted for it, for pairs whose
|
|
81
|
+
* server rows may still carry per-directory projects. An entry goes once
|
|
82
|
+
* the pair is replaced whole on the server, or is given up on.
|
|
83
|
+
*/
|
|
84
|
+
legacy: Record<string, Totals>;
|
|
67
85
|
}
|
|
68
86
|
/** A dirty entry: one account's rows for one day. */
|
|
69
87
|
export declare function pendingKey(day: string, accountKey: string): string;
|
|
70
88
|
/** The day of a dirty entry. */
|
|
71
89
|
export declare function pendingDay(entry: string): string;
|
|
90
|
+
/** The account key of a dirty entry. */
|
|
91
|
+
export declare function pendingAccount(entry: string): string;
|
|
72
92
|
/** Days with anything pending, sorted. */
|
|
73
93
|
export declare function pendingDays(state: ScanState): string[];
|
|
74
94
|
export interface TokenRow {
|
|
@@ -93,14 +113,23 @@ export interface ScanSummary {
|
|
|
93
113
|
touchedDays: string[];
|
|
94
114
|
}
|
|
95
115
|
export declare function emptyScanState(): ScanState;
|
|
116
|
+
/**
|
|
117
|
+
* Load the scan state. A version-1 state (per-directory projects) is replaced
|
|
118
|
+
* by an empty one that re-reads every log, keeping its per-pair totals as
|
|
119
|
+
* {@link ScanState.legacy}.
|
|
120
|
+
*/
|
|
96
121
|
export declare function loadScanState(file: string): ScanState;
|
|
122
|
+
/**
|
|
123
|
+
* Whether a re-attributed pair's rebuild counted less than version 1 had — a
|
|
124
|
+
* log rewritten or removed since, or a file that fell out of the window. Its
|
|
125
|
+
* server rows are then left as they are: mis-attributed, but the right total.
|
|
126
|
+
*/
|
|
127
|
+
export declare function rebuildFellShort(rebuilt: Totals, legacy: Totals): boolean;
|
|
97
128
|
export declare function saveScanState(file: string, state: ScanState): void;
|
|
98
129
|
/** `YYYY-MM-DD` of an instant in a time zone (the machine's by default). */
|
|
99
130
|
export declare function dayKey(ms: number, timeZone?: string): string;
|
|
100
131
|
/** Bucket identity. JSON so it round-trips; the day is first so it can be read cheaply. */
|
|
101
132
|
export declare function bucketKey(day: string, accountKey: string, runtime: AccountRuntime, model: string, project: string | null): string;
|
|
102
|
-
/** Basename of a working directory — the repo or folder name, never the path. */
|
|
103
|
-
export declare function projectName(cwd: unknown): string | null;
|
|
104
133
|
/**
|
|
105
134
|
* One incremental pass over every account's logs. Mutates and returns
|
|
106
135
|
* ``state``; the caller persists it.
|
|
@@ -110,8 +139,20 @@ export declare function scanUsage(accounts: ScanAccount[], state: ScanState, opt
|
|
|
110
139
|
export declare function rowsForDays(state: ScanState, days: Iterable<string>): TokenRow[];
|
|
111
140
|
/** The rows for the given `day|accountKey` pairs, in upload shape. */
|
|
112
141
|
export declare function rowsForPending(state: ScanState, pairs: Iterable<string>): TokenRow[];
|
|
113
|
-
/**
|
|
114
|
-
export
|
|
142
|
+
/** One `POST /v1/usage/token-totals` body's rows, and what it says about each (day, account) pair. */
|
|
143
|
+
export interface UploadBatch {
|
|
144
|
+
rows: TokenRow[];
|
|
145
|
+
/** Pairs whose first row is here: sent as `completeDays`, so the server replaces each one whole. */
|
|
146
|
+
complete: string[];
|
|
147
|
+
/** Pairs whose last row is here: done once this batch is taken. */
|
|
148
|
+
last: string[];
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Split rows into upload batches without splitting a (day, account) pair,
|
|
152
|
+
* so each body carries every row of the pairs it names complete. A pair
|
|
153
|
+
* larger than a batch is sent over consecutive batches: complete in the
|
|
154
|
+
* first (the server clears its rows, then stores these), added to by the rest.
|
|
155
|
+
*/
|
|
156
|
+
export declare function uploadBatches(rows: TokenRow[], size?: number): UploadBatch[];
|
|
115
157
|
/** Local totals per account since ``fromDay`` (inclusive), for `usage status`. */
|
|
116
158
|
export declare function localTotals(state: ScanState, fromDay: string): Map<string, Totals>;
|
|
117
|
-
export {};
|