@nexical/cli 0.11.23 → 0.12.1
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.
- package/README.md +90 -235
- package/dist/{chunk-OYFWMYPG.js → chunk-6DE5Q66O.js} +6 -1
- package/dist/{chunk-OYFWMYPG.js.map → chunk-6DE5Q66O.js.map} +1 -1
- package/dist/chunk-G66GMEFE.js +31 -0
- package/dist/chunk-G66GMEFE.js.map +1 -0
- package/dist/{chunk-2FKDEDDE.js → chunk-HOVS7SCD.js} +16 -3
- package/dist/chunk-HOVS7SCD.js.map +1 -0
- package/dist/{chunk-GUUPSHWC.js → chunk-JEMIKBGX.js} +3 -3
- package/dist/chunk-JGAMEJTL.js +4101 -0
- package/dist/chunk-JGAMEJTL.js.map +1 -0
- package/dist/{chunk-OUGA4CB4.js → chunk-JS6WL5NS.js} +2 -2
- package/dist/{chunk-GEESHGE4.js → chunk-L2RUXOL4.js} +2 -2
- package/dist/{chunk-54HY52LH.js → chunk-QTJIGPQ3.js} +2 -2
- package/dist/{chunk-EKCOW7FM.js → chunk-USP2MI63.js} +41 -23
- package/dist/chunk-USP2MI63.js.map +1 -0
- package/dist/{chunk-2JW5BYZW.js → chunk-VKE7R2EZ.js} +2 -2
- package/dist/{chunk-AC4B3HPJ.js → chunk-XONR27KC.js} +2 -2
- package/dist/{chunk-PJIOCW2A.js → chunk-ZWNIZB3Q.js} +2 -2
- package/dist/index.js +5 -5
- package/dist/index.js.map +1 -1
- package/dist/src/commands/deploy.d.ts +3 -3
- package/dist/src/commands/deploy.js +148 -78
- package/dist/src/commands/deploy.js.map +1 -1
- package/dist/src/commands/init.js +5 -5
- package/dist/src/commands/module/add.js +4 -4
- package/dist/src/commands/module/list.js +2 -2
- package/dist/src/commands/module/remove.js +2 -2
- package/dist/src/commands/module/update.js +2 -2
- package/dist/src/commands/prompt.js +2 -2
- package/dist/src/commands/run.js +2 -2
- package/dist/src/commands/setup.js +3 -3
- package/dist/src/deploy/config-manager.js +3 -2
- package/dist/src/deploy/providers/cloudflare.d.ts +13 -8
- package/dist/src/deploy/providers/cloudflare.js +161 -52
- package/dist/src/deploy/providers/cloudflare.js.map +1 -1
- package/dist/src/deploy/providers/dns-cloudflare.d.ts +9 -0
- package/dist/src/deploy/providers/dns-cloudflare.js +123 -0
- package/dist/src/deploy/providers/dns-cloudflare.js.map +1 -0
- package/dist/src/deploy/providers/github.d.ts +6 -2
- package/dist/src/deploy/providers/github.js +37 -45
- package/dist/src/deploy/providers/github.js.map +1 -1
- package/dist/src/deploy/providers/railway.d.ts +17 -8
- package/dist/src/deploy/providers/railway.js +106 -45
- package/dist/src/deploy/providers/railway.js.map +1 -1
- package/dist/src/deploy/registry.d.ts +7 -4
- package/dist/src/deploy/registry.js +2 -2
- package/dist/src/deploy/schema.d.ts +188 -0
- package/dist/src/deploy/schema.js +11 -0
- package/dist/src/deploy/schema.js.map +1 -0
- package/dist/src/deploy/template-manager.d.ts +12 -0
- package/dist/src/deploy/template-manager.js +9 -0
- package/dist/src/deploy/template-manager.js.map +1 -0
- package/dist/src/deploy/types.d.ts +42 -17
- package/dist/src/deploy/types.js +1 -1
- package/dist/src/deploy/types.js.map +1 -1
- package/dist/src/deploy/utils.js +2 -2
- package/dist/src/utils/discovery.js +2 -2
- package/dist/src/utils/filter.js +2 -2
- package/dist/src/utils/git.js +2 -2
- package/dist/src/utils/url-resolver.js +2 -2
- package/dist/templates/github-workflow.yaml +23 -0
- package/package.json +2 -2
- package/src/commands/deploy.ts +169 -88
- package/src/deploy/config-manager.ts +14 -1
- package/src/deploy/providers/cloudflare.ts +203 -80
- package/src/deploy/providers/dns-cloudflare.ts +134 -0
- package/src/deploy/providers/github.ts +44 -47
- package/src/deploy/providers/railway.ts +135 -55
- package/src/deploy/registry.ts +49 -28
- package/src/deploy/schema.ts +39 -0
- package/src/deploy/template-manager.ts +32 -0
- package/src/deploy/templates/github-workflow.yaml +23 -0
- package/src/deploy/types.ts +48 -16
- package/test/integration/commands/deploy.integration.test.ts +79 -3
- package/test/unit/commands/deploy.test.ts +96 -198
- package/test/unit/deploy/config-manager.test.ts +9 -5
- package/test/unit/deploy/providers/cloudflare.test.ts +95 -96
- package/test/unit/deploy/providers/dns-cloudflare.test.ts +148 -0
- package/test/unit/deploy/providers/github.test.ts +43 -47
- package/test/unit/deploy/providers/railway.test.ts +50 -261
- package/test/unit/deploy/registry.test.ts +20 -17
- package/tsup.config.ts +3 -0
- package/dist/chunk-2FKDEDDE.js.map +0 -1
- package/dist/chunk-EKCOW7FM.js.map +0 -1
- /package/dist/{chunk-GUUPSHWC.js.map → chunk-JEMIKBGX.js.map} +0 -0
- /package/dist/{chunk-OUGA4CB4.js.map → chunk-JS6WL5NS.js.map} +0 -0
- /package/dist/{chunk-GEESHGE4.js.map → chunk-L2RUXOL4.js.map} +0 -0
- /package/dist/{chunk-54HY52LH.js.map → chunk-QTJIGPQ3.js.map} +0 -0
- /package/dist/{chunk-2JW5BYZW.js.map → chunk-VKE7R2EZ.js.map} +0 -0
- /package/dist/{chunk-AC4B3HPJ.js.map → chunk-XONR27KC.js.map} +0 -0
- /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 {
|
|
3
|
+
import { HostingProvider, DeploymentContext, CIConfig, AppConfig } from '../types';
|
|
4
4
|
import { execAsync } from '../utils';
|
|
5
5
|
|
|
6
|
-
export
|
|
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
|
|
12
|
-
const
|
|
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
|
-
|
|
24
|
+
`Railway project name not found for ${app.name}. Please configure 'projectName'.`,
|
|
18
25
|
);
|
|
19
26
|
}
|
|
20
27
|
|
|
21
|
-
const
|
|
28
|
+
const projectName = baseProjectName;
|
|
22
29
|
|
|
23
|
-
logger.info(
|
|
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 "${
|
|
33
|
+
logger.info(`[Dry Run] Would check Railway status and init project "${projectName}".`);
|
|
27
34
|
return;
|
|
28
35
|
}
|
|
29
36
|
|
|
30
37
|
try {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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:
|
|
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
|
-
|
|
58
|
-
await execAsync('railway unlink', { cwd: backendDir }).catch(() => {});
|
|
59
|
+
await execAsync('railway unlink', { cwd: targetDir }).catch(() => {});
|
|
59
60
|
}
|
|
60
|
-
const initCmd = `railway init --name ${
|
|
61
|
+
const initCmd = `railway init --name ${projectName}`;
|
|
61
62
|
logger.info(`No active Railway project linked. Initializing with: ${initCmd}`);
|
|
62
|
-
await execAsync(initCmd, { cwd:
|
|
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
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
108
|
-
const tokenEnvVar =
|
|
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
|
|
117
|
-
const
|
|
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 '
|
|
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 =
|
|
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
|
|
145
|
-
|
|
146
|
-
|
|
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: [
|
|
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
|
}
|
package/src/deploy/registry.ts
CHANGED
|
@@ -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 {
|
|
4
|
+
import { HostingProvider, RepositoryProvider, DnsProvider } from './types';
|
|
5
5
|
|
|
6
6
|
export class ProviderRegistry {
|
|
7
|
-
private
|
|
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
|
-
|
|
11
|
-
this.
|
|
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
|
-
|
|
19
|
-
return this.
|
|
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
|
-
|
|
28
|
-
|
|
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 (!
|
|
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(
|
|
35
|
-
if (typeof
|
|
36
|
-
|
|
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
|
-
|
|
43
|
-
|
|
50
|
+
if (!ProviderCandidate) return;
|
|
51
|
+
|
|
52
|
+
let instance: unknown;
|
|
53
|
+
if (typeof ProviderCandidate === 'function') {
|
|
44
54
|
try {
|
|
45
|
-
|
|
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 (
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
)
|
|
59
|
-
|
|
60
|
-
|
|
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
|
package/src/deploy/types.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
77
|
+
dns?: {
|
|
54
78
|
provider: string;
|
|
55
|
-
|
|
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
|
+
}
|