@nexical/cli 0.11.4 → 0.11.6

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 (43) hide show
  1. package/dist/chunk-2FKDEDDE.js +39 -0
  2. package/dist/chunk-2FKDEDDE.js.map +1 -0
  3. package/dist/chunk-2JW5BYZW.js +24 -0
  4. package/dist/chunk-2JW5BYZW.js.map +1 -0
  5. package/dist/chunk-EKCOW7FM.js +118 -0
  6. package/dist/chunk-EKCOW7FM.js.map +1 -0
  7. package/dist/index.js +13 -11
  8. package/dist/index.js.map +1 -1
  9. package/dist/src/commands/deploy.d.ts +2 -18
  10. package/dist/src/commands/deploy.js +100 -150
  11. package/dist/src/commands/deploy.js.map +1 -1
  12. package/dist/src/commands/init.js +3 -3
  13. package/dist/src/commands/module/add.js +3 -3
  14. package/dist/src/deploy/config-manager.d.ts +11 -0
  15. package/dist/src/deploy/config-manager.js +9 -0
  16. package/dist/src/deploy/config-manager.js.map +1 -0
  17. package/dist/src/deploy/providers/cloudflare.d.ts +12 -0
  18. package/dist/src/deploy/providers/cloudflare.js +113 -0
  19. package/dist/src/deploy/providers/cloudflare.js.map +1 -0
  20. package/dist/src/deploy/providers/github.d.ts +10 -0
  21. package/dist/src/deploy/providers/github.js +121 -0
  22. package/dist/src/deploy/providers/github.js.map +1 -0
  23. package/dist/src/deploy/providers/railway.d.ts +13 -0
  24. package/dist/src/deploy/providers/railway.js +114 -0
  25. package/dist/src/deploy/providers/railway.js.map +1 -0
  26. package/dist/src/deploy/registry.d.ts +15 -0
  27. package/dist/src/deploy/registry.js +9 -0
  28. package/dist/src/deploy/registry.js.map +1 -0
  29. package/dist/src/deploy/types.d.ts +47 -0
  30. package/dist/src/deploy/types.js +8 -0
  31. package/dist/src/deploy/types.js.map +1 -0
  32. package/dist/src/deploy/utils.d.ts +6 -0
  33. package/dist/src/deploy/utils.js +11 -0
  34. package/dist/src/deploy/utils.js.map +1 -0
  35. package/package.json +13 -11
  36. package/src/commands/deploy.ts +125 -193
  37. package/src/deploy/config-manager.ts +41 -0
  38. package/src/deploy/providers/cloudflare.ts +143 -0
  39. package/src/deploy/providers/github.ts +135 -0
  40. package/src/deploy/providers/railway.ts +143 -0
  41. package/src/deploy/registry.ts +136 -0
  42. package/src/deploy/types.ts +63 -0
  43. package/src/deploy/utils.ts +13 -0
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../src/deploy/types.ts"],"sourcesContent":["export interface CIConfig {\n secrets: string[];\n variables: string[];\n installSteps?: string[];\n buildSteps?: string[];\n deploySteps?: string[];\n // Platform specific overrides (e.g. uses: action/...)\n githubActionStep?: Record<string, unknown>;\n}\n\nexport interface DeploymentContext {\n cwd: string;\n config: NexicalConfig;\n options: Record<string, unknown>;\n}\n\nexport interface DeploymentProvider {\n name: string;\n type: 'frontend' | 'backend';\n\n // Interactive or automatic setup of the provider resources\n provision(context: DeploymentContext): Promise<void>;\n\n // Returns the CI configuration for this provider\n getCIConfig(repoType: 'github' | 'gitlab'): CIConfig;\n\n // Returns a map of secrets to be set in the repository (e.g. tokens, account IDs)\n // The provider is responsible for resolving these from config/env and throwing if missing.\n getSecrets(context: DeploymentContext): Promise<Record<string, string>>;\n\n // Returns a map of variables to be set in the repository (e.g. project names, service names)\n getVariables(context: DeploymentContext): Promise<Record<string, string>>;\n}\n\nexport interface RepositoryProvider {\n name: string;\n\n // Sets secrets/variables in the repo\n configureSecrets(context: DeploymentContext, secrets: Record<string, string>): Promise<void>;\n configureVariables(context: DeploymentContext, variables: Record<string, string>): Promise<void>;\n\n // Generates and writes the CI workflow files\n generateWorkflow(context: DeploymentContext, targets: DeploymentProvider[]): Promise<void>;\n}\n\nexport interface NexicalConfig {\n deploy?: {\n backend?: {\n provider: string;\n projectName?: string;\n options?: Record<string, unknown>;\n };\n frontend?: {\n provider: string;\n projectName?: string;\n options?: Record<string, unknown>;\n };\n repository?: {\n provider: string;\n options?: Record<string, unknown>;\n };\n };\n}\n"],"mappings":";;;;;;AAAA;","names":[]}
@@ -0,0 +1,6 @@
1
+ import { exec } from 'node:child_process';
2
+
3
+ declare const execAsync: typeof exec.__promisify__;
4
+ declare function checkCommand(command: string): Promise<boolean>;
5
+
6
+ export { checkCommand, execAsync };
@@ -0,0 +1,11 @@
1
+ import { createRequire } from "module"; const require = createRequire(import.meta.url);
2
+ import {
3
+ checkCommand,
4
+ execAsync
5
+ } from "../../chunk-2JW5BYZW.js";
6
+ import "../../chunk-OYFWMYPG.js";
7
+ export {
8
+ checkCommand,
9
+ execAsync
10
+ };
11
+ //# sourceMappingURL=utils.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nexical/cli",
3
- "version": "0.11.4",
3
+ "version": "0.11.6",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "nexical": "./dist/index.js"
@@ -29,30 +29,32 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@nexical/cli-core": "^0.1.12",
32
- "yaml": "^2.3.4",
33
- "fast-glob": "^3.3.3"
32
+ "dotenv": "^17.3.1",
33
+ "fast-glob": "^3.3.3",
34
+ "jiti": "^2.6.1",
35
+ "yaml": "^2.3.4"
34
36
  },
