Skip to content

Commit 6190344

Browse files
committed
feat(config): introduce ShipnodeApp shape and apps[] (sprint 2a)
Lays the foundation for the workspace multi-app design from ADR 0004. The canonical config now carries an apps[] array (one ShipnodeApp per deploy unit) in addition to the legacy top-level per-app fields, which mirror apps[0] for downstream compat during the 3.0 transition. Key changes: - ShipnodeAppSchema (new): name, appType, appRoot, domain, pm2, healthCheck, envFile, keepReleases, sharedDirs, sharedFiles, buildDir, hooks. Refines moved here (frontend cannot declare pm2; domain requires a web app). - ShipnodeConfigSchema: workspace-level fields (ssh, remotePath, nodeVersion, pkgManager, installCommand, database, redis, backup, cloudflare, aliases) + apps[] + legacy top-level mirrors. - z.preprocess wraps the schema to synthesize apps[0] from legacy top-level fields when the input doesn't carry apps. Lets every 2.x config (including direct schema.parse calls) work unmodified. - assembleConfig: post-parse mirror forces top-level fields to match apps[0], so the canonical output is internally consistent. The builder still only sets legacy fields; sprint 2b adds .app() and .apps([]). Downstream code still reads from legacy fields; sprint 2c will migrate it to read from apps[]. 195 tests pass (3 new in assembly.test.ts covering apps[] population).
1 parent 779c665 commit 6190344

4 files changed

Lines changed: 208 additions & 30 deletions

File tree

src/config/assembly.ts

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ShipnodeConfig, Pm2App, Pm2Config } from '../shared/types.js';
1+
import type { ShipnodeConfig, Pm2App, Pm2Config, ShipnodeApp } from '../shared/types.js';
22
import { ShipnodeConfigSchema } from './schema.js';
33

44
// Loose input shape: also accepts the pre-multi-process `pm2: { name, instances, maxMemory }`
@@ -10,9 +10,10 @@ type LegacyPm2Input = {
1010
apps?: Pm2App[];
1111
};
1212

