amorphous-cli 0.1.4 → 0.1.6
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 +2 -2
- package/index.mjs +5 -0
- package/lib/agent-contract.mjs +6 -2
- package/lib/agent-contract.test.mjs +27 -4
- package/lib/api.mjs +8 -8
- package/lib/commands.mjs +15 -5
- package/lib/connect-crypto.mjs +83 -0
- package/lib/connect.test.mjs +171 -0
- package/lib/help.mjs +13 -7
- package/lib/login.mjs +117 -8
- package/lib/session.mjs +128 -6
- package/lib/update-check.mjs +188 -0
- package/lib/update-check.test.mjs +270 -0
- package/lib/version.mjs +3 -0
- package/mcp.mjs +78 -76
- package/package.json +5 -4
- package/skill/amorphous/SKILL.md +9 -9
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# amorphous-cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Build abstracts on Amorphous. An abstract can be anything. Your own agent builds it. Other people open it and write to it.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
npx amorphous-cli setup
|
|
6
|
+
npx amorphous-cli@latest setup
|
|
7
7
|
```
|
package/index.mjs
CHANGED
|
@@ -2,13 +2,18 @@
|
|
|
2
2
|
import { loadDotEnv } from './lib/config.mjs'
|
|
3
3
|
import { runCommand } from './lib/commands.mjs'
|
|
4
4
|
import { ensureLocalHttpsTrust } from './lib/tls.mjs'
|
|
5
|
+
import { startUpdateCheck } from './lib/update-check.mjs'
|
|
5
6
|
|
|
6
7
|
ensureLocalHttpsTrust()
|
|
7
8
|
loadDotEnv()
|
|
8
9
|
|
|
10
|
+
const update = startUpdateCheck({ argv: process.argv.slice(2) })
|
|
11
|
+
|
|
9
12
|
try {
|
|
10
13
|
await runCommand(process.argv.slice(2))
|
|
14
|
+
await update.finish()
|
|
11
15
|
} catch (e) {
|
|
12
16
|
console.error(e instanceof Error ? e.message : e)
|
|
17
|
+
await update.finish()
|
|
13
18
|
process.exit(1)
|
|
14
19
|
}
|
package/lib/agent-contract.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
|
-
import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
3
|
import { dirname, join } from 'node:path'
|
|
4
4
|
import { fileURLToPath } from 'node:url'
|
|
5
5
|
import { homedir } from 'node:os'
|
|
6
|
+
import { CLI_VERSION } from './version.mjs'
|
|
6
7
|
|
|
7
|
-
export const CONTRACT_VERSION =
|
|
8
|
+
export const CONTRACT_VERSION = 2
|
|
8
9
|
|
|
9
10
|
const skillRoot = join(dirname(fileURLToPath(import.meta.url)), '../skill/amorphous')
|
|
10
11
|
|
|
@@ -75,9 +76,12 @@ export function userSkillDirs(home = homedir()) {
|
|
|
75
76
|
|
|
76
77
|
export function installUserSkill(home = homedir()) {
|
|
77
78
|
const dirs = userSkillDirs(home)
|
|
79
|
+
const stamp = `${CLI_VERSION}\n`
|
|
78
80
|
for (const dest of dirs) {
|
|
81
|
+
rmSync(dest, { recursive: true, force: true })
|
|
79
82
|
mkdirSync(dest, { recursive: true })
|
|
80
83
|
cpSync(skillRoot, dest, { recursive: true })
|
|
84
|
+
writeFileSync(join(dest, '.amorphous-version'), stamp)
|
|
81
85
|
}
|
|
82
86
|
return dirs
|
|
83
87
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
|
-
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import test from 'node:test'
|
|
@@ -10,13 +10,20 @@ import {
|
|
|
10
10
|
syncProjectContract,
|
|
11
11
|
writeStarter,
|
|
12
12
|
} from './agent-contract.mjs'
|
|
13
|
+
import { CLI_VERSION } from './version.mjs'
|
|
13
14
|
|
|
14
15
|
test('setup copies the skill, and an edited contract is left alone', () => {
|
|
15
16
|
const home = mkdtempSync(join(tmpdir(), 'amorphous-skill-'))
|
|
16
17
|
const dirs = installUserSkill(home)
|
|
17
18
|
assert.equal(dirs.length, 2)
|
|
18
|
-
assert.match(readFileSync(join(dirs[0], 'SKILL.md'), 'utf8'), /
|
|
19
|
+
assert.match(readFileSync(join(dirs[0], 'SKILL.md'), 'utf8'), /build abstracts/)
|
|
19
20
|
assert.match(readFileSync(join(dirs[1], 'assets/app.js'), 'utf8'), /amorphous\.me/)
|
|
21
|
+
assert.equal(readFileSync(join(dirs[0], '.amorphous-version'), 'utf8'), `${CLI_VERSION}\n`)
|
|
22
|
+
assert.equal(readFileSync(join(dirs[1], '.amorphous-version'), 'utf8'), `${CLI_VERSION}\n`)
|
|
23
|
+
|
|
24
|
+
writeFileSync(join(dirs[1], 'stray.txt'), 'gone')
|
|
25
|
+
installUserSkill(home)
|
|
26
|
+
assert.equal(existsSync(join(dirs[1], 'stray.txt')), false)
|
|
20
27
|
|
|
21
28
|
const project = mkdtempSync(join(tmpdir(), 'amorphous-page-'))
|
|
22
29
|
assert.equal(syncProjectContract(project), 'wrote')
|
|
@@ -29,7 +36,23 @@ test('setup copies the skill, and an edited contract is left alone', () => {
|
|
|
29
36
|
assert.equal(syncProjectContract(project), 'kept')
|
|
30
37
|
assert.match(readFileSync(join(project, 'AGENTS.md'), 'utf8'), /Edited\./)
|
|
31
38
|
|
|
32
|
-
|
|
39
|
+
const older = projectContract().replace(
|
|
40
|
+
`amorphous-contract: ${CONTRACT_VERSION}`,
|
|
41
|
+
'amorphous-contract: 0',
|
|
42
|
+
)
|
|
43
|
+
writeFileSync(join(project, 'AGENTS.md'), older)
|
|
33
44
|
assert.equal(syncProjectContract(project), 'refreshed')
|
|
34
|
-
assert.match(
|
|
45
|
+
assert.match(
|
|
46
|
+
readFileSync(join(project, 'AGENTS.md'), 'utf8'),
|
|
47
|
+
new RegExp(`amorphous-contract: ${CONTRACT_VERSION}`),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
writeFileSync(
|
|
51
|
+
join(project, 'AGENTS.md'),
|
|
52
|
+
projectContract().replace(
|
|
53
|
+
`amorphous-contract: ${CONTRACT_VERSION}`,
|
|
54
|
+
`amorphous-contract: ${CONTRACT_VERSION + 1}`,
|
|
55
|
+
),
|
|
56
|
+
)
|
|
57
|
+
assert.equal(syncProjectContract(project), 'kept')
|
|
35
58
|
})
|
package/lib/api.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { cliAppFetchOrigin, cliAppHostHeader } from './config.mjs'
|
|
2
|
-
import {
|
|
2
|
+
import { ensureFreshSession } from './session.mjs'
|
|
3
3
|
|
|
4
4
|
const FRIENDLY = {
|
|
5
5
|
'You already own 2 abstracts': 'You already own 2 abstracts. Run: amorphous list',
|
|
@@ -16,10 +16,10 @@ const FRIENDLY = {
|
|
|
16
16
|
'userId required': 'Pass an email or id. Run: amorphous people',
|
|
17
17
|
'Invalid body': 'Invalid request. Run: amorphous pull',
|
|
18
18
|
'Name required': 'Name required. Run: amorphous rename --name <name>',
|
|
19
|
-
'Create failed': 'Could not create the
|
|
19
|
+
'Create failed': 'Could not create the abstract. Run: amorphous list',
|
|
20
20
|
'Publish failed': 'Could not publish. Run: amorphous push',
|
|
21
21
|
'File too large': 'That file is too large. Run: amorphous pull',
|
|
22
|
-
'Not logged in. Run: amorphous login <email>': 'Not signed in. Run: amorphous login
|
|
22
|
+
'Not logged in. Run: amorphous login <email>': 'Not signed in. Run: amorphous login',
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export function friendlyMessage(raw) {
|
|
@@ -34,11 +34,8 @@ export async function apiFetch(path, opts = {}) {
|
|
|
34
34
|
const url = `${cliAppFetchOrigin()}${path}`
|
|
35
35
|
const headers = { ...cliAppHostHeader(), ...(opts.headers || {}) }
|
|
36
36
|
if (opts.auth !== false) {
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
throw new Error('Not signed in. Run: amorphous login <email>')
|
|
40
|
-
}
|
|
41
|
-
headers.Authorization = `Bearer ${token}`
|
|
37
|
+
const session = await ensureFreshSession(opts.forceRefresh ? { force: true } : {})
|
|
38
|
+
headers.Authorization = `Bearer ${session.access_token}`
|
|
42
39
|
}
|
|
43
40
|
if (opts.body && !headers['Content-Type']) {
|
|
44
41
|
headers['Content-Type'] = 'application/json'
|
|
@@ -54,6 +51,9 @@ export async function apiFetch(path, opts = {}) {
|
|
|
54
51
|
} catch {
|
|
55
52
|
data = text
|
|
56
53
|
}
|
|
54
|
+
if (res.status === 401 && opts.auth !== false && !opts.retried) {
|
|
55
|
+
return apiFetch(path, { ...opts, retried: true, forceRefresh: true })
|
|
56
|
+
}
|
|
57
57
|
if (!res.ok) {
|
|
58
58
|
const msg =
|
|
59
59
|
typeof data === 'object' && data?.message
|
package/lib/commands.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { apiFetch } from './api.mjs'
|
|
|
4
4
|
import { abstractsOrigin, appOrigin, projectMetaPath } from './config.mjs'
|
|
5
5
|
import { recordsFromLocal, readProjectFiles, writeProjectFiles } from './files.mjs'
|
|
6
6
|
import { ALIASES, printHelp, wantsHelp } from './help.mjs'
|
|
7
|
-
import { loginWithMagicLink } from './login.mjs'
|
|
7
|
+
import { loginWithBrowser, loginWithMagicLink } from './login.mjs'
|
|
8
8
|
|
|
9
9
|
const BOOL_FLAGS = new Set([
|
|
10
10
|
'json',
|
|
@@ -166,10 +166,18 @@ function publicAbstract(row) {
|
|
|
166
166
|
async function cmdLogin(rest) {
|
|
167
167
|
const { flags, positional } = parseFlags(rest)
|
|
168
168
|
if (flags.help) return printHelp('login')
|
|
169
|
-
const email = positional[0]
|
|
170
|
-
if (
|
|
171
|
-
|
|
172
|
-
|
|
169
|
+
const email = typeof flags.email === 'string' ? flags.email : positional[0]
|
|
170
|
+
if (email) {
|
|
171
|
+
const code = typeof flags.code === 'string' ? flags.code : undefined
|
|
172
|
+
await loginWithMagicLink(String(email).trim(), { code })
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
await loginWithBrowser()
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function cmdMcp(rest) {
|
|
179
|
+
if (wantsHelp(rest)) return printHelp('mcp')
|
|
180
|
+
await import('../mcp.mjs')
|
|
173
181
|
}
|
|
174
182
|
|
|
175
183
|
async function cmdSetup(rest) {
|
|
@@ -695,6 +703,8 @@ export async function runCommand(argv, cwd = process.cwd()) {
|
|
|
695
703
|
switch (cmd) {
|
|
696
704
|
case 'login':
|
|
697
705
|
return cmdLogin(rest)
|
|
706
|
+
case 'mcp':
|
|
707
|
+
return cmdMcp(rest)
|
|
698
708
|
case 'setup':
|
|
699
709
|
return cmdSetup(rest)
|
|
700
710
|
case 'create':
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
const RFC_VERIFIER = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'
|
|
4
|
+
|
|
5
|
+
export function pkceChallenge(verifier) {
|
|
6
|
+
return createHash('sha256').update(verifier).digest('base64url')
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function createPkce() {
|
|
10
|
+
const verifier = randomBytes(32).toString('base64url')
|
|
11
|
+
return {
|
|
12
|
+
verifier,
|
|
13
|
+
challenge: pkceChallenge(verifier),
|
|
14
|
+
state: randomBytes(16).toString('base64url'),
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function statesMatch(left, right) {
|
|
19
|
+
const a = Buffer.from(String(left))
|
|
20
|
+
const b = Buffer.from(String(right))
|
|
21
|
+
if (a.length !== b.length) {
|
|
22
|
+
timingSafeEqual(a, a)
|
|
23
|
+
return false
|
|
24
|
+
}
|
|
25
|
+
return timingSafeEqual(a, b)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function parseCliPort(value) {
|
|
29
|
+
const text = typeof value === 'number' ? String(value) : value
|
|
30
|
+
if (typeof text !== 'string' || !/^[0-9]+$/.test(text)) return null
|
|
31
|
+
const port = Number(text)
|
|
32
|
+
if (!Number.isInteger(port) || port < 1024 || port > 65535) return null
|
|
33
|
+
return port
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function validChallenge(value) {
|
|
37
|
+
return typeof value === 'string' && /^[A-Za-z0-9_-]{43,128}$/.test(value)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function keyFromSecret(secret) {
|
|
41
|
+
if (!secret || typeof secret !== 'string') throw new Error('Missing connect secret')
|
|
42
|
+
return createHash('sha256').update(secret, 'utf8').digest()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function sealConnectCode(payload, secret) {
|
|
46
|
+
const key = keyFromSecret(secret)
|
|
47
|
+
const iv = randomBytes(12)
|
|
48
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv)
|
|
49
|
+
const body = Buffer.concat([
|
|
50
|
+
cipher.update(JSON.stringify(payload), 'utf8'),
|
|
51
|
+
cipher.final(),
|
|
52
|
+
])
|
|
53
|
+
const tag = cipher.getAuthTag()
|
|
54
|
+
return Buffer.concat([iv, tag, body]).toString('base64url')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function openConnectCode(code, secret) {
|
|
58
|
+
const key = keyFromSecret(secret)
|
|
59
|
+
const raw = Buffer.from(String(code), 'base64url')
|
|
60
|
+
if (raw.length < 12 + 16 + 1) throw new Error('Bad code')
|
|
61
|
+
const iv = raw.subarray(0, 12)
|
|
62
|
+
const tag = raw.subarray(12, 28)
|
|
63
|
+
const body = raw.subarray(28)
|
|
64
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv)
|
|
65
|
+
decipher.setAuthTag(tag)
|
|
66
|
+
const json = Buffer.concat([decipher.update(body), decipher.final()]).toString('utf8')
|
|
67
|
+
const payload = JSON.parse(json)
|
|
68
|
+
if (!payload || typeof payload.exp !== 'number' || payload.exp < Date.now()) {
|
|
69
|
+
throw new Error('Expired code')
|
|
70
|
+
}
|
|
71
|
+
if (typeof payload.hashed_token !== 'string' || typeof payload.user_id !== 'string') {
|
|
72
|
+
throw new Error('Bad code')
|
|
73
|
+
}
|
|
74
|
+
if (!validChallenge(payload.challenge)) throw new Error('Bad code')
|
|
75
|
+
return payload
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function challengeMatches(verifier, challenge) {
|
|
79
|
+
if (typeof verifier !== 'string' || !validChallenge(challenge)) return false
|
|
80
|
+
return statesMatch(pkceChallenge(verifier), challenge)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const RFC7636_VERIFIER = RFC_VERIFIER
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { spawn } from 'node:child_process'
|
|
3
|
+
import { chmodSync, mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import test from 'node:test'
|
|
7
|
+
import {
|
|
8
|
+
RFC7636_VERIFIER,
|
|
9
|
+
challengeMatches,
|
|
10
|
+
openConnectCode,
|
|
11
|
+
parseCliPort,
|
|
12
|
+
pkceChallenge,
|
|
13
|
+
sealConnectCode,
|
|
14
|
+
statesMatch,
|
|
15
|
+
} from './connect-crypto.mjs'
|
|
16
|
+
import { ensureFreshSession, saveSession, sessionNeedsRefresh } from './session.mjs'
|
|
17
|
+
|
|
18
|
+
test('PKCE S256 matches RFC 7636 appendix B', () => {
|
|
19
|
+
assert.equal(pkceChallenge(RFC7636_VERIFIER), 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('port check rejects anything that is not a high port', () => {
|
|
23
|
+
for (const bad of [0, 80, 99999, '80@evil.com', '8765/x', 'localhost', '', null, '0123']) {
|
|
24
|
+
assert.equal(parseCliPort(bad), null)
|
|
25
|
+
}
|
|
26
|
+
assert.equal(parseCliPort(1024), 1024)
|
|
27
|
+
assert.equal(parseCliPort('65535'), 65535)
|
|
28
|
+
assert.equal(parseCliPort(443), null)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('encrypted code round trip, tamper, and expiry', () => {
|
|
32
|
+
const secret = 'test-secret'
|
|
33
|
+
const payload = {
|
|
34
|
+
hashed_token: 'hash',
|
|
35
|
+
challenge: pkceChallenge(RFC7636_VERIFIER),
|
|
36
|
+
user_id: 'user-1',
|
|
37
|
+
exp: Date.now() + 60_000,
|
|
38
|
+
}
|
|
39
|
+
const code = sealConnectCode(payload, secret)
|
|
40
|
+
assert.deepEqual(openConnectCode(code, secret), payload)
|
|
41
|
+
|
|
42
|
+
const tampered = `${code.slice(0, -2)}${code.endsWith('a') ? 'b' : 'a'}`
|
|
43
|
+
assert.throws(() => openConnectCode(tampered, secret))
|
|
44
|
+
|
|
45
|
+
const expired = sealConnectCode({ ...payload, exp: Date.now() - 1000 }, secret)
|
|
46
|
+
assert.throws(() => openConnectCode(expired, secret))
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('state mismatch and a bad verifier fail closed', () => {
|
|
50
|
+
const challenge = pkceChallenge(RFC7636_VERIFIER)
|
|
51
|
+
assert.equal(statesMatch('abc', 'abc'), true)
|
|
52
|
+
assert.equal(statesMatch('abc', 'abd'), false)
|
|
53
|
+
assert.equal(statesMatch('abc', 'abcd'), false)
|
|
54
|
+
assert.equal(challengeMatches(RFC7636_VERIFIER, challenge), true)
|
|
55
|
+
assert.equal(challengeMatches('nope', challenge), false)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('credentials file is mode 0600 via temp file and rename', () => {
|
|
59
|
+
const dir = mkdtempSync(join(tmpdir(), 'amorphous-cred-'))
|
|
60
|
+
const path = join(dir, 'credentials.json')
|
|
61
|
+
process.env.AMORPHOUS_CREDENTIALS = path
|
|
62
|
+
saveSession({ access_token: 'a', refresh_token: 'r', expires_at: 9_999_999_999 })
|
|
63
|
+
const mode = statSync(path).mode & 0o777
|
|
64
|
+
assert.equal(mode, 0o600)
|
|
65
|
+
assert.equal(JSON.parse(readFileSync(path, 'utf8')).access_token, 'a')
|
|
66
|
+
assert.equal(chmodSync, chmodSync)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('refresh runs when expiry is near, and a later caller keeps the rotated session', async () => {
|
|
70
|
+
const dir = mkdtempSync(join(tmpdir(), 'amorphous-refresh-'))
|
|
71
|
+
const path = join(dir, 'credentials.json')
|
|
72
|
+
process.env.AMORPHOUS_CREDENTIALS = path
|
|
73
|
+
const now = 1_700_000_000_000
|
|
74
|
+
saveSession({
|
|
75
|
+
access_token: 'old',
|
|
76
|
+
refresh_token: 'refresh-1',
|
|
77
|
+
expires_at: Math.floor(now / 1000) + 30,
|
|
78
|
+
})
|
|
79
|
+
assert.equal(sessionNeedsRefresh({ expires_at: Math.floor(now / 1000) + 30 }, now), true)
|
|
80
|
+
|
|
81
|
+
let calls = 0
|
|
82
|
+
const refresh = async (session) => {
|
|
83
|
+
calls += 1
|
|
84
|
+
await new Promise((resolve) => setTimeout(resolve, 80))
|
|
85
|
+
if (session.refresh_token !== 'refresh-1') throw new Error('reused')
|
|
86
|
+
return {
|
|
87
|
+
access_token: 'new',
|
|
88
|
+
refresh_token: 'refresh-2',
|
|
89
|
+
expires_at: Math.floor(now / 1000) + 3600,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const [first, second] = await Promise.all([
|
|
94
|
+
ensureFreshSession({ now, refresh }),
|
|
95
|
+
ensureFreshSession({ now, refresh }),
|
|
96
|
+
])
|
|
97
|
+
assert.equal(calls, 1)
|
|
98
|
+
assert.equal(first.access_token, 'new')
|
|
99
|
+
assert.equal(second.access_token, 'new')
|
|
100
|
+
assert.equal(JSON.parse(readFileSync(path, 'utf8')).refresh_token, 'refresh-2')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('a rejected refresh re-reads a session another process already wrote', async () => {
|
|
104
|
+
const dir = mkdtempSync(join(tmpdir(), 'amorphous-reread-'))
|
|
105
|
+
const path = join(dir, 'credentials.json')
|
|
106
|
+
process.env.AMORPHOUS_CREDENTIALS = path
|
|
107
|
+
const now = 1_700_000_000_000
|
|
108
|
+
saveSession({
|
|
109
|
+
access_token: 'old',
|
|
110
|
+
refresh_token: 'refresh-1',
|
|
111
|
+
expires_at: Math.floor(now / 1000),
|
|
112
|
+
})
|
|
113
|
+
const session = await ensureFreshSession({
|
|
114
|
+
now,
|
|
115
|
+
refresh: async () => {
|
|
116
|
+
writeFileSync(
|
|
117
|
+
path,
|
|
118
|
+
JSON.stringify({
|
|
119
|
+
access_token: 'other',
|
|
120
|
+
refresh_token: 'refresh-2',
|
|
121
|
+
expires_at: Math.floor(now / 1000) + 3600,
|
|
122
|
+
}),
|
|
123
|
+
)
|
|
124
|
+
throw new Error('already used')
|
|
125
|
+
},
|
|
126
|
+
})
|
|
127
|
+
assert.equal(session.access_token, 'other')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('two processes refresh one credentials file', async () => {
|
|
131
|
+
const dir = mkdtempSync(join(tmpdir(), 'amorphous-procs-'))
|
|
132
|
+
const path = join(dir, 'credentials.json')
|
|
133
|
+
const counter = join(dir, 'calls.txt')
|
|
134
|
+
writeFileSync(counter, '0')
|
|
135
|
+
writeFileSync(
|
|
136
|
+
path,
|
|
137
|
+
JSON.stringify({
|
|
138
|
+
access_token: 'old',
|
|
139
|
+
refresh_token: 'refresh-1',
|
|
140
|
+
expires_at: 1,
|
|
141
|
+
}),
|
|
142
|
+
)
|
|
143
|
+
const worker = join(dir, 'worker.mjs')
|
|
144
|
+
writeFileSync(
|
|
145
|
+
worker,
|
|
146
|
+
`import { ensureFreshSession } from ${JSON.stringify(join(process.cwd(), 'cli/lib/session.mjs'))}
|
|
147
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
148
|
+
process.env.AMORPHOUS_CREDENTIALS = ${JSON.stringify(path)}
|
|
149
|
+
const counter = ${JSON.stringify(counter)}
|
|
150
|
+
const session = await ensureFreshSession({
|
|
151
|
+
now: Date.now(),
|
|
152
|
+
refresh: async (current) => {
|
|
153
|
+
const n = Number(readFileSync(counter, 'utf8')) + 1
|
|
154
|
+
writeFileSync(counter, String(n))
|
|
155
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
156
|
+
if (current.access_token !== 'old' && current.access_token !== 'new') throw new Error('stale')
|
|
157
|
+
return { access_token: 'new', refresh_token: 'refresh-2', expires_at: Math.floor(Date.now() / 1000) + 3600 }
|
|
158
|
+
},
|
|
159
|
+
})
|
|
160
|
+
if (session.access_token !== 'new') process.exit(2)
|
|
161
|
+
`,
|
|
162
|
+
)
|
|
163
|
+
const run = () =>
|
|
164
|
+
new Promise((resolve, reject) => {
|
|
165
|
+
const child = spawn(process.execPath, [worker], { stdio: 'inherit' })
|
|
166
|
+
child.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(`worker ${code}`))))
|
|
167
|
+
})
|
|
168
|
+
await Promise.all([run(), run(), run()])
|
|
169
|
+
assert.equal(readFileSync(counter, 'utf8'), '1')
|
|
170
|
+
assert.equal(JSON.parse(readFileSync(path, 'utf8')).access_token, 'new')
|
|
171
|
+
})
|
package/lib/help.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
export const ROOT_HELP = `Amorphous
|
|
2
2
|
|
|
3
3
|
setup Install the agent skill
|
|
4
|
-
login
|
|
5
|
-
|
|
4
|
+
login Sign in
|
|
5
|
+
mcp Start the local MCP server
|
|
6
|
+
list List abstracts
|
|
6
7
|
create New abstract
|
|
7
|
-
open [abstract] Open URL
|
|
8
|
+
open [abstract] Open URL
|
|
8
9
|
preview [abstract] Preview URL (draft)
|
|
9
10
|
pull Pull draft and records to this folder
|
|
10
11
|
push Push local files
|
|
@@ -35,10 +36,15 @@ export const COMMAND_HELP = {
|
|
|
35
36
|
|
|
36
37
|
Install the Amorphous skill for Claude Code, Cursor, and Codex.`,
|
|
37
38
|
|
|
38
|
-
login: `login <email> [--code <code>]
|
|
39
|
+
login: `login [--email <email>] [--code <code>]
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
--
|
|
41
|
+
Open the browser and finish sign-in there.
|
|
42
|
+
--email sends a code instead. It is for a machine with no browser, and for the local mail catcher.
|
|
43
|
+
--code skips the prompt.`,
|
|
44
|
+
|
|
45
|
+
mcp: `mcp
|
|
46
|
+
|
|
47
|
+
Start the local MCP server on stdio.`,
|
|
42
48
|
|
|
43
49
|
list: `list [--view recents|all|starred|archive] [--folder <name>] [--tag <name>] [--search <q>] [--json]
|
|
44
50
|
|
|
@@ -51,7 +57,7 @@ New abstract. Writes .amorphous.json in this directory.`,
|
|
|
51
57
|
|
|
52
58
|
open: `open [abstract] [--json]
|
|
53
59
|
|
|
54
|
-
Print the Open URL
|
|
60
|
+
Print the Open URL.`,
|
|
55
61
|
|
|
56
62
|
preview: `preview [abstract] [--json]
|
|
57
63
|
|
package/lib/login.mjs
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
1
2
|
import { createInterface } from 'node:readline'
|
|
3
|
+
import { createServer } from 'node:http'
|
|
2
4
|
import { createServer as createNetServer } from 'node:net'
|
|
3
5
|
import { createClient } from '@supabase/supabase-js'
|
|
4
|
-
import { loadDotEnv, supabaseAnonKey, supabaseUrl } from './config.mjs'
|
|
6
|
+
import { appOrigin, cliAppFetchOrigin, cliAppHostHeader, loadDotEnv, supabaseAnonKey, supabaseUrl } from './config.mjs'
|
|
7
|
+
import { createPkce, statesMatch } from './connect-crypto.mjs'
|
|
5
8
|
import { mailpitBase, sessionFromVerifyUrl, waitForMagicLinkVerifyUrl } from './mailpit.mjs'
|
|
6
|
-
import { saveSession } from './session.mjs'
|
|
9
|
+
import { saveSession, sessionRecord } from './session.mjs'
|
|
7
10
|
|
|
8
11
|
function isLocalSupabase() {
|
|
9
12
|
try {
|
|
@@ -82,14 +85,120 @@ async function readCode(email) {
|
|
|
82
85
|
}
|
|
83
86
|
|
|
84
87
|
function finish(tokens) {
|
|
85
|
-
|
|
86
|
-
|
|
88
|
+
saveSession(sessionRecord(tokens))
|
|
89
|
+
console.log('Logged in.')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function openBrowser(url) {
|
|
93
|
+
try {
|
|
94
|
+
const child =
|
|
95
|
+
process.platform === 'win32'
|
|
96
|
+
? spawn('cmd', ['/c', 'start', '', url], { stdio: 'ignore', detached: true, windowsHide: true })
|
|
97
|
+
: spawn(process.platform === 'darwin' ? 'open' : 'xdg-open', [url], {
|
|
98
|
+
stdio: 'ignore',
|
|
99
|
+
detached: true,
|
|
100
|
+
})
|
|
101
|
+
child.on('error', () => {})
|
|
102
|
+
child.unref()
|
|
103
|
+
} catch {
|
|
104
|
+
/* a failed open is not an error; the link is already printed */
|
|
87
105
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function waitForCallback(port, expectedState) {
|
|
109
|
+
let settled = false
|
|
110
|
+
let resolve
|
|
111
|
+
let reject
|
|
112
|
+
const result = new Promise((res, rej) => {
|
|
113
|
+
resolve = res
|
|
114
|
+
reject = rej
|
|
91
115
|
})
|
|
92
|
-
|
|
116
|
+
const server = createServer((req, res) => {
|
|
117
|
+
const url = new URL(req.url || '/', 'http://127.0.0.1')
|
|
118
|
+
if (settled || req.method !== 'GET' || url.pathname !== '/callback') {
|
|
119
|
+
res.writeHead(404)
|
|
120
|
+
res.end()
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
|
124
|
+
res.end('<!doctype html><title>Amorphous</title><p>You can close this window.</p>')
|
|
125
|
+
const state = url.searchParams.get('state') || ''
|
|
126
|
+
const error = url.searchParams.get('error')
|
|
127
|
+
const code = url.searchParams.get('code')
|
|
128
|
+
finishOnce(() => {
|
|
129
|
+
if (!statesMatch(state, expectedState)) {
|
|
130
|
+
reject(new Error('Sign-in was rejected.'))
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
if (error) {
|
|
134
|
+
reject(new Error(error === 'access_denied' ? 'Sign-in was cancelled.' : 'Sign-in failed.'))
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
if (!code) {
|
|
138
|
+
reject(new Error('Sign-in failed.'))
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
resolve(code)
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
const finishOnce = (fn) => {
|
|
145
|
+
if (settled) return
|
|
146
|
+
settled = true
|
|
147
|
+
clearTimeout(timer)
|
|
148
|
+
server.close()
|
|
149
|
+
fn()
|
|
150
|
+
}
|
|
151
|
+
const timer = setTimeout(() => {
|
|
152
|
+
finishOnce(() => reject(new Error('Timed out waiting for the browser.')))
|
|
153
|
+
}, 5 * 60 * 1000)
|
|
154
|
+
server.on('error', (error) => finishOnce(() => reject(error)))
|
|
155
|
+
const listening = new Promise((resolve, reject) => {
|
|
156
|
+
const onError = (error) => reject(error)
|
|
157
|
+
server.once('error', onError)
|
|
158
|
+
server.listen(port, '127.0.0.1', () => {
|
|
159
|
+
server.removeListener('error', onError)
|
|
160
|
+
resolve()
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
return { listening, result }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function exchangeCode(code, verifier) {
|
|
167
|
+
const res = await fetch(`${cliAppFetchOrigin()}/api/cli/token`, {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
headers: {
|
|
170
|
+
'Content-Type': 'application/json',
|
|
171
|
+
...cliAppHostHeader(),
|
|
172
|
+
},
|
|
173
|
+
body: JSON.stringify({ code, code_verifier: verifier }),
|
|
174
|
+
})
|
|
175
|
+
const text = await res.text()
|
|
176
|
+
let data = null
|
|
177
|
+
try {
|
|
178
|
+
data = text ? JSON.parse(text) : null
|
|
179
|
+
} catch {
|
|
180
|
+
data = null
|
|
181
|
+
}
|
|
182
|
+
if (!res.ok || !data?.access_token || !data?.refresh_token) {
|
|
183
|
+
throw new Error('Sign-in failed.')
|
|
184
|
+
}
|
|
185
|
+
return data
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function loginWithBrowser() {
|
|
189
|
+
loadDotEnv()
|
|
190
|
+
const port = await freePort()
|
|
191
|
+
const { verifier, challenge, state } = createPkce()
|
|
192
|
+
const { listening, result } = waitForCallback(port, state)
|
|
193
|
+
await listening
|
|
194
|
+
const connect = new URL('/cli/connect', appOrigin())
|
|
195
|
+
connect.searchParams.set('port', String(port))
|
|
196
|
+
connect.searchParams.set('state', state)
|
|
197
|
+
connect.searchParams.set('code_challenge', challenge)
|
|
198
|
+
console.error(connect.toString())
|
|
199
|
+
openBrowser(connect.toString())
|
|
200
|
+
const code = await result
|
|
201
|
+
finish(await exchangeCode(code, verifier))
|
|
93
202
|
}
|
|
94
203
|
|
|
95
204
|
async function verifyEmailCode(supabase, email, code) {
|