@tabbr/module-cli 0.4.0 → 0.5.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 +52 -12
- package/bin/tabbr-module.js +50 -12
- package/package.json +1 -1
- package/src/backend-build.js +62 -16
- package/src/build.js +3 -0
- package/src/config.js +89 -16
- package/src/fetch-types.js +5 -2
- package/src/source.js +6 -2
package/README.md
CHANGED
|
@@ -31,6 +31,30 @@ npx tabbr-module init # asks for the club code, module id and a fallback t
|
|
|
31
31
|
}
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
+
### Where you develop against: tabbr.config.local.json
|
|
35
|
+
|
|
36
|
+
`tabbr.config.json` describes the module and is committed. The club and API host *you* work against are yours, not
|
|
37
|
+
the module's: put them in `tabbr.config.local.json` - add it to `.gitignore` - and leave `tabbr.config.json` as it is
|
|
38
|
+
(e.g. on production, for the store). `init --local` writes it:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npx tabbr-module init --local # asks for the club code and the API host (default: your local backend,
|
|
42
|
+
# https://api.local.tabbr.be; https://api.tabbr.be for a club on production)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{ "club": "ABC", "apiHost": "https://api.local.tabbr.be" }
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
It may set `club`, `apiHost`, `localApiHost` and `shellUrl` (other keys are ignored, with a warning) and overrides
|
|
50
|
+
`tabbr.config.json` for them in every command - except `publish --store`, which goes where `tabbr.config.json` says
|
|
51
|
+
unless you pass `--local` or `--api-host`. Command-line options still win, and the CLI says which file it used.
|
|
52
|
+
The one next to `tabbr.config.json` counts, else the one a folder up - one file for every module in that folder, as
|
|
53
|
+
the Tabbr repository's `modules/` has. The module's dev preview reads the same one: its `src/app/app.config.ts` imports
|
|
54
|
+
`tabbr.config.local.json`, which the `paths` in `tsconfig.local-config.json` resolve to that file, else to
|
|
55
|
+
`tabbr.config.json` itself (copy both from one of the Tabbr repository's modules). It's never part of a store
|
|
56
|
+
submission's source.
|
|
57
|
+
|
|
34
58
|
### The title and description of your module
|
|
35
59
|
|
|
36
60
|
The name Tabbr shows for your module (in the settings list and its page header) is translated like the one of its
|
|
@@ -127,24 +151,39 @@ rules, the club's extra fields, hooks, audit). Add an `"entities"` array to `tab
|
|
|
127
151
|
`publish` checks the declarations (names, keys, types) before uploading and sends them in the backend manifest - also
|
|
128
152
|
for a module that has no backend code (then only the entities are published). The frontend addresses records by field
|
|
129
153
|
key with the SDK's `injectModuleEntity()`, the backend with `tabbr.entities.<name>`. See `docs/module-entities.md` in the
|
|
130
|
-
Tabbr repository for the field types, access rules and what the club gets; `tabbr-groups-module/` is a complete example.
|
|
154
|
+
Tabbr repository for the field types, access rules and what the club gets; `modules/tabbr-groups-module/` is a complete example.
|
|
131
155
|
|
|
132
156
|
## Contracts
|
|
133
157
|
|
|
134
|
-
What a module uses of Tabbr
|
|
135
|
-
|
|
158
|
+
What a module uses of Tabbr or of another module is a **contract** - never its code. Declare the ones you use with a
|
|
159
|
+
version range, and the module API level that has contracts:
|
|
160
|
+
|
|
161
|
+
```json
|
|
162
|
+
{
|
|
163
|
+
"engines": { "tabbr": "^1.3" },
|
|
164
|
+
"uses": { "sys.access": "^1" },
|
|
165
|
+
"optionalUses": { "tabbr.consent": "^1" }
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`publish` puts `uses` and `optionalUses` in the manifest (also for a module without backend code); Tabbr only installs
|
|
170
|
+
the module where the club has the contracts in `uses` (`optionalUses`: it works without them), and its server-side part
|
|
171
|
+
gets them as `tabbr.uses['sys.access']`. The frontend gets a contract with the SDK's `MODULE_CONTRACTS` (`await
|
|
172
|
+
inject(MODULE_CONTRACTS).get('sys.access')`, `null` when the club doesn't have it). Their types come from
|
|
173
|
+
`fetch-types --contracts` (below).
|
|
174
|
+
|
|
175
|
+
A module can **provide** a contract too (module API 1.3), named after its publisher:
|
|
136
176
|
|
|
137
177
|
```json
|
|
138
178
|
{
|
|
139
|
-
"
|
|
140
|
-
"uses": { "sys.access": "^1" }
|
|
179
|
+
"provides": { "acme.groups": { "version": "1.0.0", "types": "contracts/acme.groups.d.ts" } }
|
|
141
180
|
}
|
|
142
181
|
```
|
|
143
182
|
|
|
144
|
-
`
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
183
|
+
The `.d.ts` stands on its own (no imports) and exports `interface Frontend` and/or `interface Backend`; `publish` puts
|
|
184
|
+
it in the build (`backend/contracts/acme.groups.d.ts`), where Tabbr serves it to the modules that use the contract. Your
|
|
185
|
+
module class offers the frontend part as `provides = { 'acme.groups': ... }`, your server-side part exports the backend
|
|
186
|
+
part as `export const provides = { 'acme.groups': { ... } }`. See `docs/module-contracts.md` in the Tabbr repository.
|
|
148
187
|
|
|
149
188
|
## Usage: publish
|
|
150
189
|
|
|
@@ -180,7 +219,7 @@ upload:
|
|
|
180
219
|
|
|
181
220
|
`npx tabbr-module publish --local` publishes into your own local tabbr (the environment behind
|
|
182
221
|
`<club>.local.tabbr.be`) instead of production: it uploads to `https://api.local.tabbr.be` (set
|
|
183
|
-
`"localApiHost"` in `tabbr.config.json` to use another URL) and trusts your mkcert root CA, which Node
|
|
222
|
+
`"localApiHost"` in `tabbr.config.local.json` to use another URL) and trusts your mkcert root CA, which Node
|
|
184
223
|
doesn't do on its own (`NODE_EXTRA_CA_CERTS` is set automatically; if mkcert's root CA can't be found,
|
|
185
224
|
set it yourself - `mkcert -CAROOT` shows where it lives). `--club` picks the club in that environment;
|
|
186
225
|
login still goes through the real Keycloak, which local backends validate too.
|
|
@@ -260,8 +299,9 @@ points) to your workspace's `.gitignore` rather than committing them.
|
|
|
260
299
|
npx tabbr-module fetch-types --contracts [--cwd .] [--api-host https://api.tabbr.be]
|
|
261
300
|
```
|
|
262
301
|
|
|
263
|
-
**With `--contracts`**: fetches `GET /api/contracts/{name}/types` for every contract in `uses` into
|
|
264
|
-
`contracts/<name>.d.ts`.
|
|
302
|
+
**With `--contracts`**: fetches `GET /api/contracts/{name}/types` for every contract in `uses` and `optionalUses` into
|
|
303
|
+
`contracts/<name>.d.ts`. Tabbr's own don't depend on a club; one a module provides comes from your club (`club` in
|
|
304
|
+
tabbr.config.json) when a module there provides it, else from the module store - **commit them**: the store builds your
|
|
265
305
|
module with them. Add `contracts/*.d.ts` to your tsconfig(s); it types `MODULE_CONTRACTS.get(name)` and
|
|
266
306
|
`tabbr.uses[name]`, and its own types can be imported from `@tabbr/contracts/<name>`.
|
|
267
307
|
|
package/bin/tabbr-module.js
CHANGED
|
@@ -9,7 +9,9 @@ const { publish, publishToStore, submitSourceToStore, storeMeta } = require('../
|
|
|
9
9
|
const { packSource } = require('../src/source');
|
|
10
10
|
const { fetchScriptTypes, fetchAllEntityTypes, fetchContractTypes, VALID_KINDS } = require('../src/fetch-types');
|
|
11
11
|
const { usesOf } = require('../src/backend-build');
|
|
12
|
-
const {
|
|
12
|
+
const {
|
|
13
|
+
CONFIG_FILE, LOCAL_CONFIG_FILE, LOCAL_CONFIG_KEYS, DEFAULT_API_HOST, readConfig, readLocalConfig, requireClub, runInit,
|
|
14
|
+
} = require('../src/config');
|
|
13
15
|
const { DEFAULT_LOCAL_API_HOST, trustLocalCertificateOrRespawn } = require('../src/local');
|
|
14
16
|
const DEFAULT_KIND = 'HOOK'; // matches the server's own @RequestParam default for /meta/script
|
|
15
17
|
|
|
@@ -46,10 +48,22 @@ function printUsage() {
|
|
|
46
48
|
console.log(`Every option below can also be set in ${CONFIG_FILE} in the workspace root (option name
|
|
47
49
|
in camelCase, e.g. "club", "moduleId", "apiHost"); command-line options win.
|
|
48
50
|
|
|
51
|
+
Where YOU develop against goes in ${LOCAL_CONFIG_FILE} instead: not committed (add it to
|
|
52
|
+
.gitignore), next to ${CONFIG_FILE} or a folder up (then for every module in that folder; the
|
|
53
|
+
one in the workspace wins). Its ${LOCAL_CONFIG_KEYS.join(', ')} override those in ${CONFIG_FILE} -
|
|
54
|
+
here and in the module's dev preview; not the API host of "publish --store". E.g. { "club": "ABC",
|
|
55
|
+
"apiHost": "https://api.local.tabbr.be" } works against your local backend while ${CONFIG_FILE}
|
|
56
|
+
keeps production; "init --local" writes it.
|
|
57
|
+
|
|
49
58
|
Usage: tabbr-module init [--club <CODE>] [--module-id <id>] [--title <text>] [--api-host <url>]
|
|
50
59
|
|
|
51
60
|
Asks for the club code (and module id / title) and writes ${CONFIG_FILE}.
|
|
52
61
|
|
|
62
|
+
Usage: tabbr-module init --local [--club <CODE>] [--api-host <url>]
|
|
63
|
+
|
|
64
|
+
Asks for the club code and the API host (by default your own local backend, ${DEFAULT_LOCAL_API_HOST})
|
|
65
|
+
and writes only ${LOCAL_CONFIG_FILE}. ${CONFIG_FILE} is left as it is.
|
|
66
|
+
|
|
53
67
|
Usage: tabbr-module publish [options]
|
|
54
68
|
|
|
55
69
|
Builds and publishes whichever parts of the workspace are present, in ONE upload: a frontend (an
|
|
@@ -71,7 +85,9 @@ Backend part (all in ${CONFIG_FILE} under "backend", not as CLI flags):
|
|
|
71
85
|
secretFieldNames Names of your module's settings keys that hold secrets (masked from
|
|
72
86
|
every settings GET response once saved)
|
|
73
87
|
triggers [{type: "CRON", name, cronExpression, handler} |
|
|
74
|
-
{type: "ENTITY_HOOK", name, entity, event, handler}
|
|
88
|
+
{type: "ENTITY_HOOK", name, entity, event, handler} |
|
|
89
|
+
{type: "PUBLIC", name, handler, params} (module API 1.5:
|
|
90
|
+
GET /api/<club>/public/module/<moduleId>/data/<name>, no login)]
|
|
75
91
|
|
|
76
92
|
Optional:
|
|
77
93
|
--icon <name> Menu icon name
|
|
@@ -106,10 +122,12 @@ publishing it - what the store's builder runs.
|
|
|
106
122
|
|
|
107
123
|
Usage: tabbr-module fetch-types --contracts [--cwd <path>] [--api-host <url>]
|
|
108
124
|
|
|
109
|
-
Fetches the contracts the module uses ("uses" in ${CONFIG_FILE}, e.g.
|
|
110
|
-
contracts/<name>.d.ts - commit them: the store builds your module with them.
|
|
111
|
-
tsconfig(s); then MODULE_CONTRACTS.get('<name>') (frontend) and
|
|
112
|
-
and the contract's own types can be imported from
|
|
125
|
+
Fetches the contracts the module uses ("uses" and "optionalUses" in ${CONFIG_FILE}, e.g.
|
|
126
|
+
{ "sys.access": "^1" }) into contracts/<name>.d.ts - commit them: the store builds your module with them.
|
|
127
|
+
Include contracts/ in your tsconfig(s); then MODULE_CONTRACTS.get('<name>') (frontend) and
|
|
128
|
+
tabbr.uses['<name>'] (backend) are typed, and the contract's own types can be imported from
|
|
129
|
+
'@tabbr/contracts/<name>'. Tabbr's own contracts need no --club; one a module provides comes from your club
|
|
130
|
+
(${CONFIG_FILE} "club") when a module there provides it, else from the module store.
|
|
113
131
|
|
|
114
132
|
Usage: tabbr-module fetch-types [--club <CODE>] [--entity <name>] [options]
|
|
115
133
|
|
|
@@ -260,9 +278,10 @@ ${dts}
|
|
|
260
278
|
console.log(`Wrote ${outPath}`);
|
|
261
279
|
}
|
|
262
280
|
|
|
263
|
-
/** One file per contract the module uses: contracts/<name>.d.ts. */
|
|
281
|
+
/** One file per contract the module uses (also optionally): contracts/<name>.d.ts. */
|
|
264
282
|
async function runFetchContractTypes(args) {
|
|
265
|
-
const
|
|
283
|
+
const uses = { ...usesOf(args, 'optionalUses'), ...usesOf(args) };
|
|
284
|
+
const names = Object.keys(uses);
|
|
266
285
|
if (names.length === 0) {
|
|
267
286
|
throw new Error(`No contracts to fetch: add "uses" to ${CONFIG_FILE}, e.g. "uses": { "sys.access": "^1" }.`);
|
|
268
287
|
}
|
|
@@ -270,10 +289,11 @@ async function runFetchContractTypes(args) {
|
|
|
270
289
|
const dir = path.join(args.cwd, 'contracts');
|
|
271
290
|
fs.mkdirSync(dir, { recursive: true });
|
|
272
291
|
for (const name of names) {
|
|
273
|
-
|
|
292
|
+
// with a club: a contract one of its modules provides comes from there (else from the store)
|
|
293
|
+
const dts = await fetchContractTypes({ apiHost: args.apiHost, name, accessToken, club: args.club });
|
|
274
294
|
const outPath = path.join(dir, `${name}.d.ts`);
|
|
275
295
|
fs.writeFileSync(outPath, dts);
|
|
276
|
-
console.log(`Wrote ${outPath} (${
|
|
296
|
+
console.log(`Wrote ${outPath} (${uses[name]})`);
|
|
277
297
|
}
|
|
278
298
|
}
|
|
279
299
|
|
|
@@ -312,13 +332,31 @@ async function main() {
|
|
|
312
332
|
await runInit(flags.cwd, flags);
|
|
313
333
|
return;
|
|
314
334
|
}
|
|
315
|
-
const
|
|
335
|
+
const local = readLocalConfig(flags.cwd);
|
|
336
|
+
const localValues = { ...(local ? local.values : {}) };
|
|
337
|
+
// A store submission goes where tabbr.config.json says (production), not to a local file's API host: --local or
|
|
338
|
+
// --api-host for another one.
|
|
339
|
+
const storeHostKept = command === 'publish' && flags.store && localValues.apiHost !== undefined;
|
|
340
|
+
if (storeHostKept) delete localValues.apiHost;
|
|
341
|
+
const args = { apiHost: DEFAULT_API_HOST, ...readConfig(flags.cwd), ...localValues, ...flags };
|
|
316
342
|
if (args.local && !flags.apiHost) {
|
|
317
343
|
// --local targets your own local backend (https://api.local.tabbr.be, the same one
|
|
318
|
-
// <club>.local.tabbr.be talks to); "localApiHost" in tabbr.config.json overrides it.
|
|
344
|
+
// <club>.local.tabbr.be talks to); "localApiHost" in tabbr.config.local.json or tabbr.config.json overrides it.
|
|
319
345
|
args.apiHost = args.localApiHost || DEFAULT_LOCAL_API_HOST;
|
|
320
346
|
}
|
|
321
347
|
trustLocalCertificateOrRespawn(args.apiHost);
|
|
348
|
+
// after a respawn for the local certificate: said once
|
|
349
|
+
if (local) {
|
|
350
|
+
const where = path.relative(flags.cwd, local.file);
|
|
351
|
+
const set = Object.entries(localValues).map(([key, value]) => `${key} ${value}`).join(', ');
|
|
352
|
+
console.log(`Using ${where}${set ? `: ${set}` : ''}`);
|
|
353
|
+
if (storeHostKept) {
|
|
354
|
+
console.log(` not its apiHost: the module store at ${args.apiHost}${args.local || flags.apiHost ? '' : ' (--local for your local backend\'s)'}`);
|
|
355
|
+
}
|
|
356
|
+
if (local.ignored.length) {
|
|
357
|
+
console.warn(` ignored there: ${local.ignored.join(', ')} - only ${LOCAL_CONFIG_KEYS.join(', ')} can be set in ${LOCAL_CONFIG_FILE}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
322
360
|
if (command === 'publish') {
|
|
323
361
|
await runPublish(args);
|
|
324
362
|
} else if (command === 'build') {
|
package/package.json
CHANGED
package/src/backend-build.js
CHANGED
|
@@ -46,27 +46,67 @@ function entitiesOf(config) {
|
|
|
46
46
|
return entities;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
const CONTRACT_NAME = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/;
|
|
50
|
+
|
|
49
51
|
/**
|
|
50
|
-
* The contracts the module uses (`"uses"` in tabbr.config.json, e.g. `{ "sys.access": "^1" }`)
|
|
51
|
-
* ranges, checked just enough to fail here - Tabbr checks
|
|
52
|
-
* satisfy) when the module is published or installed.
|
|
52
|
+
* The contracts the module uses (`"uses"` in tabbr.config.json, e.g. `{ "sys.access": "^1" }`), or uses when the club
|
|
53
|
+
* has them (`key` `"optionalUses"`): their names and version ranges, checked just enough to fail here - Tabbr checks
|
|
54
|
+
* them again (a contract it doesn't have, a range it can't satisfy) when the module is published or installed.
|
|
53
55
|
*/
|
|
54
|
-
function usesOf(config) {
|
|
55
|
-
const uses = config
|
|
56
|
+
function usesOf(config, key = 'uses') {
|
|
57
|
+
const uses = config[key] || {};
|
|
56
58
|
if (typeof uses !== 'object' || Array.isArray(uses)) {
|
|
57
|
-
throw new Error(
|
|
59
|
+
throw new Error(`"${key}" in tabbr.config.json must be an object: { "<contract>": "<version range>" }, e.g. { "sys.access": "^1" }.`);
|
|
58
60
|
}
|
|
59
61
|
for (const [name, range] of Object.entries(uses)) {
|
|
60
|
-
if (
|
|
61
|
-
throw new Error(`"
|
|
62
|
+
if (!CONTRACT_NAME.test(name)) {
|
|
63
|
+
throw new Error(`"${key}": "${name}" is not a contract name (e.g. "sys.access").`);
|
|
62
64
|
}
|
|
63
65
|
if (typeof range !== 'string' || !range.trim()) {
|
|
64
|
-
throw new Error(`"
|
|
66
|
+
throw new Error(`"${key}": "${name}" needs a version range, e.g. "^1".`);
|
|
65
67
|
}
|
|
66
68
|
}
|
|
67
69
|
return uses;
|
|
68
70
|
}
|
|
69
71
|
|
|
72
|
+
/**
|
|
73
|
+
* The contracts the module provides itself (`"provides"` in tabbr.config.json, e.g.
|
|
74
|
+
* `{ "acme.groups": { "version": "1.0.0", "types": "contracts/acme.groups.d.ts" } }`): for the manifest their names and
|
|
75
|
+
* versions, and their TypeScript - which goes into the build as `backend/contracts/<name>.d.ts`, where Tabbr serves it
|
|
76
|
+
* to the modules that use the contract. The file stands on its own (no imports) and exports `interface Frontend`
|
|
77
|
+
* and/or `interface Backend`. Tabbr checks the rest (the name is the publisher's, no other module has it).
|
|
78
|
+
*/
|
|
79
|
+
function providesOf(cwd, config) {
|
|
80
|
+
const provides = config.provides || {};
|
|
81
|
+
if (typeof provides !== 'object' || Array.isArray(provides)) {
|
|
82
|
+
throw new Error('"provides" in tabbr.config.json must be an object: { "<contract>": { "version": "1.0.0", "types": "contracts/<contract>.d.ts" } }.');
|
|
83
|
+
}
|
|
84
|
+
const versions = {};
|
|
85
|
+
const files = {};
|
|
86
|
+
for (const [name, contract] of Object.entries(provides)) {
|
|
87
|
+
if (!CONTRACT_NAME.test(name) || name.startsWith('sys.')) {
|
|
88
|
+
throw new Error(`"provides": "${name}" is not a contract name a module can provide (e.g. "acme.groups"; "sys." is Tabbr's).`);
|
|
89
|
+
}
|
|
90
|
+
if (!contract || !/^\d+\.\d+\.\d+$/.test(contract.version || '')) {
|
|
91
|
+
throw new Error(`"provides": "${name}" needs a version, e.g. { "version": "1.0.0" }.`);
|
|
92
|
+
}
|
|
93
|
+
const typesPath = path.join(cwd, contract.types || `contracts/${name}.d.ts`);
|
|
94
|
+
if (!fs.existsSync(typesPath)) {
|
|
95
|
+
throw new Error(`"provides": the TypeScript of "${name}" is missing: ${typesPath}.`);
|
|
96
|
+
}
|
|
97
|
+
const source = fs.readFileSync(typesPath, 'utf8');
|
|
98
|
+
if (!/\bexport\s+interface\s+(Frontend|Backend)\b/.test(source)) {
|
|
99
|
+
throw new Error(`"provides": ${typesPath} exports neither interface Frontend nor interface Backend.`);
|
|
100
|
+
}
|
|
101
|
+
if (/^\s*import\b/m.test(source)) {
|
|
102
|
+
throw new Error(`"provides": ${typesPath} may not import anything - a contract stands on its own.`);
|
|
103
|
+
}
|
|
104
|
+
versions[name] = contract.version;
|
|
105
|
+
files[`contracts/${name}.d.ts`] = source;
|
|
106
|
+
}
|
|
107
|
+
return { versions, files };
|
|
108
|
+
}
|
|
109
|
+
|
|
70
110
|
/**
|
|
71
111
|
* Bundles a module workspace's backend part (if configured) into a single, dependency-free
|
|
72
112
|
* `bundle.js`, using esbuild so module authors can write normal TypeScript (types, imports,
|
|
@@ -75,18 +115,21 @@ function usesOf(config) {
|
|
|
75
115
|
* `BackendModuleRuntimeService`).
|
|
76
116
|
*
|
|
77
117
|
* Also writes the manifest the server reads (`manifest.json`): the backend config plus the entities the module declares
|
|
78
|
-
* and the contracts it uses - which a module can do without any backend code, so then only the manifest
|
|
79
|
-
* (`bundleJs` null).
|
|
118
|
+
* and the contracts it uses and provides - which a module can do without any backend code, so then only the manifest
|
|
119
|
+
* is produced (`bundleJs` null). `files`: more files for the build's `backend/` (the TypeScript of what it provides).
|
|
80
120
|
*
|
|
81
|
-
* Returns `null` when the workspace has no backend part, no entities and uses no contracts, so `build.js`
|
|
82
|
-
* it entirely for such a frontend-only module.
|
|
121
|
+
* Returns `null` when the workspace has no backend part, no entities and uses or provides no contracts, so `build.js`
|
|
122
|
+
* can skip it entirely for such a frontend-only module.
|
|
83
123
|
*/
|
|
84
124
|
async function buildBackend(cwd, config) {
|
|
85
125
|
const backendConfig = config.backend;
|
|
86
126
|
const entities = entitiesOf(config);
|
|
87
127
|
const uses = usesOf(config);
|
|
128
|
+
const optionalUses = usesOf(config, 'optionalUses');
|
|
129
|
+
const provides = providesOf(cwd, config);
|
|
88
130
|
const bundleJs = backendConfig ? await bundle(cwd, backendConfig) : null;
|
|
89
|
-
if (bundleJs == null && entities.length === 0 && Object.keys(uses).length === 0)
|
|
131
|
+
if (bundleJs == null && entities.length === 0 && Object.keys(uses).length === 0 && Object.keys(optionalUses).length === 0
|
|
132
|
+
&& Object.keys(provides.versions).length === 0) {
|
|
90
133
|
return null;
|
|
91
134
|
}
|
|
92
135
|
const settings = backendConfig || {};
|
|
@@ -100,9 +143,12 @@ async function buildBackend(cwd, config) {
|
|
|
100
143
|
manageRoles: bundleJs != null && settings.manageRoles === true,
|
|
101
144
|
// the contracts it uses - for its frontend part as well, so the manifest is written for a frontend-only module too
|
|
102
145
|
uses,
|
|
146
|
+
optionalUses,
|
|
147
|
+
// the contracts it provides, with their version; their TypeScript goes along as backend/contracts/<name>.d.ts
|
|
148
|
+
provides: provides.versions,
|
|
103
149
|
};
|
|
104
150
|
|
|
105
|
-
return { bundleJs, manifestJson: JSON.stringify(manifest, null, 2) };
|
|
151
|
+
return { bundleJs, manifestJson: JSON.stringify(manifest, null, 2), files: provides.files };
|
|
106
152
|
}
|
|
107
153
|
|
|
108
154
|
/** The backend entry bundled into one script, or null when there is no entry file (and none was configured explicitly). */
|
|
@@ -145,4 +191,4 @@ async function bundle(cwd, backendConfig) {
|
|
|
145
191
|
return result.outputFiles[0].text;
|
|
146
192
|
}
|
|
147
193
|
|
|
148
|
-
module.exports = { buildBackend, entitiesOf, usesOf, DEFAULT_ENTRY };
|
|
194
|
+
module.exports = { buildBackend, entitiesOf, usesOf, providesOf, DEFAULT_ENTRY };
|
package/src/build.js
CHANGED
|
@@ -82,6 +82,9 @@ function zipModule(frontendDir, backend) {
|
|
|
82
82
|
archive.append(backend.bundleJs, { name: 'backend/bundle.js' });
|
|
83
83
|
}
|
|
84
84
|
archive.append(backend.manifestJson, { name: 'backend/manifest.json' });
|
|
85
|
+
for (const [name, content] of Object.entries(backend.files || {})) {
|
|
86
|
+
archive.append(content, { name: `backend/${name}` });
|
|
87
|
+
}
|
|
85
88
|
}
|
|
86
89
|
archive.finalize();
|
|
87
90
|
});
|
package/src/config.js
CHANGED
|
@@ -3,10 +3,23 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const readline = require('readline/promises');
|
|
6
|
+
const { execFileSync } = require('child_process');
|
|
7
|
+
const { DEFAULT_LOCAL_API_HOST } = require('./local');
|
|
6
8
|
|
|
7
9
|
const CONFIG_FILE = 'tabbr.config.json';
|
|
10
|
+
const LOCAL_CONFIG_FILE = 'tabbr.config.local.json';
|
|
11
|
+
/** What a tabbr.config.local.json may set: where this checkout talks to - never what the module is. */
|
|
12
|
+
const LOCAL_CONFIG_KEYS = ['club', 'apiHost', 'localApiHost', 'shellUrl'];
|
|
8
13
|
const DEFAULT_API_HOST = 'https://api.tabbr.be';
|
|
9
14
|
|
|
15
|
+
function readJson(file) {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
18
|
+
} catch (err) {
|
|
19
|
+
throw new Error(`${file} is not valid JSON: ${err.message}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
10
23
|
/**
|
|
11
24
|
* Reads the workspace's tabbr.config.json - the one place a module project records which club it
|
|
12
25
|
* targets, its module id and so on. Both this CLI and the module's own dev-preview harness read it,
|
|
@@ -14,18 +27,31 @@ const DEFAULT_API_HOST = 'https://api.tabbr.be';
|
|
|
14
27
|
*/
|
|
15
28
|
function readConfig(cwd) {
|
|
16
29
|
const file = path.join(cwd, CONFIG_FILE);
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
30
|
+
return fs.existsSync(file) ? readJson(file) : {};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The developer's own tabbr.config.local.json (not committed): the club and API host this checkout
|
|
35
|
+
* talks to, over those in tabbr.config.json - so a module whose tabbr.config.json targets
|
|
36
|
+
* production can be developed against a local backend without editing it. The one in the
|
|
37
|
+
* workspace, else the one a folder up (which then covers every module in that folder); the module's
|
|
38
|
+
* dev preview finds the same one (tsconfig.local-config.json). Only LOCAL_CONFIG_KEYS count, the
|
|
39
|
+
* other keys are returned as `ignored`. Returns null when there is none.
|
|
40
|
+
*/
|
|
41
|
+
function readLocalConfig(cwd) {
|
|
42
|
+
const file = [path.join(cwd, LOCAL_CONFIG_FILE), path.join(cwd, '..', LOCAL_CONFIG_FILE)].find(f => fs.existsSync(f));
|
|
43
|
+
if (!file) return null;
|
|
44
|
+
const all = readJson(file);
|
|
45
|
+
const values = Object.fromEntries(LOCAL_CONFIG_KEYS.filter(key => all[key] !== undefined).map(key => [key, all[key]]));
|
|
46
|
+
const ignored = Object.keys(all).filter(key => !LOCAL_CONFIG_KEYS.includes(key));
|
|
47
|
+
return { file, values, ignored };
|
|
23
48
|
}
|
|
24
49
|
|
|
25
50
|
/** Uppercase club code, or throws with instructions when none is configured. */
|
|
26
51
|
function requireClub(club) {
|
|
27
52
|
if (!club) {
|
|
28
|
-
throw new Error(`No club configured.
|
|
53
|
+
throw new Error(`No club configured. Set "club" in ${LOCAL_CONFIG_FILE} (yours, not committed) or ${CONFIG_FILE}, ` +
|
|
54
|
+
'or run "npx tabbr-module init".');
|
|
29
55
|
}
|
|
30
56
|
return club.toUpperCase();
|
|
31
57
|
}
|
|
@@ -34,19 +60,39 @@ function slug(text) {
|
|
|
34
60
|
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
35
61
|
}
|
|
36
62
|
|
|
37
|
-
/**
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
63
|
+
/** False only when the workspace is in a git repository that would track `file`. */
|
|
64
|
+
function gitIgnores(cwd, file) {
|
|
65
|
+
try {
|
|
66
|
+
execFileSync('git', ['check-ignore', '-q', file], { cwd, stdio: 'ignore' });
|
|
67
|
+
return true;
|
|
68
|
+
} catch (err) {
|
|
69
|
+
return err.status !== 1; // 1: not ignored; anything else: no git, or not a repository
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function asker() {
|
|
44
74
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
45
75
|
const ask = async (question, fallback) => {
|
|
46
76
|
if (!process.stdin.isTTY) return fallback;
|
|
47
77
|
const answer = (await rl.question(fallback ? `${question} [${fallback}]: ` : `${question}: `)).trim();
|
|
48
78
|
return answer || fallback;
|
|
49
79
|
};
|
|
80
|
+
return { ask, close: () => rl.close() };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Asks for the club code (and offers defaults for the rest) and writes tabbr.config.json, keeping
|
|
85
|
+
* any values already in it as the defaults. With --local: only where you develop against, in
|
|
86
|
+
* tabbr.config.local.json - see runInitLocal.
|
|
87
|
+
*/
|
|
88
|
+
async function runInit(cwd, overrides) {
|
|
89
|
+
if (overrides.local) {
|
|
90
|
+
await runInitLocal(cwd, overrides);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const file = path.join(cwd, CONFIG_FILE);
|
|
94
|
+
const current = readConfig(cwd);
|
|
95
|
+
const { ask, close } = asker();
|
|
50
96
|
|
|
51
97
|
try {
|
|
52
98
|
const club = (overrides.club || await ask('Club code (the part before .tabbr.be, e.g. abc)', current.club))?.toUpperCase();
|
|
@@ -68,8 +114,35 @@ async function runInit(cwd, overrides) {
|
|
|
68
114
|
fs.writeFileSync(file, JSON.stringify(config, null, 2) + '\n');
|
|
69
115
|
console.log(`\nWrote ${file}. Next: "npm start" to preview, "npm run publish-module" to publish.`);
|
|
70
116
|
} finally {
|
|
71
|
-
|
|
117
|
+
close();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* `init --local`: asks for the club code and the API host - by default your own local backend
|
|
123
|
+
* (https://api.local.tabbr.be) - and writes them in the workspace's tabbr.config.local.json, and
|
|
124
|
+
* nothing else: tabbr.config.json, the module's manifest, stays as it is (e.g. on production, for
|
|
125
|
+
* the store).
|
|
126
|
+
*/
|
|
127
|
+
async function runInitLocal(cwd, overrides) {
|
|
128
|
+
const file = path.join(cwd, LOCAL_CONFIG_FILE);
|
|
129
|
+
const current = fs.existsSync(file) ? readJson(file) : {};
|
|
130
|
+
const { ask, close } = asker();
|
|
131
|
+
try {
|
|
132
|
+
const club = (overrides.club || await ask('Club code (e.g. abc)', current.club || readConfig(cwd).club))?.toUpperCase();
|
|
133
|
+
if (!club) throw new Error('A club code is required.');
|
|
134
|
+
const apiHost = overrides.apiHost
|
|
135
|
+
|| await ask(`API host (${DEFAULT_API_HOST} for a club on production)`, current.apiHost || DEFAULT_LOCAL_API_HOST);
|
|
136
|
+
const local = { ...current, club, apiHost };
|
|
137
|
+
fs.writeFileSync(file, JSON.stringify(local, null, 2) + '\n');
|
|
138
|
+
console.log(`\nWrote ${file}: club ${club}, apiHost ${apiHost}. Next: "npm start" to preview, ` +
|
|
139
|
+
'"npm run publish-module" to publish there.');
|
|
140
|
+
if (!gitIgnores(cwd, file)) console.warn(`It's yours, not the module's: add ${LOCAL_CONFIG_FILE} to .gitignore.`);
|
|
141
|
+
} finally {
|
|
142
|
+
close();
|
|
72
143
|
}
|
|
73
144
|
}
|
|
74
145
|
|
|
75
|
-
module.exports = {
|
|
146
|
+
module.exports = {
|
|
147
|
+
CONFIG_FILE, LOCAL_CONFIG_FILE, LOCAL_CONFIG_KEYS, DEFAULT_API_HOST, readConfig, readLocalConfig, requireClub, runInit,
|
|
148
|
+
};
|
package/src/fetch-types.js
CHANGED
|
@@ -51,10 +51,13 @@ async function fetchAllEntityTypes({ apiHost, club, accessToken }) {
|
|
|
51
51
|
* `@tabbr/contracts/<name>`, plus its entries in `TabbrUiContracts` (what `MODULE_CONTRACTS.get(name)` returns) and
|
|
52
52
|
* `TabbrContracts` (`tabbr.uses[name]` server-side). See docs/module-contracts.md (tabbr repo).
|
|
53
53
|
*/
|
|
54
|
-
async function fetchContractTypes({ apiHost, name, accessToken }) {
|
|
54
|
+
async function fetchContractTypes({ apiHost, name, accessToken, club }) {
|
|
55
55
|
const url = `${apiHost}/api/contracts/${encodeURIComponent(name)}/types`;
|
|
56
56
|
console.log(`Fetching the contract ${name} from ${url} ...`);
|
|
57
|
-
const
|
|
57
|
+
const headers = { Authorization: `Bearer ${accessToken}` };
|
|
58
|
+
// a contract a module provides: the club's version of it, when the club has that module
|
|
59
|
+
if (club) headers['X-club'] = club.toUpperCase();
|
|
60
|
+
const response = await fetch(url, { headers });
|
|
58
61
|
if (!response.ok) {
|
|
59
62
|
throw new Error(`Fetching the contract ${name} failed: ${response.status} ${await response.text()}`);
|
|
60
63
|
}
|
package/src/source.js
CHANGED
|
@@ -5,8 +5,12 @@ const path = require('path');
|
|
|
5
5
|
const { execFileSync } = require('child_process');
|
|
6
6
|
const archiver = require('archiver');
|
|
7
7
|
|
|
8
|
-
/**
|
|
9
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Never part of a module's source: installed packages, build output, caches, editor state - and the developer's own
|
|
10
|
+
* tabbr.config.local.json (where they develop against), also when it isn't in .gitignore.
|
|
11
|
+
*/
|
|
12
|
+
const SKIP = new Set(['node_modules', 'dist', '.angular', 'out-tsc', '.git', '.yalc', '.idea', '.vscode', 'coverage',
|
|
13
|
+
'tabbr.config.local.json']);
|
|
10
14
|
|
|
11
15
|
/**
|
|
12
16
|
* The workspace's source files, as the module store builds them: the files git tracks (plus new ones git doesn't ignore)
|