@fluid-app/fluid-cli-portal 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +680 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +2230 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +54 -0
- package/templates/base/index.html +14 -0
- package/templates/base/src/App.tsx +18 -0
- package/templates/base/src/index.css +80 -0
- package/templates/base/src/main.tsx +42 -0
- package/templates/base/src/navigation.config.ts +21 -0
- package/templates/base/src/portal.config.ts +32 -0
- package/templates/base/src/screens/Dashboard.tsx +133 -0
- package/templates/base/src/screens/ExampleForm.tsx +187 -0
- package/templates/base/src/vite-env.d.ts +1 -0
- package/templates/base/tsconfig.json +26 -0
- package/templates/fullstack/.dockerignore +9 -0
- package/templates/fullstack/.env.example +15 -0
- package/templates/fullstack/.github/workflows/ci.yml +47 -0
- package/templates/fullstack/.github/workflows/deploy.yml +54 -0
- package/templates/fullstack/Dockerfile +44 -0
- package/templates/fullstack/README.md.template +176 -0
- package/templates/fullstack/drizzle/0000_initial.sql +7 -0
- package/templates/fullstack/drizzle/meta/0000_snapshot.json +63 -0
- package/templates/fullstack/drizzle/meta/_journal.json +13 -0
- package/templates/fullstack/drizzle.config.ts +13 -0
- package/templates/fullstack/esbuild.config.js +14 -0
- package/templates/fullstack/eslint.config.js +13 -0
- package/templates/fullstack/package.json.template +63 -0
- package/templates/fullstack/src/fluid.config.ts.template +69 -0
- package/templates/fullstack/src/server/db/index.ts +10 -0
- package/templates/fullstack/src/server/db/migrate.ts +12 -0
- package/templates/fullstack/src/server/db/schema.ts +14 -0
- package/templates/fullstack/src/server/entry.ts +59 -0
- package/templates/fullstack/src/server/index.ts +33 -0
- package/templates/fullstack/src/server/routes/index.test.ts +123 -0
- package/templates/fullstack/src/server/routes/index.ts +109 -0
- package/templates/fullstack/src/server/routes/schemas.ts +7 -0
- package/templates/fullstack/src/test/setup.ts +9 -0
- package/templates/fullstack/vite.config.ts +39 -0
- package/templates/fullstack/vitest.config.ts +9 -0
- package/templates/starter/.env.example +1 -0
- package/templates/starter/README.md.template +218 -0
- package/templates/starter/package.json.template +40 -0
- package/templates/starter/src/fluid.config.ts.template +69 -0
- package/templates/starter/vite.config.ts +12 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,2230 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { copyFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
6
|
+
import prompts from "prompts";
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import Handlebars from "handlebars";
|
|
10
|
+
import { execa } from "execa";
|
|
11
|
+
import fs from "fs-extra";
|
|
12
|
+
import path from "path";
|
|
13
|
+
import { config } from "dotenv";
|
|
14
|
+
//#region src/types.ts
|
|
15
|
+
/**
|
|
16
|
+
* Available project templates
|
|
17
|
+
*/
|
|
18
|
+
const TEMPLATES = {
|
|
19
|
+
starter: "starter",
|
|
20
|
+
fullstack: "fullstack"
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Type guard to check if a string is a valid template name
|
|
24
|
+
*/
|
|
25
|
+
function isTemplateName(value) {
|
|
26
|
+
return Object.values(TEMPLATES).includes(value);
|
|
27
|
+
}
|
|
28
|
+
//#endregion
|
|
29
|
+
//#region src/utils/prompts.ts
|
|
30
|
+
/**
|
|
31
|
+
* Available optional page templates that can be selected during project creation.
|
|
32
|
+
* Core pages (Messaging, Contacts, CRM) are always included automatically.
|
|
33
|
+
*/
|
|
34
|
+
const OPTIONAL_PAGE_TEMPLATES = [];
|
|
35
|
+
/**
|
|
36
|
+
* Prompts the user for project configuration
|
|
37
|
+
* Pre-fills values from CLI options when provided
|
|
38
|
+
*/
|
|
39
|
+
async function promptProjectConfig(projectName, options) {
|
|
40
|
+
const questions = [];
|
|
41
|
+
if (!options.template) questions.push({
|
|
42
|
+
type: "select",
|
|
43
|
+
name: "template",
|
|
44
|
+
message: "Select a project template",
|
|
45
|
+
choices: [{
|
|
46
|
+
title: "Starter",
|
|
47
|
+
value: TEMPLATES.starter,
|
|
48
|
+
description: "Frontend-only (React + Vite + Tailwind + portal-sdk)"
|
|
49
|
+
}, {
|
|
50
|
+
title: "Fullstack",
|
|
51
|
+
value: TEMPLATES.fullstack,
|
|
52
|
+
description: "Frontend + API server (Hono + Drizzle + SQLite)"
|
|
53
|
+
}]
|
|
54
|
+
});
|
|
55
|
+
if (OPTIONAL_PAGE_TEMPLATES.length > 0) questions.push({
|
|
56
|
+
type: "multiselect",
|
|
57
|
+
name: "selectedPages",
|
|
58
|
+
message: "Select additional page templates to include",
|
|
59
|
+
instructions: "\n Space to select, Enter to confirm. Core pages (Messaging, Contacts, CRM) are always included.",
|
|
60
|
+
choices: OPTIONAL_PAGE_TEMPLATES.map((page) => ({
|
|
61
|
+
title: page.name,
|
|
62
|
+
value: {
|
|
63
|
+
id: page.id,
|
|
64
|
+
slug: page.slug,
|
|
65
|
+
name: page.name
|
|
66
|
+
},
|
|
67
|
+
description: page.description
|
|
68
|
+
}))
|
|
69
|
+
});
|
|
70
|
+
if (!options.skipInstall) questions.push({
|
|
71
|
+
type: "confirm",
|
|
72
|
+
name: "installDeps",
|
|
73
|
+
message: "Install dependencies?",
|
|
74
|
+
initial: true
|
|
75
|
+
});
|
|
76
|
+
if (questions.length === 0) {
|
|
77
|
+
const templateRaw = options.template;
|
|
78
|
+
return {
|
|
79
|
+
name: projectName,
|
|
80
|
+
template: isTemplateName(templateRaw ?? "") ? templateRaw : TEMPLATES.starter,
|
|
81
|
+
installDeps: options.skipInstall ? false : true,
|
|
82
|
+
selectedPages: []
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
let cancelled = false;
|
|
86
|
+
const response = await prompts(questions, { onCancel: () => {
|
|
87
|
+
cancelled = true;
|
|
88
|
+
return false;
|
|
89
|
+
} });
|
|
90
|
+
if (cancelled) return null;
|
|
91
|
+
const templateRaw = options.template ?? response.template;
|
|
92
|
+
const template = isTemplateName(templateRaw) ? templateRaw : TEMPLATES.starter;
|
|
93
|
+
const selectedPages = response.selectedPages ?? [];
|
|
94
|
+
return {
|
|
95
|
+
name: projectName,
|
|
96
|
+
template,
|
|
97
|
+
installDeps: options.skipInstall ? false : response.installDeps ?? true,
|
|
98
|
+
selectedPages
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
//#endregion
|
|
102
|
+
//#region src/utils/result.ts
|
|
103
|
+
/**
|
|
104
|
+
* Create a successful result
|
|
105
|
+
*/
|
|
106
|
+
function success(value) {
|
|
107
|
+
return {
|
|
108
|
+
success: true,
|
|
109
|
+
value
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Create a failed result
|
|
114
|
+
*/
|
|
115
|
+
function failure(error) {
|
|
116
|
+
return {
|
|
117
|
+
success: false,
|
|
118
|
+
error
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Type guard for successful results
|
|
123
|
+
*/
|
|
124
|
+
function isSuccess(result) {
|
|
125
|
+
return result.success === true;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Type guard for failed results
|
|
129
|
+
*/
|
|
130
|
+
function isFailure(result) {
|
|
131
|
+
return result.success === false;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Wrap a function that may throw into a Result-returning function
|
|
135
|
+
*/
|
|
136
|
+
function tryCatch(fn) {
|
|
137
|
+
try {
|
|
138
|
+
return success(fn());
|
|
139
|
+
} catch (error) {
|
|
140
|
+
return failure(error instanceof Error ? error : new Error(String(error)));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Wrap an async function that may throw into a Result-returning function
|
|
145
|
+
*/
|
|
146
|
+
async function tryCatchAsync(fn) {
|
|
147
|
+
try {
|
|
148
|
+
return success(await fn());
|
|
149
|
+
} catch (error) {
|
|
150
|
+
return failure(error instanceof Error ? error : new Error(String(error)));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Extract value from Result or throw error
|
|
155
|
+
* Use sparingly - prefer pattern matching with isSuccess/isFailure
|
|
156
|
+
*/
|
|
157
|
+
function unwrap(result) {
|
|
158
|
+
if (isSuccess(result)) return result.value;
|
|
159
|
+
throw result.error;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Extract value from Result or return a default value
|
|
163
|
+
*/
|
|
164
|
+
function unwrapOr(result, defaultValue) {
|
|
165
|
+
if (isSuccess(result)) return result.value;
|
|
166
|
+
return defaultValue;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Map over a successful Result value
|
|
170
|
+
*/
|
|
171
|
+
function mapResult(result, fn) {
|
|
172
|
+
if (isSuccess(result)) return success(fn(result.value));
|
|
173
|
+
return result;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Map over a failed Result error
|
|
177
|
+
*/
|
|
178
|
+
function mapError(result, fn) {
|
|
179
|
+
if (isFailure(result)) return failure(fn(result.error));
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Type guard to check if a value is an Error instance
|
|
184
|
+
*
|
|
185
|
+
* @example
|
|
186
|
+
* ```ts
|
|
187
|
+
* catch (error) {
|
|
188
|
+
* if (isError(error)) {
|
|
189
|
+
* console.log(error.message); // TypeScript knows this is an Error
|
|
190
|
+
* }
|
|
191
|
+
* }
|
|
192
|
+
* ```
|
|
193
|
+
*/
|
|
194
|
+
function isError(value) {
|
|
195
|
+
return value instanceof Error;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Type guard for Node.js system errors (with `code` property)
|
|
199
|
+
*
|
|
200
|
+
* @example
|
|
201
|
+
* ```ts
|
|
202
|
+
* catch (error) {
|
|
203
|
+
* if (isNodeError(error) && error.code === "ENOENT") {
|
|
204
|
+
* console.log("File not found");
|
|
205
|
+
* }
|
|
206
|
+
* }
|
|
207
|
+
* ```
|
|
208
|
+
*/
|
|
209
|
+
function isNodeError(value) {
|
|
210
|
+
return value instanceof Error && "code" in value;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Extract error message safely from unknown catch parameter.
|
|
214
|
+
* Prefer type guards (isError, isNodeError) when you need the full error object.
|
|
215
|
+
*
|
|
216
|
+
* @example
|
|
217
|
+
* ```ts
|
|
218
|
+
* catch (error) {
|
|
219
|
+
* console.log("Error: " + getErrorMessage(error));
|
|
220
|
+
* }
|
|
221
|
+
* ```
|
|
222
|
+
*/
|
|
223
|
+
function getErrorMessage(error) {
|
|
224
|
+
if (error instanceof Error) return error.message;
|
|
225
|
+
if (typeof error === "string") return error;
|
|
226
|
+
return String(error);
|
|
227
|
+
}
|
|
228
|
+
//#endregion
|
|
229
|
+
//#region src/utils/file-system.ts
|
|
230
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
231
|
+
/**
|
|
232
|
+
* Find the package root by walking up from __dirname to the nearest package.json.
|
|
233
|
+
* Works whether running from dist/ (bundled) or src/utils/ (tsx dev mode).
|
|
234
|
+
*/
|
|
235
|
+
function findPackageRoot() {
|
|
236
|
+
let dir = __dirname;
|
|
237
|
+
while (!existsSync(join(dir, "package.json"))) {
|
|
238
|
+
const parent = dirname(dir);
|
|
239
|
+
if (parent === dir) throw new Error("Could not find package root");
|
|
240
|
+
dir = parent;
|
|
241
|
+
}
|
|
242
|
+
return dir;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Error types for file system operations
|
|
246
|
+
*/
|
|
247
|
+
const FILE_SYSTEM_ERRORS = {
|
|
248
|
+
directoryNotFound: "DIRECTORY_NOT_FOUND",
|
|
249
|
+
fileNotFound: "FILE_NOT_FOUND",
|
|
250
|
+
readError: "READ_ERROR",
|
|
251
|
+
writeError: "WRITE_ERROR",
|
|
252
|
+
templateError: "TEMPLATE_ERROR"
|
|
253
|
+
};
|
|
254
|
+
/**
|
|
255
|
+
* Create a file system error
|
|
256
|
+
*/
|
|
257
|
+
function createFsError(code, message, path, cause) {
|
|
258
|
+
return {
|
|
259
|
+
code,
|
|
260
|
+
message,
|
|
261
|
+
path,
|
|
262
|
+
cause
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Gets paths for the base + overlay template system.
|
|
267
|
+
*
|
|
268
|
+
* The create command copies `base` first, then the `overlay` on top.
|
|
269
|
+
* Any overlay file with the same relative path overwrites the base version.
|
|
270
|
+
*/
|
|
271
|
+
function getTemplatePaths(templateName) {
|
|
272
|
+
const templatesDir = join(findPackageRoot(), "templates");
|
|
273
|
+
return {
|
|
274
|
+
base: join(templatesDir, "base"),
|
|
275
|
+
overlay: join(templatesDir, templateName)
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Gets all files in a directory recursively
|
|
280
|
+
*/
|
|
281
|
+
async function getFiles(dir, baseDir = dir) {
|
|
282
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
283
|
+
const files = [];
|
|
284
|
+
for (const entry of entries) {
|
|
285
|
+
const fullPath = join(dir, entry.name);
|
|
286
|
+
if (entry.isDirectory()) files.push(...await getFiles(fullPath, baseDir));
|
|
287
|
+
else files.push(fullPath.slice(baseDir.length + 1));
|
|
288
|
+
}
|
|
289
|
+
return files;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Processes a template file with Handlebars
|
|
293
|
+
* Files ending in .template have the extension removed and content processed
|
|
294
|
+
* Other files are copied as-is
|
|
295
|
+
*/
|
|
296
|
+
function processTemplate(content, variables, isTemplate, filePath) {
|
|
297
|
+
if (!isTemplate) return content;
|
|
298
|
+
try {
|
|
299
|
+
return Handlebars.compile(content)(variables);
|
|
300
|
+
} catch (err) {
|
|
301
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
302
|
+
throw new Error(`Template processing failed${filePath ? ` for ${filePath}` : ""}: ${message}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Gets the output filename for a template file
|
|
307
|
+
* Removes .template extension if present
|
|
308
|
+
*/
|
|
309
|
+
function getOutputFilename(filename) {
|
|
310
|
+
if (filename.endsWith(".template")) return filename.slice(0, -9);
|
|
311
|
+
return filename;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Copies a template directory to the target directory
|
|
315
|
+
* Processes .template files with Handlebars
|
|
316
|
+
*/
|
|
317
|
+
async function copyTemplate(templatePath, targetPath, variables) {
|
|
318
|
+
const files = await getFiles(templatePath);
|
|
319
|
+
for (const file of files) {
|
|
320
|
+
const sourcePath = join(templatePath, file);
|
|
321
|
+
const isTemplate = file.endsWith(".template");
|
|
322
|
+
const destPath = join(targetPath, getOutputFilename(file));
|
|
323
|
+
await mkdir(dirname(destPath), { recursive: true });
|
|
324
|
+
await writeFile(destPath, processTemplate(await readFile(sourcePath, "utf-8"), variables, isTemplate, sourcePath), "utf-8");
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Checks if a directory exists
|
|
329
|
+
*/
|
|
330
|
+
async function directoryExists(path) {
|
|
331
|
+
try {
|
|
332
|
+
return (await stat(path)).isDirectory();
|
|
333
|
+
} catch {
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Checks if a file exists
|
|
339
|
+
*/
|
|
340
|
+
async function fileExists(path) {
|
|
341
|
+
try {
|
|
342
|
+
return (await stat(path)).isFile();
|
|
343
|
+
} catch {
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Checks if a path exists (file or directory)
|
|
349
|
+
*/
|
|
350
|
+
async function pathExists(path) {
|
|
351
|
+
try {
|
|
352
|
+
await stat(path);
|
|
353
|
+
return true;
|
|
354
|
+
} catch {
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Creates a directory
|
|
360
|
+
*/
|
|
361
|
+
async function createDirectory(path) {
|
|
362
|
+
await mkdir(path, { recursive: true });
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Reads the SDK version from the workspace package.json
|
|
366
|
+
* Falls back to ^0.1.0 if not found
|
|
367
|
+
*/
|
|
368
|
+
async function getSdkVersion() {
|
|
369
|
+
try {
|
|
370
|
+
const content = await readFile(join(join(findPackageRoot(), "..", ".."), "portal", "sdk", "package.json"), "utf-8");
|
|
371
|
+
return `^${JSON.parse(content).version ?? "0.1.0"}`;
|
|
372
|
+
} catch {
|
|
373
|
+
return "^0.1.0";
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Read a file's content with Result-based error handling
|
|
378
|
+
*/
|
|
379
|
+
async function readFileSafe(path) {
|
|
380
|
+
try {
|
|
381
|
+
return success(await readFile(path, "utf-8"));
|
|
382
|
+
} catch (err) {
|
|
383
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
384
|
+
return failure(createFsError(FILE_SYSTEM_ERRORS.readError, `Failed to read file: ${path}`, path, error));
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Write content to a file with Result-based error handling
|
|
389
|
+
*/
|
|
390
|
+
async function writeFileSafe(path, content) {
|
|
391
|
+
try {
|
|
392
|
+
await writeFile(path, content, "utf-8");
|
|
393
|
+
return success(void 0);
|
|
394
|
+
} catch (err) {
|
|
395
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
396
|
+
return failure(createFsError(FILE_SYSTEM_ERRORS.writeError, `Failed to write file: ${path}`, path, error));
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Create a directory with Result-based error handling
|
|
401
|
+
*/
|
|
402
|
+
async function createDirectorySafe(path) {
|
|
403
|
+
try {
|
|
404
|
+
await mkdir(path, { recursive: true });
|
|
405
|
+
return success(void 0);
|
|
406
|
+
} catch (err) {
|
|
407
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
408
|
+
return failure(createFsError(FILE_SYSTEM_ERRORS.writeError, `Failed to create directory: ${path}`, path, error));
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Copy a template directory with Result-based error handling
|
|
413
|
+
*/
|
|
414
|
+
async function copyTemplateSafe(templatePath, targetPath, variables) {
|
|
415
|
+
try {
|
|
416
|
+
const files = await getFiles(templatePath);
|
|
417
|
+
for (const file of files) {
|
|
418
|
+
const sourcePath = join(templatePath, file);
|
|
419
|
+
const isTemplateFile = file.endsWith(".template");
|
|
420
|
+
const destPath = join(targetPath, getOutputFilename(file));
|
|
421
|
+
await mkdir(dirname(destPath), { recursive: true });
|
|
422
|
+
await writeFile(destPath, processTemplate(await readFile(sourcePath, "utf-8"), variables, isTemplateFile, sourcePath), "utf-8");
|
|
423
|
+
}
|
|
424
|
+
return success(void 0);
|
|
425
|
+
} catch (err) {
|
|
426
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
427
|
+
return failure(createFsError(FILE_SYSTEM_ERRORS.templateError, `Failed to copy template from ${templatePath} to ${targetPath}`, templatePath, error));
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Get SDK version with Result-based error handling
|
|
432
|
+
* Unlike getSdkVersion, this returns an error instead of a fallback
|
|
433
|
+
*/
|
|
434
|
+
async function getSdkVersionSafe() {
|
|
435
|
+
try {
|
|
436
|
+
const sdkPackagePath = join(join(findPackageRoot(), "..", ".."), "portal", "sdk", "package.json");
|
|
437
|
+
const content = await readFile(sdkPackagePath, "utf-8");
|
|
438
|
+
const version = JSON.parse(content).version;
|
|
439
|
+
if (version === void 0) return failure(createFsError(FILE_SYSTEM_ERRORS.readError, "SDK package.json does not contain a version field", sdkPackagePath));
|
|
440
|
+
return success(`^${version}`);
|
|
441
|
+
} catch (err) {
|
|
442
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
443
|
+
return failure(createFsError(FILE_SYSTEM_ERRORS.fileNotFound, "Could not find SDK package.json", void 0, error));
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
//#endregion
|
|
447
|
+
//#region src/utils/package-manager.ts
|
|
448
|
+
/**
|
|
449
|
+
* Returns the install command for pnpm
|
|
450
|
+
*/
|
|
451
|
+
function getInstallCommand() {
|
|
452
|
+
return "pnpm install";
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Returns the run command for pnpm
|
|
456
|
+
*/
|
|
457
|
+
function getRunCommand(script) {
|
|
458
|
+
return `pnpm run ${script}`;
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Runs a pnpm command in the specified directory
|
|
462
|
+
*/
|
|
463
|
+
async function runPackageManager(args, cwd) {
|
|
464
|
+
await execa("pnpm", args, {
|
|
465
|
+
cwd,
|
|
466
|
+
stdio: "inherit"
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Installs dependencies using pnpm
|
|
471
|
+
*/
|
|
472
|
+
async function installDependencies(cwd) {
|
|
473
|
+
await runPackageManager(["install"], cwd);
|
|
474
|
+
}
|
|
475
|
+
//#endregion
|
|
476
|
+
//#region src/commands/create.ts
|
|
477
|
+
const createCommand = new Command("create").description("Create a new Fluid portal application").argument("<app-name>", "Name of the application to create").option("-t, --template <template>", "Project template (starter, fullstack)").option("--skip-install", "Skip dependency installation").action(async (appName, options) => {
|
|
478
|
+
try {
|
|
479
|
+
console.log();
|
|
480
|
+
console.log(chalk.bold("Creating a new Fluid portal application"));
|
|
481
|
+
console.log();
|
|
482
|
+
if (!/^[a-z0-9-]+$/.test(appName)) {
|
|
483
|
+
console.error(chalk.red("Error: App name must contain only lowercase letters, numbers, and hyphens"));
|
|
484
|
+
process.exit(1);
|
|
485
|
+
}
|
|
486
|
+
const targetPath = join(process.cwd(), appName);
|
|
487
|
+
if (await directoryExists(targetPath)) {
|
|
488
|
+
console.error(chalk.red(`Error: Directory "${appName}" already exists`));
|
|
489
|
+
process.exit(1);
|
|
490
|
+
}
|
|
491
|
+
const config = await promptProjectConfig(appName, options);
|
|
492
|
+
if (!config) {
|
|
493
|
+
console.log();
|
|
494
|
+
console.log(chalk.yellow("Cancelled"));
|
|
495
|
+
process.exit(0);
|
|
496
|
+
}
|
|
497
|
+
console.log();
|
|
498
|
+
const templatePaths = getTemplatePaths(config.template);
|
|
499
|
+
if (!await directoryExists(templatePaths.base)) {
|
|
500
|
+
console.error(chalk.red("Error: Base template not found"));
|
|
501
|
+
process.exit(1);
|
|
502
|
+
}
|
|
503
|
+
if (!await directoryExists(templatePaths.overlay)) {
|
|
504
|
+
console.error(chalk.red(`Error: Template "${config.template}" not found`));
|
|
505
|
+
process.exit(1);
|
|
506
|
+
}
|
|
507
|
+
const sdkVersion = await getSdkVersion();
|
|
508
|
+
const spinner = ora("Creating project directory...").start();
|
|
509
|
+
try {
|
|
510
|
+
await createDirectory(targetPath);
|
|
511
|
+
spinner.succeed("Created project directory");
|
|
512
|
+
} catch (error) {
|
|
513
|
+
spinner.fail("Failed to create project directory");
|
|
514
|
+
throw error;
|
|
515
|
+
}
|
|
516
|
+
const templateVariables = {
|
|
517
|
+
projectName: config.name,
|
|
518
|
+
sdkVersion,
|
|
519
|
+
selectedPages: config.selectedPages,
|
|
520
|
+
hasSelectedPages: config.selectedPages.length > 0
|
|
521
|
+
};
|
|
522
|
+
spinner.start("Copying template files...");
|
|
523
|
+
try {
|
|
524
|
+
await copyTemplate(templatePaths.base, targetPath, templateVariables);
|
|
525
|
+
await copyTemplate(templatePaths.overlay, targetPath, templateVariables);
|
|
526
|
+
const envExamplePath = join(targetPath, ".env.example");
|
|
527
|
+
if (await fileExists(envExamplePath)) await copyFile(envExamplePath, join(targetPath, ".env"));
|
|
528
|
+
spinner.succeed("Copied template files");
|
|
529
|
+
} catch (error) {
|
|
530
|
+
spinner.fail("Failed to copy template files");
|
|
531
|
+
throw error;
|
|
532
|
+
}
|
|
533
|
+
if (config.installDeps) {
|
|
534
|
+
spinner.start("Installing dependencies with pnpm...");
|
|
535
|
+
try {
|
|
536
|
+
await installDependencies(targetPath);
|
|
537
|
+
spinner.succeed("Installed dependencies");
|
|
538
|
+
} catch {
|
|
539
|
+
spinner.fail("Failed to install dependencies");
|
|
540
|
+
console.log();
|
|
541
|
+
console.log(chalk.yellow("You can try installing dependencies manually:"));
|
|
542
|
+
console.log(chalk.cyan(` cd ${appName}`));
|
|
543
|
+
console.log(chalk.cyan(" pnpm install"));
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
console.log();
|
|
547
|
+
console.log(chalk.green.bold("Success!") + ` Created ${chalk.cyan(appName)}`);
|
|
548
|
+
console.log();
|
|
549
|
+
console.log("Next steps:");
|
|
550
|
+
console.log();
|
|
551
|
+
console.log(chalk.cyan(` cd ${appName}`));
|
|
552
|
+
if (!config.installDeps) console.log(chalk.cyan(" pnpm install"));
|
|
553
|
+
if (config.template === TEMPLATES.fullstack) console.log(chalk.cyan(` ${getRunCommand("db:push")}`) + " # create the database");
|
|
554
|
+
console.log(chalk.cyan(` ${getRunCommand("dev")}`));
|
|
555
|
+
console.log();
|
|
556
|
+
console.log("Then open " + chalk.cyan("http://localhost:5173") + " in your browser.");
|
|
557
|
+
if (config.template === TEMPLATES.fullstack) console.log("API server: " + chalk.cyan("http://localhost:5173/api/health"));
|
|
558
|
+
console.log(chalk.dim(" (port may differ if 5173 is in use — check the dev server output)"));
|
|
559
|
+
console.log();
|
|
560
|
+
console.log("Edit " + chalk.cyan("src/portal.config.ts") + " to customize your navigation.");
|
|
561
|
+
console.log();
|
|
562
|
+
} catch (error) {
|
|
563
|
+
console.log();
|
|
564
|
+
console.log(chalk.red("Error:") + " " + (error instanceof Error ? error.message : String(error)));
|
|
565
|
+
console.log();
|
|
566
|
+
process.exit(1);
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
function registerCreateCommand(ctx) {
|
|
570
|
+
ctx.program.addCommand(createCommand);
|
|
571
|
+
}
|
|
572
|
+
//#endregion
|
|
573
|
+
//#region src/commands/dev.ts
|
|
574
|
+
const devCommand = new Command("dev").description("Start the development server").option("-p, --port <port>", "Port to run the dev server on", "5173").option("--host", "Expose the dev server to the network").action(async (options) => {
|
|
575
|
+
const cwd = process.cwd();
|
|
576
|
+
if (!existsSync(join(cwd, "package.json"))) {
|
|
577
|
+
console.error(chalk.red("Error: No package.json found in current directory"));
|
|
578
|
+
console.error(chalk.yellow("Make sure you're in a Fluid project directory"));
|
|
579
|
+
process.exit(1);
|
|
580
|
+
}
|
|
581
|
+
if (!existsSync(join(cwd, "vite.config.ts"))) {
|
|
582
|
+
console.error(chalk.red("Error: No vite.config.ts found"));
|
|
583
|
+
console.error(chalk.yellow("This command must be run from a Fluid project directory"));
|
|
584
|
+
process.exit(1);
|
|
585
|
+
}
|
|
586
|
+
const viteArgs = ["vite"];
|
|
587
|
+
if (options.port) viteArgs.push("--port", String(options.port));
|
|
588
|
+
if (options.host) viteArgs.push("--host");
|
|
589
|
+
console.log();
|
|
590
|
+
console.log(chalk.bold("Starting development server..."));
|
|
591
|
+
console.log();
|
|
592
|
+
try {
|
|
593
|
+
await execa("pnpm", viteArgs, {
|
|
594
|
+
cwd,
|
|
595
|
+
stdio: "inherit"
|
|
596
|
+
});
|
|
597
|
+
} catch (error) {
|
|
598
|
+
if (error.signal === "SIGINT") return;
|
|
599
|
+
console.error(chalk.red("Development server exited with an error"));
|
|
600
|
+
process.exit(1);
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
function registerDevCommand(ctx) {
|
|
604
|
+
ctx.program.addCommand(devCommand);
|
|
605
|
+
}
|
|
606
|
+
//#endregion
|
|
607
|
+
//#region src/commands/build.ts
|
|
608
|
+
const buildCommand = new Command("build").description("Build the application for production").option("-o, --out-dir <dir>", "Output directory", "dist").action(async (options) => {
|
|
609
|
+
const cwd = process.cwd();
|
|
610
|
+
if (!existsSync(join(cwd, "package.json"))) {
|
|
611
|
+
console.error(chalk.red("Error: No package.json found in current directory"));
|
|
612
|
+
console.error(chalk.yellow("Make sure you're in a Fluid project directory"));
|
|
613
|
+
process.exit(1);
|
|
614
|
+
}
|
|
615
|
+
if (!existsSync(join(cwd, "vite.config.ts"))) {
|
|
616
|
+
console.error(chalk.red("Error: No vite.config.ts found"));
|
|
617
|
+
console.error(chalk.yellow("This command must be run from a Fluid project directory"));
|
|
618
|
+
process.exit(1);
|
|
619
|
+
}
|
|
620
|
+
console.log();
|
|
621
|
+
console.log(chalk.bold("Building for production..."));
|
|
622
|
+
console.log();
|
|
623
|
+
const spinner = ora("Building...").start();
|
|
624
|
+
try {
|
|
625
|
+
await execa("pnpm", ["run", "build"], {
|
|
626
|
+
cwd,
|
|
627
|
+
stdio: "pipe"
|
|
628
|
+
});
|
|
629
|
+
spinner.succeed("Build completed");
|
|
630
|
+
console.log();
|
|
631
|
+
console.log(`Output written to ${chalk.cyan(options.outDir ?? "dist")}/`);
|
|
632
|
+
console.log();
|
|
633
|
+
console.log("To preview the build locally:");
|
|
634
|
+
console.log(chalk.cyan(" pnpm vite preview"));
|
|
635
|
+
console.log();
|
|
636
|
+
} catch (error) {
|
|
637
|
+
spinner.fail("Build failed");
|
|
638
|
+
const execaError = error;
|
|
639
|
+
if (execaError.stderr) console.error(execaError.stderr);
|
|
640
|
+
process.exit(1);
|
|
641
|
+
}
|
|
642
|
+
});
|
|
643
|
+
function registerBuildCommand(ctx) {
|
|
644
|
+
ctx.program.addCommand(buildCommand);
|
|
645
|
+
}
|
|
646
|
+
//#endregion
|
|
647
|
+
//#region src/utils/turso.ts
|
|
648
|
+
/**
|
|
649
|
+
* Turso database provisioning utilities
|
|
650
|
+
*
|
|
651
|
+
* This module provides functions for provisioning Turso databases
|
|
652
|
+
* using the Turso Platform REST API for Fluid portal app deployments.
|
|
653
|
+
*
|
|
654
|
+
* Credential resolution order:
|
|
655
|
+
* 1. Environment variables (TURSO_API_TOKEN + TURSO_ORG) — CI/CD
|
|
656
|
+
* 2. --turso-org flag > single org > "fluid" org > current org > interactive prompt
|
|
657
|
+
* 3. Fail with instructions for both options
|
|
658
|
+
*/
|
|
659
|
+
const TURSO_API_BASE = "https://api.turso.tech/v1";
|
|
660
|
+
const TURSO_ERROR = {
|
|
661
|
+
MISSING_TOKEN: {
|
|
662
|
+
code: "MISSING_TOKEN",
|
|
663
|
+
message: "TURSO_API_TOKEN environment variable is not set"
|
|
664
|
+
},
|
|
665
|
+
MISSING_ORG: {
|
|
666
|
+
code: "MISSING_ORG",
|
|
667
|
+
message: "TURSO_ORG environment variable is not set"
|
|
668
|
+
},
|
|
669
|
+
GROUP_CREATION_FAILED: {
|
|
670
|
+
code: "GROUP_CREATION_FAILED",
|
|
671
|
+
message: "Failed to create database group"
|
|
672
|
+
},
|
|
673
|
+
DATABASE_CREATION_FAILED: {
|
|
674
|
+
code: "DATABASE_CREATION_FAILED",
|
|
675
|
+
message: "Failed to create database"
|
|
676
|
+
},
|
|
677
|
+
TOKEN_CREATION_FAILED: {
|
|
678
|
+
code: "TOKEN_CREATION_FAILED",
|
|
679
|
+
message: "Failed to create database auth token"
|
|
680
|
+
},
|
|
681
|
+
DATABASE_DELETION_FAILED: {
|
|
682
|
+
code: "DATABASE_DELETION_FAILED",
|
|
683
|
+
message: "Failed to delete database"
|
|
684
|
+
},
|
|
685
|
+
INVALID_LOCATION: {
|
|
686
|
+
code: "INVALID_LOCATION",
|
|
687
|
+
message: "Invalid database location"
|
|
688
|
+
},
|
|
689
|
+
LOCATIONS_FETCH_FAILED: {
|
|
690
|
+
code: "LOCATIONS_FETCH_FAILED",
|
|
691
|
+
message: "Failed to fetch available Turso locations"
|
|
692
|
+
},
|
|
693
|
+
TURSO_CLI_NOT_FOUND: {
|
|
694
|
+
code: "TURSO_CLI_NOT_FOUND",
|
|
695
|
+
message: "Turso CLI is not installed"
|
|
696
|
+
},
|
|
697
|
+
TURSO_CLI_NOT_AUTHENTICATED: {
|
|
698
|
+
code: "TURSO_CLI_NOT_AUTHENTICATED",
|
|
699
|
+
message: "Turso CLI is not authenticated"
|
|
700
|
+
},
|
|
701
|
+
TURSO_NO_ORGS: {
|
|
702
|
+
code: "TURSO_NO_ORGS",
|
|
703
|
+
message: "No organizations found in Turso CLI"
|
|
704
|
+
}
|
|
705
|
+
};
|
|
706
|
+
/**
|
|
707
|
+
* Create a Turso error from a constant and optional details
|
|
708
|
+
*/
|
|
709
|
+
function createTursoError(template, details) {
|
|
710
|
+
return {
|
|
711
|
+
code: template.code,
|
|
712
|
+
message: template.message,
|
|
713
|
+
details
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* Validate that required Turso environment variables are present
|
|
718
|
+
*/
|
|
719
|
+
function validateTursoConfig() {
|
|
720
|
+
const apiToken = process.env.TURSO_API_TOKEN;
|
|
721
|
+
if (!apiToken) return failure(createTursoError(TURSO_ERROR.MISSING_TOKEN));
|
|
722
|
+
const org = process.env.TURSO_ORG;
|
|
723
|
+
if (!org) return failure(createTursoError(TURSO_ERROR.MISSING_ORG));
|
|
724
|
+
return success({
|
|
725
|
+
apiToken,
|
|
726
|
+
org
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Check if the Turso CLI is installed and authenticated.
|
|
731
|
+
* Runs `turso auth whoami` which verifies both in a single call.
|
|
732
|
+
*/
|
|
733
|
+
async function isTursoCliAvailable() {
|
|
734
|
+
try {
|
|
735
|
+
await execa("turso", ["auth", "whoami"], { stdio: "pipe" });
|
|
736
|
+
return true;
|
|
737
|
+
} catch {
|
|
738
|
+
return false;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Get the Turso platform API token from the CLI.
|
|
743
|
+
* Runs `turso auth token` which outputs the token to stdout.
|
|
744
|
+
*/
|
|
745
|
+
async function getTursoCliToken() {
|
|
746
|
+
try {
|
|
747
|
+
const { stdout } = await execa("turso", ["auth", "token"], { stdio: "pipe" });
|
|
748
|
+
const token = stdout.trim().split("\n").filter(Boolean).pop()?.trim() ?? "";
|
|
749
|
+
if (!token) return failure(createTursoError(TURSO_ERROR.TURSO_CLI_NOT_AUTHENTICATED, "turso auth token returned an empty value. Run: turso auth login"));
|
|
750
|
+
return success(token);
|
|
751
|
+
} catch {
|
|
752
|
+
return failure(createTursoError(TURSO_ERROR.TURSO_CLI_NOT_AUTHENTICATED, "Failed to get token from Turso CLI. Run: turso auth login"));
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Parse the tabular output of `turso org list`.
|
|
757
|
+
*
|
|
758
|
+
* Example input:
|
|
759
|
+
* Name Slug Type
|
|
760
|
+
* My Org my-org personal (current)
|
|
761
|
+
* Team Org team-org team
|
|
762
|
+
*
|
|
763
|
+
* Exported for unit testing.
|
|
764
|
+
*/
|
|
765
|
+
function parseOrgList(stdout) {
|
|
766
|
+
const lines = stdout.trim().split("\n");
|
|
767
|
+
if (lines.length <= 1) return [];
|
|
768
|
+
return lines.slice(1).reduce((orgs, line) => {
|
|
769
|
+
const trimmed = line.trim();
|
|
770
|
+
if (!trimmed) return orgs;
|
|
771
|
+
const columns = trimmed.split(/\s{2,}/);
|
|
772
|
+
if (columns.length < 2) return orgs;
|
|
773
|
+
const name = columns[0].trim();
|
|
774
|
+
const slug = columns[1].trim().replace(/\s*\(current\)/, "");
|
|
775
|
+
const isCurrent = trimmed.includes("(current)");
|
|
776
|
+
if (name && slug) orgs.push({
|
|
777
|
+
name,
|
|
778
|
+
slug,
|
|
779
|
+
isCurrent
|
|
780
|
+
});
|
|
781
|
+
return orgs;
|
|
782
|
+
}, []);
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Get the list of Turso organizations from the CLI.
|
|
786
|
+
*/
|
|
787
|
+
async function getTursoCliOrgs() {
|
|
788
|
+
try {
|
|
789
|
+
const { stdout } = await execa("turso", ["org", "list"], { stdio: "pipe" });
|
|
790
|
+
const orgs = parseOrgList(stdout);
|
|
791
|
+
if (orgs.length === 0) return failure(createTursoError(TURSO_ERROR.TURSO_NO_ORGS, "No organizations found. Create one at https://turso.tech"));
|
|
792
|
+
return success(orgs);
|
|
793
|
+
} catch {
|
|
794
|
+
return failure(createTursoError(TURSO_ERROR.TURSO_NO_ORGS, "Failed to list organizations from Turso CLI."));
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
const TURSO_AUTH_HELP = [
|
|
798
|
+
"Option 1 — Turso CLI (recommended for local dev):",
|
|
799
|
+
" curl -sSfL https://get.tur.so/install.sh | bash",
|
|
800
|
+
" turso auth login",
|
|
801
|
+
"",
|
|
802
|
+
"Option 2 — Environment variables (CI/CD):",
|
|
803
|
+
" export TURSO_API_TOKEN=your_token_here",
|
|
804
|
+
" export TURSO_ORG=your_org_name",
|
|
805
|
+
"",
|
|
806
|
+
"Get your API token at: https://turso.tech/app/settings/api-tokens"
|
|
807
|
+
].join("\n");
|
|
808
|
+
/**
|
|
809
|
+
* Resolve Turso credentials from the best available source.
|
|
810
|
+
*
|
|
811
|
+
* Priority:
|
|
812
|
+
* 1. Environment variables (TURSO_API_TOKEN + TURSO_ORG) — immediate, for CI/CD
|
|
813
|
+
* 2. Turso CLI (turso auth token + turso org list) — interactive, for local dev
|
|
814
|
+
* 3. Fail with instructions for both options
|
|
815
|
+
*
|
|
816
|
+
* @param tursoOrgOverride - Optional org slug from --turso-org flag (skips interactive selection)
|
|
817
|
+
*/
|
|
818
|
+
async function resolveTursoConfig(tursoOrgOverride) {
|
|
819
|
+
const envToken = process.env.TURSO_API_TOKEN;
|
|
820
|
+
const envOrg = process.env.TURSO_ORG;
|
|
821
|
+
if (envToken && envOrg) return success({
|
|
822
|
+
apiToken: envToken,
|
|
823
|
+
org: envOrg,
|
|
824
|
+
source: "env"
|
|
825
|
+
});
|
|
826
|
+
if (!await isTursoCliAvailable()) return failure(createTursoError(TURSO_ERROR.TURSO_CLI_NOT_FOUND, `No Turso credentials found.\n\n${TURSO_AUTH_HELP}`));
|
|
827
|
+
const tokenResult = await getTursoCliToken();
|
|
828
|
+
if (!tokenResult.success) return tokenResult;
|
|
829
|
+
const apiToken = tokenResult.value;
|
|
830
|
+
if (tursoOrgOverride) return success({
|
|
831
|
+
apiToken,
|
|
832
|
+
org: tursoOrgOverride,
|
|
833
|
+
source: "cli"
|
|
834
|
+
});
|
|
835
|
+
const orgsResult = await getTursoCliOrgs();
|
|
836
|
+
if (!orgsResult.success) return orgsResult;
|
|
837
|
+
const orgs = orgsResult.value;
|
|
838
|
+
if (orgs.length === 1) return success({
|
|
839
|
+
apiToken,
|
|
840
|
+
org: orgs[0].slug,
|
|
841
|
+
source: "cli"
|
|
842
|
+
});
|
|
843
|
+
const fluidOrg = orgs.find((o) => o.slug === "fluid");
|
|
844
|
+
if (fluidOrg) return success({
|
|
845
|
+
apiToken,
|
|
846
|
+
org: fluidOrg.slug,
|
|
847
|
+
source: "cli"
|
|
848
|
+
});
|
|
849
|
+
const currentOrg = orgs.find((o) => o.isCurrent);
|
|
850
|
+
if (currentOrg) return success({
|
|
851
|
+
apiToken,
|
|
852
|
+
org: currentOrg.slug,
|
|
853
|
+
source: "cli"
|
|
854
|
+
});
|
|
855
|
+
const { orgSlug } = await prompts({
|
|
856
|
+
type: "select",
|
|
857
|
+
name: "orgSlug",
|
|
858
|
+
message: "Which Turso organization?",
|
|
859
|
+
choices: orgs.map((o) => ({
|
|
860
|
+
title: `${o.name} (${o.slug})`,
|
|
861
|
+
value: o.slug
|
|
862
|
+
}))
|
|
863
|
+
});
|
|
864
|
+
if (!orgSlug) return failure(createTursoError(TURSO_ERROR.TURSO_NO_ORGS, "Organization selection cancelled."));
|
|
865
|
+
return success({
|
|
866
|
+
apiToken,
|
|
867
|
+
org: orgSlug,
|
|
868
|
+
source: "cli"
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Build standard headers for Turso API requests
|
|
873
|
+
*/
|
|
874
|
+
function buildHeaders(apiToken) {
|
|
875
|
+
return {
|
|
876
|
+
Authorization: `Bearer ${apiToken}`,
|
|
877
|
+
"Content-Type": "application/json"
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
/**
|
|
881
|
+
* Fetch available Turso database locations.
|
|
882
|
+
* Returns a map of location ID → description (e.g., "aws-us-east-1" → "US East (N. Virginia)").
|
|
883
|
+
*/
|
|
884
|
+
async function fetchLocations(config) {
|
|
885
|
+
try {
|
|
886
|
+
const controller = new AbortController();
|
|
887
|
+
const timeout = setTimeout(() => controller.abort(), 3e4);
|
|
888
|
+
const response = await fetch(`${TURSO_API_BASE}/locations`, {
|
|
889
|
+
method: "GET",
|
|
890
|
+
headers: buildHeaders(config.apiToken),
|
|
891
|
+
signal: controller.signal
|
|
892
|
+
});
|
|
893
|
+
clearTimeout(timeout);
|
|
894
|
+
if (!response.ok) {
|
|
895
|
+
const body = await response.text();
|
|
896
|
+
return failure(createTursoError(TURSO_ERROR.LOCATIONS_FETCH_FAILED, `HTTP ${response.status}: ${body}`));
|
|
897
|
+
}
|
|
898
|
+
return success((await response.json()).locations);
|
|
899
|
+
} catch (err) {
|
|
900
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
901
|
+
return failure(createTursoError(TURSO_ERROR.LOCATIONS_FETCH_FAILED, message));
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
/**
|
|
905
|
+
* Validate that a location string is a known Turso location.
|
|
906
|
+
* On failure, returns an error listing all valid locations.
|
|
907
|
+
*/
|
|
908
|
+
async function validateLocation(config, location) {
|
|
909
|
+
const locationsResult = await fetchLocations(config);
|
|
910
|
+
if (!locationsResult.success) return locationsResult;
|
|
911
|
+
const locations = locationsResult.value;
|
|
912
|
+
if (location in locations) return success(void 0);
|
|
913
|
+
const validLocations = Object.entries(locations).map(([id, desc]) => ` ${id} — ${desc}`).join("\n");
|
|
914
|
+
return failure(createTursoError(TURSO_ERROR.INVALID_LOCATION, `"${location}" is not a valid Turso location.\n\nAvailable locations:\n${validLocations}`));
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Ensure a default database group exists in the Turso organization.
|
|
918
|
+
* Creates the group if it does not exist; treats 409 (conflict) as success
|
|
919
|
+
* since it means the group already exists.
|
|
920
|
+
*/
|
|
921
|
+
async function ensureGroup(config, location = "aws-us-east-1") {
|
|
922
|
+
try {
|
|
923
|
+
const listController = new AbortController();
|
|
924
|
+
const listTimeout = setTimeout(() => listController.abort(), 3e4);
|
|
925
|
+
const listResponse = await fetch(`${TURSO_API_BASE}/organizations/${config.org}/groups`, {
|
|
926
|
+
method: "GET",
|
|
927
|
+
headers: buildHeaders(config.apiToken),
|
|
928
|
+
signal: listController.signal
|
|
929
|
+
});
|
|
930
|
+
clearTimeout(listTimeout);
|
|
931
|
+
if (listResponse.ok) {
|
|
932
|
+
if ((await listResponse.json()).groups.some((g) => g.name === "default")) return success(void 0);
|
|
933
|
+
}
|
|
934
|
+
const createController = new AbortController();
|
|
935
|
+
const createTimeout = setTimeout(() => createController.abort(), 3e4);
|
|
936
|
+
const createResponse = await fetch(`${TURSO_API_BASE}/organizations/${config.org}/groups`, {
|
|
937
|
+
method: "POST",
|
|
938
|
+
headers: buildHeaders(config.apiToken),
|
|
939
|
+
body: JSON.stringify({
|
|
940
|
+
name: "default",
|
|
941
|
+
location
|
|
942
|
+
}),
|
|
943
|
+
signal: createController.signal
|
|
944
|
+
});
|
|
945
|
+
clearTimeout(createTimeout);
|
|
946
|
+
if (createResponse.ok || createResponse.status === 409) return success(void 0);
|
|
947
|
+
const body = await createResponse.text();
|
|
948
|
+
return failure(createTursoError(TURSO_ERROR.GROUP_CREATION_FAILED, `HTTP ${createResponse.status}: ${body}`));
|
|
949
|
+
} catch (err) {
|
|
950
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
951
|
+
return failure(createTursoError(TURSO_ERROR.GROUP_CREATION_FAILED, message));
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Create a new Turso database in the default group.
|
|
956
|
+
* If the database already exists (409), fetches its info via GET instead.
|
|
957
|
+
* Returns the database name and hostname.
|
|
958
|
+
*/
|
|
959
|
+
async function createDatabase(config, name) {
|
|
960
|
+
try {
|
|
961
|
+
const controller = new AbortController();
|
|
962
|
+
const timeout = setTimeout(() => controller.abort(), 3e4);
|
|
963
|
+
const response = await fetch(`${TURSO_API_BASE}/organizations/${config.org}/databases`, {
|
|
964
|
+
method: "POST",
|
|
965
|
+
headers: buildHeaders(config.apiToken),
|
|
966
|
+
body: JSON.stringify({
|
|
967
|
+
name,
|
|
968
|
+
group: "default"
|
|
969
|
+
}),
|
|
970
|
+
signal: controller.signal
|
|
971
|
+
});
|
|
972
|
+
clearTimeout(timeout);
|
|
973
|
+
if (response.ok) {
|
|
974
|
+
const data = await response.json();
|
|
975
|
+
if (!data.database) return failure(createTursoError(TURSO_ERROR.DATABASE_CREATION_FAILED, "Unexpected API response: missing database object"));
|
|
976
|
+
return success({
|
|
977
|
+
name: data.database.Name ?? data.database.name ?? name,
|
|
978
|
+
hostname: data.database.Hostname ?? data.database.hostname ?? "",
|
|
979
|
+
isNew: true
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
if (response.status === 409) {
|
|
983
|
+
const existing = await getDatabaseInfo(config, name);
|
|
984
|
+
if (!existing.success) return existing;
|
|
985
|
+
return success({
|
|
986
|
+
...existing.value,
|
|
987
|
+
isNew: false
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
const body = await response.text();
|
|
991
|
+
return failure(createTursoError(TURSO_ERROR.DATABASE_CREATION_FAILED, `HTTP ${response.status}: ${body}`));
|
|
992
|
+
} catch (err) {
|
|
993
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
994
|
+
return failure(createTursoError(TURSO_ERROR.DATABASE_CREATION_FAILED, message));
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* Fetch existing database info by name
|
|
999
|
+
*/
|
|
1000
|
+
async function getDatabaseInfo(config, name) {
|
|
1001
|
+
try {
|
|
1002
|
+
const controller = new AbortController();
|
|
1003
|
+
const timeout = setTimeout(() => controller.abort(), 3e4);
|
|
1004
|
+
const response = await fetch(`${TURSO_API_BASE}/organizations/${config.org}/databases/${name}`, {
|
|
1005
|
+
method: "GET",
|
|
1006
|
+
headers: buildHeaders(config.apiToken),
|
|
1007
|
+
signal: controller.signal
|
|
1008
|
+
});
|
|
1009
|
+
clearTimeout(timeout);
|
|
1010
|
+
if (!response.ok) {
|
|
1011
|
+
const body = await response.text();
|
|
1012
|
+
return failure(createTursoError(TURSO_ERROR.DATABASE_CREATION_FAILED, `Failed to fetch existing database info: HTTP ${response.status}: ${body}`));
|
|
1013
|
+
}
|
|
1014
|
+
const data = await response.json();
|
|
1015
|
+
if (!data.database) return failure(createTursoError(TURSO_ERROR.DATABASE_CREATION_FAILED, "Unexpected API response: missing database object"));
|
|
1016
|
+
return success({
|
|
1017
|
+
name: data.database.Name ?? data.database.name ?? name,
|
|
1018
|
+
hostname: data.database.Hostname ?? data.database.hostname ?? ""
|
|
1019
|
+
});
|
|
1020
|
+
} catch (err) {
|
|
1021
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1022
|
+
return failure(createTursoError(TURSO_ERROR.DATABASE_CREATION_FAILED, `Failed to fetch existing database info: ${message}`));
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* Delete a Turso database by name.
|
|
1027
|
+
* Returns void on success, or a TursoError on failure.
|
|
1028
|
+
*/
|
|
1029
|
+
async function deleteDatabase(config, name) {
|
|
1030
|
+
try {
|
|
1031
|
+
const controller = new AbortController();
|
|
1032
|
+
const timeout = setTimeout(() => controller.abort(), 3e4);
|
|
1033
|
+
const response = await fetch(`${TURSO_API_BASE}/organizations/${config.org}/databases/${name}`, {
|
|
1034
|
+
method: "DELETE",
|
|
1035
|
+
headers: buildHeaders(config.apiToken),
|
|
1036
|
+
signal: controller.signal
|
|
1037
|
+
});
|
|
1038
|
+
clearTimeout(timeout);
|
|
1039
|
+
if (response.ok || response.status === 404) return success(void 0);
|
|
1040
|
+
const body = await response.text();
|
|
1041
|
+
return failure(createTursoError(TURSO_ERROR.DATABASE_DELETION_FAILED, `HTTP ${response.status}: ${body}`));
|
|
1042
|
+
} catch (err) {
|
|
1043
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1044
|
+
return failure(createTursoError(TURSO_ERROR.DATABASE_DELETION_FAILED, message));
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Create an auth token for a Turso database.
|
|
1049
|
+
* Returns the JWT token string used for database connections.
|
|
1050
|
+
*/
|
|
1051
|
+
async function createDatabaseToken(config, dbName) {
|
|
1052
|
+
try {
|
|
1053
|
+
const controller = new AbortController();
|
|
1054
|
+
const timeout = setTimeout(() => controller.abort(), 3e4);
|
|
1055
|
+
const response = await fetch(`${TURSO_API_BASE}/organizations/${config.org}/databases/${dbName}/auth/tokens`, {
|
|
1056
|
+
method: "POST",
|
|
1057
|
+
headers: buildHeaders(config.apiToken),
|
|
1058
|
+
signal: controller.signal
|
|
1059
|
+
});
|
|
1060
|
+
clearTimeout(timeout);
|
|
1061
|
+
if (!response.ok) {
|
|
1062
|
+
const body = await response.text();
|
|
1063
|
+
return failure(createTursoError(TURSO_ERROR.TOKEN_CREATION_FAILED, `HTTP ${response.status}: ${body}`));
|
|
1064
|
+
}
|
|
1065
|
+
const data = await response.json();
|
|
1066
|
+
if (!data.jwt) return failure(createTursoError(TURSO_ERROR.TOKEN_CREATION_FAILED, "Unexpected API response: missing jwt field"));
|
|
1067
|
+
return success(data.jwt);
|
|
1068
|
+
} catch (err) {
|
|
1069
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1070
|
+
return failure(createTursoError(TURSO_ERROR.TOKEN_CREATION_FAILED, message));
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* Provision a complete Turso database for a project.
|
|
1075
|
+
*
|
|
1076
|
+
* Orchestrates the full flow:
|
|
1077
|
+
* 1. Ensure a default group exists
|
|
1078
|
+
* 2. Create (or retrieve) the database
|
|
1079
|
+
* 3. Generate an auth token
|
|
1080
|
+
*
|
|
1081
|
+
* Calls progress callbacks at each step so callers can display status.
|
|
1082
|
+
*/
|
|
1083
|
+
async function provisionDatabase(config, projectName, location, callbacks) {
|
|
1084
|
+
if (location) {
|
|
1085
|
+
const locationResult = await validateLocation(config, location);
|
|
1086
|
+
if (!locationResult.success) return locationResult;
|
|
1087
|
+
}
|
|
1088
|
+
callbacks?.onGroupCreating?.();
|
|
1089
|
+
const groupResult = await ensureGroup(config, location);
|
|
1090
|
+
if (!groupResult.success) return groupResult;
|
|
1091
|
+
callbacks?.onGroupReady?.();
|
|
1092
|
+
callbacks?.onDatabaseCreating?.(projectName);
|
|
1093
|
+
const dbResult = await createDatabase(config, projectName);
|
|
1094
|
+
if (!dbResult.success) return dbResult;
|
|
1095
|
+
const { name: databaseName, hostname, isNew } = dbResult.value;
|
|
1096
|
+
callbacks?.onDatabaseReady?.(databaseName);
|
|
1097
|
+
callbacks?.onTokenCreating?.();
|
|
1098
|
+
const tokenResult = await createDatabaseToken(config, databaseName);
|
|
1099
|
+
if (!tokenResult.success) return tokenResult;
|
|
1100
|
+
const authToken = tokenResult.value;
|
|
1101
|
+
callbacks?.onTokenReady?.();
|
|
1102
|
+
return success({
|
|
1103
|
+
url: `libsql://${hostname}`,
|
|
1104
|
+
authToken,
|
|
1105
|
+
databaseName,
|
|
1106
|
+
hostname,
|
|
1107
|
+
isNew
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
//#endregion
|
|
1111
|
+
//#region src/utils/cloud-run.ts
|
|
1112
|
+
/**
|
|
1113
|
+
* Cloud Run deployment utilities
|
|
1114
|
+
*
|
|
1115
|
+
* This module provides functions for deploying Fluid portal apps to Google Cloud Run
|
|
1116
|
+
* using the gcloud CLI via execa for programmatic deployments.
|
|
1117
|
+
*/
|
|
1118
|
+
/**
|
|
1119
|
+
* Cloud Run error codes and default messages
|
|
1120
|
+
*/
|
|
1121
|
+
const CLOUD_RUN_ERRORS = {
|
|
1122
|
+
GCLOUD_NOT_INSTALLED: {
|
|
1123
|
+
code: "GCLOUD_NOT_INSTALLED",
|
|
1124
|
+
message: "gcloud CLI is not installed. Install it from https://cloud.google.com/sdk/docs/install"
|
|
1125
|
+
},
|
|
1126
|
+
GCLOUD_NOT_AUTHENTICATED: {
|
|
1127
|
+
code: "GCLOUD_NOT_AUTHENTICATED",
|
|
1128
|
+
message: "gcloud CLI is not authenticated. Run 'gcloud auth login' to authenticate"
|
|
1129
|
+
},
|
|
1130
|
+
NO_GCP_PROJECT: {
|
|
1131
|
+
code: "NO_GCP_PROJECT",
|
|
1132
|
+
message: "No GCP project configured. Run 'gcloud config set project PROJECT_ID' or pass --gcp-project"
|
|
1133
|
+
},
|
|
1134
|
+
DEPLOY_FAILED: {
|
|
1135
|
+
code: "DEPLOY_FAILED",
|
|
1136
|
+
message: "Cloud Run deployment failed"
|
|
1137
|
+
},
|
|
1138
|
+
SERVICE_DELETION_FAILED: {
|
|
1139
|
+
code: "SERVICE_DELETION_FAILED",
|
|
1140
|
+
message: "Cloud Run service deletion failed"
|
|
1141
|
+
}
|
|
1142
|
+
};
|
|
1143
|
+
/**
|
|
1144
|
+
* Create a CloudRunError from a constant and optional details
|
|
1145
|
+
*/
|
|
1146
|
+
function createCloudRunError(template, details) {
|
|
1147
|
+
return {
|
|
1148
|
+
code: template.code,
|
|
1149
|
+
message: template.message,
|
|
1150
|
+
details
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* Build a KEY=VALUE string from an env vars record using gcloud's custom
|
|
1155
|
+
* delimiter syntax to avoid issues with values containing commas.
|
|
1156
|
+
* Prefix with `^::^` and join entries with `::` instead of `,`.
|
|
1157
|
+
*/
|
|
1158
|
+
function buildEnvVarsString(envVars) {
|
|
1159
|
+
return `^::^${Object.entries(envVars).map(([key, value]) => `${key}=${value}`).join("::")}`;
|
|
1160
|
+
}
|
|
1161
|
+
/**
|
|
1162
|
+
* Validate that the gcloud CLI is installed
|
|
1163
|
+
*/
|
|
1164
|
+
async function validateGcloudInstalled() {
|
|
1165
|
+
try {
|
|
1166
|
+
await execa("gcloud", ["--version"], {
|
|
1167
|
+
stdio: "pipe",
|
|
1168
|
+
timeout: 6e4
|
|
1169
|
+
});
|
|
1170
|
+
return success(void 0);
|
|
1171
|
+
} catch {
|
|
1172
|
+
return failure(createCloudRunError(CLOUD_RUN_ERRORS.GCLOUD_NOT_INSTALLED));
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
/**
|
|
1176
|
+
* Validate that the gcloud CLI has an active authenticated account
|
|
1177
|
+
*/
|
|
1178
|
+
async function validateGcloudAuth() {
|
|
1179
|
+
try {
|
|
1180
|
+
const { stdout } = await execa("gcloud", [
|
|
1181
|
+
"auth",
|
|
1182
|
+
"list",
|
|
1183
|
+
"--format=json"
|
|
1184
|
+
], {
|
|
1185
|
+
stdio: "pipe",
|
|
1186
|
+
timeout: 6e4
|
|
1187
|
+
});
|
|
1188
|
+
if (!JSON.parse(stdout).some((account) => account.status === "ACTIVE")) return failure(createCloudRunError(CLOUD_RUN_ERRORS.GCLOUD_NOT_AUTHENTICATED));
|
|
1189
|
+
return success(void 0);
|
|
1190
|
+
} catch {
|
|
1191
|
+
return failure(createCloudRunError(CLOUD_RUN_ERRORS.GCLOUD_NOT_AUTHENTICATED, "Failed to check gcloud authentication status"));
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* Get the currently configured GCP project from gcloud config
|
|
1196
|
+
*/
|
|
1197
|
+
async function getGcpProject() {
|
|
1198
|
+
try {
|
|
1199
|
+
const { stdout } = await execa("gcloud", [
|
|
1200
|
+
"config",
|
|
1201
|
+
"get-value",
|
|
1202
|
+
"project"
|
|
1203
|
+
], {
|
|
1204
|
+
stdio: "pipe",
|
|
1205
|
+
timeout: 6e4
|
|
1206
|
+
});
|
|
1207
|
+
const project = stdout.trim();
|
|
1208
|
+
if (!project || project === "(unset)") return failure(createCloudRunError(CLOUD_RUN_ERRORS.NO_GCP_PROJECT));
|
|
1209
|
+
return success(project);
|
|
1210
|
+
} catch {
|
|
1211
|
+
return failure(createCloudRunError(CLOUD_RUN_ERRORS.NO_GCP_PROJECT, "Failed to read GCP project from gcloud config"));
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
/**
|
|
1215
|
+
* Fetch the last 30 lines of the most recent Cloud Build log.
|
|
1216
|
+
* Returns `undefined` on any failure — this is best-effort diagnostic info.
|
|
1217
|
+
*/
|
|
1218
|
+
async function fetchRecentBuildLogs(gcpProject, region) {
|
|
1219
|
+
try {
|
|
1220
|
+
const { stdout: buildId } = await execa("gcloud", [
|
|
1221
|
+
"builds",
|
|
1222
|
+
"list",
|
|
1223
|
+
"--limit=1",
|
|
1224
|
+
"--project",
|
|
1225
|
+
gcpProject,
|
|
1226
|
+
"--region",
|
|
1227
|
+
region,
|
|
1228
|
+
"--format=value(id)"
|
|
1229
|
+
], {
|
|
1230
|
+
stdio: "pipe",
|
|
1231
|
+
timeout: 6e4
|
|
1232
|
+
});
|
|
1233
|
+
const trimmedId = buildId.trim();
|
|
1234
|
+
if (!trimmedId) return void 0;
|
|
1235
|
+
const { stdout: logText } = await execa("gcloud", [
|
|
1236
|
+
"builds",
|
|
1237
|
+
"log",
|
|
1238
|
+
trimmedId,
|
|
1239
|
+
"--project",
|
|
1240
|
+
gcpProject,
|
|
1241
|
+
"--region",
|
|
1242
|
+
region
|
|
1243
|
+
], {
|
|
1244
|
+
stdio: "pipe",
|
|
1245
|
+
timeout: 6e4
|
|
1246
|
+
});
|
|
1247
|
+
return logText.split("\n").slice(-30).join("\n");
|
|
1248
|
+
} catch {
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
/**
|
|
1253
|
+
* Retrieve the service URL via `gcloud run services describe`.
|
|
1254
|
+
* Used as a fallback when the deploy command does not return parseable JSON.
|
|
1255
|
+
*/
|
|
1256
|
+
async function getServiceUrl(serviceName, gcpProject, region) {
|
|
1257
|
+
try {
|
|
1258
|
+
const { stdout } = await execa("gcloud", [
|
|
1259
|
+
"run",
|
|
1260
|
+
"services",
|
|
1261
|
+
"describe",
|
|
1262
|
+
serviceName,
|
|
1263
|
+
"--project",
|
|
1264
|
+
gcpProject,
|
|
1265
|
+
"--region",
|
|
1266
|
+
region,
|
|
1267
|
+
"--format=json"
|
|
1268
|
+
], {
|
|
1269
|
+
stdio: "pipe",
|
|
1270
|
+
timeout: 6e4
|
|
1271
|
+
});
|
|
1272
|
+
const url = JSON.parse(stdout).status?.url;
|
|
1273
|
+
if (!url) return failure(createCloudRunError(CLOUD_RUN_ERRORS.DEPLOY_FAILED, "Service deployed but no URL found in service description"));
|
|
1274
|
+
return success(url);
|
|
1275
|
+
} catch (err) {
|
|
1276
|
+
const details = err instanceof Error ? err.message : "Unknown error describing service";
|
|
1277
|
+
return failure(createCloudRunError(CLOUD_RUN_ERRORS.DEPLOY_FAILED, details));
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
/**
|
|
1281
|
+
* Deploy to Cloud Run using `gcloud run deploy --source`
|
|
1282
|
+
*
|
|
1283
|
+
* This builds the container from source and deploys it in a single step.
|
|
1284
|
+
* Progress is reported through optional callbacks.
|
|
1285
|
+
*/
|
|
1286
|
+
async function deployToCloudRun(config, callbacks) {
|
|
1287
|
+
callbacks?.onValidating?.();
|
|
1288
|
+
const installCheck = await validateGcloudInstalled();
|
|
1289
|
+
if (!installCheck.success) return failure(installCheck.error);
|
|
1290
|
+
const authCheck = await validateGcloudAuth();
|
|
1291
|
+
if (!authCheck.success) return failure(authCheck.error);
|
|
1292
|
+
const args = [
|
|
1293
|
+
"run",
|
|
1294
|
+
"deploy",
|
|
1295
|
+
config.serviceName,
|
|
1296
|
+
"--source",
|
|
1297
|
+
config.sourceDir,
|
|
1298
|
+
"--project",
|
|
1299
|
+
config.gcpProject,
|
|
1300
|
+
"--region",
|
|
1301
|
+
config.region,
|
|
1302
|
+
...config.requireAuth ? [] : ["--allow-unauthenticated"],
|
|
1303
|
+
"--quiet",
|
|
1304
|
+
"--format=json"
|
|
1305
|
+
];
|
|
1306
|
+
const envVarsString = buildEnvVarsString(config.envVars);
|
|
1307
|
+
if (envVarsString) args.push("--set-env-vars", envVarsString);
|
|
1308
|
+
callbacks?.onDeploying?.();
|
|
1309
|
+
try {
|
|
1310
|
+
const { stdout } = await execa("gcloud", args, {
|
|
1311
|
+
stdio: "pipe",
|
|
1312
|
+
timeout: 3e5
|
|
1313
|
+
});
|
|
1314
|
+
let url;
|
|
1315
|
+
try {
|
|
1316
|
+
url = JSON.parse(stdout).status?.url;
|
|
1317
|
+
} catch {}
|
|
1318
|
+
if (!url) {
|
|
1319
|
+
const fallbackResult = await getServiceUrl(config.serviceName, config.gcpProject, config.region);
|
|
1320
|
+
if (!fallbackResult.success) return failure(fallbackResult.error);
|
|
1321
|
+
url = fallbackResult.value;
|
|
1322
|
+
}
|
|
1323
|
+
const result = {
|
|
1324
|
+
url,
|
|
1325
|
+
serviceName: config.serviceName,
|
|
1326
|
+
region: config.region,
|
|
1327
|
+
gcpProject: config.gcpProject
|
|
1328
|
+
};
|
|
1329
|
+
callbacks?.onDeployComplete?.(url);
|
|
1330
|
+
return success(result);
|
|
1331
|
+
} catch (err) {
|
|
1332
|
+
const execaError = err;
|
|
1333
|
+
let details = execaError.stderr ?? execaError.message ?? String(err);
|
|
1334
|
+
const buildLogs = await fetchRecentBuildLogs(config.gcpProject, config.region);
|
|
1335
|
+
if (buildLogs) details += "\n\n── Recent Cloud Build logs ─────────────────────\n" + buildLogs;
|
|
1336
|
+
return failure(createCloudRunError(CLOUD_RUN_ERRORS.DEPLOY_FAILED, details));
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
/**
|
|
1340
|
+
* Delete a Cloud Run service using `gcloud run services delete`.
|
|
1341
|
+
*/
|
|
1342
|
+
async function deleteCloudRunService(config) {
|
|
1343
|
+
try {
|
|
1344
|
+
await execa("gcloud", [
|
|
1345
|
+
"run",
|
|
1346
|
+
"services",
|
|
1347
|
+
"delete",
|
|
1348
|
+
config.serviceName,
|
|
1349
|
+
"--region",
|
|
1350
|
+
config.region,
|
|
1351
|
+
"--project",
|
|
1352
|
+
config.gcpProject,
|
|
1353
|
+
"--quiet"
|
|
1354
|
+
], {
|
|
1355
|
+
stdio: "pipe",
|
|
1356
|
+
timeout: 6e4
|
|
1357
|
+
});
|
|
1358
|
+
return success(void 0);
|
|
1359
|
+
} catch (err) {
|
|
1360
|
+
const execaError = err;
|
|
1361
|
+
return failure(createCloudRunError(CLOUD_RUN_ERRORS.SERVICE_DELETION_FAILED, execaError.stderr ?? execaError.message ?? String(err)));
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
//#endregion
|
|
1365
|
+
//#region src/utils/fluid-api.ts
|
|
1366
|
+
/**
|
|
1367
|
+
* Fluid API validation utilities
|
|
1368
|
+
*
|
|
1369
|
+
* This module provides functions for validating Fluid API keys
|
|
1370
|
+
* before deploying or destroying infrastructure. Ensures the deployer
|
|
1371
|
+
* has a valid company token before touching Cloud Run or Turso resources.
|
|
1372
|
+
*/
|
|
1373
|
+
const FLUID_API_BASE$1 = "https://api.fluid.app";
|
|
1374
|
+
const FLUID_API_ERROR = {
|
|
1375
|
+
MISSING_API_KEY: {
|
|
1376
|
+
code: "MISSING_API_KEY",
|
|
1377
|
+
message: "FLUID_COMPANY_API_KEY is not set"
|
|
1378
|
+
},
|
|
1379
|
+
INVALID_API_KEY: {
|
|
1380
|
+
code: "INVALID_API_KEY",
|
|
1381
|
+
message: "FLUID_COMPANY_API_KEY is invalid or expired"
|
|
1382
|
+
},
|
|
1383
|
+
API_UNREACHABLE: {
|
|
1384
|
+
code: "API_UNREACHABLE",
|
|
1385
|
+
message: "Could not reach the Fluid API"
|
|
1386
|
+
}
|
|
1387
|
+
};
|
|
1388
|
+
/**
|
|
1389
|
+
* Create a Fluid API error from a constant and optional details
|
|
1390
|
+
*/
|
|
1391
|
+
function createFluidApiError(template, details) {
|
|
1392
|
+
return {
|
|
1393
|
+
code: template.code,
|
|
1394
|
+
message: template.message,
|
|
1395
|
+
details
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
/**
|
|
1399
|
+
* Resolve and validate the Fluid API key.
|
|
1400
|
+
*
|
|
1401
|
+
* Priority:
|
|
1402
|
+
* 1. `apiKeyOverride` parameter (from --fluid-company-api-key flag)
|
|
1403
|
+
* 2. FLUID_COMPANY_API_KEY environment variable
|
|
1404
|
+
* 3. Interactive hidden-input prompt
|
|
1405
|
+
* 4. Fail with instructions if all sources exhausted
|
|
1406
|
+
*
|
|
1407
|
+
* Once resolved, validates against the Fluid API (GET /api/company/v1/companies/me).
|
|
1408
|
+
*
|
|
1409
|
+
* @param apiKeyOverride - Optional API key from CLI flag (skips env + prompt)
|
|
1410
|
+
*/
|
|
1411
|
+
async function resolveFluidApiKey(apiKeyOverride) {
|
|
1412
|
+
if (apiKeyOverride) return validateFluidApiKey(apiKeyOverride);
|
|
1413
|
+
const envKey = process.env.FLUID_COMPANY_API_KEY;
|
|
1414
|
+
if (envKey) return validateFluidApiKey(envKey);
|
|
1415
|
+
const { apiKey } = await prompts({
|
|
1416
|
+
type: "password",
|
|
1417
|
+
name: "apiKey",
|
|
1418
|
+
message: "Enter your Fluid company API key (FLUID_COMPANY_API_KEY)"
|
|
1419
|
+
});
|
|
1420
|
+
if (!apiKey) return failure(createFluidApiError(FLUID_API_ERROR.MISSING_API_KEY, "Set FLUID_COMPANY_API_KEY in your .env file or pass --fluid-company-api-key <key>."));
|
|
1421
|
+
return validateFluidApiKey(apiKey);
|
|
1422
|
+
}
|
|
1423
|
+
/**
|
|
1424
|
+
* Validate a Fluid API key by calling the companies/me endpoint.
|
|
1425
|
+
*
|
|
1426
|
+
* - 200 → extract company name, return success
|
|
1427
|
+
* - 401/403 → invalid or expired key
|
|
1428
|
+
* - Network error → API unreachable
|
|
1429
|
+
*/
|
|
1430
|
+
async function validateFluidApiKey(apiKey) {
|
|
1431
|
+
try {
|
|
1432
|
+
const response = await fetch(`${FLUID_API_BASE$1}/api/company/v1/companies/me`, {
|
|
1433
|
+
method: "GET",
|
|
1434
|
+
headers: {
|
|
1435
|
+
Authorization: `Bearer ${apiKey}`,
|
|
1436
|
+
"Content-Type": "application/json"
|
|
1437
|
+
}
|
|
1438
|
+
});
|
|
1439
|
+
if (response.ok) return success({
|
|
1440
|
+
name: (await response.json()).data.company.name,
|
|
1441
|
+
apiKey
|
|
1442
|
+
});
|
|
1443
|
+
if (response.status === 401 || response.status === 403) return failure(createFluidApiError(FLUID_API_ERROR.INVALID_API_KEY, `HTTP ${response.status}: Check that your FLUID_COMPANY_API_KEY is a valid, non-expired company token.`));
|
|
1444
|
+
const body = await response.text();
|
|
1445
|
+
return failure(createFluidApiError(FLUID_API_ERROR.API_UNREACHABLE, `HTTP ${response.status}: ${body}`));
|
|
1446
|
+
} catch (err) {
|
|
1447
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1448
|
+
return failure(createFluidApiError(FLUID_API_ERROR.API_UNREACHABLE, message));
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
//#endregion
|
|
1452
|
+
//#region src/utils/project.ts
|
|
1453
|
+
/**
|
|
1454
|
+
* Read project name from package.json
|
|
1455
|
+
*/
|
|
1456
|
+
async function getProjectName(cwd) {
|
|
1457
|
+
const packageJsonPath = path.join(cwd, "package.json");
|
|
1458
|
+
if (await fs.pathExists(packageJsonPath)) return (await fs.readJson(packageJsonPath)).name;
|
|
1459
|
+
}
|
|
1460
|
+
/**
|
|
1461
|
+
* Sanitize a project name into a valid Cloud Run service name.
|
|
1462
|
+
* Lowercase, alphanumeric, and hyphens only.
|
|
1463
|
+
*/
|
|
1464
|
+
function sanitizeServiceName(projectName) {
|
|
1465
|
+
return projectName.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/--+/g, "-").replace(/^-|-$/g, "");
|
|
1466
|
+
}
|
|
1467
|
+
//#endregion
|
|
1468
|
+
//#region src/utils/extract-navigation.ts
|
|
1469
|
+
/**
|
|
1470
|
+
* Navigation extraction utility
|
|
1471
|
+
*
|
|
1472
|
+
* Extracts the `navigation` export from a project's navigation.config.ts
|
|
1473
|
+
* by writing a minimal wrapper script that imports from navigation.config.ts
|
|
1474
|
+
* and serializes the result to stdout, then running it with tsx.
|
|
1475
|
+
*
|
|
1476
|
+
* This avoids transitive dependency resolution — navigation.config.ts must
|
|
1477
|
+
* be self-contained static data with no import statements.
|
|
1478
|
+
*/
|
|
1479
|
+
const EXTRACT_FILENAME = "__fluid_extract_nav.ts";
|
|
1480
|
+
/**
|
|
1481
|
+
* Extract the `navigation` export from a project's navigation.config.ts.
|
|
1482
|
+
*
|
|
1483
|
+
* Reads the config file, validates it contains no import statements,
|
|
1484
|
+
* writes a minimal wrapper that imports and serializes navigation,
|
|
1485
|
+
* and runs it with tsx. This avoids needing project dependencies
|
|
1486
|
+
* (React, etc.) to be installed.
|
|
1487
|
+
*
|
|
1488
|
+
* The temp file is always cleaned up.
|
|
1489
|
+
* Returns null if no navigation export exists.
|
|
1490
|
+
*
|
|
1491
|
+
* @param projectDir - The project root directory containing src/navigation.config.ts
|
|
1492
|
+
*/
|
|
1493
|
+
async function extractNavigation(projectDir) {
|
|
1494
|
+
const configPath = path.join(projectDir, "src", "navigation.config.ts");
|
|
1495
|
+
const extractFile = path.join(projectDir, EXTRACT_FILENAME);
|
|
1496
|
+
try {
|
|
1497
|
+
const configSource = await fs.readFile(configPath, "utf-8");
|
|
1498
|
+
if (!/export\s+(const|let)\s+navigation\s*=/.test(configSource)) return success(null);
|
|
1499
|
+
if (/^\s*import\s/m.test(configSource)) return failure({
|
|
1500
|
+
code: "EXTRACTION_FAILED",
|
|
1501
|
+
message: "navigation.config.ts must not contain import statements — it should only export static data",
|
|
1502
|
+
details: "Move all imports to portal.config.ts. navigation.config.ts must be self-contained."
|
|
1503
|
+
});
|
|
1504
|
+
const wrapperScript = [`import { navigation } from "./src/navigation.config.ts";`, `console.log(JSON.stringify(navigation ?? null));`].join("\n");
|
|
1505
|
+
await fs.writeFile(extractFile, wrapperScript, "utf-8");
|
|
1506
|
+
const output = (await execa("npx", ["tsx", EXTRACT_FILENAME], {
|
|
1507
|
+
cwd: projectDir,
|
|
1508
|
+
stdio: "pipe",
|
|
1509
|
+
env: {
|
|
1510
|
+
...process.env,
|
|
1511
|
+
NODE_ENV: "production"
|
|
1512
|
+
}
|
|
1513
|
+
})).stdout.trim();
|
|
1514
|
+
if (!output || output === "null") return success(null);
|
|
1515
|
+
try {
|
|
1516
|
+
const parsed = JSON.parse(output);
|
|
1517
|
+
if (!Array.isArray(parsed)) return failure({
|
|
1518
|
+
code: "INVALID_FORMAT",
|
|
1519
|
+
message: "navigation export is not an array",
|
|
1520
|
+
details: `Expected an array, got: ${typeof parsed}`
|
|
1521
|
+
});
|
|
1522
|
+
return success(parsed);
|
|
1523
|
+
} catch {
|
|
1524
|
+
return failure({
|
|
1525
|
+
code: "INVALID_FORMAT",
|
|
1526
|
+
message: "Failed to parse navigation output as JSON",
|
|
1527
|
+
details: output.slice(0, 200)
|
|
1528
|
+
});
|
|
1529
|
+
}
|
|
1530
|
+
} catch (err) {
|
|
1531
|
+
const error = err;
|
|
1532
|
+
return failure({
|
|
1533
|
+
code: "EXTRACTION_FAILED",
|
|
1534
|
+
message: "Failed to extract navigation from navigation.config.ts",
|
|
1535
|
+
details: error.stderr ?? error.message ?? String(err)
|
|
1536
|
+
});
|
|
1537
|
+
} finally {
|
|
1538
|
+
await fs.remove(extractFile).catch(() => {});
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
//#endregion
|
|
1542
|
+
//#region src/utils/navigation-sync.ts
|
|
1543
|
+
/**
|
|
1544
|
+
* Navigation sync utility
|
|
1545
|
+
*
|
|
1546
|
+
* Reconciles code-defined navigation items from portal.config.ts
|
|
1547
|
+
* against the Fluid OS API. Only manages items with source: "code",
|
|
1548
|
+
* leaving user-created and system items untouched.
|
|
1549
|
+
*/
|
|
1550
|
+
const FLUID_API_BASE = "https://api.fluid.app";
|
|
1551
|
+
async function apiGet(apiKey, path) {
|
|
1552
|
+
const response = await fetch(`${FLUID_API_BASE}${path}`, {
|
|
1553
|
+
method: "GET",
|
|
1554
|
+
headers: {
|
|
1555
|
+
Authorization: `Bearer ${apiKey}`,
|
|
1556
|
+
"Content-Type": "application/json"
|
|
1557
|
+
}
|
|
1558
|
+
});
|
|
1559
|
+
if (!response.ok) {
|
|
1560
|
+
const body = await response.text();
|
|
1561
|
+
throw new Error(`GET ${path} failed (${response.status}): ${body}`);
|
|
1562
|
+
}
|
|
1563
|
+
return response.json();
|
|
1564
|
+
}
|
|
1565
|
+
async function apiPost(apiKey, path, body) {
|
|
1566
|
+
const response = await fetch(`${FLUID_API_BASE}${path}`, {
|
|
1567
|
+
method: "POST",
|
|
1568
|
+
headers: {
|
|
1569
|
+
Authorization: `Bearer ${apiKey}`,
|
|
1570
|
+
"Content-Type": "application/json"
|
|
1571
|
+
},
|
|
1572
|
+
body: JSON.stringify(body)
|
|
1573
|
+
});
|
|
1574
|
+
if (!response.ok) {
|
|
1575
|
+
const text = await response.text();
|
|
1576
|
+
throw new Error(`POST ${path} failed (${response.status}): ${text}`);
|
|
1577
|
+
}
|
|
1578
|
+
return response.json();
|
|
1579
|
+
}
|
|
1580
|
+
async function apiPut(apiKey, path, body) {
|
|
1581
|
+
const response = await fetch(`${FLUID_API_BASE}${path}`, {
|
|
1582
|
+
method: "PUT",
|
|
1583
|
+
headers: {
|
|
1584
|
+
Authorization: `Bearer ${apiKey}`,
|
|
1585
|
+
"Content-Type": "application/json"
|
|
1586
|
+
},
|
|
1587
|
+
body: JSON.stringify(body)
|
|
1588
|
+
});
|
|
1589
|
+
if (!response.ok) {
|
|
1590
|
+
const text = await response.text();
|
|
1591
|
+
throw new Error(`PUT ${path} failed (${response.status}): ${text}`);
|
|
1592
|
+
}
|
|
1593
|
+
return response.json();
|
|
1594
|
+
}
|
|
1595
|
+
async function apiDelete(apiKey, path) {
|
|
1596
|
+
const response = await fetch(`${FLUID_API_BASE}${path}`, {
|
|
1597
|
+
method: "DELETE",
|
|
1598
|
+
headers: {
|
|
1599
|
+
Authorization: `Bearer ${apiKey}`,
|
|
1600
|
+
"Content-Type": "application/json"
|
|
1601
|
+
}
|
|
1602
|
+
});
|
|
1603
|
+
if (!response.ok) {
|
|
1604
|
+
const text = await response.text();
|
|
1605
|
+
throw new Error(`DELETE ${path} failed (${response.status}): ${text}`);
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
async function discoverContext(apiKey) {
|
|
1609
|
+
let definitionId;
|
|
1610
|
+
try {
|
|
1611
|
+
const active = (await apiGet(apiKey, "/api/company/fluid_os/definitions")).definitions.find((d) => d.active);
|
|
1612
|
+
if (!active) return failure({
|
|
1613
|
+
code: "NO_DEFINITION",
|
|
1614
|
+
message: "No active Fluid OS definition found",
|
|
1615
|
+
details: "Create and activate a definition in the Fluid admin dashboard first."
|
|
1616
|
+
});
|
|
1617
|
+
definitionId = active.id;
|
|
1618
|
+
} catch (err) {
|
|
1619
|
+
return failure({
|
|
1620
|
+
code: "API_ERROR",
|
|
1621
|
+
message: "Failed to fetch Fluid OS definitions",
|
|
1622
|
+
details: err instanceof Error ? err.message : String(err)
|
|
1623
|
+
});
|
|
1624
|
+
}
|
|
1625
|
+
let navigationId;
|
|
1626
|
+
try {
|
|
1627
|
+
const navs = await apiGet(apiKey, `/api/company/fluid_os/definitions/${definitionId}/navigations`);
|
|
1628
|
+
const webNav = navs.navigations.find((n) => n.name.toLowerCase().includes("web")) ?? navs.navigations[0];
|
|
1629
|
+
if (!webNav) return failure({
|
|
1630
|
+
code: "NO_NAVIGATION",
|
|
1631
|
+
message: "No navigation found for the active definition",
|
|
1632
|
+
details: "The active definition has no navigations. Create one in the Fluid admin dashboard."
|
|
1633
|
+
});
|
|
1634
|
+
navigationId = webNav.id;
|
|
1635
|
+
} catch (err) {
|
|
1636
|
+
return failure({
|
|
1637
|
+
code: "API_ERROR",
|
|
1638
|
+
message: "Failed to fetch navigations",
|
|
1639
|
+
details: err instanceof Error ? err.message : String(err)
|
|
1640
|
+
});
|
|
1641
|
+
}
|
|
1642
|
+
return success({
|
|
1643
|
+
apiKey,
|
|
1644
|
+
definitionId,
|
|
1645
|
+
navigationId
|
|
1646
|
+
});
|
|
1647
|
+
}
|
|
1648
|
+
/**
|
|
1649
|
+
* Build a lookup key for matching code items to API items.
|
|
1650
|
+
* Items with a slug are keyed by slug; headers (no slug) are keyed by "header:{label}".
|
|
1651
|
+
*/
|
|
1652
|
+
function itemKey(item) {
|
|
1653
|
+
if (item.slug) return item.slug;
|
|
1654
|
+
return `header:${item.label ?? ""}`;
|
|
1655
|
+
}
|
|
1656
|
+
/**
|
|
1657
|
+
* Recursively delete an API navigation item and all its source: "code" descendants
|
|
1658
|
+
* in post-order (deepest children first, then the item itself).
|
|
1659
|
+
*/
|
|
1660
|
+
async function deleteItemRecursive(apiKey, basePath, item, stats) {
|
|
1661
|
+
const codeChildren = (item.children ?? []).filter((c) => c.source === "code");
|
|
1662
|
+
for (const child of codeChildren) await deleteItemRecursive(apiKey, basePath, child, stats);
|
|
1663
|
+
await apiDelete(apiKey, `${basePath}/${item.id}`);
|
|
1664
|
+
stats.deleted++;
|
|
1665
|
+
}
|
|
1666
|
+
/**
|
|
1667
|
+
* Sync a level of code-defined navigation items against existing API items.
|
|
1668
|
+
* Recurses for children.
|
|
1669
|
+
*/
|
|
1670
|
+
async function syncLevel(ctx, codeItems, existingItems, parentId, stats) {
|
|
1671
|
+
const existingByKey = /* @__PURE__ */ new Map();
|
|
1672
|
+
for (const item of existingItems) if (item.source === "code") existingByKey.set(itemKey(item), item);
|
|
1673
|
+
const basePath = `/api/company/fluid_os/definitions/${ctx.definitionId}/navigations/${ctx.navigationId}/navigation_items`;
|
|
1674
|
+
const matched = /* @__PURE__ */ new Set();
|
|
1675
|
+
for (let i = 0; i < codeItems.length; i++) {
|
|
1676
|
+
const codeItem = codeItems[i];
|
|
1677
|
+
const key = itemKey(codeItem);
|
|
1678
|
+
const existing = existingByKey.get(key);
|
|
1679
|
+
matched.add(key);
|
|
1680
|
+
if (existing) {
|
|
1681
|
+
if (existing.label !== codeItem.label || existing.icon !== (codeItem.icon ?? null) || existing.position !== i + 1) {
|
|
1682
|
+
await apiPut(ctx.apiKey, `${basePath}/${existing.id}`, { navigation_item: {
|
|
1683
|
+
label: codeItem.label,
|
|
1684
|
+
icon: codeItem.icon ?? null,
|
|
1685
|
+
position: i + 1
|
|
1686
|
+
} });
|
|
1687
|
+
stats.updated++;
|
|
1688
|
+
}
|
|
1689
|
+
if (codeItem.children?.length) await syncLevel(ctx, codeItem.children, existing.children ?? [], existing.id, stats);
|
|
1690
|
+
} else {
|
|
1691
|
+
const response = await apiPost(ctx.apiKey, basePath, { navigation_item: {
|
|
1692
|
+
label: codeItem.label,
|
|
1693
|
+
slug: codeItem.slug ?? null,
|
|
1694
|
+
icon: codeItem.icon ?? null,
|
|
1695
|
+
position: i + 1,
|
|
1696
|
+
parent_id: parentId,
|
|
1697
|
+
source: "code"
|
|
1698
|
+
} });
|
|
1699
|
+
stats.created++;
|
|
1700
|
+
if (codeItem.children?.length) {
|
|
1701
|
+
const newId = response.navigation_item.id;
|
|
1702
|
+
await syncLevel(ctx, codeItem.children, [], newId, stats);
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
for (const [key, item] of existingByKey) if (!matched.has(key)) await deleteItemRecursive(ctx.apiKey, basePath, item, stats);
|
|
1707
|
+
}
|
|
1708
|
+
/**
|
|
1709
|
+
* Sync code-defined navigation items to the Fluid OS API.
|
|
1710
|
+
*
|
|
1711
|
+
* @param apiKey - Valid Fluid company API key
|
|
1712
|
+
* @param codeItems - Navigation items from portal.config.ts
|
|
1713
|
+
*/
|
|
1714
|
+
async function syncNavigation(apiKey, codeItems) {
|
|
1715
|
+
const contextResult = await discoverContext(apiKey);
|
|
1716
|
+
if (!contextResult.success) return contextResult;
|
|
1717
|
+
const ctx = {
|
|
1718
|
+
apiBase: FLUID_API_BASE,
|
|
1719
|
+
...contextResult.value
|
|
1720
|
+
};
|
|
1721
|
+
let existingItems;
|
|
1722
|
+
try {
|
|
1723
|
+
existingItems = (await apiGet(ctx.apiKey, `/api/company/fluid_os/definitions/${ctx.definitionId}/navigations/${ctx.navigationId}/navigation_items`)).navigation_items;
|
|
1724
|
+
} catch (err) {
|
|
1725
|
+
return failure({
|
|
1726
|
+
code: "API_ERROR",
|
|
1727
|
+
message: "Failed to fetch existing navigation items",
|
|
1728
|
+
details: err instanceof Error ? err.message : String(err)
|
|
1729
|
+
});
|
|
1730
|
+
}
|
|
1731
|
+
const stats = {
|
|
1732
|
+
created: 0,
|
|
1733
|
+
updated: 0,
|
|
1734
|
+
deleted: 0
|
|
1735
|
+
};
|
|
1736
|
+
try {
|
|
1737
|
+
await syncLevel(ctx, codeItems, existingItems, null, stats);
|
|
1738
|
+
} catch (err) {
|
|
1739
|
+
return failure({
|
|
1740
|
+
code: "SYNC_FAILED",
|
|
1741
|
+
message: "Navigation sync failed during reconciliation",
|
|
1742
|
+
details: err instanceof Error ? err.message : String(err)
|
|
1743
|
+
});
|
|
1744
|
+
}
|
|
1745
|
+
return success(stats);
|
|
1746
|
+
}
|
|
1747
|
+
//#endregion
|
|
1748
|
+
//#region src/commands/deploy.ts
|
|
1749
|
+
/**
|
|
1750
|
+
* Detect if the project is a fullstack template (has server entry)
|
|
1751
|
+
*/
|
|
1752
|
+
async function isFullstackProject(cwd) {
|
|
1753
|
+
return fs.pathExists(path.join(cwd, "src", "server", "index.ts"));
|
|
1754
|
+
}
|
|
1755
|
+
const deployCommand = new Command("deploy").description("Deploy the fullstack application to Cloud Run + Turso").option("--region <region>", "Cloud Run region", "us-central1").option("--gcp-project <id>", "GCP project ID (default: from gcloud config)").option("-p, --project <name>", "Service name override (default: from package.json)").option("--db-region <location>", "Turso database group location", "aws-us-east-1").option("--require-auth", "Require IAM authentication for the Cloud Run service (default: public)").option("--migrate", "Run database migrations (db:push) after successful deploy").option("--skip-local-build", "Skip the local Docker build check before deploying").option("--turso-org <slug>", "Turso organization slug (skips interactive org selection)").option("--fluid-company-api-key <key>", "Fluid company API key (skips env var lookup and prompt)").option("--skip-nav-sync", "Skip navigation sync from portal.config.ts").action(async (options) => {
|
|
1756
|
+
const cwd = process.cwd();
|
|
1757
|
+
config({ path: path.join(cwd, ".env") });
|
|
1758
|
+
console.log();
|
|
1759
|
+
console.log(chalk.blue.bold("Fluid Deploy") + chalk.gray(" (Cloud Run + Turso)"));
|
|
1760
|
+
console.log();
|
|
1761
|
+
if (!await isFullstackProject(cwd)) {
|
|
1762
|
+
console.log(chalk.red("Error:") + " This project is not a fullstack template.");
|
|
1763
|
+
console.log();
|
|
1764
|
+
console.log(chalk.yellow("fluid deploy") + " only supports fullstack projects with a Hono API server.");
|
|
1765
|
+
console.log();
|
|
1766
|
+
console.log("For static sites (starter template), deploy the " + chalk.cyan("dist/") + " folder to:");
|
|
1767
|
+
console.log(" - Firebase Hosting: " + chalk.gray("https://firebase.google.com/docs/hosting"));
|
|
1768
|
+
console.log(" - Cloud Storage: " + chalk.gray("https://cloud.google.com/storage/docs/hosting-static-website"));
|
|
1769
|
+
console.log(" - Vercel: " + chalk.gray("https://vercel.com"));
|
|
1770
|
+
console.log(" - Netlify: " + chalk.gray("https://netlify.com"));
|
|
1771
|
+
console.log();
|
|
1772
|
+
process.exit(1);
|
|
1773
|
+
}
|
|
1774
|
+
const spinner = ora();
|
|
1775
|
+
spinner.start("Validating Fluid API key...");
|
|
1776
|
+
const fluidResult = await resolveFluidApiKey(options.fluidCompanyApiKey);
|
|
1777
|
+
if (!fluidResult.success) {
|
|
1778
|
+
spinner.fail("Fluid API key validation failed");
|
|
1779
|
+
console.log();
|
|
1780
|
+
console.log(chalk.red("Error:") + " " + fluidResult.error.message);
|
|
1781
|
+
if (fluidResult.error.details) {
|
|
1782
|
+
console.log();
|
|
1783
|
+
console.log(fluidResult.error.details);
|
|
1784
|
+
}
|
|
1785
|
+
console.log();
|
|
1786
|
+
process.exit(1);
|
|
1787
|
+
}
|
|
1788
|
+
spinner.succeed(`Fluid company: ${chalk.cyan(fluidResult.value.name)}`);
|
|
1789
|
+
if (!await fs.pathExists(path.join(cwd, "Dockerfile"))) {
|
|
1790
|
+
console.log(chalk.red("Error:") + " No Dockerfile found in current directory.");
|
|
1791
|
+
console.log();
|
|
1792
|
+
console.log("Fullstack projects created with the latest template include a Dockerfile.");
|
|
1793
|
+
console.log("If you upgraded from an older template, add a Dockerfile to your project.");
|
|
1794
|
+
console.log();
|
|
1795
|
+
process.exit(1);
|
|
1796
|
+
}
|
|
1797
|
+
spinner.start("Checking gcloud CLI...");
|
|
1798
|
+
const gcloudResult = await validateGcloudInstalled();
|
|
1799
|
+
if (!gcloudResult.success) {
|
|
1800
|
+
spinner.fail("gcloud CLI not found");
|
|
1801
|
+
console.log();
|
|
1802
|
+
console.log(chalk.red("Error:") + " " + gcloudResult.error.message);
|
|
1803
|
+
console.log();
|
|
1804
|
+
console.log("Install the Google Cloud SDK: " + chalk.cyan("https://cloud.google.com/sdk/docs/install"));
|
|
1805
|
+
console.log();
|
|
1806
|
+
process.exit(1);
|
|
1807
|
+
}
|
|
1808
|
+
const authResult = await validateGcloudAuth();
|
|
1809
|
+
if (!authResult.success) {
|
|
1810
|
+
spinner.fail("gcloud not authenticated");
|
|
1811
|
+
console.log();
|
|
1812
|
+
console.log(chalk.red("Error:") + " " + authResult.error.message);
|
|
1813
|
+
console.log();
|
|
1814
|
+
console.log("Run " + chalk.cyan("gcloud auth login") + " to authenticate.");
|
|
1815
|
+
console.log();
|
|
1816
|
+
process.exit(1);
|
|
1817
|
+
}
|
|
1818
|
+
spinner.succeed("gcloud CLI ready");
|
|
1819
|
+
let gcpProject = options.gcpProject;
|
|
1820
|
+
if (!gcpProject) {
|
|
1821
|
+
spinner.start("Detecting GCP project...");
|
|
1822
|
+
const projectResult = await getGcpProject();
|
|
1823
|
+
if (!projectResult.success) {
|
|
1824
|
+
spinner.fail("No GCP project configured");
|
|
1825
|
+
console.log();
|
|
1826
|
+
console.log(chalk.red("Error:") + " " + projectResult.error.message);
|
|
1827
|
+
console.log();
|
|
1828
|
+
console.log("Either:");
|
|
1829
|
+
console.log(" - Run " + chalk.cyan("gcloud config set project PROJECT_ID"));
|
|
1830
|
+
console.log(" - Use the " + chalk.cyan("--gcp-project <id>") + " flag");
|
|
1831
|
+
console.log();
|
|
1832
|
+
process.exit(1);
|
|
1833
|
+
}
|
|
1834
|
+
gcpProject = projectResult.value;
|
|
1835
|
+
spinner.succeed(`GCP project: ${chalk.cyan(gcpProject)}`);
|
|
1836
|
+
}
|
|
1837
|
+
spinner.start("Resolving Turso credentials...");
|
|
1838
|
+
const tursoConfigResult = await resolveTursoConfig(options.tursoOrg);
|
|
1839
|
+
if (!tursoConfigResult.success) {
|
|
1840
|
+
spinner.fail("Turso credentials not found");
|
|
1841
|
+
console.log();
|
|
1842
|
+
console.log(chalk.red("Error:") + " " + tursoConfigResult.error.message);
|
|
1843
|
+
if (tursoConfigResult.error.details) {
|
|
1844
|
+
console.log();
|
|
1845
|
+
console.log(tursoConfigResult.error.details);
|
|
1846
|
+
}
|
|
1847
|
+
console.log();
|
|
1848
|
+
process.exit(1);
|
|
1849
|
+
}
|
|
1850
|
+
const tursoConfig = tursoConfigResult.value;
|
|
1851
|
+
const sourceLabel = tursoConfig.source === "env" ? "environment variables" : "Turso CLI";
|
|
1852
|
+
spinner.succeed(`Turso: authenticated via ${sourceLabel}`);
|
|
1853
|
+
const projectName = options.project ?? await getProjectName(cwd);
|
|
1854
|
+
if (!projectName) {
|
|
1855
|
+
console.log(chalk.red("Error:") + " Could not determine project name.");
|
|
1856
|
+
console.log();
|
|
1857
|
+
console.log("Either:");
|
|
1858
|
+
console.log(" - Add a " + chalk.cyan("name") + " field to package.json");
|
|
1859
|
+
console.log(" - Use the " + chalk.cyan("--project <name>") + " flag");
|
|
1860
|
+
console.log();
|
|
1861
|
+
process.exit(1);
|
|
1862
|
+
}
|
|
1863
|
+
const serviceName = sanitizeServiceName(projectName);
|
|
1864
|
+
if (!serviceName) {
|
|
1865
|
+
console.log(chalk.red("Error:") + " Project name sanitizes to an empty service name.");
|
|
1866
|
+
console.log();
|
|
1867
|
+
console.log("Use the " + chalk.cyan("--project <name>") + " flag to provide a valid service name.");
|
|
1868
|
+
console.log();
|
|
1869
|
+
process.exit(1);
|
|
1870
|
+
}
|
|
1871
|
+
const region = options.region ?? "us-central1";
|
|
1872
|
+
console.log();
|
|
1873
|
+
console.log(chalk.gray("Service: ") + chalk.white(serviceName));
|
|
1874
|
+
console.log(chalk.gray("Region: ") + chalk.white(region));
|
|
1875
|
+
console.log(chalk.gray("GCP Project: ") + chalk.white(gcpProject));
|
|
1876
|
+
console.log(chalk.gray("Turso Org: ") + chalk.white(tursoConfig.org) + chalk.gray(` (via ${sourceLabel})`));
|
|
1877
|
+
console.log();
|
|
1878
|
+
if (!options.skipLocalBuild) {
|
|
1879
|
+
let dockerAvailable = false;
|
|
1880
|
+
try {
|
|
1881
|
+
await execa("docker", ["--version"], { stdio: "pipe" });
|
|
1882
|
+
dockerAvailable = true;
|
|
1883
|
+
} catch {
|
|
1884
|
+
spinner.warn("Docker not found — skipping local build check");
|
|
1885
|
+
}
|
|
1886
|
+
if (dockerAvailable) {
|
|
1887
|
+
spinner.start("Running local Docker build check...");
|
|
1888
|
+
try {
|
|
1889
|
+
await execa("docker", [
|
|
1890
|
+
"build",
|
|
1891
|
+
"-t",
|
|
1892
|
+
`${serviceName}-check`,
|
|
1893
|
+
"."
|
|
1894
|
+
], {
|
|
1895
|
+
cwd,
|
|
1896
|
+
stdio: "pipe"
|
|
1897
|
+
});
|
|
1898
|
+
spinner.succeed("Local Docker build passed");
|
|
1899
|
+
} catch (buildErr) {
|
|
1900
|
+
spinner.fail("Local Docker build failed");
|
|
1901
|
+
const buildError = buildErr;
|
|
1902
|
+
if (buildError.stderr) {
|
|
1903
|
+
console.log();
|
|
1904
|
+
console.log(chalk.gray(buildError.stderr));
|
|
1905
|
+
}
|
|
1906
|
+
console.log();
|
|
1907
|
+
console.log(chalk.red("Fix the Dockerfile errors above before deploying."));
|
|
1908
|
+
console.log(chalk.gray("Tip: Use --skip-local-build to bypass this check."));
|
|
1909
|
+
console.log();
|
|
1910
|
+
process.exit(1);
|
|
1911
|
+
}
|
|
1912
|
+
console.log();
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
try {
|
|
1916
|
+
const dbResult = await provisionDatabase(tursoConfig, serviceName, options.dbRegion, {
|
|
1917
|
+
onGroupCreating: () => {
|
|
1918
|
+
spinner.start("Ensuring Turso database group...");
|
|
1919
|
+
},
|
|
1920
|
+
onGroupReady: () => {
|
|
1921
|
+
spinner.succeed("Database group ready");
|
|
1922
|
+
},
|
|
1923
|
+
onDatabaseCreating: (name) => {
|
|
1924
|
+
spinner.start(`Creating database "${name}"...`);
|
|
1925
|
+
},
|
|
1926
|
+
onDatabaseReady: (name) => {
|
|
1927
|
+
spinner.succeed(`Database "${name}" ready`);
|
|
1928
|
+
},
|
|
1929
|
+
onTokenCreating: () => {
|
|
1930
|
+
spinner.start("Creating database auth token...");
|
|
1931
|
+
},
|
|
1932
|
+
onTokenReady: () => {
|
|
1933
|
+
spinner.succeed("Auth token created");
|
|
1934
|
+
}
|
|
1935
|
+
});
|
|
1936
|
+
if (!dbResult.success) {
|
|
1937
|
+
spinner.fail("Turso provisioning failed");
|
|
1938
|
+
console.log();
|
|
1939
|
+
console.log(chalk.red("Error:") + " " + dbResult.error.message);
|
|
1940
|
+
if (dbResult.error.details) console.log(chalk.gray("Details: ") + dbResult.error.details);
|
|
1941
|
+
console.log();
|
|
1942
|
+
process.exit(1);
|
|
1943
|
+
}
|
|
1944
|
+
const database = dbResult.value;
|
|
1945
|
+
console.log();
|
|
1946
|
+
console.log(chalk.gray("Database URL: ") + chalk.cyan(database.url));
|
|
1947
|
+
console.log();
|
|
1948
|
+
const deployResult = await deployToCloudRun({
|
|
1949
|
+
gcpProject,
|
|
1950
|
+
region,
|
|
1951
|
+
serviceName,
|
|
1952
|
+
sourceDir: cwd,
|
|
1953
|
+
requireAuth: options.requireAuth,
|
|
1954
|
+
envVars: {
|
|
1955
|
+
DATABASE_URL: database.url,
|
|
1956
|
+
DATABASE_AUTH_TOKEN: database.authToken,
|
|
1957
|
+
NODE_ENV: "production",
|
|
1958
|
+
FLUID_COMPANY_API_KEY: fluidResult.value.apiKey
|
|
1959
|
+
}
|
|
1960
|
+
}, {
|
|
1961
|
+
onValidating: () => {
|
|
1962
|
+
spinner.start("Preparing Cloud Run deployment...");
|
|
1963
|
+
},
|
|
1964
|
+
onDeploying: () => {
|
|
1965
|
+
spinner.text = "Deploying to Cloud Run (this may take 2-5 minutes)...";
|
|
1966
|
+
},
|
|
1967
|
+
onDeployComplete: () => {
|
|
1968
|
+
spinner.succeed("Deployed to Cloud Run");
|
|
1969
|
+
}
|
|
1970
|
+
});
|
|
1971
|
+
if (!deployResult.success) {
|
|
1972
|
+
spinner.fail("Cloud Run deployment failed");
|
|
1973
|
+
console.log();
|
|
1974
|
+
console.log(chalk.red("Error:") + " " + deployResult.error.message);
|
|
1975
|
+
if (deployResult.error.details) console.log(chalk.gray("Details: ") + deployResult.error.details);
|
|
1976
|
+
console.log();
|
|
1977
|
+
process.exit(1);
|
|
1978
|
+
}
|
|
1979
|
+
console.log();
|
|
1980
|
+
console.log(chalk.green.bold("Deployed successfully!"));
|
|
1981
|
+
console.log();
|
|
1982
|
+
console.log(chalk.gray("Service URL: ") + chalk.cyan(deployResult.value.url));
|
|
1983
|
+
console.log(chalk.gray("Database: ") + chalk.cyan(database.databaseName));
|
|
1984
|
+
console.log(chalk.gray("Region: ") + chalk.cyan(deployResult.value.region));
|
|
1985
|
+
console.log(chalk.gray("GCP Project: ") + chalk.cyan(deployResult.value.gcpProject));
|
|
1986
|
+
console.log();
|
|
1987
|
+
if (!options.skipNavSync) {
|
|
1988
|
+
const configPath = path.join(cwd, "src", "navigation.config.ts");
|
|
1989
|
+
if (await fs.pathExists(configPath)) {
|
|
1990
|
+
const navSpinner = ora("Extracting navigation from navigation.config.ts...").start();
|
|
1991
|
+
const extractResult = await extractNavigation(cwd);
|
|
1992
|
+
if (extractResult.success && extractResult.value != null) {
|
|
1993
|
+
const navItems = extractResult.value;
|
|
1994
|
+
navSpinner.text = `Syncing ${navItems.length} navigation item(s)...`;
|
|
1995
|
+
const syncResult = await syncNavigation(fluidResult.value.apiKey, navItems);
|
|
1996
|
+
if (syncResult.success) {
|
|
1997
|
+
const { created, updated, deleted } = syncResult.value;
|
|
1998
|
+
const parts = [];
|
|
1999
|
+
if (created > 0) parts.push(`${created} created`);
|
|
2000
|
+
if (updated > 0) parts.push(`${updated} updated`);
|
|
2001
|
+
if (deleted > 0) parts.push(`${deleted} deleted`);
|
|
2002
|
+
if (parts.length > 0) navSpinner.succeed(`Navigation synced (${parts.join(", ")})`);
|
|
2003
|
+
else navSpinner.succeed("Navigation up to date");
|
|
2004
|
+
} else {
|
|
2005
|
+
navSpinner.warn("Navigation sync failed (deploy succeeded)");
|
|
2006
|
+
console.log(chalk.yellow(" Warning: ") + syncResult.error.message);
|
|
2007
|
+
if (syncResult.error.details) console.log(chalk.gray(" Details: ") + syncResult.error.details);
|
|
2008
|
+
}
|
|
2009
|
+
} else if (extractResult.success && extractResult.value == null) navSpinner.info("No navigation export found in navigation.config.ts — skipping sync");
|
|
2010
|
+
else {
|
|
2011
|
+
navSpinner.warn("Could not extract navigation (deploy succeeded)");
|
|
2012
|
+
if (!extractResult.success && extractResult.error.details) console.log(chalk.gray(" Details: ") + extractResult.error.details);
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
const serviceUrl = deployResult.value.url;
|
|
2017
|
+
const healthSpinner = ora("Running health check...").start();
|
|
2018
|
+
try {
|
|
2019
|
+
const controller = new AbortController();
|
|
2020
|
+
const timeout = setTimeout(() => controller.abort(), 3e4);
|
|
2021
|
+
const healthRes = await fetch(`${serviceUrl}/api/health`, { signal: controller.signal });
|
|
2022
|
+
clearTimeout(timeout);
|
|
2023
|
+
if (healthRes.ok) healthSpinner.succeed("Health check passed");
|
|
2024
|
+
else healthSpinner.warn("Health check returned non-200 (service may still be starting)");
|
|
2025
|
+
} catch {
|
|
2026
|
+
healthSpinner.warn("Health check timed out (cold start may take a moment)");
|
|
2027
|
+
}
|
|
2028
|
+
if (options.migrate || database.isNew) {
|
|
2029
|
+
if (database.isNew && !options.migrate) {
|
|
2030
|
+
console.log();
|
|
2031
|
+
console.log(chalk.blue("New database") + " — running migrations automatically...");
|
|
2032
|
+
}
|
|
2033
|
+
const migrateCmd = getRunCommand("db:push");
|
|
2034
|
+
const migrateSpinner = ora(`Running migrations (${migrateCmd})...`).start();
|
|
2035
|
+
try {
|
|
2036
|
+
const [pmBin, ...pmArgs] = migrateCmd.split(" ");
|
|
2037
|
+
await execa(pmBin, pmArgs, {
|
|
2038
|
+
cwd,
|
|
2039
|
+
stdio: "pipe",
|
|
2040
|
+
env: {
|
|
2041
|
+
...process.env,
|
|
2042
|
+
DATABASE_URL: database.url,
|
|
2043
|
+
DATABASE_AUTH_TOKEN: database.authToken
|
|
2044
|
+
}
|
|
2045
|
+
});
|
|
2046
|
+
migrateSpinner.succeed("Database migrations complete");
|
|
2047
|
+
} catch (migrateErr) {
|
|
2048
|
+
const migrateError = migrateErr;
|
|
2049
|
+
migrateSpinner.warn("Database migration failed (deploy succeeded)");
|
|
2050
|
+
console.log();
|
|
2051
|
+
console.log(chalk.yellow("Migration error:") + " " + (migrateError.stderr ?? migrateError.message ?? String(migrateErr)));
|
|
2052
|
+
console.log();
|
|
2053
|
+
console.log("Run migrations manually:");
|
|
2054
|
+
console.log(` DATABASE_URL=${database.url} DATABASE_AUTH_TOKEN=<token> ${migrateCmd}`);
|
|
2055
|
+
}
|
|
2056
|
+
} else {
|
|
2057
|
+
console.log();
|
|
2058
|
+
console.log(chalk.yellow("Important:") + " Run database migrations against your production database:");
|
|
2059
|
+
console.log(` DATABASE_URL=${database.url} DATABASE_AUTH_TOKEN=<token> pnpm db:push`);
|
|
2060
|
+
console.log(chalk.gray("Tip: Use --migrate to run migrations automatically."));
|
|
2061
|
+
}
|
|
2062
|
+
console.log();
|
|
2063
|
+
} catch (error) {
|
|
2064
|
+
spinner.fail("Deployment failed");
|
|
2065
|
+
console.log();
|
|
2066
|
+
const errorMessage = getErrorMessage(error);
|
|
2067
|
+
console.log(chalk.red("Error:") + " " + errorMessage);
|
|
2068
|
+
console.log();
|
|
2069
|
+
process.exit(1);
|
|
2070
|
+
}
|
|
2071
|
+
});
|
|
2072
|
+
function registerDeployCommand(ctx) {
|
|
2073
|
+
ctx.program.addCommand(deployCommand);
|
|
2074
|
+
}
|
|
2075
|
+
//#endregion
|
|
2076
|
+
//#region src/commands/destroy.ts
|
|
2077
|
+
const destroyCommand = new Command("destroy").description("Tear down deployed Cloud Run service and Turso database").option("--region <region>", "Cloud Run region", "us-central1").option("--gcp-project <id>", "GCP project ID (default: from gcloud config)").option("-p, --project <name>", "Service name override (default: from package.json)").option("--turso-org <slug>", "Turso organization slug (skips interactive org selection)").option("--fluid-company-api-key <key>", "Fluid company API key (skips env var lookup and prompt)").option("-y, --yes", "Skip confirmation prompt").action(async (options) => {
|
|
2078
|
+
const cwd = process.cwd();
|
|
2079
|
+
config({ path: path.join(cwd, ".env") });
|
|
2080
|
+
console.log();
|
|
2081
|
+
console.log(chalk.red.bold("Fluid Destroy") + chalk.gray(" (Cloud Run + Turso)"));
|
|
2082
|
+
console.log();
|
|
2083
|
+
const spinner = ora();
|
|
2084
|
+
spinner.start("Validating Fluid API key...");
|
|
2085
|
+
const fluidResult = await resolveFluidApiKey(options.fluidCompanyApiKey);
|
|
2086
|
+
if (!fluidResult.success) {
|
|
2087
|
+
spinner.fail("Fluid API key validation failed");
|
|
2088
|
+
console.log();
|
|
2089
|
+
console.log(chalk.red("Error:") + " " + fluidResult.error.message);
|
|
2090
|
+
if (fluidResult.error.details) {
|
|
2091
|
+
console.log();
|
|
2092
|
+
console.log(fluidResult.error.details);
|
|
2093
|
+
}
|
|
2094
|
+
console.log();
|
|
2095
|
+
process.exit(1);
|
|
2096
|
+
}
|
|
2097
|
+
spinner.succeed(`Fluid company: ${chalk.cyan(fluidResult.value.name)}`);
|
|
2098
|
+
spinner.start("Checking gcloud CLI...");
|
|
2099
|
+
const gcloudResult = await validateGcloudInstalled();
|
|
2100
|
+
if (!gcloudResult.success) {
|
|
2101
|
+
spinner.fail("gcloud CLI not found");
|
|
2102
|
+
console.log();
|
|
2103
|
+
console.log(chalk.red("Error:") + " " + gcloudResult.error.message);
|
|
2104
|
+
console.log();
|
|
2105
|
+
process.exit(1);
|
|
2106
|
+
}
|
|
2107
|
+
const authResult = await validateGcloudAuth();
|
|
2108
|
+
if (!authResult.success) {
|
|
2109
|
+
spinner.fail("gcloud not authenticated");
|
|
2110
|
+
console.log();
|
|
2111
|
+
console.log(chalk.red("Error:") + " " + authResult.error.message);
|
|
2112
|
+
console.log();
|
|
2113
|
+
process.exit(1);
|
|
2114
|
+
}
|
|
2115
|
+
spinner.succeed("gcloud CLI ready");
|
|
2116
|
+
let gcpProject = options.gcpProject;
|
|
2117
|
+
if (!gcpProject) {
|
|
2118
|
+
spinner.start("Detecting GCP project...");
|
|
2119
|
+
const projectResult = await getGcpProject();
|
|
2120
|
+
if (!projectResult.success) {
|
|
2121
|
+
spinner.fail("No GCP project configured");
|
|
2122
|
+
console.log();
|
|
2123
|
+
console.log(chalk.red("Error:") + " " + projectResult.error.message);
|
|
2124
|
+
console.log();
|
|
2125
|
+
process.exit(1);
|
|
2126
|
+
}
|
|
2127
|
+
gcpProject = projectResult.value;
|
|
2128
|
+
spinner.succeed(`GCP project: ${chalk.cyan(gcpProject)}`);
|
|
2129
|
+
}
|
|
2130
|
+
spinner.start("Resolving Turso credentials...");
|
|
2131
|
+
const tursoConfigResult = await resolveTursoConfig(options.tursoOrg);
|
|
2132
|
+
if (!tursoConfigResult.success) {
|
|
2133
|
+
spinner.fail("Turso credentials not found");
|
|
2134
|
+
console.log();
|
|
2135
|
+
console.log(chalk.red("Error:") + " " + tursoConfigResult.error.message);
|
|
2136
|
+
if (tursoConfigResult.error.details) {
|
|
2137
|
+
console.log();
|
|
2138
|
+
console.log(tursoConfigResult.error.details);
|
|
2139
|
+
}
|
|
2140
|
+
console.log();
|
|
2141
|
+
process.exit(1);
|
|
2142
|
+
}
|
|
2143
|
+
const tursoConfig = tursoConfigResult.value;
|
|
2144
|
+
spinner.succeed("Turso credentials resolved");
|
|
2145
|
+
const projectName = options.project ?? await getProjectName(cwd);
|
|
2146
|
+
if (!projectName) {
|
|
2147
|
+
console.log(chalk.red("Error:") + " Could not determine project name.");
|
|
2148
|
+
console.log();
|
|
2149
|
+
console.log("Either:");
|
|
2150
|
+
console.log(" - Add a " + chalk.cyan("name") + " field to package.json");
|
|
2151
|
+
console.log(" - Use the " + chalk.cyan("--project <name>") + " flag");
|
|
2152
|
+
console.log();
|
|
2153
|
+
process.exit(1);
|
|
2154
|
+
}
|
|
2155
|
+
const serviceName = sanitizeServiceName(projectName);
|
|
2156
|
+
if (!serviceName) {
|
|
2157
|
+
console.log(chalk.red("Error:") + " Project name sanitizes to an empty service name.");
|
|
2158
|
+
console.log();
|
|
2159
|
+
console.log("Use the " + chalk.cyan("--project <name>") + " flag to provide a valid service name.");
|
|
2160
|
+
console.log();
|
|
2161
|
+
process.exit(1);
|
|
2162
|
+
}
|
|
2163
|
+
const region = options.region ?? "us-central1";
|
|
2164
|
+
console.log();
|
|
2165
|
+
console.log(chalk.yellow("The following resources will be destroyed:"));
|
|
2166
|
+
console.log();
|
|
2167
|
+
console.log(chalk.gray(" Cloud Run service: ") + chalk.white(serviceName));
|
|
2168
|
+
console.log(chalk.gray(" Region: ") + chalk.white(region));
|
|
2169
|
+
console.log(chalk.gray(" GCP Project: ") + chalk.white(gcpProject));
|
|
2170
|
+
console.log(chalk.gray(" Turso database: ") + chalk.white(serviceName));
|
|
2171
|
+
console.log(chalk.gray(" Turso org: ") + chalk.white(tursoConfig.org));
|
|
2172
|
+
console.log();
|
|
2173
|
+
if (!options.yes) {
|
|
2174
|
+
const { confirmed } = await prompts({
|
|
2175
|
+
type: "confirm",
|
|
2176
|
+
name: "confirmed",
|
|
2177
|
+
message: "Are you sure you want to destroy these resources?",
|
|
2178
|
+
initial: false
|
|
2179
|
+
});
|
|
2180
|
+
if (!confirmed) {
|
|
2181
|
+
console.log();
|
|
2182
|
+
console.log(chalk.gray("Destroy cancelled."));
|
|
2183
|
+
console.log();
|
|
2184
|
+
return;
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
console.log();
|
|
2188
|
+
spinner.start(`Deleting Cloud Run service "${serviceName}"...`);
|
|
2189
|
+
const deleteServiceResult = await deleteCloudRunService({
|
|
2190
|
+
serviceName,
|
|
2191
|
+
gcpProject,
|
|
2192
|
+
region
|
|
2193
|
+
});
|
|
2194
|
+
if (!deleteServiceResult.success) {
|
|
2195
|
+
spinner.warn("Cloud Run service deletion failed");
|
|
2196
|
+
console.log(chalk.yellow("Warning:") + " " + deleteServiceResult.error.message);
|
|
2197
|
+
if (deleteServiceResult.error.details) console.log(chalk.gray("Details: ") + deleteServiceResult.error.details);
|
|
2198
|
+
} else spinner.succeed("Cloud Run service deleted");
|
|
2199
|
+
spinner.start(`Deleting Turso database "${serviceName}"...`);
|
|
2200
|
+
const deleteDbResult = await deleteDatabase(tursoConfig, serviceName);
|
|
2201
|
+
if (!deleteDbResult.success) {
|
|
2202
|
+
spinner.warn("Turso database deletion failed");
|
|
2203
|
+
console.log(chalk.yellow("Warning:") + " " + deleteDbResult.error.message);
|
|
2204
|
+
if (deleteDbResult.error.details) console.log(chalk.gray("Details: ") + deleteDbResult.error.details);
|
|
2205
|
+
} else spinner.succeed("Turso database deleted");
|
|
2206
|
+
console.log();
|
|
2207
|
+
if (deleteServiceResult.success && deleteDbResult.success) console.log(chalk.green.bold("All resources destroyed successfully."));
|
|
2208
|
+
else console.log(chalk.yellow.bold("Destroy completed with warnings.") + " Some resources may need manual cleanup.");
|
|
2209
|
+
console.log();
|
|
2210
|
+
});
|
|
2211
|
+
function registerDestroyCommand(ctx) {
|
|
2212
|
+
ctx.program.addCommand(destroyCommand);
|
|
2213
|
+
}
|
|
2214
|
+
//#endregion
|
|
2215
|
+
//#region src/index.ts
|
|
2216
|
+
const plugin = {
|
|
2217
|
+
name: "fluid-cli-portal",
|
|
2218
|
+
version: "0.1.0",
|
|
2219
|
+
async register(ctx) {
|
|
2220
|
+
registerCreateCommand(ctx);
|
|
2221
|
+
registerDevCommand(ctx);
|
|
2222
|
+
registerBuildCommand(ctx);
|
|
2223
|
+
registerDeployCommand(ctx);
|
|
2224
|
+
registerDestroyCommand(ctx);
|
|
2225
|
+
}
|
|
2226
|
+
};
|
|
2227
|
+
//#endregion
|
|
2228
|
+
export { CLOUD_RUN_ERRORS, FILE_SYSTEM_ERRORS, FLUID_API_ERROR, TEMPLATES, TURSO_ERROR, copyTemplate, copyTemplateSafe, createCommand, createDatabase, createDatabaseToken, createDirectory, createDirectorySafe, plugin as default, deleteCloudRunService, deleteDatabase, deployToCloudRun, destroyCommand, directoryExists, ensureGroup, failure, fetchLocations, fileExists, getErrorMessage, getGcpProject, getInstallCommand, getRunCommand, getSdkVersion, getSdkVersionSafe, getTemplatePaths, installDependencies, isError, isFailure, isNodeError, isSuccess, isTemplateName, mapError, mapResult, parseOrgList, pathExists, promptProjectConfig, provisionDatabase, readFileSafe, resolveFluidApiKey, resolveTursoConfig, runPackageManager, success, tryCatch, tryCatchAsync, unwrap, unwrapOr, validateFluidApiKey, validateGcloudAuth, validateGcloudInstalled, validateLocation, validateTursoConfig, writeFileSafe };
|
|
2229
|
+
|
|
2230
|
+
//# sourceMappingURL=index.mjs.map
|