amorphous-cli 0.1.4 → 0.1.6
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 +2 -2
- package/index.mjs +5 -0
- package/lib/agent-contract.mjs +6 -2
- package/lib/agent-contract.test.mjs +27 -4
- package/lib/api.mjs +8 -8
- package/lib/commands.mjs +15 -5
- package/lib/connect-crypto.mjs +83 -0
- package/lib/connect.test.mjs +171 -0
- package/lib/help.mjs +13 -7
- package/lib/login.mjs +117 -8
- package/lib/session.mjs +128 -6
- package/lib/update-check.mjs +188 -0
- package/lib/update-check.test.mjs +270 -0
- package/lib/version.mjs +3 -0
- package/mcp.mjs +78 -76
- package/package.json +5 -4
- package/skill/amorphous/SKILL.md +9 -9
package/lib/session.mjs
CHANGED
|
@@ -1,6 +1,79 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync, existsSync } from 'node:fs'
|
|
2
|
+
import { open, stat, unlink } from 'node:fs/promises'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import { randomBytes } from 'node:crypto'
|
|
5
|
+
import { createClient } from '@supabase/supabase-js'
|
|
6
|
+
import { credentialsPath, loadDotEnv, supabaseAnonKey, supabaseUrl } from './config.mjs'
|
|
7
|
+
|
|
8
|
+
const REFRESH_WINDOW_MS = 60_000
|
|
9
|
+
const LOCK_STALE_MS = 15_000
|
|
10
|
+
const LOCK_WAIT_MS = 20_000
|
|
11
|
+
|
|
12
|
+
export function sessionNeedsRefresh(session, now = Date.now()) {
|
|
13
|
+
if (!session?.expires_at) return true
|
|
14
|
+
return session.expires_at * 1000 - now <= REFRESH_WINDOW_MS
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function sessionRecord(tokens) {
|
|
18
|
+
if (!tokens?.access_token || !tokens?.refresh_token) {
|
|
19
|
+
throw new Error('Sign-in did not return a session.')
|
|
20
|
+
}
|
|
21
|
+
const expiresAt =
|
|
22
|
+
typeof tokens.expires_at === 'number'
|
|
23
|
+
? tokens.expires_at
|
|
24
|
+
: tokens.expires_in
|
|
25
|
+
? Math.floor(Date.now() / 1000) + Number(tokens.expires_in)
|
|
26
|
+
: undefined
|
|
27
|
+
return {
|
|
28
|
+
access_token: tokens.access_token,
|
|
29
|
+
refresh_token: tokens.refresh_token,
|
|
30
|
+
...(expiresAt ? { expires_at: expiresAt } : {}),
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function sleep(ms) {
|
|
35
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function withCredentialLock(dir, fn) {
|
|
39
|
+
mkdirSync(dir, { recursive: true })
|
|
40
|
+
try {
|
|
41
|
+
chmodSync(dir, 0o700)
|
|
42
|
+
} catch {
|
|
43
|
+
/* the directory may be owned by a parent we cannot chmod */
|
|
44
|
+
}
|
|
45
|
+
const lockPath = join(dir, 'credentials.lock')
|
|
46
|
+
const started = Date.now()
|
|
47
|
+
let handle
|
|
48
|
+
for (;;) {
|
|
49
|
+
try {
|
|
50
|
+
handle = await open(lockPath, 'wx')
|
|
51
|
+
break
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (error?.code !== 'EEXIST') throw error
|
|
54
|
+
try {
|
|
55
|
+
const info = await stat(lockPath)
|
|
56
|
+
if (Date.now() - info.mtimeMs > LOCK_STALE_MS) await unlink(lockPath)
|
|
57
|
+
} catch {
|
|
58
|
+
/* the other process released it */
|
|
59
|
+
}
|
|
60
|
+
if (Date.now() - started > LOCK_WAIT_MS) {
|
|
61
|
+
throw new Error('Not signed in. Run: amorphous login')
|
|
62
|
+
}
|
|
63
|
+
await sleep(25)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
return await fn()
|
|
68
|
+
} finally {
|
|
69
|
+
await handle.close()
|
|
70
|
+
try {
|
|
71
|
+
await unlink(lockPath)
|
|
72
|
+
} catch {
|
|
73
|
+
/* already released */
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
4
77
|
|
|
5
78
|
export function loadSession() {
|
|
6
79
|
const path = credentialsPath()
|
|
@@ -14,8 +87,57 @@ export function loadSession() {
|
|
|
14
87
|
|
|
15
88
|
export function saveSession(session) {
|
|
16
89
|
const path = credentialsPath()
|
|
17
|
-
|
|
18
|
-
|
|
90
|
+
const dir = dirname(path)
|
|
91
|
+
mkdirSync(dir, { recursive: true })
|
|
92
|
+
const tmp = join(dir, `.credentials.${process.pid}.${randomBytes(4).toString('hex')}.tmp`)
|
|
93
|
+
writeFileSync(tmp, JSON.stringify(session, null, 2), { mode: 0o600 })
|
|
94
|
+
chmodSync(tmp, 0o600)
|
|
95
|
+
renameSync(tmp, path)
|
|
96
|
+
chmodSync(path, 0o600)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function refreshWithSupabase(session) {
|
|
100
|
+
loadDotEnv()
|
|
101
|
+
const supabase = createClient(supabaseUrl(), supabaseAnonKey(), {
|
|
102
|
+
auth: { persistSession: false, autoRefreshToken: false },
|
|
103
|
+
})
|
|
104
|
+
const { data, error } = await supabase.auth.refreshSession({
|
|
105
|
+
refresh_token: session.refresh_token,
|
|
106
|
+
})
|
|
107
|
+
if (error || !data.session) throw error || new Error('Refresh failed')
|
|
108
|
+
return sessionRecord(data.session)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function ensureFreshSession(options = {}) {
|
|
112
|
+
const path = credentialsPath()
|
|
113
|
+
const dir = dirname(path)
|
|
114
|
+
return withCredentialLock(dir, async () => {
|
|
115
|
+
const now = options.now ?? Date.now()
|
|
116
|
+
const session = loadSession()
|
|
117
|
+
if (!session?.access_token) {
|
|
118
|
+
throw new Error('Not signed in. Run: amorphous login')
|
|
119
|
+
}
|
|
120
|
+
if (!options.force && !sessionNeedsRefresh(session, now)) return session
|
|
121
|
+
if (!session.refresh_token) {
|
|
122
|
+
throw new Error('Not signed in. Run: amorphous login')
|
|
123
|
+
}
|
|
124
|
+
const refresh = options.refresh ?? refreshWithSupabase
|
|
125
|
+
try {
|
|
126
|
+
const next = await refresh(session)
|
|
127
|
+
saveSession(next)
|
|
128
|
+
return next
|
|
129
|
+
} catch {
|
|
130
|
+
const again = loadSession()
|
|
131
|
+
if (
|
|
132
|
+
again?.access_token &&
|
|
133
|
+
again.access_token !== session.access_token &&
|
|
134
|
+
!sessionNeedsRefresh(again, now)
|
|
135
|
+
) {
|
|
136
|
+
return again
|
|
137
|
+
}
|
|
138
|
+
throw new Error('Not signed in. Run: amorphous login')
|
|
139
|
+
}
|
|
140
|
+
})
|
|
19
141
|
}
|
|
20
142
|
|
|
21
143
|
export function accessToken() {
|
|
@@ -26,7 +148,7 @@ export function accessToken() {
|
|
|
26
148
|
export function authHeaders(extra = {}) {
|
|
27
149
|
const token = accessToken()
|
|
28
150
|
if (!token) {
|
|
29
|
-
throw new Error('Not signed in. Run: amorphous login
|
|
151
|
+
throw new Error('Not signed in. Run: amorphous login')
|
|
30
152
|
}
|
|
31
153
|
return {
|
|
32
154
|
Authorization: `Bearer ${token}`,
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { randomBytes } from 'node:crypto'
|
|
5
|
+
import { ALIASES } from './help.mjs'
|
|
6
|
+
import { userSkillDirs } from './agent-contract.mjs'
|
|
7
|
+
import { CLI_VERSION } from './version.mjs'
|
|
8
|
+
|
|
9
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000
|
|
10
|
+
const NUDGE = 'Run: npx amorphous-cli@latest setup\n'
|
|
11
|
+
|
|
12
|
+
function parseVersion(v) {
|
|
13
|
+
if (typeof v !== 'string') return null
|
|
14
|
+
const m = v.match(/^(\d+)\.(\d+)\.(\d+)(?:-([^+]+))?(?:\+.*)?$/)
|
|
15
|
+
if (!m) return null
|
|
16
|
+
return {
|
|
17
|
+
major: Number(m[1]),
|
|
18
|
+
minor: Number(m[2]),
|
|
19
|
+
patch: Number(m[3]),
|
|
20
|
+
prerelease: m[4] ?? null,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function compareVersions(a, b) {
|
|
25
|
+
const va = parseVersion(a)
|
|
26
|
+
const vb = parseVersion(b)
|
|
27
|
+
if (!va || !vb) return null
|
|
28
|
+
if (va.major !== vb.major) return va.major < vb.major ? -1 : 1
|
|
29
|
+
if (va.minor !== vb.minor) return va.minor < vb.minor ? -1 : 1
|
|
30
|
+
if (va.patch !== vb.patch) return va.patch < vb.patch ? -1 : 1
|
|
31
|
+
if (va.prerelease && vb.prerelease) return 0
|
|
32
|
+
if (va.prerelease && !vb.prerelease) return -1
|
|
33
|
+
if (!va.prerelease && vb.prerelease) return 1
|
|
34
|
+
return 0
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function resolveCommand(argv) {
|
|
38
|
+
const raw = argv[0]
|
|
39
|
+
if (!raw) return ''
|
|
40
|
+
return ALIASES[raw] || raw
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function shouldCheck(argv, env = process.env) {
|
|
44
|
+
if ('CI' in env) return false
|
|
45
|
+
if (env.AMORPHOUS_NO_UPDATE_CHECK === '1') return false
|
|
46
|
+
if (!argv.length) return false
|
|
47
|
+
if (argv[0] === 'help') return false
|
|
48
|
+
if (argv.includes('-h') || argv.includes('--help')) return false
|
|
49
|
+
if (resolveCommand(argv) === 'mcp') return false
|
|
50
|
+
return true
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function existsSkill(path) {
|
|
54
|
+
try {
|
|
55
|
+
readFileSync(path)
|
|
56
|
+
return true
|
|
57
|
+
} catch {
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function skillStale(home, version) {
|
|
63
|
+
const dirs = userSkillDirs(home)
|
|
64
|
+
for (const dir of dirs) {
|
|
65
|
+
const skillPath = join(dir, 'SKILL.md')
|
|
66
|
+
if (!existsSkill(skillPath)) continue
|
|
67
|
+
const stampPath = join(dir, '.amorphous-version')
|
|
68
|
+
let stamp
|
|
69
|
+
try {
|
|
70
|
+
stamp = readFileSync(stampPath, 'utf8').trim()
|
|
71
|
+
} catch {
|
|
72
|
+
return true
|
|
73
|
+
}
|
|
74
|
+
const cmp = compareVersions(stamp, version)
|
|
75
|
+
if (cmp === null) continue
|
|
76
|
+
if (cmp < 0) return true
|
|
77
|
+
}
|
|
78
|
+
return false
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function cachePath(home) {
|
|
82
|
+
return join(home, '.config', 'amorphous', 'update-check.json')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function readCache(home) {
|
|
86
|
+
try {
|
|
87
|
+
const data = JSON.parse(readFileSync(cachePath(home), 'utf8'))
|
|
88
|
+
if (!data?.checkedAt) return null
|
|
89
|
+
return data
|
|
90
|
+
} catch {
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function cacheFresh(cache, now) {
|
|
96
|
+
if (!cache?.checkedAt) return false
|
|
97
|
+
const t = Date.parse(cache.checkedAt)
|
|
98
|
+
if (Number.isNaN(t)) return false
|
|
99
|
+
return now() - t < CACHE_TTL_MS
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function writeCache(home, data) {
|
|
103
|
+
const dir = join(home, '.config', 'amorphous')
|
|
104
|
+
mkdirSync(dir, { recursive: true })
|
|
105
|
+
const dest = cachePath(home)
|
|
106
|
+
const tmp = join(dir, `.update-check.${process.pid}.${randomBytes(4).toString('hex')}.tmp`)
|
|
107
|
+
writeFileSync(tmp, JSON.stringify(data) + '\n')
|
|
108
|
+
renameSync(tmp, dest)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function defaultRegistry(env) {
|
|
112
|
+
const raw = env.npm_config_registry || 'https://registry.npmjs.org'
|
|
113
|
+
return raw.replace(/\/$/, '')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function defaultFetchLatest(env = process.env) {
|
|
117
|
+
const registry = defaultRegistry(env)
|
|
118
|
+
return fetch(`${registry}/amorphous-cli/latest`, {
|
|
119
|
+
signal: AbortSignal.timeout(1500),
|
|
120
|
+
}).then(async (res) => {
|
|
121
|
+
if (!res.ok) throw new Error('registry')
|
|
122
|
+
const body = await res.json()
|
|
123
|
+
if (typeof body?.version !== 'string') throw new Error('registry')
|
|
124
|
+
return body.version
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function needsNudge({ cmd, version, latest, home }) {
|
|
129
|
+
if (latest && compareVersions(version, latest) === -1) return true
|
|
130
|
+
if (cmd !== 'setup' && skillStale(home, version)) return true
|
|
131
|
+
return false
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function startUpdateCheck({
|
|
135
|
+
argv,
|
|
136
|
+
env = process.env,
|
|
137
|
+
home = homedir(),
|
|
138
|
+
version = CLI_VERSION,
|
|
139
|
+
now = () => Date.now(),
|
|
140
|
+
fetchLatest = () => defaultFetchLatest(env),
|
|
141
|
+
stderr = process.stderr,
|
|
142
|
+
} = {}) {
|
|
143
|
+
let printed = false
|
|
144
|
+
let fetchPromise = null
|
|
145
|
+
let cache = null
|
|
146
|
+
let stale = false
|
|
147
|
+
|
|
148
|
+
if (shouldCheck(argv, env)) {
|
|
149
|
+
cache = readCache(home)
|
|
150
|
+
stale = !cacheFresh(cache, now)
|
|
151
|
+
if (stale) {
|
|
152
|
+
fetchPromise = fetchLatest().catch(() => null)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
async finish() {
|
|
158
|
+
try {
|
|
159
|
+
if (!shouldCheck(argv, env)) return
|
|
160
|
+
const cmd = resolveCommand(argv)
|
|
161
|
+
let latest = typeof cache?.latest === 'string' ? cache.latest : null
|
|
162
|
+
|
|
163
|
+
if (stale) {
|
|
164
|
+
const fetched = fetchPromise ? await fetchPromise : null
|
|
165
|
+
const next = { checkedAt: new Date(now()).toISOString() }
|
|
166
|
+
if (fetched) {
|
|
167
|
+
next.latest = fetched
|
|
168
|
+
latest = fetched
|
|
169
|
+
} else if (latest) {
|
|
170
|
+
next.latest = latest
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
writeCache(home, next)
|
|
174
|
+
} catch {
|
|
175
|
+
/* a read-only home must not change the command's exit code */
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!printed && needsNudge({ cmd, version, latest, home })) {
|
|
180
|
+
printed = true
|
|
181
|
+
stderr.write(NUDGE)
|
|
182
|
+
}
|
|
183
|
+
} catch {
|
|
184
|
+
/* a failed lookup must not change the command's exit code */
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { mkdtempSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { spawnSync } from 'node:child_process'
|
|
7
|
+
import test from 'node:test'
|
|
8
|
+
import { userSkillDirs } from './agent-contract.mjs'
|
|
9
|
+
import { CLI_VERSION } from './version.mjs'
|
|
10
|
+
import {
|
|
11
|
+
compareVersions,
|
|
12
|
+
shouldCheck,
|
|
13
|
+
skillStale,
|
|
14
|
+
startUpdateCheck,
|
|
15
|
+
} from './update-check.mjs'
|
|
16
|
+
|
|
17
|
+
const NUDGE = 'Run: npx amorphous-cli@latest setup\n'
|
|
18
|
+
|
|
19
|
+
test('compareVersions', () => {
|
|
20
|
+
assert.equal(compareVersions('1.0.0', '1.0.0'), 0)
|
|
21
|
+
assert.equal(compareVersions('1.0.0', '1.0.1'), -1)
|
|
22
|
+
assert.equal(compareVersions('1.1.0', '1.0.9'), 1)
|
|
23
|
+
assert.equal(compareVersions('2.0.0', '1.9.9'), 1)
|
|
24
|
+
assert.equal(compareVersions('1.0.0-beta', '1.0.0'), -1)
|
|
25
|
+
assert.equal(compareVersions('1.0.0-beta', '1.0.0-rc'), 0)
|
|
26
|
+
assert.equal(compareVersions('nope', '1.0.0'), null)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
function cacheFile(home) {
|
|
30
|
+
return join(home, '.config', 'amorphous', 'update-check.json')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function writeCache(home, data) {
|
|
34
|
+
const dir = join(home, '.config', 'amorphous')
|
|
35
|
+
mkdirSync(dir, { recursive: true })
|
|
36
|
+
writeFileSync(cacheFile(home), JSON.stringify(data) + '\n')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test('fresh cache nudges when cached latest is newer', async () => {
|
|
40
|
+
const home = mkdtempSync(join(tmpdir(), 'amorphous-uc-'))
|
|
41
|
+
const now = () => Date.parse('2026-01-01T12:00:00.000Z')
|
|
42
|
+
writeCache(home, { checkedAt: '2026-01-01T10:00:00.000Z', latest: '9.9.9' })
|
|
43
|
+
let fetchCalls = 0
|
|
44
|
+
let err = ''
|
|
45
|
+
const update = startUpdateCheck({
|
|
46
|
+
argv: ['list'],
|
|
47
|
+
env: {},
|
|
48
|
+
home,
|
|
49
|
+
version: '0.1.0',
|
|
50
|
+
now,
|
|
51
|
+
fetchLatest: async () => {
|
|
52
|
+
fetchCalls++
|
|
53
|
+
return '9.9.9'
|
|
54
|
+
},
|
|
55
|
+
stderr: { write: (s) => {
|
|
56
|
+
err += s
|
|
57
|
+
} },
|
|
58
|
+
})
|
|
59
|
+
await update.finish()
|
|
60
|
+
assert.equal(fetchCalls, 0)
|
|
61
|
+
assert.equal(err, NUDGE)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('stale cache fetches and rewrites cache', async () => {
|
|
65
|
+
const home = mkdtempSync(join(tmpdir(), 'amorphous-uc-'))
|
|
66
|
+
const t0 = Date.parse('2026-01-01T00:00:00.000Z')
|
|
67
|
+
let nowMs = t0
|
|
68
|
+
writeCache(home, { checkedAt: '2025-01-01T00:00:00.000Z', latest: '0.1.0' })
|
|
69
|
+
let fetchCalls = 0
|
|
70
|
+
const update = startUpdateCheck({
|
|
71
|
+
argv: ['setup'],
|
|
72
|
+
env: {},
|
|
73
|
+
home,
|
|
74
|
+
version: CLI_VERSION,
|
|
75
|
+
now: () => nowMs,
|
|
76
|
+
fetchLatest: async () => {
|
|
77
|
+
fetchCalls++
|
|
78
|
+
return CLI_VERSION
|
|
79
|
+
},
|
|
80
|
+
stderr: { write: () => {} },
|
|
81
|
+
})
|
|
82
|
+
nowMs = t0 + 1000
|
|
83
|
+
await update.finish()
|
|
84
|
+
assert.equal(fetchCalls, 1)
|
|
85
|
+
const cache = JSON.parse(readFileSync(cacheFile(home), 'utf8'))
|
|
86
|
+
assert.equal(cache.latest, CLI_VERSION)
|
|
87
|
+
assert.ok(cache.checkedAt)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('fetch failure keeps old latest and prints nothing', async () => {
|
|
91
|
+
const home = mkdtempSync(join(tmpdir(), 'amorphous-uc-'))
|
|
92
|
+
writeCache(home, { checkedAt: '2025-01-01T00:00:00.000Z', latest: '0.1.0' })
|
|
93
|
+
let err = ''
|
|
94
|
+
const update = startUpdateCheck({
|
|
95
|
+
argv: ['setup'],
|
|
96
|
+
env: {},
|
|
97
|
+
home,
|
|
98
|
+
version: CLI_VERSION,
|
|
99
|
+
now: () => Date.parse('2026-01-02T00:00:00.000Z'),
|
|
100
|
+
fetchLatest: async () => {
|
|
101
|
+
throw new Error('offline')
|
|
102
|
+
},
|
|
103
|
+
stderr: { write: (s) => {
|
|
104
|
+
err += s
|
|
105
|
+
} },
|
|
106
|
+
})
|
|
107
|
+
await update.finish()
|
|
108
|
+
assert.equal(err, '')
|
|
109
|
+
const cache = JSON.parse(readFileSync(cacheFile(home), 'utf8'))
|
|
110
|
+
assert.equal(cache.latest, '0.1.0')
|
|
111
|
+
assert.ok(cache.checkedAt)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('corrupt cache triggers fetch', async () => {
|
|
115
|
+
const home = mkdtempSync(join(tmpdir(), 'amorphous-uc-'))
|
|
116
|
+
const dir = join(home, '.config', 'amorphous')
|
|
117
|
+
mkdirSync(dir, { recursive: true })
|
|
118
|
+
writeFileSync(cacheFile(home), '{not json')
|
|
119
|
+
let fetchCalls = 0
|
|
120
|
+
const update = startUpdateCheck({
|
|
121
|
+
argv: ['setup'],
|
|
122
|
+
env: {},
|
|
123
|
+
home,
|
|
124
|
+
version: CLI_VERSION,
|
|
125
|
+
now: () => Date.now(),
|
|
126
|
+
fetchLatest: async () => {
|
|
127
|
+
fetchCalls++
|
|
128
|
+
return CLI_VERSION
|
|
129
|
+
},
|
|
130
|
+
stderr: { write: () => {} },
|
|
131
|
+
})
|
|
132
|
+
await update.finish()
|
|
133
|
+
assert.equal(fetchCalls, 1)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('equal or newer running version stays quiet', async () => {
|
|
137
|
+
const home = mkdtempSync(join(tmpdir(), 'amorphous-uc-'))
|
|
138
|
+
writeCache(home, { checkedAt: new Date().toISOString(), latest: CLI_VERSION })
|
|
139
|
+
let err = ''
|
|
140
|
+
const update = startUpdateCheck({
|
|
141
|
+
argv: ['list'],
|
|
142
|
+
env: {},
|
|
143
|
+
home,
|
|
144
|
+
version: CLI_VERSION,
|
|
145
|
+
now: () => Date.now(),
|
|
146
|
+
fetchLatest: async () => '0.0.1',
|
|
147
|
+
stderr: { write: (s) => {
|
|
148
|
+
err += s
|
|
149
|
+
} },
|
|
150
|
+
})
|
|
151
|
+
await update.finish()
|
|
152
|
+
assert.equal(err, '')
|
|
153
|
+
|
|
154
|
+
writeCache(home, { checkedAt: new Date().toISOString(), latest: '0.1.0' })
|
|
155
|
+
err = ''
|
|
156
|
+
const update2 = startUpdateCheck({
|
|
157
|
+
argv: ['list'],
|
|
158
|
+
env: {},
|
|
159
|
+
home,
|
|
160
|
+
version: CLI_VERSION,
|
|
161
|
+
now: () => Date.now(),
|
|
162
|
+
fetchLatest: async () => '0.1.0',
|
|
163
|
+
stderr: { write: (s) => {
|
|
164
|
+
err += s
|
|
165
|
+
} },
|
|
166
|
+
})
|
|
167
|
+
await update2.finish()
|
|
168
|
+
assert.equal(err, '')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('shouldCheck', () => {
|
|
172
|
+
const off = {}
|
|
173
|
+
assert.equal(shouldCheck([], off), false)
|
|
174
|
+
assert.equal(shouldCheck(['help'], off), false)
|
|
175
|
+
assert.equal(shouldCheck(['list', '--help'], off), false)
|
|
176
|
+
assert.equal(shouldCheck(['login', '-h'], off), false)
|
|
177
|
+
assert.equal(shouldCheck(['mcp'], off), false)
|
|
178
|
+
assert.equal(shouldCheck(['mcp', 'whatever'], off), false)
|
|
179
|
+
assert.equal(shouldCheck(['setup'], { CI: '1' }), false)
|
|
180
|
+
assert.equal(shouldCheck(['setup'], { AMORPHOUS_NO_UPDATE_CHECK: '1' }), false)
|
|
181
|
+
assert.equal(shouldCheck(['setup'], off), true)
|
|
182
|
+
assert.equal(shouldCheck(['grant'], off), true)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test('skillStale', () => {
|
|
186
|
+
const home = mkdtempSync(join(tmpdir(), 'amorphous-skill-stale-'))
|
|
187
|
+
assert.equal(skillStale(home, CLI_VERSION), false)
|
|
188
|
+
|
|
189
|
+
const [d0, d1] = userSkillDirs(home)
|
|
190
|
+
mkdirSync(d0, { recursive: true })
|
|
191
|
+
writeFileSync(join(d0, 'SKILL.md'), 'skill')
|
|
192
|
+
assert.equal(skillStale(home, CLI_VERSION), true)
|
|
193
|
+
|
|
194
|
+
writeFileSync(join(d0, '.amorphous-version'), '0.1.0\n')
|
|
195
|
+
assert.equal(skillStale(home, CLI_VERSION), true)
|
|
196
|
+
|
|
197
|
+
writeFileSync(join(d0, '.amorphous-version'), `${CLI_VERSION}\n`)
|
|
198
|
+
assert.equal(skillStale(home, CLI_VERSION), false)
|
|
199
|
+
|
|
200
|
+
writeFileSync(join(d0, '.amorphous-version'), 'not-a-version\n')
|
|
201
|
+
assert.equal(skillStale(home, CLI_VERSION), false)
|
|
202
|
+
|
|
203
|
+
mkdirSync(d1, { recursive: true })
|
|
204
|
+
writeFileSync(join(d1, 'SKILL.md'), 'skill')
|
|
205
|
+
writeFileSync(join(d1, '.amorphous-version'), '9.9.9\n')
|
|
206
|
+
assert.equal(skillStale(home, CLI_VERSION), false)
|
|
207
|
+
|
|
208
|
+
writeFileSync(join(d1, '.amorphous-version'), '0.1.0\n')
|
|
209
|
+
assert.equal(skillStale(home, CLI_VERSION), true)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
test('setup skips skill stamp nudge when registry matches', async () => {
|
|
213
|
+
const home = mkdtempSync(join(tmpdir(), 'amorphous-uc-'))
|
|
214
|
+
const [d0] = userSkillDirs(home)
|
|
215
|
+
mkdirSync(d0, { recursive: true })
|
|
216
|
+
writeFileSync(join(d0, 'SKILL.md'), 'skill')
|
|
217
|
+
writeCache(home, { checkedAt: new Date().toISOString(), latest: CLI_VERSION })
|
|
218
|
+
let err = ''
|
|
219
|
+
const update = startUpdateCheck({
|
|
220
|
+
argv: ['setup'],
|
|
221
|
+
env: {},
|
|
222
|
+
home,
|
|
223
|
+
version: CLI_VERSION,
|
|
224
|
+
now: () => Date.now(),
|
|
225
|
+
fetchLatest: async () => CLI_VERSION,
|
|
226
|
+
stderr: { write: (s) => {
|
|
227
|
+
err += s
|
|
228
|
+
} },
|
|
229
|
+
})
|
|
230
|
+
await update.finish()
|
|
231
|
+
assert.equal(err, '')
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
test('non-setup nudges on stale skill when registry matches', async () => {
|
|
235
|
+
const home = mkdtempSync(join(tmpdir(), 'amorphous-uc-'))
|
|
236
|
+
const [d0] = userSkillDirs(home)
|
|
237
|
+
mkdirSync(d0, { recursive: true })
|
|
238
|
+
writeFileSync(join(d0, 'SKILL.md'), 'skill')
|
|
239
|
+
writeCache(home, { checkedAt: new Date().toISOString(), latest: CLI_VERSION })
|
|
240
|
+
let err = ''
|
|
241
|
+
const update = startUpdateCheck({
|
|
242
|
+
argv: ['list'],
|
|
243
|
+
env: {},
|
|
244
|
+
home,
|
|
245
|
+
version: CLI_VERSION,
|
|
246
|
+
now: () => Date.now(),
|
|
247
|
+
fetchLatest: async () => CLI_VERSION,
|
|
248
|
+
stderr: { write: (s) => {
|
|
249
|
+
err += s
|
|
250
|
+
} },
|
|
251
|
+
})
|
|
252
|
+
await update.finish()
|
|
253
|
+
assert.equal(err, NUDGE)
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
test('integration help skips update check file', () => {
|
|
257
|
+
const home = mkdtempSync(join(tmpdir(), 'amorphous-int-'))
|
|
258
|
+
const repoRoot = join(import.meta.dirname, '../..')
|
|
259
|
+
const env = { ...process.env, HOME: home, USERPROFILE: home }
|
|
260
|
+
delete env.AMORPHOUS_NO_UPDATE_CHECK
|
|
261
|
+
delete env.CI
|
|
262
|
+
const r = spawnSync(process.execPath, ['cli/index.mjs', '--help'], {
|
|
263
|
+
cwd: repoRoot,
|
|
264
|
+
env,
|
|
265
|
+
encoding: 'utf8',
|
|
266
|
+
})
|
|
267
|
+
assert.equal(r.status, 0)
|
|
268
|
+
assert.ok(!r.stderr.includes('Run: npx amorphous-cli@latest setup'))
|
|
269
|
+
assert.throws(() => readFileSync(cacheFile(home)), { code: 'ENOENT' })
|
|
270
|
+
})
|
package/lib/version.mjs
ADDED