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 CHANGED
@@ -1,7 +1,7 @@
1
1
  # amorphous-cli
2
2
 
3
- A page people use together. Your own agent builds it. Other people open it and write to it.
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
  }
@@ -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 = 1
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'), /people use together/)
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
- writeFileSync(join(project, 'AGENTS.md'), projectContract().replace('amorphous-contract: 1', 'amorphous-contract: 0'))
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(readFileSync(join(project, 'AGENTS.md'), 'utf8'), /amorphous-contract: 1/)
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 { accessToken } from './session.mjs'
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 page. Run: amorphous list',
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 <email>',
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 token = accessToken()
38
- if (!token) {
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 (!email) throw new Error('Pass an email.')
171
- const code = typeof flags.code === 'string' ? flags.code : undefined
172
- await loginWithMagicLink(email.trim(), { code })
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 <email> Sign in
5
- list List pages
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 (the page)
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
- Send a sign-in code to that email, then type it.
41
- --code skips the prompt. An agent reads the code from the inbox and passes it here.`,
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 (the page on Amorphous).`,
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
- if (!tokens?.access_token || !tokens?.refresh_token) {
86
- throw new Error('Sign-in did not return a session.')
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
- saveSession({
89
- access_token: tokens.access_token,
90
- refresh_token: tokens.refresh_token,
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
- console.log('Logged in.')
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) {