@dokebi/cli 0.4.2 → 0.4.26

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 (188) hide show
  1. package/AI.md +262 -57
  2. package/README.md +35 -10
  3. package/catalog.json +2519 -257
  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 +518 -35
  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 +2 -0
  46. package/dist/check/shared-state-rules.js +114 -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 +307 -31
  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-show.d.ts +82 -0
  70. package/dist/config-show.js +563 -0
  71. package/dist/config-values.d.ts +8 -0
  72. package/dist/config-values.js +68 -0
  73. package/dist/configuration-scaffold.d.ts +42 -0
  74. package/dist/configuration-scaffold.js +258 -4
  75. package/dist/configuration.d.ts +63 -3
  76. package/dist/configuration.js +178 -4
  77. package/dist/deployment.d.ts +2 -0
  78. package/dist/deployment.js +111 -0
  79. package/dist/describe-db.d.ts +22 -0
  80. package/dist/describe-db.js +176 -0
  81. package/dist/describe.d.ts +92 -0
  82. package/dist/describe.js +417 -0
  83. package/dist/dev-compose.d.ts +20 -0
  84. package/dist/dev-compose.js +154 -0
  85. package/dist/dev-guard.js +44 -0
  86. package/dist/dev-host.d.ts +13 -0
  87. package/dist/dev-host.js +55 -0
  88. package/dist/dev-infra.d.ts +72 -4
  89. package/dist/dev-infra.js +237 -17
  90. package/dist/dev-watch.d.ts +34 -0
  91. package/dist/dev-watch.js +192 -0
  92. package/dist/diagnostics.d.ts +10 -7
  93. package/dist/diagnostics.js +22 -4
  94. package/dist/drift.d.ts +50 -0
  95. package/dist/drift.js +218 -0
  96. package/dist/env-file.d.ts +25 -1
  97. package/dist/env-file.js +64 -6
  98. package/dist/errors.d.ts +7 -1
  99. package/dist/example-config.d.ts +2 -0
  100. package/dist/example-config.js +3 -0
  101. package/dist/first-admin.d.ts +43 -0
  102. package/dist/first-admin.js +175 -0
  103. package/dist/formatter-ignore.d.ts +37 -0
  104. package/dist/formatter-ignore.js +344 -0
  105. package/dist/generator.d.ts +45 -3
  106. package/dist/generator.js +205 -15
  107. package/dist/guide.d.ts +1 -1
  108. package/dist/guide.js +42 -21
  109. package/dist/install-module.d.ts +20 -2
  110. package/dist/install-module.js +45 -8
  111. package/dist/install-plan.js +5 -3
  112. package/dist/layout.d.ts +12 -1
  113. package/dist/layout.js +19 -6
  114. package/dist/log-writer-control.d.ts +2 -0
  115. package/dist/log-writer-control.js +17 -2
  116. package/dist/log-writer.js +43 -10
  117. package/dist/logging-config.d.ts +8 -3
  118. package/dist/logging-config.js +19 -5
  119. package/dist/manifest.d.ts +29 -1
  120. package/dist/manifest.js +84 -6
  121. package/dist/merge-install-defaults.d.ts +0 -1
  122. package/dist/merge-install-defaults.js +13 -47
  123. package/dist/npm-client.d.ts +40 -4
  124. package/dist/npm-client.js +152 -15
  125. package/dist/npm-paths.js +18 -4
  126. package/dist/openapi.d.ts +27 -0
  127. package/dist/openapi.js +649 -0
  128. package/dist/option-fixes.d.ts +39 -0
  129. package/dist/option-fixes.js +130 -0
  130. package/dist/output.d.ts +1 -1
  131. package/dist/output.js +5 -5
  132. package/dist/plugin-lifecycle.d.ts +34 -2
  133. package/dist/plugin-lifecycle.js +195 -16
  134. package/dist/plugin-options.d.ts +19 -4
  135. package/dist/plugin-options.js +138 -6
  136. package/dist/plugins-command.js +44 -7
  137. package/dist/process-group.d.ts +39 -0
  138. package/dist/process-group.js +132 -0
  139. package/dist/project-guide.d.ts +19 -1
  140. package/dist/project-guide.js +119 -20
  141. package/dist/project-inspector.d.ts +15 -0
  142. package/dist/project-inspector.js +481 -33
  143. package/dist/project-state.d.ts +5 -0
  144. package/dist/project-state.js +8 -0
  145. package/dist/project.d.ts +203 -19
  146. package/dist/project.js +3006 -333
  147. package/dist/recipe-facts.d.ts +127 -0
  148. package/dist/recipe-facts.js +563 -0
  149. package/dist/recipe-login.d.ts +63 -0
  150. package/dist/recipe-login.js +464 -0
  151. package/dist/recipe-mail.d.ts +21 -0
  152. package/dist/recipe-mail.js +214 -0
  153. package/dist/recipe-members.d.ts +35 -0
  154. package/dist/recipe-members.js +178 -0
  155. package/dist/recipe-record.d.ts +82 -7
  156. package/dist/recipe-record.js +226 -22
  157. package/dist/recipe-reset.d.ts +2 -0
  158. package/dist/recipe-reset.js +117 -0
  159. package/dist/recipe-site.d.ts +48 -0
  160. package/dist/recipe-site.js +396 -0
  161. package/dist/recipe-step.d.ts +42 -0
  162. package/dist/recipe-step.js +89 -0
  163. package/dist/recipe.d.ts +56 -20
  164. package/dist/recipe.js +703 -131
  165. package/dist/registry.d.ts +98 -2
  166. package/dist/registry.js +171 -5
  167. package/dist/reloadable.d.ts +35 -0
  168. package/dist/reloadable.js +257 -0
  169. package/dist/server.d.ts +43 -4
  170. package/dist/server.js +601 -52
  171. package/dist/settings.d.ts +48 -0
  172. package/dist/settings.js +168 -0
  173. package/dist/site-auth.d.ts +39 -0
  174. package/dist/site-auth.js +195 -0
  175. package/dist/template-fixes.d.ts +26 -0
  176. package/dist/template-fixes.js +285 -0
  177. package/dist/test-reporter.d.ts +49 -0
  178. package/dist/test-reporter.js +190 -0
  179. package/dist/transaction.d.ts +47 -0
  180. package/dist/transaction.js +241 -7
  181. package/dist/verify-tests.d.ts +98 -0
  182. package/dist/verify-tests.js +449 -0
  183. package/dist/verify.d.ts +103 -3
  184. package/dist/verify.js +272 -21
  185. package/dist/view-choice.d.ts +58 -0
  186. package/dist/view-choice.js +131 -0
  187. package/dist/vite-watch.js +4 -2
  188. package/package.json +8 -6
