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/lib/session.mjs CHANGED
@@ -1,6 +1,79 @@
1
- import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'
2
- import { dirname } from 'node:path'
3
- import { credentialsPath } from './config.mjs'
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
- mkdirSync(dirname(path), { recursive: true })
18
- writeFileSync(path, JSON.stringify(session, null, 2))
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 <email>')
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
+ })
@@ -0,0 +1,3 @@
1
+ import { readFileSync } from 'node:fs'
2
+
3
+ export const CLI_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version