@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.
- package/LICENSE +21 -0
- package/README.md +95 -0
- package/bin/xeplr.js +77 -0
- package/lib/commands/encrypt.js +144 -0
- package/lib/generate/index.js +147 -0
- package/lib/nextSteps.js +39 -0
- package/lib/plan.js +44 -0
- package/lib/prompt.js +130 -0
- package/lib/questions.js +125 -0
- package/package.json +39 -0
- package/templates/api/app.js +21 -0
- package/templates/api/bin/www +62 -0
- package/templates/api/db/setup.js +10 -0
- package/templates/api/development.env.tpl +47 -0
- package/templates/api/env.required.js +29 -0
- package/templates/api/gitignore.tpl +2 -0
- package/templates/api/migrations/0001_tasks.sql +23 -0
- package/templates/api/migrations-auth/0001_home_menu.sql +14 -0
- package/templates/api/migrations-auth/0002_tasks_menu.sql +11 -0
- package/templates/api/models/Task.js +48 -0
- package/templates/api/models/index.js +7 -0
- package/templates/api/package.json +30 -0
- package/templates/api/routes/index.js +46 -0
- package/templates/ui/dotenv.tpl +8 -0
- package/templates/ui/gitignore.tpl +2 -0
- package/templates/ui/index.html +12 -0
- package/templates/ui/package.json +21 -0
- package/templates/ui/public/logo-wide.svg +6 -0
- package/templates/ui/public/logo.svg +4 -0
- package/templates/ui/src/App.jsx +84 -0
- package/templates/ui/src/api/tasks.js +34 -0
- package/templates/ui/src/index.css +275 -0
- package/templates/ui/src/main.jsx +27 -0
- package/templates/ui/src/pages/Home.jsx +73 -0
- package/templates/ui/src/pages/Tasks.jsx +106 -0
- package/templates/ui/src/pages/TasksHelp.jsx +70 -0
- package/templates/ui/src/useTasksController.js +83 -0
- 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 };
|
package/lib/questions.js
ADDED
|
@@ -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,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,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;
|