@@ -0,0 +1,214 @@
1
+ // Local development mail is an explicit recipe choice (D-0078 MP4).
2
+ import { execFile } from 'node:child_process';
3
+ import { createHash } from 'node:crypto';
4
+ import { readFile } from 'node:fs/promises';
5
+ import { createServer } from 'node:net';
6
+ import { join } from 'node:path';
7
+ import { promisify } from 'node:util';
8
+ import { debug, isMissing } from './diagnostics.js';
9
+ import { CliError } from './errors.js';
10
+ const exec = promisify(execFile);
11
+ export const MAIL_COMPOSE = 'compose.mail.yaml';
12
+ const CONNECTION = 'recipeMail';
13
+ const QUEUE = 'recipeMail';
14
+ /** Omitting ports reproduces the legacy template for safe migration only. */
15
+ export function mailCompose(root, ports) {
16
+ const name = `dokebi-mail-${createHash('sha256').update(root).digest('hex').slice(0, 12)}`;
17
+ return `# Local mail only. Never deploy this file to production.\nname: ${name}\nservices:\n mailpit:\n image: axllent/mailpit:v1.31.4\n ports: ["127.0.0.1:${ports?.smtp ?? ''}:1025", "127.0.0.1:${ports?.inbox ?? ''}:8025"]\n nats:\n image: nats:2.11.8-alpine\n command: ["-js", "-sd", "/data"]\n ports: ["127.0.0.1:${ports?.nats ?? ''}:4222"]\n volumes: ["mail-jobs:/data"]\nvolumes:\n mail-jobs: {}\n`;
18
+ }
19
+ async function docker(root, args) {
20
+ debug('Recipe mail Docker command', { args });
21
+ try {
22
+ return (await exec('docker', ['compose', '-f', MAIL_COMPOSE, ...args], {
23
+ cwd: root,
24
+ timeout: 120_000,
25
+ maxBuffer: 64 * 1024,
26
+ })).stdout.trim();
27
+ }
28
+ catch (cause) {
29
+ throw new CliError('MAIL_INFRA_FAILED', 'The local mail environment could not be prepared', {
30
+ cause,
31
+ details: {
32
+ fix: `Inspect docker compose -f ${MAIL_COMPOSE} ps and logs; rerun the same recipe after fixing it.`,
33
+ },
34
+ });
35
+ }
36
+ }
37
+ async function port(root, service, target) {
38
+ const address = await docker(root, ['port', service, String(target)]);
39
+ const matched = /^127\.0\.0\.1:(\d+)$/.exec(address);
40
+ const value = Number(matched?.[1]);
41
+ if (!Number.isInteger(value) || value < 1 || value > 65535)
42
+ throw new CliError('MAIL_INFRA_FAILED', 'Docker did not publish a loopback mail port');
43
+ return value;
44
+ }
45
+ /** Accept only an untouched generated template; never infer ownership from a filename. */
46
+ export function mailComposePorts(root, source) {
47
+ if (source === mailCompose(root))
48
+ return undefined;
49
+ const matches = [...source.matchAll(/127\.0\.0\.1:(\d+):(1025|8025|4222)/g)];
50
+ const values = Object.fromEntries(matches.map((match) => [match[2], Number(match[1])]));
51
+ const ports = {
52
+ smtp: values['1025'] ?? 0,
53
+ inbox: values['8025'] ?? 0,
54
+ nats: values['4222'] ?? 0,
55
+ };
56
+ if (matches.length !== 3 ||
57
+ Object.values(ports).some((value) => value < 1 || value > 65535) ||
58
+ new Set(Object.values(ports)).size !== 3 ||
59
+ source !== mailCompose(root, ports))
60
+ throw new CliError('RECIPE_FILE_CONFLICT', `${MAIL_COMPOSE} was edited; configure mail manually instead of replacing it`);
61
+ return ports;
62
+ }
63
+ async function existingMailCompose(root) {
64
+ try {
65
+ return await readFile(join(root, MAIL_COMPOSE), 'utf8');
66
+ }
67
+ catch (error) {
68
+ if (!isMissing(error))
69
+ throw error;
70
+ return undefined;
71
+ }
72
+ }
73
+ export async function checkMailCompose(root) {
74
+ const source = await existingMailCompose(root);
75
+ if (source !== undefined)
76
+ mailComposePorts(root, source);
77
+ }
78
+ /** Reserve three distinct loopback ports together, then persist them before Docker starts. */
79
+ export async function allocateMailPorts() {
80
+ const servers = [createServer(), createServer(), createServer()];
81
+ const ports = [];
82
+ try {
83
+ for (const server of servers) {
84
+ await new Promise((resolve, reject) => {
85
+ server.once('error', reject);
86
+ server.listen(0, '127.0.0.1', resolve);
87
+ });
88
+ ports.push(server.address().port);
89
+ }
90
+ const [smtp, inbox, nats] = ports;
91
+ if (!smtp || !inbox || !nats)
92
+ throw new CliError('MAIL_INFRA_FAILED', 'Could not allocate development mail ports');
93
+ debug('Allocated persistent development mail ports', { smtp, inbox, nats });
94
+ return { smtp, inbox, nats };
95
+ }
96
+ catch (cause) {
97
+ throw new CliError('MAIL_INFRA_FAILED', 'Could not reserve development mail ports', { cause });
98
+ }
99
+ finally {
100
+ await Promise.all(servers
101
+ .filter((server) => server.listening)
102
+ .map((server) => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))));
103
+ }
104
+ }
105
+ export function mailSteps(input) {
106
+ const { root, project, choice, now } = input;
107
+ if (!choice || choice === 'none')
108
+ return [];
109
+ if (choice !== 'mailpit')
110
+ return [];
111
+ const has = async (id) => (await now())?.installed.has(id) ?? false;
112
+ return [
113
+ {
114
+ id: 'mail-infra',
115
+ command: `docker compose -f ${MAIL_COMPOSE} up -d --wait`,
116
+ why: 'local Mailpit and a dedicated NATS JetStream, free loopback ports persisted in Compose before startup; compose.yaml is kept',
117
+ satisfied: async () => false,
118
+ run: async () => {
119
+ const existing = await existingMailCompose(root);
120
+ const configured = existing === undefined ? undefined : mailComposePorts(root, existing);
121
+ const selected = configured ?? (await allocateMailPorts());
122
+ const content = mailCompose(root, selected);
123
+ await project.writeRecipeFiles(root, existing !== undefined && !configured
124
+ ? {
125
+ files: [],
126
+ edits: [
127
+ {
128
+ path: MAIL_COMPOSE,
129
+ edit(text) {
130
+ if (text !== existing)
131
+ throw new CliError('RECIPE_FILE_CONFLICT', `${MAIL_COMPOSE} changed while the recipe was writing it`, {
132
+ details: {
133
+ path: MAIL_COMPOSE,
134
+ fix: `${MAIL_COMPOSE} was edited during the run: run the same recipe command again`,
135
+ },
136
+ phase: 'plan',
137
+ });
138
+ return content;
139
+ },
140
+ },
141
+ ],
142
+ }
143
+ : { files: [{ path: MAIL_COMPOSE, content }] });
144
+ await docker(root, ['up', '-d', '--wait']);
145
+ const natsPort = await port(root, 'nats', 4222);
146
+ const smtpPort = await port(root, 'mailpit', 1025);
147
+ const uiPort = await port(root, 'mailpit', 8025);
148
+ const connection = { servers: [`nats://localhost:${natsPort}`] };
149
+ const queue = {
150
+ connection: CONNECTION,
151
+ stream: 'DOKEBI_MAIL',
152
+ consumer: 'DOKEBI_MAIL_WORKER',
153
+ subject: 'dokebi.mail',
154
+ deadLetterStream: 'DOKEBI_MAIL_DLQ',
155
+ deadLetterSubject: 'dokebi.mail.dead',
156
+ };
157
+ const transport = { driver: 'smtp', host: 'localhost', port: smtpPort, tls: 'off' };
158
+ // This explicit choice owns only its named development connection and queue.
159
+ if (!(await has('dokebi-nats')))
160
+ await project.add(root, '@dokebi/nats', false, [
161
+ { path: ['connections', CONNECTION], value: connection },
162
+ ]);
163
+ else
164
+ await project.configSet(root, [
165
+ {
166
+ plugin: 'dokebi-nats',
167
+ set: { path: ['connections', CONNECTION], value: connection },
168
+ },
169
+ ]);
170
+ if (!(await has('dokebi-job')))
171
+ await project.add(root, '@dokebi/job', false, [
172
+ { path: ['queues', QUEUE], value: queue },
173
+ ]);
174
+ else
175
+ await project.configSet(root, [
176
+ { plugin: 'dokebi-job', set: { path: ['queues', QUEUE], value: queue } },
177
+ ]);
178
+ if (!(await has('dokebi-mail')))
179
+ await project.add(root, '@dokebi/mail', false, [
180
+ { path: ['transport'], value: transport },
181
+ { path: ['queue'], value: QUEUE },
182
+ ]);
183
+ else
184
+ await project.configSet(root, [
185
+ { plugin: 'dokebi-mail', set: { path: ['transport'], value: transport } },
186
+ { plugin: 'dokebi-mail', set: { path: ['queue'], value: QUEUE } },
187
+ ]);
188
+ debug('Recipe local mail configured', {
189
+ smtpPort,
190
+ natsPort,
191
+ inbox: `http://localhost:${uiPort}`,
192
+ });
193
+ },
194
+ },
195
+ {
196
+ id: 'mail-provision',
197
+ command: `npx dokebi-job-provision --queue ${QUEUE}`,
198
+ why: 'provision the dedicated mail stream and durable consumer before starting',
199
+ satisfied: async () => false,
200
+ run: async () => {
201
+ try {
202
+ await exec(join(root, 'node_modules/.bin/dokebi-job-provision'), ['--queue', QUEUE], {
203
+ cwd: root,
204
+ timeout: 30_000,
205
+ maxBuffer: 64 * 1024,
206
+ });
207
+ }
208
+ catch (cause) {
209
+ throw new CliError('MAIL_QUEUE_REQUIRED', 'The local mail queue could not be provisioned', { cause });
210
+ }
211
+ },
212
+ },
213
+ ];
214
+ }
@@ -0,0 +1,35 @@
1
+ import type { LoginDialect, LoginFile } from './recipe-login.js';
2
+ /** The names the login code uses for the app's own user table. */
3
+ export interface LoginNames {
4
+ /** The table (MongoDB: collection) of the app's users. */
5
+ readonly table: string;
6
+ /** The migration name, `<UTC time>_<migration>.ts`. */
7
+ readonly migration: string;
8
+ /** The numeric primary key column (SQL). */
9
+ readonly id: string;
10
+ /** The row type of the model (`Member`). */
11
+ readonly type: string;
12
+ /** A row as a variable (`member`), and the collection field of the MongoDB model. */
13
+ readonly row: string;
14
+ readonly rows: string;
15
+ /** The role a new row gets, and the ability that role has in authorize. */
16
+ readonly role: string;
17
+ readonly ability: string;
18
+ /** What the admin page says to a user with that ability. */
19
+ readonly mayWrite: string;
20
+ }
21
+ /**
22
+ * New projects (D-0115): app_members, the table of the app's sign-in accounts behind
23
+ * MembersModel. The app_ prefix keeps it apart from the users or members table an app often has
24
+ * of its own; the default role member may write content (content.write).
25
+ */
26
+ export declare const LOGIN_NAMES: LoginNames;
27
+ /** What recipes before D-0115 wrote (the blog's authors); such projects keep them. */
28
+ export declare const LEGACY_LOGIN_NAMES: LoginNames;
29
+ /** The table of new projects (LOGIN_NAMES.table). */
30
+ export declare const USERS_TABLE: string;
31
+ export declare const MEMBER_MODEL = "src/shared/models/members.ts";
32
+ export declare const MEMBER_SERVICE = "src/shared/services/members.ts";
33
+ /** Separate files, not layer classes grouped together in the auth extension. */
34
+ export declare function memberFiles(dialect: LoginDialect, n?: LoginNames): LoginFile[];
35
+ export declare function memberExtension(dialect: LoginDialect, n?: LoginNames): string;
@@ -0,0 +1,178 @@
1
+ /**
2
+ * New projects (D-0115): app_members, the table of the app's sign-in accounts behind
3
+ * MembersModel. The app_ prefix keeps it apart from the users or members table an app often has
4
+ * of its own; the default role member may write content (content.write).
5
+ */
6
+ export const LOGIN_NAMES = {
7
+ table: 'app_members',
8
+ migration: 'create_app_members',
9
+ id: 'member_no',
10
+ type: 'Member',
11
+ row: 'member',
12
+ rows: 'members',
13
+ role: 'member',
14
+ ability: 'content.write',
15
+ mayWrite: 'You may write content.',
16
+ };
17
+ /** What recipes before D-0115 wrote (the blog's authors); such projects keep them. */
18
+ export const LEGACY_LOGIN_NAMES = {
19
+ table: 'blog_authors',
20
+ migration: 'create_blog_authors',
21
+ id: 'author_no',
22
+ type: 'Author',
23
+ row: 'author',
24
+ rows: 'authors',
25
+ role: 'author',
26
+ ability: 'posts.write',
27
+ mayWrite: 'You may write posts.',
28
+ };
29
+ /** The table of new projects (LOGIN_NAMES.table). */
30
+ export const USERS_TABLE = LOGIN_NAMES.table;
31
+ export const MEMBER_MODEL = 'src/shared/models/members.ts';
32
+ export const MEMBER_SERVICE = 'src/shared/services/members.ts';
33
+ function sqlModel(dialect, n) {
34
+ const driver = dialect === 'postgres' ? 'pg' : dialect === 'mysql' ? 'mysql2/promise' : 'mariadb';
35
+ const mark = (n) => (dialect === 'postgres' ? `$${n}` : '?');
36
+ const select = (column, value) => {
37
+ const sql = `SELECT ${n.id}, login_name, pass_phc, logout_key, is_active, role FROM ${n.table} WHERE ${column} = ${mark(1)}`;
38
+ if (dialect === 'postgres')
39
+ return `const { rows } = await this.#pool.query<${n.type}>('${sql}', [${value}]);\n return rows[0];`;
40
+ if (dialect === 'mysql')
41
+ return `const [rows] = await this.#pool.query<(${n.type} & RowDataPacket)[]>('${sql}', [${value}]);\n return rows[0];`;
42
+ return `const rows = await this.#pool.query<${n.type}[]>('${sql}', [${value}]);\n return rows[0];`;
43
+ };
44
+ return `import type { Pool${dialect === 'mysql' ? ', RowDataPacket' : ''} } from '${driver}';
45
+
46
+ export type ${n.type} = {
47
+ ${n.id}: number;
48
+ login_name: string;
49
+ pass_phc: string | null;
50
+ logout_key: string;
51
+ is_active: ${dialect === 'postgres' ? 'boolean' : 'number | boolean'};
52
+ role: string;
53
+ };
54
+
55
+ export class MembersModel {
56
+ readonly #pool: Pool;
57
+ constructor(pool: Pool) { this.#pool = pool; }
58
+
59
+ async findByIdentity(identity: string): Promise<${n.type} | undefined> {
60
+ ${select('login_name', 'identity')}
61
+ }
62
+ async findById(id: string): Promise<${n.type} | undefined> {
63
+ if (!/^\\d{1,15}$/.test(id)) return undefined;
64
+ ${select(n.id, 'Number(id)')}
65
+ }
66
+ async updatePasswordHash(${n.row}: ${n.type}, hash: string, stamp: string): Promise<void> {
67
+ await this.#pool.query('UPDATE ${n.table} SET pass_phc = ${mark(1)}, logout_key = ${mark(2)} WHERE ${n.id} = ${mark(3)}', [hash, stamp, ${n.row}.${n.id}]);
68
+ }
69
+ }
70
+ `;
71
+ }
72
+ const MONGO_MODEL = (n) => `import { type Collection, type MongoClient, ObjectId } from 'mongodb';
73
+
74
+ export type ${n.type} = {
75
+ _id: ObjectId;
76
+ login_name: string;
77
+ pass_phc: string | null;
78
+ logout_key: string;
79
+ is_active: boolean;
80
+ role: string;
81
+ };
82
+
83
+ export class MembersModel {
84
+ readonly #${n.rows}: Collection<${n.type}>;
85
+ constructor(client: MongoClient) {
86
+ this.#${n.rows} = client.db().collection<${n.type}>('${n.table}');
87
+ }
88
+ async findByIdentity(identity: string): Promise<${n.type} | undefined> {
89
+ return (await this.#${n.rows}.findOne({ login_name: identity })) ?? undefined;
90
+ }
91
+ async findById(id: string): Promise<${n.type} | undefined> {
92
+ if (!/^[0-9a-f]{24}$/.test(id)) return undefined;
93
+ return (await this.#${n.rows}.findOne({ _id: new ObjectId(id) })) ?? undefined;
94
+ }
95
+ async updatePasswordHash(${n.row}: ${n.type}, hash: string, stamp: string): Promise<void> {
96
+ await this.#${n.rows}.updateOne({ _id: ${n.row}._id }, { $set: { pass_phc: hash, logout_key: stamp } });
97
+ }
98
+ }
99
+ `;
100
+ const SERVICE = (n) => `import { randomBytes } from 'node:crypto';
101
+ import type { ${n.type}, MembersModel } from '../models/members.ts';
102
+
103
+ /** Data and credential-update workflows, independent of HTTP and auth hook context. */
104
+ export class MembersService {
105
+ readonly #model: MembersModel;
106
+ constructor(model: MembersModel) { this.#model = model; }
107
+
108
+ findByIdentity(identity: string): Promise<${n.type} | undefined> {
109
+ return this.#model.findByIdentity(identity.trim().toLowerCase());
110
+ }
111
+ findById(id: string): Promise<${n.type} | undefined> {
112
+ return this.#model.findById(id);
113
+ }
114
+ updatePasswordHash(${n.row}: ${n.type}, hash: string, reset: boolean): Promise<void> {
115
+ // Reset invalidates other sessions; an automatic hash upgrade preserves their stamp.
116
+ const stamp = reset ? randomBytes(16).toString('base64url') : ${n.row}.logout_key;
117
+ return this.#model.updatePasswordHash(${n.row}, hash, stamp);
118
+ }
119
+ }
120
+ `;
121
+ /** Separate files, not layer classes grouped together in the auth extension. */
122
+ export function memberFiles(dialect, n = LOGIN_NAMES) {
123
+ return [
124
+ { path: MEMBER_MODEL, content: dialect === 'mongodb' ? MONGO_MODEL(n) : sqlModel(dialect, n) },
125
+ { path: MEMBER_SERVICE, content: SERVICE(n) },
126
+ ];
127
+ }
128
+ export function memberExtension(dialect, n = LOGIN_NAMES) {
129
+ const id = dialect === 'mongodb' ? `${n.row}._id.toHexString()` : `String(${n.row}.${n.id})`;
130
+ return `// Auth wiring: data is provided by MembersService through MembersModel.
131
+ // Password hashing/checking and auth token lifecycle remain owned by @dokebi/auth.
132
+ import { AuthExtension, type AuthHookContext, type AuthPasswordChange, type AuthUser } from '@dokebi/auth';
133
+ import type { PluginDependency } from '@dokebi/core';
134
+ import { dependency as db } from '@dokebi/db';
135
+ import { type ${n.type}, MembersModel } from '../../shared/models/members.ts';
136
+ import { MembersService } from '../../shared/services/members.ts';
137
+ import { main } from '../db/connections.ts';
138
+
139
+ const ROLE_ABILITIES: Readonly<Record<string, readonly string[]>> = {
140
+ ${n.role}: ['${n.ability}'],
141
+ };
142
+
143
+ export default class AppAuth extends AuthExtension<${n.type}> {
144
+ #members: MembersService | undefined;
145
+ protected override dependencies(): readonly PluginDependency[] { return [db]; }
146
+
147
+ // The extension has no setup hook. Cache the stateless composition on its first hook call,
148
+ // after dependency setup; this uses the provider's existing pool and creates no connections.
149
+ #service(context: AuthHookContext): MembersService {
150
+ return (this.#members ??= new MembersService(new MembersModel(context.getDependency(db).connection(main))));
151
+ }
152
+ protected override findByIdentity(identity: string, context: AuthHookContext) {
153
+ return this.#service(context).findByIdentity(identity);
154
+ }
155
+ protected override findById(id: string, context: AuthHookContext) {
156
+ return this.#service(context).findById(id);
157
+ }
158
+ protected override credentials(${n.row}: ${n.type}): AuthUser {
159
+ return {
160
+ id: ${id},
161
+ passwordHash: ${n.row}.pass_phc,
162
+ email: ${n.row}.login_name.includes('@') ? ${n.row}.login_name : undefined,
163
+ stamp: ${n.row}.logout_key,
164
+ active: Boolean(${n.row}.is_active),
165
+ };
166
+ }
167
+ protected override updatePasswordHash(${n.row}: ${n.type}, hash: string, context: AuthHookContext, change: AuthPasswordChange) {
168
+ return this.#service(context).updatePasswordHash(${n.row}, hash, change.reason === 'reset');
169
+ }
170
+ protected override authorize(${n.row}: ${n.type}, ability: string): boolean {
171
+ return ${n.row}.role === 'admin' || (ROLE_ABILITIES[${n.row}.role]?.includes(ability) ?? false);
172
+ }
173
+ protected override viewUser(${n.row}: ${n.type}) {
174
+ return { id: ${id}, login: ${n.row}.login_name, role: ${n.row}.role };
175
+ }
176
+ }
177
+ `;
178
+ }
@@ -1,24 +1,99 @@
1
1
  export declare const RECIPE_RECORD = ".dokebi/recipe.json";
