Skip to content

Commit 8d8a90a

Browse files
committed
refactor(config): remove legacy top-level mirrors from canonical shape (sprint 2f)
1 parent cc6322c commit 8d8a90a

8 files changed

Lines changed: 119 additions & 172 deletions

File tree

src/config/assembly.ts

Lines changed: 25 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,30 @@ type LegacyPm2Input = {
1010
apps?: Pm2App[];
1111
};
1212

13-
type AssembleInput = Omit<Partial<ShipnodeConfig>, 'pm2' | 'apps'> & {
13+
type AssembleInput = {
14+
ssh?: ShipnodeConfig['ssh'];
15+
remotePath?: string;
16+
nodeVersion?: string;
17+
pkgManager?: ShipnodeConfig['pkgManager'];
18+
installCommand?: string;
19+
database?: ShipnodeConfig['database'];
20+
redis?: ShipnodeConfig['redis'];
21+
backup?: ShipnodeConfig['backup'];
22+
cloudflare?: ShipnodeConfig['cloudflare'];
23+
aliases?: Record<string, string>;
24+
// Legacy input fields — synthesized to apps[0] by z.preprocess
25+
app?: string;
26+
domain?: string;
1427
pm2?: LegacyPm2Input | Pm2Config;
1528
backend?: { port?: number };
29+
healthCheck?: unknown;
30+
envFile?: string;
31+
keepReleases?: number;
32+
sharedDirs?: string[];
33+
sharedFiles?: string[];
34+
buildDir?: string;
35+
appRoot?: string;
36+
hooks?: unknown;
1637
apps?: Partial<ShipnodeApp>[];
1738
};
1839

@@ -38,35 +59,12 @@ function normalizePm2(
3859
*
3960
* The schema is the single source of truth — it knows about defaults, refinements, and
4061
* 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.
62+
* only does what the schema cannot: normalize the legacy `pm2: { name }` input shape
63+
* onto canonical `pm2.apps`.
4864
*/
4965
export function assembleConfig(partial: AssembleInput): ShipnodeConfig {
5066
const { backend, ...rest } = partial;
5167
const pm2 = normalizePm2(rest.pm2, backend);
5268

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;
69+
return ShipnodeConfigSchema.parse({ ...rest, pm2 });
7270
}

src/config/builder.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,29 @@ import type {
1313
} from '../shared/types.js';
1414
import { assembleConfig } from './assembly.js';
1515

16-
type BuilderState = Omit<Partial<ShipnodeConfig>, 'pm2' | 'apps'> & {
16+
type BuilderState = {
17+
ssh?: SshConfig;
18+
remotePath?: string;
19+
nodeVersion?: string;
20+
pkgManager?: PkgManager;
21+
installCommand?: string;
22+
database?: DatabaseConfig;
23+
redis?: RedisConfig;
24+
backup?: BackupConfig;
25+
cloudflare?: CloudflareConfig;
26+
aliases?: Record<string, string>;
27+
// Legacy per-app input fields (synthesized to apps[0] by z.preprocess)
28+
app?: string;
1729
pm2?: { apps: Pm2App[] };
30+
domain?: string;
31+
keepReleases?: number;
32+
healthCheck?: Partial<HealthCheckConfig>;
33+
envFile?: string;
34+
buildDir?: string;
35+
sharedDirs?: string[];
36+
sharedFiles?: string[];
37+
appRoot?: string;
38+
hooks?: { preDeploy?: HookFn; postDeploy?: HookFn };
1839
apps?: Partial<ShipnodeApp>[];
1940
};
2041

src/config/schema.ts

Lines changed: 2 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -118,17 +118,10 @@ export const ShipnodeAppSchema = z.object({
118118
{ message: 'domain requires a web app: one pm2.apps entry must declare a port', path: ['domain'] },
119119
);
120120

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-
//
127121
// A z.preprocess wrapper synthesizes `apps[0]` from the legacy top-level fields when
128122
// the input doesn't carry `apps`. This lets every 2.x config (including the existing
129123
// 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.
124+
// modification.
132125
const ShipnodeConfigBaseSchema = z.object({
133126
// workspace-level
134127
ssh: SshConfigSchema,
@@ -144,31 +137,7 @@ const ShipnodeConfigBaseSchema = z.object({
144137

145138
// canonical app list (always populated by assembleConfig; .min(1) enforced)
146139
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,
161-
}).refine(
162-
(cfg) => !(cfg.app === 'frontend' && cfg.pm2),
163-
{ message: 'frontend apps cannot declare pm2 (frontends are static files served by Caddy)', path: ['pm2'] },
164-
).refine(
165-
(cfg) => {
166-
if (!cfg.domain || cfg.app !== 'backend') return true;
167-
const hasWebApp = cfg.pm2?.apps.some((a) => a.port !== undefined);
168-
return hasWebApp ?? false;
169-
},
170-
{ message: 'domain requires a web app: one pm2.apps entry must declare a port', path: ['domain'] },
171-
);
140+
});
172141

173142
export const ShipnodeConfigSchema = z.preprocess(
174143
(input: unknown) => {

src/shared/types.ts

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -136,30 +136,6 @@ export interface ShipnodeConfig {
136136

137137
/** Canonical app list. Always populated by assembleConfig (length >= 1). */
138138
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;
145-
pm2?: Pm2Config;
146-
domain?: string;
147-
keepReleases: number;
148-
healthCheck: HealthCheckConfig;
149-
envFile: string;
150-
buildDir?: string;
151-
/**
152-
* Path (relative to the repo root) of the app within a monorepo whose
153-
* compiled output reads `.env` from its own root (AdonisJS, NestJS, etc.).
154-
* Shipnode symlinks the shared `.env` into `<appRoot>/build` and
155-
* `<appRoot>/dist`. Unset = single-app layout; shipnode auto-detects
156-
* `build` / `dist` at the repo root and any obvious `apps/*` / `packages/*`
157-
* build outputs.
158-
*/
159-
appRoot?: string;
160-
sharedDirs?: string[];
161-
sharedFiles?: string[];
162-
hooks?: HooksConfig;
163139
}
164140

165141
export interface ReleaseRecord {

tests/unit/assembly.test.ts

Lines changed: 20 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,20 @@ describe('assembleConfig', () => {
1010
remotePath: '/var/www/app',
1111
});
1212

13-
expect(config.app).toBe('backend');
13+
expect(config.apps[0].appType).toBe('backend');
1414
expect(config.ssh.host).toBe('192.168.1.1');
1515
expect(config.ssh.user).toBe('deploy');
1616
expect(config.ssh.port).toBe(22);
1717
expect(config.remotePath).toBe('/var/www/app');
18-
expect(config.keepReleases).toBe(5);
19-
expect(config.healthCheck.enabled).toBe(true);
20-
expect(config.healthCheck.path).toBe('/health');
21-
expect(config.healthCheck.timeout).toBe(30);
22-
expect(config.healthCheck.retries).toBe(3);
23-
expect(config.healthCheck.startupDelay).toBe(3);
24-
expect(config.envFile).toBe('.env');
18+
expect(config.apps[0].keepReleases).toBe(5);
19+
expect(config.apps[0].healthCheck.enabled).toBe(true);
20+
expect(config.apps[0].healthCheck.path).toBe('/health');
21+
expect(config.apps[0].healthCheck.timeout).toBe(30);
22+
expect(config.apps[0].healthCheck.retries).toBe(3);
23+
expect(config.apps[0].healthCheck.startupDelay).toBe(3);
24+
expect(config.apps[0].envFile).toBe('.env');
2525
expect(config.nodeVersion).toBe('lts');
26-
// No pm2 declared → no apps at all.
27-
expect(config.pm2).toBeUndefined();
26+
expect(config.apps[0].pm2).toBeUndefined();
2827
});
2928

3029
it('preserves explicitly provided values', () => {
@@ -38,13 +37,13 @@ describe('assembleConfig', () => {
3837
nodeVersion: '22',
3938
});
4039

41-
expect(config.app).toBe('frontend');
40+
expect(config.apps[0].appType).toBe('frontend');
4241
expect(config.ssh.port).toBe(2222);
4342
expect(config.remotePath).toBe('/opt/app');
44-
expect(config.keepReleases).toBe(10);
45-
expect(config.healthCheck.path).toBe('/api/health');
46-
expect(config.healthCheck.timeout).toBe(60);
47-
expect(config.envFile).toBe('.env.production');
43+
expect(config.apps[0].keepReleases).toBe(10);
44+
expect(config.apps[0].healthCheck.path).toBe('/api/health');
45+
expect(config.apps[0].healthCheck.timeout).toBe(60);
46+
expect(config.apps[0].envFile).toBe('.env.production');
4847
expect(config.nodeVersion).toBe('22');
4948
});
5049

@@ -56,8 +55,8 @@ describe('assembleConfig', () => {
5655
pm2: { name: 'api', instances: 2, maxMemory: '1G' },
5756
backend: { port: 8080 },
5857
});
59-
expect(config.pm2?.apps).toHaveLength(1);
60-
expect(config.pm2?.apps[0]).toMatchObject({ name: 'api', port: 8080, instances: 2, maxMemory: '1G' });
58+
expect(config.apps[0].pm2?.apps).toHaveLength(1);
59+
expect(config.apps[0].pm2?.apps[0]).toMatchObject({ name: 'api', port: 8080, instances: 2, maxMemory: '1G' });
6160
});
6261

6362
it('accepts the new pm2.apps input shape directly', () => {
@@ -67,9 +66,9 @@ describe('assembleConfig', () => {
6766
remotePath: '/var/www/app',
6867
pm2: { apps: [{ name: 'api', port: 3000 }, { name: 'worker', command: 'node dist/worker.js' }] },
6968
});
70-
expect(config.pm2?.apps).toHaveLength(2);
71-
expect(config.pm2?.apps[0].name).toBe('api');
72-
expect(config.pm2?.apps[1].name).toBe('worker');
69+
expect(config.apps[0].pm2?.apps).toHaveLength(2);
70+
expect(config.apps[0].pm2?.apps[0].name).toBe('api');
71+
expect(config.apps[0].pm2?.apps[1].name).toBe('worker');
7372
});
7473

7574
it('rejects two pm2.apps entries with ports', () => {
@@ -122,22 +121,6 @@ describe('assembleConfig', () => {
122121
expect(config.apps[0].appType).toBe('frontend');
123122
});
124123

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-
141124
it('preserves aliases through assembly', () => {
142125
const config = assembleConfig({
143126
app: 'backend',
@@ -179,6 +162,6 @@ describe('assembleConfig', () => {
179162
remotePath: '/var/www/app',
180163
});
181164

182-
expect(config.app).toBe('backend');
165+
expect(config.apps[0].appType).toBe('backend');
183166
});
184167
});

0 commit comments

Comments
 (0)