create-flowdular 0.4.3 → 0.6.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.
Files changed (130) hide show
  1. package/README.md +16 -10
  2. package/agent-template/.agents/skills/agent-tool-design/SKILL.md +1 -1
  3. package/agent-template/.agents/skills/auth-security-review/SKILL.md +2 -2
  4. package/agent-template/.agents/skills/bug-hunt/SKILL.md +1 -1
  5. package/agent-template/.agents/skills/database-adapter/SKILL.md +5 -5
  6. package/agent-template/.agents/skills/database-adapter/references/first-run-and-matrix.md +2 -2
  7. package/agent-template/.agents/skills/deploy-operate/SKILL.md +1 -1
  8. package/agent-template/.agents/skills/migration-authoring/SKILL.md +4 -4
  9. package/agent-template/.agents/skills/module-new/SKILL.md +1 -1
  10. package/agent-template/.agents/skills/spec-interview/SKILL.md +20 -20
  11. package/agent-template/.agents/skills/test-hardening/SKILL.md +2 -2
  12. package/agent-template/.agents/skills/ux-design/SKILL.md +1 -1
  13. package/agent-template/.agents/skills/workflow-development/SKILL.md +95 -9
  14. package/agent-template/.ai/README.md +5 -3
  15. package/agent-template/.ai/agents/README.md +1 -1
  16. package/agent-template/.ai/agents/sandbox/agentic-engineer.md +1 -0
  17. package/agent-template/.ai/agents/sandbox/backend-engineer.md +1 -0
  18. package/agent-template/.ai/agents/sandbox/frontend-engineer.md +1 -0
  19. package/agent-template/.ai/blueprints/add-migration/README.md +1 -1
  20. package/agent-template/.ai/blueprints/add-migration/required-files.yaml +1 -1
  21. package/agent-template/.ai/blueprints/new-module/required-files.yaml +1 -1
  22. package/agent-template/.ai/examples/bad/client-imports-server/README.md +1 -1
  23. package/agent-template/.ai/examples/bad/missing-acl/README.md +1 -1
  24. package/agent-template/.ai/examples/bad/tenant-from-body/README.md +1 -1
  25. package/agent-template/.ai/guides/application-development.md +7 -5
  26. package/agent-template/.ai/platform-capabilities.md +9 -5
  27. package/agent-template/.ai/policies/capabilities.yaml +28 -12
  28. package/agent-template/.ai/policies/task-budgets.yaml +1 -1
  29. package/agent-template/.ai/rules/flowdular.md +3 -2
  30. package/agent-template/.ai/skills/README.md +1 -1
  31. package/agent-template/.ai/skills/agent-tool-design/SKILL.md +1 -1
  32. package/agent-template/.ai/skills/auth-security-review/SKILL.md +2 -2
  33. package/agent-template/.ai/skills/bug-hunt/SKILL.md +1 -1
  34. package/agent-template/.ai/skills/database-adapter/SKILL.md +5 -5
  35. package/agent-template/.ai/skills/database-adapter/references/first-run-and-matrix.md +2 -2
  36. package/agent-template/.ai/skills/deploy-operate/SKILL.md +1 -1
  37. package/agent-template/.ai/skills/migration-authoring/SKILL.md +4 -4
  38. package/agent-template/.ai/skills/module-new/SKILL.md +1 -1
  39. package/agent-template/.ai/skills/spec-interview/SKILL.md +20 -20
  40. package/agent-template/.ai/skills/test-hardening/SKILL.md +2 -2
  41. package/agent-template/.ai/skills/ux-design/SKILL.md +1 -1
  42. package/agent-template/.ai/skills/workflow-development/SKILL.md +96 -10
  43. package/agent-template/.ai/subagents/module-executor.md +25 -0
  44. package/agent-template/.ai/subagents/reviewer.md +23 -0
  45. package/agent-template/.ai/subagents/spec-author.md +23 -0
  46. package/agent-template/.claude/agents/module-executor.md +22 -0
  47. package/agent-template/.claude/agents/reviewer.md +24 -0
  48. package/agent-template/.claude/agents/spec-author.md +20 -0
  49. package/agent-template/.claude/skills/agent-tool-design/SKILL.md +1 -1
  50. package/agent-template/.claude/skills/auth-security-review/SKILL.md +2 -2
  51. package/agent-template/.claude/skills/bug-hunt/SKILL.md +1 -1
  52. package/agent-template/.claude/skills/database-adapter/SKILL.md +5 -5
  53. package/agent-template/.claude/skills/database-adapter/references/first-run-and-matrix.md +2 -2
  54. package/agent-template/.claude/skills/deploy-operate/SKILL.md +1 -1
  55. package/agent-template/.claude/skills/migration-authoring/SKILL.md +4 -4
  56. package/agent-template/.claude/skills/module-new/SKILL.md +1 -1
  57. package/agent-template/.claude/skills/spec-interview/SKILL.md +20 -20
  58. package/agent-template/.claude/skills/test-hardening/SKILL.md +2 -2
  59. package/agent-template/.claude/skills/ux-design/SKILL.md +1 -1
  60. package/agent-template/.claude/skills/workflow-development/SKILL.md +95 -9
  61. package/agent-template/.codex/agents/module-executor.toml +17 -0
  62. package/agent-template/.codex/agents/reviewer.toml +14 -0
  63. package/agent-template/.codex/agents/spec-author.toml +15 -0
  64. package/agent-template/AGENTS.md +3 -2
  65. package/agent-template/CLAUDE.md +3 -2
  66. package/agent-template/docs/adr/0007-module-owned-agents.md +35 -1
  67. package/agent-template/docs/agent-contract.md +2 -2
  68. package/agent-template/docs/cli.md +24 -3
  69. package/agent-template/docs/configuration.md +59 -5
  70. package/agent-template/docs/database-adapters.md +20 -20
  71. package/agent-template/docs/design-system.md +3 -3
  72. package/agent-template/docs/getting-started.md +25 -32
  73. package/agent-template/docs/module-distribution.md +79 -86
  74. package/agent-template/docs/module-web-surfaces.md +9 -7
  75. package/agent-template/docs/modules.md +9 -1
  76. package/agent-template/docs/sandbox.md +117 -6
  77. package/agent-template/platform/scripts/build.mjs +7 -0
  78. package/agent-template/rulesync.jsonc +1 -1
  79. package/dist/bin.js +12 -6
  80. package/package.json +2 -2
  81. package/template/default/.env.example +10 -3
  82. package/template/default/.prettierignore +2 -0
  83. package/template/default/.vercelignore +8 -0
  84. package/template/default/README.md +26 -15
  85. package/template/default/_gitignore +3 -2
  86. package/template/default/infra/README.md +86 -65
  87. package/template/default/infra/docker/.env.example +66 -0
  88. package/template/default/infra/docker/Dockerfile +24 -10
  89. package/template/default/infra/docker/app-entrypoint.mjs +5 -0
  90. package/template/default/infra/docker/compose.yaml +105 -58
  91. package/template/default/infra/docker/database-urls.mjs +28 -0
  92. package/template/default/infra/docker/pitr.sh +177 -0
  93. package/template/default/infra/docker/postgres/10-roles.sh +16 -12
  94. package/template/default/infra/docker/start.mjs +402 -0
  95. package/template/default/infra/kubernetes/database-secret.example.yaml +3 -3
  96. package/template/default/infra/vercel/README.md +262 -0
  97. package/template/default/infra/vercel/build.mjs +214 -0
  98. package/template/default/infra/vercel/handler.mjs +100 -0
  99. package/template/default/modules/example/migrations/0001_example_core.up.sql +2 -2
  100. package/template/default/modules/example/module.json +1 -1
  101. package/template/default/modules/example/package.json +3 -3
  102. package/template/default/modules/example/spec/module.yaml +1 -1
  103. package/template/default/modules/example/src/services/migration.ts +2 -2
  104. package/template/default/modules/example/tests/module.test.ts +1 -1
  105. package/template/default/package.json +3 -2
  106. package/template/default/platform/index.html +7 -19
  107. package/template/default/platform/octane.config.ts +252 -156
  108. package/template/default/platform/package.json +5 -5
  109. package/template/default/platform/public/favicon.svg +1 -1
  110. package/template/default/platform/scripts/build.mjs +56 -0
  111. package/template/default/platform/scripts/dev.mjs +38 -0
  112. package/template/default/platform/src/App.tsrx +25 -1
  113. package/template/default/platform/src/generated/modules.server.ts +3 -0
  114. package/template/default/platform/src/server/database.ts +24 -0
  115. package/template/default/platform/src/server/runtime-role.ts +33 -0
  116. package/template/default/platform/src/server/setup/access.ts +160 -0
  117. package/template/default/platform/src/server/setup/adapters.ts +554 -0
  118. package/template/default/platform/src/server/setup/environment.ts +154 -0
  119. package/template/default/platform/src/server/setup/gate.ts +84 -0
  120. package/template/default/platform/src/server/setup/index.ts +181 -0
  121. package/template/default/platform/src/server/setup/modules.ts +123 -0
  122. package/template/default/platform/src/server/setup/page.ts +497 -0
  123. package/template/default/platform/src/server/setup/routes.ts +787 -0
  124. package/template/default/platform/src/server/setup/sanitize.ts +111 -0
  125. package/template/default/platform/src/server/setup/seed.ts +145 -0
  126. package/template/default/platform/src/server/setup/token.ts +79 -0
  127. package/template/default/platform/src/server/worker-tick.ts +193 -0
  128. package/template/default/platform/src/server/workspace-root.ts +16 -0
  129. package/template/default/render.yaml +70 -0
  130. package/template/default/vercel.json +5 -0
