@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.
Files changed (43) hide show
  1. package/README.md +47 -13
  2. package/bin/xeplr.js +10 -4
  3. package/lib/generate/index.js +131 -4
  4. package/lib/nextSteps.js +33 -18
  5. package/lib/plan.js +7 -0
  6. package/lib/questions.js +60 -1
  7. package/lib/setup.js +59 -0
  8. package/package.json +2 -1
  9. package/templates/api/app.js +20 -1
  10. package/templates/api/bin/www +18 -4
  11. package/templates/api/db/setup.js +1 -0
  12. package/templates/api/development.env.tpl +8 -2
  13. package/templates/api/migrations/README.md +9 -0
  14. package/templates/api/migrations-auth/0003_designer_menu.sql +19 -0
  15. package/templates/api/models/index.js +7 -6
  16. package/templates/api/package.json +5 -3
  17. package/templates/api/routes/index.js +6 -15
  18. package/templates/api/screens/index.js +32 -0
  19. package/templates/api/screens/task/task-edit.screen.json +103 -0
  20. package/templates/api/screens/task/task-list.screen.json +45 -0
  21. package/templates/api/screens/task/task.entity.json +10 -0
  22. package/templates/api/screens/task/task.hooks.js +37 -0
  23. package/templates/ui/package.json +4 -0
  24. package/templates/ui/src/App.jsx +18 -2
  25. package/templates/ui/src/api/factory.js +10 -0
  26. package/templates/ui/src/index.css +25 -0
  27. package/templates/ui/src/main.jsx +2 -0
  28. package/templates/ui/src/pages/Designer.jsx +74 -0
  29. package/templates/ui/src/pages/Home.jsx +4 -0
  30. package/templates/ui/src/pages/Tasks.jsx +18 -94
  31. package/templates/ui/src/pages/TasksHelp.jsx +35 -33
  32. package/templates/ui/vite.config.js +1 -1
  33. package/templates-tenancy/api/migrations/0001_tenants.sql +8 -0
  34. package/templates-tenancy/api/migrations-auth/0004_switch_menu.sql +10 -0
  35. package/templates-tenancy/api/routes/tenants.js +130 -0
  36. package/templates-tenancy/api/tenancy.js +12 -0
  37. package/templates-tenancy/ui/src/pages/SelectScope.css +45 -0
  38. package/templates-tenancy/ui/src/pages/SelectScope.jsx +154 -0
  39. package/templates-tenancy/ui/src/tenancy.js +3 -0
  40. package/templates/api/migrations/0001_tasks.sql +0 -23
  41. package/templates/api/models/Task.js +0 -48
  42. package/templates/ui/src/api/tasks.js +0 -34
  43. 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 four
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 form** — a table, a model, one line of routing, and a page, with
27
- instructions beside it showing how to add your own.
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 install
41
- npm run setup # creates both databases and their tables
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
- console.log(nextSteps.describe(answers, target));
71
- return 0;
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) {
@@ -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
- walk(TEMPLATES).forEach(function (src) {
124
- var rel = path.relative(TEMPLATES, src);
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
- if (outName === 'www') fs.chmodSync(dest, 0o755);
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
- * Printed at the end because the one thing a generator cannot do for you is
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 p = answers.prefix;
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 ' + answers.name + '/api && npm install && npm run db:encrypt',
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
- return head.concat([
24
- ' Then:',
25
- '',
26
- ' cd ' + answers.name + '/api && npm install && npm run setup',
27
- ' npm run start-auth # sign-in, port ' + answers.ports.auth,
28
- ' npm run start-api # your API, port ' + answers.ports.api,
29
- '',
30
- ' cd ../ui && npm install',
31
- ' npm run dev # the app, port ' + answers.ports.ui,
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 + ' and sign in as',
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.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": {
@@ -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
- routes: Object.assign({}, extraRoutes || {}, { '/': routes })
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
  };
@@ -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: path.join(__dirname, '..', 'migrations'),
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: routes/index.js hands MODEL CLASSES to
53
- // genericRoute at build time, and an unbound class has no connection at all.
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