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.
Files changed (105) 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/spec-approval/SKILL.md +6 -2
  10. package/agent-template/.agents/skills/spec-interview/SKILL.md +2 -2
  11. package/agent-template/.agents/skills/test-hardening/SKILL.md +2 -2
  12. package/agent-template/.agents/skills/workflow-development/SKILL.md +95 -9
  13. package/agent-template/.ai/agents/sandbox/business-manager.md +2 -2
  14. package/agent-template/.ai/blueprints/add-migration/required-files.yaml +1 -1
  15. package/agent-template/.ai/blueprints/new-module/required-files.yaml +1 -1
  16. package/agent-template/.ai/platform-capabilities.md +10 -8
  17. package/agent-template/.ai/policies/capabilities.yaml +28 -12
  18. package/agent-template/.ai/skills/README.md +1 -1
  19. package/agent-template/.ai/skills/agent-tool-design/SKILL.md +1 -1
  20. package/agent-template/.ai/skills/auth-security-review/SKILL.md +1 -1
  21. package/agent-template/.ai/skills/database-adapter/SKILL.md +5 -5
  22. package/agent-template/.ai/skills/database-adapter/references/first-run-and-matrix.md +2 -2
  23. package/agent-template/.ai/skills/deploy-operate/SKILL.md +1 -1
  24. package/agent-template/.ai/skills/migration-authoring/SKILL.md +4 -4
  25. package/agent-template/.ai/skills/module-new/SKILL.md +1 -1
  26. package/agent-template/.ai/skills/spec-approval/SKILL.md +6 -2
  27. package/agent-template/.ai/skills/spec-interview/SKILL.md +2 -2
  28. package/agent-template/.ai/skills/test-hardening/SKILL.md +2 -2
  29. package/agent-template/.ai/skills/workflow-development/SKILL.md +96 -10
  30. package/agent-template/.claude/skills/agent-tool-design/SKILL.md +1 -1
  31. package/agent-template/.claude/skills/auth-security-review/SKILL.md +1 -1
  32. package/agent-template/.claude/skills/database-adapter/SKILL.md +5 -5
  33. package/agent-template/.claude/skills/database-adapter/references/first-run-and-matrix.md +2 -2
  34. package/agent-template/.claude/skills/deploy-operate/SKILL.md +1 -1
  35. package/agent-template/.claude/skills/migration-authoring/SKILL.md +4 -4
  36. package/agent-template/.claude/skills/module-new/SKILL.md +1 -1
  37. package/agent-template/.claude/skills/spec-approval/SKILL.md +6 -2
  38. package/agent-template/.claude/skills/spec-interview/SKILL.md +2 -2
  39. package/agent-template/.claude/skills/test-hardening/SKILL.md +2 -2
  40. package/agent-template/.claude/skills/workflow-development/SKILL.md +95 -9
  41. package/agent-template/docs/adr/0003-module-settings.md +2 -0
  42. package/agent-template/docs/adr/0007-module-owned-agents.md +35 -1
  43. package/agent-template/docs/agent-contract.md +3 -3
  44. package/agent-template/docs/cli-extensions.md +1 -0
  45. package/agent-template/docs/cli.md +40 -3
  46. package/agent-template/docs/configuration.md +64 -9
  47. package/agent-template/docs/database-adapters.md +30 -22
  48. package/agent-template/docs/design-system.md +7 -3
  49. package/agent-template/docs/getting-started.md +29 -32
  50. package/agent-template/docs/module-distribution.md +79 -86
  51. package/agent-template/docs/module-web-surfaces.md +9 -7
  52. package/agent-template/docs/modules.md +6 -2
  53. package/agent-template/docs/sandbox.md +23 -4
  54. package/agent-template/platform/scripts/build.mjs +7 -0
  55. package/dist/bin.js +3 -6
  56. package/package.json +1 -1
  57. package/template/default/.env.example +8 -3
  58. package/template/default/.vercelignore +8 -0
  59. package/template/default/README.md +20 -11
  60. package/template/default/_gitignore +3 -2
  61. package/template/default/infra/README.md +86 -65
  62. package/template/default/infra/docker/.env.example +71 -0
  63. package/template/default/infra/docker/Dockerfile +29 -11
  64. package/template/default/infra/docker/app-entrypoint.mjs +5 -0
  65. package/template/default/infra/docker/compose.yaml +109 -58
  66. package/template/default/infra/docker/database-urls.mjs +28 -0
  67. package/template/default/infra/docker/pitr.sh +177 -0
  68. package/template/default/infra/docker/postgres/10-roles.sh +16 -12
  69. package/template/default/infra/docker/start.mjs +402 -0
  70. package/template/default/infra/kubernetes/database-secret.example.yaml +3 -3
  71. package/template/default/infra/kubernetes/deployment.yaml +5 -0
  72. package/template/default/infra/sdk-module-manifests.mjs +118 -0
  73. package/template/default/infra/vercel/README.md +262 -0
  74. package/template/default/infra/vercel/build.mjs +223 -0
  75. package/template/default/infra/vercel/handler.mjs +100 -0
  76. package/template/default/modules/example/migrations/0001_example_core.up.sql +2 -2
  77. package/template/default/modules/example/module.json +1 -1
  78. package/template/default/modules/example/package.json +3 -3
  79. package/template/default/modules/example/spec/module.yaml +1 -1
  80. package/template/default/modules/example/src/services/migration.ts +2 -2
  81. package/template/default/modules/example/tests/module.test.ts +1 -1
  82. package/template/default/package.json +2 -3
  83. package/template/default/platform/octane.config.ts +290 -156
  84. package/template/default/platform/package.json +5 -5
  85. package/template/default/platform/scripts/build.mjs +56 -0
  86. package/template/default/platform/scripts/dev.mjs +101 -18
  87. package/template/default/platform/src/generated/modules.server.ts +1 -0
  88. package/template/default/platform/src/server/database.ts +24 -0
  89. package/template/default/platform/src/server/lifecycle.ts +325 -0
  90. package/template/default/platform/src/server/runtime-role.ts +33 -0
  91. package/template/default/platform/src/server/setup/access.ts +160 -0
  92. package/template/default/platform/src/server/setup/adapters.ts +554 -0
  93. package/template/default/platform/src/server/setup/environment.ts +154 -0
  94. package/template/default/platform/src/server/setup/gate.ts +84 -0
  95. package/template/default/platform/src/server/setup/index.ts +181 -0
  96. package/template/default/platform/src/server/setup/modules.ts +119 -0
  97. package/template/default/platform/src/server/setup/page.ts +548 -0
  98. package/template/default/platform/src/server/setup/routes.ts +788 -0
  99. package/template/default/platform/src/server/setup/sanitize.ts +111 -0
  100. package/template/default/platform/src/server/setup/seed.ts +192 -0
  101. package/template/default/platform/src/server/setup/token.ts +79 -0
  102. package/template/default/platform/src/server/worker-tick.ts +193 -0
  103. package/template/default/platform/src/server/workspace-root.ts +16 -0
  104. package/template/default/render.yaml +70 -0
  105. package/template/default/vercel.json +5 -0
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env bash
2
+ # Point-in-time recovery for the compose stack. Base backups land in the WAL
3
+ # archive volume next to the segments; a restore replaces the cluster with one
4
+ # base backup and replays the archive up to a target time. Nothing here runs
5
+ # while the app container serves the database unless --stop-app says to stop
6
+ # it first.
7
+ set -euo pipefail
8
+
9
+ here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10
+ compose=(docker compose --env-file "$here/.env" -f "$here/compose.yaml")
11
+ archive=/var/lib/postgresql/wal-archive
12
+ recovery_wait_seconds=600
13
+
14
+ usage() {
15
+ cat <<'USAGE'
16
+ Usage:
17
+ pitr.sh base-backup
18
+ Take a base backup of the running postgres service into the WAL archive
19
+ volume (base/<stamp>/base.tar.gz).
20
+ pitr.sh list
21
+ List the base backups the archive volume holds.
22
+ pitr.sh restore --base <stamp> [--target-time '<YYYY-MM-DD HH:MM:SS+00>'] \
23
+ --confirm replace-cluster [--stop-app]
24
+ Delete the current cluster, unpack <stamp> and replay the archive up to
25
+ the target time (to the end of the archive without one). Refuses while
26
+ the app container is running unless --stop-app is passed.
27
+ USAGE
28
+ }
29
+
30
+ running() {
31
+ "${compose[@]}" ps --services --status running | grep -qx "$1"
32
+ }
33
+
34
+ base_backup() {
35
+ if ! running postgres; then
36
+ echo "The postgres service is not running; start it before taking a base backup." >&2
37
+ exit 1
38
+ fi
39
+ local stamp
40
+ stamp="$(date -u +%Y%m%dT%H%M%SZ)"
41
+ "${compose[@]}" exec -T -u postgres postgres \
42
+ pg_basebackup -U postgres -D "$archive/base/$stamp" -Ft -z -X fetch -c fast -P
43
+ echo "Base backup $stamp is in the flowdular-postgres-wal volume under base/$stamp."
44
+ echo "Copy it off the host: docker compose --env-file $here/.env -f $here/compose.yaml cp postgres:$archive/base/$stamp <dir>"
45
+ }
46
+
47
+ list() {
48
+ "${compose[@]}" run --rm --no-deps -T --entrypoint bash postgres \
49
+ -c "ls -1 $archive/base 2>/dev/null || true"
50
+ }
51
+
52
+ # Runs inside a one-off postgres container as root, with the data and archive
53
+ # volumes mounted. PITR_BASE and PITR_TARGET_TIME come from the environment.
54
+ read -r -d '' replace_cluster <<'SCRIPT' || true
55
+ set -euo pipefail
56
+ archive=/var/lib/postgresql/wal-archive
57
+ pgdata=/var/lib/postgresql/data
58
+ tarball="$archive/base/$PITR_BASE/base.tar.gz"
59
+ if [ ! -f "$tarball" ]; then
60
+ echo "No base backup at $tarball." >&2
61
+ exit 1
62
+ fi
63
+ find "$pgdata" -mindepth 1 -delete
64
+ tar -xzf "$tarball" -C "$pgdata"
65
+ {
66
+ echo ""
67
+ echo "# pitr.sh"
68
+ echo "restore_command = 'cp $archive/%f %p'"
69
+ if [ -n "$PITR_TARGET_TIME" ]; then
70
+ echo "recovery_target_time = '${PITR_TARGET_TIME//\'/\'\'}'"
71
+ fi
72
+ echo "recovery_target_action = 'promote'"
73
+ } >>"$pgdata/postgresql.conf"
74
+ touch "$pgdata/recovery.signal"
75
+ chown -R postgres:postgres "$pgdata"
76
+ chmod 700 "$pgdata"
77
+ SCRIPT
78
+
79
+ restore() {
80
+ local base="" target_time="" confirm="" stop_app=false
81
+ while [ $# -gt 0 ]; do
82
+ case "$1" in
83
+ --base)
84
+ base="${2:?--base needs a value}"
85
+ shift 2
86
+ ;;
87
+ --target-time)
88
+ target_time="${2:?--target-time needs a value}"
89
+ shift 2
90
+ ;;
91
+ --confirm)
92
+ confirm="${2:?--confirm needs a value}"
93
+ shift 2
94
+ ;;
95
+ --stop-app)
96
+ stop_app=true
97
+ shift
98
+ ;;
99
+ *)
100
+ echo "Unknown option: $1" >&2
101
+ usage >&2
102
+ exit 2
103
+ ;;
104
+ esac
105
+ done
106
+ if [ -z "$base" ]; then
107
+ echo "--base <stamp> is required; pitr.sh list shows the candidates." >&2
108
+ exit 2
109
+ fi
110
+ case "$base" in
111
+ */* | .*)
112
+ echo "--base must be a plain backup name." >&2
113
+ exit 2
114
+ ;;
115
+ esac
116
+ if [ -n "$target_time" ] &&
117
+ ! [[ "$target_time" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}[\ T][0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(Z|[+-][0-9]{2}(:?[0-9]{2})?)?$ ]]; then
118
+ echo "--target-time must look like '2026-09-14 09:30:00+00' (date, time, optional fraction and offset)." >&2
119
+ exit 2
120
+ fi
121
+ if [ "$confirm" != replace-cluster ]; then
122
+ echo "The current cluster is deleted before the base backup is unpacked. Pass --confirm replace-cluster." >&2
123
+ exit 2
124
+ fi
125
+ if running app; then
126
+ if [ "$stop_app" = true ]; then
127
+ "${compose[@]}" stop app
128
+ else
129
+ echo "The app container is running and serves the database about to be replaced. Stop it (docker compose stop app) or pass --stop-app." >&2
130
+ exit 1
131
+ fi
132
+ fi
133
+ "${compose[@]}" stop postgres
134
+ "${compose[@]}" run --rm --no-deps -T \
135
+ -e PITR_BASE="$base" -e PITR_TARGET_TIME="$target_time" \
136
+ --entrypoint bash postgres -c "$replace_cluster"
137
+ "${compose[@]}" up -d postgres
138
+ echo "Replaying the archive; waiting up to $recovery_wait_seconds seconds for promotion."
139
+ local waited=0 state
140
+ while [ "$waited" -lt "$recovery_wait_seconds" ]; do
141
+ state="$("${compose[@]}" exec -T -u postgres postgres \
142
+ psql -U postgres -d postgres -tAc 'select pg_is_in_recovery()' 2>/dev/null || true)"
143
+ if [ "$state" = f ]; then
144
+ echo "Recovery finished and the server promoted."
145
+ echo "Check the log for the recovery stop point: docker compose --env-file $here/.env -f $here/compose.yaml logs postgres | grep -i 'recovery stopping'"
146
+ echo "Then start the app (docker compose --env-file $here/.env -f $here/compose.yaml up -d app), run pnpm flowdular migration verify and check GET /api/ready."
147
+ return 0
148
+ fi
149
+ sleep 5
150
+ waited=$((waited + 5))
151
+ done
152
+ echo "The server is still in recovery after $recovery_wait_seconds seconds. Read docker compose --env-file $here/.env -f $here/compose.yaml logs postgres before doing anything else." >&2
153
+ exit 1
154
+ }
155
+
156
+ case "${1:-}" in
157
+ base-backup)
158
+ shift
159
+ base_backup "$@"
160
+ ;;
161
+ list)
162
+ shift
163
+ list "$@"
164
+ ;;
165
+ restore)
166
+ shift
167
+ restore "$@"
168
+ ;;
169
+ -h | --help | help | "")
170
+ usage
171
+ ;;
172
+ *)
173
+ echo "Unknown command: $1" >&2
174
+ usage >&2
175
+ exit 2
176
+ ;;
177
+ esac
@@ -7,25 +7,29 @@
7
7
  # poll reads, under a policy of that table's own.
8
8
  set -euo pipefail
9
9
 
10
- psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<SQL
11
- CREATE ROLE coreloom_migrator LOGIN PASSWORD '${FD_DATABASE_MIGRATOR_PASSWORD}'
10
+ psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<'SQL'
11
+ \getenv database_name POSTGRES_DB
12
+ \getenv migrator_password FD_DATABASE_MIGRATOR_PASSWORD
13
+ \getenv runtime_password FD_DATABASE_RUNTIME_PASSWORD
14
+ \getenv background_password FD_DATABASE_BACKGROUND_PASSWORD
15
+ CREATE ROLE flowdular_migrator LOGIN PASSWORD :'migrator_password'
12
16
  NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE;
13
- CREATE ROLE coreloom_runtime LOGIN PASSWORD '${FD_DATABASE_RUNTIME_PASSWORD}'
17
+ CREATE ROLE flowdular_runtime LOGIN PASSWORD :'runtime_password'
14
18
  NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE;
15
- CREATE ROLE coreloom_background LOGIN PASSWORD '${FD_DATABASE_BACKGROUND_PASSWORD}'
19
+ CREATE ROLE flowdular_background LOGIN PASSWORD :'background_password'
16
20
  NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE;
17
21
 
18
- REVOKE CONNECT ON DATABASE "$POSTGRES_DB" FROM PUBLIC;
19
- GRANT CONNECT ON DATABASE "$POSTGRES_DB" TO coreloom_migrator, coreloom_runtime, coreloom_background;
22
+ REVOKE CONNECT ON DATABASE :"database_name" FROM PUBLIC;
23
+ GRANT CONNECT ON DATABASE :"database_name" TO flowdular_migrator, flowdular_runtime, flowdular_background;
20
24
 
21
- ALTER SCHEMA public OWNER TO coreloom_migrator;
25
+ ALTER SCHEMA public OWNER TO flowdular_migrator;
22
26
  REVOKE ALL ON SCHEMA public FROM PUBLIC;
23
- GRANT USAGE ON SCHEMA public TO coreloom_runtime, coreloom_background;
27
+ GRANT USAGE ON SCHEMA public TO flowdular_runtime, flowdular_background;
24
28
 
25
29
  -- Every table the migrator creates later is reachable by the runtime role
26
30
  -- without a second grant step after each migration.
27
- ALTER DEFAULT PRIVILEGES FOR ROLE coreloom_migrator IN SCHEMA public
28
- GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO coreloom_runtime;
29
- ALTER DEFAULT PRIVILEGES FOR ROLE coreloom_migrator IN SCHEMA public
30
- GRANT USAGE, SELECT ON SEQUENCES TO coreloom_runtime;
31
+ ALTER DEFAULT PRIVILEGES FOR ROLE flowdular_migrator IN SCHEMA public
32
+ GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO flowdular_runtime;
33
+ ALTER DEFAULT PRIVILEGES FOR ROLE flowdular_migrator IN SCHEMA public
34
+ GRANT USAGE, SELECT ON SEQUENCES TO flowdular_runtime;
31
35
  SQL
@@ -0,0 +1,402 @@
1
+ #!/usr/bin/env node
2
+ import { randomBytes } from 'node:crypto';
3
+ import {
4
+ chmodSync,
5
+ closeSync,
6
+ existsSync,
7
+ fsyncSync,
8
+ lstatSync,
9
+ openSync,
10
+ readFileSync,
11
+ renameSync,
12
+ rmSync,
13
+ writeFileSync,
14
+ } from 'node:fs';
15
+ import { spawnSync } from 'node:child_process';
16
+ import { isIP } from 'node:net';
17
+ import { dirname, resolve } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { parseEnv } from 'node:util';
20
+
21
+ const directory = dirname(fileURLToPath(import.meta.url));
22
+ const envPath = resolve(directory, '.env');
23
+ const examplePath = resolve(directory, '.env.example');
24
+ const composePath = resolve(directory, 'compose.yaml');
25
+ const projectRoot = resolve(directory, '../..');
26
+
27
+ const databasePasswords = [
28
+ 'FD_POSTGRES_SUPERUSER_PASSWORD',
29
+ 'FD_DATABASE_MIGRATOR_PASSWORD',
30
+ 'FD_DATABASE_RUNTIME_PASSWORD',
31
+ 'FD_DATABASE_BACKGROUND_PASSWORD',
32
+ ];
33
+ const encryptionKeys = [
34
+ 'FD_AGENT_CREDENTIAL_KEY',
35
+ 'FD_AGENT_RUN_GRANT_KEY',
36
+ 'FD_AUTH_MFA_KEY',
37
+ 'FD_APPROVAL_GRANT_KEY',
38
+ 'FD_AUTOMATIONS_CREDENTIAL_KEY',
39
+ 'FD_NOTIFICATIONS_SECRET_KEY',
40
+ 'FD_WORKFLOWS_PAYLOAD_KEY',
41
+ 'FD_WORKFLOWS_CURSOR_KEY',
42
+ 'FD_STORAGE_ENCRYPTION_KEY',
43
+ 'FD_CONNECTORS_SECRET_KEY',
44
+ 'FD_AUDIT_ANCHOR_KEY',
45
+ ];
46
+ const secretKeys = [
47
+ ...databasePasswords,
48
+ ...encryptionKeys,
49
+ 'FD_MINIO_ROOT_PASSWORD',
50
+ ];
51
+ const plainEnvValue = /^[A-Za-z0-9_./+=:@%~-]+$/;
52
+
53
+ function setEntry(contents, key, value) {
54
+ const line = new RegExp(`^(\\s*(?:export\\s+)?${key}\\s*=)[^\\r\\n]*$`, 'm');
55
+ if (line.test(contents)) {
56
+ return contents.replace(line, (_match, prefix) => `${prefix}${value}`);
57
+ }
58
+ return `${contents.replace(/\s*$/, '\n')}${key}=${value}\n`;
59
+ }
60
+
61
+ function writePrivateFile(path, contents) {
62
+ const temporary = `${path}.${randomBytes(6).toString('hex')}.tmp`;
63
+ let handle;
64
+ try {
65
+ handle = openSync(temporary, 'wx', 0o600);
66
+ writeFileSync(handle, contents);
67
+ fsyncSync(handle);
68
+ closeSync(handle);
69
+ handle = undefined;
70
+ renameSync(temporary, path);
71
+ chmodSync(path, 0o600);
72
+ } catch (error) {
73
+ if (handle !== undefined) closeSync(handle);
74
+ rmSync(temporary, { force: true });
75
+ throw error;
76
+ }
77
+ }
78
+
79
+ function valueOf(parsed, environment, key) {
80
+ const fromFile = parsed[key] ?? '';
81
+ const fromEnvironment = environment[key] ?? '';
82
+ if (fromFile && fromEnvironment && fromFile !== fromEnvironment) {
83
+ throw new Error(
84
+ `${key} differs between the shell and infra/docker/.env. Use one value before starting.`,
85
+ );
86
+ }
87
+ return fromEnvironment || fromFile;
88
+ }
89
+
90
+ function settingOf(parsed, environment, key) {
91
+ return environment[key]?.trim() || parsed[key]?.trim() || '';
92
+ }
93
+
94
+ /** Fill only absent credentials. A restart must reuse exactly the same values. */
95
+ function ensureEnvironmentFileUnlocked(
96
+ path = envPath,
97
+ template = examplePath,
98
+ environment = process.env,
99
+ ) {
100
+ const existed = existsSync(path);
101
+ if (existed) {
102
+ const stat = lstatSync(path);
103
+ if (!stat.isFile() || stat.isSymbolicLink()) {
104
+ throw new Error('infra/docker/.env must be a regular file, not a link.');
105
+ }
106
+ }
107
+ let contents = existed
108
+ ? readFileSync(path, 'utf8')
109
+ : readFileSync(template, 'utf8');
110
+ let parsed = parseEnv(contents);
111
+ let generated = 0;
112
+ const fileProjectName = parsed.COMPOSE_PROJECT_NAME?.trim() ?? '';
113
+ const shellProjectName = environment.COMPOSE_PROJECT_NAME?.trim() ?? '';
114
+ if (
115
+ fileProjectName &&
116
+ shellProjectName &&
117
+ fileProjectName !== shellProjectName
118
+ ) {
119
+ throw new Error(
120
+ 'COMPOSE_PROJECT_NAME differs between the shell and infra/docker/.env. Use the existing project name to keep its volumes.',
121
+ );
122
+ }
123
+ /* Earlier stacks used Compose's implicit name, docker. Persist it for old
124
+ .env files so upgrading cannot silently switch to empty volumes. */
125
+ const projectName =
126
+ fileProjectName ||
127
+ shellProjectName ||
128
+ (existed ? 'docker' : `flowdular-${randomBytes(6).toString('hex')}`);
129
+ if (!/^[a-z0-9][a-z0-9_-]*$/.test(projectName)) {
130
+ throw new Error(
131
+ 'COMPOSE_PROJECT_NAME must start with a lowercase letter or digit and contain only lowercase letters, digits, underscores or hyphens.',
132
+ );
133
+ }
134
+ if (!fileProjectName) {
135
+ contents = setEntry(contents, 'COMPOSE_PROJECT_NAME', projectName);
136
+ parsed = { ...parsed, COMPOSE_PROJECT_NAME: projectName };
137
+ generated++;
138
+ }
139
+ const port = settingOf(parsed, environment, 'FD_PORT') || '3000';
140
+ if (!/^\d+$/.test(port) || Number(port) < 1 || Number(port) > 65535) {
141
+ throw new Error('FD_PORT must be an integer from 1 to 65535.');
142
+ }
143
+ const appPath =
144
+ settingOf(parsed, environment, 'FD_APPLICATION_PATH') || '/app';
145
+ if (
146
+ appPath.length > 64 ||
147
+ !/^\/[a-z][a-z0-9-]*$/.test(appPath) ||
148
+ [
149
+ 'setup',
150
+ 'health',
151
+ 'ready',
152
+ 'assets',
153
+ 'auth',
154
+ 'api',
155
+ 'sites',
156
+ 'sign-in',
157
+ 'sign-up',
158
+ 'forgot-password',
159
+ 'reset-password',
160
+ 'accept-invitation',
161
+ ].includes(appPath.slice(1))
162
+ ) {
163
+ throw new Error(
164
+ 'FD_APPLICATION_PATH must be one available path such as /app or /backoffice.',
165
+ );
166
+ }
167
+ const bindAddress =
168
+ settingOf(parsed, environment, 'FD_BIND_ADDRESS') || '127.0.0.1';
169
+ if (isIP(bindAddress) !== 4) {
170
+ throw new Error('FD_BIND_ADDRESS must be an IPv4 address.');
171
+ }
172
+ const origin =
173
+ settingOf(parsed, environment, 'FD_AUTH_PUBLIC_ORIGIN') ||
174
+ `http://localhost:${port}`;
175
+ const localhostHttp =
176
+ /^http:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/i.test(origin);
177
+ const cookieSetting = settingOf(parsed, environment, 'FD_AUTH_SECURE_COOKIE');
178
+ if (localhostHttp && cookieSetting === 'true') {
179
+ throw new Error(
180
+ 'FD_AUTH_SECURE_COOKIE=true blocks setup on local HTTP. Set it to false in infra/docker/.env or use an HTTPS origin.',
181
+ );
182
+ }
183
+ const endpoint = settingOf(parsed, environment, 'FD_STORAGE_S3_ENDPOINT');
184
+ const accessKey = settingOf(
185
+ parsed,
186
+ environment,
187
+ 'FD_STORAGE_S3_ACCESS_KEY_ID',
188
+ );
189
+ const secretKey = settingOf(
190
+ parsed,
191
+ environment,
192
+ 'FD_STORAGE_S3_SECRET_ACCESS_KEY',
193
+ );
194
+ /* An empty endpoint with AWS credentials is the existing AWS configuration.
195
+ New local installs receive an explicit MinIO endpoint in their .env. */
196
+ const localMinio =
197
+ endpoint === 'http://minio:9000' || (!endpoint && !accessKey && !secretKey);
198
+ if (
199
+ localMinio &&
200
+ settingOf(parsed, environment, 'FD_STORAGE_S3_FORCE_PATH_STYLE') === 'false'
201
+ ) {
202
+ throw new Error(
203
+ 'FD_STORAGE_S3_FORCE_PATH_STYLE must be true when using the bundled MinIO service.',
204
+ );
205
+ }
206
+ if (!localMinio && (!accessKey || !secretKey)) {
207
+ throw new Error(
208
+ 'An external S3 service needs FD_STORAGE_S3_ACCESS_KEY_ID and FD_STORAGE_S3_SECRET_ACCESS_KEY.',
209
+ );
210
+ }
211
+ if (localMinio && !endpoint) {
212
+ contents = setEntry(
213
+ contents,
214
+ 'FD_STORAGE_S3_ENDPOINT',
215
+ 'http://minio:9000',
216
+ );
217
+ parsed = { ...parsed, FD_STORAGE_S3_ENDPOINT: 'http://minio:9000' };
218
+ generated++;
219
+ }
220
+ for (const key of secretKeys) {
221
+ const present = Boolean(parsed[key]);
222
+ const value =
223
+ valueOf(parsed, environment, key) ||
224
+ (databasePasswords.includes(key) || key === 'FD_MINIO_ROOT_PASSWORD'
225
+ ? randomBytes(32).toString('hex')
226
+ : randomBytes(32).toString('base64'));
227
+ if (!present && !plainEnvValue.test(value)) {
228
+ throw new Error(
229
+ `${key} contains characters that this launcher cannot safely persist. Put it in infra/docker/.env directly.`,
230
+ );
231
+ }
232
+ if (!present) {
233
+ contents = setEntry(contents, key, value);
234
+ parsed = { ...parsed, [key]: value };
235
+ generated++;
236
+ }
237
+ }
238
+ if (!cookieSetting) {
239
+ contents = setEntry(
240
+ contents,
241
+ 'FD_AUTH_SECURE_COOKIE',
242
+ localhostHttp ? 'false' : 'true',
243
+ );
244
+ generated++;
245
+ }
246
+ if (!existsSync(path) || contents !== readFileSync(path, 'utf8'))
247
+ writePrivateFile(path, contents);
248
+ else chmodSync(path, 0o600);
249
+ return {
250
+ path,
251
+ projectName,
252
+ port: Number(port),
253
+ appPath,
254
+ bindAddress,
255
+ publicOrigin: origin.replace(/\/+$/, ''),
256
+ generated,
257
+ };
258
+ }
259
+
260
+ export function ensureEnvironmentFile(
261
+ path = envPath,
262
+ template = examplePath,
263
+ environment = process.env,
264
+ ) {
265
+ /* The lock covers reads, generated values and the atomic rename. Without it,
266
+ concurrent launchers can initialize PostgreSQL with a password that loses
267
+ the last rename and can no longer be recovered from .env. */
268
+ const lockPath = `${path}.lock`;
269
+ let lock;
270
+ try {
271
+ lock = openSync(lockPath, 'wx', 0o600);
272
+ } catch (error) {
273
+ if (error?.code === 'EEXIST') {
274
+ throw new Error(
275
+ 'Another launcher is preparing infra/docker/.env. Wait for it to finish. If it crashed, verify no launcher is running before removing infra/docker/.env.lock.',
276
+ );
277
+ }
278
+ throw error;
279
+ }
280
+ try {
281
+ return ensureEnvironmentFileUnlocked(path, template, environment);
282
+ } finally {
283
+ closeSync(lock);
284
+ rmSync(lockPath, { force: true });
285
+ }
286
+ }
287
+
288
+ function compose(args, options = {}) {
289
+ const result = spawnSync(
290
+ 'docker',
291
+ ['compose', '--env-file', envPath, '-f', composePath, ...args],
292
+ {
293
+ cwd: projectRoot,
294
+ encoding: 'utf8',
295
+ stdio: options.capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
296
+ },
297
+ );
298
+ if (result.error) throw result.error;
299
+ if (result.status !== 0) {
300
+ throw new Error(
301
+ `docker compose ${args[0]} failed${result.stderr ? `: ${result.stderr.trim()}` : ''}.`,
302
+ );
303
+ }
304
+ return result.stdout ?? '';
305
+ }
306
+
307
+ async function waitForHealth(origin) {
308
+ for (let attempt = 0; attempt < 60; attempt++) {
309
+ try {
310
+ const response = await fetch(`${origin}/api/health`, {
311
+ signal: AbortSignal.timeout(3000),
312
+ });
313
+ if (response.ok) return;
314
+ } catch {
315
+ // Compose may have started the container before the port is listening.
316
+ }
317
+ await new Promise((done) => setTimeout(done, 2000));
318
+ }
319
+ throw new Error(
320
+ `The application did not become healthy at ${origin}/api/health.`,
321
+ );
322
+ }
323
+
324
+ function openBrowser(url) {
325
+ if (
326
+ process.platform === 'linux' &&
327
+ !process.env.DISPLAY &&
328
+ !process.env.WAYLAND_DISPLAY
329
+ )
330
+ return false;
331
+ const command =
332
+ process.platform === 'darwin'
333
+ ? 'open'
334
+ : process.platform === 'win32'
335
+ ? 'rundll32.exe'
336
+ : 'xdg-open';
337
+ const args =
338
+ process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url];
339
+ const result = spawnSync(command, args, { stdio: 'ignore', timeout: 10000 });
340
+ return !result.error && result.status === 0;
341
+ }
342
+
343
+ async function main() {
344
+ const flags = new Set(process.argv.slice(2));
345
+ if (flags.has('--help')) {
346
+ console.log('Usage: node infra/docker/start.mjs [--no-open] [--no-build]');
347
+ return;
348
+ }
349
+ for (const flag of flags) {
350
+ if (!['--no-open', '--no-build'].includes(flag))
351
+ throw new Error(`Unknown option: ${flag}`);
352
+ }
353
+ const version = spawnSync('docker', ['compose', 'version'], {
354
+ stdio: 'ignore',
355
+ });
356
+ if (version.error || version.status !== 0)
357
+ throw new Error('Docker Compose is required.');
358
+ const { port, appPath, bindAddress, publicOrigin, generated } =
359
+ ensureEnvironmentFile();
360
+ console.log(
361
+ generated
362
+ ? 'Saved first-run credentials in infra/docker/.env (owner-only).'
363
+ : 'Using existing infra/docker/.env.',
364
+ );
365
+ compose(['up', '-d', ...(flags.has('--no-build') ? [] : ['--build'])]);
366
+ const healthHost = bindAddress === '0.0.0.0' ? 'localhost' : bindAddress;
367
+ const localOrigin = `http://${healthHost}:${port}`;
368
+ await waitForHealth(localOrigin);
369
+ const setupResponse = await fetch(`${localOrigin}/setup`, {
370
+ redirect: 'manual',
371
+ signal: AbortSignal.timeout(5000),
372
+ });
373
+ const setupPage =
374
+ setupResponse.ok &&
375
+ setupResponse.headers.get('x-flowdular-setup') === 'first-run';
376
+ const url = `${publicOrigin}${setupPage ? '/setup' : appPath}`;
377
+ console.log(`Flowdular is ready: ${url}`);
378
+ if (setupPage) {
379
+ console.log('Setup token from the application log:');
380
+ const logs = compose(['logs', '--no-log-prefix', '--tail', '120', 'app'], {
381
+ capture: true,
382
+ });
383
+ const token = /(?:^|\n)\s*Token\s+([^\s]+)/.exec(logs)?.[1];
384
+ if (token) console.log(` ${token}`);
385
+ else
386
+ console.log(
387
+ ' Run: docker compose -f infra/docker/compose.yaml logs app',
388
+ );
389
+ }
390
+ if (!flags.has('--no-open') && !openBrowser(url))
391
+ console.log('Open the URL in a browser on this workstation.');
392
+ }
393
+
394
+ if (
395
+ process.argv[1] &&
396
+ resolve(process.argv[1]) === fileURLToPath(import.meta.url)
397
+ ) {
398
+ main().catch((error) => {
399
+ console.error(error.message);
400
+ process.exitCode = 1;
401
+ });
402
+ }
@@ -10,6 +10,6 @@ metadata:
10
10
  name: flowdular-database
