@erclx/aitk 0.110.0 → 0.111.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/README.md +5 -1
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-orchestrate/references/orchestrator-poll.md +2 -0
- package/claude/skills/claude-orchestrate/scripts/poll.sh +61 -16
- package/docs/agents/commands.md +17 -2
- package/docs/target-projects.md +6 -0
- package/package.json +1 -1
- package/src/cli.ts +6 -20
- package/src/commands/claude.ts +34 -4
- package/src/commands/sync.ts +21 -0
- package/src/commands/upgrade.ts +231 -0
- package/src/sync/check.ts +18 -0
- package/src/version/compare.ts +53 -0
- package/src/version/installed.ts +40 -0
- package/src/version/manager.ts +67 -0
- package/src/version/skew.ts +192 -0
package/README.md
CHANGED
|
@@ -38,11 +38,15 @@ Nothing refreshes on its own. Claude Code ships auto-update off for third-party
|
|
|
38
38
|
```bash
|
|
39
39
|
claude plugin marketplace update aitk
|
|
40
40
|
claude plugin update aitk@aitk
|
|
41
|
-
|
|
41
|
+
aitk upgrade
|
|
42
42
|
```
|
|
43
43
|
|
|
44
44
|
The first two update the skills, the third updates the CLI, and they move independently. Restart Claude Code, or run `/reload-plugins`, to pick the skills up.
|
|
45
45
|
|
|
46
|
+
`aitk upgrade` reads the package manager off its own install path and reinstalls with that one, so you don't have to remember which put it there. It names what it detected before it runs anything, and it refuses a source checkout rather than reinstalling over your clone.
|
|
47
|
+
|
|
48
|
+
You don't have to wait until something breaks to find out you're behind. `aitk sync --check` and `aitk claude skills drift` both report the installed version against the newest published one, and neither changes its exit code over it, so an offline machine reads unknown rather than red.
|
|
49
|
+
|
|
46
50
|
To stop doing this by hand, turn auto-update on once under `/plugin` in the Marketplaces tab. Confirm what you are running with `aitk --version` and `claude plugin list`.
|
|
47
51
|
|
|
48
52
|
## Why
|
|
@@ -53,6 +53,8 @@ The baseline lives at `.claude/.tmp/pr-poll/baseline.txt` under the main worktre
|
|
|
53
53
|
|
|
54
54
|
The four review headings the script matches are written by `claude-pr-review` and `claude-address-review`. A project that posts its reviews under different headings edits the three jq filters in the script to match, or every pull request reads as never reviewed.
|
|
55
55
|
|
|
56
|
+
`RESPONSE` is qualified by recency as well as by count, so it means a reply the last pass has not already answered rather than one this script has not seen before. A worker answers a finding and the reviewing session posts its close-out seconds later, which is the ordinary handback rather than a race, so a count on its own reported the answered thread on the next run and the re-review it routed to stopped at its own guard. The state now fires when the newest reply is stamped later than the last pass, and on a pull request carrying no pass at all, which is a worker talking to nobody and worth the turn. A reply landing inside the same second as the pass is dropped, matching the comparison `claude-pr-review` makes on the same two fields.
|
|
57
|
+
|
|
56
58
|
`STALLED` is the one state the script derives from a heading rather than from a commit or a count, since `claude-pr-review` posts the open heading exactly when a dispatch is owed, per the threshold that skill states. The heading alone cannot carry it, because an open pass means a dispatch was owed and made, so the ordinary healthy thread is a worker still working. The age of that pass is the third test: the state fires when the open pass covers the head, nothing has followed it, and it is older than the `STALE_AFTER` seconds set at the top of the script. It reports once per entry and fires again after any commit or reply resets the thread. A project whose workers run longer than the default two hours raises that number.
|
|
57
59
|
|
|
58
60
|
The state reaches every stalled dispatch, since one threshold governs the heading and the dispatch alike and a pass carrying anything posts the open heading. A minors-only pass therefore reports here on the same terms as a blocking one, which widens the state from what it caught while the two were split. It stays a heading test rather than a count test, so nothing here pins the summary line, which is a second string this script does not own.
|
|
@@ -48,11 +48,19 @@ JQ_LAST_REVIEWED_HEAD='
|
|
|
48
48
|
| . == "## Review" or . == "## Review closed")
|
|
49
49
|
] | last | .commit.oid // empty
|
|
50
50
|
'
|
|
51
|
-
|
|
51
|
+
# The count alone answers whether a reply is new to this script, which is not
|
|
52
|
+
# the same question as whether it is newer than the pass it answers. The stamp
|
|
53
|
+
# of the newest reply comes out of the same selection so the recency test costs
|
|
54
|
+
# no second filter, and it is the one this file compares against `submittedAt`.
|
|
55
|
+
JQ_REPLY_STATE='
|
|
52
56
|
[ .comments[]
|
|
53
57
|
| select((.body // "") | split("\n")[0] | rtrimstr("\r")
|
|
54
58
|
| . == "## Review response" or . == "## Rebase")
|
|
55
|
-
]
|
|
59
|
+
] as $replies
|
|
60
|
+
| ($replies | length | tostring)
|
|
61
|
+
+ " "
|
|
62
|
+
+ ([ $replies[] | .createdAt // empty | fromdateiso8601 | floor ]
|
|
63
|
+
| max // 0 | tostring)
|
|
56
64
|
'
|
|
57
65
|
|
|
58
66
|
# `claude-pr-review` states the threshold and posts `## Review` exactly when a
|
|
@@ -69,18 +77,28 @@ JQ_REPLY_COUNT='
|
|
|
69
77
|
# script runs wherever the plugin is installed. A review carrying no stamp reads
|
|
70
78
|
# as age zero and classifies nothing, which is the same answer the carry-forward
|
|
71
79
|
# path gives a pull request this run could not read.
|
|
80
|
+
#
|
|
81
|
+
# The stamp itself is emitted as a third field beside the age it was derived
|
|
82
|
+
# from, because the reply test below compares against the instant rather than
|
|
83
|
+
# against the elapsed seconds. A missing stamp reads as zero there too, where it
|
|
84
|
+
# sends a reply to be reported rather than suppressed. The two readers therefore
|
|
85
|
+
# fail in opposite directions on the same absent field, since zero silences the
|
|
86
|
+
# age test above and zero is the value the reply test reports on.
|
|
72
87
|
JQ_LAST_REVIEW_STATE='
|
|
73
88
|
[ .reviews[]
|
|
74
89
|
| select((.body // "") | split("\n")[0] | rtrimstr("\r")
|
|
75
90
|
| . == "## Review" or . == "## Review closed")
|
|
76
91
|
] | last
|
|
77
|
-
| if . == null then "none 0"
|
|
78
|
-
else (
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
92
|
+
| if . == null then "none 0 0"
|
|
93
|
+
else (if .submittedAt == null then 0
|
|
94
|
+
else (.submittedAt | fromdateiso8601 | floor)
|
|
95
|
+
end) as $at
|
|
96
|
+
| ((.body | split("\n")[0] | rtrimstr("\r")
|
|
97
|
+
| if . == "## Review" then "open" else "closed" end)
|
|
98
|
+
+ " "
|
|
99
|
+
+ (if $at == 0 then "0" else (((now | floor) - $at) | tostring) end)
|
|
100
|
+
+ " "
|
|
101
|
+
+ ($at | tostring))
|
|
84
102
|
end
|
|
85
103
|
'
|
|
86
104
|
|
|
@@ -102,7 +120,10 @@ carry_forward() {
|
|
|
102
120
|
old=$(grep "^$n " "$STATE" || true)
|
|
103
121
|
if [ -n "$old" ]; then
|
|
104
122
|
# Every other classification compares a field against itself on a carried
|
|
105
|
-
# line, so it fires nothing.
|
|
123
|
+
# line, so it fires nothing. That is also what keeps the two stamps unread
|
|
124
|
+
# here: the baseline never held them, so both arrive empty, and the count
|
|
125
|
+
# test in front of them fails before either reaches a numeric comparison.
|
|
126
|
+
# STALLED reads a heading that is carried too, so
|
|
106
127
|
# the field is blanked to a value no branch matches. Echoing it intact would
|
|
107
128
|
# classify a pull request this run never reached while stderr below says the
|
|
108
129
|
# opposite, and one successful read restores it a run later. An already
|
|
@@ -146,9 +167,14 @@ snapshot() {
|
|
|
146
167
|
fi
|
|
147
168
|
|
|
148
169
|
prior=$(jq -r "$JQ_LAST_REVIEWED_HEAD" <<<"$payload")
|
|
149
|
-
|
|
150
|
-
#
|
|
151
|
-
#
|
|
170
|
+
# Split here rather than carried whole, because the count keeps the fourth
|
|
171
|
+
# column every baseline written so far already reads, and the stamp goes to
|
|
172
|
+
# the end of the line beside the pass stamp it is compared against.
|
|
173
|
+
reply_state=$(jq -r "$JQ_REPLY_STATE" <<<"$payload")
|
|
174
|
+
resp=${reply_state%% *}
|
|
175
|
+
reply_at=${reply_state##* }
|
|
176
|
+
# Three space-separated fields, so the line below carries them as its own
|
|
177
|
+
# sixth, seventh, and eighth rather than needing a split.
|
|
152
178
|
review_state=$(jq -r "$JQ_LAST_REVIEW_STATE" <<<"$payload")
|
|
153
179
|
|
|
154
180
|
# `gh pr view --json mergeable` reports UNKNOWN until GitHub finishes
|
|
@@ -168,7 +194,7 @@ snapshot() {
|
|
|
168
194
|
merges=conflict
|
|
169
195
|
fi
|
|
170
196
|
|
|
171
|
-
echo "$n $head ${prior:-none} $resp $merges $review_state"
|
|
197
|
+
echo "$n $head ${prior:-none} $resp $merges $review_state $reply_at"
|
|
172
198
|
done
|
|
173
199
|
}
|
|
174
200
|
|
|
@@ -185,7 +211,7 @@ CHANGED=0
|
|
|
185
211
|
# would fire on every later run and the board would never read "No movement."
|
|
186
212
|
FINAL=""
|
|
187
213
|
|
|
188
|
-
while read -r n head prior resp merges heading age; do
|
|
214
|
+
while read -r n head prior resp merges heading age pass_at reply_at; do
|
|
189
215
|
[ -z "$n" ] && continue
|
|
190
216
|
state=$heading
|
|
191
217
|
old=$(grep "^$n " "$STATE" || true)
|
|
@@ -244,7 +270,26 @@ while read -r n head prior resp merges heading age; do
|
|
|
244
270
|
fi
|
|
245
271
|
fi
|
|
246
272
|
CHANGED=1
|
|
247
|
-
elif [ "$resp" -gt "$old_resp" ]
|
|
273
|
+
elif [ "$resp" -gt "$old_resp" ] &&
|
|
274
|
+
{ [ "$pass_at" = 0 ] || [ "$reply_at" -gt "$pass_at" ]; }; then
|
|
275
|
+
# The count says the reply is new to this script and the stamp says it is
|
|
276
|
+
# newer than the pass, and both are needed. A worker answers a finding and
|
|
277
|
+
# the reviewing session closes out seconds later, so the count alone reports
|
|
278
|
+
# an answered thread on the next run and the session spends a turn learning
|
|
279
|
+
# `claude-pr-review` will refuse it. The gaps observed were 96, 24, and 35
|
|
280
|
+
# seconds, which is a reviewing session reading a reply and posting, so this
|
|
281
|
+
# is the ordinary handback rather than a race.
|
|
282
|
+
#
|
|
283
|
+
# A pull request carrying no pass at all reads as stamp zero and reports,
|
|
284
|
+
# which is the case the count was the right test for: a reply with nothing
|
|
285
|
+
# behind it is a worker talking to nobody and worth the turn. The test is
|
|
286
|
+
# strictly greater to match the guard `claude-pr-review` states on the same
|
|
287
|
+
# two fields, which drops a reply landing inside the same second as the
|
|
288
|
+
# pass, a narrower failure than the one it removes.
|
|
289
|
+
#
|
|
290
|
+
# The gate sits in the condition rather than in the body so a suppressed
|
|
291
|
+
# reply falls through to STALLED below, which is a thread this run should
|
|
292
|
+
# still be able to reach.
|
|
248
293
|
echo "RESPONSE #$n answered with no new commit"
|
|
249
294
|
CHANGED=1
|
|
250
295
|
elif [ "$heading" = open ] && [ "$old_heading" != reported ] &&
|
package/docs/agents/commands.md
CHANGED
|
@@ -13,7 +13,7 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
|
|
|
13
13
|
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
|
14
14
|
| `aitk init [path]` | Bootstrap a project with selected toolkit domains |
|
|
15
15
|
| `aitk sync [path]` | Sync all installed domains in a target project |
|
|
16
|
-
| `aitk sync --check` | Report toolkit drift
|
|
16
|
+
| `aitk sync --check` | Report toolkit drift and the installed version against the newest published (`--json`, `--exit-code`) |
|
|
17
17
|
| `aitk sandbox [cat:cmd]` | Run sandbox scenarios (interactive or routed), toolkit-only like the tree it reads |
|
|
18
18
|
| `aitk sandbox reset` | Reset sandbox to baseline |
|
|
19
19
|
| `aitk sandbox clean` | Wipe the sandbox |
|
|
@@ -44,11 +44,12 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
|
|
|
44
44
|
| `aitk context audit` | Report required sections, length, cited paths, reference form, catalog tables, provenance, superseded-decision narration, and index drift |
|
|
45
45
|
| `aitk markdown audit` | Fail any markdown path on a banned character, word, or spelling, and report the structural checkpoints |
|
|
46
46
|
| `aitk claude skills audit` | Report both skill corpora against the mechanical rules in `standards/skill.md` |
|
|
47
|
-
| `aitk claude skills drift` | Name the shipped skill bodies rewritten between a given ref and `HEAD
|
|
47
|
+
| `aitk claude skills drift` | Name the shipped skill bodies rewritten between a given ref and `HEAD`, and the installed version against the newest published (`--json`) |
|
|
48
48
|
| `aitk gov test-order` | Report where an implementation reached history ahead of the test covering it (`--json`) |
|
|
49
49
|
| `aitk audits run` | Run every audit as one set, report per check under one verdict, and compare each count to the recorded baseline (`--json`, `--record`) |
|
|
50
50
|
| `aitk audits list` | List every audit the set runs, with the corpus each reads and whether it gates (`--json`) |
|
|
51
51
|
| `aitk capture [source]` | Render HTML capture sources to PNG, toolkit-only and absent from an installed package |
|
|
52
|
+
| `aitk upgrade` | Reinstall the CLI globally with the package manager the install path names (`--json`) |
|
|
52
53
|
|
|
53
54
|
## Domain commands
|
|
54
55
|
|
|
@@ -78,3 +79,17 @@ Common patterns:
|
|
|
78
79
|
- `install <name> <path>` → install a specific entry into a target project.
|
|
79
80
|
- `sync <path>` → reapply all installed entries in a target project.
|
|
80
81
|
- `create [name]` → scaffold a new authoring entry in this repo.
|
|
82
|
+
|
|
83
|
+
## Version skew
|
|
84
|
+
|
|
85
|
+
`aitk sync --check` and `aitk claude skills drift` are the two moments a target
|
|
86
|
+
already stops to reconcile with the toolkit, so each reports the installed
|
|
87
|
+
version against the newest published one. No other command performs the lookup,
|
|
88
|
+
which keeps a registry round trip out of the catalog reads an agent runs in a
|
|
89
|
+
loop.
|
|
90
|
+
|
|
91
|
+
The report carries three states and never changes an exit code. `behind` names
|
|
92
|
+
`aitk upgrade` as the remedy, `current` says so, and `unknown` carries the
|
|
93
|
+
reason the registry could not be reached. Branch on the `skew.state` field in
|
|
94
|
+
the JSON record rather than on the exit, since an offline machine has to read as
|
|
95
|
+
unmeasured rather than as a failing check.
|
package/docs/target-projects.md
CHANGED
|
@@ -114,6 +114,12 @@ When the toolkit updates, target projects pull changes per domain. There is one
|
|
|
114
114
|
|
|
115
115
|
`aitk sync --check <path>` reports what has drifted without writing anything. It splits each difference by cause, which is the question that decides what to do next.
|
|
116
116
|
|
|
117
|
+
#### The binary first
|
|
118
|
+
|
|
119
|
+
The report opens by naming the binary running it. The installed version reads against the newest published one, and a version behind that points you at `aitk upgrade`, since every section under it is a reading taken by whichever toolkit you happen to have. An unreachable registry reports unknown with its reason rather than failing, so `--exit-code` still gates on the drift the check measured locally and an offline machine stays green.
|
|
120
|
+
|
|
121
|
+
#### Then the causes
|
|
122
|
+
|
|
117
123
|
A `stale` file still matches what the toolkit installed, so the update is mechanical. A `customized` file carries local edits, so taking the upstream version is a decision and `aitk:claude-seed-sync` is the tool for it. A `stranded` file sits where an older toolkit installed it and the toolkit has since moved, which is what `aitk:migration-standards` handles.
|
|
118
124
|
|
|
119
125
|
That attribution comes from `.claude/aitk.json`, a stamp every install and sync writes. Standards, snippets, and governance record a hash per installed file. Tooling records the stack chain it resolved instead, since its install runs no per-file walk to attribute.
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
-
import { readFileSync } from 'node:fs'
|
|
4
|
-
import { join } from 'node:path'
|
|
5
3
|
import { Command } from 'commander'
|
|
6
4
|
import { register as init } from '@/commands/init'
|
|
7
5
|
import { register as sandbox } from '@/commands/sandbox'
|
|
@@ -28,7 +26,8 @@ import { register as markdown } from '@/commands/markdown'
|
|
|
28
26
|
import { register as records } from '@/commands/records'
|
|
29
27
|
import { register as sessions } from '@/commands/sessions'
|
|
30
28
|
import { register as audits } from '@/commands/audits'
|
|
31
|
-
import {
|
|
29
|
+
import { register as upgrade } from '@/commands/upgrade'
|
|
30
|
+
import { readInstalled, UNKNOWN_LABEL } from '@/version/installed'
|
|
32
31
|
|
|
33
32
|
const GREY = '\x1b[0;90m'
|
|
34
33
|
const WHITE = '\x1b[1;37m'
|
|
@@ -65,6 +64,7 @@ function showHelp(): void {
|
|
|
65
64
|
`${GREY}│${NC} records [cmd] ${GREY}# Session records under .claude/ (validate, push, pull)${NC}`,
|
|
66
65
|
`${GREY}│${NC} sessions [cmd] ${GREY}# Resolve live sessions to worktree and branch (list)${NC}`,
|
|
67
66
|
`${GREY}│${NC} audits [cmd] ${GREY}# Run every health check as one set (run, list)${NC}`,
|
|
67
|
+
`${GREY}│${NC} upgrade ${GREY}# Reinstall the CLI globally with the manager that installed it${NC}`,
|
|
68
68
|
`${GREY}│${NC}`,
|
|
69
69
|
`${GREY}│${NC} ${WHITE}Sandbox:${NC}`,
|
|
70
70
|
`${GREY}│${NC} aitk sandbox ${GREY}# Interactive scenario picker${NC}`,
|
|
@@ -104,31 +104,16 @@ function showHelp(): void {
|
|
|
104
104
|
`${GREY}│${NC} aitk records push --json`,
|
|
105
105
|
`${GREY}│${NC} aitk sessions list --json`,
|
|
106
106
|
`${GREY}│${NC} aitk audits run --json`,
|
|
107
|
+
`${GREY}│${NC} aitk upgrade --json`,
|
|
107
108
|
`${GREY}└${NC}`,
|
|
108
109
|
]
|
|
109
110
|
console.log(lines.join('\n'))
|
|
110
111
|
}
|
|
111
112
|
|
|
112
|
-
/**
|
|
113
|
-
* Read at runtime rather than inlined, because a literal here is a second place
|
|
114
|
-
* the version lives and it stopped tracking `package.json` at `0.1.0`. The
|
|
115
|
-
* release tool writes one file and this follows it. `package.json` ships in
|
|
116
|
-
* every npm tarball regardless of the `files` list, so the read resolves from a
|
|
117
|
-
* registry install as well as from a clone.
|
|
118
|
-
*/
|
|
119
|
-
function readVersion(): string {
|
|
120
|
-
try {
|
|
121
|
-
const raw = readFileSync(join(PROJECT_ROOT, 'package.json'), 'utf8')
|
|
122
|
-
return (JSON.parse(raw) as { version?: string }).version ?? 'unknown'
|
|
123
|
-
} catch {
|
|
124
|
-
return 'unknown'
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
113
|
const program = new Command()
|
|
129
114
|
program
|
|
130
115
|
.name('aitk')
|
|
131
|
-
.version(
|
|
116
|
+
.version(readInstalled().version ?? UNKNOWN_LABEL)
|
|
132
117
|
.enablePositionalOptions()
|
|
133
118
|
.helpOption(false)
|
|
134
119
|
program.action(() => showHelp())
|
|
@@ -163,5 +148,6 @@ markdown(program)
|
|
|
163
148
|
records(program)
|
|
164
149
|
sessions(program)
|
|
165
150
|
audits(program)
|
|
151
|
+
upgrade(program)
|
|
166
152
|
|
|
167
153
|
program.parse()
|
package/src/commands/claude.ts
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
plural,
|
|
49
49
|
select,
|
|
50
50
|
} from '@/ui'
|
|
51
|
+
import { describeSkew, readSkew, type SkewReport } from '@/version/skew'
|
|
51
52
|
|
|
52
53
|
const GREEN = '\x1b[0;32m'
|
|
53
54
|
const GREY = '\x1b[0;90m'
|
|
@@ -249,14 +250,18 @@ export function register(program: Command): void {
|
|
|
249
250
|
'copy. Passing a ref older than the oldest load over-reports, which is',
|
|
250
251
|
'the safe direction. Confirm a name by reading the body.',
|
|
251
252
|
'',
|
|
253
|
+
'Every run also reports the installed version against the newest',
|
|
254
|
+
'published one. That report never changes the exit code, so an offline',
|
|
255
|
+
'machine reads it as unknown rather than as a failure.',
|
|
256
|
+
'',
|
|
252
257
|
'Examples:',
|
|
253
258
|
' aitk claude skills drift HEAD~20',
|
|
254
259
|
' aitk claude skills drift 02d7b265 --json',
|
|
255
260
|
'',
|
|
256
261
|
].join('\n'),
|
|
257
262
|
)
|
|
258
|
-
.action((ref: string, opts: SkillsDriftOptions) => {
|
|
259
|
-
process.exitCode = runSkillsDrift(ref, opts)
|
|
263
|
+
.action(async (ref: string, opts: SkillsDriftOptions) => {
|
|
264
|
+
process.exitCode = await runSkillsDrift(ref, opts)
|
|
260
265
|
})
|
|
261
266
|
}
|
|
262
267
|
|
|
@@ -506,16 +511,28 @@ function runSkillsList(opts: SkillsListOptions): number {
|
|
|
506
511
|
* it answers every run with nothing moved, which is the silence this reports
|
|
507
512
|
* against.
|
|
508
513
|
*/
|
|
509
|
-
function runSkillsDrift(
|
|
514
|
+
async function runSkillsDrift(
|
|
515
|
+
ref: string,
|
|
516
|
+
opts: SkillsDriftOptions,
|
|
517
|
+
): Promise<number> {
|
|
510
518
|
const root = process.cwd()
|
|
511
519
|
const report = readDrift(root, ref)
|
|
520
|
+
const skew = await readSkew()
|
|
512
521
|
|
|
513
522
|
if (report.kind === 'measured') {
|
|
514
523
|
intro('aitk claude skills drift')
|
|
524
|
+
reportSkew(skew)
|
|
515
525
|
reportDrift(report, ref)
|
|
516
526
|
outro()
|
|
517
527
|
} else {
|
|
518
528
|
frameError(report.reason)
|
|
529
|
+
// The refusal path names the binary too. A project consuming the plugin
|
|
530
|
+
// from a marketplace cache is refused here for having no history, and that
|
|
531
|
+
// is the moment a skew warning is worth most, since an old binary is one
|
|
532
|
+
// reason the cache and the CLI disagree in the first place.
|
|
533
|
+
if (skew.state === 'behind') {
|
|
534
|
+
process.stderr.write(`${GREY}${describeSkew(skew)}${NC}\n`)
|
|
535
|
+
}
|
|
519
536
|
}
|
|
520
537
|
|
|
521
538
|
if (opts.json) {
|
|
@@ -528,8 +545,9 @@ function runSkillsDrift(ref: string, opts: SkillsDriftOptions): number {
|
|
|
528
545
|
base: report.base,
|
|
529
546
|
head: report.head,
|
|
530
547
|
moved: report.moved,
|
|
548
|
+
skew,
|
|
531
549
|
}
|
|
532
|
-
: { root, ref, unreadable: report.reason },
|
|
550
|
+
: { root, ref, unreadable: report.reason, skew },
|
|
533
551
|
)}\n`,
|
|
534
552
|
)
|
|
535
553
|
}
|
|
@@ -537,6 +555,18 @@ function runSkillsDrift(ref: string, opts: SkillsDriftOptions): number {
|
|
|
537
555
|
return report.kind === 'measured' ? 0 : 1
|
|
538
556
|
}
|
|
539
557
|
|
|
558
|
+
/**
|
|
559
|
+
* The binary reports before the range does, for the reason the range section
|
|
560
|
+
* states about itself: the command answers what changed on disk, and a binary
|
|
561
|
+
* behind the published one is a second way the tree a session reads differs
|
|
562
|
+
* from the tree it holds.
|
|
563
|
+
*/
|
|
564
|
+
function reportSkew(skew: SkewReport): void {
|
|
565
|
+
logStep('Toolkit version')
|
|
566
|
+
if (skew.state === 'behind') logWarn(describeSkew(skew))
|
|
567
|
+
else logInfo(describeSkew(skew))
|
|
568
|
+
}
|
|
569
|
+
|
|
540
570
|
/**
|
|
541
571
|
* States the bound on every run, including the run that names nothing. A report
|
|
542
572
|
* listing only what moved reads as a verdict on what a session holds, and the
|
package/src/commands/sync.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
logWarn,
|
|
31
31
|
outro,
|
|
32
32
|
} from '@/ui'
|
|
33
|
+
import { describeSkew } from '@/version/skew'
|
|
33
34
|
|
|
34
35
|
const GREY = '\x1b[0;90m'
|
|
35
36
|
const YELLOW = '\x1b[0;33m'
|
|
@@ -100,6 +101,8 @@ async function runCheck(target: string, options: SyncOptions): Promise<number> {
|
|
|
100
101
|
function renderCheck(report: CheckReport): void {
|
|
101
102
|
intro('aitk sync --check')
|
|
102
103
|
|
|
104
|
+
renderSkew(report)
|
|
105
|
+
|
|
103
106
|
if (!report.managed) {
|
|
104
107
|
logStep('Not a toolkit project')
|
|
105
108
|
logWarn('No .claude/ directory and no CLAUDE.md at the target.')
|
|
@@ -182,6 +185,24 @@ function renderCheck(report: CheckReport): void {
|
|
|
182
185
|
)
|
|
183
186
|
}
|
|
184
187
|
|
|
188
|
+
/**
|
|
189
|
+
* The binary reports before any domain does, since a stale binary is what makes
|
|
190
|
+
* every section below it a reading from the wrong toolkit. It prints on all
|
|
191
|
+
* three states rather than only when behind: a check that goes quiet when the
|
|
192
|
+
* registry is unreachable is indistinguishable from one that found nothing.
|
|
193
|
+
*/
|
|
194
|
+
function renderSkew(report: CheckReport): void {
|
|
195
|
+
const { skew } = report
|
|
196
|
+
logStep('toolkit version')
|
|
197
|
+
|
|
198
|
+
if (skew.state === 'behind') {
|
|
199
|
+
logWarn(describeSkew(skew))
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
logInfo(describeSkew(skew))
|
|
204
|
+
}
|
|
205
|
+
|
|
185
206
|
/**
|
|
186
207
|
* Tooling prints whether it was measured before it prints any count, because a
|
|
187
208
|
* target with no chain recorded produces the same zero a current target does.
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import type { Command } from 'commander'
|
|
2
|
+
import { execa } from 'execa'
|
|
3
|
+
import { PROJECT_ROOT } from '@/project-root'
|
|
4
|
+
import {
|
|
5
|
+
frameError,
|
|
6
|
+
intro,
|
|
7
|
+
logInfo,
|
|
8
|
+
logStep,
|
|
9
|
+
logWarn,
|
|
10
|
+
outro,
|
|
11
|
+
select,
|
|
12
|
+
} from '@/ui'
|
|
13
|
+
import { readInstalled, UNKNOWN_LABEL } from '@/version/installed'
|
|
14
|
+
import { detectManager, installCommand, type Manager } from '@/version/manager'
|
|
15
|
+
import {
|
|
16
|
+
describeSkew,
|
|
17
|
+
latestOf,
|
|
18
|
+
readSkew,
|
|
19
|
+
type SkewReport,
|
|
20
|
+
} from '@/version/skew'
|
|
21
|
+
|
|
22
|
+
interface UpgradeOptions {
|
|
23
|
+
readonly json?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface UpgradeRecord {
|
|
27
|
+
readonly root: string
|
|
28
|
+
readonly manager?: string
|
|
29
|
+
readonly command?: string
|
|
30
|
+
readonly before: string
|
|
31
|
+
readonly after?: string
|
|
32
|
+
readonly latest?: string
|
|
33
|
+
readonly state: 'upgraded' | 'current' | 'cancelled' | 'refused'
|
|
34
|
+
readonly reason?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function register(program: Command): void {
|
|
38
|
+
program
|
|
39
|
+
.command('upgrade')
|
|
40
|
+
.description(
|
|
41
|
+
'Reinstall the CLI globally with whichever package manager installed it',
|
|
42
|
+
)
|
|
43
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
44
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
45
|
+
.addHelpText(
|
|
46
|
+
'after',
|
|
47
|
+
[
|
|
48
|
+
'',
|
|
49
|
+
'Exit codes:',
|
|
50
|
+
' 0 the binary is current, the reinstall completed, or it was cancelled',
|
|
51
|
+
' 1 refused or the reinstall failed, with the reason on stderr',
|
|
52
|
+
'',
|
|
53
|
+
'The package manager is read off the install path rather than guessed',
|
|
54
|
+
'from PATH, and named before anything runs. A source checkout matches no',
|
|
55
|
+
'install tree and is refused rather than reinstalled over.',
|
|
56
|
+
'',
|
|
57
|
+
'Examples:',
|
|
58
|
+
' aitk upgrade',
|
|
59
|
+
' aitk upgrade --json',
|
|
60
|
+
'',
|
|
61
|
+
].join('\n'),
|
|
62
|
+
)
|
|
63
|
+
.action(async (opts: UpgradeOptions) => {
|
|
64
|
+
process.exitCode = await runUpgrade(opts)
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Detection runs before the registry lookup so the one case that cannot upgrade
|
|
70
|
+
* at all, a source checkout, refuses without waiting on a network call whose
|
|
71
|
+
* answer it would then discard.
|
|
72
|
+
*/
|
|
73
|
+
async function runUpgrade(opts: UpgradeOptions): Promise<number> {
|
|
74
|
+
const installed = readInstalled()
|
|
75
|
+
const before = installed.version ?? UNKNOWN_LABEL
|
|
76
|
+
|
|
77
|
+
intro('aitk upgrade')
|
|
78
|
+
logStep('Installed')
|
|
79
|
+
logInfo(`${installed.name ?? UNKNOWN_LABEL} ${before}`)
|
|
80
|
+
logInfo(PROJECT_ROOT)
|
|
81
|
+
|
|
82
|
+
const manager = detectManager(PROJECT_ROOT)
|
|
83
|
+
if (manager === undefined) {
|
|
84
|
+
return refuse(
|
|
85
|
+
opts,
|
|
86
|
+
before,
|
|
87
|
+
`No package manager owns ${PROJECT_ROOT}. A source checkout is upgraded by pulling, not by reinstalling over it.`,
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// A manifest that did not parse, or carried no `name`, would otherwise reach
|
|
92
|
+
// `installCommand` and produce a global install of whatever sits under that
|
|
93
|
+
// placeholder on the registry. The prompt below defaults to yes headlessly,
|
|
94
|
+
// so nothing downstream would stop it.
|
|
95
|
+
if (installed.name === undefined) {
|
|
96
|
+
return refuse(
|
|
97
|
+
opts,
|
|
98
|
+
before,
|
|
99
|
+
`No package name in ${PROJECT_ROOT}/package.json, so there is nothing safe to reinstall. Repair the manifest, or reinstall by name yourself.`,
|
|
100
|
+
manager,
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const command = installCommand(manager.id, installed.name)
|
|
105
|
+
logStep('Detected')
|
|
106
|
+
logInfo(`${manager.id}, from the \`${manager.evidence}\` path segment`)
|
|
107
|
+
logInfo(command.join(' '))
|
|
108
|
+
|
|
109
|
+
const skew = await readSkew({ installed })
|
|
110
|
+
logStep('Published')
|
|
111
|
+
logInfo(describeSkew(skew))
|
|
112
|
+
|
|
113
|
+
if (skew.state === 'current') {
|
|
114
|
+
outro()
|
|
115
|
+
emit(opts, {
|
|
116
|
+
...base(before, manager, command, skew),
|
|
117
|
+
after: before,
|
|
118
|
+
state: 'current',
|
|
119
|
+
})
|
|
120
|
+
return 0
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return await applyUpgrade(opts, before, skew, manager, command)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Runs the reinstall and reads the version back off disk rather than trusting
|
|
128
|
+
* the manager's own report, since each spells success differently and one of
|
|
129
|
+
* them exits zero on a no-op. The read is the same `package.json` the CLI names
|
|
130
|
+
* on `--version`, which the install has overwritten by this point.
|
|
131
|
+
*
|
|
132
|
+
* An `unknown` skew reaches here rather than stopping. The operator asked for
|
|
133
|
+
* the reinstall, and the manager reports its own network failure in terms the
|
|
134
|
+
* dist-tag endpoint cannot.
|
|
135
|
+
*/
|
|
136
|
+
async function applyUpgrade(
|
|
137
|
+
opts: UpgradeOptions,
|
|
138
|
+
before: string,
|
|
139
|
+
skew: SkewReport,
|
|
140
|
+
manager: Manager,
|
|
141
|
+
command: readonly string[],
|
|
142
|
+
): Promise<number> {
|
|
143
|
+
const proceed = await select({
|
|
144
|
+
message: `Run \`${command.join(' ')}\`?`,
|
|
145
|
+
options: [
|
|
146
|
+
{ value: true, label: 'Upgrade' },
|
|
147
|
+
{ value: false, label: 'Cancel' },
|
|
148
|
+
],
|
|
149
|
+
nonInteractiveDefault: true,
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
if (!proceed) {
|
|
153
|
+
logWarn('Cancelled')
|
|
154
|
+
outro()
|
|
155
|
+
emit(opts, { ...base(before, manager, command, skew), state: 'cancelled' })
|
|
156
|
+
return 0
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
logStep('Upgrading')
|
|
160
|
+
const [bin, ...args] = command
|
|
161
|
+
// The manager's own progress goes to stderr with the rest of the UI, leaving
|
|
162
|
+
// stdout carrying nothing but the record. Inheriting all three streams would
|
|
163
|
+
// put npm's output ahead of the JSON and break every wrapper parsing it.
|
|
164
|
+
const result = await execa(bin, args, {
|
|
165
|
+
reject: false,
|
|
166
|
+
stdin: 'inherit',
|
|
167
|
+
stdout: process.stderr,
|
|
168
|
+
stderr: 'inherit',
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
if (result.exitCode !== 0) {
|
|
172
|
+
return refuse(
|
|
173
|
+
opts,
|
|
174
|
+
before,
|
|
175
|
+
`\`${command.join(' ')}\` exited ${result.exitCode}. Run it yourself to read what it reported.`,
|
|
176
|
+
manager,
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const after = readInstalled().version ?? UNKNOWN_LABEL
|
|
181
|
+
logStep('Installed')
|
|
182
|
+
logInfo(after === before ? `${after}, unchanged` : `${before} to ${after}`)
|
|
183
|
+
outro()
|
|
184
|
+
|
|
185
|
+
emit(opts, {
|
|
186
|
+
...base(before, manager, command, skew),
|
|
187
|
+
after,
|
|
188
|
+
state: 'upgraded',
|
|
189
|
+
})
|
|
190
|
+
return 0
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function base(
|
|
194
|
+
before: string,
|
|
195
|
+
manager: Manager,
|
|
196
|
+
command: readonly string[],
|
|
197
|
+
skew: SkewReport,
|
|
198
|
+
): Omit<UpgradeRecord, 'state'> {
|
|
199
|
+
const latest = latestOf(skew)
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
root: PROJECT_ROOT,
|
|
203
|
+
manager: manager.id,
|
|
204
|
+
command: command.join(' '),
|
|
205
|
+
before,
|
|
206
|
+
...(latest === undefined ? {} : { latest }),
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function refuse(
|
|
211
|
+
opts: UpgradeOptions,
|
|
212
|
+
before: string,
|
|
213
|
+
reason: string,
|
|
214
|
+
manager?: Manager,
|
|
215
|
+
): number {
|
|
216
|
+
outro()
|
|
217
|
+
frameError(reason)
|
|
218
|
+
emit(opts, {
|
|
219
|
+
root: PROJECT_ROOT,
|
|
220
|
+
...(manager === undefined ? {} : { manager: manager.id }),
|
|
221
|
+
before,
|
|
222
|
+
state: 'refused',
|
|
223
|
+
reason,
|
|
224
|
+
})
|
|
225
|
+
return 1
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function emit(opts: UpgradeOptions, record: UpgradeRecord): void {
|
|
229
|
+
if (opts.json !== true) return
|
|
230
|
+
process.stdout.write(`${JSON.stringify(record)}\n`)
|
|
231
|
+
}
|
package/src/sync/check.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { createStandardsAdapter } from '@/standards/adapter'
|
|
|
27
27
|
import { isDirectory } from '@/target'
|
|
28
28
|
import { loadManifest } from '@/tooling/manifest'
|
|
29
29
|
import { scan } from '@/tooling/scan'
|
|
30
|
+
import { readSkew, type SkewReport } from '@/version/skew'
|
|
30
31
|
|
|
31
32
|
/**
|
|
32
33
|
* Domains the sync engine walks file by file. Tooling is a stamp domain without
|
|
@@ -163,6 +164,17 @@ export interface CheckReport {
|
|
|
163
164
|
* the same question correctly. See `@/sync/reverse`.
|
|
164
165
|
*/
|
|
165
166
|
readonly reverse: ReverseReport
|
|
167
|
+
/**
|
|
168
|
+
* The binary running the check, not the target. It reports on an unmanaged
|
|
169
|
+
* target too, since a reader told to run `aitk init` is better off knowing
|
|
170
|
+
* first whether the binary about to install is the current one.
|
|
171
|
+
*
|
|
172
|
+
* `hasDrift` deliberately ignores it. A registry lookup inside a check that
|
|
173
|
+
* gates would fail CI on an offline machine for a condition the check never
|
|
174
|
+
* measured, and the state reaching the reader is the point rather than the
|
|
175
|
+
* exit code.
|
|
176
|
+
*/
|
|
177
|
+
readonly skew: SkewReport
|
|
166
178
|
}
|
|
167
179
|
|
|
168
180
|
export function installedStampDomains(target: string): ScannedDomain[] {
|
|
@@ -300,6 +312,10 @@ export async function buildCheckReport(
|
|
|
300
312
|
): Promise<CheckReport> {
|
|
301
313
|
const stamp = readStamp(target)
|
|
302
314
|
|
|
315
|
+
// Started before the local scan and awaited after it, so the network wait
|
|
316
|
+
// overlaps work the report needs anyway rather than adding to it.
|
|
317
|
+
const skewRead = readSkew()
|
|
318
|
+
|
|
303
319
|
const domains = await Promise.all(
|
|
304
320
|
installedStampDomains(target).map((domain) =>
|
|
305
321
|
buildDomainReport(toolkitRoot, target, stamp, domain),
|
|
@@ -324,6 +340,7 @@ export async function buildCheckReport(
|
|
|
324
340
|
unmigrated: [],
|
|
325
341
|
newSkills: [],
|
|
326
342
|
reverse: emptyReverseReport(),
|
|
343
|
+
skew: await skewRead,
|
|
327
344
|
}
|
|
328
345
|
}
|
|
329
346
|
|
|
@@ -337,6 +354,7 @@ export async function buildCheckReport(
|
|
|
337
354
|
unmigrated,
|
|
338
355
|
newSkills: await readNewSkills(toolkitRoot, anchors),
|
|
339
356
|
reverse: buildReverseReport(toolkitRoot, target),
|
|
357
|
+
skew: await skewRead,
|
|
340
358
|
}
|
|
341
359
|
}
|
|
342
360
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `major.minor.patch` core with an optional prerelease tail. The registry
|
|
3
|
+
* publishes both shapes under the same dist tag, so a comparison that only
|
|
4
|
+
* understood the core would read `1.0.0-rc.1` as unparseable and report the
|
|
5
|
+
* whole lookup as unknown.
|
|
6
|
+
*/
|
|
7
|
+
const VERSION =
|
|
8
|
+
/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/
|
|
9
|
+
|
|
10
|
+
export interface ParsedVersion {
|
|
11
|
+
readonly major: number
|
|
12
|
+
readonly minor: number
|
|
13
|
+
readonly patch: number
|
|
14
|
+
/** Absent on a release, which sorts above every prerelease of the same core. */
|
|
15
|
+
readonly prerelease?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function parseVersion(raw: string): ParsedVersion | undefined {
|
|
19
|
+
const match = VERSION.exec(raw.trim())
|
|
20
|
+
if (match === null) return undefined
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
major: Number(match[1]),
|
|
24
|
+
minor: Number(match[2]),
|
|
25
|
+
patch: Number(match[3]),
|
|
26
|
+
...(match[4] === undefined ? {} : { prerelease: match[4] }),
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Negative when `left` is older, positive when it is newer, zero when the two
|
|
32
|
+
* name the same version.
|
|
33
|
+
*
|
|
34
|
+
* Prerelease identifiers compare as whole strings rather than dot segment by
|
|
35
|
+
* dot segment, which is narrower than semver states. Every version this repo
|
|
36
|
+
* publishes is a plain core, so the ordering inside a prerelease series decides
|
|
37
|
+
* nothing here, and the one comparison that matters is that any prerelease
|
|
38
|
+
* sorts below the release sharing its core.
|
|
39
|
+
*/
|
|
40
|
+
export function compareVersions(
|
|
41
|
+
left: ParsedVersion,
|
|
42
|
+
right: ParsedVersion,
|
|
43
|
+
): number {
|
|
44
|
+
if (left.major !== right.major) return left.major - right.major
|
|
45
|
+
if (left.minor !== right.minor) return left.minor - right.minor
|
|
46
|
+
if (left.patch !== right.patch) return left.patch - right.patch
|
|
47
|
+
|
|
48
|
+
if (left.prerelease === right.prerelease) return 0
|
|
49
|
+
if (left.prerelease === undefined) return 1
|
|
50
|
+
if (right.prerelease === undefined) return -1
|
|
51
|
+
|
|
52
|
+
return left.prerelease < right.prerelease ? -1 : 1
|
|
53
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { PROJECT_ROOT } from '@/project-root'
|
|
4
|
+
|
|
5
|
+
/** What a reader prints in place of a field the manifest did not carry. */
|
|
6
|
+
export const UNKNOWN_LABEL = 'unknown'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Either field is absent when the manifest could not be read or did not carry
|
|
10
|
+
* it. They are optional rather than sentinel strings so a caller has to narrow
|
|
11
|
+
* before using one, which is what makes a name the manifest never supplied
|
|
12
|
+
* impossible to interpolate into a shell command. A sentinel typed `string`
|
|
13
|
+
* reads as an ordinary value at every call site and hides that case.
|
|
14
|
+
*/
|
|
15
|
+
export interface InstalledPackage {
|
|
16
|
+
readonly name?: string
|
|
17
|
+
readonly version?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Read at runtime rather than inlined, because a literal is a second place the
|
|
22
|
+
* version lives and it stopped tracking `package.json` at `0.1.0`. The release
|
|
23
|
+
* tool writes one file and this follows it. `package.json` ships in every npm
|
|
24
|
+
* tarball regardless of the `files` list, so the read resolves from a registry
|
|
25
|
+
* install as well as from a clone.
|
|
26
|
+
*
|
|
27
|
+
* This sits apart from the skew read so `src/cli.ts` can name the version on
|
|
28
|
+
* every invocation without pulling the registry lookup into the startup import
|
|
29
|
+
* graph of a CLI that compiles nothing ahead of time.
|
|
30
|
+
*/
|
|
31
|
+
export function readInstalled(root: string = PROJECT_ROOT): InstalledPackage {
|
|
32
|
+
try {
|
|
33
|
+
const raw = readFileSync(join(root, 'package.json'), 'utf8')
|
|
34
|
+
const parsed = JSON.parse(raw) as { name?: string; version?: string }
|
|
35
|
+
|
|
36
|
+
return { name: parsed.name, version: parsed.version }
|
|
37
|
+
} catch {
|
|
38
|
+
return {}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export type ManagerId = 'bun' | 'pnpm' | 'yarn' | 'npm'
|
|
2
|
+
|
|
3
|
+
export interface Manager {
|
|
4
|
+
readonly id: ManagerId
|
|
5
|
+
/** The path segment the detection matched, so a wrong read is correctable. */
|
|
6
|
+
readonly evidence: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Segments that only appear in one manager's global install tree, checked
|
|
11
|
+
* before the `node_modules` fallback because every one of these trees contains
|
|
12
|
+
* a `node_modules` too.
|
|
13
|
+
*/
|
|
14
|
+
const SIGNATURES: readonly (readonly [ManagerId, string])[] = [
|
|
15
|
+
['bun', '.bun'],
|
|
16
|
+
['pnpm', 'pnpm'],
|
|
17
|
+
['pnpm', '.pnpm'],
|
|
18
|
+
['yarn', 'yarn'],
|
|
19
|
+
['yarn', '.yarn'],
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Which package manager installed the package rooted at `root`, read off the
|
|
24
|
+
* install path rather than guessed from what is on `PATH`.
|
|
25
|
+
*
|
|
26
|
+
* Removing the guess is the whole case for the upgrade verb, so a detection
|
|
27
|
+
* that cannot be read back is worth no more than the guess it replaced. The
|
|
28
|
+
* evidence travels with the answer and the verb prints it before running
|
|
29
|
+
* anything, which lets an operator correct a wrong read without the detection
|
|
30
|
+
* having to be right every time.
|
|
31
|
+
*
|
|
32
|
+
* Returns `undefined` for a path outside any install tree, which is a source
|
|
33
|
+
* checkout. That is not a case to guess at either: reinstalling over a clone
|
|
34
|
+
* would replace what the operator is working in.
|
|
35
|
+
*/
|
|
36
|
+
export function detectManager(root: string): Manager | undefined {
|
|
37
|
+
const segments = root.split(/[/\\]/).filter((segment) => segment !== '')
|
|
38
|
+
|
|
39
|
+
for (const [id, signature] of SIGNATURES) {
|
|
40
|
+
if (segments.includes(signature)) return { id, evidence: signature }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (segments.includes('node_modules')) {
|
|
44
|
+
return { id: 'npm', evidence: 'node_modules' }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return undefined
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The global reinstall each manager spells, pinned to the newest published. */
|
|
51
|
+
export function installCommand(
|
|
52
|
+
manager: ManagerId,
|
|
53
|
+
name: string,
|
|
54
|
+
): readonly string[] {
|
|
55
|
+
const spec = `${name}@latest`
|
|
56
|
+
|
|
57
|
+
switch (manager) {
|
|
58
|
+
case 'bun':
|
|
59
|
+
return ['bun', 'add', '--global', spec]
|
|
60
|
+
case 'pnpm':
|
|
61
|
+
return ['pnpm', 'add', '--global', spec]
|
|
62
|
+
case 'yarn':
|
|
63
|
+
return ['yarn', 'global', 'add', spec]
|
|
64
|
+
case 'npm':
|
|
65
|
+
return ['npm', 'install', '--global', spec]
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { PROJECT_ROOT } from '@/project-root'
|
|
2
|
+
import { compareVersions, parseVersion } from '@/version/compare'
|
|
3
|
+
import {
|
|
4
|
+
type InstalledPackage,
|
|
5
|
+
readInstalled,
|
|
6
|
+
UNKNOWN_LABEL,
|
|
7
|
+
} from '@/version/installed'
|
|
8
|
+
import { detectManager } from '@/version/manager'
|
|
9
|
+
|
|
10
|
+
const REGISTRY = 'https://registry.npmjs.org'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Short enough that a check waiting on a dead network still returns inside the
|
|
14
|
+
* time an operator would give the command anyway. The skew line is one section
|
|
15
|
+
* of a report the rest of which needs no network at all, so the budget is set
|
|
16
|
+
* against how long the report may be held up rather than against how long the
|
|
17
|
+
* registry usually takes.
|
|
18
|
+
*/
|
|
19
|
+
const LOOKUP_TIMEOUT_MS = 3_000
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Three states and no fourth. `unknown` covers every way the question could not
|
|
23
|
+
* be answered and carries the reason, which is what a caller reports instead of
|
|
24
|
+
* a version.
|
|
25
|
+
*
|
|
26
|
+
* An installed version ahead of the published one reports `current`. That is a
|
|
27
|
+
* source checkout between a release commit and the publish job, or a local
|
|
28
|
+
* build, and neither is skew. Giving it a state of its own would fire a warning
|
|
29
|
+
* on every maintainer run for a condition with no remedy.
|
|
30
|
+
*/
|
|
31
|
+
export type SkewState = 'current' | 'behind' | 'unknown'
|
|
32
|
+
|
|
33
|
+
interface SkewBase {
|
|
34
|
+
readonly name: string
|
|
35
|
+
readonly installed: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A union rather than one shape with two optional fields, so a `behind` report
|
|
40
|
+
* cannot exist without the version it is behind and an `unknown` one cannot
|
|
41
|
+
* exist without its reason. Both are what `describeSkew` renders into a line an
|
|
42
|
+
* operator reads, and an optional field renders the word `undefined` there.
|
|
43
|
+
*/
|
|
44
|
+
export type SkewReport =
|
|
45
|
+
| (SkewBase & {
|
|
46
|
+
readonly state: 'current' | 'behind'
|
|
47
|
+
readonly latest: string
|
|
48
|
+
})
|
|
49
|
+
| (SkewBase & { readonly state: 'unknown'; readonly reason: string })
|
|
50
|
+
|
|
51
|
+
/** Resolves the newest published version, or throws for `readSkew` to absorb. */
|
|
52
|
+
export type LatestLookup = (name: string) => Promise<string>
|
|
53
|
+
|
|
54
|
+
export interface SkewOptions {
|
|
55
|
+
readonly installed?: InstalledPackage
|
|
56
|
+
readonly lookup?: LatestLookup
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The installed version against the newest published one.
|
|
61
|
+
*
|
|
62
|
+
* Never rejects and never reports through an exit code. `aitk sync --check
|
|
63
|
+
* --exit-code` gates CI on drift it measured locally, so a lookup that failed
|
|
64
|
+
* the caller would turn an offline machine into a failing check and the check
|
|
65
|
+
* would be routed around. Every failure lands in `unknown` with its reason.
|
|
66
|
+
*/
|
|
67
|
+
export async function readSkew(options: SkewOptions = {}): Promise<SkewReport> {
|
|
68
|
+
const installed = options.installed ?? readInstalled()
|
|
69
|
+
const lookup = options.lookup ?? fetchLatest
|
|
70
|
+
const { name, version } = installed
|
|
71
|
+
|
|
72
|
+
if (version === undefined) {
|
|
73
|
+
return unknown(
|
|
74
|
+
installed,
|
|
75
|
+
'No version in the package manifest, so there is nothing to compare.',
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (name === undefined) {
|
|
80
|
+
return unknown(
|
|
81
|
+
installed,
|
|
82
|
+
'No name in the package manifest, so the registry has nothing to look up.',
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const local = parseVersion(version)
|
|
87
|
+
if (local === undefined) {
|
|
88
|
+
return unknown(
|
|
89
|
+
installed,
|
|
90
|
+
`Installed version ${version} is not a version this can parse.`,
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let raw: string
|
|
95
|
+
try {
|
|
96
|
+
raw = await lookup(name)
|
|
97
|
+
} catch (error) {
|
|
98
|
+
return unknown(installed, `Registry lookup failed: ${describe(error)}`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const published = parseVersion(raw)
|
|
102
|
+
if (published === undefined) {
|
|
103
|
+
return unknown(
|
|
104
|
+
installed,
|
|
105
|
+
`Registry reported ${raw} as the newest version, which is not a version this can parse.`,
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
state: compareVersions(local, published) < 0 ? 'behind' : 'current',
|
|
111
|
+
name,
|
|
112
|
+
installed: version,
|
|
113
|
+
latest: raw,
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function unknown(installed: InstalledPackage, reason: string): SkewReport {
|
|
118
|
+
return {
|
|
119
|
+
state: 'unknown',
|
|
120
|
+
name: installed.name ?? UNKNOWN_LABEL,
|
|
121
|
+
installed: installed.version ?? UNKNOWN_LABEL,
|
|
122
|
+
reason,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The newest published version, or `undefined` when it could not be read. */
|
|
127
|
+
export function latestOf(report: SkewReport): string | undefined {
|
|
128
|
+
return report.state === 'unknown' ? undefined : report.latest
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function describe(error: unknown): string {
|
|
132
|
+
return error instanceof Error ? error.message : String(error)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The dist-tag endpoint rather than the full packument, which for this package
|
|
137
|
+
* carries every published manifest and is the larger part of a megabyte. The
|
|
138
|
+
* question is one string and this is the endpoint that answers only it.
|
|
139
|
+
*/
|
|
140
|
+
async function fetchLatest(name: string): Promise<string> {
|
|
141
|
+
const response = await fetch(
|
|
142
|
+
`${REGISTRY}/-/package/${encodeURIComponent(name)}/dist-tags`,
|
|
143
|
+
{
|
|
144
|
+
signal: AbortSignal.timeout(LOOKUP_TIMEOUT_MS),
|
|
145
|
+
headers: { accept: 'application/json' },
|
|
146
|
+
},
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
if (!response.ok) {
|
|
150
|
+
throw new Error(`registry returned ${response.status}`)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const tags = (await response.json()) as Record<string, unknown>
|
|
154
|
+
const latest = tags.latest
|
|
155
|
+
|
|
156
|
+
if (typeof latest !== 'string') {
|
|
157
|
+
throw new Error('registry reported no latest dist-tag')
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return latest
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* One line naming the state, for a caller that renders the skew beside sections
|
|
165
|
+
* it does not own. Held here so `aitk sync --check` and `aitk claude skills
|
|
166
|
+
* drift` cannot word the same three states differently.
|
|
167
|
+
*
|
|
168
|
+
* The remedy is chosen by the same detection `aitk upgrade` runs, because both
|
|
169
|
+
* callers run from a source checkout routinely and that is where the verb
|
|
170
|
+
* refuses. Naming it unconditionally sends a contributor whose clone sits a
|
|
171
|
+
* release behind to a command that declines. The read is a match against the
|
|
172
|
+
* root string rather than a filesystem call, so the line stays cheap.
|
|
173
|
+
*/
|
|
174
|
+
export function describeSkew(
|
|
175
|
+
report: SkewReport,
|
|
176
|
+
root: string = PROJECT_ROOT,
|
|
177
|
+
): string {
|
|
178
|
+
if (report.state === 'unknown') {
|
|
179
|
+
return `Installed ${report.installed}, published unknown. ${report.reason}`
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (report.state === 'current') {
|
|
183
|
+
return `Installed ${report.installed}, which is the newest published.`
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const remedy =
|
|
187
|
+
detectManager(root) === undefined
|
|
188
|
+
? 'This is a source checkout, so pull rather than reinstalling.'
|
|
189
|
+
: 'Run `aitk upgrade`.'
|
|
190
|
+
|
|
191
|
+
return `Installed ${report.installed}, published ${report.latest}. ${remedy}`
|
|
192
|
+
}
|