@@ -0,0 +1,554 @@
1
+ import { flowdularStateDirectory } from '@flowdular/sdk/kernel/runtime-config';
2
+ import { mkdirSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ import {
5
+ createDatabaseAdapterRegistry,
6
+ databaseProviderConfigFromEnvironment,
7
+ DATABASE_ADAPTER_IDS,
8
+ DATABASE_CAPABILITY_IDS,
9
+ DATABASE_DIALECT_IDS,
10
+ type DatabaseAdapter,
11
+ type DatabaseAdapterConnectionInput,
12
+ type DatabaseAdapterDescriptor,
13
+ type DatabaseAdapterId,
14
+ type DatabaseAdapterLease,
15
+ type DatabaseAdapterProbeResult,
16
+ type DatabaseAdapterRegistry,
17
+ type DatabaseAdapterState,
18
+ type DatabaseAdapterValidationIssue,
19
+ type DatabaseRow,
20
+ type DatabaseTransaction,
21
+ } from '@flowdular/sdk/database';
22
+ import {
23
+ createPlatformDatabaseProvider,
24
+ type PlatformDatabaseProvider,
25
+ } from '../database.ts';
26
+ import { classifySetupFailure, SetupProbeError } from './sanitize.ts';
27
+
28
+ export const PGLITE_ADAPTER_ID = 'flowdular.pglite';
29
+ export const POSTGRESQL_ADAPTER_ID = DATABASE_ADAPTER_IDS.postgresql;
30
+
31
+ const PROBE_TIMEOUT_MS = 5_000;
32
+ const SETUP_NAMESPACE = 'flowdular.setup';
33
+
34
+ /* Mirrors what PostgresDatabaseAdapter advertises. Both first-run options run
35
+ that adapter, so the embedded database enforces the same forced row-level
36
+ security a server does; the readiness check proves it at runtime. */
37
+ const POSTGRES_PROFILE = Object.freeze({
38
+ features: Object.freeze([
39
+ DATABASE_CAPABILITY_IDS.MIGRATION_LOCK,
40
+ DATABASE_CAPABILITY_IDS.RETURNING,
41
+ DATABASE_CAPABILITY_IDS.ROW_LEVEL_SECURITY,
42
+ DATABASE_CAPABILITY_IDS.SCHEMA_INTROSPECTION,
43
+ DATABASE_CAPABILITY_IDS.TENANT_CONTEXT,
44
+ DATABASE_CAPABILITY_IDS.TRANSACTIONAL_DDL,
45
+ DATABASE_CAPABILITY_IDS.TRANSACTIONS,
46
+ ]),
47
+ isolationLevels: Object.freeze([
48
+ 'read-committed',
49
+ 'repeatable-read',
50
+ 'serializable',
51
+ ] as const),
52
+ });
53
+
54
+ export interface SetupAdapterOptions {
55
+ readonly workspaceRoot: string;
56
+ readonly production: boolean;
57
+ }
58
+
59
+ /**
60
+ * A registered adapter plus the two things the platform, not the contract,
61
+ * owns: the environment a deployment needs so this adapter becomes its
62
+ * database, and a provider built from that same environment.
63
+ */
64
+ export interface PlatformSetupAdapter {
65
+ readonly descriptor: DatabaseAdapterDescriptor;
66
+ environment(input: DatabaseAdapterConnectionInput): Record<string, string>;
67
+ openProvider(input: DatabaseAdapterConnectionInput): PlatformDatabaseProvider;
68
+ }
69
+
70
+ export interface SetupAdapters {
71
+ readonly registry: DatabaseAdapterRegistry;
72
+ get(adapterId: string): PlatformSetupAdapter | undefined;
73
+ list(): readonly PlatformSetupAdapter[];
74
+ }
75
+
76
+ function text(
77
+ input: DatabaseAdapterConnectionInput,
78
+ key: string,
79
+ ): string | undefined {
80
+ const value = input.config[key];
81
+ if (typeof value === 'string') return value.trim() || undefined;
82
+ if (typeof value === 'number') return String(value);
83
+ return undefined;
84
+ }
85
+
86
+ function secret(
87
+ input: DatabaseAdapterConnectionInput,
88
+ key: string,
89
+ ): string | undefined {
90
+ return input.secrets[key] || undefined;
91
+ }
92
+
93
+ export function setupSecretValues(
94
+ input: DatabaseAdapterConnectionInput,
95
+ ): readonly string[] {
96
+ return Object.values(input.secrets).filter((value) => value.length > 0);
97
+ }
98
+
99
+ function withTimeout<T>(work: Promise<T>, timeoutMs: number): Promise<T> {
100
+ let timer: NodeJS.Timeout | undefined;
101
+ return Promise.race([
102
+ work,
103
+ new Promise<never>((_resolve, reject) => {
104
+ timer = setTimeout(
105
+ () => reject(new SetupProbeError('TIMED_OUT')),
106
+ timeoutMs,
107
+ );
108
+ timer.unref?.();
109
+ }),
110
+ ]).finally(() => clearTimeout(timer));
111
+ }
112
+
113
+ /* A leased runtime handle presented as the adapter the descriptor contract
114
+ returns. Disposing it releases the lease and closes every pool the
115
+ configuration opened, so a probe leaves no connection behind. */
116
+ function leasedAdapter(
117
+ provider: PlatformDatabaseProvider,
118
+ lease: DatabaseAdapterLease,
119
+ ): DatabaseAdapter {
120
+ const handle = lease.database;
121
+ let state: DatabaseAdapterState = 'ready';
122
+ return {
123
+ get state() {
124
+ return state;
125
+ },
126
+ adapterId: handle.adapterId,
127
+ dialectId: handle.dialectId,
128
+ capabilities: handle.capabilities,
129
+ schema: handle.schema,
130
+ query<Row extends DatabaseRow = DatabaseRow>(
131
+ statement: Parameters<typeof handle.query>[0],
132
+ options?: Parameters<typeof handle.query>[1],
133
+ ) {
134
+ return handle.query<Row>(statement, options);
135
+ },
136
+ execute: (statement, options) => handle.execute(statement, options),
137
+ executeScript: (script, options) => handle.executeScript(script, options),
138
+ transaction<T>(
139
+ operation: (transaction: DatabaseTransaction) => Promise<T>,
140
+ options?: Parameters<typeof handle.transaction>[1],
141
+ ) {
142
+ return handle.transaction(operation, options);
143
+ },
144
+ async dispose() {
145
+ if (state !== 'ready') return;
146
+ state = 'disposing';
147
+ try {
148
+ await lease.release();
149
+ await provider.dispose();
150
+ } finally {
151
+ state = 'disposed';
152
+ }
153
+ },
154
+ };
155
+ }
156
+
157
+ /* Reachability is answered by the provider's own readiness check, which
158
+ already refuses a runtime role holding SUPERUSER or BYPASSRLS, plus a
159
+ background lease because auth.core takes one on its first request. A
160
+ deployment that cannot serve one would boot and then fail. */
161
+ async function probeProvider(
162
+ adapter: PlatformSetupAdapter,
163
+ input: DatabaseAdapterConnectionInput,
164
+ ): Promise<DatabaseAdapterProbeResult> {
165
+ const started = Date.now();
166
+ let provider: PlatformDatabaseProvider | undefined;
167
+ try {
168
+ provider = adapter.openProvider(input);
169
+ const opened = provider;
170
+ await withTimeout(
171
+ (async () => {
172
+ await opened.check();
173
+ const background = await opened.acquire({
174
+ namespace: SETUP_NAMESPACE,
175
+ purpose: 'background',
176
+ });
177
+ await background.release();
178
+ })(),
179
+ PROBE_TIMEOUT_MS,
180
+ );
181
+ return { status: 'ready', latencyMs: Date.now() - started };
182
+ } catch (error) {
183
+ return {
184
+ status: 'unavailable',
185
+ latencyMs: Date.now() - started,
186
+ message: classifySetupFailure(error, setupSecretValues(input)).message,
187
+ };
188
+ } finally {
189
+ await provider?.dispose().catch(() => undefined);
190
+ }
191
+ }
192
+
193
+ async function connectProvider(
194
+ adapter: PlatformSetupAdapter,
195
+ input: DatabaseAdapterConnectionInput,
196
+ ): Promise<DatabaseAdapter> {
197
+ const provider = adapter.openProvider(input);
198
+ try {
199
+ const lease = await provider.acquire({
200
+ namespace: SETUP_NAMESPACE,
201
+ purpose: 'runtime',
202
+ });
203
+ return leasedAdapter(provider, lease);
204
+ } catch (error) {
205
+ await provider.dispose().catch(() => undefined);
206
+ throw error;
207
+ }
208
+ }
209
+
210
+ function issue(
211
+ field: string,
212
+ code: string,
213
+ message: string,
214
+ ): DatabaseAdapterValidationIssue {
215
+ return { field, code, message };
216
+ }
217
+
218
+ const HOST = /^[A-Za-z0-9._:[\]-]{1,255}$/;
219
+ const IDENTIFIER = /^[A-Za-z0-9_$-]{1,63}$/;
220
+ const TLS_MODES = ['verify-full', 'require', 'disable'] as const;
221
+
222
+ function dsn(
223
+ input: DatabaseAdapterConnectionInput,
224
+ user: string,
225
+ password: string,
226
+ ): string {
227
+ const host = text(input, 'host') ?? '';
228
+ const port = text(input, 'port') ?? '5432';
229
+ const database = text(input, 'database') ?? '';
230
+ return `postgresql://${encodeURIComponent(user)}:${encodeURIComponent(
231
+ password,
232
+ )}@${host}:${port}/${encodeURIComponent(database)}`;
233
+ }
234
+
235
+ function createPostgresqlAdapter(
236
+ options: SetupAdapterOptions,
237
+ ): PlatformSetupAdapter {
238
+ const adapter: PlatformSetupAdapter = {
239
+ descriptor: {
240
+ adapterId: POSTGRESQL_ADAPTER_ID,
241
+ dialectId: DATABASE_DIALECT_IDS.postgresql,
242
+ label: 'PostgreSQL server',
243
+ description:
244
+ 'A PostgreSQL server you already run. The deployment connects with three roles: one that owns the schema, one tenant-scoped role that serves requests, and one read-only role for the few cross-tenant lookups.',
245
+ capabilities: POSTGRES_PROFILE,
246
+ configurationSchema: {
247
+ version: 1,
248
+ fields: [
249
+ {
250
+ key: 'host',
251
+ label: 'Host',
252
+ description: 'Host name or address of the PostgreSQL server.',
253
+ kind: 'text',
254
+ required: true,
255
+ secret: false,
256
+ },
257
+ {
258
+ key: 'port',
259
+ label: 'Port',
260
+ description: 'Port the server listens on.',
261
+ kind: 'integer',
262
+ required: true,
263
+ secret: false,
264
+ },
265
+ {
266
+ key: 'database',
267
+ label: 'Database',
268
+ description: 'An existing, empty database this deployment owns.',
269
+ kind: 'text',
270
+ required: true,
271
+ secret: false,
272
+ },
273
+ {
274
+ key: 'migrator-user',
275
+ label: 'Migration role',
276
+ description:
277
+ 'Owns the schema. Used only while migrations run, never to serve a request.',
278
+ kind: 'text',
279
+ required: true,
280
+ secret: false,
281
+ },
282
+ {
283
+ key: 'migrator-password',
284
+ label: 'Migration role password',
285
+ description: 'Password for the migration role.',
286
+ kind: 'text',
287
+ required: true,
288
+ secret: true,
289
+ },
290
+ {
291
+ key: 'runtime-user',
292
+ label: 'Runtime role',
293
+ description:
294
+ 'Serves every request. It must hold neither SUPERUSER nor BYPASSRLS, because tenant isolation depends on that.',
295
+ kind: 'text',
296
+ required: true,
297
+ secret: false,
298
+ },
299
+ {
300
+ key: 'runtime-password',
301
+ label: 'Runtime role password',
302
+ description: 'Password for the runtime role.',
303
+ kind: 'text',
304
+ required: true,
305
+ secret: true,
306
+ },
307
+ {
308
+ key: 'background-user',
309
+ label: 'Background role',
310
+ description:
311
+ 'Reads the routing columns a scheduler poll needs across tenants. It writes nothing.',
312
+ kind: 'text',
313
+ required: true,
314
+ secret: false,
315
+ },
316
+ {
317
+ key: 'background-password',
318
+ label: 'Background role password',
319
+ description: 'Password for the background role.',
320
+ kind: 'text',
321
+ required: true,
322
+ secret: true,
323
+ },
324
+ {
325
+ key: 'tls',
326
+ label: 'TLS',
327
+ description: options.production
328
+ ? 'A production deployment verifies the server certificate in full.'
329
+ : 'How this deployment verifies the server certificate.',
330
+ kind: 'select',
331
+ required: true,
332
+ secret: false,
333
+ options: (options.production
334
+ ? (['verify-full'] as const)
335
+ : TLS_MODES
336
+ ).map((mode) => ({ label: mode, value: mode })),
337
+ },
338
+ {
339
+ key: 'tls-authority-file',
340
+ label: 'Certificate authority file',
341
+ description:
342
+ 'Path to a PEM certificate authority, when the server presents a certificate this machine does not already trust.',
343
+ kind: 'text',
344
+ required: false,
345
+ secret: false,
346
+ },
347
+ ],
348
+ },
349
+ validate(input) {
350
+ const issues: DatabaseAdapterValidationIssue[] = [];
351
+ const host = text(input, 'host');
352
+ if (!host || !HOST.test(host)) {
353
+ issues.push(
354
+ issue('host', 'INVALID', 'Enter a host name or an address.'),
355
+ );
356
+ }
357
+ const port = Number(text(input, 'port'));
358
+ if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) {
359
+ issues.push(
360
+ issue('port', 'INVALID', 'Enter a port between 1 and 65535.'),
361
+ );
362
+ }
363
+ const database = text(input, 'database');
364
+ if (!database || !IDENTIFIER.test(database)) {
365
+ issues.push(
366
+ issue('database', 'INVALID', 'Enter an existing database name.'),
367
+ );
368
+ }
369
+ for (const role of ['migrator', 'runtime', 'background'] as const) {
370
+ const user = text(input, `${role}-user`);
371
+ if (!user || !IDENTIFIER.test(user)) {
372
+ issues.push(
373
+ issue(`${role}-user`, 'INVALID', 'Enter the role name.'),
374
+ );
375
+ }
376
+ if (!secret(input, `${role}-password`)) {
377
+ issues.push(
378
+ issue(
379
+ `${role}-password`,
380
+ 'REQUIRED',
381
+ 'Enter the password for this role.',
382
+ ),
383
+ );
384
+ }
385
+ }
386
+ const migrator = text(input, 'migrator-user');
387
+ const runtime = text(input, 'runtime-user');
388
+ if (migrator && runtime && migrator === runtime) {
389
+ issues.push(
390
+ issue(
391
+ 'runtime-user',
392
+ 'INVALID',
393
+ 'The runtime role must differ from the migration role, so a request can never run schema operations.',
394
+ ),
395
+ );
396
+ }
397
+ const tls = text(input, 'tls');
398
+ if (!tls || !TLS_MODES.includes(tls as (typeof TLS_MODES)[number])) {
399
+ issues.push(issue('tls', 'INVALID', 'Choose how TLS is verified.'));
400
+ } else if (options.production && tls !== 'verify-full') {
401
+ issues.push(
402
+ issue(
403
+ 'tls',
404
+ 'INVALID',
405
+ 'A production deployment requires full certificate verification.',
406
+ ),
407
+ );
408
+ }
409
+ return issues;
410
+ },
411
+ probe: (input) => probeProvider(adapter, input),
412
+ async provision(_input, authorization) {
413
+ if (authorization.intent !== 'confirmed-first-run') {
414
+ throw new Error('Provisioning requires a confirmed first run.');
415
+ }
416
+ /* The server, the database, and the three roles belong to the
417
+ operator. Creating them would need a superuser credential this
418
+ screen deliberately never asks for. */
419
+ },
420
+ connect: (input) => connectProvider(adapter, input),
421
+ },
422
+ environment(input) {
423
+ const authority = text(input, 'tls-authority-file');
424
+ return {
425
+ FD_DATABASE_ADAPTER: 'postgresql',
426
+ FD_DATABASE_URL: dsn(
427
+ input,
428
+ text(input, 'runtime-user') ?? '',
429
+ secret(input, 'runtime-password') ?? '',
430
+ ),
431
+ FD_DATABASE_MIGRATOR_URL: dsn(
432
+ input,
433
+ text(input, 'migrator-user') ?? '',
434
+ secret(input, 'migrator-password') ?? '',
435
+ ),
436
+ FD_DATABASE_BACKGROUND_URL: dsn(
437
+ input,
438
+ text(input, 'background-user') ?? '',
439
+ secret(input, 'background-password') ?? '',
440
+ ),
441
+ FD_DATABASE_TLS: text(input, 'tls') ?? 'verify-full',
442
+ ...(authority ? { FD_DATABASE_TLS_CA_FILE: authority } : {}),
443
+ };
444
+ },
445
+ openProvider(input) {
446
+ return createPlatformDatabaseProvider(
447
+ databaseProviderConfigFromEnvironment(
448
+ {
449
+ NODE_ENV: options.production ? 'production' : 'development',
450
+ FD_DATABASE_CONNECT_TIMEOUT_MS: String(PROBE_TIMEOUT_MS),
451
+ ...adapter.environment(input),
452
+ },
453
+ options.workspaceRoot,
454
+ ),
455
+ );
456
+ },
457
+ };
458
+ return adapter;
459
+ }
460
+
461
+ function createPgliteAdapter(
462
+ options: SetupAdapterOptions,
463
+ ): PlatformSetupAdapter {
464
+ const defaultDirectory = resolve(
465
+ flowdularStateDirectory(options.workspaceRoot),
466
+ 'data',
467
+ 'pglite',
468
+ );
469
+ const directoryOf = (input: DatabaseAdapterConnectionInput): string =>
470
+ resolve(
471
+ options.workspaceRoot,
472
+ text(input, 'data-directory') ?? defaultDirectory,
473
+ );
474
+ const adapter: PlatformSetupAdapter = {
475
+ descriptor: {
476
+ adapterId: PGLITE_ADAPTER_ID,
477
+ dialectId: DATABASE_DIALECT_IDS.postgresql,
478
+ label: 'Embedded PostgreSQL',
479
+ description:
480
+ 'PostgreSQL running inside this process, storing its data in a directory on this machine. Nothing to install and no credentials to manage; it enforces the same forced row-level security a server does.',
481
+ capabilities: POSTGRES_PROFILE,
482
+ configurationSchema: {
483
+ version: 1,
484
+ fields: [
485
+ {
486
+ key: 'data-directory',
487
+ label: 'Data directory',
488
+ description:
489
+ 'Where the database files live. Leave it empty to use the default below.',
490
+ kind: 'text',
491
+ required: false,
492
+ secret: false,
493
+ },
494
+ ],
495
+ },
496
+ validate(input) {
497
+ const directory = text(input, 'data-directory');
498
+ if (directory && /[\0]/.test(directory)) {
499
+ return [
500
+ issue('data-directory', 'INVALID', 'Enter a directory path.'),
501
+ ];
502
+ }
503
+ return [];
504
+ },
505
+ probe: (input) => probeProvider(adapter, input),
506
+ async provision(input, authorization) {
507
+ if (authorization.intent !== 'confirmed-first-run') {
508
+ throw new Error('Provisioning requires a confirmed first run.');
509
+ }
510
+ /* The only external state this adapter owns is its data directory,
511
+ and the database files inside it must not be world readable. */
512
+ mkdirSync(directoryOf(input), { recursive: true, mode: 0o700 });
513
+ },
514
+ connect: (input) => connectProvider(adapter, input),
515
+ },
516
+ environment: (input) => ({
517
+ FD_DATABASE_ADAPTER: 'pglite',
518
+ FD_DATABASE_PGLITE_DIRECTORY: directoryOf(input),
519
+ }),
520
+ openProvider(input) {
521
+ return createPlatformDatabaseProvider(
522
+ databaseProviderConfigFromEnvironment(
523
+ { NODE_ENV: 'development', ...adapter.environment(input) },
524
+ options.workspaceRoot,
525
+ ),
526
+ );
527
+ },
528
+ };
529
+ return adapter;
530
+ }
531
+
532
+ /**
533
+ * The adapters this deployment can be pointed at on a first run. The embedded
534
+ * database is absent in production: a deployment states its real database
535
+ * instead of shipping one inside the process.
536
+ */
537
+ export function createSetupAdapters(
538
+ options: SetupAdapterOptions,
539
+ ): SetupAdapters {
540
+ const registry = createDatabaseAdapterRegistry();
541
+ const adapters = new Map<DatabaseAdapterId, PlatformSetupAdapter>();
542
+ const register = (adapter: PlatformSetupAdapter): void => {
543
+ registry.register(adapter.descriptor);
544
+ adapters.set(adapter.descriptor.adapterId, adapter);
545
+ };
546
+ if (!options.production) register(createPgliteAdapter(options));
547
+ register(createPostgresqlAdapter(options));
548
+ registry.seal();
549
+ return {
550
+ registry,
551
+ get: (adapterId) => adapters.get(adapterId),
552
+ list: () => [...adapters.values()],
553
+ };
554
+ }
@@ -0,0 +1,154 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import {
3
+ lstatSync,
4
+ readFileSync,
5
+ renameSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from 'node:fs';
9
+ import { resolve } from 'node:path';
10
+
11
+ /* Writing configuration is a real capability and a deployment may withhold it
12
+ on purpose: infra/docker/compose.yaml sets read_only: true. A refused write
13
+ is reported as a refused write, and the operator gets the exact block to
14
+ paste into their orchestrator instead of a success that did not happen. */
15
+
16
+ export type EnvironmentWriteStatus =
17
+ | 'failed'
18
+ | 'read-only'
19
+ | 'unchanged'
20
+ | 'written';
21
+
22
+ export interface EnvironmentWriteResult {
23
+ readonly status: EnvironmentWriteStatus;
24
+ readonly path: string;
25
+ /** Keys this run added to the file. */
26
+ readonly added: readonly string[];
27
+ /** Keys the file already set. An existing value is never replaced. */
28
+ readonly kept: readonly string[];
29
+ /** The block to paste when this deployment cannot persist it itself. */
30
+ readonly block: string;
31
+ }
32
+
33
+ const BARE_VALUE = /^[A-Za-z0-9_@%:/.,+~=?&[\]{}!*()$^-]+$/;
34
+ const KEY_LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/;
35
+
36
+ function quote(value: string): string {
37
+ if (BARE_VALUE.test(value)) return value;
38
+ if (value.includes('"') || value.includes('\\')) {
39
+ throw new Error(
40
+ 'A value containing a quote or a backslash cannot be stored in a .env file.',
41
+ );
42
+ }
43
+ return `"${value}"`;
44
+ }
45
+
46
+ function existingKeys(contents: string): ReadonlySet<string> {
47
+ const keys = new Set<string>();
48
+ for (const line of contents.split('\n')) {
49
+ const match = KEY_LINE.exec(line);
50
+ if (match?.[1]) keys.add(match[1]);
51
+ }
52
+ return keys;
53
+ }
54
+
55
+ export function renderEnvironmentBlock(
56
+ values: Readonly<Record<string, string>>,
57
+ ): string {
58
+ return Object.entries(values)
59
+ .map(([key, value]) => `${key}=${quote(value)}`)
60
+ .join('\n');
61
+ }
62
+
63
+ function isReadOnly(error: unknown): boolean {
64
+ const code =
65
+ typeof error === 'object' && error !== null && 'code' in error
66
+ ? String((error as { code: unknown }).code)
67
+ : '';
68
+ return code === 'EROFS' || code === 'EACCES' || code === 'EPERM';
69
+ }
70
+
71
+ /**
72
+ * Adds the keys this deployment is missing to `<workspaceRoot>/.env`, owner
73
+ * readable only. A key the file already sets is left exactly as it is, so a
74
+ * second run can never quietly re-point a database that is already configured.
75
+ */
76
+ export function writeEnvironmentFile(
77
+ workspaceRoot: string,
78
+ values: Readonly<Record<string, string>>,
79
+ ): EnvironmentWriteResult {
80
+ const path = resolve(workspaceRoot, '.env');
81
+ let block: string;
82
+ try {
83
+ block = renderEnvironmentBlock(values);
84
+ } catch {
85
+ /* A value a .env file cannot represent unambiguously. Reporting the keys
86
+ is the whole answer; writing a file that parses back differently is
87
+ the one outcome worse than not writing at all. */
88
+ return {
89
+ status: 'failed',
90
+ path,
91
+ added: [],
92
+ kept: [],
93
+ block: Object.keys(values).join('\n'),
94
+ };
95
+ }
96
+ let contents = '';
97
+ try {
98
+ const stat = lstatSync(path);
99
+ if (!stat.isFile() || stat.isSymbolicLink()) {
100
+ return { status: 'failed', path, added: [], kept: [], block };
101
+ }
102
+ contents = readFileSync(path, 'utf8');
103
+ } catch (error) {
104
+ /* Only a missing file may be created. An unreadable file must not be
105
+ treated as empty and replaced by the atomic rename below. */
106
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
107
+ return {
108
+ status: isReadOnly(error) ? 'read-only' : 'failed',
109
+ path,
110
+ added: [],
111
+ kept: [],
112
+ block,
113
+ };
114
+ }
115
+ }
116
+ const present = existingKeys(contents);
117
+ const added: string[] = [];
118
+ const kept: string[] = [];
119
+ for (const key of Object.keys(values)) {
120
+ if (present.has(key)) kept.push(key);
121
+ else added.push(key);
122
+ }
123
+ if (added.length === 0) {
124
+ return { status: 'unchanged', path, added, kept, block };
125
+ }
126
+ const appended = added.map((key) => `${key}=${quote(values[key]!)}`);
127
+ const next = [
128
+ ...(contents.length > 0 ? [contents.replace(/\n*$/, '\n')] : []),
129
+ '# Written by the Flowdular first-run setup.\n',
130
+ `${appended.join('\n')}\n`,
131
+ ].join('');
132
+ /* A partial .env is worse than none, and a read-only mount must leave no
133
+ trace at all, so the file appears only once it is complete. */
134
+ const temporary = `${path}.${randomBytes(6).toString('hex')}.tmp`;
135
+ try {
136
+ writeFileSync(temporary, next, { encoding: 'utf8', mode: 0o600 });
137
+ renameSync(temporary, path);
138
+ } catch (error) {
139
+ try {
140
+ rmSync(temporary, { force: true });
141
+ } catch {
142
+ /* The temporary file was never created on a filesystem that refused
143
+ the write, so there is nothing to clean up. */
144
+ }
145
+ return {
146
+ status: isReadOnly(error) ? 'read-only' : 'failed',
147
+ path,
148
+ added: [],
149
+ kept,
150
+ block,
151
+ };
152
+ }
153
+ return { status: 'written', path, added, kept, block };
154
+ }