@tabbr/module-cli 0.2.0 → 0.4.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 +100 -1
- package/bin/tabbr-module.js +88 -3
- package/package.json +1 -1
- package/src/backend-build.js +88 -14
- package/src/build.js +3 -1
- package/src/fetch-types.js +16 -1
- package/src/publish.js +73 -1
- package/src/source.js +70 -0
package/README.md
CHANGED
|
@@ -85,9 +85,16 @@ export function pollFlights() {
|
|
|
85
85
|
only by a `publish --local` (e.g. `localhost` for a mock of the external service you develop against) - never by a normal publish.
|
|
86
86
|
- `tabbr.settings.get(key)` / `.set(key, value)` - this module's own per-club settings, including
|
|
87
87
|
its own secrets (never sent back to any settings GET response once saved - `secretFieldNames`).
|
|
88
|
+
- `tabbr.access` - the club's roles (`roles()`), and - with `"manageRoles": true` in the `backend` block - the roles
|
|
89
|
+
this module gives persons: `setManagedRoles(personId, roleCodes)` makes them exactly these (e.g. groups giving its
|
|
90
|
+
members a role), `managedRoles(personId)`, `managedAssignments()`. A module only ever touches the roles it gives
|
|
91
|
+
itself (roles assigned by hand stay), can't give a system role such as ADMIN, and the roles it gave go when it is
|
|
92
|
+
uninstalled or blocked. The club sees "gives roles" when it installs the module.
|
|
88
93
|
- `triggers[].type: "CRON"` runs on the given cron expression (server-local time), with no
|
|
89
94
|
triggering entity. `"ENTITY_HOOK"` runs on the named `entity`'s `event` (`onCreate`/`onUpdate`/
|
|
90
|
-
`onDelete`), with that entity bound under its own name (e.g. `flight`) in the handler's scope.
|
|
95
|
+
`onDelete`), with that entity bound under its own name (e.g. `flight`) in the handler's scope - before the save.
|
|
96
|
+
`onCreated` / `onUpdated` run after it, once committed, in the background: for a handler that reads the database
|
|
97
|
+
(e.g. "is this person in that list now"). A module's own writes don't trigger its handlers.
|
|
91
98
|
- Needs `esbuild` installed in the workspace (`npm install -D esbuild`) - only used at publish
|
|
92
99
|
time, bundled into one dependency-free `bundle.js` the server runs in a GraalVM sandbox, so
|
|
93
100
|
regular npm packages can be imported in your TS but nothing Node-specific (no `fs`, no native
|
|
@@ -96,6 +103,49 @@ export function pollFlights() {
|
|
|
96
103
|
A "resync now" button (or similar) can fire a `CRON` trigger manually:
|
|
97
104
|
`POST /api/custommodule/{moduleId}/trigger/{triggerName}` (ADMIN).
|
|
98
105
|
|
|
106
|
+
## Entities
|
|
107
|
+
|
|
108
|
+
A module can declare its own **entities** - records Tabbr stores and serves like its own (lists, filters, access
|
|
109
|
+
rules, the club's extra fields, hooks, audit). Add an `"entities"` array to `tabbr.config.json`:
|
|
110
|
+
|
|
111
|
+
```json
|
|
112
|
+
{
|
|
113
|
+
"entities": [
|
|
114
|
+
{
|
|
115
|
+
"name": "group",
|
|
116
|
+
"label": "Groep",
|
|
117
|
+
"display": ["name"],
|
|
118
|
+
"fields": [
|
|
119
|
+
{ "key": "name", "label": "Naam", "type": "tabbr.text", "includeInSearch": true },
|
|
120
|
+
{ "key": "parent", "label": "Bovenliggende groep", "type": "tabbr.entity", "target": "group" }
|
|
121
|
+
]
|
|
122
|
+
}
|
|
123
|
+
]
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`publish` checks the declarations (names, keys, types) before uploading and sends them in the backend manifest - also
|
|
128
|
+
for a module that has no backend code (then only the entities are published). The frontend addresses records by field
|
|
129
|
+
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.
|
|
131
|
+
|
|
132
|
+
## Contracts
|
|
133
|
+
|
|
134
|
+
What a module uses of Tabbr (or, later, of another module) is a **contract** - never its code. Declare the ones you use
|
|
135
|
+
with a version range, and the module API level that has contracts:
|
|
136
|
+
|
|
137
|
+
```json
|
|
138
|
+
{
|
|
139
|
+
"engines": { "tabbr": "^1.1" },
|
|
140
|
+
"uses": { "sys.access": "^1" }
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`publish` puts `uses` in the manifest (also for a module without backend code); Tabbr only installs the module where
|
|
145
|
+
the club has those contracts, and its server-side part gets them as `tabbr.uses['sys.access']`. The frontend gets a
|
|
146
|
+
contract with the SDK's `MODULE_CONTRACTS` (`await inject(MODULE_CONTRACTS).get('sys.access')`). Their types come from
|
|
147
|
+
`fetch-types --contracts` (below). See `docs/module-contracts.md` in the Tabbr repository.
|
|
148
|
+
|
|
99
149
|
## Usage: publish
|
|
100
150
|
|
|
101
151
|
```
|
|
@@ -135,6 +185,46 @@ doesn't do on its own (`NODE_EXTRA_CA_CERTS` is set automatically; if mkcert's r
|
|
|
135
185
|
set it yourself - `mkcert -CAROOT` shows where it lives). `--club` picks the club in that environment;
|
|
136
186
|
login still goes through the real Keycloak, which local backends validate too.
|
|
137
187
|
|
|
188
|
+
### Publishing to the module store
|
|
189
|
+
|
|
190
|
+
`npx tabbr-module publish --store [--changelog "..."]` submits a new **version of a store
|
|
191
|
+
package** instead of publishing it into one club (see `docs/module-store.md` in the Tabbr repo):
|
|
192
|
+
the workspace's **source** - the files git tracks (plus new ones git doesn't ignore), never
|
|
193
|
+
`node_modules` or build output - with its `package-lock.json`. The store builds it with the
|
|
194
|
+
platform's toolchain (`tabbr-module-builder`: its own `@tabbr/module-sdk` and `-cli`, your locked
|
|
195
|
+
dependencies, your `test` and `typecheck` scripts, no network during the build); then it goes into
|
|
196
|
+
review; once approved, every club can install it under *Instellingen › Modules › Store*. Unlink
|
|
197
|
+
yalc first (`npm run unlink-local`): the store installs from the lockfile. `--prebuilt` uploads a
|
|
198
|
+
local build instead (trusted publishers only). For now only platform admins (`SUPER_ADMIN`) can
|
|
199
|
+
submit.
|
|
200
|
+
|
|
201
|
+
`npx tabbr-module build --out module.zip` builds the module into a zip without publishing it -
|
|
202
|
+
what the store's builder runs.
|
|
203
|
+
|
|
204
|
+
It needs, in `tabbr.config.json`:
|
|
205
|
+
|
|
206
|
+
```jsonc
|
|
207
|
+
{
|
|
208
|
+
"moduleId": "groups",
|
|
209
|
+
"package": "tabbr/groups", // <publisher>/<name>
|
|
210
|
+
"version": "1.2.0", // higher than every earlier version of the package
|
|
211
|
+
"engines": { "tabbr": "^1.0" }, // the module API levels it runs on (MODULE_API_LEVEL in the SDK)
|
|
212
|
+
"dependencies": { }, // optional: { "acme/payments": "^1", "sys.messaging": "*" }
|
|
213
|
+
"optionalDependencies": { "sys.messaging": "*" },
|
|
214
|
+
"permissions": { // shown to the club before it installs or updates
|
|
215
|
+
"entities": { "read": ["person"], "write": [] },
|
|
216
|
+
"personalData": ["contact"],
|
|
217
|
+
"messaging": true
|
|
218
|
+
},
|
|
219
|
+
"summary": "One line for the store",
|
|
220
|
+
"visibility": "PUBLIC" // or UNLISTED
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
The server refuses a version that can never work: a version not higher than the last, `engines`
|
|
225
|
+
that don't include the server's module API level, a changed `moduleId`, or - within the same major
|
|
226
|
+
version - entity declarations that remove or change something (a new major may).
|
|
227
|
+
|
|
138
228
|
## Usage: fetch-types
|
|
139
229
|
|
|
140
230
|
```
|
|
@@ -166,6 +256,15 @@ Fetched files reflect one specific club's live, possibly-custom field configurat
|
|
|
166
256
|
custom entities, ...) and are regenerable on demand - add `src/types/tabbr/` (or wherever `--out`
|
|
167
257
|
points) to your workspace's `.gitignore` rather than committing them.
|
|
168
258
|
|
|
259
|
+
```
|
|
260
|
+
npx tabbr-module fetch-types --contracts [--cwd .] [--api-host https://api.tabbr.be]
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
**With `--contracts`**: fetches `GET /api/contracts/{name}/types` for every contract in `uses` into
|
|
264
|
+
`contracts/<name>.d.ts`. Unlike the entity types these don't depend on a club - **commit them**: the store builds your
|
|
265
|
+
module with them. Add `contracts/*.d.ts` to your tsconfig(s); it types `MODULE_CONTRACTS.get(name)` and
|
|
266
|
+
`tabbr.uses[name]`, and its own types can be imported from `@tabbr/contracts/<name>`.
|
|
267
|
+
|
|
169
268
|
## Prerequisite: Keycloak redirect URI
|
|
170
269
|
|
|
171
270
|
Login opens your browser to Keycloak and catches the redirect on a temporary local HTTP server
|
package/bin/tabbr-module.js
CHANGED
|
@@ -5,8 +5,10 @@ const fs = require('fs');
|
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const { getAccessToken } = require('../src/auth');
|
|
7
7
|
const { buildAndZip } = require('../src/build');
|
|
8
|
-
const { publish } = require('../src/publish');
|
|
9
|
-
const {
|
|
8
|
+
const { publish, publishToStore, submitSourceToStore, storeMeta } = require('../src/publish');
|
|
9
|
+
const { packSource } = require('../src/source');
|
|
10
|
+
const { fetchScriptTypes, fetchAllEntityTypes, fetchContractTypes, VALID_KINDS } = require('../src/fetch-types');
|
|
11
|
+
const { usesOf } = require('../src/backend-build');
|
|
10
12
|
const { CONFIG_FILE, DEFAULT_API_HOST, readConfig, requireClub, runInit } = require('../src/config');
|
|
11
13
|
const { DEFAULT_LOCAL_API_HOST, trustLocalCertificateOrRespawn } = require('../src/local');
|
|
12
14
|
const DEFAULT_KIND = 'HOOK'; // matches the server's own @RequestParam default for /meta/script
|
|
@@ -29,6 +31,10 @@ function parseArgs(argv) {
|
|
|
29
31
|
case '--entity': args.entity = next(); break;
|
|
30
32
|
case '--kind': args.kind = next(); break;
|
|
31
33
|
case '--out': args.out = next(); break;
|
|
34
|
+
case '--contracts': args.contracts = true; break;
|
|
35
|
+
case '--store': args.store = true; break;
|
|
36
|
+
case '--prebuilt': args.prebuilt = true; break;
|
|
37
|
+
case '--changelog': args.changelog = next(); break;
|
|
32
38
|
default:
|
|
33
39
|
throw new Error(`Unknown option: ${arg}`);
|
|
34
40
|
}
|
|
@@ -80,6 +86,31 @@ Optional:
|
|
|
80
86
|
Requires the tabbr Keycloak client's Valid Redirect URIs to include http://localhost:*
|
|
81
87
|
(a Keycloak admin-console setting - see README.md).
|
|
82
88
|
|
|
89
|
+
Usage: tabbr-module publish --store [--changelog <text>] [--prebuilt] [options]
|
|
90
|
+
|
|
91
|
+
Submits a new version of a package to the module store (docs/module-store.md) instead of
|
|
92
|
+
publishing it into one club: the workspace's SOURCE (git-tracked files, with package-lock.json).
|
|
93
|
+
The store builds it with the platform's toolchain, then it waits for review; once approved,
|
|
94
|
+
clubs install it from Instellingen > Modules. --prebuilt uploads a local build instead (only
|
|
95
|
+
for trusted publishers). No --club needed. Needs, in ${CONFIG_FILE}:
|
|
96
|
+
package "<publisher>/<name>", e.g. "tabbr/groups"
|
|
97
|
+
version "1.4.0" - higher than every earlier version of the package
|
|
98
|
+
engines { "tabbr": "^1.0" } - the module API levels it runs on
|
|
99
|
+
and optionally dependencies, optionalDependencies, permissions, summary, visibility (PUBLIC or
|
|
100
|
+
UNLISTED) - see the groups module's ${CONFIG_FILE} for an example.
|
|
101
|
+
|
|
102
|
+
Usage: tabbr-module build --out <module.zip> [--cwd <path>]
|
|
103
|
+
|
|
104
|
+
Builds the module (frontend and/or backend part, as publish does) into a zip without
|
|
105
|
+
publishing it - what the store's builder runs.
|
|
106
|
+
|
|
107
|
+
Usage: tabbr-module fetch-types --contracts [--cwd <path>] [--api-host <url>]
|
|
108
|
+
|
|
109
|
+
Fetches the contracts the module uses ("uses" in ${CONFIG_FILE}, e.g. { "sys.access": "^1" }) into
|
|
110
|
+
contracts/<name>.d.ts - commit them: the store builds your module with them. Include contracts/ in your
|
|
111
|
+
tsconfig(s); then MODULE_CONTRACTS.get('<name>') (frontend) and tabbr.uses['<name>'] (backend) are typed,
|
|
112
|
+
and the contract's own types can be imported from '@tabbr/contracts/<name>'. No --club needed.
|
|
113
|
+
|
|
83
114
|
Usage: tabbr-module fetch-types [--club <CODE>] [--entity <name>] [options]
|
|
84
115
|
|
|
85
116
|
With --entity: fetches the ambient .d.ts for that ONE entity's script sandbox - the same typings
|
|
@@ -113,6 +144,10 @@ function requireFlags(args, requiredFlags) {
|
|
|
113
144
|
}
|
|
114
145
|
|
|
115
146
|
async function runPublish(args) {
|
|
147
|
+
if (args.store) {
|
|
148
|
+
await runPublishToStore(args);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
116
151
|
args.club = requireClub(args.club);
|
|
117
152
|
requireFlags(args, {
|
|
118
153
|
moduleId: '--module-id',
|
|
@@ -139,6 +174,33 @@ async function runPublish(args) {
|
|
|
139
174
|
console.log(JSON.stringify(result, null, 2));
|
|
140
175
|
}
|
|
141
176
|
|
|
177
|
+
async function runPublishToStore(args) {
|
|
178
|
+
requireFlags(args, { moduleId: '--module-id' });
|
|
179
|
+
const meta = storeMeta(args);
|
|
180
|
+
let version;
|
|
181
|
+
if (args.prebuilt) {
|
|
182
|
+
const zipBuffer = await buildAndZip(args.cwd, args);
|
|
183
|
+
const accessToken = await getAccessToken();
|
|
184
|
+
version = await publishToStore({ apiHost: args.apiHost, packageName: args.package, meta, zipBuffer, accessToken });
|
|
185
|
+
} else {
|
|
186
|
+
const { zip, files } = await packSource(args.cwd);
|
|
187
|
+
console.log(`Source: ${files.length} files, ${Math.round(zip.length / 1024)} KB`);
|
|
188
|
+
const accessToken = await getAccessToken();
|
|
189
|
+
version = await submitSourceToStore({ apiHost: args.apiHost, packageName: args.package, meta, sourceZip: zip, accessToken });
|
|
190
|
+
}
|
|
191
|
+
console.log(`Submitted ${version.packageName} ${version.version} - ${version.state}.` +
|
|
192
|
+
(version.state === 'BUILDING' ? ' The store builds it; then it is reviewed, and installable once approved.' : ' It can be installed once it is approved.'));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function runBuild(args) {
|
|
196
|
+
requireFlags(args, { out: '--out' });
|
|
197
|
+
const zipBuffer = await buildAndZip(args.cwd, args);
|
|
198
|
+
const out = path.resolve(args.cwd, args.out);
|
|
199
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
200
|
+
fs.writeFileSync(out, zipBuffer);
|
|
201
|
+
console.log(`Wrote ${out} (${Math.round(zipBuffer.length / 1024)} KB)`);
|
|
202
|
+
}
|
|
203
|
+
|
|
142
204
|
async function fetchAndWriteOne({ apiHost, club, entity, kind, cwd, out, accessToken }) {
|
|
143
205
|
const dts = await fetchScriptTypes({ apiHost, club, entity, kind, accessToken });
|
|
144
206
|
|
|
@@ -198,7 +260,28 @@ ${dts}
|
|
|
198
260
|
console.log(`Wrote ${outPath}`);
|
|
199
261
|
}
|
|
200
262
|
|
|
263
|
+
/** One file per contract the module uses: contracts/<name>.d.ts. */
|
|
264
|
+
async function runFetchContractTypes(args) {
|
|
265
|
+
const names = Object.keys(usesOf(args));
|
|
266
|
+
if (names.length === 0) {
|
|
267
|
+
throw new Error(`No contracts to fetch: add "uses" to ${CONFIG_FILE}, e.g. "uses": { "sys.access": "^1" }.`);
|
|
268
|
+
}
|
|
269
|
+
const accessToken = await getAccessToken();
|
|
270
|
+
const dir = path.join(args.cwd, 'contracts');
|
|
271
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
272
|
+
for (const name of names) {
|
|
273
|
+
const dts = await fetchContractTypes({ apiHost: args.apiHost, name, accessToken });
|
|
274
|
+
const outPath = path.join(dir, `${name}.d.ts`);
|
|
275
|
+
fs.writeFileSync(outPath, dts);
|
|
276
|
+
console.log(`Wrote ${outPath} (${args.uses[name]})`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
201
280
|
async function runFetchTypes(args) {
|
|
281
|
+
if (args.contracts) {
|
|
282
|
+
await runFetchContractTypes(args);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
202
285
|
args.club = requireClub(args.club);
|
|
203
286
|
if (args.kind !== DEFAULT_KIND && !args.entity) {
|
|
204
287
|
console.error('--kind only applies to a single --entity fetch (the combined file has no script sandbox, so no kind).\n');
|
|
@@ -219,7 +302,7 @@ async function runFetchTypes(args) {
|
|
|
219
302
|
async function main() {
|
|
220
303
|
const [command, ...rest] = process.argv.slice(2);
|
|
221
304
|
|
|
222
|
-
if (
|
|
305
|
+
if (!['init', 'publish', 'build', 'fetch-types'].includes(command)) {
|
|
223
306
|
printUsage();
|
|
224
307
|
process.exit(command ? 1 : 0);
|
|
225
308
|
}
|
|
@@ -238,6 +321,8 @@ async function main() {
|
|
|
238
321
|
trustLocalCertificateOrRespawn(args.apiHost);
|
|
239
322
|
if (command === 'publish') {
|
|
240
323
|
await runPublish(args);
|
|
324
|
+
} else if (command === 'build') {
|
|
325
|
+
await runBuild(args);
|
|
241
326
|
} else {
|
|
242
327
|
await runFetchTypes(args);
|
|
243
328
|
}
|
package/package.json
CHANGED
package/src/backend-build.js
CHANGED
|
@@ -9,6 +9,64 @@ const DEFAULT_ENTRY = 'backend/src/index.ts';
|
|
|
9
9
|
* in the module's TS ends up reachable as the global `__module.foo` once bundled this way. */
|
|
10
10
|
const GLOBAL_NAME = '__module';
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* The entities the module declares (`"entities"` in tabbr.config.json), checked just enough to fail here rather than at
|
|
14
|
+
* upload with a vaguer message: Tabbr validates them again against the club (names taken, entity fields pointing at
|
|
15
|
+
* entities the club has, ...). See `ModuleEntityDefinition` in tabbr-api-server for the full shape.
|
|
16
|
+
*/
|
|
17
|
+
function entitiesOf(config) {
|
|
18
|
+
const entities = config.entities || [];
|
|
19
|
+
if (!Array.isArray(entities)) {
|
|
20
|
+
throw new Error('"entities" in tabbr.config.json must be an array.');
|
|
21
|
+
}
|
|
22
|
+
for (const entity of entities) {
|
|
23
|
+
if (!entity || !/^[a-z][a-zA-Z0-9]{1,39}$/.test(entity.name || '')) {
|
|
24
|
+
throw new Error(`Entity name "${entity && entity.name}" is not valid: camelCase letters and digits, starting lowercase.`);
|
|
25
|
+
}
|
|
26
|
+
if (!Array.isArray(entity.fields) || entity.fields.length === 0) {
|
|
27
|
+
throw new Error(`Entity "${entity.name}" declares no fields.`);
|
|
28
|
+
}
|
|
29
|
+
const keys = new Set();
|
|
30
|
+
for (const field of entity.fields) {
|
|
31
|
+
if (!/^[a-z][a-zA-Z0-9]{0,39}$/.test(field.key || '')) {
|
|
32
|
+
throw new Error(`Entity "${entity.name}": field key "${field.key}" is not valid.`);
|
|
33
|
+
}
|
|
34
|
+
if (keys.has(field.key)) {
|
|
35
|
+
throw new Error(`Entity "${entity.name}": field "${field.key}" is declared twice.`);
|
|
36
|
+
}
|
|
37
|
+
keys.add(field.key);
|
|
38
|
+
if (!field.type && !field.persistentType) {
|
|
39
|
+
throw new Error(`Entity "${entity.name}": field "${field.key}" has no type (e.g. "tabbr.text").`);
|
|
40
|
+
}
|
|
41
|
+
if (field.type === 'tabbr.entity' && !field.target) {
|
|
42
|
+
throw new Error(`Entity "${entity.name}": entity field "${field.key}" has no "target".`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return entities;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The contracts the module uses (`"uses"` in tabbr.config.json, e.g. `{ "sys.access": "^1" }`): their names and version
|
|
51
|
+
* ranges, checked just enough to fail here - Tabbr checks them again (a contract it doesn't have, a range it can't
|
|
52
|
+
* satisfy) when the module is published or installed.
|
|
53
|
+
*/
|
|
54
|
+
function usesOf(config) {
|
|
55
|
+
const uses = config.uses || {};
|
|
56
|
+
if (typeof uses !== 'object' || Array.isArray(uses)) {
|
|
57
|
+
throw new Error('"uses" in tabbr.config.json must be an object: { "<contract>": "<version range>" }, e.g. { "sys.access": "^1" }.');
|
|
58
|
+
}
|
|
59
|
+
for (const [name, range] of Object.entries(uses)) {
|
|
60
|
+
if (!/^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/.test(name)) {
|
|
61
|
+
throw new Error(`"uses": "${name}" is not a contract name (e.g. "sys.access").`);
|
|
62
|
+
}
|
|
63
|
+
if (typeof range !== 'string' || !range.trim()) {
|
|
64
|
+
throw new Error(`"uses": "${name}" needs a version range, e.g. "^1".`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return uses;
|
|
68
|
+
}
|
|
69
|
+
|
|
12
70
|
/**
|
|
13
71
|
* Bundles a module workspace's backend part (if configured) into a single, dependency-free
|
|
14
72
|
* `bundle.js`, using esbuild so module authors can write normal TypeScript (types, imports,
|
|
@@ -16,14 +74,39 @@ const GLOBAL_NAME = '__module';
|
|
|
16
74
|
* server evaluates it in a sandboxed GraalVM context with no module resolution of its own (see
|
|
17
75
|
* `BackendModuleRuntimeService`).
|
|
18
76
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
77
|
+
* 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 is produced
|
|
79
|
+
* (`bundleJs` null).
|
|
80
|
+
*
|
|
81
|
+
* Returns `null` when the workspace has no backend part, no entities and uses no contracts, so `build.js` can skip
|
|
82
|
+
* it entirely for such a frontend-only module.
|
|
21
83
|
*/
|
|
22
84
|
async function buildBackend(cwd, config) {
|
|
23
85
|
const backendConfig = config.backend;
|
|
24
|
-
|
|
86
|
+
const entities = entitiesOf(config);
|
|
87
|
+
const uses = usesOf(config);
|
|
88
|
+
const bundleJs = backendConfig ? await bundle(cwd, backendConfig) : null;
|
|
89
|
+
if (bundleJs == null && entities.length === 0 && Object.keys(uses).length === 0) {
|
|
25
90
|
return null;
|
|
26
91
|
}
|
|
92
|
+
const settings = backendConfig || {};
|
|
93
|
+
const manifest = {
|
|
94
|
+
// localAllowedHosts (e.g. a mock on localhost) only ever go into a --local publish, never into one for a real environment.
|
|
95
|
+
allowedHosts: [...(settings.allowedHosts || []), ...(config.local ? settings.localAllowedHosts || [] : [])],
|
|
96
|
+
secretFieldNames: settings.secretFieldNames || [],
|
|
97
|
+
triggers: bundleJs == null ? [] : settings.triggers || [],
|
|
98
|
+
entities,
|
|
99
|
+
// the module gives persons roles (tabbr.access.setManagedRoles) - shown to the club when it installs the module
|
|
100
|
+
manageRoles: bundleJs != null && settings.manageRoles === true,
|
|
101
|
+
// the contracts it uses - for its frontend part as well, so the manifest is written for a frontend-only module too
|
|
102
|
+
uses,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
return { bundleJs, manifestJson: JSON.stringify(manifest, null, 2) };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The backend entry bundled into one script, or null when there is no entry file (and none was configured explicitly). */
|
|
109
|
+
async function bundle(cwd, backendConfig) {
|
|
27
110
|
const entry = backendConfig.entry || DEFAULT_ENTRY;
|
|
28
111
|
const entryPath = path.join(cwd, entry);
|
|
29
112
|
if (!fs.existsSync(entryPath)) {
|
|
@@ -59,16 +142,7 @@ async function buildBackend(cwd, config) {
|
|
|
59
142
|
if (!result.outputFiles || result.outputFiles.length === 0) {
|
|
60
143
|
throw new Error(`esbuild produced no output for ${entryPath}`);
|
|
61
144
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const manifest = {
|
|
65
|
-
// localAllowedHosts (e.g. a mock on localhost) only ever go into a --local publish, never into one for a real environment.
|
|
66
|
-
allowedHosts: [...(backendConfig.allowedHosts || []), ...(config.local ? backendConfig.localAllowedHosts || [] : [])],
|
|
67
|
-
secretFieldNames: backendConfig.secretFieldNames || [],
|
|
68
|
-
triggers: backendConfig.triggers || [],
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
return { bundleJs, manifestJson: JSON.stringify(manifest, null, 2) };
|
|
145
|
+
return result.outputFiles[0].text;
|
|
72
146
|
}
|
|
73
147
|
|
|
74
|
-
module.exports = { buildBackend, DEFAULT_ENTRY };
|
|
148
|
+
module.exports = { buildBackend, entitiesOf, usesOf, DEFAULT_ENTRY };
|
package/src/build.js
CHANGED
|
@@ -78,7 +78,9 @@ function zipModule(frontendDir, backend) {
|
|
|
78
78
|
archive.directory(frontendDir, false);
|
|
79
79
|
}
|
|
80
80
|
if (backend) {
|
|
81
|
-
|
|
81
|
+
if (backend.bundleJs != null) {
|
|
82
|
+
archive.append(backend.bundleJs, { name: 'backend/bundle.js' });
|
|
83
|
+
}
|
|
82
84
|
archive.append(backend.manifestJson, { name: 'backend/manifest.json' });
|
|
83
85
|
}
|
|
84
86
|
archive.finalize();
|
package/src/fetch-types.js
CHANGED
|
@@ -46,4 +46,19 @@ async function fetchAllEntityTypes({ apiHost, club, accessToken }) {
|
|
|
46
46
|
return response.text();
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
/**
|
|
50
|
+
* A contract a module uses (`"uses"` in tabbr.config.json), as Tabbr serves it: its types as the module
|
|
51
|
+
* `@tabbr/contracts/<name>`, plus its entries in `TabbrUiContracts` (what `MODULE_CONTRACTS.get(name)` returns) and
|
|
52
|
+
* `TabbrContracts` (`tabbr.uses[name]` server-side). See docs/module-contracts.md (tabbr repo).
|
|
53
|
+
*/
|
|
54
|
+
async function fetchContractTypes({ apiHost, name, accessToken }) {
|
|
55
|
+
const url = `${apiHost}/api/contracts/${encodeURIComponent(name)}/types`;
|
|
56
|
+
console.log(`Fetching the contract ${name} from ${url} ...`);
|
|
57
|
+
const response = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}` } });
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
throw new Error(`Fetching the contract ${name} failed: ${response.status} ${await response.text()}`);
|
|
60
|
+
}
|
|
61
|
+
return response.text();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { fetchScriptTypes, fetchAllEntityTypes, fetchContractTypes, VALID_KINDS };
|
package/src/publish.js
CHANGED
|
@@ -28,4 +28,76 @@ async function publish({ apiHost, club, moduleId, exposedModule, exportName, ico
|
|
|
28
28
|
return response.json();
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Submits the zipped build as a new version of a store package (docs/module-store.md): POST
|
|
33
|
+
* /api/store/packages/<publisher>/<name>/versions with the build and the version's metadata. The
|
|
34
|
+
* version then waits for review; clubs install it from the store once it is approved.
|
|
35
|
+
*/
|
|
36
|
+
async function publishToStore({ apiHost, packageName, meta, zipBuffer, accessToken }) {
|
|
37
|
+
const form = new FormData();
|
|
38
|
+
form.append('file', new Blob([zipBuffer], { type: 'application/zip' }), 'module.zip');
|
|
39
|
+
form.append('meta', new Blob([JSON.stringify(meta)], { type: 'application/json' }));
|
|
40
|
+
|
|
41
|
+
const url = `${apiHost}/api/store/packages/${packageName}/versions`;
|
|
42
|
+
console.log(`Submitting ${packageName} ${meta.version} to the module store at ${url} ...`);
|
|
43
|
+
const response = await fetch(url, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
46
|
+
body: form,
|
|
47
|
+
});
|
|
48
|
+
if (!response.ok) {
|
|
49
|
+
throw new Error(`Submit failed: ${response.status} ${await response.text()}`);
|
|
50
|
+
}
|
|
51
|
+
return response.json();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The version's metadata for the store, from tabbr.config.json (and the command line). */
|
|
55
|
+
function storeMeta(args) {
|
|
56
|
+
const missing = [];
|
|
57
|
+
if (!args.package) missing.push('"package" (e.g. "acme/groups")');
|
|
58
|
+
if (!args.version) missing.push('"version" (e.g. "1.0.0")');
|
|
59
|
+
if (!args.engines || !args.engines.tabbr) missing.push('"engines": { "tabbr": "^1.0" } (the module API levels it runs on)');
|
|
60
|
+
if (missing.length) {
|
|
61
|
+
throw new Error(`Missing in tabbr.config.json for --store: ${missing.join(', ')}`);
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
version: args.version,
|
|
65
|
+
moduleId: args.moduleId,
|
|
66
|
+
exposedModule: args.exposedModule,
|
|
67
|
+
exportName: args.exportName,
|
|
68
|
+
title: args.title,
|
|
69
|
+
icon: args.icon,
|
|
70
|
+
summary: args.summary,
|
|
71
|
+
changelog: args.changelog,
|
|
72
|
+
visibility: args.visibility,
|
|
73
|
+
engines: args.engines,
|
|
74
|
+
dependencies: args.dependencies || {},
|
|
75
|
+
optionalDependencies: args.optionalDependencies || {},
|
|
76
|
+
permissions: args.permissions || {},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Submits the workspace's source as a new version of a store package: POST
|
|
82
|
+
* /api/store/packages/<publisher>/<name>/sources. The store builds it (tabbr-module-builder) and
|
|
83
|
+
* only then puts it in review.
|
|
84
|
+
*/
|
|
85
|
+
async function submitSourceToStore({ apiHost, packageName, meta, sourceZip, accessToken }) {
|
|
86
|
+
const form = new FormData();
|
|
87
|
+
form.append('source', new Blob([sourceZip], { type: 'application/zip' }), 'source.zip');
|
|
88
|
+
form.append('meta', new Blob([JSON.stringify(meta)], { type: 'application/json' }));
|
|
89
|
+
|
|
90
|
+
const url = `${apiHost}/api/store/packages/${packageName}/sources`;
|
|
91
|
+
console.log(`Submitting the source of ${packageName} ${meta.version} to the module store at ${url} ...`);
|
|
92
|
+
const response = await fetch(url, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
95
|
+
body: form,
|
|
96
|
+
});
|
|
97
|
+
if (!response.ok) {
|
|
98
|
+
throw new Error(`Submit failed: ${response.status} ${await response.text()}`);
|
|
99
|
+
}
|
|
100
|
+
return response.json();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { publish, publishToStore, submitSourceToStore, storeMeta };
|
package/src/source.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { execFileSync } = require('child_process');
|
|
6
|
+
const archiver = require('archiver');
|
|
7
|
+
|
|
8
|
+
/** Never part of a module's source: installed packages, build output, caches, editor state. */
|
|
9
|
+
const SKIP = new Set(['node_modules', 'dist', '.angular', 'out-tsc', '.git', '.yalc', '.idea', '.vscode', 'coverage']);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The workspace's source files, as the module store builds them: the files git tracks (plus new ones git doesn't ignore)
|
|
13
|
+
* when the workspace is in a git repository, otherwise every file outside the SKIP folders. Paths relative to `cwd`.
|
|
14
|
+
*/
|
|
15
|
+
function sourceFiles(cwd) {
|
|
16
|
+
let listed = null;
|
|
17
|
+
try {
|
|
18
|
+
const out = execFileSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', '.'],
|
|
19
|
+
{ cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
20
|
+
listed = out.split('\0').filter(Boolean);
|
|
21
|
+
} catch {
|
|
22
|
+
// not a git repository (or no git): walk the folder instead
|
|
23
|
+
}
|
|
24
|
+
const files = listed ?? walk(cwd, '');
|
|
25
|
+
return files
|
|
26
|
+
.filter(file => !file.split('/').some(part => SKIP.has(part)))
|
|
27
|
+
.filter(file => fs.existsSync(path.join(cwd, file)) && fs.statSync(path.join(cwd, file)).isFile())
|
|
28
|
+
.sort();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function walk(root, rel) {
|
|
32
|
+
const result = [];
|
|
33
|
+
for (const entry of fs.readdirSync(path.join(root, rel), { withFileTypes: true })) {
|
|
34
|
+
if (SKIP.has(entry.name)) continue;
|
|
35
|
+
const child = rel ? `${rel}/${entry.name}` : entry.name;
|
|
36
|
+
if (entry.isDirectory()) result.push(...walk(root, child));
|
|
37
|
+
else if (entry.isFile()) result.push(child);
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A zip of the workspace's source (see sourceFiles), checked to have what the store needs to build it. */
|
|
43
|
+
function packSource(cwd) {
|
|
44
|
+
const files = sourceFiles(cwd);
|
|
45
|
+
for (const required of ['package.json', 'package-lock.json']) {
|
|
46
|
+
if (!files.includes(required)) {
|
|
47
|
+
throw new Error(`${required} is missing in ${cwd}` +
|
|
48
|
+
(required === 'package-lock.json' ? ' - the store installs exactly the locked dependencies; run "npm install" and commit it' : ''));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// yalc-linked packages exist only on this machine: the store uses the platform's own SDK and CLI instead
|
|
52
|
+
const lock = fs.readFileSync(path.join(cwd, 'package-lock.json'), 'utf8');
|
|
53
|
+
if (lock.includes('file:.yalc/')) {
|
|
54
|
+
throw new Error('package-lock.json links @tabbr packages with yalc ("npm run link-local") - run "npm run unlink-local" ' +
|
|
55
|
+
'first: the store builds against the published packages and replaces @tabbr/module-sdk and -cli with the platform\'s own.');
|
|
56
|
+
}
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const archive = archiver('zip', { zlib: { level: 9 } });
|
|
59
|
+
const chunks = [];
|
|
60
|
+
archive.on('data', chunk => chunks.push(chunk));
|
|
61
|
+
archive.on('end', () => resolve({ zip: Buffer.concat(chunks), files }));
|
|
62
|
+
archive.on('error', reject);
|
|
63
|
+
for (const file of files) {
|
|
64
|
+
archive.file(path.join(cwd, file), { name: file });
|
|
65
|
+
}
|
|
66
|
+
archive.finalize();
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = { packSource, sourceFiles };
|