@fluid-app/fluid-cli-portal 0.1.13 → 0.1.15
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 +1 -389
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +18 -1596
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -6
- package/templates/fullstack/.dockerignore +0 -9
- package/templates/fullstack/.env.example +0 -15
- package/templates/fullstack/.github/workflows/ci.yml +0 -47
- package/templates/fullstack/.github/workflows/deploy.yml +0 -54
- package/templates/fullstack/Dockerfile +0 -44
- package/templates/fullstack/README.md.template +0 -176
- package/templates/fullstack/drizzle/0000_initial.sql +0 -7
- package/templates/fullstack/drizzle/meta/0000_snapshot.json +0 -63
- package/templates/fullstack/drizzle/meta/_journal.json +0 -13
- package/templates/fullstack/drizzle.config.ts +0 -13
- package/templates/fullstack/esbuild.config.js +0 -14
- package/templates/fullstack/package.json.template +0 -58
- package/templates/fullstack/src/server/db/index.ts +0 -10
- package/templates/fullstack/src/server/db/migrate.ts +0 -12
- package/templates/fullstack/src/server/db/schema.ts +0 -14
- package/templates/fullstack/src/server/entry.ts +0 -59
- package/templates/fullstack/src/server/index.ts +0 -33
- package/templates/fullstack/src/server/routes/index.test.ts +0 -123
- package/templates/fullstack/src/server/routes/index.ts +0 -110
- package/templates/fullstack/src/server/routes/schemas.ts +0 -7
- package/templates/fullstack/src/test/setup.ts +0 -9
- package/templates/fullstack/vite.config.ts +0 -41
- package/templates/fullstack/vitest.config.ts +0 -9
package/dist/index.mjs
CHANGED
|
@@ -8,25 +8,14 @@ import prompts from "prompts";
|
|
|
8
8
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
import Handlebars from "handlebars";
|
|
11
|
-
import { failure, getActiveProfile, getAuthToken,
|
|
11
|
+
import { failure, getActiveProfile, getAuthToken, success } from "@fluid-app/fluid-cli";
|
|
12
12
|
import { execa } from "execa";
|
|
13
13
|
import fs from "fs-extra";
|
|
14
|
-
import path$1 from "path";
|
|
15
|
-
import { config } from "dotenv";
|
|
16
14
|
//#region src/types.ts
|
|
17
15
|
/**
|
|
18
16
|
* Available project templates
|
|
19
17
|
*/
|
|
20
|
-
const TEMPLATES = {
|
|
21
|
-
starter: "starter",
|
|
22
|
-
fullstack: "fullstack"
|
|
23
|
-
};
|
|
24
|
-
/**
|
|
25
|
-
* Type guard to check if a string is a valid template name
|
|
26
|
-
*/
|
|
27
|
-
function isTemplateName(value) {
|
|
28
|
-
return Object.values(TEMPLATES).includes(value);
|
|
29
|
-
}
|
|
18
|
+
const TEMPLATES = { starter: "starter" };
|
|
30
19
|
//#endregion
|
|
31
20
|
//#region src/utils/prompts.ts
|
|
32
21
|
/**
|
|
@@ -40,20 +29,6 @@ const OPTIONAL_PAGE_TEMPLATES = [];
|
|
|
40
29
|
*/
|
|
41
30
|
async function promptProjectConfig(projectName, options) {
|
|
42
31
|
const questions = [];
|
|
43
|
-
if (!options.template) questions.push({
|
|
44
|
-
type: "select",
|
|
45
|
-
name: "template",
|
|
46
|
-
message: "Select a project template",
|
|
47
|
-
choices: [{
|
|
48
|
-
title: "Starter",
|
|
49
|
-
value: TEMPLATES.starter,
|
|
50
|
-
description: "Frontend-only (React + Vite + Tailwind + portal-sdk)"
|
|
51
|
-
}, {
|
|
52
|
-
title: "Fullstack",
|
|
53
|
-
value: TEMPLATES.fullstack,
|
|
54
|
-
description: "Frontend + API server (Hono + Drizzle + SQLite)"
|
|
55
|
-
}]
|
|
56
|
-
});
|
|
57
32
|
if (OPTIONAL_PAGE_TEMPLATES.length > 0) questions.push({
|
|
58
33
|
type: "multiselect",
|
|
59
34
|
name: "selectedPages",
|
|
@@ -75,36 +50,25 @@ async function promptProjectConfig(projectName, options) {
|
|
|
75
50
|
message: "Install dependencies?",
|
|
76
51
|
initial: true
|
|
77
52
|
});
|
|
78
|
-
if (!process.stdin.isTTY && questions.length > 0) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const templateRaw = options.template;
|
|
89
|
-
return {
|
|
90
|
-
name: projectName,
|
|
91
|
-
template: isTemplateName(templateRaw ?? "") ? templateRaw : TEMPLATES.starter,
|
|
92
|
-
installDeps: options.skipInstall ? false : true,
|
|
93
|
-
selectedPages: []
|
|
94
|
-
};
|
|
95
|
-
}
|
|
53
|
+
if (!process.stdin.isTTY && questions.length > 0) return {
|
|
54
|
+
name: projectName,
|
|
55
|
+
installDeps: false,
|
|
56
|
+
selectedPages: []
|
|
57
|
+
};
|
|
58
|
+
if (questions.length === 0) return {
|
|
59
|
+
name: projectName,
|
|
60
|
+
installDeps: options.skipInstall ? false : true,
|
|
61
|
+
selectedPages: []
|
|
62
|
+
};
|
|
96
63
|
let cancelled = false;
|
|
97
64
|
const response = await prompts(questions, { onCancel: () => {
|
|
98
65
|
cancelled = true;
|
|
99
66
|
return false;
|
|
100
67
|
} });
|
|
101
68
|
if (cancelled) return null;
|
|
102
|
-
const templateRaw = options.template ?? response.template;
|
|
103
|
-
const template = isTemplateName(templateRaw) ? templateRaw : TEMPLATES.starter;
|
|
104
69
|
const selectedPages = response.selectedPages ?? [];
|
|
105
70
|
return {
|
|
106
71
|
name: projectName,
|
|
107
|
-
template,
|
|
108
72
|
installDeps: options.skipInstall ? false : response.installDeps ?? true,
|
|
109
73
|
selectedPages
|
|
110
74
|
};
|
|
@@ -358,7 +322,7 @@ async function installDependencies(cwd) {
|
|
|
358
322
|
}
|
|
359
323
|
//#endregion
|
|
360
324
|
//#region src/commands/create.ts
|
|
361
|
-
const createCommand = new Command("create").description("Create a new Fluid portal application").argument("<app-name>", "Name of the application to create").option("
|
|
325
|
+
const createCommand = new Command("create").description("Create a new Fluid portal application").argument("<app-name>", "Name of the application to create").option("--skip-install", "Skip dependency installation").option("-o, --output-dir <dir>", "Directory to create the project in (defaults to cwd)").action(async (appName, options) => {
|
|
362
326
|
try {
|
|
363
327
|
console.log();
|
|
364
328
|
console.log(chalk.bold("Creating a new Fluid portal application"));
|
|
@@ -379,13 +343,13 @@ const createCommand = new Command("create").description("Create a new Fluid port
|
|
|
379
343
|
process.exit(0);
|
|
380
344
|
}
|
|
381
345
|
console.log();
|
|
382
|
-
const templatePaths = getTemplatePaths(
|
|
346
|
+
const templatePaths = getTemplatePaths("starter");
|
|
383
347
|
if (!await directoryExists(templatePaths.base)) {
|
|
384
348
|
console.error(chalk.red("Error: Base template not found"));
|
|
385
349
|
process.exit(1);
|
|
386
350
|
}
|
|
387
351
|
if (!await directoryExists(templatePaths.overlay)) {
|
|
388
|
-
console.error(chalk.red(
|
|
352
|
+
console.error(chalk.red("Error: Starter template not found"));
|
|
389
353
|
process.exit(1);
|
|
390
354
|
}
|
|
391
355
|
const sdkVersion = await getSdkVersion();
|
|
@@ -435,11 +399,9 @@ const createCommand = new Command("create").description("Create a new Fluid port
|
|
|
435
399
|
const cdPath = options.outputDir ? targetPath : appName;
|
|
436
400
|
console.log(chalk.cyan(` cd ${cdPath}`));
|
|
437
401
|
if (!config.installDeps) console.log(chalk.cyan(" pnpm install"));
|
|
438
|
-
if (config.template === TEMPLATES.fullstack) console.log(chalk.cyan(` ${getRunCommand("db:push")}`) + " # create the database");
|
|
439
402
|
console.log(chalk.cyan(` ${getRunCommand("dev")}`));
|
|
440
403
|
console.log();
|
|
441
404
|
console.log("Then open " + chalk.cyan("http://localhost:5173") + " in your browser.");
|
|
442
|
-
if (config.template === TEMPLATES.fullstack) console.log("API server: " + chalk.cyan("http://localhost:5173/api/health"));
|
|
443
405
|
console.log(chalk.dim(" (port may differ if 5173 is in use — check the dev server output)"));
|
|
444
406
|
console.log();
|
|
445
407
|
console.log("Edit " + chalk.cyan("src/portal.config.ts") + " to customize your navigation.");
|
|
@@ -572,1544 +534,6 @@ const buildCommand = new Command("build").description("Build the application for
|
|
|
572
534
|
}
|
|
573
535
|
});
|
|
574
536
|
//#endregion
|
|
575
|
-
//#region src/utils/turso.ts
|
|
576
|
-
const TURSO_API_BASE = "https://api.turso.tech/v1";
|
|
577
|
-
const TURSO_ERROR = {
|
|
578
|
-
MISSING_TOKEN: {
|
|
579
|
-
code: "MISSING_TOKEN",
|
|
580
|
-
message: "TURSO_API_TOKEN environment variable is not set"
|
|
581
|
-
},
|
|
582
|
-
MISSING_ORG: {
|
|
583
|
-
code: "MISSING_ORG",
|
|
584
|
-
message: "TURSO_ORG environment variable is not set"
|
|
585
|
-
},
|
|
586
|
-
GROUP_CREATION_FAILED: {
|
|
587
|
-
code: "GROUP_CREATION_FAILED",
|
|
588
|
-
message: "Failed to create database group"
|
|
589
|
-
},
|
|
590
|
-
DATABASE_CREATION_FAILED: {
|
|
591
|
-
code: "DATABASE_CREATION_FAILED",
|
|
592
|
-
message: "Failed to create database"
|
|
593
|
-
},
|
|
594
|
-
TOKEN_CREATION_FAILED: {
|
|
595
|
-
code: "TOKEN_CREATION_FAILED",
|
|
596
|
-
message: "Failed to create database auth token"
|
|
597
|
-
},
|
|
598
|
-
DATABASE_DELETION_FAILED: {
|
|
599
|
-
code: "DATABASE_DELETION_FAILED",
|
|
600
|
-
message: "Failed to delete database"
|
|
601
|
-
},
|
|
602
|
-
INVALID_LOCATION: {
|
|
603
|
-
code: "INVALID_LOCATION",
|
|
604
|
-
message: "Invalid database location"
|
|
605
|
-
},
|
|
606
|
-
LOCATIONS_FETCH_FAILED: {
|
|
607
|
-
code: "LOCATIONS_FETCH_FAILED",
|
|
608
|
-
message: "Failed to fetch available Turso locations"
|
|
609
|
-
},
|
|
610
|
-
TURSO_CLI_NOT_FOUND: {
|
|
611
|
-
code: "TURSO_CLI_NOT_FOUND",
|
|
612
|
-
message: "Turso CLI is not installed"
|
|
613
|
-
},
|
|
614
|
-
TURSO_CLI_NOT_AUTHENTICATED: {
|
|
615
|
-
code: "TURSO_CLI_NOT_AUTHENTICATED",
|
|
616
|
-
message: "Turso CLI is not authenticated"
|
|
617
|
-
},
|
|
618
|
-
TURSO_NO_ORGS: {
|
|
619
|
-
code: "TURSO_NO_ORGS",
|
|
620
|
-
message: "No organizations found in Turso CLI"
|
|
621
|
-
}
|
|
622
|
-
};
|
|
623
|
-
/**
|
|
624
|
-
* Create a Turso error from a constant and optional details
|
|
625
|
-
*/
|
|
626
|
-
function createTursoError(template, details) {
|
|
627
|
-
return {
|
|
628
|
-
code: template.code,
|
|
629
|
-
message: template.message,
|
|
630
|
-
details
|
|
631
|
-
};
|
|
632
|
-
}
|
|
633
|
-
/**
|
|
634
|
-
* Validate that required Turso environment variables are present
|
|
635
|
-
*/
|
|
636
|
-
function validateTursoConfig() {
|
|
637
|
-
const apiToken = process.env.TURSO_API_TOKEN;
|
|
638
|
-
if (!apiToken) return failure(createTursoError(TURSO_ERROR.MISSING_TOKEN));
|
|
639
|
-
const org = process.env.TURSO_ORG;
|
|
640
|
-
if (!org) return failure(createTursoError(TURSO_ERROR.MISSING_ORG));
|
|
641
|
-
return success({
|
|
642
|
-
apiToken,
|
|
643
|
-
org
|
|
644
|
-
});
|
|
645
|
-
}
|
|
646
|
-
/**
|
|
647
|
-
* Check if the Turso CLI is installed and authenticated.
|
|
648
|
-
* Runs `turso auth whoami` which verifies both in a single call.
|
|
649
|
-
*/
|
|
650
|
-
async function isTursoCliAvailable() {
|
|
651
|
-
try {
|
|
652
|
-
await execa("turso", ["auth", "whoami"], { stdio: "pipe" });
|
|
653
|
-
return true;
|
|
654
|
-
} catch {
|
|
655
|
-
return false;
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
/**
|
|
659
|
-
* Get the Turso platform API token from the CLI.
|
|
660
|
-
* Runs `turso auth token` which outputs the token to stdout.
|
|
661
|
-
*/
|
|
662
|
-
async function getTursoCliToken() {
|
|
663
|
-
try {
|
|
664
|
-
const { stdout } = await execa("turso", ["auth", "token"], { stdio: "pipe" });
|
|
665
|
-
const token = stdout.trim().split("\n").filter(Boolean).pop()?.trim() ?? "";
|
|
666
|
-
if (!token) return failure(createTursoError(TURSO_ERROR.TURSO_CLI_NOT_AUTHENTICATED, "turso auth token returned an empty value. Run: turso auth login"));
|
|
667
|
-
return success(token);
|
|
668
|
-
} catch {
|
|
669
|
-
return failure(createTursoError(TURSO_ERROR.TURSO_CLI_NOT_AUTHENTICATED, "Failed to get token from Turso CLI. Run: turso auth login"));
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
/**
|
|
673
|
-
* Parse the tabular output of `turso org list`.
|
|
674
|
-
*
|
|
675
|
-
* Example input:
|
|
676
|
-
* Name Slug Type
|
|
677
|
-
* My Org my-org personal (current)
|
|
678
|
-
* Team Org team-org team
|
|
679
|
-
*
|
|
680
|
-
* Exported for unit testing.
|
|
681
|
-
*/
|
|
682
|
-
function parseOrgList(stdout) {
|
|
683
|
-
const lines = stdout.trim().split("\n");
|
|
684
|
-
if (lines.length <= 1) return [];
|
|
685
|
-
return lines.slice(1).reduce((orgs, line) => {
|
|
686
|
-
const trimmed = line.trim();
|
|
687
|
-
if (!trimmed) return orgs;
|
|
688
|
-
const columns = trimmed.split(/\s{2,}/);
|
|
689
|
-
if (columns.length < 2) return orgs;
|
|
690
|
-
const name = columns[0].trim();
|
|
691
|
-
const slug = columns[1].trim().replace(/\s*\(current\)/, "");
|
|
692
|
-
const isCurrent = trimmed.includes("(current)");
|
|
693
|
-
if (name && slug) orgs.push({
|
|
694
|
-
name,
|
|
695
|
-
slug,
|
|
696
|
-
isCurrent
|
|
697
|
-
});
|
|
698
|
-
return orgs;
|
|
699
|
-
}, []);
|
|
700
|
-
}
|
|
701
|
-
/**
|
|
702
|
-
* Get the list of Turso organizations from the CLI.
|
|
703
|
-
*/
|
|
704
|
-
async function getTursoCliOrgs() {
|
|
705
|
-
try {
|
|
706
|
-
const { stdout } = await execa("turso", ["org", "list"], { stdio: "pipe" });
|
|
707
|
-
const orgs = parseOrgList(stdout);
|
|
708
|
-
if (orgs.length === 0) return failure(createTursoError(TURSO_ERROR.TURSO_NO_ORGS, "No organizations found. Create one at https://turso.tech"));
|
|
709
|
-
return success(orgs);
|
|
710
|
-
} catch {
|
|
711
|
-
return failure(createTursoError(TURSO_ERROR.TURSO_NO_ORGS, "Failed to list organizations from Turso CLI."));
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
const TURSO_AUTH_HELP = [
|
|
715
|
-
"Option 1 — Turso CLI (recommended for local dev):",
|
|
716
|
-
" curl -sSfL https://get.tur.so/install.sh | bash",
|
|
717
|
-
" turso auth login",
|
|
718
|
-
"",
|
|
719
|
-
"Option 2 — Environment variables (CI/CD):",
|
|
720
|
-
" export TURSO_API_TOKEN=your_token_here",
|
|
721
|
-
" export TURSO_ORG=your_org_name",
|
|
722
|
-
"",
|
|
723
|
-
"Get your API token at: https://turso.tech/app/settings/api-tokens"
|
|
724
|
-
].join("\n");
|
|
725
|
-
/**
|
|
726
|
-
* Resolve Turso credentials from the best available source.
|
|
727
|
-
*
|
|
728
|
-
* Priority:
|
|
729
|
-
* 1. Environment variables (TURSO_API_TOKEN + TURSO_ORG) — immediate, for CI/CD
|
|
730
|
-
* 2. Turso CLI (turso auth token + turso org list) — interactive, for local dev
|
|
731
|
-
* 3. Fail with instructions for both options
|
|
732
|
-
*
|
|
733
|
-
* @param tursoOrgOverride - Optional org slug from --turso-org flag (skips interactive selection)
|
|
734
|
-
*/
|
|
735
|
-
async function resolveTursoConfig(tursoOrgOverride) {
|
|
736
|
-
const envToken = process.env.TURSO_API_TOKEN;
|
|
737
|
-
const envOrg = process.env.TURSO_ORG;
|
|
738
|
-
if (envToken && envOrg) return success({
|
|
739
|
-
apiToken: envToken,
|
|
740
|
-
org: envOrg,
|
|
741
|
-
source: "env"
|
|
742
|
-
});
|
|
743
|
-
if (!await isTursoCliAvailable()) return failure(createTursoError(TURSO_ERROR.TURSO_CLI_NOT_FOUND, `No Turso credentials found.\n\n${TURSO_AUTH_HELP}`));
|
|
744
|
-
const tokenResult = await getTursoCliToken();
|
|
745
|
-
if (!tokenResult.success) return tokenResult;
|
|
746
|
-
const apiToken = tokenResult.value;
|
|
747
|
-
if (tursoOrgOverride) return success({
|
|
748
|
-
apiToken,
|
|
749
|
-
org: tursoOrgOverride,
|
|
750
|
-
source: "cli"
|
|
751
|
-
});
|
|
752
|
-
const orgsResult = await getTursoCliOrgs();
|
|
753
|
-
if (!orgsResult.success) return orgsResult;
|
|
754
|
-
const orgs = orgsResult.value;
|
|
755
|
-
if (orgs.length === 1) return success({
|
|
756
|
-
apiToken,
|
|
757
|
-
org: orgs[0].slug,
|
|
758
|
-
source: "cli"
|
|
759
|
-
});
|
|
760
|
-
const fluidOrg = orgs.find((o) => o.slug === "fluid");
|
|
761
|
-
if (fluidOrg) return success({
|
|
762
|
-
apiToken,
|
|
763
|
-
org: fluidOrg.slug,
|
|
764
|
-
source: "cli"
|
|
765
|
-
});
|
|
766
|
-
const currentOrg = orgs.find((o) => o.isCurrent);
|
|
767
|
-
if (currentOrg) return success({
|
|
768
|
-
apiToken,
|
|
769
|
-
org: currentOrg.slug,
|
|
770
|
-
source: "cli"
|
|
771
|
-
});
|
|
772
|
-
const { orgSlug } = await prompts({
|
|
773
|
-
type: "select",
|
|
774
|
-
name: "orgSlug",
|
|
775
|
-
message: "Which Turso organization?",
|
|
776
|
-
choices: orgs.map((o) => ({
|
|
777
|
-
title: `${o.name} (${o.slug})`,
|
|
778
|
-
value: o.slug
|
|
779
|
-
}))
|
|
780
|
-
});
|
|
781
|
-
if (!orgSlug) return failure(createTursoError(TURSO_ERROR.TURSO_NO_ORGS, "Organization selection cancelled."));
|
|
782
|
-
return success({
|
|
783
|
-
apiToken,
|
|
784
|
-
org: orgSlug,
|
|
785
|
-
source: "cli"
|
|
786
|
-
});
|
|
787
|
-
}
|
|
788
|
-
/**
|
|
789
|
-
* Build standard headers for Turso API requests
|
|
790
|
-
*/
|
|
791
|
-
function buildHeaders(apiToken) {
|
|
792
|
-
return {
|
|
793
|
-
Authorization: `Bearer ${apiToken}`,
|
|
794
|
-
"Content-Type": "application/json"
|
|
795
|
-
};
|
|
796
|
-
}
|
|
797
|
-
/**
|
|
798
|
-
* Fetch available Turso database locations.
|
|
799
|
-
* Returns a map of location ID → description (e.g., "aws-us-east-1" → "US East (N. Virginia)").
|
|
800
|
-
*/
|
|
801
|
-
async function fetchLocations(config) {
|
|
802
|
-
try {
|
|
803
|
-
const controller = new AbortController();
|
|
804
|
-
const timeout = setTimeout(() => controller.abort(), 3e4);
|
|
805
|
-
const response = await fetch(`${TURSO_API_BASE}/locations`, {
|
|
806
|
-
method: "GET",
|
|
807
|
-
headers: buildHeaders(config.apiToken),
|
|
808
|
-
signal: controller.signal
|
|
809
|
-
});
|
|
810
|
-
clearTimeout(timeout);
|
|
811
|
-
if (!response.ok) {
|
|
812
|
-
const body = await response.text();
|
|
813
|
-
return failure(createTursoError(TURSO_ERROR.LOCATIONS_FETCH_FAILED, `HTTP ${response.status}: ${body}`));
|
|
814
|
-
}
|
|
815
|
-
return success((await response.json()).locations);
|
|
816
|
-
} catch (err) {
|
|
817
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
818
|
-
return failure(createTursoError(TURSO_ERROR.LOCATIONS_FETCH_FAILED, message));
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
/**
|
|
822
|
-
* Validate that a location string is a known Turso location.
|
|
823
|
-
* On failure, returns an error listing all valid locations.
|
|
824
|
-
*/
|
|
825
|
-
async function validateLocation(config, location) {
|
|
826
|
-
const locationsResult = await fetchLocations(config);
|
|
827
|
-
if (!locationsResult.success) return locationsResult;
|
|
828
|
-
const locations = locationsResult.value;
|
|
829
|
-
if (location in locations) return success(void 0);
|
|
830
|
-
const validLocations = Object.entries(locations).map(([id, desc]) => ` ${id} — ${desc}`).join("\n");
|
|
831
|
-
return failure(createTursoError(TURSO_ERROR.INVALID_LOCATION, `"${location}" is not a valid Turso location.\n\nAvailable locations:\n${validLocations}`));
|
|
832
|
-
}
|
|
833
|
-
/**
|
|
834
|
-
* Ensure a default database group exists in the Turso organization.
|
|
835
|
-
* Creates the group if it does not exist; treats 409 (conflict) as success
|
|
836
|
-
* since it means the group already exists.
|
|
837
|
-
*/
|
|
838
|
-
async function ensureGroup(config, location = "aws-us-east-1") {
|
|
839
|
-
try {
|
|
840
|
-
const listController = new AbortController();
|
|
841
|
-
const listTimeout = setTimeout(() => listController.abort(), 3e4);
|
|
842
|
-
const listResponse = await fetch(`${TURSO_API_BASE}/organizations/${config.org}/groups`, {
|
|
843
|
-
method: "GET",
|
|
844
|
-
headers: buildHeaders(config.apiToken),
|
|
845
|
-
signal: listController.signal
|
|
846
|
-
});
|
|
847
|
-
clearTimeout(listTimeout);
|
|
848
|
-
if (listResponse.ok) {
|
|
849
|
-
if ((await listResponse.json()).groups.some((g) => g.name === "default")) return success(void 0);
|
|
850
|
-
}
|
|
851
|
-
const createController = new AbortController();
|
|
852
|
-
const createTimeout = setTimeout(() => createController.abort(), 3e4);
|
|
853
|
-
const createResponse = await fetch(`${TURSO_API_BASE}/organizations/${config.org}/groups`, {
|
|
854
|
-
method: "POST",
|
|
855
|
-
headers: buildHeaders(config.apiToken),
|
|
856
|
-
body: JSON.stringify({
|
|
857
|
-
name: "default",
|
|
858
|
-
location
|
|
859
|
-
}),
|
|
860
|
-
signal: createController.signal
|
|
861
|
-
});
|
|
862
|
-
clearTimeout(createTimeout);
|
|
863
|
-
if (createResponse.ok || createResponse.status === 409) return success(void 0);
|
|
864
|
-
const body = await createResponse.text();
|
|
865
|
-
return failure(createTursoError(TURSO_ERROR.GROUP_CREATION_FAILED, `HTTP ${createResponse.status}: ${body}`));
|
|
866
|
-
} catch (err) {
|
|
867
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
868
|
-
return failure(createTursoError(TURSO_ERROR.GROUP_CREATION_FAILED, message));
|
|
869
|
-
}
|
|
870
|
-
}
|
|
871
|
-
/**
|
|
872
|
-
* Create a new Turso database in the default group.
|
|
873
|
-
* If the database already exists (409), fetches its info via GET instead.
|
|
874
|
-
* Returns the database name and hostname.
|
|
875
|
-
*/
|
|
876
|
-
async function createDatabase(config, name) {
|
|
877
|
-
try {
|
|
878
|
-
const controller = new AbortController();
|
|
879
|
-
const timeout = setTimeout(() => controller.abort(), 3e4);
|
|
880
|
-
const response = await fetch(`${TURSO_API_BASE}/organizations/${config.org}/databases`, {
|
|
881
|
-
method: "POST",
|
|
882
|
-
headers: buildHeaders(config.apiToken),
|
|
883
|
-
body: JSON.stringify({
|
|
884
|
-
name,
|
|
885
|
-
group: "default"
|
|
886
|
-
}),
|
|
887
|
-
signal: controller.signal
|
|
888
|
-
});
|
|
889
|
-
clearTimeout(timeout);
|
|
890
|
-
if (response.ok) {
|
|
891
|
-
const data = await response.json();
|
|
892
|
-
if (!data.database) return failure(createTursoError(TURSO_ERROR.DATABASE_CREATION_FAILED, "Unexpected API response: missing database object"));
|
|
893
|
-
return success({
|
|
894
|
-
name: data.database.Name ?? data.database.name ?? name,
|
|
895
|
-
hostname: data.database.Hostname ?? data.database.hostname ?? "",
|
|
896
|
-
isNew: true
|
|
897
|
-
});
|
|
898
|
-
}
|
|
899
|
-
if (response.status === 409) {
|
|
900
|
-
const existing = await getDatabaseInfo(config, name);
|
|
901
|
-
if (!existing.success) return existing;
|
|
902
|
-
return success({
|
|
903
|
-
...existing.value,
|
|
904
|
-
isNew: false
|
|
905
|
-
});
|
|
906
|
-
}
|
|
907
|
-
const body = await response.text();
|
|
908
|
-
return failure(createTursoError(TURSO_ERROR.DATABASE_CREATION_FAILED, `HTTP ${response.status}: ${body}`));
|
|
909
|
-
} catch (err) {
|
|
910
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
911
|
-
return failure(createTursoError(TURSO_ERROR.DATABASE_CREATION_FAILED, message));
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
/**
|
|
915
|
-
* Fetch existing database info by name
|
|
916
|
-
*/
|
|
917
|
-
async function getDatabaseInfo(config, name) {
|
|
918
|
-
try {
|
|
919
|
-
const controller = new AbortController();
|
|
920
|
-
const timeout = setTimeout(() => controller.abort(), 3e4);
|
|
921
|
-
const response = await fetch(`${TURSO_API_BASE}/organizations/${config.org}/databases/${name}`, {
|
|
922
|
-
method: "GET",
|
|
923
|
-
headers: buildHeaders(config.apiToken),
|
|
924
|
-
signal: controller.signal
|
|
925
|
-
});
|
|
926
|
-
clearTimeout(timeout);
|
|
927
|
-
if (!response.ok) {
|
|
928
|
-
const body = await response.text();
|
|
929
|
-
return failure(createTursoError(TURSO_ERROR.DATABASE_CREATION_FAILED, `Failed to fetch existing database info: HTTP ${response.status}: ${body}`));
|
|
930
|
-
}
|
|
931
|
-
const data = await response.json();
|
|
932
|
-
if (!data.database) return failure(createTursoError(TURSO_ERROR.DATABASE_CREATION_FAILED, "Unexpected API response: missing database object"));
|
|
933
|
-
return success({
|
|
934
|
-
name: data.database.Name ?? data.database.name ?? name,
|
|
935
|
-
hostname: data.database.Hostname ?? data.database.hostname ?? ""
|
|
936
|
-
});
|
|
937
|
-
} catch (err) {
|
|
938
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
939
|
-
return failure(createTursoError(TURSO_ERROR.DATABASE_CREATION_FAILED, `Failed to fetch existing database info: ${message}`));
|
|
940
|
-
}
|
|
941
|
-
}
|
|
942
|
-
/**
|
|
943
|
-
* Delete a Turso database by name.
|
|
944
|
-
* Returns void on success, or a TursoError on failure.
|
|
945
|
-
*/
|
|
946
|
-
async function deleteDatabase(config, name) {
|
|
947
|
-
try {
|
|
948
|
-
const controller = new AbortController();
|
|
949
|
-
const timeout = setTimeout(() => controller.abort(), 3e4);
|
|
950
|
-
const response = await fetch(`${TURSO_API_BASE}/organizations/${config.org}/databases/${name}`, {
|
|
951
|
-
method: "DELETE",
|
|
952
|
-
headers: buildHeaders(config.apiToken),
|
|
953
|
-
signal: controller.signal
|
|
954
|
-
});
|
|
955
|
-
clearTimeout(timeout);
|
|
956
|
-
if (response.ok || response.status === 404) return success(void 0);
|
|
957
|
-
const body = await response.text();
|
|
958
|
-
return failure(createTursoError(TURSO_ERROR.DATABASE_DELETION_FAILED, `HTTP ${response.status}: ${body}`));
|
|
959
|
-
} catch (err) {
|
|
960
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
961
|
-
return failure(createTursoError(TURSO_ERROR.DATABASE_DELETION_FAILED, message));
|
|
962
|
-
}
|
|
963
|
-
}
|
|
964
|
-
/**
|
|
965
|
-
* Create an auth token for a Turso database.
|
|
966
|
-
* Returns the JWT token string used for database connections.
|
|
967
|
-
*/
|
|
968
|
-
async function createDatabaseToken(config, dbName) {
|
|
969
|
-
try {
|
|
970
|
-
const controller = new AbortController();
|
|
971
|
-
const timeout = setTimeout(() => controller.abort(), 3e4);
|
|
972
|
-
const response = await fetch(`${TURSO_API_BASE}/organizations/${config.org}/databases/${dbName}/auth/tokens`, {
|
|
973
|
-
method: "POST",
|
|
974
|
-
headers: buildHeaders(config.apiToken),
|
|
975
|
-
signal: controller.signal
|
|
976
|
-
});
|
|
977
|
-
clearTimeout(timeout);
|
|
978
|
-
if (!response.ok) {
|
|
979
|
-
const body = await response.text();
|
|
980
|
-
return failure(createTursoError(TURSO_ERROR.TOKEN_CREATION_FAILED, `HTTP ${response.status}: ${body}`));
|
|
981
|
-
}
|
|
982
|
-
const data = await response.json();
|
|
983
|
-
if (!data.jwt) return failure(createTursoError(TURSO_ERROR.TOKEN_CREATION_FAILED, "Unexpected API response: missing jwt field"));
|
|
984
|
-
return success(data.jwt);
|
|
985
|
-
} catch (err) {
|
|
986
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
987
|
-
return failure(createTursoError(TURSO_ERROR.TOKEN_CREATION_FAILED, message));
|
|
988
|
-
}
|
|
989
|
-
}
|
|
990
|
-
/**
|
|
991
|
-
* Provision a complete Turso database for a project.
|
|
992
|
-
*
|
|
993
|
-
* Orchestrates the full flow:
|
|
994
|
-
* 1. Ensure a default group exists
|
|
995
|
-
* 2. Create (or retrieve) the database
|
|
996
|
-
* 3. Generate an auth token
|
|
997
|
-
*
|
|
998
|
-
* Calls progress callbacks at each step so callers can display status.
|
|
999
|
-
*/
|
|
1000
|
-
async function provisionDatabase(config, projectName, location, callbacks) {
|
|
1001
|
-
if (location) {
|
|
1002
|
-
const locationResult = await validateLocation(config, location);
|
|
1003
|
-
if (!locationResult.success) return locationResult;
|
|
1004
|
-
}
|
|
1005
|
-
callbacks?.onGroupCreating?.();
|
|
1006
|
-
const groupResult = await ensureGroup(config, location);
|
|
1007
|
-
if (!groupResult.success) return groupResult;
|
|
1008
|
-
callbacks?.onGroupReady?.();
|
|
1009
|
-
callbacks?.onDatabaseCreating?.(projectName);
|
|
1010
|
-
const dbResult = await createDatabase(config, projectName);
|
|
1011
|
-
if (!dbResult.success) return dbResult;
|
|
1012
|
-
const { name: databaseName, hostname, isNew } = dbResult.value;
|
|
1013
|
-
callbacks?.onDatabaseReady?.(databaseName);
|
|
1014
|
-
callbacks?.onTokenCreating?.();
|
|
1015
|
-
const tokenResult = await createDatabaseToken(config, databaseName);
|
|
1016
|
-
if (!tokenResult.success) return tokenResult;
|
|
1017
|
-
const authToken = tokenResult.value;
|
|
1018
|
-
callbacks?.onTokenReady?.();
|
|
1019
|
-
return success({
|
|
1020
|
-
url: `libsql://${hostname}`,
|
|
1021
|
-
authToken,
|
|
1022
|
-
databaseName,
|
|
1023
|
-
hostname,
|
|
1024
|
-
isNew
|
|
1025
|
-
});
|
|
1026
|
-
}
|
|
1027
|
-
//#endregion
|
|
1028
|
-
//#region src/utils/cloud-run.ts
|
|
1029
|
-
/**
|
|
1030
|
-
* Cloud Run error codes and default messages
|
|
1031
|
-
*/
|
|
1032
|
-
const CLOUD_RUN_ERRORS = {
|
|
1033
|
-
GCLOUD_NOT_INSTALLED: {
|
|
1034
|
-
code: "GCLOUD_NOT_INSTALLED",
|
|
1035
|
-
message: "gcloud CLI is not installed. Install it from https://cloud.google.com/sdk/docs/install"
|
|
1036
|
-
},
|
|
1037
|
-
GCLOUD_NOT_AUTHENTICATED: {
|
|
1038
|
-
code: "GCLOUD_NOT_AUTHENTICATED",
|
|
1039
|
-
message: "gcloud CLI is not authenticated. Run 'gcloud auth login' to authenticate"
|
|
1040
|
-
},
|
|
1041
|
-
NO_GCP_PROJECT: {
|
|
1042
|
-
code: "NO_GCP_PROJECT",
|
|
1043
|
-
message: "No GCP project configured. Run 'gcloud config set project PROJECT_ID' or pass --gcp-project"
|
|
1044
|
-
},
|
|
1045
|
-
DEPLOY_FAILED: {
|
|
1046
|
-
code: "DEPLOY_FAILED",
|
|
1047
|
-
message: "Cloud Run deployment failed"
|
|
1048
|
-
},
|
|
1049
|
-
SERVICE_DELETION_FAILED: {
|
|
1050
|
-
code: "SERVICE_DELETION_FAILED",
|
|
1051
|
-
message: "Cloud Run service deletion failed"
|
|
1052
|
-
}
|
|
1053
|
-
};
|
|
1054
|
-
/**
|
|
1055
|
-
* Create a CloudRunError from a constant and optional details
|
|
1056
|
-
*/
|
|
1057
|
-
function createCloudRunError(template, details) {
|
|
1058
|
-
return {
|
|
1059
|
-
code: template.code,
|
|
1060
|
-
message: template.message,
|
|
1061
|
-
details
|
|
1062
|
-
};
|
|
1063
|
-
}
|
|
1064
|
-
/**
|
|
1065
|
-
* Build a KEY=VALUE string from an env vars record using gcloud's custom
|
|
1066
|
-
* delimiter syntax to avoid issues with values containing commas.
|
|
1067
|
-
* Prefix with `^::^` and join entries with `::` instead of `,`.
|
|
1068
|
-
*/
|
|
1069
|
-
function buildEnvVarsString(envVars) {
|
|
1070
|
-
return `^::^${Object.entries(envVars).map(([key, value]) => `${key}=${value}`).join("::")}`;
|
|
1071
|
-
}
|
|
1072
|
-
/**
|
|
1073
|
-
* Validate that the gcloud CLI is installed
|
|
1074
|
-
*/
|
|
1075
|
-
async function validateGcloudInstalled() {
|
|
1076
|
-
try {
|
|
1077
|
-
await execa("gcloud", ["--version"], {
|
|
1078
|
-
stdio: "pipe",
|
|
1079
|
-
timeout: 6e4
|
|
1080
|
-
});
|
|
1081
|
-
return success(void 0);
|
|
1082
|
-
} catch {
|
|
1083
|
-
return failure(createCloudRunError(CLOUD_RUN_ERRORS.GCLOUD_NOT_INSTALLED));
|
|
1084
|
-
}
|
|
1085
|
-
}
|
|
1086
|
-
/**
|
|
1087
|
-
* Validate that the gcloud CLI has an active authenticated account
|
|
1088
|
-
*/
|
|
1089
|
-
async function validateGcloudAuth() {
|
|
1090
|
-
try {
|
|
1091
|
-
const { stdout } = await execa("gcloud", [
|
|
1092
|
-
"auth",
|
|
1093
|
-
"list",
|
|
1094
|
-
"--format=json"
|
|
1095
|
-
], {
|
|
1096
|
-
stdio: "pipe",
|
|
1097
|
-
timeout: 6e4
|
|
1098
|
-
});
|
|
1099
|
-
if (!JSON.parse(stdout).some((account) => account.status === "ACTIVE")) return failure(createCloudRunError(CLOUD_RUN_ERRORS.GCLOUD_NOT_AUTHENTICATED));
|
|
1100
|
-
return success(void 0);
|
|
1101
|
-
} catch {
|
|
1102
|
-
return failure(createCloudRunError(CLOUD_RUN_ERRORS.GCLOUD_NOT_AUTHENTICATED, "Failed to check gcloud authentication status"));
|
|
1103
|
-
}
|
|
1104
|
-
}
|
|
1105
|
-
/**
|
|
1106
|
-
* Get the currently configured GCP project from gcloud config
|
|
1107
|
-
*/
|
|
1108
|
-
async function getGcpProject() {
|
|
1109
|
-
try {
|
|
1110
|
-
const { stdout } = await execa("gcloud", [
|
|
1111
|
-
"config",
|
|
1112
|
-
"get-value",
|
|
1113
|
-
"project"
|
|
1114
|
-
], {
|
|
1115
|
-
stdio: "pipe",
|
|
1116
|
-
timeout: 6e4
|
|
1117
|
-
});
|
|
1118
|
-
const project = stdout.trim();
|
|
1119
|
-
if (!project || project === "(unset)") return failure(createCloudRunError(CLOUD_RUN_ERRORS.NO_GCP_PROJECT));
|
|
1120
|
-
return success(project);
|
|
1121
|
-
} catch {
|
|
1122
|
-
return failure(createCloudRunError(CLOUD_RUN_ERRORS.NO_GCP_PROJECT, "Failed to read GCP project from gcloud config"));
|
|
1123
|
-
}
|
|
1124
|
-
}
|
|
1125
|
-
/**
|
|
1126
|
-
* Fetch the last 30 lines of the most recent Cloud Build log.
|
|
1127
|
-
* Returns `undefined` on any failure — this is best-effort diagnostic info.
|
|
1128
|
-
*/
|
|
1129
|
-
async function fetchRecentBuildLogs(gcpProject, region) {
|
|
1130
|
-
try {
|
|
1131
|
-
const { stdout: buildId } = await execa("gcloud", [
|
|
1132
|
-
"builds",
|
|
1133
|
-
"list",
|
|
1134
|
-
"--limit=1",
|
|
1135
|
-
"--project",
|
|
1136
|
-
gcpProject,
|
|
1137
|
-
"--region",
|
|
1138
|
-
region,
|
|
1139
|
-
"--format=value(id)"
|
|
1140
|
-
], {
|
|
1141
|
-
stdio: "pipe",
|
|
1142
|
-
timeout: 6e4
|
|
1143
|
-
});
|
|
1144
|
-
const trimmedId = buildId.trim();
|
|
1145
|
-
if (!trimmedId) return void 0;
|
|
1146
|
-
const { stdout: logText } = await execa("gcloud", [
|
|
1147
|
-
"builds",
|
|
1148
|
-
"log",
|
|
1149
|
-
trimmedId,
|
|
1150
|
-
"--project",
|
|
1151
|
-
gcpProject,
|
|
1152
|
-
"--region",
|
|
1153
|
-
region
|
|
1154
|
-
], {
|
|
1155
|
-
stdio: "pipe",
|
|
1156
|
-
timeout: 6e4
|
|
1157
|
-
});
|
|
1158
|
-
return logText.split("\n").slice(-30).join("\n");
|
|
1159
|
-
} catch {
|
|
1160
|
-
return;
|
|
1161
|
-
}
|
|
1162
|
-
}
|
|
1163
|
-
/**
|
|
1164
|
-
* Retrieve the service URL via `gcloud run services describe`.
|
|
1165
|
-
* Used as a fallback when the deploy command does not return parseable JSON.
|
|
1166
|
-
*/
|
|
1167
|
-
async function getServiceUrl(serviceName, gcpProject, region) {
|
|
1168
|
-
try {
|
|
1169
|
-
const { stdout } = await execa("gcloud", [
|
|
1170
|
-
"run",
|
|
1171
|
-
"services",
|
|
1172
|
-
"describe",
|
|
1173
|
-
serviceName,
|
|
1174
|
-
"--project",
|
|
1175
|
-
gcpProject,
|
|
1176
|
-
"--region",
|
|
1177
|
-
region,
|
|
1178
|
-
"--format=json"
|
|
1179
|
-
], {
|
|
1180
|
-
stdio: "pipe",
|
|
1181
|
-
timeout: 6e4
|
|
1182
|
-
});
|
|
1183
|
-
const url = JSON.parse(stdout).status?.url;
|
|
1184
|
-
if (!url) return failure(createCloudRunError(CLOUD_RUN_ERRORS.DEPLOY_FAILED, "Service deployed but no URL found in service description"));
|
|
1185
|
-
return success(url);
|
|
1186
|
-
} catch (err) {
|
|
1187
|
-
const details = err instanceof Error ? err.message : "Unknown error describing service";
|
|
1188
|
-
return failure(createCloudRunError(CLOUD_RUN_ERRORS.DEPLOY_FAILED, details));
|
|
1189
|
-
}
|
|
1190
|
-
}
|
|
1191
|
-
/**
|
|
1192
|
-
* Deploy to Cloud Run using `gcloud run deploy --source`
|
|
1193
|
-
*
|
|
1194
|
-
* This builds the container from source and deploys it in a single step.
|
|
1195
|
-
* Progress is reported through optional callbacks.
|
|
1196
|
-
*/
|
|
1197
|
-
async function deployToCloudRun(config, callbacks) {
|
|
1198
|
-
callbacks?.onValidating?.();
|
|
1199
|
-
const installCheck = await validateGcloudInstalled();
|
|
1200
|
-
if (!installCheck.success) return failure(installCheck.error);
|
|
1201
|
-
const authCheck = await validateGcloudAuth();
|
|
1202
|
-
if (!authCheck.success) return failure(authCheck.error);
|
|
1203
|
-
const args = [
|
|
1204
|
-
"run",
|
|
1205
|
-
"deploy",
|
|
1206
|
-
config.serviceName,
|
|
1207
|
-
"--source",
|
|
1208
|
-
config.sourceDir,
|
|
1209
|
-
"--project",
|
|
1210
|
-
config.gcpProject,
|
|
1211
|
-
"--region",
|
|
1212
|
-
config.region,
|
|
1213
|
-
...config.requireAuth ? [] : ["--allow-unauthenticated"],
|
|
1214
|
-
"--quiet",
|
|
1215
|
-
"--format=json"
|
|
1216
|
-
];
|
|
1217
|
-
const envVarsString = buildEnvVarsString(config.envVars);
|
|
1218
|
-
if (envVarsString) args.push("--set-env-vars", envVarsString);
|
|
1219
|
-
callbacks?.onDeploying?.();
|
|
1220
|
-
try {
|
|
1221
|
-
const { stdout } = await execa("gcloud", args, {
|
|
1222
|
-
stdio: "pipe",
|
|
1223
|
-
timeout: 3e5
|
|
1224
|
-
});
|
|
1225
|
-
let url;
|
|
1226
|
-
try {
|
|
1227
|
-
url = JSON.parse(stdout).status?.url;
|
|
1228
|
-
} catch {}
|
|
1229
|
-
if (!url) {
|
|
1230
|
-
const fallbackResult = await getServiceUrl(config.serviceName, config.gcpProject, config.region);
|
|
1231
|
-
if (!fallbackResult.success) return failure(fallbackResult.error);
|
|
1232
|
-
url = fallbackResult.value;
|
|
1233
|
-
}
|
|
1234
|
-
const result = {
|
|
1235
|
-
url,
|
|
1236
|
-
serviceName: config.serviceName,
|
|
1237
|
-
region: config.region,
|
|
1238
|
-
gcpProject: config.gcpProject
|
|
1239
|
-
};
|
|
1240
|
-
callbacks?.onDeployComplete?.(url);
|
|
1241
|
-
return success(result);
|
|
1242
|
-
} catch (err) {
|
|
1243
|
-
const execaError = err;
|
|
1244
|
-
let details = execaError.stderr ?? execaError.message ?? String(err);
|
|
1245
|
-
const buildLogs = await fetchRecentBuildLogs(config.gcpProject, config.region);
|
|
1246
|
-
if (buildLogs) details += "\n\n── Recent Cloud Build logs ─────────────────────\n" + buildLogs;
|
|
1247
|
-
return failure(createCloudRunError(CLOUD_RUN_ERRORS.DEPLOY_FAILED, details));
|
|
1248
|
-
}
|
|
1249
|
-
}
|
|
1250
|
-
/**
|
|
1251
|
-
* Delete a Cloud Run service using `gcloud run services delete`.
|
|
1252
|
-
*/
|
|
1253
|
-
async function deleteCloudRunService(config) {
|
|
1254
|
-
try {
|
|
1255
|
-
await execa("gcloud", [
|
|
1256
|
-
"run",
|
|
1257
|
-
"services",
|
|
1258
|
-
"delete",
|
|
1259
|
-
config.serviceName,
|
|
1260
|
-
"--region",
|
|
1261
|
-
config.region,
|
|
1262
|
-
"--project",
|
|
1263
|
-
config.gcpProject,
|
|
1264
|
-
"--quiet"
|
|
1265
|
-
], {
|
|
1266
|
-
stdio: "pipe",
|
|
1267
|
-
timeout: 6e4
|
|
1268
|
-
});
|
|
1269
|
-
return success(void 0);
|
|
1270
|
-
} catch (err) {
|
|
1271
|
-
const execaError = err;
|
|
1272
|
-
return failure(createCloudRunError(CLOUD_RUN_ERRORS.SERVICE_DELETION_FAILED, execaError.stderr ?? execaError.message ?? String(err)));
|
|
1273
|
-
}
|
|
1274
|
-
}
|
|
1275
|
-
//#endregion
|
|
1276
|
-
//#region src/utils/fluid-api.ts
|
|
1277
|
-
const FLUID_API_BASE$1 = "https://api.fluid.app";
|
|
1278
|
-
const FLUID_API_ERROR = {
|
|
1279
|
-
MISSING_API_KEY: {
|
|
1280
|
-
code: "MISSING_API_KEY",
|
|
1281
|
-
message: "FLUID_COMPANY_API_KEY is not set"
|
|
1282
|
-
},
|
|
1283
|
-
INVALID_API_KEY: {
|
|
1284
|
-
code: "INVALID_API_KEY",
|
|
1285
|
-
message: "FLUID_COMPANY_API_KEY is invalid or expired"
|
|
1286
|
-
},
|
|
1287
|
-
API_UNREACHABLE: {
|
|
1288
|
-
code: "API_UNREACHABLE",
|
|
1289
|
-
message: "Could not reach the Fluid API"
|
|
1290
|
-
}
|
|
1291
|
-
};
|
|
1292
|
-
/**
|
|
1293
|
-
* Create a Fluid API error from a constant and optional details
|
|
1294
|
-
*/
|
|
1295
|
-
function createFluidApiError(template, details) {
|
|
1296
|
-
return {
|
|
1297
|
-
code: template.code,
|
|
1298
|
-
message: template.message,
|
|
1299
|
-
details
|
|
1300
|
-
};
|
|
1301
|
-
}
|
|
1302
|
-
/**
|
|
1303
|
-
* Resolve and validate the Fluid API key.
|
|
1304
|
-
*
|
|
1305
|
-
* Priority:
|
|
1306
|
-
* 1. `apiKeyOverride` parameter (from --fluid-company-api-key flag)
|
|
1307
|
-
* 2. FLUID_COMPANY_API_KEY environment variable
|
|
1308
|
-
* 3. Interactive hidden-input prompt
|
|
1309
|
-
* 4. Fail with instructions if all sources exhausted
|
|
1310
|
-
*
|
|
1311
|
-
* Once resolved, validates against the Fluid API (GET /api/company/v1/companies/me).
|
|
1312
|
-
*
|
|
1313
|
-
* @param apiKeyOverride - Optional API key from CLI flag (skips env + prompt)
|
|
1314
|
-
*/
|
|
1315
|
-
async function resolveFluidApiKey(apiKeyOverride) {
|
|
1316
|
-
if (apiKeyOverride) return validateFluidApiKey(apiKeyOverride);
|
|
1317
|
-
const envKey = process.env.FLUID_COMPANY_API_KEY;
|
|
1318
|
-
if (envKey) return validateFluidApiKey(envKey);
|
|
1319
|
-
const { apiKey } = await prompts({
|
|
1320
|
-
type: "password",
|
|
1321
|
-
name: "apiKey",
|
|
1322
|
-
message: "Enter your Fluid company API key (FLUID_COMPANY_API_KEY)"
|
|
1323
|
-
});
|
|
1324
|
-
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>."));
|
|
1325
|
-
return validateFluidApiKey(apiKey);
|
|
1326
|
-
}
|
|
1327
|
-
/**
|
|
1328
|
-
* Validate a Fluid API key by calling the companies/me endpoint.
|
|
1329
|
-
*
|
|
1330
|
-
* - 200 → extract company name, return success
|
|
1331
|
-
* - 401/403 → invalid or expired key
|
|
1332
|
-
* - Network error → API unreachable
|
|
1333
|
-
*/
|
|
1334
|
-
async function validateFluidApiKey(apiKey) {
|
|
1335
|
-
try {
|
|
1336
|
-
const response = await fetch(`${FLUID_API_BASE$1}/api/company/v1/companies/me`, {
|
|
1337
|
-
method: "GET",
|
|
1338
|
-
headers: {
|
|
1339
|
-
Authorization: `Bearer ${apiKey}`,
|
|
1340
|
-
"Content-Type": "application/json"
|
|
1341
|
-
}
|
|
1342
|
-
});
|
|
1343
|
-
if (response.ok) return success({
|
|
1344
|
-
name: (await response.json()).data.company.name,
|
|
1345
|
-
apiKey
|
|
1346
|
-
});
|
|
1347
|
-
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.`));
|
|
1348
|
-
const body = await response.text();
|
|
1349
|
-
return failure(createFluidApiError(FLUID_API_ERROR.API_UNREACHABLE, `HTTP ${response.status}: ${body}`));
|
|
1350
|
-
} catch (err) {
|
|
1351
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1352
|
-
return failure(createFluidApiError(FLUID_API_ERROR.API_UNREACHABLE, message));
|
|
1353
|
-
}
|
|
1354
|
-
}
|
|
1355
|
-
//#endregion
|
|
1356
|
-
//#region src/utils/project.ts
|
|
1357
|
-
/**
|
|
1358
|
-
* Read project name from package.json
|
|
1359
|
-
*/
|
|
1360
|
-
async function getProjectName(cwd) {
|
|
1361
|
-
const packageJsonPath = path$1.join(cwd, "package.json");
|
|
1362
|
-
if (await fs.pathExists(packageJsonPath)) return (await fs.readJson(packageJsonPath)).name;
|
|
1363
|
-
}
|
|
1364
|
-
/**
|
|
1365
|
-
* Sanitize a project name into a valid Cloud Run service name.
|
|
1366
|
-
* Lowercase, alphanumeric, and hyphens only.
|
|
1367
|
-
*/
|
|
1368
|
-
function sanitizeServiceName(projectName) {
|
|
1369
|
-
return projectName.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/--+/g, "-").replace(/^-|-$/g, "");
|
|
1370
|
-
}
|
|
1371
|
-
//#endregion
|
|
1372
|
-
//#region src/utils/extract-navigation.ts
|
|
1373
|
-
/**
|
|
1374
|
-
* Navigation extraction utility
|
|
1375
|
-
*
|
|
1376
|
-
* Extracts the `navigation` export from a project's navigation.config.ts
|
|
1377
|
-
* by writing a minimal wrapper script that imports from navigation.config.ts
|
|
1378
|
-
* and serializes the result to stdout, then running it with tsx.
|
|
1379
|
-
*
|
|
1380
|
-
* This avoids transitive dependency resolution — navigation.config.ts must
|
|
1381
|
-
* be self-contained static data with no import statements.
|
|
1382
|
-
*/
|
|
1383
|
-
const EXTRACT_FILENAME = "__fluid_extract_nav.ts";
|
|
1384
|
-
/**
|
|
1385
|
-
* Extract the `navigation` export from a project's navigation.config.ts.
|
|
1386
|
-
*
|
|
1387
|
-
* Reads the config file, validates it contains no import statements,
|
|
1388
|
-
* writes a minimal wrapper that imports and serializes navigation,
|
|
1389
|
-
* and runs it with tsx. This avoids needing project dependencies
|
|
1390
|
-
* (React, etc.) to be installed.
|
|
1391
|
-
*
|
|
1392
|
-
* The temp file is always cleaned up.
|
|
1393
|
-
* Returns null if no navigation export exists.
|
|
1394
|
-
*
|
|
1395
|
-
* @param projectDir - The project root directory containing src/navigation.config.ts
|
|
1396
|
-
*/
|
|
1397
|
-
async function extractNavigation(projectDir) {
|
|
1398
|
-
const configPath = path$1.join(projectDir, "src", "navigation.config.ts");
|
|
1399
|
-
const extractFile = path$1.join(projectDir, EXTRACT_FILENAME);
|
|
1400
|
-
try {
|
|
1401
|
-
const configSource = await fs.readFile(configPath, "utf-8");
|
|
1402
|
-
if (!/export\s+(const|let)\s+navigation\s*=/.test(configSource)) return success(null);
|
|
1403
|
-
if (/^\s*import\s/m.test(configSource)) return failure({
|
|
1404
|
-
code: "EXTRACTION_FAILED",
|
|
1405
|
-
message: "navigation.config.ts must not contain import statements — it should only export static data",
|
|
1406
|
-
details: "Move all imports to portal.config.ts. navigation.config.ts must be self-contained."
|
|
1407
|
-
});
|
|
1408
|
-
const wrapperScript = [`import { navigation } from "./src/navigation.config.ts";`, `console.log(JSON.stringify(navigation ?? null));`].join("\n");
|
|
1409
|
-
await fs.writeFile(extractFile, wrapperScript, "utf-8");
|
|
1410
|
-
const output = (await execa("npx", ["tsx", EXTRACT_FILENAME], {
|
|
1411
|
-
cwd: projectDir,
|
|
1412
|
-
stdio: "pipe",
|
|
1413
|
-
env: {
|
|
1414
|
-
...process.env,
|
|
1415
|
-
NODE_ENV: "production"
|
|
1416
|
-
}
|
|
1417
|
-
})).stdout.trim();
|
|
1418
|
-
if (!output || output === "null") return success(null);
|
|
1419
|
-
try {
|
|
1420
|
-
const parsed = JSON.parse(output);
|
|
1421
|
-
if (!Array.isArray(parsed)) return failure({
|
|
1422
|
-
code: "INVALID_FORMAT",
|
|
1423
|
-
message: "navigation export is not an array",
|
|
1424
|
-
details: `Expected an array, got: ${typeof parsed}`
|
|
1425
|
-
});
|
|
1426
|
-
return success(parsed);
|
|
1427
|
-
} catch {
|
|
1428
|
-
return failure({
|
|
1429
|
-
code: "INVALID_FORMAT",
|
|
1430
|
-
message: "Failed to parse navigation output as JSON",
|
|
1431
|
-
details: output.slice(0, 200)
|
|
1432
|
-
});
|
|
1433
|
-
}
|
|
1434
|
-
} catch (err) {
|
|
1435
|
-
const error = err;
|
|
1436
|
-
return failure({
|
|
1437
|
-
code: "EXTRACTION_FAILED",
|
|
1438
|
-
message: "Failed to extract navigation from navigation.config.ts",
|
|
1439
|
-
details: error.stderr ?? error.message ?? String(err)
|
|
1440
|
-
});
|
|
1441
|
-
} finally {
|
|
1442
|
-
await fs.remove(extractFile).catch(() => {});
|
|
1443
|
-
}
|
|
1444
|
-
}
|
|
1445
|
-
//#endregion
|
|
1446
|
-
//#region src/utils/navigation-sync.ts
|
|
1447
|
-
/**
|
|
1448
|
-
* Navigation sync utility
|
|
1449
|
-
*
|
|
1450
|
-
* Reconciles code-defined navigation items from portal.config.ts
|
|
1451
|
-
* against the Fluid OS API. Only manages items with source: "code",
|
|
1452
|
-
* leaving user-created and system items untouched.
|
|
1453
|
-
*/
|
|
1454
|
-
const FLUID_API_BASE = "https://api.fluid.app";
|
|
1455
|
-
async function apiGet(apiKey, path) {
|
|
1456
|
-
const response = await fetch(`${FLUID_API_BASE}${path}`, {
|
|
1457
|
-
method: "GET",
|
|
1458
|
-
headers: {
|
|
1459
|
-
Authorization: `Bearer ${apiKey}`,
|
|
1460
|
-
"Content-Type": "application/json"
|
|
1461
|
-
}
|
|
1462
|
-
});
|
|
1463
|
-
if (!response.ok) {
|
|
1464
|
-
const body = await response.text();
|
|
1465
|
-
throw new Error(`GET ${path} failed (${response.status}): ${body}`);
|
|
1466
|
-
}
|
|
1467
|
-
return response.json();
|
|
1468
|
-
}
|
|
1469
|
-
async function apiPost(apiKey, path, body) {
|
|
1470
|
-
const response = await fetch(`${FLUID_API_BASE}${path}`, {
|
|
1471
|
-
method: "POST",
|
|
1472
|
-
headers: {
|
|
1473
|
-
Authorization: `Bearer ${apiKey}`,
|
|
1474
|
-
"Content-Type": "application/json"
|
|
1475
|
-
},
|
|
1476
|
-
body: JSON.stringify(body)
|
|
1477
|
-
});
|
|
1478
|
-
if (!response.ok) {
|
|
1479
|
-
const text = await response.text();
|
|
1480
|
-
throw new Error(`POST ${path} failed (${response.status}): ${text}`);
|
|
1481
|
-
}
|
|
1482
|
-
return response.json();
|
|
1483
|
-
}
|
|
1484
|
-
async function apiPut(apiKey, path, body) {
|
|
1485
|
-
const response = await fetch(`${FLUID_API_BASE}${path}`, {
|
|
1486
|
-
method: "PUT",
|
|
1487
|
-
headers: {
|
|
1488
|
-
Authorization: `Bearer ${apiKey}`,
|
|
1489
|
-
"Content-Type": "application/json"
|
|
1490
|
-
},
|
|
1491
|
-
body: JSON.stringify(body)
|
|
1492
|
-
});
|
|
1493
|
-
if (!response.ok) {
|
|
1494
|
-
const text = await response.text();
|
|
1495
|
-
throw new Error(`PUT ${path} failed (${response.status}): ${text}`);
|
|
1496
|
-
}
|
|
1497
|
-
return response.json();
|
|
1498
|
-
}
|
|
1499
|
-
async function apiDelete(apiKey, path) {
|
|
1500
|
-
const response = await fetch(`${FLUID_API_BASE}${path}`, {
|
|
1501
|
-
method: "DELETE",
|
|
1502
|
-
headers: {
|
|
1503
|
-
Authorization: `Bearer ${apiKey}`,
|
|
1504
|
-
"Content-Type": "application/json"
|
|
1505
|
-
}
|
|
1506
|
-
});
|
|
1507
|
-
if (!response.ok) {
|
|
1508
|
-
const text = await response.text();
|
|
1509
|
-
throw new Error(`DELETE ${path} failed (${response.status}): ${text}`);
|
|
1510
|
-
}
|
|
1511
|
-
}
|
|
1512
|
-
async function discoverContext(apiKey) {
|
|
1513
|
-
let definitionId;
|
|
1514
|
-
try {
|
|
1515
|
-
const active = (await apiGet(apiKey, "/api/company/fluid_os/definitions")).definitions.find((d) => d.active);
|
|
1516
|
-
if (!active) return failure({
|
|
1517
|
-
code: "NO_DEFINITION",
|
|
1518
|
-
message: "No active Fluid OS definition found",
|
|
1519
|
-
details: "Create and activate a definition in the Fluid admin dashboard first."
|
|
1520
|
-
});
|
|
1521
|
-
definitionId = active.id;
|
|
1522
|
-
} catch (err) {
|
|
1523
|
-
return failure({
|
|
1524
|
-
code: "API_ERROR",
|
|
1525
|
-
message: "Failed to fetch Fluid OS definitions",
|
|
1526
|
-
details: err instanceof Error ? err.message : String(err)
|
|
1527
|
-
});
|
|
1528
|
-
}
|
|
1529
|
-
let navigationId;
|
|
1530
|
-
try {
|
|
1531
|
-
const navs = await apiGet(apiKey, `/api/company/fluid_os/definitions/${definitionId}/navigations`);
|
|
1532
|
-
const webNav = navs.navigations.find((n) => n.name.toLowerCase().includes("web")) ?? navs.navigations[0];
|
|
1533
|
-
if (!webNav) return failure({
|
|
1534
|
-
code: "NO_NAVIGATION",
|
|
1535
|
-
message: "No navigation found for the active definition",
|
|
1536
|
-
details: "The active definition has no navigations. Create one in the Fluid admin dashboard."
|
|
1537
|
-
});
|
|
1538
|
-
navigationId = webNav.id;
|
|
1539
|
-
} catch (err) {
|
|
1540
|
-
return failure({
|
|
1541
|
-
code: "API_ERROR",
|
|
1542
|
-
message: "Failed to fetch navigations",
|
|
1543
|
-
details: err instanceof Error ? err.message : String(err)
|
|
1544
|
-
});
|
|
1545
|
-
}
|
|
1546
|
-
return success({
|
|
1547
|
-
apiKey,
|
|
1548
|
-
definitionId,
|
|
1549
|
-
navigationId
|
|
1550
|
-
});
|
|
1551
|
-
}
|
|
1552
|
-
/**
|
|
1553
|
-
* Build a lookup key for matching code items to API items.
|
|
1554
|
-
* Items with a slug are keyed by slug; headers (no slug) are keyed by "header:{label}".
|
|
1555
|
-
*/
|
|
1556
|
-
function itemKey(item) {
|
|
1557
|
-
if (item.slug) return item.slug;
|
|
1558
|
-
return `header:${item.label ?? ""}`;
|
|
1559
|
-
}
|
|
1560
|
-
/**
|
|
1561
|
-
* Recursively delete an API navigation item and all its source: "code" descendants
|
|
1562
|
-
* in post-order (deepest children first, then the item itself).
|
|
1563
|
-
*/
|
|
1564
|
-
async function deleteItemRecursive(apiKey, basePath, item, stats) {
|
|
1565
|
-
const codeChildren = (item.children ?? []).filter((c) => c.source === "code");
|
|
1566
|
-
for (const child of codeChildren) await deleteItemRecursive(apiKey, basePath, child, stats);
|
|
1567
|
-
await apiDelete(apiKey, `${basePath}/${item.id}`);
|
|
1568
|
-
stats.deleted++;
|
|
1569
|
-
}
|
|
1570
|
-
/**
|
|
1571
|
-
* Sync a level of code-defined navigation items against existing API items.
|
|
1572
|
-
* Recurses for children.
|
|
1573
|
-
*/
|
|
1574
|
-
async function syncLevel(ctx, codeItems, existingItems, parentId, stats) {
|
|
1575
|
-
const existingByKey = /* @__PURE__ */ new Map();
|
|
1576
|
-
for (const item of existingItems) if (item.source === "code") existingByKey.set(itemKey(item), item);
|
|
1577
|
-
const basePath = `/api/company/fluid_os/definitions/${ctx.definitionId}/navigations/${ctx.navigationId}/navigation_items`;
|
|
1578
|
-
const matched = /* @__PURE__ */ new Set();
|
|
1579
|
-
for (let i = 0; i < codeItems.length; i++) {
|
|
1580
|
-
const codeItem = codeItems[i];
|
|
1581
|
-
const key = itemKey(codeItem);
|
|
1582
|
-
const existing = existingByKey.get(key);
|
|
1583
|
-
matched.add(key);
|
|
1584
|
-
if (existing) {
|
|
1585
|
-
if (existing.label !== codeItem.label || existing.icon !== (codeItem.icon ?? null) || existing.position !== i + 1) {
|
|
1586
|
-
await apiPut(ctx.apiKey, `${basePath}/${existing.id}`, { navigation_item: {
|
|
1587
|
-
label: codeItem.label,
|
|
1588
|
-
icon: codeItem.icon ?? null,
|
|
1589
|
-
position: i + 1
|
|
1590
|
-
} });
|
|
1591
|
-
stats.updated++;
|
|
1592
|
-
}
|
|
1593
|
-
if (codeItem.children?.length) await syncLevel(ctx, codeItem.children, existing.children ?? [], existing.id, stats);
|
|
1594
|
-
} else {
|
|
1595
|
-
const response = await apiPost(ctx.apiKey, basePath, { navigation_item: {
|
|
1596
|
-
label: codeItem.label,
|
|
1597
|
-
slug: codeItem.slug ?? null,
|
|
1598
|
-
icon: codeItem.icon ?? null,
|
|
1599
|
-
position: i + 1,
|
|
1600
|
-
parent_id: parentId,
|
|
1601
|
-
source: "code"
|
|
1602
|
-
} });
|
|
1603
|
-
stats.created++;
|
|
1604
|
-
if (codeItem.children?.length) {
|
|
1605
|
-
const newId = response.navigation_item.id;
|
|
1606
|
-
await syncLevel(ctx, codeItem.children, [], newId, stats);
|
|
1607
|
-
}
|
|
1608
|
-
}
|
|
1609
|
-
}
|
|
1610
|
-
for (const [key, item] of existingByKey) if (!matched.has(key)) await deleteItemRecursive(ctx.apiKey, basePath, item, stats);
|
|
1611
|
-
}
|
|
1612
|
-
/**
|
|
1613
|
-
* Sync code-defined navigation items to the Fluid OS API.
|
|
1614
|
-
*
|
|
1615
|
-
* @param apiKey - Valid Fluid company API key
|
|
1616
|
-
* @param codeItems - Navigation items from portal.config.ts
|
|
1617
|
-
*/
|
|
1618
|
-
async function syncNavigation(apiKey, codeItems) {
|
|
1619
|
-
const contextResult = await discoverContext(apiKey);
|
|
1620
|
-
if (!contextResult.success) return contextResult;
|
|
1621
|
-
const ctx = {
|
|
1622
|
-
apiBase: FLUID_API_BASE,
|
|
1623
|
-
...contextResult.value
|
|
1624
|
-
};
|
|
1625
|
-
let existingItems;
|
|
1626
|
-
try {
|
|
1627
|
-
existingItems = (await apiGet(ctx.apiKey, `/api/company/fluid_os/definitions/${ctx.definitionId}/navigations/${ctx.navigationId}/navigation_items`)).navigation_items;
|
|
1628
|
-
} catch (err) {
|
|
1629
|
-
return failure({
|
|
1630
|
-
code: "API_ERROR",
|
|
1631
|
-
message: "Failed to fetch existing navigation items",
|
|
1632
|
-
details: err instanceof Error ? err.message : String(err)
|
|
1633
|
-
});
|
|
1634
|
-
}
|
|
1635
|
-
const stats = {
|
|
1636
|
-
created: 0,
|
|
1637
|
-
updated: 0,
|
|
1638
|
-
deleted: 0
|
|
1639
|
-
};
|
|
1640
|
-
try {
|
|
1641
|
-
await syncLevel(ctx, codeItems, existingItems, null, stats);
|
|
1642
|
-
} catch (err) {
|
|
1643
|
-
return failure({
|
|
1644
|
-
code: "SYNC_FAILED",
|
|
1645
|
-
message: "Navigation sync failed during reconciliation",
|
|
1646
|
-
details: err instanceof Error ? err.message : String(err)
|
|
1647
|
-
});
|
|
1648
|
-
}
|
|
1649
|
-
return success(stats);
|
|
1650
|
-
}
|
|
1651
|
-
//#endregion
|
|
1652
|
-
//#region src/commands/deploy.ts
|
|
1653
|
-
/**
|
|
1654
|
-
* Detect if the project is a fullstack template (has server entry)
|
|
1655
|
-
*/
|
|
1656
|
-
async function isFullstackProject(cwd) {
|
|
1657
|
-
return fs.pathExists(path$1.join(cwd, "src", "server", "index.ts"));
|
|
1658
|
-
}
|
|
1659
|
-
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) => {
|
|
1660
|
-
const cwd = process.cwd();
|
|
1661
|
-
config({ path: path$1.join(cwd, ".env") });
|
|
1662
|
-
console.log();
|
|
1663
|
-
console.log(chalk.blue.bold("Fluid Deploy") + chalk.gray(" (Cloud Run + Turso)"));
|
|
1664
|
-
console.log();
|
|
1665
|
-
if (!await isFullstackProject(cwd)) {
|
|
1666
|
-
console.log(chalk.red("Error:") + " This project is not a fullstack template.");
|
|
1667
|
-
console.log();
|
|
1668
|
-
console.log(chalk.yellow("fluid deploy") + " only supports fullstack projects with a Hono API server.");
|
|
1669
|
-
console.log();
|
|
1670
|
-
console.log("For static sites (starter template), deploy the " + chalk.cyan("dist/") + " folder to:");
|
|
1671
|
-
console.log(" - Firebase Hosting: " + chalk.gray("https://firebase.google.com/docs/hosting"));
|
|
1672
|
-
console.log(" - Cloud Storage: " + chalk.gray("https://cloud.google.com/storage/docs/hosting-static-website"));
|
|
1673
|
-
console.log(" - Vercel: " + chalk.gray("https://vercel.com"));
|
|
1674
|
-
console.log(" - Netlify: " + chalk.gray("https://netlify.com"));
|
|
1675
|
-
console.log();
|
|
1676
|
-
process.exit(1);
|
|
1677
|
-
}
|
|
1678
|
-
const spinner = ora();
|
|
1679
|
-
spinner.start("Validating Fluid API key...");
|
|
1680
|
-
const fluidResult = await resolveFluidApiKey(options.fluidCompanyApiKey);
|
|
1681
|
-
if (!fluidResult.success) {
|
|
1682
|
-
spinner.fail("Fluid API key validation failed");
|
|
1683
|
-
console.log();
|
|
1684
|
-
console.log(chalk.red("Error:") + " " + fluidResult.error.message);
|
|
1685
|
-
if (fluidResult.error.details) {
|
|
1686
|
-
console.log();
|
|
1687
|
-
console.log(fluidResult.error.details);
|
|
1688
|
-
}
|
|
1689
|
-
console.log();
|
|
1690
|
-
process.exit(1);
|
|
1691
|
-
}
|
|
1692
|
-
spinner.succeed(`Fluid company: ${chalk.cyan(fluidResult.value.name)}`);
|
|
1693
|
-
if (!await fs.pathExists(path$1.join(cwd, "Dockerfile"))) {
|
|
1694
|
-
console.log(chalk.red("Error:") + " No Dockerfile found in current directory.");
|
|
1695
|
-
console.log();
|
|
1696
|
-
console.log("Fullstack projects created with the latest template include a Dockerfile.");
|
|
1697
|
-
console.log("If you upgraded from an older template, add a Dockerfile to your project.");
|
|
1698
|
-
console.log();
|
|
1699
|
-
process.exit(1);
|
|
1700
|
-
}
|
|
1701
|
-
spinner.start("Checking gcloud CLI...");
|
|
1702
|
-
const gcloudResult = await validateGcloudInstalled();
|
|
1703
|
-
if (!gcloudResult.success) {
|
|
1704
|
-
spinner.fail("gcloud CLI not found");
|
|
1705
|
-
console.log();
|
|
1706
|
-
console.log(chalk.red("Error:") + " " + gcloudResult.error.message);
|
|
1707
|
-
console.log();
|
|
1708
|
-
console.log("Install the Google Cloud SDK: " + chalk.cyan("https://cloud.google.com/sdk/docs/install"));
|
|
1709
|
-
console.log();
|
|
1710
|
-
process.exit(1);
|
|
1711
|
-
}
|
|
1712
|
-
const authResult = await validateGcloudAuth();
|
|
1713
|
-
if (!authResult.success) {
|
|
1714
|
-
spinner.fail("gcloud not authenticated");
|
|
1715
|
-
console.log();
|
|
1716
|
-
console.log(chalk.red("Error:") + " " + authResult.error.message);
|
|
1717
|
-
console.log();
|
|
1718
|
-
console.log("Run " + chalk.cyan("gcloud auth login") + " to authenticate.");
|
|
1719
|
-
console.log();
|
|
1720
|
-
process.exit(1);
|
|
1721
|
-
}
|
|
1722
|
-
spinner.succeed("gcloud CLI ready");
|
|
1723
|
-
let gcpProject = options.gcpProject;
|
|
1724
|
-
if (!gcpProject) {
|
|
1725
|
-
spinner.start("Detecting GCP project...");
|
|
1726
|
-
const projectResult = await getGcpProject();
|
|
1727
|
-
if (!projectResult.success) {
|
|
1728
|
-
spinner.fail("No GCP project configured");
|
|
1729
|
-
console.log();
|
|
1730
|
-
console.log(chalk.red("Error:") + " " + projectResult.error.message);
|
|
1731
|
-
console.log();
|
|
1732
|
-
console.log("Either:");
|
|
1733
|
-
console.log(" - Run " + chalk.cyan("gcloud config set project PROJECT_ID"));
|
|
1734
|
-
console.log(" - Use the " + chalk.cyan("--gcp-project <id>") + " flag");
|
|
1735
|
-
console.log();
|
|
1736
|
-
process.exit(1);
|
|
1737
|
-
}
|
|
1738
|
-
gcpProject = projectResult.value;
|
|
1739
|
-
spinner.succeed(`GCP project: ${chalk.cyan(gcpProject)}`);
|
|
1740
|
-
}
|
|
1741
|
-
spinner.start("Resolving Turso credentials...");
|
|
1742
|
-
const tursoConfigResult = await resolveTursoConfig(options.tursoOrg);
|
|
1743
|
-
if (!tursoConfigResult.success) {
|
|
1744
|
-
spinner.fail("Turso credentials not found");
|
|
1745
|
-
console.log();
|
|
1746
|
-
console.log(chalk.red("Error:") + " " + tursoConfigResult.error.message);
|
|
1747
|
-
if (tursoConfigResult.error.details) {
|
|
1748
|
-
console.log();
|
|
1749
|
-
console.log(tursoConfigResult.error.details);
|
|
1750
|
-
}
|
|
1751
|
-
console.log();
|
|
1752
|
-
process.exit(1);
|
|
1753
|
-
}
|
|
1754
|
-
const tursoConfig = tursoConfigResult.value;
|
|
1755
|
-
const sourceLabel = tursoConfig.source === "env" ? "environment variables" : "Turso CLI";
|
|
1756
|
-
spinner.succeed(`Turso: authenticated via ${sourceLabel}`);
|
|
1757
|
-
const projectName = options.project ?? await getProjectName(cwd);
|
|
1758
|
-
if (!projectName) {
|
|
1759
|
-
console.log(chalk.red("Error:") + " Could not determine project name.");
|
|
1760
|
-
console.log();
|
|
1761
|
-
console.log("Either:");
|
|
1762
|
-
console.log(" - Add a " + chalk.cyan("name") + " field to package.json");
|
|
1763
|
-
console.log(" - Use the " + chalk.cyan("--project <name>") + " flag");
|
|
1764
|
-
console.log();
|
|
1765
|
-
process.exit(1);
|
|
1766
|
-
}
|
|
1767
|
-
const serviceName = sanitizeServiceName(projectName);
|
|
1768
|
-
if (!serviceName) {
|
|
1769
|
-
console.log(chalk.red("Error:") + " Project name sanitizes to an empty service name.");
|
|
1770
|
-
console.log();
|
|
1771
|
-
console.log("Use the " + chalk.cyan("--project <name>") + " flag to provide a valid service name.");
|
|
1772
|
-
console.log();
|
|
1773
|
-
process.exit(1);
|
|
1774
|
-
}
|
|
1775
|
-
const region = options.region ?? "us-central1";
|
|
1776
|
-
console.log();
|
|
1777
|
-
console.log(chalk.gray("Service: ") + chalk.white(serviceName));
|
|
1778
|
-
console.log(chalk.gray("Region: ") + chalk.white(region));
|
|
1779
|
-
console.log(chalk.gray("GCP Project: ") + chalk.white(gcpProject));
|
|
1780
|
-
console.log(chalk.gray("Turso Org: ") + chalk.white(tursoConfig.org) + chalk.gray(` (via ${sourceLabel})`));
|
|
1781
|
-
console.log();
|
|
1782
|
-
if (!options.skipLocalBuild) {
|
|
1783
|
-
let dockerAvailable = false;
|
|
1784
|
-
try {
|
|
1785
|
-
await execa("docker", ["--version"], { stdio: "pipe" });
|
|
1786
|
-
dockerAvailable = true;
|
|
1787
|
-
} catch {
|
|
1788
|
-
spinner.warn("Docker not found — skipping local build check");
|
|
1789
|
-
}
|
|
1790
|
-
if (dockerAvailable) {
|
|
1791
|
-
spinner.start("Running local Docker build check...");
|
|
1792
|
-
try {
|
|
1793
|
-
await execa("docker", [
|
|
1794
|
-
"build",
|
|
1795
|
-
"-t",
|
|
1796
|
-
`${serviceName}-check`,
|
|
1797
|
-
"."
|
|
1798
|
-
], {
|
|
1799
|
-
cwd,
|
|
1800
|
-
stdio: "pipe"
|
|
1801
|
-
});
|
|
1802
|
-
spinner.succeed("Local Docker build passed");
|
|
1803
|
-
} catch (buildErr) {
|
|
1804
|
-
spinner.fail("Local Docker build failed");
|
|
1805
|
-
const buildError = buildErr;
|
|
1806
|
-
if (buildError.stderr) {
|
|
1807
|
-
console.log();
|
|
1808
|
-
console.log(chalk.gray(buildError.stderr));
|
|
1809
|
-
}
|
|
1810
|
-
console.log();
|
|
1811
|
-
console.log(chalk.red("Fix the Dockerfile errors above before deploying."));
|
|
1812
|
-
console.log(chalk.gray("Tip: Use --skip-local-build to bypass this check."));
|
|
1813
|
-
console.log();
|
|
1814
|
-
process.exit(1);
|
|
1815
|
-
}
|
|
1816
|
-
console.log();
|
|
1817
|
-
}
|
|
1818
|
-
}
|
|
1819
|
-
try {
|
|
1820
|
-
const dbResult = await provisionDatabase(tursoConfig, serviceName, options.dbRegion, {
|
|
1821
|
-
onGroupCreating: () => {
|
|
1822
|
-
spinner.start("Ensuring Turso database group...");
|
|
1823
|
-
},
|
|
1824
|
-
onGroupReady: () => {
|
|
1825
|
-
spinner.succeed("Database group ready");
|
|
1826
|
-
},
|
|
1827
|
-
onDatabaseCreating: (name) => {
|
|
1828
|
-
spinner.start(`Creating database "${name}"...`);
|
|
1829
|
-
},
|
|
1830
|
-
onDatabaseReady: (name) => {
|
|
1831
|
-
spinner.succeed(`Database "${name}" ready`);
|
|
1832
|
-
},
|
|
1833
|
-
onTokenCreating: () => {
|
|
1834
|
-
spinner.start("Creating database auth token...");
|
|
1835
|
-
},
|
|
1836
|
-
onTokenReady: () => {
|
|
1837
|
-
spinner.succeed("Auth token created");
|
|
1838
|
-
}
|
|
1839
|
-
});
|
|
1840
|
-
if (!dbResult.success) {
|
|
1841
|
-
spinner.fail("Turso provisioning failed");
|
|
1842
|
-
console.log();
|
|
1843
|
-
console.log(chalk.red("Error:") + " " + dbResult.error.message);
|
|
1844
|
-
if (dbResult.error.details) console.log(chalk.gray("Details: ") + dbResult.error.details);
|
|
1845
|
-
console.log();
|
|
1846
|
-
process.exit(1);
|
|
1847
|
-
}
|
|
1848
|
-
const database = dbResult.value;
|
|
1849
|
-
console.log();
|
|
1850
|
-
console.log(chalk.gray("Database URL: ") + chalk.cyan(database.url));
|
|
1851
|
-
console.log();
|
|
1852
|
-
const deployResult = await deployToCloudRun({
|
|
1853
|
-
gcpProject,
|
|
1854
|
-
region,
|
|
1855
|
-
serviceName,
|
|
1856
|
-
sourceDir: cwd,
|
|
1857
|
-
requireAuth: options.requireAuth,
|
|
1858
|
-
envVars: {
|
|
1859
|
-
DATABASE_URL: database.url,
|
|
1860
|
-
DATABASE_AUTH_TOKEN: database.authToken,
|
|
1861
|
-
NODE_ENV: "production",
|
|
1862
|
-
FLUID_COMPANY_API_KEY: fluidResult.value.apiKey
|
|
1863
|
-
}
|
|
1864
|
-
}, {
|
|
1865
|
-
onValidating: () => {
|
|
1866
|
-
spinner.start("Preparing Cloud Run deployment...");
|
|
1867
|
-
},
|
|
1868
|
-
onDeploying: () => {
|
|
1869
|
-
spinner.text = "Deploying to Cloud Run (this may take 2-5 minutes)...";
|
|
1870
|
-
},
|
|
1871
|
-
onDeployComplete: () => {
|
|
1872
|
-
spinner.succeed("Deployed to Cloud Run");
|
|
1873
|
-
}
|
|
1874
|
-
});
|
|
1875
|
-
if (!deployResult.success) {
|
|
1876
|
-
spinner.fail("Cloud Run deployment failed");
|
|
1877
|
-
console.log();
|
|
1878
|
-
console.log(chalk.red("Error:") + " " + deployResult.error.message);
|
|
1879
|
-
if (deployResult.error.details) console.log(chalk.gray("Details: ") + deployResult.error.details);
|
|
1880
|
-
console.log();
|
|
1881
|
-
process.exit(1);
|
|
1882
|
-
}
|
|
1883
|
-
console.log();
|
|
1884
|
-
console.log(chalk.green.bold("Deployed successfully!"));
|
|
1885
|
-
console.log();
|
|
1886
|
-
console.log(chalk.gray("Service URL: ") + chalk.cyan(deployResult.value.url));
|
|
1887
|
-
console.log(chalk.gray("Database: ") + chalk.cyan(database.databaseName));
|
|
1888
|
-
console.log(chalk.gray("Region: ") + chalk.cyan(deployResult.value.region));
|
|
1889
|
-
console.log(chalk.gray("GCP Project: ") + chalk.cyan(deployResult.value.gcpProject));
|
|
1890
|
-
console.log();
|
|
1891
|
-
if (!options.skipNavSync) {
|
|
1892
|
-
const configPath = path$1.join(cwd, "src", "navigation.config.ts");
|
|
1893
|
-
if (await fs.pathExists(configPath)) {
|
|
1894
|
-
const navSpinner = ora("Extracting navigation from navigation.config.ts...").start();
|
|
1895
|
-
const extractResult = await extractNavigation(cwd);
|
|
1896
|
-
if (extractResult.success && extractResult.value != null) {
|
|
1897
|
-
const navItems = extractResult.value;
|
|
1898
|
-
navSpinner.text = `Syncing ${navItems.length} navigation item(s)...`;
|
|
1899
|
-
const syncResult = await syncNavigation(fluidResult.value.apiKey, navItems);
|
|
1900
|
-
if (syncResult.success) {
|
|
1901
|
-
const { created, updated, deleted } = syncResult.value;
|
|
1902
|
-
const parts = [];
|
|
1903
|
-
if (created > 0) parts.push(`${created} created`);
|
|
1904
|
-
if (updated > 0) parts.push(`${updated} updated`);
|
|
1905
|
-
if (deleted > 0) parts.push(`${deleted} deleted`);
|
|
1906
|
-
if (parts.length > 0) navSpinner.succeed(`Navigation synced (${parts.join(", ")})`);
|
|
1907
|
-
else navSpinner.succeed("Navigation up to date");
|
|
1908
|
-
} else {
|
|
1909
|
-
navSpinner.warn("Navigation sync failed (deploy succeeded)");
|
|
1910
|
-
console.log(chalk.yellow(" Warning: ") + syncResult.error.message);
|
|
1911
|
-
if (syncResult.error.details) console.log(chalk.gray(" Details: ") + syncResult.error.details);
|
|
1912
|
-
}
|
|
1913
|
-
} else if (extractResult.success && extractResult.value == null) navSpinner.info("No navigation export found in navigation.config.ts — skipping sync");
|
|
1914
|
-
else {
|
|
1915
|
-
navSpinner.warn("Could not extract navigation (deploy succeeded)");
|
|
1916
|
-
if (!extractResult.success && extractResult.error.details) console.log(chalk.gray(" Details: ") + extractResult.error.details);
|
|
1917
|
-
}
|
|
1918
|
-
}
|
|
1919
|
-
}
|
|
1920
|
-
const serviceUrl = deployResult.value.url;
|
|
1921
|
-
const healthSpinner = ora("Running health check...").start();
|
|
1922
|
-
try {
|
|
1923
|
-
const controller = new AbortController();
|
|
1924
|
-
const timeout = setTimeout(() => controller.abort(), 3e4);
|
|
1925
|
-
const healthRes = await fetch(`${serviceUrl}/api/health`, { signal: controller.signal });
|
|
1926
|
-
clearTimeout(timeout);
|
|
1927
|
-
if (healthRes.ok) healthSpinner.succeed("Health check passed");
|
|
1928
|
-
else healthSpinner.warn("Health check returned non-200 (service may still be starting)");
|
|
1929
|
-
} catch {
|
|
1930
|
-
healthSpinner.warn("Health check timed out (cold start may take a moment)");
|
|
1931
|
-
}
|
|
1932
|
-
if (options.migrate || database.isNew) {
|
|
1933
|
-
if (database.isNew && !options.migrate) {
|
|
1934
|
-
console.log();
|
|
1935
|
-
console.log(chalk.blue("New database") + " — running migrations automatically...");
|
|
1936
|
-
}
|
|
1937
|
-
const migrateCmd = getRunCommand("db:push");
|
|
1938
|
-
const migrateSpinner = ora(`Running migrations (${migrateCmd})...`).start();
|
|
1939
|
-
try {
|
|
1940
|
-
const [pmBin, ...pmArgs] = migrateCmd.split(" ");
|
|
1941
|
-
await execa(pmBin, pmArgs, {
|
|
1942
|
-
cwd,
|
|
1943
|
-
stdio: "pipe",
|
|
1944
|
-
env: {
|
|
1945
|
-
...process.env,
|
|
1946
|
-
DATABASE_URL: database.url,
|
|
1947
|
-
DATABASE_AUTH_TOKEN: database.authToken
|
|
1948
|
-
}
|
|
1949
|
-
});
|
|
1950
|
-
migrateSpinner.succeed("Database migrations complete");
|
|
1951
|
-
} catch (migrateErr) {
|
|
1952
|
-
const migrateError = migrateErr;
|
|
1953
|
-
migrateSpinner.warn("Database migration failed (deploy succeeded)");
|
|
1954
|
-
console.log();
|
|
1955
|
-
console.log(chalk.yellow("Migration error:") + " " + (migrateError.stderr ?? migrateError.message ?? String(migrateErr)));
|
|
1956
|
-
console.log();
|
|
1957
|
-
console.log("Run migrations manually:");
|
|
1958
|
-
console.log(` DATABASE_URL=${database.url} DATABASE_AUTH_TOKEN=<token> ${migrateCmd}`);
|
|
1959
|
-
}
|
|
1960
|
-
} else {
|
|
1961
|
-
console.log();
|
|
1962
|
-
console.log(chalk.yellow("Important:") + " Run database migrations against your production database:");
|
|
1963
|
-
console.log(` DATABASE_URL=${database.url} DATABASE_AUTH_TOKEN=<token> pnpm db:push`);
|
|
1964
|
-
console.log(chalk.gray("Tip: Use --migrate to run migrations automatically."));
|
|
1965
|
-
}
|
|
1966
|
-
console.log();
|
|
1967
|
-
} catch (error) {
|
|
1968
|
-
spinner.fail("Deployment failed");
|
|
1969
|
-
console.log();
|
|
1970
|
-
const errorMessage = getErrorMessage(error);
|
|
1971
|
-
console.log(chalk.red("Error:") + " " + errorMessage);
|
|
1972
|
-
console.log();
|
|
1973
|
-
process.exit(1);
|
|
1974
|
-
}
|
|
1975
|
-
});
|
|
1976
|
-
//#endregion
|
|
1977
|
-
//#region src/commands/destroy.ts
|
|
1978
|
-
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) => {
|
|
1979
|
-
const cwd = process.cwd();
|
|
1980
|
-
config({ path: path$1.join(cwd, ".env") });
|
|
1981
|
-
console.log();
|
|
1982
|
-
console.log(chalk.red.bold("Fluid Destroy") + chalk.gray(" (Cloud Run + Turso)"));
|
|
1983
|
-
console.log();
|
|
1984
|
-
const spinner = ora();
|
|
1985
|
-
spinner.start("Validating Fluid API key...");
|
|
1986
|
-
const fluidResult = await resolveFluidApiKey(options.fluidCompanyApiKey);
|
|
1987
|
-
if (!fluidResult.success) {
|
|
1988
|
-
spinner.fail("Fluid API key validation failed");
|
|
1989
|
-
console.log();
|
|
1990
|
-
console.log(chalk.red("Error:") + " " + fluidResult.error.message);
|
|
1991
|
-
if (fluidResult.error.details) {
|
|
1992
|
-
console.log();
|
|
1993
|
-
console.log(fluidResult.error.details);
|
|
1994
|
-
}
|
|
1995
|
-
console.log();
|
|
1996
|
-
process.exit(1);
|
|
1997
|
-
}
|
|
1998
|
-
spinner.succeed(`Fluid company: ${chalk.cyan(fluidResult.value.name)}`);
|
|
1999
|
-
spinner.start("Checking gcloud CLI...");
|
|
2000
|
-
const gcloudResult = await validateGcloudInstalled();
|
|
2001
|
-
if (!gcloudResult.success) {
|
|
2002
|
-
spinner.fail("gcloud CLI not found");
|
|
2003
|
-
console.log();
|
|
2004
|
-
console.log(chalk.red("Error:") + " " + gcloudResult.error.message);
|
|
2005
|
-
console.log();
|
|
2006
|
-
process.exit(1);
|
|
2007
|
-
}
|
|
2008
|
-
const authResult = await validateGcloudAuth();
|
|
2009
|
-
if (!authResult.success) {
|
|
2010
|
-
spinner.fail("gcloud not authenticated");
|
|
2011
|
-
console.log();
|
|
2012
|
-
console.log(chalk.red("Error:") + " " + authResult.error.message);
|
|
2013
|
-
console.log();
|
|
2014
|
-
process.exit(1);
|
|
2015
|
-
}
|
|
2016
|
-
spinner.succeed("gcloud CLI ready");
|
|
2017
|
-
let gcpProject = options.gcpProject;
|
|
2018
|
-
if (!gcpProject) {
|
|
2019
|
-
spinner.start("Detecting GCP project...");
|
|
2020
|
-
const projectResult = await getGcpProject();
|
|
2021
|
-
if (!projectResult.success) {
|
|
2022
|
-
spinner.fail("No GCP project configured");
|
|
2023
|
-
console.log();
|
|
2024
|
-
console.log(chalk.red("Error:") + " " + projectResult.error.message);
|
|
2025
|
-
console.log();
|
|
2026
|
-
process.exit(1);
|
|
2027
|
-
}
|
|
2028
|
-
gcpProject = projectResult.value;
|
|
2029
|
-
spinner.succeed(`GCP project: ${chalk.cyan(gcpProject)}`);
|
|
2030
|
-
}
|
|
2031
|
-
spinner.start("Resolving Turso credentials...");
|
|
2032
|
-
const tursoConfigResult = await resolveTursoConfig(options.tursoOrg);
|
|
2033
|
-
if (!tursoConfigResult.success) {
|
|
2034
|
-
spinner.fail("Turso credentials not found");
|
|
2035
|
-
console.log();
|
|
2036
|
-
console.log(chalk.red("Error:") + " " + tursoConfigResult.error.message);
|
|
2037
|
-
if (tursoConfigResult.error.details) {
|
|
2038
|
-
console.log();
|
|
2039
|
-
console.log(tursoConfigResult.error.details);
|
|
2040
|
-
}
|
|
2041
|
-
console.log();
|
|
2042
|
-
process.exit(1);
|
|
2043
|
-
}
|
|
2044
|
-
const tursoConfig = tursoConfigResult.value;
|
|
2045
|
-
spinner.succeed("Turso credentials resolved");
|
|
2046
|
-
const projectName = options.project ?? await getProjectName(cwd);
|
|
2047
|
-
if (!projectName) {
|
|
2048
|
-
console.log(chalk.red("Error:") + " Could not determine project name.");
|
|
2049
|
-
console.log();
|
|
2050
|
-
console.log("Either:");
|
|
2051
|
-
console.log(" - Add a " + chalk.cyan("name") + " field to package.json");
|
|
2052
|
-
console.log(" - Use the " + chalk.cyan("--project <name>") + " flag");
|
|
2053
|
-
console.log();
|
|
2054
|
-
process.exit(1);
|
|
2055
|
-
}
|
|
2056
|
-
const serviceName = sanitizeServiceName(projectName);
|
|
2057
|
-
if (!serviceName) {
|
|
2058
|
-
console.log(chalk.red("Error:") + " Project name sanitizes to an empty service name.");
|
|
2059
|
-
console.log();
|
|
2060
|
-
console.log("Use the " + chalk.cyan("--project <name>") + " flag to provide a valid service name.");
|
|
2061
|
-
console.log();
|
|
2062
|
-
process.exit(1);
|
|
2063
|
-
}
|
|
2064
|
-
const region = options.region ?? "us-central1";
|
|
2065
|
-
console.log();
|
|
2066
|
-
console.log(chalk.yellow("The following resources will be destroyed:"));
|
|
2067
|
-
console.log();
|
|
2068
|
-
console.log(chalk.gray(" Cloud Run service: ") + chalk.white(serviceName));
|
|
2069
|
-
console.log(chalk.gray(" Region: ") + chalk.white(region));
|
|
2070
|
-
console.log(chalk.gray(" GCP Project: ") + chalk.white(gcpProject));
|
|
2071
|
-
console.log(chalk.gray(" Turso database: ") + chalk.white(serviceName));
|
|
2072
|
-
console.log(chalk.gray(" Turso org: ") + chalk.white(tursoConfig.org));
|
|
2073
|
-
console.log();
|
|
2074
|
-
if (!options.yes) {
|
|
2075
|
-
const { confirmed } = await prompts({
|
|
2076
|
-
type: "confirm",
|
|
2077
|
-
name: "confirmed",
|
|
2078
|
-
message: "Are you sure you want to destroy these resources?",
|
|
2079
|
-
initial: false
|
|
2080
|
-
});
|
|
2081
|
-
if (!confirmed) {
|
|
2082
|
-
console.log();
|
|
2083
|
-
console.log(chalk.gray("Destroy cancelled."));
|
|
2084
|
-
console.log();
|
|
2085
|
-
return;
|
|
2086
|
-
}
|
|
2087
|
-
}
|
|
2088
|
-
console.log();
|
|
2089
|
-
spinner.start(`Deleting Cloud Run service "${serviceName}"...`);
|
|
2090
|
-
const deleteServiceResult = await deleteCloudRunService({
|
|
2091
|
-
serviceName,
|
|
2092
|
-
gcpProject,
|
|
2093
|
-
region
|
|
2094
|
-
});
|
|
2095
|
-
if (!deleteServiceResult.success) {
|
|
2096
|
-
spinner.warn("Cloud Run service deletion failed");
|
|
2097
|
-
console.log(chalk.yellow("Warning:") + " " + deleteServiceResult.error.message);
|
|
2098
|
-
if (deleteServiceResult.error.details) console.log(chalk.gray("Details: ") + deleteServiceResult.error.details);
|
|
2099
|
-
} else spinner.succeed("Cloud Run service deleted");
|
|
2100
|
-
spinner.start(`Deleting Turso database "${serviceName}"...`);
|
|
2101
|
-
const deleteDbResult = await deleteDatabase(tursoConfig, serviceName);
|
|
2102
|
-
if (!deleteDbResult.success) {
|
|
2103
|
-
spinner.warn("Turso database deletion failed");
|
|
2104
|
-
console.log(chalk.yellow("Warning:") + " " + deleteDbResult.error.message);
|
|
2105
|
-
if (deleteDbResult.error.details) console.log(chalk.gray("Details: ") + deleteDbResult.error.details);
|
|
2106
|
-
} else spinner.succeed("Turso database deleted");
|
|
2107
|
-
console.log();
|
|
2108
|
-
if (deleteServiceResult.success && deleteDbResult.success) console.log(chalk.green.bold("All resources destroyed successfully."));
|
|
2109
|
-
else console.log(chalk.yellow.bold("Destroy completed with warnings.") + " Some resources may need manual cleanup.");
|
|
2110
|
-
console.log();
|
|
2111
|
-
});
|
|
2112
|
-
//#endregion
|
|
2113
537
|
//#region src/utils/push-validation.ts
|
|
2114
538
|
/**
|
|
2115
539
|
* Cross-reference validation and change categorization utilities for the push command.
|
|
@@ -3503,19 +1927,17 @@ const versionCommand = new Command("version").description("Manage portal definit
|
|
|
3503
1927
|
/**
|
|
3504
1928
|
* @fluid-app/fluid-cli-portal
|
|
3505
1929
|
*
|
|
3506
|
-
* Fluid CLI plugin for building
|
|
1930
|
+
* Fluid CLI plugin for building portal applications.
|
|
3507
1931
|
* Auto-discovered by @fluid-app/fluid-cli via the fluid-cli-* naming convention.
|
|
3508
1932
|
*/
|
|
3509
1933
|
const plugin = {
|
|
3510
1934
|
name: "fluid-cli-portal",
|
|
3511
1935
|
version: "0.1.0",
|
|
3512
1936
|
async register(ctx) {
|
|
3513
|
-
const portal = new Command("portal").description("Build
|
|
1937
|
+
const portal = new Command("portal").description("Build and develop portal applications");
|
|
3514
1938
|
portal.addCommand(createCommand);
|
|
3515
1939
|
portal.addCommand(devCommand);
|
|
3516
1940
|
portal.addCommand(buildCommand);
|
|
3517
|
-
portal.addCommand(deployCommand);
|
|
3518
|
-
portal.addCommand(destroyCommand);
|
|
3519
1941
|
portal.addCommand(pullCommand);
|
|
3520
1942
|
portal.addCommand(pushCommand);
|
|
3521
1943
|
portal.addCommand(widgetCommand);
|
|
@@ -3525,6 +1947,6 @@ const plugin = {
|
|
|
3525
1947
|
}
|
|
3526
1948
|
};
|
|
3527
1949
|
//#endregion
|
|
3528
|
-
export {
|
|
1950
|
+
export { FILE_SYSTEM_ERRORS, TEMPLATES, buildIdToSlugMap, buildNavigationIdToSlugMap, buildSnapshot, buildThemeIdToSlugMap, categorizeChanges, computeFileHash, copyTemplate, copyTemplateSafe, createCommand, createDirectory, createDirectorySafe, plugin as default, deriveScreenSlug, deriveSlug, diffAgainstSnapshot, directoryExists, doctorCommand, fileExists, getInstallCommand, getRunCommand, getSdkVersion, getSdkVersionSafe, getTemplatePaths, installDependencies, pathExists, promptProjectConfig, pullCommand, pushCommand, readFileSafe, readMappings, readSnapshot, removeMapping, resolveIdToSlug, resolveSlugToId, runPackageManager, slugFromPath, transformNavigation, transformNavigationItems, transformProfile, transformScreen, transformTheme, updateMapping, validateCrossReferences, versionCommand, widgetCommand, writeFileSafe, writeMappings, writeSnapshot };
|
|
3529
1951
|
|
|
3530
1952
|
//# sourceMappingURL=index.mjs.map
|