1- import { readFileSync , existsSync } from 'fs' ;
2- import { join } from 'path' ;
1+ import { readFileSync , existsSync , writeFileSync , mkdirSync } from 'fs' ;
2+ import { dirname , join , resolve , isAbsolute } from 'path' ;
33import { runRemoteCommand } from '../runner.js' ;
44import { ui } from '../ui.js' ;
55import { confirm } from '../prompt.js' ;
6+ import type { RemoteExecutor } from '../../domain/remote/executor.js' ;
67
7- interface UserEntry {
8+ export interface UserEntry {
89 username : string ;
910 publicKey : string ;
1011 sudo ?: boolean ;
1112}
1213
13- function parseUsersYml ( cwd : string ) : UserEntry [ ] {
14- const paths = [
15- join ( cwd , '.shipnode' , 'users.yml' ) ,
16- join ( cwd , 'users.yml' ) ,
17- ] ;
14+ function usersYmlPath ( cwd : string ) : string {
15+ return join ( cwd , '.shipnode' , 'users.yml' ) ;
16+ }
17+
18+ export function loadUsersYml ( cwd : string ) : UserEntry [ ] {
19+ const paths = [ usersYmlPath ( cwd ) , join ( cwd , 'users.yml' ) ] ;
1820 for ( const p of paths ) {
1921 if ( existsSync ( p ) ) {
20- const raw = readFileSync ( p , 'utf8' ) ;
21- return parseSimpleUsersYml ( raw ) ;
22+ return parseSimpleUsersYml ( readFileSync ( p , 'utf8' ) ) ;
2223 }
2324 }
2425 return [ ] ;
@@ -44,47 +45,130 @@ export function parseSimpleUsersYml(raw: string): UserEntry[] {
4445 return users ;
4546}
4647
48+ export function serializeUsersYml ( users : UserEntry [ ] ) : string {
49+ return users
50+ . map ( ( u ) => {
51+ const lines = [
52+ `- username: ${ u . username } ` ,
53+ ` publicKey: ${ u . publicKey } ` ,
54+ ] ;
55+ if ( u . sudo ) lines . push ( ' sudo: true' ) ;
56+ return lines . join ( '\n' ) ;
57+ } )
58+ . join ( '\n' ) + '\n' ;
59+ }
60+
61+ export function saveUsersYml ( cwd : string , users : UserEntry [ ] ) : string {
62+ const path = usersYmlPath ( cwd ) ;
63+ mkdirSync ( dirname ( path ) , { recursive : true } ) ;
64+ writeFileSync ( path , serializeUsersYml ( users ) ) ;
65+ return path ;
66+ }
67+
68+ export function upsertUser ( users : UserEntry [ ] , entry : UserEntry ) : UserEntry [ ] {
69+ const idx = users . findIndex ( ( u ) => u . username === entry . username ) ;
70+ if ( idx >= 0 ) {
71+ const next = users . slice ( ) ;
72+ next [ idx ] = entry ;
73+ return next ;
74+ }
75+ return [ ...users , entry ] ;
76+ }
77+
78+ export async function syncUsers ( executor : RemoteExecutor , users : UserEntry [ ] ) : Promise < void > {
79+ for ( const user of users ) {
80+ if ( ! user . publicKey ) {
81+ ui . warn ( `Skipping ${ user . username } : no publicKey` ) ;
82+ continue ;
83+ }
84+ const sudoGroups = user . sudo ? ',sudo' : '' ;
85+ const script = [
86+ `id "${ user . username } " &>/dev/null || useradd -m -s /bin/bash${ sudoGroups ? ` -G "${ sudoGroups . slice ( 1 ) } "` : '' } "${ user . username } "` ,
87+ `mkdir -p "/home/${ user . username } /.ssh"` ,
88+ `echo "${ user . publicKey } " >> "/home/${ user . username } /.ssh/authorized_keys"` ,
89+ `sort -u "/home/${ user . username } /.ssh/authorized_keys" -o "/home/${ user . username } /.ssh/authorized_keys"` ,
90+ `chmod 700 "/home/${ user . username } /.ssh"` ,
91+ `chmod 600 "/home/${ user . username } /.ssh/authorized_keys"` ,
92+ `chown -R "${ user . username } :${ user . username } " "/home/${ user . username } /.ssh"` ,
93+ ] . join ( ' && ' ) ;
94+
95+ await executor . execOrThrow ( `SUDO=""; [ "$EUID" -ne 0 ] && SUDO="sudo"; $SUDO bash -c '${ script } '` ) ;
96+ }
97+ }
98+
99+ function resolvePubKey ( cwd : string , keyOpt : string | undefined , identityFile : string | undefined ) : string {
100+ const tried : string [ ] = [ ] ;
101+ const candidates : string [ ] = [ ] ;
102+ if ( keyOpt ) candidates . push ( isAbsolute ( keyOpt ) ? keyOpt : resolve ( cwd , keyOpt ) ) ;
103+ else if ( identityFile ) candidates . push ( `${ identityFile } .pub` ) ;
104+
105+ for ( const path of candidates ) {
106+ tried . push ( path ) ;
107+ if ( existsSync ( path ) ) return readFileSync ( path , 'utf8' ) . trim ( ) ;
108+ }
109+ throw new Error (
110+ `No SSH public key found. Tried: ${ tried . join ( ', ' ) || '(none)' } . ` +
111+ `Pass --key <path> or set ssh.identityFile in shipnode.config.ts.` ,
112+ ) ;
113+ }
114+
47115export async function cmdUserSync (
48116 cwd : string ,
49117 options : { config ?: string } ,
50118) : Promise < void > {
51- const users = parseUsersYml ( cwd ) ;
119+ const users = loadUsersYml ( cwd ) ;
52120
53121 if ( users . length === 0 ) {
54- ui . warn ( 'No users.yml found. Create .shipnode/users.yml with username/publicKey entries. ' ) ;
122+ ui . warn ( 'No users.yml found. Create .shipnode/users.yml or run: shipnode user add <name> ' ) ;
55123 process . exit ( 1 ) ;
56124 }
57125
58126 await runRemoteCommand (
59127 cwd ,
60128 async ( { executor } ) => {
61129 ui . info ( `Syncing ${ users . length } user(s)...` ) ;
62-
63- for ( const user of users ) {
64- if ( ! user . publicKey ) {
65- ui . warn ( `Skipping ${ user . username } : no publicKey` ) ;
66- continue ;
67- }
68-
69- const sudoGroups = user . sudo ? ',sudo' : '' ;
70- const script = [
71- `id "${ user . username } " &>/dev/null || useradd -m -s /bin/bash${ sudoGroups ? ` -G "${ sudoGroups . slice ( 1 ) } "` : '' } "${ user . username } "` ,
72- `mkdir -p "/home/${ user . username } /.ssh"` ,
73- `echo "${ user . publicKey } " >> "/home/${ user . username } /.ssh/authorized_keys"` ,
74- `sort -u "/home/${ user . username } /.ssh/authorized_keys" -o "/home/${ user . username } /.ssh/authorized_keys"` ,
75- `chmod 700 "/home/${ user . username } /.ssh"` ,
76- `chmod 600 "/home/${ user . username } /.ssh/authorized_keys"` ,
77- `chown -R "${ user . username } :${ user . username } " "/home/${ user . username } /.ssh"` ,
78- ] . join ( ' && ' ) ;
79-
80- await executor . exec ( `SUDO=""; [ "$EUID" -ne 0 ] && SUDO="sudo"; $SUDO bash -c '${ script } '` ) ;
81- ui . success ( `Synced user: ${ user . username } ${ user . sudo ? ' (sudo)' : '' } ` ) ;
130+ await syncUsers ( executor , users ) ;
131+ for ( const u of users ) {
132+ ui . success ( `Synced user: ${ u . username } ${ u . sudo ? ' (sudo)' : '' } ` ) ;
82133 }
83134 } ,
84135 { configPath : options . config } ,
85136 ) ;
86137}
87138
139+ export async function cmdUserAdd (
140+ cwd : string ,
141+ username : string ,
142+ options : { key ?: string ; sudo ?: boolean ; noSync ?: boolean ; config ?: string } ,
143+ ) : Promise < void > {
144+ if ( ! username ) {
145+ ui . error ( 'Username required' ) ;
146+ process . exit ( 1 ) ;
147+ }
148+
149+ const existing = loadUsersYml ( cwd ) ;
150+ const { loadConfig } = await import ( '../../config/loader.js' ) ;
151+ const config = await loadConfig ( cwd , options . config ) . catch ( ( ) => null ) ;
152+ const publicKey = resolvePubKey ( cwd , options . key , config ?. ssh . identityFile ) ;
153+
154+ const entry : UserEntry = { username, publicKey, sudo : options . sudo ?? false } ;
155+ const next = upsertUser ( existing , entry ) ;
156+ const path = saveUsersYml ( cwd , next ) ;
157+ ui . success ( `Wrote ${ username } to ${ path } ` ) ;
158+
159+ if ( options . noSync ) return ;
160+
161+ await runRemoteCommand (
162+ cwd ,
163+ async ( { executor } ) => {
164+ ui . info ( `Syncing ${ username } to server...` ) ;
165+ await syncUsers ( executor , [ entry ] ) ;
166+ ui . success ( `Synced user: ${ username } ${ entry . sudo ? ' (sudo)' : '' } ` ) ;
167+ } ,
168+ { configPath : options . config } ,
169+ ) ;
170+ }
171+
88172export async function cmdUserList (
89173 cwd : string ,
90174 options : { config ?: string } ,
0 commit comments