gencow 0.1.137 → 0.1.138
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/bin/gencow.mjs +27 -10
- package/lib/template-marketplace-command.mjs +228 -0
- package/lib/template-marketplace-detection.mjs +162 -0
- package/package.json +1 -1
package/bin/gencow.mjs
CHANGED
|
@@ -12,19 +12,12 @@
|
|
|
12
12
|
* gencow db:studio — open Drizzle Studio (visual DB browser)
|
|
13
13
|
* gencow dashboard — open /_admin in browser
|
|
14
14
|
* gencow backup — manage database backups (list/create/restore/delete/download)
|
|
15
|
+
* gencow templates — publish/list/download/clone marketplace templates
|
|
15
16
|
* gencow help — show this help
|
|
16
17
|
*/
|
|
17
18
|
|
|
18
19
|
import { execSync, spawn } from "child_process";
|
|
19
|
-
import {
|
|
20
|
-
existsSync,
|
|
21
|
-
readFileSync,
|
|
22
|
-
writeFileSync,
|
|
23
|
-
unlinkSync,
|
|
24
|
-
cpSync,
|
|
25
|
-
symlinkSync,
|
|
26
|
-
copyFileSync,
|
|
27
|
-
} from "fs";
|
|
20
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, cpSync, symlinkSync, copyFileSync } from "fs";
|
|
28
21
|
import { resolve, dirname } from "path";
|
|
29
22
|
import { fileURLToPath } from "url";
|
|
30
23
|
import { createAddCommand } from "../lib/add-command.mjs";
|
|
@@ -62,6 +55,7 @@ import { createJobsCommand } from "../lib/jobs-command.mjs";
|
|
|
62
55
|
import { createLogsCommand } from "../lib/logs-command.mjs";
|
|
63
56
|
import { createStaticDeployRuntime } from "../lib/static-deploy-command.mjs";
|
|
64
57
|
import { createStaticCommand } from "../lib/static-command.mjs";
|
|
58
|
+
import { createTemplateMarketplaceCommand } from "../lib/template-marketplace-command.mjs";
|
|
65
59
|
import {
|
|
66
60
|
getAppUrl,
|
|
67
61
|
getBaseDomain,
|
|
@@ -70,7 +64,20 @@ import {
|
|
|
70
64
|
parseDurationToMinutes,
|
|
71
65
|
validatePlatformUrl,
|
|
72
66
|
} from "../lib/domain-helpers.mjs";
|
|
73
|
-
import {
|
|
67
|
+
import {
|
|
68
|
+
BOLD,
|
|
69
|
+
CYAN,
|
|
70
|
+
DIM,
|
|
71
|
+
GREEN,
|
|
72
|
+
RED,
|
|
73
|
+
RESET,
|
|
74
|
+
YELLOW,
|
|
75
|
+
error,
|
|
76
|
+
info,
|
|
77
|
+
log,
|
|
78
|
+
success,
|
|
79
|
+
warn,
|
|
80
|
+
} from "../lib/output.mjs";
|
|
74
81
|
import { platformFetch, requireCreds, rpcMutation, rpcQuery } from "../lib/platform-client.mjs";
|
|
75
82
|
|
|
76
83
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -229,6 +236,7 @@ const runStaticCommand = createStaticCommand({
|
|
|
229
236
|
requireCredsImpl: requireCreds,
|
|
230
237
|
runStaticDeployRuntimeImpl: runStaticDeployRuntime,
|
|
231
238
|
});
|
|
239
|
+
const runTemplatesCommand = createTemplateMarketplaceCommand();
|
|
232
240
|
|
|
233
241
|
// ─── Commands ─────────────────────────────────────────────
|
|
234
242
|
|
|
@@ -299,6 +307,10 @@ ${BOLD}Commands (login required):${RESET}
|
|
|
299
307
|
${GREEN}static [dir]${RESET} Deploy static files ${DIM}(dist/, out/, build/)${RESET}
|
|
300
308
|
${DIM}--prod Deploy to production app${RESET}
|
|
301
309
|
${DIM}--force, -f Skip dependency audit${RESET}
|
|
310
|
+
${GREEN}templates publish${RESET} Publish current project as a marketplace template
|
|
311
|
+
${DIM}--title, --slug, --price, --private, --unlisted${RESET}
|
|
312
|
+
${GREEN}templates list${RESET} Browse public templates
|
|
313
|
+
${GREEN}templates clone <slug>${RESET} Clone a template into a local directory
|
|
302
314
|
${GREEN}deploy${RESET} Deploy backend to cloud ${DIM}(dev by default)${RESET}
|
|
303
315
|
${DIM}--prod Deploy to production (Pro+ only)${RESET}
|
|
304
316
|
${DIM}--rollback Rollback to previous deployment${RESET}
|
|
@@ -355,6 +367,10 @@ ${BOLD}Examples:${RESET}
|
|
|
355
367
|
|
|
356
368
|
${DIM}# Inspect stale workflows:${RESET}
|
|
357
369
|
gencow jobs workflow cleanup --status terminal --older-than 7d
|
|
370
|
+
|
|
371
|
+
${DIM}# Publish and clone templates:${RESET}
|
|
372
|
+
gencow templates publish --title "CRM Starter" --price 29
|
|
373
|
+
gencow templates clone crm-starter
|
|
358
374
|
`);
|
|
359
375
|
},
|
|
360
376
|
|
|
@@ -379,6 +395,7 @@ ${BOLD}Examples:${RESET}
|
|
|
379
395
|
// ── backup / domain ──────────────────────────────────
|
|
380
396
|
backup: runBackupCommand,
|
|
381
397
|
domain: runDomainCommand,
|
|
398
|
+
templates: runTemplatesCommand,
|
|
382
399
|
|
|
383
400
|
async mcp() {
|
|
384
401
|
info(`MCP server is ${BOLD}coming soon${RESET}.`);
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { execSync } from "child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
3
|
+
import { basename, resolve } from "path";
|
|
4
|
+
import { BOLD, CYAN, DIM, GREEN, RESET, error, info, log, success } from "./output.mjs";
|
|
5
|
+
import { platformFetch, requireCreds } from "./platform-client.mjs";
|
|
6
|
+
import {
|
|
7
|
+
buildTemplateTarExcludes,
|
|
8
|
+
detectStaticTemplateProject,
|
|
9
|
+
validateStaticOutput,
|
|
10
|
+
} from "./template-marketplace-detection.mjs";
|
|
11
|
+
|
|
12
|
+
export function parseTemplateArgs(args) {
|
|
13
|
+
const [subcommand = "list", ...rest] = args;
|
|
14
|
+
const options = { _: [], price: "0", currency: "USD", visibility: "public" };
|
|
15
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
16
|
+
const arg = rest[index];
|
|
17
|
+
if (arg === "--title") options.title = rest[++index];
|
|
18
|
+
else if (arg === "--summary") options.summary = rest[++index];
|
|
19
|
+
else if (arg === "--slug") options.slug = rest[++index];
|
|
20
|
+
else if (arg === "--price") options.price = rest[++index];
|
|
21
|
+
else if (arg === "--currency") options.currency = rest[++index];
|
|
22
|
+
else if (arg === "--visibility") options.visibility = rest[++index];
|
|
23
|
+
else if (arg === "--private") options.visibility = "private";
|
|
24
|
+
else if (arg === "--unlisted") options.visibility = "unlisted";
|
|
25
|
+
else if (arg === "--build") options.build = rest[++index];
|
|
26
|
+
else if (arg === "--out") options.out = rest[++index];
|
|
27
|
+
else if (arg === "--frontend-root") options.frontendRoot = rest[++index];
|
|
28
|
+
else if (arg === "--backend-root") options.backendRoot = rest[++index];
|
|
29
|
+
else if (arg === "--version") options.version = rest[++index];
|
|
30
|
+
else if (arg === "--out-file") options.outFile = rest[++index];
|
|
31
|
+
else options._.push(arg);
|
|
32
|
+
}
|
|
33
|
+
return { subcommand, options };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function centsFromPrice(value) {
|
|
37
|
+
const numeric = Number(value || 0);
|
|
38
|
+
if (!Number.isFinite(numeric) || numeric < 0) throw new Error("Invalid --price");
|
|
39
|
+
return Math.round(numeric * 100);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function shellQuote(value) {
|
|
43
|
+
return `'${String(value).replace(/'/g, "'\\''")}'`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function createSourceBundle({ cwd, staticOutputDir, execSyncImpl = execSync, mkdirSyncImpl = mkdirSync }) {
|
|
47
|
+
const out = resolve(cwd, ".gencow", "template-source.tar.gz");
|
|
48
|
+
mkdirSyncImpl(resolve(cwd, ".gencow"), { recursive: true });
|
|
49
|
+
const excludes = buildTemplateTarExcludes(staticOutputDir)
|
|
50
|
+
.map((item) => `--exclude=${shellQuote(item)}`)
|
|
51
|
+
.join(" ");
|
|
52
|
+
execSyncImpl(`COPYFILE_DISABLE=1 tar ${excludes} -czf ${shellQuote(out)} .`, { cwd });
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function createStaticBundle({ cwd, outputDir, execSyncImpl = execSync, mkdirSyncImpl = mkdirSync }) {
|
|
57
|
+
const out = resolve(cwd, ".gencow", "template-static.tar.gz");
|
|
58
|
+
mkdirSyncImpl(resolve(cwd, ".gencow"), { recursive: true });
|
|
59
|
+
execSyncImpl(`COPYFILE_DISABLE=1 tar -czf ${shellQuote(out)} -C ${shellQuote(resolve(cwd, outputDir))} .`, {
|
|
60
|
+
cwd,
|
|
61
|
+
});
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function rpc(creds, name, args, platformFetchImpl) {
|
|
66
|
+
const res = await platformFetchImpl(creds, "/api/query", {
|
|
67
|
+
method: "POST",
|
|
68
|
+
headers: { "Content-Type": "application/json" },
|
|
69
|
+
body: JSON.stringify({ name, args }),
|
|
70
|
+
});
|
|
71
|
+
const data = await res.json().catch(() => ({}));
|
|
72
|
+
if (!res.ok) throw new Error(data.error || res.statusText);
|
|
73
|
+
return data.result ?? data;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function createTemplateMarketplaceCommand({
|
|
77
|
+
cwdImpl = () => process.cwd(),
|
|
78
|
+
errorImpl = error,
|
|
79
|
+
execSyncImpl = execSync,
|
|
80
|
+
existsSyncImpl = existsSync,
|
|
81
|
+
exitImpl = (code) => process.exit(code),
|
|
82
|
+
infoImpl = info,
|
|
83
|
+
logImpl = log,
|
|
84
|
+
platformFetchImpl = platformFetch,
|
|
85
|
+
readFileSyncImpl = readFileSync,
|
|
86
|
+
requireCredsImpl = requireCreds,
|
|
87
|
+
resolvePathImpl = resolve,
|
|
88
|
+
rmSyncImpl = rmSync,
|
|
89
|
+
successImpl = success,
|
|
90
|
+
writeFileSyncImpl = writeFileSync,
|
|
91
|
+
} = {}) {
|
|
92
|
+
async function publish(options) {
|
|
93
|
+
const cwd = cwdImpl();
|
|
94
|
+
const detection = detectStaticTemplateProject({
|
|
95
|
+
cwd,
|
|
96
|
+
explicit: {
|
|
97
|
+
frontendRoot: options.frontendRoot,
|
|
98
|
+
backendRoot: options.backendRoot,
|
|
99
|
+
buildCommand: options.build,
|
|
100
|
+
outputDir: options.out,
|
|
101
|
+
},
|
|
102
|
+
existsSyncImpl,
|
|
103
|
+
readFileSyncImpl,
|
|
104
|
+
resolvePathImpl,
|
|
105
|
+
});
|
|
106
|
+
if (!detection.ok) throw new Error(detection.error);
|
|
107
|
+
|
|
108
|
+
logImpl(`\n${BOLD}${CYAN}Gencow Template Publish${RESET}\n`);
|
|
109
|
+
infoImpl(`Framework: ${detection.frontendFramework}`);
|
|
110
|
+
infoImpl(`Kind: ${detection.projectKind}`);
|
|
111
|
+
|
|
112
|
+
if (detection.buildCommand) {
|
|
113
|
+
infoImpl(`Build: ${detection.buildCommand}`);
|
|
114
|
+
execSyncImpl(detection.buildCommand, {
|
|
115
|
+
cwd: resolvePathImpl(cwd, detection.buildWorkdir),
|
|
116
|
+
stdio: "inherit",
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
const outputValidation = validateStaticOutput({
|
|
120
|
+
cwd: resolvePathImpl(cwd, detection.buildWorkdir),
|
|
121
|
+
outputDir: detection.staticOutputDir,
|
|
122
|
+
existsSyncImpl,
|
|
123
|
+
resolvePathImpl,
|
|
124
|
+
});
|
|
125
|
+
if (!outputValidation.ok) throw new Error(outputValidation.error);
|
|
126
|
+
|
|
127
|
+
const sourceBundle = createSourceBundle({
|
|
128
|
+
cwd,
|
|
129
|
+
staticOutputDir: detection.staticOutputDir,
|
|
130
|
+
execSyncImpl,
|
|
131
|
+
});
|
|
132
|
+
const staticBundle = createStaticBundle({
|
|
133
|
+
cwd: resolvePathImpl(cwd, detection.buildWorkdir),
|
|
134
|
+
outputDir: detection.staticOutputDir,
|
|
135
|
+
execSyncImpl,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const creds = requireCredsImpl();
|
|
139
|
+
const priceCents = centsFromPrice(options.price);
|
|
140
|
+
const form = new FormData();
|
|
141
|
+
form.append("title", options.title || basename(cwd));
|
|
142
|
+
form.append("summary", options.summary || "");
|
|
143
|
+
form.append("slug", options.slug || "");
|
|
144
|
+
form.append("version", options.version || "1.0.0");
|
|
145
|
+
form.append("priceType", priceCents > 0 ? "paid" : "free");
|
|
146
|
+
form.append("priceCents", String(priceCents));
|
|
147
|
+
form.append("currency", options.currency || "USD");
|
|
148
|
+
form.append("visibilityIntent", options.visibility || "public");
|
|
149
|
+
form.append("projectKind", detection.projectKind);
|
|
150
|
+
form.append("frontendFramework", detection.frontendFramework);
|
|
151
|
+
form.append("packageManager", detection.packageManager);
|
|
152
|
+
form.append("frontendRoot", detection.frontendRoot);
|
|
153
|
+
form.append("backendRoot", detection.backendRoot);
|
|
154
|
+
form.append("buildWorkdir", detection.buildWorkdir);
|
|
155
|
+
form.append("buildCommand", detection.buildCommand);
|
|
156
|
+
form.append("staticOutputDir", detection.staticOutputDir);
|
|
157
|
+
form.append("bundle", new Blob([readFileSyncImpl(sourceBundle)]), "template-source.tar.gz");
|
|
158
|
+
form.append("staticBundle", new Blob([readFileSyncImpl(staticBundle)]), "template-static.tar.gz");
|
|
159
|
+
|
|
160
|
+
const res = await platformFetchImpl(creds, "/platform/templates/publish", { method: "POST", body: form });
|
|
161
|
+
const body = await res.json().catch(() => ({}));
|
|
162
|
+
rmSyncImpl(sourceBundle, { force: true });
|
|
163
|
+
rmSyncImpl(staticBundle, { force: true });
|
|
164
|
+
if (!res.ok) throw new Error(body.error || res.statusText);
|
|
165
|
+
successImpl(`Template submitted: ${body.slug}`);
|
|
166
|
+
infoImpl(`Status: draft review`);
|
|
167
|
+
if (body.audit?.errors?.length) infoImpl(`${DIM}Audit errors: ${body.audit.errors.join(", ")}${RESET}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function list() {
|
|
171
|
+
const rows = await rpc(requireCredsImpl(), "templates.listPublic", {}, platformFetchImpl);
|
|
172
|
+
for (const row of rows)
|
|
173
|
+
logImpl(
|
|
174
|
+
`${GREEN}${row.slug}${RESET} ${row.priceType === "paid" ? `$${(row.priceCents / 100).toFixed(2)}` : "free"} ${row.title}`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function infoCommand(slug) {
|
|
179
|
+
const data = await rpc(requireCredsImpl(), "templates.getPublic", { slug }, platformFetchImpl);
|
|
180
|
+
if (!data) throw new Error("Template not found");
|
|
181
|
+
logImpl(`${BOLD}${data.listing.title}${RESET}`);
|
|
182
|
+
logImpl(`${DIM}${data.listing.summary || ""}${RESET}`);
|
|
183
|
+
logImpl(
|
|
184
|
+
`Price: ${data.listing.priceType === "paid" ? `$${(data.listing.priceCents / 100).toFixed(2)}` : "free"}`,
|
|
185
|
+
);
|
|
186
|
+
if (data.release?.previewFrontendUrl) logImpl(`Preview: ${data.release.previewFrontendUrl}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function download(slug, options, cloneMode = false) {
|
|
190
|
+
const creds = requireCredsImpl();
|
|
191
|
+
const versionQuery = options.version
|
|
192
|
+
? `?version=${encodeURIComponent(options.version)}${cloneMode ? "&clone=1" : ""}`
|
|
193
|
+
: cloneMode
|
|
194
|
+
? "?clone=1"
|
|
195
|
+
: "";
|
|
196
|
+
const res = await platformFetchImpl(creds, `/platform/templates/${slug}/download${versionQuery}`);
|
|
197
|
+
if (!res.ok) {
|
|
198
|
+
const body = await res.json().catch(() => ({}));
|
|
199
|
+
throw new Error(body.error || res.statusText);
|
|
200
|
+
}
|
|
201
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
202
|
+
const out = options.outFile || `${slug}.tar.gz`;
|
|
203
|
+
writeFileSyncImpl(resolvePathImpl(cwdImpl(), out), buffer);
|
|
204
|
+
successImpl(`Downloaded: ${out}`);
|
|
205
|
+
return resolvePathImpl(cwdImpl(), out);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return async function runTemplatesCommand(...args) {
|
|
209
|
+
const { subcommand, options } = parseTemplateArgs(args);
|
|
210
|
+
if (subcommand === "publish") return publish(options);
|
|
211
|
+
if (subcommand === "list") return list();
|
|
212
|
+
if (subcommand === "info") return infoCommand(options._[0]);
|
|
213
|
+
if (subcommand === "download") return download(options._[0], options);
|
|
214
|
+
if (subcommand === "clone") {
|
|
215
|
+
const slug = options._[0];
|
|
216
|
+
const dir = options._[1] || slug;
|
|
217
|
+
const archive = await download(slug, { ...options, outFile: ".gencow-template-clone.tar.gz" }, true);
|
|
218
|
+
execSyncImpl(`mkdir -p ${shellQuote(dir)} && tar -xzf ${shellQuote(archive)} -C ${shellQuote(dir)}`, {
|
|
219
|
+
cwd: cwdImpl(),
|
|
220
|
+
});
|
|
221
|
+
rmSyncImpl(archive, { force: true });
|
|
222
|
+
successImpl(`Cloned into: ${dir}`);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
errorImpl("Usage: gencow templates <list|info|publish|download|clone>");
|
|
226
|
+
exitImpl(1);
|
|
227
|
+
};
|
|
228
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "fs";
|
|
2
|
+
import { isAbsolute, resolve } from "path";
|
|
3
|
+
|
|
4
|
+
const CONFIG_FILES = {
|
|
5
|
+
vite: ["vite.config.ts", "vite.config.js", "vite.config.mjs"],
|
|
6
|
+
astro: ["astro.config.ts", "astro.config.mjs", "astro.config.js"],
|
|
7
|
+
svelte: ["svelte.config.ts", "svelte.config.js"],
|
|
8
|
+
next: ["next.config.ts", "next.config.js", "next.config.mjs"],
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function detectPackageManager({
|
|
12
|
+
cwd = process.cwd(),
|
|
13
|
+
existsSyncImpl = existsSync,
|
|
14
|
+
resolvePathImpl = resolve,
|
|
15
|
+
} = {}) {
|
|
16
|
+
if (existsSyncImpl(resolvePathImpl(cwd, "pnpm-lock.yaml"))) return "pnpm";
|
|
17
|
+
if (existsSyncImpl(resolvePathImpl(cwd, "bun.lockb"))) return "bun";
|
|
18
|
+
if (existsSyncImpl(resolvePathImpl(cwd, "yarn.lock"))) return "yarn";
|
|
19
|
+
if (existsSyncImpl(resolvePathImpl(cwd, "package-lock.json"))) return "npm";
|
|
20
|
+
return "unknown";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readPackageJson({ cwd, readFileSyncImpl = readFileSync, resolvePathImpl = resolve }) {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(readFileSyncImpl(resolvePathImpl(cwd, "package.json"), "utf8"));
|
|
26
|
+
} catch {
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function hasAnyConfig(cwd, files, { existsSyncImpl = existsSync, resolvePathImpl = resolve }) {
|
|
32
|
+
return files.some((file) => existsSyncImpl(resolvePathImpl(cwd, file)));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isSafeProjectPath(value, { allowDot = true } = {}) {
|
|
36
|
+
const normalized = String(value || "")
|
|
37
|
+
.replace(/\\/g, "/")
|
|
38
|
+
.replace(/\/+$/, "");
|
|
39
|
+
if (!normalized) return false;
|
|
40
|
+
if (normalized === ".") return allowDot;
|
|
41
|
+
if (isAbsolute(normalized) || normalized.startsWith("~")) return false;
|
|
42
|
+
return !normalized.split("/").some((part) => part === "" || part === "." || part === "..");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function detectStaticTemplateProject({
|
|
46
|
+
cwd = process.cwd(),
|
|
47
|
+
explicit = {},
|
|
48
|
+
existsSyncImpl = existsSync,
|
|
49
|
+
readFileSyncImpl = readFileSync,
|
|
50
|
+
resolvePathImpl = resolve,
|
|
51
|
+
} = {}) {
|
|
52
|
+
const pkg = readPackageJson({ cwd, readFileSyncImpl, resolvePathImpl });
|
|
53
|
+
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
54
|
+
const metadata = pkg.gencowTemplate || {};
|
|
55
|
+
|
|
56
|
+
const frontendRoot = explicit.frontendRoot || metadata.frontend?.root || ".";
|
|
57
|
+
const backendRoot = explicit.backendRoot || metadata.backendRoot || "gencow";
|
|
58
|
+
if (!isSafeProjectPath(frontendRoot) || !isSafeProjectPath(backendRoot)) {
|
|
59
|
+
return { ok: false, error: "Template frontend/backend roots must be safe relative paths" };
|
|
60
|
+
}
|
|
61
|
+
const frontendCwd = resolvePathImpl(cwd, frontendRoot);
|
|
62
|
+
const frontendPkg =
|
|
63
|
+
frontendRoot === "." ? pkg : readPackageJson({ cwd: frontendCwd, readFileSyncImpl, resolvePathImpl });
|
|
64
|
+
const frontendDeps = { ...(frontendPkg.dependencies || {}), ...(frontendPkg.devDependencies || {}) };
|
|
65
|
+
const allDeps = { ...deps, ...frontendDeps };
|
|
66
|
+
|
|
67
|
+
if (hasAnyConfig(frontendCwd, CONFIG_FILES.next, { existsSyncImpl, resolvePathImpl }) || allDeps.next) {
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
error: "MVP는 Vite/Astro/Svelte static frontend만 지원하며 Next.js/SSR frontend는 지원하지 않는다",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const candidates = [];
|
|
75
|
+
if (hasAnyConfig(frontendCwd, CONFIG_FILES.vite, { existsSyncImpl, resolvePathImpl }) || allDeps.vite) {
|
|
76
|
+
candidates.push("vite");
|
|
77
|
+
}
|
|
78
|
+
if (hasAnyConfig(frontendCwd, CONFIG_FILES.astro, { existsSyncImpl, resolvePathImpl }) || allDeps.astro) {
|
|
79
|
+
candidates.push("astro");
|
|
80
|
+
}
|
|
81
|
+
if (
|
|
82
|
+
hasAnyConfig(frontendCwd, CONFIG_FILES.svelte, { existsSyncImpl, resolvePathImpl }) &&
|
|
83
|
+
allDeps["@sveltejs/adapter-static"]
|
|
84
|
+
) {
|
|
85
|
+
candidates.push("sveltekit_static");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!explicit.frontendRoot && candidates.length > 1) {
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
error: "Multiple static frontend candidates found. Pass --frontend-root and --out explicitly.",
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const packageManager = detectPackageManager({ cwd, existsSyncImpl, resolvePathImpl });
|
|
96
|
+
const framework = explicit.framework || metadata.frontend?.framework || candidates[0] || "unknown";
|
|
97
|
+
const buildCommand =
|
|
98
|
+
explicit.buildCommand ||
|
|
99
|
+
metadata.frontend?.buildCommand ||
|
|
100
|
+
(frontendPkg.scripts?.build ? `${packageManager === "unknown" ? "npm" : packageManager} build` : "");
|
|
101
|
+
const outputDir =
|
|
102
|
+
explicit.outputDir || metadata.frontend?.outputDir || (framework === "astro" ? "dist" : "dist");
|
|
103
|
+
if (!isSafeProjectPath(outputDir, { allowDot: false })) {
|
|
104
|
+
return { ok: false, error: "Template static output directory must be a safe relative path" };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const hasBackend =
|
|
108
|
+
existsSyncImpl(resolvePathImpl(cwd, backendRoot, "index.ts")) ||
|
|
109
|
+
existsSyncImpl(resolvePathImpl(cwd, backendRoot, "schema.ts")) ||
|
|
110
|
+
existsSyncImpl(resolvePathImpl(cwd, "gencow.config.ts"));
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
ok: true,
|
|
114
|
+
projectKind: hasBackend ? "full_stack" : "static_only",
|
|
115
|
+
frontendFramework: framework,
|
|
116
|
+
packageManager,
|
|
117
|
+
frontendRoot,
|
|
118
|
+
backendRoot,
|
|
119
|
+
buildWorkdir: frontendRoot,
|
|
120
|
+
buildCommand,
|
|
121
|
+
staticOutputDir: outputDir,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function validateStaticOutput({
|
|
126
|
+
cwd = process.cwd(),
|
|
127
|
+
outputDir,
|
|
128
|
+
existsSyncImpl = existsSync,
|
|
129
|
+
readdirSyncImpl = readdirSync,
|
|
130
|
+
statSyncImpl = statSync,
|
|
131
|
+
resolvePathImpl = resolve,
|
|
132
|
+
}) {
|
|
133
|
+
if (!isSafeProjectPath(outputDir, { allowDot: false })) {
|
|
134
|
+
return { ok: false, error: "Static output directory must be a safe relative path" };
|
|
135
|
+
}
|
|
136
|
+
const absolute = resolvePathImpl(cwd, outputDir);
|
|
137
|
+
if (!existsSyncImpl(resolvePathImpl(absolute, "index.html"))) {
|
|
138
|
+
return { ok: false, error: `Static output missing index.html: ${outputDir}` };
|
|
139
|
+
}
|
|
140
|
+
const entries = readdirSyncImpl(absolute);
|
|
141
|
+
const hasAsset = entries.some((entry) => {
|
|
142
|
+
const entryPath = resolvePathImpl(absolute, entry);
|
|
143
|
+
return entry !== "index.html" && statSyncImpl(entryPath).size >= 0;
|
|
144
|
+
});
|
|
145
|
+
return hasAsset ? { ok: true } : { ok: false, error: `Static output has no assets: ${outputDir}` };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function buildTemplateTarExcludes(staticOutputDir) {
|
|
149
|
+
const excludes = [
|
|
150
|
+
".git",
|
|
151
|
+
".gencow",
|
|
152
|
+
".env",
|
|
153
|
+
".env.*",
|
|
154
|
+
"node_modules",
|
|
155
|
+
"coverage",
|
|
156
|
+
".next",
|
|
157
|
+
".svelte-kit",
|
|
158
|
+
"gencow.json",
|
|
159
|
+
];
|
|
160
|
+
if (staticOutputDir) excludes.push(staticOutputDir.replace(/\/$/, ""));
|
|
161
|
+
return excludes;
|
|
162
|
+
}
|