create-flowdular 0.4.3 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -10
- package/agent-template/.agents/skills/agent-tool-design/SKILL.md +1 -1
- package/agent-template/.agents/skills/auth-security-review/SKILL.md +2 -2
- package/agent-template/.agents/skills/bug-hunt/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-interview/SKILL.md +20 -20
- package/agent-template/.agents/skills/test-hardening/SKILL.md +2 -2
- package/agent-template/.agents/skills/ux-design/SKILL.md +1 -1
- package/agent-template/.agents/skills/workflow-development/SKILL.md +95 -9
- package/agent-template/.ai/README.md +5 -3
- package/agent-template/.ai/agents/README.md +1 -1
- package/agent-template/.ai/agents/sandbox/agentic-engineer.md +1 -0
- package/agent-template/.ai/agents/sandbox/backend-engineer.md +1 -0
- package/agent-template/.ai/agents/sandbox/frontend-engineer.md +1 -0
- package/agent-template/.ai/blueprints/add-migration/README.md +1 -1
- 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/examples/bad/client-imports-server/README.md +1 -1
- package/agent-template/.ai/examples/bad/missing-acl/README.md +1 -1
- package/agent-template/.ai/examples/bad/tenant-from-body/README.md +1 -1
- package/agent-template/.ai/guides/application-development.md +7 -5
- package/agent-template/.ai/platform-capabilities.md +9 -5
- package/agent-template/.ai/policies/capabilities.yaml +28 -12
- package/agent-template/.ai/policies/task-budgets.yaml +1 -1
- package/agent-template/.ai/rules/flowdular.md +3 -2
- 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 +2 -2
- package/agent-template/.ai/skills/bug-hunt/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-interview/SKILL.md +20 -20
- package/agent-template/.ai/skills/test-hardening/SKILL.md +2 -2
- package/agent-template/.ai/skills/ux-design/SKILL.md +1 -1
- package/agent-template/.ai/skills/workflow-development/SKILL.md +96 -10
- package/agent-template/.ai/subagents/module-executor.md +25 -0
- package/agent-template/.ai/subagents/reviewer.md +23 -0
- package/agent-template/.ai/subagents/spec-author.md +23 -0
- package/agent-template/.claude/agents/module-executor.md +22 -0
- package/agent-template/.claude/agents/reviewer.md +24 -0
- package/agent-template/.claude/agents/spec-author.md +20 -0
- package/agent-template/.claude/skills/agent-tool-design/SKILL.md +1 -1
- package/agent-template/.claude/skills/auth-security-review/SKILL.md +2 -2
- package/agent-template/.claude/skills/bug-hunt/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-interview/SKILL.md +20 -20
- package/agent-template/.claude/skills/test-hardening/SKILL.md +2 -2
- package/agent-template/.claude/skills/ux-design/SKILL.md +1 -1
- package/agent-template/.claude/skills/workflow-development/SKILL.md +95 -9
- package/agent-template/.codex/agents/module-executor.toml +17 -0
- package/agent-template/.codex/agents/reviewer.toml +14 -0
- package/agent-template/.codex/agents/spec-author.toml +15 -0
- package/agent-template/AGENTS.md +3 -2
- package/agent-template/CLAUDE.md +3 -2
- package/agent-template/docs/adr/0007-module-owned-agents.md +35 -1
- package/agent-template/docs/agent-contract.md +2 -2
- package/agent-template/docs/cli.md +24 -3
- package/agent-template/docs/configuration.md +59 -5
- package/agent-template/docs/database-adapters.md +20 -20
- package/agent-template/docs/design-system.md +3 -3
- package/agent-template/docs/getting-started.md +25 -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 +9 -1
- package/agent-template/docs/sandbox.md +117 -6
- package/agent-template/platform/scripts/build.mjs +7 -0
- package/agent-template/rulesync.jsonc +1 -1
- package/dist/bin.js +12 -6
- package/package.json +2 -2
- package/template/default/.env.example +10 -3
- package/template/default/.prettierignore +2 -0
- package/template/default/.vercelignore +8 -0
- package/template/default/README.md +26 -15
- package/template/default/_gitignore +3 -2
- package/template/default/infra/README.md +86 -65
- package/template/default/infra/docker/.env.example +66 -0
- package/template/default/infra/docker/Dockerfile +24 -10
- package/template/default/infra/docker/app-entrypoint.mjs +5 -0
- package/template/default/infra/docker/compose.yaml +105 -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/vercel/README.md +262 -0
- package/template/default/infra/vercel/build.mjs +214 -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 +3 -2
- package/template/default/platform/index.html +7 -19
- package/template/default/platform/octane.config.ts +252 -156
- package/template/default/platform/package.json +5 -5
- package/template/default/platform/public/favicon.svg +1 -1
- package/template/default/platform/scripts/build.mjs +56 -0
- package/template/default/platform/scripts/dev.mjs +38 -0
- package/template/default/platform/src/App.tsrx +25 -1
- package/template/default/platform/src/generated/modules.server.ts +3 -0
- package/template/default/platform/src/server/database.ts +24 -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 +123 -0
- package/template/default/platform/src/server/setup/page.ts +497 -0
- package/template/default/platform/src/server/setup/routes.ts +787 -0
- package/template/default/platform/src/server/setup/sanitize.ts +111 -0
- package/template/default/platform/src/server/setup/seed.ts +145 -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,28 @@
|
|
|
1
|
+
/** Build local PostgreSQL URLs after Compose has injected raw passwords. */
|
|
2
|
+
export function installDatabaseUrls(environment) {
|
|
3
|
+
const connections = [
|
|
4
|
+
['FD_DATABASE_URL', 'FD_DATABASE_RUNTIME_PASSWORD', 'flowdular_runtime'],
|
|
5
|
+
[
|
|
6
|
+
'FD_DATABASE_MIGRATOR_URL',
|
|
7
|
+
'FD_DATABASE_MIGRATOR_PASSWORD',
|
|
8
|
+
'flowdular_migrator',
|
|
9
|
+
],
|
|
10
|
+
[
|
|
11
|
+
'FD_DATABASE_BACKGROUND_URL',
|
|
12
|
+
'FD_DATABASE_BACKGROUND_PASSWORD',
|
|
13
|
+
'flowdular_background',
|
|
14
|
+
],
|
|
15
|
+
];
|
|
16
|
+
for (const [urlKey, passwordKey, role] of connections) {
|
|
17
|
+
if (!environment[urlKey]?.trim()) {
|
|
18
|
+
const password = environment[passwordKey];
|
|
19
|
+
if (!password)
|
|
20
|
+
throw new Error(
|
|
21
|
+
`${passwordKey} is required for the local PostgreSQL service.`,
|
|
22
|
+
);
|
|
23
|
+
environment[urlKey] =
|
|
24
|
+
`postgresql://${role}:${encodeURIComponent(password)}@postgres:5432/flowdular`;
|
|
25
|
+
}
|
|
26
|
+
delete environment[passwordKey];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -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
|
-
|
|
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
|
|
17
|
+
CREATE ROLE flowdular_runtime LOGIN PASSWORD :'runtime_password'
|
|
14
18
|
NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE;
|
|
15
|
-
CREATE ROLE
|
|
19
|
+
CREATE ROLE flowdular_background LOGIN PASSWORD :'background_password'
|
|
16
20
|
NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE;
|
|
17
21
|
|
|
18
|
-
REVOKE CONNECT ON DATABASE "
|
|
19
|
-
GRANT CONNECT ON DATABASE "
|
|
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
|
|
25
|
+
ALTER SCHEMA public OWNER TO flowdular_migrator;
|
|
22
26
|
REVOKE ALL ON SCHEMA public FROM PUBLIC;
|
|
23
|
-
GRANT USAGE ON SCHEMA public TO
|
|
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
|
|
28
|
-
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO
|
|
29
|
-
ALTER DEFAULT PRIVILEGES FOR ROLE
|
|
30
|
-
GRANT USAGE, SELECT ON SEQUENCES TO
|
|
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://
|
|
14
|
-
runtimeUrl: postgresql://
|
|
15
|
-
backgroundUrl: postgresql://
|
|
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
|