@ai-adoption-score/ai-coding-usage 1.0.1 → 1.2.0
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/README.md +32 -17
- package/package.json +1 -1
- package/src/checkin.mjs +11 -2
- package/src/cli.mjs +14 -11
- package/src/config.mjs +33 -1
- package/src/rows.mjs +56 -2
- package/src/scan.mjs +7 -11
- package/src/state.mjs +3 -2
- package/src/sync.mjs +34 -10
- package/src/update.mjs +173 -0
package/README.md
CHANGED
|
@@ -5,11 +5,12 @@ Score. It counts how a person uses Claude Code and Codex on their Mac and, once
|
|
|
5
5
|
company's survey. Prompts, the AI's answers, code, file names, command text and session titles never leave the Mac.
|
|
6
6
|
|
|
7
7
|
You don't install it yourself: your survey page shows one line with a code,
|
|
8
|
-
`npx @ai-adoption-score/ai-coding-usage@
|
|
8
|
+
`npx @ai-adoption-score/ai-coding-usage@latest connect <code>`, to paste into Claude Code, Codex or Terminal.
|
|
9
9
|
|
|
10
10
|
Connected once, it shares by itself: a hook in Claude Code and Codex syncs after a session's replies, at most every 5
|
|
11
11
|
minutes and always when a session ends, and an automatic ping-back, once an hour and without any numbers, tells the
|
|
12
|
-
report "no AI use today" from "no signal".
|
|
12
|
+
report "no AI use today" from "no signal". Once a day the ping-back also looks for a newer version on npm and puts it
|
|
13
|
+
in place, so nobody runs the line again to get it.
|
|
13
14
|
|
|
14
15
|
## How it works, step by step
|
|
15
16
|
|
|
@@ -20,22 +21,31 @@ In short: connect, choose on the survey page, then share. Before the choice, onl
|
|
|
20
21
|
2. **The add-on puts its hooks and the ping-back in place** and copies itself to `~/.ai-adoption-score`. Why: the
|
|
21
22
|
survey gets the numbers after every session without anyone running a command again, and a hook can't run from
|
|
22
23
|
npx's temporary folder. The ping-back carries no numbers; it lets the report tell "no AI use today" from "no signal".
|
|
23
|
-
3. **It sends the names of the repositories used in the last
|
|
24
|
+
3. **It sends the names of the repositories used in the last 90 days, and nothing else.** Why: the person chooses on
|
|
24
25
|
their survey page, and the page needs the names to list them. The choice isn't asked in the terminal, because the
|
|
25
26
|
line usually runs inside Claude Code or Codex, where the add-on can't ask questions. The server keeps the list for
|
|
26
27
|
an hour at most.
|
|
27
28
|
4. **The person ticks what to share** on their survey page; everything is ticked to start with. When they press
|
|
28
29
|
Share, the list is deleted, and with it the names they didn't tick.
|
|
29
|
-
5. **Only then do numbers leave the Mac:** the last
|
|
30
|
+
5. **Only then do numbers leave the Mac:** the last 90 days of what they ticked, then the new numbers after each
|
|
30
31
|
session. Before every sync the add-on asks what they chose and keeps everything else on the Mac; the server checks
|
|
31
|
-
the same rule again.
|
|
32
|
-
|
|
32
|
+
the same rule again. After that first sync, each sync reads the last 30 days only, where rows can still change.
|
|
33
|
+
Why 90: the survey's report can show three months. Claude Code deletes its own records after 30 days unless its
|
|
34
|
+
`cleanupPeriodDays` setting says otherwise, so the older days hold what is left of them; Codex keeps its records.
|
|
35
|
+
A Mac connected with an earlier version, which sent 30 days, sends the 60 before them once, after it updates
|
|
36
|
+
itself and the server says it takes 90.
|
|
37
|
+
6. **It keeps itself up to date.** Once a day the ping-back asks npm for the latest version. A newer one is
|
|
38
|
+
downloaded, checked against the checksum npm lists for it, started once to see that it runs on this Mac's Node, and
|
|
39
|
+
put in place of the add-on's copy; the hooks, the ping-back and the settings stay as they are. Why: a fix or a new
|
|
40
|
+
number reaches every Mac without anyone running the line again. It never goes back to an older version, skips a
|
|
41
|
+
version npm marks as withdrawn, and keeps the copy that works when anything fails.
|
|
42
|
+
7. **It removes itself** when the Mac is disconnected on the survey page or the survey closes. `disconnect` does the
|
|
33
43
|
same from the Mac.
|
|
34
44
|
|
|
35
45
|
The person makes their choice on the survey page, so the survey's server keeps it and the add-on follows the server's
|
|
36
46
|
answer. It still checks what it's told:
|
|
37
47
|
|
|
38
|
-
- It reads
|
|
48
|
+
- It reads 90 days back at most, whatever the server asks for, and 30 when the server names no number.
|
|
39
49
|
- It shares nothing when the choice isn't one of the two kinds the server keeps: what the person ticked, or
|
|
40
50
|
`{ "all": true }` for links made before people could choose.
|
|
41
51
|
- It sends the token over https only. Plain http works only for this Mac's own address (`localhost`), for testing.
|
|
@@ -43,8 +53,8 @@ answer. It still checks what it's told:
|
|
|
43
53
|
## Commands
|
|
44
54
|
|
|
45
55
|
- `connect <code> [--server <url>]`: links this Mac to the survey with the code from the survey page and puts the
|
|
46
|
-
hooks and the ping-back in place. Then it sends the names of the repositories used in the last
|
|
47
|
-
else, so the person can choose on their survey page which to share; a background wait shares the last
|
|
56
|
+
hooks and the ping-back in place. Then it sends the names of the repositories used in the last 90 days, and nothing
|
|
57
|
+
else, so the person can choose on their survey page which to share; a background wait shares the last 90 days of
|
|
48
58
|
those once they press Share. The server defaults to `https://app.ai-adoption-score.app`;
|
|
49
59
|
`AI_ADOPTION_SCORE_SERVER` or `--server http://localhost:3000` point it at a local app; any other server has to be
|
|
50
60
|
https. Run again on a Mac that is connected, it keeps that Mac's link on the same server (the survey page still
|
|
@@ -52,8 +62,8 @@ answer. It still checks what it's told:
|
|
|
52
62
|
to the new one.
|
|
53
63
|
- `sync`: shares now instead of after the next session. It counts the window again and sends the rows that changed
|
|
54
64
|
since the server last accepted them; the server replaces what it had, so nothing counts twice.
|
|
55
|
-
- `status`: which survey this Mac shares with, what the person chose, the hooks and when they last ran,
|
|
56
|
-
last
|
|
65
|
+
- `status`: which survey this Mac shares with, what the person chose, the hooks and when they last ran, when it last
|
|
66
|
+
shared, and the add-on's version and when it last looked for a newer one.
|
|
57
67
|
- `disconnect`: stops sharing from this Mac and removes everything it put in place.
|
|
58
68
|
- `preview [--days 30] [--company github.com/acme] [--json]`: what would be shared, without sending.
|
|
59
69
|
|
|
@@ -88,14 +98,15 @@ nothing, and `disconnect` still takes them away.
|
|
|
88
98
|
|
|
89
99
|
For testing, `AI_ADOPTION_SCORE_HOME`, `AI_ADOPTION_SCORE_CLAUDE_SETTINGS`, `AI_ADOPTION_SCORE_CODEX_HOOKS` and
|
|
90
100
|
`AI_ADOPTION_SCORE_LAUNCH_AGENTS` move each of these elsewhere, and `AI_ADOPTION_SCORE_SKIP_LAUNCHCTL=1` leaves launchd
|
|
91
|
-
alone. `AI_ADOPTION_SCORE_NODE` names a Node for `run.sh` to try.
|
|
101
|
+
alone. `AI_ADOPTION_SCORE_NODE` names a Node for `run.sh` to try. `AI_ADOPTION_SCORE_REGISTRY` points the daily update
|
|
102
|
+
at another npm registry; like the server, it has to be https, or http on this Mac itself.
|
|
92
103
|
|
|
93
104
|
## Try it
|
|
94
105
|
|
|
95
106
|
Count this Mac's last 30 days and see what would be shared, without connecting and without sending anything:
|
|
96
107
|
|
|
97
108
|
```bash
|
|
98
|
-
npx @ai-adoption-score/ai-coding-usage@
|
|
109
|
+
npx @ai-adoption-score/ai-coding-usage@latest preview
|
|
99
110
|
```
|
|
100
111
|
|
|
101
112
|
Add `--json` to see the exact rows, or `--days 7` for a shorter window. It needs Node.js 18.17 or newer and git, and
|
|
@@ -132,10 +143,10 @@ again and ticks it. Work outside any repository is one more box.
|
|
|
132
143
|
## What a row holds
|
|
133
144
|
|
|
134
145
|
One row per session, day, repository and branch: the tool, a hash of the session id, the day and the first and last
|
|
135
|
-
moment, the repository and branch and whether the branch is pushed, active minutes
|
|
136
|
-
commands, file edits, lines added and removed, connector calls by name, skills, helper agents,
|
|
137
|
-
commits, and per model its replies and tokens. `npx @ai-adoption-score/ai-coding-usage@
|
|
138
|
-
would send.
|
|
146
|
+
moment, the repository and branch and whether the branch is pushed, active minutes and the same time by hour, prompts,
|
|
147
|
+
stops, rejected actions, commands, file edits, lines added and removed, connector calls by name, skills, helper agents,
|
|
148
|
+
commits and pushed commits, and per model its replies and tokens. `npx @ai-adoption-score/ai-coding-usage@latest
|
|
149
|
+
preview --json` prints the exact rows this Mac would send.
|
|
139
150
|
|
|
140
151
|
Never shared: prompts, AI answers, code, file names and paths, command text, session titles.
|
|
141
152
|
|
|
@@ -155,3 +166,7 @@ Never shared: prompts, AI answers, code, file names and paths, command text, ses
|
|
|
155
166
|
lines as added. A failed or turned-down edit, or a scratch file elsewhere, doesn't count.
|
|
156
167
|
- Active minutes add up the gaps under five minutes. A commit counts for the session whose window holds it, from its
|
|
157
168
|
first moment to an hour after its last; each commit counts once.
|
|
169
|
+
- Time by hour (`minutesByHour`, from 1.1) is the same active time cut at the hours (UTC). A minute when several
|
|
170
|
+
sessions were active at once is split between them, so a person's rows add up to the time they really spent: on one
|
|
171
|
+
Mac with sessions side by side, 30 days of active minutes came to 185 hours and their time by hour to the 92 really
|
|
172
|
+
spent. Only the rows that are shared take part, so a session that stays on the Mac takes no share.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-adoption-score/ai-coding-usage",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Shares how you use Claude Code and Codex with your company's AI Adoption Score survey. Never prompts, AI answers, code or file names.",
|
|
5
5
|
"homepage": "https://ai-adoption-score.app/product/ai-coding-usage",
|
|
6
6
|
"repository": {
|
package/src/checkin.mjs
CHANGED
|
@@ -2,17 +2,18 @@
|
|
|
2
2
|
import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { CLIENT_VERSION, post } from './api.mjs'
|
|
5
|
-
import { isCurrentConfig, readConfig } from './config.mjs'
|
|
5
|
+
import { isCurrentConfig, readConfig, takeHistoryDays } from './config.mjs'
|
|
6
6
|
import { hookState, uninstallAll } from './install.mjs'
|
|
7
7
|
import { log } from './log.mjs'
|
|
8
8
|
import { paths } from './paths.mjs'
|
|
9
9
|
import { localDay } from './rows.mjs'
|
|
10
10
|
import { readState, updateState } from './state.mjs'
|
|
11
|
+
import { updateIfDue } from './update.mjs'
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* The hourly check-in (a launchd agent): tells the survey this Mac is there, and whether each tool's hook is still in
|
|
14
15
|
* place and running. It sends no usage numbers. A day with a check-in and no use is a real zero in the report; a day
|
|
15
|
-
* without one is a gap.
|
|
16
|
+
* without one is a gap. Once a day it also looks for a newer add-on (update.mjs).
|
|
16
17
|
*/
|
|
17
18
|
|
|
18
19
|
const NOT_APPROVED_GRACE_MS = 15 * 60_000
|
|
@@ -88,9 +89,17 @@ export async function runCheckIn(env = process.env) {
|
|
|
88
89
|
updateState((current) => {
|
|
89
90
|
current.lastCheckInAt = new Date().toISOString()
|
|
90
91
|
}, env)
|
|
92
|
+
// A server that now takes more days back than these settings say: the next sync sends the days in between once.
|
|
93
|
+
if (status === 'active') takeHistoryDays(config, response.body, env)
|
|
91
94
|
log(`check-in: ${response.status} ${status ?? ''} claude=${checkIn.claudeHook} codex=${checkIn.codexHook}`, env)
|
|
92
95
|
} catch (error) {
|
|
93
96
|
log(`check-in failed: ${error instanceof Error ? error.message : String(error)}`, env)
|
|
97
|
+
return 0
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
await updateIfDue(env)
|
|
101
|
+
} catch (error) {
|
|
102
|
+
log(`update failed: ${error instanceof Error ? error.message : String(error)}`, env)
|
|
94
103
|
}
|
|
95
104
|
return 0
|
|
96
105
|
}
|
package/src/cli.mjs
CHANGED
|
@@ -10,7 +10,8 @@ import { DEFAULT_SERVER, readConfig, writeConfig } from './config.mjs'
|
|
|
10
10
|
import { runHook } from './hook.mjs'
|
|
11
11
|
import { hookState, installAll, uninstallAll } from './install.mjs'
|
|
12
12
|
import { paths } from './paths.mjs'
|
|
13
|
-
import { daysToRead, scanUsage } from './scan.mjs'
|
|
13
|
+
import { MAX_HISTORY_DAYS, daysToRead, scanUsage } from './scan.mjs'
|
|
14
|
+
import { timeByHour } from './rows.mjs'
|
|
14
15
|
import { readState } from './state.mjs'
|
|
15
16
|
import { describeChoice, repositoryList } from './choice.mjs'
|
|
16
17
|
import { formatSummary } from './summary.mjs'
|
|
@@ -27,14 +28,15 @@ Usage
|
|
|
27
28
|
ai-coding-usage preview [--days 30] [--company github.com/acme] [--json]
|
|
28
29
|
|
|
29
30
|
connect Link this Mac to your survey with the code from your survey page. It adds a hook to Claude Code and
|
|
30
|
-
Codex and an automatic ping-back, and sends the names of the repositories you used in the last
|
|
31
|
-
you can choose on your survey page which to share. Then it shares the last
|
|
32
|
-
that, after each session. Run it again to change your choice.
|
|
33
|
-
|
|
31
|
+
Codex and an automatic ping-back, and sends the names of the repositories you used in the last 90 days, so
|
|
32
|
+
you can choose on your survey page which to share. Then it shares the last 90 days of those, and after
|
|
33
|
+
that, after each session. Run it again to change your choice. Once a day the ping-back looks for a
|
|
34
|
+
newer version of the add-on and puts it in place.
|
|
35
|
+
status Show which survey this Mac shares with, what you chose, its hooks, when it last shared, and its version.
|
|
34
36
|
sync Share now, instead of after the next session.
|
|
35
37
|
disconnect Stop sharing from this Mac: removes the hooks, the ping-back and the add-on's settings.
|
|
36
38
|
preview Count this Mac's last 30 days and show what would be shared. Nothing is sent.
|
|
37
|
-
--days How many days back, 1 to
|
|
39
|
+
--days How many days back, 1 to 90.
|
|
38
40
|
--company An account whose repositories are your company's, to mark them. Repeat for more.
|
|
39
41
|
--json Print the exact rows instead of the summary.
|
|
40
42
|
`
|
|
@@ -108,7 +110,7 @@ async function connect(args) {
|
|
|
108
110
|
else if (!code) code = args[index]
|
|
109
111
|
else return fail(`Unknown option "${args[index]}".`)
|
|
110
112
|
}
|
|
111
|
-
if (!code) return fail('Add the code from your survey page: npx @ai-adoption-score/ai-coding-usage@
|
|
113
|
+
if (!code) return fail('Add the code from your survey page: npx @ai-adoption-score/ai-coding-usage@latest connect K7Q2-9M4X')
|
|
112
114
|
const problem = serverProblem(server)
|
|
113
115
|
if (problem) return fail(problem)
|
|
114
116
|
// Run again on a Mac that is connected: the same server carries the link on with the new token, so it doesn't
|
|
@@ -129,7 +131,7 @@ async function connect(args) {
|
|
|
129
131
|
return fail(response.body?.error ?? `Couldn't connect: the server answered ${response.status}.`)
|
|
130
132
|
}
|
|
131
133
|
const { token, companyName, syncIntervalSeconds } = response.body
|
|
132
|
-
// However far back the survey asks for, the add-on reads
|
|
134
|
+
// However far back the survey asks for, the add-on reads 90 days at most (config.mjs).
|
|
133
135
|
const historyDays = daysToRead(response.body.historyDays)
|
|
134
136
|
// A new connection starts with nothing sent.
|
|
135
137
|
rmSync(paths().state, { force: true })
|
|
@@ -157,7 +159,7 @@ async function connect(args) {
|
|
|
157
159
|
const found = list.repositories.length
|
|
158
160
|
say(`Found ${found} ${found === 1 ? 'repository' : 'repositories'}${list.outsideGit ? ', and work outside a repository' : ''}, in the last ${historyDays} days.`)
|
|
159
161
|
say('Now choose on your survey page which to share. Nothing from your sessions is sent until you do.')
|
|
160
|
-
say(
|
|
162
|
+
say(`Then the last ${historyDays} days of what you chose are shared, and after that, your numbers after each session.`)
|
|
161
163
|
// Waits in the background, so this line returns at once in Claude Code or Codex too.
|
|
162
164
|
const waiter = spawn(process.execPath, [paths().appBin, 'sync', '--background', '--wait-for-choice'], {
|
|
163
165
|
detached: true,
|
|
@@ -226,6 +228,7 @@ async function status() {
|
|
|
226
228
|
say(`Codex hook: ${hookState('codex')}${state.hookRuns.codex ? `, last ran ${state.hookRuns.codex}` : ''}`)
|
|
227
229
|
say(state.lastSyncAt ? `Last shared: ${state.lastSyncAt}.` : 'Nothing shared yet.')
|
|
228
230
|
if (state.lastCheckInAt) say(`Last ping-back: ${state.lastCheckInAt}.`)
|
|
231
|
+
say(`Add-on ${CLIENT_VERSION}; it looks for a newer version once a day${state.lastUpdateCheckAt ? `, last on ${state.lastUpdateCheckAt}` : ''}.`)
|
|
229
232
|
if (state.lastError) say(`Last problem: ${state.lastError}`)
|
|
230
233
|
return 0
|
|
231
234
|
}
|
|
@@ -256,7 +259,7 @@ async function preview(rest) {
|
|
|
256
259
|
if (option === '--json') json = true
|
|
257
260
|
else if (option === '--days') {
|
|
258
261
|
days = Number(rest[++index])
|
|
259
|
-
if (!Number.isInteger(days) || days < 1 || days >
|
|
262
|
+
if (!Number.isInteger(days) || days < 1 || days > MAX_HISTORY_DAYS) return fail(`--days needs a whole number from 1 to ${MAX_HISTORY_DAYS}.`)
|
|
260
263
|
} else if (option === '--company') {
|
|
261
264
|
const account = rest[++index]
|
|
262
265
|
if (!account) return fail('--company needs an account, like github.com/acme.')
|
|
@@ -265,6 +268,6 @@ async function preview(rest) {
|
|
|
265
268
|
}
|
|
266
269
|
if (process.platform !== 'darwin') return fail('AI Coding Usage works on a Mac for now. Nothing was read.')
|
|
267
270
|
const scan = await scanUsage({ days })
|
|
268
|
-
process.stdout.write(json ? `${JSON.stringify({ since: scan.since, until: scan.until, rows: scan.rows }, null, 2)}\n` : `${formatSummary(scan, { companyAccounts })}\n`)
|
|
271
|
+
process.stdout.write(json ? `${JSON.stringify({ since: scan.since, until: scan.until, rows: timeByHour(scan.rows) }, null, 2)}\n` : `${formatSummary(scan, { companyAccounts })}\n`)
|
|
269
272
|
return 0
|
|
270
273
|
}
|
package/src/config.mjs
CHANGED
|
@@ -7,12 +7,44 @@ import { join } from 'node:path'
|
|
|
7
7
|
* The add-on's own folder, `~/.ai-adoption-score`: the connection's settings and its device token. Only the person's
|
|
8
8
|
* own account can read it. `AI_ADOPTION_SCORE_HOME` points it elsewhere, for testing.
|
|
9
9
|
*
|
|
10
|
+
* `historyDays` is how many days back the server takes, and `historyDaysSent` how many of them went out in one sync:
|
|
11
|
+
* fewer, and the next sync reads them all once (`sync.mjs`).
|
|
12
|
+
*
|
|
10
13
|
* @typedef {{ server: string, token: string, companyName: string, connectedAt: string, syncIntervalSeconds: number,
|
|
11
|
-
* historyDays: number, lastSyncAt?: string, initialSyncDone?: boolean }} UsageConfig
|
|
14
|
+
* historyDays: number, historyDaysSent?: number, lastSyncAt?: string, initialSyncDone?: boolean }} UsageConfig
|
|
12
15
|
*/
|
|
13
16
|
|
|
14
17
|
export const DEFAULT_SERVER = 'https://app.ai-adoption-score.app'
|
|
15
18
|
|
|
19
|
+
/** The add-on never reads further back than this, whatever the server asks for. */
|
|
20
|
+
export const MAX_HISTORY_DAYS = 90
|
|
21
|
+
/** What a server that names no number takes: every server before add-on 1.2 took 30 days. */
|
|
22
|
+
const DEFAULT_HISTORY_DAYS = 30
|
|
23
|
+
|
|
24
|
+
/** How many days to read: a whole number from 1 to 90, and 30 when `days` isn't one. @param {unknown} days */
|
|
25
|
+
export function daysToRead(days) {
|
|
26
|
+
const whole = Math.trunc(Number(days))
|
|
27
|
+
return Number.isFinite(whole) && whole >= 1 ? Math.min(whole, MAX_HISTORY_DAYS) : DEFAULT_HISTORY_DAYS
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A server's answer to a sync or a check-in that takes more days than this Mac's settings say (`historyDays`: 30 before,
|
|
32
|
+
* 90 since add-on 1.2): the settings take the new number, so the next sync sends the days in between once. Never fewer,
|
|
33
|
+
* so a Mac never sends a day its server would refuse. Returns the settings as they are now.
|
|
34
|
+
* @param {UsageConfig} config
|
|
35
|
+
* @param {unknown} answer
|
|
36
|
+
* @returns {UsageConfig}
|
|
37
|
+
*/
|
|
38
|
+
export function takeHistoryDays(config, answer, env = process.env) {
|
|
39
|
+
const named = answer && typeof answer === 'object' && 'historyDays' in answer ? answer.historyDays : undefined
|
|
40
|
+
if (named === undefined) return config
|
|
41
|
+
const offered = daysToRead(named)
|
|
42
|
+
if (offered <= (config.historyDays ?? 0) || !isCurrentConfig(config, env)) return config
|
|
43
|
+
const next = { ...(readConfig(env) ?? config), historyDays: offered }
|
|
44
|
+
writeConfig(next, env)
|
|
45
|
+
return next
|
|
46
|
+
}
|
|
47
|
+
|
|
16
48
|
export function configDir(env = process.env) {
|
|
17
49
|
return env.AI_ADOPTION_SCORE_HOME || join(homedir(), '.ai-adoption-score')
|
|
18
50
|
}
|
package/src/rows.mjs
CHANGED
|
@@ -12,7 +12,8 @@ import { createHash } from 'node:crypto'
|
|
|
12
12
|
* repository: string | null, branch: string | null, branchPushed: boolean, activeMinutes: number,
|
|
13
13
|
* prompts: number, interrupts: number, rejectedActions: number, commands: number, fileEdits: number,
|
|
14
14
|
* linesAdded: number, linesRemoved: number, connectors: Record<string, number>, skills: number,
|
|
15
|
-
* helperAgents: number, commits: number, commitsPushed: number, models: Record<string, ModelTokens
|
|
15
|
+
* helperAgents: number, commits: number, commitsPushed: number, models: Record<string, ModelTokens>,
|
|
16
|
+
* minutesByHour?: Record<string, number>
|
|
16
17
|
* }} UsageRow
|
|
17
18
|
*
|
|
18
19
|
* What a row collects before it is finished; `gitTop` and `times` never leave the Mac.
|
|
@@ -111,6 +112,57 @@ export function changedLines(before, after) {
|
|
|
111
112
|
return { added: newEnd - start, removed: oldEnd - start }
|
|
112
113
|
}
|
|
113
114
|
|
|
115
|
+
/** Each finished row's moments, kept here so they never leave the Mac: `timeByHour` needs them. */
|
|
116
|
+
const momentsOf = new WeakMap()
|
|
117
|
+
const HOUR_MS = 3_600_000
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Time with AI by hour (owner, 2026-10-30): each row's active stretches, the gaps under five minutes between its
|
|
121
|
+
* moments, cut at the hours (UTC, `YYYY-MM-DDTHH`), with a stretch when several of `rows` were active at once shared
|
|
122
|
+
* evenly between them. So the rows of a day add up to the time the person really spent with AI, however many sessions
|
|
123
|
+
* ran side by side, where active minutes count each session's own. Only the rows given take part, so a session that
|
|
124
|
+
* isn't shared changes nothing in the ones that are.
|
|
125
|
+
* @param {UsageRow[]} rows @returns {UsageRow[]}
|
|
126
|
+
*/
|
|
127
|
+
export function timeByHour(rows) {
|
|
128
|
+
/** @type {Array<[number, number, number]>} */
|
|
129
|
+
const points = []
|
|
130
|
+
rows.forEach((row, index) => {
|
|
131
|
+
const times = momentsOf.get(row) ?? []
|
|
132
|
+
for (let at = 1; at < times.length; at += 1) {
|
|
133
|
+
if (times[at] > times[at - 1] && times[at] - times[at - 1] < ACTIVE_GAP_MS) points.push([times[at - 1], 1, index], [times[at], -1, index])
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
// At the same moment, a stretch ends before the next begins.
|
|
137
|
+
points.sort((left, right) => left[0] - right[0] || left[1] - right[1])
|
|
138
|
+
/** @type {Map<string, number>[]} */
|
|
139
|
+
const shares = rows.map(() => new Map())
|
|
140
|
+
/** @type {Map<number, number>} the rows active now, and how many of their stretches */
|
|
141
|
+
const open = new Map()
|
|
142
|
+
let previous = 0
|
|
143
|
+
for (const [time, step, index] of points) {
|
|
144
|
+
for (let from = previous; open.size > 0 && from < time; ) {
|
|
145
|
+
const to = Math.min((Math.floor(from / HOUR_MS) + 1) * HOUR_MS, time)
|
|
146
|
+
const hour = new Date(from).toISOString().slice(0, 13)
|
|
147
|
+
for (const active of open.keys()) shares[active].set(hour, (shares[active].get(hour) ?? 0) + (to - from) / open.size)
|
|
148
|
+
from = to
|
|
149
|
+
}
|
|
150
|
+
const count = (open.get(index) ?? 0) + step
|
|
151
|
+
if (count > 0) open.set(index, count)
|
|
152
|
+
else open.delete(index)
|
|
153
|
+
previous = time
|
|
154
|
+
}
|
|
155
|
+
return rows.map((row, index) => {
|
|
156
|
+
/** @type {Record<string, number>} */
|
|
157
|
+
const minutesByHour = {}
|
|
158
|
+
for (const [hour, ms] of shares[index]) {
|
|
159
|
+
const minutes = Math.round(ms / 6_000) / 10
|
|
160
|
+
if (minutes > 0) minutesByHour[hour] = minutes
|
|
161
|
+
}
|
|
162
|
+
return { ...row, minutesByHour }
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
114
166
|
/** Working time: the gaps between a row's moments that are shorter than five minutes, added up. */
|
|
115
167
|
function activeMinutes(times) {
|
|
116
168
|
if (times.length === 0) return 0
|
|
@@ -130,7 +182,7 @@ function activeMinutes(times) {
|
|
|
130
182
|
export function finishRow(draft, isPushed) {
|
|
131
183
|
const times = [...draft.times].sort((left, right) => left - right)
|
|
132
184
|
const { sessionId, gitTop, times: _times, ...rest } = draft
|
|
133
|
-
|
|
185
|
+
const row = {
|
|
134
186
|
...rest,
|
|
135
187
|
sessionKey: createHash('sha256').update(`${draft.tool}:${sessionId}`).digest('hex').slice(0, 32),
|
|
136
188
|
firstEventAt: new Date(times[0]).toISOString(),
|
|
@@ -138,6 +190,8 @@ export function finishRow(draft, isPushed) {
|
|
|
138
190
|
branchPushed: isPushed(gitTop, draft.branch),
|
|
139
191
|
activeMinutes: activeMinutes(times),
|
|
140
192
|
}
|
|
193
|
+
momentsOf.set(row, times)
|
|
194
|
+
return row
|
|
141
195
|
}
|
|
142
196
|
|
|
143
197
|
/** Whether a row has anything worth sending: a reply from the AI or a prompt from the person. */
|
package/src/scan.mjs
CHANGED
|
@@ -4,22 +4,18 @@ import { readCodex } from './codex.mjs'
|
|
|
4
4
|
import { loadDesktopConnectorNames } from './connectors.mjs'
|
|
5
5
|
import { assignCommits, createGitInfo } from './git.mjs'
|
|
6
6
|
import { createRowSet, finishRow, hasUse, windowStart } from './rows.mjs'
|
|
7
|
+
import { daysToRead } from './config.mjs'
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
export const MAX_HISTORY_DAYS = 30
|
|
10
|
-
|
|
11
|
-
/** How many days to read: a whole number from 1 to 30, and 30 when `days` isn't one. @param {unknown} days */
|
|
12
|
-
export function daysToRead(days) {
|
|
13
|
-
const whole = Math.trunc(Number(days))
|
|
14
|
-
return Number.isFinite(whole) && whole >= 1 ? Math.min(whole, MAX_HISTORY_DAYS) : MAX_HISTORY_DAYS
|
|
15
|
-
}
|
|
9
|
+
export { MAX_HISTORY_DAYS, daysToRead } from './config.mjs'
|
|
16
10
|
|
|
17
11
|
/**
|
|
18
|
-
* Reads this Mac's Claude Code and Codex records for the last `days` calendar days
|
|
19
|
-
* that would be sent, with how much of the records could be read. Nothing is written and nothing leaves
|
|
12
|
+
* Reads this Mac's Claude Code and Codex records for the last `days` calendar days (30 unless asked, 90 at most) and
|
|
13
|
+
* returns the rows that would be sent, with how much of the records could be read. Nothing is written and nothing leaves
|
|
14
|
+
* the Mac. Claude Code deletes its own records after 30 days unless its `cleanupPeriodDays` says otherwise, so older days
|
|
15
|
+
* hold what is left of them; Codex keeps its records.
|
|
20
16
|
* @param {{ days?: number, now?: number, env?: NodeJS.ProcessEnv }} [options]
|
|
21
17
|
*/
|
|
22
|
-
export async function scanUsage({ days =
|
|
18
|
+
export async function scanUsage({ days = 30, now = Date.now(), env = process.env } = {}) {
|
|
23
19
|
const since = windowStart(now, daysToRead(days))
|
|
24
20
|
const rows = createRowSet()
|
|
25
21
|
const gitInfo = createGitInfo()
|
package/src/state.mjs
CHANGED
|
@@ -5,13 +5,14 @@ import { paths } from './paths.mjs'
|
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* What the add-on remembers between runs, beside its settings: which rows the server accepted (by a hash of each), when
|
|
8
|
-
* it last synced, when each tool's hook last ran,
|
|
8
|
+
* it last synced, when each tool's hook last ran, how long the server asked it to wait, and when it last looked for a
|
|
9
|
+
* newer version. Only names and numbers.
|
|
9
10
|
*
|
|
10
11
|
* @typedef {{
|
|
11
12
|
* sent: Record<string, string>,
|
|
12
13
|
* lastSyncStartedAt?: string, lastSyncAt?: string, notBefore?: string,
|
|
13
14
|
* hookRuns: Partial<Record<'claude-code' | 'codex', string>>,
|
|
14
|
-
* lastCheckInAt?: string, lastError?: string
|
|
15
|
+
* lastCheckInAt?: string, lastError?: string, lastUpdateCheckAt?: string
|
|
15
16
|
* }} UsageState
|
|
16
17
|
*/
|
|
17
18
|
|
package/src/sync.mjs
CHANGED
|
@@ -5,11 +5,11 @@ import { constants, setPriority } from 'node:os'
|
|
|
5
5
|
import { get, post, CLIENT_VERSION } from './api.mjs'
|
|
6
6
|
import { reportedHookState } from './checkin.mjs'
|
|
7
7
|
import { isShared, readChoice } from './choice.mjs'
|
|
8
|
-
import { isCurrentConfig, writeConfig } from './config.mjs'
|
|
8
|
+
import { isCurrentConfig, readConfig, takeHistoryDays, writeConfig } from './config.mjs'
|
|
9
9
|
import { hookState, uninstallAll } from './install.mjs'
|
|
10
10
|
import { log } from './log.mjs'
|
|
11
11
|
import { paths } from './paths.mjs'
|
|
12
|
-
import { localDay } from './rows.mjs'
|
|
12
|
+
import { localDay, timeByHour } from './rows.mjs'
|
|
13
13
|
import { scanUsage } from './scan.mjs'
|
|
14
14
|
import { readState, updateState } from './state.mjs'
|
|
15
15
|
|
|
@@ -18,6 +18,12 @@ const MAX_ROWS = 400
|
|
|
18
18
|
const MAX_BYTES = 200 * 1024
|
|
19
19
|
/** A sync that started this long ago and still holds the lock has died. */
|
|
20
20
|
const STALE_LOCK_MS = 15 * 60_000
|
|
21
|
+
/**
|
|
22
|
+
* The days every sync reads once all the days the server takes went out: the rows that can still change (today's, a
|
|
23
|
+
* branch pushed later, a commit made after a session) are in them, and reading more would only take longer (about 20
|
|
24
|
+
* seconds for 30 days on a busy Mac).
|
|
25
|
+
*/
|
|
26
|
+
export const SYNC_DAYS = 30
|
|
21
27
|
|
|
22
28
|
/** @param {import('./rows.mjs').UsageRow[]} rows */
|
|
23
29
|
export function batches(rows) {
|
|
@@ -136,8 +142,13 @@ export async function syncNow(config, { background = false, reason = 'manual', e
|
|
|
136
142
|
}
|
|
137
143
|
const { choice } = asked
|
|
138
144
|
|
|
139
|
-
|
|
140
|
-
|
|
145
|
+
// All the days the server takes go out in one sync: the first one, and once more when the server comes to take
|
|
146
|
+
// more than before (90 instead of 30 since add-on 1.2). Every other sync reads the last 30 days.
|
|
147
|
+
const wholeWindow = (config.historyDaysSent ?? 0) < config.historyDays
|
|
148
|
+
const days = wholeWindow ? config.historyDays : Math.min(config.historyDays, SYNC_DAYS)
|
|
149
|
+
const scan = await scanUsage({ days, env })
|
|
150
|
+
// Time by hour among the rows that go out only, so a session that stays on the Mac takes no share of it.
|
|
151
|
+
const included = timeByHour(scan.rows.filter((row) => sendsTool(row.tool, config, state, env) && isShared(choice, row.repository)))
|
|
141
152
|
const changed = included.filter((row) => state.sent[rowKey(row)] !== rowHash(row))
|
|
142
153
|
const checkIn = {
|
|
143
154
|
day: localDay(Date.now()),
|
|
@@ -147,9 +158,12 @@ export async function syncNow(config, { background = false, reason = 'manual', e
|
|
|
147
158
|
const groups = changed.length > 0 ? batches(changed) : [[]]
|
|
148
159
|
let accepted = 0
|
|
149
160
|
let status = asked.status === 'revoked' || asked.status === 'closed' ? asked.status : 'active'
|
|
161
|
+
/** @type {unknown} */
|
|
162
|
+
let answer = null
|
|
150
163
|
for (const [index, rows] of status === 'active' ? groups.entries() : []) {
|
|
151
164
|
const body = { schemaVersion: 1, clientVersion: CLIENT_VERSION, rows, ...(index === 0 ? { checkIn } : {}) }
|
|
152
165
|
const response = await post(config.server, '/api/usage/sync', body, config.token)
|
|
166
|
+
answer = response.body
|
|
153
167
|
status = response.body?.status ?? (response.status === 401 ? 'revoked' : 'error')
|
|
154
168
|
if (response.status === 429 || status === 'paused') {
|
|
155
169
|
const wait = (response.retryAfterSeconds ?? response.body?.nextSyncAfterSeconds ?? 600) * 1000
|
|
@@ -182,9 +196,19 @@ export async function syncNow(config, { background = false, reason = 'manual', e
|
|
|
182
196
|
updateState((current) => {
|
|
183
197
|
current.sent = Object.fromEntries(Object.entries(current.sent).filter(([key]) => keep.has(key)))
|
|
184
198
|
}, env)
|
|
185
|
-
// The first sync is the one with the person's choice: it sends
|
|
186
|
-
|
|
187
|
-
|
|
199
|
+
// The first sync is the one with the person's choice: it sends every tool's days. Once all the days the server takes
|
|
200
|
+
// went out, the next syncs read the last 30.
|
|
201
|
+
if (choice && (!config.initialSyncDone || wholeWindow) && isCurrentConfig(config, env)) {
|
|
202
|
+
const current = readConfig(env) ?? config
|
|
203
|
+
writeConfig({
|
|
204
|
+
...current,
|
|
205
|
+
...(config.initialSyncDone ? {} : { initialSyncDone: true, lastSyncAt: new Date().toISOString() }),
|
|
206
|
+
...(wholeWindow ? { historyDaysSent: days } : {}),
|
|
207
|
+
}, env)
|
|
208
|
+
}
|
|
209
|
+
// A server that takes more days than these settings say: the next sync sends the days in between.
|
|
210
|
+
takeHistoryDays(config, answer, env)
|
|
211
|
+
log(`sync (${reason}): sent ${accepted} of ${included.length} rows${wholeWindow ? `, reading ${days} days` : ''}`, env)
|
|
188
212
|
} else {
|
|
189
213
|
log(`sync (${reason}): ${status}`, env)
|
|
190
214
|
}
|
|
@@ -204,9 +228,9 @@ export async function syncNow(config, { background = false, reason = 'manual', e
|
|
|
204
228
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
205
229
|
|
|
206
230
|
/**
|
|
207
|
-
* Right after connecting, in the background: waits while the person chooses on their survey page, then sends the
|
|
208
|
-
*
|
|
209
|
-
* choose sends them all the same.
|
|
231
|
+
* Right after connecting, in the background: waits while the person chooses on their survey page, then sends the days
|
|
232
|
+
* the server takes (90) of what they chose. It gives up when the list on their page expires (an hour); the next sync
|
|
233
|
+
* after they choose sends them all the same.
|
|
210
234
|
* @param {import('./config.mjs').UsageConfig} config
|
|
211
235
|
* @param {{ env?: NodeJS.ProcessEnv, timeoutMs?: number }} [options]
|
|
212
236
|
*/
|
package/src/update.mjs
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { execFileSync } from 'node:child_process'
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
import { mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
5
|
+
import { dirname, join } from 'node:path'
|
|
6
|
+
import { gunzipSync } from 'node:zlib'
|
|
7
|
+
import { CLIENT_VERSION, serverProblem } from './api.mjs'
|
|
8
|
+
import { log } from './log.mjs'
|
|
9
|
+
import { paths } from './paths.mjs'
|
|
10
|
+
import { readState, updateState } from './state.mjs'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The add-on keeps itself up to date (owner, 2026-10-30): once a day the ping-back asks npm for the latest version.
|
|
14
|
+
* When there is a newer one, it downloads it, checks it is byte for byte the file npm lists for that version, checks
|
|
15
|
+
* that it starts on this Mac's Node, and puts it in place of its own copy; the next hook or ping-back runs it. Nothing
|
|
16
|
+
* is sent to npm but the request itself. The hooks, the ping-back and the settings stay as they are, and anything that
|
|
17
|
+
* goes wrong leaves the copy that works.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const PACKAGE = '@ai-adoption-score/ai-coding-usage'
|
|
21
|
+
const REGISTRY = 'https://registry.npmjs.org'
|
|
22
|
+
const CHECK_EVERY_MS = 24 * 60 * 60_000
|
|
23
|
+
const TIMEOUT_MS = 30_000
|
|
24
|
+
const MAX_DOWNLOAD_BYTES = 5 * 1024 * 1024
|
|
25
|
+
|
|
26
|
+
/** "1.2.3" as numbers; null for anything else, such as a pre-release. @param {unknown} version */
|
|
27
|
+
function versionParts(version) {
|
|
28
|
+
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(version))
|
|
29
|
+
return match ? match.slice(1).map(Number) : null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Whether `candidate` is a later release than `current`; never a step back. @param {unknown} candidate @param {unknown} current */
|
|
33
|
+
export function isNewer(candidate, current) {
|
|
34
|
+
const next = versionParts(candidate)
|
|
35
|
+
const now = versionParts(current)
|
|
36
|
+
if (!next || !now) return false
|
|
37
|
+
for (let index = 0; index < 3; index += 1) if (next[index] !== now[index]) return next[index] > now[index]
|
|
38
|
+
return false
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Whether this Node is new enough for `engines.node` written as ">=18.17"; any other form is left to the start check. */
|
|
42
|
+
function nodeFits(engines, running = process.versions.node) {
|
|
43
|
+
const wanted = /^>=\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(String(engines?.node ?? '').trim())
|
|
44
|
+
if (!wanted) return true
|
|
45
|
+
const need = wanted.slice(1).map((part) => Number(part ?? 0))
|
|
46
|
+
const have = running.split('.').map(Number)
|
|
47
|
+
for (let index = 0; index < 3; index += 1) if ((have[index] ?? 0) !== need[index]) return (have[index] ?? 0) > need[index]
|
|
48
|
+
return true
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @param {string} url @param {Record<string, string>} headers */
|
|
52
|
+
async function download(url, headers = {}) {
|
|
53
|
+
const controller = new AbortController()
|
|
54
|
+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
|
|
55
|
+
try {
|
|
56
|
+
const response = await fetch(url, { headers: { 'user-agent': `ai-coding-usage/${CLIENT_VERSION}`, ...headers }, signal: controller.signal })
|
|
57
|
+
if (!response.ok) throw new Error(`${url} answered ${response.status}`)
|
|
58
|
+
const body = Buffer.from(await response.arrayBuffer())
|
|
59
|
+
if (body.length > MAX_DOWNLOAD_BYTES) throw new Error(`${url} is larger than expected`)
|
|
60
|
+
return body
|
|
61
|
+
} finally {
|
|
62
|
+
clearTimeout(timer)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The files of an npm package: a gzipped tar with every entry under `package/`. Only plain files whose path stays
|
|
68
|
+
* inside the package are read; links and anything else are left out.
|
|
69
|
+
* @param {Buffer} tarball @returns {Map<string, { data: Buffer, mode: number }>}
|
|
70
|
+
*/
|
|
71
|
+
export function readPackage(tarball) {
|
|
72
|
+
const archive = gunzipSync(tarball)
|
|
73
|
+
const files = new Map()
|
|
74
|
+
/** @type {string | null} */
|
|
75
|
+
let paxPath = null
|
|
76
|
+
for (let offset = 0; offset + 512 <= archive.length; ) {
|
|
77
|
+
const header = archive.subarray(offset, offset + 512)
|
|
78
|
+
if (header.every((byte) => byte === 0)) break
|
|
79
|
+
const field = (start, length) => header.subarray(start, start + length).toString('utf8').replace(/\0[\s\S]*$/, '')
|
|
80
|
+
const size = parseInt(field(124, 12).trim() || '0', 8)
|
|
81
|
+
const type = field(156, 1) || '0'
|
|
82
|
+
const prefix = field(257, 5) === 'ustar' ? field(345, 155) : ''
|
|
83
|
+
const name = paxPath ?? (prefix ? `${prefix}/${field(0, 100)}` : field(0, 100))
|
|
84
|
+
paxPath = null
|
|
85
|
+
const body = archive.subarray(offset + 512, offset + 512 + size)
|
|
86
|
+
offset += 512 + Math.ceil(size / 512) * 512
|
|
87
|
+
if (type === 'x') {
|
|
88
|
+
// An extended header: its `path` names the next entry.
|
|
89
|
+
paxPath = /(?:^|\n)\d+ path=([^\n]*)\n/.exec(body.toString('utf8'))?.[1] ?? null
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
if (type !== '0') continue
|
|
93
|
+
const parts = name.split('/')
|
|
94
|
+
if (parts[0] !== 'package' || parts.length < 2 || parts.some((part) => part === '' || part === '.' || part === '..')) continue
|
|
95
|
+
files.set(parts.slice(1).join('/'), { data: Buffer.from(body), mode: parseInt(field(100, 8).trim() || '644', 8) & 0o755 })
|
|
96
|
+
}
|
|
97
|
+
return files
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Puts `version` in place of the add-on's copy, when it is the one npm lists and it starts here.
|
|
102
|
+
* @param {{ version: string, dist?: { tarball?: string, integrity?: string } }} manifest @param {string} registry
|
|
103
|
+
*/
|
|
104
|
+
async function install(manifest, registry, env) {
|
|
105
|
+
const where = paths(env)
|
|
106
|
+
const tarball = manifest.dist?.tarball
|
|
107
|
+
const integrity = /^sha512-(.+)$/.exec(manifest.dist?.integrity ?? '')?.[1]
|
|
108
|
+
if (!tarball || !integrity || new URL(tarball).origin !== new URL(registry).origin) throw new Error(`npm lists no file for ${manifest.version} that can be checked`)
|
|
109
|
+
const archive = await download(tarball)
|
|
110
|
+
if (createHash('sha512').update(archive).digest('base64') !== integrity) throw new Error(`the file for ${manifest.version} isn't the one npm lists`)
|
|
111
|
+
const files = readPackage(archive)
|
|
112
|
+
const listed = JSON.parse(files.get('package.json')?.data.toString('utf8') ?? '{}')
|
|
113
|
+
if (listed.name !== PACKAGE || listed.version !== manifest.version || !files.has('bin/ai-coding-usage.mjs')) throw new Error(`the file for ${manifest.version} isn't this add-on`)
|
|
114
|
+
|
|
115
|
+
// The new copy beside the old one, as connecting puts it: its program and its package.json, nothing else.
|
|
116
|
+
const fresh = `${where.app}.update`
|
|
117
|
+
const previous = `${where.app}.previous`
|
|
118
|
+
rmSync(fresh, { recursive: true, force: true })
|
|
119
|
+
try {
|
|
120
|
+
for (const [path, file] of files) {
|
|
121
|
+
if (path !== 'package.json' && !path.startsWith('bin/') && !path.startsWith('src/')) continue
|
|
122
|
+
mkdirSync(dirname(join(fresh, path)), { recursive: true })
|
|
123
|
+
writeFileSync(join(fresh, path), file.data, { mode: file.mode })
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
// It has to start on this Node before it replaces the copy that works: its help loads all of it and exits.
|
|
127
|
+
execFileSync(process.execPath, [join(fresh, 'bin', 'ai-coding-usage.mjs'), 'help'], { stdio: 'ignore', timeout: TIMEOUT_MS, env })
|
|
128
|
+
} catch {
|
|
129
|
+
throw new Error(`${manifest.version} doesn't start on Node ${process.versions.node}`)
|
|
130
|
+
}
|
|
131
|
+
rmSync(previous, { recursive: true, force: true })
|
|
132
|
+
renameSync(where.app, previous)
|
|
133
|
+
try {
|
|
134
|
+
renameSync(fresh, where.app)
|
|
135
|
+
} catch (error) {
|
|
136
|
+
renameSync(previous, where.app)
|
|
137
|
+
throw error
|
|
138
|
+
}
|
|
139
|
+
rmSync(previous, { recursive: true, force: true })
|
|
140
|
+
} finally {
|
|
141
|
+
rmSync(fresh, { recursive: true, force: true })
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The ping-back's daily look at npm. `AI_ADOPTION_SCORE_REGISTRY` points it at another registry, for testing; like the
|
|
147
|
+
* survey's server, it has to be https, or http on this Mac itself.
|
|
148
|
+
* @returns {Promise<'not-due' | 'up-to-date' | 'updated' | 'skipped'>}
|
|
149
|
+
*/
|
|
150
|
+
export async function updateIfDue(env = process.env, now = Date.now()) {
|
|
151
|
+
const last = readState(env).lastUpdateCheckAt
|
|
152
|
+
if (last && now - Date.parse(last) < CHECK_EVERY_MS) return 'not-due'
|
|
153
|
+
const registry = env.AI_ADOPTION_SCORE_REGISTRY || REGISTRY
|
|
154
|
+
const problem = serverProblem(registry)
|
|
155
|
+
if (problem) throw new Error(problem)
|
|
156
|
+
const document = JSON.parse(
|
|
157
|
+
(await download(`${registry.replace(/\/+$/, '')}/${PACKAGE.replace('/', '%2f')}`, { accept: 'application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8' })).toString('utf8')
|
|
158
|
+
)
|
|
159
|
+
// npm answered: the next look is tomorrow, whatever happens next.
|
|
160
|
+
updateState((current) => {
|
|
161
|
+
current.lastUpdateCheckAt = new Date(now).toISOString()
|
|
162
|
+
}, env)
|
|
163
|
+
const latest = document?.['dist-tags']?.latest
|
|
164
|
+
const manifest = document?.versions?.[latest]
|
|
165
|
+
if (!isNewer(latest, CLIENT_VERSION) || !manifest) return 'up-to-date'
|
|
166
|
+
if (manifest.deprecated || !nodeFits(manifest.engines)) {
|
|
167
|
+
log(`update: ${latest} is out, but ${manifest.deprecated ? 'it was withdrawn' : `it needs Node ${manifest.engines.node}`}; staying on ${CLIENT_VERSION}`, env)
|
|
168
|
+
return 'skipped'
|
|
169
|
+
}
|
|
170
|
+
await install(manifest, registry, env)
|
|
171
|
+
log(`update: ${CLIENT_VERSION} → ${latest}`, env)
|
|
172
|
+
return 'updated'
|
|
173
|
+
}
|