Skip to content

Commit 90278fa

Browse files
committed
feat(cli): add connect command for indexer authentication
1 parent 52f7269 commit 90278fa

4 files changed

Lines changed: 323 additions & 2 deletions

File tree

.changeset/cli-connect-command.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
cli: minor
3+
---
4+
5+
Add connect command with interactive indexer connection, browser-based approval, and recovery phrase setup.

apps/cli/src/commands/connect.ts

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import * as clack from '@clack/prompts'
2+
import { execFile } from 'child_process'
3+
import { hexToUint8 } from '@siastorage/core'
4+
import { APP_META } from '@siastorage/core/config'
5+
import { createCliAppService, isTestMode, type CliApp } from '../app'
6+
import { c } from '../lib/format'
7+
8+
const DEFAULT_INDEXER_URL = 'https://sia.storage'
9+
10+
export async function connectCommand(dataDir: string) {
11+
const app = await createCliAppService(dataDir)
12+
13+
try {
14+
if (isTestMode) {
15+
await runTestModeConnect(app)
16+
return
17+
}
18+
19+
await runInteractiveConnect(app)
20+
} finally {
21+
app.db.close?.()
22+
// Force exit: the NAPI Builder has no dispose method to release its
23+
// native handle, so the event loop won't drain. All work is committed.
24+
process.exit(0)
25+
}
26+
}
27+
28+
async function runTestModeConnect(app: CliApp) {
29+
const indexerUrl = DEFAULT_INDEXER_URL
30+
const appMetaJson = JSON.stringify(APP_META)
31+
32+
await app.service.auth.builder.create(indexerUrl, appMetaJson)
33+
await app.service.auth.builder.requestConnection()
34+
await app.service.auth.builder.waitForApproval()
35+
36+
const phrase = await app.service.auth.generateRecoveryPhrase()
37+
const appKeyHex = await app.service.auth.builder.register(phrase)
38+
39+
await app.service.auth.setAppKey(indexerUrl, hexToUint8(appKeyHex))
40+
await app.service.settings.setIndexerURL(indexerUrl)
41+
await app.service.settings.setHasOnboarded(true)
42+
43+
console.log(c.green('Connected successfully!'))
44+
}
45+
46+
async function runInteractiveConnect(app: CliApp) {
47+
if (!(await confirmOverwriteIfOnboarded(app))) return
48+
49+
const indexerUrl = await promptIndexerUrl()
50+
if (!indexerUrl) return
51+
52+
if (!(await runApprovalFlow(app, indexerUrl))) return
53+
54+
const mnemonic = await capturePhrase(app)
55+
if (!mnemonic) return
56+
57+
await registerAndPersist(app, indexerUrl, mnemonic)
58+
}
59+
60+
async function confirmOverwriteIfOnboarded(app: CliApp): Promise<boolean> {
61+
const hasOnboarded = await app.service.settings.getHasOnboarded()
62+
if (!hasOnboarded) return true
63+
64+
const shouldContinue = await clack.confirm({
65+
message: 'Already connected. Overwrite existing configuration?',
66+
})
67+
if (clack.isCancel(shouldContinue) || !shouldContinue) {
68+
console.log(c.dim('Cancelled'))
69+
return false
70+
}
71+
return true
72+
}
73+
74+
async function promptIndexerUrl(): Promise<string | undefined> {
75+
const indexerUrl = await clack.text({
76+
message: 'Indexer URL',
77+
initialValue: DEFAULT_INDEXER_URL,
78+
validate: (v) => {
79+
if (!v) return 'URL is required'
80+
try {
81+
new URL(v)
82+
} catch {
83+
return 'Invalid URL'
84+
}
85+
},
86+
})
87+
if (clack.isCancel(indexerUrl)) return undefined
88+
return indexerUrl
89+
}
90+
91+
async function runApprovalFlow(app: CliApp, indexerUrl: string): Promise<boolean> {
92+
const appMetaJson = JSON.stringify(APP_META)
93+
await app.service.auth.builder.create(indexerUrl, appMetaJson)
94+
const approvalUrl = await app.service.auth.builder.requestConnection()
95+
96+
console.log()
97+
console.log('Open this URL to approve the connection:')
98+
console.log(c.cyan(approvalUrl))
99+
console.log()
100+
101+
openInBrowser(approvalUrl)
102+
103+
const spinner = clack.spinner()
104+
spinner.start('Waiting for approval...')
105+
try {
106+
await app.service.auth.builder.waitForApproval()
107+
spinner.stop('Approved!')
108+
return true
109+
} catch {
110+
spinner.stop('Approval failed or cancelled')
111+
return false
112+
}
113+
}
114+
115+
function openInBrowser(url: string): void {
116+
const [cmd, args] =
117+
process.platform === 'darwin'
118+
? (['open', [url]] as const)
119+
: process.platform === 'win32'
120+
? (['cmd', ['/c', 'start', '""', url]] as const)
121+
: (['xdg-open', [url]] as const)
122+
try {
123+
execFile(cmd, [...args])
124+
} catch {
125+
// Browser open failed; user can copy the URL manually.
126+
}
127+
}
128+
129+
async function capturePhrase(app: CliApp): Promise<string | undefined> {
130+
const phraseAction = await clack.select({
131+
message: 'Recovery phrase',
132+
options: [
133+
{ value: 'generate', label: 'Generate new recovery phrase' },
134+
{ value: 'import', label: 'Enter existing recovery phrase' },
135+
],
136+
})
137+
if (clack.isCancel(phraseAction)) return undefined
138+
139+
if (phraseAction === 'generate') {
140+
const mnemonic = await app.service.auth.generateRecoveryPhrase()
141+
printRecoveryPhrase(mnemonic)
142+
return mnemonic
143+
}
144+
145+
const phrase = await clack.text({
146+
message: 'Enter your 12-word recovery phrase',
147+
validate: (v) => {
148+
if (!v) return 'Phrase is required'
149+
const words = v.trim().split(/\s+/)
150+
if (words.length !== 12) return 'Recovery phrase must be 12 words'
151+
},
152+
})
153+
if (clack.isCancel(phrase)) return undefined
154+
const mnemonic = phrase.trim()
155+
156+
try {
157+
await app.service.auth.validateRecoveryPhrase(mnemonic)
158+
} catch {
159+
console.error(c.red('Invalid recovery phrase'))
160+
return undefined
161+
}
162+
return mnemonic
163+
}
164+
165+
function printRecoveryPhrase(mnemonic: string): void {
166+
console.log()
167+
console.log(c.bold('Recovery Phrase (save this securely):'))
168+
console.log()
169+
const words = mnemonic.split(' ')
170+
for (let row = 0; row < 3; row++) {
171+
const line = words
172+
.slice(row * 4, row * 4 + 4)
173+
.map((w, i) => `${c.dim(`${row * 4 + i + 1}.`)} ${w}`)
174+
.join(' ')
175+
console.log(` ${line}`)
176+
}
177+
console.log()
178+
}
179+
180+
async function registerAndPersist(
181+
app: CliApp,
182+
indexerUrl: string,
183+
mnemonic: string,
184+
): Promise<void> {
185+
const spinner = clack.spinner()
186+
spinner.start('Registering...')
187+
try {
188+
const appKeyHex = await app.service.auth.builder.register(mnemonic)
189+
await app.service.auth.setAppKey(indexerUrl, hexToUint8(appKeyHex))
190+
await app.service.settings.setIndexerURL(indexerUrl)
191+
await app.service.settings.setHasOnboarded(true)
192+
193+
spinner.stop('Registered!')
194+
console.log()
195+
console.log(c.green('Connected successfully!'))
196+
console.log(c.dim('Run "sia daemon start" to begin syncing'))
197+
} catch (e) {
198+
spinner.stop('Registration failed')
199+
console.error(e instanceof Error ? e.message : String(e))
200+
}
201+
}

