create-flowdular 0.6.0 → 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/agent-template/.agents/skills/spec-approval/SKILL.md +6 -2
- package/agent-template/.agents/skills/spec-interview/SKILL.md +2 -2
- package/agent-template/.ai/agents/sandbox/business-manager.md +2 -2
- package/agent-template/.ai/platform-capabilities.md +4 -4
- 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/.claude/skills/spec-approval/SKILL.md +6 -2
- package/agent-template/.claude/skills/spec-interview/SKILL.md +2 -2
- package/agent-template/docs/adr/0003-module-settings.md +2 -0
- package/agent-template/docs/agent-contract.md +1 -1
- package/agent-template/docs/cli-extensions.md +1 -0
- package/agent-template/docs/cli.md +16 -0
- package/agent-template/docs/configuration.md +32 -4
- package/agent-template/docs/database-adapters.md +10 -2
- package/agent-template/docs/design-system.md +6 -2
- package/agent-template/docs/getting-started.md +5 -1
- package/agent-template/docs/modules.md +3 -1
- package/agent-template/docs/sandbox.md +23 -4
- package/package.json +1 -1
- package/template/default/.env.example +5 -0
- package/template/default/infra/docker/.env.example +5 -0
- package/template/default/infra/docker/Dockerfile +5 -1
- package/template/default/infra/docker/compose.yaml +4 -0
- package/template/default/infra/kubernetes/deployment.yaml +5 -0
- package/template/default/infra/sdk-module-manifests.mjs +118 -0
- package/template/default/infra/vercel/build.mjs +9 -0
- package/template/default/modules/example/package.json +1 -1
- package/template/default/package.json +2 -3
- package/template/default/platform/octane.config.ts +53 -15
- package/template/default/platform/package.json +1 -1
- package/template/default/platform/scripts/dev.mjs +66 -21
- package/template/default/platform/src/server/lifecycle.ts +325 -0
- package/template/default/platform/src/server/setup/modules.ts +28 -32
- package/template/default/platform/src/server/setup/page.ts +54 -3
- package/template/default/platform/src/server/setup/routes.ts +1 -0
- package/template/default/platform/src/server/setup/seed.ts +51 -4
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cp,
|
|
3
|
+
lstat,
|
|
4
|
+
mkdir,
|
|
5
|
+
readFile,
|
|
6
|
+
realpath,
|
|
7
|
+
writeFile,
|
|
8
|
+
} from 'node:fs/promises';
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
11
|
+
|
|
12
|
+
/* A deployment ships no node_modules, yet the platform lists the modules
|
|
13
|
+
@flowdular/sdk ships by resolving the SDK's module index from platform/. This
|
|
14
|
+
copies what that lookup reads (the index, each indexed module.json and its
|
|
15
|
+
spec/module.yaml, and a package.json exporting the index) to the same place
|
|
16
|
+
under the deployment root. A workspace without the SDK copies nothing.
|
|
17
|
+
|
|
18
|
+
Usage: node infra/sdk-module-manifests.mjs <workspace> <deployment root> */
|
|
19
|
+
|
|
20
|
+
/* The platform refuses a larger index, so the copy does too. */
|
|
21
|
+
const INDEX_LIMIT = 256;
|
|
22
|
+
const UNRESOLVED = new Set([
|
|
23
|
+
'MODULE_NOT_FOUND',
|
|
24
|
+
'ERR_PACKAGE_PATH_NOT_EXPORTED',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
const [workspaceArgument, deploymentArgument] = process.argv.slice(2);
|
|
28
|
+
if (!workspaceArgument || !deploymentArgument) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
'Usage: node infra/sdk-module-manifests.mjs <workspace> <deployment root>',
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
const workspace = resolve(workspaceArgument);
|
|
34
|
+
const destination = join(
|
|
35
|
+
resolve(deploymentArgument),
|
|
36
|
+
'platform/node_modules/@flowdular/sdk',
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
function sdkIndexPath() {
|
|
40
|
+
try {
|
|
41
|
+
return createRequire(join(workspace, 'platform/package.json')).resolve(
|
|
42
|
+
'@flowdular/sdk/modules.json',
|
|
43
|
+
);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (UNRESOLVED.has(error.code)) return null;
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function insidePath(root, path) {
|
|
51
|
+
const fromRoot = relative(root, path);
|
|
52
|
+
return (
|
|
53
|
+
Boolean(fromRoot) && !fromRoot.startsWith('..') && !isAbsolute(fromRoot)
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* Copies a file the index names, refusing one that leaves the SDK lexically or
|
|
58
|
+
through a link. An absent optional file is skipped. */
|
|
59
|
+
async function copyFromSdk(sdkRoot, path, optional = false) {
|
|
60
|
+
const lexical = resolve(sdkRoot, path);
|
|
61
|
+
if (!insidePath(sdkRoot, lexical)) {
|
|
62
|
+
throw new Error(`SDK module file escapes the package: ${path}`);
|
|
63
|
+
}
|
|
64
|
+
let source;
|
|
65
|
+
try {
|
|
66
|
+
source = await realpath(lexical);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (optional && error.code === 'ENOENT') return;
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
if (!insidePath(sdkRoot, source) || !(await lstat(source)).isFile()) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`SDK module file must be a regular file inside the package: ${path}`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
const target = join(destination, relative(sdkRoot, lexical));
|
|
77
|
+
await mkdir(dirname(target), { recursive: true });
|
|
78
|
+
await cp(source, target);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const indexPath = sdkIndexPath();
|
|
82
|
+
if (indexPath) {
|
|
83
|
+
const sdkRoot = await realpath(dirname(indexPath));
|
|
84
|
+
const indexSource = await readFile(indexPath, 'utf8');
|
|
85
|
+
const index = JSON.parse(indexSource);
|
|
86
|
+
if (
|
|
87
|
+
index.schemaVersion !== 1 ||
|
|
88
|
+
!Array.isArray(index.modules) ||
|
|
89
|
+
index.modules.length > INDEX_LIMIT
|
|
90
|
+
) {
|
|
91
|
+
throw new Error('Invalid SDK module index.');
|
|
92
|
+
}
|
|
93
|
+
await mkdir(destination, { recursive: true });
|
|
94
|
+
for (const entry of index.modules) {
|
|
95
|
+
if (typeof entry?.manifest !== 'string') {
|
|
96
|
+
throw new Error('Invalid SDK module manifest path.');
|
|
97
|
+
}
|
|
98
|
+
await copyFromSdk(sdkRoot, entry.manifest);
|
|
99
|
+
await copyFromSdk(
|
|
100
|
+
sdkRoot,
|
|
101
|
+
join(dirname(entry.manifest), 'spec/module.yaml'),
|
|
102
|
+
true,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
await writeFile(join(destination, 'modules.json'), indexSource);
|
|
106
|
+
await writeFile(
|
|
107
|
+
join(destination, 'package.json'),
|
|
108
|
+
`${JSON.stringify(
|
|
109
|
+
{
|
|
110
|
+
name: '@flowdular/sdk',
|
|
111
|
+
private: true,
|
|
112
|
+
exports: { './modules.json': './modules.json' },
|
|
113
|
+
},
|
|
114
|
+
null,
|
|
115
|
+
'\t',
|
|
116
|
+
)}\n`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
@@ -160,6 +160,15 @@ async function writeFunction(directory, runtimeRole) {
|
|
|
160
160
|
);
|
|
161
161
|
}
|
|
162
162
|
}
|
|
163
|
+
const sdkManifests = spawnSync(
|
|
164
|
+
process.execPath,
|
|
165
|
+
[join(repositoryRoot, 'infra/sdk-module-manifests.mjs'), root, directory],
|
|
166
|
+
{ stdio: 'inherit' },
|
|
167
|
+
);
|
|
168
|
+
if (sdkManifests.error) throw sdkManifests.error;
|
|
169
|
+
if (sdkManifests.status !== 0) {
|
|
170
|
+
throw new Error('Copying the SDK module manifests failed.');
|
|
171
|
+
}
|
|
163
172
|
await copyRegular(
|
|
164
173
|
join(repositoryRoot, 'infra/vercel/handler.mjs'),
|
|
165
174
|
join(directory, 'handler.mjs'),
|
|
@@ -15,7 +15,6 @@
|
|
|
15
15
|
"format": "prettier --write .",
|
|
16
16
|
"format:check": "prettier --check .",
|
|
17
17
|
"flowdular": "flowdular",
|
|
18
|
-
"cl": "flowdular",
|
|
19
18
|
"doctor": "flowdular doctor",
|
|
20
19
|
"verify": "pnpm rules:check && pnpm typecheck && pnpm test && flowdular spec validate --all && flowdular module validate && pnpm format:check",
|
|
21
20
|
"rules:generate": "rulesync generate",
|
|
@@ -23,10 +22,10 @@
|
|
|
23
22
|
"build": "flowdular module sync --apply && pnpm --filter @app/platform build"
|
|
24
23
|
},
|
|
25
24
|
"devDependencies": {
|
|
26
|
-
"@flowdular/sandbox": "0.6.
|
|
25
|
+
"@flowdular/sandbox": "0.6.1",
|
|
27
26
|
"@tsrx/prettier-plugin": "0.3.120",
|
|
28
27
|
"prettier": "3.6.2",
|
|
29
|
-
"flowdular": "0.6.
|
|
28
|
+
"flowdular": "0.6.1",
|
|
30
29
|
"rulesync": "16.21.0"
|
|
31
30
|
}
|
|
32
31
|
}
|
|
@@ -44,6 +44,10 @@ import {
|
|
|
44
44
|
createReadinessEndpoint,
|
|
45
45
|
healthEndpoint,
|
|
46
46
|
} from './src/server/health.ts';
|
|
47
|
+
import {
|
|
48
|
+
createPlatformRuntimeLifecycle,
|
|
49
|
+
prepareAndActivatePlatformRuntimeLifecycle,
|
|
50
|
+
} from './src/server/lifecycle.ts';
|
|
47
51
|
import { createMetricsRoutes } from './src/server/metrics.ts';
|
|
48
52
|
import {
|
|
49
53
|
platformRuntimeRole,
|
|
@@ -97,6 +101,11 @@ function firstRunConfig(databasePreconfigured = false) {
|
|
|
97
101
|
}
|
|
98
102
|
|
|
99
103
|
async function createPlatformConfig() {
|
|
104
|
+
if (process.env.FD_INTERNAL_PLATFORM_TERMINATING === 'true') {
|
|
105
|
+
throw new Error(
|
|
106
|
+
'Platform startup was requested while the process is stopping.',
|
|
107
|
+
);
|
|
108
|
+
}
|
|
100
109
|
loadPlatformEnvironmentFile(workspaceRoot);
|
|
101
110
|
const serverless = process.env.FD_DEPLOYMENT_TARGET === 'vercel';
|
|
102
111
|
if (!building && !platformDatabaseConfigured(process.env)) {
|
|
@@ -215,10 +224,11 @@ async function createPlatformConfig() {
|
|
|
215
224
|
catalogue, so every reader sees the declarations the modules agreed on. */
|
|
216
225
|
dataClasses.seal();
|
|
217
226
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
227
|
+
/* Each evaluation of this file is one generation, and Vite evaluates it more
|
|
228
|
+
than once per process. The generation retires when the next one is
|
|
229
|
+
prepared, when the development server stops, or on a stop signal. */
|
|
230
|
+
const lifecycle = createPlatformRuntimeLifecycle();
|
|
231
|
+
lifecycle.add(async () => {
|
|
222
232
|
await ticker?.close();
|
|
223
233
|
for (const composition of moduleCompositions) {
|
|
224
234
|
await composition.stop?.();
|
|
@@ -230,24 +240,38 @@ async function createPlatformConfig() {
|
|
|
230
240
|
/* Last, so the spans and error reports this process queued while it stopped
|
|
231
241
|
still leave with it. */
|
|
232
242
|
await observability.dispose();
|
|
233
|
-
};
|
|
243
|
+
});
|
|
234
244
|
|
|
235
245
|
/* check() proves the runtime role holds neither SUPERUSER nor BYPASSRLS before
|
|
236
246
|
any module reads a row. */
|
|
237
247
|
if (!building) {
|
|
238
248
|
try {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
249
|
+
/* The previous generation retires once this one is prepared, and this
|
|
250
|
+
one starts its workers only after the previous has drained. */
|
|
251
|
+
await prepareAndActivatePlatformRuntimeLifecycle(lifecycle, [
|
|
252
|
+
async () => {
|
|
253
|
+
await databases.check();
|
|
254
|
+
},
|
|
255
|
+
/* Platform-scoped settings are read by background work before any request
|
|
256
|
+
could prime them; a workspace is primed by the authentication middleware. */
|
|
257
|
+
() => settings.prime(PLATFORM_SETTINGS_TENANT),
|
|
258
|
+
...moduleCompositions.map(
|
|
259
|
+
(composition) => () => composition.prepare?.(),
|
|
260
|
+
),
|
|
261
|
+
]);
|
|
262
|
+
/* A stop that arrived while this generation was preparing retired only
|
|
263
|
+
the generations active then, so this one retires itself. */
|
|
264
|
+
if (process.env.FD_INTERNAL_PLATFORM_TERMINATING === 'true') {
|
|
265
|
+
throw new Error(
|
|
266
|
+
'Platform startup was requested while the process is stopping.',
|
|
267
|
+
);
|
|
268
|
+
}
|
|
245
269
|
for (const composition of moduleCompositions) composition.start?.();
|
|
246
270
|
await startModuleWorkers(moduleCompositions, runtimeRole);
|
|
247
271
|
} catch (error) {
|
|
248
272
|
/* A worker that started before the failure would keep running in a
|
|
249
273
|
process that never serves. The boot failure is the one rethrown. */
|
|
250
|
-
await
|
|
274
|
+
await lifecycle.retire().catch((cleanupError: unknown) => {
|
|
251
275
|
serverLogger().error('platform boot cleanup failed', {
|
|
252
276
|
module: 'platform',
|
|
253
277
|
err: cleanupError,
|
|
@@ -259,10 +283,23 @@ async function createPlatformConfig() {
|
|
|
259
283
|
|
|
260
284
|
// Bundling needs route declarations without background work or retained leases.
|
|
261
285
|
if (building) {
|
|
262
|
-
await
|
|
286
|
+
await lifecycle.retire();
|
|
263
287
|
} else {
|
|
264
|
-
|
|
265
|
-
|
|
288
|
+
/* scripts/dev.mjs retires every generation itself; a production server
|
|
289
|
+
has only these. */
|
|
290
|
+
const retire = () =>
|
|
291
|
+
void lifecycle.retire().catch((error: unknown) => {
|
|
292
|
+
serverLogger().error('platform shutdown failed', {
|
|
293
|
+
module: 'platform',
|
|
294
|
+
err: error,
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
process.once('SIGINT', retire);
|
|
298
|
+
process.once('SIGTERM', retire);
|
|
299
|
+
lifecycle.add(() => {
|
|
300
|
+
process.off('SIGINT', retire);
|
|
301
|
+
process.off('SIGTERM', retire);
|
|
302
|
+
});
|
|
266
303
|
}
|
|
267
304
|
|
|
268
305
|
return defineConfig({
|
|
@@ -273,6 +310,7 @@ async function createPlatformConfig() {
|
|
|
273
310
|
createCorsMiddleware({
|
|
274
311
|
allowOrigin: (origin) => authRuntime.apiOriginAllowed(origin),
|
|
275
312
|
}),
|
|
313
|
+
lifecycle.middleware,
|
|
276
314
|
...(firstRun ? [firstRun.middleware] : []),
|
|
277
315
|
authRuntime.middleware,
|
|
278
316
|
],
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createServer as createHttpServer } from 'node:http';
|
|
1
2
|
import { resolve } from 'node:path';
|
|
2
3
|
import { fileURLToPath } from 'node:url';
|
|
3
4
|
import { spawnSync } from 'node:child_process';
|
|
@@ -9,6 +10,12 @@ import {
|
|
|
9
10
|
createTheme,
|
|
10
11
|
printReady,
|
|
11
12
|
} from '@flowdular/sdk/dev-console';
|
|
13
|
+
import {
|
|
14
|
+
PLATFORM_SHUTDOWN_BUDGET_MS,
|
|
15
|
+
retirePlatformRuntimes,
|
|
16
|
+
stopOnSignals,
|
|
17
|
+
stopServing,
|
|
18
|
+
} from '@flowdular/sdk/dev-console/shutdown';
|
|
12
19
|
|
|
13
20
|
const appRoot = resolve(fileURLToPath(new URL('..', import.meta.url)));
|
|
14
21
|
/* `pnpm dev -- --port 4396 --host 0.0.0.0` overrides vite.config.ts, so a
|
|
@@ -27,36 +34,81 @@ const verbose =
|
|
|
27
34
|
const color = shouldUseColor();
|
|
28
35
|
const restoreConsole = installOctaneConsoleBridge(verbose, color);
|
|
29
36
|
|
|
37
|
+
/* Vite serves in middleware mode behind this server. A Vite that listens
|
|
38
|
+
itself exits the process on SIGTERM as soon as it has closed, before
|
|
39
|
+
octane.config.ts has released its databases. */
|
|
30
40
|
let server;
|
|
41
|
+
const httpServer = createHttpServer((request, response) =>
|
|
42
|
+
server.middlewares(request, response),
|
|
43
|
+
);
|
|
44
|
+
let localUrl;
|
|
31
45
|
try {
|
|
32
46
|
server = await createServer({
|
|
33
47
|
root: appRoot,
|
|
34
48
|
configFile: resolve(appRoot, 'vite.config.ts'),
|
|
35
49
|
customLogger: createOctaneLogger(verbose, color),
|
|
36
50
|
clearScreen: false,
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
:
|
|
40
|
-
|
|
41
|
-
|
|
51
|
+
server: {
|
|
52
|
+
middlewareMode: true,
|
|
53
|
+
ws: { server: httpServer },
|
|
54
|
+
...(Number.isInteger(port) && port > 0 ? { port, strictPort: true } : {}),
|
|
55
|
+
...(host ? { host } : {}),
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
/* The address Vite would have bound: vite.config.ts or the flags above,
|
|
59
|
+
and localhost when neither names a host. */
|
|
60
|
+
const listenPort = server.config.server.port;
|
|
61
|
+
const configuredHost = server.config.server.host;
|
|
62
|
+
const listenHost =
|
|
63
|
+
configuredHost === true ? undefined : configuredHost || 'localhost';
|
|
64
|
+
await new Promise((resolveListen, rejectListen) => {
|
|
65
|
+
httpServer.once('error', rejectListen);
|
|
66
|
+
httpServer.listen(listenPort, listenHost, () => {
|
|
67
|
+
httpServer.off('error', rejectListen);
|
|
68
|
+
resolveListen();
|
|
69
|
+
});
|
|
42
70
|
});
|
|
43
|
-
|
|
71
|
+
const displayHost =
|
|
72
|
+
listenHost === undefined || listenHost === '0.0.0.0' || listenHost === '::'
|
|
73
|
+
? 'localhost'
|
|
74
|
+
: listenHost;
|
|
75
|
+
localUrl = `http://${displayHost.includes(':') ? `[${displayHost}]` : displayHost}:${listenPort}/`;
|
|
44
76
|
} catch (error) {
|
|
77
|
+
if (httpServer.listening) httpServer.close();
|
|
45
78
|
restoreConsole();
|
|
46
79
|
throw error;
|
|
47
80
|
}
|
|
81
|
+
|
|
82
|
+
/* The process ends on its own once every runtime generation has released
|
|
83
|
+
what it holds, so one that was still preparing when the stop arrived
|
|
84
|
+
drains as well; the deadline bounds the wait. */
|
|
85
|
+
async function stop() {
|
|
86
|
+
process.env.FD_INTERNAL_PLATFORM_TERMINATING = 'true';
|
|
87
|
+
try {
|
|
88
|
+
await Promise.all([
|
|
89
|
+
stopServing(httpServer, server),
|
|
90
|
+
retirePlatformRuntimes(),
|
|
91
|
+
]);
|
|
92
|
+
} finally {
|
|
93
|
+
await server.close();
|
|
94
|
+
restoreConsole();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
stopOnSignals(
|
|
98
|
+
() =>
|
|
99
|
+
void stop().catch((error) => {
|
|
100
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
101
|
+
process.exitCode = 1;
|
|
102
|
+
}),
|
|
103
|
+
{ deadlineMs: PLATFORM_SHUTDOWN_BUDGET_MS },
|
|
104
|
+
);
|
|
105
|
+
|
|
48
106
|
printReady({
|
|
49
107
|
title: 'FLOWDULAR',
|
|
50
108
|
subtitle: 'development workspace',
|
|
51
109
|
theme: createTheme(color),
|
|
52
110
|
lines: [
|
|
53
|
-
[
|
|
54
|
-
'local',
|
|
55
|
-
server.resolvedUrls?.local?.[0] ??
|
|
56
|
-
server.resolvedUrls?.network?.[0] ??
|
|
57
|
-
'the address vite.config.ts sets',
|
|
58
|
-
'info',
|
|
59
|
-
],
|
|
111
|
+
['local', localUrl, 'info'],
|
|
60
112
|
['diagnostics', verbose ? 'verbose' : 'quiet · use --verbose', 'muted'],
|
|
61
113
|
],
|
|
62
114
|
});
|
|
@@ -80,8 +132,7 @@ function openBrowser(url) {
|
|
|
80
132
|
return !result.error && result.status === 0;
|
|
81
133
|
}
|
|
82
134
|
|
|
83
|
-
|
|
84
|
-
if (localUrl && !process.argv.includes('--no-open')) {
|
|
135
|
+
if (!process.argv.includes('--no-open')) {
|
|
85
136
|
const setupUrl = new URL('/setup', localUrl).href;
|
|
86
137
|
try {
|
|
87
138
|
const response = await fetch(setupUrl, {
|
|
@@ -96,9 +147,3 @@ if (localUrl && !process.argv.includes('--no-open')) {
|
|
|
96
147
|
// The server remains usable when a browser is unavailable.
|
|
97
148
|
}
|
|
98
149
|
}
|
|
99
|
-
|
|
100
|
-
/* Closing without exiting lets octane.config.ts release the database on the
|
|
101
|
-
same signal; the process ends once both have drained. */
|
|
102
|
-
const stop = () => void server.close().finally(restoreConsole);
|
|
103
|
-
process.once('SIGINT', stop);
|
|
104
|
-
process.once('SIGTERM', stop);
|