@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.
- package/_templates/app/new/config/biome.json.ejs.t +54 -0
- package/_templates/app/new/config/components.json.ejs.t +28 -0
- package/_templates/app/new/config/tailwind.config.js.ejs.t +15 -0
- package/_templates/app/new/{tsconfig.json.ejs.t → config/tsconfig.json.ejs.t} +4 -1
- package/_templates/app/new/config/vite.config.ts.ejs.t +27 -0
- package/_templates/app/new/cursor/data-modeling.mdc.ejs.t +1996 -0
- package/_templates/app/new/cursor/mcp.json.ejs.t +15 -0
- package/_templates/app/new/cursor/rules.mdc.ejs.t +12 -0
- package/_templates/app/new/root/PRD.md.ejs.t +5 -0
- package/_templates/app/new/{package.json.ejs.t → root/package.json.ejs.t} +12 -3
- package/_templates/app/new/{App.test.tsx.ejs.t → src/App.test.tsx.ejs.t} +5 -5
- package/_templates/app/new/{App.tsx.ejs.t → src/App.tsx.ejs.t} +2 -3
- package/_templates/app/new/src/lib/utils.ts.ejs.t +10 -0
- package/_templates/app/new/{main.tsx.ejs.t → src/main.tsx.ejs.t} +2 -0
- package/_templates/app/new/src/styles.css.ejs.t +25 -0
- package/bin/auth/authentication-flow.js +89 -0
- package/bin/auth/callback-server.js +181 -0
- package/bin/auth/certificate-manager.js +81 -0
- package/bin/auth/client-credentials.js +240 -0
- package/bin/auth/oauth-client.js +92 -0
- package/bin/cli.js +45 -5
- package/bin/deploy-command.js +246 -0
- package/bin/deploy-interactive-command.js +382 -0
- package/bin/utils/crypto.js +35 -0
- package/dist/deploy/index.d.ts +7 -0
- package/dist/deploy/index.js +43 -1
- package/dist/vite/index.js +1 -1
- package/package.json +3 -2
- package/src/deploy/application-deployer.ts +38 -0
- package/src/deploy/deploy.ts +8 -0
- package/src/vite/fusion-open-plugin.ts +1 -1
- package/_templates/app/new/biome.json.ejs.t +0 -25
- package/_templates/app/new/vite.config.ts.ejs.t +0 -15
- /package/_templates/app/new/{tsconfig.node.json.ejs.t → config/tsconfig.node.json.ejs.t} +0 -0
- /package/_templates/app/new/{vitest.config.ts.ejs.t → config/vitest.config.ts.ejs.t} +0 -0
- /package/_templates/app/new/{vitest.setup.ts.ejs.t → config/vitest.setup.ts.ejs.t} +0 -0
- /package/_templates/app/new/{app.json.ejs.t → root/app.json.ejs.t} +0 -0
- /package/_templates/app/new/{gitignore.ejs.t → root/gitignore.ejs.t} +0 -0
- /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: () =>
|
|
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
|
|
46
|
-
|
|
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
|
|
50
|
-
npx @cognite/dune create
|
|
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
|
+
}
|