Skip to content

Commit 7c711ff

Browse files
committed
feat(builder): ShipnodeAppBuilder + .apps([]) workspace composition (sprint 2b)
Adds the public API surface for workspace multi-app declared in ADR 0004: - New ShipnodeAppBuilder class with the per-app methods (backend/ frontend/name/pm2/port/worker/domain/appRoot/envFile/healthCheck/ noHealthCheck/keepReleases/sharedDirs/sharedFiles/buildDir/preDeploy/ postDeploy). - shipnode.app() on the root builder returns a new ShipnodeAppBuilder. - Standalone app() factory exported from the package root, for composing apps outside the workspace expression. - shipnode.apps([api, web]) on the root builder accepts the array and hands it to assembleConfig as apps[]. Legacy single-app builders (every 2.x config) keep working unchanged because .apps([]) is not called and the schema's z.preprocess synthesizes apps[0] from the legacy top-level fields. When .apps([]) IS called, those apps win over any per-app methods called on the root builder (apps wins; mixing is supported but discouraged). 4 new tests in builder.test.ts cover: factory equivalence, multi-app composition, workers in app builders, frontend+pm2 refine rejection. 199 tests pass. No downstream consumer migrated yet — that's sprint 2c.
1 parent 6190344 commit 7c711ff

3 files changed

Lines changed: 240 additions & 4 deletions

File tree

src/config/builder.ts

