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.
Files changed (36) hide show
  1. package/agent-template/.agents/skills/spec-approval/SKILL.md +6 -2
  2. package/agent-template/.agents/skills/spec-interview/SKILL.md +2 -2
  3. package/agent-template/.ai/agents/sandbox/business-manager.md +2 -2
  4. package/agent-template/.ai/platform-capabilities.md +4 -4
  5. package/agent-template/.ai/skills/spec-approval/SKILL.md +6 -2
  6. package/agent-template/.ai/skills/spec-interview/SKILL.md +2 -2
  7. package/agent-template/.claude/skills/spec-approval/SKILL.md +6 -2
  8. package/agent-template/.claude/skills/spec-interview/SKILL.md +2 -2
  9. package/agent-template/docs/adr/0003-module-settings.md +2 -0
  10. package/agent-template/docs/agent-contract.md +1 -1
  11. package/agent-template/docs/cli-extensions.md +1 -0
  12. package/agent-template/docs/cli.md +16 -0
  13. package/agent-template/docs/configuration.md +32 -4
  14. package/agent-template/docs/database-adapters.md +10 -2
  15. package/agent-template/docs/design-system.md +6 -2
  16. package/agent-template/docs/getting-started.md +5 -1
  17. package/agent-template/docs/modules.md +3 -1
  18. package/agent-template/docs/sandbox.md +23 -4
  19. package/package.json +1 -1
  20. package/template/default/.env.example +5 -0
  21. package/template/default/infra/docker/.env.example +5 -0
  22. package/template/default/infra/docker/Dockerfile +5 -1
  23. package/template/default/infra/docker/compose.yaml +4 -0
  24. package/template/default/infra/kubernetes/deployment.yaml +5 -0
  25. package/template/default/infra/sdk-module-manifests.mjs +118 -0
  26. package/template/default/infra/vercel/build.mjs +9 -0
  27. package/template/default/modules/example/package.json +1 -1
  28. package/template/default/package.json +2 -3
  29. package/template/default/platform/octane.config.ts +53 -15
  30. package/template/default/platform/package.json +1 -1
  31. package/template/default/platform/scripts/dev.mjs +66 -21
  32. package/template/default/platform/src/server/lifecycle.ts +325 -0
  33. package/template/default/platform/src/server/setup/modules.ts +28 -32
  34. package/template/default/platform/src/server/setup/page.ts +54 -3
  35. package/template/default/platform/src/server/setup/routes.ts +1 -0
  36. 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'),
@@ -16,7 +16,7 @@
16
16
  "dependencies": {
17
17
  "octane": "0.9.1",
18
18
  "segment-state": "0.4.0",
19
- "@flowdular/sdk": "0.6.0"
19
+ "@flowdular/sdk": "0.6.1"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@tsrx/typescript-plugin": "0.3.120",
@@ -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.0",
25
+ "@flowdular/sandbox": "0.6.1",
27
26
  "@tsrx/prettier-plugin": "0.3.120",
28
27
  "prettier": "3.6.2",
29
- "flowdular": "0.6.0",
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
- let stopping = false;
219
- const shutdown = async () => {
220
- if (stopping) return;
221
- stopping = true;
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
- await databases.check();
240
- /* Platform-scoped settings are read by background work before any request
241
- could prime them; a workspace is primed by the authentication middleware. */
242
- await settings.prime(PLATFORM_SETTINGS_TENANT);
243
- for (const composition of moduleCompositions)
244
- await composition.prepare?.();
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 shutdown().catch((cleanupError: unknown) => {
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 shutdown();
286
+ await lifecycle.retire();
263
287
  } else {
264
- process.once('SIGINT', () => void shutdown());
265
- process.once('SIGTERM', () => void shutdown());
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
  ],
@@ -15,7 +15,7 @@
15
15
  "@octanejs/vite-plugin": "0.2.1",
16
16
  "octane": "0.9.1",
17
17
  "pg": "8.23.0",
18
- "@flowdular/sdk": "0.6.0"
18
+ "@flowdular/sdk": "0.6.1"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@octanejs/app-core": "0.1.1",
@@ -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
- ...(Number.isInteger(port) && port > 0
38
- ? { server: { port, strictPort: true, ...(host ? { host } : {}) } }
39
- : host
40
- ? { server: { host } }
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
- await server.listen();
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
- const localUrl = server.resolvedUrls?.local?.[0];
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);