Skip to content

Commit c45eb10

Browse files
committed
Add server targets and accessories foundation
1 parent 5cbec3c commit c45eb10

20 files changed

Lines changed: 589 additions & 54 deletions

src/cli/commands/deploy.ts

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,33 +2,36 @@ import chalk from 'chalk';
22
import { loadConfig } from '../../config/loader.js';
33
import { DeployService } from '../../services/deploy.service.js';
44
import { LoggingExecutor } from '../../infrastructure/ssh/logging-executor.js';
5-
import { runRemoteCommand } from '../runner.js';
5+
import { runRemoteCommandForTargets } from '../runner.js';
66
import { ui } from '../ui.js';
77
import type { ShipnodeConfig, ShipnodeApp } from '../../shared/types.js';
88
import { getActiveApp } from '../../domain/workspace.js';
99
import { getPm2Name } from '../../domain/pm2/apps.js';
10+
import { configForServer, getServerTargets, resolveServerName } from '../../domain/servers.js';
1011

1112
export async function cmdDeploy(cwd: string, options: { dryRun?: boolean; skipBuild?: boolean; app?: string; config?: string }): Promise<void> {
1213
const config = await loadConfig(cwd, options.config);
13-
const targetConfig = options.app
14-
? { ...config, apps: [getActiveApp(config, options.app)] }
15-
: config;
14+
const selectedApp = options.app ? getActiveApp(config, options.app) : undefined;
15+
const targetConfig = selectedApp ? { ...config, apps: [selectedApp] } : config;
1616

1717
if (options.dryRun) {
1818
printDryRun(targetConfig, options.skipBuild ?? false);
1919
return;
2020
}
2121

22-
await runRemoteCommand(
22+
await runRemoteCommandForTargets(
2323
cwd,
24-
async ({ config, executor }) => {
24+
async ({ config, executor, serverName }) => {
25+
const app = options.app ? config.apps.find((candidate) => candidate.name === options.app) : undefined;
2526
const deployConfig = options.app
26-
? { ...config, apps: [getActiveApp(config, options.app!)] }
27+
? { ...config, apps: app ? [app] : [] }
2728
: config;
29+
if (deployConfig.apps.length === 0 && Object.keys(deployConfig.accessories ?? {}).length === 0) return;
2830

2931
ui.banner();
3032
const names = deployConfig.apps.map((a) => a.name).join(', ');
31-
ui.step(`Deploying ${chalk.bold(names)}${config.ssh.user}@${config.ssh.host}`);
33+
const label = names || Object.keys(deployConfig.accessories ?? {}).join(', ');
34+
ui.step(`Deploying ${chalk.bold(label)}${serverName} (${config.ssh.user}@${config.ssh.host})`);
3235

3336
const deployer = new DeployService(new LoggingExecutor(executor), deployConfig);
3437
await deployer.execute(cwd, options.skipBuild ?? false);
@@ -45,12 +48,13 @@ export async function cmdDeploy(cwd: string, options: { dryRun?: boolean; skipBu
4548
);
4649
}
4750

48-
function renderAppPlan(app: ShipnodeApp, skipBuild: boolean): string {
51+
function renderAppPlan(config: ShipnodeConfig, app: ShipnodeApp, skipBuild: boolean): string {
4952
const namespace = app.pm2?.apps[0]?.name;
5053
const web = app.pm2?.apps.find((a) => a.port !== undefined);
5154

5255
const serverRows: [string, string][] = [
5356
['App type', app.appType],
57+
['Server', resolveServerName(config, app.on)],
5458
['App root', app.appRoot ?? '(repo root)'],
5559
['Keep releases', String(app.keepReleases)],
5660
];
@@ -114,18 +118,22 @@ function renderAppPlan(app: ShipnodeApp, skipBuild: boolean): string {
114118
].join('\n');
115119
}
116120

117-
function printDryRun(config: ShipnodeConfig, skipBuild: boolean): void {
121+
export function printDryRun(config: ShipnodeConfig, skipBuild: boolean): void {
118122
ui.banner();
119123

124+
const servers = getServerTargets(config).map((target) => `${target.name}=${target.ssh.user}@${target.ssh.host}:${target.ssh.port}`).join(', ');
120125
const header = [
121126
chalk.bold('Workspace'),
122-
` ${chalk.dim('Host'.padEnd(14))} ${config.ssh.user}@${config.ssh.host}:${config.ssh.port}`,
127+
` ${chalk.dim('Servers'.padEnd(14))} ${servers}`,
123128
` ${chalk.dim('Remote path'.padEnd(14))} ${config.remotePath}`,
124129
` ${chalk.dim('Node'.padEnd(14))} ${config.nodeVersion}`,
125130
` ${chalk.dim('Apps'.padEnd(14))} ${config.apps.map((a) => a.name).join(', ')}`,
126131
].join('\n');
127132

128-
const perApp = config.apps.map((app) => renderAppPlan(app, skipBuild)).join('\n\n');
133+
const perApp = getServerTargets(config)
134+
.map((target) => configForServer(config, target.name))
135+
.flatMap((targetConfig) => targetConfig.apps.map((app) => renderAppPlan(config, app, skipBuild)))
136+
.join('\n\n');
129137

130138
ui.note([header, '', perApp].join('\n'), 'Dry run — no changes will be made');
131139
}

src/cli/commands/doctor.ts

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
import { runRemoteCommand } from '../runner.js';
1+
import { runRemoteCommandForTargets } from '../runner.js';
22
import { ui } from '../ui.js';
3-
import { getActiveApp } from '../../domain/workspace.js';
43
import type { ShipnodeConfig } from '../../shared/types.js';
54

65
export async function cmdDoctor(cwd: string, options: { config?: string; security?: boolean }): Promise<void> {
7-
await runRemoteCommand(
6+
await runRemoteCommandForTargets(
87
cwd,
9-
async ({ config, executor }) => {
8+
async ({ config, executor, serverName }) => {
109
ui.heading('ShipNode Doctor');
10+
ui.heading(`Server: ${serverName} (${config.ssh.user}@${config.ssh.host})`);
1111

1212
await checkLocal(config);
1313

@@ -17,12 +17,11 @@ export async function cmdDoctor(cwd: string, options: { config?: string; securit
1717
await checkSecurity(config, executor);
1818
}
1919
},
20-
{ configPath: options.config },
20+
{ configPath: options.config, includeEmpty: true },
2121
);
2222
}
2323

2424
function checkLocal(config: ShipnodeConfig): void {
25-
const app = getActiveApp(config);
2625
ui.info('Checking local configuration...');
2726

2827
const issues: string[] = [];
@@ -39,7 +38,7 @@ function checkLocal(config: ShipnodeConfig): void {
3938
issues.push('Remote path is not configured');
4039
}
4140

42-
if (app.appType === 'backend' && !app.pm2?.apps.length) {
41+
if (config.apps.some((app) => app.appType === 'backend' && !app.pm2?.apps.length)) {
4342
issues.push('PM2 apps are not configured for backend app');
4443
}
4544

@@ -53,15 +52,21 @@ function checkLocal(config: ShipnodeConfig): void {
5352
}
5453

5554
async function checkRemote(
56-
config: { ssh: { host: string; user: string }; remotePath: string },
55+
config: ShipnodeConfig,
5756
executor: { exec: (cmd: string, opts?: { timeout?: number }) => Promise<{ stdout: string; exitCode: number }> },
5857
): Promise<void> {
5958
ui.info('Checking remote server...');
6059

60+
const needsNode = config.apps.length > 0;
61+
const needsPm2 = config.apps.some((app) => app.appType === 'backend' && app.pm2);
62+
const needsCaddy = config.apps.some((app) => app.domain);
63+
const needsDocker = Object.keys(config.accessories ?? {}).length > 0;
64+
6165
const checks = [
62-
{ name: 'Node', cmd: 'node --version' },
63-
{ name: 'PM2', cmd: 'pm2 --version' },
64-
{ name: 'Caddy', cmd: 'caddy version' },
66+
...(needsNode ? [{ name: 'Node', cmd: 'node --version' }] : []),
67+
...(needsPm2 ? [{ name: 'PM2', cmd: 'pm2 --version' }] : []),
68+
...(needsCaddy ? [{ name: 'Caddy', cmd: 'caddy version' }] : []),
69+
...(needsDocker ? [{ name: 'Docker', cmd: 'docker --version' }] : []),
6570
{ name: 'rsync', cmd: 'rsync --version' },
6671
{ name: 'jq', cmd: 'jq --version' },
6772
];
@@ -79,12 +84,14 @@ async function checkRemote(
7984
}
8085
}
8186

82-
ui.info('Checking deployment directory...');
83-
const dirResult = await executor.exec(`test -d "${config.remotePath}" && echo "exists" || echo "missing"`);
84-
if (dirResult.stdout === 'exists') {
85-
ui.success(`Deployment directory exists: ${config.remotePath}`);
86-
} else {
87-
ui.warn(`Deployment directory does not exist. Run 'shipnode setup' first.`);
87+
if (config.apps.length > 0) {
88+
ui.info('Checking deployment directory...');
89+
const dirResult = await executor.exec(`test -d "${config.remotePath}" && echo "exists" || echo "missing"`);
90+
if (dirResult.stdout === 'exists') {
91+
ui.success(`Deployment directory exists: ${config.remotePath}`);
92+
} else {
93+
ui.warn(`Deployment directory does not exist. Run 'shipnode setup' first.`);
94+
}
8895
}
8996
}
9097

src/cli/commands/setup.ts

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { existsSync, readFileSync } from 'fs';
22
import { Listr } from 'listr2';
3-
import { runRemoteCommand } from '../runner.js';
3+
import { runRemoteCommandForTargets } from '../runner.js';
44
import { ui } from '../ui.js';
55
import type { RemoteExecutor } from '../../domain/remote/executor.js';
66
import type { ShipnodeConfig, NetworkDatabaseConfig } from '../../shared/types.js';
@@ -22,11 +22,11 @@ interface SetupOptions {
2222
}
2323

2424
export async function cmdSetup(cwd: string, options: SetupOptions): Promise<void> {
25-
await runRemoteCommand(
25+
await runRemoteCommandForTargets(
2626
cwd,
27-
async ({ config, executor }) => {
27+
async ({ config, executor, serverName }) => {
2828
ui.banner();
29-
ui.step(`Setting up ${config.ssh.user}@${config.ssh.host}`);
29+
ui.step(`Setting up ${serverName} (${config.ssh.user}@${config.ssh.host})`);
3030
const created = !options.noDeployUser && (await bootstrapDeployUser(cwd, config, executor));
3131
await buildTasks(executor, config, created ? DEPLOY_USER : null).run();
3232
if (created) {
@@ -43,7 +43,7 @@ export async function cmdSetup(cwd: string, options: SetupOptions): Promise<void
4343
ui.outro('Server ready — run: shipnode deploy');
4444
}
4545
},
46-
{ configPath: options.config },
46+
{ configPath: options.config, includeEmpty: true },
4747
);
4848
}
4949

@@ -99,6 +99,8 @@ function buildTasks(executor: RemoteExecutor, config: ShipnodeConfig, ownerUser:
9999
// so PM2 processes run as that user and `pm2-<user>.service` matches. Without
100100
// this, everything lands in root's home and deploy has no pm2 on their PATH.
101101
const targetHome = ownerUser ? `/home/${ownerUser}` : '$HOME';
102+
const hasApps = config.apps.length > 0;
103+
const hasAccessories = Object.keys(config.accessories ?? {}).length > 0;
102104

103105
return new Listr(
104106
[
@@ -120,7 +122,7 @@ function buildTasks(executor: RemoteExecutor, config: ShipnodeConfig, ownerUser:
120122
},
121123
], { concurrent: false }),
122124
},
123-
{
125+
...(hasApps ? [{
124126
title: `Mise (version manager)${ownerUser ? ` for ${ownerUser}` : ''}`,
125127
task: () =>
126128
executor.execOrThrow(
@@ -129,8 +131,8 @@ function buildTasks(executor: RemoteExecutor, config: ShipnodeConfig, ownerUser:
129131
`if ! command -v mise &>/dev/null && [ ! -x "$HOME/.local/bin/mise" ]; then curl -fsSL https://mise.run | sh; fi`,
130132
),
131133
),
132-
},
133-
{
134+
}] : []),
135+
...(hasApps ? [{
134136
title: `Node.js ${config.nodeVersion}`,
135137
task: (_ctx: object, task: any) => task.newListr([
136138
{
@@ -142,8 +144,8 @@ function buildTasks(executor: RemoteExecutor, config: ShipnodeConfig, ownerUser:
142144
task: () => executor.execOrThrow(asUser(ownerUser, `${mise}; mise use -g -y "node@${nodeVersion}"`)),
143145
},
144146
], { concurrent: false }),
145-
},
146-
{
147+
}] : []),
148+
...(hasApps && config.apps.some((app) => app.appType === 'backend' && app.pm2) ? [{
147149
title: 'PM2',
148150
task: (_ctx: object, task: any) => task.newListr([
149151
{
@@ -180,8 +182,8 @@ function buildTasks(executor: RemoteExecutor, config: ShipnodeConfig, ownerUser:
180182
),
181183
},
182184
], { concurrent: false }),
183-
},
184-
{
185+
}] : []),
186+
...(hasApps && config.apps.some((app) => app.domain) ? [{
185187
title: 'Caddy',
186188
task: () =>
187189
executor.execOrThrow(
@@ -193,7 +195,23 @@ function buildTasks(executor: RemoteExecutor, config: ShipnodeConfig, ownerUser:
193195
' $SUDO apt-get update && $SUDO apt-get install -y caddy; ' +
194196
'fi',
195197
),
196-
},
198+
}] : []),
199+
...(hasAccessories ? [{
200+
title: 'Docker',
201+
task: () =>
202+
executor.execOrThrow(
203+
'SUDO=""; [ "$EUID" -ne 0 ] && SUDO="sudo"; ' +
204+
'if ! command -v docker &>/dev/null; then ' +
205+
' $SUDO apt-get install -y ca-certificates curl gnupg; ' +
206+
' $SUDO install -m 0755 -d /etc/apt/keyrings; ' +
207+
' curl -fsSL https://download.docker.com/linux/ubuntu/gpg | $SUDO gpg --dearmor -o /etc/apt/keyrings/docker.gpg; ' +
208+
' $SUDO chmod a+r /etc/apt/keyrings/docker.gpg; ' +
209+
' echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | $SUDO tee /etc/apt/sources.list.d/docker.list > /dev/null; ' +
210+
' $SUDO apt-get update && $SUDO apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin; ' +
211+
'fi; ' +
212+
'$SUDO systemctl enable docker && $SUDO systemctl start docker',
213+
),
214+
}] : []),
197215
...(config.database && config.database.type !== 'sqlite' && config.database.host === 'localhost'
198216
? [{
199217
title: `Database (${config.database.type})`,
@@ -234,7 +252,7 @@ function buildTasks(executor: RemoteExecutor, config: ShipnodeConfig, ownerUser:
234252
},
235253
}]
236254
: []),
237-
{
255+
...(hasApps ? [{
238256
title: 'Deployment directories',
239257
task: (_ctx: object, task: any) => task.newListr([
240258
{
@@ -248,7 +266,7 @@ function buildTasks(executor: RemoteExecutor, config: ShipnodeConfig, ownerUser:
248266
: `mkdir -p "${config.remotePath}/releases" "${config.remotePath}/shared" "${config.remotePath}/.shipnode"`),
249267
),
250268
},
251-
{
269+
...(config.apps.some((app) => app.domain) ? [{
252270
title: 'Configure Caddy include',
253271
task: () => executor.execOrThrow(
254272
'SUDO=""; [ "$EUID" -ne 0 ] && SUDO="sudo"; ' +
@@ -257,9 +275,9 @@ function buildTasks(executor: RemoteExecutor, config: ShipnodeConfig, ownerUser:
257275
'echo "import /etc/caddy/conf.d/*.caddy" | $SUDO tee -a /etc/caddy/Caddyfile > /dev/null && ' +
258276
'$SUDO systemctl reload caddy 2>/dev/null || true',
259277
),
260-
},
278+
}] : []),
261279
], { concurrent: false }),
262-
},
280+
}] : []),
263281
],
264282
{ rendererOptions: { collapseErrors: false } },
265283
);

src/cli/commands/status.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
1-
import { runRemoteCommand } from '../runner.js';
1+
import { runRemoteCommandForTargets } from '../runner.js';
22
import { ui } from '../ui.js';
3-
import { getActiveApp } from '../../domain/workspace.js';
43
import { getDeploymentName, getPm2Name } from '../../domain/pm2/apps.js';
54

65
export async function cmdStatus(cwd: string, options: { config?: string; app?: string }): Promise<void> {
7-
await runRemoteCommand(
6+
await runRemoteCommandForTargets(
87
cwd,
9-
async ({ config, executor }) => {
8+
async ({ config, executor, serverName }) => {
109
const apps = options.app
11-
? [getActiveApp(config, options.app)]
10+
? config.apps.filter((app) => app.name === options.app)
1211
: config.apps;
1312

13+
if (apps.length === 0) return;
14+
ui.heading(`Server: ${serverName} (${config.ssh.user}@${config.ssh.host})`);
15+
1416
for (const app of apps) {
1517
ui.heading(`Status: ${app.name} (${app.appType})`);
1618

src/cli/runner.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { SshConnection } from '../infrastructure/ssh/connection.js';
33
import type { RemoteExecutor } from '../domain/remote/executor.js';
44
import { loadConfig } from '../config/loader.js';
55
import { ui } from './ui.js';
6+
import { configForServer, getServerTargets } from '../domain/servers.js';
67

78
/**
89
* A command that operates against a remote host through an executor.
@@ -41,6 +42,35 @@ export async function runRemoteCommand(
4142
}
4243
}
4344

45+
export async function runRemoteCommandForTargets(
46+
cwd: string,
47+
command: (ctx: { config: ShipnodeConfig; executor: RemoteExecutor; serverName: string }) => Promise<void>,
48+
options: { configPath?: string; includeEmpty?: boolean } = {},
49+
): Promise<void> {
50+
const config = await loadConfig(cwd, options.configPath);
51+
52+
try {
53+
for (const target of getServerTargets(config)) {
54+
const targetConfig = configForServer(config, target.name);
55+
if (!options.includeEmpty && targetConfig.apps.length === 0 && Object.keys(targetConfig.accessories ?? {}).length === 0) {
56+
continue;
57+
}
58+
59+
const ssh = new SshConnection();
60+
try {
61+
await ssh.connect(target.ssh);
62+
await command({ config: targetConfig, executor: ssh, serverName: target.name });
63+
} finally {
64+
ssh.disconnect();
65+
}
66+
}
67+
} catch (error) {
68+
const message = error instanceof Error ? error.message : String(error);
69+
ui.error(message);
70+
process.exit(1);
71+
}
72+
}
73+
4474
/**
4575
* Run a local command that does not need a remote connection.
4676
*

0 commit comments

Comments
 (0)