@erclx/aitk 0.109.0 → 0.111.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 +5 -1
- package/claude/.claude-plugin/plugin.json +1 -1
- package/docs/agents/audits.md +88 -0
- package/docs/agents/commands.md +20 -2
- package/docs/agents/index.md +1 -0
- package/docs/agents/scripting.md +13 -9
- package/docs/target-projects.md +6 -0
- package/package.json +1 -1
- package/scripts/core/verify.sh +74 -6
- package/src/audits/baseline.ts +194 -0
- package/src/audits/catalog.ts +464 -0
- package/src/audits/run.ts +202 -0
- package/src/cli.ts +10 -20
- package/src/commands/audits.ts +342 -0
- 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
|
@@ -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
|
+
}
|