amorphous-cli 0.1.6 → 0.1.8
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 +1 -1
- package/hooks/session-start.mjs +60 -0
- package/lib/agent-contract.mjs +1 -1
- package/lib/agent-contract.test.mjs +4 -1
- package/lib/agent-hooks.mjs +134 -0
- package/lib/agent-hooks.test.mjs +46 -0
- package/lib/api.mjs +18 -18
- package/lib/commands.mjs +59 -16
- package/lib/config.mjs +30 -0
- package/lib/files.mjs +42 -0
- package/lib/help.mjs +16 -8
- package/lib/login.mjs +1 -1
- package/lib/serve.mjs +360 -0
- package/lib/serve.test.mjs +123 -0
- package/lib/session.mjs +5 -5
- package/lib/tool-result.mjs +8 -0
- package/lib/tool-result.test.mjs +17 -0
- package/lib/update-check.mjs +36 -5
- package/lib/update-check.test.mjs +27 -1
- package/mcp.mjs +11 -7
- package/package.json +3 -2
- package/skill/amorphous/SKILL.md +16 -11
- package/skill/amorphous/assets/app.js +1 -19
- package/skill/amorphous/assets/index.html +0 -5
- package/skill/amorphous/assets/styles.css +0 -20
package/lib/serve.mjs
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { createServer } from 'node:http'
|
|
2
|
+
import { existsSync, readFileSync, watch } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { apiFetch } from './api.mjs'
|
|
5
|
+
import { cliAbstractsFetchOrigin, cliAbstractsHostHeader } from './config.mjs'
|
|
6
|
+
import { insertLocalRecord, readProjectFiles, recordsFromLocal, replaceLocalRecord } from './files.mjs'
|
|
7
|
+
|
|
8
|
+
const BAR_CSS = `#amorphous-serve{position:fixed;z-index:2147483646;left:12px;bottom:12px;display:flex;gap:6px;align-items:center;padding:4px 6px 4px 10px;border-radius:999px;background:#18181b;color:#fff;font:500 12px/1 system-ui,-apple-system,sans-serif}
|
|
9
|
+
#amorphous-serve button{border:0;border-radius:999px;padding:4px 8px;font:inherit;color:#a1a1aa;background:transparent;cursor:pointer}
|
|
10
|
+
#amorphous-serve button[aria-pressed="true"]{background:#fff;color:#18181b}`
|
|
11
|
+
|
|
12
|
+
function stripScriptTags(html) {
|
|
13
|
+
return String(html || '').replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function readCookie(req, name) {
|
|
17
|
+
const raw = req.headers.cookie || ''
|
|
18
|
+
const match = new RegExp(`(?:^|;\\s*)${name}=([^;]+)`).exec(raw)
|
|
19
|
+
return match ? decodeURIComponent(match[1]) : ''
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function recordsMode(req, url) {
|
|
23
|
+
const query = url.searchParams.get('records')
|
|
24
|
+
if (query === 'prod' || query === 'local') return query
|
|
25
|
+
const cookie = readCookie(req, 'amorphous_records')
|
|
26
|
+
return cookie === 'prod' ? 'prod' : 'local'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function readJson(req) {
|
|
30
|
+
const chunks = []
|
|
31
|
+
for await (const chunk of req) chunks.push(chunk)
|
|
32
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
33
|
+
if (!text) return {}
|
|
34
|
+
return JSON.parse(text)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function send(res, status, body, headers = {}) {
|
|
38
|
+
const payload = body == null ? '' : typeof body === 'string' ? body : JSON.stringify(body)
|
|
39
|
+
res.writeHead(status, {
|
|
40
|
+
'Cache-Control': 'no-store',
|
|
41
|
+
...headers,
|
|
42
|
+
...(payload && typeof body !== 'string' ? { 'Content-Type': 'application/json; charset=utf-8' } : {}),
|
|
43
|
+
})
|
|
44
|
+
res.end(payload)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function pageHtml(html, mode) {
|
|
48
|
+
const pressed = (value) => (mode === value ? ' aria-pressed="true"' : '')
|
|
49
|
+
return `<!DOCTYPE html>
|
|
50
|
+
<html>
|
|
51
|
+
<head>
|
|
52
|
+
<meta charset="utf-8">
|
|
53
|
+
<link rel="stylesheet" href="/styles.css">
|
|
54
|
+
<style>${BAR_CSS}</style>
|
|
55
|
+
</head>
|
|
56
|
+
<body>
|
|
57
|
+
<div id="amorphous-root">${stripScriptTags(html)}</div>
|
|
58
|
+
<div id="amorphous-serve" data-mode="${mode}">
|
|
59
|
+
<span>Records</span>
|
|
60
|
+
<button type="button" data-set="local"${pressed('local')}>Local</button>
|
|
61
|
+
<button type="button" data-set="prod"${pressed('prod')}>Prod</button>
|
|
62
|
+
</div>
|
|
63
|
+
<script>
|
|
64
|
+
window.amorphous = {
|
|
65
|
+
login() {},
|
|
66
|
+
async me() {
|
|
67
|
+
const res = await fetch('/__amorphous/me')
|
|
68
|
+
if (!res.ok || res.status === 204) return null
|
|
69
|
+
const text = await res.text()
|
|
70
|
+
if (!text) return null
|
|
71
|
+
return JSON.parse(text)
|
|
72
|
+
},
|
|
73
|
+
async names(ids) {
|
|
74
|
+
const res = await fetch('/__amorphous/names', {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: { 'Content-Type': 'application/json' },
|
|
77
|
+
body: JSON.stringify({ ids })
|
|
78
|
+
})
|
|
79
|
+
if (!res.ok) return { names: {} }
|
|
80
|
+
return res.json()
|
|
81
|
+
},
|
|
82
|
+
data: {
|
|
83
|
+
async find() {
|
|
84
|
+
const res = await fetch('/__amorphous/data')
|
|
85
|
+
if (!res.ok) return []
|
|
86
|
+
const payload = await res.json()
|
|
87
|
+
return payload.records || []
|
|
88
|
+
},
|
|
89
|
+
async insert(row) {
|
|
90
|
+
const body = { ...row }
|
|
91
|
+
if (!body.id && crypto.randomUUID) body.id = crypto.randomUUID()
|
|
92
|
+
const res = await fetch('/__amorphous/data', {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: { 'Content-Type': 'application/json' },
|
|
95
|
+
body: JSON.stringify(body)
|
|
96
|
+
})
|
|
97
|
+
if (!res.ok) throw new Error('insert failed: ' + res.status)
|
|
98
|
+
return body
|
|
99
|
+
},
|
|
100
|
+
async replace(id, row) {
|
|
101
|
+
const res = await fetch('/__amorphous/data/' + encodeURIComponent(id), {
|
|
102
|
+
method: 'PUT',
|
|
103
|
+
headers: { 'Content-Type': 'application/json' },
|
|
104
|
+
body: JSON.stringify(row)
|
|
105
|
+
})
|
|
106
|
+
if (!res.ok) throw new Error('replace failed: ' + res.status)
|
|
107
|
+
return row
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
document.getElementById('amorphous-serve').addEventListener('click', function (event) {
|
|
112
|
+
var mode = event.target && event.target.getAttribute('data-set')
|
|
113
|
+
if (!mode) return
|
|
114
|
+
fetch('/__amorphous/mode', {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
headers: { 'Content-Type': 'application/json' },
|
|
117
|
+
body: JSON.stringify({ mode: mode })
|
|
118
|
+
}).then(function () { location.reload() })
|
|
119
|
+
})
|
|
120
|
+
new EventSource('/__amorphous/events').addEventListener('reload', function () {
|
|
121
|
+
location.reload()
|
|
122
|
+
})
|
|
123
|
+
</script>
|
|
124
|
+
<script src="/app.js"></script>
|
|
125
|
+
</body>
|
|
126
|
+
</html>`
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function passFromResponse(res) {
|
|
130
|
+
const list = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : []
|
|
131
|
+
const lines = list.length ? list : [res.headers.get('set-cookie') || '']
|
|
132
|
+
for (const line of lines) {
|
|
133
|
+
const match = /(?:^|,\s*)am_pass=([^;]+)/.exec(line)
|
|
134
|
+
if (match) return `am_pass=${match[1]}`
|
|
135
|
+
}
|
|
136
|
+
return null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function mintAbstractPass(abstractId) {
|
|
140
|
+
const data = await apiFetch(`/api/abstracts/${abstractId}/code`, { method: 'POST', body: '{}' })
|
|
141
|
+
const code = data?.code
|
|
142
|
+
if (!code) throw new Error('Could not open the abstract records.')
|
|
143
|
+
const res = await fetch(`${cliAbstractsFetchOrigin()}/${abstractId}?code=${encodeURIComponent(code)}`, {
|
|
144
|
+
redirect: 'manual',
|
|
145
|
+
headers: { ...cliAbstractsHostHeader() },
|
|
146
|
+
})
|
|
147
|
+
const cookie = passFromResponse(res)
|
|
148
|
+
if (!cookie) throw new Error('Could not open the abstract records.')
|
|
149
|
+
return cookie
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function proxyAbstract(abstractId, path, init, session) {
|
|
153
|
+
const headers = {
|
|
154
|
+
...cliAbstractsHostHeader(),
|
|
155
|
+
...(init.headers || {}),
|
|
156
|
+
}
|
|
157
|
+
if (session.cookie) headers.Cookie = session.cookie
|
|
158
|
+
let res
|
|
159
|
+
try {
|
|
160
|
+
res = await fetch(`${cliAbstractsFetchOrigin()}/${abstractId}${path}`, {
|
|
161
|
+
method: init.method || 'GET',
|
|
162
|
+
headers,
|
|
163
|
+
body: init.body,
|
|
164
|
+
redirect: 'manual',
|
|
165
|
+
})
|
|
166
|
+
} catch {
|
|
167
|
+
return new Response(JSON.stringify({ message: 'Could not reach the abstract.' }), {
|
|
168
|
+
status: 502,
|
|
169
|
+
headers: { 'Content-Type': 'application/json' },
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
if (res.status === 401 && session.cookie && !init.retried) {
|
|
173
|
+
session.cookie = null
|
|
174
|
+
session.cookie = await session.mint()
|
|
175
|
+
return proxyAbstract(abstractId, path, { ...init, retried: true }, session)
|
|
176
|
+
}
|
|
177
|
+
return res
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function startServe(options) {
|
|
181
|
+
const cwd = options.cwd
|
|
182
|
+
const abstractId = options.abstractId
|
|
183
|
+
const host = options.host || '127.0.0.1'
|
|
184
|
+
const session = {
|
|
185
|
+
cookie: null,
|
|
186
|
+
failed: false,
|
|
187
|
+
mint: options.mintPass || (() => mintAbstractPass(abstractId)),
|
|
188
|
+
}
|
|
189
|
+
const clients = new Set()
|
|
190
|
+
let reloadTimer = null
|
|
191
|
+
|
|
192
|
+
function notifyReload() {
|
|
193
|
+
clearTimeout(reloadTimer)
|
|
194
|
+
reloadTimer = setTimeout(() => {
|
|
195
|
+
for (const client of clients) client.write('event: reload\ndata: 1\n\n')
|
|
196
|
+
}, 60)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const watchers = []
|
|
200
|
+
for (const name of ['index.html', 'styles.css', 'app.js']) {
|
|
201
|
+
const path = join(cwd, name)
|
|
202
|
+
if (!existsSync(path)) continue
|
|
203
|
+
watchers.push(watch(path, notifyReload))
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function ensurePass() {
|
|
207
|
+
if (session.cookie) return session.cookie
|
|
208
|
+
if (session.failed) return null
|
|
209
|
+
try {
|
|
210
|
+
session.cookie = await session.mint()
|
|
211
|
+
return session.cookie
|
|
212
|
+
} catch {
|
|
213
|
+
session.failed = true
|
|
214
|
+
return null
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function prod(path, init) {
|
|
219
|
+
if (options.prodFetch) return options.prodFetch(abstractId, path, init, session)
|
|
220
|
+
const cookie = await ensurePass()
|
|
221
|
+
if (!cookie && init.auth !== false) {
|
|
222
|
+
return new Response(JSON.stringify({ message: 'Not signed in. Run: npx amorphous-cli@latest login' }), {
|
|
223
|
+
status: 401,
|
|
224
|
+
headers: { 'Content-Type': 'application/json' },
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
const res = await proxyAbstract(abstractId, path, { ...init, headers: { ...(init.headers || {}), Cookie: cookie || '' } }, session)
|
|
228
|
+
const text = await res.text()
|
|
229
|
+
return new Response(text, { status: res.status, headers: { 'Content-Type': res.headers.get('content-type') || 'application/json' } })
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const server = createServer(async (req, res) => {
|
|
233
|
+
try {
|
|
234
|
+
const url = new URL(req.url || '/', 'http://127.0.0.1')
|
|
235
|
+
if (req.method === 'GET' && url.pathname === '/') {
|
|
236
|
+
const mode = recordsMode(req, url)
|
|
237
|
+
const headers = { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }
|
|
238
|
+
if (url.searchParams.get('records') === 'local' || url.searchParams.get('records') === 'prod') {
|
|
239
|
+
headers['Set-Cookie'] = `amorphous_records=${mode}; Path=/; SameSite=Lax`
|
|
240
|
+
}
|
|
241
|
+
send(res, 200, pageHtml(readProjectFiles(cwd).html, mode), headers)
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
if (req.method === 'GET' && url.pathname === '/styles.css') {
|
|
245
|
+
send(res, 200, readProjectFiles(cwd).css, { 'Content-Type': 'text/css; charset=utf-8' })
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
if (req.method === 'GET' && url.pathname === '/app.js') {
|
|
249
|
+
send(res, 200, readProjectFiles(cwd).js, { 'Content-Type': 'text/javascript; charset=utf-8' })
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
if (req.method === 'GET' && url.pathname === '/__amorphous/events') {
|
|
253
|
+
res.writeHead(200, {
|
|
254
|
+
'Content-Type': 'text/event-stream',
|
|
255
|
+
'Cache-Control': 'no-cache',
|
|
256
|
+
Connection: 'keep-alive',
|
|
257
|
+
})
|
|
258
|
+
res.write(':\n\n')
|
|
259
|
+
clients.add(res)
|
|
260
|
+
req.on('close', () => clients.delete(res))
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
if (req.method === 'POST' && url.pathname === '/__amorphous/mode') {
|
|
264
|
+
const body = await readJson(req)
|
|
265
|
+
const mode = body.mode === 'prod' ? 'prod' : 'local'
|
|
266
|
+
send(res, 204, null, { 'Set-Cookie': `amorphous_records=${mode}; Path=/; SameSite=Lax` })
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
const mode = recordsMode(req, url)
|
|
270
|
+
if (req.method === 'GET' && url.pathname === '/__amorphous/me') {
|
|
271
|
+
const upstream = await prod('/api/me', { method: 'GET', auth: false })
|
|
272
|
+
const text = await upstream.text()
|
|
273
|
+
send(res, upstream.ok ? 200 : upstream.status, text, {
|
|
274
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
275
|
+
})
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
if (req.method === 'POST' && url.pathname === '/__amorphous/names') {
|
|
279
|
+
const body = await readJson(req)
|
|
280
|
+
const upstream = await prod('/api/names', {
|
|
281
|
+
method: 'POST',
|
|
282
|
+
auth: false,
|
|
283
|
+
headers: { 'Content-Type': 'application/json' },
|
|
284
|
+
body: JSON.stringify({ ids: Array.isArray(body.ids) ? body.ids : [] }),
|
|
285
|
+
})
|
|
286
|
+
const text = await upstream.text()
|
|
287
|
+
send(res, upstream.ok ? 200 : upstream.status, text, {
|
|
288
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
289
|
+
})
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
if (url.pathname === '/__amorphous/data' || url.pathname.startsWith('/__amorphous/data/')) {
|
|
293
|
+
if (mode === 'prod') {
|
|
294
|
+
const recordId = decodeURIComponent(url.pathname.slice('/__amorphous/data/'.length))
|
|
295
|
+
const path = req.method === 'PUT' ? `/api/data/${encodeURIComponent(recordId)}` : '/api/data'
|
|
296
|
+
const init = { method: req.method, auth: true }
|
|
297
|
+
if (req.method === 'POST' || req.method === 'PUT') {
|
|
298
|
+
init.headers = { 'Content-Type': 'application/json' }
|
|
299
|
+
init.body = JSON.stringify(await readJson(req))
|
|
300
|
+
}
|
|
301
|
+
const upstream = await prod(path, init)
|
|
302
|
+
const text = await upstream.text()
|
|
303
|
+
send(res, upstream.status, text, { 'Content-Type': 'application/json; charset=utf-8' })
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
if (req.method === 'GET') {
|
|
307
|
+
send(res, 200, { records: recordsFromLocal(cwd) })
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
if (req.method === 'POST') {
|
|
311
|
+
const result = insertLocalRecord(cwd, await readJson(req))
|
|
312
|
+
send(res, result.status, result.row || { message: result.message })
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
if (req.method === 'PUT') {
|
|
316
|
+
const recordId = decodeURIComponent(url.pathname.slice('/__amorphous/data/'.length))
|
|
317
|
+
const result = replaceLocalRecord(cwd, recordId, await readJson(req))
|
|
318
|
+
send(res, result.status, result.row || { message: result.message })
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
send(res, 404, { message: 'Not found' })
|
|
323
|
+
} catch (error) {
|
|
324
|
+
if (error instanceof SyntaxError) {
|
|
325
|
+
send(res, 400, { message: 'Invalid request' })
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
send(res, 500, { message: error instanceof Error ? error.message : 'Request failed' })
|
|
329
|
+
}
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
const ready = new Promise((resolve, reject) => {
|
|
333
|
+
server.once('error', reject)
|
|
334
|
+
server.listen(options.port || 0, host, () => {
|
|
335
|
+
server.removeListener('error', reject)
|
|
336
|
+
const address = server.address()
|
|
337
|
+
const port = typeof address === 'object' && address ? address.port : options.port
|
|
338
|
+
const url = `http://${host}:${port}/`
|
|
339
|
+
resolve({ url, port })
|
|
340
|
+
})
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
return ready.then((info) => {
|
|
344
|
+
if (!options.silent) {
|
|
345
|
+
if (options.json) console.log(JSON.stringify({ url: info.url }))
|
|
346
|
+
else console.log(info.url)
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
...info,
|
|
350
|
+
close() {
|
|
351
|
+
clearTimeout(reloadTimer)
|
|
352
|
+
for (const watcher of watchers) watcher.close()
|
|
353
|
+
for (const client of clients) client.end()
|
|
354
|
+
return new Promise((resolve, reject) => {
|
|
355
|
+
server.close((error) => (error ? reject(error) : resolve()))
|
|
356
|
+
})
|
|
357
|
+
},
|
|
358
|
+
}
|
|
359
|
+
})
|
|
360
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import test from 'node:test'
|
|
6
|
+
import { startServe } from './serve.mjs'
|
|
7
|
+
|
|
8
|
+
function project() {
|
|
9
|
+
const cwd = mkdtempSync(join(tmpdir(), 'amorphous-serve-'))
|
|
10
|
+
writeFileSync(join(cwd, 'index.html'), '<main><script>alert(1)</script><p id="who">Hi</p></main>')
|
|
11
|
+
writeFileSync(join(cwd, 'styles.css'), 'main{color:black}')
|
|
12
|
+
writeFileSync(join(cwd, 'app.js'), 'document.title = "served"')
|
|
13
|
+
writeFileSync(join(cwd, 'data.ndjson'), '')
|
|
14
|
+
writeFileSync(join(cwd, 'private.ndjson'), '')
|
|
15
|
+
return cwd
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function cookieFrom(res) {
|
|
19
|
+
const line = res.headers.get('set-cookie') || ''
|
|
20
|
+
const match = /amorphous_records=(local|prod)/.exec(line)
|
|
21
|
+
return match ? `amorphous_records=${match[1]}` : ''
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test('serve reloads local source and keeps prod records off disk', async () => {
|
|
25
|
+
const cwd = project()
|
|
26
|
+
const calls = []
|
|
27
|
+
const running = await startServe({
|
|
28
|
+
cwd,
|
|
29
|
+
abstractId: '00000000-0000-4000-8000-000000000001',
|
|
30
|
+
silent: true,
|
|
31
|
+
prodFetch: async (_id, path, init) => {
|
|
32
|
+
calls.push({ path, method: init.method, body: init.body })
|
|
33
|
+
if (path === '/api/data' && init.method === 'GET') {
|
|
34
|
+
return new Response(JSON.stringify({ records: [{ id: 'live', visibility: 'shared', text: 'remote' }] }), {
|
|
35
|
+
status: 200,
|
|
36
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
if (path === '/api/data' && init.method === 'POST') {
|
|
40
|
+
return new Response(JSON.stringify({ ok: true }), { status: 200 })
|
|
41
|
+
}
|
|
42
|
+
if (path === '/api/me') {
|
|
43
|
+
return new Response(JSON.stringify({ id: 'user-1', name: 'Ada' }), { status: 200 })
|
|
44
|
+
}
|
|
45
|
+
return new Response(JSON.stringify({ names: { 'user-1': 'Ada' } }), { status: 200 })
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const page = await fetch(running.url)
|
|
51
|
+
const html = await page.text()
|
|
52
|
+
assert.match(html, /id="who"/)
|
|
53
|
+
assert.doesNotMatch(html, /alert\(1\)/)
|
|
54
|
+
assert.match(html, /data-set="local" aria-pressed="true"/)
|
|
55
|
+
assert.match(html, /data-set="prod"/)
|
|
56
|
+
assert.equal(await (await fetch(`${running.url}styles.css`)).text(), 'main{color:black}')
|
|
57
|
+
assert.equal(await (await fetch(`${running.url}app.js`)).text(), 'document.title = "served"')
|
|
58
|
+
|
|
59
|
+
const inserted = await fetch(`${running.url}__amorphous/data`, {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers: { 'Content-Type': 'application/json' },
|
|
62
|
+
body: JSON.stringify({ id: 'local-1', text: 'note' }),
|
|
63
|
+
})
|
|
64
|
+
assert.equal(inserted.status, 200)
|
|
65
|
+
const found = await (await fetch(`${running.url}__amorphous/data`)).json()
|
|
66
|
+
assert.deepEqual(found.records, [{ id: 'local-1', text: 'note', visibility: 'shared' }])
|
|
67
|
+
|
|
68
|
+
const replaced = await fetch(`${running.url}__amorphous/data/local-1`, {
|
|
69
|
+
method: 'PUT',
|
|
70
|
+
headers: { 'Content-Type': 'application/json' },
|
|
71
|
+
body: JSON.stringify({ text: 'edited' }),
|
|
72
|
+
})
|
|
73
|
+
assert.equal(replaced.status, 200)
|
|
74
|
+
assert.match(readFileSync(join(cwd, 'data.ndjson'), 'utf8'), /"text":"edited"/)
|
|
75
|
+
assert.equal(calls.filter((call) => call.path === '/api/data').length, 0)
|
|
76
|
+
|
|
77
|
+
const again = await fetch(`${running.url}__amorphous/data`, {
|
|
78
|
+
method: 'POST',
|
|
79
|
+
headers: { 'Content-Type': 'application/json' },
|
|
80
|
+
body: JSON.stringify({ id: 'local-1', text: 'nope' }),
|
|
81
|
+
})
|
|
82
|
+
assert.equal(again.status, 409)
|
|
83
|
+
|
|
84
|
+
const switched = await fetch(`${running.url}__amorphous/mode`, {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
headers: { 'Content-Type': 'application/json' },
|
|
87
|
+
body: JSON.stringify({ mode: 'prod' }),
|
|
88
|
+
})
|
|
89
|
+
const jar = cookieFrom(switched)
|
|
90
|
+
assert.equal(jar, 'amorphous_records=prod')
|
|
91
|
+
const live = await (
|
|
92
|
+
await fetch(`${running.url}__amorphous/data`, { headers: { Cookie: jar } })
|
|
93
|
+
).json()
|
|
94
|
+
assert.equal(live.records[0].text, 'remote')
|
|
95
|
+
const pushed = await fetch(`${running.url}__amorphous/data`, {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: { 'Content-Type': 'application/json', Cookie: jar },
|
|
98
|
+
body: JSON.stringify({ id: 'from-prod', text: 'up' }),
|
|
99
|
+
})
|
|
100
|
+
assert.equal(pushed.status, 200)
|
|
101
|
+
assert.doesNotMatch(readFileSync(join(cwd, 'data.ndjson'), 'utf8'), /from-prod/)
|
|
102
|
+
assert.equal(calls.filter((call) => call.method === 'POST' && call.path === '/api/data').length, 1)
|
|
103
|
+
|
|
104
|
+
const me = await (await fetch(`${running.url}__amorphous/me`)).json()
|
|
105
|
+
assert.equal(me.name, 'Ada')
|
|
106
|
+
|
|
107
|
+
const events = await fetch(`${running.url}__amorphous/events`)
|
|
108
|
+
const reader = events.body.getReader()
|
|
109
|
+
const first = await reader.read()
|
|
110
|
+
assert.match(Buffer.from(first.value).toString(), /:/)
|
|
111
|
+
writeFileSync(join(cwd, 'styles.css'), 'main{color:white}')
|
|
112
|
+
let chunk = ''
|
|
113
|
+
const deadline = Date.now() + 2000
|
|
114
|
+
while (!chunk.includes('reload') && Date.now() < deadline) {
|
|
115
|
+
const next = await reader.read()
|
|
116
|
+
chunk += Buffer.from(next.value).toString()
|
|
117
|
+
}
|
|
118
|
+
assert.match(chunk, /reload/)
|
|
119
|
+
reader.cancel()
|
|
120
|
+
} finally {
|
|
121
|
+
await running.close()
|
|
122
|
+
}
|
|
123
|
+
})
|
package/lib/session.mjs
CHANGED
|
@@ -58,7 +58,7 @@ async function withCredentialLock(dir, fn) {
|
|
|
58
58
|
/* the other process released it */
|
|
59
59
|
}
|
|
60
60
|
if (Date.now() - started > LOCK_WAIT_MS) {
|
|
61
|
-
throw new Error('Not signed in. Run: amorphous login')
|
|
61
|
+
throw new Error('Not signed in. Run: npx amorphous-cli@latest login')
|
|
62
62
|
}
|
|
63
63
|
await sleep(25)
|
|
64
64
|
}
|
|
@@ -115,11 +115,11 @@ export async function ensureFreshSession(options = {}) {
|
|
|
115
115
|
const now = options.now ?? Date.now()
|
|
116
116
|
const session = loadSession()
|
|
117
117
|
if (!session?.access_token) {
|
|
118
|
-
throw new Error('Not signed in. Run: amorphous login')
|
|
118
|
+
throw new Error('Not signed in. Run: npx amorphous-cli@latest login')
|
|
119
119
|
}
|
|
120
120
|
if (!options.force && !sessionNeedsRefresh(session, now)) return session
|
|
121
121
|
if (!session.refresh_token) {
|
|
122
|
-
throw new Error('Not signed in. Run: amorphous login')
|
|
122
|
+
throw new Error('Not signed in. Run: npx amorphous-cli@latest login')
|
|
123
123
|
}
|
|
124
124
|
const refresh = options.refresh ?? refreshWithSupabase
|
|
125
125
|
try {
|
|
@@ -135,7 +135,7 @@ export async function ensureFreshSession(options = {}) {
|
|
|
135
135
|
) {
|
|
136
136
|
return again
|
|
137
137
|
}
|
|
138
|
-
throw new Error('Not signed in. Run: amorphous login')
|
|
138
|
+
throw new Error('Not signed in. Run: npx amorphous-cli@latest login')
|
|
139
139
|
}
|
|
140
140
|
})
|
|
141
141
|
}
|
|
@@ -148,7 +148,7 @@ export function accessToken() {
|
|
|
148
148
|
export function authHeaders(extra = {}) {
|
|
149
149
|
const token = accessToken()
|
|
150
150
|
if (!token) {
|
|
151
|
-
throw new Error('Not signed in. Run: amorphous login')
|
|
151
|
+
throw new Error('Not signed in. Run: npx amorphous-cli@latest login')
|
|
152
152
|
}
|
|
153
153
|
return {
|
|
154
154
|
Authorization: `Bearer ${token}`,
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Tool text for an MCP result. The model sees `content`, not the child process stderr. */
|
|
2
|
+
export function toolText(stdout, stderr, code) {
|
|
3
|
+
const out = String(stdout || '').trim()
|
|
4
|
+
const err = String(stderr || '').trim()
|
|
5
|
+
if (code !== 0) return err || out || `exit ${code}`
|
|
6
|
+
if (!err) return out || 'ok'
|
|
7
|
+
return out ? `${out}\n${err}` : err
|
|
8
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import { toolText } from './tool-result.mjs'
|
|
4
|
+
|
|
5
|
+
const NUDGE = 'Run: npx amorphous-cli@latest setup'
|
|
6
|
+
|
|
7
|
+
test('a successful command keeps stderr the model must see', () => {
|
|
8
|
+
assert.equal(toolText('[{"id":"a"}]', `${NUDGE}\n`, 0), `[{"id":"a"}]\n${NUDGE}`)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test('a failed command returns the error text', () => {
|
|
12
|
+
assert.equal(toolText('', 'Not signed in. Run: npx amorphous-cli@latest login\n', 1), 'Not signed in. Run: npx amorphous-cli@latest login')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('empty success is ok', () => {
|
|
16
|
+
assert.equal(toolText('', '', 0), 'ok')
|
|
17
|
+
})
|
package/lib/update-check.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
|
2
2
|
import { homedir } from 'node:os'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { randomBytes } from 'node:crypto'
|
|
5
|
+
import { installUserSkill } from './agent-contract.mjs'
|
|
5
6
|
import { ALIASES } from './help.mjs'
|
|
6
7
|
import { userSkillDirs } from './agent-contract.mjs'
|
|
7
8
|
import { CLI_VERSION } from './version.mjs'
|
|
@@ -59,6 +60,29 @@ function existsSkill(path) {
|
|
|
59
60
|
}
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
export function shouldRefreshSkill(home, version) {
|
|
64
|
+
let refresh = false
|
|
65
|
+
for (const dir of userSkillDirs(home)) {
|
|
66
|
+
const skillPath = join(dir, 'SKILL.md')
|
|
67
|
+
const stampPath = join(dir, '.amorphous-version')
|
|
68
|
+
if (!existsSkill(skillPath)) {
|
|
69
|
+
refresh = true
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
let stamp
|
|
73
|
+
try {
|
|
74
|
+
stamp = readFileSync(stampPath, 'utf8').trim()
|
|
75
|
+
} catch {
|
|
76
|
+
refresh = true
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
const cmp = compareVersions(stamp, version)
|
|
80
|
+
if (cmp != null && cmp > 0) return false
|
|
81
|
+
if (cmp == null || cmp < 0) refresh = true
|
|
82
|
+
}
|
|
83
|
+
return refresh
|
|
84
|
+
}
|
|
85
|
+
|
|
62
86
|
export function skillStale(home, version) {
|
|
63
87
|
const dirs = userSkillDirs(home)
|
|
64
88
|
for (const dir of dirs) {
|
|
@@ -125,10 +149,8 @@ export function defaultFetchLatest(env = process.env) {
|
|
|
125
149
|
})
|
|
126
150
|
}
|
|
127
151
|
|
|
128
|
-
function needsNudge({
|
|
129
|
-
|
|
130
|
-
if (cmd !== 'setup' && skillStale(home, version)) return true
|
|
131
|
-
return false
|
|
152
|
+
function needsNudge({ version, latest }) {
|
|
153
|
+
return Boolean(latest && compareVersions(version, latest) === -1)
|
|
132
154
|
}
|
|
133
155
|
|
|
134
156
|
export function startUpdateCheck({
|
|
@@ -176,7 +198,16 @@ export function startUpdateCheck({
|
|
|
176
198
|
}
|
|
177
199
|
}
|
|
178
200
|
|
|
179
|
-
|
|
201
|
+
const behind = needsNudge({ version, latest })
|
|
202
|
+
if (!behind && cmd !== 'setup' && shouldRefreshSkill(home, version)) {
|
|
203
|
+
try {
|
|
204
|
+
installUserSkill(home)
|
|
205
|
+
} catch {
|
|
206
|
+
/* a read-only home must not change the command's exit code */
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!printed && behind) {
|
|
180
211
|
printed = true
|
|
181
212
|
stderr.write(NUDGE)
|
|
182
213
|
}
|
|
@@ -231,7 +231,7 @@ test('setup skips skill stamp nudge when registry matches', async () => {
|
|
|
231
231
|
assert.equal(err, '')
|
|
232
232
|
})
|
|
233
233
|
|
|
234
|
-
test('non-setup
|
|
234
|
+
test('non-setup refreshes a stale skill when registry matches', async () => {
|
|
235
235
|
const home = mkdtempSync(join(tmpdir(), 'amorphous-uc-'))
|
|
236
236
|
const [d0] = userSkillDirs(home)
|
|
237
237
|
mkdirSync(d0, { recursive: true })
|
|
@@ -250,7 +250,33 @@ test('non-setup nudges on stale skill when registry matches', async () => {
|
|
|
250
250
|
} },
|
|
251
251
|
})
|
|
252
252
|
await update.finish()
|
|
253
|
+
assert.equal(err, '')
|
|
254
|
+
assert.equal(readFileSync(join(d0, '.amorphous-version'), 'utf8').trim(), CLI_VERSION)
|
|
255
|
+
assert.match(readFileSync(join(d0, 'SKILL.md'), 'utf8'), /npx amorphous-cli@latest/)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
test('a newer registry is not copied over the installed skill', async () => {
|
|
259
|
+
const home = mkdtempSync(join(tmpdir(), 'amorphous-uc-'))
|
|
260
|
+
const [d0] = userSkillDirs(home)
|
|
261
|
+
mkdirSync(d0, { recursive: true })
|
|
262
|
+
writeFileSync(join(d0, 'SKILL.md'), 'keep-this')
|
|
263
|
+
writeFileSync(join(d0, '.amorphous-version'), '0.0.1\n')
|
|
264
|
+
writeCache(home, { checkedAt: new Date().toISOString(), latest: '9.9.9' })
|
|
265
|
+
let err = ''
|
|
266
|
+
const update = startUpdateCheck({
|
|
267
|
+
argv: ['list'],
|
|
268
|
+
env: {},
|
|
269
|
+
home,
|
|
270
|
+
version: CLI_VERSION,
|
|
271
|
+
now: () => Date.now(),
|
|
272
|
+
fetchLatest: async () => '9.9.9',
|
|
273
|
+
stderr: { write: (s) => {
|
|
274
|
+
err += s
|
|
275
|
+
} },
|
|
276
|
+
})
|
|
277
|
+
await update.finish()
|
|
253
278
|
assert.equal(err, NUDGE)
|
|
279
|
+
assert.equal(readFileSync(join(d0, 'SKILL.md'), 'utf8'), 'keep-this')
|
|
254
280
|
})
|
|
255
281
|
|
|
256
282
|
test('integration help skips update check file', () => {
|