Skip to content

Commit 8f7ffc5

Browse files
committed
fix: install devDependencies on the server so builds work
Default install commands for npm, yarn, and bun dropped the --production flag — without devDependencies the build step (tsc, vite, tsup) fails. pnpm was already correct; this completes the same fix for the others. Adds .installCommand(cmd) and .pkgManager(pm, { installCommand }) for users who need different flags (e.g. --legacy-peer-deps). The override applies to both the initial install and the post-symlink relink.
1 parent 99c068d commit 8f7ffc5

12 files changed

Lines changed: 95 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@ All notable changes to `@devalade/shipnode` will be documented here.
1515
- **PM2 status check** — after every deploy, `pm2 jlist` is parsed and each declared app must be `online` with `restart_time === 0`. Catches workers that boot and crash before the HTTP health check would notice anything. Runs even on worker-only deployments.
1616
- **Per-app `env`** — each `pm2.apps` entry can set its own env vars (`{ WORKER_QUEUE: 'emails' }`); PM2 loads the shared `.env` via `env_file` and applies per-app overrides on top.
1717

18+
### Fixed
19+
- **Builds no longer fail because devDependencies are missing.** The default install commands for `npm`, `yarn`, and `bun` no longer pass `--production` / `--frozen-lockfile --production` — they now install everything so the subsequent build step has access to `tsc`, `vite`, `tsup`, etc. (pnpm was already fixed for this in v2.0.13; this completes the same fix for the other package managers.)
20+
21+
### Added
22+
- Two ways to override the server-side install command when you need flags the default doesn't carry (e.g. `'npm ci --legacy-peer-deps'`):
23+
- `.installCommand(cmd)` — standalone builder method.
24+
- `.pkgManager(pm, { installCommand: cmd })` — second-arg form, useful when you're already pinning the package manager.
25+
Both write to the same field. Override applies to both the initial install and the post-symlink relink; `--prefer-offline` is not appended to overridden commands.
26+
1827
### Changed
1928
- **Ecosystem file is per-release**`ecosystem.config.cjs` now lives inside each release directory instead of `<remotePath>/shared/`. PM2 references it via `<remotePath>/current/ecosystem.config.cjs` so rollback restores the exact process set that was active for that release (a worker added in v2 won't crash-loop after rolling back to v1). See [ADR-0001](docs/adr/0001-per-release-ecosystem-file.md).
2029
- **Builder back-compat preserved**`.pm2(name, opts)` and `.port(n)` still work and now act as sugar on the first app. Existing `shipnode.config.ts` files don't need changes.

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,8 @@ export default shipnode
103103
| `.worker({ name, command, ... })` || Add a worker process supervised alongside the web app |
104104
| `.domain(d)` || Domain for Caddy config |
105105
| `.nodeVersion(v)` | `lts` | Node version (via mise) |
106-
| `.pkgManager(pm)` | auto-detected | `npm` \| `yarn` \| `pnpm` \| `bun` |
106+
| `.pkgManager(pm, opts?)` | auto-detected | `npm` \| `yarn` \| `pnpm` \| `bun`; `opts.installCommand` overrides the install command |
107+
| `.installCommand(cmd)` | derived from pkg manager | Override the install command run on the server (e.g. `'npm ci --legacy-peer-deps'`). Equivalent to `pkgManager(pm, { installCommand: cmd })` |
107108
| `.buildDir(dir)` | auto-detected | Frontend build output dir |
108109
| `.zeroDowntime({ keepReleases? })` | true, 5 | Zero-downtime releases |
109110
| `.legacy()` || Simple in-place deploy |

src/config/assembly.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export function assembleConfig(partial: AssembleInput): ShipnodeConfig {
8080
envFile: partial.envFile ?? DEFAULTS.ENV_FILE,
8181
nodeVersion: partial.nodeVersion ?? DEFAULTS.NODE_VERSION,
8282
pkgManager: partial.pkgManager,
83+
installCommand: partial.installCommand,
8384
sharedDirs: partial.sharedDirs,
8485
sharedFiles: partial.sharedFiles,
8586
buildDir: partial.buildDir,

src/config/builder.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,14 @@ export class ShipnodeBuilder {
123123
return this;
124124
}
125125

126-
pkgManager(pm: PkgManager): this {
126+
pkgManager(pm: PkgManager, opts?: { installCommand?: string }): this {
127127
this.config.pkgManager = pm;
128+
if (opts?.installCommand !== undefined) this.config.installCommand = opts.installCommand;
129+
return this;
130+
}
131+
132+
installCommand(cmd: string): this {
133+
this.config.installCommand = cmd;
128134
return this;
129135
}
130136

src/config/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ export const ShipnodeConfigSchema = z.object({
9292
envFile: z.string().default('.env'),
9393
nodeVersion: z.string().default('lts'),
9494
pkgManager: z.enum(['npm', 'yarn', 'pnpm', 'bun']).optional(),
95+
installCommand: z.string().min(1).optional(),
9596
buildDir: z.string().optional(),
9697
sharedDirs: z.array(z.string()).optional(),
9798
sharedFiles: z.array(z.string()).optional(),

src/domain/deploy/backend-strategy.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export class BackendStrategy implements DeploymentStrategy {
4848

4949
async setupEnvironment(ctx: StrategyContext): Promise<void> {
5050
const pkgManager = await this.resolvePkgManager();
51-
const installCmd = getInstallCommand(pkgManager);
51+
const installCmd = this.config.installCommand ?? getInstallCommand(pkgManager);
5252
const runCmd = getRunCommand(pkgManager);
5353

5454
const commands = [
@@ -110,9 +110,12 @@ export class BackendStrategy implements DeploymentStrategy {
110110
// Re-run install from the final directory so the pkg manager's module
111111
// resolution state matches the path PM2 will use. Packages are already
112112
// in the local store so this is a fast offline relink, not a download.
113-
const installCmd = getInstallCommand(pkgManager);
113+
// If the user supplied a custom installCommand we use it verbatim — they've
114+
// chosen their flags and appending --prefer-offline would compose poorly.
115+
const baseInstall = this.config.installCommand ?? getInstallCommand(pkgManager);
116+
const relinkInstall = this.config.installCommand ? baseInstall : `${baseInstall} --prefer-offline`;
114117
const installResult = await ctx.executor.exec(
115-
`cd "${cdPath}" && ${mise} && ${installCmd} --prefer-offline`,
118+
`cd "${cdPath}" && ${mise} && ${relinkInstall}`,
116119
);
117120
this.assertNoBuildScriptsIgnored(pkgManager, installResult);
118121
if (installResult.exitCode !== 0) {

src/domain/framework/detector.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ async function detectOrm(cwd: string): Promise<string | undefined> {
114114
}
115115

116116
export function getInstallCommand(pkgManager: PkgManager): string {
117-
return PKG_MANAGER_COMMANDS[pkgManager]?.install ?? 'npm ci --production';
117+
return PKG_MANAGER_COMMANDS[pkgManager]?.install ?? 'npm ci';
118118
}
119119

120120
export function getRunCommand(pkgManager: PkgManager): string {

src/shared/constants.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,11 @@ export const ORM_PATTERNS = {
8080
} as const;
8181

8282
export const PKG_MANAGER_COMMANDS: Record<string, { install: string; run: string }> = {
83-
npm: { install: 'npm ci --production', run: 'npm run' },
84-
yarn: { install: 'yarn install --production', run: 'yarn' },
83+
// No --production / --omit=dev flags: the install runs on the server and the
84+
// build step that follows needs devDependencies (tsc, vite, tsup, …). Users
85+
// who want a different install can override via .installCommand(cmd).
86+
npm: { install: 'npm ci', run: 'npm run' },
87+
yarn: { install: 'yarn install --frozen-lockfile', run: 'yarn' },
8588
pnpm: { install: 'pnpm install', run: 'pnpm' },
86-
bun: { install: 'bun install --production', run: 'bun run' },
89+
bun: { install: 'bun install', run: 'bun run' },
8790
} as const;

src/shared/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ export interface ShipnodeConfig {
111111
envFile: string;
112112
nodeVersion: string;
113113
pkgManager?: PkgManager;
114+
/** Override the install command run on the server. Defaults to the package manager's standard install (e.g. `npm ci`). Use to add flags like `--legacy-peer-deps`, switch to a frozen-lockfile variant, etc. */
115+
installCommand?: string;
114116
buildDir?: string;
115117
sharedDirs?: string[];
116118
sharedFiles?: string[];

tests/unit/backend-strategy.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,17 @@ describe('BackendStrategy.setupEnvironment', () => {
115115
expect(cmd).toContain('npm ci');
116116
});
117117

118+
it('uses the custom installCommand override when set', async () => {
119+
const config = makeConfig({ pkgManager: 'npm', installCommand: 'npm ci --legacy-peer-deps' });
120+
const strategy = new BackendStrategy(config, '/local/project');
121+
const executor = new FakeRemoteExecutor();
122+
await strategy.setupEnvironment!(makeCtx(executor, { config }));
123+
124+
const cmd = executor.getLastCommand()!.command;
125+
expect(cmd).toContain('npm ci --legacy-peer-deps');
126+
expect(cmd).not.toContain('npm ci\n'); // not the default
127+
});
128+
118129
it('uses pnpm install when pkgManager is pnpm', async () => {
119130
const strategy = new BackendStrategy(makeConfig({ pkgManager: 'pnpm' }), '/local/project');
120131
const executor = new FakeRemoteExecutor();
@@ -312,6 +323,17 @@ describe('BackendStrategy.startApp', () => {
312323
expect(writeCmd).toContain('api-worker');
313324
});
314325

326+
it('uses the custom installCommand on the post-symlink relink (no --prefer-offline appended)', async () => {
327+
const config = makeConfig({ pkgManager: 'npm', installCommand: 'npm ci --legacy-peer-deps' });
328+
const strategy = new BackendStrategy(config, '/local/project');
329+
const executor = new FakeRemoteExecutor();
330+
await strategy.startApp!(makeCtx(executor, { config }));
331+
332+
const relinkCmd = executor.getHistory()[1].command;
333+
expect(relinkCmd).toContain('npm ci --legacy-peer-deps');
334+
expect(relinkCmd).not.toContain('--prefer-offline');
335+
});
336+
315337
it('emits the silent legacy-name pm2 delete fallback', async () => {
316338
const strategy = new BackendStrategy(makeConfig({ pm2: { name: 'myapp' } }), '/local/project');
317339
const executor = new FakeRemoteExecutor();

0 commit comments

Comments
 (0)