@getstrata/starter 0.1.13 → 1.0.1
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/README.md +27 -9
- package/dist/cli.js +427 -168
- package/dist/templates/overlays/api/docs/API.md +45 -5
- package/dist/templates/overlays/spa-react/frontend/src/App.tsx +4 -25
- package/dist/templates/overlays/spa-react/frontend/src/pages/HomePage.tsx +72 -0
- package/dist/templates/overlays/spa-react/frontend/src/pages/LoginPage.tsx +6 -3
- package/dist/templates/overlays/spa-react/frontend/src/pages/NotFoundPage.tsx +1 -1
- package/dist/templates/package.json +3 -3
- package/dist/templates/src/bootstrap/config.ts +11 -0
- package/dist/templates/src/bootstrap/createApp.ts +2 -1
- package/dist/templates/src/bootstrap/server.ts +1 -1
- package/dist/templates/src/lib/view.ts +2 -2
- package/dist/templates/src/modules/site/index.ts +1 -1
- package/package.json +2 -2
- package/dist/templates/overlays/spa-react/frontend/src/pages/OrganizationsPage.tsx +0 -69
- package/dist/templates/overlays/spa-react/frontend/src/pages/ProjectsPage.tsx +0 -65
- package/dist/templates/overlays/spa-react/frontend/src/pages/TasksPage.tsx +0 -65
package/dist/cli.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
// src/generate.ts
|
|
5
5
|
import { existsSync as existsSync2, mkdirSync as mkdirSync2, rmSync as rmSync2 } from "fs";
|
|
6
|
-
import { join as join2, resolve } from "path";
|
|
6
|
+
import { basename, join as join2, resolve } from "path";
|
|
7
7
|
|
|
8
8
|
// src/copy.ts
|
|
9
9
|
import {
|
|
@@ -110,6 +110,12 @@ function usesTenantTable(tenancy) {
|
|
|
110
110
|
function htmlAuthKit(auth) {
|
|
111
111
|
return authUsesCookie(auth);
|
|
112
112
|
}
|
|
113
|
+
function needsFrontendBuild(frontend) {
|
|
114
|
+
return frontend === "spa-react" || frontend === "hybrid";
|
|
115
|
+
}
|
|
116
|
+
function nowTimestampLiteral(database) {
|
|
117
|
+
return database === "mysql" ? `new Date().toISOString().slice(0, 19).replace("T", " ")` : "new Date().toISOString()";
|
|
118
|
+
}
|
|
113
119
|
function extraApplies(extra, auth) {
|
|
114
120
|
if (extra === "metrics") {
|
|
115
121
|
return true;
|
|
@@ -485,6 +491,20 @@ function applyDockerFlags(layers, flags) {
|
|
|
485
491
|
}
|
|
486
492
|
return layers;
|
|
487
493
|
}
|
|
494
|
+
function extraFlagName(extra) {
|
|
495
|
+
return extra === "emailVerification" ? "email-verification" : extra;
|
|
496
|
+
}
|
|
497
|
+
function dropInapplicableExtras(layers, flags) {
|
|
498
|
+
for (const key of Object.keys(layers.extras)) {
|
|
499
|
+
if (extraApplies(key, layers.auth)) {
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
if (flags.extras[key] === true) {
|
|
503
|
+
console.warn(`Ignoring --${extraFlagName(key)}: not available with --auth ${layers.auth}.`);
|
|
504
|
+
}
|
|
505
|
+
layers.extras[key] = false;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
488
508
|
function applyFlagOverrides(base, flags) {
|
|
489
509
|
const next = {
|
|
490
510
|
...base,
|
|
@@ -499,8 +519,10 @@ function applyFlagOverrides(base, flags) {
|
|
|
499
519
|
extras: { ...base.extras, ...flags.extras }
|
|
500
520
|
};
|
|
501
521
|
if (next.database !== "postgres" && next.tenancy === "rls") {
|
|
522
|
+
console.warn(`Using --tenancy column: rls is Postgres-only (SET LOCAL app.tenant_id) and ${next.database} has no equivalent.`);
|
|
502
523
|
next.tenancy = "column";
|
|
503
524
|
}
|
|
525
|
+
dropInapplicableExtras(next, flags);
|
|
504
526
|
return reconcileDocker(applyDockerFlags(next, flags));
|
|
505
527
|
}
|
|
506
528
|
function layersFromFlags(flags) {
|
|
@@ -1113,16 +1135,13 @@ function sqlTrue(layers) {
|
|
|
1113
1135
|
}
|
|
1114
1136
|
function renderPendingMfaTs() {
|
|
1115
1137
|
return `import { createHmac, timingSafeEqual } from "node:crypto";
|
|
1138
|
+
import { sessionSecret } from "./config.ts";
|
|
1116
1139
|
|
|
1117
1140
|
const COOKIE = "strata_mfa_pending";
|
|
1118
1141
|
|
|
1119
|
-
function secret(): string {
|
|
1120
|
-
return process.env.SESSION_SECRET?.trim() || "dev-session-secret-change-me-please-32ch";
|
|
1121
|
-
}
|
|
1122
|
-
|
|
1123
1142
|
function sign(userId: number, issuedAt: number): string {
|
|
1124
1143
|
const payload = \`\${userId}.\${issuedAt}\`;
|
|
1125
|
-
const signature = createHmac("sha256",
|
|
1144
|
+
const signature = createHmac("sha256", sessionSecret()).update(payload).digest("hex");
|
|
1126
1145
|
return \`\${payload}.\${signature}\`;
|
|
1127
1146
|
}
|
|
1128
1147
|
|
|
@@ -1203,6 +1222,8 @@ function renderAuthModule(layers) {
|
|
|
1203
1222
|
imports.push(`import { hashPassword, verifyPassword } from "@getstrata/core/auth/password";`);
|
|
1204
1223
|
if (authUsesToken(layers.auth)) {
|
|
1205
1224
|
imports.push(`import { hashApiToken } from "@getstrata/core/auth/tokenHash";`);
|
|
1225
|
+
imports.push(`import { sqlTimestamp } from "@getstrata/core/database/dialect";`);
|
|
1226
|
+
imports.push(`import { resolveDefaultTokenExpiryDays } from "@getstrata/core/security/tokenExpiry";`);
|
|
1206
1227
|
}
|
|
1207
1228
|
if (mfa) {
|
|
1208
1229
|
imports.push(`import { protectMfaSecret, revealMfaSecret } from "@getstrata/core/crypto/mfaSecret";`);
|
|
@@ -1261,11 +1282,22 @@ function sessionUser(user: { id: number; name?: string | null; email?: string |
|
|
|
1261
1282
|
return jsonResponse({ error: "Invalid credentials" }, { status: 422 });
|
|
1262
1283
|
}
|
|
1263
1284
|
const plain = \`strp_\${randomBytes(24).toString("hex")}\`;
|
|
1285
|
+
// API_TOKEN_DEFAULT_EXPIRY_DAYS bounds every minted token; unset means no expiry.
|
|
1286
|
+
const expiryDays = resolveDefaultTokenExpiryDays();
|
|
1287
|
+
const expiresAt = expiryDays
|
|
1288
|
+
? new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000)
|
|
1289
|
+
: null;
|
|
1264
1290
|
await getSql().unsafe(
|
|
1265
|
-
"INSERT INTO api_tokens (user_id, name, token_hash, abilities) VALUES (${ph(layers,
|
|
1266
|
-
[
|
|
1291
|
+
"INSERT INTO api_tokens (user_id, name, token_hash, abilities, expires_at) VALUES (${ph(layers, 5)})",
|
|
1292
|
+
[
|
|
1293
|
+
user.id,
|
|
1294
|
+
"spa",
|
|
1295
|
+
hashApiToken(plain),
|
|
1296
|
+
JSON.stringify(["profile:read"]),
|
|
1297
|
+
expiresAt ? sqlTimestamp(expiresAt) : null,
|
|
1298
|
+
],
|
|
1267
1299
|
);
|
|
1268
|
-
return jsonResponse({ token: plain });
|
|
1300
|
+
return jsonResponse({ token: plain, expires_at: expiresAt?.toISOString() ?? null });
|
|
1269
1301
|
})),
|
|
1270
1302
|
},
|
|
1271
1303
|
"/api/v1/auth/me": {
|
|
@@ -1372,7 +1404,7 @@ function sessionUser(user: { id: number; name?: string | null; email?: string |
|
|
|
1372
1404
|
}
|
|
1373
1405
|
await getSql().unsafe(
|
|
1374
1406
|
"UPDATE users SET email_verified_at = ${verifiedPh} WHERE id = ${idPh}",
|
|
1375
|
-
[
|
|
1407
|
+
[${nowTimestampLiteral(layers.database)}, id],
|
|
1376
1408
|
);
|
|
1377
1409
|
return jsonResponse({ ok: true });
|
|
1378
1410
|
})),
|
|
@@ -1533,7 +1565,7 @@ function sessionUser(user: { id: number; name?: string | null; email?: string |
|
|
|
1533
1565
|
if (Number.isInteger(id) && id > 0) {
|
|
1534
1566
|
await getSql().unsafe(
|
|
1535
1567
|
"UPDATE users SET email_verified_at = ${verifiedPh} WHERE id = ${idPh}",
|
|
1536
|
-
[
|
|
1568
|
+
[${nowTimestampLiteral(layers.database)}, id],
|
|
1537
1569
|
);
|
|
1538
1570
|
const record = await starterAuthDirectory.findByIdOrThrow(id);
|
|
1539
1571
|
return flashResponse(
|
|
@@ -1668,17 +1700,27 @@ export default authModule;
|
|
|
1668
1700
|
function renderSiteModule(_layers) {
|
|
1669
1701
|
return `import type { AppModule } from "@getstrata/bootstrap/contracts";
|
|
1670
1702
|
import { withErrorHandling } from "@getstrata/core/http/response";
|
|
1671
|
-
import { pingDatabase } from "../../bootstrap/database.ts";
|
|
1703
|
+
import { getSql, pingDatabase } from "../../bootstrap/database.ts";
|
|
1672
1704
|
import { plainText, renderPage } from "../../lib/view.ts";
|
|
1673
1705
|
|
|
1706
|
+
// Proves the database answers and the schema is migrated. Point it at a table your app owns.
|
|
1707
|
+
async function schemaReady(): Promise<boolean> {
|
|
1708
|
+
try {
|
|
1709
|
+
await getSql().unsafe("SELECT 1 FROM notes LIMIT 1");
|
|
1710
|
+
return true;
|
|
1711
|
+
} catch {
|
|
1712
|
+
return false;
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1674
1716
|
const siteModule: AppModule = {
|
|
1675
1717
|
name: "site",
|
|
1676
1718
|
order: 1,
|
|
1677
1719
|
routes({ kernel }) {
|
|
1678
1720
|
return {
|
|
1679
1721
|
"/health": kernel.wrap("api", withErrorHandling(async () => {
|
|
1680
|
-
const
|
|
1681
|
-
return plainText(
|
|
1722
|
+
const ok = (await pingDatabase()) && (await schemaReady());
|
|
1723
|
+
return plainText(ok ? "ok" : "degraded", ok ? 200 : 503);
|
|
1682
1724
|
})),
|
|
1683
1725
|
};
|
|
1684
1726
|
},
|
|
@@ -2009,7 +2051,7 @@ function renderAuthDirectory(layers) {
|
|
|
2009
2051
|
}
|
|
2010
2052
|
const hashed = hashApiToken(token);
|
|
2011
2053
|
const rows = await getSql().unsafe<
|
|
2012
|
-
|
|
2054
|
+
{
|
|
2013
2055
|
id: number;
|
|
2014
2056
|
user_id: number;
|
|
2015
2057
|
abilities: string;
|
|
@@ -2017,7 +2059,7 @@ function renderAuthDirectory(layers) {
|
|
|
2017
2059
|
role?: string;
|
|
2018
2060
|
is_admin?: number | boolean;
|
|
2019
2061
|
email_verified_at?: Date | string | null;
|
|
2020
|
-
}
|
|
2062
|
+
}
|
|
2021
2063
|
>(
|
|
2022
2064
|
\`SELECT t.id, t.user_id, t.abilities, t.expires_at, u.is_admin, u.email_verified_at
|
|
2023
2065
|
FROM api_tokens t INNER JOIN users u ON u.id = t.user_id
|
|
@@ -2052,83 +2094,74 @@ function renderAuthDirectory(layers) {
|
|
|
2052
2094
|
const hashImport = authUsesToken(layers.auth) ? `import { hashApiToken } from "@getstrata/core/auth/tokenHash";
|
|
2053
2095
|
` : "";
|
|
2054
2096
|
const mfaSelect = layers.extras.mfa ? ", mfa_enabled, mfa_secret, mfa_recovery_codes" : "";
|
|
2097
|
+
const mfaColumns = layers.extras.mfa ? `
|
|
2098
|
+
mfa_enabled?: number | boolean;
|
|
2099
|
+
mfa_secret?: string | null;
|
|
2100
|
+
mfa_recovery_codes?: string | null;` : "";
|
|
2055
2101
|
const mfaReturn = layers.extras.mfa ? `
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2102
|
+
mfa_enabled: row.mfa_enabled === true || row.mfa_enabled === 1,
|
|
2103
|
+
mfa_secret: row.mfa_secret ?? null,
|
|
2104
|
+
mfa_recovery_codes: row.mfa_recovery_codes ?? null,` : "";
|
|
2105
|
+
const userColumns = `id, name, email, is_admin, email_verified_at, password${mfaSelect}`;
|
|
2059
2106
|
return `import type { AuthUser } from "@getstrata/core/auth/authContext";
|
|
2060
2107
|
import { verifyPassword } from "@getstrata/core/auth/password";
|
|
2061
2108
|
${hashImport}import type { AuthUserDirectory } from "@getstrata/core/contracts/authUserDirectory";
|
|
2062
2109
|
import { getSql } from "./database.ts";
|
|
2063
2110
|
|
|
2111
|
+
type UserRow = {
|
|
2112
|
+
id: number;
|
|
2113
|
+
name: string;
|
|
2114
|
+
email: string;
|
|
2115
|
+
is_admin: number | boolean;
|
|
2116
|
+
email_verified_at: Date | string | null;
|
|
2117
|
+
password: string;${mfaColumns}
|
|
2118
|
+
};
|
|
2119
|
+
|
|
2064
2120
|
function mapRole(isAdmin: unknown): string {
|
|
2065
2121
|
return isAdmin === true || isAdmin === 1 || isAdmin === "1" ? "admin" : "member";
|
|
2066
2122
|
}
|
|
2067
2123
|
|
|
2124
|
+
function mapUserRow(row: UserRow) {
|
|
2125
|
+
return {
|
|
2126
|
+
id: Number(row.id),
|
|
2127
|
+
name: row.name,
|
|
2128
|
+
email: row.email,
|
|
2129
|
+
role: mapRole(row.is_admin),
|
|
2130
|
+
email_verified_at: row.email_verified_at ?? null,
|
|
2131
|
+
password: row.password,${mfaReturn}
|
|
2132
|
+
};
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
async function findUserById(id: number) {
|
|
2136
|
+
const rows = await getSql().unsafe<UserRow>(
|
|
2137
|
+
"SELECT ${userColumns} FROM users WHERE id = ${placeholder}",
|
|
2138
|
+
[id],
|
|
2139
|
+
);
|
|
2140
|
+
const row = rows[0];
|
|
2141
|
+
if (!row) {
|
|
2142
|
+
throw new Error(\`User \${id} not found.\`);
|
|
2143
|
+
}
|
|
2144
|
+
return mapUserRow(row);
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
async function findUserByEmail(email: string) {
|
|
2148
|
+
const rows = await getSql().unsafe<UserRow>(
|
|
2149
|
+
"SELECT ${userColumns} FROM users WHERE email = ${placeholder}",
|
|
2150
|
+
[email.trim().toLowerCase()],
|
|
2151
|
+
);
|
|
2152
|
+
const row = rows[0];
|
|
2153
|
+
return row ? mapUserRow(row) : null;
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2068
2156
|
export const starterAuthDirectory: AuthUserDirectory = {
|
|
2069
2157
|
${tokenLookup.replace("WHERE t.token_hash = ?", `WHERE t.token_hash = ${placeholder}`)}
|
|
2070
2158
|
|
|
2071
|
-
|
|
2072
|
-
const rows = await getSql().unsafe<
|
|
2073
|
-
Array<{
|
|
2074
|
-
id: number;
|
|
2075
|
-
name: string;
|
|
2076
|
-
email: string;
|
|
2077
|
-
is_admin: number | boolean;
|
|
2078
|
-
email_verified_at: Date | string | null;
|
|
2079
|
-
password: string;
|
|
2080
|
-
mfa_enabled?: number | boolean;
|
|
2081
|
-
mfa_secret?: string | null;
|
|
2082
|
-
mfa_recovery_codes?: string | null;
|
|
2083
|
-
}>
|
|
2084
|
-
>(\`SELECT id, name, email, is_admin, email_verified_at, password${mfaSelect} FROM users WHERE id = ${placeholder}\`, [id]);
|
|
2085
|
-
const row = rows[0];
|
|
2086
|
-
if (!row) {
|
|
2087
|
-
throw new Error(\`User \${id} not found.\`);
|
|
2088
|
-
}
|
|
2089
|
-
return {
|
|
2090
|
-
id: Number(row.id),
|
|
2091
|
-
name: row.name,
|
|
2092
|
-
email: row.email,
|
|
2093
|
-
role: mapRole(row.is_admin),
|
|
2094
|
-
email_verified_at: row.email_verified_at ?? null,
|
|
2095
|
-
password: row.password,${mfaReturn}
|
|
2096
|
-
};
|
|
2097
|
-
},
|
|
2159
|
+
findByIdOrThrow: findUserById,
|
|
2098
2160
|
|
|
2099
|
-
|
|
2100
|
-
const rows = await getSql().unsafe<
|
|
2101
|
-
Array<{
|
|
2102
|
-
id: number;
|
|
2103
|
-
name: string;
|
|
2104
|
-
email: string;
|
|
2105
|
-
is_admin: number | boolean;
|
|
2106
|
-
email_verified_at: Date | string | null;
|
|
2107
|
-
password: string;
|
|
2108
|
-
mfa_enabled?: number | boolean;
|
|
2109
|
-
mfa_secret?: string | null;
|
|
2110
|
-
mfa_recovery_codes?: string | null;
|
|
2111
|
-
}>
|
|
2112
|
-
>(
|
|
2113
|
-
\`SELECT id, name, email, is_admin, email_verified_at, password${mfaSelect} FROM users WHERE email = ${placeholder}\`,
|
|
2114
|
-
[email.trim().toLowerCase()],
|
|
2115
|
-
);
|
|
2116
|
-
const row = rows[0];
|
|
2117
|
-
if (!row) {
|
|
2118
|
-
return null;
|
|
2119
|
-
}
|
|
2120
|
-
return {
|
|
2121
|
-
id: Number(row.id),
|
|
2122
|
-
name: row.name,
|
|
2123
|
-
email: row.email,
|
|
2124
|
-
role: mapRole(row.is_admin),
|
|
2125
|
-
email_verified_at: row.email_verified_at ?? null,
|
|
2126
|
-
password: row.password,${mfaReturn}
|
|
2127
|
-
};
|
|
2128
|
-
},
|
|
2161
|
+
findByEmail: findUserByEmail,
|
|
2129
2162
|
|
|
2130
2163
|
async verifyCredentials(email: string, password: string): Promise<AuthUser | null> {
|
|
2131
|
-
const user = await
|
|
2164
|
+
const user = await findUserByEmail(email);
|
|
2132
2165
|
if (!user?.password || !(await verifyPassword(password, user.password))) {
|
|
2133
2166
|
return null;
|
|
2134
2167
|
}
|
|
@@ -2187,7 +2220,7 @@ export default authProvider;
|
|
|
2187
2220
|
`;
|
|
2188
2221
|
}
|
|
2189
2222
|
const cookieBlock = authUsesCookie(layers.auth) ? ` const auth = createCookieSessionAuthManager({
|
|
2190
|
-
secret:
|
|
2223
|
+
secret: sessionSecret(),
|
|
2191
2224
|
cookieName: "strata_session",
|
|
2192
2225
|
mapUser: (user) => ({
|
|
2193
2226
|
id: user.id,
|
|
@@ -2233,6 +2266,9 @@ export default authProvider;
|
|
|
2233
2266
|
`)},
|
|
2234
2267
|
} from "@getstrata/core/contracts/serviceTokens";`);
|
|
2235
2268
|
imports.push(`import { starterAuthDirectory } from "../authDirectory.ts";`);
|
|
2269
|
+
if (authUsesCookie(layers.auth)) {
|
|
2270
|
+
imports.push(`import { sessionSecret } from "../config.ts";`);
|
|
2271
|
+
}
|
|
2236
2272
|
return `${imports.join(`
|
|
2237
2273
|
`)}
|
|
2238
2274
|
|
|
@@ -2258,7 +2294,7 @@ function envFlag(value) {
|
|
|
2258
2294
|
return value ? "true" : "false";
|
|
2259
2295
|
}
|
|
2260
2296
|
function appDatabaseName(projectName) {
|
|
2261
|
-
return
|
|
2297
|
+
return projectName.replace(/[^A-Za-z0-9_]/g, "_");
|
|
2262
2298
|
}
|
|
2263
2299
|
function defaultDatabaseUrl(layers, projectName) {
|
|
2264
2300
|
if (layers.database === "sqlite") {
|
|
@@ -2278,6 +2314,10 @@ function renderEnvExample(projectName, layers) {
|
|
|
2278
2314
|
"PORT=3000",
|
|
2279
2315
|
"APP_URL=http://localhost:3000",
|
|
2280
2316
|
`DATABASE_URL=${defaultDatabaseUrl(layers, projectName)}`,
|
|
2317
|
+
...layers.database === "sqlite" ? [] : [
|
|
2318
|
+
"# Optional. Migrate and boot against a different database than DATABASE_URL.",
|
|
2319
|
+
"# APP_DATABASE_URL="
|
|
2320
|
+
],
|
|
2281
2321
|
`DB_CONNECTION=${layers.database === "postgres" ? "pgsql" : layers.database}`,
|
|
2282
2322
|
`FRONTEND_MODE=${layers.frontend}`,
|
|
2283
2323
|
`SPA_PREFIX=${layers.spaPrefix}`,
|
|
@@ -2285,9 +2325,13 @@ function renderEnvExample(projectName, layers) {
|
|
|
2285
2325
|
`CACHE_DRIVER=${layers.cache}`,
|
|
2286
2326
|
`QUEUE_DRIVER=${layers.queue}`,
|
|
2287
2327
|
`MAIL_DRIVER=${layers.mail}`,
|
|
2288
|
-
`AUTH_DEV_HEADERS=${envFlag(layers.auth === "headers")}
|
|
2289
|
-
`FEATURE_PUBLIC_READS=${envFlag(layers.frontend !== "api")}`
|
|
2328
|
+
`AUTH_DEV_HEADERS=${envFlag(layers.auth === "headers")}`
|
|
2290
2329
|
];
|
|
2330
|
+
if (layers.frontend === "api") {
|
|
2331
|
+
lines.push("FEATURE_PUBLIC_READS=false");
|
|
2332
|
+
} else {
|
|
2333
|
+
lines.push("# Local convenience so the welcome page reads without a login.", "# Production boot is blocked unless this is false.", "FEATURE_PUBLIC_READS=true");
|
|
2334
|
+
}
|
|
2291
2335
|
if (needsRedis(layers)) {
|
|
2292
2336
|
lines.push("REDIS_URL=redis://127.0.0.1:6379");
|
|
2293
2337
|
} else {
|
|
@@ -2338,6 +2382,7 @@ function renderEnvExample(projectName, layers) {
|
|
|
2338
2382
|
lines.push("# MAIL_HOST=");
|
|
2339
2383
|
lines.push("# MAIL_FROM=");
|
|
2340
2384
|
}
|
|
2385
|
+
lines.push("# Behind a reverse proxy, trust X-Forwarded-For (rightmost public hop) for throttles and session IPs.");
|
|
2341
2386
|
lines.push("# TRUST_FORWARDED_FOR=true");
|
|
2342
2387
|
return `${lines.join(`
|
|
2343
2388
|
`)}
|
|
@@ -2424,35 +2469,90 @@ dist
|
|
|
2424
2469
|
frontend/dist
|
|
2425
2470
|
storage/*.sqlite
|
|
2426
2471
|
storage/*.sqlite-journal
|
|
2472
|
+
storage/*.sqlite-wal
|
|
2473
|
+
storage/*.sqlite-shm
|
|
2427
2474
|
coverage
|
|
2428
2475
|
*.tsbuildinfo
|
|
2429
2476
|
`;
|
|
2430
2477
|
}
|
|
2478
|
+
function renderDockerfile(layers) {
|
|
2479
|
+
const frontend = needsFrontendBuild(layers.frontend);
|
|
2480
|
+
const lines = [
|
|
2481
|
+
'# Production image. Build once, run with env from your platform; see README "Deploy".',
|
|
2482
|
+
"FROM oven/bun:1.4 AS deps",
|
|
2483
|
+
"WORKDIR /app",
|
|
2484
|
+
"COPY package.json bun.lock ./",
|
|
2485
|
+
"RUN bun install --frozen-lockfile --production",
|
|
2486
|
+
""
|
|
2487
|
+
];
|
|
2488
|
+
if (frontend) {
|
|
2489
|
+
lines.push("FROM oven/bun:1.4 AS frontend", "WORKDIR /app/frontend", "COPY frontend/package.json frontend/bun.lock ./", "RUN bun install --frozen-lockfile", "COPY frontend/ ./", "RUN bun run build", "");
|
|
2490
|
+
}
|
|
2491
|
+
lines.push("FROM oven/bun:1.4-slim AS runtime", "WORKDIR /app", "ENV APP_ENV=production", "ENV AUTH_DEV_HEADERS=false", "ENV PORT=3000", "COPY --from=deps /app/node_modules ./node_modules", "COPY . .");
|
|
2492
|
+
if (frontend) {
|
|
2493
|
+
lines.push("COPY --from=frontend /app/frontend/dist ./frontend/dist");
|
|
2494
|
+
}
|
|
2495
|
+
lines.push("RUN mkdir -p storage && chown -R bun:bun /app", "USER bun", "EXPOSE 3000");
|
|
2496
|
+
if (layers.database === "sqlite") {
|
|
2497
|
+
lines.push("# SQLite lives in storage/; mount a volume there or the data dies with the container.", 'VOLUME ["/app/storage"]');
|
|
2498
|
+
}
|
|
2499
|
+
lines.push(`HEALTHCHECK --interval=30s --timeout=3s --start-period=10s CMD ["bun", "-e", "fetch('http://127.0.0.1:' + process.env.PORT + '/health').then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"]`, 'CMD ["bun", "run", "start"]');
|
|
2500
|
+
return `${lines.join(`
|
|
2501
|
+
`)}
|
|
2502
|
+
`;
|
|
2503
|
+
}
|
|
2504
|
+
function renderDockerignore(layers) {
|
|
2505
|
+
const lines = [
|
|
2506
|
+
".git",
|
|
2507
|
+
"node_modules",
|
|
2508
|
+
".env",
|
|
2509
|
+
".env.*",
|
|
2510
|
+
"!.env.example",
|
|
2511
|
+
"storage/*.sqlite",
|
|
2512
|
+
"storage/*.sqlite-journal",
|
|
2513
|
+
"storage/*.sqlite-wal",
|
|
2514
|
+
"storage/*.sqlite-shm",
|
|
2515
|
+
"coverage",
|
|
2516
|
+
"docker-compose.yml"
|
|
2517
|
+
];
|
|
2518
|
+
if (needsFrontendBuild(layers.frontend)) {
|
|
2519
|
+
lines.push("frontend/node_modules", "frontend/dist");
|
|
2520
|
+
}
|
|
2521
|
+
return `${lines.join(`
|
|
2522
|
+
`)}
|
|
2523
|
+
`;
|
|
2524
|
+
}
|
|
2431
2525
|
function renderPackageJson(projectName, options = {}) {
|
|
2432
2526
|
const coreDeps = options.workspaceDependencies ? {
|
|
2433
2527
|
"@getstrata/bootstrap": "workspace:*",
|
|
2434
2528
|
"@getstrata/cli": "workspace:*",
|
|
2435
2529
|
"@getstrata/core": "workspace:*"
|
|
2436
2530
|
} : {
|
|
2437
|
-
"@getstrata/bootstrap": "^0.
|
|
2438
|
-
"@getstrata/cli": "^0.
|
|
2439
|
-
"@getstrata/core": "^0.
|
|
2531
|
+
"@getstrata/bootstrap": "^1.0.1",
|
|
2532
|
+
"@getstrata/cli": "^1.0.1",
|
|
2533
|
+
"@getstrata/core": "^1.0.1"
|
|
2440
2534
|
};
|
|
2441
2535
|
if (options.layers?.database === "mysql") {
|
|
2442
2536
|
coreDeps.mysql2 = "^3.24.3";
|
|
2443
2537
|
}
|
|
2538
|
+
const scripts = {
|
|
2539
|
+
dev: "strata dev",
|
|
2540
|
+
start: "strata start",
|
|
2541
|
+
"db:migrate": "strata migrate",
|
|
2542
|
+
"db:fresh": "strata migrate:fresh",
|
|
2543
|
+
check: "tsc --noEmit"
|
|
2544
|
+
};
|
|
2545
|
+
if (options.layers && needsFrontendBuild(options.layers.frontend)) {
|
|
2546
|
+
scripts["frontend:install"] = "cd frontend && bun install";
|
|
2547
|
+
scripts["frontend:build"] = "cd frontend && bun run build";
|
|
2548
|
+
scripts["frontend:dev"] = "cd frontend && bun run dev";
|
|
2549
|
+
}
|
|
2444
2550
|
return `${JSON.stringify({
|
|
2445
2551
|
name: projectName,
|
|
2446
2552
|
version: "0.1.0",
|
|
2447
2553
|
private: true,
|
|
2448
2554
|
type: "module",
|
|
2449
|
-
scripts
|
|
2450
|
-
dev: "strata dev",
|
|
2451
|
-
start: "strata start",
|
|
2452
|
-
"db:migrate": "strata migrate",
|
|
2453
|
-
"db:fresh": "strata migrate:fresh",
|
|
2454
|
-
check: "tsc --noEmit"
|
|
2455
|
-
},
|
|
2555
|
+
scripts,
|
|
2456
2556
|
dependencies: coreDeps,
|
|
2457
2557
|
devDependencies: {
|
|
2458
2558
|
"@types/bun": "^1.4.0",
|
|
@@ -2524,13 +2624,86 @@ function renderSupportingToolsReadme(layers) {
|
|
|
2524
2624
|
`)}
|
|
2525
2625
|
`;
|
|
2526
2626
|
}
|
|
2627
|
+
function renderApiDocs(projectName, layers) {
|
|
2628
|
+
const rows = ["| Method | Path | Notes |", "| --- | --- | --- |"];
|
|
2629
|
+
rows.push("| `GET` | `/health` | Plain text `ok` (200), or `degraded` (503) when the database or the migrated schema is unavailable. `/ready` returns the same checks as JSON. |");
|
|
2630
|
+
rows.push("| `GET` | `/` | Welcome page. Restyle or replace it. |");
|
|
2631
|
+
if (authUsesToken(layers.auth)) {
|
|
2632
|
+
rows.push("| `POST` | `/api/v1/auth/login` | `{ email, password }` returns `{ token }`. |", "| `POST` | `/api/v1/auth/register` | Creates a user and returns a token. |", "| `GET` | `/api/v1/auth/me` | Requires `Authorization: Bearer <token>`. |");
|
|
2633
|
+
}
|
|
2634
|
+
if (authUsesJwt(layers.auth)) {
|
|
2635
|
+
rows.push("| `POST` | `/api/auth/token` | Mints a short-lived JWT. Not an HTML session. |");
|
|
2636
|
+
}
|
|
2637
|
+
if (authNeedsUsers(layers.auth)) {
|
|
2638
|
+
rows.push("| `POST` | `/api/v1/auth/forgot-password` | Sends a signed reset link through the mail driver. |", "| `POST` | `/api/v1/auth/reset-password` | Consumes the signed link. |", "| `GET` | `/api/user` | Current user for the active guard. |");
|
|
2639
|
+
}
|
|
2640
|
+
if (layers.extras.metrics) {
|
|
2641
|
+
rows.push("| `GET` | `/metrics` | Prometheus text. Production requires `Authorization: Bearer <METRICS_TOKEN>`. |");
|
|
2642
|
+
}
|
|
2643
|
+
const authNote = layers.auth === "headers" ? `Auth is \`headers\`. Send \`x-authenticated-user-id\` (and optional \`x-authenticated-user-role\`) for local work and tests. There are no login endpoints and no \`users\` table. Production must set \`AUTH_DEV_HEADERS=false\`, which turns those headers off and leaves you without a guard, so pick another auth layer before you ship.` : authUsesToken(layers.auth) ? `Sign in with \`POST /api/v1/auth/login\`, then send \`Authorization: Bearer <token>\` on every request. Tokens are stored hashed in \`api_tokens\` and expire after \`API_TOKEN_DEFAULT_EXPIRY_DAYS\` (30 in \`.env.example\`). The response includes \`expires_at\`.` : `Mint a JWT with \`POST /api/auth/token\`, then send \`Authorization: Bearer <jwt>\`. JWTs expire; re-mint rather than refreshing in place.`;
|
|
2644
|
+
return `# ${projectName} API
|
|
2645
|
+
|
|
2646
|
+
\`FRONTEND_MODE=${layers.frontend}\`. ${layers.frontend === "api" ? "No server-rendered views beyond the welcome page and no SPA assets." : `HTML is served alongside this API. The SPA is mounted at \`${layers.spaPrefix}\`.`}
|
|
2647
|
+
|
|
2648
|
+
## Routes this app serves today
|
|
2649
|
+
|
|
2650
|
+
${rows.join(`
|
|
2651
|
+
`)}
|
|
2652
|
+
|
|
2653
|
+
There is no CRUD endpoint for the seeded \`notes\` table. Adding your own routes is the first thing you do.
|
|
2654
|
+
|
|
2655
|
+
## Auth
|
|
2656
|
+
|
|
2657
|
+
${authNote}
|
|
2658
|
+
|
|
2659
|
+
## Adding a route
|
|
2660
|
+
|
|
2661
|
+
Create a module under \`src/modules/\` and return a route map. Modules are discovered on boot.
|
|
2662
|
+
|
|
2663
|
+
\`\`\`typescript
|
|
2664
|
+
// src/modules/notes/index.ts
|
|
2665
|
+
import type { AppModule } from "@getstrata/bootstrap/contracts";
|
|
2666
|
+
import { jsonResponse } from "@getstrata/core/http/response";
|
|
2667
|
+
import { getSql } from "../../bootstrap/database.ts";
|
|
2668
|
+
|
|
2669
|
+
const notesModule: AppModule = {
|
|
2670
|
+
name: "notes",
|
|
2671
|
+
order: 2,
|
|
2672
|
+
routes({ kernel }) {
|
|
2673
|
+
return {
|
|
2674
|
+
"/api/v1/notes": kernel.wrap("api", async () => {
|
|
2675
|
+
const rows = await getSql().unsafe<{ id: number; body: string }>(
|
|
2676
|
+
"SELECT id, body FROM notes ORDER BY id DESC",
|
|
2677
|
+
);
|
|
2678
|
+
return jsonResponse({ data: rows });
|
|
2679
|
+
}),
|
|
2680
|
+
};
|
|
2681
|
+
},
|
|
2682
|
+
};
|
|
2683
|
+
|
|
2684
|
+
export default notesModule;
|
|
2685
|
+
\`\`\`
|
|
2686
|
+
|
|
2687
|
+
Import from \`@getstrata/core/...\` subpaths rather than the package root, so singleton state such as the database pool stays shared.
|
|
2688
|
+
|
|
2689
|
+
## Docs
|
|
2690
|
+
|
|
2691
|
+
- [Building apps](https://github.com/EyK-26/strata/blob/main/docs/BUILDING-APPS.md)
|
|
2692
|
+
- [Auth choices](https://github.com/EyK-26/strata/blob/main/docs/AUTH.md)
|
|
2693
|
+
- [Databases](https://github.com/EyK-26/strata/blob/main/docs/DATABASE.md)
|
|
2694
|
+
`;
|
|
2695
|
+
}
|
|
2527
2696
|
function renderReadme(projectName, layers) {
|
|
2528
2697
|
const docker = renderDockerCompose(projectName, layers);
|
|
2529
2698
|
const next = [`cd ${projectName}`, "cp .env.example .env"];
|
|
2530
2699
|
if (docker) {
|
|
2531
2700
|
next.push("docker compose up -d");
|
|
2532
2701
|
}
|
|
2533
|
-
next.push("bun install"
|
|
2702
|
+
next.push("bun install");
|
|
2703
|
+
if (needsFrontendBuild(layers.frontend)) {
|
|
2704
|
+
next.push("bun run frontend:install", "bun run frontend:build");
|
|
2705
|
+
}
|
|
2706
|
+
next.push("bun run db:migrate", "bun run dev");
|
|
2534
2707
|
const extras = Object.entries(layers.extras).filter(([, on]) => on).map(([key]) => key);
|
|
2535
2708
|
const dockerServices = selectedDockerServices(layers);
|
|
2536
2709
|
const neededTools = neededDockerServices(layers);
|
|
@@ -2585,13 +2758,49 @@ Cookie name is \`strata_session\`. Forms send CSRF as \`_token\`.
|
|
|
2585
2758
|
` : ""}${authUsesToken(layers.auth) ? `
|
|
2586
2759
|
Opaque token login: \`POST /api/v1/auth/login\` with \`{ "email", "password" }\`. Register: \`POST /api/v1/auth/register\`. Forgot/reset: \`POST /api/v1/auth/forgot-password\` and signed \`POST /api/v1/auth/reset-password\`. Send \`Authorization: Bearer\` after login.
|
|
2587
2760
|
` : ""}${authUsesJwt(layers.auth) ? `
|
|
2588
|
-
JWT mint: \`POST /api/auth/token\` with email and password. Short-lived.
|
|
2761
|
+
JWT mint: \`POST /api/auth/token\` with email and password. Short-lived. Do not use JWT as an HTML cookie session.
|
|
2589
2762
|
` : ""}${layers.extras.metrics ? `
|
|
2590
2763
|
Prometheus scrape: \`GET /metrics\`. Production requires \`Authorization: Bearer <METRICS_TOKEN>\`.
|
|
2591
|
-
` : ""}
|
|
2764
|
+
` : ""}${needsFrontendBuild(layers.frontend) ? `
|
|
2765
|
+
## Frontend
|
|
2766
|
+
|
|
2767
|
+
The React app lives in \`frontend/\` with its own \`package.json\`. It is not built by \`bun install\` at the root.
|
|
2768
|
+
|
|
2769
|
+
\`\`\`bash
|
|
2770
|
+
bun run frontend:install
|
|
2771
|
+
bun run frontend:build
|
|
2772
|
+
\`\`\`
|
|
2773
|
+
|
|
2774
|
+
Until \`frontend/dist\` exists, \`${layers.spaPrefix}\` answers 503. Use \`bun run frontend:dev\` for the Vite-style dev server with hot reload.${layers.frontend === "hybrid" ? ` HTML stays at \`/\` and the SPA is served under \`${layers.spaPrefix}/*\`.` : ""}
|
|
2775
|
+
` : ""}${layers.database === "sqlite" ? "" : `
|
|
2776
|
+
## Database
|
|
2777
|
+
|
|
2778
|
+
The app uses the database named in \`DATABASE_URL\` and creates it on first migrate when the connection user may. Set \`APP_DATABASE_URL\` only when migrations and the app should target a different database than \`DATABASE_URL\`.
|
|
2779
|
+
`}
|
|
2780
|
+
## Deploy
|
|
2781
|
+
|
|
2782
|
+
\`Dockerfile\` builds a production image from the committed \`bun.lock\` (run \`bun install\` once and commit the lockfile).${needsFrontendBuild(layers.frontend) ? " The React frontend is built inside the image." : ""}
|
|
2783
|
+
|
|
2784
|
+
\`\`\`bash
|
|
2785
|
+
docker build -t ${projectName} .
|
|
2786
|
+
docker run --rm -p 3000:3000 --env-file .env.production ${projectName}
|
|
2787
|
+
\`\`\`
|
|
2788
|
+
|
|
2789
|
+
Migrations are a deploy step, not a boot step: run \`docker run --rm --env-file .env.production ${projectName} bun run db:migrate\` before the new version takes traffic.${layers.database === "sqlite" ? " SQLite stores its file in `/app/storage`; mount a volume there (`-v strata_data:/app/storage`) or the data is lost with the container." : ""}
|
|
2790
|
+
The image sets \`APP_ENV=production\` and \`AUTH_DEV_HEADERS=false\`; everything else in the Production list below comes from your environment (the \`.env.production\` file above is one way).
|
|
2791
|
+
|
|
2592
2792
|
## Production
|
|
2593
2793
|
|
|
2594
|
-
\`createApp\` calls \`assertProductionSecrets()\` when \`APP_ENV=production\`.
|
|
2794
|
+
\`createApp\` calls \`assertProductionSecrets()\` when \`APP_ENV=production\`. That check fails closed, so read this before your first production boot.
|
|
2795
|
+
|
|
2796
|
+
- Replace every \`change-me\` placeholder in \`.env\`. The guard rejects the values this generator wrote, not just empty ones.
|
|
2797
|
+
- Set \`APP_URL\` to the public origin (for example \`https://app.example.com\`). Signed links and redirects are built from it; localhost is rejected.
|
|
2798
|
+
- Set \`AUTH_DEV_HEADERS=false\`.
|
|
2799
|
+
- Set \`FEATURE_PUBLIC_READS=false\`. ${layers.frontend === "api" ? "This app already ships `false`." : "This app ships `true` so the local welcome page reads without a login. Production requires `false`."}
|
|
2800
|
+
- Cross-origin browser calls are off in production until you set \`CORS_ALLOWED_ORIGINS\` to explicit origins. A \`*\` entry is rejected. Non-browser clients are unaffected.
|
|
2801
|
+
- Behind a reverse proxy or load balancer, set \`TRUST_FORWARDED_FOR=true\` so throttles and session records see the client address instead of the proxy. Only the rightmost public hop of \`X-Forwarded-For\` is trusted.
|
|
2802
|
+
${authUsesCookie(layers.auth) ? "- Set `SESSION_SECRET` to 32+ characters.\n" : ""}${authUsesToken(layers.auth) ? "- Set `TOKEN_HASH_PEPPER`.\n" : ""}${layers.extras.scim ? "- Set `SCIM_BEARER_TOKEN`.\n" : ""}${layers.extras.metrics ? "- Set `METRICS_TOKEN`.\n" : ""}
|
|
2803
|
+
\`strata start\` does not migrate when \`APP_ENV=production\`. Run \`bun run db:migrate\` as a deploy step. \`GET /health\` answers 503 until the schema exists, so a fresh deploy stays out of rotation until it is migrated.
|
|
2595
2804
|
`;
|
|
2596
2805
|
}
|
|
2597
2806
|
|
|
@@ -2612,6 +2821,8 @@ function dialectFragments(database) {
|
|
|
2612
2821
|
return {
|
|
2613
2822
|
id: "INTEGER PRIMARY KEY AUTOINCREMENT",
|
|
2614
2823
|
text: "TEXT",
|
|
2824
|
+
keyText: "TEXT",
|
|
2825
|
+
defaultText: "TEXT",
|
|
2615
2826
|
timestamp: "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
|
2616
2827
|
timestampNull: "TEXT",
|
|
2617
2828
|
bool: "INTEGER NOT NULL DEFAULT 0"
|
|
@@ -2621,6 +2832,8 @@ function dialectFragments(database) {
|
|
|
2621
2832
|
return {
|
|
2622
2833
|
id: "INT AUTO_INCREMENT PRIMARY KEY",
|
|
2623
2834
|
text: "TEXT",
|
|
2835
|
+
keyText: "VARCHAR(255)",
|
|
2836
|
+
defaultText: "VARCHAR(1024)",
|
|
2624
2837
|
timestamp: "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
|
2625
2838
|
timestampNull: "DATETIME NULL",
|
|
2626
2839
|
bool: "TINYINT(1) NOT NULL DEFAULT 0"
|
|
@@ -2629,6 +2842,8 @@ function dialectFragments(database) {
|
|
|
2629
2842
|
return {
|
|
2630
2843
|
id: "SERIAL PRIMARY KEY",
|
|
2631
2844
|
text: "TEXT",
|
|
2845
|
+
keyText: "TEXT",
|
|
2846
|
+
defaultText: "TEXT",
|
|
2632
2847
|
timestamp: "TIMESTAMPTZ NOT NULL DEFAULT NOW()",
|
|
2633
2848
|
timestampNull: "TIMESTAMPTZ",
|
|
2634
2849
|
bool: "BOOLEAN NOT NULL DEFAULT FALSE"
|
|
@@ -2820,9 +3035,9 @@ function renderMigrateTs(layers) {
|
|
|
2820
3035
|
if (tenancyOn) {
|
|
2821
3036
|
statements.push(`CREATE TABLE IF NOT EXISTS tenant (
|
|
2822
3037
|
id ${d.id},
|
|
2823
|
-
slug ${d.
|
|
2824
|
-
plan ${d.
|
|
2825
|
-
region ${d.
|
|
3038
|
+
slug ${d.keyText} NOT NULL UNIQUE,
|
|
3039
|
+
plan ${d.defaultText} NOT NULL DEFAULT 'enterprise',
|
|
3040
|
+
region ${d.defaultText} NOT NULL DEFAULT 'eu'
|
|
2826
3041
|
)`);
|
|
2827
3042
|
}
|
|
2828
3043
|
statements.push(`CREATE TABLE IF NOT EXISTS notes (
|
|
@@ -2840,7 +3055,7 @@ function renderMigrateTs(layers) {
|
|
|
2840
3055
|
statements.push(`CREATE TABLE IF NOT EXISTS users (
|
|
2841
3056
|
id ${d.id},
|
|
2842
3057
|
name ${d.text} NOT NULL,
|
|
2843
|
-
email ${d.
|
|
3058
|
+
email ${d.keyText} NOT NULL UNIQUE,
|
|
2844
3059
|
password ${d.text} NOT NULL,
|
|
2845
3060
|
is_admin ${d.bool},${tenantColumn}${mfaColumns}
|
|
2846
3061
|
email_verified_at ${d.timestampNull},
|
|
@@ -2849,7 +3064,7 @@ function renderMigrateTs(layers) {
|
|
|
2849
3064
|
}
|
|
2850
3065
|
if (authUsesCookie(layers.auth)) {
|
|
2851
3066
|
statements.push(`CREATE TABLE IF NOT EXISTS sessions (
|
|
2852
|
-
id ${d.
|
|
3067
|
+
id ${d.keyText} PRIMARY KEY,
|
|
2853
3068
|
user_id INTEGER NOT NULL,
|
|
2854
3069
|
expires_at ${d.timestamp},
|
|
2855
3070
|
user_agent ${d.text},
|
|
@@ -2862,8 +3077,8 @@ function renderMigrateTs(layers) {
|
|
|
2862
3077
|
id ${d.id},
|
|
2863
3078
|
user_id INTEGER NOT NULL,
|
|
2864
3079
|
name ${d.text} NOT NULL,
|
|
2865
|
-
token_hash ${d.
|
|
2866
|
-
abilities ${d.
|
|
3080
|
+
token_hash ${d.keyText} NOT NULL UNIQUE,
|
|
3081
|
+
abilities ${d.defaultText} NOT NULL DEFAULT '[]',
|
|
2867
3082
|
expires_at ${d.timestampNull},
|
|
2868
3083
|
last_used_at ${d.timestampNull},
|
|
2869
3084
|
created_at ${d.timestamp}
|
|
@@ -2878,10 +3093,10 @@ function renderMigrateTs(layers) {
|
|
|
2878
3093
|
const userPlaceholders = verifyOn ? ph ? "$1, $2, $3, $4, $5), ($6, $7, $8, $9, $10" : "?, ?, ?, ?, ?), (?, ?, ?, ?, ?" : ph ? "$1, $2, $3, $4), ($5, $6, $7, $8" : "?, ?, ?, ?), (?, ?, ?, ?";
|
|
2879
3094
|
const adminFlag = ph ? "false" : "0";
|
|
2880
3095
|
const adminTrue = ph ? "true" : "1";
|
|
2881
|
-
const verifiedNow =
|
|
3096
|
+
const verifiedNow = nowTimestampLiteral(layers.database);
|
|
2882
3097
|
const userValues = verifyOn ? `["Demo User", "demo@example.com", password, ${adminFlag}, ${verifiedNow}, "Admin User", "admin@example.test", password, ${adminTrue}, ${verifiedNow}]` : `["Demo User", "demo@example.com", password, ${adminFlag}, "Admin User", "admin@example.test", password, ${adminTrue}]`;
|
|
2883
3098
|
const seedTenant = tenancyOn ? `
|
|
2884
|
-
const [{ count: tenantCount }] = await sql.unsafe<
|
|
3099
|
+
const [{ count: tenantCount }] = await sql.unsafe<{ count: string | number }>(
|
|
2885
3100
|
"SELECT COUNT(*) AS count FROM tenant",
|
|
2886
3101
|
);
|
|
2887
3102
|
if (Number(tenantCount) === 0) {
|
|
@@ -2891,7 +3106,7 @@ function renderMigrateTs(layers) {
|
|
|
2891
3106
|
);
|
|
2892
3107
|
}` : "";
|
|
2893
3108
|
const seedUsers = authNeedsUsers(layers.auth) ? `
|
|
2894
|
-
const [{ count: userCount }] = await sql.unsafe<
|
|
3109
|
+
const [{ count: userCount }] = await sql.unsafe<{ count: string | number }>(
|
|
2895
3110
|
"SELECT COUNT(*) AS count FROM users",
|
|
2896
3111
|
);
|
|
2897
3112
|
if (Number(userCount) === 0) {
|
|
@@ -2904,7 +3119,7 @@ function renderMigrateTs(layers) {
|
|
|
2904
3119
|
const hashImport = authNeedsUsers(layers.auth) ? `import { hashPassword } from "@getstrata/core/auth/password";
|
|
2905
3120
|
` : "";
|
|
2906
3121
|
const seedBlock = `${seedTenant}${seedUsers}`;
|
|
2907
|
-
return `${hashImport}${ensureImport(layers)}import { getSql } from "../bootstrap/database.ts";
|
|
3122
|
+
return `${hashImport}${ensureImport(layers)}import { closeDatabase, getSql } from "../bootstrap/database.ts";
|
|
2908
3123
|
|
|
2909
3124
|
const migrations = [
|
|
2910
3125
|
${list}
|
|
@@ -2912,7 +3127,7 @@ ${list}
|
|
|
2912
3127
|
|
|
2913
3128
|
export async function seed() {
|
|
2914
3129
|
${ensureCall(layers)} const sql = getSql();
|
|
2915
|
-
const [{ count }] = await sql.unsafe<
|
|
3130
|
+
const [{ count }] = await sql.unsafe<{ count: string | number }>(
|
|
2916
3131
|
"SELECT COUNT(*) AS count FROM notes",
|
|
2917
3132
|
);
|
|
2918
3133
|
if (Number(count) === 0) {
|
|
@@ -2930,9 +3145,15 @@ ${ensureCall(layers)} const sql = getSql();
|
|
|
2930
3145
|
await seed();
|
|
2931
3146
|
}
|
|
2932
3147
|
|
|
3148
|
+
/** The CLI calls this after migrate() so pooled drivers do not hold the process open. */
|
|
3149
|
+
export async function close() {
|
|
3150
|
+
await closeDatabase();
|
|
3151
|
+
}
|
|
3152
|
+
|
|
2933
3153
|
if (import.meta.main) {
|
|
2934
3154
|
await migrate();
|
|
2935
3155
|
console.log("Database migrated and seeded.");
|
|
3156
|
+
await close();
|
|
2936
3157
|
process.exit(0);
|
|
2937
3158
|
}
|
|
2938
3159
|
`;
|
|
@@ -2957,7 +3178,7 @@ function dropTables(layers) {
|
|
|
2957
3178
|
function renderFreshTs(layers) {
|
|
2958
3179
|
const tables = dropTables(layers);
|
|
2959
3180
|
const cascade = layers.database === "sqlite" ? "" : " CASCADE";
|
|
2960
|
-
return `${ensureImport(layers)}import { getSql } from "../bootstrap/database.ts";
|
|
3181
|
+
return `${ensureImport(layers)}import { closeDatabase, getSql } from "../bootstrap/database.ts";
|
|
2961
3182
|
import { migrate } from "./migrate.ts";
|
|
2962
3183
|
|
|
2963
3184
|
const tables = ${JSON.stringify(tables)};
|
|
@@ -2970,9 +3191,15 @@ ${ensureCall(layers)} const sql = getSql();
|
|
|
2970
3191
|
await migrate();
|
|
2971
3192
|
}
|
|
2972
3193
|
|
|
3194
|
+
/** The CLI calls this after fresh() so pooled drivers do not hold the process open. */
|
|
3195
|
+
export async function close() {
|
|
3196
|
+
await closeDatabase();
|
|
3197
|
+
}
|
|
3198
|
+
|
|
2973
3199
|
if (import.meta.main) {
|
|
2974
3200
|
await fresh();
|
|
2975
3201
|
console.log("Database reset, migrated, and seeded.");
|
|
3202
|
+
await close();
|
|
2976
3203
|
process.exit(0);
|
|
2977
3204
|
}
|
|
2978
3205
|
`;
|
|
@@ -3000,7 +3227,7 @@ ${ensureCall(layers)} const sql = getSql();
|
|
|
3000
3227
|
console.log("Starter schema (inline SQL, not a migration runner):");
|
|
3001
3228
|
for (const table of tables) {
|
|
3002
3229
|
try {
|
|
3003
|
-
const rows = await sql.unsafe<
|
|
3230
|
+
const rows = await sql.unsafe<{ count: string | number }>(
|
|
3004
3231
|
\`SELECT COUNT(*) AS count FROM \${table}\`,
|
|
3005
3232
|
);
|
|
3006
3233
|
console.log(\`- [present] \${table} (rows: \${rows[0]?.count ?? 0})\`);
|
|
@@ -3073,6 +3300,17 @@ export function loadConfig(): AppConfig {
|
|
|
3073
3300
|
databaseUrl,
|
|
3074
3301
|
};
|
|
3075
3302
|
}
|
|
3303
|
+
|
|
3304
|
+
/** Cookie sessions and signed cookies are keyed by this; there is no default. */
|
|
3305
|
+
export function sessionSecret(): string {
|
|
3306
|
+
const secret = process.env.SESSION_SECRET?.trim();
|
|
3307
|
+
if (!secret) {
|
|
3308
|
+
throw new Error(
|
|
3309
|
+
"SESSION_SECRET is required. Copy .env.example to .env and set it (32+ characters).",
|
|
3310
|
+
);
|
|
3311
|
+
}
|
|
3312
|
+
return secret;
|
|
3313
|
+
}
|
|
3076
3314
|
`;
|
|
3077
3315
|
}
|
|
3078
3316
|
function renderConfigProvider(layers) {
|
|
@@ -3133,50 +3371,62 @@ function renderEnsureDatabaseTs(layers, projectName) {
|
|
|
3133
3371
|
}
|
|
3134
3372
|
const database = appDatabaseName(projectName);
|
|
3135
3373
|
const fallback = layers.database === "mysql" ? `mysql://root:root@localhost:3306/${database}` : `postgresql://postgres:postgres@localhost:5432/${database}`;
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3374
|
+
const resolveUrl = `/**
|
|
3375
|
+
* The database name comes from DATABASE_URL. Set APP_DATABASE_URL to point
|
|
3376
|
+
* migrations and the app at a different database than DATABASE_URL.
|
|
3377
|
+
*/
|
|
3139
3378
|
function resolveAppDatabaseUrl(): string {
|
|
3140
3379
|
const explicit = process.env.APP_DATABASE_URL?.trim();
|
|
3141
3380
|
if (explicit) {
|
|
3142
3381
|
return explicit;
|
|
3143
3382
|
}
|
|
3383
|
+
return process.env.DATABASE_URL?.trim() || DEFAULT_DATABASE_URL;
|
|
3384
|
+
}
|
|
3144
3385
|
|
|
3145
|
-
|
|
3386
|
+
/** Reject anything we would have to quote before interpolating into DDL. */
|
|
3387
|
+
function safeDatabaseName(url: string): string {
|
|
3388
|
+
let name = "";
|
|
3146
3389
|
try {
|
|
3147
|
-
|
|
3148
|
-
url.pathname = \`/\${APP_DATABASE}\`;
|
|
3149
|
-
return url.toString();
|
|
3390
|
+
name = decodeURIComponent(new URL(url).pathname.replace(/^\\//, ""));
|
|
3150
3391
|
} catch {
|
|
3151
|
-
|
|
3392
|
+
throw new Error(\`DATABASE_URL is not a valid URL: \${url}\`);
|
|
3393
|
+
}
|
|
3394
|
+
if (!name) {
|
|
3395
|
+
throw new Error("DATABASE_URL is missing a database name.");
|
|
3396
|
+
}
|
|
3397
|
+
if (name.replace(/[^A-Za-z0-9_]/g, "") !== name) {
|
|
3398
|
+
throw new Error(\`Refusing to create a database with an unsafe name: \${name}\`);
|
|
3152
3399
|
}
|
|
3400
|
+
return name;
|
|
3153
3401
|
}
|
|
3402
|
+
`;
|
|
3403
|
+
if (layers.database === "mysql") {
|
|
3404
|
+
return `import { createConnection } from "mysql2/promise";
|
|
3405
|
+
|
|
3406
|
+
const DEFAULT_DATABASE_URL = ${JSON.stringify(fallback)};
|
|
3154
3407
|
|
|
3408
|
+
${resolveUrl}
|
|
3155
3409
|
export async function ensureAppDatabase(): Promise<string> {
|
|
3156
3410
|
const url = resolveAppDatabaseUrl();
|
|
3411
|
+
const name = safeDatabaseName(url);
|
|
3412
|
+
|
|
3413
|
+
const admin = new URL(url);
|
|
3414
|
+
admin.pathname = "/";
|
|
3415
|
+
const connection = await createConnection(admin.toString());
|
|
3416
|
+
try {
|
|
3417
|
+
await connection.query(\`CREATE DATABASE IF NOT EXISTS \${name}\`);
|
|
3418
|
+
} finally {
|
|
3419
|
+
await connection.end();
|
|
3420
|
+
}
|
|
3421
|
+
|
|
3157
3422
|
process.env.DATABASE_URL = url;
|
|
3158
3423
|
return url;
|
|
3159
3424
|
}
|
|
3160
3425
|
`;
|
|
3161
3426
|
}
|
|
3162
|
-
return `const
|
|
3163
|
-
|
|
3164
|
-
function resolveAppDatabaseUrl(): string {
|
|
3165
|
-
const explicit = process.env.APP_DATABASE_URL?.trim();
|
|
3166
|
-
if (explicit) {
|
|
3167
|
-
return explicit;
|
|
3168
|
-
}
|
|
3169
|
-
|
|
3170
|
-
const base = process.env.DATABASE_URL?.trim() || ${JSON.stringify(fallback)};
|
|
3171
|
-
try {
|
|
3172
|
-
const url = new URL(base);
|
|
3173
|
-
url.pathname = \`/\${APP_DATABASE}\`;
|
|
3174
|
-
return url.toString();
|
|
3175
|
-
} catch {
|
|
3176
|
-
return base;
|
|
3177
|
-
}
|
|
3178
|
-
}
|
|
3427
|
+
return `const DEFAULT_DATABASE_URL = ${JSON.stringify(fallback)};
|
|
3179
3428
|
|
|
3429
|
+
${resolveUrl}
|
|
3180
3430
|
function adminCandidateUrls(url: string): string[] {
|
|
3181
3431
|
const names = ["postgres", "template1"];
|
|
3182
3432
|
try {
|
|
@@ -3185,7 +3435,6 @@ function adminCandidateUrls(url: string): string[] {
|
|
|
3185
3435
|
names.push(current);
|
|
3186
3436
|
}
|
|
3187
3437
|
} catch {
|
|
3188
|
-
// keep the built-in admin databases
|
|
3189
3438
|
}
|
|
3190
3439
|
return names.map((name) => {
|
|
3191
3440
|
const admin = new URL(url);
|
|
@@ -3213,16 +3462,7 @@ async function openAdminConnection(url: string): Promise<Bun.SQL> {
|
|
|
3213
3462
|
|
|
3214
3463
|
export async function ensureAppDatabase(): Promise<string> {
|
|
3215
3464
|
const url = resolveAppDatabaseUrl();
|
|
3216
|
-
const
|
|
3217
|
-
const name = decodeURIComponent(parsed.pathname.replace(/^\\//, ""));
|
|
3218
|
-
if (!name) {
|
|
3219
|
-
throw new Error("DATABASE_URL is missing a database name.");
|
|
3220
|
-
}
|
|
3221
|
-
|
|
3222
|
-
const identifier = name.replace(/[^A-Za-z0-9_]/g, "");
|
|
3223
|
-
if (identifier !== name) {
|
|
3224
|
-
throw new Error(\`Refusing to create a database with an unsafe name: \${name}\`);
|
|
3225
|
-
}
|
|
3465
|
+
const name = safeDatabaseName(url);
|
|
3226
3466
|
|
|
3227
3467
|
const adminSql = await openAdminConnection(url);
|
|
3228
3468
|
try {
|
|
@@ -3230,14 +3470,13 @@ export async function ensureAppDatabase(): Promise<string> {
|
|
|
3230
3470
|
SELECT 1 AS ok FROM pg_database WHERE datname = \${name}
|
|
3231
3471
|
\`;
|
|
3232
3472
|
if (rows.length === 0) {
|
|
3233
|
-
await adminSql.unsafe(\`CREATE DATABASE \${
|
|
3473
|
+
await adminSql.unsafe(\`CREATE DATABASE \${name}\`);
|
|
3234
3474
|
}
|
|
3235
3475
|
} finally {
|
|
3236
3476
|
await adminSql.close();
|
|
3237
3477
|
}
|
|
3238
3478
|
|
|
3239
3479
|
process.env.DATABASE_URL = url;
|
|
3240
|
-
process.env.APP_DATABASE_URL = url;
|
|
3241
3480
|
return url;
|
|
3242
3481
|
}
|
|
3243
3482
|
`;
|
|
@@ -3351,9 +3590,12 @@ function createAppContext(): AppContext {
|
|
|
3351
3590
|
}
|
|
3352
3591
|
|
|
3353
3592
|
export async function bootstrapApp(options: BootstrapOptions = {}): Promise<BootstrappedApp> {
|
|
3354
|
-
const
|
|
3593
|
+
const isProduction = process.env.APP_ENV === "production";
|
|
3594
|
+
// Dev boots migrate for convenience. Production must not mutate schema on
|
|
3595
|
+
// start, so run \`strata migrate\` as an explicit deploy step instead.
|
|
3596
|
+
const { migrate: runMigrate = !isProduction } = options;
|
|
3355
3597
|
|
|
3356
|
-
if (
|
|
3598
|
+
if (isProduction) {
|
|
3357
3599
|
assertProductionSecrets();
|
|
3358
3600
|
}
|
|
3359
3601
|
|
|
@@ -3453,8 +3695,8 @@ ${userBlock}
|
|
|
3453
3695
|
return htmlResponse(html, { status });
|
|
3454
3696
|
}
|
|
3455
3697
|
|
|
3456
|
-
export function plainText(body: string): Response {
|
|
3457
|
-
return new Response(body, { headers: { "content-type": "text/plain; charset=utf-8" } });
|
|
3698
|
+
export function plainText(body: string, status = 200): Response {
|
|
3699
|
+
return new Response(body, { status, headers: { "content-type": "text/plain; charset=utf-8" } });
|
|
3458
3700
|
}
|
|
3459
3701
|
`;
|
|
3460
3702
|
}
|
|
@@ -3612,12 +3854,12 @@ const scimModule: AppModule = {
|
|
|
3612
3854
|
const match = /userName\\s+eq\\s+"([^"]+)"/i.exec(filter);
|
|
3613
3855
|
let rows: UserRow[];
|
|
3614
3856
|
if (match?.[1]) {
|
|
3615
|
-
rows = await getSql().unsafe<UserRow
|
|
3857
|
+
rows = await getSql().unsafe<UserRow>(
|
|
3616
3858
|
"SELECT id, name, email FROM users WHERE email = ${emailPh}",
|
|
3617
3859
|
[match[1].trim().toLowerCase()],
|
|
3618
3860
|
);
|
|
3619
3861
|
} else {
|
|
3620
|
-
rows = await getSql().unsafe<UserRow
|
|
3862
|
+
rows = await getSql().unsafe<UserRow>("SELECT id, name, email FROM users");
|
|
3621
3863
|
}
|
|
3622
3864
|
const startIndex = Math.max(1, Number.parseInt(url.searchParams.get("startIndex") ?? "1", 10) || 1);
|
|
3623
3865
|
const count = Math.min(200, Math.max(1, Number.parseInt(url.searchParams.get("count") ?? String(rows.length || 1), 10) || 200));
|
|
@@ -3640,7 +3882,7 @@ const scimModule: AppModule = {
|
|
|
3640
3882
|
if (!email) {
|
|
3641
3883
|
return scimError("userName is required.", 400);
|
|
3642
3884
|
}
|
|
3643
|
-
const existing = await getSql().unsafe<UserRow
|
|
3885
|
+
const existing = await getSql().unsafe<UserRow>(
|
|
3644
3886
|
"SELECT id, name, email FROM users WHERE email = ${emailPh}",
|
|
3645
3887
|
[email],
|
|
3646
3888
|
);
|
|
@@ -3653,7 +3895,7 @@ const scimModule: AppModule = {
|
|
|
3653
3895
|
"INSERT INTO users (${insertCols}) VALUES (${insertPh})",
|
|
3654
3896
|
[name, email, hashed${insertTail}],
|
|
3655
3897
|
);
|
|
3656
|
-
const created = await getSql().unsafe<UserRow
|
|
3898
|
+
const created = await getSql().unsafe<UserRow>(
|
|
3657
3899
|
"SELECT id, name, email FROM users WHERE email = ${emailPh}",
|
|
3658
3900
|
[email],
|
|
3659
3901
|
);
|
|
@@ -3670,7 +3912,7 @@ const scimModule: AppModule = {
|
|
|
3670
3912
|
"api",
|
|
3671
3913
|
wrapScim(async (request) => {
|
|
3672
3914
|
const id = Number.parseInt(routeParams(request).id ?? "", 10);
|
|
3673
|
-
const rows = await getSql().unsafe<UserRow
|
|
3915
|
+
const rows = await getSql().unsafe<UserRow>(
|
|
3674
3916
|
"SELECT id, name, email FROM users WHERE id = ${idPh}",
|
|
3675
3917
|
[id],
|
|
3676
3918
|
);
|
|
@@ -3695,7 +3937,7 @@ const scimModule: AppModule = {
|
|
|
3695
3937
|
"UPDATE users SET name = ${updatePh.split(", ")[0]}, email = ${updatePh.split(", ")[1]} WHERE id = ${updatePh.split(", ")[2]}",
|
|
3696
3938
|
[name, email, id],
|
|
3697
3939
|
);
|
|
3698
|
-
const rows = await getSql().unsafe<UserRow
|
|
3940
|
+
const rows = await getSql().unsafe<UserRow>(
|
|
3699
3941
|
"SELECT id, name, email FROM users WHERE id = ${idPh}",
|
|
3700
3942
|
[id],
|
|
3701
3943
|
);
|
|
@@ -3710,7 +3952,7 @@ const scimModule: AppModule = {
|
|
|
3710
3952
|
"api",
|
|
3711
3953
|
wrapScim(async (request) => {
|
|
3712
3954
|
const id = Number.parseInt(routeParams(request).id ?? "", 10);
|
|
3713
|
-
const existing = await getSql().unsafe<UserRow
|
|
3955
|
+
const existing = await getSql().unsafe<UserRow>(
|
|
3714
3956
|
"SELECT id, name, email FROM users WHERE id = ${idPh}",
|
|
3715
3957
|
[id],
|
|
3716
3958
|
);
|
|
@@ -3802,13 +4044,16 @@ function assertProjectName(projectName) {
|
|
|
3802
4044
|
throw new Error("Project name must contain only letters, numbers, hyphens, and underscores.");
|
|
3803
4045
|
}
|
|
3804
4046
|
}
|
|
4047
|
+
function resolveProjectTarget(rawTarget, cwd) {
|
|
4048
|
+
const targetDir = resolve(cwd, rawTarget.trim());
|
|
4049
|
+
const projectName = basename(targetDir);
|
|
4050
|
+
assertProjectName(projectName);
|
|
4051
|
+
return { projectName, targetDir };
|
|
4052
|
+
}
|
|
3805
4053
|
function applyFrontendOverlays(overlayRoot, targetDir, layers) {
|
|
3806
4054
|
if (layers.frontend === "hybrid" || layers.frontend === "spa-react") {
|
|
3807
4055
|
copyOverlayTree(join2(overlayRoot, "spa-react"), targetDir);
|
|
3808
4056
|
}
|
|
3809
|
-
if (layers.frontend === "api") {
|
|
3810
|
-
copyOverlayTree(join2(overlayRoot, "api"), targetDir);
|
|
3811
|
-
}
|
|
3812
4057
|
}
|
|
3813
4058
|
function writeGeneratedFiles(options) {
|
|
3814
4059
|
const { targetDir, projectName, layers } = options;
|
|
@@ -3817,7 +4062,14 @@ function writeGeneratedFiles(options) {
|
|
|
3817
4062
|
writeText(join2(targetDir, ".gitignore"), renderGitignore());
|
|
3818
4063
|
writeText(join2(targetDir, "package.json"), renderPackageJson(projectName, { ...options, layers }));
|
|
3819
4064
|
writeText(join2(targetDir, "README.md"), renderReadme(projectName, layers));
|
|
4065
|
+
if (layers.frontend === "api") {
|
|
4066
|
+
writeText(join2(targetDir, "docs/API.md"), renderApiDocs(projectName, layers));
|
|
4067
|
+
} else {
|
|
4068
|
+
removeIfExists(join2(targetDir, "docs/API.md"));
|
|
4069
|
+
}
|
|
3820
4070
|
writeText(join2(targetDir, "strata.layers.json"), renderLayersManifest(projectName, layers));
|
|
4071
|
+
writeText(join2(targetDir, "Dockerfile"), renderDockerfile(layers));
|
|
4072
|
+
writeText(join2(targetDir, ".dockerignore"), renderDockerignore(layers));
|
|
3821
4073
|
const compose = renderDockerCompose(projectName, layers);
|
|
3822
4074
|
if (compose) {
|
|
3823
4075
|
writeText(join2(targetDir, "docker-compose.yml"), compose);
|
|
@@ -3888,7 +4140,7 @@ function writeGeneratedFiles(options) {
|
|
|
3888
4140
|
mkdirSync2(join2(targetDir, "storage"), { recursive: true });
|
|
3889
4141
|
writeText(join2(targetDir, "storage/.gitkeep"), "");
|
|
3890
4142
|
}
|
|
3891
|
-
function printNextSteps(projectName, layers, compose) {
|
|
4143
|
+
function printNextSteps(projectName, layers, compose, cdTarget = projectName) {
|
|
3892
4144
|
const dockerOn = selectedDockerServices(layers);
|
|
3893
4145
|
const neededTools = neededDockerServices(layers);
|
|
3894
4146
|
const localOn = neededTools.filter((name) => !dockerOn.includes(name));
|
|
@@ -3899,7 +4151,7 @@ Created Strata app in ${projectName}/
|
|
|
3899
4151
|
console.log(`frontend=${layers.frontend} db=${layers.database} auth=${layers.auth} docker=${dockerSummary}`);
|
|
3900
4152
|
console.log(`
|
|
3901
4153
|
Next steps:`);
|
|
3902
|
-
console.log(` cd ${
|
|
4154
|
+
console.log(` cd ${cdTarget}`);
|
|
3903
4155
|
console.log(" cp .env.example .env");
|
|
3904
4156
|
if (compose) {
|
|
3905
4157
|
console.log(" docker compose up -d");
|
|
@@ -3911,8 +4163,15 @@ Next steps:`);
|
|
|
3911
4163
|
console.log(` Point env at local ${localOn.join(", ")} (see README).`);
|
|
3912
4164
|
}
|
|
3913
4165
|
console.log(" bun install");
|
|
3914
|
-
|
|
3915
|
-
|
|
4166
|
+
if (needsFrontendBuild(layers.frontend)) {
|
|
4167
|
+
console.log(" bun run frontend:install");
|
|
4168
|
+
console.log(" bun run frontend:build");
|
|
4169
|
+
}
|
|
4170
|
+
console.log(" bun run db:migrate");
|
|
4171
|
+
console.log(` bun run dev
|
|
4172
|
+
`);
|
|
4173
|
+
console.log("The strata binary is local to the app, so use the bun run scripts above.");
|
|
4174
|
+
console.log(`Run it directly with bunx strata <command> from inside the app directory.
|
|
3916
4175
|
`);
|
|
3917
4176
|
}
|
|
3918
4177
|
function generateProject(options) {
|
|
@@ -3941,16 +4200,16 @@ async function runCreateStrata(argv, cwd = process.cwd()) {
|
|
|
3941
4200
|
}
|
|
3942
4201
|
try {
|
|
3943
4202
|
const plan = await resolveStarterPlan(flags);
|
|
3944
|
-
const targetDir =
|
|
4203
|
+
const { projectName, targetDir } = resolveProjectTarget(plan.projectName, cwd);
|
|
3945
4204
|
generateProject({
|
|
3946
|
-
projectName
|
|
4205
|
+
projectName,
|
|
3947
4206
|
targetDir,
|
|
3948
4207
|
layers: plan.layers,
|
|
3949
4208
|
templateRoot: resolveTemplateRoot(),
|
|
3950
4209
|
overlayRoot: resolveOverlayRoot(),
|
|
3951
4210
|
force: flags.force
|
|
3952
4211
|
});
|
|
3953
|
-
printNextSteps(
|
|
4212
|
+
printNextSteps(projectName, plan.layers, renderDockerCompose(projectName, plan.layers) !== null, plan.projectName);
|
|
3954
4213
|
return 0;
|
|
3955
4214
|
} catch (error) {
|
|
3956
4215
|
console.error(error instanceof Error ? error.message : error);
|