@nexical/cli 0.11.23 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +90 -235
  2. package/dist/{chunk-OYFWMYPG.js → chunk-6DE5Q66O.js} +6 -1
  3. package/dist/{chunk-OYFWMYPG.js.map → chunk-6DE5Q66O.js.map} +1 -1
  4. package/dist/chunk-G66GMEFE.js +31 -0
  5. package/dist/chunk-G66GMEFE.js.map +1 -0
  6. package/dist/{chunk-2FKDEDDE.js → chunk-HOVS7SCD.js} +16 -3
  7. package/dist/chunk-HOVS7SCD.js.map +1 -0
  8. package/dist/{chunk-GUUPSHWC.js → chunk-JEMIKBGX.js} +3 -3
  9. package/dist/chunk-JGAMEJTL.js +4101 -0
  10. package/dist/chunk-JGAMEJTL.js.map +1 -0
  11. package/dist/{chunk-OUGA4CB4.js → chunk-JS6WL5NS.js} +2 -2
  12. package/dist/{chunk-GEESHGE4.js → chunk-L2RUXOL4.js} +2 -2
  13. package/dist/{chunk-54HY52LH.js → chunk-QTJIGPQ3.js} +2 -2
  14. package/dist/{chunk-EKCOW7FM.js → chunk-USP2MI63.js} +41 -23
  15. package/dist/chunk-USP2MI63.js.map +1 -0
  16. package/dist/{chunk-2JW5BYZW.js → chunk-VKE7R2EZ.js} +2 -2
  17. package/dist/{chunk-AC4B3HPJ.js → chunk-XONR27KC.js} +2 -2
  18. package/dist/{chunk-PJIOCW2A.js → chunk-ZWNIZB3Q.js} +2 -2
  19. package/dist/index.js +5 -5
  20. package/dist/index.js.map +1 -1
  21. package/dist/src/commands/deploy.d.ts +3 -3
  22. package/dist/src/commands/deploy.js +134 -78
  23. package/dist/src/commands/deploy.js.map +1 -1
  24. package/dist/src/commands/init.js +5 -5
  25. package/dist/src/commands/module/add.js +4 -4
  26. package/dist/src/commands/module/list.js +2 -2
  27. package/dist/src/commands/module/remove.js +2 -2
  28. package/dist/src/commands/module/update.js +2 -2
  29. package/dist/src/commands/prompt.js +2 -2
  30. package/dist/src/commands/run.js +2 -2
  31. package/dist/src/commands/setup.js +3 -3
  32. package/dist/src/deploy/config-manager.js +3 -2
  33. package/dist/src/deploy/providers/cloudflare.d.ts +13 -8
  34. package/dist/src/deploy/providers/cloudflare.js +161 -52
  35. package/dist/src/deploy/providers/cloudflare.js.map +1 -1
  36. package/dist/src/deploy/providers/dns-cloudflare.d.ts +9 -0
  37. package/dist/src/deploy/providers/dns-cloudflare.js +123 -0
  38. package/dist/src/deploy/providers/dns-cloudflare.js.map +1 -0
  39. package/dist/src/deploy/providers/github.d.ts +6 -2
  40. package/dist/src/deploy/providers/github.js +37 -45
  41. package/dist/src/deploy/providers/github.js.map +1 -1
  42. package/dist/src/deploy/providers/railway.d.ts +17 -8
  43. package/dist/src/deploy/providers/railway.js +106 -45
  44. package/dist/src/deploy/providers/railway.js.map +1 -1
  45. package/dist/src/deploy/registry.d.ts +7 -4
  46. package/dist/src/deploy/registry.js +2 -2
  47. package/dist/src/deploy/schema.d.ts +188 -0
  48. package/dist/src/deploy/schema.js +11 -0
  49. package/dist/src/deploy/schema.js.map +1 -0
  50. package/dist/src/deploy/template-manager.d.ts +12 -0
  51. package/dist/src/deploy/template-manager.js +9 -0
  52. package/dist/src/deploy/template-manager.js.map +1 -0
  53. package/dist/src/deploy/types.d.ts +42 -17
  54. package/dist/src/deploy/types.js +1 -1
  55. package/dist/src/deploy/types.js.map +1 -1
  56. package/dist/src/deploy/utils.js +2 -2
  57. package/dist/src/utils/discovery.js +2 -2
  58. package/dist/src/utils/filter.js +2 -2
  59. package/dist/src/utils/git.js +2 -2
  60. package/dist/src/utils/url-resolver.js +2 -2
  61. package/dist/templates/github-workflow.yaml +23 -0
  62. package/package.json +2 -2
  63. package/src/commands/deploy.ts +157 -93
  64. package/src/deploy/config-manager.ts +14 -1
  65. package/src/deploy/providers/cloudflare.ts +203 -80
  66. package/src/deploy/providers/dns-cloudflare.ts +134 -0
  67. package/src/deploy/providers/github.ts +44 -47
  68. package/src/deploy/providers/railway.ts +135 -55
  69. package/src/deploy/registry.ts +49 -28
  70. package/src/deploy/schema.ts +39 -0
  71. package/src/deploy/template-manager.ts +32 -0
  72. package/src/deploy/templates/github-workflow.yaml +23 -0
  73. package/src/deploy/types.ts +48 -16
  74. package/test/integration/commands/deploy.integration.test.ts +79 -3
  75. package/test/unit/commands/deploy.test.ts +96 -198
  76. package/test/unit/deploy/config-manager.test.ts +9 -5
  77. package/test/unit/deploy/providers/cloudflare.test.ts +95 -96
  78. package/test/unit/deploy/providers/dns-cloudflare.test.ts +148 -0
  79. package/test/unit/deploy/providers/github.test.ts +43 -47
  80. package/test/unit/deploy/providers/railway.test.ts +50 -261
  81. package/test/unit/deploy/registry.test.ts +20 -17
  82. package/tsup.config.ts +3 -0
  83. package/dist/chunk-2FKDEDDE.js.map +0 -1
  84. package/dist/chunk-EKCOW7FM.js.map +0 -1
  85. /package/dist/{chunk-GUUPSHWC.js.map → chunk-JEMIKBGX.js.map} +0 -0
  86. /package/dist/{chunk-OUGA4CB4.js.map → chunk-JS6WL5NS.js.map} +0 -0
  87. /package/dist/{chunk-GEESHGE4.js.map → chunk-L2RUXOL4.js.map} +0 -0
  88. /package/dist/{chunk-54HY52LH.js.map → chunk-QTJIGPQ3.js.map} +0 -0
  89. /package/dist/{chunk-2JW5BYZW.js.map → chunk-VKE7R2EZ.js.map} +0 -0
  90. /package/dist/{chunk-AC4B3HPJ.js.map → chunk-XONR27KC.js.map} +0 -0
  91. /package/dist/{chunk-PJIOCW2A.js.map → chunk-ZWNIZB3Q.js.map} +0 -0
