@dokebi/cli 0.4.2 → 0.4.27

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 (200) hide show
  1. package/AI.md +279 -57
  2. package/README.md +35 -10
  3. package/catalog.json +2520 -258
  4. package/describe.schema.json +748 -0
  5. package/dist/api-app.d.ts +2 -0
  6. package/dist/api-app.js +35 -0
  7. package/dist/app-urls.js +3 -2
  8. package/dist/bin.js +638 -37
  9. package/dist/capability-guide.d.ts +30 -0
  10. package/dist/capability-guide.js +122 -0
  11. package/dist/catalog.d.ts +16 -2
  12. package/dist/catalog.js +19 -7
  13. package/dist/check/asset-rules.d.ts +6 -0
  14. package/dist/check/asset-rules.js +163 -0
  15. package/dist/check/browser-sources.d.ts +20 -0
  16. package/dist/check/browser-sources.js +89 -0
  17. package/dist/check/console-rules.d.ts +6 -0
  18. package/dist/check/console-rules.js +40 -0
  19. package/dist/check/error-rules.d.ts +21 -0
  20. package/dist/check/error-rules.js +212 -0
  21. package/dist/check/form-rules.d.ts +12 -0
  22. package/dist/check/form-rules.js +69 -6
  23. package/dist/check/formatter-rules.d.ts +2 -0
  24. package/dist/check/formatter-rules.js +71 -0
  25. package/dist/check/i18n-rules.d.ts +27 -0
  26. package/dist/check/i18n-rules.js +353 -0
  27. package/dist/check/index.d.ts +15 -1
  28. package/dist/check/index.js +89 -11
  29. package/dist/check/mail-rules.d.ts +4 -0
  30. package/dist/check/mail-rules.js +72 -0
  31. package/dist/check/module-rules.d.ts +13 -0
  32. package/dist/check/module-rules.js +104 -0
  33. package/dist/check/packages.d.ts +8 -0
  34. package/dist/check/packages.js +16 -0
  35. package/dist/check/pool-rules.d.ts +8 -0
  36. package/dist/check/pool-rules.js +164 -0
  37. package/dist/check/problems.d.ts +6 -1
  38. package/dist/check/problems.js +100 -2
  39. package/dist/check/project-rules.d.ts +23 -3
  40. package/dist/check/project-rules.js +265 -23
  41. package/dist/check/seal-rules.d.ts +6 -0
  42. package/dist/check/seal-rules.js +68 -0
  43. package/dist/check/security-rules.d.ts +10 -0
  44. package/dist/check/security-rules.js +450 -0
  45. package/dist/check/shared-state-rules.d.ts +15 -0
  46. package/dist/check/shared-state-rules.js +117 -0
  47. package/dist/check/source-rules.d.ts +26 -3
  48. package/dist/check/source-rules.js +249 -28
  49. package/dist/check/structure-rules.d.ts +23 -0
  50. package/dist/check/structure-rules.js +502 -0
  51. package/dist/check/timer-rules.d.ts +7 -0
  52. package/dist/check/timer-rules.js +543 -0
  53. package/dist/check/typescript.d.ts +35 -5
  54. package/dist/check/typescript.js +163 -17
  55. package/dist/check/vite-rules.d.ts +2 -0
  56. package/dist/check/vite-rules.js +136 -0
  57. package/dist/check/wait-rules.d.ts +16 -0
  58. package/dist/check/wait-rules.js +337 -0
  59. package/dist/commands.d.ts +7 -0
  60. package/dist/commands.js +381 -36
  61. package/dist/config-diff.d.ts +42 -0
  62. package/dist/config-diff.js +289 -0
  63. package/dist/config-edit.d.ts +35 -2
  64. package/dist/config-edit.js +145 -13
  65. package/dist/config-init.d.ts +15 -0
  66. package/dist/config-init.js +61 -0
  67. package/dist/config-paths.d.ts +22 -0
  68. package/dist/config-paths.js +82 -0
  69. package/dist/config-secrets.d.ts +46 -0
  70. package/dist/config-secrets.js +187 -0
  71. package/dist/config-show.d.ts +82 -0
  72. package/dist/config-show.js +563 -0
  73. package/dist/config-values.d.ts +8 -0
  74. package/dist/config-values.js +68 -0
  75. package/dist/configuration-scaffold.d.ts +42 -0
  76. package/dist/configuration-scaffold.js +258 -4
  77. package/dist/configuration.d.ts +63 -3
  78. package/dist/configuration.js +178 -4
  79. package/dist/deploy-scripts.d.ts +4 -0
  80. package/dist/deploy-scripts.js +361 -0
  81. package/dist/deploy-templates.d.ts +46 -0
  82. package/dist/deploy-templates.js +379 -0
  83. package/dist/deploy.d.ts +46 -0
  84. package/dist/deploy.js +592 -0
  85. package/dist/deployment.d.ts +2 -0
  86. package/dist/deployment.js +111 -0
  87. package/dist/describe-db.d.ts +22 -0
  88. package/dist/describe-db.js +176 -0
  89. package/dist/describe.d.ts +92 -0
  90. package/dist/describe.js +417 -0
  91. package/dist/dev-compose.d.ts +20 -0
  92. package/dist/dev-compose.js +154 -0
  93. package/dist/dev-guard.js +44 -0
  94. package/dist/dev-host.d.ts +13 -0
  95. package/dist/dev-host.js +55 -0
  96. package/dist/dev-infra.d.ts +72 -4
  97. package/dist/dev-infra.js +237 -17
  98. package/dist/dev-watch.d.ts +34 -0
  99. package/dist/dev-watch.js +192 -0
  100. package/dist/diagnostics.d.ts +10 -7
  101. package/dist/diagnostics.js +22 -4
  102. package/dist/drift.d.ts +50 -0
  103. package/dist/drift.js +218 -0
  104. package/dist/env-file.d.ts +25 -1
  105. package/dist/env-file.js +64 -6
  106. package/dist/errors.d.ts +7 -1
  107. package/dist/example-config.d.ts +2 -0
  108. package/dist/example-config.js +3 -0
  109. package/dist/first-admin.d.ts +43 -0
  110. package/dist/first-admin.js +175 -0
  111. package/dist/formatter-ignore.d.ts +37 -0
  112. package/dist/formatter-ignore.js +344 -0
  113. package/dist/generator.d.ts +45 -3
  114. package/dist/generator.js +205 -15
  115. package/dist/guide.d.ts +1 -1
  116. package/dist/guide.js +42 -21
  117. package/dist/install-module.d.ts +20 -2
  118. package/dist/install-module.js +45 -8
  119. package/dist/install-plan.js +5 -3
  120. package/dist/layout.d.ts +12 -1
  121. package/dist/layout.js +19 -6
  122. package/dist/log-writer-control.d.ts +2 -0
  123. package/dist/log-writer-control.js +17 -2
  124. package/dist/log-writer.js +43 -10
  125. package/dist/logging-config.d.ts +8 -3
  126. package/dist/logging-config.js +19 -5
  127. package/dist/manifest.d.ts +29 -1
  128. package/dist/manifest.js +84 -6
  129. package/dist/merge-install-defaults.d.ts +0 -1
  130. package/dist/merge-install-defaults.js +13 -47
  131. package/dist/npm-client.d.ts +40 -4
  132. package/dist/npm-client.js +152 -15
  133. package/dist/npm-paths.js +18 -4
  134. package/dist/openapi.d.ts +27 -0
  135. package/dist/openapi.js +649 -0
  136. package/dist/option-fixes.d.ts +39 -0
  137. package/dist/option-fixes.js +130 -0
  138. package/dist/output.d.ts +1 -1
  139. package/dist/output.js +5 -5
  140. package/dist/plugin-lifecycle.d.ts +34 -2
  141. package/dist/plugin-lifecycle.js +195 -16
  142. package/dist/plugin-options.d.ts +19 -4
  143. package/dist/plugin-options.js +138 -6
  144. package/dist/plugins-command.js +44 -7
  145. package/dist/process-group.d.ts +39 -0
  146. package/dist/process-group.js +132 -0
  147. package/dist/production-doctor.d.ts +3 -0
  148. package/dist/production-doctor.js +176 -0
  149. package/dist/project-guide.d.ts +19 -1
  150. package/dist/project-guide.js +119 -20
  151. package/dist/project-inspector.d.ts +15 -0
  152. package/dist/project-inspector.js +497 -33
  153. package/dist/project-state.d.ts +5 -0
  154. package/dist/project-state.js +8 -0
  155. package/dist/project.d.ts +203 -19
  156. package/dist/project.js +3006 -333
  157. package/dist/recipe-facts.d.ts +127 -0
  158. package/dist/recipe-facts.js +563 -0
  159. package/dist/recipe-login.d.ts +63 -0
  160. package/dist/recipe-login.js +464 -0
  161. package/dist/recipe-mail.d.ts +21 -0
  162. package/dist/recipe-mail.js +214 -0
  163. package/dist/recipe-members.d.ts +35 -0
  164. package/dist/recipe-members.js +178 -0
  165. package/dist/recipe-record.d.ts +82 -7
  166. package/dist/recipe-record.js +226 -22
  167. package/dist/recipe-reset.d.ts +2 -0
  168. package/dist/recipe-reset.js +117 -0
  169. package/dist/recipe-site.d.ts +48 -0
  170. package/dist/recipe-site.js +396 -0
  171. package/dist/recipe-step.d.ts +42 -0
  172. package/dist/recipe-step.js +89 -0
  173. package/dist/recipe.d.ts +56 -20
  174. package/dist/recipe.js +703 -131
  175. package/dist/registry.d.ts +98 -2
  176. package/dist/registry.js +171 -5
  177. package/dist/reloadable.d.ts +35 -0
  178. package/dist/reloadable.js +257 -0
  179. package/dist/remote-verify.d.ts +31 -0
  180. package/dist/remote-verify.js +204 -0
  181. package/dist/server.d.ts +43 -4
  182. package/dist/server.js +601 -52
  183. package/dist/settings.d.ts +48 -0
  184. package/dist/settings.js +168 -0
  185. package/dist/site-auth.d.ts +39 -0
  186. package/dist/site-auth.js +195 -0
  187. package/dist/template-fixes.d.ts +26 -0
  188. package/dist/template-fixes.js +285 -0
  189. package/dist/test-reporter.d.ts +49 -0
  190. package/dist/test-reporter.js +190 -0
  191. package/dist/transaction.d.ts +47 -0
  192. package/dist/transaction.js +241 -7
  193. package/dist/verify-tests.d.ts +98 -0
  194. package/dist/verify-tests.js +449 -0
  195. package/dist/verify.d.ts +103 -3
  196. package/dist/verify.js +272 -21
  197. package/dist/view-choice.d.ts +58 -0
  198. package/dist/view-choice.js +131 -0
  199. package/dist/vite-watch.js +4 -2
  200. package/package.json +8 -6