35
37
  "devDependencies": {
38
+ "@eslint/js": "^9.39.2",
36
39
  "@types/fs-extra": "^11.0.4",
37
40
  "@types/node": "^20.10.0",
38
41
  "@vitest/coverage-v8": "^4.0.15",
39
- "execa": "^9.6.1",
40
- "fs-extra": "^11.3.2",
41
- "tsup": "^8.0.1",
42
- "tsx": "^4.21.0",
43
- "typescript": "^5.3.3",
44
- "vitest": "^4.0.15",
45
- "@eslint/js": "^9.39.2",
46
42
  "eslint": "^9.39.2",
47
43
  "eslint-config-prettier": "^10.1.8",
48
44
  "eslint-plugin-astro": "^1.5.0",
49
45
  "eslint-plugin-jsx-a11y": "^6.10.2",
50
46
  "eslint-plugin-react": "^7.37.5",
51
47
  "eslint-plugin-react-hooks": "^7.0.1",
48
+ "execa": "^9.6.1",
49
+ "fs-extra": "^11.3.2",
52
50
  "globals": "^17.2.0",
53
51
  "husky": "^9.1.7",
54
52
  "lint-staged": "^16.2.7",
55
53
  "prettier": "^3.8.1",
56
- "typescript-eslint": "^8.54.0"
54
+ "tsup": "^8.0.1",
55
+ "tsx": "^4.21.0",
56
+ "typescript": "^5.3.3",
57
+ "typescript-eslint": "^8.54.0",
58
+ "vitest": "^4.0.15"
57
59
  }
58
60
  }
@@ -1,238 +1,170 @@
1
- import { BaseCommand } from '@nexical/cli-core';
2
- import { exec } from 'node:child_process';
3
- import { promisify } from 'node:util';
4
1
  import path from 'node:path';
