@xeplr/cli 0.1.11 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -9
- package/lib/generate/index.js +46 -75
- package/lib/questions.js +3 -42
- package/package.json +1 -1
- package/templates/CLAUDE.md +4 -4
- package/templates/README.md +50 -0
- package/templates/api/app.js +8 -2
- package/templates/api/bin/workflow +52 -0
- package/templates/api/bin/www +15 -1
- package/templates/api/env.required.js +0 -1
- package/templates/api/gitignore.tpl +5 -0
- package/templates/api/migrations-auth/0005_flows_menu.sql +14 -0
- package/templates/api/package.json +6 -4
- package/templates/api/production.env.example +39 -0
- package/templates/api/routes/flows.js +47 -0
- package/templates/api/routes/index.js +4 -1
- package/templates/api/screens/task/task-edit.screen.json +2 -2
- package/templates/api/workflow.js +34 -0
- package/templates/ui/README.md +2 -1
- package/templates/ui/dotenv.tpl +2 -1
- package/templates/ui/package.json +5 -3
- package/templates/ui/src/App.jsx +27 -6
- package/templates/ui/src/api/flows.js +8 -0
- package/templates/ui/src/index.css +19 -2
- package/templates/ui/src/menu.js +1 -0
- package/templates/ui/src/pages/ConfigureUI.jsx +4 -3
- package/templates/ui/src/pages/EditTask.jsx +5 -5
- package/templates/ui/src/pages/FlowDesigner.jsx +54 -0
- package/templates/ui/src/pages/Flows.jsx +125 -0
- package/templates/ui/src/pages/FlowsHome.jsx +65 -0
- package/templates/ui/src/pages/Journey.jsx +33 -0
- package/templates/ui/src/pages/Tasks.jsx +2 -2
- package/templates/ui/src/pages/TasksHelp.jsx +2 -1
- package/templates/ui/vite.config.js +7 -1
package/README.md
CHANGED
|
@@ -41,9 +41,12 @@ Already working, with nothing to wire up:
|
|
|
41
41
|
- **Menu**: rename, reorder and hide the side rail's items.
|
|
42
42
|
|
|
43
43
|
Nobody else sees it, and the API refuses everyone else.
|
|
44
|
-
- **Flows,
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
- **Flows, in every app** — your forms one after another, where what someone
|
|
45
|
+
fills in decides the next screen. Designed in Configure UI → Flows, walked at
|
|
46
|
+
`/journey/<key>`, and resumable. Run by `@xeplr/workflow` **inside the API**,
|
|
47
|
+
in the app's own database — nothing extra to start, no port, no setting. It
|
|
48
|
+
follows the app's tenancy (none, or the levels you chose) and never creates
|
|
49
|
+
companies or workspaces of its own.
|
|
47
50
|
- **Multi-tenancy, if you ask for it** — companies (or companies and
|
|
48
51
|
workspaces, up to four levels). Every row is stamped with the one it was made
|
|
49
52
|
in, every read sees only its rows, membership is checked on every request,
|
|
@@ -74,6 +77,7 @@ These are architecture decisions, not defaults to tune — each one exists becau
|
|
|
74
77
|
| **`REDIS_PREFIX` is required and unique per app** (`api/development.env`, set to `<name>:`). | `@xeplr/auth`, `@xeplr/utils` | Two apps on one Redis under the shared default serve each other's sessions, menus and API permissions. Sign-in refuses to start without it, or with `xeplr:`. |
|
|
75
78
|
| **Designing, new forms and publishing are Super Admin only.** | `api/routes/access.js`, `@xeplr/auth` menu routes | Publishing changes database tables. Every other screen route checks the permission catalog. |
|
|
76
79
|
| **The browser is not a security boundary.** Front-end hooks (`ui/src/pages/Edit<Form>.jsx`) shape what people see and send; anything that must hold goes in server hooks (`api/screens/<form>/<form>.hooks.js`); data shape in the model (`<form>.model.js`). | `@xeplr/ui-factory`, `@xeplr/factory` | |
|
|
80
|
+
| **Anything every app would write identically lives in a package, not in the generated app.** A generated app is a starting point that then lives its own life — glue copied into it can never be fixed centrally, so an app made last month keeps that copy forever. When the flow designer needed this app's forms as its Screen steps, the wiring went into `@xeplr/ui-factory` (`useScreenSource`) and the generated page passes two props. | `@xeplr/ui-factory`, `@xeplr/ui-workflow` | A fix or a new control type has to reach apps that already exist, and it cannot reach code that was copied into them. `test/cli.test.js` fails if the glue reappears in the generated app. |
|
|
77
81
|
| **Every data address is under `/api`.** | `api/routes`, `ui/vite.config.js` | A page path and a data path must never collide. |
|
|
78
82
|
| **No guessed database names or connections.** | `api/development.env` | A wrong-but-present value starts cleanly and reads as empty data. |
|
|
79
83
|
| **Email is checked at start, never fatal.** The sign-in banner shows `email ✓ …` or `✗ NOT WORKING — reason`. | `@xeplr/auth` | Sign-in works without email; activation, invite and reset links cannot be sent until `EMAIL_PROVIDER` is set. |
|
|
@@ -198,16 +202,17 @@ no defaults, ever.
|
|
|
198
202
|
| Super admin email | the account you sign in with |
|
|
199
203
|
| Super admin password | its password |
|
|
200
204
|
| Multi-tenant? | no — or the levels, outermost first: `company`, `company, workspace` |
|
|
201
|
-
| Flows — is Xeplr Workflow running? | no — or its address (`http://localhost:19122`) and its backend folder. Yes adds **Configure UI → Flows**, a `/journey/<key>` page, and `/api/flows` forwarded to Workflow — and sets multi-tenancy to Workflow's own levels, company then workspace, since it keeps every journey inside both |
|
|
202
205
|
| Database connection | host, port, user, password — or skip, and fill it in later |
|
|
203
206
|
|
|
204
207
|
Encryption keys and signing secrets are **generated, never asked**.
|
|
205
208
|
|
|
206
|
-
**Flows
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
209
|
+
**Flows, inside the API.** `@xeplr/workflow` is a dependency of `api/`, and
|
|
210
|
+
`bin/www` starts it with `registerWorkflow` after the screens — in the app's
|
|
211
|
+
own database (`DB_API`, its own migration record `workflow_migrations`), with
|
|
212
|
+
`tenantTables: false` so it follows the app's tenancy rather than creating
|
|
213
|
+
`companies` / `workspaces`, and `access: true` so each flows route checks the
|
|
214
|
+
caller's permissions, which its `migrations-auth` grants (loaded through
|
|
215
|
+
`XEPLR_AUTH_MIGRATIONS`).
|
|
211
216
|
|
|
212
217
|
## Licence
|
|
213
218
|
|
package/lib/generate/index.js
CHANGED
|
@@ -8,10 +8,6 @@ var TEMPLATES = path.join(__dirname, '..', '..', 'templates');
|
|
|
8
8
|
// with the same path as one there replaces it.
|
|
9
9
|
var TENANCY_TEMPLATES = path.join(__dirname, '..', '..', 'templates-tenancy');
|
|
10
10
|
|
|
11
|
-
// Files that exist ONLY in an app connected to Xeplr Workflow — the route that
|
|
12
|
-
// forwards flows to it, the designer, and the page a journey runs on.
|
|
13
|
-
var FLOWS_TEMPLATES = path.join(__dirname, '..', '..', 'templates-flows');
|
|
14
|
-
|
|
15
11
|
// Files whose names cannot ship as-is. `.env` and `.gitignore` are both
|
|
16
12
|
// swallowed on the way here — npm refuses to pack a .gitignore, and an .env in
|
|
17
13
|
// a published package is a mistake waiting to happen — so they travel under a
|
|
@@ -32,56 +28,51 @@ function substitutions(answers) {
|
|
|
32
28
|
__UI_PORT__: String(answers.ports.ui),
|
|
33
29
|
__AUTH_PORT__: String(answers.ports.auth),
|
|
34
30
|
__API_PORT__: String(answers.ports.api),
|
|
31
|
+
// The port after the API's — suggested for a service run on its own (workflow).
|
|
32
|
+
__NEXT_PORT__: String(answers.ports.api + 1),
|
|
35
33
|
__ENCRYPTION_KEY__: answers.secrets.encryptionKey,
|
|
36
34
|
__JWT_SECRET__: answers.secrets.jwtSecret,
|
|
37
35
|
__ADMIN_EMAIL__: answers.admin.email,
|
|
38
36
|
__ADMIN_PASSWORD__: answers.admin.password,
|
|
39
37
|
__DB_CONNECTION__: answers.connection || '',
|
|
40
38
|
__CONNECTION_NOTE__: connectionNote(answers)
|
|
41
|
-
}, tenancySubstitutions(answers.tenancy || []), flowsSubstitutions(answers
|
|
39
|
+
}, tenancySubstitutions(answers.tenancy || []), flowsSubstitutions(answers));
|
|
42
40
|
}
|
|
43
41
|
|
|
44
42
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
43
|
+
* FLOWS ARE IN EVERY APP, and so is what runs them: @xeplr/workflow, started
|
|
44
|
+
* INSIDE the API (bin/www → registerWorkflow) in the app's own database. There
|
|
45
|
+
* is no Workflow server to run, no port, no setting and nothing to connect, so
|
|
46
|
+
* nothing here depends on an answer — these tokens only carry text.
|
|
48
47
|
*/
|
|
49
|
-
function flowsSubstitutions(
|
|
50
|
-
var on = Boolean(workflow);
|
|
48
|
+
function flowsSubstitutions(answers) {
|
|
51
49
|
return {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
'#
|
|
56
|
-
'
|
|
50
|
+
// Where workflow runs. Its database is DB_API with the app's login — no
|
|
51
|
+
// setting; only the port, and only to run it as its own service.
|
|
52
|
+
__WF_ENV__: [
|
|
53
|
+
'# ── Flows (@xeplr/workflow) ──',
|
|
54
|
+
'# BLANK: workflow runs INSIDE this API (npm run start-api) — nothing else to',
|
|
55
|
+
'# start. Set a port to run it as its OWN SERVICE (npm run start-workflow);',
|
|
56
|
+
'# then point /api/workflow at it: WORKFLOW_URL in ui/.env for local, nginx',
|
|
57
|
+
'# in production (README, "Production"). This app\'s next free port: ' + (answers.ports.api + 1) + '.',
|
|
58
|
+
'WORKFLOW_PORT=',
|
|
57
59
|
''
|
|
58
|
-
].join('\n')
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
'
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
' <Route path="/configure/flows/:flow" element={<FlowDesigner />} />',
|
|
73
|
-
' {/* A journey: its run id joins the address once it starts, so it can be resumed. */}',
|
|
74
|
-
' <Route path="/journey/:flow" element={<Journey />} />',
|
|
75
|
-
' <Route path="/journey/:flow/:run" element={<Journey />} />'
|
|
76
|
-
].join('\n') : '',
|
|
77
|
-
__WF_TAB_IMPORT__: on ? "import Flows from './Flows.jsx'" : '',
|
|
78
|
-
__WF_TAB__: on ? " { id: 'flows', label: 'Flows', Page: Flows }," : '',
|
|
79
|
-
__WF_TAB_NOTE__: on ? '// Flows forms one after another — what is filled in decides the next; run by Xeplr Workflow' : '',
|
|
80
|
-
__WF_README__: on ? flowsReadme(workflow, answers) : '',
|
|
81
|
-
__WF_CLAUDE__: on ? [
|
|
82
|
-
'**Flows** — forms one after another, run by Xeplr Workflow (`WORKFLOW_URL`). Design them in Configure UI → Flows, never in code: a flow is data in Workflow, reached through `/api/flows`, and walked at `/journey/<key>`. An arrow tests one field of the screen it leaves; a form that is a step must be published first.',
|
|
60
|
+
].join('\n'),
|
|
61
|
+
__WF_UI_ENV__: [
|
|
62
|
+
'',
|
|
63
|
+
'# Where /api/workflow goes. BLANK: the API above (workflow runs inside it).',
|
|
64
|
+
'# Set it when api/development.env sets WORKFLOW_PORT — e.g.',
|
|
65
|
+
'# http://localhost:' + (answers.ports.api + 1),
|
|
66
|
+
'WORKFLOW_URL='
|
|
67
|
+
].join('\n'),
|
|
68
|
+
// Workflow's permissions — who may design a flow, who may run one — load
|
|
69
|
+
// into this app's sign-in service after the app's own.
|
|
70
|
+
__WF_AUTH_MIGRATIONS__: ',./node_modules/@xeplr/workflow/migrations-auth',
|
|
71
|
+
__WF_README__: flowsReadme(answers),
|
|
72
|
+
__WF_CLAUDE__: [
|
|
73
|
+
'**Flows** — forms one after another, run by `@xeplr/workflow` INSIDE this API (bin/www starts it) and stored in this app\'s own database. Design them in Configure UI → Flows, never in code: a flow is data, reached through `/api/flows` (routes/flows.js), and walked at `/journey/<key>`. An arrow tests one field of the screen it leaves; a form that is a step must be published first. Workflow follows this app\'s tenancy — it never creates companies or workspaces of its own.',
|
|
83
74
|
''
|
|
84
|
-
].join('\n')
|
|
75
|
+
].join('\n')
|
|
85
76
|
};
|
|
86
77
|
}
|
|
87
78
|
|
|
@@ -126,6 +117,8 @@ function tenancySubstitutions(levels) {
|
|
|
126
117
|
" '/api/tenants': tenants.router,"
|
|
127
118
|
].join('\n') : '',
|
|
128
119
|
__MT_MEMBER_GATE__: on ? 'tenants.memberGate' : 'access.anyone',
|
|
120
|
+
// api/app.js — the request middleware bin/workflow runs as well.
|
|
121
|
+
__MT_MIDDLEWARE_LIST__: on ? '[mtMiddleware()]' : '[]',
|
|
129
122
|
__MT_MEMBER_NOUN__: on ? levels.map(function (l) { return l.label.toLowerCase(); }).join(' and ') : 'data (no tenancy: nothing to check)',
|
|
130
123
|
|
|
131
124
|
// ui/src/main.jsx
|
|
@@ -166,33 +159,23 @@ function tenancySubstitutions(levels) {
|
|
|
166
159
|
};
|
|
167
160
|
}
|
|
168
161
|
|
|
169
|
-
/**
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
*/
|
|
173
|
-
function flowsReadme(workflow, answers) {
|
|
162
|
+
/** The README's Flows section. */
|
|
163
|
+
function flowsReadme(answers) {
|
|
164
|
+
var tenancy = answers.tenancy || [];
|
|
174
165
|
return [
|
|
175
166
|
'## Flows',
|
|
176
167
|
'',
|
|
177
|
-
'Your forms, one after another: what someone fills in on one decides which comes next. Design one in **Configure UI → Flows**, publish it, and
|
|
168
|
+
'Your forms, one after another: what someone fills in on one decides which comes next. Design one in **Configure UI → Flows**, publish it, and people start it from **Flows** in the side rail (or at `/journey/<key>`). The run id joins the address once it starts, so a journey can be reloaded, left, or handed to someone else and opened on the step it is on.',
|
|
178
169
|
'',
|
|
179
|
-
'Flows are run by
|
|
170
|
+
'Flows are run by **`@xeplr/workflow`**, which runs **inside this API** — `bin/www` starts it after the screens. Nothing else to start, no port, no setting:',
|
|
180
171
|
'',
|
|
181
|
-
'
|
|
182
|
-
'',
|
|
183
|
-
'**
|
|
184
|
-
''
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
'
|
|
188
|
-
'REDIS_PREFIX=' + answers.name + ':',
|
|
189
|
-
'```',
|
|
190
|
-
'',
|
|
191
|
-
'If Workflow\'s database server is not this app\'s, also set `AUTH_DB_CONNECTION_INFO_ENCRYPTED` in Workflow to this app\'s server, encrypted with **Workflow\'s** `ENCRYPTION_KEY` (`npm run db:encrypt` in Workflow\'s backend). Otherwise every flow call fails with *database "' + answers.name + '_auth" does not exist*.',
|
|
192
|
-
'',
|
|
193
|
-
workflow.dir
|
|
194
|
-
? 'Its access rules — who may design a flow, who may run one — are loaded by this app\'s sign-in service from `' + workflow.dir + '/migrations-auth` (in `XEPLR_AUTH_MIGRATIONS`). Restart the sign-in service after changing it.'
|
|
195
|
-
: 'Its access rules — who may design a flow, who may run one — live in Workflow\'s `backend/migrations-auth`. Add that folder to `XEPLR_AUTH_MIGRATIONS` in `api/development.env` (comma separated, after `./migrations-auth`) and restart the sign-in service; until then, flows answer 403.',
|
|
172
|
+
'| | |',
|
|
173
|
+
'|---|---|',
|
|
174
|
+
'| **Database** | this app\'s own (`DB_API`). Workflow\'s tables (`workflows`, `workflow_steps`, `workflow_runs`…) sit beside the forms\' tables, and it keeps its own record of which of its migrations ran (`workflow_migrations`). Each screen still saves into its own table. |',
|
|
175
|
+
'| **Tenancy** | ' + (tenancy.length
|
|
176
|
+
? 'this app\'s — flows and runs belong to the ' + tenancy.map(function (l) { return l.label.toLowerCase(); }).join(' and ') + ' chosen after signing in, checked by the same membership gate as everything else.'
|
|
177
|
+
: 'none, like the rest of this app — flows belong to the whole app.') + ' Workflow never creates companies or workspaces of its own. |',
|
|
178
|
+
'| **Permissions** | each route checks the caller\'s (`List flows`, `Start flow run`, `Publish flow`…), granted to Super Admin, CompanyAdmin, Creator and — read only — Viewer by `@xeplr/workflow`\'s `migrations-auth`, which `XEPLR_AUTH_MIGRATIONS` loads. |',
|
|
196
179
|
''
|
|
197
180
|
].join('\n');
|
|
198
181
|
}
|
|
@@ -309,15 +292,6 @@ function generate(answers, target) {
|
|
|
309
292
|
throw new Error(target + ' already exists and is not empty.');
|
|
310
293
|
}
|
|
311
294
|
|
|
312
|
-
// Workflow files every flow and run under a company; an app without
|
|
313
|
-
// companies would build, start, and then refuse every flow it tried to make.
|
|
314
|
-
if (answers.workflow) {
|
|
315
|
-
var q = require('../questions');
|
|
316
|
-
if (!q.sameLevels(answers.tenancy || [], q.tenancyLevels(q.WORKFLOW_LEVELS))) {
|
|
317
|
-
throw new Error('Flows need the same multi-tenancy as Xeplr Workflow — levels "' + q.WORKFLOW_LEVELS + '" — because Workflow keeps every journey inside a company and a workspace.');
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
|
|
321
295
|
var subs = substitutions(answers);
|
|
322
296
|
var written = [];
|
|
323
297
|
|
|
@@ -326,9 +300,6 @@ function generate(answers, target) {
|
|
|
326
300
|
if (answers.tenancy && answers.tenancy.length) {
|
|
327
301
|
walk(TENANCY_TEMPLATES).forEach(function (src) { sources[path.relative(TENANCY_TEMPLATES, src)] = src; });
|
|
328
302
|
}
|
|
329
|
-
if (answers.workflow) {
|
|
330
|
-
walk(FLOWS_TEMPLATES).forEach(function (src) { sources[path.relative(FLOWS_TEMPLATES, src)] = src; });
|
|
331
|
-
}
|
|
332
303
|
|
|
333
304
|
Object.keys(sources).forEach(function (rel) {
|
|
334
305
|
var src = sources[rel];
|
package/lib/questions.js
CHANGED
|
@@ -135,38 +135,8 @@ async function collect(session, argvName) {
|
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
// ── flows ─────────────────────────────────────────────────────────────
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
// screen. The journey is run by Xeplr Workflow — a separate service, not a
|
|
141
|
-
// package — so this only asks where it is. Off, the app has no flows at all.
|
|
142
|
-
var workflow = null;
|
|
143
|
-
var wantsFlows = await session.confirm('Flows — is Xeplr Workflow running for this app?', false);
|
|
144
|
-
if (wantsFlows) {
|
|
145
|
-
var workflowUrl = await session.ask(' Workflow API address?', {
|
|
146
|
-
default: 'http://localhost:19122',
|
|
147
|
-
validate: function (v) { return /^https?:\/\/[^\s]+$/.test(v) ? true : 'An http:// or https:// address.'; }
|
|
148
|
-
});
|
|
149
|
-
// Workflow keeps its access rules (who may design a flow, who may run one)
|
|
150
|
-
// in migrations-auth/. This app's sign-in service has to load them, so it
|
|
151
|
-
// needs to know where they are. Blank is allowed: the README says how.
|
|
152
|
-
var workflowDir = await session.ask(' Workflow backend folder (for its access rules — blank to add later)?', {
|
|
153
|
-
default: '',
|
|
154
|
-
validate: function (v) { return !v || !/\s/.test(v) ? true : 'A path without spaces.'; }
|
|
155
|
-
});
|
|
156
|
-
workflow = { url: workflowUrl.replace(/\/+$/, ''), dir: workflowDir.replace(/\/+$/, '') };
|
|
157
|
-
|
|
158
|
-
// Workflow files every flow and every run under a company and a workspace
|
|
159
|
-
// — the two levels it registers, with those headers. An app that sends
|
|
160
|
-
// anything else builds, starts, and then has every flow refused, so the
|
|
161
|
-
// levels that make flows work are set here, and said out loud.
|
|
162
|
-
if (!sameLevels(tenancy, tenancyLevels(WORKFLOW_LEVELS))) {
|
|
163
|
-
tenancy = tenancyLevels(WORKFLOW_LEVELS);
|
|
164
|
-
if (session.say) {
|
|
165
|
-
session.say(' Flows need the same tenancy as Xeplr Workflow — company, then workspace —');
|
|
166
|
-
session.say(' so this app uses those two levels.');
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
}
|
|
138
|
+
// Nothing to ask: every app has flows, run by @xeplr/workflow inside its own
|
|
139
|
+
// API and database (see lib/generate's flowsSubstitutions).
|
|
170
140
|
|
|
171
141
|
// ── the database ──────────────────────────────────────────────────────
|
|
172
142
|
//
|
|
@@ -208,7 +178,6 @@ async function collect(session, argvName) {
|
|
|
208
178
|
databases: { auth: name + '_auth', api: name + '_api' },
|
|
209
179
|
admin: { email: adminEmail, password: adminPassword },
|
|
210
180
|
tenancy: tenancy,
|
|
211
|
-
workflow: workflow,
|
|
212
181
|
|
|
213
182
|
// GENERATED, never asked. Nobody should be inventing these by hand, and a
|
|
214
183
|
// prompt invites a weak answer or a reused one.
|
|
@@ -216,12 +185,4 @@ async function collect(session, argvName) {
|
|
|
216
185
|
};
|
|
217
186
|
}
|
|
218
187
|
|
|
219
|
-
|
|
220
|
-
var WORKFLOW_LEVELS = 'company, workspace';
|
|
221
|
-
|
|
222
|
-
/** Same levels, in the same order, with the same headers. */
|
|
223
|
-
function sameLevels(a, b) {
|
|
224
|
-
return a.length === b.length && a.every(function (l, i) { return l.header === b[i].header; });
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
module.exports = { collect: collect, envPrefix: envPrefix, tenancyLevels: tenancyLevels, sameLevels: sameLevels, WORKFLOW_LEVELS: WORKFLOW_LEVELS, NAME_RE: NAME_RE };
|
|
188
|
+
module.exports = { collect: collect, envPrefix: envPrefix, tenancyLevels: tenancyLevels, NAME_RE: NAME_RE };
|
package/package.json
CHANGED
package/templates/CLAUDE.md
CHANGED
|
@@ -11,7 +11,7 @@ A new UI is a **form**: a list, and an add / edit form it opens — on a page of
|
|
|
11
11
|
|
|
12
12
|
Create every file below — the hooks and the model run the defaults (`super`) until the request needs more.
|
|
13
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.
|
|
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. **Use the ready-made fields** for email, phone, website, LinkedIn, age, date of birth, gender, country, amount, percentage, yes/no and postal code — `"type": "email"` and so on ("Choosing controls" in AUTHORING.md): they bring the keyboard, format check and options with them, so don't write those by hand.
|
|
15
15
|
2. **Screens + server files** — from `api/`:
|
|
16
16
|
```sh
|
|
17
17
|
npx xeplr-factory screens screens/<form>/<form>.entity.json -o screens/<form> --no-pages
|
|
@@ -22,10 +22,10 @@ Create every file below — the hooks and the model run the defaults (`super`) u
|
|
|
22
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
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
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 `{ key: '<Forms>', icon, clickHandler }` — by **key**, with an **icon** (a collapsed rail shows icons only). Never write the label in code.
|
|
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 `{ key: '<Forms>', icon, path: '/<forms>', clickHandler }` — by **key**, with an **icon** (a collapsed rail shows icons only) and the **path** (so the rail marks it as the current page). Never write the label in code.
|
|
26
26
|
7. **The menu row** — `api/migrations-auth/000N_<forms>_menu.sql` (next free number): copy `0002_tasks_menu.sql`, its `name` being the **key** spelled exactly as in `drawerItems` — an unknown key is dropped silently. Set `label` only if it should read differently; people can rename it later in Configure UI → Menu. Then, in `api/`: `npm run migrate:auth`, and restart the sign-in service.
|
|
27
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
|
|
28
|
+
9. **Check** — open the page, New, fill in the form and press **Save**; it goes back to the list and the row is in the table.
|
|
29
29
|
|
|
30
30
|
Then change only what the request asks for:
|
|
31
31
|
|
|
@@ -47,7 +47,7 @@ Then change only what the request asks for:
|
|
|
47
47
|
- **`file`** — the column holds the **path**, never the file. Say which extensions (`"accept": ".pdf,.docx"`) and how large (`"maxSize": 10`, in MB); the API enforces both, by extension, and keeps the file under `FACTORY_FILES_DIR`. Uploading needs `multer` in `api/` (already in package.json).
|
|
48
48
|
- **`stepper`** — a journey across the top. Every other field says which step it is on (`"step": "Planning"`), and one without a `step` shows on every step. `step(ctx)` in the front-end hooks runs before each move: return `false` to stay, a step's key to go there, `ctx.disable(['planning'])` to rule steps out (struck through on the bar, passed over by Next and Back) and `ctx.enable()` to bring them back.
|
|
49
49
|
|
|
50
|
-
**A list opens its form on a page or in a popup** — `"openIn": "page"` in the entity spec, or Configure UI → Forms → the list. A page needs `onOpenRecord` on the list's page (where to navigate) and `onDone` on the form's page (where
|
|
50
|
+
**A list opens its form on a page or in a popup** — `"openIn": "page"` in the entity spec, or Configure UI → Forms → the list. A page needs `onOpenRecord` on the list's page (where to navigate) and `onDone` on the form's page (where Save and Cancel go back to): `Tasks.jsx` and `EditTask.jsx` show both, with routes `/tasks/new` and `/tasks/:id` in `App.jsx`.
|
|
51
51
|
|
|
52
52
|
__WF_CLAUDE__
|
|
53
53
|
**Keys and labels.** A form's key names its screens and table and never changes once published; its label is the screens' name. A menu item's key (`menus.name`) is what `drawerItems` matches; its label is what the rail shows, renamed in Configure UI → Menu. Code only ever uses keys.
|
package/templates/README.md
CHANGED
|
@@ -21,6 +21,56 @@ cd ui && npm run dev # the app http://localhost:__UI_PORT_
|
|
|
21
21
|
|
|
22
22
|
Open http://localhost:__UI_PORT__ and sign in as `__ADMIN_EMAIL__`.
|
|
23
23
|
|
|
24
|
+
Flows (Xeplr Workflow) run **inside the API** — nothing else to start. To run
|
|
25
|
+
them as their own service instead, see "Where each address goes" below.
|
|
26
|
+
|
|
27
|
+
## Where each address goes
|
|
28
|
+
|
|
29
|
+
The browser only ever calls **paths** on the app's own address — never a port.
|
|
30
|
+
What serves each path is decided in ONE place per environment: Vite's dev proxy
|
|
31
|
+
locally (`ui/vite.config.js`, from `ui/.env`), nginx in production.
|
|
32
|
+
|
|
33
|
+
| path | served by | local (`ui/.env`) | production (nginx) |
|
|
34
|
+
|---|---|---|---|
|
|
35
|
+
| `/auth/api/…` | the sign-in service | `AUTH_URL` | `127.0.0.1:<AUTH_PORT>` |
|
|
36
|
+
| `/api/workflow/…` | flows — `@xeplr/workflow` | `WORKFLOW_URL`, or `API_URL` when blank | the workflow process, or the API when it runs inside |
|
|
37
|
+
| `/api/…`, `/health`, `/whoami` | the API | `API_URL` | `127.0.0.1:<__PREFIX___PORT>` |
|
|
38
|
+
|
|
39
|
+
**Workflow on its own port.** Set `WORKFLOW_PORT` in `api/development.env`
|
|
40
|
+
(this app's next free port is __NEXT_PORT__), set `WORKFLOW_URL` in `ui/.env` to
|
|
41
|
+
`http://localhost:__NEXT_PORT__`, and start it beside the API:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
cd api && npm run start-workflow # flows http://localhost:__NEXT_PORT__
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Same database, same sign-in, same permissions and the same `/api/workflow/…`
|
|
48
|
+
paths — only the process differs. Leave `WORKFLOW_PORT` blank and it runs inside
|
|
49
|
+
the API again.
|
|
50
|
+
|
|
51
|
+
### Production
|
|
52
|
+
|
|
53
|
+
`api/production.env.example` lists every setting (copy it to
|
|
54
|
+
`api/production.env`, fill in, start with `NODE_ENV=production`). Build the UI
|
|
55
|
+
(`cd ui && npm run build`) and let nginx serve it and route the paths — the
|
|
56
|
+
more specific `/api/workflow/` first:
|
|
57
|
+
|
|
58
|
+
```nginx
|
|
59
|
+
server {
|
|
60
|
+
server_name app.example.com;
|
|
61
|
+
root /srv/__NAME__/ui/dist;
|
|
62
|
+
|
|
63
|
+
location /auth/api/ { proxy_pass http://127.0.0.1:__AUTH_PORT__; }
|
|
64
|
+
location /api/workflow/ { proxy_pass http://127.0.0.1:__API_PORT__; } # or WORKFLOW_PORT when it runs on its own
|
|
65
|
+
location /api/ { proxy_pass http://127.0.0.1:__API_PORT__; }
|
|
66
|
+
location = /health { proxy_pass http://127.0.0.1:__API_PORT__; }
|
|
67
|
+
location = /whoami { proxy_pass http://127.0.0.1:__API_PORT__; }
|
|
68
|
+
location / { try_files $uri /index.html; } # the React app's own pages
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Every one of these checks the same sign-in token, so one login covers them all.
|
|
73
|
+
|
|
24
74
|
## What is in it
|
|
25
75
|
|
|
26
76
|
- **Sign-in** — login, register, password reset, profile, and the admin screens for users, roles and permissions (`@xeplr/auth`, `@xeplr/ui-account`).
|
package/templates/api/app.js
CHANGED
|
@@ -12,7 +12,13 @@ var memberGate = __MT_MEMBER_GATE__;
|
|
|
12
12
|
// default could only ever fire in a process that skipped that check.
|
|
13
13
|
var port = process.env.__PREFIX___PORT;
|
|
14
14
|
|
|
15
|
-
module.exports =
|
|
15
|
+
module.exports = buildApp;
|
|
16
|
+
// bin/www and bin/workflow hand the same gate to the flows router they start,
|
|
17
|
+
// and bin/workflow runs the same request middleware (tenancy) as this app.
|
|
18
|
+
module.exports.memberGate = memberGate;
|
|
19
|
+
module.exports.middleware = function() { return __MT_MIDDLEWARE_LIST__; };
|
|
20
|
+
|
|
21
|
+
function buildApp(extraRoutes) {
|
|
16
22
|
return createApp(port, '__NAME___api', {
|
|
17
23
|
// Every route needs a valid token, checked against the auth service at
|
|
18
24
|
// AUTH_URL. This app names no `middleware` of its own — it takes the
|
|
@@ -48,4 +54,4 @@ __MT_APP_ROUTES__
|
|
|
48
54
|
})
|
|
49
55
|
})
|
|
50
56
|
});
|
|
51
|
-
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// XEPLR WORKFLOW AS ITS OWN SERVICE — `npm run start-workflow`.
|
|
4
|
+
//
|
|
5
|
+
// Only when WORKFLOW_PORT is set (development.env / production.env). Blank, it
|
|
6
|
+
// runs inside the API instead and this refuses to start, saying so.
|
|
7
|
+
//
|
|
8
|
+
// The SAME workflow as the embedded one (../workflow.js): this app's database,
|
|
9
|
+
// this app's tenancy and membership gate, the same permission checks, and the
|
|
10
|
+
// same path — /api/workflow — so the browser cannot tell the two apart.
|
|
11
|
+
// What routes /api/workflow here: ui/.env WORKFLOW_URL for Vite's dev proxy,
|
|
12
|
+
// nginx in production (README, "Production").
|
|
13
|
+
require('dotenv').config({ path: require('path').join(__dirname, '..', (process.env.NODE_ENV || 'development') + '.env') });
|
|
14
|
+
|
|
15
|
+
if (!process.env.WORKFLOW_PORT) {
|
|
16
|
+
console.error('\n[workflow] WORKFLOW_PORT is blank — workflow runs INSIDE the API (npm run start-api).');
|
|
17
|
+
console.error(' To run it as its own service, set WORKFLOW_PORT in development.env,');
|
|
18
|
+
console.error(' and WORKFLOW_URL in ui/.env to http://localhost:<that port>.\n');
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
require('@xeplr/base-apis').checkEnv(require('../env.required.js'), { appName: 'workflow' });
|
|
23
|
+
|
|
24
|
+
var express = require('express');
|
|
25
|
+
var createApp = require('@xeplr/base-apis/express');
|
|
26
|
+
|
|
27
|
+
async function start() {
|
|
28
|
+
// This app's tenancy levels, registered exactly as the API registers them.
|
|
29
|
+
await require('../db/setup');
|
|
30
|
+
var app = require('../app');
|
|
31
|
+
var workflowRouter = await require('../workflow')(app.memberGate);
|
|
32
|
+
|
|
33
|
+
var health = express.Router();
|
|
34
|
+
health.get('/', function(req, res) { res.json({ status: 'ok', service: 'workflow' }); });
|
|
35
|
+
|
|
36
|
+
createApp(process.env.WORKFLOW_PORT, '__NAME___workflow', {
|
|
37
|
+
// Every route needs a valid token, checked against the same sign-in
|
|
38
|
+
// service as the API — one login for both.
|
|
39
|
+
auth: { publicPaths: ['/health'] },
|
|
40
|
+
log: { logDir: process.env.LOG_DIR || './logs' },
|
|
41
|
+
middleware: app.middleware(),
|
|
42
|
+
routes: {
|
|
43
|
+
'/health': health,
|
|
44
|
+
'/api/workflow': workflowRouter
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
start().catch(function(err) {
|
|
50
|
+
console.error('[workflow] startup failed:', err.stack || err.message);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
});
|
package/templates/api/bin/www
CHANGED
|
@@ -76,7 +76,21 @@ async function start() {
|
|
|
76
76
|
var published = await factory.publishScreens(screens.documents);
|
|
77
77
|
if (published.published.length) console.log('[api] published screens: ' + published.published.join(', '));
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
// FLOWS — Xeplr Workflow, inside this API and in this app's own database
|
|
80
|
+
// (routes/flows.js says why). It creates its tables on first start, keeps its
|
|
81
|
+
// own migration record, and follows this app's tenancy instead of creating
|
|
82
|
+
// companies / workspaces of its own.
|
|
83
|
+
var buildApp = require('../app');
|
|
84
|
+
// WORKFLOW_PORT set: it runs as its own service (npm run start-workflow),
|
|
85
|
+
// and this API leaves it alone.
|
|
86
|
+
if (process.env.WORKFLOW_PORT) {
|
|
87
|
+
console.log('[api] flows: workflow runs on its own port ' + process.env.WORKFLOW_PORT + ' (npm run start-workflow)');
|
|
88
|
+
buildApp();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
require('../routes/flows').mount(await require('../workflow')(buildApp.memberGate));
|
|
92
|
+
|
|
93
|
+
buildApp();
|
|
80
94
|
}
|
|
81
95
|
|
|
82
96
|
start().catch(function(err) {
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
node_modules/
|
|
2
2
|
logs/
|
|
3
|
+
# Settings hold this app's keys, secrets and database login — never committed.
|
|
4
|
+
# production.env.example lists every name, with no values.
|
|
5
|
+
development.env
|
|
6
|
+
production.env
|
|
7
|
+
*.local.env
|
|
3
8
|
# Files people upload through a form's file field (FACTORY_FILES_DIR).
|
|
4
9
|
uploads/
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
-- "Flows" — the side-rail item for journeys across the app's forms.
|
|
2
|
+
--
|
|
3
|
+
-- ALWAYS THERE: flows are run by @xeplr/workflow inside this app's own API,
|
|
4
|
+
-- so there is nothing to switch on.
|
|
5
|
+
--
|
|
6
|
+
-- The name is the KEY: it must match ui/src/App.jsx's drawerItems `key`
|
|
7
|
+
-- EXACTLY. What people read is the label, changed in Configure UI → Menu.
|
|
8
|
+
-- (0005, not 0004: a multi-tenant app's own 0004 is the switch menu.)
|
|
9
|
+
|
|
10
|
+
INSERT INTO "menus" (id, name, "menuGroup", "isPublic", "isActive", "mtId1", "recordCreatedDate", "recordModifiedDate")
|
|
11
|
+
SELECT encode(gen_random_bytes(12), 'hex'), 'Flows', '', true, true, '*', now(), now()
|
|
12
|
+
WHERE NOT EXISTS (SELECT 1 FROM "menus" WHERE name = 'Flows');
|
|
13
|
+
|
|
14
|
+
UPDATE "menus" SET "isPublic" = true WHERE name = 'Flows';
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"version": "1.0.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
|
-
"description": "__NAME__ backend
|
|
6
|
+
"description": "__NAME__ backend \u2014 @xeplr/base-apis app.",
|
|
7
7
|
"main": "app.js",
|
|
8
8
|
"scripts": {
|
|
9
9
|
"check-env": "dotenv -e development.env -- xeplr-check-env",
|
|
@@ -14,15 +14,17 @@
|
|
|
14
14
|
"migrate:auth": "dotenv -e development.env -- xeplr-auth-migrate up --connection-env AUTH_DB_CONNECTION_INFO_ENCRYPTED",
|
|
15
15
|
"migrate:up": "dotenv -e development.env -- xeplr-migrate up --dir ./migrations --connectionName api --connection-env __PREFIX___CONNECTION",
|
|
16
16
|
"start-auth": "dotenv -e ./development.env -- xeplr-auth-server",
|
|
17
|
-
"start-api": "node ./bin/www"
|
|
17
|
+
"start-api": "node ./bin/www",
|
|
18
|
+
"start-workflow": "node ./bin/workflow"
|
|
18
19
|
},
|
|
19
20
|
"dependencies": {
|
|
20
21
|
"@xeplr/auth": "^1.1.4",
|
|
21
22
|
"@xeplr/base-apis": "^2.0.0",
|
|
22
23
|
"@xeplr/db": "^1.0.6",
|
|
23
|
-
"@xeplr/factory": "^1.0
|
|
24
|
-
"@xeplr/ui-factory": "^1.0
|
|
24
|
+
"@xeplr/factory": "^1.1.0",
|
|
25
|
+
"@xeplr/ui-factory": "^1.1.0",
|
|
25
26
|
"@xeplr/utils": "^1.0.11",
|
|
27
|
+
"@xeplr/workflow": "^1.2.0",
|
|
26
28
|
"dotenv": "^17.4.2",
|
|
27
29
|
"express": "^5.2.0",
|
|
28
30
|
"multer": "^2.0.2"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# __NAME__ API — PRODUCTION settings. Copy to production.env on the server
|
|
2
|
+
# and fill in; start with NODE_ENV=production (bin/www and bin/workflow read
|
|
3
|
+
# <NODE_ENV>.env). production.env is never committed (.gitignore).
|
|
4
|
+
#
|
|
5
|
+
# Every value that is a secret is blank here. Generate new ones for production
|
|
6
|
+
# — never reuse development's.
|
|
7
|
+
|
|
8
|
+
NODE_ENV=production
|
|
9
|
+
LOG_DIR=./logs
|
|
10
|
+
|
|
11
|
+
# ── Ports: one per process on this server ──
|
|
12
|
+
__PREFIX___PORT=__API_PORT__
|
|
13
|
+
AUTH_PORT=__AUTH_PORT__
|
|
14
|
+
# Blank: workflow runs inside the API. Set: it runs as its own process
|
|
15
|
+
# (npm run start-workflow) and nginx sends /api/workflow to it.
|
|
16
|
+
WORKFLOW_PORT=
|
|
17
|
+
|
|
18
|
+
# ── Databases (same server, one login) ──
|
|
19
|
+
ENCRYPTION_KEY=
|
|
20
|
+
__PREFIX___CONNECTION=
|
|
21
|
+
AUTH_DB_CONNECTION_INFO_ENCRYPTED=
|
|
22
|
+
DB_API=__NAME___api
|
|
23
|
+
AUTH_DB_NAME=__NAME___auth
|
|
24
|
+
|
|
25
|
+
# ── Sign-in ──
|
|
26
|
+
REDIS_PREFIX=__NAME__:
|
|
27
|
+
AUTH_JWT_SECRET=
|
|
28
|
+
# Where THIS server reaches the sign-in service (not the public address).
|
|
29
|
+
AUTH_URL=http://127.0.0.1:__AUTH_PORT__
|
|
30
|
+
XEPLR_AUTH_MIGRATIONS=./node_modules/@xeplr/factory/migrations-auth,./migrations-auth,./node_modules/@xeplr/workflow/migrations-auth
|
|
31
|
+
AUTH_SUPER_ADMIN_EMAIL=
|
|
32
|
+
AUTH_SUPER_ADMIN_PASSWORD=
|
|
33
|
+
|
|
34
|
+
# ── Public addresses (what people's browsers open) ──
|
|
35
|
+
AUTH_ACTIVATION_URL=https://app.example.com/auth/activate
|
|
36
|
+
AUTH_INVITE_URL=https://app.example.com/auth/accept-invite
|
|
37
|
+
|
|
38
|
+
# ── Uploaded files — an absolute path on the server ──
|
|
39
|
+
FACTORY_FILES_DIR=/var/lib/__NAME__/uploads/factory
|