create-flowdular 0.5.1 → 0.6.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 +8 -5
- package/agent-template/.agents/skills/agent-tool-design/SKILL.md +1 -1
- package/agent-template/.agents/skills/auth-security-review/SKILL.md +1 -1
- package/agent-template/.agents/skills/database-adapter/SKILL.md +5 -5
- package/agent-template/.agents/skills/database-adapter/references/first-run-and-matrix.md +2 -2
- package/agent-template/.agents/skills/deploy-operate/SKILL.md +1 -1
- package/agent-template/.agents/skills/migration-authoring/SKILL.md +4 -4
- package/agent-template/.agents/skills/module-new/SKILL.md +1 -1
- package/agent-template/.agents/skills/spec-approval/SKILL.md +6 -2
- package/agent-template/.agents/skills/spec-interview/SKILL.md +2 -2
- package/agent-template/.agents/skills/test-hardening/SKILL.md +2 -2
- package/agent-template/.agents/skills/workflow-development/SKILL.md +95 -9
- package/agent-template/.ai/agents/sandbox/business-manager.md +2 -2
- package/agent-template/.ai/blueprints/add-migration/required-files.yaml +1 -1
- package/agent-template/.ai/blueprints/new-module/required-files.yaml +1 -1
- package/agent-template/.ai/platform-capabilities.md +10 -8
- package/agent-template/.ai/policies/capabilities.yaml +28 -12
- package/agent-template/.ai/skills/README.md +1 -1
- package/agent-template/.ai/skills/agent-tool-design/SKILL.md +1 -1
- package/agent-template/.ai/skills/auth-security-review/SKILL.md +1 -1
- package/agent-template/.ai/skills/database-adapter/SKILL.md +5 -5
- package/agent-template/.ai/skills/database-adapter/references/first-run-and-matrix.md +2 -2
- package/agent-template/.ai/skills/deploy-operate/SKILL.md +1 -1
- package/agent-template/.ai/skills/migration-authoring/SKILL.md +4 -4
- package/agent-template/.ai/skills/module-new/SKILL.md +1 -1
- package/agent-template/.ai/skills/spec-approval/SKILL.md +6 -2
- package/agent-template/.ai/skills/spec-interview/SKILL.md +2 -2
- package/agent-template/.ai/skills/test-hardening/SKILL.md +2 -2
- package/agent-template/.ai/skills/workflow-development/SKILL.md +96 -10
- package/agent-template/.claude/skills/agent-tool-design/SKILL.md +1 -1
- package/agent-template/.claude/skills/auth-security-review/SKILL.md +1 -1
- package/agent-template/.claude/skills/database-adapter/SKILL.md +5 -5
- package/agent-template/.claude/skills/database-adapter/references/first-run-and-matrix.md +2 -2
- package/agent-template/.claude/skills/deploy-operate/SKILL.md +1 -1
- package/agent-template/.claude/skills/migration-authoring/SKILL.md +4 -4
- package/agent-template/.claude/skills/module-new/SKILL.md +1 -1
- package/agent-template/.claude/skills/spec-approval/SKILL.md +6 -2
- package/agent-template/.claude/skills/spec-interview/SKILL.md +2 -2
- package/agent-template/.claude/skills/test-hardening/SKILL.md +2 -2
- package/agent-template/.claude/skills/workflow-development/SKILL.md +95 -9
- package/agent-template/docs/adr/0003-module-settings.md +2 -0
- package/agent-template/docs/adr/0007-module-owned-agents.md +35 -1
- package/agent-template/docs/agent-contract.md +3 -3
- package/agent-template/docs/cli-extensions.md +1 -0
- package/agent-template/docs/cli.md +40 -3
- package/agent-template/docs/configuration.md +64 -9
- package/agent-template/docs/database-adapters.md +30 -22
- package/agent-template/docs/design-system.md +7 -3
- package/agent-template/docs/getting-started.md +29 -32
- package/agent-template/docs/module-distribution.md +79 -86
- package/agent-template/docs/module-web-surfaces.md +9 -7
- package/agent-template/docs/modules.md +6 -2
- package/agent-template/docs/sandbox.md +23 -4
- package/agent-template/platform/scripts/build.mjs +7 -0
- package/dist/bin.js +3 -6
- package/package.json +1 -1
- package/template/default/.env.example +8 -3
- package/template/default/.vercelignore +8 -0
- package/template/default/README.md +20 -11
- package/template/default/_gitignore +3 -2
- package/template/default/infra/README.md +86 -65
- package/template/default/infra/docker/.env.example +71 -0
- package/template/default/infra/docker/Dockerfile +29 -11
- package/template/default/infra/docker/app-entrypoint.mjs +5 -0
- package/template/default/infra/docker/compose.yaml +109 -58
- package/template/default/infra/docker/database-urls.mjs +28 -0
- package/template/default/infra/docker/pitr.sh +177 -0
- package/template/default/infra/docker/postgres/10-roles.sh +16 -12
- package/template/default/infra/docker/start.mjs +402 -0
- package/template/default/infra/kubernetes/database-secret.example.yaml +3 -3
- package/template/default/infra/kubernetes/deployment.yaml +5 -0
- package/template/default/infra/sdk-module-manifests.mjs +118 -0
- package/template/default/infra/vercel/README.md +262 -0
- package/template/default/infra/vercel/build.mjs +223 -0
- package/template/default/infra/vercel/handler.mjs +100 -0
- package/template/default/modules/example/migrations/0001_example_core.up.sql +2 -2
- package/template/default/modules/example/module.json +1 -1
- package/template/default/modules/example/package.json +3 -3
- package/template/default/modules/example/spec/module.yaml +1 -1
- package/template/default/modules/example/src/services/migration.ts +2 -2
- package/template/default/modules/example/tests/module.test.ts +1 -1
- package/template/default/package.json +2 -3
- package/template/default/platform/octane.config.ts +290 -156
- package/template/default/platform/package.json +5 -5
- package/template/default/platform/scripts/build.mjs +56 -0
- package/template/default/platform/scripts/dev.mjs +101 -18
- package/template/default/platform/src/generated/modules.server.ts +1 -0
- package/template/default/platform/src/server/database.ts +24 -0
- package/template/default/platform/src/server/lifecycle.ts +325 -0
- package/template/default/platform/src/server/runtime-role.ts +33 -0
- package/template/default/platform/src/server/setup/access.ts +160 -0
- package/template/default/platform/src/server/setup/adapters.ts +554 -0
- package/template/default/platform/src/server/setup/environment.ts +154 -0
- package/template/default/platform/src/server/setup/gate.ts +84 -0
- package/template/default/platform/src/server/setup/index.ts +181 -0
- package/template/default/platform/src/server/setup/modules.ts +119 -0
- package/template/default/platform/src/server/setup/page.ts +548 -0
- package/template/default/platform/src/server/setup/routes.ts +788 -0
- package/template/default/platform/src/server/setup/sanitize.ts +111 -0
- package/template/default/platform/src/server/setup/seed.ts +192 -0
- package/template/default/platform/src/server/setup/token.ts +79 -0
- package/template/default/platform/src/server/worker-tick.ts +193 -0
- package/template/default/platform/src/server/workspace-root.ts +16 -0
- package/template/default/render.yaml +70 -0
- package/template/default/vercel.json +5 -0
|
@@ -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
|
+
}
|
|
@@ -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,119 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
DATABASE_CAPABILITY_IDS,
|
|
5
|
+
DATABASE_DIALECT_IDS,
|
|
6
|
+
type ModuleDatabaseRequirements,
|
|
7
|
+
} from '@flowdular/sdk/database';
|
|
8
|
+
import { findModuleManifests } from '@flowdular/sdk/kernel/module-manifests';
|
|
9
|
+
import projectManifest from '../../../../flowdular.json';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What every database-owning module in this repository asks its lease for. The
|
|
13
|
+
* module manifest carries no machine-readable database block yet, so the
|
|
14
|
+
* platform applies the strictest set any enabled module requests rather than
|
|
15
|
+
* guessing a looser one per module. The precise fix is a `database` block in
|
|
16
|
+
* packages/contracts/schemas/module.schema.json that each module fills in.
|
|
17
|
+
*/
|
|
18
|
+
export const PLATFORM_MODULE_DATABASE_REQUIREMENTS = Object.freeze({
|
|
19
|
+
dialectIds: Object.freeze([DATABASE_DIALECT_IDS.postgresql]),
|
|
20
|
+
capabilities: Object.freeze([
|
|
21
|
+
DATABASE_CAPABILITY_IDS.MIGRATION_LOCK,
|
|
22
|
+
DATABASE_CAPABILITY_IDS.SCHEMA_INTROSPECTION,
|
|
23
|
+
DATABASE_CAPABILITY_IDS.TRANSACTIONAL_DDL,
|
|
24
|
+
DATABASE_CAPABILITY_IDS.TRANSACTIONS,
|
|
25
|
+
]),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export interface EnabledDatabaseModules {
|
|
29
|
+
readonly modules: readonly ModuleDatabaseRequirements[];
|
|
30
|
+
/**
|
|
31
|
+
* True when a module manifest could not be read, so the enabled module it
|
|
32
|
+
* belongs to is treated as owning tenant-scoped tables. Over-approximating
|
|
33
|
+
* keeps the check strict; the review screen says the list came from this
|
|
34
|
+
* fallback.
|
|
35
|
+
*/
|
|
36
|
+
readonly approximated: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface ModuleManifest {
|
|
40
|
+
readonly id?: unknown;
|
|
41
|
+
readonly capabilities?: unknown;
|
|
42
|
+
readonly tenancy?: unknown;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface ProjectManifest {
|
|
46
|
+
readonly modules?: {
|
|
47
|
+
readonly roots?: unknown;
|
|
48
|
+
readonly enabled?: unknown;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readJson<T>(path: string): T | null {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(readFileSync(path, 'utf8')) as T;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function stringList(value: unknown): readonly string[] {
|
|
61
|
+
return Array.isArray(value)
|
|
62
|
+
? value.filter((entry): entry is string => typeof entry === 'string')
|
|
63
|
+
: [];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function requirements(
|
|
67
|
+
moduleId: string,
|
|
68
|
+
tenantOwned: boolean,
|
|
69
|
+
): ModuleDatabaseRequirements {
|
|
70
|
+
return {
|
|
71
|
+
moduleId,
|
|
72
|
+
tenantOwned,
|
|
73
|
+
dialectIds: PLATFORM_MODULE_DATABASE_REQUIREMENTS.dialectIds,
|
|
74
|
+
capabilities: PLATFORM_MODULE_DATABASE_REQUIREMENTS.capabilities,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The enabled modules that own database tables, in `flowdular.json` order.
|
|
80
|
+
* Manifests are found where the CLI finds them, @flowdular/sdk included. A
|
|
81
|
+
* container image ships only `platform/dist`, so the enabled list falls back
|
|
82
|
+
* to the manifest bundled at build time and an enabled module whose manifest
|
|
83
|
+
* is not beside it is assumed to own tenant-scoped tables.
|
|
84
|
+
*/
|
|
85
|
+
export function enabledDatabaseModules(
|
|
86
|
+
workspaceRoot: string,
|
|
87
|
+
): EnabledDatabaseModules {
|
|
88
|
+
const project =
|
|
89
|
+
readJson<ProjectManifest>(resolve(workspaceRoot, 'flowdular.json')) ??
|
|
90
|
+
(projectManifest as ProjectManifest);
|
|
91
|
+
const enabled = stringList(project.modules?.enabled);
|
|
92
|
+
if (enabled.length === 0) return { modules: [], approximated: false };
|
|
93
|
+
let paths: readonly string[] = [];
|
|
94
|
+
try {
|
|
95
|
+
paths = findModuleManifests(workspaceRoot, project.modules?.roots);
|
|
96
|
+
} catch {
|
|
97
|
+
/* Unreadable roots leave every module to the strict fallback below. */
|
|
98
|
+
}
|
|
99
|
+
const manifests = new Map<string, ModuleManifest>();
|
|
100
|
+
for (const path of paths) {
|
|
101
|
+
const manifest = readJson<ModuleManifest>(path);
|
|
102
|
+
if (typeof manifest?.id === 'string') {
|
|
103
|
+
manifests.set(manifest.id, manifest);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const modules: ModuleDatabaseRequirements[] = [];
|
|
107
|
+
let approximated = false;
|
|
108
|
+
for (const moduleId of enabled) {
|
|
109
|
+
const manifest = manifests.get(moduleId);
|
|
110
|
+
if (!manifest) {
|
|
111
|
+
modules.push(requirements(moduleId, true));
|
|
112
|
+
approximated = true;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (!stringList(manifest.capabilities).includes('database')) continue;
|
|
116
|
+
modules.push(requirements(moduleId, manifest.tenancy === 'required'));
|
|
117
|
+
}
|
|
118
|
+
return { modules, approximated };
|
|
119
|
+
}
|