11import React , { useState , useEffect } from 'react' ;
22import { render , Box , Text , useInput , useApp } from 'ink' ;
33import TextInput from 'ink-text-input' ;
4+ import crypto from 'crypto' ;
45import { loadAgentConfig , saveAgentConfig , getConfigDir , ensureConfigDir } from '../config/loader' ;
56import { discoverSSHKeys } from '../ssh' ;
67import type { SSHKeyInfo } from '../shared/client-types' ;
78
8- type Step = 'welcome' | 'github' | 'ssh' | 'tailscale' | 'complete' ;
9+ type Step = 'welcome' | 'auth' | ' github' | 'ssh' | 'tailscale' | 'complete' ;
910
10- const STEPS : Step [ ] = [ 'welcome' , 'github' , 'ssh' , 'tailscale' , 'complete' ] ;
11+ const STEPS : Step [ ] = [ 'welcome' , 'auth' , ' github', 'ssh' , 'tailscale' , 'complete' ] ;
1112
1213interface WizardState {
14+ authToken : string ;
15+ authTokenGenerated : boolean ;
1316 githubToken : string ;
1417 selectedSSHKeys : string [ ] ;
1518 tailscaleAuthKey : string ;
@@ -31,6 +34,7 @@ function WelcomeStep({ onNext }: { onNext: () => void }) {
3134 </ Box >
3235 < Text > This wizard will help you configure:</ Text >
3336 < Box marginLeft = { 2 } flexDirection = "column" >
37+ < Text > • API security (auth token)</ Text >
3438 < Text > • Git access (GitHub token)</ Text >
3539 < Text > • SSH keys for workspaces</ Text >
3640 < Text > • Tailscale networking</ Text >
@@ -69,7 +73,7 @@ function TokenInputStep({
6973 } else if ( key . escape ) {
7074 onBack ( ) ;
7175 } else if ( input === 'v' && key . ctrl ) {
72- setShowValue ( ( s : boolean ) => ! s ) ;
76+ setShowValue ( ( s ) => ! s ) ;
7377 } else if ( input === 's' && optional ) {
7478 onNext ( ) ;
7579 }
@@ -100,6 +104,81 @@ function TokenInputStep({
100104 ) ;
101105}
102106
107+ function AuthStep ( {
108+ token,
109+ isNew,
110+ onGenerate,
111+ onNext,
112+ onBack,
113+ } : {
114+ token : string ;
115+ isNew : boolean ;
116+ onGenerate : ( ) => void ;
117+ onNext : ( ) => void ;
118+ onBack : ( ) => void ;
119+ } ) {
120+ const [ showToken , setShowToken ] = useState ( false ) ;
121+
122+ useInput ( ( input , key ) => {
123+ if ( key . return ) {
124+ onNext ( ) ;
125+ } else if ( key . escape ) {
126+ onBack ( ) ;
127+ } else if ( input === 'g' && ! token ) {
128+ onGenerate ( ) ;
129+ } else if ( input === 'v' && key . ctrl ) {
130+ setShowToken ( ( s ) => ! s ) ;
131+ }
132+ } ) ;
133+
134+ const maskedToken = token ? `${ token . slice ( 0 , 10 ) } ...${ token . slice ( - 4 ) } ` : '' ;
135+
136+ return (
137+ < Box flexDirection = "column" gap = { 1 } >
138+ < Text bold > API Security</ Text >
139+ < Text color = "gray" >
140+ Secure your Perry agent with token authentication. Clients will need this token to connect.
141+ </ Text >
142+
143+ < Box marginTop = { 1 } flexDirection = "column" >
144+ { token ? (
145+ < >
146+ < Box >
147+ < Text color = "green" > ✓ </ Text >
148+ < Text > Auth token { isNew ? 'generated' : 'configured' } </ Text >
149+ </ Box >
150+ < Box marginTop = { 1 } >
151+ < Text > Token: </ Text >
152+ < Text color = "cyan" > { showToken ? token : maskedToken } </ Text >
153+ </ Box >
154+ < Box marginTop = { 1 } flexDirection = "column" >
155+ < Text color = "yellow" > Save this token! You'll need it to configure clients:</ Text >
156+ < Box marginLeft = { 2 } >
157+ < Text color = "gray" > CLI: perry config token { showToken ? token : '<token>' } </ Text >
158+ </ Box >
159+ < Box marginLeft = { 2 } >
160+ < Text color = "gray" > Web: Enter when prompted on first visit</ Text >
161+ </ Box >
162+ </ Box >
163+ </ >
164+ ) : (
165+ < >
166+ < Text color = "yellow" > No auth token configured.</ Text >
167+ < Text > Without a token, anyone with network access can control your agent.</ Text >
168+ </ >
169+ ) }
170+ </ Box >
171+
172+ < Box marginTop = { 1 } >
173+ < Text color = "gray" >
174+ { token ? 'Ctrl+V to show/hide token, ' : 'G to generate token, ' }
175+ Enter to continue, Esc to go back
176+ </ Text >
177+ </ Box >
178+ </ Box >
179+ ) ;
180+ }
181+
103182function SSHKeySelectStep ( {
104183 keys,
105184 selected,
@@ -118,9 +197,9 @@ function SSHKeySelectStep({
118197
119198 useInput ( ( input , key ) => {
120199 if ( key . upArrow ) {
121- setHighlighted ( ( h : number ) => Math . max ( 0 , h - 1 ) ) ;
200+ setHighlighted ( ( h ) => Math . max ( 0 , h - 1 ) ) ;
122201 } else if ( key . downArrow ) {
123- setHighlighted ( ( h : number ) => Math . min ( privateKeys . length - 1 , h + 1 ) ) ;
202+ setHighlighted ( ( h ) => Math . min ( privateKeys . length - 1 , h + 1 ) ) ;
124203 } else if ( input === ' ' && privateKeys . length > 0 ) {
125204 onToggle ( privateKeys [ highlighted ] . path ) ;
126205 } else if ( key . return ) {
@@ -175,6 +254,7 @@ function CompleteStep({ state, onFinish }: { state: WizardState; onFinish: () =>
175254 } ) ;
176255
177256 const configured : string [ ] = [ ] ;
257+ if ( state . authToken ) configured . push ( 'Auth token' ) ;
178258 if ( state . githubToken ) configured . push ( 'GitHub' ) ;
179259 if ( state . selectedSSHKeys . length > 0 )
180260 configured . push ( `${ state . selectedSSHKeys . length } SSH key(s)` ) ;
@@ -197,6 +277,14 @@ function CompleteStep({ state, onFinish }: { state: WizardState; onFinish: () =>
197277 ) : (
198278 < Text color = "yellow" > No configuration added. You can always configure later.</ Text >
199279 ) }
280+ { state . authToken && (
281+ < Box marginTop = { 1 } flexDirection = "column" >
282+ < Text color = "yellow" > Remember to configure your clients with the auth token:</ Text >
283+ < Box marginLeft = { 2 } >
284+ < Text color = "gray" > perry config token { '<token>' } </ Text >
285+ </ Box >
286+ </ Box >
287+ ) }
200288 < Box marginTop = { 1 } >
201289 < Text > Start the agent with: </ Text >
202290 < Text color = "cyan" > perry agent run</ Text >
@@ -217,6 +305,8 @@ function SetupWizard() {
217305 const [ step , setStep ] = useState < Step > ( 'welcome' ) ;
218306 const [ sshKeys , setSSHKeys ] = useState < SSHKeyInfo [ ] > ( [ ] ) ;
219307 const [ state , setState ] = useState < WizardState > ( {
308+ authToken : '' ,
309+ authTokenGenerated : false ,
220310 githubToken : '' ,
221311 selectedSSHKeys : [ ] ,
222312 tailscaleAuthKey : '' ,
@@ -234,8 +324,10 @@ function SetupWizard() {
234324 const configDir = getConfigDir ( ) ;
235325 await ensureConfigDir ( configDir ) ;
236326 const config = await loadAgentConfig ( configDir ) ;
237- setState ( ( s : WizardState ) => ( {
327+ setState ( ( s ) => ( {
238328 ...s ,
329+ authToken : config . auth ?. token || '' ,
330+ authTokenGenerated : false ,
239331 githubToken : config . agents ?. github ?. token || '' ,
240332 selectedSSHKeys : config . ssh ?. global . copy || [ ] ,
241333 tailscaleAuthKey : config . tailscale ?. authKey || '' ,
@@ -259,21 +351,34 @@ function SetupWizard() {
259351 } ;
260352
261353 const toggleSSHKey = ( path : string ) => {
262- setState ( ( s : WizardState ) => ( {
354+ setState ( ( s ) => ( {
263355 ...s ,
264356 selectedSSHKeys : s . selectedSSHKeys . includes ( path )
265- ? s . selectedSSHKeys . filter ( ( k : string ) => k !== path )
357+ ? s . selectedSSHKeys . filter ( ( k ) => k !== path )
266358 : [ ...s . selectedSSHKeys , path ] ,
267359 } ) ) ;
268360 } ;
269361
362+ const generateAuthToken = ( ) => {
363+ const token = `perry-${ crypto . randomBytes ( 16 ) . toString ( 'hex' ) } ` ;
364+ setState ( ( s ) => ( {
365+ ...s ,
366+ authToken : token ,
367+ authTokenGenerated : true ,
368+ } ) ) ;
369+ } ;
370+
270371 const saveAndFinish = async ( ) => {
271372 setSaving ( true ) ;
272373 try {
273374 const configDir = getConfigDir ( ) ;
274375 await ensureConfigDir ( configDir ) ;
275376 const config = await loadAgentConfig ( configDir ) ;
276377
378+ if ( state . authTokenGenerated && state . authToken ) {
379+ config . auth = { ...config . auth , token : state . authToken } ;
380+ }
381+
277382 if ( state . githubToken ) {
278383 config . agents = {
279384 ...config . agents ,
@@ -325,13 +430,22 @@ function SetupWizard() {
325430 ) : (
326431 < >
327432 { step === 'welcome' && < WelcomeStep onNext = { nextStep } /> }
433+ { step === 'auth' && (
434+ < AuthStep
435+ token = { state . authToken }
436+ isNew = { state . authTokenGenerated }
437+ onGenerate = { generateAuthToken }
438+ onNext = { nextStep }
439+ onBack = { prevStep }
440+ />
441+ ) }
328442 { step === 'github' && (
329443 < TokenInputStep
330444 title = "GitHub Personal Access Token"
331445 placeholder = "ghp_... or github_pat_..."
332446 helpText = "Create at https://github.com/settings/personal-access-tokens/new"
333447 value = { state . githubToken }
334- onChange = { ( v : string ) => setState ( ( s : WizardState ) => ( { ...s , githubToken : v } ) ) }
448+ onChange = { ( v ) => setState ( ( s ) => ( { ...s , githubToken : v } ) ) }
335449 onNext = { nextStep }
336450 onBack = { prevStep }
337451 optional
@@ -352,9 +466,7 @@ function SetupWizard() {
352466 placeholder = "tskey-auth-..."
353467 helpText = "Generate at https://login.tailscale.com/admin/settings/keys (Reusable: Yes, Ephemeral: No)"
354468 value = { state . tailscaleAuthKey }
355- onChange = { ( v : string ) =>
356- setState ( ( s : WizardState ) => ( { ...s , tailscaleAuthKey : v } ) )
357- }
469+ onChange = { ( v ) => setState ( ( s ) => ( { ...s , tailscaleAuthKey : v } ) ) }
358470 onNext = { nextStep }
359471 onBack = { prevStep }
360472 optional
0 commit comments