@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.
@@ -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
+ }