@@ -1,52 +1,54 @@
1
1
  import path from 'node:path';
2
2
  import { logger } from '@nexical/cli-core';
3
- import { DeploymentProvider, DeploymentContext, CIConfig } from '../types';
3
+ import { HostingProvider, DeploymentContext, CIConfig, AppConfig } from '../types';
4
4
  import { execAsync } from '../utils';
5
5
 
6
- export class RailwayProvider implements DeploymentProvider {
6
+ export interface RailwayConfig {
7
+ token?: string;
8
+ services?: {
9
+ type: string;
10
+ name: string;
11
+ [key: string]: unknown;
12
+ }[];
13
+ }
14
+
15
+ export class RailwayProvider implements HostingProvider {
7
16
  name = 'railway';
8
- type = 'backend' as const;
9
17
 
10
- async provision(context: DeploymentContext): Promise<void> {
11
- const backendDir = path.join(context.cwd, 'apps/backend');
12
- const env = (context.options.env as string) || 'production';
13
- const baseProjectName = context.config.deploy?.backend?.projectName;
18
+ async provision(context: DeploymentContext, app: AppConfig): Promise<void> {
19
+ const targetDir = app.target ? path.resolve(context.cwd, app.target) : context.cwd;
20
+ const baseProjectName = app.projectName;
14
21
 
15
22
  if (!baseProjectName) {
16
23
  throw new Error(
17
- "Railway project name not found in nexical.yaml. Please configure 'deploy.backend.projectName'.",
24
+ `Railway project name not found for ${app.name}. Please configure 'projectName'.`,
18
25
  );
19
26
  }
20
27
 
21
- const railwayName = env === 'production' ? baseProjectName : `${baseProjectName}-${env}`;
28
+ const projectName = baseProjectName;
22
29
 
23
- logger.info('Configuring Railway...');
30
+ logger.info(`Configuring Railway project "${projectName}" for ${app.name}...`);
24
31
 
25
32
  if (context.options.dryRun) {
26
- logger.info(`[Dry Run] Would check Railway status and init project "${railwayName}".`);
33
+ logger.info(`[Dry Run] Would check Railway status and init project "${projectName}".`);
27
34
  return;
28
35
  }
29
36
 
30
37
  try {
31
- // We consciously DO NOT pass any RAILWAY_API_TOKEN to the subprocess.
32
- // The user may have an invalid token in their .env file (which process.env inherits).
33
- // We want to force the Railway CLI to use the locally logged-in user's credentials.
34
- const env = { ...process.env };
35
- delete env.RAILWAY_API_TOKEN;
36
- delete env.RAILWAY_TOKEN;
38
+ const processEnv = { ...process.env };
39
+ delete processEnv.RAILWAY_API_TOKEN;
40
+ delete processEnv.RAILWAY_TOKEN;
37
41
 
38
42
  logger.info('Using local Railway CLI credentials (environment variables stripped).');
39
43
 
40
- // Check status to see if we are linked to a project
41
44
  try {
42
- await execAsync('railway status', { cwd: backendDir, env });
45
+ await execAsync('railway status', { cwd: targetDir, env: processEnv });
43
46
  } catch (error: unknown) {
44
47
  const errMsg = error instanceof Error ? error.message : String(error);
45
48
  const stderr = (error as { stderr?: string }).stderr || '';
46
49
  const stdout = (error as { stdout?: string }).stdout || '';
47
50
  const fullError = `${errMsg} ${stderr} ${stdout}`;
48
51
 
49
- // If status fails, likely project doesn't exist locally or we aren't linked.
50
52
  if (
51
53
  fullError.includes('Project not found') ||
52
54
  fullError.includes('No project') ||
@@ -54,38 +56,60 @@ export class RailwayProvider implements DeploymentProvider {
54
56
  ) {
55
57
  if (fullError.includes('Project is deleted')) {
56
58
  logger.info('[Railway] Project is deleted. Unlinking...');
57
- // If it's deleted, we might need to unlink first to clean up local config
58
- await execAsync('railway unlink', { cwd: backendDir }).catch(() => {});
59
+ await execAsync('railway unlink', { cwd: targetDir }).catch(() => {});
59
60
  }
60
- const initCmd = `railway init --name ${railwayName}`;
61
+ const initCmd = `railway init --name ${projectName}`;
61
62
  logger.info(`No active Railway project linked. Initializing with: ${initCmd}`);
62
- await execAsync(initCmd, { cwd: backendDir, env });
63
+ await execAsync(initCmd, { cwd: targetDir, env: processEnv });
63
64
  } else if (
64
65
  fullError.includes('Invalid RAILWAY_API_TOKEN') ||
65
66
  fullError.includes('Unauthorized')
66
67
  ) {
67
68
  throw new Error('Railway authentication failed during status check.');
68
69
  } else {
69
- // Some other error (e.g. timeout), warn and try to proceed
70
70
  logger.warn(`Railway status check failed: ${errMsg}. Proceeding.`);
71
71
  }
72
72
  }
73
73
 
74
- logger.info(`Adding PostgreSQL service if missing for "${railwayName}"...`);
75
- const { stdout: status } = await execAsync('railway status', { cwd: backendDir, env }).catch(
76
- () => ({ stdout: '' }),
77
- );
78
- if (!status.includes('postgres')) {
79
- try {
80
- await execAsync('railway add --database postgres', { cwd: backendDir, env });
81
- } catch {
82
- logger.warn('Failed to auto-add PostgreSQL.');
74
+ const rwConfig = (app.railway as RailwayConfig) || {};
75
+ const services = rwConfig.services || [];
76
+ if (services.length > 0) {
77
+ logger.info(`Provisioning ${services.length} services for project "${projectName}"...`);
78
+
79
+ // Re-check status once to see what's already there
80
+ const statusData = await execAsync('railway status', {
81
+ cwd: targetDir,
82
+ env: processEnv,
83
+ }).catch(() => ({ stdout: '' }));
84
+ const status = (statusData as { stdout: string }).stdout || '';
85
+
86
+ for (const service of services) {
87
+ if (service.type === 'database') {
88
+ const dbName = service.name;
89
+ if (!status.toLowerCase().includes(dbName.toLowerCase())) {
90
+ logger.info(`Adding ${dbName} service to project "${projectName}"...`);
91
+ try {
92
+ await execAsync(`railway add --database ${dbName}`, {
93
+ cwd: targetDir,
94
+ env: processEnv,
95
+ });
96
+ } catch (err: unknown) {
97
+ logger.warn(
98
+ `Failed to auto-add ${dbName} database: ${err instanceof Error ? err.message : String(err)}`,
99
+ );
100
+ }
101
+ } else {
102
+ logger.info(`Service ${dbName} already present in project "${projectName}".`);
103
+ }
104
+ } else {
105
+ logger.warn(
106
+ `Service type "${service.type}" is not yet supported for automatic provisioning.`,
107
+ );
108
+ }
83
109
  }
84
110
  }
85
111
  } catch (e: unknown) {
86
- // Rethrow explicit auth errors, otherwise warn
87
112
  const errMsg = e instanceof Error ? e.message : String(e);
88
-
89
113
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
90
114
  const stderr = (e as any).stderr || '';
91
115
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -103,9 +127,9 @@ export class RailwayProvider implements DeploymentProvider {
103
127
  }
104
128
  }
105
129
 
106
- private resolveToken(context: DeploymentContext): string | undefined {
107
- const options = context.config.deploy?.backend?.options || {};
108
- const tokenEnvVar = typeof options.tokenEnvVar === 'string' ? options.tokenEnvVar : undefined;
130
+ private resolveToken(context: DeploymentContext, app: AppConfig): string | undefined {
131
+ const rwConfig = (app.railway as RailwayConfig) || {};
132
+ const tokenEnvVar = rwConfig.token;
109
133
  return (
110
134
  process.env.RAILWAY_API_TOKEN?.trim() ||
111
135
  (tokenEnvVar ? process.env[tokenEnvVar]?.trim() : undefined) ||
@@ -113,46 +137,102 @@ export class RailwayProvider implements DeploymentProvider {
113
137
  );
114
138
  }
115
139
 
116
- async getSecrets(context: DeploymentContext): Promise<Record<string, string>> {
117
- const token = this.resolveToken(context);
140
+ async deploy(context: DeploymentContext, app: AppConfig): Promise<void> {
141
+ const targetDir = app.target ? path.resolve(context.cwd, app.target) : context.cwd;
142
+
143
+ logger.info(`Deploying ${app.name} to Railway...`);
144
+
145
+ if (context.options.dryRun) {
146
+ logger.info(`[Dry Run] Would run "railway up" in ${targetDir}.`);
147
+ return;
148
+ }
149
+
150
+ const token = this.resolveToken(context, app);
151
+ const processEnv = { ...process.env };
152
+ if (token) {
153
+ processEnv.RAILWAY_TOKEN = token;
154
+ }
155
+
156
+ await execAsync('railway up --detach', {
157
+ cwd: targetDir,
158
+ env: processEnv,
159
+ });
160
+
161
+ logger.success(`Successfully deployed ${app.name} to Railway.`);
162
+ }
163
+
164
+ async getSecrets(context: DeploymentContext, app: AppConfig): Promise<Record<string, string>> {
165
+ const token = this.resolveToken(context, app);
118
166
  const secrets: Record<string, string> = {};
119
167
 
120
168
  if (!token) {
121
- // Strict check: Error if token is missing
122
169
  throw new Error(
123
- `Railway Token not found. Please provide it via:\n` +
170
+ `Railway Token not found for ${app.name}. Please provide it via:\n` +
124
171
  `1. Setting RAILWAY_API_TOKEN in .env (Recommended)\n` +
125
- `2. Configuring 'deploy.backend.options.tokenEnvVar' in nexical.yaml\n` +
172
+ `2. Configuring 'railway.token' and setting that env var in .env\n` +
126
173
  `3. Setting RAILWAY_TOKEN in .env`,
127
174
  );
128
175
  }
129
176
 
130
177
  secrets['RAILWAY_API_TOKEN'] = token;
178
+
179
+ // Custom mapped secrets
180
+ if (app.secrets) {
181
+ for (const [key, envVar] of Object.entries(app.secrets)) {
182
+ const value = process.env[envVar];
183
+ if (!value) {
184
+ throw new Error(`Custom secret '${key}' mapping failed: Env var '${envVar}' not found.`);
185
+ }
186
+ secrets[key] = value;
187
+ }
188
+ }
189
+
131
190
  return secrets;
132
191
  }
133
192
 
134
- async getVariables(context: DeploymentContext): Promise<Record<string, string>> {
193
+ async getVariables(context: DeploymentContext, app: AppConfig): Promise<Record<string, string>> {
135
194
  const env = (context.options.env as string) || 'production';
136
- const baseProjectName = context.config.deploy?.backend?.projectName;
195
+ const baseProjectName = app.projectName;
137
196
 
138
197
  if (!baseProjectName) {
139
- throw new Error(
140
- "Railway project name not found in nexical.yaml. Please configure 'deploy.backend.projectName'.",
141
- );
198
+ throw new Error(`Railway project name not found for ${app.name}.`);
142
199
  }
143
200
 
144
- const projectName = env === 'production' ? baseProjectName : `${baseProjectName}-${env}`;
145
- return {
146
- RAILWAY_PROJECT_NAME: projectName,
201
+ const result: Record<string, string> = {
202
+ RAILWAY_PROJECT_NAME: baseProjectName,
203
+ RAILWAY_ENVIRONMENT: env,
147
204
  };
205
+
206
+ // Custom mapped variables
207
+ if (app.env) {
208
+ for (const [key, value] of Object.entries(app.env)) {
209
+ const resolvedValue = process.env[value] || value;
210
+ result[key] = resolvedValue;
211
+ }
212
+ }
213
+
214
+ return result;
148
215
  }
149
216
 
150
- getCIConfig(): CIConfig {
217
+ getCIConfig(repoType: 'github' | 'gitlab', app: AppConfig): CIConfig {
151
218
  return {
152
219
  secrets: ['RAILWAY_API_TOKEN'],
153
- variables: [],
220
+ variables: ['RAILWAY_ENVIRONMENT'],
154
221
  installSteps: ['npm install -g @railway/cli'],
155
- deploySteps: ['railway up --detach --project=${{ vars.RAILWAY_PROJECT_NAME }}'],
222
+ deploySteps: [
223
+ `railway up --detach --project=\${{ vars.RAILWAY_PROJECT_NAME }} --environment=\${{ vars.RAILWAY_ENVIRONMENT }}`,
224
+ ],
156
225
  };
157
226
  }
227
+
228
+ getDefaultDnsTarget(app: AppConfig): string | undefined {
229
+ // Railway typically creates a [project-name]-[environment].up.railway.app domain.
230
+ // For simpler custom domain linking, users often just CNAME directly to up.railway.app
231
+ // or the specific assigned railway generated domain if it's predictable.
232
+ // For automatic resolution without runtime polling, returning the predictable project CNAME.
233
+ if (app.projectName) {
234
+ return `${app.projectName}.up.railway.app`;
235
+ }
236
+ return undefined;
237
+ }
158
238
  }
@@ -1,64 +1,85 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'node:fs/promises';
3
3
  import { logger } from '@nexical/cli-core';
4
- import { DeploymentProvider, RepositoryProvider } from './types';
4
+ import { HostingProvider, RepositoryProvider, DnsProvider } from './types';
5
5
 
6
6
  export class ProviderRegistry {
7
- private deploymentProviders: Map<string, DeploymentProvider> = new Map();
7
+ private hostingProviders: Map<string, HostingProvider> = new Map();
8
8
  private repositoryProviders: Map<string, RepositoryProvider> = new Map();
9
+ private dnsProviders: Map<string, DnsProvider> = new Map();
9
10
 
10
- registerDeploymentProvider(provider: DeploymentProvider) {
11
- this.deploymentProviders.set(provider.name, provider);
11
+ registerHostingProvider(provider: HostingProvider) {
12
+ this.hostingProviders.set(provider.name, provider);
12
13
  }
13
14
 
14
15
  registerRepositoryProvider(provider: RepositoryProvider) {
15
16
  this.repositoryProviders.set(provider.name, provider);
16
17
  }
17
18
 
18
- getDeploymentProvider(name: string): DeploymentProvider | undefined {
19
- return this.deploymentProviders.get(name);
19
+ getHostingProvider(name: string): HostingProvider | undefined {
20
+ return this.hostingProviders.get(name);
20
21
  }
21
22
 
22
23
  getRepositoryProvider(name: string): RepositoryProvider | undefined {
23
24
  return this.repositoryProviders.get(name);
24
25
  }
25
26
 
27
+ registerDnsProvider(provider: DnsProvider) {
28
+ this.dnsProviders.set(provider.name, provider);
29
+ }
30
+
31
+ getDnsProvider(name: string): DnsProvider | undefined {
32
+ return this.dnsProviders.get(name);
33
+ }
34
+
26
35
  private registerProviderFromModule(module: unknown, source: string) {
27
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
28
- const moduleAny = module as any;
29
- let provider = moduleAny.default;
36
+ const exports = module as Record<string, unknown>;
37
+ let ProviderCandidate = exports?.default;
30
38
 
31
39
  // Handle named exports if default is missing (fallback)
32
- if (!provider && Object.keys(moduleAny).length > 0) {
40
+ if (!ProviderCandidate && exports && Object.keys(exports).length > 0) {
33
41
  // Try to find a class export that looks like a provider
34
- for (const key of Object.keys(moduleAny)) {
35
- if (typeof moduleAny[key] === 'function') {
36
- provider = moduleAny[key];
42
+ for (const key of Object.keys(exports)) {
43
+ if (typeof exports[key] === 'function') {
44
+ ProviderCandidate = exports[key];
37
45
  break;
38
46
  }
39
47
  }
40
48
  }
41
49
 
42
- // If it's a class, instantiate it
43
- if (typeof provider === 'function') {
50
+ if (!ProviderCandidate) return;
51
+
52
+ let instance: unknown;
53
+ if (typeof ProviderCandidate === 'function') {
44
54
  try {
45
- provider = new provider();
55
+ instance = new (ProviderCandidate as new () => unknown)();
46
56
  } catch {
47
- // Not a constructor or failed
57
+ // Not a constructor or failed, could be a regular function
58
+ instance = ProviderCandidate;
48
59
  }
60
+ } else {
61
+ instance = ProviderCandidate;
49
62
  }
50
63
 
51
- if (provider) {
52
- if (typeof provider.provision === 'function' && typeof provider.getCIConfig === 'function') {
53
- logger.info(`[Registry] Loaded ${source} deployment provider: ${provider.name}`);
54
- this.registerDeploymentProvider(provider as DeploymentProvider);
55
- } else if (
56
- typeof provider.configureSecrets === 'function' &&
57
- typeof provider.generateWorkflow === 'function'
58
- ) {
59
- logger.info(`[Registry] Loaded ${source} repository provider: ${provider.name}`);
60
- this.registerRepositoryProvider(provider as RepositoryProvider);
61
- }
64
+ if (!instance || typeof instance !== 'object') return;
65
+
66
+ const provider = instance as Record<string, unknown>;
67
+
68
+ if (typeof provider.provision === 'function' && typeof provider.getCIConfig === 'function') {
69
+ const p = provider as unknown as HostingProvider;
70
+ logger.info(`[Registry] Loaded ${source} hosting provider: ${p.name}`);
71
+ this.registerHostingProvider(p);
72
+ } else if (
73
+ typeof provider.configureSecrets === 'function' &&
74
+ typeof provider.generateWorkflow === 'function'
75
+ ) {
76
+ const p = provider as unknown as RepositoryProvider;
77
+ logger.info(`[Registry] Loaded ${source} repository provider: ${p.name}`);
78
+ this.registerRepositoryProvider(p);
79
+ } else if (typeof provider.provision === 'function' && provider.type === 'dns') {
80
+ const p = provider as unknown as DnsProvider;
81
+ logger.info(`[Registry] Loaded ${source} DNS provider: ${p.name}`);
82
+ this.registerDnsProvider(p);
62
83
  }
63
84
  }
64
85
 
@@ -0,0 +1,39 @@
1
+ import { z } from 'zod';
2
+
3
+ export const AppConfigSchema = z
4
+ .object({
5
+ provider: z.string(),
6
+ projectName: z.string().optional(),
7
+ target: z.string().optional(),
8
+ buildCommand: z.string().optional(),
9
+ artifactPath: z.string().optional(),
10
+ paths: z.array(z.string()).optional(),
11
+ options: z.record(z.string(), z.any()).optional(),
12
+ env: z.record(z.string(), z.string()).optional(),
13
+ secrets: z.record(z.string(), z.string()).optional(),
14
+ domain: z.union([z.string(), z.array(z.string())]).optional(),
15
+ dnsTarget: z.string().optional(),
16
+ })
17
+ .passthrough();
18
+
19
+ export const DeploymentSchema = z.object({
20
+ deploy: z
21
+ .object({
22
+ repository: z
23
+ .object({
24
+ provider: z.string(),
25
+ options: z.record(z.string(), z.any()).optional(),
26
+ })
27
+ .optional(),
28
+ dns: z
29
+ .object({
30
+ provider: z.string(),
31
+ })
32
+ .passthrough()
33
+ .optional(),
34
+ apps: z.record(z.string(), AppConfigSchema).optional(),
35
+ })
36
+ .optional(),
37
+ });
38
+
39
+ export type ValidatedNexicalConfig = z.infer<typeof DeploymentSchema>;
@@ -0,0 +1,32 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import YAML from 'yaml';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+
8
+ export interface WorkflowTemplateData {
9
+ APP_NAME: string;
10
+ PROVIDER_NAME: string;
11
+ [key: string]: string;
12
+ }
13
+
14
+ export class TemplateManager {
15
+ private templatesDir: string;
16
+
17
+ constructor() {
18
+ this.templatesDir = path.join(__dirname, 'templates');
19
+ }
20
+
21
+ async loadWorkflow(name: string, data: WorkflowTemplateData): Promise<unknown> {
22
+ const templatePath = path.join(this.templatesDir, `${name}.yaml`);
23
+ let content = await fs.readFile(templatePath, 'utf-8');
24
+
25
+ // Simple placeholder replacement
26
+ for (const [key, value] of Object.entries(data)) {
27
+ content = content.split(`\${${key}}`).join(value);
28
+ }
29
+
30
+ return YAML.parse(content);
31
+ }
32
+ }
@@ -0,0 +1,23 @@
1
+ name: Deploy ${APP_NAME} to ${PROVIDER_NAME}
2
+ on:
3
+ push:
4
+ branches: [main]
5
+ workflow_dispatch: {}
6
+ jobs:
7
+ deploy:
8
+ runs-on: ubuntu-latest
9
+ permissions:
10
+ contents: read
11
+ deployments: write
12
+ steps:
13
+ - name: Checkout
14
+ uses: actions/checkout@v4
15
+ with:
16
+ submodules: recursive
17
+ - name: Setup Node
18
+ uses: actions/setup-node@v4
19
+ with:
20
+ node-version: 20
21
+ cache: npm
22
+ - name: Install Dependencies
23
+ run: npm ci
@@ -8,28 +8,49 @@ export interface CIConfig {
8
8
  githubActionStep?: Record<string, unknown>;
9
9
  }
10
10
 
11
+ export interface AppConfig {
12
+ name: string;
13
+ provider: string;
14
+ projectName?: string;
15
+ target?: string;
16
+ buildCommand?: string;
17
+ artifactPath?: string;
18
+ paths?: string[];
19
+ options?: Record<string, unknown>;
20
+ env?: Record<string, string>;
21
+ secrets?: Record<string, string>;
22
+ domain?: string | string[];
23
+ dnsTarget?: string;
24
+ [key: string]: unknown;
25
+ }
26
+
11
27
  export interface DeploymentContext {
12
28
  cwd: string;
13
29
  config: NexicalConfig;
14
30
  options: Record<string, unknown>;
15
31
  }
16
32
 
17
- export interface DeploymentProvider {
33
+ export interface HostingProvider {
18
34
  name: string;
19
- type: 'frontend' | 'backend';
20
35
 
21
36
  // Interactive or automatic setup of the provider resources
22
- provision(context: DeploymentContext): Promise<void>;
37
+ provision(context: DeploymentContext, app: AppConfig): Promise<void>;
23
38
 
24
39
  // Returns the CI configuration for this provider
25
- getCIConfig(repoType: 'github' | 'gitlab'): CIConfig;
40
+ getCIConfig(repoType: 'github' | 'gitlab', app: AppConfig): CIConfig;
26
41
 
27
42
  // Returns a map of secrets to be set in the repository (e.g. tokens, account IDs)
28
43
  // The provider is responsible for resolving these from config/env and throwing if missing.
29
- getSecrets(context: DeploymentContext): Promise<Record<string, string>>;
44
+ getSecrets(context: DeploymentContext, app: AppConfig): Promise<Record<string, string>>;
30
45
 
31
46
  // Returns a map of variables to be set in the repository (e.g. project names, service names)
32
- getVariables(context: DeploymentContext): Promise<Record<string, string>>;
47
+ getVariables(context: DeploymentContext, app: AppConfig): Promise<Record<string, string>>;
48
+
49
+ // Performs a manual build/deployment from the local machine
50
+ deploy?(context: DeploymentContext, app: AppConfig): Promise<void>;
51
+
52
+ // Optional: Automatically infer the DnsTarget from the hosting configuration
53
+ getDefaultDnsTarget?(app: AppConfig): string | undefined;
33
54
  }
34
55
 
35
56
  export interface RepositoryProvider {
@@ -40,24 +61,35 @@ export interface RepositoryProvider {
40
61
  configureVariables(context: DeploymentContext, variables: Record<string, string>): Promise<void>;
41
62
 
42
63
  // Generates and writes the CI workflow files
43
- generateWorkflow(context: DeploymentContext, targets: DeploymentProvider[]): Promise<void>;
64
+ generateWorkflow(
65
+ context: DeploymentContext,
66
+ targets: { provider: HostingProvider; app: AppConfig }[],
67
+ ): Promise<void>;
44
68
  }
45
69
 
46
70
  export interface NexicalConfig {
47
71
  deploy?: {
48
- backend?: {
72
+ apps?: Record<string, Omit<AppConfig, 'name'>>;
73
+ repository?: {
49
74
  provider: string;
50
- projectName?: string;
51
75
  options?: Record<string, unknown>;
52
76
  };
53
- frontend?: {
77
+ dns?: {
54
78
  provider: string;
55
- projectName?: string;
56
- options?: Record<string, unknown>;
57
- };
58
- repository?: {
59
- provider: string;
60
- options?: Record<string, unknown>;
79
+ [key: string]: unknown;
61
80
  };
62
81
  };
63
82
  }
83
+
84
+ export interface DnsRecord {
85
+ type: string;
86
+ name: string;
87
+ content: string;
88
+ proxied?: boolean;
89
+ }
90
+
91
+ export interface DnsProvider {
92
+ name: string;
93
+ type?: 'dns';
94
+ provision(context: DeploymentContext, records: DnsRecord[]): Promise<void>;
95
+ }