@xeplr/cli 0.1.1 → 0.1.3
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 +47 -13
- package/bin/xeplr.js +10 -4
- package/lib/generate/index.js +131 -4
- package/lib/nextSteps.js +33 -18
- package/lib/plan.js +7 -0
- package/lib/questions.js +60 -1
- package/lib/setup.js +59 -0
- package/package.json +2 -1
- package/templates/api/app.js +20 -1
- package/templates/api/bin/www +18 -4
- package/templates/api/db/setup.js +1 -0
- package/templates/api/development.env.tpl +8 -2
- package/templates/api/migrations/README.md +9 -0
- package/templates/api/migrations-auth/0003_designer_menu.sql +19 -0
- package/templates/api/models/index.js +7 -6
- package/templates/api/package.json +5 -3
- package/templates/api/routes/index.js +6 -15
- package/templates/api/screens/index.js +32 -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/ui/package.json +4 -0
- package/templates/ui/src/App.jsx +18 -2
- package/templates/ui/src/api/factory.js +10 -0
- package/templates/ui/src/index.css +25 -0
- package/templates/ui/src/main.jsx +2 -0
- package/templates/ui/src/pages/Designer.jsx +74 -0
- package/templates/ui/src/pages/Home.jsx +4 -0
- package/templates/ui/src/pages/Tasks.jsx +18 -94
- package/templates/ui/src/pages/TasksHelp.jsx +35 -33
- 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 +130 -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,8 +23,18 @@ 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 Designer page** — move, add and restyle fields, then Publish; the table
|
|
32
|
+
gains its new columns as you do. Only the admin roles see it, and the API
|
|
33
|
+
refuses everyone else.
|
|
34
|
+
- **Multi-tenancy, if you ask for it** — companies (or companies and
|
|
35
|
+
workspaces, up to four levels). Every row is stamped with the one it was made
|
|
36
|
+
in, every read sees only its rows, membership is checked on every request,
|
|
37
|
+
and choosing one is the first screen after signing in.
|
|
28
38
|
- **A welcome screen** that checks it can reach everything and tells you which
|
|
29
39
|
files to change first.
|
|
30
40
|
|
|
@@ -32,23 +42,45 @@ Already working, with nothing to wire up:
|
|
|
32
42
|
|
|
33
43
|
- **Node.js** 18 or later
|
|
34
44
|
- **PostgreSQL**, running, with a user allowed to create databases
|
|
45
|
+
- **Redis**, running — sign-in sessions are kept there
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# macOS
|
|
49
|
+
brew services start redis
|
|
50
|
+
|
|
51
|
+
# Linux
|
|
52
|
+
sudo systemctl start redis
|
|
53
|
+
|
|
54
|
+
# Windows — Redis has no native build, so Docker or WSL
|
|
55
|
+
docker run -d -p 6379:6379 redis
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Without Redis, login appears to work and then every request is refused as
|
|
59
|
+
"Invalid or expired token". The sign-in service checks for it at startup and
|
|
60
|
+
refuses to start rather than let you meet that message.
|
|
61
|
+
|
|
62
|
+
Works on macOS, Linux and Windows. Nothing it generates depends on a unix
|
|
63
|
+
shell.
|
|
35
64
|
|
|
36
65
|
## Then
|
|
37
66
|
|
|
67
|
+
The installer has already run `npm install` in both halves and — when you gave
|
|
68
|
+
the database connection — `npm run setup`, which creates both databases, the
|
|
69
|
+
sign-in tables and the permissions. Start it:
|
|
70
|
+
|
|
38
71
|
```bash
|
|
39
|
-
cd myapp/api
|
|
40
|
-
npm
|
|
41
|
-
npm run
|
|
42
|
-
npm run start-auth # sign-in
|
|
43
|
-
npm run start-api # your API
|
|
44
|
-
|
|
45
|
-
cd ../ui
|
|
46
|
-
npm install
|
|
47
|
-
npm run dev
|
|
72
|
+
cd myapp/api && npm run start-auth # sign-in
|
|
73
|
+
cd myapp/api && npm run start-api # your API — publishes the sample screens on first start
|
|
74
|
+
cd myapp/ui && npm run dev # the app
|
|
48
75
|
```
|
|
49
76
|
|
|
50
77
|
Open the UI and sign in with the account you named.
|
|
51
78
|
|
|
79
|
+
`xeplr new myapp --no-install` only writes the files; then run `npm install` in
|
|
80
|
+
`api` and `ui`, and `npm run setup` in `api`, yourself. If set-up stops part
|
|
81
|
+
way (Postgres not running, say), the installer says which step and the command
|
|
82
|
+
to run again.
|
|
83
|
+
|
|
52
84
|
## Changing the database connection later
|
|
53
85
|
|
|
54
86
|
A password rotates, the database moves, or the first answer was wrong. From
|
|
@@ -87,6 +119,8 @@ no defaults, ever.
|
|
|
87
119
|
| Port decade | three consecutive ports — ui, sign-in, api |
|
|
88
120
|
| Super admin email | the account you sign in with |
|
|
89
121
|
| Super admin password | its password |
|
|
122
|
+
| Multi-tenant? | no — or the levels, outermost first: `company`, `company, workspace` |
|
|
123
|
+
| Database connection | host, port, user, password — or skip, and fill it in later |
|
|
90
124
|
|
|
91
125
|
Encryption keys and signing secrets are **generated, never asked**.
|
|
92
126
|
|
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,113 @@ 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_FACTORY_AUTH__: on ? [
|
|
81
|
+
' // …and only for a ' + first.label.toLowerCase() + ' the caller belongs to.',
|
|
82
|
+
' auth: tenants.memberGate'
|
|
83
|
+
].join('\n') : '',
|
|
84
|
+
|
|
85
|
+
// ui/src/main.jsx
|
|
86
|
+
__MT_UI_IMPORTS__: on ? [
|
|
87
|
+
"import { registerMTs } from '@xeplr/ui-account'",
|
|
88
|
+
"import { tenancy } from './tenancy.js'"
|
|
89
|
+
].join('\n') : '',
|
|
90
|
+
__MT_UI_REGISTER__: on ? [
|
|
91
|
+
'',
|
|
92
|
+
'// TENANCY — the same levels as the API. authFetch then sends the chosen',
|
|
93
|
+
'// ' + levels.map(function (l) { return l.label.toLowerCase(); }).join(' and ') + ' on every request.',
|
|
94
|
+
'registerMTs(tenancy.slots)'
|
|
95
|
+
].join('\n') : '',
|
|
96
|
+
|
|
97
|
+
// ui/src/App.jsx
|
|
98
|
+
__MT_APP_UI_IMPORTS__: on ? "import SelectScope, { ScopeGate } from './pages/SelectScope.jsx'" : '',
|
|
99
|
+
__MT_DRAWER_ITEM__: on ? " { name: 'Switch " + first.label.toLowerCase() + "', icon: SwitchIcon, clickHandler: () => navigate('/select') }," : '',
|
|
100
|
+
__MT_SWITCH_ICON__: on ? [
|
|
101
|
+
'const SwitchIcon = (',
|
|
102
|
+
' <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"',
|
|
103
|
+
' strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">',
|
|
104
|
+
' <path d="M17 1l4 4-4 4" /><path d="M3 11V9a4 4 0 0 1 4-4h14" />',
|
|
105
|
+
' <path d="M7 23l-4-4 4-4" /><path d="M21 13v2a4 4 0 0 1-4 4H3" />',
|
|
106
|
+
' </svg>',
|
|
107
|
+
')',
|
|
108
|
+
''
|
|
109
|
+
].join('\n') : '',
|
|
110
|
+
__MT_SELECT_ROUTE__: on ? [
|
|
111
|
+
' {/* Picking the ' + levels.map(function (l) { return l.label.toLowerCase(); }).join(' and ') + ' — signed in, but outside the gate below. */}',
|
|
112
|
+
' <Route element={<ProtectedRoute><Shell /></ProtectedRoute>}>',
|
|
113
|
+
' <Route path="/select" element={<SelectScope />} />',
|
|
114
|
+
' </Route>',
|
|
115
|
+
''
|
|
116
|
+
].join('\n') : '',
|
|
117
|
+
__MT_GATE_OPEN__: on ? '<ScopeGate>' : '',
|
|
118
|
+
__MT_GATE_CLOSE__: on ? '</ScopeGate>' : ''
|
|
32
119
|
};
|
|
33
120
|
}
|
|
34
121
|
|
|
122
|
+
/** One level's own table: what the picker lists. Its parents' ids in mtIdN. */
|
|
123
|
+
function tenantTableSql(level, i) {
|
|
124
|
+
var parents = i === 0 ? 'none — the outermost level' : i === 1 ? 'mtId1 holds the one above' : 'mtId1–mtId' + i + ' hold the ones above';
|
|
125
|
+
return [
|
|
126
|
+
'-- ' + level.label + ' (' + level.key + ', header ' + level.header + ') — parents: ' + parents + '.',
|
|
127
|
+
'CREATE TABLE IF NOT EXISTS "' + level.table + '" (',
|
|
128
|
+
' "id" varchar(25) PRIMARY KEY,',
|
|
129
|
+
' "name" varchar(200) NOT NULL,',
|
|
130
|
+
' "isActive" boolean DEFAULT true,',
|
|
131
|
+
' "mtId1" varchar(25),',
|
|
132
|
+
' "mtId2" varchar(25),',
|
|
133
|
+
' "mtId3" varchar(25),',
|
|
134
|
+
' "mtId4" varchar(25),',
|
|
135
|
+
' "recordCreatedDate" timestamptz DEFAULT now(),',
|
|
136
|
+
' "recordModifiedDate" timestamptz DEFAULT now(),',
|
|
137
|
+
' "recordCreatedBy" varchar(25),',
|
|
138
|
+
' "recordModifiedBy" varchar(25)',
|
|
139
|
+
');',
|
|
140
|
+
''
|
|
141
|
+
].join('\n');
|
|
142
|
+
}
|
|
143
|
+
|
|
35
144
|
/**
|
|
36
145
|
* The comment above the connection setting.
|
|
37
146
|
*
|
|
@@ -72,6 +181,11 @@ function connectionNote(answers) {
|
|
|
72
181
|
}
|
|
73
182
|
|
|
74
183
|
function fill(body, subs) {
|
|
184
|
+
// A line holding nothing but a token that expands to nothing goes entirely —
|
|
185
|
+
// the plain app's files read as if the tenancy lines were never there.
|
|
186
|
+
body = body.replace(/^[ \t]*(__[A-Z][A-Z0-9_]*[A-Z0-9]__)[ \t]*\r?\n/gm, function (line, token) {
|
|
187
|
+
return subs[token] === '' ? '' : line;
|
|
188
|
+
});
|
|
75
189
|
// The inner name must START and END with a letter or digit.
|
|
76
190
|
//
|
|
77
191
|
// Without that, a greedy match on `__PREFIX___PORT` takes `__PREFIX___` —
|
|
@@ -120,8 +234,14 @@ function generate(answers, target) {
|
|
|
120
234
|
var subs = substitutions(answers);
|
|
121
235
|
var written = [];
|
|
122
236
|
|
|
123
|
-
|
|
124
|
-
|
|
237
|
+
var sources = {};
|
|
238
|
+
walk(TEMPLATES).forEach(function (src) { sources[path.relative(TEMPLATES, src)] = src; });
|
|
239
|
+
if (answers.tenancy && answers.tenancy.length) {
|
|
240
|
+
walk(TENANCY_TEMPLATES).forEach(function (src) { sources[path.relative(TENANCY_TEMPLATES, src)] = src; });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
Object.keys(sources).forEach(function (rel) {
|
|
244
|
+
var src = sources[rel];
|
|
125
245
|
var dir = path.dirname(rel);
|
|
126
246
|
var base = path.basename(rel);
|
|
127
247
|
var outName = RENAME[base] || base;
|
|
@@ -136,7 +256,14 @@ function generate(answers, target) {
|
|
|
136
256
|
}
|
|
137
257
|
|
|
138
258
|
// bin/www is executed directly, so it has to keep the bit that says so.
|
|
139
|
-
|
|
259
|
+
//
|
|
260
|
+
// Wrapped because this is a unix idea. Windows has no executable bit —
|
|
261
|
+
// chmod there only touches the read-only flag — and a filesystem that
|
|
262
|
+
// refuses it entirely must not take the whole generation down over a
|
|
263
|
+
// permission that platform does not use.
|
|
264
|
+
if (outName === 'www') {
|
|
265
|
+
try { fs.chmodSync(dest, 0o755); } catch (err) { /* not a unix filesystem */ }
|
|
266
|
+
}
|
|
140
267
|
|
|
141
268
|
written.push(path.join(dir, outName));
|
|
142
269
|
});
|
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,6 +21,13 @@ 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');
|
|
26
33
|
L.push(' ENCRYPTION_KEY and AUTH_JWT_SECRET generated, not asked');
|
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.3",
|
|
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": {
|
package/templates/api/app.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
var createApp = require('@xeplr/base-apis/express');
|
|
2
|
+
var factory = require('@xeplr/factory');
|
|
2
3
|
var routes = require('./routes');
|
|
4
|
+
__MT_APP_REQUIRE__
|
|
3
5
|
|
|
4
6
|
// No `|| __API_PORT__` fallback — the port is required in env.required.js, so a
|
|
5
7
|
// default could only ever fire in a process that skipped that check.
|
|
@@ -16,6 +18,23 @@ module.exports = function buildApp(extraRoutes) {
|
|
|
16
18
|
publicPaths: ['/health']
|
|
17
19
|
},
|
|
18
20
|
log: { logDir: process.env.LOG_DIR || './logs' },
|
|
19
|
-
|
|
21
|
+
__MT_APP_MIDDLEWARE__
|
|
22
|
+
routes: Object.assign({}, extraRoutes || {}, {
|
|
23
|
+
'/': routes,
|
|
24
|
+
__MT_APP_ROUTES__
|
|
25
|
+
|
|
26
|
+
// THE SCREENS — every entity made with the Designer, one set of routes:
|
|
27
|
+
// /api/factory/screens/... designs: load, save draft, publish
|
|
28
|
+
// /api/factory/records/... a screen's records: list, one, save, delete
|
|
29
|
+
//
|
|
30
|
+
// access: true — each route answers only a caller whose permissions name
|
|
31
|
+
// it ("Save factory record", "Publish factory screen", ...). Granted to
|
|
32
|
+
// Super Admin and CompanyAdmin (everything), Creator (use the screens) and
|
|
33
|
+
// Viewer (read) by node_modules/@xeplr/factory/migrations-auth.
|
|
34
|
+
'/api': factory.router({
|
|
35
|
+
access: true,
|
|
36
|
+
__MT_FACTORY_AUTH__
|
|
37
|
+
})
|
|
38
|
+
})
|
|
20
39
|
});
|
|
21
40
|
};
|
package/templates/api/bin/www
CHANGED
|
@@ -32,6 +32,8 @@ require('@xeplr/base-apis').checkEnv(require('../env.required.js'), { appName: '
|
|
|
32
32
|
var path = require('path');
|
|
33
33
|
var { resolveConfig } = require('@xeplr/db');
|
|
34
34
|
var { up } = require('@xeplr/db').sqlMigrator;
|
|
35
|
+
var factory = require('@xeplr/factory');
|
|
36
|
+
var screens = require('../screens');
|
|
35
37
|
|
|
36
38
|
async function start() {
|
|
37
39
|
// Name the connection before anything uses it — the migrator resolves by
|
|
@@ -40,18 +42,30 @@ async function start() {
|
|
|
40
42
|
|
|
41
43
|
// Migrations run ON BOOT, so starting the API is the only thing anyone has
|
|
42
44
|
// to remember. Already-applied ones are skipped.
|
|
45
|
+
//
|
|
46
|
+
// The screens' own table (factory_screens) first, then this app's.
|
|
43
47
|
var result = await up({
|
|
44
48
|
db: process.env.DB_API,
|
|
45
|
-
dir:
|
|
49
|
+
dir: factory.migrationsDir,
|
|
50
|
+
extDir: path.join(__dirname, '..', 'migrations'),
|
|
46
51
|
connectionName: 'api'
|
|
47
52
|
});
|
|
48
53
|
console.log(result.migrations.length
|
|
49
54
|
? '[api] ran ' + result.migrations.length + ' migrations'
|
|
50
55
|
: '[api] migrations up to date');
|
|
51
56
|
|
|
52
|
-
// AWAITED before the app is built:
|
|
53
|
-
//
|
|
54
|
-
await require('../db/setup');
|
|
57
|
+
// AWAITED before the app is built: a model handed to genericRoute at build
|
|
58
|
+
// time has no connection at all until this has finished.
|
|
59
|
+
var knex = await require('../db/setup');
|
|
60
|
+
|
|
61
|
+
// The screens. A hooks file with a typo ("beforeSave") stops startup here,
|
|
62
|
+
// rather than silently never running.
|
|
63
|
+
await factory.init({ knex: knex, hooks: screens.hooks });
|
|
64
|
+
|
|
65
|
+
// Screens never published yet are published now, creating their tables.
|
|
66
|
+
// Ones already published are left alone — the Designer owns them.
|
|
67
|
+
var published = await factory.publishScreens(screens.documents);
|
|
68
|
+
if (published.published.length) console.log('[api] published screens: ' + published.published.join(', '));
|
|
55
69
|
|
|
56
70
|
require('../app')();
|
|
57
71
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// wire the models to it. Exports the connection promise, awaited by callers
|
|
3
3
|
// that need it ready.
|
|
4
4
|
var { getConnection } = require('@xeplr/db');
|
|
5
|
+
__MT_DB_REGISTER__
|
|
5
6
|
|
|
6
7
|
// No `|| '__NAME___api'` fallback. DB_API is required at startup
|
|
7
8
|
// (env.required.js), so a default could only ever fire in a process that
|