amorphous-cli 0.1.0 → 0.1.1

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/lib/commands.mjs CHANGED
@@ -167,7 +167,8 @@ async function cmdLogin(rest) {
167
167
  if (flags.help) return printHelp('login')
168
168
  const email = positional[0]
169
169
  if (!email) throw new Error('Pass an email.')
170
- await loginWithMagicLink(email.trim())
170
+ const code = typeof flags.code === 'string' ? flags.code : undefined
171
+ await loginWithMagicLink(email.trim(), { code })
171
172
  }
172
173
 
173
174
  async function cmdCreate(cwd, rest) {
package/lib/help.mjs CHANGED
@@ -30,9 +30,10 @@ Run amorphous <command> --help for flags. Pass --json where you need machine out
30
30
  `
31
31
 
32
32
  export const COMMAND_HELP = {
33
- login: `login <email>
33
+ login: `login <email> [--code <code>]
34
34
 
35
- Sign in with a magic link sent to that email.`,
35
+ Send a sign-in code to that email, then type it.
36
+ --code skips the prompt when you already have the code.`,
36
37
 
37
38
  list: `list [--view recents|all|starred|archive] [--folder <name>] [--tag <name>] [--search <q>] [--json]
38
39
 
package/lib/login.mjs CHANGED
@@ -1,10 +1,19 @@
1
- import { createServer } from 'node:http'
1
+ import { createInterface } from 'node:readline'
2
2
  import { createServer as createNetServer } from 'node:net'
3
3
  import { createClient } from '@supabase/supabase-js'
4
4
  import { loadDotEnv, supabaseAnonKey, supabaseUrl } from './config.mjs'
5
5
  import { mailpitBase, sessionFromVerifyUrl, waitForMagicLinkVerifyUrl } from './mailpit.mjs'
6
6
  import { saveSession } from './session.mjs'
7
7
 
8
+ function isLocalSupabase() {
9
+ try {
10
+ const host = new URL(supabaseUrl()).hostname
11
+ return host === '127.0.0.1' || host === 'localhost'
12
+ } catch {
13
+ return false
14
+ }
15
+ }
16
+
8
17
  async function freePort(preferred) {
9
18
  if (preferred) {
10
19
  const ok = await new Promise((resolve) => {
@@ -29,6 +38,7 @@ async function freePort(preferred) {
29
38
 
30
39
  async function mailpitReachable() {
31
40
  if (process.env.AMORPHOUS_AGENT_LOGIN === '0') return false
41
+ if (!isLocalSupabase()) return false
32
42
  try {
33
43
  const res = await fetch(`${mailpitBase()}/api/v1/messages`, {
34
44
  signal: AbortSignal.timeout(1500),
@@ -54,100 +64,69 @@ async function loginViaMailpit(supabase, email, redirectTo, sinceMs) {
54
64
  }
55
65
  }
56
66
 
57
- async function loginViaCallbackServer(supabase, email, redirectTo) {
58
- const callbackPort = new URL(redirectTo).port || '8765'
59
- return new Promise((resolve, reject) => {
60
- const server = createServer(async (req, res) => {
61
- try {
62
- const url = new URL(req.url || '/', redirectTo)
63
- if (url.pathname === '/callback' && url.searchParams.get('code')) {
64
- const code = url.searchParams.get('code')
65
- const { data, error } = await supabase.auth.exchangeCodeForSession(code)
66
- if (error) throw error
67
- res.writeHead(200, { 'Content-Type': 'text/html' })
68
- res.end('<p>Signed in. You can close this tab and return to the terminal.</p>')
69
- server.close()
70
- resolve(data.session)
71
- return
72
- }
73
- if (url.pathname === '/callback') {
74
- res.writeHead(200, { 'Content-Type': 'text/html' })
75
- res.end(`<!DOCTYPE html><html><body><p>Completing sign-in…</p><script>
76
- const h = new URLSearchParams(location.hash.slice(1));
77
- const access_token = h.get('access_token');
78
- const refresh_token = h.get('refresh_token');
79
- if (access_token) {
80
- fetch('/token', { method:'POST', headers:{'Content-Type':'application/json'},
81
- body: JSON.stringify({ access_token, refresh_token }) })
82
- .then(() => { document.body.innerHTML = '<p>Signed in. Close this tab.</p>'; });
83
- }
84
- </script></body></html>`)
85
- return
86
- }
87
- if (url.pathname === '/token' && req.method === 'POST') {
88
- let body = ''
89
- for await (const chunk of req) body += chunk
90
- const parsed = JSON.parse(body)
91
- res.writeHead(204)
92
- res.end()
93
- server.close()
94
- resolve({
95
- access_token: parsed.access_token,
96
- refresh_token: parsed.refresh_token,
97
- })
98
- return
99
- }
100
- res.writeHead(404)
101
- res.end()
102
- } catch (e) {
103
- reject(e)
104
- }
105
- })
106
- server.listen(callbackPort, '127.0.0.1', async () => {
107
- const { error } = await supabase.auth.signInWithOtp({
108
- email,
109
- options: { emailRedirectTo: redirectTo },
110
- })
111
- if (error) {
112
- server.close()
113
- reject(error)
114
- return
115
- }
116
- console.error(`Magic link sent to ${email}. Open it (Mailpit ${mailpitBase()}).`)
117
- console.error(`Waiting for callback on ${redirectTo} …`)
67
+ async function readCode() {
68
+ if (!process.stdin.isTTY) {
69
+ throw new Error('Pass the code with --code.')
70
+ }
71
+ const rl = createInterface({ input: process.stdin, output: process.stderr })
72
+ try {
73
+ const answer = await new Promise((resolve) => {
74
+ rl.question('Code: ', resolve)
118
75
  })
119
- server.on('error', reject)
120
- setTimeout(() => {
121
- server.close()
122
- reject(new Error('Login timed out after 5 minutes'))
123
- }, 5 * 60 * 1000)
76
+ return String(answer || '').replace(/\s/g, '')
77
+ } finally {
78
+ rl.close()
79
+ }
80
+ }
81
+
82
+ function finish(tokens) {
83
+ if (!tokens?.access_token || !tokens?.refresh_token) {
84
+ throw new Error('Sign-in did not return a session.')
85
+ }
86
+ saveSession({
87
+ access_token: tokens.access_token,
88
+ refresh_token: tokens.refresh_token,
89
+ })
90
+ console.log('Logged in.')
91
+ }
92
+
93
+ async function verifyEmailCode(supabase, email, code) {
94
+ const token = String(code || '').replace(/\s/g, '')
95
+ if (!/^\d{6,10}$/.test(token)) throw new Error('Enter the code from the email.')
96
+ const { data, error } = await supabase.auth.verifyOtp({
97
+ email,
98
+ token,
99
+ type: 'email',
124
100
  })
101
+ if (error) throw error
102
+ return data.session
125
103
  }
126
104
 
127
- export async function loginWithMagicLink(email) {
105
+ export async function loginWithMagicLink(email, { code } = {}) {
128
106
  loadDotEnv()
129
- const preferred = Number(process.env.AMORPHOUS_CLI_CALLBACK_PORT || 8765)
130
- const port = await freePort(preferred)
131
- const redirectTo = `http://127.0.0.1:${port}/callback`
132
107
  const supabase = createClient(supabaseUrl(), supabaseAnonKey())
133
- const sinceMs = Date.now()
134
108
 
135
- let tokens
136
- if (await mailpitReachable()) {
109
+ if (!code && (await mailpitReachable())) {
110
+ const preferred = Number(process.env.AMORPHOUS_CLI_CALLBACK_PORT || 8765)
111
+ const port = await freePort(preferred)
112
+ const redirectTo = `http://127.0.0.1:${port}/callback`
113
+ const sinceMs = Date.now()
137
114
  const { error } = await supabase.auth.signInWithOtp({
138
115
  email,
139
116
  options: { emailRedirectTo: redirectTo },
140
117
  })
141
118
  if (error) throw error
142
- console.error(`Magic link sent to ${email}. Reading Mailpit (${mailpitBase()})…`)
143
- tokens = await loginViaMailpit(supabase, email, redirectTo, sinceMs)
144
- } else {
145
- tokens = await loginViaCallbackServer(supabase, email, redirectTo)
119
+ console.error(`Code sent to ${email}. Reading the local mail catcher…`)
120
+ finish(await loginViaMailpit(supabase, email, redirectTo, sinceMs))
121
+ return
146
122
  }
147
123
 
148
- saveSession({
149
- access_token: tokens.access_token,
150
- refresh_token: tokens.refresh_token,
151
- })
152
- console.log('Logged in.')
124
+ if (!code) {
125
+ const { error } = await supabase.auth.signInWithOtp({ email })
126
+ if (error) throw error
127
+ console.error(`Code sent to ${email}.`)
128
+ code = await readCode()
129
+ }
130
+
131
+ finish(await verifyEmailCode(supabase, email, code))
153
132
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amorphous-cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Command line for Amorphous",
5
5
  "type": "module",
6
6
  "bin": {