@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.
Files changed (45) hide show
  1. package/dist/index.d.mts +680 -0
  2. package/dist/index.d.mts.map +1 -0
  3. package/dist/index.mjs +2230 -0
  4. package/dist/index.mjs.map +1 -0
  5. package/package.json +54 -0
  6. package/templates/base/index.html +14 -0
  7. package/templates/base/src/App.tsx +18 -0
  8. package/templates/base/src/index.css +80 -0
  9. package/templates/base/src/main.tsx +42 -0
  10. package/templates/base/src/navigation.config.ts +21 -0
  11. package/templates/base/src/portal.config.ts +32 -0
  12. package/templates/base/src/screens/Dashboard.tsx +133 -0
  13. package/templates/base/src/screens/ExampleForm.tsx +187 -0
  14. package/templates/base/src/vite-env.d.ts +1 -0
  15. package/templates/base/tsconfig.json +26 -0
  16. package/templates/fullstack/.dockerignore +9 -0
  17. package/templates/fullstack/.env.example +15 -0
  18. package/templates/fullstack/.github/workflows/ci.yml +47 -0
  19. package/templates/fullstack/.github/workflows/deploy.yml +54 -0
  20. package/templates/fullstack/Dockerfile +44 -0
  21. package/templates/fullstack/README.md.template +176 -0
  22. package/templates/fullstack/drizzle/0000_initial.sql +7 -0
  23. package/templates/fullstack/drizzle/meta/0000_snapshot.json +63 -0
  24. package/templates/fullstack/drizzle/meta/_journal.json +13 -0
  25. package/templates/fullstack/drizzle.config.ts +13 -0
  26. package/templates/fullstack/esbuild.config.js +14 -0
  27. package/templates/fullstack/eslint.config.js +13 -0
  28. package/templates/fullstack/package.json.template +63 -0
  29. package/templates/fullstack/src/fluid.config.ts.template +69 -0
  30. package/templates/fullstack/src/server/db/index.ts +10 -0
  31. package/templates/fullstack/src/server/db/migrate.ts +12 -0
  32. package/templates/fullstack/src/server/db/schema.ts +14 -0
  33. package/templates/fullstack/src/server/entry.ts +59 -0
  34. package/templates/fullstack/src/server/index.ts +33 -0
  35. package/templates/fullstack/src/server/routes/index.test.ts +123 -0
  36. package/templates/fullstack/src/server/routes/index.ts +109 -0
  37. package/templates/fullstack/src/server/routes/schemas.ts +7 -0
  38. package/templates/fullstack/src/test/setup.ts +9 -0
  39. package/templates/fullstack/vite.config.ts +39 -0
  40. package/templates/fullstack/vitest.config.ts +9 -0
  41. package/templates/starter/.env.example +1 -0
  42. package/templates/starter/README.md.template +218 -0
  43. package/templates/starter/package.json.template +40 -0
  44. package/templates/starter/src/fluid.config.ts.template +69 -0
  45. 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