@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.
- 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 +134 -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 +157 -93
- 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,74 +1,130 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
1
2
|
import { logger } from '@nexical/cli-core';
|
|
2
|
-
import {
|
|
3
|
+
import { HostingProvider, DeploymentContext, CIConfig, AppConfig } from '../types';
|
|
3
4
|
import { execAsync } from '../utils';
|
|
4
5
|
|
|
5
|
-
export
|
|
6
|
+
export interface CloudflareConfig {
|
|
7
|
+
token?: string;
|
|
8
|
+
account?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class CloudflareProvider implements HostingProvider {
|
|
6
12
|
name = 'cloudflare';
|
|
7
|
-
type = 'frontend' as const;
|
|
8
13
|
|
|
9
|
-
async provision(context: DeploymentContext): Promise<void> {
|
|
14
|
+
async provision(context: DeploymentContext, app: AppConfig): Promise<void> {
|
|
10
15
|
const env = (context.options.env as string) || 'production';
|
|
11
|
-
const baseProjectName =
|
|
16
|
+
const baseProjectName = app.projectName;
|
|
12
17
|
|
|
13
18
|
if (!baseProjectName) {
|
|
14
19
|
throw new Error(
|
|
15
|
-
|
|
20
|
+
`Cloudflare project name not found for ${app.name}. Please configure 'projectName'.`,
|
|
16
21
|
);
|
|
17
22
|
}
|
|
18
23
|
|
|
19
24
|
const projectName = env === 'production' ? baseProjectName : `${baseProjectName}-${env}`;
|
|
20
25
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
// Resolve credentials:
|
|
24
|
-
// 1. CLI flag (options)
|
|
25
|
-
// 2. Env var defined in config (options.apiTokenEnvVar)
|
|
26
|
-
// 3. Default env var (CLOUDFLARE_API_TOKEN)
|
|
27
|
-
const apiTokenEnvVar =
|
|
28
|
-
typeof options.apiTokenEnvVar === 'string' ? options.apiTokenEnvVar : undefined;
|
|
29
|
-
const apiToken =
|
|
30
|
-
(typeof context.options.cloudflareToken === 'string'
|
|
31
|
-
? context.options.cloudflareToken
|
|
32
|
-
: undefined) ||
|
|
33
|
-
(apiTokenEnvVar ? process.env[apiTokenEnvVar] : undefined) ||
|
|
34
|
-
process.env.CLOUDFLARE_API_TOKEN;
|
|
35
|
-
|
|
36
|
-
const accountIdEnvVar =
|
|
37
|
-
typeof options.accountIdEnvVar === 'string' ? options.accountIdEnvVar : undefined;
|
|
38
|
-
const accountId =
|
|
39
|
-
(typeof context.options.cloudflareAccount === 'string'
|
|
40
|
-
? context.options.cloudflareAccount
|
|
41
|
-
: undefined) ||
|
|
42
|
-
(accountIdEnvVar ? process.env[accountIdEnvVar] : undefined) ||
|
|
43
|
-
process.env.CLOUDFLARE_ACCOUNT_ID;
|
|
44
|
-
|
|
45
|
-
logger.info('Configuring Cloudflare Pages...');
|
|
26
|
+
logger.info(`Configuring Cloudflare Pages for ${app.name}...`);
|
|
46
27
|
|
|
47
28
|
if (context.options.dryRun) {
|
|
48
29
|
logger.info(
|
|
49
|
-
`[Dry Run] Would check Cloudflare
|
|
30
|
+
`[Dry Run] Would check Cloudflare status and provision project "${projectName}".`,
|
|
50
31
|
);
|
|
51
32
|
return;
|
|
52
33
|
}
|
|
53
34
|
|
|
54
|
-
if (!apiToken || !accountId) {
|
|
55
|
-
logger.warn('Cloudflare credentials missing. Skipping automated Cloudflare setup.');
|
|
56
|
-
logger.info('You can manually set up Cloudflare Pages and add the secrets to GitHub.');
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
35
|
try {
|
|
36
|
+
const secrets = await this.getSecrets(context, app).catch(() => undefined);
|
|
37
|
+
if (!secrets) {
|
|
38
|
+
logger.warn(
|
|
39
|
+
`Cloudflare credentials missing for ${app.name}. Skipping provisioning. ` +
|
|
40
|
+
'Ensure CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID are set.',
|
|
41
|
+
);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const processEnv = {
|
|
46
|
+
...process.env,
|
|
47
|
+
...secrets,
|
|
48
|
+
NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --dns-result-order=ipv4first`.trim(),
|
|
49
|
+
};
|
|
61
50
|
logger.info(`Ensuring Cloudflare Pages project "${projectName}" exists...`);
|
|
62
51
|
try {
|
|
63
52
|
await execAsync(`wrangler pages project create ${projectName} --production-branch main`, {
|
|
64
|
-
env:
|
|
65
|
-
...process.env,
|
|
66
|
-
CLOUDFLARE_API_TOKEN: apiToken,
|
|
67
|
-
CLOUDFLARE_ACCOUNT_ID: accountId,
|
|
68
|
-
},
|
|
53
|
+
env: processEnv,
|
|
69
54
|
});
|
|
70
|
-
} catch {
|
|
71
|
-
|
|
55
|
+
} catch (err: unknown) {
|
|
56
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
57
|
+
if (message.includes('already exists')) {
|
|
58
|
+
logger.info('Cloudflare project already exists.');
|
|
59
|
+
} else {
|
|
60
|
+
throw err;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Handle Linked Domains
|
|
65
|
+
if (app.domain) {
|
|
66
|
+
const domains = Array.isArray(app.domain) ? app.domain : [app.domain];
|
|
67
|
+
logger.info(
|
|
68
|
+
`Linking ${domains.length} domains to Cloudflare Pages project "${projectName}"...`,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const apiToken = secrets.CLOUDFLARE_API_TOKEN;
|
|
72
|
+
const accountId = secrets.CLOUDFLARE_ACCOUNT_ID;
|
|
73
|
+
|
|
74
|
+
// Fetch existing domains to avoid redundant calls
|
|
75
|
+
const listRes = await fetch(
|
|
76
|
+
`https://api.cloudflare.com/client/v4/accounts/${accountId}/pages/projects/${projectName}/domains`,
|
|
77
|
+
{
|
|
78
|
+
headers: {
|
|
79
|
+
Authorization: `Bearer ${apiToken}`,
|
|
80
|
+
'Content-Type': 'application/json',
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
if (!listRes.ok) {
|
|
86
|
+
const errorText = await listRes.text();
|
|
87
|
+
logger.warn(`Failed to fetch existing linked domains: ${errorText}`);
|
|
88
|
+
} else {
|
|
89
|
+
const listJson = (await listRes.json()) as {
|
|
90
|
+
success: boolean;
|
|
91
|
+
result: { domain: string }[];
|
|
92
|
+
};
|
|
93
|
+
const existingDomains = listJson.success ? listJson.result.map((d) => d.domain) : [];
|
|
94
|
+
|
|
95
|
+
for (const domain of domains) {
|
|
96
|
+
if (existingDomains.includes(domain)) {
|
|
97
|
+
logger.info(`[Cloudflare Pages] Domain ${domain} is already linked.`);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
logger.info(`[Cloudflare Pages] Linking domain ${domain}...`);
|
|
102
|
+
const linkRes = await fetch(
|
|
103
|
+
`https://api.cloudflare.com/client/v4/accounts/${accountId}/pages/projects/${projectName}/domains`,
|
|
104
|
+
{
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: {
|
|
107
|
+
Authorization: `Bearer ${apiToken}`,
|
|
108
|
+
'Content-Type': 'application/json',
|
|
109
|
+
},
|
|
110
|
+
body: JSON.stringify({ name: domain }), // Pages API uses 'name' for the domain string in some versions, but docs suggest 'name' or just object. Let's verify 'name' vs 'domain'.
|
|
111
|
+
// Correction: The API docs say POST body should be { "name": "example.com" }
|
|
112
|
+
},
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
if (!linkRes.ok) {
|
|
116
|
+
const errorText = await linkRes.text();
|
|
117
|
+
// If it failed because it exists but wasn't in list (unlikely but safe)
|
|
118
|
+
if (errorText.includes('already exists') || errorText.includes('1008')) {
|
|
119
|
+
logger.info(`[Cloudflare Pages] Domain ${domain} already linked.`);
|
|
120
|
+
} else {
|
|
121
|
+
logger.warn(`[Cloudflare Pages] Failed to link domain ${domain}: ${errorText}`);
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
logger.success(`[Cloudflare Pages] Linked domain ${domain}.`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
72
128
|
}
|
|
73
129
|
} catch (e: unknown) {
|
|
74
130
|
logger.warn('Cloudflare setup failed.');
|
|
@@ -76,76 +132,143 @@ export class CloudflareProvider implements DeploymentProvider {
|
|
|
76
132
|
}
|
|
77
133
|
}
|
|
78
134
|
|
|
79
|
-
async getSecrets(context: DeploymentContext): Promise<Record<string, string>> {
|
|
80
|
-
const
|
|
81
|
-
const
|
|
135
|
+
async getSecrets(context: DeploymentContext, app: AppConfig): Promise<Record<string, string>> {
|
|
136
|
+
const cfConfig = (app.cloudflare as CloudflareConfig) || {};
|
|
137
|
+
const apiTokenEnvVar = cfConfig.token;
|
|
138
|
+
const accountIdEnvVar = cfConfig.account;
|
|
82
139
|
|
|
83
|
-
// Resolve API Token
|
|
84
|
-
const apiTokenEnvVar =
|
|
85
|
-
typeof options.apiTokenEnvVar === 'string' ? options.apiTokenEnvVar : undefined;
|
|
86
140
|
const apiToken =
|
|
87
|
-
|
|
88
|
-
process.env
|
|
141
|
+
process.env.CLOUDFLARE_API_TOKEN?.trim() ||
|
|
142
|
+
(apiTokenEnvVar ? process.env[apiTokenEnvVar]?.trim() : undefined);
|
|
143
|
+
const accountId =
|
|
144
|
+
process.env.CLOUDFLARE_ACCOUNT_ID?.trim() ||
|
|
145
|
+
(accountIdEnvVar ? process.env[accountIdEnvVar]?.trim() : undefined);
|
|
89
146
|
|
|
90
147
|
if (!apiToken) {
|
|
91
148
|
throw new Error(
|
|
92
|
-
`Cloudflare API Token not found. Please provide it via:\n` +
|
|
93
|
-
`1.
|
|
94
|
-
`2.
|
|
149
|
+
`Cloudflare API Token not found for ${app.name}. Please provide it via:\n` +
|
|
150
|
+
`1. Setting CLOUDFLARE_API_TOKEN in .env (Recommended)\n` +
|
|
151
|
+
`2. Configuring 'cloudflare.token' and setting that env var in .env`,
|
|
95
152
|
);
|
|
96
153
|
}
|
|
97
|
-
secrets['CLOUDFLARE_API_TOKEN'] = apiToken;
|
|
98
|
-
|
|
99
|
-
// Resolve Account ID
|
|
100
|
-
const accountIdEnvVar =
|
|
101
|
-
typeof options.accountIdEnvVar === 'string' ? options.accountIdEnvVar : undefined;
|
|
102
|
-
const accountId =
|
|
103
|
-
(accountIdEnvVar ? process.env[accountIdEnvVar] : undefined) ||
|
|
104
|
-
process.env.CLOUDFLARE_ACCOUNT_ID;
|
|
105
154
|
|
|
106
155
|
if (!accountId) {
|
|
107
156
|
throw new Error(
|
|
108
|
-
`Cloudflare Account ID not found. Please provide it via:\n` +
|
|
109
|
-
`1.
|
|
110
|
-
`2.
|
|
157
|
+
`Cloudflare Account ID not found for ${app.name}. Please provide it via:\n` +
|
|
158
|
+
`1. Setting CLOUDFLARE_ACCOUNT_ID in .env (Recommended)\n` +
|
|
159
|
+
`2. Configuring 'cloudflare.account' and setting that env var in .env`,
|
|
111
160
|
);
|
|
112
161
|
}
|
|
113
|
-
|
|
162
|
+
|
|
163
|
+
const secrets: Record<string, string> = {
|
|
164
|
+
CLOUDFLARE_API_TOKEN: apiToken,
|
|
165
|
+
CLOUDFLARE_ACCOUNT_ID: accountId,
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// Custom mapped secrets
|
|
169
|
+
if (app.secrets) {
|
|
170
|
+
for (const [key, envVar] of Object.entries(app.secrets)) {
|
|
171
|
+
const value = process.env[envVar];
|
|
172
|
+
if (!value) {
|
|
173
|
+
throw new Error(`Custom secret '${key}' mapping failed: Env var '${envVar}' not found.`);
|
|
174
|
+
}
|
|
175
|
+
secrets[key] = value;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
114
178
|
|
|
115
179
|
return secrets;
|
|
116
180
|
}
|
|
117
181
|
|
|
118
|
-
async getVariables(context: DeploymentContext): Promise<Record<string, string>> {
|
|
182
|
+
async getVariables(context: DeploymentContext, app: AppConfig): Promise<Record<string, string>> {
|
|
119
183
|
const env = (context.options.env as string) || 'production';
|
|
120
|
-
const baseProjectName =
|
|
184
|
+
const baseProjectName = app.projectName;
|
|
121
185
|
|
|
122
186
|
if (!baseProjectName) {
|
|
123
|
-
throw new Error(
|
|
124
|
-
"Cloudflare project name not found in nexical.yaml. Please configure 'deploy.frontend.projectName'.",
|
|
125
|
-
);
|
|
187
|
+
throw new Error(`Cloudflare project name not found for ${app.name}.`);
|
|
126
188
|
}
|
|
127
189
|
|
|
128
190
|
const projectName = env === 'production' ? baseProjectName : `${baseProjectName}-${env}`;
|
|
129
|
-
|
|
130
|
-
|
|
191
|
+
const varName = `CLOUDFLARE_PROJECT_NAME_${app.name.toUpperCase().replace(/-/g, '_')}`;
|
|
192
|
+
const result: Record<string, string> = {
|
|
193
|
+
[varName]: projectName,
|
|
131
194
|
};
|
|
195
|
+
|
|
196
|
+
// Custom mapped variables
|
|
197
|
+
if (app.env) {
|
|
198
|
+
for (const [key, value] of Object.entries(app.env)) {
|
|
199
|
+
// If it looks like an env var, try to resolve it, otherwise use literal
|
|
200
|
+
const resolvedValue = process.env[value] || value;
|
|
201
|
+
result[key] = resolvedValue;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return result;
|
|
132
206
|
}
|
|
133
207
|
|
|
134
|
-
getCIConfig(): CIConfig {
|
|
208
|
+
getCIConfig(repoType: 'github' | 'gitlab', app: AppConfig): CIConfig {
|
|
209
|
+
const varName = `CLOUDFLARE_PROJECT_NAME_${app.name.toUpperCase().replace(/-/g, '_')}`;
|
|
210
|
+
const artifactPath = app.artifactPath || 'dist';
|
|
135
211
|
return {
|
|
136
212
|
secrets: ['CLOUDFLARE_API_TOKEN', 'CLOUDFLARE_ACCOUNT_ID'],
|
|
137
|
-
variables: [
|
|
213
|
+
variables: [varName],
|
|
138
214
|
deploySteps: [], // Handled by action
|
|
139
215
|
githubActionStep: {
|
|
140
|
-
name:
|
|
216
|
+
name: `Deploy ${app.name} to Cloudflare Pages`,
|
|
141
217
|
uses: 'cloudflare/wrangler-action@v3',
|
|
142
218
|
with: {
|
|
143
219
|
apiToken: '${{ secrets.CLOUDFLARE_API_TOKEN }}',
|
|
144
220
|
accountId: '${{ secrets.CLOUDFLARE_ACCOUNT_ID }}',
|
|
145
|
-
command:
|
|
146
|
-
workingDirectory: '
|
|
221
|
+
command: `pages deploy ${artifactPath} --project-name=\${{ vars.${varName} }}`,
|
|
222
|
+
workingDirectory: app.target || '.',
|
|
147
223
|
},
|
|
148
224
|
},
|
|
149
225
|
};
|
|
150
226
|
}
|
|
227
|
+
|
|
228
|
+
async deploy(context: DeploymentContext, app: AppConfig): Promise<void> {
|
|
229
|
+
const env = (context.options.env as string) || 'production';
|
|
230
|
+
const baseProjectName = app.projectName;
|
|
231
|
+
const artifactPath = app.artifactPath || 'dist';
|
|
232
|
+
const targetDir = app.target ? path.resolve(context.cwd, app.target) : context.cwd;
|
|
233
|
+
|
|
234
|
+
if (!baseProjectName) {
|
|
235
|
+
throw new Error(`Cloudflare project name not found for ${app.name}.`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const projectName = env === 'production' ? baseProjectName : `${baseProjectName}-${env}`;
|
|
239
|
+
|
|
240
|
+
logger.info(`Deploying ${app.name} to Cloudflare Pages project "${projectName}"...`);
|
|
241
|
+
|
|
242
|
+
if (context.options.dryRun) {
|
|
243
|
+
logger.info(
|
|
244
|
+
`[Dry Run] Would deploy directory "${artifactPath}" to Cloudflare project "${projectName}".`,
|
|
245
|
+
);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const secrets = await this.getSecrets(context, app);
|
|
250
|
+
const processEnv = {
|
|
251
|
+
...process.env,
|
|
252
|
+
...secrets,
|
|
253
|
+
NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --dns-result-order=ipv4first`.trim(),
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
await execAsync(`wrangler pages deploy ${artifactPath} --project-name=${projectName}`, {
|
|
257
|
+
cwd: targetDir,
|
|
258
|
+
env: processEnv,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
logger.success(`Successfully deployed ${app.name} to Cloudflare Pages.`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
getDefaultDnsTarget(app: AppConfig): string | undefined {
|
|
265
|
+
// Cloudflare pages gives a predictable .pages.dev alias
|
|
266
|
+
// Note: This does not take environment into account for custom domains usually,
|
|
267
|
+
// custom domains are typically linked to the production project alias or a specific branch alias.
|
|
268
|
+
// For standard custom domain linkage, we return the production project alias.
|
|
269
|
+
if (app.projectName) {
|
|
270
|
+
return `${app.projectName}.pages.dev`;
|
|
271
|
+
}
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
151
274
|
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { logger } from '@nexical/cli-core';
|
|
2
|
+
import { DnsProvider, DeploymentContext, DnsRecord } from '../types';
|
|
3
|
+
|
|
4
|
+
export class CloudflareDnsProvider implements DnsProvider {
|
|
5
|
+
name = 'cloudflare';
|
|
6
|
+
type = 'dns' as const;
|
|
7
|
+
|
|
8
|
+
async provision(context: DeploymentContext, records: DnsRecord[]): Promise<void> {
|
|
9
|
+
const dnsConfig = context.config.deploy?.dns;
|
|
10
|
+
|
|
11
|
+
// Cloudflare specific token handling
|
|
12
|
+
const cfConfig = dnsConfig?.cloudflare as { token?: string; zone?: string } | undefined;
|
|
13
|
+
const apiToken = process.env.CLOUDFLARE_API_TOKEN || cfConfig?.token;
|
|
14
|
+
const zoneId = process.env.CLOUDFLARE_ZONE_ID || cfConfig?.zone;
|
|
15
|
+
|
|
16
|
+
if (!apiToken) {
|
|
17
|
+
throw new Error(
|
|
18
|
+
'Cloudflare API token not found. Set CLOUDFLARE_API_TOKEN environment variable or deploy.dns.cloudflare.token in nexical.yaml',
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
if (!zoneId) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
'Cloudflare Zone ID not found. Set CLOUDFLARE_ZONE_ID environment variable or deploy.dns.cloudflare.zone in nexical.yaml',
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (records.length === 0) {
|
|
28
|
+
logger.info(`[Cloudflare DNS] No DNS records to provision.`);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
logger.info(`[Cloudflare DNS] Provisioning ${records.length} records...`);
|
|
33
|
+
|
|
34
|
+
if (context.options.dryRun) {
|
|
35
|
+
for (const record of records) {
|
|
36
|
+
logger.info(
|
|
37
|
+
`[Dry Run] Would create/update DNS record: ${record.name} -> ${record.content} (${record.type})`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Fetch existing records for this zone to avoid creating duplicates
|
|
44
|
+
const response = await fetch(
|
|
45
|
+
`https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records`,
|
|
46
|
+
{
|
|
47
|
+
headers: {
|
|
48
|
+
Authorization: `Bearer ${apiToken}`,
|
|
49
|
+
'Content-Type': 'application/json',
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
if (!response.ok) {
|
|
55
|
+
const errorText = await response.text();
|
|
56
|
+
throw new Error(
|
|
57
|
+
`Failed to fetch Cloudflare DNS records: ${response.status} ${response.statusText} - ${errorText}`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const jsonRes = (await response.json()) as {
|
|
62
|
+
success: boolean;
|
|
63
|
+
result: { id: string; type: string; name: string; content: string; proxied: boolean }[];
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
if (!jsonRes.success) {
|
|
67
|
+
throw new Error('Cloudflare API returned success: false when fetching DNS records.');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const existingRecords = jsonRes.result || [];
|
|
71
|
+
|
|
72
|
+
for (const record of records) {
|
|
73
|
+
// Find matching record by name and type
|
|
74
|
+
const match = existingRecords.find((r) => r.name === record.name && r.type === record.type);
|
|
75
|
+
|
|
76
|
+
const payload = {
|
|
77
|
+
type: record.type,
|
|
78
|
+
name: record.name,
|
|
79
|
+
content: record.content,
|
|
80
|
+
proxied: record.proxied ?? true, // Default to proxied for Cloudflare
|
|
81
|
+
ttl: 1, // Automatic
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
if (match) {
|
|
85
|
+
// Update if content or proxied status differs
|
|
86
|
+
if (match.content !== record.content || match.proxied !== payload.proxied) {
|
|
87
|
+
logger.info(
|
|
88
|
+
`[Cloudflare DNS] Updating ${record.name} (${record.type}) -> ${record.content}`,
|
|
89
|
+
);
|
|
90
|
+
const updateRes = await fetch(
|
|
91
|
+
`https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records/${match.id}`,
|
|
92
|
+
{
|
|
93
|
+
method: 'PUT',
|
|
94
|
+
headers: {
|
|
95
|
+
Authorization: `Bearer ${apiToken}`,
|
|
96
|
+
'Content-Type': 'application/json',
|
|
97
|
+
},
|
|
98
|
+
body: JSON.stringify(payload),
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
if (!updateRes.ok) {
|
|
102
|
+
const errorText = await updateRes.text();
|
|
103
|
+
throw new Error(`Failed to update DNS record ${record.name}: ${errorText}`);
|
|
104
|
+
}
|
|
105
|
+
} else {
|
|
106
|
+
logger.info(`[Cloudflare DNS] Record ${record.name} is already up to date.`);
|
|
107
|
+
}
|
|
108
|
+
} else {
|
|
109
|
+
// Create new record
|
|
110
|
+
logger.info(
|
|
111
|
+
`[Cloudflare DNS] Creating ${record.name} (${record.type}) -> ${record.content}`,
|
|
112
|
+
);
|
|
113
|
+
const createRes = await fetch(
|
|
114
|
+
`https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records`,
|
|
115
|
+
{
|
|
116
|
+
method: 'POST',
|
|
117
|
+
headers: {
|
|
118
|
+
Authorization: `Bearer ${apiToken}`,
|
|
119
|
+
'Content-Type': 'application/json',
|
|
120
|
+
},
|
|
121
|
+
body: JSON.stringify(payload),
|
|
122
|
+
},
|
|
123
|
+
);
|
|
124
|
+
if (!createRes.ok) {
|
|
125
|
+
const errorText = await createRes.text();
|
|
126
|
+
throw new Error(`Failed to create DNS record ${record.name}: ${errorText}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
logger.success(`[Cloudflare DNS] Finished provisioning DNS records.`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export default CloudflareDnsProvider;
|
|
@@ -2,11 +2,13 @@ import fs from 'node:fs/promises';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import YAML from 'yaml';
|
|
4
4
|
import { logger } from '@nexical/cli-core';
|
|
5
|
-
import { RepositoryProvider, DeploymentContext,
|
|
5
|
+
import { RepositoryProvider, DeploymentContext, HostingProvider, AppConfig } from '../types';
|
|
6
6
|
import { execAsync } from '../utils';
|
|
7
|
+
import { TemplateManager } from '../template-manager';
|
|
7
8
|
|
|
8
9
|
export class GitHubProvider implements RepositoryProvider {
|
|
9
10
|
name = 'github';
|
|
11
|
+
private templateManager = new TemplateManager();
|
|
10
12
|
|
|
11
13
|
async configureSecrets(
|
|
12
14
|
context: DeploymentContext,
|
|
@@ -38,58 +40,48 @@ export class GitHubProvider implements RepositoryProvider {
|
|
|
38
40
|
}
|
|
39
41
|
}
|
|
40
42
|
|
|
41
|
-
async generateWorkflow(
|
|
43
|
+
async generateWorkflow(
|
|
44
|
+
context: DeploymentContext,
|
|
45
|
+
targets: { provider: HostingProvider; app: AppConfig }[],
|
|
46
|
+
): Promise<void> {
|
|
42
47
|
const workflowsDir = path.join(context.cwd, '.github/workflows');
|
|
43
48
|
await fs.mkdir(workflowsDir, { recursive: true });
|
|
44
49
|
|
|
45
|
-
for (const
|
|
46
|
-
const config =
|
|
50
|
+
for (const { provider, app } of targets) {
|
|
51
|
+
const config = provider.getCIConfig('github', app);
|
|
47
52
|
if (!config) continue;
|
|
48
53
|
|
|
49
|
-
const filename = `deploy-${
|
|
54
|
+
const filename = `deploy-${app.name}.yml`;
|
|
50
55
|
const filepath = path.join(workflowsDir, filename);
|
|
51
56
|
|
|
52
|
-
const workflow
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
{
|
|
72
|
-
name: 'Setup Node',
|
|
73
|
-
uses: 'actions/setup-node@v4',
|
|
74
|
-
with: { 'node-version': 20, cache: 'npm' },
|
|
75
|
-
},
|
|
76
|
-
{
|
|
77
|
-
name: 'Install Dependencies',
|
|
78
|
-
run: 'npm ci',
|
|
79
|
-
},
|
|
80
|
-
],
|
|
81
|
-
},
|
|
82
|
-
},
|
|
83
|
-
};
|
|
57
|
+
const workflow = await this.templateManager.loadWorkflow('github-workflow', {
|
|
58
|
+
APP_NAME: app.name,
|
|
59
|
+
PROVIDER_NAME: provider.name,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Update push trigger if paths are specified
|
|
63
|
+
if (app.paths && app.paths.length > 0) {
|
|
64
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
65
|
+
const workflowAny = workflow as any;
|
|
66
|
+
if (typeof workflowAny.on === 'string') {
|
|
67
|
+
workflowAny.on = {
|
|
68
|
+
push: { branches: [workflowAny.on] },
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (!workflowAny.on.push) {
|
|
72
|
+
workflowAny.on.push = { branches: ['main'] };
|
|
73
|
+
}
|
|
74
|
+
workflowAny.on.push.paths = app.paths;
|
|
75
|
+
}
|
|
84
76
|
|
|
85
77
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
86
78
|
const steps = (workflow as any).jobs.deploy.steps;
|
|
87
79
|
|
|
88
|
-
// Build (if
|
|
89
|
-
if (
|
|
80
|
+
// Build (if applicable)
|
|
81
|
+
if (app.buildCommand) {
|
|
90
82
|
steps.push({
|
|
91
|
-
name:
|
|
92
|
-
run:
|
|
83
|
+
name: `Build ${app.name}`,
|
|
84
|
+
run: app.buildCommand,
|
|
93
85
|
});
|
|
94
86
|
}
|
|
95
87
|
|
|
@@ -97,7 +89,7 @@ export class GitHubProvider implements RepositoryProvider {
|
|
|
97
89
|
if (config.installSteps) {
|
|
98
90
|
for (const step of config.installSteps) {
|
|
99
91
|
steps.push({
|
|
100
|
-
name: `Install ${
|
|
92
|
+
name: `Install ${provider.name} CLI`,
|
|
101
93
|
run: step,
|
|
102
94
|
});
|
|
103
95
|
}
|
|
@@ -107,13 +99,18 @@ export class GitHubProvider implements RepositoryProvider {
|
|
|
107
99
|
if (config.deploySteps) {
|
|
108
100
|
for (const step of config.deploySteps) {
|
|
109
101
|
const deployStep: Record<string, unknown> = {
|
|
110
|
-
name: `Deploy to ${
|
|
102
|
+
name: `Deploy ${app.name} to ${provider.name}`,
|
|
111
103
|
run: step,
|
|
112
|
-
'working-directory': target
|
|
104
|
+
'working-directory': app.target || '.',
|
|
113
105
|
};
|
|
114
106
|
|
|
115
|
-
|
|
116
|
-
|
|
107
|
+
const allSecrets = [...(config.secrets || [])];
|
|
108
|
+
if (app.secrets) {
|
|
109
|
+
allSecrets.push(...Object.keys(app.secrets));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (allSecrets.length > 0) {
|
|
113
|
+
deployStep.env = allSecrets.reduce((acc: Record<string, string>, secret) => {
|
|
117
114
|
acc[secret] = `\${{ secrets.${secret} }}`;
|
|
118
115
|
return acc;
|
|
119
116
|
}, {});
|
|
@@ -128,7 +125,7 @@ export class GitHubProvider implements RepositoryProvider {
|
|
|
128
125
|
steps.push(config.githubActionStep);
|
|
129
126
|
}
|
|
130
127
|
|
|
131
|
-
await fs.writeFile(filepath, YAML.stringify(workflow), 'utf-8');
|
|
128
|
+
await fs.writeFile(filepath, YAML.stringify(workflow, { lineWidth: 0 }), 'utf-8');
|
|
132
129
|
logger.info(`Generated workflow: ${filepath}`);
|
|
133
130
|
}
|
|
134
131
|
}
|