11
11
  type: Opaque
12
12
  stringData:
13
- migratorUrl: postgresql://coreloom_migrator:CHANGE_ME@postgres:5432/flowdular
14
- runtimeUrl: postgresql://coreloom_runtime:CHANGE_ME@postgres:5432/flowdular
15
- backgroundUrl: postgresql://coreloom_background:CHANGE_ME@postgres:5432/flowdular
13
+ migratorUrl: postgresql://flowdular_migrator:CHANGE_ME@postgres:5432/flowdular
14
+ runtimeUrl: postgresql://flowdular_runtime:CHANGE_ME@postgres:5432/flowdular
15
+ backgroundUrl: postgresql://flowdular_background:CHANGE_ME@postgres:5432/flowdular
@@ -61,6 +61,11 @@ spec:
61
61
  key: backgroundUrl
62
62
  - name: FD_AUTH_ALLOW_SIGN_UP
63
63
  value: 'false'
64
+ # Tenant id that overrides the operator workspace, the one that
65
+ # changes platform settings such as the branding and the mail relay.
66
+ # Empty, the workspace auth.core recorded at first-run setup decides.
67
+ - name: FD_OPERATOR_TENANT
68
+ value: ''
64
69
  - name: FD_AUTH_SECURE_COOKIE
65
70
  value: 'true'
66
71
  # With none, invitations and password resets are refused. The smtp