@@ -0,0 +1,396 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { AUTO_DOMAIN, devHost } from './dev-host.js';
4
+ import { debug, isMissing } from './diagnostics.js';
5
+ import { CliError } from './errors.js';
6
+ import { exists, securePath } from './filesystem.js';
7
+ import { MOVED_RECIPE_FILES } from './layout.js';
8
+ import { provider, shell, view } from './recipe-step.js';
9
+ /** Where older site recipes wrote the job worker; it keeps working (doctor LEGACY_LAYOUT). */
10
+ export const LEGACY_SITE_WORKER_PATH = 'src/site-worker.ts';
11
+ /** Process-level runtime wiring sits in src/infra/runtime, beside the cluster worker factory. */
12
+ export const SITE_WORKER_PATH = MOVED_RECIPE_FILES[LEGACY_SITE_WORKER_PATH];
13
+ /** The import specifier src/main.ts uses for a worker file under src/. */
14
+ const fromMain = (path) => `./${path.slice('src/'.length)}`;
15
+ /**
16
+ * The opening of SITE.md, the guide the recipe writes once (kept byte for byte, so a re-run finds
17
+ * the guide it wrote). Its conditions ("until connected", "needs … before verify") are how the
18
+ * scaffold starts. D-0137: it is no longer a recorded notice: the record holds the `{fact:site}`
19
+ * token and verify says what it finds; records written before keep this text and are read the
20
+ * same way (recipe-facts.ts recordedFact).
21
+ */
22
+ export const SITE_NOTICE = 'Site scaffolding separates domains and auth realms. User lookup and login screens are app code: the generated auth extensions deny every login until connected to your user store. Admin and API routes require authentication. A job worker needs a configured NATS connection and a provisioned siteWorker queue before verify; the thread worker handles CPU tasks in this process.';
23
+ /**
24
+ * The site's apps, domains, realms and worker. `label` is the project's host label (D-0102):
25
+ * `--domains auto` gives every app `<app>.<label>.localhost`, unique to this project.
26
+ */
27
+ export function siteDefinition(answers, label) {
28
+ const apps = answers.apps?.value === 'none' ? [] : (answers.apps?.value ?? '').split(',');
29
+ const worker = answers.worker?.value ?? 'none';
30
+ if (!apps.length && worker === 'none')
31
+ throw new CliError('INVALID_ANSWER', 'Select at least one app or worker', {
32
+ phase: 'arguments',
33
+ });
34
+ const map = (id) => {
35
+ const raw = answers[id]?.value ?? '';
36
+ if (raw === AUTO_DOMAIN)
37
+ return Object.fromEntries(apps.map((app) => [app, id === 'domains' ? devHost(app, label) : app]));
38
+ const result = Object.create(null);
39
+ if (!apps.length && raw === 'none')
40
+ return result;
41
+ for (const pair of raw.split(',')) {
42
+ const [app, value, extra] = pair.split('=');
43
+ const valid = id === 'domains'
44
+ ? /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/
45
+ : /^[a-z][a-z0-9-]{0,31}$/;
46
+ if (!app ||
47
+ !apps.includes(app) ||
48
+ !value ||
49
+ extra !== undefined ||
50
+ !valid.test(value) ||
51
+ Object.hasOwn(result, app))
52
+ throw new CliError('INVALID_ANSWER', `--${id} requires one app=value pair for each selected app`, { phase: 'arguments' });
53
+ result[app] = value;
54
+ }
55
+ if (apps.some((app) => !Object.hasOwn(result, app)) ||
56
+ (id === 'domains' && new Set(Object.values(result)).size !== apps.length))
57
+ throw new CliError('INVALID_ANSWER', `--${id} has missing apps or duplicate domains`, {
58
+ phase: 'arguments',
59
+ });
60
+ return Object.fromEntries(apps.map((app) => [app, result[app]]));
61
+ };
62
+ return {
63
+ apps,
64
+ worker,
65
+ domains: map('domains'),
66
+ realms: map('realms'),
67
+ inertia: answers.view?.value.startsWith('inertia-') ?? false,
68
+ ssr: answers.view?.value === 'inertia-vue-ssr',
69
+ };
70
+ }
71
+ export async function checkSite(root, answers, label) {
72
+ const expected = JSON.stringify(siteDefinition(answers, label), null, 2) + '\n';
73
+ const path = join(root, '.dokebi/site-plan.json');
74
+ if (await exists(path)) {
75
+ if ((await readFile(path, 'utf8')) !== expected)
76
+ throw new CliError('RECIPE_FILE_CONFLICT', 'This site has different app/domain/realm/worker choices; change it explicitly instead of applying another site plan', { phase: 'arguments' });
77
+ }
78
+ else if ((await view(root))?.apps.length)
79
+ throw new CliError('RECIPE_FILE_CONFLICT', 'The site recipe requires a new project or a project without existing apps', { phase: 'arguments' });
80
+ }
81
+ /** The auth extension the site recipe writes: one stub class per realm (exported for its tests). */
82
+ export function siteAuthSource(realms) {
83
+ return `import { AuthExtension, type AuthHookContext, type AuthUser } from '@dokebi/auth';
84
+ interface Account { id: string; passwordHash: string; stamp: string; roles: string[] }
85
+ // Connect each realm to its user store. Empty lookup intentionally denies login.
86
+ class SiteAuth extends AuthExtension<Account> {
87
+ protected override findByIdentity(_identity: string, context: AuthHookContext): Account | undefined {
88
+ if (context.logger.isEnabled('debug')) context.logger.log('debug', 'Site user store is not configured', { code: 'SITE_AUTH_UNCONFIGURED', realm: context.realm });
89
+ return undefined;
90
+ }
91
+ protected override findById(_id: string, context: AuthHookContext): Account | undefined {
92
+ if (context.logger.isEnabled('debug')) context.logger.log('debug', 'Site user store is not configured', { code: 'SITE_AUTH_UNCONFIGURED', realm: context.realm });
93
+ return undefined;
94
+ }
95
+ protected override credentials(account: Account): AuthUser { return { id: account.id, passwordHash: account.passwordHash, stamp: account.stamp, roles: account.roles }; }
96
+ }
97
+ export default class DefaultAuth extends SiteAuth {}
98
+ ${realms
99
+ .filter((name) => name !== 'default')
100
+ .map((_, i) => `class Realm${i} extends SiteAuth {}`)
101
+ .join('\n')}
102
+ export const realms = { ${realms
103
+ .filter((name) => name !== 'default')
104
+ .map((name, i) => `${JSON.stringify(name)}: Realm${i}`)
105
+ .join(', ')} };
106
+ `;
107
+ }
108
+ /** What the site scaffold's GET /login answers until the app writes its login (verify reads it). */
109
+ export const SITE_LOGIN_HINT = 'Connect the realm extension to your user store and implement login with request.auth.attempt.';
110
+ /** The routes file of a site app (the scaffold's GET /login lives there). */
111
+ export const siteRoutesPath = (app) => `src/apps/${app}/site-routes.ts`;
112
+ function routesSource(app, realm) {
113
+ return `import type { AppContext, AppRouter } from '@dokebi/app';
114
+ ${app === 'api' ? '' : "import { routes as pages } from './routes.ts';"}
115
+ export function siteRoutes(route: AppRouter, app: AppContext): void {
116
+ ${app === 'api' ? `route.get('/', async (request) => ({ app: 'api', authenticated: Boolean(await request.auth.user()) }));` : 'pages(route, app);'}
117
+ route.get('/login', () => ({ message: ${JSON.stringify(SITE_LOGIN_HINT)}, realm: ${JSON.stringify(realm)} }), { config: { auth: false } });
118
+ route.get('/identity', async (request) => ({ authenticated: Boolean(await request.auth.user()), realm: ${JSON.stringify(realm)} }), { config: { auth: 'required' } });
119
+ }
120
+ `;
121
+ }
122
+ const JOB_SOURCE = `import type { Plugin } from '@dokebi/core';
123
+ import { dependency as job } from '@dokebi/job';
124
+ // Process wiring for the siteWorker queue; register feature handlers in the setup of the module
125
+ // that owns the feature (src/shared/modules/<module>/module.ts) and call its services.
126
+ export const siteWorker: Plugin<void> = {
127
+ metadata: { name: 'site-worker', version: '1.0.0', core: '^0.4.0', dependencies: [job] },
128
+ setup(context) {
129
+ context.logger.log('debug', 'Registering site worker handler');
130
+ const queue = context.getDependency(job).queue('siteWorker');
131
+ queue.handle('site.example', async (_payload, task) => {
132
+ // Replace with idempotent business work; a job may be delivered more than once.
133
+ context.logger.log('debug', 'Processing site example job', { id: task.id, attempt: task.attempt });
134
+ });
135
+ },
136
+ };
137
+ `;
138
+ /** SITE.md: what the recipe chose and what remains application work. */
139
+ function siteGuide(spec, workerPath) {
140
+ return `# Site\n\n${SITE_NOTICE}\n\nApps: ${spec.apps.join(', ') || 'none'}. Worker: ${spec.worker}.\n\n${spec.apps.map((app) => `- ${app}: ${spec.domains[app]}, realm ${spec.realms[app]}`).join('\n')}\n\nDomain mapping does not change DNS: \`*.localhost\` names reach this machine without a hosts file; add other local names to your hosts file or use a proxy.\nSession cookies are host-only; sharing a realm does not automatically share login cookies. The default hosts (\`--domains auto\`) carry the project name, so another Dokebi project on this machine never shares their cookies.\nFor a job worker configure options.dokebi-nats.connections.siteWorker.servers in private dokebi.yaml, then run npx dokebi-job-provision --queue siteWorker before npx dokebi verify --json. The example handler is site.example in ${workerPath}; replace it with idempotent business work. It runs in this process, not a separately deployed service.\nFor a thread worker edit src/infra/worker/tasks.ts and call the worker provider from your own plugin; no public task endpoint is generated.\n`;
141
+ }
142
+ /**
143
+ * A browser app of the site (not api): its host is sealed (D-0092 §1, D-0062), EJS and Inertia
144
+ * alike. Server rendering cannot be sealed (SEAL_SSR_CONFLICT), so SSR apps stay plain and the
145
+ * recipe notice says so.
146
+ */
147
+ export function sealedSiteApp(spec, app) {
148
+ return app !== 'api' && !spec.ssr;
149
+ }
150
+ /** Site reuses transactional install/app/config/file commands and the common recipe executor. */
151
+ export function siteSteps(catalog, _recipe, answers, context) {
152
+ const { root, project } = context;
153
+ const spec = siteDefinition(answers, context.hostLabel);
154
+ const steps = [];
155
+ if (context.init)
156
+ steps.push({
157
+ id: 'init',
158
+ command: `npx -y @dokebi/cli init ${JSON.stringify(root)}`,
159
+ why: 'create the site project',
160
+ satisfied: async () => !!(await view(root)),
161
+ run: async () => {
162
+ await project.init(root);
163
+ },
164
+ });
165
+ const logger = provider(catalog, 'logging.targets');
166
+ steps.push({
167
+ id: 'logger-plugin',
168
+ command: `npx dokebi add ${logger}`,
169
+ why: 'the app logger (D-0151): JSON lines on stdout, readable lines in dokebi dev, redaction, and a rotating file target to turn on in production; init adds it, this step adds it to an older project',
170
+ satisfied: async () => (await view(root))?.packages.has(logger) ?? false,
171
+ run: async () => {
172
+ await project.add(root, logger);
173
+ },
174
+ });
175
+ steps.push({
176
+ id: 'site-plan',
177
+ command: 'recipe: record site choices',
178
+ why: 'resume the same plan without taking over existing apps',
179
+ satisfied: async () => false,
180
+ run: async () => {
181
+ await project.writeRecipeFiles(root, {
182
+ files: [{ path: '.dokebi/site-plan.json', content: JSON.stringify(spec, null, 2) + '\n' }],
183
+ });
184
+ },
185
+ });
186
+ const add = (capability, id, firstApp, sets = []) => {
187
+ const name = provider(catalog, capability);
188
+ steps.push({
189
+ id,
190
+ command: `npx dokebi add ${name}${firstApp === false ? ' --no-first-app' : ''}`,
191
+ why: `install ${capability}`,
192
+ satisfied: async () => (await view(root))?.packages.has(name) ?? false,
193
+ run: async () => {
194
+ await project.add(root, name, false, sets, {
195
+ ...(firstApp === undefined ? {} : { firstApp }),
196
+ });
197
+ },
198
+ });
199
+ };
200
+ // Browser apps are sealed (the consumer convention, D-0092 §1, D-0062): their hosts get
201
+ // seal: true. EJS forms go through @dokebi/seal/forms (the shared layout writes the seal head);
202
+ // Inertia pages and visits through the sealInertia bootstrap of views/app.js. The api app
203
+ // answers API clients, and SSR apps cannot be sealed (SEAL_SSR_CONFLICT): both stay plain.
204
+ const sealed = (app) => sealedSiteApp(spec, app);
205
+ if (spec.apps.length) {
206
+ add('web.apps', 'apps-plugin', false);
207
+ if (spec.inertia && spec.apps.some((app) => app !== 'api'))
208
+ add(`view.${answers.view?.value}`, 'view-plugin');
209
+ add('web.session', 'session');
210
+ add('csrf', 'csrf');
211
+ add('auth', 'auth');
212
+ if (spec.apps.some(sealed))
213
+ add('security.seal', 'seal');
214
+ for (const app of spec.apps)
215
+ steps.push({
216
+ id: `app-${app}`,
217
+ command: `npx dokebi app add ${app} --domain ${spec.domains[app]}${spec.inertia && app !== 'api' ? ` --inertia${spec.ssr ? ' --ssr' : ''}` : ''}${sealed(app) ? ' --seal' : ''}`,
218
+ why: `separate ${app} domain and realm${sealed(app) ? ' (a sealed browser host)' : ''}`,
219
+ satisfied: async () => (await view(root))?.apps.includes(app) ?? false,
220
+ run: async () => {
221
+ await project.appAdd(root, app, {
222
+ domains: [spec.domains[app]],
223
+ inertia: spec.inertia && app !== 'api',
224
+ ssr: spec.ssr && app !== 'api',
225
+ seal: sealed(app),
226
+ });
227
+ },
228
+ });
229
+ }
230
+ if (spec.worker === 'thread')
231
+ add('worker.thread', 'worker');
232
+ if (spec.worker === 'job') {
233
+ add('messaging.nats', 'nats', undefined, [
234
+ {
235
+ path: ['connections', 'siteWorker'],
236
+ value: { servers: ['nats://configure-me.invalid:4222'] },
237
+ },
238
+ ]);
239
+ add('job.durable', 'worker', undefined, [
240
+ {
241
+ path: ['queues', 'siteWorker'],
242
+ value: {
243
+ connection: 'siteWorker',
244
+ stream: 'SITE_WORK',
245
+ consumer: 'SITE_WORKER',
246
+ subject: 'site.work',
247
+ deadLetterStream: 'SITE_DEAD',
248
+ deadLetterSubject: 'site.dead',
249
+ },
250
+ },
251
+ ]);
252
+ }
253
+ steps.push({
254
+ id: 'site-files',
255
+ command: 'recipe: write site realm extensions and route/worker entries',
256
+ why: 'safe starting points; application user stores and login screens remain explicit',
257
+ satisfied: async () => false,
258
+ run: async () => {
259
+ const files = [];
260
+ const edits = [];
261
+ if (spec.apps.length)
262
+ files.push({
263
+ path: 'src/infra/auth/extension.ts',
264
+ content: siteAuthSource([...new Set(Object.values(spec.realms))]),
265
+ replaceTemplate: true,
266
+ });
267
+ for (const app of spec.apps) {
268
+ files.push({
269
+ path: siteRoutesPath(app),
270
+ content: routesSource(app, spec.realms[app]),
271
+ });
272
+ edits.push({
273
+ path: `src/apps/${app}/app.ts`,
274
+ edit(text) {
275
+ const next = "import { siteRoutes as routes } from './site-routes.ts';";
276
+ if (text.includes(next))
277
+ return text;
278
+ const previous = "import { routes } from './routes.ts';";
279
+ if (!text.includes(previous))
280
+ throw new CliError('RECIPE_FILE_CONFLICT', `src/apps/${app}/app.ts has custom routing`);
281
+ return text.replace(previous, next);
282
+ },
283
+ });
284
+ }
285
+ // A project made by an older site recipe keeps its worker at the src root: the re-run
286
+ // writes no second copy and keeps SITE.md naming it; doctor reports LEGACY_LAYOUT.
287
+ const legacy = spec.worker === 'job' && (await exists(await securePath(root, LEGACY_SITE_WORKER_PATH)));
288
+ const workerPath = legacy ? LEGACY_SITE_WORKER_PATH : SITE_WORKER_PATH;
289
+ if (legacy)
290
+ debug('Site recipe keeps the legacy worker path', {
291
+ path: LEGACY_SITE_WORKER_PATH,
292
+ moveTo: SITE_WORKER_PATH,
293
+ });
294
+ if (spec.worker === 'job') {
295
+ if (!legacy)
296
+ files.push({ path: SITE_WORKER_PATH, content: JOB_SOURCE });
297
+ edits.push({
298
+ path: 'src/main.ts',
299
+ edit(text) {
300
+ if (text.includes('app.register(siteWorker);'))
301
+ return text;
302
+ if (!text.includes('await app.start();'))
303
+ throw new CliError('RECIPE_FILE_CONFLICT', 'src/main.ts has a custom lifecycle');
304
+ return (`import { siteWorker } from '${fromMain(workerPath)}';\n` +
305
+ text.replace('await app.start();', 'app.register(siteWorker);\nawait app.start();'));
306
+ },
307
+ });
308
+ }
309
+ // SITE.md as an older recipe wrote it (naming the old worker path) is kept as it is.
310
+ let guide;
311
+ try {
312
+ guide = await readFile(await securePath(root, 'SITE.md'), 'utf8');
313
+ }
314
+ catch (error) {
315
+ // A missing SITE.md is the first run; any other read failure stops the step.
316
+ if (!isMissing(error))
317
+ throw error;
318
+ }
319
+ const legacyGuide = siteGuide(spec, LEGACY_SITE_WORKER_PATH);
320
+ files.push({
321
+ path: 'SITE.md',
322
+ content: guide === legacyGuide ? legacyGuide : siteGuide(spec, workerPath),
323
+ });
324
+ await project.writeRecipeFiles(root, { files, edits });
325
+ },
326
+ });
327
+ steps.push({
328
+ id: 'site-config',
329
+ command: 'recipe: configure app domains, realms and worker resources',
330
+ why: 'apply selected isolation settings; never create credentials for an external server',
331
+ satisfied: async () => false,
332
+ run: async () => {
333
+ const sets = [];
334
+ if (spec.apps.length) {
335
+ sets.push({ plugin: 'dokebi-web', set: { path: ['port'], value: context.port } });
336
+ // Sealed EJS forms are form bodies (dokebi-web refuses them while formBody is off).
337
+ if (!spec.inertia && spec.apps.some(sealed))
338
+ sets.push({ plugin: 'dokebi-web', set: { path: ['formBody'], value: true } });
339
+ for (const app of spec.apps) {
340
+ sets.push({
341
+ plugin: 'dokebi-auth',
342
+ set: {
343
+ path: ['apps', app],
344
+ value: {
345
+ realm: spec.realms[app],
346
+ default: app === 'web' ? 'optional' : 'required',
347
+ loginPath: '/login',
348
+ homePath: '/',
349
+ via: app === 'api' ? 'any' : 'session',
350
+ },
351
+ },
352
+ });
353
+ }
354
+ }
355
+ if (spec.worker === 'job') {
356
+ const current = await view(root);
357
+ const nats = current?.options['dokebi-nats'];
358
+ const job = current?.options['dokebi-job'];
359
+ if (!nats?.connections?.siteWorker)
360
+ sets.push({
361
+ plugin: 'dokebi-nats',
362
+ set: {
363
+ path: ['connections', 'siteWorker'],
364
+ value: { servers: ['nats://configure-me.invalid:4222'] },
365
+ },
366
+ });
367
+ if (!job?.queues?.siteWorker)
368
+ sets.push({
369
+ plugin: 'dokebi-job',
370
+ set: {
371
+ path: ['queues', 'siteWorker'],
372
+ value: {
373
+ connection: 'siteWorker',
374
+ stream: 'SITE_WORK',
375
+ consumer: 'SITE_WORKER',
376
+ subject: 'site.work',
377
+ deadLetterStream: 'SITE_DEAD',
378
+ deadLetterSubject: 'site.dead',
379
+ },
380
+ },
381
+ });
382
+ }
383
+ if (sets.length)
384
+ await project.configSet(root, sets);
385
+ },
386
+ });
387
+ if (spec.inertia && spec.apps.some((app) => app !== 'api'))
388
+ steps.push({
389
+ id: 'build',
390
+ command: 'npm run build',
391
+ why: 'build selected Vue apps and SSR bundles',
392
+ satisfied: async () => false,
393
+ run: () => shell(root, 'npm', ['run', 'build'], 300000),
394
+ });
395
+ return steps;
396
+ }
@@ -0,0 +1,42 @@
1
+ import { type Catalog } from './catalog.js';
2
+ /**
3
+ * Who settled an answer (D-0095): `user` — the user or the request named it; `ai` — the user left
4
+ * it to the agent, which chose it (`--ai-chose`, with `aiReason` from `--ai-reason`); `default` —
5
+ * left to the agent and filled with the catalog default (`defaultReason`); `recipe` — fixed by the
6
+ * recipe, not a question.
7
+ */
8
+ export type AnsweredBy = 'user' | 'ai' | 'default' | 'recipe';
9
+ export interface Answer {
10
+ readonly value: string;
11
+ readonly answeredBy: AnsweredBy;
12
+ readonly defaultReason?: string;
13
+ readonly aiReason?: string;
14
+ }
15
+ export interface PlanStep {
16
+ readonly id: string;
17
+ /** The real command (run it yourself to do the same step). */
18
+ readonly command: string;
19
+ readonly why: string;
20
+ }
21
+ export interface Step extends PlanStep {
22
+ satisfied(): Promise<boolean>;
23
+ run(): Promise<void>;
24
+ }
25
+ /** The plugin package that provides a capability (the recipe never names packages). */
26
+ export declare function provider(catalog: Catalog, capability: string): string;
27
+ export interface ProjectView {
28
+ installed: Set<string>;
29
+ packages: Set<string>;
30
+ apps: string[];
31
+ modules: string[];
32
+ options: Record<string, unknown>;
33
+ }
34
+ /** The project as the steps see it now; undefined while the folder is not a project yet. */
35
+ export declare function view(root: string): Promise<ProjectView | undefined>;
36
+ /**
37
+ * Runs a command in the project folder; a failure becomes STEP_COMMAND_FAILED with the bounded,
38
+ * redacted end of its output (`OutputTail` of npm-client). The command runs in its own process
39
+ * group, which the time limit kills whole: killing npm alone left `npm run build`'s Vite running,
40
+ * and the pipes it holds kept the step open long after the limit.
41
+ */
42
+ export declare function shell(root: string, command: string, args: string[], timeoutMs: number): Promise<void>;
@@ -0,0 +1,89 @@
1
+ // What every recipe step builder shares (recipe.ts, recipe-site.ts, recipe-mail.ts): the step
2
+ // and answer shapes, the project as the steps see it, the capability → package lookup and the
3
+ // command runner. It imports none of the recipe modules, so they never import each other for it.
4
+ import { spawn } from 'node:child_process';
5
+ import { readFile } from 'node:fs/promises';
6
+ import { join } from 'node:path';
7
+ import { providersOf } from './catalog.js';
8
+ import { parseProjectConfig, readProjectSettings } from './configuration.js';
9
+ import { debug, isMissing } from './diagnostics.js';
10
+ import { CliError } from './errors.js';
11
+ import { OutputTail } from './npm-client.js';
12
+ import { forwardTermination, killGroup, PROCESS_GROUPS } from './process-group.js';
13
+ /** The plugin package that provides a capability (the recipe never names packages). */
14
+ export function provider(catalog, capability) {
15
+ const found = providersOf(catalog, capability).find((item) => item.kind === 'plugin');
16
+ if (!found)
17
+ throw new CliError('CATALOG_INVALID', `No plugin provides ${capability}`, {
18
+ details: { capability },
19
+ });
20
+ return found.package;
21
+ }
22
+ /** The project as the steps see it now; undefined while the folder is not a project yet. */
23
+ export async function view(root) {
24
+ let stateText;
25
+ try {
26
+ stateText = await readFile(join(root, '.dokebi/state.json'), 'utf8');
27
+ }
28
+ catch (error) {
29
+ if (isMissing(error))
30
+ return undefined;
31
+ throw error;
32
+ }
33
+ const state = JSON.parse(stateText);
34
+ // M50: the settings file the app reads (DOKEBI_CONFIG, else dokebi.yaml).
35
+ const options = parseProjectConfig((await readProjectSettings(root)).source).options;
36
+ return {
37
+ installed: new Set((state.plugins ?? []).map((plugin) => plugin.id)),
38
+ packages: new Set((state.plugins ?? []).flatMap((plugin) => plugin.packageName ?? [])),
39
+ apps: state.apps ?? [],
40
+ modules: state.modules ?? [],
41
+ options,
42
+ };
43
+ }
44
+ /**
45
+ * Runs a command in the project folder; a failure becomes STEP_COMMAND_FAILED with the bounded,
46
+ * redacted end of its output (`OutputTail` of npm-client). The command runs in its own process
47
+ * group, which the time limit kills whole: killing npm alone left `npm run build`'s Vite running,
48
+ * and the pipes it holds kept the step open long after the limit.
49
+ */
50
+ export async function shell(root, command, args, timeoutMs) {
51
+ const npmPath = process.env.npm_execpath;
52
+ const viaNode = command === 'npm' && npmPath?.endsWith('npm-cli.js');
53
+ const file = viaNode ? process.execPath : command;
54
+ const argv = viaNode ? [npmPath, ...args] : args;
55
+ debug('recipe: running', { command, args, root });
56
+ await new Promise((done, fail) => {
57
+ const child = spawn(file, argv, {
58
+ cwd: root,
59
+ stdio: ['ignore', 'pipe', 'pipe'],
60
+ detached: PROCESS_GROUPS,
61
+ });
62
+ const forwarding = forwardTermination(child.pid);
63
+ const output = new OutputTail();
64
+ const keep = (chunk) => output.push(chunk);
65
+ child.stdout.on('data', keep);
66
+ child.stderr.on('data', keep);
67
+ let timedOut = false;
68
+ const timer = setTimeout(() => {
69
+ timedOut = true;
70
+ debug('recipe: the step command reached its time limit; killing it', { command, timeoutMs });
71
+ killGroup(child.pid, 'SIGKILL');
72
+ }, timeoutMs);
73
+ child.once('error', (cause) => {
74
+ clearTimeout(timer);
75
+ forwarding();
76
+ fail(new CliError('STEP_COMMAND_FAILED', `${command} could not run`, { cause }));
77
+ });
78
+ child.once('close', (code, signal) => {
79
+ clearTimeout(timer);
80
+ forwarding();
81
+ if (code === 0)
82
+ done();
83
+ else
84
+ fail(new CliError('STEP_COMMAND_FAILED', `${command} ${args.join(' ')} failed`, {
85
+ details: { exitCode: code, signal, timedOut, output: output.text() },
86
+ }));
87
+ });
88
+ });
89
+ }
package/dist/recipe.d.ts CHANGED
@@ -1,5 +1,8 @@
1
- import { type CatalogOption } from './catalog.js';
1
+ import { type Catalog, type CatalogOption, type CatalogRecipe } from './catalog.js';
2
2
  import { type NextStep, type Project } from './project.js';
