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/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
+ }
@@ -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(/&amp;/g, '&').replace(/&quot;/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
+ }
@@ -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
+ }