@solcreek/cli 0.4.8 → 0.4.10

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.
@@ -1,18 +1,17 @@
1
1
  import { defineCommand } from "citty";
2
2
  import consola from "consola";
3
- import { existsSync, readFileSync, writeFileSync, readdirSync, statSync, rmSync } from "node:fs";
3
+ import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs";
4
4
  // ajv is lazy-imported only when --template --data is used (avoid top-level crash if deps missing)
5
5
  import { join, resolve } from "node:path";
6
6
  import { execSync, execFileSync } from "node:child_process";
7
- import { CreekClient, CreekAuthError, isSSRFramework, getSSRServerEntry, getClientAssetsDir, getDefaultBuildOutput, detectFramework, resolveConfig, formatDetectionSummary, resolvedConfigToResources, resolvedConfigToBindingRequirements, ConfigNotFoundError, getSSRServerDir, collectServerFiles, isPreBundledFramework, detectAstroCloudflareBuild, detectNextjsMode, detectMonorepo, } from "@solcreek/sdk";
7
+ import { CreekClient, CreekAuthError, detectFramework, resolveConfig, formatDetectionSummary, resolvedConfigToResources, resolvedConfigToBindingRequirements, ConfigNotFoundError, detectNextjsMode, detectMonorepo, } from "@solcreek/sdk";
8
8
  import { getToken, getApiUrl } from "../utils/config.js";
9
9
  import { collectAssets } from "../utils/bundle.js";
10
- import { bundleSSRServer } from "../utils/ssr-bundle.js";
11
- import { bundleWorker } from "../utils/worker-bundle.js";
12
10
  import { sandboxDeploy, pollSandboxStatus, printSandboxSuccess } from "../utils/sandbox.js";
11
+ import { prepareDeployBundle } from "../utils/prepare-bundle.js";
13
12
  import { isTTY, jsonOutput, resolveJsonMode, globalArgs, shouldAutoConfirm, AUTH_BREADCRUMBS, NO_PROJECT_BREADCRUMBS } from "../utils/output.js";
14
13
  import { ensureTosAccepted } from "../utils/tos.js";
15
- import { buildNextjs, patchBundledWorker, hasAdapterOutput } from "../utils/nextjs.js";
14
+ import { hasAdapterOutput } from "../utils/nextjs.js";
16
15
  import { isRepoUrl, parseRepoUrl, validateRepoUrl, validateSubpath, RepoUrlError } from "../utils/repo-url.js";
17
16
  import { checkGitInstalled, cloneRepo, detectPackageManager, installDependencies, cleanupDir as cleanupCloneDir, GitCloneError } from "../utils/git-clone.js";
