create-flowdular 0.5.1 → 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 (92) hide show
  1. package/README.md +8 -5
  2. package/agent-template/.agents/skills/agent-tool-design/SKILL.md +1 -1
  3. package/agent-template/.agents/skills/auth-security-review/SKILL.md +1 -1
  4. package/agent-template/.agents/skills/database-adapter/SKILL.md +5 -5
  5. package/agent-template/.agents/skills/database-adapter/references/first-run-and-matrix.md +2 -2
  6. package/agent-template/.agents/skills/deploy-operate/SKILL.md +1 -1
  7. package/agent-template/.agents/skills/migration-authoring/SKILL.md +4 -4
  8. package/agent-template/.agents/skills/module-new/SKILL.md +1 -1
  9. package/agent-template/.agents/skills/test-hardening/SKILL.md +2 -2
  10. package/agent-template/.agents/skills/workflow-development/SKILL.md +95 -9
  11. package/agent-template/.ai/blueprints/add-migration/required-files.yaml +1 -1
  12. package/agent-template/.ai/blueprints/new-module/required-files.yaml +1 -1
  13. package/agent-template/.ai/platform-capabilities.md +7 -5
  14. package/agent-template/.ai/policies/capabilities.yaml +28 -12
  15. package/agent-template/.ai/skills/README.md +1 -1
  16. package/agent-template/.ai/skills/agent-tool-design/SKILL.md +1 -1
  17. package/agent-template/.ai/skills/auth-security-review/SKILL.md +1 -1
  18. package/agent-template/.ai/skills/database-adapter/SKILL.md +5 -5
  19. package/agent-template/.ai/skills/database-adapter/references/first-run-and-matrix.md +2 -2
  20. package/agent-template/.ai/skills/deploy-operate/SKILL.md +1 -1
  21. package/agent-template/.ai/skills/migration-authoring/SKILL.md +4 -4
  22. package/agent-template/.ai/skills/module-new/SKILL.md +1 -1
  23. package/agent-template/.ai/skills/test-hardening/SKILL.md +2 -2
  24. package/agent-template/.ai/skills/workflow-development/SKILL.md +96 -10
  25. package/agent-template/.claude/skills/agent-tool-design/SKILL.md +1 -1
  26. package/agent-template/.claude/skills/auth-security-review/SKILL.md +1 -1
  27. package/agent-template/.claude/skills/database-adapter/SKILL.md +5 -5
  28. package/agent-template/.claude/skills/database-adapter/references/first-run-and-matrix.md +2 -2
  29. package/agent-template/.claude/skills/deploy-operate/SKILL.md +1 -1
  30. package/agent-template/.claude/skills/migration-authoring/SKILL.md +4 -4
  31. package/agent-template/.claude/skills/module-new/SKILL.md +1 -1
  32. package/agent-template/.claude/skills/test-hardening/SKILL.md +2 -2
  33. package/agent-template/.claude/skills/workflow-development/SKILL.md +95 -9
  34. package/agent-template/docs/adr/0007-module-owned-agents.md +35 -1
  35. package/agent-template/docs/agent-contract.md +2 -2
  36. package/agent-template/docs/cli.md +24 -3
  37. package/agent-template/docs/configuration.md +32 -5
  38. package/agent-template/docs/database-adapters.md +20 -20
  39. package/agent-template/docs/design-system.md +1 -1
  40. package/agent-template/docs/getting-started.md +25 -32
  41. package/agent-template/docs/module-distribution.md +79 -86
  42. package/agent-template/docs/module-web-surfaces.md +9 -7
  43. package/agent-template/docs/modules.md +3 -1
  44. package/agent-template/platform/scripts/build.mjs +7 -0
  45. package/dist/bin.js +3 -6
  46. package/package.json +1 -1
  47. package/template/default/.env.example +3 -3
  48. package/template/default/.vercelignore +8 -0
  49. package/template/default/README.md +20 -11
  50. package/template/default/_gitignore +3 -2
  51. package/template/default/infra/README.md +86 -65
  52. package/template/default/infra/docker/.env.example +66 -0
  53. package/template/default/infra/docker/Dockerfile +24 -10
  54. package/template/default/infra/docker/app-entrypoint.mjs +5 -0
  55. package/template/default/infra/docker/compose.yaml +105 -58
  56. package/template/default/infra/docker/database-urls.mjs +28 -0
  57. package/template/default/infra/docker/pitr.sh +177 -0
  58. package/template/default/infra/docker/postgres/10-roles.sh +16 -12
  59. package/template/default/infra/docker/start.mjs +402 -0
  60. package/template/default/infra/kubernetes/database-secret.example.yaml +3 -3
  61. package/template/default/infra/vercel/README.md +262 -0
  62. package/template/default/infra/vercel/build.mjs +214 -0
  63. package/template/default/infra/vercel/handler.mjs +100 -0
  64. package/template/default/modules/example/migrations/0001_example_core.up.sql +2 -2
  65. package/template/default/modules/example/module.json +1 -1
  66. package/template/default/modules/example/package.json +3 -3
  67. package/template/default/modules/example/spec/module.yaml +1 -1
  68. package/template/default/modules/example/src/services/migration.ts +2 -2
  69. package/template/default/modules/example/tests/module.test.ts +1 -1
  70. package/template/default/package.json +2 -2
  71. package/template/default/platform/octane.config.ts +252 -156
  72. package/template/default/platform/package.json +5 -5
  73. package/template/default/platform/scripts/build.mjs +56 -0
  74. package/template/default/platform/scripts/dev.mjs +38 -0
  75. package/template/default/platform/src/generated/modules.server.ts +1 -0
  76. package/template/default/platform/src/server/database.ts +24 -0
  77. package/template/default/platform/src/server/runtime-role.ts +33 -0
  78. package/template/default/platform/src/server/setup/access.ts +160 -0
  79. package/template/default/platform/src/server/setup/adapters.ts +554 -0
  80. package/template/default/platform/src/server/setup/environment.ts +154 -0
  81. package/template/default/platform/src/server/setup/gate.ts +84 -0
  82. package/template/default/platform/src/server/setup/index.ts +181 -0
  83. package/template/default/platform/src/server/setup/modules.ts +123 -0
  84. package/template/default/platform/src/server/setup/page.ts +497 -0
  85. package/template/default/platform/src/server/setup/routes.ts +787 -0
  86. package/template/default/platform/src/server/setup/sanitize.ts +111 -0
  87. package/template/default/platform/src/server/setup/seed.ts +145 -0
  88. package/template/default/platform/src/server/setup/token.ts +79 -0
  89. package/template/default/platform/src/server/worker-tick.ts +193 -0
  90. package/template/default/platform/src/server/workspace-root.ts +16 -0
  91. package/template/default/render.yaml +70 -0
  92. package/template/default/vercel.json +5 -0
