amorphous-cli 0.3.6 → 0.3.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/lib/files.mjs +1 -1
- package/lib/serve.mjs +75 -4
- package/lib/serve.test.mjs +81 -4
- package/package.json +1 -1
- package/skill/amorphous/SKILL.md +1 -1
package/lib/files.mjs
CHANGED
|
@@ -69,7 +69,7 @@ export function insertLocalRecord(cwd, row) {
|
|
|
69
69
|
body.visibility = body.visibility === 'private' ? 'private' : 'shared'
|
|
70
70
|
if (!body.id) body.id = randomUUID()
|
|
71
71
|
body.revision = 1
|
|
72
|
-
body.collection = 'default'
|
|
72
|
+
body.collection = typeof body.collection === 'string' && body.collection ? body.collection : 'default'
|
|
73
73
|
body.created_by = null
|
|
74
74
|
const rows = recordsFromLocal(cwd)
|
|
75
75
|
if (rows.some((r) => r.id === body.id)) return { status: 409, message: 'Conflict' }
|
package/lib/serve.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { existsSync, readFileSync, watch } from 'node:fs'
|
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { apiFetch } from './api.mjs'
|
|
5
5
|
import { randomUUID } from 'node:crypto'
|
|
6
|
-
import { insertLocalRecord, readProjectFiles, recordsFromLocal, replaceLocalRecord } from './files.mjs'
|
|
6
|
+
import { insertLocalRecord, readProjectFiles, recordsFromLocal, replaceLocalRecord, writeLocalRecords } from './files.mjs'
|
|
7
7
|
|
|
8
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
9
|
#amorphous-serve button{border:0;border-radius:999px;padding:4px 8px;font:inherit;color:#a1a1aa;background:transparent;cursor:pointer}
|
|
@@ -185,6 +185,50 @@ export function startServe(options) {
|
|
|
185
185
|
const invoke = (name, input) => hosted(`${appPath}/actions/${name}/invoke${environmentQuery}`, {
|
|
186
186
|
method: 'POST', headers: { 'Idempotency-Key': randomUUID() }, body: JSON.stringify(input),
|
|
187
187
|
})
|
|
188
|
+
function localAction(name, input) {
|
|
189
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) return { status: 'validation_error' }
|
|
190
|
+
const collection = typeof input.collection === 'string' && input.collection ? input.collection : null
|
|
191
|
+
const visibility = input.visibility
|
|
192
|
+
if (!collection || !['shared', 'private'].includes(visibility) ||
|
|
193
|
+
(name.startsWith('private.') && (collection !== 'default' || visibility !== 'private')) ||
|
|
194
|
+
(name.startsWith('items.') && (collection !== 'items' || visibility !== 'shared')) ||
|
|
195
|
+
(name.startsWith('form.') && (collection !== 'form' || visibility !== 'shared'))) {
|
|
196
|
+
return { status: 'validation_error' }
|
|
197
|
+
}
|
|
198
|
+
const rows = recordsFromLocal(cwd)
|
|
199
|
+
const wireRow = (row) => {
|
|
200
|
+
const { id, revision, visibility: rowVisibility, collection: rowCollection, created_by, ...body } = row
|
|
201
|
+
return { id, revision, visibility: rowVisibility, collection: rowCollection, created_by, body }
|
|
202
|
+
}
|
|
203
|
+
if (['records.query', 'private.query', 'form.mine'].includes(name)) {
|
|
204
|
+
const filtered = rows.filter((row) => row.collection === collection && row.visibility === visibility &&
|
|
205
|
+
(!input.id || row.id === input.id))
|
|
206
|
+
const after = input.cursor ? filtered.findIndex((row) => row.id === input.cursor) + 1 : 0
|
|
207
|
+
return { status: 'ok', output: filtered.slice(after, after + Math.max(1, Math.min(100, Number(input.limit) || 100))).map(wireRow) }
|
|
208
|
+
}
|
|
209
|
+
if (['records.create', 'private.create', 'items.add', 'form.submit'].includes(name)) {
|
|
210
|
+
if (!input.body || typeof input.body !== 'object' || Array.isArray(input.body)) return { status: 'validation_error' }
|
|
211
|
+
const result = insertLocalRecord(cwd, { ...input.body, ...(input.id ? { id: input.id } : {}), visibility, collection })
|
|
212
|
+
if (result.status !== 200) return { status: result.status === 409 ? 'conflict' : 'validation_error' }
|
|
213
|
+
return { status: 'ok', output: wireRow(result.row) }
|
|
214
|
+
}
|
|
215
|
+
if (['records.patch', 'private.patch', 'items.done', 'items.move', 'items.edit', 'form.edit'].includes(name)) {
|
|
216
|
+
const row = rows.find((entry) => entry.id === input.id)
|
|
217
|
+
if (!row || row.visibility !== visibility || row.collection !== collection) return { status: 'not_found' }
|
|
218
|
+
if (row.revision !== input.expectedRevision) return { status: 'conflict' }
|
|
219
|
+
if (!input.body || typeof input.body !== 'object' || Array.isArray(input.body)) return { status: 'validation_error' }
|
|
220
|
+
const result = replaceLocalRecord(cwd, row.id, input.body)
|
|
221
|
+
return result.status === 200 ? { status: 'ok', output: wireRow(result.row) } : { status: 'not_found' }
|
|
222
|
+
}
|
|
223
|
+
if (['records.delete', 'private.delete', 'items.delete'].includes(name)) {
|
|
224
|
+
const row = rows.find((entry) => entry.id === input.id)
|
|
225
|
+
if (!row || row.visibility !== visibility || row.collection !== collection) return { status: 'not_found' }
|
|
226
|
+
if (row.revision !== input.expectedRevision) return { status: 'conflict' }
|
|
227
|
+
writeLocalRecords(cwd, rows.filter((entry) => entry.id !== row.id))
|
|
228
|
+
return { status: 'ok', output: { id: row.id, deleted: true } }
|
|
229
|
+
}
|
|
230
|
+
return { status: 'validation_error' }
|
|
231
|
+
}
|
|
188
232
|
const recordFromOutput = (row) => ({ ...row.body, id: row.id, revision: row.revision,
|
|
189
233
|
visibility: row.visibility, collection: row.collection, created_by: row.created_by })
|
|
190
234
|
async function hostedRecords(method, url, req) {
|
|
@@ -252,6 +296,10 @@ export function startServe(options) {
|
|
|
252
296
|
return
|
|
253
297
|
}
|
|
254
298
|
if (req.method === 'GET' && url.pathname === '/') {
|
|
299
|
+
send(res, 302, null, { Location: `/${abstractId}/${url.search}` })
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
if (req.method === 'GET' && (url.pathname === `/${abstractId}` || url.pathname === `/${abstractId}/`)) {
|
|
255
303
|
const mode = recordsMode(req, url)
|
|
256
304
|
const headers = { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }
|
|
257
305
|
if (url.searchParams.get('records') === 'local' || url.searchParams.get('records') === 'prod') {
|
|
@@ -268,6 +316,28 @@ export function startServe(options) {
|
|
|
268
316
|
send(res, 200, readProjectFiles(cwd).js, { 'Content-Type': 'text/javascript; charset=utf-8' })
|
|
269
317
|
return
|
|
270
318
|
}
|
|
319
|
+
const actionMatch = /^\/api\/v1\/apps\/([^/]+)\/actions\/([a-z.]+)\/invoke$/.exec(url.pathname)
|
|
320
|
+
if (actionMatch) {
|
|
321
|
+
if (req.method !== 'POST') {
|
|
322
|
+
send(res, 405, { message: 'Method not allowed' })
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
if (actionMatch[1] !== abstractId) {
|
|
326
|
+
send(res, 404, { message: 'Not found' })
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
const body = await readJson(req)
|
|
330
|
+
if (recordsMode(req, url) === 'prod') {
|
|
331
|
+
const receipt = await hosted(`${appPath}/actions/${actionMatch[2]}/invoke${environmentQuery}`, {
|
|
332
|
+
method: 'POST', headers: { 'Idempotency-Key': req.headers['idempotency-key'] || randomUUID() },
|
|
333
|
+
body: JSON.stringify(body),
|
|
334
|
+
})
|
|
335
|
+
send(res, 200, receipt)
|
|
336
|
+
} else {
|
|
337
|
+
send(res, 200, localAction(actionMatch[2], body))
|
|
338
|
+
}
|
|
339
|
+
return
|
|
340
|
+
}
|
|
271
341
|
if (req.method === 'GET' && url.pathname === '/__amorphous/events') {
|
|
272
342
|
res.writeHead(200, {
|
|
273
343
|
'Content-Type': 'text/event-stream',
|
|
@@ -377,14 +447,15 @@ export function startServe(options) {
|
|
|
377
447
|
const address = server.address()
|
|
378
448
|
const port = typeof address === 'object' && address ? address.port : options.port
|
|
379
449
|
const url = `http://${host}:${port}/`
|
|
380
|
-
|
|
450
|
+
const appUrl = `${url}${abstractId}/`
|
|
451
|
+
resolve({ url, appUrl, port })
|
|
381
452
|
})
|
|
382
453
|
})
|
|
383
454
|
|
|
384
455
|
return ready.then((info) => {
|
|
385
456
|
if (!options.silent) {
|
|
386
|
-
if (options.json) console.log(JSON.stringify({ url: info.
|
|
387
|
-
else console.log(info.
|
|
457
|
+
if (options.json) console.log(JSON.stringify({ url: info.appUrl }))
|
|
458
|
+
else console.log(info.appUrl)
|
|
388
459
|
}
|
|
389
460
|
return {
|
|
390
461
|
...info,
|
package/lib/serve.test.mjs
CHANGED
|
@@ -45,9 +45,11 @@ test('serve reloads local source and keeps prod records off disk', async () => {
|
|
|
45
45
|
})
|
|
46
46
|
|
|
47
47
|
try {
|
|
48
|
-
const origin = running.url.
|
|
48
|
+
const origin = new URL(running.url).origin
|
|
49
|
+
assert.equal(new URL(running.appUrl).pathname, '/00000000-0000-4000-8000-000000000001/')
|
|
49
50
|
const page = await fetch(running.url)
|
|
50
51
|
const html = await page.text()
|
|
52
|
+
assert.equal(page.url, running.appUrl)
|
|
51
53
|
assert.match(html, /id="who"/)
|
|
52
54
|
assert.doesNotMatch(html, /alert\(1\)/)
|
|
53
55
|
assert.match(html, /data-set="local" aria-pressed="true"/)
|
|
@@ -127,6 +129,81 @@ test('serve reloads local source and keeps prod records off disk', async () => {
|
|
|
127
129
|
}
|
|
128
130
|
})
|
|
129
131
|
|
|
132
|
+
test('serve routes generated recipe actions to local records in local mode', async () => {
|
|
133
|
+
const cwd = project()
|
|
134
|
+
const appId = '00000000-0000-4000-8000-000000000001'
|
|
135
|
+
const calls = []
|
|
136
|
+
const running = await startServe({ cwd, abstractId: appId, silent: true,
|
|
137
|
+
hostedFetch: async (path) => { calls.push(path); throw new Error('Local actions must not reach production') },
|
|
138
|
+
})
|
|
139
|
+
try {
|
|
140
|
+
const origin = new URL(running.url).origin
|
|
141
|
+
const invoke = async (name, input) => fetch(`${running.url}api/v1/apps/${appId}/actions/${name}/invoke`, {
|
|
142
|
+
method: 'POST', headers: { Origin: origin, 'Content-Type': 'application/json' }, body: JSON.stringify(input),
|
|
143
|
+
})
|
|
144
|
+
const create = await invoke('private.create', { collection: 'default', visibility: 'private',
|
|
145
|
+
body: { thing_to_track: 'QA local entry', date: '2026-10-22', done: false } })
|
|
146
|
+
assert.equal(create.status, 200)
|
|
147
|
+
const created = await create.json()
|
|
148
|
+
assert.equal(created.status, 'ok')
|
|
149
|
+
assert.equal(created.output.body.thing_to_track, 'QA local entry')
|
|
150
|
+
assert.equal(created.output.visibility, 'private')
|
|
151
|
+
|
|
152
|
+
const query = await invoke('private.query', { collection: 'default', visibility: 'private', limit: 100 })
|
|
153
|
+
assert.deepEqual((await query.json()).output.map(row => row.body.thing_to_track), ['QA local entry'])
|
|
154
|
+
assert.match(readFileSync(join(cwd, 'private.ndjson'), 'utf8'), /QA local entry/)
|
|
155
|
+
|
|
156
|
+
const remove = await invoke('private.delete', { collection: 'default', visibility: 'private',
|
|
157
|
+
id: created.output.id, expectedRevision: created.output.revision })
|
|
158
|
+
assert.equal((await remove.json()).status, 'ok')
|
|
159
|
+
assert.equal((await invoke('private.query', { collection: 'default', visibility: 'private', limit: 100 })
|
|
160
|
+
.then(response => response.json())).output.length, 0)
|
|
161
|
+
assert.equal(readFileSync(join(cwd, 'private.ndjson'), 'utf8'), '')
|
|
162
|
+
|
|
163
|
+
const item = await invoke('items.add', { collection: 'items', visibility: 'shared', body: { title: 'QA milk' } })
|
|
164
|
+
const itemCreated = (await item.json()).output
|
|
165
|
+
assert.equal(itemCreated.collection, 'items')
|
|
166
|
+
const itemQuery = await invoke('records.query', { collection: 'items', visibility: 'shared', limit: 100 })
|
|
167
|
+
assert.equal((await itemQuery.json()).output[0].body.title, 'QA milk')
|
|
168
|
+
const itemDone = await invoke('items.done', { collection: 'items', visibility: 'shared', id: itemCreated.id,
|
|
169
|
+
expectedRevision: itemCreated.revision, body: { done: true } })
|
|
170
|
+
assert.equal((await itemDone.json()).output.body.done, true)
|
|
171
|
+
const itemDelete = await invoke('items.delete', { collection: 'items', visibility: 'shared', id: itemCreated.id,
|
|
172
|
+
expectedRevision: itemCreated.revision + 1 })
|
|
173
|
+
assert.equal((await itemDelete.json()).status, 'ok')
|
|
174
|
+
|
|
175
|
+
const signup = await invoke('form.submit', { collection: 'form', visibility: 'shared', body: { email: 'qa@example.invalid' } })
|
|
176
|
+
assert.equal((await signup.json()).output.collection, 'form')
|
|
177
|
+
assert.match(readFileSync(join(cwd, 'data.ndjson'), 'utf8'), /qa@example\.invalid/)
|
|
178
|
+
assert.deepEqual(calls, [])
|
|
179
|
+
} finally { await running.close() }
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('serve proxies generated recipe actions to the selected hosted environment in live mode', async () => {
|
|
183
|
+
const appId = '00000000-0000-4000-8000-000000000001'
|
|
184
|
+
const calls = []
|
|
185
|
+
const running = await startServe({ cwd: project(), abstractId: appId, silent: true, environment: 'preview',
|
|
186
|
+
hostedFetch: async (path, init) => {
|
|
187
|
+
calls.push({ path, init })
|
|
188
|
+
return { status: 'ok', output: [{ id: 'hosted-entry' }] }
|
|
189
|
+
},
|
|
190
|
+
})
|
|
191
|
+
try {
|
|
192
|
+
const origin = new URL(running.url).origin
|
|
193
|
+
const mode = await fetch(`${origin}/__amorphous/mode`, { method: 'POST',
|
|
194
|
+
headers: { Origin: origin, 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'prod' }) })
|
|
195
|
+
const response = await fetch(`${origin}/api/v1/apps/${appId}/actions/private.query/invoke`, {
|
|
196
|
+
method: 'POST', headers: { Origin: origin, Cookie: cookieFrom(mode), 'Content-Type': 'application/json',
|
|
197
|
+
'Idempotency-Key': 'recipe-query-1' }, body: JSON.stringify({ collection: 'default', visibility: 'private', limit: 10 }),
|
|
198
|
+
})
|
|
199
|
+
assert.equal(response.status, 200)
|
|
200
|
+
assert.deepEqual(await response.json(), { status: 'ok', output: [{ id: 'hosted-entry' }] })
|
|
201
|
+
assert.equal(calls.length, 1)
|
|
202
|
+
assert.equal(calls[0].path, `/api/v1/apps/${appId}/actions/private.query/invoke?environment=preview`)
|
|
203
|
+
assert.equal(calls[0].init.headers['Idempotency-Key'], 'recipe-query-1')
|
|
204
|
+
} finally { await running.close() }
|
|
205
|
+
})
|
|
206
|
+
|
|
130
207
|
test('serve live mode invokes hosted actions with no pass cookie', async () => {
|
|
131
208
|
const cwd = project()
|
|
132
209
|
const calls = []
|
|
@@ -144,7 +221,7 @@ test('serve live mode invokes hosted actions with no pass cookie', async () => {
|
|
|
144
221
|
},
|
|
145
222
|
})
|
|
146
223
|
try {
|
|
147
|
-
const origin = running.url.
|
|
224
|
+
const origin = new URL(running.url).origin
|
|
148
225
|
const mode = await fetch(`${running.url}__amorphous/mode`, { method: 'POST',
|
|
149
226
|
headers: { Origin: origin, 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'prod' }) })
|
|
150
227
|
assert.equal(mode.status, 204)
|
|
@@ -177,7 +254,7 @@ test('serve continues both visibilities with a nondefault hosted page limit', as
|
|
|
177
254
|
},
|
|
178
255
|
})
|
|
179
256
|
try {
|
|
180
|
-
const origin = running.url.
|
|
257
|
+
const origin = new URL(running.url).origin
|
|
181
258
|
const mode = await fetch(`${running.url}__amorphous/mode`, { method: 'POST',
|
|
182
259
|
headers: { Origin: origin, 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'prod' }) })
|
|
183
260
|
const found = await (await fetch(`${running.url}__amorphous/data`,
|
|
@@ -229,7 +306,7 @@ test('serve exposes scoped hosted undo and keeps local undo hosted-only', async
|
|
|
229
306
|
},
|
|
230
307
|
})
|
|
231
308
|
try {
|
|
232
|
-
const origin = running.url.
|
|
309
|
+
const origin = new URL(running.url).origin
|
|
233
310
|
const localHistory = await fetch(`${running.url}__amorphous/recovery/${recordId}`)
|
|
234
311
|
assert.deepEqual(await localHistory.json(), { revisions: [] })
|
|
235
312
|
const localUndo = await fetch(`${running.url}__amorphous/recovery/${recordId}`, {
|
package/package.json
CHANGED
package/skill/amorphous/SKILL.md
CHANGED
|
@@ -48,7 +48,7 @@ Then choose the path that matches the request:
|
|
|
48
48
|
- **Existing abstract in a new folder:** Find the abstract with `amorphous-cli list --view all`, or use the id/name the person gave you. If the person has owner or editor access and the task needs source or records, run `amorphous-cli pull <id-or-name>` first in a new, empty project folder. This creates `.amorphous.json` and downloads the draft and records. Then run `amorphous-cli context` once, and only then read the guides and source files. `context` alone does not bind the folder; it writes guide files, so running explicit `pull <id-or-name>` afterward will fail because the directory is no longer empty. If the folder is not empty, do not risk its files: use a new empty folder or ask the person which existing project folder to use. If they have view or use access, skip pull. Run `amorphous-cli context <id-or-name>` once to load the guides. When the task needs live interaction, run `amorphous-cli open <id-or-name>` and open the printed Amorphous page; use its **Open live abstract** link to launch the app. The CLI command prints the page URL; it does not launch a browser itself. A use member can submit a source recommendation only when the source files are already available in an authorized project folder; without them, ask the owner/editor to provide the source or grant edit access.
|
|
49
49
|
- If context fails, do not read existing guide files: they may belong to another account or be stale after access was revoked. Preserve unsaved edits and ask the person to restore access or reconnect. Do not run context again during this task.
|
|
50
50
|
- **Access:** View members can open the live abstract and load its guides, but cannot pull, recommend, or change source. Use members can use the live abstract and submit source recommendations for the owner to review when source files are already available to them. Only owners and editors can pull and edit source or publish a version; only the owner can invite/remove members, change visibility, or make a version live. Follow the access already granted; ask the owner for a role change if the requested work needs more access.
|
|
51
|
-
- **New abstract:** Run `amorphous-cli create --name "..." [--template blank|list|board|landing]`. The list, board, and landing starters are working examples, not just mockups.
|
|
51
|
+
- **New abstract:** Run `amorphous-cli create --name "..." [--template blank|list|board|landing]`. The list, board, and landing starters are working examples, not just mockups. When Amorphous provides a recipe JSON (for example, **Start with an agent** on the homepage), save it in the project folder and run `amorphous-cli create --recipe-config <file>` to create and bind that recipe and write its generated files into the folder.
|
|
52
52
|
|
|
53
53
|
After `context` succeeds, follow its final guide-reading instruction. It names only guide files that contain text; if neither guide contains text, continue without opening empty guide files. The private-guide handling warning appears only when that guide contains text.
|
|
54
54
|
|