amorphous-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.mjs +14 -0
- package/lib/api.mjs +67 -0
- package/lib/commands.mjs +736 -0
- package/lib/config.mjs +92 -0
- package/lib/files.mjs +50 -0
- package/lib/help.mjs +150 -0
- package/lib/login.mjs +153 -0
- package/lib/mailpit.mjs +99 -0
- package/lib/session.mjs +36 -0
- package/lib/tls.mjs +25 -0
- package/mcp.mjs +132 -0
- package/package.json +30 -0
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
export function loadDotEnv(cwd = process.cwd()) {
|
|
6
|
+
const roots = []
|
|
7
|
+
if (process.env.AMORPHOUS_ROOT) roots.push(process.env.AMORPHOUS_ROOT)
|
|
8
|
+
let dir = cwd
|
|
9
|
+
for (let i = 0; i < 8; i++) {
|
|
10
|
+
roots.push(dir)
|
|
11
|
+
const parent = join(dir, '..')
|
|
12
|
+
if (parent === dir) break
|
|
13
|
+
dir = parent
|
|
14
|
+
}
|
|
15
|
+
for (const base of roots) {
|
|
16
|
+
const path = join(base, '.env')
|
|
17
|
+
if (!existsSync(path)) continue
|
|
18
|
+
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
19
|
+
const m = /^([^#=]+)=(.*)$/.exec(line.trim())
|
|
20
|
+
if (m && process.env[m[1]] === undefined) {
|
|
21
|
+
process.env[m[1]] = m[2]
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Hosted defaults. `APP_ORIGIN` and the other env vars, including a local `.env`, override these. */
|
|
29
|
+
const HOSTED = {
|
|
30
|
+
appOrigin: 'https://amorphous.one',
|
|
31
|
+
abstractsOrigin: 'https://abstracts.amorphous.one',
|
|
32
|
+
supabaseUrl: 'https://tzbimtuvddovafbfkxbl.supabase.co',
|
|
33
|
+
supabaseAnonKey:
|
|
34
|
+
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InR6YmltdHV2ZGRvdmFmYmZreGJsIiwicm9sZSI6ImFub24iLCJpYXQiOjE3OTEyODczNTAsImV4cCI6MjEwNjg2MzM1MH0.S5HPmOmB0-JuulTEN1uUAJMOlCnPlkSm0BsSMBFCpXs',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function appOrigin() {
|
|
38
|
+
return process.env.APP_ORIGIN || HOSTED.appOrigin
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Local Herd HTTPS: hit Nuxt on loopback with the app Host header (no TLS). */
|
|
42
|
+
export function cliAppFetchOrigin() {
|
|
43
|
+
if (process.env.AMORPHOUS_CLI_APP_URL) {
|
|
44
|
+
return process.env.AMORPHOUS_CLI_APP_URL.replace(/\/$/, '')
|
|
45
|
+
}
|
|
46
|
+
const origin = appOrigin()
|
|
47
|
+
try {
|
|
48
|
+
const u = new URL(origin)
|
|
49
|
+
if (u.protocol === 'https:' && (u.hostname.endsWith('.test') || u.hostname === 'localhost')) {
|
|
50
|
+
const port = process.env.AMORPHOUS_DEV_PORT || '3847'
|
|
51
|
+
return `http://127.0.0.1:${port}`
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
/* ignore */
|
|
55
|
+
}
|
|
56
|
+
return origin.replace(/\/$/, '')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function cliAppHostHeader() {
|
|
60
|
+
const origin = appOrigin()
|
|
61
|
+
const fetchOrigin = cliAppFetchOrigin()
|
|
62
|
+
if (!fetchOrigin.startsWith('http://127.0.0.1')) return {}
|
|
63
|
+
try {
|
|
64
|
+
const u = new URL(origin)
|
|
65
|
+
return { Host: u.host }
|
|
66
|
+
} catch {
|
|
67
|
+
return {}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function abstractsOrigin() {
|
|
72
|
+
return process.env.ABSTRACTS_ORIGIN || HOSTED.abstractsOrigin
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function supabaseUrl() {
|
|
76
|
+
return process.env.SUPABASE_URL || HOSTED.supabaseUrl
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function supabaseAnonKey() {
|
|
80
|
+
return process.env.SUPABASE_ANON_KEY || process.env.SUPABASE_KEY || HOSTED.supabaseAnonKey
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function credentialsPath() {
|
|
84
|
+
return (
|
|
85
|
+
process.env.AMORPHOUS_CREDENTIALS ||
|
|
86
|
+
join(homedir(), '.config', 'amorphous', 'credentials.json')
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function projectMetaPath(cwd = process.cwd()) {
|
|
91
|
+
return join(cwd, '.amorphous.json')
|
|
92
|
+
}
|
package/lib/files.mjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
const FILES = ['index.html', 'styles.css', 'app.js', 'data.ndjson', 'private.ndjson']
|
|
5
|
+
|
|
6
|
+
export function readNdjson(path) {
|
|
7
|
+
if (!existsSync(path)) return []
|
|
8
|
+
return readFileSync(path, 'utf8')
|
|
9
|
+
.split('\n')
|
|
10
|
+
.map((l) => l.trim())
|
|
11
|
+
.filter(Boolean)
|
|
12
|
+
.map((l) => JSON.parse(l))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function writeNdjson(path, rows) {
|
|
16
|
+
const body = rows.map((r) => JSON.stringify(r)).join('\n')
|
|
17
|
+
writeFileSync(path, body ? `${body}\n` : '')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function readProjectFiles(cwd) {
|
|
21
|
+
return {
|
|
22
|
+
html: existsSync(join(cwd, 'index.html'))
|
|
23
|
+
? readFileSync(join(cwd, 'index.html'), 'utf8')
|
|
24
|
+
: '',
|
|
25
|
+
css: existsSync(join(cwd, 'styles.css'))
|
|
26
|
+
? readFileSync(join(cwd, 'styles.css'), 'utf8')
|
|
27
|
+
: '',
|
|
28
|
+
js: existsSync(join(cwd, 'app.js')) ? readFileSync(join(cwd, 'app.js'), 'utf8') : '',
|
|
29
|
+
shared: readNdjson(join(cwd, 'data.ndjson')),
|
|
30
|
+
private: readNdjson(join(cwd, 'private.ndjson')),
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function writeProjectFiles(cwd, payload) {
|
|
35
|
+
writeFileSync(join(cwd, 'index.html'), payload.html ?? '')
|
|
36
|
+
writeFileSync(join(cwd, 'styles.css'), payload.css ?? '')
|
|
37
|
+
writeFileSync(join(cwd, 'app.js'), payload.js ?? '')
|
|
38
|
+
writeNdjson(join(cwd, 'data.ndjson'), payload.shared ?? [])
|
|
39
|
+
writeNdjson(join(cwd, 'private.ndjson'), payload.private ?? [])
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function recordsFromLocal(cwd) {
|
|
43
|
+
const { shared, private: priv } = readProjectFiles(cwd)
|
|
44
|
+
return [
|
|
45
|
+
...shared.map((r) => ({ ...r, visibility: r.visibility || 'shared' })),
|
|
46
|
+
...priv.map((r) => ({ ...r, visibility: 'private' })),
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export { FILES }
|
package/lib/help.mjs
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
export const ROOT_HELP = `Amorphous
|
|
2
|
+
|
|
3
|
+
login <email> Sign in
|
|
4
|
+
list List abstracts
|
|
5
|
+
create New abstract
|
|
6
|
+
open [abstract] Open URL (the page)
|
|
7
|
+
preview [abstract] Preview URL (draft)
|
|
8
|
+
pull Pull draft and records to this folder
|
|
9
|
+
push Push local files
|
|
10
|
+
publish [--live] Publish a version; --live is Go live
|
|
11
|
+
live [abstract] Make a version live
|
|
12
|
+
visibility anyone|members Anyone can view / Members only
|
|
13
|
+
invite <email> Invite
|
|
14
|
+
remove <email|id> Remove
|
|
15
|
+
people [abstract] People
|
|
16
|
+
tag / untag Tag
|
|
17
|
+
move --folder Move to folder
|
|
18
|
+
archive / restore Archive / Restore
|
|
19
|
+
star / unstar Star
|
|
20
|
+
suggest-folder [--apply] Suggest folder
|
|
21
|
+
folder new New folder
|
|
22
|
+
folder list Folders
|
|
23
|
+
folder move Move folder
|
|
24
|
+
folder invite Invite to a folder
|
|
25
|
+
folder remove Remove from a folder
|
|
26
|
+
folder people People on a folder
|
|
27
|
+
rename --name Rename
|
|
28
|
+
|
|
29
|
+
Run amorphous <command> --help for flags. Pass --json where you need machine output.
|
|
30
|
+
`
|
|
31
|
+
|
|
32
|
+
export const COMMAND_HELP = {
|
|
33
|
+
login: `login <email>
|
|
34
|
+
|
|
35
|
+
Sign in with a magic link sent to that email.`,
|
|
36
|
+
|
|
37
|
+
list: `list [--view recents|all|starred|archive] [--folder <name>] [--tag <name>] [--search <q>] [--json]
|
|
38
|
+
|
|
39
|
+
List abstracts. Default view is all (All Abstracts).
|
|
40
|
+
--folder filters to a folder. --tag filters to a tag.`,
|
|
41
|
+
|
|
42
|
+
create: `create [--name <name>] [--folder <name>] [--json]
|
|
43
|
+
|
|
44
|
+
New abstract. Writes .amorphous.json in this directory.`,
|
|
45
|
+
|
|
46
|
+
open: `open [abstract] [--json]
|
|
47
|
+
|
|
48
|
+
Print the Open URL (the page on Amorphous).`,
|
|
49
|
+
|
|
50
|
+
preview: `preview [abstract] [--json]
|
|
51
|
+
|
|
52
|
+
Print the Preview URL (draft).`,
|
|
53
|
+
|
|
54
|
+
pull: `pull
|
|
55
|
+
|
|
56
|
+
Pull the draft and records into this directory.`,
|
|
57
|
+
|
|
58
|
+
push: `push
|
|
59
|
+
|
|
60
|
+
Push local index.html, styles.css, app.js, and records.`,
|
|
61
|
+
|
|
62
|
+
publish: `publish [--live]
|
|
63
|
+
|
|
64
|
+
Publish a new version. --live also makes it the live version (Go live).`,
|
|
65
|
+
|
|
66
|
+
live: `live [abstract] [--version <id>]
|
|
67
|
+
|
|
68
|
+
Make this the live version. Uses the latest published version if you omit --version.`,
|
|
69
|
+
|
|
70
|
+
visibility: `visibility <anyone|members> [abstract] [--version <id>]
|
|
71
|
+
|
|
72
|
+
Anyone can view, or Members only. Uses the live version if you omit --version.`,
|
|
73
|
+
|
|
74
|
+
invite: `invite <email> [view|use|edit] [--abstract <id|name>]
|
|
75
|
+
|
|
76
|
+
Invite someone by email. Access: view, use (Can use), or edit. Default: view.`,
|
|
77
|
+
|
|
78
|
+
remove: `remove <email|id> [--abstract <id|name>]
|
|
79
|
+
|
|
80
|
+
Remove someone. Use their email or id from people.`,
|
|
81
|
+
|
|
82
|
+
people: `people [abstract] [--json]
|
|
83
|
+
|
|
84
|
+
People on this abstract.`,
|
|
85
|
+
|
|
86
|
+
tag: `tag [abstract] --tag <name>
|
|
87
|
+
|
|
88
|
+
Tag.`,
|
|
89
|
+
|
|
90
|
+
untag: `untag [abstract] --tag <name>
|
|
91
|
+
|
|
92
|
+
Remove a tag.`,
|
|
93
|
+
|
|
94
|
+
move: `move [abstract] --folder <name|path> | --none
|
|
95
|
+
|
|
96
|
+
Move to folder. --none is No folder.`,
|
|
97
|
+
|
|
98
|
+
archive: `archive [abstract]
|
|
99
|
+
|
|
100
|
+
Archive.`,
|
|
101
|
+
|
|
102
|
+
restore: `restore [abstract]
|
|
103
|
+
|
|
104
|
+
Restore.`,
|
|
105
|
+
|
|
106
|
+
star: `star [abstract]
|
|
107
|
+
|
|
108
|
+
Star.`,
|
|
109
|
+
|
|
110
|
+
unstar: `unstar [abstract]
|
|
111
|
+
|
|
112
|
+
Unstar.`,
|
|
113
|
+
|
|
114
|
+
'suggest-folder': `suggest-folder [abstract] [--apply] [--json]
|
|
115
|
+
|
|
116
|
+
Suggest folder from the name. Default is a suggestion only. --apply uses it.`,
|
|
117
|
+
|
|
118
|
+
folder: `folder <new|list|move|invite|remove|people> …
|
|
119
|
+
|
|
120
|
+
folder new --name <name> [--parent <id>]
|
|
121
|
+
folder list [--json]
|
|
122
|
+
folder move <folder> --parent <id|none>
|
|
123
|
+
folder invite <folder> --email <email>
|
|
124
|
+
folder remove <folder> <email|id>
|
|
125
|
+
folder people <folder> [--json]
|
|
126
|
+
|
|
127
|
+
New folder, move a folder, or invite people to a folder.`,
|
|
128
|
+
|
|
129
|
+
rename: `rename [abstract] --name <name>
|
|
130
|
+
|
|
131
|
+
Rename.`,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const ALIASES = {
|
|
135
|
+
grant: 'invite',
|
|
136
|
+
revoke: 'remove',
|
|
137
|
+
unarchive: 'restore',
|
|
138
|
+
organise: 'suggest-folder',
|
|
139
|
+
'auto-file': 'suggest-folder',
|
|
140
|
+
collection: 'folder',
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function wantsHelp(args) {
|
|
144
|
+
return args.includes('--help') || args.includes('-h')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function printHelp(name) {
|
|
148
|
+
const text = name ? COMMAND_HELP[name] : ROOT_HELP
|
|
149
|
+
console.log(text.trimEnd())
|
|
150
|
+
}
|
package/lib/login.mjs
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { createServer } from 'node:http'
|
|
2
|
+
import { createServer as createNetServer } from 'node:net'
|
|
3
|
+
import { createClient } from '@supabase/supabase-js'
|
|
4
|
+
import { loadDotEnv, supabaseAnonKey, supabaseUrl } from './config.mjs'
|
|
5
|
+
import { mailpitBase, sessionFromVerifyUrl, waitForMagicLinkVerifyUrl } from './mailpit.mjs'
|
|
6
|
+
import { saveSession } from './session.mjs'
|
|
7
|
+
|
|
8
|
+
async function freePort(preferred) {
|
|
9
|
+
if (preferred) {
|
|
10
|
+
const ok = await new Promise((resolve) => {
|
|
11
|
+
const s = createNetServer()
|
|
12
|
+
s.once('error', () => resolve(false))
|
|
13
|
+
s.listen(preferred, '127.0.0.1', () => {
|
|
14
|
+
s.close(() => resolve(true))
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
if (ok) return preferred
|
|
18
|
+
}
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
const s = createNetServer()
|
|
21
|
+
s.listen(0, '127.0.0.1', () => {
|
|
22
|
+
const addr = s.address()
|
|
23
|
+
const port = typeof addr === 'object' && addr ? addr.port : 8765
|
|
24
|
+
s.close(() => resolve(port))
|
|
25
|
+
})
|
|
26
|
+
s.on('error', reject)
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function mailpitReachable() {
|
|
31
|
+
if (process.env.AMORPHOUS_AGENT_LOGIN === '0') return false
|
|
32
|
+
try {
|
|
33
|
+
const res = await fetch(`${mailpitBase()}/api/v1/messages`, {
|
|
34
|
+
signal: AbortSignal.timeout(1500),
|
|
35
|
+
})
|
|
36
|
+
return res.ok
|
|
37
|
+
} catch {
|
|
38
|
+
return false
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function loginViaMailpit(supabase, email, redirectTo, sinceMs) {
|
|
43
|
+
const verifyUrl = await waitForMagicLinkVerifyUrl(email, { sinceMs })
|
|
44
|
+
const prefix = redirectTo.split('?')[0]
|
|
45
|
+
const material = await sessionFromVerifyUrl(verifyUrl, prefix)
|
|
46
|
+
if (material.kind === 'code') {
|
|
47
|
+
const { data, error } = await supabase.auth.exchangeCodeForSession(material.code)
|
|
48
|
+
if (error) throw error
|
|
49
|
+
return data.session
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
access_token: material.access_token,
|
|
53
|
+
refresh_token: material.refresh_token,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
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} …`)
|
|
118
|
+
})
|
|
119
|
+
server.on('error', reject)
|
|
120
|
+
setTimeout(() => {
|
|
121
|
+
server.close()
|
|
122
|
+
reject(new Error('Login timed out after 5 minutes'))
|
|
123
|
+
}, 5 * 60 * 1000)
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function loginWithMagicLink(email) {
|
|
128
|
+
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
|
+
const supabase = createClient(supabaseUrl(), supabaseAnonKey())
|
|
133
|
+
const sinceMs = Date.now()
|
|
134
|
+
|
|
135
|
+
let tokens
|
|
136
|
+
if (await mailpitReachable()) {
|
|
137
|
+
const { error } = await supabase.auth.signInWithOtp({
|
|
138
|
+
email,
|
|
139
|
+
options: { emailRedirectTo: redirectTo },
|
|
140
|
+
})
|
|
141
|
+
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)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
saveSession({
|
|
149
|
+
access_token: tokens.access_token,
|
|
150
|
+
refresh_token: tokens.refresh_token,
|
|
151
|
+
})
|
|
152
|
+
console.log('Logged in.')
|
|
153
|
+
}
|
package/lib/mailpit.mjs
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
const DEFAULT_MAILPIT = 'http://127.0.0.1:55424'
|
|
2
|
+
|
|
3
|
+
export function mailpitBase() {
|
|
4
|
+
return (
|
|
5
|
+
process.env.AMORPHOUS_MAILPIT_URL ||
|
|
6
|
+
process.env.MAILPIT_URL ||
|
|
7
|
+
process.env.INBUCKET_URL ||
|
|
8
|
+
DEFAULT_MAILPIT
|
|
9
|
+
).replace(/\/$/, '')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function decodeHtmlAttr(url) {
|
|
13
|
+
return url.replace(/&/g, '&').replace(/"/g, '"')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function extractVerifyHref(html) {
|
|
17
|
+
const re = /href="([^"]*\/auth\/v1\/verify[^"]*)"/i
|
|
18
|
+
const m = re.exec(html || '')
|
|
19
|
+
return m ? decodeHtmlAttr(m[1]) : null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function listMessages(base) {
|
|
23
|
+
const res = await fetch(`${base}/api/v1/messages`)
|
|
24
|
+
if (!res.ok) throw new Error(`Mailpit list failed: ${res.status}`)
|
|
25
|
+
const data = await res.json()
|
|
26
|
+
return data.messages || []
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function messageHtml(base, id) {
|
|
30
|
+
const res = await fetch(`${base}/api/v1/message/${id}`)
|
|
31
|
+
if (!res.ok) throw new Error(`Mailpit read failed: ${res.status}`)
|
|
32
|
+
const data = await res.json()
|
|
33
|
+
return data.HTML || data.Text || ''
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Wait for a magic-link email and return the auth verify URL (not yet followed).
|
|
38
|
+
*/
|
|
39
|
+
export async function waitForMagicLinkVerifyUrl(email, { sinceMs = Date.now(), timeoutMs = 120_000 } = {}) {
|
|
40
|
+
const base = mailpitBase()
|
|
41
|
+
const want = email.trim().toLowerCase()
|
|
42
|
+
const deadline = Date.now() + timeoutMs
|
|
43
|
+
|
|
44
|
+
while (Date.now() < deadline) {
|
|
45
|
+
const messages = await listMessages(base)
|
|
46
|
+
for (const msg of messages) {
|
|
47
|
+
const to = (msg.To || [])
|
|
48
|
+
.map((t) => (t.Address || '').toLowerCase())
|
|
49
|
+
.filter(Boolean)
|
|
50
|
+
if (!to.includes(want)) continue
|
|
51
|
+
const created = new Date(msg.Created).getTime()
|
|
52
|
+
if (created < sinceMs - 2000) continue
|
|
53
|
+
if (!/sign-in link/i.test(msg.Subject || '')) continue
|
|
54
|
+
const html = await messageHtml(base, msg.ID)
|
|
55
|
+
const href = extractVerifyHref(html)
|
|
56
|
+
if (href) return href
|
|
57
|
+
}
|
|
58
|
+
await new Promise((r) => setTimeout(r, 750))
|
|
59
|
+
}
|
|
60
|
+
throw new Error(`No magic-link email for ${email} in Mailpit (${base}) within ${timeoutMs / 1000}s`)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function sessionFromCallbackUrl(callbackUrl) {
|
|
64
|
+
const parsed = new URL(callbackUrl)
|
|
65
|
+
const queryCode = parsed.searchParams.get('code')
|
|
66
|
+
if (queryCode) return { kind: 'code', code: queryCode }
|
|
67
|
+
const hash = parsed.hash?.startsWith('#') ? parsed.hash.slice(1) : ''
|
|
68
|
+
const hp = new URLSearchParams(hash)
|
|
69
|
+
const access_token = hp.get('access_token')
|
|
70
|
+
const refresh_token = hp.get('refresh_token')
|
|
71
|
+
if (access_token) {
|
|
72
|
+
return { kind: 'tokens', access_token, refresh_token: refresh_token || undefined }
|
|
73
|
+
}
|
|
74
|
+
const err = hp.get('error_description') || hp.get('error')
|
|
75
|
+
if (err) throw new Error(`Magic link failed: ${decodeURIComponent(err)}`)
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Follow GoTrue verify redirect and read session material from the CLI callback URL. */
|
|
80
|
+
export async function sessionFromVerifyUrl(verifyUrl, expectedRedirectPrefix) {
|
|
81
|
+
let url = verifyUrl
|
|
82
|
+
for (let i = 0; i < 8; i++) {
|
|
83
|
+
const res = await fetch(url, { redirect: 'manual' })
|
|
84
|
+
if (res.status >= 300 && res.status < 400) {
|
|
85
|
+
const loc = res.headers.get('location')
|
|
86
|
+
if (!loc) break
|
|
87
|
+
const next = new URL(loc, url).href
|
|
88
|
+
if (next.startsWith(expectedRedirectPrefix)) {
|
|
89
|
+
const session = sessionFromCallbackUrl(next)
|
|
90
|
+
if (session) return session
|
|
91
|
+
throw new Error('Magic link callback had no auth code')
|
|
92
|
+
}
|
|
93
|
+
url = next
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
break
|
|
97
|
+
}
|
|
98
|
+
throw new Error('Magic link did not return an auth code for the CLI callback')
|
|
99
|
+
}
|
package/lib/session.mjs
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
|
+
import { credentialsPath } from './config.mjs'
|
|
4
|
+
|
|
5
|
+
export function loadSession() {
|
|
6
|
+
const path = credentialsPath()
|
|
7
|
+
if (!existsSync(path)) return null
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(readFileSync(path, 'utf8'))
|
|
10
|
+
} catch {
|
|
11
|
+
return null
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function saveSession(session) {
|
|
16
|
+
const path = credentialsPath()
|
|
17
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
18
|
+
writeFileSync(path, JSON.stringify(session, null, 2))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function accessToken() {
|
|
22
|
+
const session = loadSession()
|
|
23
|
+
return session?.access_token || null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function authHeaders(extra = {}) {
|
|
27
|
+
const token = accessToken()
|
|
28
|
+
if (!token) {
|
|
29
|
+
throw new Error('Not signed in. Run: amorphous login <email>')
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
Authorization: `Bearer ${token}`,
|
|
33
|
+
'Content-Type': 'application/json',
|
|
34
|
+
...extra,
|
|
35
|
+
}
|
|
36
|
+
}
|
package/lib/tls.mjs
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
/** Herd / Valet .test HTTPS needs the local CA or Node's system trust store. */
|
|
6
|
+
export function ensureLocalHttpsTrust() {
|
|
7
|
+
if (process.env.NODE_OPTIONS?.includes('use-system-ca')) return
|
|
8
|
+
|
|
9
|
+
const herdCa = join(
|
|
10
|
+
homedir(),
|
|
11
|
+
'.config',
|
|
12
|
+
'herd',
|
|
13
|
+
'config',
|
|
14
|
+
'valet',
|
|
15
|
+
'CA',
|
|
16
|
+
'LaravelValetCASelfSigned.pem',
|
|
17
|
+
)
|
|
18
|
+
const valetCa = join(homedir(), '.config', 'valet', 'CA', 'LaravelValetCASelfSigned.pem')
|
|
19
|
+
const ca = [process.env.AMORPHOUS_TLS_CA, herdCa, valetCa].find(
|
|
20
|
+
(p) => p && existsSync(p),
|
|
21
|
+
)
|
|
22
|
+
if (ca && !process.env.NODE_EXTRA_CA_CERTS) {
|
|
23
|
+
process.env.NODE_EXTRA_CA_CERTS = ca
|
|
24
|
+
}
|
|
25
|
+
}
|