apps/cli/src/commands/daemon.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import {
2+
getPaths,
3+
isDaemonRunning,
4+
readDaemonPid,
5+
readState,
6+
sendIpcCommand,
7+
} from '@siastorage/node-adapters'
8+
import { ensureDaemonRunning } from '../daemon/supervisor'
9+
import { c, formatRelativeDate } from '../lib/format'
10+
11+
export async function daemonCommand(
12+
dataDir: string,
13+
action: string,
14+
_opts?: { foreground?: boolean },
15+
) {
16+
const p = getPaths(dataDir)
17+
18+
switch (action) {
19+
case 'start': {
20+
if (isDaemonRunning(p.pidPath)) {
21+
const pid = readDaemonPid(p.pidPath)
22+
console.log(`Daemon already running (PID: ${pid})`)
23+
return
24+
}
25+
await ensureDaemonRunning(p)
26+
const pid = readDaemonPid(p.pidPath)
27+
console.log(c.green(`Daemon started (PID: ${pid})`))
28+
break
29+
}
30+
31+
case 'stop': {
32+
const pid = readDaemonPid(p.pidPath)
33+
if (!pid || !isDaemonRunning(p.pidPath)) {
34+
console.log(c.dim('Daemon is not running'))
35+
return
36+
}
37+
try {
38+
await sendIpcCommand(p.sockPath, 'shutdown', {}, 5000)
39+
} catch {
40+
// IPC may close before we get a response, that's fine
41+
}
42+
// Wait for process to exit
43+
const deadline = Date.now() + 5000
44+
while (Date.now() < deadline) {
45+
try {
46+
process.kill(pid, 0)
47+
await new Promise((r) => setTimeout(r, 100))
48+
} catch {
49+
break
50+
}
51+
}
52+
console.log(c.green('Daemon stopped'))
53+
break
54+
}
55+
56+
case 'restart': {
57+
const pid = readDaemonPid(p.pidPath)
58+
if (pid && isDaemonRunning(p.pidPath)) {
59+
try {
60+
await sendIpcCommand(p.sockPath, 'shutdown', {}, 5000)
61+
} catch {
62+
// fine
63+
}
64+
const deadline = Date.now() + 5000
65+
while (Date.now() < deadline) {
66+
try {
67+
process.kill(pid, 0)
68+
await new Promise((r) => setTimeout(r, 100))
69+
} catch {
70+
break
71+
}
72+
}
73+
}
74+
await ensureDaemonRunning(p)
75+
const newPid = readDaemonPid(p.pidPath)
76+
console.log(c.green(`Daemon restarted (PID: ${newPid})`))
77+
break
78+
}
79+
80+
case 'status':
81+
default: {
82+
const running = isDaemonRunning(p.pidPath)
83+
const pid = readDaemonPid(p.pidPath)
84+
const state = readState(p.statePath)
85+
86+
if (!running) {
87+
console.log(`Daemon: ${c.dim('stopped')}`)
88+
return
89+
}
90+
91+
console.log(`Daemon: ${c.green('running')}`)
92+
console.log(`PID: ${pid}`)
93+
if (state) {
94+
console.log(`Uptime: ${formatRelativeDate(state.startedAt).replace(' ago', '')}`)
95+
console.log(`Connected: ${state.connected ? c.green('yes') : c.red('no')}`)
96+
}
97+
break
98+
}
99+
}
100+
}

apps/cli/src/index.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,23 @@ if (process.env.SIA_DAEMON_MODE === '1') {
3232
console.log('Run "sia --help" for available commands')
3333
})
3434

35-
// Commands are registered in subsequent PRs via program.command()
36-
// They use dynamic imports so the scaffold compiles independently.
35+
program
36+
.command('connect')
37+
.description('Connect to a Sia indexer')
38+
.action(async () => {
39+
const { connectCommand } = await import('./commands/connect')
40+
await connectCommand(resolveDataDir())
41+
})
42+
43+
program
44+
.command('daemon')
45+
.description('Manage the background daemon')
46+
.argument('[action]', 'start, stop, restart, or status', 'status')
47+
.option('-f, --foreground', 'Run in foreground')
48+
.action(async (action: string, opts: { foreground?: boolean }) => {
49+
const { daemonCommand } = await import('./commands/daemon')
50+
await daemonCommand(resolveDataDir(), action, opts)
51+
})
3752

3853
program.parse()
3954
}

0 commit comments

Comments
 (0)