@@ -0,0 +1,84 @@
1
+ import { ServerRoute, type Context, type Middleware } from '@octanejs/app-core';
2
+
3
+ export interface FirstRunGate {
4
+ /** Runs ahead of authentication. Until a workspace exists it answers every
5
+ * request itself, except the pass-through paths. */
6
+ readonly middleware: Middleware;
7
+ /** Global middleware runs only for a matched route, so /setup needs one. */
8
+ readonly routes: readonly ServerRoute[];
9
+ }
10
+
11
+ export interface FirstRunGateOptions {
12
+ readonly applicationPath: string;
13
+ /** Served normally before the first workspace exists, such as /api/ready. */
14
+ readonly passThrough: readonly string[];
15
+ readonly workspaceExists: () => Promise<boolean>;
16
+ /** Builds the /setup handler. `claimed` opens this instance at once. */
17
+ readonly setup: (
18
+ claimed: () => void,
19
+ ) => (context: Context) => Promise<Response>;
20
+ }
21
+
22
+ function redirect(location: string): Response {
23
+ return new Response(null, {
24
+ status: 303,
25
+ headers: { location, 'cache-control': 'no-store' },
26
+ });
27
+ }
28
+
29
+ export function createFirstRunGate(options: FirstRunGateOptions): FirstRunGate {
30
+ let open = false;
31
+ let checking: Promise<boolean> | null = null;
32
+ const setup = options.setup(() => {
33
+ open = true;
34
+ });
35
+ /* Asked on every request until it first answers yes, so an instance that
36
+ did not run setup itself opens on its next request after another did.
37
+ Concurrent requests share one query; a failed one keeps the gate shut. */
38
+ const workspaceExists = (): Promise<boolean> =>
39
+ (checking ??= options.workspaceExists().then(
40
+ (exists) => {
41
+ checking = null;
42
+ if (exists) open = true;
43
+ return exists;
44
+ },
45
+ () => {
46
+ checking = null;
47
+ return false;
48
+ },
49
+ ));
50
+ const middleware: Middleware = async (context, next) => {
51
+ if (open || (await workspaceExists())) return next();
52
+ const { pathname } = context.url;
53
+ if (pathname === '/setup') return setup(context);
54
+ if (options.passThrough.includes(pathname)) return next();
55
+ const method = context.request.method;
56
+ if (
57
+ (method === 'GET' || method === 'HEAD') &&
58
+ pathname !== '/api' &&
59
+ !pathname.startsWith('/api/')
60
+ ) {
61
+ return redirect('/setup');
62
+ }
63
+ return Response.json(
64
+ {
65
+ error: {
66
+ code: 'PLATFORM_NOT_CONFIGURED',
67
+ message:
68
+ 'Complete the first-run setup at /setup before using the platform.',
69
+ },
70
+ },
71
+ { status: 503, headers: { 'cache-control': 'no-store' } },
72
+ );
73
+ };
74
+ return {
75
+ middleware,
76
+ routes: [
77
+ new ServerRoute({
78
+ path: '/setup',
79
+ methods: ['GET', 'POST'],
80
+ handler: () => redirect(options.applicationPath),
81
+ }),
82
+ ],
83
+ };
84
+ }
@@ -0,0 +1,181 @@
1
+ import process from 'node:process';
2
+ import type { ServerRoute } from '@octanejs/app-core';
3
+ import {
4
+ authRuntimeOptionsFromEnvironment,
5
+ createAuthRuntime,
6
+ } from '@flowdular/sdk/modules/auth/server';
7
+ import {
8
+ createPlatformDatabaseProvider,
9
+ databaseProviderConfigFromEnvironment,
10
+ } from '../database.ts';
11
+ import { createSetupAccess } from './access.ts';
12
+ import { createSetupAdapters } from './adapters.ts';
13
+ import { createFirstRunGate, type FirstRunGate } from './gate.ts';
14
+ import { enabledDatabaseModules } from './modules.ts';
15
+ import { createSetupHandler, createSetupRoutes } from './routes.ts';
16
+ import { issueSetupToken } from './token.ts';
17
+
18
+ export { clearSetupToken } from './token.ts';
19
+
20
+ const FIRST_RUN_SYMBOL = Symbol.for('flowdular.platform.first-run-setup');
21
+
22
+ export interface FirstRunSetup {
23
+ readonly routes: readonly ServerRoute[];
24
+ readonly token: string;
25
+ readonly tokenFile: string | null;
26
+ }
27
+
28
+ export interface FirstRunSetupOptions {
29
+ readonly environment: NodeJS.ProcessEnv;
30
+ readonly workspaceRoot: string;
31
+ readonly databasePreconfigured?: boolean;
32
+ readonly applicationPath?: string;
33
+ readonly webMountPaths?: readonly string[];
34
+ readonly log?: (message: string) => void;
35
+ }
36
+
37
+ /** A reachable, empty configured database still needs its first owner.
38
+ * Auth's normal migration path is used before the read, and an outage throws
39
+ * rather than accidentally opening setup against an existing installation.
40
+ */
41
+ export async function configuredDatabaseNeedsFirstRun(
42
+ environment: NodeJS.ProcessEnv,
43
+ workspaceRoot: string,
44
+ ): Promise<boolean> {
45
+ const databases = createPlatformDatabaseProvider(
46
+ databaseProviderConfigFromEnvironment(environment, workspaceRoot),
47
+ );
48
+ try {
49
+ await databases.check();
50
+ const auth = createAuthRuntime({
51
+ ...authRuntimeOptionsFromEnvironment(environment, workspaceRoot),
52
+ databases,
53
+ });
54
+ try {
55
+ return !(await (await auth.service()).hasAnyTenant());
56
+ } finally {
57
+ await auth.dispose();
58
+ }
59
+ } finally {
60
+ await databases.dispose();
61
+ }
62
+ }
63
+
64
+ type ProcessWithSetup = NodeJS.Process & {
65
+ [FIRST_RUN_SYMBOL]?: FirstRunSetup;
66
+ };
67
+
68
+ function origin(environment: NodeJS.ProcessEnv): string {
69
+ const configured = environment.FD_AUTH_PUBLIC_ORIGIN?.trim();
70
+ if (configured) return configured.replace(/\/+$/, '');
71
+ return `http://127.0.0.1:${environment.PORT?.trim() || '4310'}`;
72
+ }
73
+
74
+ /**
75
+ * The routes a deployment serves while it has no database. Held on the process
76
+ * so a development re-evaluation of the configuration keeps the token, the
77
+ * lockout counter, and the operator's half-finished session alive; a
78
+ * production process evaluates the configuration once, so this is a plain
79
+ * construction there.
80
+ */
81
+ export function createFirstRunSetup(
82
+ options: FirstRunSetupOptions,
83
+ ): FirstRunSetup {
84
+ const owner = process as ProcessWithSetup;
85
+ const existing = owner[FIRST_RUN_SYMBOL];
86
+ if (existing) return existing;
87
+ const production = options.environment.NODE_ENV === 'production';
88
+ const issued = issueSetupToken({
89
+ workspaceRoot: options.workspaceRoot,
90
+ origin: origin(options.environment),
91
+ });
92
+ const adapters = createSetupAdapters({
93
+ workspaceRoot: options.workspaceRoot,
94
+ production,
95
+ });
96
+ const enabled = enabledDatabaseModules(options.workspaceRoot);
97
+ const setup: FirstRunSetup = {
98
+ token: issued.token,
99
+ tokenFile: issued.file,
100
+ routes: createSetupRoutes({
101
+ environment: options.environment,
102
+ databasePreconfigured: options.databasePreconfigured ?? false,
103
+ defaultApplicationPath: options.applicationPath ?? '/app',
104
+ webMountPaths: options.webMountPaths ?? [],
105
+ workspaceRoot: options.workspaceRoot,
106
+ adapters,
107
+ access: createSetupAccess(issued.token),
108
+ modules: enabled.modules,
109
+ modulesApproximated: enabled.approximated,
110
+ tokenFile: issued.file,
111
+ secureCookies:
112
+ options.environment.FD_AUTH_SECURE_COOKIE === 'false'
113
+ ? false
114
+ : production,
115
+ }),
116
+ };
117
+ owner[FIRST_RUN_SYMBOL] = setup;
118
+ (options.log ?? console.log)(issued.banner);
119
+ return setup;
120
+ }
121
+
122
+ export interface InPlaceFirstRunOptions {
123
+ readonly environment: NodeJS.ProcessEnv;
124
+ readonly workspaceRoot: string;
125
+ readonly applicationPath: string;
126
+ readonly webMountPaths: readonly string[];
127
+ readonly passThrough: readonly string[];
128
+ readonly workspaceExists: () => Promise<boolean>;
129
+ readonly log?: (message: string) => void;
130
+ }
131
+
132
+ /**
133
+ * First run for a deployment that can neither restart itself nor write files,
134
+ * such as a Vercel Function: the composed application serves setup behind a
135
+ * gate until a workspace exists. The deploy command keeps the token and the
136
+ * deployment holds only its SHA-256, so nothing is issued or written here.
137
+ */
138
+ export function createInPlaceFirstRun(
139
+ options: InPlaceFirstRunOptions,
140
+ ): FirstRunGate {
141
+ const digest = options.environment.FD_SETUP_TOKEN_SHA256?.trim();
142
+ if (!digest) {
143
+ throw new Error(
144
+ 'The database has no workspace yet and FD_SETUP_TOKEN_SHA256 is not set. Run flowdular deploy start vercel --apply, which sets it and prints the setup token.',
145
+ );
146
+ }
147
+ const access = createSetupAccess({ sha256: digest });
148
+ const production = options.environment.NODE_ENV === 'production';
149
+ const adapters = createSetupAdapters({
150
+ workspaceRoot: options.workspaceRoot,
151
+ production,
152
+ });
153
+ const enabled = enabledDatabaseModules(options.workspaceRoot);
154
+ const gate = createFirstRunGate({
155
+ applicationPath: options.applicationPath,
156
+ passThrough: options.passThrough,
157
+ workspaceExists: options.workspaceExists,
158
+ setup: (claimed) =>
159
+ createSetupHandler({
160
+ environment: options.environment,
161
+ databasePreconfigured: true,
162
+ defaultApplicationPath: options.applicationPath,
163
+ webMountPaths: options.webMountPaths,
164
+ workspaceRoot: options.workspaceRoot,
165
+ adapters,
166
+ access,
167
+ modules: enabled.modules,
168
+ modulesApproximated: enabled.approximated,
169
+ tokenFile: null,
170
+ secureCookies:
171
+ options.environment.FD_AUTH_SECURE_COOKIE === 'false'
172
+ ? false
173
+ : production,
174
+ inPlace: { onClaimed: claimed },
175
+ }),
176
+ });
177
+ (options.log ?? console.log)(
178
+ 'No workspace exists yet. Open /setup and enter the setup token the deploy command printed.',
179
+ );
180
+ return gate;
181
+ }
@@ -0,0 +1,123 @@
1
+ import { readdirSync, readFileSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import {
4
+ DATABASE_CAPABILITY_IDS,
5
+ DATABASE_DIALECT_IDS,
6
+ type ModuleDatabaseRequirements,
7
+ } from '@flowdular/sdk/database';
8
+ import projectManifest from '../../../../flowdular.json';
9
+
10
+ /**
11
+ * What every database-owning module in this repository asks its lease for. The
12
+ * module manifest carries no machine-readable database block yet, so the
13
+ * platform applies the strictest set any enabled module requests rather than
14
+ * guessing a looser one per module. The precise fix is a `database` block in
15
+ * packages/contracts/schemas/module.schema.json that each module fills in.
16
+ */
17
+ export const PLATFORM_MODULE_DATABASE_REQUIREMENTS = Object.freeze({
18
+ dialectIds: Object.freeze([DATABASE_DIALECT_IDS.postgresql]),
19
+ capabilities: Object.freeze([
20
+ DATABASE_CAPABILITY_IDS.MIGRATION_LOCK,
21
+ DATABASE_CAPABILITY_IDS.SCHEMA_INTROSPECTION,
22
+ DATABASE_CAPABILITY_IDS.TRANSACTIONAL_DDL,
23
+ DATABASE_CAPABILITY_IDS.TRANSACTIONS,
24
+ ]),
25
+ });
26
+
27
+ export interface EnabledDatabaseModules {
28
+ readonly modules: readonly ModuleDatabaseRequirements[];
29
+ /**
30
+ * True when the module manifests could not be read, so every enabled module
31
+ * is treated as owning tenant-scoped tables. Over-approximating keeps the
32
+ * check strict; the review screen says the list came from this fallback.
33
+ */
34
+ readonly approximated: boolean;
35
+ }
36
+
37
+ interface ModuleManifest {
38
+ readonly id?: unknown;
39
+ readonly capabilities?: unknown;
40
+ readonly tenancy?: unknown;
41
+ }
42
+
43
+ interface ProjectManifest {
44
+ readonly modules?: {
45
+ readonly roots?: unknown;
46
+ readonly enabled?: unknown;
47
+ };
48
+ }
49
+
50
+ function readJson<T>(path: string): T | null {
51
+ try {
52
+ return JSON.parse(readFileSync(path, 'utf8')) as T;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ function stringList(value: unknown): readonly string[] {
59
+ return Array.isArray(value)
60
+ ? value.filter((entry): entry is string => typeof entry === 'string')
61
+ : [];
62
+ }
63
+
64
+ function requirements(
65
+ moduleId: string,
66
+ tenantOwned: boolean,
67
+ ): ModuleDatabaseRequirements {
68
+ return {
69
+ moduleId,
70
+ tenantOwned,
71
+ dialectIds: PLATFORM_MODULE_DATABASE_REQUIREMENTS.dialectIds,
72
+ capabilities: PLATFORM_MODULE_DATABASE_REQUIREMENTS.capabilities,
73
+ };
74
+ }
75
+
76
+ /**
77
+ * The enabled modules that own database tables, in `flowdular.json` order.
78
+ * A container image ships only `platform/dist`, so the enabled list falls back
79
+ * to the manifest bundled at build time and, without the module manifests
80
+ * beside it, every enabled module is assumed to own tenant-scoped tables.
81
+ */
82
+ export function enabledDatabaseModules(
83
+ workspaceRoot: string,
84
+ ): EnabledDatabaseModules {
85
+ const project =
86
+ readJson<ProjectManifest>(resolve(workspaceRoot, 'flowdular.json')) ??
87
+ (projectManifest as ProjectManifest);
88
+ const enabled = stringList(project.modules?.enabled);
89
+ if (enabled.length === 0) return { modules: [], approximated: false };
90
+ const roots = stringList(project.modules?.roots);
91
+ const manifests = new Map<string, ModuleManifest>();
92
+ for (const root of roots.length > 0 ? roots : ['modules']) {
93
+ const directory = resolve(workspaceRoot, root);
94
+ let entries: readonly string[];
95
+ try {
96
+ entries = readdirSync(directory);
97
+ } catch {
98
+ continue;
99
+ }
100
+ for (const entry of entries) {
101
+ const manifest = readJson<ModuleManifest>(
102
+ join(directory, entry, 'module.json'),
103
+ );
104
+ if (typeof manifest?.id === 'string') {
105
+ manifests.set(manifest.id, manifest);
106
+ }
107
+ }
108
+ }
109
+ if (manifests.size === 0) {
110
+ return {
111
+ modules: enabled.map((moduleId) => requirements(moduleId, true)),
112
+ approximated: true,
113
+ };
114
+ }
115
+ const modules: ModuleDatabaseRequirements[] = [];
116
+ for (const moduleId of enabled) {
117
+ const manifest = manifests.get(moduleId);
118
+ if (!manifest) continue;
119
+ if (!stringList(manifest.capabilities).includes('database')) continue;
120
+ modules.push(requirements(moduleId, manifest.tenancy === 'required'));
121
+ }
122
+ return { modules, approximated: false };
123
+ }