agnostic-ai 0.62.0 → 0.63.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 +16 -2
- package/bin/agnostic-ai.js +7 -2
- package/lib/download.js +174 -30
- package/lib/download_test.js +267 -4
- package/package.json +16 -5
package/README.md
CHANGED
|
@@ -13,10 +13,24 @@ Or install it globally:
|
|
|
13
13
|
npm install -g agnostic-ai
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
+
npm is one route of several. The same binary installs through Homebrew on macOS and Linux:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
brew install --cask Chemaclass/tap/agnostic-ai
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Or through the install script, which needs no package manager:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
curl -fsSL https://raw.githubusercontent.com/Chemaclass/agnostic-ai/main/scripts/install.sh | bash
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Windows, Go, and manual download are covered in [all install options](https://agnostic-ai.org/docs/installation/).
|
|
29
|
+
|
|
16
30
|
This package is a thin wrapper: it downloads the prebuilt Go binary for your platform from [GitHub Releases](https://github.com/Chemaclass/agnostic-ai/releases) and runs it. Supported platforms are macOS, Linux, and Windows on x64 and arm64.
|
|
17
31
|
|
|
18
|
-
The download normally happens on install. Under npm's install-script gating (`--ignore-scripts`, or npm 11's default prompt), it happens on first run instead.
|
|
32
|
+
The download normally happens on install. Under npm's install-script gating (`--ignore-scripts`, or npm 11's default prompt), it happens on first run instead. Set `AGNOSTIC_AI_VERSION` to any [release tag](https://github.com/Chemaclass/agnostic-ai/releases) to pin a different binary.
|
|
19
33
|
|
|
20
|
-
Full docs, targets, and configuration: [
|
|
34
|
+
Full docs, targets, and configuration: [agnostic-ai.org](https://agnostic-ai.org).
|
|
21
35
|
|
|
22
36
|
MIT licensed.
|
package/bin/agnostic-ai.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
'use strict'
|
|
3
3
|
|
|
4
4
|
const { spawnSync } = require('node:child_process')
|
|
5
|
+
const os = require('node:os')
|
|
5
6
|
const { ensureBinary } = require('../lib/download')
|
|
6
7
|
|
|
7
8
|
async function main() {
|
|
@@ -20,8 +21,12 @@ async function main() {
|
|
|
20
21
|
console.error(`agnostic-ai: ${result.error.message}`)
|
|
21
22
|
process.exit(1)
|
|
22
23
|
}
|
|
23
|
-
// A signalled child reports null status; 128+signal is the shell
|
|
24
|
-
|
|
24
|
+
// A signalled child reports a null status; 128+signal is the shell
|
|
25
|
+
// convention, so a caller can tell a SIGINT (130) from a SIGKILL (137).
|
|
26
|
+
if (result.status === null) {
|
|
27
|
+
process.exit(128 + (os.constants.signals[result.signal] || 0))
|
|
28
|
+
}
|
|
29
|
+
process.exit(result.status)
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
main()
|
package/lib/download.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
const { execFileSync } = require('node:child_process')
|
|
8
8
|
const crypto = require('node:crypto')
|
|
9
9
|
const fs = require('node:fs')
|
|
10
|
+
const http = require('node:http')
|
|
10
11
|
const https = require('node:https')
|
|
11
12
|
const os = require('node:os')
|
|
12
13
|
const path = require('node:path')
|
|
@@ -17,6 +18,17 @@ const BINARY = process.platform === 'win32' ? 'agnostic-ai.exe' : 'agnostic-ai'
|
|
|
17
18
|
const PLATFORMS = { darwin: 'darwin', linux: 'linux', win32: 'windows' }
|
|
18
19
|
const ARCHS = { x64: 'amd64', arm64: 'arm64' }
|
|
19
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
|
+
|
|
20
32
|
function target() {
|
|
21
33
|
const goos = PLATFORMS[process.platform]
|
|
22
34
|
const goarch = ARCHS[process.arch]
|
|
@@ -38,42 +50,151 @@ function binaryPath() {
|
|
|
38
50
|
return path.join(__dirname, '..', 'bin', BINARY)
|
|
39
51
|
}
|
|
40
52
|
|
|
41
|
-
|
|
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) {
|
|
42
89
|
return new Promise((resolve, reject) => {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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)
|
|
60
111
|
})
|
|
61
|
-
.on('error',
|
|
112
|
+
.on('error', fail)
|
|
62
113
|
})
|
|
63
114
|
}
|
|
64
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
|
+
|
|
65
189
|
// The published package carries the release version; a checkout carries the
|
|
66
190
|
// 0.0.0-dev placeholder, which has no matching release, so fall back to latest.
|
|
67
191
|
async function resolveVersion() {
|
|
68
|
-
if (process.env.AGNOSTIC_AI_VERSION) return process.env.AGNOSTIC_AI_VERSION
|
|
192
|
+
if (process.env.AGNOSTIC_AI_VERSION) return releaseTag(process.env.AGNOSTIC_AI_VERSION)
|
|
69
193
|
|
|
70
194
|
const { version } = require('../package.json')
|
|
71
|
-
if (version && !version.startsWith('0.0.0')) return
|
|
195
|
+
if (version && !version.startsWith('0.0.0')) return releaseTag(version)
|
|
72
196
|
|
|
73
|
-
|
|
74
|
-
const tag = JSON.parse(body.toString('utf8')).tag_name
|
|
75
|
-
if (!tag) throw new Error('could not resolve the latest agnostic-ai release')
|
|
76
|
-
return tag
|
|
197
|
+
return latestTag()
|
|
77
198
|
}
|
|
78
199
|
|
|
79
200
|
function downloadUrl(version, asset) {
|
|
@@ -90,6 +211,21 @@ async function verifyChecksum(archive, asset, version) {
|
|
|
90
211
|
if (actual !== expected) throw new Error(`checksum mismatch for ${asset}`)
|
|
91
212
|
}
|
|
92
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
|
+
|
|
93
229
|
async function ensureBinary({ log = () => {} } = {}) {
|
|
94
230
|
const dest = binaryPath()
|
|
95
231
|
if (fs.existsSync(dest)) return dest
|
|
@@ -103,10 +239,7 @@ async function ensureBinary({ log = () => {} } = {}) {
|
|
|
103
239
|
const archive = path.join(work, asset)
|
|
104
240
|
fs.writeFileSync(archive, await get(downloadUrl(version, asset)))
|
|
105
241
|
await verifyChecksum(archive, asset, version)
|
|
106
|
-
|
|
107
|
-
// bsdtar reads both tar.gz and zip, and ships with macOS and Windows 10
|
|
108
|
-
// 1803+; Linux only ever gets the tar.gz here, so GNU tar is fine too.
|
|
109
|
-
execFileSync('tar', ['-xf', archive, '-C', work, BINARY], { stdio: 'ignore' })
|
|
242
|
+
extract(archive, work)
|
|
110
243
|
|
|
111
244
|
fs.mkdirSync(path.dirname(dest), { recursive: true })
|
|
112
245
|
fs.copyFileSync(path.join(work, BINARY), dest)
|
|
@@ -119,4 +252,15 @@ async function ensureBinary({ log = () => {} } = {}) {
|
|
|
119
252
|
return dest
|
|
120
253
|
}
|
|
121
254
|
|
|
122
|
-
module.exports = {
|
|
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
CHANGED
|
@@ -2,12 +2,98 @@
|
|
|
2
2
|
|
|
3
3
|
// Run: node npm/lib/download_test.js (or npm test inside npm/)
|
|
4
4
|
//
|
|
5
|
-
// Covers the pure mapping logic
|
|
6
|
-
//
|
|
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.
|
|
7
9
|
|
|
8
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')
|
|
9
17
|
const path = require('node:path')
|
|
10
|
-
const {
|
|
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
|
+
}
|
|
11
97
|
|
|
12
98
|
const tests = {
|
|
13
99
|
'asset name matches the release archive for this platform'() {
|
|
@@ -42,6 +128,180 @@ const tests = {
|
|
|
42
128
|
delete process.env.AGNOSTIC_AI_VERSION
|
|
43
129
|
}
|
|
44
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
|
+
},
|
|
45
305
|
}
|
|
46
306
|
|
|
47
307
|
async function run() {
|
|
@@ -56,7 +316,10 @@ async function run() {
|
|
|
56
316
|
}
|
|
57
317
|
}
|
|
58
318
|
console.log(`\n${Object.keys(tests).length - failed} passed, ${failed} failed`)
|
|
59
|
-
process.exit(
|
|
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
|
|
60
323
|
}
|
|
61
324
|
|
|
62
325
|
run()
|
package/package.json
CHANGED
|
@@ -1,19 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agnostic-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.63.0",
|
|
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",
|
|
7
7
|
"cli",
|
|
8
|
+
"agents",
|
|
9
|
+
"skills",
|
|
10
|
+
"rules",
|
|
11
|
+
"hooks",
|
|
12
|
+
"mcp",
|
|
13
|
+
"prompts",
|
|
14
|
+
"agents-md",
|
|
8
15
|
"claude",
|
|
16
|
+
"claude-code",
|
|
9
17
|
"codex",
|
|
10
18
|
"cursor",
|
|
11
19
|
"copilot",
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
20
|
+
"gemini",
|
|
21
|
+
"windsurf",
|
|
22
|
+
"cline",
|
|
23
|
+
"opencode",
|
|
24
|
+
"ai-tools",
|
|
25
|
+
"developer-tools"
|
|
15
26
|
],
|
|
16
|
-
"homepage": "https://
|
|
27
|
+
"homepage": "https://agnostic-ai.org",
|
|
17
28
|
"bugs": {
|
|
18
29
|
"url": "https://github.com/Chemaclass/agnostic-ai/issues"
|
|
19
30
|
},
|