glashjs 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +161 -164
- package/bin/glash.mjs +58 -0
- package/package.json +13 -2
- package/src/auth.mjs +96 -0
- package/src/build.mjs +5 -0
- package/src/config.mjs +2 -0
- package/src/create.mjs +593 -0
- package/src/env.mjs +88 -0
- package/src/index.mjs +2 -0
- package/src/postgres.mjs +77 -0
- package/src/routes.mjs +7 -0
- package/src/server/jsx.mjs +3 -3
- package/src/server/server.mjs +24 -5
- package/src/server-functions.mjs +47 -0
- package/src/sql-runner.mjs +30 -0
- package/src/typed-routes.mjs +65 -0
package/src/config.mjs
CHANGED
|
@@ -19,6 +19,8 @@ export const DEFAULT_CONFIG = {
|
|
|
19
19
|
publicDir: 'public',
|
|
20
20
|
// File-based routes (pages + api/) served by `glash dev` / `glash serve`.
|
|
21
21
|
routesDir: 'routes',
|
|
22
|
+
// Global stylesheets injected into every rendered document.
|
|
23
|
+
stylesheets: [],
|
|
22
24
|
port: 3000,
|
|
23
25
|
outDir: '.glash/out',
|
|
24
26
|
themeColor: '#0b0d12',
|
package/src/create.mjs
ADDED
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
// glashjs create
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Interactive project scaffolder for the GlashDB-native full-stack framework.
|
|
4
|
+
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { randomBytes } from 'node:crypto';
|
|
7
|
+
import { promises as fs } from 'node:fs';
|
|
8
|
+
import { createInterface } from 'node:readline/promises';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { generateTypedRoutes } from './typed-routes.mjs';
|
|
11
|
+
|
|
12
|
+
const CSS_CHOICES = new Set(['tailwind', 'plain', 'none']);
|
|
13
|
+
const PM_CHOICES = new Set(['npm', 'pnpm', 'yarn', 'bun']);
|
|
14
|
+
|
|
15
|
+
export async function createProject(options = {}) {
|
|
16
|
+
const cwd = options.cwd ?? process.cwd();
|
|
17
|
+
const interactive = options.interactive ?? (process.stdin.isTTY && process.stdout.isTTY && !options.yes);
|
|
18
|
+
const rl = interactive ? createInterface({ input: process.stdin, output: process.stdout }) : null;
|
|
19
|
+
try {
|
|
20
|
+
const answers = await collectAnswers(options, rl);
|
|
21
|
+
const target = path.resolve(cwd, answers.directory);
|
|
22
|
+
await assertWritableTarget(target, answers.force);
|
|
23
|
+
await fs.mkdir(target, { recursive: true });
|
|
24
|
+
await writeProject(target, answers);
|
|
25
|
+
await generateTypedRoutes({ root: target, log: () => undefined });
|
|
26
|
+
|
|
27
|
+
if (answers.install) await installDependencies(target, answers.packageManager);
|
|
28
|
+
if (answers.git) await initGit(target);
|
|
29
|
+
|
|
30
|
+
printNextSteps(target, answers, options.log ?? console.log);
|
|
31
|
+
return { target, answers };
|
|
32
|
+
} finally {
|
|
33
|
+
rl?.close();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function collectAnswers(options, rl) {
|
|
38
|
+
const name = slugify(options.name || await ask(rl, 'Project name', 'glash-app'));
|
|
39
|
+
const css = normalizeChoice(options.css || await ask(rl, 'CSS setup (tailwind/plain/none)', 'tailwind'), CSS_CHOICES, 'tailwind');
|
|
40
|
+
const postgres = boolAnswer(options.postgres, await ask(rl, 'Use Postgres by default?', 'yes'));
|
|
41
|
+
const auth = boolAnswer(options.auth, await ask(rl, 'Add glashAuth routes?', 'yes'));
|
|
42
|
+
const sqlRunner = boolAnswer(options.sqlRunner, await ask(rl, 'Add SQL runner support?', 'yes'));
|
|
43
|
+
const aiPrompts = boolAnswer(options.aiPrompts, await ask(rl, 'Add AI deployment prompts?', 'yes'));
|
|
44
|
+
const install = boolAnswer(options.install, await ask(rl, 'Install dependencies now?', 'yes'));
|
|
45
|
+
const packageManager = normalizeChoice(options.packageManager || await ask(rl, 'Package manager (npm/pnpm/yarn/bun)', detectPackageManager()), PM_CHOICES, 'npm');
|
|
46
|
+
const git = boolAnswer(options.git, await ask(rl, 'Initialize git?', 'yes'));
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
name,
|
|
50
|
+
directory: options.directory || name,
|
|
51
|
+
css,
|
|
52
|
+
postgres,
|
|
53
|
+
auth,
|
|
54
|
+
sqlRunner,
|
|
55
|
+
aiPrompts,
|
|
56
|
+
install,
|
|
57
|
+
packageManager,
|
|
58
|
+
git,
|
|
59
|
+
force: !!options.force,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function writeProject(root, answers) {
|
|
64
|
+
const files = projectFiles(answers);
|
|
65
|
+
for (const [file, contents] of Object.entries(files)) await writeFile(root, file, contents);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function projectFiles(answers) {
|
|
69
|
+
const hasCss = answers.css !== 'none';
|
|
70
|
+
const cssBuild = 'tailwindcss -i ./styles/input.css -o ./public/app.css';
|
|
71
|
+
const scripts = {
|
|
72
|
+
dev: answers.css === 'tailwind' ? `${cssBuild} && glashjs dev` : 'glashjs dev',
|
|
73
|
+
build: answers.css === 'tailwind' ? `glashjs typegen && ${cssBuild} && glashjs build` : 'glashjs typegen && glashjs build',
|
|
74
|
+
start: 'glashjs serve',
|
|
75
|
+
deploy: 'glashjs deploy',
|
|
76
|
+
typegen: 'glashjs typegen',
|
|
77
|
+
};
|
|
78
|
+
if (answers.css === 'tailwind') scripts['css:build'] = cssBuild;
|
|
79
|
+
if (answers.sqlRunner) scripts.sql = 'glashjs sql db/schema.sql';
|
|
80
|
+
|
|
81
|
+
const dependencies = {
|
|
82
|
+
glashjs: 'latest',
|
|
83
|
+
esbuild: '^0.28.0',
|
|
84
|
+
pg: '^8.16.3',
|
|
85
|
+
preact: '^10.29.2',
|
|
86
|
+
'preact-render-to-string': '^6.7.0',
|
|
87
|
+
};
|
|
88
|
+
const devDependencies = answers.css === 'tailwind'
|
|
89
|
+
? { tailwindcss: '^4.1.0', '@tailwindcss/cli': '^4.1.0' }
|
|
90
|
+
: {};
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
'package.json': json({
|
|
94
|
+
name: answers.name,
|
|
95
|
+
version: '0.1.0',
|
|
96
|
+
private: true,
|
|
97
|
+
type: 'module',
|
|
98
|
+
scripts,
|
|
99
|
+
dependencies,
|
|
100
|
+
...(Object.keys(devDependencies).length ? { devDependencies } : {}),
|
|
101
|
+
}),
|
|
102
|
+
'.gitignore': gitignore(),
|
|
103
|
+
'.env.example': envExample(answers),
|
|
104
|
+
'.env.local': envLocal(answers),
|
|
105
|
+
'glash.config.mjs': configFile(answers, hasCss),
|
|
106
|
+
'README.md': projectReadme(answers),
|
|
107
|
+
'routes/_layout.jsx': layoutRoute(),
|
|
108
|
+
'routes/index.jsx': indexRoute(answers),
|
|
109
|
+
'routes/api/health.mjs': healthRoute(),
|
|
110
|
+
'db/schema.sql': schemaSql(answers),
|
|
111
|
+
...(hasCss ? { 'public/app.css': appCss(answers.css) } : {}),
|
|
112
|
+
...(answers.css === 'tailwind' ? { 'styles/input.css': tailwindInput() } : {}),
|
|
113
|
+
...(answers.auth ? authRoutes() : {}),
|
|
114
|
+
...(answers.sqlRunner ? sqlRunnerRoutes() : {}),
|
|
115
|
+
...(answers.aiPrompts ? { '.glash/prompts/deploy.md': aiDeployPrompt(answers) } : {}),
|
|
116
|
+
'public/favicon.svg': faviconSvg(),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function configFile(answers, hasCss) {
|
|
121
|
+
return `import { defineConfig } from 'glashjs/config';
|
|
122
|
+
|
|
123
|
+
export default defineConfig({
|
|
124
|
+
name: '${titleCase(answers.name)}',
|
|
125
|
+
shortName: '${answers.name}',
|
|
126
|
+
routesDir: 'routes',
|
|
127
|
+
publicDir: 'public',
|
|
128
|
+
outDir: '.glash/out',
|
|
129
|
+
offline: true,
|
|
130
|
+
animatedFavicon: true,
|
|
131
|
+
stylesheets: ${hasCss ? "['/app.css']" : '[]'},
|
|
132
|
+
dataPrefixes: ['/api/', '/auth/', '/rest/', '/live', '/stream'],
|
|
133
|
+
security: {
|
|
134
|
+
csp: {
|
|
135
|
+
connectSrc: ["'self'", 'https://api.glashdb.com'],
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function layoutRoute() {
|
|
143
|
+
return `import { Link } from 'glashjs/link';
|
|
144
|
+
|
|
145
|
+
export default function RootLayout({ children }) {
|
|
146
|
+
return (
|
|
147
|
+
<div className="shell">
|
|
148
|
+
<header className="nav">
|
|
149
|
+
<Link href="/" className="brand">glashjs</Link>
|
|
150
|
+
<nav>
|
|
151
|
+
<Link href="/api/health">API</Link>
|
|
152
|
+
<Link href="/api/auth/me">Auth</Link>
|
|
153
|
+
</nav>
|
|
154
|
+
</header>
|
|
155
|
+
{children}
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function indexRoute(answers) {
|
|
163
|
+
return `import { useState } from 'preact/hooks';
|
|
164
|
+
import { callServerFunction } from 'glashjs/server-functions';
|
|
165
|
+
|
|
166
|
+
export const metadata = {
|
|
167
|
+
title: '${titleCase(answers.name)}',
|
|
168
|
+
description: 'A GlashDB-native full-stack app with framework, hosting, Postgres, auth, and deploy.',
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
export function getServerData() {
|
|
172
|
+
return {
|
|
173
|
+
features: [
|
|
174
|
+
'File-based routing',
|
|
175
|
+
'Typed routes',
|
|
176
|
+
'Server functions',
|
|
177
|
+
'API routes',
|
|
178
|
+
'Postgres by default',
|
|
179
|
+
'glashAuth built in',
|
|
180
|
+
'SQL runner support',
|
|
181
|
+
'AI deployment prompts',
|
|
182
|
+
'Zero-config hosting',
|
|
183
|
+
'Automatic env management',
|
|
184
|
+
'One-command deployment',
|
|
185
|
+
],
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export default function Home({ features = [] }) {
|
|
190
|
+
const [email, setEmail] = useState('');
|
|
191
|
+
const [message, setMessage] = useState('');
|
|
192
|
+
|
|
193
|
+
async function sendDemo() {
|
|
194
|
+
try {
|
|
195
|
+
const result = await callServerFunction('/api/functions/contact', { email });
|
|
196
|
+
setMessage(result.message);
|
|
197
|
+
} catch (error) {
|
|
198
|
+
setMessage(error.message);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return (
|
|
203
|
+
<main className="hero">
|
|
204
|
+
<p className="eyebrow">GlashDB native</p>
|
|
205
|
+
<h1>The Postgres-native full-stack framework for builders who want to ship without DevOps.</h1>
|
|
206
|
+
<p className="lede">
|
|
207
|
+
Framework + Hosting + Database + Auth + Deploy in one project. Start local, add real env vars, then ship with one command.
|
|
208
|
+
</p>
|
|
209
|
+
|
|
210
|
+
<section className="panel">
|
|
211
|
+
<div>
|
|
212
|
+
<h2>Server function demo</h2>
|
|
213
|
+
<p>Calls a file-based API route that runs only on the server.</p>
|
|
214
|
+
</div>
|
|
215
|
+
<div className="form">
|
|
216
|
+
<input value={email} onInput={(event) => setEmail(event.currentTarget.value)} placeholder="you@example.com" />
|
|
217
|
+
<button type="button" onClick={sendDemo}>Ping server</button>
|
|
218
|
+
</div>
|
|
219
|
+
{message ? <p className="result">{message}</p> : null}
|
|
220
|
+
</section>
|
|
221
|
+
|
|
222
|
+
<ul className="features">
|
|
223
|
+
{features.map((feature) => <li key={feature}>{feature}</li>)}
|
|
224
|
+
</ul>
|
|
225
|
+
</main>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function healthRoute() {
|
|
232
|
+
return `import { json } from 'glashjs';
|
|
233
|
+
import { optionalEnv } from 'glashjs/env';
|
|
234
|
+
|
|
235
|
+
export const GET = () => json({
|
|
236
|
+
ok: true,
|
|
237
|
+
framework: 'glashjs',
|
|
238
|
+
database: optionalEnv('DATABASE_URL') ? 'configured' : 'not configured',
|
|
239
|
+
auth: optionalEnv('GLASHDB_ANON_KEY') ? 'configured' : 'not configured',
|
|
240
|
+
});
|
|
241
|
+
`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function authRoutes() {
|
|
245
|
+
return {
|
|
246
|
+
'routes/api/auth/signup.mjs': `import { json } from 'glashjs';
|
|
247
|
+
import { glashAuth, sessionCookie } from 'glashjs/auth';
|
|
248
|
+
|
|
249
|
+
export const POST = async (ctx) => {
|
|
250
|
+
const auth = glashAuth();
|
|
251
|
+
const session = await auth.signup(ctx.body);
|
|
252
|
+
return json(session, { headers: { 'set-cookie': sessionCookie(session) } });
|
|
253
|
+
};
|
|
254
|
+
`,
|
|
255
|
+
'routes/api/auth/signin.mjs': `import { json } from 'glashjs';
|
|
256
|
+
import { glashAuth, sessionCookie } from 'glashjs/auth';
|
|
257
|
+
|
|
258
|
+
export const POST = async (ctx) => {
|
|
259
|
+
const auth = glashAuth();
|
|
260
|
+
const session = await auth.signin(ctx.body);
|
|
261
|
+
return json(session, { headers: { 'set-cookie': sessionCookie(session) } });
|
|
262
|
+
};
|
|
263
|
+
`,
|
|
264
|
+
'routes/api/auth/me.mjs': `import { json } from 'glashjs';
|
|
265
|
+
import { glashAuth, readSessionCookie } from 'glashjs/auth';
|
|
266
|
+
|
|
267
|
+
export const GET = async (ctx) => {
|
|
268
|
+
const session = readSessionCookie(ctx);
|
|
269
|
+
if (!session?.accessToken) return json({ user: null }, { status: 401 });
|
|
270
|
+
const user = await glashAuth().me(session.accessToken);
|
|
271
|
+
return json({ user });
|
|
272
|
+
};
|
|
273
|
+
`,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function sqlRunnerRoutes() {
|
|
278
|
+
return {
|
|
279
|
+
'routes/api/functions/contact.mjs': `import { serverFunction } from 'glashjs/server-functions';
|
|
280
|
+
import { sql } from 'glashjs/postgres';
|
|
281
|
+
|
|
282
|
+
export const POST = serverFunction(async ({ email }) => {
|
|
283
|
+
const value = String(email || '').trim();
|
|
284
|
+
if (!value.includes('@')) return { message: 'Add an email to test the server function.' };
|
|
285
|
+
await sql\`
|
|
286
|
+
insert into contacts (email)
|
|
287
|
+
values (\${value})
|
|
288
|
+
on conflict (email) do update set updated_at = now()
|
|
289
|
+
\`;
|
|
290
|
+
return { message: \`Saved \${value} in Postgres.\` };
|
|
291
|
+
});
|
|
292
|
+
`,
|
|
293
|
+
'routes/api/sql/run.mjs': `import { json } from 'glashjs';
|
|
294
|
+
import { query } from 'glashjs/postgres';
|
|
295
|
+
|
|
296
|
+
export const POST = async (ctx) => {
|
|
297
|
+
const expected = process.env.GLASH_SQL_RUNNER_TOKEN;
|
|
298
|
+
const token = String(ctx.headers.authorization || '').replace(/^Bearer\\s+/i, '');
|
|
299
|
+
if (!expected || token !== expected) return json({ error: 'SQL runner token required' }, { status: 401 });
|
|
300
|
+
const statement = String(ctx.body?.statement || '');
|
|
301
|
+
const params = Array.isArray(ctx.body?.params) ? ctx.body.params : [];
|
|
302
|
+
if (!statement.trim()) return json({ error: 'statement is required' }, { status: 400 });
|
|
303
|
+
const result = await query(statement, params);
|
|
304
|
+
return json({ rowCount: result.rowCount, rows: result.rows });
|
|
305
|
+
};
|
|
306
|
+
`,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function schemaSql() {
|
|
311
|
+
return `create table if not exists contacts (
|
|
312
|
+
id bigserial primary key,
|
|
313
|
+
email text unique not null,
|
|
314
|
+
created_at timestamptz not null default now(),
|
|
315
|
+
updated_at timestamptz not null default now()
|
|
316
|
+
);
|
|
317
|
+
`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function envExample(answers) {
|
|
321
|
+
return `# GlashDB fills these during hosted deploys. Keep local values in .env.local.
|
|
322
|
+
GLASHDB_API_URL="https://api.glashdb.com/api"
|
|
323
|
+
GLASHDB_PROJECT_ID=""
|
|
324
|
+
DATABASE_URL="postgresql://user:password@localhost:5432/${answers.name}?sslmode=disable"
|
|
325
|
+
DIRECT_URL=""
|
|
326
|
+
GLASHDB_ANON_KEY=""
|
|
327
|
+
GLASH_SQL_RUNNER_TOKEN="${randomToken()}"
|
|
328
|
+
`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function envLocal(answers) {
|
|
332
|
+
return `# Local development only. Do not commit real secrets.
|
|
333
|
+
GLASHDB_API_URL="https://api.glashdb.com/api"
|
|
334
|
+
GLASHDB_PROJECT_ID=""
|
|
335
|
+
DATABASE_URL="postgresql://user:password@localhost:5432/${answers.name}?sslmode=disable"
|
|
336
|
+
DIRECT_URL=""
|
|
337
|
+
GLASHDB_ANON_KEY=""
|
|
338
|
+
GLASH_SQL_RUNNER_TOKEN="${randomToken()}"
|
|
339
|
+
`;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function projectReadme(answers) {
|
|
343
|
+
const install = answers.install ? 'Dependencies were installed during create.' : `Install dependencies first:\n\n\`\`\`bash\n${answers.packageManager} install\n\`\`\``;
|
|
344
|
+
return `# ${titleCase(answers.name)}
|
|
345
|
+
|
|
346
|
+
Built with glashjs: The Postgres-native full-stack framework for builders who want to ship without DevOps.
|
|
347
|
+
|
|
348
|
+
${install}
|
|
349
|
+
|
|
350
|
+
## Run locally
|
|
351
|
+
|
|
352
|
+
\`\`\`bash
|
|
353
|
+
${answers.packageManager} run dev
|
|
354
|
+
\`\`\`
|
|
355
|
+
|
|
356
|
+
## Useful commands
|
|
357
|
+
|
|
358
|
+
\`\`\`bash
|
|
359
|
+
${answers.packageManager} run typegen # typed file-based routes
|
|
360
|
+
${answers.packageManager} run build # production build
|
|
361
|
+
${answers.packageManager} run start # serve the built app
|
|
362
|
+
${answers.packageManager} run deploy # one-command deployment to GlashDB
|
|
363
|
+
${answers.sqlRunner ? `${answers.packageManager} run sql # run db/schema.sql against DATABASE_URL` : ''}
|
|
364
|
+
\`\`\`
|
|
365
|
+
|
|
366
|
+
## First-class features in this starter
|
|
367
|
+
|
|
368
|
+
- File-based routing in \`routes/\`.
|
|
369
|
+
- Typed routes through \`glashjs typegen\`.
|
|
370
|
+
- Server functions under \`routes/api/functions/\`.
|
|
371
|
+
- API routes under \`routes/api/\`.
|
|
372
|
+
- Postgres by default through \`glashjs/postgres\`.
|
|
373
|
+
- glashAuth helpers through \`glashjs/auth\`.
|
|
374
|
+
- SQL runner support through \`glashjs sql\`.
|
|
375
|
+
- AI deployment prompts in \`.glash/prompts/\`.
|
|
376
|
+
- Zero-config hosting with \`glashjs deploy\`.
|
|
377
|
+
- Automatic env management with GlashDB project variables.
|
|
378
|
+
- One-command deployment.
|
|
379
|
+
|
|
380
|
+
Set real values in \`.env.local\` for local work, then use GlashDB env management for production.
|
|
381
|
+
`;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function aiDeployPrompt(answers) {
|
|
385
|
+
return `# GlashDB deployment prompt
|
|
386
|
+
|
|
387
|
+
You are preparing a glashjs app for deployment.
|
|
388
|
+
|
|
389
|
+
Project: ${answers.name}
|
|
390
|
+
Stack: framework + hosting + Postgres + glashAuth + deploy
|
|
391
|
+
|
|
392
|
+
Before deployment:
|
|
393
|
+
- Confirm \`npm run build\` passes.
|
|
394
|
+
- Confirm \`DATABASE_URL\`, \`GLASHDB_PROJECT_ID\`, and \`GLASHDB_ANON_KEY\` are set when the app uses database/auth.
|
|
395
|
+
- Keep \`public/\` assets deploy-safe. Use \`glash deploy --asset-mode auto\` for large media.
|
|
396
|
+
- Prefer one command: \`npm run deploy\`.
|
|
397
|
+
|
|
398
|
+
When logs fail, explain the failing route, env var, SQL statement, or package install step before changing code.
|
|
399
|
+
`;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function appCss(css) {
|
|
403
|
+
if (css === 'tailwind') {
|
|
404
|
+
return `@layer base {
|
|
405
|
+
:root { color-scheme: dark; }
|
|
406
|
+
}
|
|
407
|
+
`;
|
|
408
|
+
}
|
|
409
|
+
return `:root {
|
|
410
|
+
color-scheme: dark;
|
|
411
|
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
412
|
+
background: #050505;
|
|
413
|
+
color: #f5f5f5;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
* { box-sizing: border-box; }
|
|
417
|
+
body { margin: 0; min-height: 100vh; background: #050505; }
|
|
418
|
+
a { color: inherit; text-decoration: none; }
|
|
419
|
+
|
|
420
|
+
.shell { min-height: 100vh; max-width: 1120px; margin: 0 auto; padding: 24px; }
|
|
421
|
+
.nav { height: 56px; display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid #262626; }
|
|
422
|
+
.brand { font-weight: 700; }
|
|
423
|
+
.nav nav { display: flex; gap: 14px; color: #a3a3a3; font-size: 14px; }
|
|
424
|
+
.hero { padding: 72px 0; }
|
|
425
|
+
.eyebrow { color: #f97316; text-transform: uppercase; letter-spacing: .18em; font-size: 12px; }
|
|
426
|
+
h1 { max-width: 900px; font-size: clamp(42px, 7vw, 88px); line-height: 1.02; letter-spacing: 0; margin: 14px 0; }
|
|
427
|
+
.lede { color: #b5b5b5; max-width: 720px; font-size: 18px; line-height: 1.65; }
|
|
428
|
+
.panel { margin-top: 34px; border: 1px solid #262626; border-radius: 8px; padding: 22px; display: grid; gap: 16px; background: #0a0a0a; }
|
|
429
|
+
.panel h2 { margin: 0 0 6px; }
|
|
430
|
+
.panel p { margin: 0; color: #a3a3a3; }
|
|
431
|
+
.form { display: flex; flex-wrap: wrap; gap: 10px; }
|
|
432
|
+
input, button { min-height: 42px; border-radius: 6px; border: 1px solid #333; background: #111; color: #fff; padding: 0 12px; font: inherit; }
|
|
433
|
+
button { background: #f97316; border-color: #f97316; color: #111; font-weight: 700; cursor: pointer; }
|
|
434
|
+
.result { color: #fff !important; }
|
|
435
|
+
.features { margin: 28px 0 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 10px; list-style: none; }
|
|
436
|
+
.features li { border: 1px solid #262626; border-radius: 8px; padding: 14px; background: #0a0a0a; color: #d4d4d4; }
|
|
437
|
+
`;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function tailwindInput() {
|
|
441
|
+
return `@import "tailwindcss";
|
|
442
|
+
|
|
443
|
+
@source "../routes/**/*.{js,jsx,mjs,ts,tsx}";
|
|
444
|
+
|
|
445
|
+
@layer base {
|
|
446
|
+
:root {
|
|
447
|
+
color-scheme: dark;
|
|
448
|
+
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
|
449
|
+
background: #050505;
|
|
450
|
+
color: #f5f5f5;
|
|
451
|
+
}
|
|
452
|
+
body { margin: 0; min-height: 100vh; background: #050505; }
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
@layer components {
|
|
456
|
+
.shell { @apply min-h-screen mx-auto max-w-6xl px-6 py-6; }
|
|
457
|
+
.nav { @apply flex h-14 items-center justify-between gap-4 border-b border-neutral-800; }
|
|
458
|
+
.brand { @apply font-bold text-white; }
|
|
459
|
+
.nav nav { @apply flex gap-4 text-sm text-neutral-400; }
|
|
460
|
+
.hero { @apply py-20; }
|
|
461
|
+
.eyebrow { @apply text-xs uppercase tracking-[0.18em] text-orange-500; }
|
|
462
|
+
h1 { @apply mt-3 max-w-5xl text-5xl font-semibold leading-none text-white md:text-7xl; }
|
|
463
|
+
.lede { @apply mt-5 max-w-3xl text-lg leading-8 text-neutral-400; }
|
|
464
|
+
.panel { @apply mt-9 grid gap-4 rounded-lg border border-neutral-800 bg-neutral-950 p-6; }
|
|
465
|
+
.panel h2 { @apply text-xl font-semibold text-white; }
|
|
466
|
+
.panel p { @apply text-neutral-400; }
|
|
467
|
+
.form { @apply flex flex-wrap gap-3; }
|
|
468
|
+
input { @apply min-h-11 rounded-md border border-neutral-700 bg-neutral-900 px-3 text-white; }
|
|
469
|
+
button { @apply min-h-11 rounded-md bg-orange-500 px-4 font-bold text-neutral-950; }
|
|
470
|
+
.result { @apply text-white; }
|
|
471
|
+
.features { @apply mt-7 grid list-none gap-3 p-0 sm:grid-cols-2 lg:grid-cols-3; }
|
|
472
|
+
.features li { @apply rounded-lg border border-neutral-800 bg-neutral-950 p-4 text-neutral-300; }
|
|
473
|
+
}
|
|
474
|
+
`;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function faviconSvg() {
|
|
478
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
|
479
|
+
<rect width="64" height="64" rx="14" fill="#050505"/>
|
|
480
|
+
<circle cx="32" cy="32" r="17" fill="#f97316"/>
|
|
481
|
+
<circle cx="32" cy="32" r="7" fill="#050505"/>
|
|
482
|
+
</svg>
|
|
483
|
+
`;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function gitignore() {
|
|
487
|
+
return `node_modules
|
|
488
|
+
.env
|
|
489
|
+
.env.local
|
|
490
|
+
.glash/out
|
|
491
|
+
.DS_Store
|
|
492
|
+
npm-debug.log*
|
|
493
|
+
pnpm-debug.log*
|
|
494
|
+
yarn-debug.log*
|
|
495
|
+
`;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async function ask(rl, question, fallback) {
|
|
499
|
+
if (!rl) return fallback;
|
|
500
|
+
const answer = await rl.question(`${question} (${fallback}): `);
|
|
501
|
+
return answer.trim() || fallback;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function boolAnswer(value, fallback) {
|
|
505
|
+
if (typeof value === 'boolean') return value;
|
|
506
|
+
const raw = String(value ?? fallback ?? 'yes').trim().toLowerCase();
|
|
507
|
+
return ['y', 'yes', 'true', '1', 'on'].includes(raw);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function normalizeChoice(value, choices, fallback) {
|
|
511
|
+
const raw = String(value || '').trim().toLowerCase();
|
|
512
|
+
return choices.has(raw) ? raw : fallback;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function detectPackageManager() {
|
|
516
|
+
const userAgent = process.env.npm_config_user_agent || '';
|
|
517
|
+
if (userAgent.startsWith('pnpm')) return 'pnpm';
|
|
518
|
+
if (userAgent.startsWith('yarn')) return 'yarn';
|
|
519
|
+
if (userAgent.startsWith('bun')) return 'bun';
|
|
520
|
+
return 'npm';
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async function assertWritableTarget(target, force) {
|
|
524
|
+
try {
|
|
525
|
+
const entries = await fs.readdir(target);
|
|
526
|
+
const meaningful = entries.filter((name) => !['.DS_Store'].includes(name));
|
|
527
|
+
if (meaningful.length && !force) {
|
|
528
|
+
throw new Error(`Target directory is not empty: ${target}. Use --force to write into it.`);
|
|
529
|
+
}
|
|
530
|
+
} catch (error) {
|
|
531
|
+
if (error.code !== 'ENOENT') throw error;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async function writeFile(root, file, contents) {
|
|
536
|
+
const target = path.join(root, file);
|
|
537
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
538
|
+
await fs.writeFile(target, contents);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async function installDependencies(root, packageManager) {
|
|
542
|
+
const args = packageManager === 'npm' ? ['install']
|
|
543
|
+
: packageManager === 'pnpm' ? ['install']
|
|
544
|
+
: packageManager === 'yarn' ? ['install']
|
|
545
|
+
: ['install'];
|
|
546
|
+
await run(packageManager, args, root);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async function initGit(root) {
|
|
550
|
+
try { await run('git', ['init'], root, { silent: true }); } catch {}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function run(cmd, args, cwd, options = {}) {
|
|
554
|
+
return new Promise((resolve, reject) => {
|
|
555
|
+
const child = spawn(cmd, args, { cwd, stdio: options.silent ? 'ignore' : 'inherit' });
|
|
556
|
+
child.on('error', reject);
|
|
557
|
+
child.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(`${cmd} ${args.join(' ')} exited with code ${code}`))));
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function printNextSteps(target, answers, log) {
|
|
562
|
+
const rel = path.relative(process.cwd(), target) || '.';
|
|
563
|
+
log(`\nCreated glashjs project in ${rel}`);
|
|
564
|
+
log('\nNext steps:');
|
|
565
|
+
log(` cd ${shellQuote(rel)}`);
|
|
566
|
+
if (!answers.install) log(` ${answers.packageManager} install`);
|
|
567
|
+
log(` ${answers.packageManager} run dev`);
|
|
568
|
+
log(` ${answers.packageManager} run deploy`);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function slugify(value) {
|
|
572
|
+
return String(value || 'glash-app')
|
|
573
|
+
.trim()
|
|
574
|
+
.toLowerCase()
|
|
575
|
+
.replace(/[^a-z0-9._-]+/g, '-')
|
|
576
|
+
.replace(/^-+|-+$/g, '') || 'glash-app';
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function titleCase(value) {
|
|
580
|
+
return String(value).replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function randomToken() {
|
|
584
|
+
return randomBytes(24).toString('base64url');
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function shellQuote(value) {
|
|
588
|
+
return /^[a-zA-Z0-9_./-]+$/.test(value) ? value : JSON.stringify(value);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function json(value) {
|
|
592
|
+
return JSON.stringify(value, null, 2) + '\n';
|
|
593
|
+
}
|
package/src/env.mjs
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// glashjs environment helpers
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Small, runtime-safe helpers for the env contract GlashDB manages during
|
|
4
|
+
// deploy. They work locally from process.env and become zero-config once the
|
|
5
|
+
// project is hosted on GlashDB.
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
export function env(name, options = {}) {
|
|
10
|
+
const fallback = Object.prototype.hasOwnProperty.call(options, 'default') ? options.default : undefined;
|
|
11
|
+
const required = options.required ?? fallback === undefined;
|
|
12
|
+
const value = process.env[name] ?? fallback;
|
|
13
|
+
if (required && (value === undefined || value === '')) {
|
|
14
|
+
throw new Error(`Missing required environment variable: ${name}`);
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function optionalEnv(name, fallback = '') {
|
|
20
|
+
return env(name, { required: false, default: fallback });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function requireEnv(names) {
|
|
24
|
+
const out = {};
|
|
25
|
+
for (const name of names) out[name] = env(name);
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function publicEnv(prefixes = ['PUBLIC_', 'GLASH_PUBLIC_', 'NEXT_PUBLIC_']) {
|
|
30
|
+
const list = Array.isArray(prefixes) ? prefixes : [prefixes];
|
|
31
|
+
const out = {};
|
|
32
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
33
|
+
if (list.some((prefix) => key.startsWith(prefix))) out[key] = value;
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function glashProjectEnv() {
|
|
39
|
+
return {
|
|
40
|
+
projectId: optionalEnv('GLASHDB_PROJECT_ID', optionalEnv('GLASHAUTH_PROJECT_ID', '')),
|
|
41
|
+
apiUrl: optionalEnv('GLASHDB_API_URL', 'https://api.glashdb.com/api'),
|
|
42
|
+
databaseUrl: optionalEnv('DATABASE_URL', ''),
|
|
43
|
+
directUrl: optionalEnv('DIRECT_URL', ''),
|
|
44
|
+
anonKey: optionalEnv('GLASHDB_ANON_KEY', optionalEnv('GLASHAUTH_ANON_KEY', '')),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function loadEnvFiles(root = process.cwd(), options = {}) {
|
|
49
|
+
const mode = options.mode ?? process.env.NODE_ENV ?? 'development';
|
|
50
|
+
const locked = new Set(Object.keys(process.env));
|
|
51
|
+
const files = [
|
|
52
|
+
'.env',
|
|
53
|
+
'.env.local',
|
|
54
|
+
`.env.${mode}`,
|
|
55
|
+
`.env.${mode}.local`,
|
|
56
|
+
];
|
|
57
|
+
const loaded = [];
|
|
58
|
+
for (const file of files) {
|
|
59
|
+
const full = path.resolve(root, file);
|
|
60
|
+
if (!existsSync(full)) continue;
|
|
61
|
+
const pairs = parseEnv(readFileSync(full, 'utf8'));
|
|
62
|
+
for (const [key, value] of Object.entries(pairs)) {
|
|
63
|
+
if (options.override || !locked.has(key)) process.env[key] = value;
|
|
64
|
+
}
|
|
65
|
+
loaded.push(file);
|
|
66
|
+
}
|
|
67
|
+
return loaded;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseEnv(text) {
|
|
71
|
+
const out = {};
|
|
72
|
+
for (const line of text.split(/\r?\n/)) {
|
|
73
|
+
const trimmed = line.trim();
|
|
74
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
75
|
+
const match = /^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(trimmed);
|
|
76
|
+
if (!match) continue;
|
|
77
|
+
out[match[1]] = unquote(match[2]);
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function unquote(value) {
|
|
83
|
+
const trimmed = value.trim();
|
|
84
|
+
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
85
|
+
return trimmed.slice(1, -1).replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\'/g, "'");
|
|
86
|
+
}
|
|
87
|
+
return trimmed.replace(/\s+#.*$/, '');
|
|
88
|
+
}
|