Lines changed: 150 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type {
22
ShipnodeConfig,
3+
ShipnodeApp,
34
SshConfig,
45
Pm2App,
56
HealthCheckConfig,
@@ -12,7 +13,12 @@ import type {
1213
} from '../shared/types.js';
1314
import { assembleConfig } from './assembly.js';
1415

15-
type BuilderState = Omit<Partial<ShipnodeConfig>, 'pm2'> & {
16+
type BuilderState = Omit<Partial<ShipnodeConfig>, 'pm2' | 'apps'> & {
17+
pm2?: { apps: Pm2App[] };
18+
apps?: Partial<ShipnodeApp>[];
19+
};
20+
21+
type AppBuilderState = Omit<Partial<ShipnodeApp>, 'pm2'> & {
1622
pm2?: { apps: Pm2App[] };
1723
};
1824

@@ -179,13 +185,156 @@ export class ShipnodeBuilder {
179185
return this;
180186
}
181187

188+
/**
189+
* Start a new per-app sub-builder. Pair with `.apps([api, web])` on the workspace
190+
* builder to declare a multi-app deployment. See docs/adr/0004-workspace-multi-app.md.
191+
*/
192+
app(): ShipnodeAppBuilder {
193+
return new ShipnodeAppBuilder();
194+
}
195+
196+
/**
197+
* Declare the apps that compose this workspace. Each app gets its own release
198+
* directory, PM2 process group, Caddy site, and Cloudflare ingress entry. When
199+
* `.apps([])` is called, the per-app methods on this root builder (.pm2/.port/
200+
* .worker/.domain/.healthCheck/.preDeploy/.postDeploy/.appRoot/.envFile/etc.) are
201+
* ignored — the apps declared here take over. When `.apps([])` is not called, the
202+
* per-app methods continue to write to an implicit single default app (legacy 2.x
203+
* behavior). Mixing both is supported but `.apps([])` wins.
204+
*/
205+
apps(apps: ShipnodeAppBuilder[]): this {
206+
this.config.apps = apps.map((b) => b.toApp());
207+
return this;
208+
}
209+
182210
build(): ShipnodeConfig {
183211
return assembleConfig(this.config);
184212
}
185213
}
186214

215+
/**
216+
* Per-app builder. Created via `shipnode.app()` or the standalone `app()` factory.
217+
* Mirrors the per-app subset of the workspace builder. Pass the result to
218+
* `shipnode.apps([...])` on the workspace builder to compose a multi-app deployment.
219+
*/
220+
export class ShipnodeAppBuilder {
221+
private state: AppBuilderState = {};
222+
223+
private firstPm2App(): Pm2App {
224+
if (!this.state.pm2) this.state.pm2 = { apps: [] };
225+
if (this.state.pm2.apps.length === 0) this.state.pm2.apps.push({ name: this.state.name ?? 'app' });
226+
return this.state.pm2.apps[0];
227+
}
228+
229+
backend(): this {
230+
this.state.appType = 'backend';
231+
return this;
232+
}
233+
234+
frontend(): this {
235+
this.state.appType = 'frontend';
236+
return this;
237+
}
238+
239+
name(n: string): this {
240+
this.state.name = n;
241+
return this;
242+
}
243+
244+
pm2(name: string, opts?: { instances?: number; maxMemory?: string }): this {
245+
const app = this.firstPm2App();
246+
app.name = name;
247+
if (opts?.instances !== undefined) app.instances = opts.instances;
248+
if (opts?.maxMemory !== undefined) app.maxMemory = opts.maxMemory;
249+
return this;
250+
}
251+
252+
port(n: number): this {
253+
this.firstPm2App().port = n;
254+
return this;
255+
}
256+
257+
worker(opts: WorkerOptions): this {
258+
if (!this.state.pm2) this.state.pm2 = { apps: [] };
259+
this.state.pm2.apps.push({ ...opts });
260+
return this;
261+
}
262+
263+
domain(d: string): this {
264+
this.state.domain = d;
265+
return this;
266+
}
267+
268+
appRoot(dir: string): this {
269+
this.state.appRoot = dir;
270+
return this;
271+
}
272+
273+
envFile(f: string): this {
274+
this.state.envFile = f;
275+
return this;
276+
}
277+
278+
keepReleases(n: number): this {
279+
this.state.keepReleases = n;
280+
return this;
281+
}
282+
283+
sharedDirs(dirs: string[]): this {
284+
this.state.sharedDirs = dirs;
285+
return this;
286+
}
287+
288+
sharedFiles(files: string[]): this {
289+
this.state.sharedFiles = files;
290+
return this;
291+
}
292+
293+
buildDir(dir: string): this {
294+
this.state.buildDir = dir;
295+
return this;
296+
}
297+
298+
healthCheck(path: string, opts?: Partial<HealthCheckConfig>): this {
299+
this.state.healthCheck = {
300+
...(this.state.healthCheck ?? {}),
301+
enabled: true,
302+
path,
303+
timeout: opts?.timeout ?? 30,
304+
retries: opts?.retries ?? 3,
305+
startupDelay: opts?.startupDelay ?? 3,
306+
};
307+
return this;
308+
}
309+
310+
noHealthCheck(): this {
311+
this.state.healthCheck = { enabled: false, path: '/health', timeout: 30, retries: 3, startupDelay: 3 };
312+
return this;
313+
}
314+
315+
preDeploy(fn: HookFn): this {
316+
this.state.hooks = { ...(this.state.hooks ?? {}), preDeploy: fn };
317+
return this;
318+
}
319+
320+
postDeploy(fn: HookFn): this {
321+
this.state.hooks = { ...(this.state.hooks ?? {}), postDeploy: fn };
322+
return this;
323+
}
324+
325+
/** Internal: hand off the accumulated state to the workspace builder. */
326+
toApp(): Partial<ShipnodeApp> {
327+
return this.state as Partial<ShipnodeApp>;
328+
}
329+
}
330+
187331
export const shipnode = new ShipnodeBuilder();
188332

333+
/** Standalone factory for a per-app sub-builder. Equivalent to `shipnode.app()`. */
334+
export function app(): ShipnodeAppBuilder {
335+
return new ShipnodeAppBuilder();
336+
}
337+
189338
export function defineConfig(builder: ShipnodeBuilder): ShipnodeBuilder {
190339
return builder;
191340
}

src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
export { shipnode, defineConfig } from './config/builder.js';
2-
export type { ShipnodeConfig, SshConfig, Pm2Config, Pm2App, HealthCheckConfig, DatabaseConfig, HookContext, HookFn, AppType, PkgManager } from './shared/types.js';
1+
export { shipnode, app, defineConfig, ShipnodeAppBuilder } from './config/builder.js';
2+
export type { ShipnodeConfig, ShipnodeApp, SshConfig, Pm2Config, Pm2App, HealthCheckConfig, DatabaseConfig, HookContext, HookFn, AppType, PkgManager } from './shared/types.js';
33
export { loadConfig } from './config/loader.js';
44
export { detectFramework, detectPkgManager, parsePackageJson } from './domain/framework/detector.js';
55
export type { FrameworkDetectionResult } from './shared/types.js';

tests/unit/builder.test.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, vi } from 'vitest';
2-
import { shipnode, ShipnodeBuilder } from '../../src/config/builder.js';
2+
import { shipnode, ShipnodeBuilder, ShipnodeAppBuilder, app } from '../../src/config/builder.js';
33

