agnostic-ai 0.63.0 → 0.64.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 +4 -2
- package/bin/agnostic-ai.js +70 -5
- package/lib/platforms.js +64 -0
- package/package.json +12 -15
- package/lib/download.js +0 -266
- package/lib/download_test.js +0 -325
- package/scripts/postinstall.js +0 -12
package/README.md
CHANGED
|
@@ -27,9 +27,11 @@ curl -fsSL https://raw.githubusercontent.com/Chemaclass/agnostic-ai/main/scripts
|
|
|
27
27
|
|
|
28
28
|
Windows, Go, and manual download are covered in [all install options](https://agnostic-ai.org/docs/installation/).
|
|
29
29
|
|
|
30
|
-
This package is a thin wrapper
|
|
30
|
+
This package is a thin wrapper around the prebuilt Go binary. The binary ships inside a platform package (`@agnostic-ai/darwin-arm64` and five siblings), declared as optional dependencies. npm reads each one's `os` and `cpu` and installs only the one that matches your machine. Supported platforms are macOS, Linux, and Windows on x64 and arm64.
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
Nothing is downloaded and no install script runs, so the package works under `--ignore-scripts`, behind a proxy, and from an offline npm mirror. To pin a version, pin the package: `npm install -g agnostic-ai@<version>`.
|
|
33
|
+
|
|
34
|
+
Set `AGNOSTIC_AI_BINARY` to an absolute path to run a binary this package does not ship, such as one you built yourself.
|
|
33
35
|
|
|
34
36
|
Full docs, targets, and configuration: [agnostic-ai.org](https://agnostic-ai.org).
|
|
35
37
|
|
package/bin/agnostic-ai.js
CHANGED
|
@@ -3,14 +3,79 @@
|
|
|
3
3
|
|
|
4
4
|
const { spawnSync } = require('node:child_process')
|
|
5
5
|
const os = require('node:os')
|
|
6
|
-
const
|
|
6
|
+
const path = require('node:path')
|
|
7
|
+
const { entryPoint, packageName, platformFor } = require('../lib/platforms')
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
const DOCS = 'https://agnostic-ai.org/docs/installation/'
|
|
10
|
+
|
|
11
|
+
// A global install resolves its dependencies from the global tree, a project
|
|
12
|
+
// install from the project's, and `npm install` without `-g` only ever writes
|
|
13
|
+
// the second. Telling a globally installed CLI to run the project command
|
|
14
|
+
// leaves it exactly as broken as it was, so the repair hint has to know which
|
|
15
|
+
// tree it is sitting in.
|
|
16
|
+
//
|
|
17
|
+
// npm puts a global package under its own prefix: `<prefix>/lib/node_modules`
|
|
18
|
+
// on macOS and Linux, `<prefix>\node_modules` on Windows, where <prefix> is
|
|
19
|
+
// npm's own directory (commonly `.../npm`). A project install sits in a
|
|
20
|
+
// `node_modules` whose parent is the project. Unrecognised layouts fall back
|
|
21
|
+
// to the project command, which is the common case, and the message names the
|
|
22
|
+
// other form either way.
|
|
23
|
+
function isGlobalInstall(dir) {
|
|
24
|
+
const parts = path.resolve(dir).split(path.sep)
|
|
25
|
+
const i = parts.lastIndexOf('node_modules')
|
|
26
|
+
if (i < 1) return false
|
|
27
|
+
return parts[i - 1] === 'lib' || parts[i - 1] === 'npm'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The binary ships inside a platform package that npm installed as an optional
|
|
31
|
+
// dependency, so finding it is a module lookup, not a download. Nothing here
|
|
32
|
+
// touches the network.
|
|
33
|
+
function resolveBinary() {
|
|
34
|
+
// Escape hatch for a binary this package does not ship: a local build, an
|
|
35
|
+
// unsupported CPU, an air-gapped machine that already has one on disk.
|
|
36
|
+
const override = process.env.AGNOSTIC_AI_BINARY
|
|
37
|
+
if (override) return override
|
|
38
|
+
|
|
39
|
+
const platform = platformFor(process.platform, process.arch)
|
|
40
|
+
if (!platform) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`no prebuilt binary for ${process.platform}/${process.arch}. Build one with ` +
|
|
43
|
+
'`go install github.com/chemaclass/agnostic-ai/cmd/agnostic-ai@latest` and point ' +
|
|
44
|
+
`AGNOSTIC_AI_BINARY at it, or pick another route: ${DOCS}`
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
return require.resolve(entryPoint(platform))
|
|
50
|
+
} catch {
|
|
51
|
+
// npm reuses a lockfile written on another platform without re-resolving
|
|
52
|
+
// optional dependencies, which leaves the matching package absent
|
|
53
|
+
// (npm/cli#4828). `--no-optional` and `--omit=optional` do the same on
|
|
54
|
+
// purpose. Both look like a successful install until the CLI runs.
|
|
55
|
+
//
|
|
56
|
+
// --include=optional is not decoration: the second cause is an
|
|
57
|
+
// `omit=optional` sitting in the user's npm config, and a plain reinstall
|
|
58
|
+
// obeys it and skips the package again.
|
|
59
|
+
const isGlobal = isGlobalInstall(__dirname)
|
|
60
|
+
const scope = isGlobal ? 'installed globally' : 'installed in this project'
|
|
61
|
+
const fix = isGlobal
|
|
62
|
+
? 'npm install -g agnostic-ai --force --include=optional'
|
|
63
|
+
: 'npm install agnostic-ai --force --include=optional'
|
|
64
|
+
const other = isGlobal ? 'drop -g for a project install' : 'add -g for a global install'
|
|
65
|
+
throw new Error(
|
|
66
|
+
`${packageName(platform)} is not installed, so there is no binary to run. npm installs ` +
|
|
67
|
+
'it as an optional dependency; a lockfile copied from another platform or an install ' +
|
|
68
|
+
`run with --omit=optional skips it. This copy is ${scope}, so reinstall with ` +
|
|
69
|
+
`\`${fix}\` (${other}), or set AGNOSTIC_AI_BINARY to a binary you already have. ` +
|
|
70
|
+
`Other routes: ${DOCS}`
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function main() {
|
|
9
76
|
let binary
|
|
10
77
|
try {
|
|
11
|
-
|
|
12
|
-
// ran with --ignore-scripts or without network.
|
|
13
|
-
binary = await ensureBinary({ log: (m) => console.error(`agnostic-ai: ${m}`) })
|
|
78
|
+
binary = resolveBinary()
|
|
14
79
|
} catch (err) {
|
|
15
80
|
console.error(`agnostic-ai: ${err.message}`)
|
|
16
81
|
process.exit(1)
|
package/lib/platforms.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// The one table the whole npm distribution reads: the bin shim uses it to find
|
|
4
|
+
// the binary at runtime, scripts/build-platform-packages.js uses it to emit the
|
|
5
|
+
// packages at release time, and the parent package.json pins the same six names
|
|
6
|
+
// in optionalDependencies.
|
|
7
|
+
//
|
|
8
|
+
// Keeping it in one file is the point. npm skips an optional dependency whose
|
|
9
|
+
// `os` or `cpu` does not match the machine, and it skips it silently, so a pair
|
|
10
|
+
// that disagrees with what the shim asks for installs nothing and leaves the CLI
|
|
11
|
+
// missing with no error anywhere. Two copies of this table is how that happens.
|
|
12
|
+
|
|
13
|
+
// Published under the project's npm organization. The dedicated scope keeps
|
|
14
|
+
// ownership separate from a maintainer account, and makes the project name in
|
|
15
|
+
// each package redundant: `@agnostic-ai/win32-x64` already identifies both.
|
|
16
|
+
const SCOPE = '@agnostic-ai'
|
|
17
|
+
|
|
18
|
+
const BINARY = 'agnostic-ai'
|
|
19
|
+
|
|
20
|
+
// `os` and `cpu` are npm's own spellings, matched against process.platform and
|
|
21
|
+
// process.arch. `goos` and `goarch` are Go's, and name the release archive the
|
|
22
|
+
// binary is extracted from. The two vocabularies differ on win32/windows and
|
|
23
|
+
// x64/amd64, which is the mapping this table exists to hold.
|
|
24
|
+
const PLATFORMS = [
|
|
25
|
+
{ os: 'darwin', cpu: 'arm64', goos: 'darwin', goarch: 'arm64' },
|
|
26
|
+
{ os: 'darwin', cpu: 'x64', goos: 'darwin', goarch: 'amd64' },
|
|
27
|
+
{ os: 'linux', cpu: 'arm64', goos: 'linux', goarch: 'arm64' },
|
|
28
|
+
{ os: 'linux', cpu: 'x64', goos: 'linux', goarch: 'amd64' },
|
|
29
|
+
{ os: 'win32', cpu: 'arm64', goos: 'windows', goarch: 'arm64' },
|
|
30
|
+
{ os: 'win32', cpu: 'x64', goos: 'windows', goarch: 'amd64' },
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
function packageName(platform) {
|
|
34
|
+
return `${SCOPE}/${platform.os}-${platform.cpu}`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function binaryName(platform) {
|
|
38
|
+
return platform.os === 'win32' ? `${BINARY}.exe` : BINARY
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// What the shim hands require.resolve(). A subpath of the platform package, so
|
|
42
|
+
// Node walks the same node_modules lookup npm just populated.
|
|
43
|
+
function entryPoint(platform) {
|
|
44
|
+
return `${packageName(platform)}/${binaryName(platform)}`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function platformFor(nodeOs, nodeCpu) {
|
|
48
|
+
return PLATFORMS.find((p) => p.os === nodeOs && p.cpu === nodeCpu)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Exact pins, never a range. A platform package carries a binary built from one
|
|
52
|
+
// commit, so a caret would let npm pair a new CLI with an old binary.
|
|
53
|
+
function optionalDependencies(version) {
|
|
54
|
+
return Object.fromEntries(PLATFORMS.map((p) => [packageName(p), version]))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = {
|
|
58
|
+
PLATFORMS,
|
|
59
|
+
binaryName,
|
|
60
|
+
entryPoint,
|
|
61
|
+
optionalDependencies,
|
|
62
|
+
packageName,
|
|
63
|
+
platformFor,
|
|
64
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agnostic-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.64.1",
|
|
4
4
|
"description": "One spec, every AI CLI. Sync agents, skills, rules, hooks, and MCP servers to Claude Code, Codex, Gemini, Cursor, Copilot, and 20 more.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -39,25 +39,22 @@
|
|
|
39
39
|
"agnostic-ai": "bin/agnostic-ai.js"
|
|
40
40
|
},
|
|
41
41
|
"files": [
|
|
42
|
-
"bin/",
|
|
43
|
-
"lib/",
|
|
44
|
-
"scripts/",
|
|
42
|
+
"bin/agnostic-ai.js",
|
|
43
|
+
"lib/platforms.js",
|
|
45
44
|
"README.md"
|
|
46
45
|
],
|
|
47
46
|
"scripts": {
|
|
48
|
-
"
|
|
49
|
-
"test": "node lib/download_test.js"
|
|
47
|
+
"test": "node lib/platforms_test.js && node scripts/build-platform-packages_test.js"
|
|
50
48
|
},
|
|
51
49
|
"engines": {
|
|
52
50
|
"node": ">=18"
|
|
53
51
|
},
|
|
54
|
-
"
|
|
55
|
-
"darwin",
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
"x64"
|
|
61
|
-
|
|
62
|
-
]
|
|
52
|
+
"optionalDependencies": {
|
|
53
|
+
"@agnostic-ai/darwin-arm64": "0.64.1",
|
|
54
|
+
"@agnostic-ai/darwin-x64": "0.64.1",
|
|
55
|
+
"@agnostic-ai/linux-arm64": "0.64.1",
|
|
56
|
+
"@agnostic-ai/linux-x64": "0.64.1",
|
|
57
|
+
"@agnostic-ai/win32-arm64": "0.64.1",
|
|
58
|
+
"@agnostic-ai/win32-x64": "0.64.1"
|
|
59
|
+
}
|
|
63
60
|
}
|
package/lib/download.js
DELETED
|
@@ -1,266 +0,0 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
|
|
3
|
-
// Fetches the agnostic-ai release binary for the current platform. Shared by
|
|
4
|
-
// the postinstall hook and the bin shim, so a failed install still recovers on
|
|
5
|
-
// first run instead of leaving a broken command.
|
|
6
|
-
|
|
7
|
-
const { execFileSync } = require('node:child_process')
|
|
8
|
-
const crypto = require('node:crypto')
|
|
9
|
-
const fs = require('node:fs')
|
|
10
|
-
const http = require('node:http')
|
|
11
|
-
const https = require('node:https')
|
|
12
|
-
const os = require('node:os')
|
|
13
|
-
const path = require('node:path')
|
|
14
|
-
|
|
15
|
-
const REPO = 'Chemaclass/agnostic-ai'
|
|
16
|
-
const BINARY = process.platform === 'win32' ? 'agnostic-ai.exe' : 'agnostic-ai'
|
|
17
|
-
|
|
18
|
-
const PLATFORMS = { darwin: 'darwin', linux: 'linux', win32: 'windows' }
|
|
19
|
-
const ARCHS = { x64: 'amd64', arm64: 'arm64' }
|
|
20
|
-
|
|
21
|
-
// GitHub redirects a release asset to object storage exactly once, so five hops
|
|
22
|
-
// is slack for a proxy in the way while still ending a redirect loop.
|
|
23
|
-
const MAX_REDIRECTS = 5
|
|
24
|
-
|
|
25
|
-
// Silence, not slowness, is what this catches: the archives are a few MB, so 30
|
|
26
|
-
// seconds without a single byte means the connection is wedged. Left unbounded,
|
|
27
|
-
// the OS connect timeout alone runs to 75 seconds, and a server that accepts and
|
|
28
|
-
// never answers never times out at all. This runs inside postinstall, where a
|
|
29
|
-
// stall blocks `npm install` for the whole project.
|
|
30
|
-
const TIMEOUT_MS = 30_000
|
|
31
|
-
|
|
32
|
-
function target() {
|
|
33
|
-
const goos = PLATFORMS[process.platform]
|
|
34
|
-
const goarch = ARCHS[process.arch]
|
|
35
|
-
if (!goos || !goarch) {
|
|
36
|
-
throw new Error(
|
|
37
|
-
`agnostic-ai has no prebuilt binary for ${process.platform}/${process.arch}. ` +
|
|
38
|
-
'Build from source: go install github.com/chemaclass/agnostic-ai/cmd/agnostic-ai@latest'
|
|
39
|
-
)
|
|
40
|
-
}
|
|
41
|
-
return { goos, goarch, ext: goos === 'windows' ? 'zip' : 'tar.gz' }
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function assetName() {
|
|
45
|
-
const { goos, goarch, ext } = target()
|
|
46
|
-
return `agnostic-ai_${goos}_${goarch}.${ext}`
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function binaryPath() {
|
|
50
|
-
return path.join(__dirname, '..', 'bin', BINARY)
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// Node throws an AggregateError with an empty `message` when a host resolves to
|
|
54
|
-
// several addresses and every connection fails, which is the ordinary shape of
|
|
55
|
-
// github.com behind a firewall. Printing `err.message` then prints nothing, so
|
|
56
|
-
// rebuild a message out of what the error does carry.
|
|
57
|
-
function describeError(err) {
|
|
58
|
-
if (!err || err.message) return err
|
|
59
|
-
|
|
60
|
-
const codes = new Set()
|
|
61
|
-
const causes = new Set()
|
|
62
|
-
if (err.code) codes.add(err.code)
|
|
63
|
-
for (const cause of Array.isArray(err.errors) ? err.errors : []) {
|
|
64
|
-
if (!cause) continue
|
|
65
|
-
if (cause.code) codes.add(cause.code)
|
|
66
|
-
if (cause.message) causes.add(cause.message)
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const summary = codes.size ? [...codes].join(', ') : `${err.name || 'Error'} with no message`
|
|
70
|
-
const described = new Error(causes.size ? `${summary}: ${[...causes].join('; ')}` : summary)
|
|
71
|
-
described.cause = err
|
|
72
|
-
return described
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function client(url) {
|
|
76
|
-
if (url.startsWith('https:')) return https
|
|
77
|
-
// Only reachable through a redirect; kept so a plain http hop reports
|
|
78
|
-
// something readable instead of ERR_INVALID_PROTOCOL.
|
|
79
|
-
if (url.startsWith('http:')) return http
|
|
80
|
-
throw new Error(`GET ${url} uses an unsupported protocol`)
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// Resolves once the response headers are in, with a `body()` that reads the
|
|
84
|
-
// rest. Splitting it that way lets a caller act on the headers alone, and keeps
|
|
85
|
-
// the one timeout covering both halves: the socket timeout fires on silence
|
|
86
|
-
// whether or not the body has started, and a request torn down by it must
|
|
87
|
-
// report the timeout rather than the `aborted` the stream raises in its wake.
|
|
88
|
-
function request(url, timeoutMs) {
|
|
89
|
-
return new Promise((resolve, reject) => {
|
|
90
|
-
let timedOut = null
|
|
91
|
-
const fail = (err) => reject(timedOut || describeError(err))
|
|
92
|
-
|
|
93
|
-
const req = client(url)
|
|
94
|
-
.get(url, { headers: { 'user-agent': 'agnostic-ai-npm' }, timeout: timeoutMs }, (res) => {
|
|
95
|
-
resolve({
|
|
96
|
-
res,
|
|
97
|
-
body: () =>
|
|
98
|
-
new Promise((done, failBody) => {
|
|
99
|
-
const chunks = []
|
|
100
|
-
res.on('data', (c) => chunks.push(c))
|
|
101
|
-
res.on('end', () => done(Buffer.concat(chunks)))
|
|
102
|
-
res.on('error', (err) => failBody(timedOut || describeError(err)))
|
|
103
|
-
}),
|
|
104
|
-
})
|
|
105
|
-
})
|
|
106
|
-
.on('timeout', () => {
|
|
107
|
-
// The socket timeout only raises the event; the request has to be torn
|
|
108
|
-
// down by hand, and destroy(err) is what surfaces as a rejection.
|
|
109
|
-
timedOut = new Error(`GET ${url} timed out after ${timeoutMs / 1000}s of silence`)
|
|
110
|
-
req.destroy(timedOut)
|
|
111
|
-
})
|
|
112
|
-
.on('error', fail)
|
|
113
|
-
})
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Drain a response nobody will read. The listener is not optional: a response
|
|
117
|
-
// that errors with nothing attached takes the whole process down.
|
|
118
|
-
function discard(res) {
|
|
119
|
-
res.resume()
|
|
120
|
-
res.on('error', () => {})
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function redirectTo(res) {
|
|
124
|
-
const { statusCode, headers } = res
|
|
125
|
-
return statusCode >= 300 && statusCode < 400 && headers.location ? headers.location : ''
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
async function get(url, { redirects = 0, timeoutMs = TIMEOUT_MS } = {}) {
|
|
129
|
-
const { res, body } = await request(url, timeoutMs)
|
|
130
|
-
|
|
131
|
-
// GitHub redirects release assets to a signed object-store URL.
|
|
132
|
-
const next = redirectTo(res)
|
|
133
|
-
if (next) {
|
|
134
|
-
discard(res)
|
|
135
|
-
if (redirects >= MAX_REDIRECTS) {
|
|
136
|
-
throw new Error(`GET ${url} still redirecting after ${MAX_REDIRECTS} hops`)
|
|
137
|
-
}
|
|
138
|
-
if (url.startsWith('https:') && next.startsWith('http:')) {
|
|
139
|
-
throw new Error(`GET ${url} redirects to plain http (${next}); refusing`)
|
|
140
|
-
}
|
|
141
|
-
return get(next, { redirects: redirects + 1, timeoutMs })
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
if (res.statusCode !== 200) {
|
|
145
|
-
discard(res)
|
|
146
|
-
throw new Error(`GET ${url} failed with HTTP ${res.statusCode}`)
|
|
147
|
-
}
|
|
148
|
-
return body()
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// Release tags carry the `v`, but `npm view` and package.json print the bare
|
|
152
|
-
// number, so both spellings of AGNOSTIC_AI_VERSION have to reach the same tag.
|
|
153
|
-
// Anything that does not start with a digit is passed through untouched.
|
|
154
|
-
function releaseTag(version) {
|
|
155
|
-
const trimmed = version.trim()
|
|
156
|
-
return /^\d/.test(trimmed) ? `v${trimmed}` : trimmed
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// `releases/latest` on github.com answers 302 to the tag page, so the tag is in
|
|
160
|
-
// the Location header. That keeps release resolution off api.github.com, which
|
|
161
|
-
// allows 60 unauthenticated requests an hour per IP: a shared CI address burns
|
|
162
|
-
// through that and every install then fails with HTTP 403 (#940). The redirect
|
|
163
|
-
// target is what this wants, so it reads the response itself instead of going
|
|
164
|
-
// through get(), which exists to follow the redirect and hand back a body.
|
|
165
|
-
async function latestTag(url = `https://github.com/${REPO}/releases/latest`) {
|
|
166
|
-
const { res } = await request(url, TIMEOUT_MS)
|
|
167
|
-
discard(res)
|
|
168
|
-
|
|
169
|
-
const location = redirectTo(res)
|
|
170
|
-
if (location) {
|
|
171
|
-
const tag = decodeURIComponent(location.split('/').pop() || '')
|
|
172
|
-
if (/^v?\d/.test(tag)) return tag
|
|
173
|
-
throw new Error(`could not read a release tag out of ${location}`)
|
|
174
|
-
}
|
|
175
|
-
if (res.statusCode === 403 || res.statusCode === 429) {
|
|
176
|
-
throw new Error(
|
|
177
|
-
`GitHub rate-limited the release lookup (HTTP ${res.statusCode}). Retry in a few ` +
|
|
178
|
-
'minutes, or pin the binary with AGNOSTIC_AI_VERSION=vX.Y.Z'
|
|
179
|
-
)
|
|
180
|
-
}
|
|
181
|
-
if (res.statusCode === 404) {
|
|
182
|
-
throw new Error(
|
|
183
|
-
`${REPO} reports no published release (HTTP 404). Pin one with AGNOSTIC_AI_VERSION=vX.Y.Z`
|
|
184
|
-
)
|
|
185
|
-
}
|
|
186
|
-
throw new Error(`could not resolve the latest agnostic-ai release: HTTP ${res.statusCode}`)
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// The published package carries the release version; a checkout carries the
|
|
190
|
-
// 0.0.0-dev placeholder, which has no matching release, so fall back to latest.
|
|
191
|
-
async function resolveVersion() {
|
|
192
|
-
if (process.env.AGNOSTIC_AI_VERSION) return releaseTag(process.env.AGNOSTIC_AI_VERSION)
|
|
193
|
-
|
|
194
|
-
const { version } = require('../package.json')
|
|
195
|
-
if (version && !version.startsWith('0.0.0')) return releaseTag(version)
|
|
196
|
-
|
|
197
|
-
return latestTag()
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function downloadUrl(version, asset) {
|
|
201
|
-
return `https://github.com/${REPO}/releases/download/${version}/${asset}`
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
async function verifyChecksum(archive, asset, version) {
|
|
205
|
-
const sums = (await get(downloadUrl(version, 'checksums.txt'))).toString('utf8')
|
|
206
|
-
const line = sums.split('\n').find((l) => l.trim().endsWith(asset))
|
|
207
|
-
if (!line) throw new Error(`${asset} missing from checksums.txt`)
|
|
208
|
-
|
|
209
|
-
const expected = line.trim().split(/\s+/)[0]
|
|
210
|
-
const actual = crypto.createHash('sha256').update(fs.readFileSync(archive)).digest('hex')
|
|
211
|
-
if (actual !== expected) throw new Error(`checksum mismatch for ${asset}`)
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function extract(archive, dir) {
|
|
215
|
-
// bsdtar reads both tar.gz and zip, and ships with macOS and Windows 10
|
|
216
|
-
// 1803+; Linux only ever gets the tar.gz here, so GNU tar is fine too.
|
|
217
|
-
try {
|
|
218
|
-
execFileSync('tar', ['-xf', archive, '-C', dir, BINARY], { stdio: 'ignore' })
|
|
219
|
-
} catch (err) {
|
|
220
|
-
const reason = err.code === 'ENOENT' ? 'tar is not installed' : describeError(err).message
|
|
221
|
-
throw new Error(
|
|
222
|
-
`could not extract ${path.basename(archive)}: ${reason}. Install agnostic-ai another ` +
|
|
223
|
-
'way instead: go install github.com/chemaclass/agnostic-ai/cmd/agnostic-ai@latest, ' +
|
|
224
|
-
'or the routes at https://agnostic-ai.org/docs/installation/'
|
|
225
|
-
)
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
async function ensureBinary({ log = () => {} } = {}) {
|
|
230
|
-
const dest = binaryPath()
|
|
231
|
-
if (fs.existsSync(dest)) return dest
|
|
232
|
-
|
|
233
|
-
const version = await resolveVersion()
|
|
234
|
-
const asset = assetName()
|
|
235
|
-
log(`downloading agnostic-ai ${version} (${asset})`)
|
|
236
|
-
|
|
237
|
-
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'agnostic-ai-'))
|
|
238
|
-
try {
|
|
239
|
-
const archive = path.join(work, asset)
|
|
240
|
-
fs.writeFileSync(archive, await get(downloadUrl(version, asset)))
|
|
241
|
-
await verifyChecksum(archive, asset, version)
|
|
242
|
-
extract(archive, work)
|
|
243
|
-
|
|
244
|
-
fs.mkdirSync(path.dirname(dest), { recursive: true })
|
|
245
|
-
fs.copyFileSync(path.join(work, BINARY), dest)
|
|
246
|
-
fs.chmodSync(dest, 0o755)
|
|
247
|
-
} finally {
|
|
248
|
-
fs.rmSync(work, { recursive: true, force: true })
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
log(`installed ${dest}`)
|
|
252
|
-
return dest
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
module.exports = {
|
|
256
|
-
assetName,
|
|
257
|
-
binaryPath,
|
|
258
|
-
describeError,
|
|
259
|
-
downloadUrl,
|
|
260
|
-
ensureBinary,
|
|
261
|
-
extract,
|
|
262
|
-
get,
|
|
263
|
-
latestTag,
|
|
264
|
-
resolveVersion,
|
|
265
|
-
target,
|
|
266
|
-
}
|
package/lib/download_test.js
DELETED
|
@@ -1,325 +0,0 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
|
|
3
|
-
// Run: node npm/lib/download_test.js (or npm test inside npm/)
|
|
4
|
-
//
|
|
5
|
-
// Covers the pure mapping logic plus the failure paths of the download: every
|
|
6
|
-
// server here is a throwaway listener on 127.0.0.1, so the suite needs no
|
|
7
|
-
// network and no dependencies. The happy path is exercised end to end by
|
|
8
|
-
// .github/workflows/install.yml against a real release.
|
|
9
|
-
|
|
10
|
-
const assert = require('node:assert')
|
|
11
|
-
const { spawnSync } = require('node:child_process')
|
|
12
|
-
const fs = require('node:fs')
|
|
13
|
-
const http = require('node:http')
|
|
14
|
-
const https = require('node:https')
|
|
15
|
-
const net = require('node:net')
|
|
16
|
-
const os = require('node:os')
|
|
17
|
-
const path = require('node:path')
|
|
18
|
-
const {
|
|
19
|
-
assetName,
|
|
20
|
-
binaryPath,
|
|
21
|
-
describeError,
|
|
22
|
-
downloadUrl,
|
|
23
|
-
extract,
|
|
24
|
-
get,
|
|
25
|
-
latestTag,
|
|
26
|
-
resolveVersion,
|
|
27
|
-
target,
|
|
28
|
-
} = require('./download')
|
|
29
|
-
|
|
30
|
-
function listen(server) {
|
|
31
|
-
return new Promise((resolve) => {
|
|
32
|
-
server.listen(0, '127.0.0.1', () => resolve(`http://127.0.0.1:${server.address().port}`))
|
|
33
|
-
})
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function close(server, sockets = []) {
|
|
37
|
-
return new Promise((resolve) => {
|
|
38
|
-
// close() alone waits for every open connection: the keep-alive sockets an
|
|
39
|
-
// http.Server holds, and the one the wedged server keeps open on purpose.
|
|
40
|
-
// Drop both, or the suite hangs here.
|
|
41
|
-
if (server.closeAllConnections) server.closeAllConnections()
|
|
42
|
-
for (const socket of sockets) socket.destroy()
|
|
43
|
-
server.close(resolve)
|
|
44
|
-
})
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
async function rejection(promise) {
|
|
48
|
-
try {
|
|
49
|
-
await promise
|
|
50
|
-
} catch (err) {
|
|
51
|
-
return err
|
|
52
|
-
}
|
|
53
|
-
throw new Error('expected the promise to reject')
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function thrown(fn) {
|
|
57
|
-
try {
|
|
58
|
-
fn()
|
|
59
|
-
} catch (err) {
|
|
60
|
-
return err
|
|
61
|
-
}
|
|
62
|
-
throw new Error('expected the call to throw')
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// A real AggregateError, thrown by Node itself: the custom lookup hands the
|
|
66
|
-
// connect both loopback addresses so every attempt fails, which is the shape of
|
|
67
|
-
// github.com resolving to several IPs behind a firewall. Both refuse instantly,
|
|
68
|
-
// so this stays fast and needs no network.
|
|
69
|
-
function multiAddressFailure(port) {
|
|
70
|
-
return new Promise((resolve) => {
|
|
71
|
-
https
|
|
72
|
-
.get(
|
|
73
|
-
`https://localhost:${port}/x`,
|
|
74
|
-
{
|
|
75
|
-
autoSelectFamily: true,
|
|
76
|
-
lookup: (host, opts, cb) =>
|
|
77
|
-
cb(null, [
|
|
78
|
-
{ address: '127.0.0.1', family: 4 },
|
|
79
|
-
{ address: '::1', family: 6 },
|
|
80
|
-
]),
|
|
81
|
-
},
|
|
82
|
-
() => {}
|
|
83
|
-
)
|
|
84
|
-
.on('error', resolve)
|
|
85
|
-
})
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function closedPort() {
|
|
89
|
-
return new Promise((resolve) => {
|
|
90
|
-
const probe = net.createServer()
|
|
91
|
-
probe.listen(0, '127.0.0.1', () => {
|
|
92
|
-
const { port } = probe.address()
|
|
93
|
-
probe.close(() => resolve(port))
|
|
94
|
-
})
|
|
95
|
-
})
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const tests = {
|
|
99
|
-
'asset name matches the release archive for this platform'() {
|
|
100
|
-
const { goos, goarch } = target()
|
|
101
|
-
const expected = goos === 'windows'
|
|
102
|
-
? `agnostic-ai_${goos}_${goarch}.zip`
|
|
103
|
-
: `agnostic-ai_${goos}_${goarch}.tar.gz`
|
|
104
|
-
assert.strictEqual(assetName(), expected)
|
|
105
|
-
},
|
|
106
|
-
|
|
107
|
-
'windows gets a zip, every other platform a tar.gz'() {
|
|
108
|
-
assert.strictEqual(target().ext, process.platform === 'win32' ? 'zip' : 'tar.gz')
|
|
109
|
-
},
|
|
110
|
-
|
|
111
|
-
'download url points at the tagged release asset'() {
|
|
112
|
-
assert.strictEqual(
|
|
113
|
-
downloadUrl('v0.45.0', 'agnostic-ai_linux_amd64.tar.gz'),
|
|
114
|
-
'https://github.com/Chemaclass/agnostic-ai/releases/download/v0.45.0/agnostic-ai_linux_amd64.tar.gz'
|
|
115
|
-
)
|
|
116
|
-
},
|
|
117
|
-
|
|
118
|
-
'binary path lands in the package bin dir'() {
|
|
119
|
-
assert.strictEqual(path.dirname(binaryPath()), path.join(__dirname, '..', 'bin'))
|
|
120
|
-
assert.match(path.basename(binaryPath()), /^agnostic-ai(\.exe)?$/)
|
|
121
|
-
},
|
|
122
|
-
|
|
123
|
-
async 'env override wins over the package version'() {
|
|
124
|
-
process.env.AGNOSTIC_AI_VERSION = 'v1.2.3'
|
|
125
|
-
try {
|
|
126
|
-
assert.strictEqual(await resolveVersion(), 'v1.2.3')
|
|
127
|
-
} finally {
|
|
128
|
-
delete process.env.AGNOSTIC_AI_VERSION
|
|
129
|
-
}
|
|
130
|
-
},
|
|
131
|
-
|
|
132
|
-
async 'a version pin resolves the same with or without the v prefix'() {
|
|
133
|
-
try {
|
|
134
|
-
for (const pin of ['0.61.0', 'v0.61.0', ' 0.61.0 ']) {
|
|
135
|
-
process.env.AGNOSTIC_AI_VERSION = pin
|
|
136
|
-
assert.strictEqual(await resolveVersion(), 'v0.61.0', `pin ${JSON.stringify(pin)}`)
|
|
137
|
-
}
|
|
138
|
-
} finally {
|
|
139
|
-
delete process.env.AGNOSTIC_AI_VERSION
|
|
140
|
-
}
|
|
141
|
-
},
|
|
142
|
-
|
|
143
|
-
async 'an error with no message is described from its code and its causes'() {
|
|
144
|
-
const port = await closedPort()
|
|
145
|
-
const err = await multiAddressFailure(port)
|
|
146
|
-
|
|
147
|
-
// Guard the premise: this is what the user currently sees printed.
|
|
148
|
-
assert.ok(err instanceof AggregateError, `expected an AggregateError, got ${err.name}`)
|
|
149
|
-
assert.strictEqual(err.message, '')
|
|
150
|
-
|
|
151
|
-
const described = describeError(err).message
|
|
152
|
-
assert.ok(described.length > 0, 'described message is empty')
|
|
153
|
-
assert.match(described, /ECONNREFUSED/)
|
|
154
|
-
assert.ok(described.includes(`127.0.0.1:${port}`), `missing the address: ${described}`)
|
|
155
|
-
assert.ok(described.includes('::1'), `missing the second address: ${described}`)
|
|
156
|
-
},
|
|
157
|
-
|
|
158
|
-
'an error that already has a message is left alone'() {
|
|
159
|
-
const err = new Error('connect ETIMEDOUT 203.0.113.1:443')
|
|
160
|
-
assert.strictEqual(describeError(err), err)
|
|
161
|
-
},
|
|
162
|
-
|
|
163
|
-
async 'a server that never answers fails with a timeout naming the url'() {
|
|
164
|
-
// Accept the connection and say nothing: no OS-level timeout ever fires.
|
|
165
|
-
const accepted = []
|
|
166
|
-
const server = net.createServer((socket) => accepted.push(socket))
|
|
167
|
-
const base = await listen(server)
|
|
168
|
-
try {
|
|
169
|
-
const err = await rejection(get(`${base}/wedged`, { timeoutMs: 200 }))
|
|
170
|
-
assert.ok(err.message.includes(`${base}/wedged`), `missing the url: ${err.message}`)
|
|
171
|
-
assert.match(err.message, /timed out after 0\.2s/)
|
|
172
|
-
} finally {
|
|
173
|
-
await close(server, accepted)
|
|
174
|
-
}
|
|
175
|
-
},
|
|
176
|
-
|
|
177
|
-
async 'a body that stops mid-stream reports the timeout, not `aborted`'() {
|
|
178
|
-
const server = http.createServer((req, res) => {
|
|
179
|
-
res.writeHead(200, { 'content-length': '100' })
|
|
180
|
-
res.write('half a bod') // and then nothing, ever
|
|
181
|
-
})
|
|
182
|
-
const base = await listen(server)
|
|
183
|
-
try {
|
|
184
|
-
const err = await rejection(get(`${base}/stalled`, { timeoutMs: 200 }))
|
|
185
|
-
assert.ok(err.message.includes(`${base}/stalled`), `missing the url: ${err.message}`)
|
|
186
|
-
assert.match(err.message, /timed out after 0\.2s/)
|
|
187
|
-
} finally {
|
|
188
|
-
await close(server)
|
|
189
|
-
}
|
|
190
|
-
},
|
|
191
|
-
|
|
192
|
-
async 'a single redirect is still followed'() {
|
|
193
|
-
const server = http.createServer((req, res) => {
|
|
194
|
-
if (req.url === '/asset') {
|
|
195
|
-
res.writeHead(302, { location: `${base}/storage` })
|
|
196
|
-
res.end()
|
|
197
|
-
return
|
|
198
|
-
}
|
|
199
|
-
res.writeHead(200)
|
|
200
|
-
res.end('payload')
|
|
201
|
-
})
|
|
202
|
-
const base = await listen(server)
|
|
203
|
-
try {
|
|
204
|
-
assert.strictEqual((await get(`${base}/asset`)).toString('utf8'), 'payload')
|
|
205
|
-
} finally {
|
|
206
|
-
await close(server)
|
|
207
|
-
}
|
|
208
|
-
},
|
|
209
|
-
|
|
210
|
-
async 'a redirect loop stops at the cap'() {
|
|
211
|
-
let hops = 0
|
|
212
|
-
const server = http.createServer((req, res) => {
|
|
213
|
-
hops++
|
|
214
|
-
res.writeHead(302, { location: `${base}/loop` })
|
|
215
|
-
res.end()
|
|
216
|
-
})
|
|
217
|
-
const base = await listen(server)
|
|
218
|
-
try {
|
|
219
|
-
const err = await rejection(get(`${base}/loop`))
|
|
220
|
-
assert.match(err.message, /redirect/i)
|
|
221
|
-
assert.match(err.message, /5/)
|
|
222
|
-
assert.ok(hops <= 6, `followed ${hops} hops, expected at most 6`)
|
|
223
|
-
} finally {
|
|
224
|
-
await close(server)
|
|
225
|
-
}
|
|
226
|
-
},
|
|
227
|
-
|
|
228
|
-
async 'the latest release comes from the redirect, not the rate-limited api'() {
|
|
229
|
-
const seen = []
|
|
230
|
-
const server = http.createServer((req, res) => {
|
|
231
|
-
seen.push(req.url)
|
|
232
|
-
res.writeHead(302, { location: 'https://github.com/Chemaclass/agnostic-ai/releases/tag/v0.62.0' })
|
|
233
|
-
res.end()
|
|
234
|
-
})
|
|
235
|
-
const base = await listen(server)
|
|
236
|
-
try {
|
|
237
|
-
assert.strictEqual(await latestTag(`${base}/releases/latest`), 'v0.62.0')
|
|
238
|
-
assert.deepStrictEqual(seen, ['/releases/latest'])
|
|
239
|
-
} finally {
|
|
240
|
-
await close(server)
|
|
241
|
-
}
|
|
242
|
-
},
|
|
243
|
-
|
|
244
|
-
async 'a rate-limited release lookup says so, a missing release says that'() {
|
|
245
|
-
let status = 403
|
|
246
|
-
const server = http.createServer((req, res) => {
|
|
247
|
-
res.writeHead(status)
|
|
248
|
-
res.end()
|
|
249
|
-
})
|
|
250
|
-
const base = await listen(server)
|
|
251
|
-
try {
|
|
252
|
-
const limited = await rejection(latestTag(`${base}/releases/latest`))
|
|
253
|
-
assert.match(limited.message, /rate-limited/)
|
|
254
|
-
assert.match(limited.message, /AGNOSTIC_AI_VERSION/)
|
|
255
|
-
|
|
256
|
-
status = 404
|
|
257
|
-
const missing = await rejection(latestTag(`${base}/releases/latest`))
|
|
258
|
-
assert.match(missing.message, /no published release/)
|
|
259
|
-
assert.ok(!/rate-limited/.test(missing.message), missing.message)
|
|
260
|
-
} finally {
|
|
261
|
-
await close(server)
|
|
262
|
-
}
|
|
263
|
-
},
|
|
264
|
-
|
|
265
|
-
'a failed extraction names the archive and an alternative install route'() {
|
|
266
|
-
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'agnostic-ai-extract-'))
|
|
267
|
-
try {
|
|
268
|
-
const archive = path.join(work, 'agnostic-ai_broken.tar.gz')
|
|
269
|
-
fs.writeFileSync(archive, 'not an archive')
|
|
270
|
-
const err = thrown(() => extract(archive, work))
|
|
271
|
-
assert.ok(err.message.includes('agnostic-ai_broken.tar.gz'), `missing the archive: ${err.message}`)
|
|
272
|
-
assert.match(err.message, /go install github\.com\/chemaclass\/agnostic-ai/)
|
|
273
|
-
} finally {
|
|
274
|
-
fs.rmSync(work, { recursive: true, force: true })
|
|
275
|
-
}
|
|
276
|
-
},
|
|
277
|
-
|
|
278
|
-
'the bin shim exits with 128 plus the signal number'() {
|
|
279
|
-
if (process.platform === 'win32') {
|
|
280
|
-
console.log(' skipped: no POSIX signals on windows')
|
|
281
|
-
return
|
|
282
|
-
}
|
|
283
|
-
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'agnostic-ai-shim-'))
|
|
284
|
-
try {
|
|
285
|
-
fs.mkdirSync(path.join(work, 'bin'))
|
|
286
|
-
fs.mkdirSync(path.join(work, 'lib'))
|
|
287
|
-
fs.writeFileSync(path.join(work, 'package.json'), '{"version":"0.0.0-dev"}')
|
|
288
|
-
fs.copyFileSync(path.join(__dirname, 'download.js'), path.join(work, 'lib', 'download.js'))
|
|
289
|
-
const shim = path.join(work, 'bin', 'agnostic-ai.js')
|
|
290
|
-
fs.copyFileSync(path.join(__dirname, '..', 'bin', 'agnostic-ai.js'), shim)
|
|
291
|
-
|
|
292
|
-
// ensureBinary short-circuits on an existing binary, so the shim spawns
|
|
293
|
-
// this stub instead of downloading anything. It kills itself with
|
|
294
|
-
// SIGTERM (15), so the shim must report 143.
|
|
295
|
-
const stub = path.join(work, 'bin', 'agnostic-ai')
|
|
296
|
-
fs.writeFileSync(stub, '#!/bin/sh\nkill -TERM $$\n')
|
|
297
|
-
fs.chmodSync(stub, 0o755)
|
|
298
|
-
|
|
299
|
-
const run = spawnSync(process.execPath, [shim], { encoding: 'utf8' })
|
|
300
|
-
assert.strictEqual(run.status, 128 + os.constants.signals.SIGTERM, run.stderr)
|
|
301
|
-
} finally {
|
|
302
|
-
fs.rmSync(work, { recursive: true, force: true })
|
|
303
|
-
}
|
|
304
|
-
},
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
async function run() {
|
|
308
|
-
let failed = 0
|
|
309
|
-
for (const [name, fn] of Object.entries(tests)) {
|
|
310
|
-
try {
|
|
311
|
-
await fn()
|
|
312
|
-
console.log(`ok ${name}`)
|
|
313
|
-
} catch (err) {
|
|
314
|
-
failed++
|
|
315
|
-
console.error(`FAIL ${name}\n ${err.message}`)
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
console.log(`\n${Object.keys(tests).length - failed} passed, ${failed} failed`)
|
|
319
|
-
// exitCode, not exit(): process.exit() drops whatever stdout has not flushed
|
|
320
|
-
// yet, which silently swallows the tail of the report when it runs in CI with
|
|
321
|
-
// stdout on a pipe.
|
|
322
|
-
process.exitCode = failed === 0 ? 0 : 1
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
run()
|
package/scripts/postinstall.js
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
|
|
3
|
-
// Downloads the binary at install time. A failure here is a warning, never an
|
|
4
|
-
// install error: the bin shim retries on first run, so an offline install or a
|
|
5
|
-
// blocked proxy does not break `npm install` for the whole project.
|
|
6
|
-
|
|
7
|
-
const { ensureBinary } = require('../lib/download')
|
|
8
|
-
|
|
9
|
-
ensureBinary({ log: (m) => console.log(`agnostic-ai: ${m}`) }).catch((err) => {
|
|
10
|
-
console.warn(`agnostic-ai: ${err.message}`)
|
|
11
|
-
console.warn('agnostic-ai: the binary will be fetched on first run instead')
|
|
12
|
-
})
|