3
+ import { type Answer, type PlanStep } from './recipe-step.js';
4
+ export { csrfNotice, SEAL_HEAD, SEAL_HTTP_MODULE, sealNotice, ssrSealNotice, } from './recipe-facts.js';
5
+ export type { Answer, PlanStep, ProjectView, Step } from './recipe-step.js';
3
6
  export interface RecipeRequest {
4
7
  readonly name: string;
5
8
  /** Project folder; created with init when it does not exist yet. Default: the current one. */
@@ -9,10 +12,18 @@ export interface RecipeRequest {
9
12
  /** Fill unanswered questions with their defaults (only when the user delegated the choice). */
10
13
  readonly defaults: boolean;
11
14
  /**
12
- * Where the answers came from (D-0065 §11): `user` — the user answered the questions;
13
- * `defaults` — the user said to decide (same as `defaults`). Applying needs one of them.
15
+ * Where the answers came from (D-0065 §11): `user` — the given answers are the user's (or the
16
+ * request's explicit) choices, except those named in `aiChose`; `defaults` — the user said to
17
+ * decide everything (same as `defaults`; every given answer is the agent's). Applying needs one.
14
18
  */
15
19
  readonly answersFrom?: 'user' | 'defaults';
20
+ /**
21
+ * D-0095: the question ids (or flag names) of given answers the agent chose itself because the
22
+ * user left them to it. Each needs its flag; a question the recipe fixes cannot be one.
23
+ */
24
+ readonly aiChose?: readonly string[];
25
+ /** D-0095: the agent's one-line reason per id of `aiChose` (optional, 1–200 characters). */
26
+ readonly aiReasons?: Readonly<Record<string, string>>;
16
27
  readonly dryRun: boolean;
17
28
  readonly port?: number;
18
29
  readonly dbPort?: number;
@@ -28,23 +39,13 @@ export interface Question {
28
39
  readonly flag: string;
29
40
  readonly question: string;
30
41
  readonly options: readonly QuestionOption[];
42
+ readonly input?: 'text';
31
43
  readonly default: string;
32
44
  readonly defaultReason: string;
33
45
  /** Asked only when another answer matches (devInfra: when db is not none). */
34
46
  readonly when?: Readonly<Record<string, string>>;
35
47
  readonly answer?: Answer;
36
48
  }
37
- export interface Answer {
38
- readonly value: string;
39
- readonly answeredBy: 'flag' | 'default' | 'recipe';
40
- readonly defaultReason?: string;
41
- }
42
- export interface PlanStep {
43
- readonly id: string;
44
- /** The real command (run it yourself to do the same step). */
45
- readonly command: string;
46
- readonly why: string;
47
- }
48
49
  export interface Preflight {
49
50
  readonly code: string;
50
51
  readonly severity: 'error' | 'info';
@@ -68,11 +69,11 @@ export interface NeedsAnswers {
68
69
  readonly missing: readonly string[];
69
70
  /** What to do now (D-0065 §11): ask, end the turn, and only then run the command. */
70
71
  readonly next: string;
72
+ /** D-0098: what the recipe does not build (the api recipe: no browser screens), for the AI. */
73
+ readonly notice?: string;
71
74
  /** Always true: the agent's turn ends with the questions; nothing runs before the answers. */
72
75
  readonly waitForUser: true;
73
76
  }
74
- /** The first sentence of `next` (and of the guide and AGENTS.md §1), word for word. */
75
- export declare const ASK_AND_WAIT = "Ask these questions and end your turn; do not run recipe/add/config until the user answers. Only use --defaults if the user said to decide.";
76
77
  export interface RecipeOutcome {
77
78
  readonly status: 'planned' | 'applied';
78
79
  readonly recipe: string;
@@ -82,12 +83,15 @@ export interface RecipeOutcome {
82
83
  readonly answersFrom: 'user' | 'defaults' | null;
83
84
  /**
84
85
  * Tell the user (one line each): the answers and who gave them, defaults with their reasons,
85
- * and what the app does not protect (no CSRF protection for HTML forms, csrf is absent).
86
+ * and what to know: how forms send the CSRF token, where the admin login and its first
87
+ * password are (D-0076 §6).
86
88
  */
87
89
  readonly report: string;
88
90
  readonly plan: {
89
91
  readonly steps: readonly PlanStep[];
90
92
  };
93
+ /** D-0098: what the recipe does not build (the api recipe: no browser screens), for the AI. */
94
+ readonly notice?: string;
91
95
  readonly preflight: readonly Preflight[];
92
96
  readonly completedSteps: readonly string[];
93
97
  readonly skippedSteps: readonly string[];
@@ -100,6 +104,24 @@ export interface RecipeOutcome {
100
104
  readonly question?: string;
101
105
  readonly next?: string;
102
106
  }
107
+ /**
108
+ * D-0098: a recipe that builds no browser screens (`screens: false`, the api recipe) answers the
109
+ * view with `none`. It is not a question (the recipe is the choice) and not a value the recipe
110
+ * fixes: who chose no screens is recorded like an answer, the user's unless the AI names `view`
111
+ * in `--ai-chose` (or the user left every choice to it), and verify's tellUser says it. Its app
112
+ * skeleton (the first app, which `add @dokebi/app` writes) has an EJS home page, but no screens
113
+ * for people. An app added later with `app add` inherits view none (no views, a JSON API) unless
114
+ * it says `--view ejs` or `--inertia` (M50).
115
+ */
116
+ export declare const SCREENS = "view";
117
+ /** The view answer of a recipe without browser screens. */
118
+ export declare const NO_SCREENS = "none";
119
+ /** The recipes that ask the view (blog, site): where a service people use in a browser goes. */
120
+ export declare function screenRecipes(catalog: Catalog): string[];
121
+ /** What a recipe without screens tells the AI before and after planning (D-0098). */
122
+ export declare function noScreensNotice(recipe: string, browserRecipes: readonly string[]): string;
123
+ /** The recorded notice of a recipe without screens: verify's tellUser tells the user (D-0098). */
124
+ export declare function noScreensRecord(recipe: string, browserRecipes: readonly string[]): string;
103
125
  /** `dokebi recipe list`. */
104
126
  export declare function listRecipes(): Promise<{
105
127
  recipes: {
@@ -112,9 +134,23 @@ export declare function listRecipes(): Promise<{
112
134
  /** The devInfra docker note when `docker` is not on PATH (the default stays; the user decides). */
113
135
  export declare const DOCKER_NOT_FOUND = "docker was not found on PATH: install and start Docker before applying with docker, or choose url (a database server you already have)";
114
136
  /**
115
- * Runs a command in the project folder; a failure becomes STEP_COMMAND_FAILED with the bounded,
116
- * redacted end of its output (`OutputTail` of npm-client).
137
+ * The tellUser line of `--admin login`: where to sign in and where the first password is.
138
+ * `appUrl` is the app's `<scheme>://<domain>:<port>`, or APP_URL_PLACEHOLDER in the recipe record
139
+ * (verify fills in the address of the moment, AI-M05). `firstAdmin` is firstAdminPhrase, or
140
+ * FIRST_ADMIN_PLACEHOLDER in the record: verify reads the login and the section from the project
141
+ * as it is then (D-0115: the builder may rename the section or sign the admin in by email).
142
+ */
143
+ export declare function loginNotice(appUrl: string, devInfra: string | undefined, firstAdmin?: string): string;
144
+ /**
145
+ * The hosts of a blog/api plan (D-0102): the recipe's `web.domain` is `auto`, the project's own
146
+ * development hosts `main.<label>.localhost` and `admin.<label>.localhost`, so two projects on one
147
+ * machine never share cookies. A recipe naming a domain keeps it (admin then on `admin.<domain>`).
148
+ * An app that already exists keeps the first domain dokebi.yaml maps to it (`options`), so a plan
149
+ * applied again to an older project (main on `localhost`) seals and links the host it really has.
117
150
  */
118
- export declare function shell(root: string, command: string, args: string[], timeoutMs: number): Promise<void>;
151
+ export declare function recipeHosts(recipe: Pick<CatalogRecipe, 'web'>, label: string, options?: Record<string, unknown>): {
152
+ readonly main: string;
153
+ readonly admin: string;
154
+ };
119
155
  /** `dokebi recipe <name> …`. */
120
156
  export declare function runRecipe(request: RecipeRequest, project: Project): Promise<NeedsAnswers | RecipeOutcome>;