2
+ /**
3
+ * How the agent passes `tellUser` on (AI-M05 F6: an agent summarized the lines in another
4
+ * language instead of relaying them): verify's JSON carries it next to the lines, the human
5
+ * output prints it as the block's heading.
6
+ */
7
+ export declare const TELL_USER_RELAY: {
8
+ readonly tellUser: 'verbatim';
9
+ readonly instruction: 'Copy these lines unchanged into your final message to the user: do not translate, reword or summarize them. You may add your own summary after them.';
10
+ };
11
+ /** Stands for `<scheme>://<domain>:<port>` of the app in a recorded notice. */
12
+ export declare const APP_URL_PLACEHOLDER = "{appUrl}";
13
+ /** Stands for the first admin's login and password key in the login notice (firstAdminPhrase). */
14
+ export declare const FIRST_ADMIN_PLACEHOLDER = "{firstAdmin}";
15
+ /** What the login notice says about protected pages (D-0115: no word of one kind of app). */
16
+ export declare const LOGIN_PAGES = "pages that need a login send other visitors to the login page.";
2
17
  export interface RecordedAnswer {
3
18
  readonly value: string;
4
- readonly answeredBy: 'flag' | 'default' | 'recipe';
19
+ /** D-0095: `user`, `ai` (with `aiReason` when the agent gave one), `default`, `recipe`. */
20
+ readonly answeredBy: 'user' | 'ai' | 'default' | 'recipe';
5
21
  readonly defaultReason?: string;
22
+ readonly aiReason?: string;
6
23
  }
