@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.
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 +148 -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 +169 -88
  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,74 +1,130 @@
1
+ import path from 'node:path';
1
2
  import { logger } from '@nexical/cli-core';
2
- import { DeploymentProvider, DeploymentContext, CIConfig } from '../types';
3
+ import { HostingProvider, DeploymentContext, CIConfig, AppConfig } from '../types';
3
4
  import { execAsync } from '../utils';
4
5
 
5
- export class CloudflareProvider implements DeploymentProvider {
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 = context.config.deploy?.frontend?.projectName;
16
+ const baseProjectName = app.projectName;
12
17
 
13
18
  if (!baseProjectName) {
14
19
  throw new Error(
15
- "Cloudflare project name not found in nexical.yaml. Please configure 'deploy.frontend.projectName'.",
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
- const options = context.config.deploy?.frontend?.options || {};
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 Pages project "${projectName}" and create if missing.`,
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
- logger.info('Cloudflare project might already exist.');
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 options = context.config.deploy?.frontend?.options || {};
81
- const secrets: Record<string, string> = {};
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
- (apiTokenEnvVar ? process.env[apiTokenEnvVar] : undefined) ||
88
- process.env.CLOUDFLARE_API_TOKEN;
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. Configuring 'deploy.frontend.options.apiTokenEnvVar' in nexical.yaml and setting that env var in .env\n` +
94
- `2. Setting CLOUDFLARE_API_TOKEN in .env`,
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. Configuring 'deploy.frontend.options.accountIdEnvVar' in nexical.yaml and setting that env var in .env\n` +
110
- `2. Setting CLOUDFLARE_ACCOUNT_ID in .env`,
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
- secrets['CLOUDFLARE_ACCOUNT_ID'] = accountId;
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 = context.config.deploy?.frontend?.projectName;
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
- return {
130
- CLOUDFLARE_PROJECT_NAME: projectName,
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: ['CLOUDFLARE_PROJECT_NAME'],
213
+ variables: [varName],
138
214
  deploySteps: [], // Handled by action
139
215
  githubActionStep: {
140
- name: 'Deploy to Cloudflare Pages',
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: 'pages deploy dist --project-name=${{ vars.CLOUDFLARE_PROJECT_NAME }}',
146
- workingDirectory: 'apps/frontend',
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, DeploymentProvider } from '../types';
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(context: DeploymentContext, targets: DeploymentProvider[]): Promise<void> {
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 target of targets) {
46
- const config = target.getCIConfig('github');
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-${target.type}.yml`;
54
+ const filename = `deploy-${app.name}.yml`;
50
55
  const filepath = path.join(workflowsDir, filename);
51
56
 
52
- const workflow: Record<string, unknown> = {
53
- name: `Deploy ${target.type === 'backend' ? 'Backend' : 'Frontend'} to ${target.name}`,
54
- on: {
55
- push: { branches: ['main'] },
56
- workflow_dispatch: {},
57
- },
58
- jobs: {
59
- deploy: {
60
- 'runs-on': 'ubuntu-latest',
61
- permissions: {
62
- contents: 'read',
63
- deployments: 'write',
64
- },
65
- steps: [
66
- {
67
- name: 'Checkout',
68
- uses: 'actions/checkout@v4',
69
- with: { submodules: 'recursive' },
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 frontend)
89
- if (target.type === 'frontend') {
80
+ // Build (if applicable)
81
+ if (app.buildCommand) {
90
82
  steps.push({
91
- name: 'Build Frontend',
92
- run: 'npm run build --workspace=@app/frontend',
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 ${target.name} CLI`,
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 ${target.name}`,
102
+ name: `Deploy ${app.name} to ${provider.name}`,
111
103
  run: step,
112
- 'working-directory': target.type === 'backend' ? 'apps/backend' : 'apps/frontend',
104
+ 'working-directory': app.target || '.',
113
105
  };
114
106
 
115
- if (config.secrets && config.secrets.length > 0) {
116
- deployStep.env = config.secrets.reduce((acc: Record<string, string>, secret) => {
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
  }