@cognite/dune 0.1.2 → 0.2.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 (39) hide show
  1. package/_templates/app/new/config/biome.json.ejs.t +54 -0
  2. package/_templates/app/new/config/components.json.ejs.t +28 -0
  3. package/_templates/app/new/config/tailwind.config.js.ejs.t +15 -0
  4. package/_templates/app/new/{tsconfig.json.ejs.t → config/tsconfig.json.ejs.t} +4 -1
  5. package/_templates/app/new/config/vite.config.ts.ejs.t +27 -0
  6. package/_templates/app/new/cursor/data-modeling.mdc.ejs.t +1996 -0
  7. package/_templates/app/new/cursor/mcp.json.ejs.t +15 -0
  8. package/_templates/app/new/cursor/rules.mdc.ejs.t +12 -0
  9. package/_templates/app/new/root/PRD.md.ejs.t +5 -0
  10. package/_templates/app/new/{package.json.ejs.t → root/package.json.ejs.t} +12 -3
  11. package/_templates/app/new/{App.test.tsx.ejs.t → src/App.test.tsx.ejs.t} +5 -5
  12. package/_templates/app/new/{App.tsx.ejs.t → src/App.tsx.ejs.t} +2 -3
  13. package/_templates/app/new/src/lib/utils.ts.ejs.t +10 -0
  14. package/_templates/app/new/{main.tsx.ejs.t → src/main.tsx.ejs.t} +2 -0
  15. package/_templates/app/new/src/styles.css.ejs.t +25 -0
  16. package/bin/auth/authentication-flow.js +89 -0
  17. package/bin/auth/callback-server.js +181 -0
  18. package/bin/auth/certificate-manager.js +81 -0
  19. package/bin/auth/client-credentials.js +240 -0
  20. package/bin/auth/oauth-client.js +92 -0
  21. package/bin/cli.js +45 -5
  22. package/bin/deploy-command.js +246 -0
  23. package/bin/deploy-interactive-command.js +382 -0
  24. package/bin/utils/crypto.js +35 -0
  25. package/dist/deploy/index.d.ts +7 -0
  26. package/dist/deploy/index.js +43 -1
  27. package/dist/vite/index.js +1 -1
  28. package/package.json +3 -2
  29. package/src/deploy/application-deployer.ts +38 -0
  30. package/src/deploy/deploy.ts +8 -0
  31. package/src/vite/fusion-open-plugin.ts +1 -1
  32. package/_templates/app/new/biome.json.ejs.t +0 -25
  33. package/_templates/app/new/vite.config.ts.ejs.t +0 -15
  34. /package/_templates/app/new/{tsconfig.node.json.ejs.t → config/tsconfig.node.json.ejs.t} +0 -0
  35. /package/_templates/app/new/{vitest.config.ts.ejs.t → config/vitest.config.ts.ejs.t} +0 -0
  36. /package/_templates/app/new/{vitest.setup.ts.ejs.t → config/vitest.setup.ts.ejs.t} +0 -0
  37. /package/_templates/app/new/{app.json.ejs.t → root/app.json.ejs.t} +0 -0
  38. /package/_templates/app/new/{gitignore.ejs.t → root/gitignore.ejs.t} +0 -0
  39. /package/_templates/app/new/{index.html.ejs.t → root/index.html.ejs.t} +0 -0
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Extract cluster name from BASE_URL
3
+ * @param {string} url - Full URL like "https://aw-dub-gp-001.cognitedata.com"
4
+ * @returns {string} Cluster name like "aw-dub-gp-001"
5
+ */
6
+ function extractClusterFromUrl(url) {
7
+ if (!url) return "";
8
+
9
+ try {
10
+ const urlObj = new URL(url);
11
+ const hostname = urlObj.hostname;
12
+ // Remove .cognitedata.com suffix
13
+ return hostname.replace(/\.cognitedata\.com$/, "");
14
+ } catch (error) {
15
+ // Fallback to simple string manipulation
16
+ let cluster = url.replace(/^https?:\/\//, "");
17
+ cluster = cluster.split("/")[0]; // Remove path
18
+ cluster = cluster.split(":")[0]; // Remove port
19
+ cluster = cluster.replace(/\.cognitedata\.com$/, "");
20
+ return cluster;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Client Credentials Authentication
26
+ *
27
+ * Handles authentication using OAuth2 client credentials flow for CI/CD environments.
28
+ * Supports both CDF and Entra ID authentication providers.
29
+ */
30
+
31
+ // biome-ignore lint/complexity/noStaticOnlyClass: <explanation>
32
+ export class ClientCredentialsAuth {
33
+ /**
34
+ * Get access token using client credentials (CDF provider)
35
+ * @param {string} clientId - OAuth client ID
36
+ * @param {string} clientSecret - OAuth client secret
37
+ * @returns {Promise<string>} Access token
38
+ */
39
+ static async getTokenCdf(clientId, clientSecret) {
40
+ if (!clientId || !clientSecret) {
41
+ throw new Error("CLIENT_ID and CLIENT_SECRET must be provided");
42
+ }
43
+
44
+ const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
45
+ const header = `Basic ${credentials}`;
46
+
47
+ console.log("🔑 Authenticating with CDF client credentials...");
48
+
49
+ try {
50
+ const response = await fetch("https://auth.cognite.com/oauth2/token", {
51
+ method: "POST",
52
+ headers: {
53
+ Authorization: header,
54
+ "Content-Type": "application/x-www-form-urlencoded",
55
+ },
56
+ body: new URLSearchParams({
57
+ grant_type: "client_credentials",
58
+ }),
59
+ });
60
+
61
+ if (!response.ok) {
62
+ const errorText = await response.text();
63
+ throw new Error(
64
+ `Authentication failed: ${response.status} ${response.statusText}\n${errorText}`
65
+ );
66
+ }
67
+
68
+ const data = await response.json();
69
+
70
+ if (!data.access_token) {
71
+ throw new Error("No access token returned from authentication");
72
+ }
73
+
74
+ return data.access_token;
75
+ } catch (error) {
76
+ throw new Error(`Failed to authenticate with CDF: ${error.message}`);
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Get access token using client credentials (Entra ID provider)
82
+ * @param {string} clientId - OAuth client ID
83
+ * @param {string} clientSecret - OAuth client secret
84
+ * @param {string} tokenUrl - Token endpoint URL
85
+ * @param {string} scope - OAuth scope
86
+ * @returns {Promise<string>} Access token
87
+ */
88
+ static async getTokenEntra(clientId, clientSecret, tokenUrl, scope) {
89
+ if (!clientId || !clientSecret || !tokenUrl || !scope) {
90
+ throw new Error(
91
+ "CLIENT_ID, CLIENT_SECRET, TOKEN_URL, and SCOPES must be provided for Entra ID"
92
+ );
93
+ }
94
+
95
+ console.log("🔑 Authenticating with Entra ID client credentials...");
96
+
97
+ try {
98
+ const response = await fetch(tokenUrl, {
99
+ method: "POST",
100
+ headers: {
101
+ "Content-Type": "application/x-www-form-urlencoded",
102
+ },
103
+ body: new URLSearchParams({
104
+ client_id: clientId,
105
+ client_secret: clientSecret,
106
+ scope: scope,
107
+ grant_type: "client_credentials",
108
+ }),
109
+ });
110
+
111
+ if (!response.ok) {
112
+ const errorText = await response.text();
113
+ throw new Error(
114
+ `Authentication failed: ${response.status} ${response.statusText}\n${errorText}`
115
+ );
116
+ }
117
+
118
+ const data = await response.json();
119
+
120
+ if (!data.access_token) {
121
+ throw new Error("No access token returned from authentication");
122
+ }
123
+
124
+ return data.access_token;
125
+ } catch (error) {
126
+ throw new Error(`Failed to authenticate with Entra ID: ${error.message}`);
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Get access token using the configured provider
132
+ * @param {Object} config - Authentication configuration
133
+ * @param {string} config.provider - Provider type ('cdf' or 'entra')
134
+ * @param {string} config.clientId - OAuth client ID
135
+ * @param {string} config.clientSecret - OAuth client secret
136
+ * @param {string} [config.tenantId] - Tenant ID (required for Entra)
137
+ * @param {string} [config.tokenUrl] - Token URL (optional for Entra, will be auto-generated)
138
+ * @param {string} [config.scopes] - OAuth scopes (optional for Entra, will be auto-generated)
139
+ * @param {string} [config.cluster] - CDF cluster (used for auto-generating scope)
140
+ * @returns {Promise<string>} Access token
141
+ */
142
+ static async getToken(config) {
143
+ const provider = (config.provider || "cdf").toLowerCase();
144
+
145
+ if (provider === "cdf") {
146
+ return ClientCredentialsAuth.getTokenCdf(config.clientId, config.clientSecret);
147
+ }
148
+ if (provider === "entra") {
149
+ const tokenUrl = ClientCredentialsAuth.buildEntraTokenUrl(config.tenantId, config.tokenUrl);
150
+ const scope = ClientCredentialsAuth.buildEntraScope(config.cluster, config.scopes);
151
+
152
+ return ClientCredentialsAuth.getTokenEntra(
153
+ config.clientId,
154
+ config.clientSecret,
155
+ tokenUrl,
156
+ scope
157
+ );
158
+ }
159
+ throw new Error(`Unsupported provider: ${provider}. Supported providers: 'cdf', 'entra'`);
160
+ }
161
+
162
+ /**
163
+ * Build Entra ID token URL
164
+ * @param {string} tenantId - Tenant ID
165
+ * @param {string} [tokenUrl] - Optional token URL override
166
+ * @returns {string} Token URL
167
+ */
168
+ static buildEntraTokenUrl(tenantId, tokenUrl) {
169
+ if (tokenUrl) {
170
+ return tokenUrl;
171
+ }
172
+
173
+ if (!tenantId) {
174
+ throw new Error("TENANT_ID is required for Entra ID authentication (or provide TOKEN_URL)");
175
+ }
176
+
177
+ return `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
178
+ }
179
+
180
+ /**
181
+ * Build Entra ID scope
182
+ * @param {string} cluster - CDF cluster name (e.g., "aw-dub-gp-001")
183
+ * @param {string} [scopes] - Optional scopes override
184
+ * @returns {string} Scope
185
+ */
186
+ static buildEntraScope(cluster, scopes) {
187
+ if (scopes) return scopes;
188
+
189
+ if (!cluster) {
190
+ throw new Error(
191
+ "Cluster information is required to auto-generate scopes (or provide SCOPES)"
192
+ );
193
+ }
194
+
195
+ return `https://${cluster}.cognitedata.com/.default`;
196
+ }
197
+
198
+ /**
199
+ * Load credentials from environment variables
200
+ * @returns {Object} Configuration object with provider and credentials
201
+ */
202
+ static loadFromEnv() {
203
+ const provider = (process.env.PROVIDER || "cdf").toLowerCase();
204
+ const clientId = process.env.CLIENT_ID;
205
+ const clientSecret = process.env.CLIENT_SECRET;
206
+
207
+ if (!clientId || !clientSecret) {
208
+ throw new Error(
209
+ "Missing required environment variables: CLIENT_ID and CLIENT_SECRET must be set"
210
+ );
211
+ }
212
+
213
+ const config = {
214
+ provider,
215
+ clientId,
216
+ clientSecret,
217
+ };
218
+
219
+ // Load provider-specific configuration
220
+ if (provider === "entra") {
221
+ config.tenantId = process.env.TENANT_ID;
222
+ config.tokenUrl = process.env.TOKEN_URL;
223
+ config.scopes = process.env.SCOPES;
224
+
225
+ // Validate required fields
226
+ if (!config.tenantId && !config.tokenUrl) {
227
+ throw new Error("For Entra ID provider, either TENANT_ID or TOKEN_URL must be set");
228
+ }
229
+
230
+ // Extract cluster from BASE_URL for scope generation
231
+ if (process.env.BASE_URL) {
232
+ config.cluster = extractClusterFromUrl(process.env.BASE_URL);
233
+ } else if (!config.scopes) {
234
+ throw new Error("For Entra ID provider, either BASE_URL or SCOPES must be set");
235
+ }
236
+ }
237
+
238
+ return config;
239
+ }
240
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * OAuth 2.0 / OIDC Client
3
+ *
4
+ * Handles OAuth 2.0 authentication flow with PKCE for CDF.
5
+ */
6
+
7
+ export class OAuthClient {
8
+ /**
9
+ * @param {Object} config - OAuth configuration
10
+ * @param {string} config.clientId - OAuth client ID
11
+ * @param {string} config.authority - OAuth authority URL
12
+ * @param {string} config.redirectUri - OAuth redirect URI
13
+ */
14
+ constructor(config) {
15
+ this.config = config;
16
+ }
17
+
18
+ /**
19
+ * Fetch OpenID Connect configuration from authority
20
+ * @returns {Promise<Object>} OpenID configuration including endpoints
21
+ * @throws {Error} If configuration fetch fails
22
+ */
23
+ async fetchOpenIdConfiguration() {
24
+ const url = `${this.config.authority}/.well-known/openid-configuration`;
25
+ const response = await fetch(url);
26
+
27
+ if (!response.ok) {
28
+ throw new Error(`Failed to fetch OpenID configuration: ${response.statusText}`);
29
+ }
30
+
31
+ return response.json();
32
+ }
33
+
34
+ /**
35
+ * Exchange authorization code for access tokens
36
+ * @param {string} tokenEndpoint - Token endpoint URL
37
+ * @param {string} code - Authorization code
38
+ * @param {string} codeVerifier - PKCE code verifier
39
+ * @returns {Promise<Object>} Token response with access_token
40
+ * @throws {Error} If token exchange fails
41
+ */
42
+ async exchangeCodeForTokens(tokenEndpoint, code, codeVerifier) {
43
+ const params = new URLSearchParams({
44
+ grant_type: "authorization_code",
45
+ client_id: this.config.clientId,
46
+ code: code,
47
+ redirect_uri: this.config.redirectUri,
48
+ code_verifier: codeVerifier,
49
+ });
50
+
51
+ const response = await fetch(tokenEndpoint, {
52
+ method: "POST",
53
+ headers: {
54
+ "Content-Type": "application/x-www-form-urlencoded",
55
+ },
56
+ body: params.toString(),
57
+ });
58
+
59
+ if (!response.ok) {
60
+ const error = await response.text();
61
+ throw new Error(`Token exchange failed: ${error}`);
62
+ }
63
+
64
+ return response.json();
65
+ }
66
+
67
+ /**
68
+ * Build authorization URL with PKCE parameters
69
+ * @param {string} authorizationEndpoint - Authorization endpoint URL
70
+ * @param {string} codeChallenge - PKCE code challenge
71
+ * @param {string} state - CSRF protection state
72
+ * @param {string} [organization] - Optional organization hint
73
+ * @returns {string} Complete authorization URL
74
+ */
75
+ buildAuthorizationUrl(authorizationEndpoint, codeChallenge, state, organization) {
76
+ const params = new URLSearchParams({
77
+ client_id: this.config.clientId,
78
+ redirect_uri: this.config.redirectUri,
79
+ response_type: "code",
80
+ scope: "openid profile email",
81
+ state: state,
82
+ code_challenge: codeChallenge,
83
+ code_challenge_method: "S256",
84
+ });
85
+
86
+ if (organization) {
87
+ params.append("organization_hint", organization);
88
+ }
89
+
90
+ return `${authorizationEndpoint}?${params.toString()}`;
91
+ }
92
+ }
package/bin/cli.js CHANGED
@@ -18,11 +18,25 @@ async function main() {
18
18
  try {
19
19
  const enquirer = await import("enquirer");
20
20
 
21
+ // Track the app name from prompts
22
+ let appName = null;
23
+
21
24
  await runner(hygenArgs, {
22
25
  templates: defaultTemplates,
23
26
  cwd: process.cwd(),
24
27
  logger: new Logger(console.log.bind(console)),
25
- createPrompter: () => enquirer.default,
28
+ createPrompter: () => {
29
+ // Wrap enquirer to capture the app name
30
+ return {
31
+ prompt: async (prompts) => {
32
+ const result = await enquirer.default.prompt(prompts);
33
+ if (result.name) {
34
+ appName = result.name;
35
+ }
36
+ return result;
37
+ },
38
+ };
39
+ },
26
40
  exec: async (action, body) => {
27
41
  const { execa } = await import("execa");
28
42
  const opts = body && body.length > 0 ? { input: body } : {};
@@ -30,10 +44,32 @@ async function main() {
30
44
  },
31
45
  debug: !!process.env.DEBUG,
32
46
  });
47
+
48
+ // Print success message with next steps
49
+ console.log(`
50
+ ✅ App created successfully!
51
+
52
+ To open in Cursor:
53
+ cursor ${appName || "your-app"}
54
+ Or:
55
+ cd ${appName || "your-app"}
56
+ pnpm install
57
+ pnpm dev
58
+
59
+ To deploy your app:
60
+ cd ${appName || "your-app"}
61
+ npx @cognite/dune deploy:interactive
62
+ `);
33
63
  } catch (error) {
34
64
  console.error("Error:", error.message);
35
65
  process.exit(1);
36
66
  }
67
+ } else if (command === "deploy") {
68
+ const { handleDeploy } = await import("./deploy-command.js");
69
+ await handleDeploy(args.slice(1));
70
+ } else if (command === "deploy:interactive") {
71
+ const { handleDeployInteractive } = await import("./deploy-interactive-command.js");
72
+ await handleDeployInteractive(args.slice(1));
37
73
  } else if (command === "help" || command === "--help" || command === "-h") {
38
74
  console.log(`
39
75
  @cognite/dune - Build and deploy React apps to Cognite Data Fusion
@@ -42,12 +78,16 @@ Usage:
42
78
  npx @cognite/dune [command]
43
79
 
44
80
  Commands:
45
- create, new Create a new Dune application (default)
46
- help Show this help message
81
+ create, new Create a new Dune application (default)
82
+ deploy Build and deploy using environment credentials
83
+ deploy:interactive Build and deploy with browser-based login
84
+ help Show this help message
47
85
 
48
86
  Examples:
49
- npx @cognite/dune # Create a new app (interactive)
50
- npx @cognite/dune create # Create a new app (interactive)
87
+ npx @cognite/dune # Create a new app (interactive)
88
+ npx @cognite/dune create # Create a new app (interactive)
89
+ npx @cognite/dune deploy # Deploy with env credentials
90
+ npx @cognite/dune deploy:interactive # Deploy with browser login
51
91
 
52
92
  For programmatic usage:
53
93
  import { DuneAuthProvider, useDune } from "@cognite/dune/auth"
@@ -0,0 +1,246 @@
1
+ import { execSync } from "node:child_process";
2
+ import { existsSync, readFileSync, unlinkSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+
5
+ /**
6
+ * Generate Fusion app URL
7
+ */
8
+ function generateFusionUrl(deployment, appExternalId, versionTag) {
9
+ const { org, project, baseUrl } = deployment;
10
+ const cluster = baseUrl?.split("//")[1];
11
+
12
+ if (!org || !project || !cluster) {
13
+ return null;
14
+ }
15
+
16
+ return `https://${org}.fusion.cognite.com/${project}/streamlit-apps/dune/${appExternalId}-${versionTag}?cluster=${cluster}&workspace=industrial-tools`;
17
+ }
18
+
19
+ /**
20
+ * Load app.json from a directory
21
+ */
22
+ function loadAppConfig(appDir) {
23
+ const configPath = resolve(appDir, "app.json");
24
+ if (!existsSync(configPath)) {
25
+ return null;
26
+ }
27
+ try {
28
+ return JSON.parse(readFileSync(configPath, "utf-8"));
29
+ } catch (error) {
30
+ console.error(`Error parsing app.json: ${error.message}`);
31
+ return null;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Load .env file from a directory
37
+ */
38
+ function loadEnvFile(dir) {
39
+ const envPath = resolve(dir, ".env");
40
+ if (existsSync(envPath)) {
41
+ console.log(`Loading environment variables from ${envPath}`);
42
+ const envContent = readFileSync(envPath, "utf-8");
43
+ envContent.split("\n").forEach((line) => {
44
+ const trimmedLine = line.trim();
45
+ if (!trimmedLine || trimmedLine.startsWith("#")) {
46
+ return;
47
+ }
48
+ const match = trimmedLine.match(/^([^=]+)=(.*)$/);
49
+ if (match) {
50
+ const key = match[1].trim();
51
+ const value = match[2].trim().replace(/^["']|["']$/g, "");
52
+ process.env[key] = value;
53
+ }
54
+ });
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Detect the package manager being used
60
+ */
61
+ function detectPackageManager(appDir) {
62
+ if (existsSync(resolve(appDir, "pnpm-lock.yaml"))) return "pnpm";
63
+ if (existsSync(resolve(appDir, "yarn.lock"))) return "yarn";
64
+ if (existsSync(resolve(appDir, "bun.lockb"))) return "bun";
65
+ return "npm";
66
+ }
67
+
68
+ /**
69
+ * Build the app
70
+ */
71
+ function buildApp(appDir, verbose = true) {
72
+ const pm = detectPackageManager(appDir);
73
+ console.log(`📦 Building app with ${pm}...`);
74
+
75
+ execSync(`${pm} run build`, {
76
+ cwd: appDir,
77
+ stdio: verbose ? "inherit" : "pipe",
78
+ });
79
+
80
+ console.log("✅ Build successful");
81
+ }
82
+
83
+ /**
84
+ * Print deploy help message
85
+ */
86
+ function printHelp() {
87
+ console.log(`
88
+ Deploy your Dune app to Cognite Data Fusion
89
+
90
+ Usage:
91
+ npx @cognite/dune deploy [options]
92
+
93
+ Options:
94
+ --deployment, -d <target> Deployment target (index or project name)
95
+ --skip-build Skip the build step
96
+ --help, -h Show this help message
97
+
98
+ Environment:
99
+ The deploy command requires authentication credentials to be set via
100
+ environment variables. Set the secret specified in deploySecretName
101
+ in your app.json, or use a .env file.
102
+
103
+ Examples:
104
+ npx @cognite/dune deploy # Deploy to first deployment target
105
+ npx @cognite/dune deploy -d 1 # Deploy to second deployment target
106
+ npx @cognite/dune deploy -d my-project # Deploy to project by name
107
+ npx @cognite/dune deploy --skip-build # Deploy without rebuilding
108
+ `);
109
+ }
110
+
111
+ /**
112
+ * Deploy command handler
113
+ */
114
+ export async function handleDeploy(args) {
115
+ // Check for help first, before any other operations
116
+ if (args.includes("--help") || args.includes("-h")) {
117
+ printHelp();
118
+ process.exit(0);
119
+ }
120
+
121
+ const cwd = process.cwd();
122
+
123
+ // Load .env file if present
124
+ loadEnvFile(cwd);
125
+
126
+ // Load app.json
127
+ const appConfig = loadAppConfig(cwd);
128
+ if (!appConfig) {
129
+ console.error("❌ No app.json found in current directory");
130
+ console.error("Make sure you're running this command from your app's root directory.");
131
+ process.exit(1);
132
+ }
133
+
134
+ if (!appConfig.deployments || appConfig.deployments.length === 0) {
135
+ console.error("❌ No deployments configured in app.json");
136
+ process.exit(1);
137
+ }
138
+
139
+ // Parse arguments
140
+ let deploymentIndex = 0;
141
+ let skipBuild = false;
142
+
143
+ for (let i = 0; i < args.length; i++) {
144
+ const arg = args[i];
145
+ if (arg === "--deployment" || arg === "-d") {
146
+ const value = args[++i];
147
+ if (value === undefined) {
148
+ console.error("❌ --deployment requires a value (index or project name)");
149
+ process.exit(1);
150
+ }
151
+ // Try to parse as index first
152
+ const idx = Number.parseInt(value, 10);
153
+ if (!Number.isNaN(idx)) {
154
+ deploymentIndex = idx;
155
+ } else {
156
+ // Search by project name
157
+ const found = appConfig.deployments.findIndex(
158
+ (d) => d.project === value || `${d.org}/${d.project}` === value
159
+ );
160
+ if (found === -1) {
161
+ console.error(`❌ No deployment found for project: ${value}`);
162
+ console.log("Available deployments:");
163
+ appConfig.deployments.forEach((d, i) => {
164
+ console.log(` ${i}: ${d.org}/${d.project}`);
165
+ });
166
+ process.exit(1);
167
+ }
168
+ deploymentIndex = found;
169
+ }
170
+ } else if (arg === "--skip-build") {
171
+ skipBuild = true;
172
+ }
173
+ }
174
+
175
+ // Validate deployment index
176
+ if (deploymentIndex < 0 || deploymentIndex >= appConfig.deployments.length) {
177
+ console.error(`❌ Invalid deployment index: ${deploymentIndex}`);
178
+ console.log(`Available deployments (0-${appConfig.deployments.length - 1}):`);
179
+ appConfig.deployments.forEach((d, i) => {
180
+ console.log(` ${i}: ${d.org}/${d.project}`);
181
+ });
182
+ process.exit(1);
183
+ }
184
+
185
+ const deployment = appConfig.deployments[deploymentIndex];
186
+
187
+ console.log("\n🚀 Dune Deploy");
188
+ console.log("================");
189
+ console.log(`App: ${appConfig.name} (${appConfig.externalId})`);
190
+ console.log(`Version: ${appConfig.versionTag}`);
191
+ console.log(`Target: ${deployment.org}/${deployment.project}`);
192
+ console.log();
193
+
194
+ // Build the app
195
+ if (!skipBuild) {
196
+ try {
197
+ buildApp(cwd);
198
+ } catch (error) {
199
+ console.error("❌ Build failed:", error.message);
200
+ process.exit(1);
201
+ }
202
+ }
203
+
204
+ // Import deploy function and deploy
205
+ try {
206
+ const { deploy } = await import("../dist/deploy/index.js");
207
+
208
+ console.log(`\n📤 Deploying to ${deployment.org}/${deployment.project}...`);
209
+
210
+ await deploy(
211
+ deployment,
212
+ {
213
+ externalId: appConfig.externalId,
214
+ name: appConfig.name,
215
+ description: appConfig.description,
216
+ versionTag: appConfig.versionTag,
217
+ },
218
+ cwd
219
+ );
220
+
221
+ console.log(
222
+ `\n✅ Successfully deployed ${appConfig.name} to ${deployment.org}/${deployment.project}`
223
+ );
224
+
225
+ if (deployment.published) {
226
+ console.log("🌐 App is published and available to all users");
227
+ } else {
228
+ console.log("🔒 App is deployed in draft mode");
229
+ }
230
+
231
+ // Generate and display the app URL
232
+ const appUrl = generateFusionUrl(deployment, appConfig.externalId, appConfig.versionTag);
233
+ if (appUrl) {
234
+ console.log(`\n🔗 Open your app:\n ${appUrl}`);
235
+ }
236
+
237
+ process.exit(0);
238
+ } catch (error) {
239
+ console.error("\n❌ Deployment failed:", error.message);
240
+ if (error.message.includes("secret not found")) {
241
+ console.log(`\nMake sure the environment variable "${deployment.deploySecretName}" is set.`);
242
+ console.log("You can set it in a .env file in your app's root directory.");
243
+ }
244
+ process.exit(1);
245
+ }
246
+ }