7
24
  export interface AppliedRecipe {
8
25
  readonly recipe: string;
9
26
  readonly appliedAt: string;
10
- /** `user`: the user answered; `defaults`: the user left the choices to the agent. */
27
+ /**
28
+ * `user`: the given answers are the user's (except those the agent chose, `answeredBy: ai`);
29
+ * `defaults`: the user left every choice to the agent.
30
+ */
11
31
  readonly answersFrom: 'user' | 'defaults';
12
32
  readonly answers: Readonly<Record<string, RecordedAnswer>>;
13
- /** What the user must know about the result (one line each). */
33
+ /** What the user must know about the result (one line each; APP_URL_PLACEHOLDER for the app). */
14
34
  readonly notices: readonly string[];
35
+ /** The domain the recipe serves the app on (`localhost`): picks the address for the notices. */
36
+ readonly domain?: string;
37
+ }
38
+ /** Where the app answers now: verify's `urls`, else the port of dokebi.yaml. */
39
+ export interface AppAddress {
40
+ readonly urls: readonly string[];
41
+ readonly port?: number;
42
+ }
43
+ /**
44
+ * D-0098: a product choice made outside a recipe: the view of an app that `dokebi app add` (or
45
+ * the first app of `dokebi add <apps host>`) created. `unstated`: an EJS app added without saying
46
+ * who chose it; EJS is not the default view (Inertia + Vue), so the user hears about it.
47
+ */
48
+ export interface RecordedChoice {
49
+ readonly app: string;
50
+ readonly id: 'view';
51
+ /** A value of the view question: `ejs`, `inertia-vue`, `inertia-vue-ssr`. */
52
+ readonly value: string;
53
+ readonly answeredBy: 'user' | 'ai' | 'unstated';
54
+ readonly aiReason?: string;
55
+ readonly recordedAt: string;
15
56
  }
