@xeplr/cli 0.1.1

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 (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +95 -0
  3. package/bin/xeplr.js +77 -0
  4. package/lib/commands/encrypt.js +144 -0
  5. package/lib/generate/index.js +147 -0
  6. package/lib/nextSteps.js +39 -0
  7. package/lib/plan.js +44 -0
  8. package/lib/prompt.js +130 -0
  9. package/lib/questions.js +125 -0
  10. package/package.json +39 -0
  11. package/templates/api/app.js +21 -0
  12. package/templates/api/bin/www +62 -0
  13. package/templates/api/db/setup.js +10 -0
  14. package/templates/api/development.env.tpl +47 -0
  15. package/templates/api/env.required.js +29 -0
  16. package/templates/api/gitignore.tpl +2 -0
  17. package/templates/api/migrations/0001_tasks.sql +23 -0
  18. package/templates/api/migrations-auth/0001_home_menu.sql +14 -0
  19. package/templates/api/migrations-auth/0002_tasks_menu.sql +11 -0
  20. package/templates/api/models/Task.js +48 -0
  21. package/templates/api/models/index.js +7 -0
  22. package/templates/api/package.json +30 -0
  23. package/templates/api/routes/index.js +46 -0
  24. package/templates/ui/dotenv.tpl +8 -0
  25. package/templates/ui/gitignore.tpl +2 -0
  26. package/templates/ui/index.html +12 -0
  27. package/templates/ui/package.json +21 -0
  28. package/templates/ui/public/logo-wide.svg +6 -0
  29. package/templates/ui/public/logo.svg +4 -0
  30. package/templates/ui/src/App.jsx +84 -0
  31. package/templates/ui/src/api/tasks.js +34 -0
  32. package/templates/ui/src/index.css +275 -0
  33. package/templates/ui/src/main.jsx +27 -0
  34. package/templates/ui/src/pages/Home.jsx +73 -0
  35. package/templates/ui/src/pages/Tasks.jsx +106 -0
  36. package/templates/ui/src/pages/TasksHelp.jsx +70 -0
  37. package/templates/ui/src/useTasksController.js +83 -0
  38. package/templates/ui/vite.config.js +50 -0
package/lib/prompt.js ADDED
@@ -0,0 +1,130 @@
1
+ // Asking questions on a terminal, with nothing installed.
2
+ //
3
+ // NO DEPENDENCIES, deliberately. This is the first thing anybody runs, usually
4
+ // through `npx`, and every package it pulls is time spent before the first
5
+ // question and one more thing that can fail on a machine we have never seen.
6
+ // readline has been in Node since forever and does everything needed here.
7
+ var readline = require('readline');
8
+
9
+ /**
10
+ * WHY THIS QUEUES LINES INSTEAD OF CALLING rl.question() PER ANSWER.
11
+ *
12
+ * rl.question() registers a ONE-SHOT listener. On a terminal that is fine,
13
+ * because a human cannot type faster than we can re-register it. Piped input
14
+ * is not: readline reads the whole chunk and emits every 'line' synchronously,
15
+ * so each answer after the first arrives while no question is pending and is
16
+ * DROPPED ON THE FLOOR. The next question then waits for input that has
17
+ * already been and gone, and the process exits mid-wizard having silently
18
+ * discarded most of what it was given.
19
+ *
20
+ * So we listen for 'line' permanently and keep what arrives. A question takes
21
+ * the next queued line if one is waiting, or waits for the next one. Identical
22
+ * behaviour typed or piped — which matters, because piping answers is how this
23
+ * gets tested and how anybody scripts it.
24
+ */
25
+ function createSession(options) {
26
+ options = options || {};
27
+ var input = options.input || process.stdin;
28
+ var output = options.output || process.stdout;
29
+
30
+ var rl = readline.createInterface({ input: input, output: output });
31
+
32
+ var queued = []; // lines that arrived before anything asked for them
33
+ var waiting = []; // resolvers waiting for a line that has not arrived
34
+ var closed = false;
35
+
36
+ rl.on('line', function (line) {
37
+ if (waiting.length) return waiting.shift()(line);
38
+ queued.push(line);
39
+ });
40
+
41
+ rl.on('close', function () {
42
+ closed = true;
43
+ // Anything still waiting gets an empty string, so its default applies and
44
+ // the caller ends cleanly rather than hanging on a stream that is finished.
45
+ while (waiting.length) waiting.shift()('');
46
+ });
47
+
48
+ function nextLine() {
49
+ if (queued.length) return Promise.resolve(queued.shift());
50
+ if (closed) return Promise.resolve('');
51
+ return new Promise(function (resolve) { waiting.push(resolve); });
52
+ }
53
+
54
+ /**
55
+ * @param {string} question
56
+ * @param {object} [opts]
57
+ * @param {string} [opts.default] shown in brackets; returned on empty input
58
+ * @param {Function} [opts.validate] (value) => true | 'why it is wrong'
59
+ */
60
+ async function ask(question, opts) {
61
+ opts = opts || {};
62
+ var suffix = opts.default ? ' (' + opts.default + ')' : '';
63
+
64
+ for (;;) {
65
+ output.write(question + suffix + ' ');
66
+ var answer = await nextLine();
67
+ var value = (answer || '').trim() || opts.default || '';
68
+
69
+ if (!opts.validate) return value;
70
+
71
+ var verdict = opts.validate(value);
72
+ if (verdict === true) return value;
73
+
74
+ // Re-ask rather than exit. Being thrown out of a wizard on the fourth
75
+ // question because of a typo means starting over.
76
+ output.write(' ' + verdict + '\n');
77
+
78
+ // ...unless there is no more input to re-ask with. Piped or redirected
79
+ // input would otherwise loop forever printing the same complaint.
80
+ if (closed && !queued.length) {
81
+ throw new Error(question + ' — ' + verdict);
82
+ }
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Like ask(), but the typing does not appear on screen.
88
+ *
89
+ * Only when the input is a real terminal. Piped input has no echo to
90
+ * suppress, and trying to mute it there breaks reading altogether — which
91
+ * would make the one thing nobody can retype impossible to script.
92
+ */
93
+ async function askSecret(question) {
94
+ if (!input.isTTY) return ask(question);
95
+
96
+ output.write(question + ' ');
97
+ var muted = true;
98
+ var onData = function (char) {
99
+ // Stop hiding once the line ends, so the next prompt is visible.
100
+ if (char.toString() === '\n' || char.toString() === '\r') muted = false;
101
+ };
102
+ input.on('data', onData);
103
+
104
+ var originalWrite = output.write.bind(output);
105
+ output.write = function (chunk) {
106
+ if (muted) return true; // swallow the echo
107
+ return originalWrite(chunk);
108
+ };
109
+
110
+ try {
111
+ var value = await nextLine();
112
+ return (value || '').trim();
113
+ } finally {
114
+ output.write = originalWrite;
115
+ input.removeListener('data', onData);
116
+ originalWrite('\n');
117
+ }
118
+ }
119
+
120
+ async function confirm(question, defaultYes) {
121
+ var v = await ask(question + (defaultYes ? ' [Y/n]' : ' [y/N]'), {
122
+ default: defaultYes ? 'y' : 'n'
123
+ });
124
+ return /^y/i.test(v);
125
+ }
126
+
127
+ return { ask: ask, askSecret: askSecret, confirm: confirm, close: function () { rl.close(); } };
128
+ }
129
+
130
+ module.exports = { createSession: createSession };
@@ -0,0 +1,125 @@
1
+ var crypto = require('crypto');
2
+ var { encrypt } = require('@xeplr/utils/isomorphic/crypto');
3
+
4
+ // A name that works as a folder, an npm package name, AND a database name.
5
+ //
6
+ // Must start with a letter and END with a letter or digit. A trailing dash
7
+ // passes as a folder name and then fails twice over: `@thing-/api` is not a
8
+ // legal package name, and `thing-_api` is a database name that has to be
9
+ // quoted everywhere it appears.
10
+ var NAME_RE = /^[a-z][a-z0-9-]{0,39}[a-z0-9]$/;
11
+
12
+ // Ports are taken a DECADE at a time — ui, auth, api — so two products can run
13
+ // side by side without either being renumbered. xeplr-bi holds 19100, workflow
14
+ // 19120.
15
+ var TAKEN_DECADES = [19100, 19120];
16
+
17
+ function envPrefix(name) {
18
+ // yyan → YYAN, my-app → MY_APP. This becomes MY_APP_PORT and
19
+ // MY_APP_CONNECTION, so it has to be a legal env var name.
20
+ return name.toUpperCase().replace(/-/g, '_');
21
+ }
22
+
23
+ function secret() {
24
+ return crypto.randomBytes(32).toString('hex');
25
+ }
26
+
27
+ /**
28
+ * Everything the generator needs, collected from the person running this.
29
+ *
30
+ * ASKS AS LITTLE AS POSSIBLE. Anything that can be computed is computed,
31
+ * anything that can be generated is generated, and the one value that can be
32
+ * neither — the database connection — is deliberately left blank rather than
33
+ * guessed at. See lib/plan.js.
34
+ */
35
+ async function collect(session, argvName) {
36
+ // Generated up front: the connection below is encrypted with it.
37
+ var encryptionKey = secret();
38
+
39
+ var name = argvName;
40
+ if (!name || NAME_RE.test(name) !== true) {
41
+ name = await session.ask('Project name?', {
42
+ default: argvName || undefined,
43
+ validate: function (v) {
44
+ return NAME_RE.test(v) ? true
45
+ : 'Lowercase letters, numbers and dashes, starting with a letter.';
46
+ }
47
+ });
48
+ }
49
+
50
+ var decade = await session.ask('Port decade?', {
51
+ default: '19140',
52
+ validate: function (v) {
53
+ if (!/^\d{4,5}0$/.test(v)) return 'A number ending in 0, e.g. 19140.';
54
+ if (TAKEN_DECADES.indexOf(Number(v)) !== -1) {
55
+ return Number(v) + ' is already used by another xeplr product.';
56
+ }
57
+ return true;
58
+ }
59
+ });
60
+
61
+ var adminEmail = await session.ask('Super admin email?', {
62
+ default: 'admin@' + name + '.local',
63
+ validate: function (v) {
64
+ return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v) ? true : 'That is not an email address.';
65
+ }
66
+ });
67
+
68
+ var adminPassword = await session.ask('Super admin password?', {
69
+ default: 'ChangeMe123',
70
+ validate: function (v) {
71
+ // These are substituted into SQL text by the migrator, so the characters
72
+ // that would break that are refused here rather than at migrate time.
73
+ if (v.length < 8) return 'At least 8 characters.';
74
+ if (/["'\s$#]/.test(v)) return 'No quotes, spaces, $ or # — it is substituted into SQL.';
75
+ return true;
76
+ }
77
+ });
78
+
79
+ // ── the database ──────────────────────────────────────────────────────
80
+ //
81
+ // ASKED, not defaulted, and not left for the reader to do by hand either.
82
+ // The rule is that a database location is never GUESSED — asking is the
83
+ // opposite of guessing. Doing it here also spares everyone a long command
84
+ // with two --package flags whose result has to be pasted into two places.
85
+ //
86
+ // Declining is fine: the settings are written blank with the command above
87
+ // them, and the app refuses to start until they are filled in.
88
+ var connection = '';
89
+ var haveDb = await session.confirm('Set up the database connection now?', true);
90
+ if (haveDb) {
91
+ var dbHost = await session.ask(' Database host?', { default: 'localhost' });
92
+ var dbPort = await session.ask(' Database port?', {
93
+ default: '5432',
94
+ validate: function (v) { return /^\d+$/.test(v) ? true : 'A number.'; }
95
+ });
96
+ var dbUser = await session.ask(' Database user?', { default: 'postgres' });
97
+ var dbPass = await session.askSecret(' Database password?');
98
+
99
+ // Host, port, user and password are encrypted TOGETHER into one string —
100
+ // which is why a single setting is enough for the app to connect.
101
+ connection = await encrypt(JSON.stringify({
102
+ host: dbHost,
103
+ port: Number(dbPort),
104
+ user: dbUser,
105
+ password: dbPass
106
+ }), encryptionKey);
107
+ }
108
+
109
+ var base = Number(decade);
110
+
111
+ return {
112
+ connection: connection,
113
+ name: name,
114
+ prefix: envPrefix(name),
115
+ ports: { ui: base, auth: base + 1, api: base + 2 },
116
+ databases: { auth: name + '_auth', api: name + '_api' },
117
+ admin: { email: adminEmail, password: adminPassword },
118
+
119
+ // GENERATED, never asked. Nobody should be inventing these by hand, and a
120
+ // prompt invites a weak answer or a reused one.
121
+ secrets: { encryptionKey: encryptionKey, jwtSecret: secret() }
122
+ };
123
+ }
124
+
125
+ module.exports = { collect: collect, envPrefix: envPrefix, NAME_RE: NAME_RE };
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@xeplr/cli",
3
+ "version": "0.1.1",
4
+ "description": "Creates a working xeplr application — API, sign-in and UI — from a few answers.",
5
+ "license": "MIT",
6
+ "author": "xeppie <admin@xeplr.com>",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Xeplr/xeplr-cli.git"
10
+ },
11
+ "homepage": "https://github.com/Xeplr/xeplr-cli#readme",
12
+ "keywords": [
13
+ "xeplr",
14
+ "scaffold",
15
+ "generator",
16
+ "cli"
17
+ ],
18
+ "bin": {
19
+ "xeplr": "./bin/xeplr.js"
20
+ },
21
+ "engines": {
22
+ "node": ">=18"
23
+ },
24
+ "scripts": {
25
+ "test": "node --test test/*.test.js"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "files": [
31
+ "bin/",
32
+ "lib/",
33
+ "templates/",
34
+ "README.md"
35
+ ],
36
+ "dependencies": {
37
+ "@xeplr/utils": "^1.0.7"
38
+ }
39
+ }
@@ -0,0 +1,21 @@
1
+ var createApp = require('@xeplr/base-apis/express');
2
+ var routes = require('./routes');
3
+
4
+ // No `|| __API_PORT__` fallback — the port is required in env.required.js, so a
5
+ // default could only ever fire in a process that skipped that check.
6
+ var port = process.env.__PREFIX___PORT;
7
+
8
+ module.exports = function buildApp(extraRoutes) {
9
+ return createApp(port, '__NAME___api', {
10
+ // Every route needs a valid token, checked against the auth service at
11
+ // AUTH_URL. This app names no `middleware` of its own — it takes the
12
+ // framework's gate rather than writing one.
13
+ auth: {
14
+ // The only exception, and it earns it: a health check has no token to
15
+ // send. Prefixes, so '/health' also covers '/health/anything'.
16
+ publicPaths: ['/health']
17
+ },
18
+ log: { logDir: process.env.LOG_DIR || './logs' },
19
+ routes: Object.assign({}, extraRoutes || {}, { '/': routes })
20
+ });
21
+ };
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+
3
+ require('dotenv').config({ path: require('path').join(__dirname, '..', (process.env.NODE_ENV || 'development') + '.env') });
4
+
5
+ // ── the database connection, checked first and by name ──────────────────
6
+ //
7
+ // BEFORE the general check, because this one has a fix that nobody can guess
8
+ // from a variable name. The generic check can only say "this is missing";
9
+ // these two settings hold an ENCRYPTED value that has to be produced by a
10
+ // command, so a bare name sends somebody looking for a password to type into
11
+ // a field that will not accept one.
12
+ var CONNECTION_SETTINGS = ['__PREFIX___CONNECTION', 'AUTH_DB_CONNECTION_INFO_ENCRYPTED'];
13
+ var blank = CONNECTION_SETTINGS.filter(function (name) { return !process.env[name]; });
14
+
15
+ if (blank.length) {
16
+ console.error('\n[api] No database connection.\n');
17
+ console.error(' ' + blank.join(' and '));
18
+ console.error(' ' + (blank.length > 1 ? 'are' : 'is') + ' blank in development.env.\n');
19
+ console.error(' These hold your database host, port, user and password,');
20
+ console.error(' encrypted together into one value. To fill them in:\n');
21
+ console.error(' npm run db:encrypt\n');
22
+ console.error(' It asks for the details and writes both settings for you.');
23
+ console.error(' Non-interactively:\n');
24
+ console.error(' npm run db:encrypt -- --host localhost --port 5432 \\');
25
+ console.error(' --user postgres --password \'<yours>\' --write\n');
26
+ process.exit(1);
27
+ }
28
+
29
+ // Fail fast on the rest — shared check from @xeplr/base-apis.
30
+ require('@xeplr/base-apis').checkEnv(require('../env.required.js'), { appName: 'api' });
31
+
32
+ var path = require('path');
33
+ var { resolveConfig } = require('@xeplr/db');
34
+ var { up } = require('@xeplr/db').sqlMigrator;
35
+
36
+ async function start() {
37
+ // Name the connection before anything uses it — the migrator resolves by
38
+ // name from a registry that getConnection does not populate.
39
+ await resolveConfig('api', process.env.__PREFIX___CONNECTION);
40
+
41
+ // Migrations run ON BOOT, so starting the API is the only thing anyone has
42
+ // to remember. Already-applied ones are skipped.
43
+ var result = await up({
44
+ db: process.env.DB_API,
45
+ dir: path.join(__dirname, '..', 'migrations'),
46
+ connectionName: 'api'
47
+ });
48
+ console.log(result.migrations.length
49
+ ? '[api] ran ' + result.migrations.length + ' migrations'
50
+ : '[api] migrations up to date');
51
+
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');
55
+
56
+ require('../app')();
57
+ }
58
+
59
+ start().catch(function(err) {
60
+ console.error('[api] startup failed:', err.stack || err.message);
61
+ process.exit(1);
62
+ });
@@ -0,0 +1,10 @@
1
+ // One call: decrypt the connection, connect to this app's own database, and
2
+ // wire the models to it. Exports the connection promise, awaited by callers
3
+ // that need it ready.
4
+ var { getConnection } = require('@xeplr/db');
5
+
6
+ // No `|| '__NAME___api'` fallback. DB_API is required at startup
7
+ // (env.required.js), so a default could only ever fire in a process that
8
+ // skipped that check — and silently connecting to a database nobody named is
9
+ // exactly what the check exists to prevent.
10
+ module.exports = getConnection(process.env.DB_API, process.env.__PREFIX___CONNECTION);
@@ -0,0 +1,47 @@
1
+ # __NAME__ — development settings.
2
+ #
3
+ # Ports: __UI_PORT__ ui · __AUTH_PORT__ auth · __API_PORT__ api
4
+
5
+ NODE_ENV=development
6
+ LOG_DIR=./logs
7
+
8
+ # ── API service ──
9
+ __PREFIX___PORT=__API_PORT__
10
+
11
+ # ── Shared ──
12
+ # Locks and unlocks the database connections below. Generated for you; keep it.
13
+ # Lose it and the connection strings below have to be made again.
14
+ ENCRYPTION_KEY=__ENCRYPTION_KEY__
15
+
16
+ __CONNECTION_NOTE__
17
+ __PREFIX___CONNECTION=__DB_CONNECTION__
18
+ AUTH_DB_CONNECTION_INFO_ENCRYPTED=__DB_CONNECTION__
19
+
20
+ # ── Databases ──
21
+ # Two of them: accounts in one, your own data in the other. Both are created
22
+ # for you the first time you run the setup.
23
+ DB_API=__NAME___api
24
+ AUTH_DB_NAME=__NAME___auth
25
+
26
+ # ── Sign-in service ──
27
+ AUTH_PORT=__AUTH_PORT__
28
+ AUTH_JWT_SECRET=__JWT_SECRET__
29
+
30
+ # Where the sign-in service is. Your API asks it to check every token.
31
+ AUTH_URL=http://localhost:__AUTH_PORT__
32
+
33
+ # Your own sign-in setup files — menu entries and anything else this app adds.
34
+ XEPLR_AUTH_MIGRATIONS=./migrations-auth
35
+
36
+ # ── The first account you sign in with ──
37
+ # Created for you when you set up the database. Change the password here and
38
+ # delete the account to reset it.
39
+ AUTH_SUPER_ADMIN_EMAIL=__ADMIN_EMAIL__
40
+ AUTH_SUPER_ADMIN_PASSWORD=__ADMIN_PASSWORD__
41
+
42
+ # ── Links that go out in emails ──
43
+ # FULLY QUALIFIED, and they point at UI pages rather than at the API. The
44
+ # sign-in service builds these links and appends the token as ?token=... — an
45
+ # email template can carry a token as a value but cannot build an address.
46
+ AUTH_ACTIVATION_URL=http://localhost:__UI_PORT__/auth/activate
47
+ AUTH_INVITE_URL=http://localhost:__UI_PORT__/auth/accept-invite
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Mandatory env vars for the __NAME__ API process. Checked at startup
3
+ * (bin/www) and at build (`npm run check-env`).
4
+ *
5
+ * SPREAD EACH LIBRARY'S OWN LIST rather than re-typing its variable names.
6
+ * The names belong to the library, so a new requirement it adds lands here on
7
+ * the next upgrade with nothing to remember — and nothing drifts out of step.
8
+ * Only APP-SPECIFIC names are written out literally below.
9
+ */
10
+ module.exports = [
11
+ // ENCRYPTION_KEY, AUTH_JWT_SECRET, AUTH_PORT, AUTH_DB_NAME,
12
+ // XEPLR_AUTH_MIGRATIONS
13
+ ...require('@xeplr/auth').requiredEnv,
14
+
15
+ // ── this app's own ──
16
+ '__PREFIX___PORT',
17
+
18
+ // Where the sign-in service is. createApp gates every route by default and
19
+ // validates against this, so a missing one already stops the boot with a
20
+ // named variable — listing it here just moves the same complaint to
21
+ // `npm run check-env`, before anything starts at all.
22
+ 'AUTH_URL',
23
+
24
+ // This app's own database. NEVER defaulted: a wrong-but-present name boots
25
+ // clean, migrates, serves traffic and reads as EMPTY DATA rather than as an
26
+ // error — the most expensive way for this to go wrong.
27
+ 'DB_API',
28
+ '__PREFIX___CONNECTION'
29
+ ];
@@ -0,0 +1,2 @@
1
+ node_modules/
2
+ logs/
@@ -0,0 +1,23 @@
1
+ -- The sample table.
2
+ --
3
+ -- `id` is varchar(25) because ids are minted by the framework
4
+ -- (@xeplr/utils' generateId) rather than by the database.
5
+ --
6
+ -- `isActive` is what makes a delete SOFT: the generic delete route sets it
7
+ -- false and every query filters on it, so a deleted row stops appearing
8
+ -- without ever leaving the table.
9
+
10
+ CREATE TABLE IF NOT EXISTS "tasks" (
11
+ "id" varchar(25) PRIMARY KEY,
12
+ "title" varchar(200) NOT NULL,
13
+ "description" varchar(1000),
14
+ "status" varchar(20) NOT NULL DEFAULT 'todo',
15
+ "dueDate" date,
16
+ "isActive" boolean DEFAULT true,
17
+ "recordCreatedDate" timestamptz,
18
+ "recordModifiedDate" timestamptz,
19
+ "recordCreatedBy" varchar(25),
20
+ "recordModifiedBy" varchar(25)
21
+ );
22
+
23
+ CREATE INDEX IF NOT EXISTS "tasks_status_idx" ON "tasks" ("status");
@@ -0,0 +1,14 @@
1
+ -- "Home" — this app's own drawer item, matching the name used in the UI's
2
+ -- drawerItems list (ui/src/App.jsx).
3
+ --
4
+ -- Without this row the item is dropped from the drawer SILENTLY: the name has
5
+ -- to exist here for @xeplr/ui-account to show it, and an unrecognised one
6
+ -- produces no error anywhere.
7
+ --
8
+ -- isPublic: every signed-in user should see Home, whatever their role.
9
+
10
+ INSERT INTO "menus" (id, name, "menuGroup", "isPublic", "isActive", "mtId1", "recordCreatedDate", "recordModifiedDate")
11
+ SELECT encode(gen_random_bytes(12), 'hex'), 'Home', '', true, true, '*', now(), now()
12
+ WHERE NOT EXISTS (SELECT 1 FROM "menus" WHERE name = 'Home');
13
+
14
+ UPDATE "menus" SET "isPublic" = true WHERE name = 'Home';
@@ -0,0 +1,11 @@
1
+ -- "Tasks" — the drawer item for the sample page.
2
+ --
3
+ -- The name must match ui/src/App.jsx's drawerItems entry EXACTLY.
4
+ -- @xeplr/ui-account drops unrecognised names silently, so a typo removes the
5
+ -- item with no error anywhere.
6
+
7
+ INSERT INTO "menus" (id, name, "menuGroup", "isPublic", "isActive", "mtId1", "recordCreatedDate", "recordModifiedDate")
8
+ SELECT encode(gen_random_bytes(12), 'hex'), 'Tasks', '', true, true, '*', now(), now()
9
+ WHERE NOT EXISTS (SELECT 1 FROM "menus" WHERE name = 'Tasks');
10
+
11
+ UPDATE "menus" SET "isPublic" = true WHERE name = 'Tasks';
@@ -0,0 +1,48 @@
1
+ var { BaseModel } = require('@xeplr/db');
2
+
3
+ /**
4
+ * A task. The sample model — deliberately small, and deliberately showing the
5
+ * three things almost every real model needs: required fields, a value
6
+ * restricted to a known set, and a date.
7
+ *
8
+ * `jsonSchema` is not decoration. It is checked on every insert and update, so
9
+ * a bad value is refused by the API with a message naming the field, rather
10
+ * than reaching the database and failing as a constraint error nobody can read.
11
+ */
12
+ class Task extends BaseModel {
13
+ static get tableName() { return 'tasks'; }
14
+ static get idColumn() { return 'id'; }
15
+
16
+ // Rows are NOT scoped to a company/workspace here, because this app has not
17
+ // declared any tenancy levels. Leave it as the default (true) and add
18
+ // registerMTs() later, and every row written before that has no owner.
19
+ static get multiTenant() { return false; }
20
+
21
+ static get jsonSchema() {
22
+ return {
23
+ type: 'object',
24
+ required: ['title'],
25
+ properties: {
26
+ // 25 characters, because that is what the framework's own id generator
27
+ // produces (see genericController — ids are minted there, not here).
28
+ id: { type: 'string', maxLength: 25 },
29
+ title: { type: 'string', minLength: 1, maxLength: 200 },
30
+ description: { type: ['string', 'null'], maxLength: 1000 },
31
+ status: { type: 'string', enum: ['todo', 'in_progress', 'done'] },
32
+ dueDate: { type: ['string', 'null'] },
33
+ isActive: { type: 'boolean' },
34
+ recordCreatedDate: { type: ['string', 'null'] },
35
+ recordModifiedDate: { type: ['string', 'null'] },
36
+ recordCreatedBy: { type: ['string', 'null'], maxLength: 25 },
37
+ recordModifiedBy: { type: ['string', 'null'], maxLength: 25 }
38
+ }
39
+ };
40
+ }
41
+
42
+ $beforeInsert() {
43
+ super.$beforeInsert(); // keeps recordCreatedDate / isActive handling
44
+ if (!this.status) this.status = 'todo';
45
+ }
46
+ }
47
+
48
+ module.exports = Task;
@@ -0,0 +1,7 @@
1
+ // Every model this app owns. Add yours here — routes/index.js reads them from
2
+ // this one place rather than reaching into individual files.
3
+ var Task = require('./Task');
4
+
5
+ module.exports = {
6
+ Task: Task
7
+ };
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@__NAME__/api",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "license": "UNLICENSED",
6
+ "description": "__NAME__ backend — @xeplr/base-apis app.",
7
+ "main": "app.js",
8
+ "scripts": {
9
+ "check-env": "dotenv -e development.env -- xeplr-check-env",
10
+ "setup": "npm run db:create && npm run db:create:api && npm run migrate:auth",
11
+ "db:encrypt": "xeplr encrypt",
12
+ "db:create": "dotenv -e development.env -- xeplr-migrate create-db --connectionName auth --connection-env AUTH_DB_CONNECTION_INFO_ENCRYPTED --db-env AUTH_DB_NAME",
13
+ "db:create:api": "dotenv -e development.env -- xeplr-migrate create-db --connectionName api --connection-env __PREFIX___CONNECTION --db-env DB_API",
14
+ "migrate:auth": "dotenv -e development.env -- xeplr-auth-migrate up --connection-env AUTH_DB_CONNECTION_INFO_ENCRYPTED",
15
+ "migrate:up": "dotenv -e development.env -- xeplr-migrate up --dir ./migrations --connectionName api --connection-env __PREFIX___CONNECTION",
16
+ "start-auth": "dotenv -e ./development.env -- xeplr-auth-server",
17
+ "start-api": "NODE_ENV=development node ./bin/www"
18
+ },
19
+ "dependencies": {
20
+ "@xeplr/auth": "^1.1.0",
21
+ "@xeplr/base-apis": "^2.0.0",
22
+ "@xeplr/db": "^1.0.5",
23
+ "dotenv": "^17.4.2",
24
+ "express": "^5.2.0"
25
+ },
26
+ "devDependencies": {
27
+ "dotenv-cli": "^8.0.0",
28
+ "@xeplr/cli": "^0.1.0"
29
+ }
30
+ }
@@ -0,0 +1,46 @@
1
+ var express = require('express');
2
+ var { genericRoute } = require('@xeplr/base-apis');
3
+ var { Task } = require('../models');
4
+
5
+ var router = express.Router();
6
+
7
+ // PUBLIC — listed in app.js's auth.publicPaths. Whatever polls this has never
8
+ // signed in, so it has no token to send. It reports liveness and nothing else.
9
+ router.get('/health', function(req, res) {
10
+ res.json({ status: 'ok' });
11
+ });
12
+
13
+ // GATED, like everything not named public.
14
+ router.get('/', function(req, res) {
15
+ res.json({ service: 'api', status: 'running', name: '__NAME__-api' });
16
+ });
17
+
18
+ // Who is calling, and what they are allowed to reach. Both were put on the
19
+ // request by the auth gate, so this makes no call of its own.
20
+ router.get('/whoami', function(req, res) {
21
+ res.json({ user: req.user, roles: (req.access || {}).roles || [] });
22
+ });
23
+
24
+ // ── tasks ────────────────────────────────────────────────────────────────
25
+ //
26
+ // MOUNTED UNDER /api, and that prefix matters. The browser asks the UI's dev
27
+ // server for BOTH its own pages and its data, and the dev server decides by
28
+ // path which to forward. Serve data at /tasks and the UI's own /tasks page is
29
+ // shadowed by it — opening that page shows raw JSON instead of the app.
30
+ //
31
+ // Keeping every data address under /api means a page path and a data path can
32
+ // never collide, whatever either is called later.
33
+ //
34
+ // FOUR ADDRESSES FROM ONE LINE. Hand genericRoute a model and it builds:
35
+ //
36
+ // GET /api/tasks list, paginated (?page=1&limit=50)
37
+ // GET /api/tasks/:id one task
38
+ // POST /api/tasks/save create and update, in one transaction
39
+ // POST /api/tasks/delete soft delete by ids
40
+ //
41
+ // No handler is written for any of them. The model's own jsonSchema is what
42
+ // validates the incoming values, so the rules live in one place rather than
43
+ // being restated here.
44
+ router.use('/api/tasks', genericRoute({ key: 'task', model: Task }));
45
+
46
+ module.exports = router;