@xeplr/cli 0.1.2 → 0.1.4
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/README.md +37 -13
- package/bin/xeplr.js +10 -4
- package/lib/generate/index.js +121 -3
- package/lib/nextSteps.js +33 -18
- package/lib/plan.js +8 -0
- package/lib/questions.js +60 -1
- package/lib/setup.js +59 -0
- package/package.json +2 -1
- package/templates/CLAUDE.md +57 -0
- package/templates/api/app.js +31 -1
- package/templates/api/bin/www +19 -4
- package/templates/api/db/setup.js +1 -0
- package/templates/api/development.env.tpl +8 -2
- package/templates/api/env.required.js +1 -1
- package/templates/api/migrations/README.md +9 -0
- package/templates/api/migrations-auth/0003_forms_menu.sql +19 -0
- package/templates/api/models/index.js +7 -6
- package/templates/api/package.json +5 -2
- package/templates/api/routes/access.js +25 -0
- package/templates/api/routes/index.js +6 -15
- package/templates/api/screens/index.js +39 -0
- package/templates/api/screens/task/task-edit.screen.json +103 -0
- package/templates/api/screens/task/task-list.screen.json +45 -0
- package/templates/api/screens/task/task.entity.json +10 -0
- package/templates/api/screens/task/task.hooks.js +37 -0
- package/templates/api/screens/task/task.model.js +29 -0
- package/templates/ui/package.json +4 -0
- package/templates/ui/src/App.jsx +22 -2
- package/templates/ui/src/api/factory.js +10 -0
- package/templates/ui/src/index.css +60 -0
- package/templates/ui/src/main.jsx +2 -0
- package/templates/ui/src/pages/EditTask.jsx +57 -0
- package/templates/ui/src/pages/FormDesigner.jsx +74 -0
- package/templates/ui/src/pages/FormRecords.jsx +24 -0
- package/templates/ui/src/pages/Forms.jsx +144 -0
- package/templates/ui/src/pages/Home.jsx +4 -0
- package/templates/ui/src/pages/Tasks.jsx +20 -94
- package/templates/ui/src/pages/TasksHelp.jsx +66 -50
- package/templates/ui/vite.config.js +1 -1
- package/templates-tenancy/api/migrations/0001_tenants.sql +8 -0
- package/templates-tenancy/api/migrations-auth/0004_switch_menu.sql +10 -0
- package/templates-tenancy/api/routes/tenants.js +126 -0
- package/templates-tenancy/api/tenancy.js +12 -0
- package/templates-tenancy/ui/src/pages/SelectScope.css +45 -0
- package/templates-tenancy/ui/src/pages/SelectScope.jsx +154 -0
- package/templates-tenancy/ui/src/tenancy.js +3 -0
- package/templates/api/migrations/0001_tasks.sql +0 -23
- package/templates/api/models/Task.js +0 -48
- package/templates/ui/src/api/tasks.js +0 -34
- package/templates/ui/src/useTasksController.js +0 -83
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @xeplr/cli
|
|
2
2
|
|
|
3
|
-
Creates a working xeplr application — an API, sign-in, and a UI — from
|
|
4
|
-
questions.
|
|
3
|
+
Creates a working xeplr application — an API, sign-in, and a UI — from a few
|
|
4
|
+
questions, installs it, and sets up its databases.
|
|
5
5
|
|
|
6
6
|
```bash
|
|
7
7
|
npx @xeplr/cli new myapp
|
|
@@ -23,10 +23,29 @@ Already working, with nothing to wire up:
|
|
|
23
23
|
- **A guarded API** — every address needs a valid token. An address you want
|
|
24
24
|
open is named explicitly; there is no way to leave the whole thing public by
|
|
25
25
|
forgetting.
|
|
26
|
-
- **A sample
|
|
27
|
-
|
|
26
|
+
- **A sample built with the screen designer** — a task list whose New and Edit
|
|
27
|
+
open the task form in a popup, saving as you type into an ordinary `tasks`
|
|
28
|
+
table. Neither is written by hand: both are screens
|
|
29
|
+
([`@xeplr/ui-factory`](https://www.npmjs.com/package/@xeplr/ui-factory)),
|
|
30
|
+
published when the API first starts.
|
|
31
|
+
- **A Forms page** for Super Admin — every form in the app; make a new one by
|
|
32
|
+
hand (no Claude needed), design its form and its list, publish (which creates
|
|
33
|
+
or changes its table), open it. Nobody else sees it, and the API refuses
|
|
34
|
+
everyone else.
|
|
35
|
+
- **Multi-tenancy, if you ask for it** — companies (or companies and
|
|
36
|
+
workspaces, up to four levels). Every row is stamped with the one it was made
|
|
37
|
+
in, every read sees only its rows, membership is checked on every request,
|
|
38
|
+
and choosing one is the first screen after signing in.
|
|
28
39
|
- **A welcome screen** that checks it can reach everything and tells you which
|
|
29
40
|
files to change first.
|
|
41
|
+
- **A `CLAUDE.md`** that tells Claude how to create a new UI in this app — the
|
|
42
|
+
screens, the page with its front-end hooks, the server hooks and model, the
|
|
43
|
+
route and menu entry — and a ready prompt (also shown beside the sample):
|
|
44
|
+
|
|
45
|
+
> Create a new UI for farming departments. One record is a farming department
|
|
46
|
+
> with: Name (required), Region (dropdown: North, South, East, West), Area in
|
|
47
|
+
> acres (number, at least 0), Started on (date), Notes (long text). Show Name,
|
|
48
|
+
> Region and Area in the list. Follow "Create a new UI" in CLAUDE.md.
|
|
30
49
|
|
|
31
50
|
## Before you start
|
|
32
51
|
|
|
@@ -54,20 +73,23 @@ shell.
|
|
|
54
73
|
|
|
55
74
|
## Then
|
|
56
75
|
|
|
76
|
+
The installer has already run `npm install` in both halves and — when you gave
|
|
77
|
+
the database connection — `npm run setup`, which creates both databases, the
|
|
78
|
+
sign-in tables and the permissions. Start it:
|
|
79
|
+
|
|
57
80
|
```bash
|
|
58
|
-
cd myapp/api
|
|
59
|
-
npm
|
|
60
|
-
npm run
|
|
61
|
-
npm run start-auth # sign-in
|
|
62
|
-
npm run start-api # your API
|
|
63
|
-
|
|
64
|
-
cd ../ui
|
|
65
|
-
npm install
|
|
66
|
-
npm run dev
|
|
81
|
+
cd myapp/api && npm run start-auth # sign-in
|
|
82
|
+
cd myapp/api && npm run start-api # your API — publishes the sample screens on first start
|
|
83
|
+
cd myapp/ui && npm run dev # the app
|
|
67
84
|
```
|
|
68
85
|
|
|
69
86
|
Open the UI and sign in with the account you named.
|
|
70
87
|
|
|
88
|
+
`xeplr new myapp --no-install` only writes the files; then run `npm install` in
|
|
89
|
+
`api` and `ui`, and `npm run setup` in `api`, yourself. If set-up stops part
|
|
90
|
+
way (Postgres not running, say), the installer says which step and the command
|
|
91
|
+
to run again.
|
|
92
|
+
|
|
71
93
|
## Changing the database connection later
|
|
72
94
|
|
|
73
95
|
A password rotates, the database moves, or the first answer was wrong. From
|
|
@@ -106,6 +128,8 @@ no defaults, ever.
|
|
|
106
128
|
| Port decade | three consecutive ports — ui, sign-in, api |
|
|
107
129
|
| Super admin email | the account you sign in with |
|
|
108
130
|
| Super admin password | its password |
|
|
131
|
+
| Multi-tenant? | no — or the levels, outermost first: `company`, `company, workspace` |
|
|
132
|
+
| Database connection | host, port, user, password — or skip, and fill it in later |
|
|
109
133
|
|
|
110
134
|
Encryption keys and signing secrets are **generated, never asked**.
|
|
111
135
|
|
package/bin/xeplr.js
CHANGED
|
@@ -6,6 +6,7 @@ var questions = require('../lib/questions');
|
|
|
6
6
|
var plan = require('../lib/plan');
|
|
7
7
|
var generate = require('../lib/generate');
|
|
8
8
|
var nextSteps = require('../lib/nextSteps');
|
|
9
|
+
var setup = require('../lib/setup');
|
|
9
10
|
var encryptCmd = require('../lib/commands/encrypt');
|
|
10
11
|
|
|
11
12
|
var HELP = [
|
|
@@ -13,7 +14,8 @@ var HELP = [
|
|
|
13
14
|
' xeplr — create a working xeplr application',
|
|
14
15
|
'',
|
|
15
16
|
' Usage:',
|
|
16
|
-
' xeplr new [name] create a project',
|
|
17
|
+
' xeplr new [name] create a project, install it and set up its databases',
|
|
18
|
+
' xeplr new [name] --no-install only write the files',
|
|
17
19
|
' xeplr encrypt redo the database connection',
|
|
18
20
|
'',
|
|
19
21
|
' new Creates a project with an API, sign-in and a UI, all talking',
|
|
@@ -52,7 +54,7 @@ async function main() {
|
|
|
52
54
|
|
|
53
55
|
var session = prompt.createSession();
|
|
54
56
|
try {
|
|
55
|
-
var answers = await questions.collect(session, argv[1]);
|
|
57
|
+
var answers = await questions.collect(session, argv[1] && argv[1].indexOf('--') !== 0 ? argv[1] : undefined);
|
|
56
58
|
console.log(plan.describe(answers));
|
|
57
59
|
|
|
58
60
|
var go = await session.confirm(' Create it?', true);
|
|
@@ -60,6 +62,8 @@ async function main() {
|
|
|
60
62
|
console.log('\n Nothing was written.\n');
|
|
61
63
|
return 0;
|
|
62
64
|
}
|
|
65
|
+
var installNow = argv.indexOf('--no-install') === -1 && await session.confirm(
|
|
66
|
+
answers.connection ? ' Install packages and set up the databases now?' : ' Install packages now?', true);
|
|
63
67
|
} finally {
|
|
64
68
|
session.close();
|
|
65
69
|
}
|
|
@@ -67,8 +71,10 @@ async function main() {
|
|
|
67
71
|
var target = path.resolve(process.cwd(), answers.name);
|
|
68
72
|
var result = generate.generate(answers, target);
|
|
69
73
|
console.log('\n Wrote ' + result.written.length + ' files.');
|
|
70
|
-
|
|
71
|
-
|
|
74
|
+
|
|
75
|
+
var done = installNow ? await setup.run(answers, target) : null;
|
|
76
|
+
console.log(nextSteps.describe(answers, target, done));
|
|
77
|
+
return done && !done.ok ? 1 : 0;
|
|
72
78
|
}
|
|
73
79
|
|
|
74
80
|
main().then(function (code) { process.exit(code); }, function (err) {
|
package/lib/generate/index.js
CHANGED
|
@@ -3,6 +3,11 @@ var path = require('path');
|
|
|
3
3
|
|
|
4
4
|
var TEMPLATES = path.join(__dirname, '..', '..', 'templates');
|
|
5
5
|
|
|
6
|
+
// Files that exist ONLY in a multi-tenant app — the company tables, the picker,
|
|
7
|
+
// the membership check. Laid over templates/ when tenancy is on; a file here
|
|
8
|
+
// with the same path as one there replaces it.
|
|
9
|
+
var TENANCY_TEMPLATES = path.join(__dirname, '..', '..', 'templates-tenancy');
|
|
10
|
+
|
|
6
11
|
// Files whose names cannot ship as-is. `.env` and `.gitignore` are both
|
|
7
12
|
// swallowed on the way here — npm refuses to pack a .gitignore, and an .env in
|
|
8
13
|
// a published package is a mistake waiting to happen — so they travel under a
|
|
@@ -17,7 +22,7 @@ var RENAME = {
|
|
|
17
22
|
// never filled shows up as a missing key rather than as the literal
|
|
18
23
|
// __SOMETHING__ in a generated file.
|
|
19
24
|
function substitutions(answers) {
|
|
20
|
-
return {
|
|
25
|
+
return Object.assign({
|
|
21
26
|
__NAME__: answers.name,
|
|
22
27
|
__PREFIX__: answers.prefix,
|
|
23
28
|
__UI_PORT__: String(answers.ports.ui),
|
|
@@ -29,9 +34,111 @@ function substitutions(answers) {
|
|
|
29
34
|
__ADMIN_PASSWORD__: answers.admin.password,
|
|
30
35
|
__DB_CONNECTION__: answers.connection || '',
|
|
31
36
|
__CONNECTION_NOTE__: connectionNote(answers)
|
|
37
|
+
}, tenancySubstitutions(answers.tenancy || []));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The code a multi-tenant app has and a plain one does not.
|
|
42
|
+
*
|
|
43
|
+
* Every token exists in both, so a template can use it unconditionally: in a
|
|
44
|
+
* plain app each expands to nothing, and a line holding only that token is
|
|
45
|
+
* removed rather than left blank (see fill).
|
|
46
|
+
*/
|
|
47
|
+
function tenancySubstitutions(levels) {
|
|
48
|
+
var on = levels.length > 0;
|
|
49
|
+
var slots = {};
|
|
50
|
+
levels.forEach(function (l) { slots[l.key] = { name: l.name, header: l.header }; });
|
|
51
|
+
var first = levels[0];
|
|
52
|
+
return {
|
|
53
|
+
__MT_TENANCY_JSON__: JSON.stringify({ levels: levels, slots: slots }, null, 2),
|
|
54
|
+
__MT_LEVEL_NAMES__: levels.map(function (l) { return l.label.toLowerCase(); }).join(' → '),
|
|
55
|
+
__MT_SWITCH_NAME__: on ? 'Switch ' + first.label.toLowerCase() : '',
|
|
56
|
+
__MT_TABLES_SQL__: levels.map(tenantTableSql).join('\n'),
|
|
57
|
+
|
|
58
|
+
// api/db/setup.js
|
|
59
|
+
__MT_DB_REGISTER__: on ? [
|
|
60
|
+
'',
|
|
61
|
+
'// TENANCY — the levels data belongs to (' + levels.map(function (l) { return l.label.toLowerCase(); }).join(', ') + '), from tenancy.js.',
|
|
62
|
+
'// The UI registers the same ones, so its requests carry the same headers.',
|
|
63
|
+
"require('@xeplr/db').registerMTs(require('../tenancy').slots);"
|
|
64
|
+
].join('\n') : '',
|
|
65
|
+
|
|
66
|
+
// api/app.js
|
|
67
|
+
__MT_APP_REQUIRE__: on ? [
|
|
68
|
+
"var { mtMiddleware } = require('@xeplr/db');",
|
|
69
|
+
"var tenants = require('./routes/tenants');"
|
|
70
|
+
].join('\n') : '',
|
|
71
|
+
__MT_APP_MIDDLEWARE__: on ? [
|
|
72
|
+
' // Puts the request\'s ' + levels.map(function (l) { return l.header; }).join(' / ') + ' on every query: rows are',
|
|
73
|
+
' // stamped with it on insert, and only its rows are read.',
|
|
74
|
+
' middleware: [mtMiddleware()],'
|
|
75
|
+
].join('\n') : '',
|
|
76
|
+
__MT_APP_ROUTES__: on ? [
|
|
77
|
+
' // The ' + levels.map(function (l) { return l.table; }).join(' / ') + ' the picker lists and adds to.',
|
|
78
|
+
" '/api/tenants': tenants.router,"
|
|
79
|
+
].join('\n') : '',
|
|
80
|
+
__MT_MEMBER_GATE__: on ? 'tenants.memberGate' : 'access.anyone',
|
|
81
|
+
__MT_MEMBER_NOUN__: on ? levels.map(function (l) { return l.label.toLowerCase(); }).join(' and ') : 'data (no tenancy: nothing to check)',
|
|
82
|
+
|
|
83
|
+
// ui/src/main.jsx
|
|
84
|
+
__MT_UI_IMPORTS__: on ? [
|
|
85
|
+
"import { registerMTs } from '@xeplr/ui-account'",
|
|
86
|
+
"import { tenancy } from './tenancy.js'"
|
|
87
|
+
].join('\n') : '',
|
|
88
|
+
__MT_UI_REGISTER__: on ? [
|
|
89
|
+
'',
|
|
90
|
+
'// TENANCY — the same levels as the API. authFetch then sends the chosen',
|
|
91
|
+
'// ' + levels.map(function (l) { return l.label.toLowerCase(); }).join(' and ') + ' on every request.',
|
|
92
|
+
'registerMTs(tenancy.slots)'
|
|
93
|
+
].join('\n') : '',
|
|
94
|
+
|
|
95
|
+
// ui/src/App.jsx
|
|
96
|
+
__MT_APP_UI_IMPORTS__: on ? "import SelectScope, { ScopeGate } from './pages/SelectScope.jsx'" : '',
|
|
97
|
+
__MT_DRAWER_ITEM__: on ? " { name: 'Switch " + first.label.toLowerCase() + "', icon: SwitchIcon, clickHandler: () => navigate('/select') }," : '',
|
|
98
|
+
__MT_SWITCH_ICON__: on ? [
|
|
99
|
+
'const SwitchIcon = (',
|
|
100
|
+
' <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"',
|
|
101
|
+
' strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">',
|
|
102
|
+
' <path d="M17 1l4 4-4 4" /><path d="M3 11V9a4 4 0 0 1 4-4h14" />',
|
|
103
|
+
' <path d="M7 23l-4-4 4-4" /><path d="M21 13v2a4 4 0 0 1-4 4H3" />',
|
|
104
|
+
' </svg>',
|
|
105
|
+
')',
|
|
106
|
+
''
|
|
107
|
+
].join('\n') : '',
|
|
108
|
+
__MT_SELECT_ROUTE__: on ? [
|
|
109
|
+
' {/* Picking the ' + levels.map(function (l) { return l.label.toLowerCase(); }).join(' and ') + ' — signed in, but outside the gate below. */}',
|
|
110
|
+
' <Route element={<ProtectedRoute><Shell /></ProtectedRoute>}>',
|
|
111
|
+
' <Route path="/select" element={<SelectScope />} />',
|
|
112
|
+
' </Route>',
|
|
113
|
+
''
|
|
114
|
+
].join('\n') : '',
|
|
115
|
+
__MT_GATE_OPEN__: on ? '<ScopeGate>' : '',
|
|
116
|
+
__MT_GATE_CLOSE__: on ? '</ScopeGate>' : ''
|
|
32
117
|
};
|
|
33
118
|
}
|
|
34
119
|
|
|
120
|
+
/** One level's own table: what the picker lists. Its parents' ids in mtIdN. */
|
|
121
|
+
function tenantTableSql(level, i) {
|
|
122
|
+
var parents = i === 0 ? 'none — the outermost level' : i === 1 ? 'mtId1 holds the one above' : 'mtId1–mtId' + i + ' hold the ones above';
|
|
123
|
+
return [
|
|
124
|
+
'-- ' + level.label + ' (' + level.key + ', header ' + level.header + ') — parents: ' + parents + '.',
|
|
125
|
+
'CREATE TABLE IF NOT EXISTS "' + level.table + '" (',
|
|
126
|
+
' "id" varchar(25) PRIMARY KEY,',
|
|
127
|
+
' "name" varchar(200) NOT NULL,',
|
|
128
|
+
' "isActive" boolean DEFAULT true,',
|
|
129
|
+
' "mtId1" varchar(25),',
|
|
130
|
+
' "mtId2" varchar(25),',
|
|
131
|
+
' "mtId3" varchar(25),',
|
|
132
|
+
' "mtId4" varchar(25),',
|
|
133
|
+
' "recordCreatedDate" timestamptz DEFAULT now(),',
|
|
134
|
+
' "recordModifiedDate" timestamptz DEFAULT now(),',
|
|
135
|
+
' "recordCreatedBy" varchar(25),',
|
|
136
|
+
' "recordModifiedBy" varchar(25)',
|
|
137
|
+
');',
|
|
138
|
+
''
|
|
139
|
+
].join('\n');
|
|
140
|
+
}
|
|
141
|
+
|
|
35
142
|
/**
|
|
36
143
|
* The comment above the connection setting.
|
|
37
144
|
*
|
|
@@ -72,6 +179,11 @@ function connectionNote(answers) {
|
|
|
72
179
|
}
|
|
73
180
|
|
|
74
181
|
function fill(body, subs) {
|
|
182
|
+
// A line holding nothing but a token that expands to nothing goes entirely —
|
|
183
|
+
// the plain app's files read as if the tenancy lines were never there.
|
|
184
|
+
body = body.replace(/^[ \t]*(__[A-Z][A-Z0-9_]*[A-Z0-9]__)[ \t]*\r?\n/gm, function (line, token) {
|
|
185
|
+
return subs[token] === '' ? '' : line;
|
|
186
|
+
});
|
|
75
187
|
// The inner name must START and END with a letter or digit.
|
|
76
188
|
//
|
|
77
189
|
// Without that, a greedy match on `__PREFIX___PORT` takes `__PREFIX___` —
|
|
@@ -120,8 +232,14 @@ function generate(answers, target) {
|
|
|
120
232
|
var subs = substitutions(answers);
|
|
121
233
|
var written = [];
|
|
122
234
|
|
|
123
|
-
|
|
124
|
-
|
|
235
|
+
var sources = {};
|
|
236
|
+
walk(TEMPLATES).forEach(function (src) { sources[path.relative(TEMPLATES, src)] = src; });
|
|
237
|
+
if (answers.tenancy && answers.tenancy.length) {
|
|
238
|
+
walk(TENANCY_TEMPLATES).forEach(function (src) { sources[path.relative(TENANCY_TEMPLATES, src)] = src; });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
Object.keys(sources).forEach(function (rel) {
|
|
242
|
+
var src = sources[rel];
|
|
125
243
|
var dir = path.dirname(rel);
|
|
126
244
|
var base = path.basename(rel);
|
|
127
245
|
var outName = RENAME[base] || base;
|
package/lib/nextSteps.js
CHANGED
|
@@ -1,37 +1,52 @@
|
|
|
1
|
+
var path = require('path');
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
|
-
* What to do now that the files exist
|
|
4
|
+
* What to do now that the files exist — and, when the installer set the
|
|
5
|
+
* project up itself, only what is left.
|
|
3
6
|
*
|
|
4
|
-
*
|
|
5
|
-
* the database connection — and a project that looks finished but will not
|
|
6
|
-
* start is worse than one that says what is left.
|
|
7
|
+
* @param setup the result of lib/setup.js, or null when it was not run
|
|
7
8
|
*/
|
|
8
|
-
function describe(answers, dir) {
|
|
9
|
-
var
|
|
9
|
+
function describe(answers, dir, setup) {
|
|
10
|
+
var api = answers.name + '/api';
|
|
10
11
|
var head = ['', ' Created ' + dir, ''];
|
|
12
|
+
var installed = setup && (setup.ok || (setup.failed && setup.failed.args[0] !== 'install'));
|
|
13
|
+
|
|
14
|
+
if (setup && !setup.ok) {
|
|
15
|
+
head = head.concat([
|
|
16
|
+
' SET-UP STOPPED at: ' + setup.failed.label,
|
|
17
|
+
' The messages above say why. Fix that, then run it again:',
|
|
18
|
+
'',
|
|
19
|
+
' cd ' + path.join(answers.name, path.basename(setup.failed.cwd)) + ' && npm ' + setup.failed.args.join(' '),
|
|
20
|
+
''
|
|
21
|
+
]);
|
|
22
|
+
}
|
|
11
23
|
|
|
12
24
|
if (!answers.connection) {
|
|
13
25
|
head = head.concat([
|
|
14
26
|
' ONE THING LEFT — your database connection:',
|
|
15
27
|
'',
|
|
16
|
-
' cd ' +
|
|
28
|
+
' cd ' + api + ' && ' + (installed ? '' : 'npm install && ') + 'npm run db:encrypt && npm run setup',
|
|
17
29
|
'',
|
|
18
30
|
' It asks for your database details and fills them in for you.',
|
|
19
31
|
''
|
|
20
32
|
]);
|
|
21
33
|
}
|
|
22
34
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
' cd ' +
|
|
27
|
-
'
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
var ready = setup && setup.ok && setup.databases;
|
|
36
|
+
var start = ready ? [' Start it — three terminals:', ''] : [' Then:', ''];
|
|
37
|
+
if (!ready && answers.connection) {
|
|
38
|
+
start.push(' cd ' + api + ' && npm install && npm run setup');
|
|
39
|
+
start.push(' cd ../ui' + ' '.repeat(Math.max(1, api.length - 5)) + '&& npm install');
|
|
40
|
+
start.push('');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return head.concat(start, [
|
|
44
|
+
' cd ' + api + ' && npm run start-auth # sign-in, port ' + answers.ports.auth,
|
|
45
|
+
' cd ' + api + ' && npm run start-api # your API, port ' + answers.ports.api,
|
|
46
|
+
' cd ' + answers.name + '/ui && npm run dev # the app, port ' + answers.ports.ui,
|
|
32
47
|
'',
|
|
33
|
-
' Then open http://localhost:' + answers.ports.ui
|
|
34
|
-
' ' + answers.admin.email + '.',
|
|
48
|
+
' Sign-in needs Redis running. Then open http://localhost:' + answers.ports.ui,
|
|
49
|
+
' and sign in as ' + answers.admin.email + '.',
|
|
35
50
|
''
|
|
36
51
|
]).join('\n');
|
|
37
52
|
}
|
package/lib/plan.js
CHANGED
|
@@ -21,8 +21,16 @@ function describe(answers) {
|
|
|
21
21
|
L.push('');
|
|
22
22
|
L.push(' You will sign in as ' + answers.admin.email + '.');
|
|
23
23
|
L.push('');
|
|
24
|
+
if (answers.tenancy && answers.tenancy.length) {
|
|
25
|
+
L.push(' Multi-tenant: ' + answers.tenancy.map(function (l) { return l.label.toLowerCase() + ' (' + l.header + ')'; }).join(' → ') + '.');
|
|
26
|
+
L.push(' Choosing one is the first screen after signing in.');
|
|
27
|
+
} else {
|
|
28
|
+
L.push(' Not multi-tenant: data belongs to the app as a whole.');
|
|
29
|
+
}
|
|
30
|
+
L.push('');
|
|
24
31
|
L.push(' Settings written for you:');
|
|
25
32
|
L.push(' ' + p + '_PORT, AUTH_PORT, AUTH_URL, DB_API, AUTH_DB_NAME');
|
|
33
|
+
L.push(' REDIS_PREFIX ' + answers.name + ':');
|
|
26
34
|
L.push(' ENCRYPTION_KEY and AUTH_JWT_SECRET generated, not asked');
|
|
27
35
|
L.push(' AUTH_SUPER_ADMIN_EMAIL / _PASSWORD from your answers');
|
|
28
36
|
L.push('');
|
package/lib/questions.js
CHANGED
|
@@ -20,6 +20,46 @@ function envPrefix(name) {
|
|
|
20
20
|
return name.toUpperCase().replace(/-/g, '_');
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// ── tenancy ──────────────────────────────────────────────────────────────
|
|
24
|
+
//
|
|
25
|
+
// At most four levels — @xeplr/db has four tenant columns (mtId1-4) — named
|
|
26
|
+
// outermost first: "company", or "company, workspace".
|
|
27
|
+
var LEVEL_RE = /^[a-z][a-z ]{0,30}[a-z]$/;
|
|
28
|
+
|
|
29
|
+
function plural(word) {
|
|
30
|
+
if (/[^aeiou]y$/.test(word)) return word.slice(0, -1) + 'ies';
|
|
31
|
+
if (/(s|x|z|ch|sh)$/.test(word)) return word + 'es';
|
|
32
|
+
return word + 's';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* "company, business unit" → one entry per level, every name derived once:
|
|
37
|
+
* { key: 'l2', label: 'Business unit', name: 'businessUnitId',
|
|
38
|
+
* header: 'x-business-unit-id', table: 'business_units' }
|
|
39
|
+
*
|
|
40
|
+
* @returns {object[]} or throws, with a sentence to show
|
|
41
|
+
*/
|
|
42
|
+
function tenancyLevels(text) {
|
|
43
|
+
var words = String(text || '').split(',').map(function (w) { return w.trim().toLowerCase().replace(/\s+/g, ' '); }).filter(Boolean);
|
|
44
|
+
if (!words.length) throw new Error('Name at least one level, e.g. company.');
|
|
45
|
+
if (words.length > 4) throw new Error('At most four levels.');
|
|
46
|
+
words.forEach(function (w) {
|
|
47
|
+
if (!LEVEL_RE.test(w)) throw new Error('"' + w + '" — letters and spaces only, e.g. company or business unit.');
|
|
48
|
+
});
|
|
49
|
+
if (new Set(words).size !== words.length) throw new Error('Each level needs a different name.');
|
|
50
|
+
return words.map(function (w, i) {
|
|
51
|
+
var parts = w.split(' ');
|
|
52
|
+
var camel = parts[0] + parts.slice(1).map(function (p) { return p[0].toUpperCase() + p.slice(1); }).join('');
|
|
53
|
+
return {
|
|
54
|
+
key: 'l' + (i + 1),
|
|
55
|
+
label: w[0].toUpperCase() + w.slice(1),
|
|
56
|
+
name: camel + 'Id',
|
|
57
|
+
header: 'x-' + parts.join('-') + '-id',
|
|
58
|
+
table: parts.slice(0, -1).concat(plural(parts[parts.length - 1])).join('_')
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
23
63
|
function secret() {
|
|
24
64
|
return crypto.randomBytes(32).toString('hex');
|
|
25
65
|
}
|
|
@@ -76,6 +116,24 @@ async function collect(session, argvName) {
|
|
|
76
116
|
}
|
|
77
117
|
});
|
|
78
118
|
|
|
119
|
+
// ── tenancy ───────────────────────────────────────────────────────────
|
|
120
|
+
//
|
|
121
|
+
// Whether data belongs to companies (or workspaces, or both). Off, the app
|
|
122
|
+
// has no tenancy at all. On, every row is stamped with the company it was
|
|
123
|
+
// made in, every query sees only that company's rows, and picking one is
|
|
124
|
+
// the first screen after signing in.
|
|
125
|
+
var tenancy = [];
|
|
126
|
+
var multiTenant = await session.confirm('Multi-tenant — does data belong to companies (or similar)?', false);
|
|
127
|
+
if (multiTenant) {
|
|
128
|
+
var levelsText = await session.ask(' Tenancy levels, outermost first?', {
|
|
129
|
+
default: 'company',
|
|
130
|
+
validate: function (v) {
|
|
131
|
+
try { tenancyLevels(v); return true; } catch (err) { return err.message; }
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
tenancy = tenancyLevels(levelsText);
|
|
135
|
+
}
|
|
136
|
+
|
|
79
137
|
// ── the database ──────────────────────────────────────────────────────
|
|
80
138
|
//
|
|
81
139
|
// ASKED, not defaulted, and not left for the reader to do by hand either.
|
|
@@ -115,6 +173,7 @@ async function collect(session, argvName) {
|
|
|
115
173
|
ports: { ui: base, auth: base + 1, api: base + 2 },
|
|
116
174
|
databases: { auth: name + '_auth', api: name + '_api' },
|
|
117
175
|
admin: { email: adminEmail, password: adminPassword },
|
|
176
|
+
tenancy: tenancy,
|
|
118
177
|
|
|
119
178
|
// GENERATED, never asked. Nobody should be inventing these by hand, and a
|
|
120
179
|
// prompt invites a weak answer or a reused one.
|
|
@@ -122,4 +181,4 @@ async function collect(session, argvName) {
|
|
|
122
181
|
};
|
|
123
182
|
}
|
|
124
183
|
|
|
125
|
-
module.exports = { collect: collect, envPrefix: envPrefix, NAME_RE: NAME_RE };
|
|
184
|
+
module.exports = { collect: collect, envPrefix: envPrefix, tenancyLevels: tenancyLevels, NAME_RE: NAME_RE };
|
package/lib/setup.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
var { spawn } = require('child_process');
|
|
2
|
+
var path = require('path');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The steps that turn written files into a project that starts: packages
|
|
6
|
+
* installed, both databases created, sign-in tables and permissions in place.
|
|
7
|
+
*
|
|
8
|
+
* The database step needs the connection. Without one it is left out, and
|
|
9
|
+
* nextSteps says how to finish.
|
|
10
|
+
*/
|
|
11
|
+
function steps(answers, target) {
|
|
12
|
+
var api = path.join(target, 'api');
|
|
13
|
+
var ui = path.join(target, 'ui');
|
|
14
|
+
var list = [
|
|
15
|
+
{ label: 'Installing the API packages', cwd: api, args: ['install'] },
|
|
16
|
+
{ label: 'Installing the UI packages', cwd: ui, args: ['install'] }
|
|
17
|
+
];
|
|
18
|
+
if (answers.connection) {
|
|
19
|
+
list.push({ label: 'Creating the databases, sign-in tables and permissions', cwd: api, args: ['run', 'setup'] });
|
|
20
|
+
}
|
|
21
|
+
return list;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* npm, streaming its output as it goes — an install is minutes of silence
|
|
26
|
+
* otherwise, and its own error text is the most useful thing to see if it
|
|
27
|
+
* fails.
|
|
28
|
+
*
|
|
29
|
+
* shell on Windows only: npm is npm.cmd there, and Node refuses to spawn a
|
|
30
|
+
* .cmd without a shell. Nowhere else does anything go through one.
|
|
31
|
+
*/
|
|
32
|
+
function npm(args, cwd, runner) {
|
|
33
|
+
if (runner) return runner(args, cwd);
|
|
34
|
+
return new Promise(function (resolve) {
|
|
35
|
+
var child = spawn('npm', args, { cwd: cwd, stdio: 'inherit', shell: process.platform === 'win32' });
|
|
36
|
+
child.on('error', function (err) { console.error(' ' + err.message); resolve(1); });
|
|
37
|
+
child.on('exit', function (code) { resolve(code === null ? 1 : code); });
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param options.runner (args, cwd) → Promise<exit code>; for tests
|
|
43
|
+
* @param options.log line printer (default console.log)
|
|
44
|
+
* @returns {Promise<{ ok: boolean, databases: boolean, failed?: object }>}
|
|
45
|
+
*/
|
|
46
|
+
async function run(answers, target, options) {
|
|
47
|
+
options = options || {};
|
|
48
|
+
var log = options.log || console.log;
|
|
49
|
+
var list = steps(answers, target);
|
|
50
|
+
for (var i = 0; i < list.length; i++) {
|
|
51
|
+
var step = list[i];
|
|
52
|
+
log('\n ' + step.label + '… (npm ' + step.args.join(' ') + ', in ' + path.relative(process.cwd(), step.cwd) + ')\n');
|
|
53
|
+
var code = await npm(step.args, step.cwd, options.runner);
|
|
54
|
+
if (code !== 0) return { ok: false, databases: false, failed: step };
|
|
55
|
+
}
|
|
56
|
+
return { ok: true, databases: !!answers.connection };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { run: run, steps: steps };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xeplr/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Creates a working xeplr application — API, sign-in and UI — from a few answers.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "xeppie <admin@xeplr.com>",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"bin/",
|
|
32
32
|
"lib/",
|
|
33
33
|
"templates/",
|
|
34
|
+
"templates-tenancy/",
|
|
34
35
|
"README.md"
|
|
35
36
|
],
|
|
36
37
|
"dependencies": {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# __NAME__
|
|
2
|
+
|
|
3
|
+
An xeplr app: `api/` (Node, @xeplr/base-apis, sign-in by @xeplr/auth) and `ui/` (React + Vite, @xeplr/ui-account).
|
|
4
|
+
Forms are **screens** made with @xeplr/ui-factory and served by @xeplr/factory. The sample is **Tasks**.
|
|
5
|
+
|
|
6
|
+
Read `api/node_modules/@xeplr/ui-factory/AUTHORING.md` for what screens, hooks and models mean. This file says where they go in **this** app.
|
|
7
|
+
|
|
8
|
+
## Create a new UI
|
|
9
|
+
|
|
10
|
+
A new UI is a **form**: a list, and an add / edit form it opens in a popup, saved in its own table. Use the singular for `<form>` (`farming_department`), the plural for the page (`FarmingDepartments`).
|
|
11
|
+
|
|
12
|
+
Create every file below — the hooks and the model run the defaults (`super`) until the request needs more.
|
|
13
|
+
|
|
14
|
+
1. **The spec** — `api/screens/<form>/<form>.entity.json`: the name and the fields in reading order. Copy `api/screens/task/task.entity.json`; the format is "The entity spec" in AUTHORING.md. A dropdown on another form's table needs that form to exist first.
|
|
15
|
+
2. **Screens + server files** — from `api/`:
|
|
16
|
+
```sh
|
|
17
|
+
npx xeplr-factory screens screens/<form>/<form>.entity.json -o screens/<form> --no-pages
|
|
18
|
+
npx xeplr-factory validate screens/<form>/<form>-edit.screen.json
|
|
19
|
+
npx xeplr-factory validate screens/<form>/<form>-list.screen.json
|
|
20
|
+
```
|
|
21
|
+
Writes `<form>-list.screen.json`, `<form>-edit.screen.json`, `<form>.hooks.js` (server hooks), `<form>.model.js` (server model).
|
|
22
|
+
3. **Register them** in `api/screens/index.js`: both screens in `documents` (edit before list), `<form>_edit: require('./<form>/<form>.hooks')` in `hooks`, `require('./<form>/<form>.model')` in `models`.
|
|
23
|
+
4. **The form's page, with the front-end hooks** — `ui/src/pages/Edit<Form>.jsx`: copy `EditTask.jsx`; rename `TaskHooks` → `<Form>Hooks`, `taskHooks` → `<form>Hooks`, `'task_edit'` → `'<form>_edit'`. Keep every method calling `super`.
|
|
24
|
+
5. **The list's page** — `ui/src/pages/<Forms>.jsx`: copy `Tasks.jsx`; load `'<form>_list'`, import `<form>Hooks` from `./Edit<Form>.jsx`, drop `TasksHelp`.
|
|
25
|
+
6. **Route and side-rail item** — `ui/src/App.jsx`: import the list page, add `<Route path="/<forms>" element={<Forms />} />` next to `/tasks`, and a `drawerItems` entry with an **icon** (a collapsed rail shows icons only).
|
|
26
|
+
7. **The menu row** — `api/migrations-auth/000N_<forms>_menu.sql` (next free number): copy `0002_tasks_menu.sql`, with the item's name spelled **exactly** as in `drawerItems` — an unknown name is dropped silently. Then, in `api/`: `npm run migrate:auth`, and restart the sign-in service (menus are cached for 10 minutes).
|
|
27
|
+
8. **Restart the API.** It publishes screens that were never published — creating the table — and logs `[api] published screens: <form>_edit, <form>_list`.
|
|
28
|
+
9. **Check** — open the page, New, fill in the form; the popup says "All changes saved" and the row is in the table.
|
|
29
|
+
|
|
30
|
+
Then change only what the request asks for:
|
|
31
|
+
|
|
32
|
+
| the request says… | change |
|
|
33
|
+
|---|---|
|
|
34
|
+
| a field, label, rule, dropdown, layout, colour | the screen JSON → validate → publish on the Forms page (or, never published yet, restart the API) |
|
|
35
|
+
| hide / filter rows on screen, an extra button on each row, fill in a value before saving | front-end hooks — `ui/src/pages/Edit<Form>.jsx` |
|
|
36
|
+
| must / only if / check against / email when / only managers see | server hooks — `api/screens/<form>/<form>.hooks.js` |
|
|
37
|
+
| store as / convert / comma-separated ↔ array / work out X from Y | server model — `api/screens/<form>/<form>.model.js` |
|
|
38
|
+
| a query in your own server code | `factory.table('<forms table>')` — this company, active rows, the model applied |
|
|
39
|
+
|
|
40
|
+
**Without code:** Super Admin can also make a form on the **Forms** page (New form → design → Publish → Open). It gets no page, hooks or model files; add them with the steps above when it needs them.
|
|
41
|
+
|
|
42
|
+
### The prompt
|
|
43
|
+
|
|
44
|
+
> Create a new UI for **farming departments**. One record is a farming department with: **Name** (required), **Region** (dropdown: North, South, East, West), **Area in acres** (number, at least 0), **Started on** (date), **Notes** (long text). Show Name, Region and Area in the list. Follow "Create a new UI" in CLAUDE.md.
|
|
45
|
+
|
|
46
|
+
Replace the bold parts. Add behaviour in the same message if you want it — "a Mark active button on each row", "Region is required when Area is over 100", "store tags as an array".
|
|
47
|
+
|
|
48
|
+
## Rules
|
|
49
|
+
|
|
50
|
+
- **Records live in real tables, one column per field — never JSON.** A screen's design is the only JSON, in the `factory_screens` table.
|
|
51
|
+
- **Never rename a field that is already a column** — it would drop the column and its data. Add a new field instead.
|
|
52
|
+
- A published screen's design lives in the **database**. Editing its `.screen.json` changes nothing afterwards: publish the change on the Forms page.
|
|
53
|
+
- Every data address is under `/api` (a page and a data path must never collide). New routes go in `api/routes/index.js`, and `ui/vite.config.js` must proxy their prefix.
|
|
54
|
+
- Designing and publishing are **Super Admin only** (`api/routes/access.js`); every other route checks the permission catalog.
|
|
55
|
+
- The browser is not a security boundary: anything that must hold goes in server hooks.
|
|
56
|
+
- Hooks and models are **methods** calling `super` — never arrow-function properties.
|
|
57
|
+
- `authFetch` returns the parsed body, not a Response.
|
package/templates/api/app.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
var createApp = require('@xeplr/base-apis/express');
|
|
2
|
+
var factory = require('@xeplr/factory');
|
|
2
3
|
var routes = require('./routes');
|
|
4
|
+
var access = require('./routes/access');
|
|
5
|
+
__MT_APP_REQUIRE__
|
|
6
|
+
|
|
7
|
+
// Every screen route first checks the caller may use the request's
|
|
8
|
+
// __MT_MEMBER_NOUN__.
|
|
9
|
+
var memberGate = __MT_MEMBER_GATE__;
|
|
3
10
|
|
|
4
11
|
// No `|| __API_PORT__` fallback — the port is required in env.required.js, so a
|
|
5
12
|
// default could only ever fire in a process that skipped that check.
|
|
@@ -16,6 +23,29 @@ module.exports = function buildApp(extraRoutes) {
|
|
|
16
23
|
publicPaths: ['/health']
|
|
17
24
|
},
|
|
18
25
|
log: { logDir: process.env.LOG_DIR || './logs' },
|
|
19
|
-
|
|
26
|
+
__MT_APP_MIDDLEWARE__
|
|
27
|
+
routes: Object.assign({}, extraRoutes || {}, {
|
|
28
|
+
'/': routes,
|
|
29
|
+
__MT_APP_ROUTES__
|
|
30
|
+
|
|
31
|
+
// THE SCREENS — every form, one set of routes:
|
|
32
|
+
// /api/factory/screens/... designs: load, save draft, publish
|
|
33
|
+
// /api/factory/records/... a screen's records: list, one, save, delete
|
|
34
|
+
//
|
|
35
|
+
// access: true — each route answers only a caller whose permissions name
|
|
36
|
+
// it ("Save factory record", "Publish factory screen", ...). Granted to
|
|
37
|
+
// Super Admin and CompanyAdmin (everything), Creator (use the screens) and
|
|
38
|
+
// Viewer (read) by node_modules/@xeplr/factory/migrations-auth.
|
|
39
|
+
'/api': factory.router({
|
|
40
|
+
access: true,
|
|
41
|
+
auth: {
|
|
42
|
+
view: memberGate,
|
|
43
|
+
write: memberGate,
|
|
44
|
+
// Designing, new forms and publishing change tables: Super Admin
|
|
45
|
+
// only (routes/access.js).
|
|
46
|
+
design: [memberGate, access.superAdminOnly]
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
})
|
|
20
50
|
});
|
|
21
51
|
};
|