amorphous-cli 0.1.13 → 0.3.0
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 +4 -0
- package/lib/agent-contract.mjs +1 -1
- package/lib/agent-contract.test.mjs +15 -0
- package/lib/agent-guide-commands.test.mjs +429 -0
- package/lib/agent-guides-files.mjs +541 -0
- package/lib/agent-guides-files.test.mjs +367 -0
- package/lib/api.mjs +12 -7
- package/lib/app-origins.mjs +52 -0
- package/lib/app-origins.test.mjs +40 -0
- package/lib/commands.mjs +306 -29
- package/lib/files.mjs +10 -2
- package/lib/help.mjs +17 -5
- package/lib/participant-client.mjs +82 -0
- package/lib/participant-client.test.mjs +34 -0
- package/lib/pull.test.mjs +42 -0
- package/lib/recipe-create.test.mjs +37 -0
- package/lib/serve.mjs +152 -111
- package/lib/serve.test.mjs +180 -22
- package/package.json +5 -2
- package/participant-mcp.mjs +55 -0
- package/skill/amorphous/SKILL.md +6 -1
package/lib/commands.mjs
CHANGED
|
@@ -1,15 +1,34 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync, lstatSync, openSync, closeSync, mkdirSync, unlinkSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
2
3
|
import { installUserSkill, syncProjectContract, writeStarter } from './agent-contract.mjs'
|
|
3
4
|
import { ensureSessionHooks } from './agent-hooks.mjs'
|
|
4
5
|
import { shouldRefreshSkill } from './update-check.mjs'
|
|
5
6
|
import { CLI_VERSION } from './version.mjs'
|
|
6
7
|
import { homedir } from 'node:os'
|
|
7
8
|
import { apiFetch } from './api.mjs'
|
|
8
|
-
import {
|
|
9
|
+
import { appOrigin, projectMetaPath } from './config.mjs'
|
|
10
|
+
import { urlsFromDiscovery } from './app-origins.mjs'
|
|
9
11
|
import { recordsFromLocal, readProjectFiles, writeProjectFiles } from './files.mjs'
|
|
10
12
|
import { startServe } from './serve.mjs'
|
|
11
13
|
import { ALIASES, printHelp, wantsHelp } from './help.mjs'
|
|
12
14
|
import { loginWithBrowser, loginWithMagicLink } from './login.mjs'
|
|
15
|
+
import { ensureFreshSession } from './session.mjs'
|
|
16
|
+
import {
|
|
17
|
+
activateGuideIdentity,
|
|
18
|
+
guideBodyHash,
|
|
19
|
+
guidePaths,
|
|
20
|
+
ensureGuideFilesIgnored,
|
|
21
|
+
materializeGuide,
|
|
22
|
+
readGuideSyncState,
|
|
23
|
+
writeGuideSyncState,
|
|
24
|
+
} from './agent-guides-files.mjs'
|
|
25
|
+
|
|
26
|
+
const COMBINED_GUIDE_MAX_BYTES = 48 * 1024
|
|
27
|
+
const GUIDE_MAX_BYTES = 32 * 1024
|
|
28
|
+
const QUARANTINE_USER_IDS = [
|
|
29
|
+
'ffffffff-ffff-4fff-8fff-ffffffffffff',
|
|
30
|
+
'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee',
|
|
31
|
+
]
|
|
13
32
|
|
|
14
33
|
const BOOL_FLAGS = new Set([
|
|
15
34
|
'json',
|
|
@@ -80,6 +99,18 @@ function writeMeta(cwd, abstractId) {
|
|
|
80
99
|
writeFileSync(projectMetaPath(cwd), JSON.stringify({ abstractId }, null, 2) + '\n')
|
|
81
100
|
}
|
|
82
101
|
|
|
102
|
+
export function writeRecipeFiles(cwd, source) {
|
|
103
|
+
const files = [['index.html', source?.html], ['styles.css', source?.css], ['app.js', source?.js]]
|
|
104
|
+
if (files.some(([, content]) => typeof content !== 'string')) throw new Error('Recipe response did not include source files.')
|
|
105
|
+
const paths = files.map(([name]) => join(cwd, name))
|
|
106
|
+
if ([...paths, join(cwd, 'data.ndjson'), join(cwd, 'private.ndjson')].some(existsSync)) {
|
|
107
|
+
throw new Error('Recipe creation needs an empty source folder so it can write the generated files safely.')
|
|
108
|
+
}
|
|
109
|
+
for (let index = 0; index < files.length; index++) writeFileSync(paths[index], files[index][1])
|
|
110
|
+
writeFileSync(join(cwd, 'data.ndjson'), '')
|
|
111
|
+
writeFileSync(join(cwd, 'private.ndjson'), '')
|
|
112
|
+
}
|
|
113
|
+
|
|
83
114
|
function requireMeta(cwd) {
|
|
84
115
|
const meta = readMeta(cwd)
|
|
85
116
|
if (!meta?.abstractId) {
|
|
@@ -88,12 +119,38 @@ function requireMeta(cwd) {
|
|
|
88
119
|
return meta.abstractId
|
|
89
120
|
}
|
|
90
121
|
|
|
91
|
-
async function
|
|
122
|
+
async function initializeEmptyAbstractGuide(cwd, abstractId) {
|
|
123
|
+
ensureGuideFilesIgnored(cwd)
|
|
124
|
+
const path = join(cwd, 'agent.md')
|
|
125
|
+
try {
|
|
126
|
+
// Preserve anything already at this path, including a symlink or directory.
|
|
127
|
+
lstatSync(path)
|
|
128
|
+
return false
|
|
129
|
+
} catch (error) {
|
|
130
|
+
if (error?.code !== 'ENOENT') throw error
|
|
131
|
+
}
|
|
132
|
+
const fd = openSync(path, 'wx', 0o600)
|
|
133
|
+
closeSync(fd)
|
|
134
|
+
const { userId } = await guideSession()
|
|
135
|
+
const paths = guidePaths(cwd, abstractId, userId)
|
|
136
|
+
if (!existsSync(paths.privateGuide) && !existsSync(paths.activeIdentity)) {
|
|
137
|
+
const internal = join(cwd, '.amorphous-guides')
|
|
138
|
+
try { mkdirSync(internal, { mode: 0o700 }) } catch (error) { if (error?.code !== 'EEXIST') throw error }
|
|
139
|
+
if (lstatSync(internal).isSymbolicLink()) throw new Error('Unsafe agent-guide state directory.')
|
|
140
|
+
const marker = JSON.stringify({ schemaVersion: 1, abstractId: paths.abstractId, userId: paths.userId }) + '\n'
|
|
141
|
+
const stateFd = openSync(paths.activeIdentity, 'wx', 0o600)
|
|
142
|
+
try { writeFileSync(stateFd, marker) } finally { closeSync(stateFd) }
|
|
143
|
+
materializeGuide({ projectRoot: cwd, abstractId, userId, kind: 'abstract', body: '', revision: 0 })
|
|
144
|
+
}
|
|
145
|
+
return true
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function resolveAbstractId(cwd, flags, positional, request = apiFetch) {
|
|
92
149
|
if (flags.abstract) return flags.abstract
|
|
93
150
|
if (positional[0]) {
|
|
94
151
|
const token = positional[0]
|
|
95
152
|
if (/^[0-9a-f-]{36}$/i.test(token)) return token
|
|
96
|
-
const data = await
|
|
153
|
+
const data = await request('/api/abstracts?view=all')
|
|
97
154
|
const hit = (data.abstracts || []).find(
|
|
98
155
|
(a) => a.name.toLowerCase() === token.toLowerCase(),
|
|
99
156
|
)
|
|
@@ -107,6 +164,176 @@ async function resolveAbstractId(cwd, flags, positional) {
|
|
|
107
164
|
}
|
|
108
165
|
}
|
|
109
166
|
|
|
167
|
+
function sessionUserId(accessToken) {
|
|
168
|
+
try {
|
|
169
|
+
const payload = accessToken.split('.')[1]
|
|
170
|
+
const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'))
|
|
171
|
+
if (typeof decoded.sub !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(decoded.sub)) throw new Error()
|
|
172
|
+
return decoded.sub.toLowerCase()
|
|
173
|
+
} catch {
|
|
174
|
+
throw new Error('The signed-in Amorphous session has no valid user identity. Run amorphous-cli login.')
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function validateGuideResponse(value, label) {
|
|
179
|
+
if (!value || typeof value !== 'object' || typeof value.body !== 'string' ||
|
|
180
|
+
!Number.isSafeInteger(value.revision) || value.revision < 0) {
|
|
181
|
+
throw new Error(`The ${label} response is unavailable or invalid.`)
|
|
182
|
+
}
|
|
183
|
+
if (Buffer.byteLength(value.body, 'utf8') > 32 * 1024) {
|
|
184
|
+
throw new Error(`The ${label} exceeds the 32 KiB UTF-8 limit. Neither guide was loaded.`)
|
|
185
|
+
}
|
|
186
|
+
return value
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function guideSession() {
|
|
190
|
+
const session = await ensureFreshSession()
|
|
191
|
+
return { accessToken: session.access_token, userId: sessionUserId(session.access_token) }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function quarantineActiveGuides(cwd, fallbackAbstractId) {
|
|
195
|
+
const markerPath = join(cwd, '.amorphous-guides', 'active.json')
|
|
196
|
+
let marker = null
|
|
197
|
+
try {
|
|
198
|
+
const info = lstatSync(markerPath)
|
|
199
|
+
if (info.isSymbolicLink() || !info.isFile()) throw new Error('Unsafe active guide identity file; local guide bytes were not moved.')
|
|
200
|
+
try { marker = JSON.parse(readFileSync(markerPath, 'utf8')) } catch { marker = null }
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (error?.code !== 'ENOENT') throw error
|
|
203
|
+
}
|
|
204
|
+
const validUuid = (value) => typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)
|
|
205
|
+
const abstractId = validUuid(marker?.abstractId)
|
|
206
|
+
? marker.abstractId
|
|
207
|
+
: validUuid(fallbackAbstractId)
|
|
208
|
+
? fallbackAbstractId
|
|
209
|
+
: '00000000-0000-4000-8000-000000000000'
|
|
210
|
+
const markedUser = marker?.userId
|
|
211
|
+
let switched
|
|
212
|
+
let lastError
|
|
213
|
+
for (const userId of QUARANTINE_USER_IDS) {
|
|
214
|
+
if (userId === markedUser) continue
|
|
215
|
+
try {
|
|
216
|
+
switched = activateGuideIdentity(cwd, abstractId, userId)
|
|
217
|
+
break
|
|
218
|
+
} catch (error) { lastError = error }
|
|
219
|
+
}
|
|
220
|
+
if (!switched) throw lastError || new Error('Could not safely quarantine active guide files.')
|
|
221
|
+
try {
|
|
222
|
+
const info = lstatSync(markerPath)
|
|
223
|
+
if (info.isSymbolicLink() || !info.isFile()) throw new Error('Unsafe active guide identity file after recovery.')
|
|
224
|
+
unlinkSync(markerPath)
|
|
225
|
+
} catch (error) { if (error?.code !== 'ENOENT') throw error }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function enforceActiveGuideCaps(cwd, abstractId, userId) {
|
|
229
|
+
const paths = guidePaths(cwd, abstractId, userId)
|
|
230
|
+
const files = [paths.abstractGuide, paths.privateGuide]
|
|
231
|
+
let combinedBytes = 0
|
|
232
|
+
for (const path of files) {
|
|
233
|
+
const info = lstatSync(path)
|
|
234
|
+
if (info.isSymbolicLink() || !info.isFile()) throw new Error('Active guide files must be regular non-symlink files.')
|
|
235
|
+
if (info.size > GUIDE_MAX_BYTES) {
|
|
236
|
+
throw new Error('An active guide exceeds the 32 KiB UTF-8 context limit. Neither guide was loaded; local text is preserved without truncation.')
|
|
237
|
+
}
|
|
238
|
+
combinedBytes += info.size
|
|
239
|
+
}
|
|
240
|
+
if (combinedBytes > COMBINED_GUIDE_MAX_BYTES) {
|
|
241
|
+
throw new Error('Active guide files exceed the combined 48 KiB UTF-8 context limit. Neither guide was loaded; local text is preserved without truncation.')
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function cmdContext(cwd, rest) {
|
|
246
|
+
const { flags, positional } = parseFlags(rest)
|
|
247
|
+
if (flags.help) return printHelp('context')
|
|
248
|
+
let authorizationComplete = false
|
|
249
|
+
let id
|
|
250
|
+
try {
|
|
251
|
+
const { accessToken, userId } = await guideSession()
|
|
252
|
+
const request = (path) => apiFetch(path, { accessToken })
|
|
253
|
+
id = await resolveAbstractId(cwd, flags, positional, request)
|
|
254
|
+
const [abstractGuide, privateGuide] = await Promise.all([
|
|
255
|
+
request(`/api/abstracts/${id}/agent-guide`).then((value) => validateGuideResponse(value, 'abstract guide')),
|
|
256
|
+
request(`/api/abstracts/${id}/private-agent-guide`).then((value) => validateGuideResponse(value, 'private guide')),
|
|
257
|
+
])
|
|
258
|
+
authorizationComplete = true
|
|
259
|
+
const combinedBytes = Buffer.byteLength(abstractGuide.body, 'utf8') + Buffer.byteLength(privateGuide.body, 'utf8')
|
|
260
|
+
if (combinedBytes > COMBINED_GUIDE_MAX_BYTES) {
|
|
261
|
+
throw new Error('Agent guides exceed the combined 48 KiB UTF-8 context limit. Neither guide was loaded; reduce their size and run amorphous-cli context again.')
|
|
262
|
+
}
|
|
263
|
+
const identity = activateGuideIdentity(cwd, id, userId)
|
|
264
|
+
if (!identity.privateSafeToLoad) {
|
|
265
|
+
throw new Error('Guide identity is uncertain. Existing local files were preserved in recovery and neither guide was loaded; confirm the account and run amorphous-cli context again.')
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const documents = [
|
|
269
|
+
{ kind: 'abstract', guide: abstractGuide },
|
|
270
|
+
{ kind: 'private', guide: privateGuide },
|
|
271
|
+
]
|
|
272
|
+
const outcomes = []
|
|
273
|
+
for (const { kind, guide } of documents) {
|
|
274
|
+
const outcome = materializeGuide({ projectRoot: cwd, abstractId: id, userId, kind, body: guide.body, revision: guide.revision })
|
|
275
|
+
if (outcome.status === 'identity-uncertain') {
|
|
276
|
+
throw new Error('Guide identity changed during context loading. Do not use either local guide; confirm the account and run amorphous-cli context again.')
|
|
277
|
+
}
|
|
278
|
+
outcomes.push(outcome)
|
|
279
|
+
}
|
|
280
|
+
enforceActiveGuideCaps(cwd, id, userId)
|
|
281
|
+
const conflicts = outcomes.filter((outcome) => outcome.status === 'conflict')
|
|
282
|
+
if (conflicts.length) {
|
|
283
|
+
const paths = conflicts.map((item) => item.conflictPath).join(', ')
|
|
284
|
+
throw new Error(`An agent guide changed locally and remotely. Both copies were preserved; review the ignored conflict file${conflicts.length > 1 ? 's' : ''}: ${paths}. Resolve before asking the agent to use guide context.`)
|
|
285
|
+
}
|
|
286
|
+
console.log(`Loaded guide context for ${id} (abstract revision ${abstractGuide.revision}; private revision ${privateGuide.revision}).`)
|
|
287
|
+
console.log('Context does not fetch source; source release/version is separate from these guide revisions.')
|
|
288
|
+
console.log('The private guide is supplied to this local agent process and cannot be recalled after download.')
|
|
289
|
+
console.log('Read agent.md, then private-agent.md before working on the abstract.')
|
|
290
|
+
} catch (error) {
|
|
291
|
+
if (!authorizationComplete) {
|
|
292
|
+
try { quarantineActiveGuides(cwd, id) } catch (quarantineError) {
|
|
293
|
+
throw new Error(`${error.message} Active guide files could not be safely quarantined: ${quarantineError.message}`)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
throw error
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function cmdGuide(cwd, rest) {
|
|
301
|
+
const { flags, positional } = parseFlags(rest)
|
|
302
|
+
if (flags.help || positional[0] !== 'save') return printHelp('guide')
|
|
303
|
+
const kind = positional[1]
|
|
304
|
+
if (!['abstract', 'private'].includes(kind)) throw new Error('Use: amorphous-cli guide save <abstract|private> [abstract].')
|
|
305
|
+
const abstractToken = positional[2]
|
|
306
|
+
const { accessToken, userId } = await guideSession()
|
|
307
|
+
const request = (path, options) => apiFetch(path, { ...options, accessToken })
|
|
308
|
+
const id = await resolveAbstractId(cwd, flags, abstractToken ? [abstractToken] : [], request)
|
|
309
|
+
const endpoint = kind === 'abstract' ? 'agent-guide' : 'private-agent-guide'
|
|
310
|
+
const current = validateGuideResponse(await request(`/api/abstracts/${id}/${endpoint}`), `${kind} guide`)
|
|
311
|
+
const paths = guidePaths(cwd, id, userId)
|
|
312
|
+
const identity = activateGuideIdentity(cwd, id, userId)
|
|
313
|
+
if (!identity.privateSafeToLoad) throw new Error('Guide identity is uncertain; preserved local files are not eligible for saving. Run context after confirming the signed-in account.')
|
|
314
|
+
const state = readGuideSyncState(cwd, id, userId)
|
|
315
|
+
const base = state?.guides?.[kind]
|
|
316
|
+
if (!base) throw new Error('No saved guide revision is available. Run amorphous-cli context first.')
|
|
317
|
+
if (base.revision !== current.revision) throw new Error('The server guide changed since context was loaded. Run amorphous-cli context, resolve any conflict, then save again.')
|
|
318
|
+
const path = kind === 'abstract' ? paths.abstractGuide : paths.privateGuide
|
|
319
|
+
const file = lstatSync(path)
|
|
320
|
+
if (file.isSymbolicLink() || !file.isFile()) throw new Error('Guide file must be a regular non-symlink file.')
|
|
321
|
+
const body = readFileSync(path, 'utf8')
|
|
322
|
+
if (Buffer.byteLength(body, 'utf8') > 32 * 1024) throw new Error('Guide exceeds the 32 KiB UTF-8 limit; it was not saved.')
|
|
323
|
+
if (guideBodyHash(body) === base.hash) {
|
|
324
|
+
console.log(`No ${kind} guide changes to save.`)
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
const saved = validateGuideResponse(await request(`/api/abstracts/${id}/${endpoint}`, {
|
|
328
|
+
method: 'PUT', body: JSON.stringify({ body, expectedRevision: base.revision }),
|
|
329
|
+
}), `${kind} guide save`)
|
|
330
|
+
writeGuideSyncState(cwd, id, userId, {
|
|
331
|
+
...(state?.guides ?? {}),
|
|
332
|
+
[kind]: { revision: saved.revision, hash: guideBodyHash(body) },
|
|
333
|
+
})
|
|
334
|
+
console.log(`Saved ${kind} guide revision ${saved.revision}.`)
|
|
335
|
+
}
|
|
336
|
+
|
|
110
337
|
async function loadFolders() {
|
|
111
338
|
const meta = await apiFetch('/api/organise/meta')
|
|
112
339
|
return meta.all_collections || meta.collections || []
|
|
@@ -215,6 +442,36 @@ They tell you when to push, preview, publish, or invite.`)
|
|
|
215
442
|
async function cmdCreate(cwd, rest) {
|
|
216
443
|
const { flags } = parseFlags(rest)
|
|
217
444
|
if (flags.help) return printHelp('create')
|
|
445
|
+
if (flags['recipe-config'] === true) throw new Error('Pass a JSON file with --recipe-config <file>.')
|
|
446
|
+
if (typeof flags['recipe-config'] === 'string') {
|
|
447
|
+
const recipePath = flags['recipe-config'].startsWith('/') ? flags['recipe-config'] : join(cwd, flags['recipe-config'])
|
|
448
|
+
let config
|
|
449
|
+
try { config = JSON.parse(readFileSync(recipePath, 'utf8')) } catch { throw new Error('Could not read recipe config JSON.') }
|
|
450
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('Recipe config must be a JSON object.')
|
|
451
|
+
if (typeof flags.name === 'string' && flags.name.trim()) config.name = flags.name.trim()
|
|
452
|
+
const sourceTargets = ['index.html', 'styles.css', 'app.js', 'data.ndjson', 'private.ndjson', '.amorphous.json']
|
|
453
|
+
if (sourceTargets.some(name => existsSync(join(cwd, name)))) {
|
|
454
|
+
throw new Error('Recipe creation needs an empty project folder.')
|
|
455
|
+
}
|
|
456
|
+
const data = await apiFetch('/api/abstracts/recipe', { method: 'POST', body: JSON.stringify(config) })
|
|
457
|
+
writeRecipeFiles(cwd, data.source)
|
|
458
|
+
writeMeta(cwd, data.id)
|
|
459
|
+
await initializeEmptyAbstractGuide(cwd, data.id)
|
|
460
|
+
if (data.recoveryContract) {
|
|
461
|
+
writeFileSync(projectMetaPath(cwd), JSON.stringify({ abstractId: data.id, recoveryContract: data.recoveryContract }, null, 2) + '\n')
|
|
462
|
+
}
|
|
463
|
+
const folderName = folderToken(flags)
|
|
464
|
+
if (folderName) {
|
|
465
|
+
const folder = await resolveFolder(folderName)
|
|
466
|
+
await apiFetch(`/api/abstracts/${data.id}/filing`, {
|
|
467
|
+
method: 'PATCH', body: JSON.stringify({ collection_id: folder.id }),
|
|
468
|
+
})
|
|
469
|
+
}
|
|
470
|
+
syncProjectContract(cwd)
|
|
471
|
+
if (jsonMode(flags)) printJson({ id: data.id, name: data.name })
|
|
472
|
+
else { console.log(data.id); console.log('Prefer amorphous-cli serve while you edit.') }
|
|
473
|
+
return
|
|
474
|
+
}
|
|
218
475
|
const template = typeof flags.template === 'string' ? flags.template : 'blank'
|
|
219
476
|
const data = await apiFetch('/api/abstracts', { method: 'POST', body: JSON.stringify({ template }) })
|
|
220
477
|
const id = data.id
|
|
@@ -235,6 +492,7 @@ async function cmdCreate(cwd, rest) {
|
|
|
235
492
|
})
|
|
236
493
|
}
|
|
237
494
|
writeStarter(cwd, template)
|
|
495
|
+
await initializeEmptyAbstractGuide(cwd, id)
|
|
238
496
|
syncProjectContract(cwd)
|
|
239
497
|
if (jsonMode(flags)) printJson({ id, name: name || 'Untitled' })
|
|
240
498
|
else {
|
|
@@ -341,24 +599,38 @@ async function cmdPeople(cwd, rest) {
|
|
|
341
599
|
}
|
|
342
600
|
}
|
|
343
601
|
|
|
602
|
+
export function writePulledProject(cwd, id, detail, records, { fresh = false } = {}) {
|
|
603
|
+
if (fresh && readdirSync(cwd).length) {
|
|
604
|
+
throw new Error('Pulling into a new folder needs an empty directory.')
|
|
605
|
+
}
|
|
606
|
+
const abstract = detail?.abstract
|
|
607
|
+
if (!abstract || typeof abstract.draft_html !== 'string' || typeof abstract.draft_css !== 'string' || typeof abstract.draft_js !== 'string') {
|
|
608
|
+
throw new Error('Abstract response did not include editable source files.')
|
|
609
|
+
}
|
|
610
|
+
const rows = records?.records || []
|
|
611
|
+
writeProjectFiles(cwd, {
|
|
612
|
+
html: abstract.draft_html,
|
|
613
|
+
css: abstract.draft_css,
|
|
614
|
+
js: abstract.draft_js,
|
|
615
|
+
shared: rows.filter((r) => r.visibility !== 'private'),
|
|
616
|
+
private: rows.filter((r) => r.visibility === 'private'),
|
|
617
|
+
})
|
|
618
|
+
const recoveryContract = abstract.draft_recovery_contract
|
|
619
|
+
writeFileSync(projectMetaPath(cwd), JSON.stringify({
|
|
620
|
+
abstractId: id,
|
|
621
|
+
...(recoveryContract && typeof recoveryContract === 'object' ? { recoveryContract } : {}),
|
|
622
|
+
}, null, 2) + '\n')
|
|
623
|
+
syncProjectContract(cwd)
|
|
624
|
+
}
|
|
625
|
+
|
|
344
626
|
async function cmdPull(cwd, rest) {
|
|
345
|
-
const { flags } = parseFlags(rest)
|
|
627
|
+
const { flags, positional } = parseFlags(rest)
|
|
346
628
|
if (flags.help) return printHelp('pull')
|
|
347
|
-
const id =
|
|
629
|
+
const id = await resolveAbstractId(cwd, flags, positional)
|
|
348
630
|
const detail = await apiFetch(`/api/abstracts/${id}`)
|
|
349
631
|
warnIfRemoteChanged(detail)
|
|
350
632
|
const records = await apiFetch(`/api/abstracts/${id}/records`)
|
|
351
|
-
|
|
352
|
-
const shared = rows.filter((r) => r.visibility !== 'private')
|
|
353
|
-
const priv = rows.filter((r) => r.visibility === 'private')
|
|
354
|
-
writeProjectFiles(cwd, {
|
|
355
|
-
html: detail.abstract.draft_html,
|
|
356
|
-
css: detail.abstract.draft_css,
|
|
357
|
-
js: detail.abstract.draft_js,
|
|
358
|
-
shared,
|
|
359
|
-
private: priv,
|
|
360
|
-
})
|
|
361
|
-
syncProjectContract(cwd)
|
|
633
|
+
writePulledProject(cwd, id, detail, records, { fresh: positional.length > 0 || Boolean(flags.abstract) })
|
|
362
634
|
console.log('Pulled.')
|
|
363
635
|
console.log('Prefer amorphous-cli serve while you edit.')
|
|
364
636
|
}
|
|
@@ -367,7 +639,8 @@ async function cmdPush(cwd, rest) {
|
|
|
367
639
|
const { flags } = parseFlags(rest)
|
|
368
640
|
if (flags.help) return printHelp('push')
|
|
369
641
|
const id = requireMeta(cwd)
|
|
370
|
-
|
|
642
|
+
const detail = await apiFetch(`/api/abstracts/${id}`)
|
|
643
|
+
warnIfRemoteChanged(detail)
|
|
371
644
|
if (flags.data) {
|
|
372
645
|
const localRecords = recordsFromLocal(cwd)
|
|
373
646
|
const { refused } = await apiFetch(`/api/abstracts/${id}/records`, {
|
|
@@ -381,7 +654,8 @@ async function cmdPush(cwd, rest) {
|
|
|
381
654
|
const { html, css, js } = readProjectFiles(cwd)
|
|
382
655
|
await apiFetch(`/api/abstracts/${id}/push`, {
|
|
383
656
|
method: 'POST',
|
|
384
|
-
body: JSON.stringify({ html, css, js
|
|
657
|
+
body: JSON.stringify({ html, css, js, expectedRevision: detail.abstract.draft_revision,
|
|
658
|
+
...(readMeta(cwd)?.recoveryContract ? { recoveryContract: readMeta(cwd).recoveryContract } : {}) }),
|
|
385
659
|
})
|
|
386
660
|
console.log(flags.data ? 'Pushed source and records.' : 'Pushed source.')
|
|
387
661
|
}
|
|
@@ -398,6 +672,7 @@ async function cmdServe(cwd, rest) {
|
|
|
398
672
|
cwd,
|
|
399
673
|
abstractId: id,
|
|
400
674
|
port,
|
|
675
|
+
environment: flags.preview ? 'preview' : 'production',
|
|
401
676
|
json: jsonMode(flags),
|
|
402
677
|
})
|
|
403
678
|
let warnedRemote = false
|
|
@@ -428,19 +703,16 @@ async function cmdServe(cwd, rest) {
|
|
|
428
703
|
})
|
|
429
704
|
}
|
|
430
705
|
|
|
431
|
-
function urlsFor(id) {
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
preview: `${abstractsOrigin().replace(/\/$/, '')}/${id}/draft`,
|
|
435
|
-
live: `${abstractsOrigin().replace(/\/$/, '')}/${id}`,
|
|
436
|
-
}
|
|
706
|
+
async function urlsFor(id) {
|
|
707
|
+
const discovery = await apiFetch('/api/runtime/origins', { auth: false })
|
|
708
|
+
return urlsFromDiscovery(id, appOrigin(), discovery, process.env)
|
|
437
709
|
}
|
|
438
710
|
|
|
439
711
|
async function cmdOpen(cwd, rest) {
|
|
440
712
|
const { flags, positional } = parseFlags(rest)
|
|
441
713
|
if (flags.help) return printHelp('open')
|
|
442
714
|
const id = await resolveAbstractId(cwd, flags, positional)
|
|
443
|
-
const urls = urlsFor(id)
|
|
715
|
+
const urls = await urlsFor(id)
|
|
444
716
|
if (jsonMode(flags)) printJson(urls)
|
|
445
717
|
else console.log(urls.open)
|
|
446
718
|
}
|
|
@@ -451,7 +723,7 @@ async function cmdPreview(cwd, rest) {
|
|
|
451
723
|
const id = await resolveAbstractId(cwd, flags, positional)
|
|
452
724
|
const detail = await apiFetch(`/api/abstracts/${id}`)
|
|
453
725
|
warnIfRemoteChanged(detail)
|
|
454
|
-
const urls = urlsFor(id)
|
|
726
|
+
const urls = await urlsFor(id)
|
|
455
727
|
if (jsonMode(flags)) printJson({ preview: urls.preview, remoteChangedSincePush: Boolean(detail.remoteChangedSincePush) })
|
|
456
728
|
else console.log(urls.preview)
|
|
457
729
|
}
|
|
@@ -460,11 +732,12 @@ async function cmdPublish(cwd, rest) {
|
|
|
460
732
|
const { flags, positional } = parseFlags(rest)
|
|
461
733
|
if (flags.help) return printHelp('publish')
|
|
462
734
|
const id = await resolveAbstractId(cwd, flags, positional)
|
|
463
|
-
|
|
735
|
+
const detail = await apiFetch(`/api/abstracts/${id}`)
|
|
736
|
+
warnIfRemoteChanged(detail)
|
|
464
737
|
const live = Boolean(flags.live)
|
|
465
738
|
const version = await apiFetch(`/api/abstracts/${id}/publish`, {
|
|
466
739
|
method: 'POST',
|
|
467
|
-
body: JSON.stringify({ live }),
|
|
740
|
+
body: JSON.stringify({ live, expectedRevision: detail.abstract.draft_revision }),
|
|
468
741
|
})
|
|
469
742
|
if (jsonMode(flags)) printJson({ id: version.id, label: version.label, live })
|
|
470
743
|
else console.log(live ? 'It’s live.' : 'Published.')
|
|
@@ -751,6 +1024,10 @@ export async function runCommand(argv, cwd = process.cwd()) {
|
|
|
751
1024
|
return cmdPeople(cwd, rest)
|
|
752
1025
|
case 'pull':
|
|
753
1026
|
return cmdPull(cwd, rest)
|
|
1027
|
+
case 'context':
|
|
1028
|
+
return cmdContext(cwd, rest)
|
|
1029
|
+
case 'guide':
|
|
1030
|
+
return cmdGuide(cwd, rest)
|
|
754
1031
|
case 'push':
|
|
755
1032
|
return cmdPush(cwd, rest)
|
|
756
1033
|
case 'open':
|
package/lib/files.mjs
CHANGED
|
@@ -43,8 +43,10 @@ export function writeProjectFiles(cwd, payload) {
|
|
|
43
43
|
export function recordsFromLocal(cwd) {
|
|
44
44
|
const { shared, private: priv } = readProjectFiles(cwd)
|
|
45
45
|
return [
|
|
46
|
-
...shared.map((r) => ({ ...r, visibility: r.visibility || 'shared'
|
|
47
|
-
|
|
46
|
+
...shared.map((r) => ({ ...r, revision: r.revision || 1, visibility: r.visibility || 'shared',
|
|
47
|
+
collection: r.collection || 'default', created_by: r.created_by || null })),
|
|
48
|
+
...priv.map((r) => ({ ...r, revision: r.revision || 1, visibility: 'private',
|
|
49
|
+
collection: r.collection || 'default', created_by: r.created_by || null })),
|
|
48
50
|
]
|
|
49
51
|
}
|
|
50
52
|
|
|
@@ -66,6 +68,9 @@ export function insertLocalRecord(cwd, row) {
|
|
|
66
68
|
}
|
|
67
69
|
body.visibility = body.visibility === 'private' ? 'private' : 'shared'
|
|
68
70
|
if (!body.id) body.id = randomUUID()
|
|
71
|
+
body.revision = 1
|
|
72
|
+
body.collection = 'default'
|
|
73
|
+
body.created_by = null
|
|
69
74
|
const rows = recordsFromLocal(cwd)
|
|
70
75
|
if (rows.some((r) => r.id === body.id)) return { status: 409, message: 'Conflict' }
|
|
71
76
|
rows.push(body)
|
|
@@ -83,6 +88,9 @@ export function replaceLocalRecord(cwd, id, row) {
|
|
|
83
88
|
...(row && typeof row === 'object' ? row : {}),
|
|
84
89
|
id,
|
|
85
90
|
visibility: existing.visibility,
|
|
91
|
+
revision: existing.revision + 1,
|
|
92
|
+
collection: existing.collection,
|
|
93
|
+
created_by: existing.created_by,
|
|
86
94
|
}
|
|
87
95
|
rows[index] = next
|
|
88
96
|
writeLocalRecords(cwd, rows)
|
package/lib/help.mjs
CHANGED
|
@@ -9,6 +9,8 @@ export const ROOT_HELP = `Amorphous
|
|
|
9
9
|
preview [abstract] Preview URL (draft)
|
|
10
10
|
serve Develop in this folder
|
|
11
11
|
pull Pull draft and records to this folder
|
|
12
|
+
context [abstract] Load authorized agent guides only
|
|
13
|
+
guide save <abstract|private> Save the selected guide explicitly
|
|
12
14
|
push Push local files
|
|
13
15
|
publish [--live] Publish a version; --live is Go live
|
|
14
16
|
recommend [--note <text>] Send this draft to the owner for review
|
|
@@ -52,9 +54,9 @@ Start the local MCP server on stdio.`,
|
|
|
52
54
|
List abstracts. Default view is all (All Abstracts).
|
|
53
55
|
--folder filters to a folder. --tag filters to a tag.`,
|
|
54
56
|
|
|
55
|
-
create: `create [--name <name>] [--folder <name>] [--template blank|list|board|landing] [--json]
|
|
57
|
+
create: `create [--name <name>] [--folder <name>] [--template blank|list|board|landing] [--recipe-config <file>] [--json]
|
|
56
58
|
|
|
57
|
-
New abstract. Writes .amorphous.json in this directory.`,
|
|
59
|
+
New abstract. --recipe-config creates a supported recipe and writes its generated source files. Writes .amorphous.json in this directory.`,
|
|
58
60
|
|
|
59
61
|
open: `open [abstract] [--json]
|
|
60
62
|
|
|
@@ -73,18 +75,28 @@ Records start local, in this folder. The page can switch to prod, which reads an
|
|
|
73
75
|
|
|
74
76
|
Pull the draft and records into this directory.`,
|
|
75
77
|
|
|
78
|
+
context: `context [abstract]
|
|
79
|
+
|
|
80
|
+
Load the current user's authorized agent guides without pulling source or records.
|
|
81
|
+
Run once when beginning work on an existing abstract. The guides are ignored local files; private guide text is supplied to this local agent process and cannot be recalled after download.`,
|
|
82
|
+
|
|
83
|
+
guide: `guide save <abstract|private> [abstract]
|
|
84
|
+
|
|
85
|
+
Explicitly save the selected local guide using its last loaded revision. A stale revision is refused so neither copy is overwritten. Generic push, recommend, and publish never save or include guides.`,
|
|
86
|
+
|
|
76
87
|
push: `push [--data]
|
|
77
88
|
|
|
78
89
|
Push local index.html, styles.css, and app.js.
|
|
79
|
-
--data also merges records from this folder. A record changed both here and on the abstract is refused
|
|
90
|
+
--data also merges records from this folder. A record changed both here and on the abstract is refused.
|
|
91
|
+
Agent guides are separate: push never uploads them. Use amorphous-cli guide save explicitly.`,
|
|
80
92
|
|
|
81
93
|
publish: `publish [--live]
|
|
82
94
|
|
|
83
|
-
Publish a new version. --live also makes it the live version (Go live).`,
|
|
95
|
+
Publish a new version. --live also makes it the live version (Go live). Guides are not included.`,
|
|
84
96
|
|
|
85
97
|
recommend: `recommend [--note <text>] [--json]
|
|
86
98
|
|
|
87
|
-
Send this folder's HTML, CSS, and JavaScript to the owner as a proposal. It does not change the live version.`,
|
|
99
|
+
Send this folder's HTML, CSS, and JavaScript to the owner as a proposal. It does not change the live version or include agent guides.`,
|
|
88
100
|
|
|
89
101
|
live: `live [abstract] [--version <id>]
|
|
90
102
|
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const ACTION = /^[a-z][a-z0-9_.-]{0,79}$/
|
|
2
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
3
|
+
const TOKEN = /^[A-Za-z0-9_-]{40,64}$/
|
|
4
|
+
const RECEIPT = /^[0-9a-f]{8}-[0-9a-f-]{27,}$/i
|
|
5
|
+
const ACTION_NAMES = new Set([
|
|
6
|
+
'records.query', 'records.create', 'records.patch', 'records.delete',
|
|
7
|
+
'items.add', 'items.done', 'items.move', 'items.edit', 'items.delete',
|
|
8
|
+
'private.query', 'private.create', 'private.patch', 'private.delete',
|
|
9
|
+
'form.submit', 'form.mine', 'form.edit',
|
|
10
|
+
])
|
|
11
|
+
|
|
12
|
+
export function createParticipantClient({ origin, appId, token, hostHeaders = {}, fetchImpl = fetch }) {
|
|
13
|
+
const base = new URL(origin)
|
|
14
|
+
if (!['https:', 'http:'].includes(base.protocol) || base.username || base.password ||
|
|
15
|
+
base.search || base.hash || (base.pathname !== '/' && base.pathname !== '')) {
|
|
16
|
+
throw new TypeError('Participant app origin must be an HTTP(S) origin')
|
|
17
|
+
}
|
|
18
|
+
if (base.protocol === 'http:' && !['localhost', '127.0.0.1', '[::1]'].includes(base.hostname) &&
|
|
19
|
+
!base.hostname.endsWith('.localhost')) {
|
|
20
|
+
throw new TypeError('Participant grant requires HTTPS outside local development')
|
|
21
|
+
}
|
|
22
|
+
if (!UUID.test(appId) || !TOKEN.test(token)) throw new TypeError('Invalid participant connection settings')
|
|
23
|
+
if (Object.keys(hostHeaders).some((key) => key.toLowerCase() !== 'host') ||
|
|
24
|
+
Object.values(hostHeaders).some((value) => typeof value !== 'string' || /[\r\n]/.test(value))) {
|
|
25
|
+
throw new TypeError('Invalid participant host configuration')
|
|
26
|
+
}
|
|
27
|
+
const root = `/api/v1/apps/${appId.toLowerCase()}`
|
|
28
|
+
|
|
29
|
+
async function request(path, { method = 'GET', body, idempotencyKey, approvalId } = {}) {
|
|
30
|
+
if (typeof path !== 'string' || !path.startsWith(`${root}/`) || path.includes('..') || path.includes('?')) {
|
|
31
|
+
throw new Error('Unsupported participant endpoint')
|
|
32
|
+
}
|
|
33
|
+
const actionRoute = new RegExp(`^${root}/actions/([^/]+)/invoke$`).exec(path)
|
|
34
|
+
const receiptPrefix = `${root}/receipts/`
|
|
35
|
+
const getOnly = path === `${root}/me` || path === `${root}/actions` ||
|
|
36
|
+
path === `${root}/status` || path === `${root}/activity` ||
|
|
37
|
+
(path.startsWith(receiptPrefix) && RECEIPT.test(path.slice(receiptPrefix.length)) )
|
|
38
|
+
const postAction = Boolean(actionRoute && ACTION_NAMES.has(actionRoute[1]))
|
|
39
|
+
const allowed = (getOnly && method === 'GET') || (postAction && method === 'POST')
|
|
40
|
+
if (!allowed) throw new Error('Unsupported participant endpoint')
|
|
41
|
+
const headers = { ...hostHeaders, Authorization: `AmorphousGrant ${token}`, Accept: 'application/json' }
|
|
42
|
+
if (body !== undefined) headers['Content-Type'] = 'application/json'
|
|
43
|
+
if (idempotencyKey !== undefined) {
|
|
44
|
+
if (typeof idempotencyKey !== 'string' || idempotencyKey.length < 1 || idempotencyKey.length > 200) {
|
|
45
|
+
throw new TypeError('Invalid idempotency key')
|
|
46
|
+
}
|
|
47
|
+
headers['Idempotency-Key'] = idempotencyKey
|
|
48
|
+
}
|
|
49
|
+
if (approvalId !== undefined) {
|
|
50
|
+
if (typeof approvalId !== 'string' || !UUID.test(approvalId)) throw new TypeError('Invalid approval id')
|
|
51
|
+
headers['Amorphous-Approval'] = approvalId
|
|
52
|
+
}
|
|
53
|
+
const response = await fetchImpl(new URL(path, base), {
|
|
54
|
+
method, headers, redirect: 'error', ...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
55
|
+
})
|
|
56
|
+
const raw = await response.text()
|
|
57
|
+
let payload
|
|
58
|
+
try { payload = raw ? JSON.parse(raw) : null } catch { payload = null }
|
|
59
|
+
if (!response.ok) throw new Error(`Participant request failed (${response.status})`)
|
|
60
|
+
return payload
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return Object.freeze({
|
|
64
|
+
request,
|
|
65
|
+
me: () => request(`${root}/me`),
|
|
66
|
+
discover: () => request(`${root}/actions`),
|
|
67
|
+
invoke: (action, input, idempotencyKey, approvalId) => {
|
|
68
|
+
if (typeof action !== 'string' || !ACTION.test(action) || !ACTION_NAMES.has(action)) throw new TypeError('Invalid action')
|
|
69
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new TypeError('Invalid action input')
|
|
70
|
+
if (approvalId !== undefined && (typeof approvalId !== 'string' || !RECEIPT.test(approvalId))) {
|
|
71
|
+
throw new TypeError('Invalid approval id')
|
|
72
|
+
}
|
|
73
|
+
return request(`${root}/actions/${action}/invoke`, { method: 'POST', body: input, idempotencyKey, approvalId })
|
|
74
|
+
},
|
|
75
|
+
receipt: (receiptId) => {
|
|
76
|
+
if (typeof receiptId !== 'string' || !RECEIPT.test(receiptId)) throw new TypeError('Invalid receipt id')
|
|
77
|
+
return request(`${root}/receipts/${receiptId}`)
|
|
78
|
+
},
|
|
79
|
+
status: () => request(`${root}/status`),
|
|
80
|
+
activity: () => request(`${root}/activity`),
|
|
81
|
+
})
|
|
82
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { createParticipantClient } from './participant-client.mjs'
|
|
4
|
+
|
|
5
|
+
const id = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
|
6
|
+
|
|
7
|
+
test('participant client sends only its grant credential to selected-app endpoints', async () => {
|
|
8
|
+
const calls = []
|
|
9
|
+
const client = createParticipantClient({
|
|
10
|
+
origin: 'https://amorphous.test/', appId: id, token: 'A'.repeat(43),
|
|
11
|
+
fetchImpl: async (url, options) => {
|
|
12
|
+
calls.push({ url: String(url), options })
|
|
13
|
+
return new Response('{"ok":true}', { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
14
|
+
},
|
|
15
|
+
})
|
|
16
|
+
await client.me()
|
|
17
|
+
await client.invoke('items.add', { collection: 'items', visibility: 'shared', body: { title: 'Milk' } }, 'req-1', id)
|
|
18
|
+
assert.equal(calls[0].url, `https://amorphous.test/api/v1/apps/${id}/me`)
|
|
19
|
+
assert.equal(calls[0].options.headers.Authorization, `AmorphousGrant ${'A'.repeat(43)}`)
|
|
20
|
+
assert.equal(calls[0].options.redirect, 'error')
|
|
21
|
+
assert.equal(calls[1].options.headers['Idempotency-Key'], 'req-1')
|
|
22
|
+
assert.equal(calls[1].options.headers['Amorphous-Approval'], id)
|
|
23
|
+
assert.equal(calls[1].options.headers.Authorization, `AmorphousGrant ${'A'.repeat(43)}`)
|
|
24
|
+
assert.equal(calls.some(({ url }) => !url.includes(`/apps/${id}/`)), false)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('participant client rejects non-selected or privileged routes and invalid actions', async () => {
|
|
28
|
+
const client = createParticipantClient({ origin: 'https://amorphous.test', appId: id,
|
|
29
|
+
token: 'A'.repeat(43), fetchImpl: async () => { throw new Error('unexpected network') } })
|
|
30
|
+
await assert.rejects(client.request('/api/v1/apps'), /Unsupported participant endpoint/)
|
|
31
|
+
await assert.rejects(client.request(`/api/v1/apps/${id}/actions`, { method: 'POST' }), /Unsupported participant endpoint/)
|
|
32
|
+
assert.throws(() => client.invoke('items.add', null, 'req-1'), /Invalid action input/)
|
|
33
|
+
assert.throws(() => client.invoke('publish', {}, 'req-1'), /Invalid action/)
|
|
34
|
+
})
|