Skip to content

Commit db50f78

Browse files
committed
feat(setup): bootstrap deploy user and add shipnode user add
`shipnode setup` now creates a `deploy` user (sudo, keyed off `${ssh.identityFile}.pub`) as its first step, registers it in `.shipnode/users.yml`, and chowns `remotePath` to that user so the subsequent deploy can run as a non-root account. Opt out with `--no-deploy-user`. After setup, the user is prompted to switch `ssh.user` in the config and run `shipnode harden`. New CLI: `shipnode user add <name> [--key path] [--sudo] [--no-sync]` writes/updates `.shipnode/users.yml` and syncs the entry to the server in one step, removing the manual YAML editing that `user sync` previously required. Refactors user.ts so parse/serialize/sync helpers are reusable from both `user add` and setup.
1 parent b56c4e5 commit db50f78

3 files changed

Lines changed: 180 additions & 38 deletions

File tree

src/cli/commands/setup.ts

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { existsSync, readFileSync } from 'fs';
12
import { Listr } from 'listr2';
23
import { runRemoteCommand } from '../runner.js';
34
import { ui } from '../ui.js';
@@ -11,21 +12,66 @@ import {
1112
buildRedisConfigureCommand,
1213
buildRedisProbeCommand,
1314
} from '../../infrastructure/provisioning/commands.js';
15+
import { loadUsersYml, saveUsersYml, syncUsers, upsertUser } from './user.js';
1416

15-
export async function cmdSetup(cwd: string, options: { config?: string }): Promise<void> {
17+
const DEPLOY_USER = 'deploy';
18+
19+
interface SetupOptions {
20+
config?: string;
21+
noDeployUser?: boolean;
22+
}
23+
24+
export async function cmdSetup(cwd: string, options: SetupOptions): Promise<void> {
1625
await runRemoteCommand(
1726
cwd,
1827
async ({ config, executor }) => {
1928
ui.banner();
2029
ui.step(`Setting up ${config.ssh.user}@${config.ssh.host}`);
21-
await buildTasks(executor, config).run();
22-
ui.outro('Server ready — run: shipnode deploy');
30+
const created = !options.noDeployUser && (await bootstrapDeployUser(cwd, config, executor));
31+
await buildTasks(executor, config, created ? DEPLOY_USER : null).run();
32+
if (created) {
33+
ui.note(
34+
[
35+
`A '${DEPLOY_USER}' user was created and owns ${config.remotePath}.`,
36+
`Switch ssh.user in shipnode.config.ts to '${DEPLOY_USER}', then:`,
37+
` shipnode harden # disable root SSH`,
38+
` shipnode deploy`,
39+
].join('\n'),
40+
'Next steps',
41+
);
42+
} else {
43+
ui.outro('Server ready — run: shipnode deploy');
44+
}
2345
},
2446
{ configPath: options.config },
2547
);
2648
}
2749

28-
function buildTasks(executor: RemoteExecutor, config: ShipnodeConfig) {
50+
async function bootstrapDeployUser(
51+
cwd: string,
52+
config: ShipnodeConfig,
53+
executor: RemoteExecutor,
54+
): Promise<boolean> {
55+
const pubPath = `${config.ssh.identityFile}.pub`;
56+
if (!existsSync(pubPath)) {
57+
ui.warn(`Skipping ${DEPLOY_USER} user: no public key at ${pubPath}. Pass --no-deploy-user to silence or generate one with 'ssh-keygen'.`);
58+
return false;
59+
}
60+
const publicKey = readFileSync(pubPath, 'utf8').trim();
61+
const existing = loadUsersYml(cwd);
62+
const entry = { username: DEPLOY_USER, publicKey, sudo: true };
63+
const already = existing.find((u) => u.username === DEPLOY_USER && u.publicKey === publicKey);
64+
if (!already) {
65+
const next = upsertUser(existing, entry);
66+
const path = saveUsersYml(cwd, next);
67+
ui.info(`Registered '${DEPLOY_USER}' in ${path}`);
68+
}
69+
ui.info(`Creating '${DEPLOY_USER}' on ${config.ssh.host}...`);
70+
await syncUsers(executor, [entry]);
71+
return true;
72+
}
73+
74+
function buildTasks(executor: RemoteExecutor, config: ShipnodeConfig, ownerUser: string | null) {
2975
const nodeVersion = config.nodeVersion === 'lts' ? '24' : config.nodeVersion;
3076
const mise = `export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:$PATH"`;
3177

@@ -150,7 +196,10 @@ function buildTasks(executor: RemoteExecutor, config: ShipnodeConfig) {
150196
{
151197
title: `Create release structure at ${config.remotePath}`,
152198
task: () => executor.execOrThrow(
153-
`mkdir -p "${config.remotePath}/releases" "${config.remotePath}/shared" "${config.remotePath}/.shipnode"`,
199+
'SUDO=""; [ "$EUID" -ne 0 ] && SUDO="sudo"; ' +
200+
`$SUDO mkdir -p "${config.remotePath}" && ` +
201+
(ownerUser ? `$SUDO chown "${ownerUser}:${ownerUser}" "${config.remotePath}" && ` : '') +
202+
`$SUDO -u "${ownerUser ?? '$USER'}" mkdir -p "${config.remotePath}/releases" "${config.remotePath}/shared" "${config.remotePath}/.shipnode"`,
154203
),
155204
},
156205
{

src/cli/commands/user.ts

Lines changed: 116 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,25 @@
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';
33
import { runRemoteCommand } from '../runner.js';
44
import { ui } from '../ui.js';
55
import { 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+
47115
export 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+
88172
export async function cmdUserList(
89173
cwd: string,
90174
options: { config?: string },

src/cli/index.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { cmdStop } from './commands/stop.js';
2323
import { cmdMetrics } from './commands/metrics.js';
2424
import { cmdEject } from './commands/eject.js';
2525
import { cmdHelp } from './commands/help.js';
26-
import { cmdUserSync, cmdUserList, cmdUserRemove } from './commands/user.js';
26+
import { cmdUserAdd, cmdUserSync, cmdUserList, cmdUserRemove } from './commands/user.js';
2727
import { cmdUpgrade } from './commands/upgrade.js';
2828
import { cmdBackupSetup, cmdBackupRun, cmdBackupStatus, cmdBackupList } from './commands/backup.js';
2929
import { cmdCloudflareInit, cmdCloudflareAudit, cmdCloudflareStatus } from './commands/cloudflare.js';
@@ -53,6 +53,7 @@ program
5353
program
5454
.command('setup')
5555
.description('Setup a new server with required dependencies')
56+
.option('--no-deploy-user', 'Skip creating the deploy user (advanced)')
5657
.option('--config <path>', 'Use a specific config file')
5758
.action((opts) => cmdSetup(process.cwd(), opts));
5859

@@ -198,6 +199,14 @@ config.command('path')
198199

199200
const user = program.command('user').description('Manage server users');
200201

202+
user.command('add <username>')
203+
.description('Add a user to .shipnode/users.yml and sync to server')
204+
.option('--key <path>', 'Public key file (default: ssh.identityFile + .pub)')
205+
.option('--sudo', 'Grant sudo privileges')
206+
.option('--no-sync', 'Only write to users.yml, do not sync to server')
207+
.option('--config <path>', 'Use a specific config file')
208+
.action((username: string, opts) => cmdUserAdd(process.cwd(), username, opts));
209+
201210
user.command('sync')
202211
.description('Sync users from .shipnode/users.yml to server')
203212
.option('--config <path>', 'Use a specific config file')

0 commit comments

Comments
 (0)