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 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
- resolve({ url, port })
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.url }))
387
- else console.log(info.url)
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,
@@ -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.slice(0, -1)
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.slice(0, -1)
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.slice(0, -1)
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.slice(0, -1)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amorphous-cli",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "mcpName": "io.github.amorphous-one/amorphous",
5
5
  "description": "Make whatever you want, yourself, with your own agent, or with others.",
6
6
  "type": "module",
@@ -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