44
describe('ShipnodeBuilder', () => {
55
// Schema-coverage regression: every setter on the builder must produce a field that
@@ -324,3 +324,90 @@ describe('ShipnodeBuilder', () => {
324324
expect(config.cloudflare?.lockdownFirewall).toBe(true);
325325
});
326326
});
327+
328+
describe('ShipnodeAppBuilder + workspace .apps([])', () => {
329+
it('shipnode.app() and the standalone app() factory both produce a ShipnodeAppBuilder', () => {
330+
expect(shipnode.app()).toBeInstanceOf(ShipnodeAppBuilder);
331+
expect(app()).toBeInstanceOf(ShipnodeAppBuilder);
332+
});
333+
334+
it('composes a multi-app workspace via .apps([api, web])', () => {
335+
const api = app()
336+
.backend()
337+
.name('api')
338+
.appRoot('apps/backend')
339+
.domain('api.example.com')
340+
.pm2('api')
341+
.port(3333)
342+
.envFile('.env.production')
343+
.postDeploy(vi.fn());
344+
345+
const web = app()
346+
.backend()
347+
.name('web')
348+
.appRoot('apps/frontend')
349+
.domain('example.com')
350+
.pm2('web')
351+
.port(3000);
352+
353+
const config = new ShipnodeBuilder()
354+
.ssh({ host: '1.2.3.4', user: 'root' })
355+
.deployTo('/var/www/example')
356+
.nodeVersion('24')
357+
.apps([api, web])
358+
.build();
359+
360+
expect(config.apps).toHaveLength(2);
361+
expect(config.apps[0]).toMatchObject({
362+
name: 'api',
363+
appType: 'backend',
364+
appRoot: 'apps/backend',
365+
domain: 'api.example.com',
366+
envFile: '.env.production',
367+
});
368+
expect(config.apps[0].pm2?.apps[0]).toMatchObject({ name: 'api', port: 3333 });
369+
expect(config.apps[1]).toMatchObject({
370+
name: 'web',
371+
appType: 'backend',
372+
appRoot: 'apps/frontend',
373+
domain: 'example.com',
374+
});
375+
expect(config.apps[1].pm2?.apps[0]).toMatchObject({ name: 'web', port: 3000 });
376+
// Legacy top-level mirrors point to apps[0]
377+
expect(config.domain).toBe('api.example.com');
378+
expect(config.appRoot).toBe('apps/backend');
379+
});
380+
381+
it('app builder collects workers alongside the web app', () => {
382+
const api = app()
383+
.backend()
384+
.name('api')
385+
.pm2('api')
386+
.port(3333)
387+
.worker({ name: 'mailer', command: 'node dist/mailer.js' })
388+
.worker({ name: 'queue', command: 'node dist/queue.js' });
389+
390+
const config = new ShipnodeBuilder()
391+
.ssh({ host: '1.2.3.4', user: 'root' })
392+
.deployTo('/var/www/app')
393+
.apps([api])
394+
.build();
395+
396+
expect(config.apps[0].pm2?.apps).toHaveLength(3);
397+
expect(config.apps[0].pm2?.apps.map((a) => a.name)).toEqual(['api', 'mailer', 'queue']);
398+
});
399+
400+
it('frontend app cannot declare pm2 (refine on ShipnodeAppSchema)', () => {
401+
const bad = app()
402+
.frontend()
403+
.name('web')
404+
.pm2('web')
405+
.port(3000);
406+
407+
expect(() => new ShipnodeBuilder()
408+
.ssh({ host: '1.2.3.4', user: 'root' })
409+
.deployTo('/var/www/app')
410+
.apps([bad])
411+
.build()).toThrow();
412+
});
413+
});

0 commit comments

Comments
 (0)