18
17
  function section(name) {
@@ -676,7 +675,6 @@ async function deploySandbox(cwd, skipBuild, jsonMode = false, resolved, tos) {
676
675
  }
677
676
  const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
678
677
  const framework = resolved?.framework ?? detectFramework(pkg);
679
- // Detect Next.js mode (static vs opennext SSR)
680
678
  const nextjsMode = framework === "nextjs" ? detectNextjsMode(pkg, cwd) : null;
681
679
  const monorepo = detectMonorepo(cwd);
682
680
  section("Detect");
@@ -684,120 +682,22 @@ async function deploySandbox(cwd, skipBuild, jsonMode = false, resolved, tos) {
684
682
  if (nextjsMode)
685
683
  consola.info(` Next.js mode: ${nextjsMode}${monorepo.isMonorepo ? " (monorepo)" : ""}`);
686
684
  consola.info(" Mode: sandbox (60 min preview)");
687
- // Build
688
- if (!skipBuild) {
685
+ // Pre-build header so the section banner ("Build") still appears
686
+ // before build output streams. Then prepareDeployBundle owns the
687
+ // actual build + plan + collect + bundle pipeline.
688
+ if (!skipBuild)
689
689
  section("Build");
690
- if (nextjsMode === "opennext") {
691
- // Next.js SSR on CF Workers: adapter (>= 16.2) or legacy opennext
692
- try {
693
- buildNextjs(cwd, monorepo.isMonorepo);
694
- }
695
- catch {
696
- consola.error("Next.js build failed");
697
- process.exit(1);
698
- }
699
- consola.success(" Build complete");
700
- }
701
- else {
702
- const buildCommand = resolved?.buildCommand || "npm run build";
703
- if (!buildCommand) {
704
- consola.error("No build script found in package.json.");
705
- consola.info("Add a 'build' script or use --skip-build if already built.");
706
- process.exit(1);
707
- }
708
- consola.start(` ${buildCommand}`);
709
- try {
710
- execSync(buildCommand, { cwd, stdio: "inherit" });
711
- }
712
- catch {
713
- consola.error("Build failed");
714
- consola.info("");
715
- consola.info(" Common fixes:");
716
- consola.info(" npm install (missing dependencies?)");
717
- consola.info(" Check for TypeScript errors");
718
- process.exit(1);
719
- }
720
- consola.success(" Build complete");
721
- }
722
- }
723
- const useAdapterOutput = framework === "nextjs" && hasAdapterOutput(cwd);
724
- const outputDir = useAdapterOutput
725
- ? resolve(cwd, ".creek/adapter-output")
726
- : resolve(cwd, resolved?.buildOutput ?? getDefaultBuildOutput(framework));
727
- if (!existsSync(outputDir)) {
728
- consola.error(`Build output not found: ${outputDir}`);
729
- if (framework) {
730
- consola.info(`Expected output for ${framework}: ${getDefaultBuildOutput(framework)}`);
731
- }
732
- process.exit(1);
733
- }
734
- // Collect assets + determine render mode.
735
- //
736
- // Three shapes, mirroring the authenticated deploy path:
737
- // 1. Framework-detected SSR (Astro/Nuxt/etc.) → bundle the framework's
738
- // server entry, upload dist/ as assets.
739
- // 2. User-declared Worker (`[build].worker` in creek.toml, no framework)
740
- // → bundle the Worker entry with esbuild, serverFiles = { worker.js }.
741
- // `dist/` assets are skipped for now — the "Workers + Static Assets
742
- // coexist" zero-config pattern is tracked separately and requires
743
- // build-pipeline changes; until it lands, users inline HTML/JS/CSS
744
- // into the Worker (see docs).
745
- // 3. Neither → plain SPA/static, everything goes as assets.
746
- const isSSR = isSSRFramework(framework);
747
- const isWorker = !framework && !!resolved?.workerEntry;
748
- const renderMode = isWorker ? "worker" : (isSSR ? "ssr" : "spa");
749
- let clientAssets = {};
750
- let fileList = [];
751
- if (!isWorker) {
752
- let clientAssetsDir;
753
- if (useAdapterOutput) {
754
- clientAssetsDir = resolve(outputDir, "assets");
755
- }
756
- else {
757
- clientAssetsDir = outputDir;
758
- if (isSSR && framework) {
759
- const subdir = getClientAssetsDir(framework);
760
- if (subdir)
761
- clientAssetsDir = resolve(outputDir, subdir);
762
- }
763
- }
764
- const collected = collectAssets(clientAssetsDir);
765
- clientAssets = collected.assets;
766
- fileList = collected.fileList;
767
- }
690
+ const prepared = await prepareDeployBundle({
691
+ cwd,
692
+ resolved: resolved ?? {}, // sandbox path can be called without resolved when delegating; guarded above
693
+ skipBuild,
694
+ });
695
+ const { plan, fileList, assets: clientAssets, serverFiles, effectiveRenderMode } = prepared;
768
696
  section("Upload");
769
- if (isWorker) {
770
- consola.info(` Worker mode (${resolved.workerEntry})`);
771
- }
772
- else {
697
+ consola.info(` Mode: ${effectiveRenderMode}${plan.worker.entry ? ` (worker: ${plan.worker.entry})` : ""}`);
698
+ if (plan.assets.enabled) {
773
699
  consola.info(` ${fileList.length} assets (${assetSummary(fileList)})`);
774
700
  }
775
- let serverFiles;
776
- if (isSSR && framework) {
777
- const serverEntry = getSSRServerEntry(framework);
778
- if (serverEntry) {
779
- const serverEntryPath = resolve(outputDir, serverEntry);
780
- if (existsSync(serverEntryPath)) {
781
- consola.start(" Bundling SSR server...");
782
- const bundled = await bundleSSRServer(serverEntryPath);
783
- serverFiles = { "server.js": Buffer.from(bundled).toString("base64") };
784
- consola.success(` SSR bundled (${Math.round(bundled.length / 1024)}KB)`);
785
- }
786
- }
787
- }
788
- else if (isWorker && resolved?.workerEntry) {
789
- const workerEntryPath = resolve(cwd, resolved.workerEntry);
790
- if (!existsSync(workerEntryPath)) {
791
- consola.error(`Worker entry not found: ${resolved.workerEntry}`);
792
- process.exit(1);
793
- }
794
- consola.start(" Bundling worker...");
795
- const bundled = await bundleWorker(workerEntryPath, cwd, {
796
- hasClientAssets: false,
797
- });
798
- serverFiles = { "worker.js": Buffer.from(bundled).toString("base64") };
799
- consola.success(` Worker bundled (${Math.round(bundled.length / 1024)}KB)`);
800
- }
801
701
  // Deploy to sandbox
802
702
  if (!jsonMode) {
803
703
  section("Deploy");
@@ -807,13 +707,13 @@ async function deploySandbox(cwd, skipBuild, jsonMode = false, resolved, tos) {
807
707
  const result = await sandboxDeploy({
808
708
  manifest: {
809
709
  assets: fileList,
810
- hasWorker: isSSR || isWorker,
811
- entrypoint: resolved?.workerEntry ?? null,
812
- renderMode,
710
+ hasWorker: prepared.serverFiles !== undefined,
711
+ entrypoint: prepared.effectiveEntrypoint,
712
+ renderMode: effectiveRenderMode,
813
713
  },
814
714
  assets: clientAssets,
815
715
  serverFiles,
816
- framework: framework ?? undefined,
716
+ framework: prepared.framework ?? undefined,
817
717
  source: "cli",
818
718
  ...(resolved
819
719
  ? { bindings: resolvedConfigToBindingRequirements(resolved) }
@@ -987,20 +887,19 @@ async function deployAuthenticated(cwd, resolved, token, skipBuild, jsonMode = f
987
887
  if (!jsonMode)
988
888
  consola.success(` Created project: ${project.slug}`);
989
889
  }
990
- // Determine deploy mode
890
+ // Framework is resolved upstream; everything else (SSR / worker /
891
+ // assets / bundling) is decided inside prepareDeployBundle via the
892
+ // SDK's planDeploy resolver.
991
893
  const framework = resolved.framework;
992
- const isSSR = isSSRFramework(framework);
993
- const isWorker = !framework && !!resolved.workerEntry;
994
- const renderMode = isWorker ? "worker" : (isSSR ? "ssr" : "spa");
995
- // Detect Next.js mode for special build handling
894
+ // Detect Next.js mode for the info banner only — actual handling is
895
+ // inside prepareDeployBundle.
996
896
  const pkg = existsSync(join(cwd, "package.json"))
997
897
  ? JSON.parse(readFileSync(join(cwd, "package.json"), "utf-8"))
998
898
  : {};
999
899
  const nextjsMode = framework === "nextjs" ? detectNextjsMode(pkg, cwd) : null;
1000
900
  const monorepo = framework === "nextjs" ? detectMonorepo(cwd) : { isMonorepo: false, root: null };
1001
- if (nextjsMode) {
1002
- if (!jsonMode)
1003
- consola.info(` Next.js mode: ${nextjsMode}${monorepo.isMonorepo ? " (monorepo)" : ""}`);
901
+ if (nextjsMode && !jsonMode) {
902
+ consola.info(` Next.js mode: ${nextjsMode}${monorepo.isMonorepo ? " (monorepo)" : ""}`);
1004
903
  }
1005
904
  // --- ⚡ Turbo deploy: check if server has a cached build for this commit ---
1006
905
  // Read the local git HEAD SHA. If the working tree is clean and the
@@ -1010,198 +909,21 @@ async function deployAuthenticated(cwd, resolved, token, skipBuild, jsonMode = f
1010
909
  if (turboResult) {
1011
910
  return; // ⚡ done — server deployed from cache
1012
911
  }
1013
- // Build (skip for pure Workers with no build command)
1014
- if (!skipBuild && resolved.buildCommand) {
912
+ // Single source of truth for build plan collect → bundle. Both
913
+ // sandbox and authenticated paths call the same function; they
914
+ // diverge only in where the bundle gets POSTed.
915
+ if (!skipBuild && resolved.buildCommand)
1015
916
  section("Build");
1016
- if (nextjsMode === "opennext") {
1017
- try {
1018
- buildNextjs(cwd, monorepo.isMonorepo);
1019
- }
1020
- catch {
1021
- consola.error("Next.js build failed");
1022
- process.exit(1);
1023
- }
1024
- consola.success(" Build complete");
1025
- }
1026
- else {
1027
- const buildCmd = resolved.buildCommand;
1028
- if (buildCmd.length > 500) {
1029
- consola.error("Invalid build command (too long)");
1030
- process.exit(1);
1031
- }
1032
- consola.start(` ${buildCmd}`);
1033
- try {
1034
- execSync(buildCmd, { cwd, stdio: "inherit" });
1035
- }
1036
- catch {
1037
- consola.error("Build failed");
1038
- process.exit(1);
1039
- }
1040
- consola.success(" Build complete");
1041
- }
1042
- }
1043
- // Post-build detection: Astro + @astrojs/cloudflare produces a
1044
- // pre-bundled Worker (dist/server/entry.mjs) + split client assets
1045
- // (dist/client/). We can only detect this after build — before
1046
- // build `framework === "astro"` could mean SSG or CF-adapter-SSR.
1047
- const astroCF = framework === "astro" ? detectAstroCloudflareBuild(cwd) : null;
1048
- // Collect client assets
1049
- // Worker + SPA hybrid: if a Worker project has buildOutput with built files, collect them too
1050
- let clientAssets = {};
1051
- let fileList = [];
1052
- const workerHasClientAssets = isWorker && resolved.buildOutput && resolved.buildOutput !== "." &&
1053
- existsSync(resolve(cwd, resolved.buildOutput));
1054
- if (!isWorker || workerHasClientAssets) {
1055
- let clientAssetsDir;
1056
- // Adapter output takes precedence for Next.js
1057
- if (framework === "nextjs" && hasAdapterOutput(cwd)) {
1058
- clientAssetsDir = resolve(cwd, ".creek/adapter-output/assets");
1059
- }
1060
- else if (astroCF) {
1061
- // Astro CF adapter splits its output: client assets live in
1062
- // dist/client/ (not dist/), so redirect the collector there.
1063
- clientAssetsDir = resolve(cwd, astroCF.assetsDir);
1064
- }
1065
- else {
1066
- const outputDir = resolve(cwd, resolved.buildOutput);
1067
- if (!existsSync(outputDir)) {
1068
- consola.error(`Build output directory not found: ${resolved.buildOutput}`);
1069
- process.exit(1);
1070
- }
1071
- clientAssetsDir = outputDir;
1072
- if (isSSR && framework) {
1073
- const clientSubdir = getClientAssetsDir(framework);
1074
- if (clientSubdir) {
1075
- clientAssetsDir = resolve(outputDir, clientSubdir);
1076
- }
1077
- }
1078
- }
1079
- section("Upload");
1080
- ({ assets: clientAssets, fileList } = collectAssets(clientAssetsDir));
1081
- consola.info(` ${fileList.length} assets (${assetSummary(fileList)})`);
1082
- }
1083
- // Bundle server/worker files
1084
- let serverFiles;
1085
- if (astroCF) {
1086
- // Astro CF adapter: upload dist/server/ as worker modules
1087
- // (same shape Nuxt/SolidStart use — pre-bundled, do not re-bundle).
1088
- const serverDir = resolve(cwd, astroCF.serverDir);
1089
- if (existsSync(serverDir)) {
1090
- consola.start(" Collecting Astro CF server files...");
1091
- const collected = collectServerFiles(serverDir);
1092
- const fileCount = Object.keys(collected).length;
1093
- serverFiles = Object.fromEntries(Object.entries(collected).map(([p, buf]) => [p, buf.toString("base64")]));
1094
- consola.success(` Astro CF worker: ${fileCount} files`);
1095
- }
1096
- }
1097
- else if (isSSR && framework) {
1098
- if (framework === "nextjs" && hasAdapterOutput(cwd)) {
1099
- // Adapter path: read pre-bundled output from .creek/adapter-output/
1100
- const adapterServerDir = resolve(cwd, ".creek/adapter-output/server");
1101
- consola.start(" Collecting adapter output...");
1102
- const collected = {};
1103
- if (existsSync(adapterServerDir)) {
1104
- for (const f of readdirSync(adapterServerDir)) {
1105
- const fp = join(adapterServerDir, f);
1106
- if (!statSync(fp).isFile())
1107
- continue;
1108
- if (f.endsWith(".map"))
1109
- continue;
1110
- let content = readFileSync(fp);
1111
- // Patch bare Node.js module imports → node: prefix (workerd requires it)
1112
- // Note: nodejs_compat_v2 handles most modules, but bare specifiers
1113
- // (without node: prefix) still need patching.
1114
- if (f.endsWith(".js") || f.endsWith(".mjs")) {
1115
- content = Buffer.from(patchBareNodeImports(content.toString("utf-8")));
1116
- }
1117
- collected[f] = content;
1118
- }
1119
- }
1120
- const fileCount = Object.keys(collected).length;
1121
- serverFiles = Object.fromEntries(Object.entries(collected).map(([p, buf]) => [p, buf.toString("base64")]));
1122
- consola.success(` Worker bundled: ${fileCount} files (${Math.round(Object.values(collected).reduce((s, b) => s + b.length, 0) / 1024)}KB)`);
1123
- }
1124
- else if (framework === "nextjs") {
1125
- // Legacy path: use wrangler to produce a single bundled worker
1126
- const bundleDir = resolve(cwd, ".creek/bundled");
1127
- consola.start(" Bundling Next.js worker (legacy)...");
1128
- execSync(`npx wrangler deploy --dry-run --outdir "${bundleDir}"`, { cwd, stdio: "pipe" });
1129
- patchBundledWorker(bundleDir, resolve(cwd, ".open-next"));
1130
- const collected = {};
1131
- if (existsSync(bundleDir)) {
1132
- for (const f of readdirSync(bundleDir)) {
1133
- const fp = join(bundleDir, f);
1134
- if (!statSync(fp).isFile())
1135
- continue;
1136
- if (f.endsWith(".map") || f === "README.md")
1137
- continue;
1138
- collected[f] = readFileSync(fp);
1139
- }
1140
- }
1141
- const fileCount = Object.keys(collected).length;
1142
- serverFiles = Object.fromEntries(Object.entries(collected).map(([p, buf]) => [p, buf.toString("base64")]));
1143
- consola.success(` Worker bundled: ${fileCount} files (${Math.round(Object.values(collected).reduce((s, b) => s + b.length, 0) / 1024)}KB)`);
1144
- }
1145
- else if (isPreBundledFramework(framework)) {
1146
- // Other pre-bundled SSR frameworks (Nuxt, SvelteKit, etc.)
1147
- const serverDirRel = getSSRServerDir(framework);
1148
- if (serverDirRel) {
1149
- const serverDir = resolve(cwd, serverDirRel);
1150
- if (existsSync(serverDir)) {
1151
- consola.start(" Collecting SSR server files...");
1152
- const collected = collectServerFiles(serverDir);
1153
- const fileCount = Object.keys(collected).length;
1154
- serverFiles = Object.fromEntries(Object.entries(collected).map(([p, buf]) => [p, buf.toString("base64")]));
1155
- consola.success(` SSR server: ${fileCount} files`);
1156
- }
1157
- }
1158
- }
1159
- else {
1160
- // Non-pre-bundled SSR: esbuild single-file bundle (fallback)
1161
- const outputDir = resolve(cwd, resolved.buildOutput);
1162
- const serverEntry = getSSRServerEntry(framework);
1163
- if (serverEntry) {
1164
- const serverEntryPath = resolve(outputDir, serverEntry);
1165
- if (existsSync(serverEntryPath)) {
1166
- consola.start(" Bundling SSR server...");
1167
- const bundled = await bundleSSRServer(serverEntryPath);
1168
- serverFiles = {
1169
- "server.js": Buffer.from(bundled).toString("base64"),
1170
- };
1171
- consola.success(` SSR bundled (${Math.round(bundled.length / 1024)}KB)`);
1172
- }
1173
- }
1174
- }
1175
- }
1176
- else if (isWorker && resolved.workerEntry) {
1177
- // Worker: auto-generate _setEnv wrapper + esbuild bundle
1178
- const workerEntryPath = resolve(cwd, resolved.workerEntry);
1179
- if (existsSync(workerEntryPath)) {
1180
- section("Bundle");
1181
- consola.start(" Bundling worker...");
1182
- const bundled = await bundleWorker(workerEntryPath, cwd, {
1183
- hasClientAssets: !!workerHasClientAssets,
1184
- });
1185
- serverFiles = {
1186
- "worker.js": Buffer.from(bundled).toString("base64"),
1187
- };
1188
- consola.success(` Worker bundled (${Math.round(bundled.length / 1024)}KB)`);
1189
- }
1190
- else {
1191
- consola.error(`Worker entry not found: ${resolved.workerEntry}`);
1192
- process.exit(1);
1193
- }
1194
- }
917
+ const prepared = await prepareDeployBundle({ cwd, resolved, skipBuild });
918
+ const { plan, framework: detectedFramework, effectiveRenderMode, effectiveEntrypoint, fileList, assets: clientAssets, serverFiles, } = prepared;
919
+ void detectedFramework; // framework var above is the source of truth here
920
+ section("Upload");
921
+ consola.info(` ${fileList.length} assets (${assetSummary(fileList)})`);
1195
922
  section("Deploy");
1196
923
  consola.start(" Creating deployment...");
1197
924
  const { deployment } = await client.createDeployment(project.id);
1198
925
  consola.start(" Uploading bundle...");
1199
- // If the Astro CF adapter fired post-build, the project is actually
1200
- // SSR (not SPA): overwrite the pre-build-computed renderMode and
1201
- // point the entrypoint at the adapter-emitted entry.mjs.
1202
- const effectiveRenderMode = astroCF ? "ssr" : renderMode;
1203
- const effectiveHasWorker = astroCF ? true : (isSSR || isWorker);
1204
- const effectiveEntrypoint = astroCF ? "entry.mjs" : resolved.workerEntry;
926
+ const effectiveHasWorker = serverFiles !== undefined;
1205
927
  const bundle = {
1206
928
  manifest: {
1207
929
  assets: fileList,
@@ -0,0 +1,63 @@
1
+ /**
2
+ * prepareDeployBundle — single source of truth for "given a project,
3
+ * produce the bundle that gets shipped to either sandbox-api or
4
+ * control-plane". Both `deploySandbox` and `deployAuthenticated` call
5
+ * this; they only differ in (a) whether they auth/look-up a project
6
+ * and (b) which API receives the bundle.
7
+ *
8
+ * Historically these two paths each rolled their own copy of the
9
+ * detect → build → collect → bundle pipeline. They drifted: the
10
+ * sandbox path was missing the worker branch for ~2 weeks (cli@0.4.6
11
+ * regression) and again missed the workers+assets coexist pattern.
12
+ * That divergence is the architectural smell — this file kills it.
13
+ *
14
+ * The function:
15
+ * 1. Reads framework from package.json (or accepts pre-resolved one)
16
+ * 2. Runs the build script (skip with skipBuild: true)
17
+ * 3. Calls SDK's planDeploy() to decide spa/ssr/worker shape
18
+ * 4. Detects post-build framework adapters (Astro CF) and refines
19
+ * 5. Collects static assets per the plan
20
+ * 6. Bundles the worker per the plan (5 framework-aware strategies)
21
+ * 7. Filters worker file out of clientAssets when it lives inside
22
+ * 8. Returns the canonical bundle envelope
23
+ *
24
+ * IO is not abstracted — the function calls execSync, readFileSync,
25
+ * esbuild. Callers needing test isolation should use fixture dirs.
26
+ */
27
+ import { type DeployPlan, type Framework, type ResolvedConfig } from "@solcreek/sdk";
28
+ export interface PrepareDeployBundleInput {
29
+ /** Absolute project directory. */
30
+ cwd: string;
31
+ /** Pre-resolved config (creek.toml or wrangler.* parse). Required. */
32
+ resolved: ResolvedConfig;
33
+ /** When true, skip the build script. Caller is asserting dist/ is current. */
34
+ skipBuild: boolean;
35
+ }
36
+ export interface PreparedDeployBundle {
37
+ /** The plan that drove preparation — passed back so callers can
38
+ * inspect renderMode / worker strategy without re-deriving. */
39
+ plan: DeployPlan;
40
+ /** Framework detected (or pre-resolved). null = static / vanilla worker. */
41
+ framework: Framework | null;
42
+ /** Whether Astro `@astrojs/cloudflare` adapter fired post-build. */
43
+ astroAdapter: {
44
+ serverDir: string;
45
+ assetsDir: string;
46
+ } | null;
47
+ /**
48
+ * Render mode after post-build refinement. Equal to plan.renderMode
49
+ * unless an adapter (e.g. Astro CF) upgraded an SPA-classified
50
+ * project to true SSR.
51
+ */
52
+ effectiveRenderMode: "spa" | "ssr" | "worker";
53
+ /** Main module name the deploy API should treat as the worker entry. */
54
+ effectiveEntrypoint: string | null;
55
+ /** Asset list (paths relative to root, with leading `/`). */
56
+ fileList: string[];
57
+ /** Asset bytes, base64-encoded, keyed by path. */
58
+ assets: Record<string, string>;
59
+ /** Server / worker files, base64-encoded. Undefined for pure SPA. */
60
+ serverFiles?: Record<string, string>;
61
+ }
62
+ export declare function prepareDeployBundle(input: PrepareDeployBundleInput): Promise<PreparedDeployBundle>;
63
+ //# sourceMappingURL=prepare-bundle.d.ts.map
@@ -0,0 +1,266 @@
1
+ /**
2
+ * prepareDeployBundle — single source of truth for "given a project,
3
+ * produce the bundle that gets shipped to either sandbox-api or
4
+ * control-plane". Both `deploySandbox` and `deployAuthenticated` call
5
+ * this; they only differ in (a) whether they auth/look-up a project
6
+ * and (b) which API receives the bundle.
7
+ *
8
+ * Historically these two paths each rolled their own copy of the
9
+ * detect → build → collect → bundle pipeline. They drifted: the
10
+ * sandbox path was missing the worker branch for ~2 weeks (cli@0.4.6
11
+ * regression) and again missed the workers+assets coexist pattern.
12
+ * That divergence is the architectural smell — this file kills it.
13
+ *
14
+ * The function:
15
+ * 1. Reads framework from package.json (or accepts pre-resolved one)
16
+ * 2. Runs the build script (skip with skipBuild: true)
17
+ * 3. Calls SDK's planDeploy() to decide spa/ssr/worker shape
18
+ * 4. Detects post-build framework adapters (Astro CF) and refines
19
+ * 5. Collects static assets per the plan
20
+ * 6. Bundles the worker per the plan (5 framework-aware strategies)
21
+ * 7. Filters worker file out of clientAssets when it lives inside
22
+ * 8. Returns the canonical bundle envelope
23
+ *
24
+ * IO is not abstracted — the function calls execSync, readFileSync,
25
+ * esbuild. Callers needing test isolation should use fixture dirs.
26
+ */
27
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
28
+ import { join, resolve } from "node:path";
29
+ import { execSync } from "node:child_process";
30
+ import consola from "consola";
31
+ import { detectFramework, detectAstroCloudflareBuild, detectMonorepo, detectNextjsMode, getSSRServerDir, getSSRServerEntry, getClientAssetsDir, getDefaultBuildOutput, isSSRFramework, isPreBundledFramework, collectServerFiles, planDeploy, } from "@solcreek/sdk";
32
+ import { collectAssets } from "./bundle.js";
33
+ import { bundleSSRServer } from "./ssr-bundle.js";
34
+ import { bundleWorker } from "./worker-bundle.js";
35
+ import { hasAdapterOutput, buildNextjs, patchBundledWorker } from "./nextjs.js";
36
+ import { patchBareNodeImports } from "../commands/deploy.js";
37
+ export async function prepareDeployBundle(input) {
38
+ const { cwd, resolved, skipBuild } = input;
39
+ // 1. Framework detection. Trust the resolved config if it carries
40
+ // one (creek.toml can pin it); otherwise re-read package.json. We
41
+ // re-read here rather than rely solely on resolved so that a project
42
+ // without creek.toml still gets the auto-detected framework when
43
+ // running against a pre-existing wrangler.* config.
44
+ const pkgJsonPath = join(cwd, "package.json");
45
+ const pkg = existsSync(pkgJsonPath)
46
+ ? JSON.parse(readFileSync(pkgJsonPath, "utf-8"))
47
+ : null;
48
+ const framework = resolved.framework ?? (pkg ? detectFramework(pkg) : null);
49
+ const nextjsMode = framework === "nextjs" && pkg ? detectNextjsMode(pkg, cwd) : null;
50
+ const monorepo = framework === "nextjs" ? detectMonorepo(cwd) : { isMonorepo: false, root: null };
51
+ // 2. Build (when not skipped). Framework-specific build for Next.js
52
+ // adapter; otherwise the user's build script.
53
+ if (!skipBuild && resolved.buildCommand) {
54
+ if (nextjsMode === "opennext") {
55
+ try {
56
+ buildNextjs(cwd, monorepo.isMonorepo);
57
+ }
58
+ catch {
59
+ consola.error("Next.js build failed");
60
+ process.exit(1);
61
+ }
62
+ }
63
+ else {
64
+ const buildCmd = resolved.buildCommand;
65
+ if (buildCmd.length > 500) {
66
+ consola.error("Invalid build command (too long)");
67
+ process.exit(1);
68
+ }
69
+ consola.start(` ${buildCmd}`);
70
+ try {
71
+ execSync(buildCmd, { cwd, stdio: "inherit" });
72
+ }
73
+ catch {
74
+ consola.error("Build failed");
75
+ process.exit(1);
76
+ }
77
+ consola.success(" Build complete");
78
+ }
79
+ }
80
+ // 3. Compute output dir. Next.js adapter writes to .creek/adapter-output;
81
+ // everything else honours [build].output.
82
+ const useAdapterOutput = framework === "nextjs" && hasAdapterOutput(cwd);
83
+ const outputDir = useAdapterOutput
84
+ ? resolve(cwd, ".creek/adapter-output")
85
+ : resolve(cwd, resolved.buildOutput || getDefaultBuildOutput(framework));
86
+ // 4. Post-build framework adapter detection. Astro can be either SSG
87
+ // or CF-adapter-SSR; we only know which after build.
88
+ const astroAdapter = framework === "astro" ? detectAstroCloudflareBuild(cwd) : null;
89
+ // 5. Plan the deploy shape. planDeploy is a pure function — pass in
90
+ // detection results, get out a structured plan with explicit error
91
+ // cases. All branching that used to be inline in deploy.ts lives in
92
+ // deploy-plan.ts now (table-tested).
93
+ const planResult = planDeploy({
94
+ framework,
95
+ workerEntry: resolved.workerEntry ?? null,
96
+ workerEntryExists: !!resolved.workerEntry &&
97
+ existsSync(resolve(cwd, resolved.workerEntry)),
98
+ buildOutput: resolved.buildOutput || "dist",
99
+ buildOutputExists: existsSync(outputDir),
100
+ astroCF: astroAdapter,
101
+ });
102
+ if (!planResult.ok) {
103
+ consola.error(planResult.reason);
104
+ process.exit(1);
105
+ }
106
+ const plan = planResult.plan;
107
+ // 6. Collect client assets. The dir depends on framework (Next.js
108
+ // adapter, Astro CF split output) but the inclusion decision is the
109
+ // plan's call.
110
+ let clientAssets = {};
111
+ let fileList = [];
112
+ if (plan.assets.enabled && plan.assets.dir) {
113
+ let clientAssetsDir;
114
+ if (useAdapterOutput) {
115
+ clientAssetsDir = resolve(outputDir, "assets");
116
+ }
117
+ else if (astroAdapter) {
118
+ clientAssetsDir = resolve(cwd, astroAdapter.assetsDir);
119
+ }
120
+ else {
121
+ clientAssetsDir = resolve(cwd, plan.assets.dir);
122
+ // SSR frameworks split client assets into a sub-dir of buildOutput.
123
+ if (isSSRFramework(framework) && framework) {
124
+ const subdir = getClientAssetsDir(framework);
125
+ if (subdir)
126
+ clientAssetsDir = resolve(clientAssetsDir, subdir);
127
+ }
128
+ }
129
+ const collected = collectAssets(clientAssetsDir);
130
+ clientAssets = collected.assets;
131
+ fileList = collected.fileList;
132
+ }
133
+ // 7. Bundle server / worker. Five strategies, dispatched by either
134
+ // (a) the framework when it's pre-bundled SSR, or (b) the plan's
135
+ // worker.strategy for user-declared workers.
136
+ let serverFiles;
137
+ if (astroAdapter) {
138
+ // Astro CF adapter writes a pre-bundled worker we just upload.
139
+ const serverDir = resolve(cwd, astroAdapter.serverDir);
140
+ if (existsSync(serverDir)) {
141
+ consola.start(" Collecting Astro CF server files...");
142
+ const collected = collectServerFiles(serverDir);
143
+ serverFiles = base64ServerFiles(collected);
144
+ consola.success(` Astro CF worker: ${Object.keys(collected).length} files`);
145
+ }
146
+ }
147
+ else if (isSSRFramework(framework) && framework) {
148
+ if (framework === "nextjs" && hasAdapterOutput(cwd)) {
149
+ // Next.js adapter output → patch bare imports, upload as-is.
150
+ const adapterServerDir = resolve(cwd, ".creek/adapter-output/server");
151
+ consola.start(" Collecting adapter output...");
152
+ const collected = {};
153
+ if (existsSync(adapterServerDir)) {
154
+ for (const f of readdirSync(adapterServerDir)) {
155
+ const fp = join(adapterServerDir, f);
156
+ if (!statSync(fp).isFile() || f.endsWith(".map"))
157
+ continue;
158
+ let content = readFileSync(fp);
159
+ if (f.endsWith(".js") || f.endsWith(".mjs")) {
160
+ content = Buffer.from(patchBareNodeImports(content.toString("utf-8")));
161
+ }
162
+ collected[f] = content;
163
+ }
164
+ }
165
+ serverFiles = base64ServerFiles(collected);
166
+ consola.success(` Worker bundled: ${Object.keys(collected).length} files (${kb(collected)}KB)`);
167
+ }
168
+ else if (framework === "nextjs") {
169
+ // Legacy Next.js: wrangler dry-run produces the bundle.
170
+ const bundleDir = resolve(cwd, ".creek/bundled");
171
+ consola.start(" Bundling Next.js worker (legacy)...");
172
+ execSync(`npx wrangler deploy --dry-run --outdir "${bundleDir}"`, { cwd, stdio: "pipe" });
173
+ patchBundledWorker(bundleDir, resolve(cwd, ".open-next"));
174
+ const collected = {};
175
+ if (existsSync(bundleDir)) {
176
+ for (const f of readdirSync(bundleDir)) {
177
+ const fp = join(bundleDir, f);
178
+ if (!statSync(fp).isFile() || f.endsWith(".map") || f === "README.md")
179
+ continue;
180
+ collected[f] = readFileSync(fp);
181
+ }
182
+ }
183
+ serverFiles = base64ServerFiles(collected);
184
+ consola.success(` Worker bundled: ${Object.keys(collected).length} files (${kb(collected)}KB)`);
185
+ }
186
+ else if (isPreBundledFramework(framework)) {
187
+ // Nuxt / SvelteKit / SolidStart — the framework already produced
188
+ // a bundled server; we just collect.
189
+ const serverDirRel = getSSRServerDir(framework);
190
+ if (serverDirRel) {
191
+ const serverDir = resolve(cwd, serverDirRel);
192
+ if (existsSync(serverDir)) {
193
+ consola.start(" Collecting SSR server files...");
194
+ const collected = collectServerFiles(serverDir);
195
+ serverFiles = base64ServerFiles(collected);
196
+ consola.success(` SSR server: ${Object.keys(collected).length} files`);
197
+ }
198
+ }
199
+ }
200
+ else {
201
+ // Fallback for SSR frameworks that emit a single entry file.
202
+ const serverEntry = getSSRServerEntry(framework);
203
+ if (serverEntry) {
204
+ const serverEntryPath = resolve(outputDir, serverEntry);
205
+ if (existsSync(serverEntryPath)) {
206
+ consola.start(" Bundling SSR server...");
207
+ const bundled = await bundleSSRServer(serverEntryPath);
208
+ serverFiles = { "server.js": Buffer.from(bundled).toString("base64") };
209
+ consola.success(` SSR bundled (${Math.round(bundled.length / 1024)}KB)`);
210
+ }
211
+ }
212
+ }
213
+ }
214
+ else if (plan.worker.strategy === "esbuild-bundle" && plan.worker.entry) {
215
+ const workerEntryPath = resolve(cwd, plan.worker.entry);
216
+ consola.start(" Bundling worker...");
217
+ const bundled = await bundleWorker(workerEntryPath, cwd, {
218
+ hasClientAssets: plan.assets.enabled,
219
+ });
220
+ serverFiles = { "worker.js": Buffer.from(bundled).toString("base64") };
221
+ consola.success(` Worker bundled (${Math.round(bundled.length / 1024)}KB)`);
222
+ }
223
+ else if (plan.worker.strategy === "upload-asis" && plan.worker.entry) {
224
+ // Pre-bundled worker (e.g. dist/_worker.mjs from the user's own
225
+ // esbuild step). Ship verbatim — no Creek runtime wrapper, no
226
+ // re-bundle. This is the path that keeps deployed bundles free of
227
+ // any @solcreek/* dependency.
228
+ const bytes = readFileSync(resolve(cwd, plan.worker.entry));
229
+ serverFiles = { "worker.js": bytes.toString("base64") };
230
+ consola.success(` Worker (pre-bundled, ${Math.round(bytes.length / 1024)}KB)`);
231
+ }
232
+ // 8. When the worker bundle lives INSIDE the asset dir
233
+ // (dist/_worker.mjs), drop it from clientAssets so it isn't also
234
+ // served as a publicly accessible static file.
235
+ if (plan.assets.excludeFile) {
236
+ delete clientAssets[plan.assets.excludeFile];
237
+ delete clientAssets["/" + plan.assets.excludeFile];
238
+ fileList = fileList.filter((p) => p !== plan.assets.excludeFile && p !== "/" + plan.assets.excludeFile);
239
+ }
240
+ // 9. Resolve effective render mode + entrypoint. Astro CF post-build
241
+ // detection upgrades a pre-build "spa" classification to true SSR.
242
+ const effectiveRenderMode = astroAdapter
243
+ ? "ssr"
244
+ : plan.renderMode;
245
+ const effectiveEntrypoint = astroAdapter
246
+ ? "entry.mjs"
247
+ : (plan.worker.entry ?? null);
248
+ return {
249
+ plan,
250
+ framework,
251
+ astroAdapter,
252
+ effectiveRenderMode,
253
+ effectiveEntrypoint,
254
+ fileList,
255
+ assets: clientAssets,
256
+ serverFiles,
257
+ };
258
+ }
259
+ // --- helpers ---
260
+ function base64ServerFiles(collected) {
261
+ return Object.fromEntries(Object.entries(collected).map(([p, buf]) => [p, buf.toString("base64")]));
262
+ }
263
+ function kb(collected) {
264
+ return Math.round(Object.values(collected).reduce((s, b) => s + b.length, 0) / 1024);
265
+ }
266
+ //# sourceMappingURL=prepare-bundle.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solcreek/cli",
3
- "version": "0.4.8",
3
+ "version": "0.4.10",
4
4
  "description": "CLI for the Creek deployment platform",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -33,7 +33,7 @@
33
33
  "esbuild": "^0.25.0",
34
34
  "smol-toml": "^1.3.1",
35
35
  "ws": "^8.20.0",
36
- "@solcreek/sdk": "0.4.3"
36
+ "@solcreek/sdk": "0.4.4"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@testing-library/dom": "^10.4.1",