Skip to content

Commit cc6322c

Browse files
committed
feat(cli): --app <name> flag on deploy/env/restart/stop/logs/status/metrics/run (sprint 2e)
1 parent b1aa641 commit cc6322c

10 files changed

Lines changed: 217 additions & 144 deletions

File tree

src/cli/commands/deploy.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,26 +8,34 @@ import type { ShipnodeConfig } from '../../shared/types.js';
88
import { getActiveApp } from '../../domain/workspace.js';
99
import { getDeploymentName, getWebApp } from '../../domain/pm2/apps.js';
1010

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

1417
if (options.dryRun) {
15-
printDryRun(config, options.skipBuild ?? false);
18+
printDryRun(targetConfig, options.skipBuild ?? false);
1619
return;
1720
}
1821

1922
await runRemoteCommand(
2023
cwd,
2124
async ({ config, executor }) => {
25+
const deployConfig = options.app
26+
? { ...config, apps: [getActiveApp(config, options.app!)] }
27+
: config;
28+
2229
ui.banner();
23-
ui.step(`Deploying ${chalk.bold(getDeploymentName(config) ?? getActiveApp(config).appType)}${config.ssh.user}@${config.ssh.host}`);
30+
const names = deployConfig.apps.map((a) => a.name).join(', ');
31+
ui.step(`Deploying ${chalk.bold(names)}${config.ssh.user}@${config.ssh.host}`);
2432

25-
const deployer = new DeployService(new LoggingExecutor(executor), config);
33+
const deployer = new DeployService(new LoggingExecutor(executor), deployConfig);
2634
await deployer.execute(cwd, options.skipBuild ?? false);
2735

2836
const lines = [
2937
`host ${config.ssh.user}@${config.ssh.host}`,
30-
getActiveApp(config).domain ? `url https://${getActiveApp(config).domain}` : '',
38+
...deployConfig.apps.filter((a) => a.domain).map((a) => `url https://${a.domain}`),
3139
].filter(Boolean).join('\n');
3240

3341
ui.note(lines, 'Done');

src/cli/commands/env.ts

Lines changed: 46 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -8,63 +8,64 @@ import { getDeploymentName } from '../../domain/pm2/apps.js';
88

99
export async function cmdEnv(
1010
cwd: string,
11-
options: { file?: string; config?: string },
11+
options: { file?: string; config?: string; app?: string },
1212
): Promise<void> {
1313
await runRemoteCommand(
1414
cwd,
1515
async ({ config, executor }) => {
16-
const app = getActiveApp(config);
17-
const envFile = options.file ?? app.envFile;
18-
const localEnvPath = resolve(cwd, envFile);
16+
const apps = options.app
17+
? [getActiveApp(config, options.app)]
18+
: config.apps;
1919

20-
if (!(await pathExists(localEnvPath))) {
21-
throw new Error(`Environment file not found: ${envFile}`);
22-
}
23-
24-
ui.info(`Uploading ${envFile} to server...`);
20+
for (const app of apps) {
21+
const envFile = options.file ?? app.envFile;
22+
const localEnvPath = resolve(cwd, envFile);
2523

26-
const content = await readFile(localEnvPath);
27-
const b64 = content.toString('base64');
24+
if (!(await pathExists(localEnvPath))) {
25+
throw new Error(`Environment file not found: ${envFile}`);
26+
}
2827

29-
// Store with the configured envFile name so the PM2 ecosystem reference
30-
// (`shared/${envFile}`) and the workDir symlink target stay consistent.
31-
// Maintain a `.env` alias too — older configs and any external scripts
32-
// that read `shared/.env` keep working.
33-
const sharedEnv = `${config.remotePath}/shared/${app.envFile}`;
34-
const sharedEnvAlias = `${config.remotePath}/shared/.env`;
35-
await executor.exec(`mkdir -p "${config.remotePath}/shared"`);
36-
await executor.exec(`echo "${b64}" | base64 -d > "${sharedEnv}"`);
37-
await executor.exec(`chmod 600 "${sharedEnv}"`);
38-
if (app.envFile !== '.env') {
39-
await executor.exec(`ln -sf "${sharedEnv}" "${sharedEnvAlias}"`);
40-
}
41-
ui.success(`Uploaded to ${sharedEnv}`);
28+
ui.info(`Uploading ${envFile} for app '${app.name}'...`);
4229

43-
const linkResult = await executor.exec(
44-
`if [ -d "${config.remotePath}/current" ]; then ` +
45-
`ln -sfn "${sharedEnv}" "${config.remotePath}/current/.env" && echo "linked"; ` +
46-
`fi`,
47-
);
48-
if (linkResult.stdout === 'linked') {
49-
ui.success('Linked shared .env to current release');
50-
}
30+
const content = await readFile(localEnvPath);
31+
const b64 = content.toString('base64');
32+
const appPath = `${config.remotePath}/${app.name}`;
33+
const sharedEnv = `${appPath}/shared/${app.envFile}`;
34+
const sharedEnvAlias = `${appPath}/shared/.env`;
35+
await executor.exec(`mkdir -p "${appPath}/shared"`);
36+
await executor.exec(`echo "${b64}" | base64 -d > "${sharedEnv}"`);
37+
await executor.exec(`chmod 600 "${sharedEnv}"`);
38+
if (app.envFile !== '.env') {
39+
await executor.exec(`ln -sf "${sharedEnv}" "${sharedEnvAlias}"`);
40+
}
41+
ui.success(`Uploaded to ${sharedEnv}`);
5142

52-
const namespace = getDeploymentName(config);
53-
if (app.appType === 'backend' && namespace) {
54-
const nodeVersion = config.nodeVersion === 'lts' ? '24' : config.nodeVersion;
55-
const mise = `export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:$PATH"`;
56-
const checkResult = await executor.exec(
57-
`${mise}; mise exec node@${nodeVersion} -- pm2 describe ${namespace} 2>/dev/null && echo "running" || echo "stopped"`,
43+
const linkResult = await executor.exec(
44+
`if [ -d "${appPath}/current" ]; then ` +
45+
`ln -sfn "${sharedEnv}" "${appPath}/current/.env" && echo "linked"; ` +
46+
`fi`,
5847
);
48+
if (linkResult.stdout === 'linked') {
49+
ui.success('Linked shared .env to current release');
50+
}
5951

60-
if (checkResult.stdout.includes('running')) {
61-
ui.info('Reloading deployment to pick up environment variables...');
62-
await executor.exec(
63-
`${mise}; mise exec node@${nodeVersion} -- pm2 reload ${namespace} --update-env`,
52+
const namespace = getDeploymentName({ ...config, apps: [app] } as any);
53+
if (app.appType === 'backend' && namespace) {
54+
const nodeVersion = config.nodeVersion === 'lts' ? '24' : config.nodeVersion;
55+
const mise = `export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:$PATH"`;
56+
const checkResult = await executor.exec(
57+
`${mise}; mise exec node@${nodeVersion} -- pm2 describe ${namespace} 2>/dev/null && echo "running" || echo "stopped"`,
6458
);
65-
ui.success('Deployment reloaded with new environment variables');
66-
} else {
67-
ui.warn('Deployment not running. Variables will be loaded on next deploy.');
59+
60+
if (checkResult.stdout.includes('running')) {
61+
ui.info('Reloading deployment to pick up environment variables...');
62+
await executor.exec(
63+
`${mise}; mise exec node@${nodeVersion} -- pm2 reload ${namespace} --update-env`,
64+
);
65+
ui.success('Deployment reloaded with new environment variables');
66+
} else {
67+
ui.warn('Deployment not running. Variables will be loaded on next deploy.');
68+
}
6869
}
6970
}
7071
},

src/cli/commands/logs.ts

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,37 @@
11
import { runRemoteCommand } from '../runner.js';
22
import { getActiveApp } from '../../domain/workspace.js';
3-
import { getDeploymentName, resolveProcessTarget } from '../../domain/pm2/apps.js';
43

5-
export async function cmdLogs(cwd: string, options: { lines?: number; config?: string; process?: string }): Promise<void> {
4+
export async function cmdLogs(cwd: string, options: { lines?: number; config?: string; process?: string; app?: string }): Promise<void> {
65
await runRemoteCommand(
76
cwd,
87
async ({ config, executor }) => {
9-
const namespace = getDeploymentName(config);
10-
if (getActiveApp(config).appType !== 'backend' || !namespace) {
11-
throw new Error('Logs only available for backend apps with PM2');
8+
const apps = options.app
9+
? [getActiveApp(config, options.app)]
10+
: config.apps.filter((a) => a.appType === 'backend' && a.pm2);
11+
12+
if (apps.length === 0) {
13+
throw new Error('No backend apps with PM2 configured');
14+
}
15+
16+
if (options.process && apps.length > 1) {
17+
throw new Error('--process requires --app to target a specific app');
1218
}
13-
const target = options.process ? resolveProcessTarget(config, options.process) : namespace;
19+
1420
const lines = options.lines ?? 100;
1521
const nodeVersion = config.nodeVersion === 'lts' ? '24' : config.nodeVersion;
1622
const mise = `export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:$PATH"`;
17-
const result = await executor.exec(
18-
`${mise}; mise exec "node@${nodeVersion}" -- pm2 logs ${target} --lines ${lines} --nostream`,
19-
);
20-
if (result.stdout) process.stdout.write(result.stdout + '\n');
21-
if (result.stderr) process.stderr.write(result.stderr + '\n');
23+
24+
for (const app of apps) {
25+
const namespace = app.pm2!.apps[0].name;
26+
const target = options.process
27+
? app.pm2!.apps.find((a) => a.name === options.process)?.name ?? namespace
28+
: namespace;
29+
const result = await executor.exec(
30+
`${mise}; mise exec "node@${nodeVersion}" -- pm2 logs ${target} --lines ${lines} --nostream`,
31+
);
32+
if (result.stdout) process.stdout.write(`[${app.name}] ${result.stdout}\n`);
33+
if (result.stderr) process.stderr.write(`[${app.name}] ${result.stderr}\n`);
34+
}
2235
},
2336
{ configPath: options.config },
2437
);

src/cli/commands/metrics.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@ import { loadConfig } from '../../config/loader.js';
33
import { getActiveApp } from '../../domain/workspace.js';
44
import { getDeploymentName } from '../../domain/pm2/apps.js';
55

6-
export async function cmdMetrics(cwd: string, options: { config?: string }): Promise<void> {
6+
export async function cmdMetrics(cwd: string, options: { config?: string; app?: string }): Promise<void> {
77
const config = await loadConfig(cwd, options.config);
8-
if (getActiveApp(config).appType !== 'backend' || !getDeploymentName(config)) {
8+
const app = options.app ? getActiveApp(config, options.app) : config.apps[0];
9+
10+
if (app.appType !== 'backend' || !getDeploymentName({ ...config, apps: [app] } as any)) {
911
throw new Error('Metrics only available for backend apps with PM2');
1012
}
13+
1114
const nodeVersion = config.nodeVersion === 'lts' ? '24' : config.nodeVersion;
1215
const mise = `export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:$PATH"`;
1316
const remoteCmd = `${mise}; mise exec "node@${nodeVersion}" -- pm2 monit`;

src/cli/commands/restart.ts

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,36 @@
11
import { runRemoteCommand } from '../runner.js';
22
import { ui } from '../ui.js';
33
import { getActiveApp } from '../../domain/workspace.js';
4-
import { getDeploymentName, resolveProcessTarget } from '../../domain/pm2/apps.js';
54

6-
export async function cmdRestart(cwd: string, options: { config?: string; process?: string }): Promise<void> {
5+
export async function cmdRestart(cwd: string, options: { config?: string; process?: string; app?: string }): Promise<void> {
76
await runRemoteCommand(
87
cwd,
98
async ({ config, executor }) => {
10-
const namespace = getDeploymentName(config);
11-
if (getActiveApp(config).appType !== 'backend' || !namespace) {
12-
throw new Error('Restart only available for backend apps with PM2');
9+
const apps = options.app
10+
? [getActiveApp(config, options.app)]
11+
: config.apps.filter((a) => a.appType === 'backend' && a.pm2);
12+
13+
if (apps.length === 0) {
14+
throw new Error('No backend apps with PM2 configured');
15+
}
16+
17+
if (options.process && apps.length > 1) {
18+
throw new Error('--process requires --app to target a specific app');
1319
}
14-
const target = options.process ? resolveProcessTarget(config, options.process) : namespace;
20+
1521
const nodeVersion = config.nodeVersion === 'lts' ? '24' : config.nodeVersion;
1622
const mise = `export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:$PATH"`;
17-
await executor.exec(
18-
`${mise}; mise exec "node@${nodeVersion}" -- pm2 reload ${target} --update-env`,
19-
);
20-
ui.success(options.process
21-
? `Process '${options.process}' restarted successfully`
22-
: `Deployment '${namespace}' restarted successfully`);
23+
24+
for (const app of apps) {
25+
const namespace = app.pm2!.apps[0].name;
26+
const target = options.process
27+
? app.pm2!.apps.find((a) => a.name === options.process)?.name ?? namespace
28+
: namespace;
29+
await executor.exec(
30+
`${mise}; mise exec "node@${nodeVersion}" -- pm2 reload ${target} --update-env`,
31+
);
32+
ui.success(`App '${app.name}' restarted successfully`);
33+
}
2334
},
2435
{ configPath: options.config },
2536
);

src/cli/commands/rollback.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,23 @@ import { ReleaseManager } from '../../domain/release/manager.js';
33
import { HealthCheckService } from '../../services/health.service.js';
44
import { ui } from '../ui.js';
55
import { confirm } from '../prompt.js';
6+
import { loadConfig } from '../../config/loader.js';
67
import { getActiveApp } from '../../domain/workspace.js';
78
import { getDeploymentName, getEcosystemPath } from '../../domain/pm2/apps.js';
89

910
export async function cmdRollback(
1011
cwd: string,
11-
options: { steps?: number; config?: string },
12+
options: { steps?: number; app?: string; config?: string },
1213
): Promise<void> {
14+
if (!options.app) {
15+
throw new Error(
16+
`rollback requires --app <name>. Available apps: ${(await loadConfig(cwd, options.config)).apps.map((a) => a.name).join(', ')}`,
17+
);
18+
}
1319
await runRemoteCommand(
1420
cwd,
1521
async ({ config, executor }) => {
16-
const app = getActiveApp(config);
22+
const app = getActiveApp(config, options.app);
1723
const appPath = `${config.remotePath}/${app.name}`;
1824
const releases = new ReleaseManager(executor, appPath, app.keepReleases);
1925
const stepsBack = options.steps ?? 1;

src/cli/commands/run.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { basename } from 'path';
22
import { execa } from 'execa';
33
import { loadConfig } from '../../config/loader.js';
4+
import { getActiveApp } from '../../domain/workspace.js';
45
import { SshConnection } from '../../infrastructure/ssh/connection.js';
56
import { ui } from '../ui.js';
67

@@ -12,11 +13,11 @@ function shellQuote(cmd: string): string {
1213

1314
export async function cmdRun(
1415
cwd: string,
15-
options: { tty?: boolean; config?: string },
16+
options: { tty?: boolean; config?: string; app?: string },
1617
cmdArgs: string[],
1718
): Promise<void> {
1819
if (cmdArgs.length === 0) {
19-
console.error('Usage: shipnode run [--tty] <command> [args...]');
20+
console.error('Usage: shipnode run [--tty] [--app <name>] <command> [args...]');
2021
console.error('');
2122
console.error('Examples:');
2223
console.error(' shipnode run node -e "console.log(process.version)"');
@@ -26,6 +27,7 @@ export async function cmdRun(
2627
}
2728

2829
const config = await loadConfig(cwd, options.config);
30+
const app = options.app ? getActiveApp(config, options.app) : config.apps[0];
2931

3032
const aliasExpansion = config.aliases?.[cmdArgs[0]];
3133
const resolvedArgs = aliasExpansion
@@ -34,17 +36,17 @@ export async function cmdRun(
3436

3537
const nodeVersion = config.nodeVersion === 'lts' ? '24' : config.nodeVersion;
3638
const mise = `export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:$PATH"`;
37-
const remotePath = config.remotePath;
39+
const appPath = `${config.remotePath}/${app.name}`;
3840
const cmd = resolvedArgs.join(' ');
3941
const cmdBase = basename(resolvedArgs[0]);
4042

4143
const isInteractive = options.tty === true || INTERACTIVE_SHELLS.has(cmdBase);
4244

43-
const sourceEnv = `if [ -f "${remotePath}/shared/.env" ]; then source "${remotePath}/shared/.env"; else echo "Warning: shared/.env not found" >&2; fi`;
45+
const sourceEnv = `if [ -f "${appPath}/shared/.env" ]; then source "${appPath}/shared/.env"; else echo "Warning: shared/.env not found" >&2; fi`;
4446

4547
const remoteCmd = [
4648
mise,
47-
`cd "${remotePath}/current"`,
49+
`cd "${appPath}/current"`,
4850
sourceEnv,
4951
`mise exec node@${nodeVersion} -- bash -lc ${shellQuote(cmd)}`,
5052
].join(' && ');

0 commit comments

Comments
 (0)