16
57
  /** The recipes recorded in the project; none when there is no record (or it is unreadable). */
17
58
  export declare function readAppliedRecipes(root: string): Promise<AppliedRecipe[]>;
18
- /** Records an applied recipe, replacing an earlier application of the same recipe. */
19
- export declare function saveAppliedRecipe(root: string, entry: AppliedRecipe): Promise<void>;
59
+ /** D-0098: the choices recorded outside recipes (the views of apps). */
60
+ export declare function readRecordedChoices(root: string): Promise<RecordedChoice[]>;
61
+ /**
62
+ * Records an applied recipe, replacing an earlier application of the same recipe. The recorded
63
+ * app choices of `apps` (the apps the recipe answers for) give way to the recipe's answers.
64
+ */
65
+ export declare function saveAppliedRecipe(root: string, entry: AppliedRecipe, apps?: readonly string[]): Promise<void>;
66
+ /**
67
+ * `dokebi app remove`: drops the choices recorded for `app`. True when the record changed (the
68
+ * file is left alone when it holds none of that app).
69
+ */
70
+ export declare function forgetAppChoices(root: string, app: string): Promise<boolean>;
71
+ /** D-0098: records the view of an app made outside a recipe, replacing that app's earlier one. */
72
+ export declare function saveRecordedChoice(root: string, choice: RecordedChoice): Promise<void>;
73
+ /** Whether a recorded notice names the first admin (filled from the project by verify). */
74
+ export declare function namesFirstAdmin(notice: string): boolean;
75
+ /** The same part as the recipe says it now: what the recipe built, true whatever comes later. */
76
+ export declare const NO_SCREENS_BUILT = "the recipe built a JSON API for other programs, with no pages where people sign up, browse or press buttons.";
77
+ /**
78
+ * One recorded notice as the user hears it now. A fact notice (D-0137) is the text verify read
79
+ * from the project (`facts`, by factKey), or, without it, that the CLI could not read it; '' when
80
+ * nothing is left to say. Any other notice gets the current app address: the placeholder filled
81
+ * in, or (a record from before the placeholder) a loopback address whose port is not the app's
82
+ * replaced by the current one; without a current address the placeholder becomes
83
+ * `http://<domain>`. The first admin placeholder gets `firstAdmin` (D-0115).
84
+ */
85
+ export declare function fillNotice(notice: string, entry: AppliedRecipe, address: AppAddress, firstAdmin?: string, facts?: ReadonlyMap<string, string>): string;
86
+ /** What the user hears about an answer the agent chose (D-0095), with its reason when given. */
87
+ export declare const AI_CHOICE_LINE = "chosen by the AI (you left the choice to it)";
88
+ /** D-0098: an EJS app added without saying who chose EJS. */
89
+ export declare const UNSTATED_VIEW_LINE = "not the default view (Inertia + Vue), and who chose it was not recorded";
20
90
  /**
21
91
  * The lines for the user's final message: each answer and who chose it (a default with its
22
- * reason), then the notices. Answers fixed by the recipe itself are not choices and are left out.
92
+ * reason, an AI choice with the agent's reason when it gave one), then the notices with the
93
+ * current app address. Answers fixed by the recipe itself are not choices and are left out.
23
94
  */
24
- export declare function tellUserLines(recipes: readonly AppliedRecipe[]): string[];
95
+ export declare function tellUserLines(recipes: readonly AppliedRecipe[], address?: AppAddress, choices?: readonly RecordedChoice[],
96
+ /** firstAdminPhrase of the project now (D-0115); without it, the not-found words. */
97
+ firstAdmin?: string,
98
+ /** D-0137: the fact notices as verify read them from the project (factTexts). */
99
+ facts?: ReadonlyMap<string, string>): string[];