@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.
Files changed (50) hide show
  1. package/README.md +37 -13
  2. package/bin/xeplr.js +10 -4
  3. package/lib/generate/index.js +121 -3
  4. package/lib/nextSteps.js +33 -18
  5. package/lib/plan.js +8 -0
  6. package/lib/questions.js +60 -1
  7. package/lib/setup.js +59 -0
  8. package/package.json +2 -1
  9. package/templates/CLAUDE.md +57 -0
  10. package/templates/api/app.js +31 -1
  11. package/templates/api/bin/www +19 -4
  12. package/templates/api/db/setup.js +1 -0
  13. package/templates/api/development.env.tpl +8 -2
  14. package/templates/api/env.required.js +1 -1
  15. package/templates/api/migrations/README.md +9 -0
  16. package/templates/api/migrations-auth/0003_forms_menu.sql +19 -0
  17. package/templates/api/models/index.js +7 -6
  18. package/templates/api/package.json +5 -2
  19. package/templates/api/routes/access.js +25 -0
  20. package/templates/api/routes/index.js +6 -15
  21. package/templates/api/screens/index.js +39 -0
  22. package/templates/api/screens/task/task-edit.screen.json +103 -0
  23. package/templates/api/screens/task/task-list.screen.json +45 -0
  24. package/templates/api/screens/task/task.entity.json +10 -0
  25. package/templates/api/screens/task/task.hooks.js +37 -0
  26. package/templates/api/screens/task/task.model.js +29 -0
  27. package/templates/ui/package.json +4 -0
  28. package/templates/ui/src/App.jsx +22 -2
  29. package/templates/ui/src/api/factory.js +10 -0
  30. package/templates/ui/src/index.css +60 -0
  31. package/templates/ui/src/main.jsx +2 -0
  32. package/templates/ui/src/pages/EditTask.jsx +57 -0
  33. package/templates/ui/src/pages/FormDesigner.jsx +74 -0
  34. package/templates/ui/src/pages/FormRecords.jsx +24 -0
  35. package/templates/ui/src/pages/Forms.jsx +144 -0
  36. package/templates/ui/src/pages/Home.jsx +4 -0
  37. package/templates/ui/src/pages/Tasks.jsx +20 -94
  38. package/templates/ui/src/pages/TasksHelp.jsx +66 -50
  39. package/templates/ui/vite.config.js +1 -1
  40. package/templates-tenancy/api/migrations/0001_tenants.sql +8 -0
  41. package/templates-tenancy/api/migrations-auth/0004_switch_menu.sql +10 -0
  42. package/templates-tenancy/api/routes/tenants.js +126 -0
  43. package/templates-tenancy/api/tenancy.js +12 -0
  44. package/templates-tenancy/ui/src/pages/SelectScope.css +45 -0
  45. package/templates-tenancy/ui/src/pages/SelectScope.jsx +154 -0
  46. package/templates-tenancy/ui/src/tenancy.js +3 -0
  47. package/templates/api/migrations/0001_tasks.sql +0 -23
  48. package/templates/api/models/Task.js +0 -48
  49. package/templates/ui/src/api/tasks.js +0 -34
  50. 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,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 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 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 install
60
- npm run setup # creates both databases and their tables
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
- 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,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
- walk(TEMPLATES).forEach(function (src) {
124
- var rel = path.relative(TEMPLATES, src);
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
- * 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,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.2",
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.
@@ -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
- routes: Object.assign({}, extraRoutes || {}, { '/': routes })
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
  };