amorphous-cli 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/hooks/session-start.mjs +1 -1
- package/lib/agent-contract.mjs +1 -1
- package/lib/api.mjs +17 -17
- package/lib/commands.mjs +39 -10
- package/lib/config.mjs +30 -0
- package/lib/files.mjs +42 -0
- package/lib/help.mjs +10 -3
- package/lib/login.mjs +1 -1
- package/lib/serve.mjs +360 -0
- package/lib/serve.test.mjs +123 -0
- package/lib/session.mjs +5 -5
- package/lib/tool-result.test.mjs +2 -2
- package/lib/update-check.mjs +1 -1
- package/lib/update-check.test.mjs +3 -3
- package/mcp.mjs +5 -3
- package/package.json +2 -2
- package/skill/amorphous/SKILL.md +13 -10
- package/skill/amorphous/assets/app.js +1 -19
- package/skill/amorphous/assets/index.html +0 -5
- package/skill/amorphous/assets/styles.css +0 -20
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# amorphous-cli
|
|
2
2
|
|
|
3
|
-
Make whatever you want, yourself, with your own agent, or with others. And whatever you want can be private, public, or even have users
|
|
3
|
+
Make whatever you want, yourself, with your own agent, or with others. And whatever you want can be private, public, or even have users.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
npx amorphous-cli@latest setup
|
package/hooks/session-start.mjs
CHANGED
|
@@ -48,7 +48,7 @@ if (target === 'cursor') {
|
|
|
48
48
|
process.stdout.write(
|
|
49
49
|
`${JSON.stringify({
|
|
50
50
|
additional_context:
|
|
51
|
-
'The Amorphous skill on this machine was just updated. Read ~/.agents/skills/amorphous/SKILL.md and follow it.
|
|
51
|
+
'The Amorphous skill on this machine was just updated. Read ~/.agents/skills/amorphous/SKILL.md and follow it. Install the CLI with npm install -g amorphous-cli@latest and invoke it as amorphous-cli.',
|
|
52
52
|
})}\n`,
|
|
53
53
|
)
|
|
54
54
|
} else {
|
package/lib/agent-contract.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
5
5
|
import { homedir } from 'node:os'
|
|
6
6
|
import { CLI_VERSION } from './version.mjs'
|
|
7
7
|
|
|
8
|
-
export const CONTRACT_VERSION =
|
|
8
|
+
export const CONTRACT_VERSION = 10
|
|
9
9
|
|
|
10
10
|
const skillRoot = join(dirname(fileURLToPath(import.meta.url)), '../skill/amorphous')
|
|
11
11
|
|
package/lib/api.mjs
CHANGED
|
@@ -2,24 +2,24 @@ import { cliAppFetchOrigin, cliAppHostHeader } from './config.mjs'
|
|
|
2
2
|
import { ensureFreshSession } from './session.mjs'
|
|
3
3
|
|
|
4
4
|
const FRIENDLY = {
|
|
5
|
-
'You already own 2 abstracts': 'You already own 2 abstracts. Run:
|
|
6
|
-
'This abstract already has 8 people': 'This abstract already has 8 people. Run:
|
|
5
|
+
'You already own 2 abstracts': 'You already own 2 abstracts. Run: amorphous-cli list',
|
|
6
|
+
'This abstract already has 8 people': 'This abstract already has 8 people. Run: amorphous-cli people',
|
|
7
7
|
'User not found': 'No account with that email yet.',
|
|
8
|
-
'Grant failed': 'Could not invite. Run:
|
|
9
|
-
'Cannot revoke owner': "You can't remove the owner. Run:
|
|
10
|
-
'Cannot remove yourself': "You can't remove yourself. Run:
|
|
11
|
-
'Collection not found': 'Folder not found. Run:
|
|
12
|
-
Forbidden: "You don't have access. Run:
|
|
13
|
-
'Not Found': 'Not found. Run:
|
|
14
|
-
'Owner required': 'Only the owner can do that. Run:
|
|
15
|
-
'id required': 'Missing id. Run:
|
|
16
|
-
'userId required': 'Pass an email or id. Run:
|
|
17
|
-
'Invalid body': 'Invalid request. Run:
|
|
18
|
-
'Name required': 'Name required. Run:
|
|
19
|
-
'Create failed': 'Could not create the abstract. Run:
|
|
20
|
-
'Publish failed': 'Could not publish. Run:
|
|
21
|
-
'File too large': 'That file is too large. Run:
|
|
22
|
-
'Not logged in. Run: amorphous login <email>': 'Not signed in. Run:
|
|
8
|
+
'Grant failed': 'Could not invite. Run: amorphous-cli people',
|
|
9
|
+
'Cannot revoke owner': "You can't remove the owner. Run: amorphous-cli people",
|
|
10
|
+
'Cannot remove yourself': "You can't remove yourself. Run: amorphous-cli people",
|
|
11
|
+
'Collection not found': 'Folder not found. Run: amorphous-cli folder list',
|
|
12
|
+
Forbidden: "You don't have access. Run: amorphous-cli list",
|
|
13
|
+
'Not Found': 'Not found. Run: amorphous-cli list',
|
|
14
|
+
'Owner required': 'Only the owner can do that. Run: amorphous-cli people',
|
|
15
|
+
'id required': 'Missing id. Run: amorphous-cli list',
|
|
16
|
+
'userId required': 'Pass an email or id. Run: amorphous-cli people',
|
|
17
|
+
'Invalid body': 'Invalid request. Run: amorphous-cli pull',
|
|
18
|
+
'Name required': 'Name required. Run: amorphous-cli rename --name <name>',
|
|
19
|
+
'Create failed': 'Could not create the abstract. Run: amorphous-cli list',
|
|
20
|
+
'Publish failed': 'Could not publish. Run: amorphous-cli push',
|
|
21
|
+
'File too large': 'That file is too large. Run: amorphous-cli pull',
|
|
22
|
+
'Not logged in. Run: amorphous login <email>': 'Not signed in. Run: amorphous-cli login',
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export function friendlyMessage(raw) {
|
package/lib/commands.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { homedir } from 'node:os'
|
|
|
7
7
|
import { apiFetch } from './api.mjs'
|
|
8
8
|
import { abstractsOrigin, appOrigin, projectMetaPath } from './config.mjs'
|
|
9
9
|
import { recordsFromLocal, readProjectFiles, writeProjectFiles } from './files.mjs'
|
|
10
|
+
import { startServe } from './serve.mjs'
|
|
10
11
|
import { ALIASES, printHelp, wantsHelp } from './help.mjs'
|
|
11
12
|
import { loginWithBrowser, loginWithMagicLink } from './login.mjs'
|
|
12
13
|
|
|
@@ -23,6 +24,7 @@ const BOOL_FLAGS = new Set([
|
|
|
23
24
|
'open',
|
|
24
25
|
'closed',
|
|
25
26
|
'quiet',
|
|
27
|
+
'data',
|
|
26
28
|
])
|
|
27
29
|
|
|
28
30
|
function parseFlags(args) {
|
|
@@ -75,7 +77,7 @@ function writeMeta(cwd, abstractId) {
|
|
|
75
77
|
function requireMeta(cwd) {
|
|
76
78
|
const meta = readMeta(cwd)
|
|
77
79
|
if (!meta?.abstractId) {
|
|
78
|
-
throw new Error('No .amorphous.json here. Run
|
|
80
|
+
throw new Error('No .amorphous.json here. Run amorphous-cli create first.')
|
|
79
81
|
}
|
|
80
82
|
return meta.abstractId
|
|
81
83
|
}
|
|
@@ -330,20 +332,45 @@ async function cmdPush(cwd, rest) {
|
|
|
330
332
|
const { flags } = parseFlags(rest)
|
|
331
333
|
if (flags.help) return printHelp('push')
|
|
332
334
|
const id = requireMeta(cwd)
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
335
|
+
if (flags.data) {
|
|
336
|
+
const localRecords = recordsFromLocal(cwd)
|
|
337
|
+
const { refused } = await apiFetch(`/api/abstracts/${id}/records`, {
|
|
338
|
+
method: 'POST',
|
|
339
|
+
body: JSON.stringify({ records: localRecords }),
|
|
340
|
+
})
|
|
341
|
+
if (refused?.length) {
|
|
342
|
+
console.error(`Skipped (already live): ${refused.join(', ')}. Run: amorphous-cli pull`)
|
|
343
|
+
}
|
|
340
344
|
}
|
|
341
345
|
const { html, css, js } = readProjectFiles(cwd)
|
|
342
346
|
await apiFetch(`/api/abstracts/${id}/push`, {
|
|
343
347
|
method: 'POST',
|
|
344
348
|
body: JSON.stringify({ html, css, js }),
|
|
345
349
|
})
|
|
346
|
-
console.log('Pushed.')
|
|
350
|
+
console.log(flags.data ? 'Pushed source and records.' : 'Pushed source.')
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function cmdServe(cwd, rest) {
|
|
354
|
+
const { flags } = parseFlags(rest)
|
|
355
|
+
if (flags.help) return printHelp('serve')
|
|
356
|
+
const id = requireMeta(cwd)
|
|
357
|
+
const port = flags.port ? Number(flags.port) : 0
|
|
358
|
+
if (flags.port && (!Number.isInteger(port) || port < 1 || port > 65535)) {
|
|
359
|
+
throw new Error('Use --port <number>.')
|
|
360
|
+
}
|
|
361
|
+
const running = await startServe({
|
|
362
|
+
cwd,
|
|
363
|
+
abstractId: id,
|
|
364
|
+
port,
|
|
365
|
+
json: jsonMode(flags),
|
|
366
|
+
})
|
|
367
|
+
await new Promise((resolve) => {
|
|
368
|
+
const stop = () => {
|
|
369
|
+
running.close().then(resolve, resolve)
|
|
370
|
+
}
|
|
371
|
+
process.once('SIGINT', stop)
|
|
372
|
+
process.once('SIGTERM', stop)
|
|
373
|
+
})
|
|
347
374
|
}
|
|
348
375
|
|
|
349
376
|
function urlsFor(id) {
|
|
@@ -737,6 +764,8 @@ export async function runCommand(argv, cwd = process.cwd()) {
|
|
|
737
764
|
return cmdOpen(cwd, rest)
|
|
738
765
|
case 'preview':
|
|
739
766
|
return cmdPreview(cwd, rest)
|
|
767
|
+
case 'serve':
|
|
768
|
+
return cmdServe(cwd, rest)
|
|
740
769
|
case 'publish':
|
|
741
770
|
return cmdPublish(cwd, rest)
|
|
742
771
|
case 'live':
|
|
@@ -770,6 +799,6 @@ export async function runCommand(argv, cwd = process.cwd()) {
|
|
|
770
799
|
if (rest[0] === 'member') return cmdSpaceMember(rest.slice(1))
|
|
771
800
|
throw new Error('Use folder instead.')
|
|
772
801
|
default:
|
|
773
|
-
throw new Error(`Unknown command “${raw}”. Run
|
|
802
|
+
throw new Error(`Unknown command “${raw}”. Run amorphous-cli --help.`)
|
|
774
803
|
}
|
|
775
804
|
}
|
package/lib/config.mjs
CHANGED
|
@@ -72,6 +72,36 @@ export function abstractsOrigin() {
|
|
|
72
72
|
return process.env.ABSTRACTS_ORIGIN || HOSTED.abstractsOrigin
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/** Local Herd HTTPS: hit Nuxt on loopback with the abstracts Host header (no TLS). */
|
|
76
|
+
export function cliAbstractsFetchOrigin() {
|
|
77
|
+
if (process.env.AMORPHOUS_CLI_ABSTRACTS_URL) {
|
|
78
|
+
return process.env.AMORPHOUS_CLI_ABSTRACTS_URL.replace(/\/$/, '')
|
|
79
|
+
}
|
|
80
|
+
const origin = abstractsOrigin()
|
|
81
|
+
try {
|
|
82
|
+
const u = new URL(origin)
|
|
83
|
+
if (u.protocol === 'https:' && (u.hostname.endsWith('.test') || u.hostname === 'localhost')) {
|
|
84
|
+
const port = process.env.AMORPHOUS_DEV_PORT || '3847'
|
|
85
|
+
return `http://127.0.0.1:${port}`
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
/* ignore */
|
|
89
|
+
}
|
|
90
|
+
return origin.replace(/\/$/, '')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function cliAbstractsHostHeader() {
|
|
94
|
+
const origin = abstractsOrigin()
|
|
95
|
+
const fetchOrigin = cliAbstractsFetchOrigin()
|
|
96
|
+
if (!fetchOrigin.startsWith('http://127.0.0.1')) return {}
|
|
97
|
+
try {
|
|
98
|
+
const u = new URL(origin)
|
|
99
|
+
return { Host: u.host }
|
|
100
|
+
} catch {
|
|
101
|
+
return {}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
75
105
|
export function supabaseUrl() {
|
|
76
106
|
return process.env.SUPABASE_URL || HOSTED.supabaseUrl
|
|
77
107
|
}
|
package/lib/files.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
1
2
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
|
2
3
|
import { join } from 'node:path'
|
|
3
4
|
|
|
@@ -47,4 +48,45 @@ export function recordsFromLocal(cwd) {
|
|
|
47
48
|
]
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
export function writeLocalRecords(cwd, rows) {
|
|
52
|
+
writeNdjson(
|
|
53
|
+
join(cwd, 'data.ndjson'),
|
|
54
|
+
rows.filter((r) => r.visibility !== 'private'),
|
|
55
|
+
)
|
|
56
|
+
writeNdjson(
|
|
57
|
+
join(cwd, 'private.ndjson'),
|
|
58
|
+
rows.filter((r) => r.visibility === 'private'),
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function insertLocalRecord(cwd, row) {
|
|
63
|
+
const body = { ...(row && typeof row === 'object' ? row : {}) }
|
|
64
|
+
if (body.visibility && body.visibility !== 'shared' && body.visibility !== 'private') {
|
|
65
|
+
return { status: 400, message: 'Invalid visibility' }
|
|
66
|
+
}
|
|
67
|
+
body.visibility = body.visibility === 'private' ? 'private' : 'shared'
|
|
68
|
+
if (!body.id) body.id = randomUUID()
|
|
69
|
+
const rows = recordsFromLocal(cwd)
|
|
70
|
+
if (rows.some((r) => r.id === body.id)) return { status: 409, message: 'Conflict' }
|
|
71
|
+
rows.push(body)
|
|
72
|
+
writeLocalRecords(cwd, rows)
|
|
73
|
+
return { status: 200, row: body }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function replaceLocalRecord(cwd, id, row) {
|
|
77
|
+
const rows = recordsFromLocal(cwd)
|
|
78
|
+
const index = rows.findIndex((r) => r.id === id)
|
|
79
|
+
if (index < 0) return { status: 404, message: 'Not Found' }
|
|
80
|
+
const existing = rows[index]
|
|
81
|
+
const next = {
|
|
82
|
+
...(existing && typeof existing === 'object' ? existing : {}),
|
|
83
|
+
...(row && typeof row === 'object' ? row : {}),
|
|
84
|
+
id,
|
|
85
|
+
visibility: existing.visibility,
|
|
86
|
+
}
|
|
87
|
+
rows[index] = next
|
|
88
|
+
writeLocalRecords(cwd, rows)
|
|
89
|
+
return { status: 200, row: next }
|
|
90
|
+
}
|
|
91
|
+
|
|
50
92
|
export { FILES }
|
package/lib/help.mjs
CHANGED
|
@@ -7,6 +7,7 @@ export const ROOT_HELP = `Amorphous
|
|
|
7
7
|
create New abstract
|
|
8
8
|
open [abstract] Open URL
|
|
9
9
|
preview [abstract] Preview URL (draft)
|
|
10
|
+
serve Serve this folder locally
|
|
10
11
|
pull Pull draft and records to this folder
|
|
11
12
|
push Push local files
|
|
12
13
|
publish [--live] Publish a version; --live is Go live
|
|
@@ -28,7 +29,7 @@ export const ROOT_HELP = `Amorphous
|
|
|
28
29
|
folder people People on a folder
|
|
29
30
|
rename --name Rename
|
|
30
31
|
|
|
31
|
-
Run
|
|
32
|
+
Run amorphous-cli <command> --help for flags. Pass --json where you need machine output.
|
|
32
33
|
`
|
|
33
34
|
|
|
34
35
|
export const COMMAND_HELP = {
|
|
@@ -64,13 +65,19 @@ Print the Open URL.`,
|
|
|
64
65
|
|
|
65
66
|
Print the Preview URL (draft).`,
|
|
66
67
|
|
|
68
|
+
serve: `serve [--port <n>] [--json]
|
|
69
|
+
|
|
70
|
+
Serve this folder on 127.0.0.1 and reload when index.html, styles.css, or app.js changes.
|
|
71
|
+
Records start local, in this folder. The page can switch to prod, which reads and writes the abstract's records.`,
|
|
72
|
+
|
|
67
73
|
pull: `pull
|
|
68
74
|
|
|
69
75
|
Pull the draft and records into this directory.`,
|
|
70
76
|
|
|
71
|
-
push: `push
|
|
77
|
+
push: `push [--data]
|
|
72
78
|
|
|
73
|
-
Push local index.html, styles.css, app.js
|
|
79
|
+
Push local index.html, styles.css, and app.js.
|
|
80
|
+
--data also merges records from this folder. A record changed both here and on the abstract is refused.`,
|
|
74
81
|
|
|
75
82
|
publish: `publish [--live]
|
|
76
83
|
|
package/lib/login.mjs
CHANGED
|
@@ -70,7 +70,7 @@ async function loginViaMailpit(supabase, email, redirectTo, sinceMs) {
|
|
|
70
70
|
async function readCode(email) {
|
|
71
71
|
if (!process.stdin.isTTY) {
|
|
72
72
|
throw new Error(
|
|
73
|
-
`Code sent to ${email}. Read it from that inbox, then run:
|
|
73
|
+
`Code sent to ${email}. Read it from that inbox, then run: amorphous-cli login ${email} --code <code>`,
|
|
74
74
|
)
|
|
75
75
|
}
|
|
76
76
|
const rl = createInterface({ input: process.stdin, output: process.stderr })
|
package/lib/serve.mjs
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { createServer } from 'node:http'
|
|
2
|
+
import { existsSync, readFileSync, watch } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { apiFetch } from './api.mjs'
|
|
5
|
+
import { cliAbstractsFetchOrigin, cliAbstractsHostHeader } from './config.mjs'
|
|
6
|
+
import { insertLocalRecord, readProjectFiles, recordsFromLocal, replaceLocalRecord } from './files.mjs'
|
|
7
|
+
|
|
8
|
+
const BAR_CSS = `#amorphous-serve{position:fixed;z-index:2147483646;left:12px;bottom:12px;display:flex;gap:6px;align-items:center;padding:4px 6px 4px 10px;border-radius:999px;background:#18181b;color:#fff;font:500 12px/1 system-ui,-apple-system,sans-serif}
|
|
9
|
+
#amorphous-serve button{border:0;border-radius:999px;padding:4px 8px;font:inherit;color:#a1a1aa;background:transparent;cursor:pointer}
|
|
10
|
+
#amorphous-serve button[aria-pressed="true"]{background:#fff;color:#18181b}`
|
|
11
|
+
|
|
12
|
+
function stripScriptTags(html) {
|
|
13
|
+
return String(html || '').replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function readCookie(req, name) {
|
|
17
|
+
const raw = req.headers.cookie || ''
|
|
18
|
+
const match = new RegExp(`(?:^|;\\s*)${name}=([^;]+)`).exec(raw)
|
|
19
|
+
return match ? decodeURIComponent(match[1]) : ''
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function recordsMode(req, url) {
|
|
23
|
+
const query = url.searchParams.get('records')
|
|
24
|
+
if (query === 'prod' || query === 'local') return query
|
|
25
|
+
const cookie = readCookie(req, 'amorphous_records')
|
|
26
|
+
return cookie === 'prod' ? 'prod' : 'local'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function readJson(req) {
|
|
30
|
+
const chunks = []
|
|
31
|
+
for await (const chunk of req) chunks.push(chunk)
|
|
32
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
33
|
+
if (!text) return {}
|
|
34
|
+
return JSON.parse(text)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function send(res, status, body, headers = {}) {
|
|
38
|
+
const payload = body == null ? '' : typeof body === 'string' ? body : JSON.stringify(body)
|
|
39
|
+
res.writeHead(status, {
|
|
40
|
+
'Cache-Control': 'no-store',
|
|
41
|
+
...headers,
|
|
42
|
+
...(payload && typeof body !== 'string' ? { 'Content-Type': 'application/json; charset=utf-8' } : {}),
|
|
43
|
+
})
|
|
44
|
+
res.end(payload)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function pageHtml(html, mode) {
|
|
48
|
+
const pressed = (value) => (mode === value ? ' aria-pressed="true"' : '')
|
|
49
|
+
return `<!DOCTYPE html>
|
|
50
|
+
<html>
|
|
51
|
+
<head>
|
|
52
|
+
<meta charset="utf-8">
|
|
53
|
+
<link rel="stylesheet" href="/styles.css">
|
|
54
|
+
<style>${BAR_CSS}</style>
|
|
55
|
+
</head>
|
|
56
|
+
<body>
|
|
57
|
+
<div id="amorphous-root">${stripScriptTags(html)}</div>
|
|
58
|
+
<div id="amorphous-serve" data-mode="${mode}">
|
|
59
|
+
<span>Records</span>
|
|
60
|
+
<button type="button" data-set="local"${pressed('local')}>Local</button>
|
|
61
|
+
<button type="button" data-set="prod"${pressed('prod')}>Prod</button>
|
|
62
|
+
</div>
|
|
63
|
+
<script>
|
|
64
|
+
window.amorphous = {
|
|
65
|
+
login() {},
|
|
66
|
+
async me() {
|
|
67
|
+
const res = await fetch('/__amorphous/me')
|
|
68
|
+
if (!res.ok || res.status === 204) return null
|
|
69
|
+
const text = await res.text()
|
|
70
|
+
if (!text) return null
|
|
71
|
+
return JSON.parse(text)
|
|
72
|
+
},
|
|
73
|
+
async names(ids) {
|
|
74
|
+
const res = await fetch('/__amorphous/names', {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: { 'Content-Type': 'application/json' },
|
|
77
|
+
body: JSON.stringify({ ids })
|
|
78
|
+
})
|
|
79
|
+
if (!res.ok) return { names: {} }
|
|
80
|
+
return res.json()
|
|
81
|
+
},
|
|
82
|
+
data: {
|
|
83
|
+
async find() {
|
|
84
|
+
const res = await fetch('/__amorphous/data')
|
|
85
|
+
if (!res.ok) return []
|
|
86
|
+
const payload = await res.json()
|
|
87
|
+
return payload.records || []
|
|
88
|
+
},
|
|
89
|
+
async insert(row) {
|
|
90
|
+
const body = { ...row }
|
|
91
|
+
if (!body.id && crypto.randomUUID) body.id = crypto.randomUUID()
|
|
92
|
+
const res = await fetch('/__amorphous/data', {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: { 'Content-Type': 'application/json' },
|
|
95
|
+
body: JSON.stringify(body)
|
|
96
|
+
})
|
|
97
|
+
if (!res.ok) throw new Error('insert failed: ' + res.status)
|
|
98
|
+
return body
|
|
99
|
+
},
|
|
100
|
+
async replace(id, row) {
|
|
101
|
+
const res = await fetch('/__amorphous/data/' + encodeURIComponent(id), {
|
|
102
|
+
method: 'PUT',
|
|
103
|
+
headers: { 'Content-Type': 'application/json' },
|
|
104
|
+
body: JSON.stringify(row)
|
|
105
|
+
})
|
|
106
|
+
if (!res.ok) throw new Error('replace failed: ' + res.status)
|
|
107
|
+
return row
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
document.getElementById('amorphous-serve').addEventListener('click', function (event) {
|
|
112
|
+
var mode = event.target && event.target.getAttribute('data-set')
|
|
113
|
+
if (!mode) return
|
|
114
|
+
fetch('/__amorphous/mode', {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
headers: { 'Content-Type': 'application/json' },
|
|
117
|
+
body: JSON.stringify({ mode: mode })
|
|
118
|
+
}).then(function () { location.reload() })
|
|
119
|
+
})
|
|
120
|
+
new EventSource('/__amorphous/events').addEventListener('reload', function () {
|
|
121
|
+
location.reload()
|
|
122
|
+
})
|
|
123
|
+
</script>
|
|
124
|
+
<script src="/app.js"></script>
|
|
125
|
+
</body>
|
|
126
|
+
</html>`
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function passFromResponse(res) {
|
|
130
|
+
const list = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : []
|
|
131
|
+
const lines = list.length ? list : [res.headers.get('set-cookie') || '']
|
|
132
|
+
for (const line of lines) {
|
|
133
|
+
const match = /(?:^|,\s*)am_pass=([^;]+)/.exec(line)
|
|
134
|
+
if (match) return `am_pass=${match[1]}`
|
|
135
|
+
}
|
|
136
|
+
return null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function mintAbstractPass(abstractId) {
|
|
140
|
+
const data = await apiFetch(`/api/abstracts/${abstractId}/code`, { method: 'POST', body: '{}' })
|
|
141
|
+
const code = data?.code
|
|
142
|
+
if (!code) throw new Error('Could not open the abstract records.')
|
|
143
|
+
const res = await fetch(`${cliAbstractsFetchOrigin()}/${abstractId}?code=${encodeURIComponent(code)}`, {
|
|
144
|
+
redirect: 'manual',
|
|
145
|
+
headers: { ...cliAbstractsHostHeader() },
|
|
146
|
+
})
|
|
147
|
+
const cookie = passFromResponse(res)
|
|
148
|
+
if (!cookie) throw new Error('Could not open the abstract records.')
|
|
149
|
+
return cookie
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function proxyAbstract(abstractId, path, init, session) {
|
|
153
|
+
const headers = {
|
|
154
|
+
...cliAbstractsHostHeader(),
|
|
155
|
+
...(init.headers || {}),
|
|
156
|
+
}
|
|
157
|
+
if (session.cookie) headers.Cookie = session.cookie
|
|
158
|
+
let res
|
|
159
|
+
try {
|
|
160
|
+
res = await fetch(`${cliAbstractsFetchOrigin()}/${abstractId}${path}`, {
|
|
161
|
+
method: init.method || 'GET',
|
|
162
|
+
headers,
|
|
163
|
+
body: init.body,
|
|
164
|
+
redirect: 'manual',
|
|
165
|
+
})
|
|
166
|
+
} catch {
|
|
167
|
+
return new Response(JSON.stringify({ message: 'Could not reach the abstract.' }), {
|
|
168
|
+
status: 502,
|
|
169
|
+
headers: { 'Content-Type': 'application/json' },
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
if (res.status === 401 && session.cookie && !init.retried) {
|
|
173
|
+
session.cookie = null
|
|
174
|
+
session.cookie = await session.mint()
|
|
175
|
+
return proxyAbstract(abstractId, path, { ...init, retried: true }, session)
|
|
176
|
+
}
|
|
177
|
+
return res
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function startServe(options) {
|
|
181
|
+
const cwd = options.cwd
|
|
182
|
+
const abstractId = options.abstractId
|
|
183
|
+
const host = options.host || '127.0.0.1'
|
|
184
|
+
const session = {
|
|
185
|
+
cookie: null,
|
|
186
|
+
failed: false,
|
|
187
|
+
mint: options.mintPass || (() => mintAbstractPass(abstractId)),
|
|
188
|
+
}
|
|
189
|
+
const clients = new Set()
|
|
190
|
+
let reloadTimer = null
|
|
191
|
+
|
|
192
|
+
function notifyReload() {
|
|
193
|
+
clearTimeout(reloadTimer)
|
|
194
|
+
reloadTimer = setTimeout(() => {
|
|
195
|
+
for (const client of clients) client.write('event: reload\ndata: 1\n\n')
|
|
196
|
+
}, 60)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const watchers = []
|
|
200
|
+
for (const name of ['index.html', 'styles.css', 'app.js']) {
|
|
201
|
+
const path = join(cwd, name)
|
|
202
|
+
if (!existsSync(path)) continue
|
|
203
|
+
watchers.push(watch(path, notifyReload))
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function ensurePass() {
|
|
207
|
+
if (session.cookie) return session.cookie
|
|
208
|
+
if (session.failed) return null
|
|
209
|
+
try {
|
|
210
|
+
session.cookie = await session.mint()
|
|
211
|
+
return session.cookie
|
|
212
|
+
} catch {
|
|
213
|
+
session.failed = true
|
|
214
|
+
return null
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function prod(path, init) {
|
|
219
|
+
if (options.prodFetch) return options.prodFetch(abstractId, path, init, session)
|
|
220
|
+
const cookie = await ensurePass()
|
|
221
|
+
if (!cookie && init.auth !== false) {
|
|
222
|
+
return new Response(JSON.stringify({ message: 'Not signed in. Run: amorphous-cli login' }), {
|
|
223
|
+
status: 401,
|
|
224
|
+
headers: { 'Content-Type': 'application/json' },
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
const res = await proxyAbstract(abstractId, path, { ...init, headers: { ...(init.headers || {}), Cookie: cookie || '' } }, session)
|
|
228
|
+
const text = await res.text()
|
|
229
|
+
return new Response(text, { status: res.status, headers: { 'Content-Type': res.headers.get('content-type') || 'application/json' } })
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const server = createServer(async (req, res) => {
|
|
233
|
+
try {
|
|
234
|
+
const url = new URL(req.url || '/', 'http://127.0.0.1')
|
|
235
|
+
if (req.method === 'GET' && url.pathname === '/') {
|
|
236
|
+
const mode = recordsMode(req, url)
|
|
237
|
+
const headers = { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }
|
|
238
|
+
if (url.searchParams.get('records') === 'local' || url.searchParams.get('records') === 'prod') {
|
|
239
|
+
headers['Set-Cookie'] = `amorphous_records=${mode}; Path=/; SameSite=Lax`
|
|
240
|
+
}
|
|
241
|
+
send(res, 200, pageHtml(readProjectFiles(cwd).html, mode), headers)
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
if (req.method === 'GET' && url.pathname === '/styles.css') {
|
|
245
|
+
send(res, 200, readProjectFiles(cwd).css, { 'Content-Type': 'text/css; charset=utf-8' })
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
if (req.method === 'GET' && url.pathname === '/app.js') {
|
|
249
|
+
send(res, 200, readProjectFiles(cwd).js, { 'Content-Type': 'text/javascript; charset=utf-8' })
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
if (req.method === 'GET' && url.pathname === '/__amorphous/events') {
|
|
253
|
+
res.writeHead(200, {
|
|
254
|
+
'Content-Type': 'text/event-stream',
|
|
255
|
+
'Cache-Control': 'no-cache',
|
|
256
|
+
Connection: 'keep-alive',
|
|
257
|
+
})
|
|
258
|
+
res.write(':\n\n')
|
|
259
|
+
clients.add(res)
|
|
260
|
+
req.on('close', () => clients.delete(res))
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
if (req.method === 'POST' && url.pathname === '/__amorphous/mode') {
|
|
264
|
+
const body = await readJson(req)
|
|
265
|
+
const mode = body.mode === 'prod' ? 'prod' : 'local'
|
|
266
|
+
send(res, 204, null, { 'Set-Cookie': `amorphous_records=${mode}; Path=/; SameSite=Lax` })
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
const mode = recordsMode(req, url)
|
|
270
|
+
if (req.method === 'GET' && url.pathname === '/__amorphous/me') {
|
|
271
|
+
const upstream = await prod('/api/me', { method: 'GET', auth: false })
|
|
272
|
+
const text = await upstream.text()
|
|
273
|
+
send(res, upstream.ok ? 200 : upstream.status, text, {
|
|
274
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
275
|
+
})
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
if (req.method === 'POST' && url.pathname === '/__amorphous/names') {
|
|
279
|
+
const body = await readJson(req)
|
|
280
|
+
const upstream = await prod('/api/names', {
|
|
281
|
+
method: 'POST',
|
|
282
|
+
auth: false,
|
|
283
|
+
headers: { 'Content-Type': 'application/json' },
|
|
284
|
+
body: JSON.stringify({ ids: Array.isArray(body.ids) ? body.ids : [] }),
|
|
285
|
+
})
|
|
286
|
+
const text = await upstream.text()
|
|
287
|
+
send(res, upstream.ok ? 200 : upstream.status, text, {
|
|
288
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
289
|
+
})
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
if (url.pathname === '/__amorphous/data' || url.pathname.startsWith('/__amorphous/data/')) {
|
|
293
|
+
if (mode === 'prod') {
|
|
294
|
+
const recordId = decodeURIComponent(url.pathname.slice('/__amorphous/data/'.length))
|
|
295
|
+
const path = req.method === 'PUT' ? `/api/data/${encodeURIComponent(recordId)}` : '/api/data'
|
|
296
|
+
const init = { method: req.method, auth: true }
|
|
297
|
+
if (req.method === 'POST' || req.method === 'PUT') {
|
|
298
|
+
init.headers = { 'Content-Type': 'application/json' }
|
|
299
|
+
init.body = JSON.stringify(await readJson(req))
|
|
300
|
+
}
|
|
301
|
+
const upstream = await prod(path, init)
|
|
302
|
+
const text = await upstream.text()
|
|
303
|
+
send(res, upstream.status, text, { 'Content-Type': 'application/json; charset=utf-8' })
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
if (req.method === 'GET') {
|
|
307
|
+
send(res, 200, { records: recordsFromLocal(cwd) })
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
if (req.method === 'POST') {
|
|
311
|
+
const result = insertLocalRecord(cwd, await readJson(req))
|
|
312
|
+
send(res, result.status, result.row || { message: result.message })
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
if (req.method === 'PUT') {
|
|
316
|
+
const recordId = decodeURIComponent(url.pathname.slice('/__amorphous/data/'.length))
|
|
317
|
+
const result = replaceLocalRecord(cwd, recordId, await readJson(req))
|
|
318
|
+
send(res, result.status, result.row || { message: result.message })
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
send(res, 404, { message: 'Not found' })
|
|
323
|
+
} catch (error) {
|
|
324
|
+
if (error instanceof SyntaxError) {
|
|
325
|
+
send(res, 400, { message: 'Invalid request' })
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
send(res, 500, { message: error instanceof Error ? error.message : 'Request failed' })
|
|
329
|
+
}
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
const ready = new Promise((resolve, reject) => {
|
|
333
|
+
server.once('error', reject)
|
|
334
|
+
server.listen(options.port || 0, host, () => {
|
|
335
|
+
server.removeListener('error', reject)
|
|
336
|
+
const address = server.address()
|
|
337
|
+
const port = typeof address === 'object' && address ? address.port : options.port
|
|
338
|
+
const url = `http://${host}:${port}/`
|
|
339
|
+
resolve({ url, port })
|
|
340
|
+
})
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
return ready.then((info) => {
|
|
344
|
+
if (!options.silent) {
|
|
345
|
+
if (options.json) console.log(JSON.stringify({ url: info.url }))
|
|
346
|
+
else console.log(info.url)
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
...info,
|
|
350
|
+
close() {
|
|
351
|
+
clearTimeout(reloadTimer)
|
|
352
|
+
for (const watcher of watchers) watcher.close()
|
|
353
|
+
for (const client of clients) client.end()
|
|
354
|
+
return new Promise((resolve, reject) => {
|
|
355
|
+
server.close((error) => (error ? reject(error) : resolve()))
|
|
356
|
+
})
|
|
357
|
+
},
|
|
358
|
+
}
|
|
359
|
+
})
|
|
360
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import test from 'node:test'
|
|
6
|
+
import { startServe } from './serve.mjs'
|
|
7
|
+
|
|
8
|
+
function project() {
|
|
9
|
+
const cwd = mkdtempSync(join(tmpdir(), 'amorphous-serve-'))
|
|
10
|
+
writeFileSync(join(cwd, 'index.html'), '<main><script>alert(1)</script><p id="who">Hi</p></main>')
|
|
11
|
+
writeFileSync(join(cwd, 'styles.css'), 'main{color:black}')
|
|
12
|
+
writeFileSync(join(cwd, 'app.js'), 'document.title = "served"')
|
|
13
|
+
writeFileSync(join(cwd, 'data.ndjson'), '')
|
|
14
|
+
writeFileSync(join(cwd, 'private.ndjson'), '')
|
|
15
|
+
return cwd
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function cookieFrom(res) {
|
|
19
|
+
const line = res.headers.get('set-cookie') || ''
|
|
20
|
+
const match = /amorphous_records=(local|prod)/.exec(line)
|
|
21
|
+
return match ? `amorphous_records=${match[1]}` : ''
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test('serve reloads local source and keeps prod records off disk', async () => {
|
|
25
|
+
const cwd = project()
|
|
26
|
+
const calls = []
|
|
27
|
+
const running = await startServe({
|
|
28
|
+
cwd,
|
|
29
|
+
abstractId: '00000000-0000-4000-8000-000000000001',
|
|
30
|
+
silent: true,
|
|
31
|
+
prodFetch: async (_id, path, init) => {
|
|
32
|
+
calls.push({ path, method: init.method, body: init.body })
|
|
33
|
+
if (path === '/api/data' && init.method === 'GET') {
|
|
34
|
+
return new Response(JSON.stringify({ records: [{ id: 'live', visibility: 'shared', text: 'remote' }] }), {
|
|
35
|
+
status: 200,
|
|
36
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
if (path === '/api/data' && init.method === 'POST') {
|
|
40
|
+
return new Response(JSON.stringify({ ok: true }), { status: 200 })
|
|
41
|
+
}
|
|
42
|
+
if (path === '/api/me') {
|
|
43
|
+
return new Response(JSON.stringify({ id: 'user-1', name: 'Ada' }), { status: 200 })
|
|
44
|
+
}
|
|
45
|
+
return new Response(JSON.stringify({ names: { 'user-1': 'Ada' } }), { status: 200 })
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const page = await fetch(running.url)
|
|
51
|
+
const html = await page.text()
|
|
52
|
+
assert.match(html, /id="who"/)
|
|
53
|
+
assert.doesNotMatch(html, /alert\(1\)/)
|
|
54
|
+
assert.match(html, /data-set="local" aria-pressed="true"/)
|
|
55
|
+
assert.match(html, /data-set="prod"/)
|
|
56
|
+
assert.equal(await (await fetch(`${running.url}styles.css`)).text(), 'main{color:black}')
|
|
57
|
+
assert.equal(await (await fetch(`${running.url}app.js`)).text(), 'document.title = "served"')
|
|
58
|
+
|
|
59
|
+
const inserted = await fetch(`${running.url}__amorphous/data`, {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers: { 'Content-Type': 'application/json' },
|
|
62
|
+
body: JSON.stringify({ id: 'local-1', text: 'note' }),
|
|
63
|
+
})
|
|
64
|
+
assert.equal(inserted.status, 200)
|
|
65
|
+
const found = await (await fetch(`${running.url}__amorphous/data`)).json()
|
|
66
|
+
assert.deepEqual(found.records, [{ id: 'local-1', text: 'note', visibility: 'shared' }])
|
|
67
|
+
|
|
68
|
+
const replaced = await fetch(`${running.url}__amorphous/data/local-1`, {
|
|
69
|
+
method: 'PUT',
|
|
70
|
+
headers: { 'Content-Type': 'application/json' },
|
|
71
|
+
body: JSON.stringify({ text: 'edited' }),
|
|
72
|
+
})
|
|
73
|
+
assert.equal(replaced.status, 200)
|
|
74
|
+
assert.match(readFileSync(join(cwd, 'data.ndjson'), 'utf8'), /"text":"edited"/)
|
|
75
|
+
assert.equal(calls.filter((call) => call.path === '/api/data').length, 0)
|
|
76
|
+
|
|
77
|
+
const again = await fetch(`${running.url}__amorphous/data`, {
|
|
78
|
+
method: 'POST',
|
|
79
|
+
headers: { 'Content-Type': 'application/json' },
|
|
80
|
+
body: JSON.stringify({ id: 'local-1', text: 'nope' }),
|
|
81
|
+
})
|
|
82
|
+
assert.equal(again.status, 409)
|
|
83
|
+
|
|
84
|
+
const switched = await fetch(`${running.url}__amorphous/mode`, {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
headers: { 'Content-Type': 'application/json' },
|
|
87
|
+
body: JSON.stringify({ mode: 'prod' }),
|
|
88
|
+
})
|
|
89
|
+
const jar = cookieFrom(switched)
|
|
90
|
+
assert.equal(jar, 'amorphous_records=prod')
|
|
91
|
+
const live = await (
|
|
92
|
+
await fetch(`${running.url}__amorphous/data`, { headers: { Cookie: jar } })
|
|
93
|
+
).json()
|
|
94
|
+
assert.equal(live.records[0].text, 'remote')
|
|
95
|
+
const pushed = await fetch(`${running.url}__amorphous/data`, {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: { 'Content-Type': 'application/json', Cookie: jar },
|
|
98
|
+
body: JSON.stringify({ id: 'from-prod', text: 'up' }),
|
|
99
|
+
})
|
|
100
|
+
assert.equal(pushed.status, 200)
|
|
101
|
+
assert.doesNotMatch(readFileSync(join(cwd, 'data.ndjson'), 'utf8'), /from-prod/)
|
|
102
|
+
assert.equal(calls.filter((call) => call.method === 'POST' && call.path === '/api/data').length, 1)
|
|
103
|
+
|
|
104
|
+
const me = await (await fetch(`${running.url}__amorphous/me`)).json()
|
|
105
|
+
assert.equal(me.name, 'Ada')
|
|
106
|
+
|
|
107
|
+
const events = await fetch(`${running.url}__amorphous/events`)
|
|
108
|
+
const reader = events.body.getReader()
|
|
109
|
+
const first = await reader.read()
|
|
110
|
+
assert.match(Buffer.from(first.value).toString(), /:/)
|
|
111
|
+
writeFileSync(join(cwd, 'styles.css'), 'main{color:white}')
|
|
112
|
+
let chunk = ''
|
|
113
|
+
const deadline = Date.now() + 2000
|
|
114
|
+
while (!chunk.includes('reload') && Date.now() < deadline) {
|
|
115
|
+
const next = await reader.read()
|
|
116
|
+
chunk += Buffer.from(next.value).toString()
|
|
117
|
+
}
|
|
118
|
+
assert.match(chunk, /reload/)
|
|
119
|
+
reader.cancel()
|
|
120
|
+
} finally {
|
|
121
|
+
await running.close()
|
|
122
|
+
}
|
|
123
|
+
})
|
package/lib/session.mjs
CHANGED
|
@@ -58,7 +58,7 @@ async function withCredentialLock(dir, fn) {
|
|
|
58
58
|
/* the other process released it */
|
|
59
59
|
}
|
|
60
60
|
if (Date.now() - started > LOCK_WAIT_MS) {
|
|
61
|
-
throw new Error('Not signed in. Run:
|
|
61
|
+
throw new Error('Not signed in. Run: amorphous-cli login')
|
|
62
62
|
}
|
|
63
63
|
await sleep(25)
|
|
64
64
|
}
|
|
@@ -115,11 +115,11 @@ export async function ensureFreshSession(options = {}) {
|
|
|
115
115
|
const now = options.now ?? Date.now()
|
|
116
116
|
const session = loadSession()
|
|
117
117
|
if (!session?.access_token) {
|
|
118
|
-
throw new Error('Not signed in. Run:
|
|
118
|
+
throw new Error('Not signed in. Run: amorphous-cli login')
|
|
119
119
|
}
|
|
120
120
|
if (!options.force && !sessionNeedsRefresh(session, now)) return session
|
|
121
121
|
if (!session.refresh_token) {
|
|
122
|
-
throw new Error('Not signed in. Run:
|
|
122
|
+
throw new Error('Not signed in. Run: amorphous-cli login')
|
|
123
123
|
}
|
|
124
124
|
const refresh = options.refresh ?? refreshWithSupabase
|
|
125
125
|
try {
|
|
@@ -135,7 +135,7 @@ export async function ensureFreshSession(options = {}) {
|
|
|
135
135
|
) {
|
|
136
136
|
return again
|
|
137
137
|
}
|
|
138
|
-
throw new Error('Not signed in. Run:
|
|
138
|
+
throw new Error('Not signed in. Run: amorphous-cli login')
|
|
139
139
|
}
|
|
140
140
|
})
|
|
141
141
|
}
|
|
@@ -148,7 +148,7 @@ export function accessToken() {
|
|
|
148
148
|
export function authHeaders(extra = {}) {
|
|
149
149
|
const token = accessToken()
|
|
150
150
|
if (!token) {
|
|
151
|
-
throw new Error('Not signed in. Run:
|
|
151
|
+
throw new Error('Not signed in. Run: amorphous-cli login')
|
|
152
152
|
}
|
|
153
153
|
return {
|
|
154
154
|
Authorization: `Bearer ${token}`,
|
package/lib/tool-result.test.mjs
CHANGED
|
@@ -2,14 +2,14 @@ import assert from 'node:assert/strict'
|
|
|
2
2
|
import test from 'node:test'
|
|
3
3
|
import { toolText } from './tool-result.mjs'
|
|
4
4
|
|
|
5
|
-
const NUDGE = 'Run:
|
|
5
|
+
const NUDGE = 'Run: npm install -g amorphous-cli@latest && amorphous-cli setup'
|
|
6
6
|
|
|
7
7
|
test('a successful command keeps stderr the model must see', () => {
|
|
8
8
|
assert.equal(toolText('[{"id":"a"}]', `${NUDGE}\n`, 0), `[{"id":"a"}]\n${NUDGE}`)
|
|
9
9
|
})
|
|
10
10
|
|
|
11
11
|
test('a failed command returns the error text', () => {
|
|
12
|
-
assert.equal(toolText('', 'Not signed in. Run:
|
|
12
|
+
assert.equal(toolText('', 'Not signed in. Run: amorphous-cli login\n', 1), 'Not signed in. Run: amorphous-cli login')
|
|
13
13
|
})
|
|
14
14
|
|
|
15
15
|
test('empty success is ok', () => {
|
package/lib/update-check.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { userSkillDirs } from './agent-contract.mjs'
|
|
|
8
8
|
import { CLI_VERSION } from './version.mjs'
|
|
9
9
|
|
|
10
10
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000
|
|
11
|
-
const NUDGE = 'Run:
|
|
11
|
+
const NUDGE = 'Run: npm install -g amorphous-cli@latest && amorphous-cli setup\n'
|
|
12
12
|
|
|
13
13
|
function parseVersion(v) {
|
|
14
14
|
if (typeof v !== 'string') return null
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
startUpdateCheck,
|
|
15
15
|
} from './update-check.mjs'
|
|
16
16
|
|
|
17
|
-
const NUDGE = 'Run:
|
|
17
|
+
const NUDGE = 'Run: npm install -g amorphous-cli@latest && amorphous-cli setup\n'
|
|
18
18
|
|
|
19
19
|
test('compareVersions', () => {
|
|
20
20
|
assert.equal(compareVersions('1.0.0', '1.0.0'), 0)
|
|
@@ -252,7 +252,7 @@ test('non-setup refreshes a stale skill when registry matches', async () => {
|
|
|
252
252
|
await update.finish()
|
|
253
253
|
assert.equal(err, '')
|
|
254
254
|
assert.equal(readFileSync(join(d0, '.amorphous-version'), 'utf8').trim(), CLI_VERSION)
|
|
255
|
-
assert.match(readFileSync(join(d0, 'SKILL.md'), 'utf8'), /
|
|
255
|
+
assert.match(readFileSync(join(d0, 'SKILL.md'), 'utf8'), /npm install -g amorphous-cli@latest/)
|
|
256
256
|
})
|
|
257
257
|
|
|
258
258
|
test('a newer registry is not copied over the installed skill', async () => {
|
|
@@ -291,6 +291,6 @@ test('integration help skips update check file', () => {
|
|
|
291
291
|
encoding: 'utf8',
|
|
292
292
|
})
|
|
293
293
|
assert.equal(r.status, 0)
|
|
294
|
-
assert.ok(!r.stderr.includes('Run:
|
|
294
|
+
assert.ok(!r.stderr.includes('Run: npm install -g amorphous-cli@latest && amorphous-cli setup'))
|
|
295
295
|
assert.throws(() => readFileSync(cacheFile(home)), { code: 'ENOENT' })
|
|
296
296
|
})
|
package/mcp.mjs
CHANGED
|
@@ -84,11 +84,13 @@ await serveStdio(() => {
|
|
|
84
84
|
{
|
|
85
85
|
title: 'Push the abstract',
|
|
86
86
|
description:
|
|
87
|
-
'Upload the local
|
|
88
|
-
inputSchema: z.object({
|
|
87
|
+
'Upload the local source. Set data to also merge records from this folder. A record changed both here and on the live abstract is refused; edit it and push again.',
|
|
88
|
+
inputSchema: z.object({
|
|
89
|
+
data: z.boolean().optional().describe('Also merge records from this folder'),
|
|
90
|
+
}),
|
|
89
91
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
90
92
|
},
|
|
91
|
-
async () => call(['push']),
|
|
93
|
+
async ({ data }) => call(data ? ['push', '--data'] : ['push']),
|
|
92
94
|
)
|
|
93
95
|
|
|
94
96
|
server.registerTool(
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amorphous-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"mcpName": "io.github.amorphous-one/amorphous",
|
|
5
|
-
"description": "Make whatever you want, yourself, with your own agent, or with others.
|
|
5
|
+
"description": "Make whatever you want, yourself, with your own agent, or with others.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"amorphous-cli": "index.mjs",
|
package/skill/amorphous/SKILL.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: amorphous
|
|
3
|
-
description: Make whatever you want, yourself, with your own agent, or with others.
|
|
3
|
+
description: Make whatever you want, yourself, with your own agent, or with others. Use when someone wants an abstract other people can open, or wants to invite people, publish, or change an abstract.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
Make whatever you want, yourself, with your own agent, or with others. And whatever you want can be private, public, or even have users
|
|
6
|
+
Make whatever you want, yourself, with your own agent, or with others. And whatever you want can be private, public, or even have users.
|
|
7
7
|
|
|
8
8
|
## Files
|
|
9
9
|
|
|
@@ -32,17 +32,20 @@ A private record belongs to the signed-in person who wrote it. The abstract does
|
|
|
32
32
|
|
|
33
33
|
## Loop
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
Install the CLI once with `npm install -g amorphous-cli@latest`, then call it as `amorphous-cli <command>`. If a command prints `Run: npm install -g amorphous-cli@latest && amorphous-cli setup`, run that line, then repeat the command.
|
|
36
36
|
|
|
37
|
-
1. If you are not signed in, run `
|
|
38
|
-
2. `
|
|
37
|
+
1. If you are not signed in, run `amorphous-cli login`. Show the person the link it prints and ask them to finish in the browser. Do not ask for an email or a code.
|
|
38
|
+
2. `amorphous-cli create --name "..."` in a folder, or `amorphous-cli pull` when `.amorphous.json` is already there.
|
|
39
39
|
3. Edit the files to match what they asked for. `me` and `names` are how the abstract knows who is here.
|
|
40
|
-
4. `
|
|
41
|
-
5. `
|
|
42
|
-
6. `
|
|
43
|
-
7. `
|
|
40
|
+
4. `amorphous-cli serve` and open that URL. It reloads when the files change. Records on that page start in this folder. Switch the page to prod to read and write the abstract's records.
|
|
41
|
+
5. `amorphous-cli push` sends the source. `amorphous-cli push --data` also sends the records in this folder.
|
|
42
|
+
6. `amorphous-cli preview` and open that URL.
|
|
43
|
+
7. `amorphous-cli publish --live`
|
|
44
|
+
8. `amorphous-cli invite <email> use` or `edit` or `view`. It prints a link and does not send an email. Give them that link. They open it and sign in with that email.
|
|
44
45
|
|
|
45
|
-
`
|
|
46
|
+
`amorphous-cli visibility anyone` lets anyone view and write shared records. `amorphous-cli visibility members` keeps the abstract to invited people.
|
|
47
|
+
|
|
48
|
+
Do not make a public abstract that lets anyone keep adding shared records, such as an open list. Those records stay on the abstract and use up its storage. Keep shared records to a set you replace, or keep the abstract to invited people.
|
|
46
49
|
|
|
47
50
|
A person's access is view, use, or edit. Member tiers, scores, and roles inside an abstract are records on that abstract.
|
|
48
51
|
|
|
@@ -1,26 +1,8 @@
|
|
|
1
1
|
const who = document.getElementById('who')
|
|
2
|
-
const list = document.getElementById('items')
|
|
3
|
-
const form = document.getElementById('add')
|
|
4
2
|
|
|
5
3
|
async function refresh() {
|
|
6
4
|
const me = await amorphous.me()
|
|
7
|
-
who.textContent = me ? me.name : 'Sign in
|
|
8
|
-
const rows = await amorphous.data.find()
|
|
9
|
-
list.replaceChildren()
|
|
10
|
-
for (const row of rows) {
|
|
11
|
-
const li = document.createElement('li')
|
|
12
|
-
li.textContent = typeof row.text === 'string' ? row.text : ''
|
|
13
|
-
list.appendChild(li)
|
|
14
|
-
}
|
|
5
|
+
who.textContent = me ? me.name : 'Sign in'
|
|
15
6
|
}
|
|
16
7
|
|
|
17
|
-
form.addEventListener('submit', async (event) => {
|
|
18
|
-
event.preventDefault()
|
|
19
|
-
const text = new FormData(form).get('text')
|
|
20
|
-
if (typeof text !== 'string' || !text.trim()) return
|
|
21
|
-
await amorphous.data.insert({ text: text.trim() })
|
|
22
|
-
form.reset()
|
|
23
|
-
await refresh()
|
|
24
|
-
})
|
|
25
|
-
|
|
26
8
|
refresh()
|
|
@@ -9,23 +9,3 @@ main {
|
|
|
9
9
|
#who {
|
|
10
10
|
color: #52525b;
|
|
11
11
|
}
|
|
12
|
-
|
|
13
|
-
ul {
|
|
14
|
-
padding-left: 1.1rem;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
form {
|
|
18
|
-
display: flex;
|
|
19
|
-
gap: 0.5rem;
|
|
20
|
-
margin-top: 1rem;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
input {
|
|
24
|
-
flex: 1;
|
|
25
|
-
font: inherit;
|
|
26
|
-
padding: 0.4rem 0.55rem;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
button {
|
|
30
|
-
font: inherit;
|
|
31
|
-
}
|