2
+ import dotenv from 'dotenv';
3
+ import { BaseCommand } from '@nexical/cli-core';
4
+ import { ConfigManager } from '../deploy/config-manager';
5
+ import { ProviderRegistry } from '../deploy/registry';
6
+ import { DeploymentContext } from '../deploy/types';
7
+
8
+ export default class DeployCommand extends BaseCommand {
9
+ static description = `Deploy the application based on nexical.yaml configuration.
5
10
 
6
- const execAsync = promisify(exec);
11
+ This command orchestrates the deployment of your frontend and backend applications
12
+ by interacting with the providers specified in your configuration file.
7
13
 
8
- interface DeployOptions {
9
- dryRun: boolean;
10
- railwayToken?: string;
11
- railwayName?: string;
12
- backendName: string;
13
- frontendName: string;
14
- cloudflareToken?: string;
15
- cloudflareAccount?: string;
16
- }
14
+ CONFIGURATION:
15
+ - Requires a 'nexical.yaml' file in the project root.
16
+ - If the file or specific sections are missing, the CLI will prompt you to run an interactive setup
17
+ and save the configuration for future uses.
18
+ - Supports loading environment variables from a .env file in the project root.
17
19
 
18
- export default class DeployCommand extends BaseCommand {
19
- static description = `Deploy the application to Railway and Cloudflare.
20
-
21
- ENVIRONMENT SETUP & PREREQUISITES:
22
- 1. Install Required CLIs:
23
- - Railway CLI: npm i -g @railway/cli
24
- - Wrangler (Cloudflare): npm i -g wrangler
25
- - GitHub CLI: https://cli.github.com/
26
-
27
- 2. Authentication:
28
- - Railway: Run 'railway login'
29
- - GitHub: Run 'gh auth login'
30
- - Cloudflare: Obtain an API Token (with Pages edit permissions) and your Account ID from the dashboard.
31
-
32
- 3. Configuration:
33
- Run this command with --cloudflare-token and --cloudflare-account to automate the full setup.
34
- Optional: Use --railway-token if you prefer not to use the interactive login.
35
- Optional: Use --railway-name to specify a custom Railway project name.
36
- Optional: Use --backend-name to specify the Railway service name (default: nexical-backend).
37
- Optional: Use --frontend-name to specify the Cloudflare Pages project name (default: nexical-frontend).
20
+ PROVIDERS:
21
+ - Backend: Railway, etc.
22
+ - Frontend: Cloudflare Pages, etc.
23
+ - Repository: GitHub, GitLab, etc.
38
24
 
39
25
  PROCESS:
40
- - Provisions a PostgreSQL database on Railway (if missing).
41
- - Creates a Cloudflare Pages project for the frontend.
42
- - Syncs all necessary deployment secrets and variables to GitHub for CI/CD automation.`;
26
+ 1. Loads environment variables from '.env'.
27
+ 2. Loads configuration from 'nexical.yaml'.
28
+ 3. Provisions resources via the selected providers.
29
+ 4. Configures the repository (secrets/variables) for CI/CD.
30
+ 5. Generates CI/CD workflow files.`;
43
31
 
44
32
  static args = {
45
33
  options: [
46
34
  {
47
- name: '--dry-run',
48
- description: 'Simulate the deployment process without making changes.',
49
- default: false,
50
- },
51
- {
52
- name: '--railway-token <token>',
53
- description: 'Railway Project Token (optional if already logged in).',
54
- },
55
- {
56
- name: '--railway-name <name>',
57
- description: 'Railway Project Name (used during initialization).',
35
+ name: '--backend <provider>',
36
+ description: 'Override backend provider',
58
37
  },
59
38
  {
60
- name: '--backend-name <name>',
61
- description: 'Backend service name on Railway.',
62
- default: 'nexical-backend',
39
+ name: '--frontend <provider>',
40
+ description: 'Override frontend provider',
63
41
  },
64
42
  {
65
- name: '--frontend-name <name>',
66
- description: 'Frontend project name on Cloudflare.',
67
- default: 'nexical-frontend',
43
+ name: '--repo <provider>',
44
+ description: 'Override repositroy provider',
68
45
  },
69
46
  {
70
- name: '--cloudflare-token <token>',
71
- description: 'Cloudflare API Token.',
72
- },
73
- {
74
- name: '--cloudflare-account <id>',
75
- description: 'Cloudflare Account ID.',
47
+ name: '--dry-run',
48
+ description: 'Simulate the deployment process',
49
+ default: false,
76
50
  },
77
51
  ],
78
52
  };
79
53
 
80
- async run(options: DeployOptions) {
81
- this.info('Starting Nexical Deployment Automation...');
54
+ async run(options: Record<string, unknown>) {
55
+ this.info('Starting Nexical Deployment...');
82
56
 
83
- if (options.dryRun) {
84
- this.notice('DRY RUN MODE ENABLED');
85
- }
57
+ // Load environment variables from .env
58
+ dotenv.config({ path: path.join(process.cwd(), '.env') });
86
59
 
87
- try {
88
- // 1. Railway Setup
89
- await this.setupRailway(options);
90
-
91
- // 2. Cloudflare Setup
92
- await this.setupCloudflare(options);
93
-
94
- // 3. GitHub Configuration (Secrets & Variables)
95
- await this.setupGitHubConfig(options);
96
-
97
- this.success('Deployment setup complete! Your application is being deployed.');
98
- } catch (error: unknown) {
99
- if (error instanceof Error) {
100
- this.error(`Deployment failed: ${error.message}`);
101
- } else {
102
- this.error(`Deployment failed: ${String(error)}`);
103
- }
104
- process.exit(1);
105
- }
106
- }
60
+ const configManager = new ConfigManager(process.cwd());
61
+ const config = await configManager.load();
62
+ const registry = new ProviderRegistry();
107
63
 
108
- private async setupRailway(options: DeployOptions) {
109
- this.info('Configuring Railway...');
64
+ // Register core and local providers
65
+ await registry.loadCoreProviders();
66
+ await registry.loadLocalProviders(process.cwd());
110
67
 
111
- if (options.dryRun) {
112
- const initCmd = options.railwayName
113
- ? `railway init --name ${options.railwayName}`
114
- : 'railway init';
115
- this.info(`[Dry Run] Would run: ${initCmd}`);
116
- this.info('[Dry Run] Would run: railway add --database postgres');
117
- return;
68
+ // Resolve providers (CLI flags > Config > Error)
69
+ const backendProviderName =
70
+ (options.backend as string | undefined) || config.deploy?.backend?.provider;
71
+ if (!backendProviderName) {
72
+ this.error(
73
+ "Backend provider not specified. Use --backend flag or configure 'deploy.backend.provider' in nexical.yaml.",
74
+ );
118
75
  }
119
76
 
120
- try {
121
- // Check if railway project exists or init
122
- // Note: railway init might be interactive, so we might need to handle that or assume user has linked.
123
- // For now, let's assume we use 'railway link' if they passed a token or have it set.
124
-
125
- this.info('Ensuring Railway project is linked...');
126
- // Note: We intentionally DO NOT set process.env.RAILWAY_TOKEN here.
127
- // Management commands (init, status, add) require an interactive session (railway login).
128
- // The provided --railway-token is reserved for GitHub Secrets setup (CI/CD).
129
-
130
- const backendDir = path.join(process.cwd(), 'apps/backend');
131
-
132
- try {
133
- await execAsync('railway status', { cwd: backendDir });
134
- } catch {
135
- const initCmd = options.railwayName
136
- ? `railway init --name ${options.railwayName}`
137
- : 'railway init';
138
- this.info(`No Railway project detected in apps/backend. Initializing with: ${initCmd}`);
139
- await execAsync(initCmd, { cwd: backendDir });
140
- }
141
-
142
- this.info(`Adding PostgreSQL service if missing for "${options.backendName}"...`);
143
- const { stdout: status } = await execAsync('railway status', { cwd: backendDir });
144
- if (!status.includes('postgres')) {
145
- await execAsync('railway add --database postgres', { cwd: backendDir });
146
- }
147
- } catch (e: unknown) {
148
- this.warn(
149
- 'Railway setup encountered an issue. Ensure you are logged in with `railway login`.',
77
+ const frontendProviderName =
78
+ (options.frontend as string | undefined) || config.deploy?.frontend?.provider;
79
+ if (!frontendProviderName) {
80
+ this.error(
81
+ "Frontend provider not specified. Use --frontend flag or configure 'deploy.frontend.provider' in nexical.yaml.",
150
82
  );
151
- throw e;
152
83
  }
153
- }
154
-
155
- private async setupCloudflare(options: DeployOptions) {
156
- this.info('Configuring Cloudflare Pages...');
157
84
 
158
- if (options.dryRun) {
159
- this.info(`[Dry Run] Would run: wrangler pages project create ${options.frontendName}`);
160
- return;
85
+ const repoProviderName =
86
+ (options.repo as string | undefined) || config.deploy?.repository?.provider;
87
+ if (!repoProviderName) {
88
+ this.error(
89
+ "Repository provider not specified. Use --repo flag or configure 'deploy.repository.provider' in nexical.yaml.",
90
+ );
161
91
  }
162
92
 
163
- if (!options.cloudflareToken || !options.cloudflareAccount) {
164
- this.warn('Cloudflare credentials missing. Skipping automated Cloudflare setup.');
165
- this.info('You can manually set up Cloudflare Pages and add the secrets to GitHub.');
166
- return;
93
+ const backendProvider = registry.getDeploymentProvider(backendProviderName!);
94
+ const frontendProvider = registry.getDeploymentProvider(frontendProviderName!);
95
+ const repoProvider = registry.getRepositoryProvider(repoProviderName!);
96
+
97
+ if (!backendProvider) throw new Error(`Backend provider '${backendProviderName}' not found.`);
98
+ if (!frontendProvider)
99
+ throw new Error(`Frontend provider '${frontendProviderName}' not found.`);
100
+ if (!repoProvider) throw new Error(`Repository provider '${repoProviderName}' not found.`);
101
+
102
+ const context: DeploymentContext = {
103
+ cwd: process.cwd(),
104
+ config,
105
+ options,
106
+ };
107
+
108
+ // Provision
109
+ this.info(`Provisioning Backend with ${backendProvider.name}...`);
110
+ await backendProvider.provision(context);
111
+
112
+ this.info(`Provisioning Frontend with ${frontendProvider.name}...`);
113
+ await frontendProvider.provision(context);
114
+
115
+ // Configure Repo
116
+ this.info(`Configuring Repository with ${repoProvider.name}...`);
117
+
118
+ const secrets: Record<string, string> = {};
119
+
120
+ // Collect secrets from Backend Provider
121
+ this.info(`Resolving secrets from ${backendProvider.name}...`);
122
+ try {
123
+ const backendSecrets = await backendProvider.getSecrets(context);
124
+ Object.assign(secrets, backendSecrets);
125
+ } catch (e: unknown) {
126
+ const message = e instanceof Error ? e.message : String(e);
127
+ this.error(`Failed to resolve secrets for ${backendProvider.name}: ${message}`);
167
128
  }
168
129
 
130
+ // Collect secrets from Frontend Provider
131
+ this.info(`Resolving secrets from ${frontendProvider.name}...`);
169
132
  try {
170
- // Use wrangler to create project if it doesn't exist
171
- const projectName = options.frontendName;
172
- this.info(`Ensuring Cloudflare Pages project "${projectName}" exists...`);
173
-
174
- try {
175
- await execAsync(`wrangler pages project create ${projectName} --production-branch main`, {
176
- env: {
177
- ...process.env,
178
- CLOUDFLARE_API_TOKEN: options.cloudflareToken,
179
- CLOUDFLARE_ACCOUNT_ID: options.cloudflareAccount,
180
- },
181
- });
182
- } catch {
183
- this.info('Cloudflare project might already exist.');
184
- }
133
+ const frontendSecrets = await frontendProvider.getSecrets(context);
134
+ Object.assign(secrets, frontendSecrets);
185
135
  } catch (e: unknown) {
186
- this.warn('Cloudflare setup failed.');
187
- throw e;
136
+ const message = e instanceof Error ? e.message : String(e);
137
+ this.error(`Failed to resolve secrets for ${frontendProvider.name}: ${message}`);
188
138
  }
189
- }
190
139
 
191
- private async setupGitHubConfig(options: DeployOptions) {
192
- this.info('Configuring GitHub Secrets and Variables...');
140
+ await repoProvider.configureSecrets(context, secrets);
193
141
 
194
- if (options.dryRun) {
195
- this.info('[Dry Run] Would run: gh secret set RAILWAY_TOKEN');
196
- this.info('[Dry Run] Would run: gh secret set CLOUDFLARE_API_TOKEN');
197
- this.info('[Dry Run] Would run: gh secret set CLOUDFLARE_ACCOUNT_ID');
198
- this.info(
199
- `[Dry Run] Would run: gh variable set RAILWAY_SERVICE_NAME --body "${options.backendName}"`,
200
- );
201
- this.info(
202
- `[Dry Run] Would run: gh variable set CLOUDFLARE_PROJECT_NAME --body "${options.frontendName}"`,
203
- );
204
- return;
142
+ const variables: Record<string, string> = {};
143
+
144
+ // Collect variables from Backend Provider
145
+ try {
146
+ const backendVars = await backendProvider.getVariables(context);
147
+ Object.assign(variables, backendVars);
148
+ } catch (e: unknown) {
149
+ const message = e instanceof Error ? e.message : String(e);
150
+ this.error(`Failed to resolve variables for ${backendProvider.name}: ${message}`);
205
151
  }
206
152
 
153
+ // Collect variables from Frontend Provider
207
154
  try {
208
- if (options.railwayToken) {
209
- this.info('Setting RAILWAY_TOKEN in GitHub...');
210
- await execAsync(`gh secret set RAILWAY_TOKEN --body "${options.railwayToken}"`);
211
- }
212
-
213
- if (options.cloudflareToken) {
214
- this.info('Setting CLOUDFLARE_API_TOKEN in GitHub...');
215
- await execAsync(`gh secret set CLOUDFLARE_API_TOKEN --body "${options.cloudflareToken}"`);
216
- }
217
-
218
- if (options.cloudflareAccount) {
219
- this.info('Setting CLOUDFLARE_ACCOUNT_ID in GitHub...');
220
- await execAsync(
221
- `gh secret set CLOUDFLARE_ACCOUNT_ID --body "${options.cloudflareAccount}"`,
222
- );
223
- }
224
-
225
- // Set variables
226
- this.info(`Setting RAILWAY_SERVICE_NAME to "${options.backendName}" in GitHub...`);
227
- await execAsync(`gh variable set RAILWAY_SERVICE_NAME --body "${options.backendName}"`);
228
-
229
- this.info(`Setting CLOUDFLARE_PROJECT_NAME to "${options.frontendName}" in GitHub...`);
230
- await execAsync(`gh variable set CLOUDFLARE_PROJECT_NAME --body "${options.frontendName}"`);
155
+ const frontendVars = await frontendProvider.getVariables(context);
156
+ Object.assign(variables, frontendVars);
231
157
  } catch (e: unknown) {
232
- this.warn(
233
- 'GitHub configuration failed. Ensure you have the GitHub CLI (gh) installed and are logged in.',
234
- );
235
- throw e;
158
+ const message = e instanceof Error ? e.message : String(e);
159
+ this.error(`Failed to resolve variables for ${frontendProvider.name}: ${message}`);
236
160
  }
161
+
162
+ await repoProvider.configureVariables(context, variables);
163
+
164
+ // Generate Workflows
165
+ this.info('Generating CI/CD Workflows...');
166
+ await repoProvider.generateWorkflow(context, [backendProvider, frontendProvider]);
167
+
168
+ this.success('Deployment configuration complete!');
237
169
  }
238
170
  }
@@ -0,0 +1,41 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import YAML from 'yaml';
4
+ import { NexicalConfig } from './types';
5
+
6
+ export class ConfigManager {
7
+ private configPath: string;
8
+
9
+ constructor(cwd: string) {
10
+ this.configPath = path.join(cwd, 'nexical.yaml');
11
+ }
12
+
13
+ async load(): Promise<NexicalConfig> {
14
+ try {
15
+ const content = await fs.readFile(this.configPath, 'utf-8');
16
+ return YAML.parse(content) as NexicalConfig;
17
+ } catch (error: unknown) {
18
+ if (
19
+ error &&
20
+ typeof error === 'object' &&
21
+ 'code' in error &&
22
+ (error as { code: unknown }).code === 'ENOENT'
23
+ ) {
24
+ return {};
25
+ }
26
+ throw error;
27
+ }
28
+ }
29
+
30
+ async save(config: NexicalConfig): Promise<void> {
31
+ const content = YAML.stringify(config);
32
+ await fs.writeFile(this.configPath, content, 'utf-8');
33
+ }
34
+
35
+ exists(): Promise<boolean> {
36
+ return fs
37
+ .access(this.configPath)
38
+ .then(() => true)
39
+ .catch(() => false);
40
+ }
41
+ }
@@ -0,0 +1,143 @@
1
+ import { logger } from '@nexical/cli-core';
2
+ import { DeploymentProvider, DeploymentContext, CIConfig } from '../types';
3
+ import { execAsync } from '../utils';
4
+
5
+ export class CloudflareProvider implements DeploymentProvider {
6
+ name = 'cloudflare';
7
+ type = 'frontend' as const;
8
+
9
+ async provision(context: DeploymentContext): Promise<void> {
10
+ const projectName = context.config.deploy?.frontend?.projectName;
11
+
12
+ if (!projectName) {
13
+ throw new Error(
14
+ "Cloudflare project name not found in nexical.yaml. Please configure 'deploy.frontend.projectName'.",
15
+ );
16
+ }
17
+
18
+ const options = context.config.deploy?.frontend?.options || {};
19
+
20
+ // Resolve credentials:
21
+ // 1. CLI flag (options)
22
+ // 2. Env var defined in config (options.apiTokenEnvVar)
23
+ // 3. Default env var (CLOUDFLARE_API_TOKEN)
24
+ const apiTokenEnvVar =
25
+ typeof options.apiTokenEnvVar === 'string' ? options.apiTokenEnvVar : undefined;
26
+ const apiToken =
27
+ (typeof context.options.cloudflareToken === 'string'
28
+ ? context.options.cloudflareToken
29
+ : undefined) ||
30
+ (apiTokenEnvVar ? process.env[apiTokenEnvVar] : undefined) ||
31
+ process.env.CLOUDFLARE_API_TOKEN;
32
+
33
+ const accountIdEnvVar =
34
+ typeof options.accountIdEnvVar === 'string' ? options.accountIdEnvVar : undefined;
35
+ const accountId =
36
+ (typeof context.options.cloudflareAccount === 'string'
37
+ ? context.options.cloudflareAccount
38
+ : undefined) ||
39
+ (accountIdEnvVar ? process.env[accountIdEnvVar] : undefined) ||
40
+ process.env.CLOUDFLARE_ACCOUNT_ID;
41
+
42
+ logger.info('Configuring Cloudflare Pages...');
43
+
44
+ if (context.options.dryRun) {
45
+ logger.info('[Dry Run] Would check Cloudflare Pages project and create if missing.');
46
+ return;
47
+ }
48
+
49
+ if (!apiToken || !accountId) {
50
+ logger.warn('Cloudflare credentials missing. Skipping automated Cloudflare setup.');
51
+ logger.info('You can manually set up Cloudflare Pages and add the secrets to GitHub.');
52
+ return;
53
+ }
54
+
55
+ try {
56
+ logger.info(`Ensuring Cloudflare Pages project "${projectName}" exists...`);
57
+ try {
58
+ await execAsync(`wrangler pages project create ${projectName} --production-branch main`, {
59
+ env: {
60
+ ...process.env,
61
+ CLOUDFLARE_API_TOKEN: apiToken,
62
+ CLOUDFLARE_ACCOUNT_ID: accountId,
63
+ },
64
+ });
65
+ } catch {
66
+ logger.info('Cloudflare project might already exist.');
67
+ }
68
+ } catch (e: unknown) {
69
+ logger.warn('Cloudflare setup failed.');
70
+ throw e;
71
+ }
72
+ }
73
+
74
+ async getSecrets(context: DeploymentContext): Promise<Record<string, string>> {
75
+ const options = context.config.deploy?.frontend?.options || {};
76
+ const secrets: Record<string, string> = {};
77
+
78
+ // Resolve API Token
79
+ const apiTokenEnvVar =
80
+ typeof options.apiTokenEnvVar === 'string' ? options.apiTokenEnvVar : undefined;
81
+ const apiToken =
82
+ (apiTokenEnvVar ? process.env[apiTokenEnvVar] : undefined) ||
83
+ process.env.CLOUDFLARE_API_TOKEN;
84
+
85
+ if (!apiToken) {
86
+ throw new Error(
87
+ `Cloudflare API Token not found. Please provide it via:\n` +
88
+ `1. Configuring 'deploy.frontend.options.apiTokenEnvVar' in nexical.yaml and setting that env var in .env\n` +
89
+ `2. Setting CLOUDFLARE_API_TOKEN in .env`,
90
+ );
91
+ }
92
+ secrets['CLOUDFLARE_API_TOKEN'] = apiToken;
93
+
94
+ // Resolve Account ID
95
+ const accountIdEnvVar =
96
+ typeof options.accountIdEnvVar === 'string' ? options.accountIdEnvVar : undefined;
97
+ const accountId =
98
+ (accountIdEnvVar ? process.env[accountIdEnvVar] : undefined) ||
99
+ process.env.CLOUDFLARE_ACCOUNT_ID;
100
+
101
+ if (!accountId) {
102
+ throw new Error(
103
+ `Cloudflare Account ID not found. Please provide it via:\n` +
104
+ `1. Configuring 'deploy.frontend.options.accountIdEnvVar' in nexical.yaml and setting that env var in .env\n` +
105
+ `2. Setting CLOUDFLARE_ACCOUNT_ID in .env`,
106
+ );
107
+ }
108
+ secrets['CLOUDFLARE_ACCOUNT_ID'] = accountId;
109
+
110
+ return secrets;
111
+ }
112
+
113
+ async getVariables(context: DeploymentContext): Promise<Record<string, string>> {
114
+ const projectName = context.config.deploy?.frontend?.projectName;
115
+
116
+ if (!projectName) {
117
+ throw new Error(
118
+ "Cloudflare project name not found in nexical.yaml. Please configure 'deploy.frontend.projectName'.",
119
+ );
120
+ }
121
+ return {
122
+ CLOUDFLARE_PROJECT_NAME: projectName,
123
+ };
124
+ }
125
+
126
+ getCIConfig(): CIConfig {
127
+ return {
128
+ secrets: ['CLOUDFLARE_API_TOKEN', 'CLOUDFLARE_ACCOUNT_ID'],
129
+ variables: ['CLOUDFLARE_PROJECT_NAME'],
130
+ deploySteps: [], // Handled by action
131
+ githubActionStep: {
132
+ name: 'Deploy to Cloudflare Pages',
133
+ uses: 'cloudflare/wrangler-action@v3',
134
+ with: {
135
+ apiToken: '${{ secrets.CLOUDFLARE_API_TOKEN }}',
136
+ accountId: '${{ secrets.CLOUDFLARE_ACCOUNT_ID }}',
137
+ command: 'pages deploy dist --project-name=${{ vars.CLOUDFLARE_PROJECT_NAME }}',
138
+ workingDirectory: 'apps/frontend',
139
+ },
140
+ },
141
+ };
142
+ }
143
+ }