@dokebi/cli 0.4.1 → 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.
- package/AI.md +262 -57
- package/README.md +35 -10
- package/catalog.json +2519 -257
- package/describe.schema.json +748 -0
- package/dist/api-app.d.ts +2 -0
- package/dist/api-app.js +35 -0
- package/dist/app-urls.js +3 -2
- package/dist/bin.js +527 -35
- package/dist/capability-guide.d.ts +30 -0
- package/dist/capability-guide.js +122 -0
- package/dist/catalog.d.ts +16 -2
- package/dist/catalog.js +19 -7
- package/dist/check/asset-rules.d.ts +6 -0
- package/dist/check/asset-rules.js +163 -0
- package/dist/check/browser-sources.d.ts +20 -0
- package/dist/check/browser-sources.js +89 -0
- package/dist/check/console-rules.d.ts +6 -0
- package/dist/check/console-rules.js +40 -0
- package/dist/check/error-rules.d.ts +21 -0
- package/dist/check/error-rules.js +212 -0
- package/dist/check/form-rules.d.ts +12 -0
- package/dist/check/form-rules.js +69 -6
- package/dist/check/formatter-rules.d.ts +2 -0
- package/dist/check/formatter-rules.js +71 -0
- package/dist/check/i18n-rules.d.ts +27 -0
- package/dist/check/i18n-rules.js +353 -0
- package/dist/check/index.d.ts +15 -1
- package/dist/check/index.js +89 -11
- package/dist/check/mail-rules.d.ts +4 -0
- package/dist/check/mail-rules.js +72 -0
- package/dist/check/module-rules.d.ts +13 -0
- package/dist/check/module-rules.js +104 -0
- package/dist/check/packages.d.ts +8 -0
- package/dist/check/packages.js +16 -0
- package/dist/check/pool-rules.d.ts +8 -0
- package/dist/check/pool-rules.js +164 -0
- package/dist/check/problems.d.ts +6 -1
- package/dist/check/problems.js +100 -2
- package/dist/check/project-rules.d.ts +23 -3
- package/dist/check/project-rules.js +265 -23
- package/dist/check/seal-rules.d.ts +6 -0
- package/dist/check/seal-rules.js +68 -0
- package/dist/check/security-rules.d.ts +10 -0
- package/dist/check/security-rules.js +450 -0
- package/dist/check/shared-state-rules.d.ts +2 -0
- package/dist/check/shared-state-rules.js +114 -0
- package/dist/check/source-rules.d.ts +26 -3
- package/dist/check/source-rules.js +249 -28
- package/dist/check/structure-rules.d.ts +23 -0
- package/dist/check/structure-rules.js +502 -0
- package/dist/check/timer-rules.d.ts +7 -0
- package/dist/check/timer-rules.js +543 -0
- package/dist/check/typescript.d.ts +35 -5
- package/dist/check/typescript.js +163 -17
- package/dist/check/vite-rules.d.ts +2 -0
- package/dist/check/vite-rules.js +136 -0
- package/dist/check/wait-rules.d.ts +16 -0
- package/dist/check/wait-rules.js +337 -0
- package/dist/commands.d.ts +7 -0
- package/dist/commands.js +307 -31
- package/dist/config-diff.d.ts +42 -0
- package/dist/config-diff.js +289 -0
- package/dist/config-edit.d.ts +35 -2
- package/dist/config-edit.js +145 -13
- package/dist/config-init.d.ts +15 -0
- package/dist/config-init.js +61 -0
- package/dist/config-paths.d.ts +22 -0
- package/dist/config-paths.js +82 -0
- package/dist/config-show.d.ts +82 -0
- package/dist/config-show.js +563 -0
- package/dist/config-values.d.ts +8 -0
- package/dist/config-values.js +68 -0
- package/dist/configuration-scaffold.d.ts +42 -0
- package/dist/configuration-scaffold.js +258 -4
- package/dist/configuration.d.ts +63 -3
- package/dist/configuration.js +178 -4
- package/dist/deployment.d.ts +2 -0
- package/dist/deployment.js +111 -0
- package/dist/describe-db.d.ts +22 -0
- package/dist/describe-db.js +176 -0
- package/dist/describe.d.ts +92 -0
- package/dist/describe.js +417 -0
- package/dist/dev-compose.d.ts +20 -0
- package/dist/dev-compose.js +154 -0
- package/dist/dev-guard.js +44 -0
- package/dist/dev-host.d.ts +13 -0
- package/dist/dev-host.js +55 -0
- package/dist/dev-infra.d.ts +72 -4
- package/dist/dev-infra.js +237 -17
- package/dist/dev-watch.d.ts +34 -0
- package/dist/dev-watch.js +192 -0
- package/dist/diagnostics.d.ts +10 -7
- package/dist/diagnostics.js +22 -4
- package/dist/drift.d.ts +50 -0
- package/dist/drift.js +218 -0
- package/dist/env-file.d.ts +25 -1
- package/dist/env-file.js +64 -6
- package/dist/errors.d.ts +7 -1
- package/dist/example-config.d.ts +2 -0
- package/dist/example-config.js +3 -0
- package/dist/first-admin.d.ts +43 -0
- package/dist/first-admin.js +175 -0
- package/dist/formatter-ignore.d.ts +37 -0
- package/dist/formatter-ignore.js +344 -0
- package/dist/generator.d.ts +45 -3
- package/dist/generator.js +205 -15
- package/dist/guide.d.ts +1 -1
- package/dist/guide.js +42 -21
- package/dist/install-module.d.ts +29 -3
- package/dist/install-module.js +142 -13
- package/dist/install-plan.js +5 -3
- package/dist/layout.d.ts +12 -1
- package/dist/layout.js +19 -6
- package/dist/log-writer-control.d.ts +2 -0
- package/dist/log-writer-control.js +106 -27
- package/dist/log-writer.js +43 -10
- package/dist/logging-config.d.ts +8 -3
- package/dist/logging-config.js +19 -5
- package/dist/manifest.d.ts +29 -1
- package/dist/manifest.js +84 -6
- package/dist/merge-install-defaults.d.ts +0 -1
- package/dist/merge-install-defaults.js +13 -47
- package/dist/npm-client.d.ts +59 -4
- package/dist/npm-client.js +220 -16
- package/dist/npm-paths.js +18 -4
- package/dist/openapi.d.ts +27 -0
- package/dist/openapi.js +649 -0
- package/dist/option-fixes.d.ts +39 -0
- package/dist/option-fixes.js +130 -0
- package/dist/output.d.ts +1 -1
- package/dist/output.js +5 -5
- package/dist/plugin-lifecycle.d.ts +34 -2
- package/dist/plugin-lifecycle.js +206 -20
- package/dist/plugin-options.d.ts +19 -4
- package/dist/plugin-options.js +138 -6
- package/dist/plugins-command.js +44 -7
- package/dist/process-group.d.ts +39 -0
- package/dist/process-group.js +132 -0
- package/dist/project-guide.d.ts +19 -1
- package/dist/project-guide.js +119 -20
- package/dist/project-inspector.d.ts +15 -0
- package/dist/project-inspector.js +481 -33
- package/dist/project-state.d.ts +5 -0
- package/dist/project-state.js +8 -0
- package/dist/project.d.ts +203 -19
- package/dist/project.js +3016 -336
- package/dist/recipe-facts.d.ts +127 -0
- package/dist/recipe-facts.js +563 -0
- package/dist/recipe-login.d.ts +63 -0
- package/dist/recipe-login.js +464 -0
- package/dist/recipe-mail.d.ts +21 -0
- package/dist/recipe-mail.js +214 -0
- package/dist/recipe-members.d.ts +35 -0
- package/dist/recipe-members.js +178 -0
- package/dist/recipe-record.d.ts +82 -7
- package/dist/recipe-record.js +226 -22
- package/dist/recipe-reset.d.ts +2 -0
- package/dist/recipe-reset.js +117 -0
- package/dist/recipe-site.d.ts +48 -0
- package/dist/recipe-site.js +396 -0
- package/dist/recipe-step.d.ts +42 -0
- package/dist/recipe-step.js +89 -0
- package/dist/recipe.d.ts +56 -20
- package/dist/recipe.js +703 -131
- package/dist/registry.d.ts +98 -2
- package/dist/registry.js +171 -5
- package/dist/reloadable.d.ts +35 -0
- package/dist/reloadable.js +257 -0
- package/dist/server.d.ts +50 -4
- package/dist/server.js +790 -103
- package/dist/settings.d.ts +48 -0
- package/dist/settings.js +168 -0
- package/dist/site-auth.d.ts +39 -0
- package/dist/site-auth.js +195 -0
- package/dist/template-fixes.d.ts +26 -0
- package/dist/template-fixes.js +285 -0
- package/dist/test-reporter.d.ts +49 -0
- package/dist/test-reporter.js +190 -0
- package/dist/transaction.d.ts +67 -0
- package/dist/transaction.js +428 -46
- package/dist/verify-tests.d.ts +98 -0
- package/dist/verify-tests.js +449 -0
- package/dist/verify.d.ts +113 -4
- package/dist/verify.js +326 -28
- package/dist/view-choice.d.ts +58 -0
- package/dist/view-choice.js +131 -0
- package/dist/vite-watch.js +4 -2
- package/package.json +8 -6
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
// The admin login of `dokebi recipe blog --admin login` (D-0076 §6): the user code the recipe
|
|
2
|
+
// writes after `add @dokebi/auth`. The user table and its column names are the app's own
|
|
3
|
+
// (app_members: member_no, login_name, pass_phc, logout_key, is_active, role), so the generated
|
|
4
|
+
// extension class shows how @dokebi/auth maps any table; nothing here knows the plugin's
|
|
5
|
+
// internals. Every file is user code from the moment it is written. D-0115: a project an older
|
|
6
|
+
// recipe made keeps blog_authors and its names (recipeLoginNames), byte for byte.
|
|
7
|
+
//
|
|
8
|
+
// Files: the auth extension (replaces the untouched template of `add`), a migration and a seed
|
|
9
|
+
// of connection main for the database dialect, the login routes and two pages of the main app
|
|
10
|
+
// for the view (EJS or Inertia), and one line in src/apps/main/routes.ts. dokebi.yaml gets
|
|
11
|
+
// `options.first-admin` with the login name and a generated initial password the seed hashes
|
|
12
|
+
// (D-0115: a project an older recipe gave `options.blog-admin` keeps that name).
|
|
13
|
+
import { CliError } from './errors.js';
|
|
14
|
+
import { LEGACY_LOGIN_NAMES, LOGIN_NAMES, MEMBER_MODEL, memberExtension, memberFiles, USERS_TABLE, } from './recipe-members.js';
|
|
15
|
+
import { resetFiles } from './recipe-reset.js';
|
|
16
|
+
/**
|
|
17
|
+
* The dokebi.yaml section the first admin comes from (no plugin owns it). D-0115: named for what
|
|
18
|
+
* it holds (the first admin, like the seed first-admin.ts), not for one kind of app.
|
|
19
|
+
*/
|
|
20
|
+
export const ADMIN_SECTION = 'first-admin';
|
|
21
|
+
/** The name older recipes wrote (until D-0115); such projects keep it and keep working. */
|
|
22
|
+
export const LEGACY_ADMIN_SECTION = 'blog-admin';
|
|
23
|
+
/** Both names the CLI reads (config show, check, verify). */
|
|
24
|
+
export const ADMIN_SECTIONS = [ADMIN_SECTION, LEGACY_ADMIN_SECTION];
|
|
25
|
+
/** The key of the generated initial password in that section (never printed). */
|
|
26
|
+
export const ADMIN_PASSWORD_KEY = 'initialPassword';
|
|
27
|
+
/** The login name of the first admin. */
|
|
28
|
+
export const ADMIN_LOGIN = 'admin';
|
|
29
|
+
/** The table (collection) of the app's users, and the names of its code (D-0115). */
|
|
30
|
+
export { LEGACY_LOGIN_NAMES, LOGIN_NAMES, USERS_TABLE };
|
|
31
|
+
/** The one sentence of every failed login (never tell an unknown name from a wrong password). */
|
|
32
|
+
export const LOGIN_FAILED = 'Invalid login or password.';
|
|
33
|
+
/** Where a login goes, and the auth `homePath` (a signed-in user on the login page goes there). */
|
|
34
|
+
export const ADMIN_PATH = '/admin';
|
|
35
|
+
export const EXTENSION_FILE = 'src/infra/auth/extension.ts';
|
|
36
|
+
export const SEED_FILE = 'src/infra/db/seeds/main/first-admin.ts';
|
|
37
|
+
/**
|
|
38
|
+
* The section the recipe writes the first admin into (D-0115): the older name when the project
|
|
39
|
+
* already uses it (its section in dokebi.yaml, or the seed an older recipe wrote reads it), so a
|
|
40
|
+
* recipe applied again never splits one admin over two sections; otherwise ADMIN_SECTION.
|
|
41
|
+
*/
|
|
42
|
+
export function recipeAdminSection(options, seedText = '') {
|
|
43
|
+
if (Object.hasOwn(options, ADMIN_SECTION))
|
|
44
|
+
return ADMIN_SECTION;
|
|
45
|
+
if (Object.hasOwn(options, LEGACY_ADMIN_SECTION) ||
|
|
46
|
+
seedText.includes(`options['${LEGACY_ADMIN_SECTION}']`))
|
|
47
|
+
return LEGACY_ADMIN_SECTION;
|
|
48
|
+
return ADMIN_SECTION;
|
|
49
|
+
}
|
|
50
|
+
const MIGRATIONS = 'src/infra/db/migrations/main';
|
|
51
|
+
/**
|
|
52
|
+
* The names of the login code for this project (D-0115): an older recipe's blog_authors when the
|
|
53
|
+
* project has its migration, or a model, extension or seed that names that table; otherwise
|
|
54
|
+
* LOGIN_NAMES. So migrationPath and a recipe applied again never make a second table or a
|
|
55
|
+
* migration that conflicts with the one the project has.
|
|
56
|
+
*/
|
|
57
|
+
export function recipeLoginNames(migrations, sources = []) {
|
|
58
|
+
const legacy = LEGACY_LOGIN_NAMES;
|
|
59
|
+
if (migrations.some((name) => new RegExp(`^\\d{14}_${legacy.migration}\\.ts$`).test(name)) ||
|
|
60
|
+
sources.some((text) => text.includes(legacy.table)))
|
|
61
|
+
return legacy;
|
|
62
|
+
return LOGIN_NAMES;
|
|
63
|
+
}
|
|
64
|
+
/** The project files that tell an older recipe's login code (recipeLoginNames). */
|
|
65
|
+
export const LOGIN_NAME_SOURCES = [MEMBER_MODEL, EXTENSION_FILE, SEED_FILE];
|
|
66
|
+
const APP = 'src/apps/main';
|
|
67
|
+
/** `20261017093000`: the UTC time a new migration file is named after (dokebi-migrate make). */
|
|
68
|
+
export function migrationStamp(date) {
|
|
69
|
+
return date.toISOString().replace(/\D/g, '').slice(0, 14);
|
|
70
|
+
}
|
|
71
|
+
/** The migration file this project already has, else a new name for `now`. */
|
|
72
|
+
export function migrationPath(existing, now, names = recipeLoginNames(existing)) {
|
|
73
|
+
const found = existing.find((name) => new RegExp(`^\\d{14}_${names.migration}\\.ts$`).test(name));
|
|
74
|
+
return `${MIGRATIONS}/${found ?? `${migrationStamp(now)}_${names.migration}.ts`}`;
|
|
75
|
+
}
|
|
76
|
+
export const MIGRATIONS_DIRECTORY = MIGRATIONS;
|
|
77
|
+
const HEADER = (what) => `// ${what}, written by \`dokebi recipe blog --admin login\` (D-0076 §6); user code from now on.`;
|
|
78
|
+
// --- migration ------------------------------------------------------------------------------
|
|
79
|
+
const MIGRATION_INTRO = (dialect) => `${HEADER(`Migration of connection main (${dialect})`)}
|
|
80
|
+
// The app's own user table for @dokebi/auth: the column names are this app's, mapped in
|
|
81
|
+
// src/infra/auth/extension.ts. Run it with npx dokebi-migrate up (never at app start).
|
|
82
|
+
`;
|
|
83
|
+
function migration(dialect, n) {
|
|
84
|
+
if (dialect === 'postgres')
|
|
85
|
+
return `${MIGRATION_INTRO(dialect)}import type { PoolClient as Db } from 'pg';
|
|
86
|
+
|
|
87
|
+
export async function up(db: Db): Promise<void> {
|
|
88
|
+
await db.query(\`CREATE TABLE ${n.table} (
|
|
89
|
+
${n.id.padEnd(10)} serial PRIMARY KEY,
|
|
90
|
+
login_name text NOT NULL UNIQUE,
|
|
91
|
+
pass_phc text,
|
|
92
|
+
logout_key text NOT NULL,
|
|
93
|
+
is_active boolean NOT NULL DEFAULT true,
|
|
94
|
+
role text NOT NULL DEFAULT '${n.role}',
|
|
95
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
96
|
+
)\`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function down(db: Db): Promise<void> {
|
|
100
|
+
await db.query('DROP TABLE ${n.table}');
|
|
101
|
+
}
|
|
102
|
+
`;
|
|
103
|
+
if (dialect === 'mongodb')
|
|
104
|
+
return `${MIGRATION_INTRO(dialect)}// MongoDB has no transactions here: each step can run again.
|
|
105
|
+
import type { Db } from 'mongodb';
|
|
106
|
+
|
|
107
|
+
async function exists(db: Db): Promise<boolean> {
|
|
108
|
+
const found = await db.listCollections({ name: '${n.table}' }, { nameOnly: true }).toArray();
|
|
109
|
+
return found.length > 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function up(db: Db): Promise<void> {
|
|
113
|
+
if (!(await exists(db))) await db.createCollection('${n.table}');
|
|
114
|
+
await db
|
|
115
|
+
.collection('${n.table}')
|
|
116
|
+
.createIndex({ login_name: 1 }, { unique: true, name: 'login_name_unique' });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function down(db: Db): Promise<void> {
|
|
120
|
+
if (await exists(db)) await db.dropCollection('${n.table}');
|
|
121
|
+
}
|
|
122
|
+
`;
|
|
123
|
+
const type = dialect === 'mysql'
|
|
124
|
+
? "import type { PoolConnection as Db } from 'mysql2/promise';"
|
|
125
|
+
: "import type { PoolConnection as Db } from 'mariadb';";
|
|
126
|
+
return `${MIGRATION_INTRO(dialect)}// DDL commits at once: one statement, safe to run again (IF NOT EXISTS).
|
|
127
|
+
${type}
|
|
128
|
+
|
|
129
|
+
export async function up(db: Db): Promise<void> {
|
|
130
|
+
await db.query(\`CREATE TABLE IF NOT EXISTS ${n.table} (
|
|
131
|
+
${n.id.padEnd(10)} int AUTO_INCREMENT PRIMARY KEY,
|
|
132
|
+
login_name varchar(190) NOT NULL UNIQUE,
|
|
133
|
+
pass_phc varchar(255) NULL,
|
|
134
|
+
logout_key varchar(64) NOT NULL,
|
|
135
|
+
is_active boolean NOT NULL DEFAULT true,
|
|
136
|
+
role varchar(32) NOT NULL DEFAULT '${n.role}',
|
|
137
|
+
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
138
|
+
) CHARACTER SET utf8mb4\`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function down(db: Db): Promise<void> {
|
|
142
|
+
await db.query('DROP TABLE IF EXISTS ${n.table}');
|
|
143
|
+
}
|
|
144
|
+
`;
|
|
145
|
+
}
|
|
146
|
+
// --- seed -----------------------------------------------------------------------------------
|
|
147
|
+
const SEED_INTRO = (section) => `${HEADER('Seed of connection main: the first admin')}
|
|
148
|
+
// The login name and the initial password come from dokebi.yaml (options.${section}); the
|
|
149
|
+
// password is hashed with passwords.hash and never stored, logged or printed in clear. It runs
|
|
150
|
+
// again safely: an existing admin is left as it is, so a changed password is never reset, and
|
|
151
|
+
// once the admin exists the initial password may be removed from dokebi.yaml.
|
|
152
|
+
// Run it with npx dokebi-migrate seed (after npx dokebi-migrate up).
|
|
153
|
+
`;
|
|
154
|
+
const SEED_CONFIG = (section) => `/** options.${section} of dokebi.yaml (this file is src/infra/db/seeds/main/). */
|
|
155
|
+
async function firstAdmin(): Promise<{ login: string; password(): string }> {
|
|
156
|
+
const config = await loadConfig(new URL('../../../../../dokebi.yaml', import.meta.url));
|
|
157
|
+
const section: unknown = config.options['${section}'];
|
|
158
|
+
const values = (section && typeof section === 'object' ? section : {}) as Record<string, unknown>;
|
|
159
|
+
const login =
|
|
160
|
+
typeof values.login === 'string' && values.login.trim()
|
|
161
|
+
? values.login.trim().toLowerCase()
|
|
162
|
+
: '${ADMIN_LOGIN}';
|
|
163
|
+
return {
|
|
164
|
+
login,
|
|
165
|
+
// Read only when the admin does not exist yet.
|
|
166
|
+
password() {
|
|
167
|
+
const value = values.${ADMIN_PASSWORD_KEY};
|
|
168
|
+
if (typeof value !== 'string' || value.length < 12)
|
|
169
|
+
throw new Error(
|
|
170
|
+
'Set options.${section}.${ADMIN_PASSWORD_KEY} (12 characters or more) in dokebi.yaml: the first admin does not exist yet',
|
|
171
|
+
);
|
|
172
|
+
return value;
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
`;
|
|
177
|
+
function seed(dialect, section, n) {
|
|
178
|
+
const imports = `import { randomBytes } from 'node:crypto';
|
|
179
|
+
import { passwords } from '@dokebi/auth';
|
|
180
|
+
import { loadConfig } from '@dokebi/core';
|
|
181
|
+
`;
|
|
182
|
+
const body = dialect === 'postgres'
|
|
183
|
+
? `import type { PoolClient as Db } from 'pg';
|
|
184
|
+
|
|
185
|
+
${SEED_CONFIG(section)}
|
|
186
|
+
export async function run(db: Db): Promise<void> {
|
|
187
|
+
const admin = await firstAdmin();
|
|
188
|
+
const found = await db.query('SELECT 1 FROM ${n.table} WHERE login_name = $1', [admin.login]);
|
|
189
|
+
if (found.rowCount) return;
|
|
190
|
+
await db.query(
|
|
191
|
+
"INSERT INTO ${n.table} (login_name, pass_phc, logout_key, role) VALUES ($1, $2, $3, 'admin') ON CONFLICT (login_name) DO NOTHING",
|
|
192
|
+
[admin.login, await passwords.hash(admin.password()), randomBytes(16).toString('base64url')],
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
`
|
|
196
|
+
: dialect === 'mongodb'
|
|
197
|
+
? `import type { Db } from 'mongodb';
|
|
198
|
+
|
|
199
|
+
${SEED_CONFIG(section)}
|
|
200
|
+
export async function run(db: Db): Promise<void> {
|
|
201
|
+
const admin = await firstAdmin();
|
|
202
|
+
const ${n.rows} = db.collection('${n.table}');
|
|
203
|
+
if (await ${n.rows}.findOne({ login_name: admin.login }, { projection: { _id: 1 } })) return;
|
|
204
|
+
await ${n.rows}.updateOne(
|
|
205
|
+
{ login_name: admin.login },
|
|
206
|
+
{
|
|
207
|
+
$setOnInsert: {
|
|
208
|
+
login_name: admin.login,
|
|
209
|
+
pass_phc: await passwords.hash(admin.password()),
|
|
210
|
+
logout_key: randomBytes(16).toString('base64url'),
|
|
211
|
+
is_active: true,
|
|
212
|
+
role: 'admin',
|
|
213
|
+
created_at: new Date(),
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
{ upsert: true },
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
`
|
|
220
|
+
: `import type { PoolConnection as Db${dialect === 'mysql' ? ', RowDataPacket' : ''} } from '${dialect === 'mysql' ? 'mysql2/promise' : 'mariadb'}';
|
|
221
|
+
|
|
222
|
+
${SEED_CONFIG(section)}
|
|
223
|
+
export async function run(db: Db): Promise<void> {
|
|
224
|
+
const admin = await firstAdmin();
|
|
225
|
+
${dialect === 'mysql'
|
|
226
|
+
? `const [found] = await db.query<RowDataPacket[]>(
|
|
227
|
+
'SELECT 1 FROM ${n.table} WHERE login_name = ?',
|
|
228
|
+
[admin.login],
|
|
229
|
+
);`
|
|
230
|
+
: `const found = await db.query<unknown[]>('SELECT 1 FROM ${n.table} WHERE login_name = ?', [
|
|
231
|
+
admin.login,
|
|
232
|
+
]);`}
|
|
233
|
+
if (found.length) return;
|
|
234
|
+
await db.query(
|
|
235
|
+
"INSERT IGNORE INTO ${n.table} (login_name, pass_phc, logout_key, role) VALUES (?, ?, ?, 'admin')",
|
|
236
|
+
[admin.login, await passwords.hash(admin.password()), randomBytes(16).toString('base64url')],
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
`;
|
|
240
|
+
return `${SEED_INTRO(section)}${imports}${body}`;
|
|
241
|
+
}
|
|
242
|
+
// --- routes and pages -----------------------------------------------------------------------
|
|
243
|
+
function authController(view, n) {
|
|
244
|
+
return view === 'ejs'
|
|
245
|
+
? `// Login controller of the main app, written by \`dokebi recipe blog --admin login\` (D-0076 §6); user code from now on.
|
|
246
|
+
// @dokebi/auth checks the password, throttles and starts a new session (request.auth.attempt);
|
|
247
|
+
// this file only shows the pages. One sentence for every failed login: never tell an unknown login
|
|
248
|
+
// name from a wrong password. @dokebi/csrf checks every POST (the pages send the token).
|
|
249
|
+
import type { AppPage } from '@dokebi/app';
|
|
250
|
+
import type { FastifyRequest, FastifyReply } from 'fastify';
|
|
251
|
+
import { validationError } from '@dokebi/web';
|
|
252
|
+
|
|
253
|
+
/** The only failure sentence of the login form. */
|
|
254
|
+
export const LOGIN_FAILED = '${LOGIN_FAILED}';
|
|
255
|
+
|
|
256
|
+
export class AuthController {
|
|
257
|
+
loginPage(_request: FastifyRequest, reply: FastifyReply, page: AppPage) {
|
|
258
|
+
return page.view.reply(reply, 'login', {});
|
|
259
|
+
}
|
|
260
|
+
async login(request: FastifyRequest, reply: FastifyReply) {
|
|
261
|
+
const { login, password } = (request.body ?? {}) as Record<string, unknown>;
|
|
262
|
+
const result = await request.auth.attempt({
|
|
263
|
+
identity: typeof login === 'string' ? login : '',
|
|
264
|
+
password: typeof password === 'string' ? password : '',
|
|
265
|
+
});
|
|
266
|
+
// invalid, inactive or throttled: the form comes back (303) with the same sentence.
|
|
267
|
+
if (!result.ok) throw validationError({ login: LOGIN_FAILED });
|
|
268
|
+
return reply.redirect(await request.auth.takeReturnPath('${ADMIN_PATH}'), 303);
|
|
269
|
+
}
|
|
270
|
+
async logout(request: FastifyRequest, reply: FastifyReply) {
|
|
271
|
+
await request.auth.logout();
|
|
272
|
+
return reply.redirect('/', 303);
|
|
273
|
+
}
|
|
274
|
+
async dashboard(request: FastifyRequest, reply: FastifyReply, page: AppPage) {
|
|
275
|
+
return page.view.reply(reply, 'admin', { canWrite: await request.auth.can('${n.ability}') });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
`
|
|
279
|
+
: `// Login controller of the main app, written by \`dokebi recipe blog --admin login\` (D-0076 §6); user code from now on.
|
|
280
|
+
// @dokebi/auth checks the password, throttles and starts a new session (request.auth.attempt);
|
|
281
|
+
// this file only shows the pages. One sentence for every failed login: never tell an unknown login
|
|
282
|
+
// name from a wrong password. @dokebi/csrf checks every POST (the pages send the token).
|
|
283
|
+
import type { AppPage, AppInertia } from '@dokebi/app';
|
|
284
|
+
import type { FastifyRequest, FastifyReply } from 'fastify';
|
|
285
|
+
import { validationError } from '@dokebi/web';
|
|
286
|
+
|
|
287
|
+
/** The only failure sentence of the login form. */
|
|
288
|
+
export const LOGIN_FAILED = '${LOGIN_FAILED}';
|
|
289
|
+
|
|
290
|
+
function inertia(page: AppPage): AppInertia {
|
|
291
|
+
if (!page.inertia) throw new Error('Add @dokebi/template-inertia for this app');
|
|
292
|
+
return page.inertia;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export class AuthController {
|
|
296
|
+
loginPage(request: FastifyRequest, reply: FastifyReply, page: AppPage) {
|
|
297
|
+
return inertia(page).render(request, reply, 'Login');
|
|
298
|
+
}
|
|
299
|
+
async login(request: FastifyRequest, reply: FastifyReply, page: AppPage) {
|
|
300
|
+
const { login, password } = (request.body ?? {}) as Record<string, unknown>;
|
|
301
|
+
const result = await request.auth.attempt({
|
|
302
|
+
identity: typeof login === 'string' ? login : '',
|
|
303
|
+
password: typeof password === 'string' ? password : '',
|
|
304
|
+
});
|
|
305
|
+
// invalid, inactive or throttled: form.errors.login gets the same sentence.
|
|
306
|
+
if (!result.ok) throw validationError({ login: LOGIN_FAILED });
|
|
307
|
+
return inertia(page).redirect(request, reply, await request.auth.takeReturnPath('${ADMIN_PATH}'));
|
|
308
|
+
}
|
|
309
|
+
async logout(request: FastifyRequest, reply: FastifyReply, page: AppPage) {
|
|
310
|
+
await request.auth.logout();
|
|
311
|
+
return inertia(page).redirect(request, reply, '/');
|
|
312
|
+
}
|
|
313
|
+
async dashboard(request: FastifyRequest, reply: FastifyReply, page: AppPage) {
|
|
314
|
+
return inertia(page).render(request, reply, 'Admin', {
|
|
315
|
+
canWrite: await request.auth.can('${n.ability}'),
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
`;
|
|
320
|
+
}
|
|
321
|
+
function routes(view) {
|
|
322
|
+
const args = view === 'ejs' ? 'request, reply' : 'request, reply, page';
|
|
323
|
+
return `import type { AppRouter } from '@dokebi/app';
|
|
324
|
+
import { AuthController } from '../controllers/auth.ts';
|
|
325
|
+
|
|
326
|
+
export function loginRoutes(route: AppRouter): void {
|
|
327
|
+
const controller = new AuthController();
|
|
328
|
+
route.get('/login', (_request, reply, page) => controller.loginPage(_request, reply, page), {
|
|
329
|
+
config: { auth: 'guest' },
|
|
330
|
+
});
|
|
331
|
+
route.post('/login', (${args}) => controller.login(${args}), { config: { auth: 'guest' } });
|
|
332
|
+
route.post('/logout', (${args}) => controller.logout(${args}), { config: { auth: false } });
|
|
333
|
+
route.get('/admin', (request, reply, page) => controller.dashboard(request, reply, page), { config: { auth: 'required' } });
|
|
334
|
+
}
|
|
335
|
+
`;
|
|
336
|
+
}
|
|
337
|
+
const LOGIN_EJS = `<main>
|
|
338
|
+
<h1>Sign in</h1>
|
|
339
|
+
<form method="post" action="/login">
|
|
340
|
+
<%- data.csrf.field %>
|
|
341
|
+
<% if (data.errors.login) { %><p role="alert"><%= data.errors.login %></p><% } %>
|
|
342
|
+
<label>Login <input name="login" value="<%= data.old.login ?? '' %>" autocomplete="username" required></label>
|
|
343
|
+
<label>Password <input name="password" type="password" autocomplete="current-password" required></label>
|
|
344
|
+
<button>Sign in</button>
|
|
345
|
+
</form>
|
|
346
|
+
</main>
|
|
347
|
+
`;
|
|
348
|
+
const ADMIN_EJS = (n) => `<main>
|
|
349
|
+
<h1>Admin</h1>
|
|
350
|
+
<p>Signed in as <%= data.auth.user?.login %> (<%= data.auth.user?.role %>).</p>
|
|
351
|
+
<% if (data.canWrite) { %><p>${n.mayWrite}</p><% } %>
|
|
352
|
+
<form method="post" action="/logout">
|
|
353
|
+
<%- data.csrf.field %>
|
|
354
|
+
<button>Sign out</button>
|
|
355
|
+
</form>
|
|
356
|
+
</main>
|
|
357
|
+
`;
|
|
358
|
+
const LOGIN_VUE = `<script setup lang="ts">
|
|
359
|
+
import { useForm } from '@inertiajs/vue3';
|
|
360
|
+
|
|
361
|
+
// @dokebi/csrf: Inertia sends the XSRF-TOKEN cookie as the X-XSRF-TOKEN header by itself.
|
|
362
|
+
const form = useForm({ login: '', password: '' });
|
|
363
|
+
function submit() {
|
|
364
|
+
// A failed login changes only the errors; a successful one opens another page.
|
|
365
|
+
form.post('/login', { only: ['errors'], onFinish: () => form.reset('password') });
|
|
366
|
+
}
|
|
367
|
+
</script>
|
|
368
|
+
<template>
|
|
369
|
+
<h1>Sign in</h1>
|
|
370
|
+
<form @submit.prevent="submit">
|
|
371
|
+
<p v-if="form.errors.login" role="alert">{{ form.errors.login }}</p>
|
|
372
|
+
<label>Login <input v-model="form.login" autocomplete="username" required /></label>
|
|
373
|
+
<label>Password <input v-model="form.password" type="password" autocomplete="current-password" required /></label>
|
|
374
|
+
<button :disabled="form.processing">Sign in</button>
|
|
375
|
+
</form>
|
|
376
|
+
</template>
|
|
377
|
+
`;
|
|
378
|
+
const ADMIN_VUE = (n) => `<script setup lang="ts">
|
|
379
|
+
import { router, usePage } from '@inertiajs/vue3';
|
|
380
|
+
|
|
381
|
+
defineProps<{ canWrite: boolean }>();
|
|
382
|
+
// @dokebi/auth shares the signed-in user as \`auth\`; typed here, so the template is checked.
|
|
383
|
+
const page = usePage<{ auth?: { user?: { login: string; role: string } } }>();
|
|
384
|
+
function signOut() {
|
|
385
|
+
// dokebi-check: allow inertia-form-only signing out opens another page with all its props
|
|
386
|
+
router.post('/logout');
|
|
387
|
+
}
|
|
388
|
+
</script>
|
|
389
|
+
<template>
|
|
390
|
+
<h1>Admin</h1>
|
|
391
|
+
<p>Signed in as {{ page.props.auth?.user?.login }} ({{ page.props.auth?.user?.role }}).</p>
|
|
392
|
+
<p v-if="canWrite">${n.mayWrite}</p>
|
|
393
|
+
<button type="button" @click="signOut">Sign out</button>
|
|
394
|
+
</template>
|
|
395
|
+
`;
|
|
396
|
+
/** Every file of the admin login for a dialect and view (the migration path decided by the caller). */
|
|
397
|
+
export function loginFiles(dialect, view, migrationFile, mail = false,
|
|
398
|
+
/** The section of dokebi.yaml the seed reads (recipeAdminSection). */
|
|
399
|
+
section = ADMIN_SECTION,
|
|
400
|
+
/** The table and code names (recipeLoginNames, D-0115). */
|
|
401
|
+
names = LOGIN_NAMES) {
|
|
402
|
+
const pages = view === 'ejs'
|
|
403
|
+
? [
|
|
404
|
+
{
|
|
405
|
+
path: `${APP}/views/default/login.ejs`,
|
|
406
|
+
content: mail
|
|
407
|
+
? LOGIN_EJS.replace('</form>', '</form><p><a href="/password/forgot">Forgot your password?</a></p>')
|
|
408
|
+
: LOGIN_EJS,
|
|
409
|
+
},
|
|
410
|
+
{ path: `${APP}/views/default/admin.ejs`, content: ADMIN_EJS(names) },
|
|
411
|
+
]
|
|
412
|
+
: [
|
|
413
|
+
{
|
|
414
|
+
path: `${APP}/views/default/Pages/Login.vue`,
|
|
415
|
+
content: mail
|
|
416
|
+
? LOGIN_VUE.replace('</form>', '</form><p><a href="/password/forgot">Forgot your password?</a></p>')
|
|
417
|
+
: LOGIN_VUE,
|
|
418
|
+
},
|
|
419
|
+
{ path: `${APP}/views/default/Pages/Admin.vue`, content: ADMIN_VUE(names) },
|
|
420
|
+
];
|
|
421
|
+
return [
|
|
422
|
+
{
|
|
423
|
+
path: EXTENSION_FILE,
|
|
424
|
+
content: memberExtension(dialect, names),
|
|
425
|
+
replaceTemplate: true,
|
|
426
|
+
},
|
|
427
|
+
...memberFiles(dialect, names),
|
|
428
|
+
{ path: migrationFile, content: migration(dialect, names) },
|
|
429
|
+
{ path: SEED_FILE, content: seed(dialect, section, names) },
|
|
430
|
+
{
|
|
431
|
+
path: `${APP}/routes/auth.ts`,
|
|
432
|
+
content: mail
|
|
433
|
+
? `import { passwordResetRoutes } from './password-reset.ts';\n${routes(view)}`.replace('export function loginRoutes(route: AppRouter): void {', 'export function loginRoutes(route: AppRouter): void {\n passwordResetRoutes(route);')
|
|
434
|
+
: routes(view),
|
|
435
|
+
},
|
|
436
|
+
{ path: `${APP}/controllers/auth.ts`, content: authController(view, names) },
|
|
437
|
+
...pages,
|
|
438
|
+
...(mail ? resetFiles() : []),
|
|
439
|
+
];
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* src/apps/main/routes.ts with the login routes added: an import after the last import and a call
|
|
443
|
+
* at the top of `routes(route, …)`. Unchanged when already there; RECIPE_FILE_CONFLICT when the
|
|
444
|
+
* file has no `export function routes(<router>…)` to add it to.
|
|
445
|
+
*/
|
|
446
|
+
export function withLoginRoutes(text) {
|
|
447
|
+
if (/\bloginRoutes\(/.test(text))
|
|
448
|
+
return text;
|
|
449
|
+
const start = /export function routes\(\s*(\w+)[^)]*\)[^{]*\{\n/.exec(text);
|
|
450
|
+
const imports = [...text.matchAll(/^import [^;]*;\n/gm)].at(-1);
|
|
451
|
+
if (!start || !imports)
|
|
452
|
+
throw new CliError('RECIPE_FILE_CONFLICT', `${APP}/routes.ts has no export function routes(route, …) to add the login routes to`, {
|
|
453
|
+
details: {
|
|
454
|
+
path: `${APP}/routes.ts`,
|
|
455
|
+
fix: `call loginRoutes(route) from ${APP}/routes/auth.ts in your routes yourself`,
|
|
456
|
+
},
|
|
457
|
+
});
|
|
458
|
+
const call = ` // Login, sign-out and the admin page (dokebi recipe blog --admin login).\n loginRoutes(${start[1]});\n`;
|
|
459
|
+
const at = (start.index ?? 0) + start[0].length;
|
|
460
|
+
const withCall = `${text.slice(0, at)}${call}${text.slice(at)}`;
|
|
461
|
+
const after = (imports.index ?? 0) + imports[0].length;
|
|
462
|
+
return `${withCall.slice(0, after)}import { loginRoutes } from './routes/auth.ts';\n${withCall.slice(after)}`;
|
|
463
|
+
}
|
|
464
|
+
export const ROUTES_FILE = `${APP}/routes.ts`;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Project } from './project.js';
|
|
2
|
+
import type { ProjectView, Step } from './recipe-step.js';
|
|
3
|
+
export declare const MAIL_COMPOSE = "compose.mail.yaml";
|
|
4
|
+
export interface MailPorts {
|
|
5
|
+
smtp: number;
|
|
6
|
+
inbox: number;
|
|
7
|
+
nats: number;
|
|
8
|
+
}
|
|
9
|
+
/** Omitting ports reproduces the legacy template for safe migration only. */
|
|
10
|
+
export declare function mailCompose(root: string, ports?: MailPorts): string;
|
|
11
|
+
/** Accept only an untouched generated template; never infer ownership from a filename. */
|
|
12
|
+
export declare function mailComposePorts(root: string, source: string): MailPorts | undefined;
|
|
13
|
+
export declare function checkMailCompose(root: string): Promise<void>;
|
|
14
|
+
/** Reserve three distinct loopback ports together, then persist them before Docker starts. */
|
|
15
|
+
export declare function allocateMailPorts(): Promise<MailPorts>;
|
|
16
|
+
export declare function mailSteps(input: {
|
|
17
|
+
root: string;
|
|
18
|
+
project: Project;
|
|
19
|
+
choice: string | undefined;
|
|
20
|
+
now(): Promise<ProjectView | undefined>;
|
|
21
|
+
}): Step[];
|