umpordez 0.0.2 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +16 -0
- package/build.mjs +409 -0
- package/cli.mjs +87 -109
- package/create.mjs +334 -0
- package/package.json +2 -7
- package/template/.claude/settings.local.json +20 -0
- package/template/.editorconfig +11 -0
- package/template/.github/copilot-instructions.md +186 -0
- package/template/.nvimrc +7 -0
- package/template/.vscode/extensions.json +9 -0
- package/template/.vscode/settings.json +28 -0
- package/template/AGENTS.md +305 -0
- package/template/CLAUDE.md +140 -0
- package/template/architecture.md +518 -0
- package/template/code.md +211 -0
- package/template/dev.sh +45 -0
- package/template/install.sh +47 -0
- package/template/misc/nginx.conf +151 -0
- package/template/misc/systemd/{{PROJECT_SLUG}}-api.service +31 -0
- package/template/misc/systemd/{{PROJECT_SLUG}}-site-api.service +31 -0
- package/template/misc/systemd/{{PROJECT_SLUG}}-site.service +30 -0
- package/template/seed.sh +23 -0
- package/template/server/.env.sample +40 -0
- package/template/server/.prettierrc +4 -0
- package/template/server/apps/api/app.ts +42 -0
- package/template/server/apps/api/routes/account.ts +98 -0
- package/template/server/apps/api/routes/admin.ts +80 -0
- package/template/server/apps/api/routes/auth.ts +222 -0
- package/template/server/apps/api/routes/files.ts +89 -0
- package/template/server/apps/api/routes/index.ts +19 -0
- package/template/server/apps/api/routes/user.ts +17 -0
- package/template/server/apps/shared/middlewares/demand-account-access.ts +52 -0
- package/template/server/apps/shared/middlewares/demand-admin-user.ts +25 -0
- package/template/server/apps/shared/middlewares/demand-user.ts +17 -0
- package/template/server/apps/shared/middlewares/error-handler.ts +28 -0
- package/template/server/apps/shared/middlewares/request-logger.ts +40 -0
- package/template/server/apps/shared/middlewares/try-set-user-by-token.ts +26 -0
- package/template/server/apps/shared/utils.ts +71 -0
- package/template/server/apps/site-api/app.ts +42 -0
- package/template/server/apps/site-api/routes/index.ts +10 -0
- package/template/server/apps/site-api/routes/public.ts +25 -0
- package/template/server/console/index.ts +26 -0
- package/template/server/console/runner.ts +67 -0
- package/template/server/console/tasks/example.ts +29 -0
- package/template/server/console/tasks/seed-admin.ts +71 -0
- package/template/server/core/context.ts +49 -0
- package/template/server/core/db.ts +12 -0
- package/template/server/core/email.ts +87 -0
- package/template/server/core/errors.ts +44 -0
- package/template/server/core/knexfile.ts +28 -0
- package/template/server/core/logger.ts +60 -0
- package/template/server/core/models/account.ts +319 -0
- package/template/server/core/models/auth.ts +317 -0
- package/template/server/core/models/base.ts +19 -0
- package/template/server/core/models/user.ts +343 -0
- package/template/server/core/s3.ts +183 -0
- package/template/server/emails/_styles.css +5 -0
- package/template/server/emails/_template.html +28 -0
- package/template/server/emails/accountWelcome.html +4 -0
- package/template/server/emails/forgetPassword.html +5 -0
- package/template/server/eslint.config.js +16 -0
- package/template/server/knex.sh +4 -0
- package/template/server/migrations/20260208000000_initial-schema.ts +56 -0
- package/template/server/migrations/20260208000001_seed-admin.ts +42 -0
- package/template/server/migrations/20260208000002_add-user-avatar.ts +13 -0
- package/template/server/package.json +58 -0
- package/template/server/tsconfig.json +27 -0
- package/template/server/types/api.ts +20 -0
- package/template/server/types/express.d.ts +35 -0
- package/template/ui/admin/.prettierrc +4 -0
- package/template/ui/admin/components.json +20 -0
- package/template/ui/admin/eslint.config.js +16 -0
- package/template/ui/admin/index.html +12 -0
- package/template/ui/admin/package.json +57 -0
- package/template/ui/admin/postcss.config.js +6 -0
- package/template/ui/admin/src/app.tsx +13 -0
- package/template/ui/admin/src/components/nav-user.tsx +95 -0
- package/template/ui/admin/src/components/theme-provider.tsx +65 -0
- package/template/ui/admin/src/components/theme-toggle.tsx +17 -0
- package/template/ui/admin/src/components/ui/badge.tsx +36 -0
- package/template/ui/admin/src/components/ui/button.tsx +56 -0
- package/template/ui/admin/src/components/ui/calendar.tsx +68 -0
- package/template/ui/admin/src/components/ui/card.tsx +79 -0
- package/template/ui/admin/src/components/ui/checkbox.tsx +28 -0
- package/template/ui/admin/src/components/ui/date-picker.tsx +78 -0
- package/template/ui/admin/src/components/ui/date-range-picker.tsx +208 -0
- package/template/ui/admin/src/components/ui/dialog.tsx +121 -0
- package/template/ui/admin/src/components/ui/dropdown-menu.tsx +200 -0
- package/template/ui/admin/src/components/ui/input.tsx +22 -0
- package/template/ui/admin/src/components/ui/label.tsx +24 -0
- package/template/ui/admin/src/components/ui/popover.tsx +29 -0
- package/template/ui/admin/src/components/ui/select.tsx +158 -0
- package/template/ui/admin/src/components/ui/separator.tsx +29 -0
- package/template/ui/admin/src/components/ui/sheet.tsx +136 -0
- package/template/ui/admin/src/components/ui/sidebar.tsx +759 -0
- package/template/ui/admin/src/components/ui/skeleton.tsx +15 -0
- package/template/ui/admin/src/components/ui/sonner.tsx +26 -0
- package/template/ui/admin/src/components/ui/switch.tsx +27 -0
- package/template/ui/admin/src/components/ui/table.tsx +117 -0
- package/template/ui/admin/src/components/ui/tabs.tsx +53 -0
- package/template/ui/admin/src/components/ui/textarea.tsx +22 -0
- package/template/ui/admin/src/components/ui/tooltip.tsx +28 -0
- package/template/ui/admin/src/hooks/queries/use-accounts.ts +68 -0
- package/template/ui/admin/src/hooks/queries/use-auth.ts +61 -0
- package/template/ui/admin/src/hooks/queries/use-dashboard.ts +70 -0
- package/template/ui/admin/src/hooks/queries/use-members.ts +90 -0
- package/template/ui/admin/src/hooks/queries/use-users.ts +99 -0
- package/template/ui/admin/src/hooks/use-confirm.tsx +92 -0
- package/template/ui/admin/src/hooks/use-mobile.tsx +22 -0
- package/template/ui/admin/src/hooks/use-sidebar.tsx +4 -0
- package/template/ui/admin/src/hooks/use-user.tsx +86 -0
- package/template/ui/admin/src/index.css +22 -0
- package/template/ui/admin/src/layouts/account.tsx +278 -0
- package/template/ui/admin/src/layouts/admin.tsx +182 -0
- package/template/ui/admin/src/layouts/public.tsx +9 -0
- package/template/ui/admin/src/layouts/user.tsx +107 -0
- package/template/ui/admin/src/lib/config.ts +9 -0
- package/template/ui/admin/src/lib/fetch-api.ts +101 -0
- package/template/ui/admin/src/lib/query-client.ts +12 -0
- package/template/ui/admin/src/lib/query-keys.ts +44 -0
- package/template/ui/admin/src/lib/utils.ts +24 -0
- package/template/ui/admin/src/main.tsx +17 -0
- package/template/ui/admin/src/pages/account/dashboard.tsx +128 -0
- package/template/ui/admin/src/pages/account/members.tsx +414 -0
- package/template/ui/admin/src/pages/account/settings.tsx +210 -0
- package/template/ui/admin/src/pages/admin/accounts.tsx +164 -0
- package/template/ui/admin/src/pages/admin/dashboard.tsx +243 -0
- package/template/ui/admin/src/pages/admin/users.tsx +395 -0
- package/template/ui/admin/src/pages/public/error.tsx +24 -0
- package/template/ui/admin/src/pages/public/forgot-password.tsx +74 -0
- package/template/ui/admin/src/pages/public/login.tsx +102 -0
- package/template/ui/admin/src/pages/public/reset-password.tsx +106 -0
- package/template/ui/admin/src/pages/public/signup.tsx +118 -0
- package/template/ui/admin/src/pages/user/profile.tsx +313 -0
- package/template/ui/admin/src/pages/user/select-account.tsx +114 -0
- package/template/ui/admin/src/router.tsx +71 -0
- package/template/ui/admin/tailwind.config.ts +71 -0
- package/template/ui/admin/tsconfig.json +24 -0
- package/template/ui/admin/vite.config.ts +15 -0
- package/template/ui/site/.env.sample +3 -0
- package/template/ui/site/.prettierrc +4 -0
- package/template/ui/site/app.ts +50 -0
- package/template/ui/site/eslint.config.js +16 -0
- package/template/ui/site/package.json +33 -0
- package/template/ui/site/styles/input.css +3 -0
- package/template/ui/site/tailwind.config.js +8 -0
- package/template/ui/site/tsconfig.json +15 -0
- package/template/ui/site/views/pages/home.ejs +305 -0
- package/template/ui/site/views/partials/footer.ejs +44 -0
- package/template/ui/site/views/partials/header.ejs +83 -0
- package/template-variables.mjs +186 -0
package/create.mjs
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import inquirer from 'inquirer';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { execSync } from 'node:child_process';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { buildReplacements } from './template-variables.mjs';
|
|
8
|
+
|
|
9
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const TEMPLATE_DIR = path.join(__dirname, 'template');
|
|
11
|
+
|
|
12
|
+
// umpordez.com design tokens
|
|
13
|
+
const green = chalk.hex('#02e027');
|
|
14
|
+
const cyan = chalk.hex('#00fff9');
|
|
15
|
+
const red = chalk.hex('#ff3c00');
|
|
16
|
+
const dim = chalk.hex('#a0a0a0');
|
|
17
|
+
|
|
18
|
+
const BINARY_EXTENSIONS = new Set([
|
|
19
|
+
'.png', '.jpg', '.jpeg', '.gif', '.ico', '.svg',
|
|
20
|
+
'.woff', '.woff2', '.ttf', '.eot', '.otf',
|
|
21
|
+
'.zip', '.tar', '.gz', '.br',
|
|
22
|
+
'.pdf', '.mp3', '.mp4', '.webm', '.ogg',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
function slugify(text) {
|
|
26
|
+
return text
|
|
27
|
+
.toLowerCase()
|
|
28
|
+
.normalize('NFD')
|
|
29
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
30
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
31
|
+
.replace(/^-|-$/g, '');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function toDbName(slug) {
|
|
35
|
+
return slug.replace(/-/g, '_');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function promptProjectDetails() {
|
|
39
|
+
console.log(green.bold(' Project Setup'));
|
|
40
|
+
console.log(chalk.hex('#2c2c2c')(' ' + '\u2500'.repeat(40)));
|
|
41
|
+
console.log('');
|
|
42
|
+
|
|
43
|
+
const { name } = await inquirer.prompt([{
|
|
44
|
+
type: 'input',
|
|
45
|
+
name: 'name',
|
|
46
|
+
message: 'Project name (e.g. "Minha Empresa"):',
|
|
47
|
+
validate: (v) => v.trim() ? true : 'Project name is required',
|
|
48
|
+
}]);
|
|
49
|
+
|
|
50
|
+
const suggestedSlug = slugify(name);
|
|
51
|
+
const suggestedDb = toDbName(suggestedSlug);
|
|
52
|
+
|
|
53
|
+
const answers = await inquirer.prompt([
|
|
54
|
+
{
|
|
55
|
+
type: 'input',
|
|
56
|
+
name: 'slug',
|
|
57
|
+
message: 'Project slug:',
|
|
58
|
+
default: suggestedSlug,
|
|
59
|
+
validate: (v) => /^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(v) || v.length === 1 && /^[a-z0-9]$/.test(v)
|
|
60
|
+
? true
|
|
61
|
+
: 'Slug must be lowercase alphanumeric with hyphens (no leading/trailing hyphens)',
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
type: 'input',
|
|
65
|
+
name: 'description',
|
|
66
|
+
message: 'Short description:',
|
|
67
|
+
default: '',
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
type: 'input',
|
|
71
|
+
name: 'domain',
|
|
72
|
+
message: 'Domain (e.g. "minhaempresa.com.br"):',
|
|
73
|
+
validate: (v) => v.trim() ? true : 'Domain is required',
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
type: 'input',
|
|
77
|
+
name: 'dbName',
|
|
78
|
+
message: 'Database name:',
|
|
79
|
+
default: suggestedDb,
|
|
80
|
+
validate: (v) => /^[a-z_][a-z0-9_]*$/.test(v)
|
|
81
|
+
? true
|
|
82
|
+
: 'Database name must be lowercase with underscores',
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
type: 'input',
|
|
86
|
+
name: 'outputDir',
|
|
87
|
+
message: 'Output directory:',
|
|
88
|
+
default: `./${suggestedSlug}`,
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
type: 'input',
|
|
92
|
+
name: 'adminEmail',
|
|
93
|
+
message: 'Admin email:',
|
|
94
|
+
default: 'admin@localhost.com',
|
|
95
|
+
validate: (v) => v.includes('@') ? true : 'Must be a valid email',
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
type: 'password',
|
|
99
|
+
name: 'adminPassword',
|
|
100
|
+
message: 'Admin password:',
|
|
101
|
+
validate: (v) => v.length >= 6 ? true : 'Password must be at least 6 characters',
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
type: 'input',
|
|
105
|
+
name: 'apiPort',
|
|
106
|
+
message: 'Admin API port:',
|
|
107
|
+
default: '4000',
|
|
108
|
+
validate: (v) => /^\d+$/.test(v) && +v > 0 && +v < 65536
|
|
109
|
+
? true
|
|
110
|
+
: 'Must be a valid port number',
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
type: 'input',
|
|
114
|
+
name: 'siteApiPort',
|
|
115
|
+
message: 'Site API port:',
|
|
116
|
+
default: '4001',
|
|
117
|
+
validate: (v) => /^\d+$/.test(v) && +v > 0 && +v < 65536
|
|
118
|
+
? true
|
|
119
|
+
: 'Must be a valid port number',
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
type: 'input',
|
|
123
|
+
name: 'adminUiPort',
|
|
124
|
+
message: 'Admin panel port:',
|
|
125
|
+
default: '5173',
|
|
126
|
+
validate: (v) => /^\d+$/.test(v) && +v > 0 && +v < 65536
|
|
127
|
+
? true
|
|
128
|
+
: 'Must be a valid port number',
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
type: 'input',
|
|
132
|
+
name: 'sitePort',
|
|
133
|
+
message: 'Public site port:',
|
|
134
|
+
default: '3000',
|
|
135
|
+
validate: (v) => /^\d+$/.test(v) && +v > 0 && +v < 65536
|
|
136
|
+
? true
|
|
137
|
+
: 'Must be a valid port number',
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
type: 'input',
|
|
141
|
+
name: 'primaryColor',
|
|
142
|
+
message: 'Primary color (hex):',
|
|
143
|
+
default: '#2563eb',
|
|
144
|
+
validate: (v) => /^#?([0-9a-fA-F]{6})$/.test(v.trim())
|
|
145
|
+
? true
|
|
146
|
+
: 'Must be a valid hex color (e.g. #2563eb)',
|
|
147
|
+
filter: (v) => v.trim().startsWith('#') ? v.trim() : `#${v.trim()}`,
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
type: 'list',
|
|
151
|
+
name: 'defaultTheme',
|
|
152
|
+
message: 'Default theme:',
|
|
153
|
+
choices: [
|
|
154
|
+
{ name: 'Light', value: 'light' },
|
|
155
|
+
{ name: 'Dark', value: 'dark' },
|
|
156
|
+
{ name: 'System (follows OS preference)', value: 'system' },
|
|
157
|
+
],
|
|
158
|
+
default: 'light',
|
|
159
|
+
},
|
|
160
|
+
]);
|
|
161
|
+
|
|
162
|
+
return { name: name.trim(), ...answers };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function showSummary(details) {
|
|
166
|
+
console.log('');
|
|
167
|
+
console.log(green.bold(' Project Summary'));
|
|
168
|
+
console.log(chalk.hex('#2c2c2c')(' ' + '\u2500'.repeat(40)));
|
|
169
|
+
console.log('');
|
|
170
|
+
console.log(` ${chalk.bold('Name:')} ${cyan(details.name)}`);
|
|
171
|
+
console.log(` ${chalk.bold('Slug:')} ${details.slug}`);
|
|
172
|
+
console.log(` ${chalk.bold('Description:')} ${details.description || dim('(none)')}`);
|
|
173
|
+
console.log(` ${chalk.bold('Domain:')} ${cyan(details.domain)}`);
|
|
174
|
+
console.log(` ${chalk.bold('Database:')} ${details.dbName}`);
|
|
175
|
+
console.log(` ${chalk.bold('Output:')} ${details.outputDir}`);
|
|
176
|
+
console.log(` ${chalk.bold('Admin:')} ${details.adminEmail}`);
|
|
177
|
+
console.log(` ${chalk.bold('Ports:')} API ${cyan(details.apiPort)} | Site API ${cyan(details.siteApiPort)} | Admin ${cyan(details.adminUiPort)} | Site ${cyan(details.sitePort)}`);
|
|
178
|
+
console.log(` ${chalk.bold('Color:')} ${chalk.hex(details.primaryColor)('\u2588\u2588')} ${details.primaryColor}`);
|
|
179
|
+
console.log(` ${chalk.bold('Theme:')} ${details.defaultTheme}`);
|
|
180
|
+
console.log('');
|
|
181
|
+
console.log(dim(' What gets generated:'));
|
|
182
|
+
console.log(dim(` ${green('>')} Admin API (Express + TypeScript)`));
|
|
183
|
+
console.log(dim(` ${green('>')} Site API (Express + TypeScript)`));
|
|
184
|
+
console.log(dim(` ${green('>')} React admin panel (Vite + shadcn/ui)`));
|
|
185
|
+
console.log(dim(` ${green('>')} Public site (EJS + Tailwind)`));
|
|
186
|
+
console.log(dim(` ${green('>')} PostgreSQL migrations + seed`));
|
|
187
|
+
console.log(dim(` ${green('>')} S3 file uploads`));
|
|
188
|
+
console.log(dim(` ${green('>')} Console task runner`));
|
|
189
|
+
console.log(dim(` ${green('>')} Production build system`));
|
|
190
|
+
console.log('');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function getAllFiles(dir) {
|
|
194
|
+
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
195
|
+
const files = [];
|
|
196
|
+
|
|
197
|
+
for (const entry of entries) {
|
|
198
|
+
const fullPath = path.join(dir, entry.name);
|
|
199
|
+
if (entry.isDirectory()) {
|
|
200
|
+
files.push(...await getAllFiles(fullPath));
|
|
201
|
+
} else {
|
|
202
|
+
files.push(fullPath);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return files;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function isBinaryFile(filePath) {
|
|
210
|
+
return BINARY_EXTENSIONS.has(path.extname(filePath).toLowerCase());
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function replacePlaceholders(content, replacements) {
|
|
214
|
+
let result = content;
|
|
215
|
+
for (const [placeholder, value] of Object.entries(replacements)) {
|
|
216
|
+
result = result.replaceAll(placeholder, value);
|
|
217
|
+
}
|
|
218
|
+
return result;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function createProject() {
|
|
222
|
+
const details = await promptProjectDetails();
|
|
223
|
+
|
|
224
|
+
showSummary(details);
|
|
225
|
+
|
|
226
|
+
const { confirm } = await inquirer.prompt([{
|
|
227
|
+
type: 'confirm',
|
|
228
|
+
name: 'confirm',
|
|
229
|
+
message: 'Look good? Let\'s go!',
|
|
230
|
+
default: true,
|
|
231
|
+
}]);
|
|
232
|
+
|
|
233
|
+
if (!confirm) {
|
|
234
|
+
console.log(chalk.yellow('Aborted. No files were created.'));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const outputDir = path.resolve(details.outputDir);
|
|
239
|
+
|
|
240
|
+
if (fs.existsSync(outputDir)) {
|
|
241
|
+
const { overwrite } = await inquirer.prompt([{
|
|
242
|
+
type: 'confirm',
|
|
243
|
+
name: 'overwrite',
|
|
244
|
+
message: `Directory ${outputDir} already exists. Continue anyway?`,
|
|
245
|
+
default: false,
|
|
246
|
+
}]);
|
|
247
|
+
|
|
248
|
+
if (!overwrite) {
|
|
249
|
+
console.log(chalk.yellow('Aborted.'));
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const replacements = buildReplacements(details);
|
|
255
|
+
|
|
256
|
+
console.log('');
|
|
257
|
+
|
|
258
|
+
// [1/5] Copy template
|
|
259
|
+
console.log(green('[1/5]') + ' Copying template files...');
|
|
260
|
+
await fs.promises.cp(TEMPLATE_DIR, outputDir, { recursive: true });
|
|
261
|
+
|
|
262
|
+
// [2/5] Replace placeholders
|
|
263
|
+
const files = await getAllFiles(outputDir);
|
|
264
|
+
let replacedCount = 0;
|
|
265
|
+
|
|
266
|
+
for (const filePath of files) {
|
|
267
|
+
if (isBinaryFile(filePath)) {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const content = await fs.promises.readFile(filePath, 'utf-8');
|
|
272
|
+
const replaced = replacePlaceholders(content, replacements);
|
|
273
|
+
|
|
274
|
+
if (replaced !== content) {
|
|
275
|
+
await fs.promises.writeFile(filePath, replaced, 'utf-8');
|
|
276
|
+
replacedCount++;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
console.log(green('[2/5]') + ` Replacing placeholders... ${dim(`(${replacedCount} files updated)`)}`);
|
|
281
|
+
|
|
282
|
+
// [3/5] Rename files
|
|
283
|
+
const allEntries = await getAllFiles(outputDir);
|
|
284
|
+
for (const filePath of allEntries) {
|
|
285
|
+
const dir = path.dirname(filePath);
|
|
286
|
+
const basename = path.basename(filePath);
|
|
287
|
+
|
|
288
|
+
if (basename.includes('{{PROJECT_SLUG}}')) {
|
|
289
|
+
const newName = basename.replaceAll('{{PROJECT_SLUG}}', details.slug);
|
|
290
|
+
const newPath = path.join(dir, newName);
|
|
291
|
+
await fs.promises.rename(filePath, newPath);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
console.log(green('[3/5]') + ' Renaming template files...');
|
|
296
|
+
|
|
297
|
+
// [4/5] Make scripts executable
|
|
298
|
+
const scripts = [
|
|
299
|
+
'dev.sh',
|
|
300
|
+
'install.sh',
|
|
301
|
+
'seed.sh',
|
|
302
|
+
'server/knex.sh',
|
|
303
|
+
];
|
|
304
|
+
|
|
305
|
+
for (const script of scripts) {
|
|
306
|
+
const scriptPath = path.join(outputDir, script);
|
|
307
|
+
if (fs.existsSync(scriptPath)) {
|
|
308
|
+
await fs.promises.chmod(scriptPath, 0o755);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
console.log(green('[4/5]') + ' Making scripts executable...');
|
|
313
|
+
|
|
314
|
+
// [5/5] Git init
|
|
315
|
+
execSync('git init', { cwd: outputDir, stdio: 'ignore' });
|
|
316
|
+
console.log(green('[5/5]') + ' Initializing git repository...');
|
|
317
|
+
|
|
318
|
+
// Done!
|
|
319
|
+
console.log('');
|
|
320
|
+
console.log(green.bold(` \u2713 "${details.name}" created successfully!`));
|
|
321
|
+
console.log('');
|
|
322
|
+
console.log(chalk.bold(' Next steps:'));
|
|
323
|
+
console.log(` ${green('1.')} cd ${details.outputDir}`);
|
|
324
|
+
console.log(` ${green('2.')} ./install.sh`);
|
|
325
|
+
console.log(` ${green('3.')} ./seed.sh`);
|
|
326
|
+
console.log(` ${green('4.')} ./dev.sh`);
|
|
327
|
+
console.log('');
|
|
328
|
+
console.log(dim(` Admin panel: ${cyan(`http://localhost:${details.adminUiPort}`)}`));
|
|
329
|
+
console.log(dim(` Admin API: ${cyan(`http://localhost:${details.apiPort}`)}`));
|
|
330
|
+
console.log(dim(` Site: ${cyan(`http://localhost:${details.sitePort}`)}`));
|
|
331
|
+
console.log('');
|
|
332
|
+
console.log(dim(' Happy hacking! - ') + cyan('youtube.com/ligeiro'));
|
|
333
|
+
console.log('');
|
|
334
|
+
}
|
package/package.json
CHANGED
|
@@ -1,14 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "umpordez",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.0
|
|
5
|
-
"description": "
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"description": "SaaS starter kit generator - multi-tenant TypeScript + React + PostgreSQL",
|
|
6
6
|
"main": "cli.mjs",
|
|
7
|
-
"imports": {
|
|
8
|
-
"#root/*": "./*",
|
|
9
|
-
"#tests/*": "./tests/*",
|
|
10
|
-
"#core/*": "./core/*"
|
|
11
|
-
},
|
|
12
7
|
"scripts": {
|
|
13
8
|
"test": "test"
|
|
14
9
|
},
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"Bash(ls:*)",
|
|
5
|
+
"Bash(npm install:*)",
|
|
6
|
+
"Bash(npm run:*)",
|
|
7
|
+
"Bash(npm ls:*)",
|
|
8
|
+
"Bash(npx tsc:*)",
|
|
9
|
+
"Bash(npx knex:*)",
|
|
10
|
+
"Bash(npx tsx:*)",
|
|
11
|
+
"Bash(node -e:*)",
|
|
12
|
+
"Bash(psql:*)",
|
|
13
|
+
"Bash(chmod:*)",
|
|
14
|
+
"Bash(tree:*)",
|
|
15
|
+
"Bash(curl:*)",
|
|
16
|
+
"Bash(git add:*)",
|
|
17
|
+
"Bash(git commit:*)"
|
|
18
|
+
]
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# Copilot Instructions
|
|
2
|
+
|
|
3
|
+
## Project: {{PROJECT_NAME}}
|
|
4
|
+
|
|
5
|
+
{{PROJECT_DESCRIPTION}}
|
|
6
|
+
|
|
7
|
+
Multi-tenant SaaS application built with the **umpordez** architecture pattern for the Brazilian market.
|
|
8
|
+
|
|
9
|
+
## Tech Stack
|
|
10
|
+
|
|
11
|
+
- **Backend:** TypeScript, Express.js, PostgreSQL, Knex.js, Zod
|
|
12
|
+
- **Admin UI:** React 18, Vite, TailwindCSS, shadcn/ui (Radix), React Router DOM 6, TanStack Query v5, React Hook Form + Zod
|
|
13
|
+
- **Site UI:** Express + EJS + TailwindCSS
|
|
14
|
+
- **Auth:** httpOnly JWT cookies (30-day expiry)
|
|
15
|
+
- **Uploads:** AWS S3 via multer-s3 with signed URLs
|
|
16
|
+
|
|
17
|
+
## Architecture
|
|
18
|
+
|
|
19
|
+
- Multi-API: `server/apps/api/` (admin, port 4000) + `server/apps/site-api/` (site, port 4001)
|
|
20
|
+
- Shared core: `server/core/` (db, models, context, s3, email, errors, logger)
|
|
21
|
+
- Shared middleware: `server/apps/shared/` (auth guards, error handler, buildHandler)
|
|
22
|
+
- Context-based DI: fresh `Context` per request with all models instantiated
|
|
23
|
+
- Models extend `BaseModel`, access DB via `this.knex`, other models via `this.context`
|
|
24
|
+
- Routes use `buildHandler()` wrapper for async error handling + Zod validation
|
|
25
|
+
- Multi-tenant: users -> user_in_accounts -> accounts -> domain entities
|
|
26
|
+
- Roles: admin (platform-wide), owner, manager, user (per-account)
|
|
27
|
+
|
|
28
|
+
## Code Style
|
|
29
|
+
|
|
30
|
+
- 4-space indentation, semicolons required, LF line endings
|
|
31
|
+
- TypeScript strict mode everywhere
|
|
32
|
+
- Code in English, UI text in Portuguese (Brazilian market)
|
|
33
|
+
- Path aliases: `#core/*`, `#shared/*`, `#api/*` (server), `@/*` (admin UI)
|
|
34
|
+
|
|
35
|
+
## Key Patterns — Follow These When Generating Code
|
|
36
|
+
|
|
37
|
+
### Backend: Models (data access)
|
|
38
|
+
|
|
39
|
+
All database queries go in models, never in route handlers directly. Models extend `BaseModel` and live in `server/core/models/`.
|
|
40
|
+
|
|
41
|
+
```typescript
|
|
42
|
+
import BaseModel from './base.js';
|
|
43
|
+
import { NotFoundError, ValidationError } from '#core/errors.js';
|
|
44
|
+
|
|
45
|
+
class MyEntityModel extends BaseModel {
|
|
46
|
+
async list(options = {}) {
|
|
47
|
+
const page = Math.max(1, options.page || 1);
|
|
48
|
+
const limit = Math.min(100, Math.max(1, options.limit || 20));
|
|
49
|
+
const offset = (page - 1) * limit;
|
|
50
|
+
const baseQuery = this.knex('my_entities').where('account_id', accountId);
|
|
51
|
+
if (options.search?.trim()) {
|
|
52
|
+
baseQuery.whereILike('name', `%${options.search.trim()}%`);
|
|
53
|
+
}
|
|
54
|
+
const [{ count }] = await baseQuery.clone().count('id as count');
|
|
55
|
+
const total = parseInt(String(count), 10);
|
|
56
|
+
const rows = await baseQuery.clone().select('*')
|
|
57
|
+
.orderBy('utc_created_on', 'desc').limit(limit).offset(offset);
|
|
58
|
+
return { rows, total, page, limit, totalPages: Math.ceil(total / limit) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async findById(id: string) {
|
|
62
|
+
const row = await this.knex('my_entities').where({ id }).first();
|
|
63
|
+
if (!row) throw new NotFoundError('Entidade não encontrada');
|
|
64
|
+
return row;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Register in `server/core/context.ts`: add field + `this.myEntity = new MyEntityModel(this);`
|
|
70
|
+
|
|
71
|
+
### Backend: Routes
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
import { buildHandler } from '#shared/utils.js';
|
|
75
|
+
import { z } from 'zod';
|
|
76
|
+
|
|
77
|
+
const createSchema = z.object({ name: z.string().min(1, 'Nome é obrigatório') });
|
|
78
|
+
|
|
79
|
+
async function handleCreate(req: Request, res: Response): Promise<void> {
|
|
80
|
+
const data = createSchema.parse(req.body);
|
|
81
|
+
const result = await req.context.myEntity.create(req.accountId!, data);
|
|
82
|
+
res.json({ ok: true, result });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
router.post('/my-entities', buildHandler(handleCreate));
|
|
86
|
+
|
|
87
|
+
export default function makeEndpoint(app: Express): void {
|
|
88
|
+
app.use('/api/account/:accountId',
|
|
89
|
+
trySetUserByTokenMiddleware, demandUserMiddleware,
|
|
90
|
+
demandAccountAccessMiddleware, router);
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Backend: Errors
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
throw new ValidationError('Campo obrigatório'); // 400
|
|
98
|
+
throw new NotFoundError('Não encontrado'); // 404
|
|
99
|
+
throw new ConflictError('Já existe'); // 409
|
|
100
|
+
throw new ForbiddenError('Sem permissão'); // 403
|
|
101
|
+
throw new UnauthorizedError('Não autorizado'); // 401
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Backend: Transactions
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
await req.context.knexTransaction(async () => {
|
|
108
|
+
// All model ops share the same transaction; rollback on throw
|
|
109
|
+
});
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Backend: Migrations (raw SQL)
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
export async function up(knex: Knex): Promise<void> {
|
|
116
|
+
await knex.schema.raw(`
|
|
117
|
+
CREATE TABLE my_entities (
|
|
118
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
119
|
+
account_id UUID NOT NULL REFERENCES accounts(id),
|
|
120
|
+
name VARCHAR(255) NOT NULL,
|
|
121
|
+
utc_created_on TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
122
|
+
utc_updated_on TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
123
|
+
);
|
|
124
|
+
CREATE INDEX idx_my_entities_account ON my_entities(account_id);
|
|
125
|
+
`);
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Frontend: fetchApi
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
import fetchApi from '@/lib/fetch-api';
|
|
133
|
+
const data = await fetchApi('user/accounts'); // GET
|
|
134
|
+
const data = await fetchApi('admin/users', { page: 1 }); // GET with params
|
|
135
|
+
const data = await fetchApi('login', { email, password }, 'POST'); // POST
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Uses `credentials: 'include'` for cookie auth.
|
|
139
|
+
|
|
140
|
+
### Frontend: React Query hooks
|
|
141
|
+
|
|
142
|
+
```typescript
|
|
143
|
+
export function useMyEntities(accountId: string, filters: PaginationFilters) {
|
|
144
|
+
return useQuery({
|
|
145
|
+
queryKey: queryKeys.myEntities.list(accountId, filters),
|
|
146
|
+
placeholderData: keepPreviousData,
|
|
147
|
+
queryFn: async () => {
|
|
148
|
+
const res = await fetchApi(`account/${accountId}/my-entities`, filters);
|
|
149
|
+
if (!res.ok) throw new Error(res.message);
|
|
150
|
+
return res;
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Use `keepPreviousData` on paginated/searchable queries. Add keys to `lib/query-keys.ts`. Invalidate related queries in mutation `onSuccess`.
|
|
157
|
+
|
|
158
|
+
### Frontend: Confirm modal
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
const confirm = useConfirm();
|
|
162
|
+
const ok = await confirm({
|
|
163
|
+
title: 'Excluir', description: 'Tem certeza?',
|
|
164
|
+
confirmLabel: 'Excluir', variant: 'destructive',
|
|
165
|
+
});
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Frontend: Layouts
|
|
169
|
+
|
|
170
|
+
| Layout | Auth | Route prefix |
|
|
171
|
+
|---|---|---|
|
|
172
|
+
| `PublicLayout` | None | `/` |
|
|
173
|
+
| `UserLayout` | `useUser()` | `/user` |
|
|
174
|
+
| `AdminLayout` | admin role | `/admin` |
|
|
175
|
+
| `AccountLayout` | account access | `/account/:accountId` |
|
|
176
|
+
|
|
177
|
+
## File Naming
|
|
178
|
+
|
|
179
|
+
- Components: PascalCase (`AppSidebar.tsx`)
|
|
180
|
+
- Hooks: kebab-case with `use-` prefix (`use-user.tsx`)
|
|
181
|
+
- Routes/Models/Utils: kebab-case (`demand-user.ts`, `fetch-api.ts`)
|
|
182
|
+
- Migrations: timestamp prefix (`20260208000000_description.ts`)
|
|
183
|
+
|
|
184
|
+
## Multi-Tenancy
|
|
185
|
+
|
|
186
|
+
Always filter by `account_id` in tenant-scoped queries. The `demand-account-access` middleware verifies access and sets `req.accountId` + `req.accountRole`. Admin users bypass the check.
|
package/template/.nvimrc
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"editor.tabSize": 4,
|
|
3
|
+
"editor.insertSpaces": true,
|
|
4
|
+
"editor.formatOnSave": true,
|
|
5
|
+
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
|
6
|
+
"editor.codeActionsOnSave": {
|
|
7
|
+
"source.fixAll.eslint": "explicit"
|
|
8
|
+
},
|
|
9
|
+
"files.eol": "\n",
|
|
10
|
+
"files.trimTrailingWhitespace": true,
|
|
11
|
+
"files.insertFinalNewline": true,
|
|
12
|
+
"typescript.preferences.importModuleSpecifier": "non-relative",
|
|
13
|
+
"typescript.tsdk": "node_modules/typescript/lib",
|
|
14
|
+
"search.exclude": {
|
|
15
|
+
"**/node_modules": true,
|
|
16
|
+
"**/build": true,
|
|
17
|
+
"**/dist": true
|
|
18
|
+
},
|
|
19
|
+
"[typescript]": {
|
|
20
|
+
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
|
21
|
+
},
|
|
22
|
+
"[typescriptreact]": {
|
|
23
|
+
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
|
24
|
+
},
|
|
25
|
+
"[json]": {
|
|
26
|
+
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
|
27
|
+
}
|
|
28
|
+
}
|