amorphous-cli 0.3.5 → 0.3.7

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.
@@ -22,6 +22,13 @@ test('starter app scripts parse as browser classic scripts', () => {
22
22
  }
23
23
  })
24
24
 
25
+ test('board starter explains horizontal scrolling on mobile', () => {
26
+ const dir = mkdtempSync(join(tmpdir(), 'amorphous-board-scroll-hint-'))
27
+ writeStarter(dir, 'board')
28
+ const html = readFileSync(join(dir, 'index.html'), 'utf8')
29
+ assert.match(html, /Scroll sideways to see all columns\./)
30
+ })
31
+
25
32
  test('setup copies the skill, and an edited contract is left alone', () => {
26
33
  const home = mkdtempSync(join(tmpdir(), 'amorphous-skill-'))
27
34
  const dirs = installUserSkill(home)
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.5",
3
+ "version": "0.3.7",
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",
package/templates.json CHANGED
@@ -16,8 +16,8 @@
16
16
  "board": {
17
17
  "label": "Board",
18
18
  "description": "A small kanban board with shared cards",
19
- "html": "<main class=\"shell\">\n <header><p class=\"eyebrow\">TEAM BOARD</p><h1>Project board</h1><p class=\"muted\">Move cards across the columns as work progresses.</p></header>\n <form id=\"add-form\"><input id=\"new-card\" aria-label=\"New card\" placeholder=\"Add a card…\" maxlength=\"180\" required><button>Add card</button></form>\n <section id=\"columns\" class=\"columns\"></section>\n</main>",
20
- "css": "* { box-sizing: border-box; } body { margin: 0; background: #f4f5f7; color: #20242b; font: 15px/1.5 system-ui, sans-serif; } .shell { max-width: 1120px; margin: 7vh auto; padding: 28px; } .eyebrow { color: #727985; font-size: 11px; font-weight: 700; letter-spacing: .14em; } h1 { margin: 4px 0; font-size: clamp(32px, 7vw, 48px); letter-spacing: -.05em; } .muted { color: #727985; } form { display: flex; gap: 8px; max-width: 580px; margin: 24px 0; } input { min-width: 0; flex: 1; padding: 11px 13px; border: 1px solid #d7dbe2; border-radius: 8px; font: inherit; } button { padding: 9px 14px; border: 0; border-radius: 8px; background: #29313d; color: #fff; font: inherit; cursor: pointer; } .columns { display: grid; grid-template-columns: repeat(3, minmax(220px, 1fr)); gap: 14px; align-items: start; overflow-x: auto; } .column { min-height: 190px; padding: 12px; border: 1px solid #e0e3e9; border-radius: 12px; background: #e9ecf1; } .column h2 { margin: 0 0 12px; font-size: 13px; text-transform: uppercase; letter-spacing: .06em; } .card { display: flex; align-items: center; gap: 8px; margin: 8px 0; padding: 11px; border: 1px solid #e2e4e9; border-radius: 9px; background: white; } .card span { flex: 1; overflow-wrap: anywhere; } .card select { max-width: 105px; padding: 5px; border: 1px solid #d7dbe2; border-radius: 6px; background: white; font: inherit; font-size: 12px; } @media (max-width: 760px) { .columns { grid-template-columns: repeat(3, minmax(250px, 1fr)); } }",
19
+ "html": "<main class=\"shell\">\n <header><p class=\"eyebrow\">TEAM BOARD</p><h1>Project board</h1><p class=\"muted\">Move cards across the columns as work progresses.</p></header>\n <p id=\"columns-scroll-hint\" class=\"muted scroll-hint\">Scroll sideways to see all columns.</p>\n <form id=\"add-form\"><input id=\"new-card\" aria-label=\"New card\" placeholder=\"Add a card…\" maxlength=\"180\" required><button>Add card</button></form>\n <section id=\"columns\" class=\"columns\" aria-describedby=\"columns-scroll-hint\"></section>\n</main>",
20
+ "css": "* { box-sizing: border-box; } body { margin: 0; background: #f4f5f7; color: #20242b; font: 15px/1.5 system-ui, sans-serif; } .shell { max-width: 1120px; margin: 7vh auto; padding: 28px; } .eyebrow { color: #727985; font-size: 11px; font-weight: 700; letter-spacing: .14em; } h1 { margin: 4px 0; font-size: clamp(32px, 7vw, 48px); letter-spacing: -.05em; } .muted { color: #727985; } .scroll-hint { display: none; } form { display: flex; gap: 8px; max-width: 580px; margin: 24px 0; } input { min-width: 0; flex: 1; padding: 11px 13px; border: 1px solid #d7dbe2; border-radius: 8px; font: inherit; } button { padding: 9px 14px; border: 0; border-radius: 8px; background: #29313d; color: #fff; font: inherit; cursor: pointer; } .columns { display: grid; grid-template-columns: repeat(3, minmax(220px, 1fr)); gap: 14px; align-items: start; overflow-x: auto; } .column { min-height: 190px; padding: 12px; border: 1px solid #e0e3e9; border-radius: 12px; background: #e9ecf1; } .column h2 { margin: 0 0 12px; font-size: 13px; text-transform: uppercase; letter-spacing: .06em; } .card { display: flex; align-items: center; gap: 8px; margin: 8px 0; padding: 11px; border: 1px solid #e2e4e9; border-radius: 9px; background: white; } .card span { flex: 1; overflow-wrap: anywhere; } .card select { max-width: 105px; padding: 5px; border: 1px solid #d7dbe2; border-radius: 6px; background: white; font: inherit; font-size: 12px; } @media (max-width: 760px) { .scroll-hint { display: block; margin: -8px 0 14px; } .columns { grid-template-columns: repeat(3, minmax(250px, 1fr)); } }",
21
21
  "js": "const stages = ['To do', 'In progress', 'Done']\nconst form = document.querySelector('#add-form')\nconst input = document.querySelector('#new-card')\nconst columns = document.querySelector('#columns')\n\nasync function render() {\n const rows = (await amorphous.data.find()).filter(row => row.kind === 'board-card')\n columns.replaceChildren()\n for (const stage of stages) {\n const column = document.createElement('section')\n column.className = 'column'\n const heading = document.createElement('h2')\n heading.textContent = `${stage} · ${rows.filter(row => (row.stage || stages[0]) === stage).length}`\n column.append(heading)\n for (const row of rows.filter(item => (item.stage || stages[0]) === stage)) {\n const card = document.createElement('article')\n card.className = 'card'\n const text = document.createElement('span')\n text.textContent = row.text\n const select = document.createElement('select')\n select.setAttribute('aria-label', `Move ${row.text}`)\n for (const optionText of stages) {\n const option = document.createElement('option')\n option.value = optionText\n option.textContent = optionText\n option.selected = optionText === (row.stage || stages[0])\n select.append(option)\n }\n select.addEventListener('change', async () => {\n await amorphous.data.replace(row.id, { ...row, stage: select.value })\n await render()\n })\n card.append(text, select)\n column.append(card)\n }\n columns.append(column)\n }\n}\n\nform.addEventListener('submit', async event => {\n event.preventDefault()\n const text = input.value.trim()\n if (!text) return\n await amorphous.data.insert({ kind: 'board-card', text, stage: stages[0] })\n input.value = ''\n await render()\n})\n\nrender()"
22
22
  },
23
23
  "landing": {