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,548 @@
1
+ import type {
2
+ DatabaseAdapterProbeResult,
3
+ DatabaseAdapterPublicDescriptor,
4
+ DatabaseConfigurationField,
5
+ } from '@flowdular/sdk/database';
6
+ import { SETUP_CSRF_FIELD } from './access.ts';
7
+ import type { EnvironmentWriteResult } from './environment.ts';
8
+ import type { FirstRunSeed } from './seed.ts';
9
+
10
+ /* The installer runs before the application bundle can be served: there is no
11
+ database, so there is no shell, no session and no client build to depend on.
12
+ It therefore ships its own markup and its own copy of the design tokens it
13
+ uses, taken from packages/ui/src/styles/tokens.css, and namespaces every
14
+ class as setup-* so it can never restyle a ui-* primitive. */
15
+
16
+ const STEPS = ['Unlock', 'Database', 'Workspace', 'Review', 'Sign in'] as const;
17
+ export type SetupStepName = (typeof STEPS)[number];
18
+
19
+ export interface ModuleCompatibility {
20
+ readonly moduleId: string;
21
+ readonly tenantOwned: boolean;
22
+ readonly issues: readonly string[];
23
+ }
24
+
25
+ export interface SetupPageView {
26
+ readonly step: SetupStepName;
27
+ readonly csrfToken: string | null;
28
+ readonly databasePreconfigured: boolean;
29
+ /** The application itself serves setup and needs no restart afterwards. */
30
+ readonly inPlace?: boolean;
31
+ readonly autoRestart: boolean;
32
+ readonly error: string | null;
33
+ readonly notice: string | null;
34
+ readonly adapters: readonly DatabaseAdapterPublicDescriptor[];
35
+ readonly selectedAdapterId: string | null;
36
+ readonly fieldErrors: Readonly<Record<string, string>>;
37
+ readonly values: Readonly<Record<string, string>>;
38
+ readonly probe: DatabaseAdapterProbeResult | null;
39
+ readonly modules: readonly ModuleCompatibility[];
40
+ readonly environment: EnvironmentWriteResult | null;
41
+ readonly seed: FirstRunSeed | null;
42
+ readonly modulesApproximated: boolean;
43
+ readonly tokenFile: string | null;
44
+ readonly passwordMinLength: number;
45
+ }
46
+
47
+ export function escapeHtml(value: string): string {
48
+ return value
49
+ .replaceAll('&', '&amp;')
50
+ .replaceAll('<', '&lt;')
51
+ .replaceAll('>', '&gt;')
52
+ .replaceAll('"', '&quot;')
53
+ .replaceAll("'", '&#39;');
54
+ }
55
+
56
+ /* Only the tokens this page uses, with the values tokens.css defines. A first
57
+ run cannot import the stylesheet: the container image ships platform/dist
58
+ and nothing else. */
59
+ const STYLES = `
60
+ :root{
61
+ --ink-0:#ffffff;--ink-25:#f9fafc;--ink-50:#f4f6fa;--ink-100:#e7ebf2;
62
+ --ink-200:#d3dae5;--ink-300:#b2bccc;--ink-400:#8794ab;--ink-500:#64738f;
63
+ --ink-600:#475572;--ink-900:#141b2e;--blue-50:#f1f5fe;--blue-100:#e3ebfc;
64
+ --blue-600:#2557d6;--blue-700:#1c45b3;--copper-300:#e08a45;--copper-500:#c9722d;
65
+ --green-50:#e6f5ec;--green-200:#b7e3c8;--green-600:#1b7f4e;
66
+ --amber-50:#fff4d6;--amber-200:#f3dc9a;--amber-600:#9a6700;
67
+ --red-50:#fce9e7;--red-200:#f2bdb9;--red-600:#c4322b;
68
+ --bg:var(--ink-50);--surface:var(--ink-0);--surface-2:var(--ink-25);
69
+ --line:var(--ink-200);--line-2:var(--ink-100);--ink:var(--ink-900);
70
+ --ink-2:var(--ink-600);--ink-3:var(--ink-500);--ink-4:var(--ink-400);
71
+ --primary:var(--blue-600);--primary-hover:var(--blue-700);--focus:var(--blue-600);
72
+ --success:var(--green-600);--success-bg:var(--green-50);--success-line:var(--green-200);
73
+ --warning:var(--amber-600);--warning-bg:var(--amber-50);--warning-line:var(--amber-200);
74
+ --danger:var(--red-600);--danger-bg:var(--red-50);--danger-line:var(--red-200);
75
+ --font-sans:'IBM Plex Sans Variable','IBM Plex Sans','Segoe UI',system-ui,-apple-system,sans-serif;
76
+ --font-mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,monospace;
77
+ --text-xs:11px;--text-sm:12px;--text-md:13px;--text-base:14px;--text-lg:16px;
78
+ --control-h:36px;--r-sm:4px;--r:6px;--r-md:8px;--r-lg:12px;
79
+ --shadow-sm:0 1px 2px rgba(20,27,46,.06);
80
+ }
81
+ *{box-sizing:border-box}
82
+ html,body{margin:0;padding:0}
83
+ body{font-family:var(--font-sans);font-size:var(--text-base);color:var(--ink);background:var(--bg);-webkit-font-smoothing:antialiased}
84
+ .setup-page{display:grid;min-height:100vh;grid-template-columns:minmax(360px,.95fr) minmax(480px,1.05fr)}
85
+ .setup-story{position:relative;display:flex;min-height:100vh;flex-direction:column;padding:40px 48px;color:var(--ink-0);background:var(--ink-900);overflow:hidden}
86
+ .setup-story>*{position:relative}
87
+ .setup-backdrop{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}
88
+ .setup-brand{display:inline-flex;width:fit-content;align-items:center;gap:9px;font-size:var(--text-lg);font-weight:600;letter-spacing:-.01em}
89
+ .setup-brand svg{display:block}
90
+ .setup-kicker{font-size:var(--text-xs);font-weight:600;letter-spacing:.09em;text-transform:uppercase;color:var(--ink-3)}
91
+ .setup-story .setup-kicker{color:var(--copper-300)}
92
+ .setup-story__copy{max-width:560px;margin-block:auto;padding:60px 0}
93
+ .setup-story h1{max-width:16ch;margin:14px 0 16px;font-size:clamp(28px,3vw,36px);font-weight:600;line-height:1.12;letter-spacing:-.02em}
94
+ .setup-story__copy p{max-width:44ch;margin:0;font-size:var(--text-base);line-height:1.6;color:var(--ink-300)}
95
+ .setup-story footer{display:flex;justify-content:space-between;gap:16px;font-family:var(--font-mono);font-size:var(--text-xs);letter-spacing:.04em;color:var(--ink-400)}
96
+ .setup-panel{display:grid;place-items:center;padding:48px 32px;background:var(--bg)}
97
+ .setup-card{display:grid;gap:14px;width:min(520px,100%);padding:28px;background:var(--surface);border:1px solid var(--line);border-radius:var(--r-lg);box-shadow:var(--shadow-sm)}
98
+ .setup-card h2{margin:2px 0 0;font-size:20px;font-weight:600;letter-spacing:-.015em}
99
+ .setup-card__sub{margin:0 0 6px;font-size:var(--text-md);line-height:1.55;color:var(--ink-3)}
100
+ .setup-steps{display:flex;flex-wrap:wrap;gap:14px;margin:0 0 4px;padding:0;list-style:none}
101
+ .setup-steps li{display:inline-flex;align-items:center;gap:7px;font-size:var(--text-sm);font-weight:500;color:var(--ink-4)}
102
+ .setup-steps li>i{display:grid;width:20px;height:20px;place-items:center;font-family:var(--font-mono);font-size:10px;font-style:normal;color:var(--ink-3);background:var(--surface-2);border:1px solid var(--line);border-radius:50%}
103
+ .setup-steps li.is-active{color:var(--ink)}
104
+ .setup-steps li.is-active>i{color:var(--ink-0);background:var(--primary);border-color:var(--primary)}
105
+ .setup-steps li.is-done{color:var(--ink-3)}
106
+ .setup-steps li.is-done>i{color:var(--success);background:var(--success-bg);border-color:var(--success-line)}
107
+ .setup-form{display:grid;gap:14px}
108
+ .setup-field{display:grid;gap:5px}
109
+ .setup-label{font-size:var(--text-sm);font-weight:500;color:var(--ink-2)}
110
+ .setup-input,.setup-select{width:100%;height:var(--control-h);padding:0 10px;font:inherit;font-size:var(--text-md);color:var(--ink);background:var(--surface);border:1px solid var(--line);border-radius:var(--r)}
111
+ .setup-input[readonly]{color:var(--ink-2);background:var(--surface-2)}
112
+ .setup-input:focus-visible,.setup-select:focus-visible,.setup-btn:focus-visible,.setup-choice:focus-within{outline:2px solid var(--focus);outline-offset:1px}
113
+ .setup-input--error{border-color:var(--danger)}
114
+ .setup-help{font-size:var(--text-sm);line-height:1.5;color:var(--ink-3)}
115
+ .setup-help--error{color:var(--danger)}
116
+ .setup-row{display:grid;gap:12px;grid-template-columns:repeat(2,minmax(0,1fr))}
117
+ .setup-btn{display:inline-flex;height:var(--control-h);align-items:center;justify-content:center;gap:6px;padding:0 16px;font:inherit;font-size:var(--text-md);font-weight:500;color:var(--ink);text-decoration:none;cursor:pointer;background:var(--surface);border:1px solid var(--line);border-radius:var(--r)}
118
+ .setup-btn:hover{background:var(--surface-2)}
119
+ .setup-btn--primary{color:var(--ink-0);background:var(--primary);border-color:var(--primary)}
120
+ .setup-btn--primary:hover{background:var(--primary-hover);border-color:var(--primary-hover)}
121
+ .setup-btn--block{width:100%}
122
+ .setup-foot{display:flex;align-items:center;justify-content:space-between;gap:10px}
123
+ .setup-foot>:only-child{margin-left:auto}
124
+ .setup-alert{padding:10px 12px;font-size:var(--text-md);line-height:1.5;border:1px solid var(--danger-line);border-radius:var(--r);color:var(--danger);background:var(--danger-bg)}
125
+ .setup-alert--info{color:var(--ink-2);background:var(--blue-50);border-color:var(--blue-100)}
126
+ .setup-alert--warning{color:var(--amber-600);background:var(--warning-bg);border-color:var(--warning-line)}
127
+ .setup-alert--success{color:var(--green-600);background:var(--success-bg);border-color:var(--success-line)}
128
+ .setup-choices{display:grid;gap:10px}
129
+ .setup-choice{display:grid;grid-template-columns:auto 1fr;gap:10px;padding:12px 14px;border:1px solid var(--line);border-radius:var(--r-md);background:var(--surface)}
130
+ .setup-choice b{font-size:var(--text-base);font-weight:600}
131
+ .setup-choice span{display:block;margin-top:3px;font-size:var(--text-sm);line-height:1.5;color:var(--ink-3)}
132
+ .setup-list{display:grid;gap:6px;margin:0;padding:0;list-style:none}
133
+ .setup-list li{display:flex;align-items:baseline;justify-content:space-between;gap:12px;padding:7px 10px;font-size:var(--text-md);background:var(--surface-2);border:1px solid var(--line-2);border-radius:var(--r-sm)}
134
+ .setup-list li>*{min-width:0;overflow-wrap:anywhere}
135
+ .setup-list code{font-family:var(--font-mono);font-size:var(--text-sm)}
136
+ .setup-state{font-size:var(--text-sm);font-weight:500}
137
+ .setup-state--ok{color:var(--success)}
138
+ .setup-state--bad{color:var(--danger)}
139
+ .setup-credentials{display:grid;gap:8px;margin:0;padding:0}
140
+ .setup-credentials div{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 12px;align-items:baseline;padding:10px 12px;background:var(--surface-2);border:1px solid var(--line);border-radius:var(--r)}
141
+ .setup-credentials code{font-family:var(--font-mono);font-size:var(--text-md);overflow-wrap:anywhere;-webkit-user-select:all;user-select:all}
142
+ .setup-credentials b{grid-column:1/-1;font-size:var(--text-xs);font-weight:600;letter-spacing:.09em;text-transform:uppercase;color:var(--ink-3)}
143
+ .setup-fieldset{border:0;margin:0;padding:0}
144
+ .setup-legend{padding:0}
145
+ .setup-sublabel{margin:0 0 6px}
146
+ .setup-note--gap{margin-top:8px}
147
+ .setup-note{margin:0;font-size:var(--text-sm);line-height:1.55;color:var(--ink-3)}
148
+ @media (max-width:900px){
149
+ .setup-page{grid-template-columns:1fr}
150
+ .setup-story{min-height:280px;padding:28px}
151
+ .setup-story__copy{padding:36px 0 16px}
152
+ .setup-story footer{display:none}
153
+ .setup-panel{padding:28px 16px 48px}
154
+ .setup-row{grid-template-columns:1fr}
155
+ }
156
+ @media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
157
+ `;
158
+
159
+ /* Decoration only: aria-hidden, pointer-events none, and every step is
160
+ readable with the canvas missing entirely. Mirrors the sign-in backdrop so
161
+ the installer looks like the product it installs. */
162
+ const BACKDROP = `
163
+ (function(){
164
+ var c=document.getElementById('setup-backdrop');
165
+ var p=c&&c.parentElement,x=c&&c.getContext&&c.getContext('2d');
166
+ if(!c||!p||!x)return;
167
+ var S=30,R=130,dots=[],w=0,h=0,s=1,t=0,px=-1e4,py=-1e4,frame=0;
168
+ var reduced=window.matchMedia('(prefers-reduced-motion: reduce)');
169
+ function build(){var r=p.getBoundingClientRect();w=r.width;h=r.height;
170
+ s=Math.min(window.devicePixelRatio||1,2);c.width=Math.max(1,Math.round(w*s));c.height=Math.max(1,Math.round(h*s));
171
+ dots=[];var i=0;for(var y=S/2;y<h;y+=S)for(var q=S/2;q<w;q+=S)dots.push({x:q,y:y,ox:q,oy:y,vx:0,vy:0,copper:i++%23===0});}
172
+ function draw(animate){x.setTransform(s,0,0,s,0,0);x.clearRect(0,0,w,h);
173
+ for(var i=0;i<dots.length;i++){var d=dots[i];
174
+ if(animate){var dx=d.x-px,dy=d.y-py,q=dx*dx+dy*dy;
175
+ if(q<R*R){var l=Math.sqrt(q)||1,f=(1-l/R)*3.2;d.vx+=dx/l*f;d.vy+=dy/l*f;}
176
+ d.vx+=(d.ox+Math.sin(t*.7+d.oy*.045)*1.6-d.x)*.02;
177
+ d.vy+=(d.oy+Math.cos(t*.6+d.ox*.05)*1.6-d.y)*.02;
178
+ d.vx*=.9;d.vy*=.9;d.x+=d.vx;d.y+=d.vy;}
179
+ var drift=Math.hypot(d.x-d.ox,d.y-d.oy),a=Math.min(.5,.14+drift*.02);
180
+ x.beginPath();x.arc(d.x,d.y,d.copper?1.9:1.4,0,Math.PI*2);
181
+ x.fillStyle=d.copper?'rgba(224,138,69,'+(a+.14)+')':'rgba(135,148,171,'+a+')';x.fill();}}
182
+ function tick(){t+=1/60;draw(true);frame=requestAnimationFrame(tick);}
183
+ build();
184
+ if(reduced.matches){draw(false);}else{
185
+ p.addEventListener('pointermove',function(e){var r=p.getBoundingClientRect();px=e.clientX-r.left;py=e.clientY-r.top;});
186
+ p.addEventListener('pointerleave',function(){px=-1e4;py=-1e4;});
187
+ frame=requestAnimationFrame(tick);}
188
+ new ResizeObserver(function(){cancelAnimationFrame(frame);build();
189
+ if(reduced.matches){draw(false);}else{frame=requestAnimationFrame(tick);}}).observe(p);
190
+ })();
191
+ `;
192
+
193
+ const MARK = `<svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true"><g fill="#ffffff"><rect x="2" y="3" width="20" height="5" rx="2.5"/><rect x="8" y="10" width="14" height="5" rx="2.5"/></g><g fill="#e08a45"><rect x="14" y="17" width="8" height="5" rx="2.5"/></g></svg>`;
194
+
195
+ function steps(current: SetupStepName, databasePreconfigured: boolean): string {
196
+ const index = STEPS.indexOf(current);
197
+ const items = STEPS.map((name, position) => {
198
+ const done =
199
+ position < index || (databasePreconfigured && name === 'Database');
200
+ const state = position === index ? 'is-active' : done ? 'is-done' : '';
201
+ const marker =
202
+ done && position !== index ? '&#10003;' : `0${position + 1}`.slice(-2);
203
+ const label =
204
+ databasePreconfigured && name === 'Database'
205
+ ? 'Database, configured by deployment'
206
+ : name;
207
+ return `<li class="${state}"${position === index ? ' aria-current="step"' : ''} aria-label="${escapeHtml(label)}"><i aria-hidden="true">${marker}</i>${escapeHtml(name)}</li>`;
208
+ });
209
+ return `<ol class="setup-steps" aria-label="Setup progress">${items.join('')}</ol>`;
210
+ }
211
+
212
+ function field(
213
+ definition: DatabaseConfigurationField,
214
+ adapterId: string,
215
+ view: SetupPageView,
216
+ ): string {
217
+ const name = `field:${adapterId}:${definition.key}`;
218
+ const id = `f-${adapterId.replaceAll('.', '-')}-${definition.key}`;
219
+ const error = view.fieldErrors[definition.key];
220
+ const raw = view.values[name] ?? '';
221
+ /* A secret is never rendered back into the form: the operator retypes it
222
+ rather than having the browser and the response carry it again. */
223
+ const value = definition.secret ? '' : raw;
224
+ const describedBy = `${id}-help`;
225
+ const control = definition.secret
226
+ ? `<input class="setup-input${error ? ' setup-input--error' : ''}" id="${id}" name="${escapeHtml(name)}" type="password" autocomplete="new-password" spellcheck="false" maxlength="512"${definition.required ? ' required' : ''} aria-describedby="${describedBy}">`
227
+ : definition.kind === 'select'
228
+ ? `<select class="setup-select${error ? ' setup-input--error' : ''}" id="${id}" name="${escapeHtml(name)}" aria-describedby="${describedBy}">${(
229
+ definition.options ?? []
230
+ )
231
+ .map(
232
+ (option) =>
233
+ `<option value="${escapeHtml(option.value)}"${option.value === value ? ' selected' : ''}>${escapeHtml(option.label)}</option>`,
234
+ )
235
+ .join('')}</select>`
236
+ : `<input class="setup-input${error ? ' setup-input--error' : ''}" id="${id}" name="${escapeHtml(name)}" type="${definition.kind === 'integer' ? 'number' : 'text'}" value="${escapeHtml(value)}" spellcheck="false" maxlength="512"${definition.required ? ' required' : ''} aria-describedby="${describedBy}">`;
237
+ return `<div class="setup-field"><label class="setup-label" for="${id}">${escapeHtml(definition.label)}</label>${control}<p class="setup-help${error ? ' setup-help--error' : ''}" id="${describedBy}"${error ? ' role="alert"' : ''}>${escapeHtml(error ?? definition.description)}</p></div>`;
238
+ }
239
+
240
+ function csrf(view: SetupPageView): string {
241
+ return view.csrfToken
242
+ ? `<input type="hidden" name="${SETUP_CSRF_FIELD}" value="${escapeHtml(view.csrfToken)}">`
243
+ : '';
244
+ }
245
+
246
+ function alerts(view: SetupPageView): string {
247
+ return [
248
+ view.error
249
+ ? `<p class="setup-alert" role="alert">${escapeHtml(view.error)}</p>`
250
+ : '',
251
+ view.notice
252
+ ? `<p class="setup-alert setup-alert--info">${escapeHtml(view.notice)}</p>`
253
+ : '',
254
+ ].join('');
255
+ }
256
+
257
+ function unlockStep(view: SetupPageView): string {
258
+ return `<header><span class="setup-kicker">First run</span><h2>Unlock setup</h2>
259
+ <p class="setup-card__sub">${
260
+ view.inPlace
261
+ ? 'The setup token was printed by the command that deployed this app'
262
+ : `The setup token was printed in this deployment's output when it started${
263
+ view.tokenFile
264
+ ? ` and written to <code>${escapeHtml(view.tokenFile)}</code>`
265
+ : ''
266
+ }`
267
+ }. Paste it here to continue.</p></header>
268
+ ${steps(view.step, view.databasePreconfigured)}${alerts(view)}
269
+ <form class="setup-form" method="post" action="/setup"><input type="hidden" name="step" value="unlock">
270
+ <div class="setup-field"><label class="setup-label" for="setup-token">Setup token</label>
271
+ <input class="setup-input" id="setup-token" name="token" type="password" autocomplete="off" spellcheck="false" maxlength="256" required autofocus aria-describedby="setup-token-help">
272
+ <p class="setup-help" id="setup-token-help">${view.inPlace ? 'Running the deploy command again issues a new token.' : 'Restarting this deployment issues a new token.'}</p></div>
273
+ <button class="setup-btn setup-btn--primary setup-btn--block" type="submit">Continue</button></form>`;
274
+ }
275
+
276
+ function configureStep(view: SetupPageView): string {
277
+ const selected = view.selectedAdapterId ?? view.adapters[0]?.adapterId ?? '';
278
+ const choices = view.adapters
279
+ .map(
280
+ (adapter) =>
281
+ `<label class="setup-choice"><input type="radio" name="adapter" value="${escapeHtml(adapter.adapterId)}"${adapter.adapterId === selected ? ' checked' : ''}><span><b>${escapeHtml(adapter.label)}</b><span>${escapeHtml(adapter.description)}</span></span></label>`,
282
+ )
283
+ .join('');
284
+ const fieldsets = view.adapters
285
+ .map((adapter) => {
286
+ const inner = adapter.configurationSchema.fields
287
+ .map((definition) => field(definition, adapter.adapterId, view))
288
+ .join('');
289
+ /* Every fieldset is rendered open. The script below collapses the ones
290
+ the operator did not pick, so the form stays complete without it. */
291
+ return `<fieldset class="setup-form setup-fieldset" data-adapter="${escapeHtml(adapter.adapterId)}"><legend class="setup-label setup-legend">${escapeHtml(adapter.label)}</legend>${inner || '<p class="setup-help">Nothing to configure.</p>'}</fieldset>`;
292
+ })
293
+ .join('');
294
+ return `<header><span class="setup-kicker">Database</span><h2>Connect PostgreSQL</h2>
295
+ <p class="setup-card__sub">Choose a PostgreSQL connection. Flowdular tests it before running migrations or creating a workspace.</p></header>
296
+ ${steps(view.step, view.databasePreconfigured)}${alerts(view)}
297
+ <form class="setup-form" method="post" action="/setup">${csrf(view)}<input type="hidden" name="step" value="configure">
298
+ <div class="setup-choices">${choices}</div>${fieldsets}
299
+ <button class="setup-btn setup-btn--primary setup-btn--block" type="submit">Test connection</button></form>`;
300
+ }
301
+
302
+ /* Mirrors validateWorkspaceSlug (3 to 48 characters, no double hyphen) in the
303
+ syntax browsers compile pattern attributes with (the v flag). */
304
+ const WORKSPACE_SLUG_INPUT_PATTERN =
305
+ '(?!.*--)[a-z0-9][a-z0-9\\-]{1,46}[a-z0-9]';
306
+
307
+ /* What the address field keeps of typed or pasted text. Accented letters fold
308
+ to their base letter: NFD splits the accent off and the drop below removes
309
+ it, and the map covers letters that do not decompose. A short value and a
310
+ trailing hyphen pass, because typing goes through both; the pattern and the
311
+ server refuse them on submit. Every step reads left to right, one character
312
+ at a time, so filtering the text up to the end of an edit tells what that
313
+ edit may insert. The page ships this source and tests evaluate it. */
314
+ export const WORKSPACE_SLUG_TYPING_FILTER = String.raw`(function(){var fold={'ł':'l','Ł':'l','ø':'o','Ø':'o','đ':'d','Đ':'d','ð':'d','Ð':'d','þ':'th','Þ':'th','æ':'ae','Æ':'ae','œ':'oe','Œ':'oe','ß':'ss','ẞ':'ss','ı':'i'};
315
+ return function(value){return value.normalize('NFD').replace(/[^\x00-\x7f]/g,function(c){return fold[c]||c;}).toLowerCase().replace(/\s/g,'-').replace(/[^a-z0-9-]/g,'').replace(/-{2,}/g,'-').replace(/^-/,'').slice(0,48);};})()`;
316
+
317
+ /* An insertion at the selection that the page may cancel is filtered before
318
+ the browser applies it and replayed as an editing command, so undo, redo,
319
+ the caret and maxlength only ever see filtered text; assigning the value
320
+ instead corrupts Chrome's undo history. A failed replay lets the raw edit
321
+ through for the pattern to refuse. Deletions pass untouched: they add no
322
+ character, and rewriting them would erase separators mid-edit. Composed
323
+ text is filtered once committed, in every engine. */
324
+ const WORKSPACE_SLUG_TYPING = `
325
+ (function(filter){var input=document.getElementById('setup-workspaceSlug');if(!input)return;
326
+ function kept(start,end,text){var value=input.value,head=value.slice(0,start);
327
+ var next=filter(head+text).slice(filter(head).length);
328
+ return next.slice(-1)==='-'&&value.charAt(end)==='-'?next.slice(0,-1):next;}
329
+ function insert(text){return document.execCommand(text?'insertText':'delete',false,text);}
330
+ input.addEventListener('beforeinput',function(event){
331
+ if(!event.cancelable||event.isComposing||!/^insert(Text|FromPaste|FromDrop)$/.test(event.inputType))return;
332
+ var text=event.data!=null?event.data:event.dataTransfer?event.dataTransfer.getData('text/plain'):'';
333
+ var next=kept(input.selectionStart,input.selectionEnd,text);
334
+ if(next!==text&&(!next||insert(next)))event.preventDefault();});
335
+ input.addEventListener('compositionend',function(event){
336
+ var text=event.data||'',end=input.selectionStart,start=end-text.length;
337
+ if(!text||start<0||input.value.slice(start,end)!==text)return;
338
+ var next=kept(start,end,text);
339
+ if(next!==text){input.setSelectionRange(start,end);insert(next);}});
340
+ })(${WORKSPACE_SLUG_TYPING_FILTER});
341
+ `;
342
+
343
+ function workspaceField(
344
+ view: SetupPageView,
345
+ name: string,
346
+ label: string,
347
+ options: {
348
+ readonly type?: 'text' | 'email' | 'password';
349
+ readonly autocomplete?: string;
350
+ readonly autocapitalize?: string;
351
+ readonly autocorrect?: string;
352
+ readonly hint: string;
353
+ readonly maxlength: number;
354
+ readonly spellcheck?: boolean;
355
+ readonly readonly?: boolean;
356
+ readonly pattern?: string;
357
+ },
358
+ ): string {
359
+ const id = `setup-${name}`;
360
+ const error = view.fieldErrors[name];
361
+ const type = options.type ?? 'text';
362
+ const value = type === 'password' ? '' : (view.values[name] ?? '');
363
+ return `<div class="setup-field"><label class="setup-label" for="${id}">${escapeHtml(label)}</label>
364
+ <input class="setup-input${error ? ' setup-input--error' : ''}" id="${id}" name="${name}" type="${type}"${type === 'password' ? '' : ` value="${escapeHtml(value)}"`}${options.autocomplete ? ` autocomplete="${escapeHtml(options.autocomplete)}"` : ''}${options.autocapitalize ? ` autocapitalize="${escapeHtml(options.autocapitalize)}"` : ''}${options.autocorrect ? ` autocorrect="${escapeHtml(options.autocorrect)}"` : ''}${options.spellcheck === false ? ' spellcheck="false"' : ''}${options.readonly ? ' readonly' : ''}${options.pattern ? ` pattern="${escapeHtml(options.pattern)}"` : ''} maxlength="${options.maxlength}" required aria-describedby="${id}-help"${error ? ' aria-invalid="true"' : ''}>
365
+ <p class="setup-help${error ? ' setup-help--error' : ''}" id="${id}-help"${error ? ' role="alert"' : ''}>${escapeHtml(error ?? options.hint)}</p></div>`;
366
+ }
367
+
368
+ function workspaceStep(view: SetupPageView): string {
369
+ const back = view.databasePreconfigured
370
+ ? ''
371
+ : '<button class="setup-btn" type="submit" name="step" value="back" formnovalidate>Back</button>';
372
+ const nextClass = view.databasePreconfigured
373
+ ? 'setup-btn setup-btn--primary setup-btn--block'
374
+ : 'setup-btn setup-btn--primary';
375
+ return `<header><span class="setup-kicker">Workspace</span><h2>Create your workspace</h2>
376
+ <p class="setup-card__sub">Set up the first workspace and the owner account you will use to sign in.</p></header>
377
+ ${steps(view.step, view.databasePreconfigured)}${alerts(view)}
378
+ ${view.databasePreconfigured ? '<p class="setup-alert setup-alert--success">PostgreSQL is already configured for this deployment.</p>' : ''}
379
+ <form class="setup-form" method="post" action="/setup">${csrf(view)}
380
+ ${workspaceField(view, 'workspaceName', 'Workspace name', { hint: 'The name shown to people in this workspace.', maxlength: 120 })}
381
+ ${workspaceField(view, 'workspaceSlug', 'Workspace address', { hint: '3 to 48 lowercase letters, numbers, or single hyphens.', maxlength: 48, autocapitalize: 'none', autocorrect: 'off', spellcheck: false, pattern: WORKSPACE_SLUG_INPUT_PATTERN })}
382
+ <div class="setup-row">
383
+ ${workspaceField(view, 'ownerName', 'Your name', { hint: 'Shown on your owner account.', maxlength: 80, autocomplete: 'name' })}
384
+ ${workspaceField(view, 'ownerEmail', 'Your email', { type: 'email', hint: 'Used to sign in.', maxlength: 254, autocomplete: 'email', spellcheck: false })}
385
+ </div>
386
+ ${workspaceField(view, 'ownerPassword', 'Your password', { type: 'password', hint: `Use at least ${view.passwordMinLength} characters. Your password will not be shown again.`, maxlength: 512, autocomplete: 'new-password' })}
387
+ ${workspaceField(view, 'ownerPasswordConfirm', 'Confirm your password', { type: 'password', hint: 'Re-enter the password for the owner account.', maxlength: 512, autocomplete: 'new-password' })}
388
+ ${workspaceField(view, 'applicationPath', 'Backoffice address', { hint: view.databasePreconfigured ? 'This address is set by the deployment. Change its configuration and restart to choose another.' : 'Use /app or another path, such as /backoffice. Takes effect after restart.', maxlength: 64, spellcheck: false, readonly: view.databasePreconfigured })}
389
+ <div class="setup-foot">${back}<button class="${nextClass}" type="submit" name="step" value="workspace">Review setup</button></div></form>`;
390
+ }
391
+
392
+ function reviewStep(view: SetupPageView): string {
393
+ const blocked = view.modules.filter((module) => module.issues.length > 0);
394
+ const probe = view.probe;
395
+ const databaseReady =
396
+ probe?.status === 'ready' || (view.databasePreconfigured && probe === null);
397
+ const modules = view.modules
398
+ .map(
399
+ (module) =>
400
+ `<li><code>${escapeHtml(module.moduleId)}</code><span class="setup-state ${module.issues.length > 0 ? 'setup-state--bad' : 'setup-state--ok'}">${
401
+ module.issues.length > 0
402
+ ? escapeHtml(module.issues.join(' '))
403
+ : module.tenantOwned
404
+ ? 'tenant isolated'
405
+ : 'compatible'
406
+ }</span></li>`,
407
+ )
408
+ .join('');
409
+ return `<header><span class="setup-kicker">Review</span><h2>Review setup</h2>
410
+ <p class="setup-card__sub">${
411
+ blocked.length > 0
412
+ ? 'This database cannot serve every enabled module, so it is not activated.'
413
+ : view.inPlace
414
+ ? 'Applying creates your workspace and owner account.'
415
+ : 'Applying creates your workspace and owner account. Enabled modules finish preparing when the app restarts.'
416
+ }</p></header>
417
+ ${steps(view.step, view.databasePreconfigured)}${alerts(view)}
418
+ ${
419
+ probe?.status === 'ready'
420
+ ? `<p class="setup-alert setup-alert--success">Connected in ${probe.latencyMs} ms as the runtime role, which holds neither SUPERUSER nor BYPASSRLS.</p>`
421
+ : view.databasePreconfigured && probe === null
422
+ ? '<p class="setup-alert setup-alert--success">PostgreSQL is configured by this deployment.</p>'
423
+ : `<p class="setup-alert" role="alert">${escapeHtml(probe?.message ?? 'The connection could not be tested.')}</p>`
424
+ }
425
+ <div><p class="setup-label setup-sublabel">Your workspace</p><ul class="setup-list">
426
+ <li><span>Name</span><strong>${escapeHtml(view.values.workspaceName ?? '')}</strong></li>
427
+ <li><span>Address</span><code>${escapeHtml(view.values.workspaceSlug ?? '')}</code></li>
428
+ <li><span>Owner</span><span>${escapeHtml(view.values.ownerName ?? '')} &lt;${escapeHtml(view.values.ownerEmail ?? '')}&gt;</span></li>
429
+ <li><span>Backoffice</span><code>${escapeHtml(view.values.applicationPath ?? '/app')}</code></li>
430
+ </ul></div>
431
+ <div><p class="setup-label setup-sublabel">Enabled modules that own tables</p><ul class="setup-list">${modules || '<li><span class="setup-help">No enabled module owns database tables.</span></li>'}</ul>${
432
+ view.modulesApproximated
433
+ ? '<p class="setup-note setup-note--gap">This build ships without some module manifests, so each enabled module without one is checked against the strictest requirements any module can ask for.</p>'
434
+ : ''
435
+ }</div>
436
+ <form class="setup-form" method="post" action="/setup">${csrf(view)}
437
+ <div class="setup-foot"><button class="setup-btn" type="submit" name="step" value="back">Back</button>
438
+ ${
439
+ blocked.length === 0 && databaseReady
440
+ ? '<button class="setup-btn setup-btn--primary" type="submit" name="step" value="apply">Migrate and create workspace</button>'
441
+ : ''
442
+ }</div></form>`;
443
+ }
444
+
445
+ function environmentSection(result: EnvironmentWriteResult): string {
446
+ const keys = [...result.block.matchAll(/(?:^|\n)(FD_[A-Z0-9_]+)(?:=|$)/g)]
447
+ .map((match) => match[1])
448
+ .filter((key): key is string => Boolean(key));
449
+ const keyNames =
450
+ keys.length > 0
451
+ ? escapeHtml(keys.join(', '))
452
+ : 'the database connection keys';
453
+ if (result.status === 'read-only') {
454
+ return `<p class="setup-alert setup-alert--warning">This deployment's filesystem is read only. Set ${keyNames} in this service's environment using the connection values you entered, then restart. Without those settings, a restart returns to setup.</p>`;
455
+ }
456
+ if (result.status === 'failed') {
457
+ return `<p class="setup-alert" role="alert">The connection settings could not be stored in <code>${escapeHtml(result.path)}</code>. Set ${keyNames} in this service's environment using the connection values you entered, then restart.</p>`;
458
+ }
459
+ if (result.status === 'unchanged') {
460
+ return `<p class="setup-alert setup-alert--info">The required connection settings were already present in <code>${escapeHtml(result.path)}</code>. Nothing was replaced.</p>`;
461
+ }
462
+ return `<p class="setup-note">Stored in <code>${escapeHtml(result.path)}</code>, readable only by this user${
463
+ result.kept.length > 0
464
+ ? `. ${escapeHtml(result.kept.join(', '))} was already set there and was left as it is`
465
+ : ''
466
+ }.</p>`;
467
+ }
468
+
469
+ function ungrantedNotice(modules: readonly string[]): string {
470
+ if (modules.length === 0) return '';
471
+ const commands = modules
472
+ .map(
473
+ (id) =>
474
+ `<br><code>pnpm flowdular auth sync-scopes --module ${escapeHtml(id)} --apply</code>`,
475
+ )
476
+ .join('');
477
+ return `<p class="setup-alert setup-alert--warning" role="alert">The owner was not granted the permissions of ${escapeHtml(modules.join(', '))}. Run ${modules.length === 1 ? 'this command' : 'these commands'} with this deployment's database settings (stop the app first if it uses the embedded database), then sign in:${commands}</p>`;
478
+ }
479
+
480
+ function doneStep(view: SetupPageView): string {
481
+ const seed = view.seed;
482
+ const owner = seed?.accounts[0];
483
+ const needsEnvironment =
484
+ view.environment?.status === 'read-only' ||
485
+ view.environment?.status === 'failed';
486
+ const nextStep = needsEnvironment
487
+ ? 'Save the connection settings in this deployment, then restart it to sign in.'
488
+ : view.inPlace
489
+ ? 'Sign in to start using it.'
490
+ : view.autoRestart
491
+ ? 'The app is restarting. Wait a moment, then sign in. If it does not restart, restart the deployment.'
492
+ : 'Restart this deployment to leave setup and open the sign-in screen.';
493
+ return `<header><span class="setup-kicker">Sign in</span><h2>Flowdular is ready</h2>
494
+ <p class="setup-card__sub">The workspace ${escapeHtml(seed?.workspace.name ?? '')} is ready. ${nextStep}</p></header>
495
+ ${steps(view.step, view.databasePreconfigured)}${alerts(view)}${ungrantedNotice(seed?.ungrantedModules ?? [])}
496
+ <div><p class="setup-label setup-sublabel">Sign in as the workspace owner</p><div class="setup-credentials"><div><b>Owner account</b><code>${escapeHtml(owner?.email ?? view.values.ownerEmail ?? '')}</code></div></div>
497
+ <p class="setup-note setup-note--gap">After signing in, you can connect an AI model in Providers. This is optional and can be done later.</p></div>
498
+ ${view.environment ? environmentSection(view.environment) : ''}
499
+ <a class="setup-btn setup-btn--primary setup-btn--block" href="${escapeHtml(view.values.applicationPath ?? '/app')}">Go to sign in</a>`;
500
+ }
501
+
502
+ export function renderSetupPage(view: SetupPageView, nonce: string): string {
503
+ /* This script runs only after the entire completion page has loaded. The
504
+ restart request is separate from the response that created the owner. */
505
+ const restartScript =
506
+ view.step === 'Sign in' && view.autoRestart && view.csrfToken
507
+ ? `<script nonce="${nonce}">fetch('/setup',{method:'POST',credentials:'same-origin',headers:{'content-type':'application/x-www-form-urlencoded'},body:new URLSearchParams({step:'restart',setupCsrf:'${view.csrfToken}'}),keepalive:true}).catch(()=>{});</script>`
508
+ : '';
509
+ const slugScript =
510
+ view.step === 'Workspace'
511
+ ? `<script nonce="${nonce}">${WORKSPACE_SLUG_TYPING}</script>`
512
+ : '';
513
+ const body =
514
+ view.step === 'Unlock'
515
+ ? unlockStep(view)
516
+ : view.step === 'Database'
517
+ ? configureStep(view)
518
+ : view.step === 'Workspace'
519
+ ? workspaceStep(view)
520
+ : view.step === 'Review'
521
+ ? reviewStep(view)
522
+ : doneStep(view);
523
+ return `<!doctype html>
524
+ <html lang="en"><head><meta charset="utf-8">
525
+ <meta name="viewport" content="width=device-width,initial-scale=1">
526
+ <meta name="robots" content="noindex,nofollow,noarchive">
527
+ <title>Set up Flowdular</title>
528
+ <style nonce="${nonce}">${STYLES}</style></head>
529
+ <body><main class="setup-page">
530
+ <section class="setup-story" aria-label="Flowdular">
531
+ <canvas class="setup-backdrop" id="setup-backdrop" aria-hidden="true"></canvas>
532
+ <span class="setup-brand">${MARK}Flowdular</span>
533
+ <div class="setup-story__copy"><span class="setup-kicker">Installation</span>
534
+ <h1>Set up your workspace and make it yours.</h1>
535
+ <p>Flowdular will check the database, prepare your modules, and create your first workspace. If this deployment already provides PostgreSQL, you can start with your workspace details.</p></div>
536
+ <footer><span>Agentic foundation platform</span><span>Setup</span></footer></section>
537
+ <section class="setup-panel"><div class="setup-card">${body}</div></section>
538
+ </main>
539
+ <script nonce="${nonce}">${BACKDROP}</script>
540
+ <script nonce="${nonce}">
541
+ (function(){var f=document.querySelectorAll('input[name="adapter"]');if(!f.length)return;
542
+ function sync(){for(var i=0;i<f.length;i++){var s=document.querySelector('fieldset[data-adapter="'+f[i].value+'"]');
543
+ if(s)s.hidden=!f[i].checked;}}
544
+ for(var i=0;i<f.length;i++)f[i].addEventListener('change',sync);sync();})();
545
+ </script>
546
+ ${slugScript}${restartScript}
547
+ </body></html>`;
548
+ }