13-
type AssembleInput = Omit<Partial<ShipnodeConfig>, 'pm2'> & {
13+
type AssembleInput = Omit<Partial<ShipnodeConfig>, 'pm2' | 'apps'> & {
1414
pm2?: LegacyPm2Input | Pm2Config;
1515
backend?: { port?: number };
16+
apps?: Partial<ShipnodeApp>[];
1617
};
1718

1819
function normalizePm2(
@@ -35,14 +36,37 @@ function normalizePm2(
3536
/**
3637
* Assemble a partial config into a fully-validated ShipnodeConfig.
3738
*
38-
* The schema is the single source of truth: all defaults, validation, and refinements
39-
* live there. assembleConfig only does what the schema cannot — normalize the legacy
40-
* pm2/backend input shape onto canonical pm2.apps — then hands the rest to zod parse.
41-
* Every field declared in the schema is preserved automatically; adding a new field
42-
* to the schema (and its corresponding builder method) requires no change here.
39+
* The schema is the single source of truth — it knows about defaults, refinements, and
40+
* the legacy-fields-to-apps[0] synthesis (via its z.preprocess wrapper). assembleConfig
41+
* only does what the schema cannot:
42+
*
43+
* 1. Normalize the legacy `pm2: { name }` input shape onto canonical `pm2.apps`.
44+
* 2. After parse, mirror `apps[0].<field>` back onto the legacy top-level fields so
45+
* downstream code still reading `config.domain`, `config.pm2`, etc. keeps working
46+
* during the 3.0 transition. Sprint 2c will migrate downstream consumers to read
47+
* from `apps[]`, after which the mirror can be removed.
4348
*/
4449
export function assembleConfig(partial: AssembleInput): ShipnodeConfig {
4550
const { backend, ...rest } = partial;
46-
const pm2 = normalizePm2(partial.pm2, backend);
47-
return ShipnodeConfigSchema.parse({ ...rest, pm2 }) as ShipnodeConfig;
51+
const pm2 = normalizePm2(rest.pm2, backend);
52+
53+
const parsed = ShipnodeConfigSchema.parse({ ...rest, pm2 });
54+
55+
// Force legacy top-level mirrors to match apps[0]: when the user mixed both shapes,
56+
// apps wins; when the user only used legacy top-level fields, this is a no-op.
57+
const first = parsed.apps[0];
58+
return {
59+
...parsed,
60+
app: first.appType,
61+
pm2: first.pm2,
62+
domain: first.domain,
63+
healthCheck: first.healthCheck,
64+
envFile: first.envFile,
65+
keepReleases: first.keepReleases,
66+
sharedDirs: first.sharedDirs,
67+
sharedFiles: first.sharedFiles,
68+
buildDir: first.buildDir,
69+
appRoot: first.appRoot,
70+
hooks: first.hooks,
71+
} as ShipnodeConfig;
4872
}

src/config/schema.ts

Lines changed: 89 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -89,28 +89,75 @@ export const HooksConfigSchema = z.object({
8989
postDeploy: HookFnSchema.optional(),
9090
}).optional();
9191

92-
export const ShipnodeConfigSchema = z.object({
93-
app: z.enum(['backend', 'frontend']).default('backend'),
94-
ssh: SshConfigSchema,
95-
remotePath: z.string().min(1, 'Remote path is required').default('/var/www/app'),
96-
pm2: Pm2ConfigSchema.optional(),
92+
// Per-app fields — anything that differs between two apps deployed to the same server.
93+
// Workspace-level fields (ssh, deployTo, nodeVersion, pkgManager, cloudflare, database,
94+
// redis, backup, aliases) live on ShipnodeConfigSchema. See docs/adr/0004-workspace-multi-app.md
95+
// for the rationale of the split.
96+
export const ShipnodeAppSchema = z.object({
97+
name: z.string().refine(isValidPm2Name, 'app name must be alphanumeric, dash, or underscore (max 64 chars)').default('app'),
98+
appType: z.enum(['backend', 'frontend']).default('backend'),
99+
appRoot: z.string().optional(),
97100
domain: z.string().refine(isValidDomain, 'Must be a valid domain (no protocol)').optional(),
98-
keepReleases: z.number().int().min(1).default(5),
101+
pm2: Pm2ConfigSchema.optional(),
99102
healthCheck: HealthCheckConfigSchema,
100103
envFile: z.string().default('.env'),
104+
keepReleases: z.number().int().min(1).default(5),
105+
sharedDirs: z.array(z.string()).optional(),
106+
sharedFiles: z.array(z.string()).optional(),
107+
buildDir: z.string().optional(),
108+
hooks: HooksConfigSchema,
109+
}).refine(
110+
(cfg) => !(cfg.appType === 'frontend' && cfg.pm2),
111+
{ message: 'frontend apps cannot declare pm2 (frontends are static files served by Caddy)', path: ['pm2'] },
112+
).refine(
113+
(cfg) => {
114+
if (!cfg.domain || cfg.appType !== 'backend') return true;
115+
const hasWebApp = cfg.pm2?.apps.some((a) => a.port !== undefined);
116+
return hasWebApp ?? false;
117+
},
118+
{ message: 'domain requires a web app: one pm2.apps entry must declare a port', path: ['domain'] },
119+
);
120+
121+
// Dual shape during 3.0 transition: the canonical config carries BOTH `apps[]` (the new
122+
// workspace shape) AND the legacy top-level per-app fields (app/domain/pm2/healthCheck/
123+
// envFile/keepReleases/sharedDirs/sharedFiles/buildDir/appRoot/hooks). Downstream code
124+
// reading the legacy fields keeps working unchanged; new code reads from `apps[]`.
125+
// Sprint 2c/2d will migrate downstream and drop the legacy fields.
126+
//
127+
// A z.preprocess wrapper synthesizes `apps[0]` from the legacy top-level fields when
128+
// the input doesn't carry `apps`. This lets every 2.x config (including the existing
129+
// schema.test.ts cases that call ShipnodeConfigSchema.safeParse directly) parse without
130+
// modification. assembleConfig then post-processes to mirror apps[0] back onto the
131+
// legacy top-level fields, so the canonical output is internally consistent.
132+
const ShipnodeConfigBaseSchema = z.object({
133+
// workspace-level
134+
ssh: SshConfigSchema,
135+
remotePath: z.string().min(1, 'Remote path is required').default('/var/www/app'),
101136
nodeVersion: z.string().default('lts'),
102137
pkgManager: z.enum(['npm', 'yarn', 'pnpm', 'bun']).optional(),
103138
installCommand: z.string().min(1).optional(),
104-
buildDir: z.string().optional(),
105-
appRoot: z.string().optional(),
106-
sharedDirs: z.array(z.string()).optional(),
107-
sharedFiles: z.array(z.string()).optional(),
108139
database: DatabaseConfigSchema,
109140
redis: RedisConfigSchema,
110141
backup: BackupConfigSchema,
111142
cloudflare: CloudflareConfigSchema,
112-
hooks: HooksConfigSchema,
113143
aliases: z.record(z.string(), z.string()).optional(),
144+
145+
// canonical app list (always populated by assembleConfig; .min(1) enforced)
146+
apps: z.array(ShipnodeAppSchema).min(1, 'workspace must contain at least one app'),
147+
148+
// legacy top-level mirrors — kept during 3.0 transition for downstream compat.
149+
// Always equal to apps[0].<field> after assembleConfig runs.
150+
app: z.enum(['backend', 'frontend']).default('backend'),
151+
domain: z.string().refine(isValidDomain, 'Must be a valid domain (no protocol)').optional(),
152+
pm2: Pm2ConfigSchema.optional(),
153+
healthCheck: HealthCheckConfigSchema,
154+
envFile: z.string().default('.env'),
155+
keepReleases: z.number().int().min(1).default(5),
156+
sharedDirs: z.array(z.string()).optional(),
157+
sharedFiles: z.array(z.string()).optional(),
158+
buildDir: z.string().optional(),
159+
appRoot: z.string().optional(),
160+
hooks: HooksConfigSchema,
114161
}).refine(
115162
(cfg) => !(cfg.app === 'frontend' && cfg.pm2),
116163
{ message: 'frontend apps cannot declare pm2 (frontends are static files served by Caddy)', path: ['pm2'] },
@@ -123,4 +170,35 @@ export const ShipnodeConfigSchema = z.object({
123170
{ message: 'domain requires a web app: one pm2.apps entry must declare a port', path: ['domain'] },
124171
);
125172

173+
export const ShipnodeConfigSchema = z.preprocess(
174+
(input: unknown) => {
175+
if (typeof input !== 'object' || input === null || Array.isArray(input)) return input;
176+
const obj = input as Record<string, unknown>;
177+
if (obj.apps !== undefined) return obj;
178+
179+
// Synthesize apps[0] from legacy top-level fields. The name defaults to the first
180+
// pm2.apps[].name if there is one (so .pm2('biormin') → app.name === 'biormin').
181+
const pm2 = obj.pm2 as { apps?: Array<{ name?: string }> } | undefined;
182+
const pm2Name = pm2?.apps?.[0]?.name;
183+
return {
184+
...obj,
185+
apps: [{
186+
name: pm2Name ?? 'app',
187+
appType: obj.app,
188+
appRoot: obj.appRoot,
189+
domain: obj.domain,
190+
pm2: obj.pm2,
191+
healthCheck: obj.healthCheck,
192+
envFile: obj.envFile,
193+
keepReleases: obj.keepReleases,
194+
sharedDirs: obj.sharedDirs,
195+
sharedFiles: obj.sharedFiles,
196+
buildDir: obj.buildDir,
197+
hooks: obj.hooks,
198+
}],
199+
};
200+
},
201+
ShipnodeConfigBaseSchema,
202+
);
203+
126204
export type ShipnodeConfigSchema = z.infer<typeof ShipnodeConfigSchema>;

src/shared/types.ts

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -100,19 +100,53 @@ export interface HooksConfig {
100100
postDeploy?: HookFn;
101101
}
102102

103+
/**
104+
* A single deployment unit (a "deploy unit" in ADR 0004 terminology). A workspace
105+
* declares one or more of these; each has its own release directory, PM2 process
106+
* group, Caddy site, and Cloudflare ingress entry.
107+
*/
108+
export interface ShipnodeApp {
109+
name: string;
110+
appType: AppType;
111+
appRoot?: string;
112+
domain?: string;
113+
pm2?: Pm2Config;
114+
healthCheck: HealthCheckConfig;
115+
envFile: string;
116+
keepReleases: number;
117+
sharedDirs?: string[];
118+
sharedFiles?: string[];
119+
buildDir?: string;
120+
hooks?: HooksConfig;
121+
}
122+
103123
export interface ShipnodeConfig {
104-
app: AppType;
124+
// workspace-level
105125
ssh: SshConfig;
106126
remotePath: string;
127+
nodeVersion: string;
128+
pkgManager?: PkgManager;
129+
/** 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. */
130+
installCommand?: string;
131+
database?: DatabaseConfig;
132+
redis?: RedisConfig;
133+
backup?: BackupConfig;
134+
cloudflare?: CloudflareConfig;
135+
aliases?: Record<string, string>;
136+
137+
/** Canonical app list. Always populated by assembleConfig (length >= 1). */
138+
apps: ShipnodeApp[];
139+
140+
// Legacy top-level mirrors of apps[0].<field>. Kept during the 3.0 transition so
141+
// downstream code reading these fields directly keeps working. New code should
142+
// read from apps[]. Sprint 2c will migrate the downstream consumers, after which
143+
// these mirrors can be removed.
144+
app: AppType;
107145
pm2?: Pm2Config;
108146
domain?: string;
109147
keepReleases: number;
110148
healthCheck: HealthCheckConfig;
111149
envFile: string;
112-
nodeVersion: string;
113-
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;
116150
buildDir?: string;
117151
/**
118152
* Path (relative to the repo root) of the app within a monorepo whose
@@ -125,12 +159,7 @@ export interface ShipnodeConfig {
125159
appRoot?: string;
126160
sharedDirs?: string[];
127161
sharedFiles?: string[];
128-
database?: DatabaseConfig;
129-
redis?: RedisConfig;
130-
backup?: BackupConfig;
131-
cloudflare?: CloudflareConfig;
132162
hooks?: HooksConfig;
133-
aliases?: Record<string, string>;
134163
}
135164

136165
export interface ReleaseRecord {

tests/unit/assembly.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,53 @@ describe('assembleConfig', () => {
9191
})).toThrow();
9292
});
9393

94+
it('synthesizes apps[0] from legacy top-level fields', () => {
95+
const config = assembleConfig({
96+
app: 'backend',
97+
ssh: { host: '1.2.3.4', user: 'deploy' },
98+
remotePath: '/var/www/app',
99+
pm2: { apps: [{ name: 'api', port: 3000 }] },
100+
domain: 'api.example.com',
101+
appRoot: 'apps/backend',
102+
envFile: '.env.production',
103+
});
104+
expect(config.apps).toHaveLength(1);
105+
expect(config.apps[0]).toMatchObject({
106+
name: 'api',
107+
appType: 'backend',
108+
domain: 'api.example.com',
109+
appRoot: 'apps/backend',
110+
envFile: '.env.production',
111+
});
112+
expect(config.apps[0].pm2?.apps[0].name).toBe('api');
113+
});
114+
115+
it('app.name defaults to "app" when no pm2 is declared', () => {
116+
const config = assembleConfig({
117+
app: 'frontend',
118+
ssh: { host: '1.2.3.4', user: 'deploy' },
119+
remotePath: '/var/www/static',
120+
});
121+
expect(config.apps[0].name).toBe('app');
122+
expect(config.apps[0].appType).toBe('frontend');
123+
});
124+
125+
it('legacy top-level mirrors are kept in sync with apps[0]', () => {
126+
const config = assembleConfig({
127+
app: 'backend',
128+
ssh: { host: '1.2.3.4', user: 'deploy' },
129+
remotePath: '/var/www/app',
130+
pm2: { apps: [{ name: 'api', port: 3000 }] },
131+
domain: 'api.example.com',
132+
envFile: '.env.production',
133+
});
134+
expect(config.app).toBe(config.apps[0].appType);
135+
expect(config.domain).toBe(config.apps[0].domain);
136+
expect(config.envFile).toBe(config.apps[0].envFile);
137+
expect(config.pm2).toBe(config.apps[0].pm2);
138+
expect(config.healthCheck).toEqual(config.apps[0].healthCheck);
139+
});
140+
94141
it('preserves aliases through assembly', () => {
95142
const config = assembleConfig({
96143
app: 'backend',

0 commit comments

Comments
 (0)