@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 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
@@ -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 { fetchScriptTypes, fetchAllEntityTypes, VALID_KINDS } = require('../src/fetch-types');
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 (command !== 'init' && command !== 'publish' && command !== 'fetch-types') {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tabbr/module-cli",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Set up, build and publish a Native Federation club module to a tabbr club.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "SystemWorks BV",
@@ -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
- * Returns `null` when the workspace has no backend part configured, so `build.js` can skip it
20
- * entirely for a frontend-only module.
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
- if (!backendConfig) {
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
- const bundleJs = result.outputFiles[0].text;
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
- archive.append(backend.bundleJs, { name: 'backend/bundle.js' });
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();
@@ -46,4 +46,19 @@ async function fetchAllEntityTypes({ apiHost, club, accessToken }) {
46
46
  return response.text();
47
47
  }
48
48
 
49
- module.exports = { fetchScriptTypes, fetchAllEntityTypes, VALID_KINDS };
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
- module.exports = { publish };
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 };