@tabbr/module-cli 0.1.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/LICENSE +34 -0
- package/README.md +113 -0
- package/bin/tabbr-module.js +221 -0
- package/package.json +33 -0
- package/src/auth.js +144 -0
- package/src/build.js +91 -0
- package/src/config.js +75 -0
- package/src/fetch-types.js +49 -0
- package/src/publish.js +29 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
Tabbr Module CLI License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SystemWorks BV. All rights reserved.
|
|
4
|
+
|
|
5
|
+
1. Grant. Subject to the terms below, SystemWorks BV ("Licensor") grants you a
|
|
6
|
+
free, non-exclusive, non-transferable, revocable license to use, copy and
|
|
7
|
+
modify the Tabbr Module CLI (the "Software") solely to develop, test and
|
|
8
|
+
run modules that are deployed on and used with the Tabbr platform operated
|
|
9
|
+
by Licensor.
|
|
10
|
+
|
|
11
|
+
2. Restrictions. You may not:
|
|
12
|
+
a. use the Software, or any part or derivative of it, for any purpose other
|
|
13
|
+
than the one in section 1, including with any platform, product or
|
|
14
|
+
service other than Tabbr;
|
|
15
|
+
b. distribute, sublicense, sell or publish the Software or derivatives of it,
|
|
16
|
+
except as part of a module distributed solely for use on the Tabbr
|
|
17
|
+
platform;
|
|
18
|
+
c. remove or alter this license or any copyright notice.
|
|
19
|
+
|
|
20
|
+
3. Ownership. The Software is licensed, not sold. Licensor retains all rights,
|
|
21
|
+
title and interest in the Software. Modules you write remain yours, but this
|
|
22
|
+
license does not grant any right to the Tabbr platform itself, which is
|
|
23
|
+
governed by its own terms of use.
|
|
24
|
+
|
|
25
|
+
4. Termination. This license ends automatically if you breach it, or when your
|
|
26
|
+
access to the Tabbr platform ends. On termination you must stop using the
|
|
27
|
+
Software.
|
|
28
|
+
|
|
29
|
+
5. No warranty. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
|
|
30
|
+
TO THE EXTENT PERMITTED BY LAW, LICENSOR IS NOT LIABLE FOR ANY DAMAGES
|
|
31
|
+
ARISING FROM THE USE OF THE SOFTWARE.
|
|
32
|
+
|
|
33
|
+
6. Governing law. This license is governed by Belgian law. The courts of Ghent
|
|
34
|
+
(Gent), Belgium have exclusive jurisdiction.
|
package/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# @tabbr/module-cli
|
|
2
|
+
|
|
3
|
+
Builds a Native Federation club-module workspace and publishes it to a tabbr club. Also fetches
|
|
4
|
+
a club's own generated TypeScript typings into your module workspace.
|
|
5
|
+
|
|
6
|
+
The quickest start is the starter project,
|
|
7
|
+
[systemworks-bv/tabbr-module-starter](https://github.com/systemworks-bv/tabbr-module-starter),
|
|
8
|
+
which has this CLI and npm scripts for it set up already.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install -D @tabbr/module-cli
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Configuration: tabbr.config.json
|
|
15
|
+
|
|
16
|
+
Every option can be passed on the command line, but normally lives in `tabbr.config.json` in the
|
|
17
|
+
workspace root (option names in camelCase; command-line options win). `init` writes it for you:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npx tabbr-module init # asks for the club code, module id and title
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"club": "ABC",
|
|
26
|
+
"apiHost": "https://api.tabbr.be",
|
|
27
|
+
"moduleId": "club.abc.my-module",
|
|
28
|
+
"title": "My module",
|
|
29
|
+
"exposedModule": "./Module",
|
|
30
|
+
"exportName": "Module"
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage: publish
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
npx tabbr-module publish [--club ABC] [--module-id club.abc.hello] \
|
|
38
|
+
[--exposed-module ./Module] [--export-name Module] \
|
|
39
|
+
[--icon waving_hand] [--title "Hello"] [--cwd .] [--api-host https://api.tabbr.be]
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
You need the ADMIN role in the club you publish to.
|
|
43
|
+
|
|
44
|
+
`--cwd` defaults to the current directory; run the command from your module workspace's root
|
|
45
|
+
(where its `angular.json`/`federation.config.js` live), or pass `--cwd` to point at it.
|
|
46
|
+
|
|
47
|
+
### What it does
|
|
48
|
+
|
|
49
|
+
1. Runs `ng build` in the target workspace.
|
|
50
|
+
2. Locates the build output (via `angular.json`'s `build-original` target) and verifies
|
|
51
|
+
`remoteEntry.json` exists at its root.
|
|
52
|
+
3. Zips the output.
|
|
53
|
+
4. Logs you in via a browser redirect to Keycloak (PKCE, no password ever touches this CLI) and
|
|
54
|
+
caches the resulting token at `~/.tabbr/credentials.json` for reuse on later runs.
|
|
55
|
+
5. Uploads the zip to `POST /api/custommodule` with the club in the `X-club` header, which hosts
|
|
56
|
+
the files and registers the module against that club. You need the ADMIN role in that club.
|
|
57
|
+
|
|
58
|
+
## Usage: fetch-types
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
npx tabbr-module fetch-types [--club ABC] \
|
|
62
|
+
[--out src/types/tabbr/entities.d.ts] [--cwd .] [--api-host https://api.tabbr.be]
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**Default (no `--entity`)**: fetches `GET /api/club/{club}/entities/types` - every entity this
|
|
66
|
+
club has (custom fields included), as plain TypeScript data interfaces in ONE combined file
|
|
67
|
+
(`src/types/tabbr/entities.d.ts` by default). No script-sandbox globals, so safe to have all of
|
|
68
|
+
them in scope together - meant for typing your own component code (e.g. an `httpResource`'s
|
|
69
|
+
response), not the script editor. This is the easy way to get everything at once.
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
npx tabbr-module fetch-types [--club ABC] --entity flight \
|
|
73
|
+
[--kind HOOK|CALCULATION|DEFAULT_CALCULATION|RELEVANT_CONDITION] \
|
|
74
|
+
[--out src/types/tabbr/flight.hook.d.ts] [--cwd .] [--api-host https://api.tabbr.be]
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**With `--entity`**: fetches `GET /api/{entity}/meta/script?kind={kind}` instead - the same
|
|
78
|
+
generated ambient `.d.ts` tabbr-ui's own script editor (Monaco) feeds itself for that entity's
|
|
79
|
+
hook/calculation scripts. It types the script sandbox, not your REST responses, and declares
|
|
80
|
+
script-only globals (`current`, `onCreate`, ...) that don't exist in a browser context - see the
|
|
81
|
+
comment header the command writes atop the output file for the full explanation, and for why
|
|
82
|
+
files fetched for different entities/kinds should generally not sit in the same TypeScript
|
|
83
|
+
project as each other (or as the combined `entities.d.ts` above).
|
|
84
|
+
|
|
85
|
+
Fetched files reflect one specific club's live, possibly-custom field configuration (field names,
|
|
86
|
+
custom entities, ...) and are regenerable on demand - add `src/types/tabbr/` (or wherever `--out`
|
|
87
|
+
points) to your workspace's `.gitignore` rather than committing them.
|
|
88
|
+
|
|
89
|
+
## Prerequisite: Keycloak redirect URI
|
|
90
|
+
|
|
91
|
+
Login opens your browser to Keycloak and catches the redirect on a temporary local HTTP server
|
|
92
|
+
(an ephemeral `http://localhost:<port>/callback`). This requires `http://localhost:*` (or your
|
|
93
|
+
chosen fixed port) to be added to the `tabbr` Keycloak client's **Valid Redirect URIs** in the
|
|
94
|
+
Keycloak admin console - a one-time setup step, not something this CLI can configure itself.
|
|
95
|
+
|
|
96
|
+
## Non-goals (v1)
|
|
97
|
+
|
|
98
|
+
No `login`/`logout`/`whoami` subcommands, no multi-club profiles - `publish` covers the
|
|
99
|
+
end-to-end flow on its own.
|
|
100
|
+
|
|
101
|
+
## Releasing
|
|
102
|
+
|
|
103
|
+
Commit your CLI changes first, then from this package:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
npm run release -- patch --dry-run # preview
|
|
107
|
+
npm run release -- patch # or minor / major / an explicit 0.3.0
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Same flow as the SDK (see `tabbr-module-sdk/README.md`): the script bumps the version, commits,
|
|
111
|
+
tags `cli-v<version>` and pushes; the tag triggers `.github/workflows/publish-module-cli.yml`,
|
|
112
|
+
which publishes via npm Trusted Publishing (owner `systemworks-bv`, repository `gliderdesk`,
|
|
113
|
+
workflow `publish-module-cli.yml`).
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { getAccessToken } = require('../src/auth');
|
|
7
|
+
const { buildAndZip } = require('../src/build');
|
|
8
|
+
const { publish } = require('../src/publish');
|
|
9
|
+
const { fetchScriptTypes, fetchAllEntityTypes, VALID_KINDS } = require('../src/fetch-types');
|
|
10
|
+
const { CONFIG_FILE, DEFAULT_API_HOST, readConfig, requireClub, runInit } = require('../src/config');
|
|
11
|
+
const DEFAULT_KIND = 'HOOK'; // matches the server's own @RequestParam default for /meta/script
|
|
12
|
+
|
|
13
|
+
function parseArgs(argv) {
|
|
14
|
+
const args = { cwd: process.cwd(), kind: DEFAULT_KIND };
|
|
15
|
+
for (let i = 0; i < argv.length; i++) {
|
|
16
|
+
const arg = argv[i];
|
|
17
|
+
const next = () => argv[++i];
|
|
18
|
+
switch (arg) {
|
|
19
|
+
case '--club': args.club = next(); break;
|
|
20
|
+
case '--module-id': args.moduleId = next(); break;
|
|
21
|
+
case '--exposed-module': args.exposedModule = next(); break;
|
|
22
|
+
case '--export-name': args.exportName = next(); break;
|
|
23
|
+
case '--icon': args.icon = next(); break;
|
|
24
|
+
case '--title': args.title = next(); break;
|
|
25
|
+
case '--cwd': args.cwd = path.resolve(next()); break;
|
|
26
|
+
case '--api-host': args.apiHost = next(); break;
|
|
27
|
+
case '--entity': args.entity = next(); break;
|
|
28
|
+
case '--kind': args.kind = next(); break;
|
|
29
|
+
case '--out': args.out = next(); break;
|
|
30
|
+
default:
|
|
31
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return args;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function printUsage() {
|
|
38
|
+
console.log(`Every option below can also be set in ${CONFIG_FILE} in the workspace root (option name
|
|
39
|
+
in camelCase, e.g. "club", "moduleId", "apiHost"); command-line options win.
|
|
40
|
+
|
|
41
|
+
Usage: tabbr-module init [--club <CODE>] [--module-id <id>] [--title <text>] [--api-host <url>]
|
|
42
|
+
|
|
43
|
+
Asks for the club code (and module id / title) and writes ${CONFIG_FILE}.
|
|
44
|
+
|
|
45
|
+
Usage: tabbr-module publish [options]
|
|
46
|
+
|
|
47
|
+
Required (command line or ${CONFIG_FILE}):
|
|
48
|
+
--club <CODE> Club code to publish to, e.g. ABC; you need the ADMIN role there
|
|
49
|
+
--module-id <id> Module id, unique within the club, e.g. club.abc.hello
|
|
50
|
+
--exposed-module <./Foo> Key in your federation.config.js "exposes" map
|
|
51
|
+
--export-name <Foo> Named (or default) export implementing PlatformModuleImplementation
|
|
52
|
+
|
|
53
|
+
Optional:
|
|
54
|
+
--icon <name> Menu icon name
|
|
55
|
+
--title <text> Display title
|
|
56
|
+
--cwd <path> Angular workspace to build (default: current directory)
|
|
57
|
+
--api-host <url> Tabbr API host (default: ${DEFAULT_API_HOST})
|
|
58
|
+
|
|
59
|
+
Requires the tabbr Keycloak client's Valid Redirect URIs to include http://localhost:*
|
|
60
|
+
(a Keycloak admin-console setting - see README.md).
|
|
61
|
+
|
|
62
|
+
Usage: tabbr-module fetch-types [--club <CODE>] [--entity <name>] [options]
|
|
63
|
+
|
|
64
|
+
With --entity: fetches the ambient .d.ts for that ONE entity's script sandbox - the same typings
|
|
65
|
+
tabbr-ui's in-app script editor (Monaco) uses for hook/calculation/default-value/relevancy-condition
|
|
66
|
+
scripts on that entity, globals (current/onCreate/...) included.
|
|
67
|
+
|
|
68
|
+
Without --entity (default): fetches EVERY entity the club has as plain data interfaces in ONE
|
|
69
|
+
combined file - no script globals, meant for general club-data typing in your own component code
|
|
70
|
+
(not the script editor). See --out's default below.
|
|
71
|
+
|
|
72
|
+
Required (command line or ${CONFIG_FILE}):
|
|
73
|
+
--club <CODE> Club code whose entity metadata to fetch, e.g. ABC
|
|
74
|
+
|
|
75
|
+
Optional:
|
|
76
|
+
--entity <name> Entity's REST path segment, e.g. flight, member (default: every entity, combined)
|
|
77
|
+
--kind <KIND> Only with --entity: one of ${VALID_KINDS.join(', ')} (default: ${DEFAULT_KIND})
|
|
78
|
+
--out <path> Output file (default: src/types/tabbr/<entity>.<kind>.d.ts with --entity,
|
|
79
|
+
src/types/tabbr/entities.d.ts without)
|
|
80
|
+
--cwd <path> Workspace root, used to resolve the default --out (default: current directory)
|
|
81
|
+
--api-host <url> Tabbr API host (default: ${DEFAULT_API_HOST})`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function requireFlags(args, requiredFlags) {
|
|
85
|
+
for (const [key, flag] of Object.entries(requiredFlags)) {
|
|
86
|
+
if (!args[key]) {
|
|
87
|
+
console.error(`Missing required option: ${flag}\n`);
|
|
88
|
+
printUsage();
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function runPublish(args) {
|
|
95
|
+
args.club = requireClub(args.club);
|
|
96
|
+
requireFlags(args, {
|
|
97
|
+
moduleId: '--module-id',
|
|
98
|
+
exposedModule: '--exposed-module',
|
|
99
|
+
exportName: '--export-name',
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const zipBuffer = await buildAndZip(args.cwd);
|
|
103
|
+
const accessToken = await getAccessToken();
|
|
104
|
+
const result = await publish({
|
|
105
|
+
apiHost: args.apiHost,
|
|
106
|
+
club: args.club,
|
|
107
|
+
moduleId: args.moduleId,
|
|
108
|
+
exposedModule: args.exposedModule,
|
|
109
|
+
exportName: args.exportName,
|
|
110
|
+
icon: args.icon,
|
|
111
|
+
title: args.title,
|
|
112
|
+
zipBuffer,
|
|
113
|
+
accessToken,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
console.log('Published successfully. Registered modules for this club:');
|
|
117
|
+
console.log(JSON.stringify(result, null, 2));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function fetchAndWriteOne({ apiHost, club, entity, kind, cwd, out, accessToken }) {
|
|
121
|
+
const dts = await fetchScriptTypes({ apiHost, club, entity, kind, accessToken });
|
|
122
|
+
|
|
123
|
+
const outPath = out
|
|
124
|
+
? path.resolve(cwd, out)
|
|
125
|
+
: path.join(cwd, 'src', 'types', 'tabbr', `${entity}.${kind.toLowerCase()}.d.ts`);
|
|
126
|
+
|
|
127
|
+
const header = `// GENERATED by \`tabbr-module fetch-types\` - do not hand-edit, re-run to refresh.
|
|
128
|
+
// Club: ${club.toUpperCase()} | Entity: ${entity} | Kind: ${kind} | Fetched: ${new Date().toISOString()}
|
|
129
|
+
//
|
|
130
|
+
// This is the SAME ambient .d.ts tabbr-ui's own script editor (Monaco, for club-configured
|
|
131
|
+
// hook/calculation/default-value/relevancy-condition scripts) feeds itself via
|
|
132
|
+
// GET /api/${entity}/meta/script?kind=${kind} - see ScriptTypeGenerator
|
|
133
|
+
// (tabbr-api-server) and ScriptEditorMonacoService (tabbr-ui). It types the SCRIPT SANDBOX for
|
|
134
|
+
// this club's actual entity/field configuration (including any custom/dynamic fields this club
|
|
135
|
+
// has added), not necessarily the JSON shape returned by this club's REST API. It also declares
|
|
136
|
+
// script-only globals (e.g. \`current\`/\`original\`, \`onCreate\`/\`onUpdate\`/\`onDelete\` for HOOK;
|
|
137
|
+
// \`addErrorMessage\` etc. from the base script lib) that don't exist in a browser/component
|
|
138
|
+
// context - harmless to leave declared, just ignore them.
|
|
139
|
+
//
|
|
140
|
+
// If you fetch types for more than one entity/kind, keep the resulting files in DIFFERENT
|
|
141
|
+
// TypeScript projects (or otherwise out of the same tsconfig's "include") if you're not sure
|
|
142
|
+
// they're compatible: globals like \`current\` are declared with a DIFFERENT type per kind/entity,
|
|
143
|
+
// and TypeScript errors on incompatible duplicate ambient declarations if two such files are both
|
|
144
|
+
// in scope at once. This is also why \`fetch-types\` with no --entity writes one file per entity
|
|
145
|
+
// rather than one combined file.
|
|
146
|
+
|
|
147
|
+
${dts}
|
|
148
|
+
`;
|
|
149
|
+
|
|
150
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
151
|
+
fs.writeFileSync(outPath, header);
|
|
152
|
+
return outPath;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function runFetchAllEntityTypes(args) {
|
|
156
|
+
const dts = await fetchAllEntityTypes({ apiHost: args.apiHost, club: args.club, accessToken: args.accessToken });
|
|
157
|
+
|
|
158
|
+
const outPath = args.out
|
|
159
|
+
? path.resolve(args.cwd, args.out)
|
|
160
|
+
: path.join(args.cwd, 'src', 'types', 'tabbr', 'entities.d.ts');
|
|
161
|
+
|
|
162
|
+
const header = `// GENERATED by \`tabbr-module fetch-types\` - do not hand-edit, re-run to refresh.
|
|
163
|
+
// Club: ${args.club.toUpperCase()} | Fetched: ${new Date().toISOString()}
|
|
164
|
+
//
|
|
165
|
+
// Every entity this club has (custom fields included), as plain data interfaces - see
|
|
166
|
+
// ScriptTypeGenerator#generateAllEntityTypes (tabbr-api-server) and
|
|
167
|
+
// GET /api/club/${args.club.toUpperCase()}/entities/types. Not the script-sandbox context
|
|
168
|
+
// \`--entity <name>\` fetches (no current/onCreate/... globals here) - safe to use in your own
|
|
169
|
+
// component code, e.g. to type an httpResource's response.
|
|
170
|
+
|
|
171
|
+
${dts}
|
|
172
|
+
`;
|
|
173
|
+
|
|
174
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
175
|
+
fs.writeFileSync(outPath, header);
|
|
176
|
+
console.log(`Wrote ${outPath}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function runFetchTypes(args) {
|
|
180
|
+
args.club = requireClub(args.club);
|
|
181
|
+
if (args.kind !== DEFAULT_KIND && !args.entity) {
|
|
182
|
+
console.error('--kind only applies to a single --entity fetch (the combined file has no script sandbox, so no kind).\n');
|
|
183
|
+
printUsage();
|
|
184
|
+
process.exit(1);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const accessToken = await getAccessToken();
|
|
188
|
+
|
|
189
|
+
if (args.entity) {
|
|
190
|
+
const outPath = await fetchAndWriteOne({ ...args, accessToken });
|
|
191
|
+
console.log(`Wrote ${outPath}`);
|
|
192
|
+
} else {
|
|
193
|
+
await runFetchAllEntityTypes({ ...args, accessToken });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function main() {
|
|
198
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
199
|
+
|
|
200
|
+
if (command !== 'init' && command !== 'publish' && command !== 'fetch-types') {
|
|
201
|
+
printUsage();
|
|
202
|
+
process.exit(command ? 1 : 0);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const flags = parseArgs(rest);
|
|
206
|
+
if (command === 'init') {
|
|
207
|
+
await runInit(flags.cwd, flags);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const args = { apiHost: DEFAULT_API_HOST, ...readConfig(flags.cwd), ...flags };
|
|
211
|
+
if (command === 'publish') {
|
|
212
|
+
await runPublish(args);
|
|
213
|
+
} else {
|
|
214
|
+
await runFetchTypes(args);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
main().catch(err => {
|
|
219
|
+
console.error(err.message || err);
|
|
220
|
+
process.exit(1);
|
|
221
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tabbr/module-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Set up, build and publish a Native Federation club module to a tabbr club.",
|
|
5
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
6
|
+
"author": "SystemWorks BV",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/systemworks-bv/gliderdesk.git",
|
|
10
|
+
"directory": "tabbr-module-cli"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public",
|
|
14
|
+
"registry": "https://registry.npmjs.org/"
|
|
15
|
+
},
|
|
16
|
+
"bin": {
|
|
17
|
+
"tabbr-module": "bin/tabbr-module.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"bin",
|
|
21
|
+
"src"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"release": "node scripts/release.mjs"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=20"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"archiver": "^7.0.1",
|
|
31
|
+
"open": "^10.1.0"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/auth.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const http = require('http');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
// Same Keycloak client the tabbr-ui shell and the SDK's dev bridge use - see
|
|
10
|
+
// tabbr-ui/src/environments/environment.ts and tabbr-module-sdk/src/dev-bridge.
|
|
11
|
+
const KEYCLOAK_URL = 'https://auth.tabbr.net';
|
|
12
|
+
const KEYCLOAK_REALM = 'tabbr';
|
|
13
|
+
const KEYCLOAK_CLIENT_ID = 'tabbr';
|
|
14
|
+
|
|
15
|
+
const CREDENTIALS_PATH = path.join(os.homedir(), '.tabbr', 'credentials.json');
|
|
16
|
+
|
|
17
|
+
function base64url(buffer) {
|
|
18
|
+
return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readCachedCredentials() {
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(fs.readFileSync(CREDENTIALS_PATH, 'utf-8'));
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function writeCachedCredentials(creds) {
|
|
30
|
+
fs.mkdirSync(path.dirname(CREDENTIALS_PATH), { recursive: true });
|
|
31
|
+
fs.writeFileSync(CREDENTIALS_PATH, JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function exchangeToken(params) {
|
|
35
|
+
const response = await fetch(`${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token`, {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
38
|
+
body: new URLSearchParams(params).toString(),
|
|
39
|
+
});
|
|
40
|
+
if (!response.ok) {
|
|
41
|
+
throw new Error(`Token request failed: ${response.status} ${await response.text()}`);
|
|
42
|
+
}
|
|
43
|
+
return response.json();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function toCachedCredentials(tokenResponse) {
|
|
47
|
+
return {
|
|
48
|
+
accessToken: tokenResponse.access_token,
|
|
49
|
+
refreshToken: tokenResponse.refresh_token,
|
|
50
|
+
// A little slack so a token that's about to expire doesn't get used anyway.
|
|
51
|
+
expiresAt: Date.now() + (tokenResponse.expires_in - 30) * 1000,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function refreshAccessToken(cached) {
|
|
56
|
+
const tokenResponse = await exchangeToken({
|
|
57
|
+
grant_type: 'refresh_token',
|
|
58
|
+
refresh_token: cached.refreshToken,
|
|
59
|
+
client_id: KEYCLOAK_CLIENT_ID,
|
|
60
|
+
});
|
|
61
|
+
return toCachedCredentials(tokenResponse);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Full-page-redirect + PKCE login via a temporary local HTTP server catching the callback. */
|
|
65
|
+
async function interactiveLogin() {
|
|
66
|
+
const verifier = base64url(crypto.randomBytes(32));
|
|
67
|
+
const challenge = base64url(crypto.createHash('sha256').update(verifier).digest());
|
|
68
|
+
|
|
69
|
+
// Set once the server actually has a port (inside listen's callback) and reused below -
|
|
70
|
+
// server.address() returns null once server.close() has been called, so the request handler
|
|
71
|
+
// must not try to recompute it from the (by-then-closing) server itself.
|
|
72
|
+
let redirectUri;
|
|
73
|
+
|
|
74
|
+
const { code } = await new Promise((resolve, reject) => {
|
|
75
|
+
const server = http.createServer((req, res) => {
|
|
76
|
+
const url = new URL(req.url, 'http://localhost');
|
|
77
|
+
if (url.pathname !== '/callback') {
|
|
78
|
+
res.writeHead(404).end();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const code = url.searchParams.get('code');
|
|
82
|
+
const error = url.searchParams.get('error');
|
|
83
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
84
|
+
res.end(error
|
|
85
|
+
? `<h1>Login failed</h1><p>${error}</p><p>You can close this tab.</p>`
|
|
86
|
+
: '<h1>Logged in</h1><p>You can close this tab and return to the terminal.</p>');
|
|
87
|
+
server.close();
|
|
88
|
+
if (error) {
|
|
89
|
+
reject(new Error(`Keycloak login failed: ${error}`));
|
|
90
|
+
} else {
|
|
91
|
+
resolve({ code });
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
server.listen(0, 'localhost', () => {
|
|
95
|
+
const port = server.address().port;
|
|
96
|
+
redirectUri = `http://localhost:${port}/callback`;
|
|
97
|
+
const authUrl = new URL(`${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/auth`);
|
|
98
|
+
authUrl.searchParams.set('client_id', KEYCLOAK_CLIENT_ID);
|
|
99
|
+
authUrl.searchParams.set('response_type', 'code');
|
|
100
|
+
authUrl.searchParams.set('scope', 'openid');
|
|
101
|
+
authUrl.searchParams.set('redirect_uri', redirectUri);
|
|
102
|
+
authUrl.searchParams.set('code_challenge', challenge);
|
|
103
|
+
authUrl.searchParams.set('code_challenge_method', 'S256');
|
|
104
|
+
|
|
105
|
+
console.log('Opening your browser to log in via Keycloak...');
|
|
106
|
+
console.log(`If it doesn't open automatically, visit:\n${authUrl.toString()}\n`);
|
|
107
|
+
import('open').then(({ default: open }) => open(authUrl.toString())).catch(() => {
|
|
108
|
+
// Non-fatal: the URL is already printed above for manual use.
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
server.on('error', reject);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const tokenResponse = await exchangeToken({
|
|
115
|
+
grant_type: 'authorization_code',
|
|
116
|
+
code,
|
|
117
|
+
redirect_uri: redirectUri,
|
|
118
|
+
client_id: KEYCLOAK_CLIENT_ID,
|
|
119
|
+
code_verifier: verifier,
|
|
120
|
+
});
|
|
121
|
+
return toCachedCredentials(tokenResponse);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Returns a valid access token, reusing/refreshing the cache before falling back to a fresh login. */
|
|
125
|
+
async function getAccessToken() {
|
|
126
|
+
const cached = readCachedCredentials();
|
|
127
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
128
|
+
return cached.accessToken;
|
|
129
|
+
}
|
|
130
|
+
if (cached && cached.refreshToken) {
|
|
131
|
+
try {
|
|
132
|
+
const refreshed = await refreshAccessToken(cached);
|
|
133
|
+
writeCachedCredentials(refreshed);
|
|
134
|
+
return refreshed.accessToken;
|
|
135
|
+
} catch {
|
|
136
|
+
// Refresh token expired/revoked - fall through to interactive login.
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const creds = await interactiveLogin();
|
|
140
|
+
writeCachedCredentials(creds);
|
|
141
|
+
return creds.accessToken;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = { getAccessToken, CREDENTIALS_PATH };
|
package/src/build.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execFileSync } = require('child_process');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const archiver = require('archiver');
|
|
7
|
+
|
|
8
|
+
/** Reads angular.json and resolves the browser output directory for the workspace's one project. */
|
|
9
|
+
function resolveOutputDir(cwd) {
|
|
10
|
+
const angularJsonPath = path.join(cwd, 'angular.json');
|
|
11
|
+
if (!fs.existsSync(angularJsonPath)) {
|
|
12
|
+
throw new Error(`No angular.json found in ${cwd} - run this from an Angular workspace root (or pass --cwd).`);
|
|
13
|
+
}
|
|
14
|
+
const angularJson = JSON.parse(fs.readFileSync(angularJsonPath, 'utf-8'));
|
|
15
|
+
const projectName = Object.keys(angularJson.projects)[0];
|
|
16
|
+
const project = angularJson.projects[projectName];
|
|
17
|
+
// native-federation's build target wraps the real Angular application builder under
|
|
18
|
+
// "build-original" (see tabbr-ui/angular.json and tabbr-custom-module-poc/angular.json) -
|
|
19
|
+
// that's where the actual outputPath lives.
|
|
20
|
+
const buildTarget = project.architect['build-original'] || project.architect['build'];
|
|
21
|
+
const outputPath = buildTarget.options.outputPath;
|
|
22
|
+
const base = typeof outputPath === 'string' ? outputPath : outputPath.base;
|
|
23
|
+
// Angular's application builder always nests the actual browser bundle under a "browser/"
|
|
24
|
+
// subfolder of outputPath - confirmed empirically against this exact builder this session.
|
|
25
|
+
return path.join(cwd, base, 'browser');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Runs `ng build` in `cwd` by invoking the workspace's own locally-installed Angular CLI entry
|
|
30
|
+
* point directly with the current Node binary.
|
|
31
|
+
*
|
|
32
|
+
* Deliberately NOT `execFileSync('npx', ['ng', 'build'], { shell: true })`, which was the previous
|
|
33
|
+
* implementation and had three problems at once:
|
|
34
|
+
* - Node's DEP0190 deprecation warning (passing an args array together with `shell: true`).
|
|
35
|
+
* - Passing args through a shell at all means they're re-parsed by the shell, so anything less
|
|
36
|
+
* trusted than a hardcoded literal would need manual escaping.
|
|
37
|
+
* - On Windows `npx` is `npx.cmd`, and since the CVE-2024-27980 fix Node refuses to spawn
|
|
38
|
+
* `.cmd`/`.bat` WITHOUT `shell: true` - so dropping the shell alone wasn't an option there.
|
|
39
|
+
* Going straight to `node node_modules/@angular/cli/bin/ng.js` sidesteps all three: no shell, no
|
|
40
|
+
* args re-parsing, no platform-specific launcher script, and it's faster (skips npx resolution).
|
|
41
|
+
*
|
|
42
|
+
* Explicitly `--configuration production`, not a bare `ng build`: a module workspace's
|
|
43
|
+
* angular.json typically sets `defaultConfiguration: "development"` on the native-federation
|
|
44
|
+
* build target (so a plain `ng build`/`ng serve` during local development doesn't need the flag
|
|
45
|
+
* spelled out every time) - which a bare `ng build` here would silently inherit, publishing an
|
|
46
|
+
* unoptimized dev build (unminified, `dev: true`, native-federation's build-notifications
|
|
47
|
+
* live-reload hook baked in) to the club instead of the production one. The live-reload hook in
|
|
48
|
+
* particular then 404s at runtime (it points at a dev server that doesn't exist once statically
|
|
49
|
+
* published) and the unshared-per-pass token identities a dev build produces differently from
|
|
50
|
+
* prod have caused real `NG0201: No provider found for ...` failures in published modules.
|
|
51
|
+
*/
|
|
52
|
+
function runNgBuild(cwd) {
|
|
53
|
+
const ngJs = path.join(cwd, 'node_modules', '@angular', 'cli', 'bin', 'ng.js');
|
|
54
|
+
if (!fs.existsSync(ngJs)) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`No local Angular CLI found at ${ngJs} - run "npm install" in ${cwd} first.`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
console.log(`Building in ${cwd} ...`);
|
|
60
|
+
execFileSync(process.execPath, [ngJs, 'build', '--configuration', 'production'], { cwd, stdio: 'inherit' });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Zips outputDir's contents flat (remoteEntry.json at the zip root) into a Buffer. */
|
|
64
|
+
function zipDirectory(outputDir) {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const archive = archiver('zip', { zlib: { level: 9 } });
|
|
67
|
+
const chunks = [];
|
|
68
|
+
archive.on('data', chunk => chunks.push(chunk));
|
|
69
|
+
archive.on('end', () => resolve(Buffer.concat(chunks)));
|
|
70
|
+
archive.on('error', reject);
|
|
71
|
+
archive.directory(outputDir, false);
|
|
72
|
+
archive.finalize();
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Builds the workspace at `cwd`, verifies remoteEntry.json exists, and returns a zip Buffer. */
|
|
77
|
+
async function buildAndZip(cwd) {
|
|
78
|
+
runNgBuild(cwd);
|
|
79
|
+
const outputDir = resolveOutputDir(cwd);
|
|
80
|
+
const remoteEntryPath = path.join(outputDir, 'remoteEntry.json');
|
|
81
|
+
if (!fs.existsSync(remoteEntryPath)) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`Build output at ${outputDir} has no remoteEntry.json. ` +
|
|
84
|
+
`Is this workspace's federation.config.js missing an "exposes" entry?`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
console.log(`Zipping ${outputDir} ...`);
|
|
88
|
+
return zipDirectory(outputDir);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { buildAndZip, resolveOutputDir };
|
package/src/config.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const readline = require('readline/promises');
|
|
6
|
+
|
|
7
|
+
const CONFIG_FILE = 'tabbr.config.json';
|
|
8
|
+
const DEFAULT_API_HOST = 'https://api.tabbr.be';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Reads the workspace's tabbr.config.json - the one place a module project records which club it
|
|
12
|
+
* targets, its module id and so on. Both this CLI and the module's own dev-preview harness read it,
|
|
13
|
+
* so they can't drift apart. Returns {} when there is no such file.
|
|
14
|
+
*/
|
|
15
|
+
function readConfig(cwd) {
|
|
16
|
+
const file = path.join(cwd, CONFIG_FILE);
|
|
17
|
+
if (!fs.existsSync(file)) return {};
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
20
|
+
} catch (err) {
|
|
21
|
+
throw new Error(`${file} is not valid JSON: ${err.message}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Uppercase club code, or throws with instructions when none is configured. */
|
|
26
|
+
function requireClub(club) {
|
|
27
|
+
if (!club) {
|
|
28
|
+
throw new Error(`No club configured. Run "npx tabbr-module init" (or set "club" in ${CONFIG_FILE}).`);
|
|
29
|
+
}
|
|
30
|
+
return club.toUpperCase();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function slug(text) {
|
|
34
|
+
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Asks for the club code (and offers defaults for the rest) and writes tabbr.config.json, keeping
|
|
39
|
+
* any values already in it as the defaults.
|
|
40
|
+
*/
|
|
41
|
+
async function runInit(cwd, overrides) {
|
|
42
|
+
const file = path.join(cwd, CONFIG_FILE);
|
|
43
|
+
const current = readConfig(cwd);
|
|
44
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
45
|
+
const ask = async (question, fallback) => {
|
|
46
|
+
if (!process.stdin.isTTY) return fallback;
|
|
47
|
+
const answer = (await rl.question(fallback ? `${question} [${fallback}]: ` : `${question}: `)).trim();
|
|
48
|
+
return answer || fallback;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const club = (overrides.club || await ask('Club code (the part before .tabbr.be, e.g. abc)', current.club))?.toUpperCase();
|
|
53
|
+
if (!club) throw new Error('A club code is required.');
|
|
54
|
+
const name = slug(path.basename(cwd)) || 'my-module';
|
|
55
|
+
const moduleId = overrides.moduleId
|
|
56
|
+
|| await ask('Module id (unique within the club)', current.moduleId || `club.${club.toLowerCase()}.${name}`);
|
|
57
|
+
const title = overrides.title || await ask('Menu title', current.title || 'My module');
|
|
58
|
+
|
|
59
|
+
const config = {
|
|
60
|
+
...current,
|
|
61
|
+
club,
|
|
62
|
+
apiHost: overrides.apiHost || current.apiHost || DEFAULT_API_HOST,
|
|
63
|
+
moduleId,
|
|
64
|
+
title,
|
|
65
|
+
exposedModule: current.exposedModule || './Module',
|
|
66
|
+
exportName: current.exportName || 'Module',
|
|
67
|
+
};
|
|
68
|
+
fs.writeFileSync(file, JSON.stringify(config, null, 2) + '\n');
|
|
69
|
+
console.log(`\nWrote ${file}. Next: "npm start" to preview, "npm run publish-module" to publish.`);
|
|
70
|
+
} finally {
|
|
71
|
+
rl.close();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { CONFIG_FILE, DEFAULT_API_HOST, readConfig, requireClub, runInit };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Mirrors ScriptTypeGenerator.ScriptKind (tabbr-api-server) - keep in sync if that enum changes.
|
|
4
|
+
const VALID_KINDS = ['HOOK', 'CALCULATION', 'DEFAULT_CALCULATION', 'RELEVANT_CONDITION'];
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Fetches the ambient .d.ts tabbr-ui's own script editor (Monaco, for club-configured hook/
|
|
8
|
+
* calculation/default-value/relevancy-condition scripts) feeds itself, for a given club/entity/
|
|
9
|
+
* kind - see ScriptTypeGenerator (tabbr-api-server) and ScriptEditorMonacoService (tabbr-ui).
|
|
10
|
+
*/
|
|
11
|
+
async function fetchScriptTypes({ apiHost, club, entity, kind, accessToken }) {
|
|
12
|
+
if (!VALID_KINDS.includes(kind)) {
|
|
13
|
+
throw new Error(`Invalid --kind "${kind}". Must be one of: ${VALID_KINDS.join(', ')}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const url = `${apiHost}/api/${entity}/meta/script?kind=${kind}`;
|
|
17
|
+
console.log(`Fetching script types for entity "${entity}" (kind ${kind}) from ${url} ...`);
|
|
18
|
+
const response = await fetch(url, {
|
|
19
|
+
// X-club is required: CurrentClubFilter (tabbr-api-server) resolves the "current club" for
|
|
20
|
+
// this request from this header, not from any path variable - same pattern as publish.js.
|
|
21
|
+
headers: { Authorization: `Bearer ${accessToken}`, 'X-club': club.toUpperCase() },
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
if (!response.ok) {
|
|
25
|
+
throw new Error(`Fetching script types failed: ${response.status} ${await response.text()}`);
|
|
26
|
+
}
|
|
27
|
+
return response.text();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Every entity this club has, as plain TypeScript data interfaces in ONE combined file - no
|
|
32
|
+
* script-sandbox globals, so (unlike fetchScriptTypes) safe to fetch for every entity at once and
|
|
33
|
+
* write to a single .d.ts. See ScriptTypeGenerator#generateAllEntityTypes (tabbr-api-server) for
|
|
34
|
+
* why. For general club-data typing in module component code, not the script editor.
|
|
35
|
+
*/
|
|
36
|
+
async function fetchAllEntityTypes({ apiHost, club, accessToken }) {
|
|
37
|
+
const url = `${apiHost}/api/club/${club.toUpperCase()}/entities/types`;
|
|
38
|
+
console.log(`Fetching all entity types for club ${club.toUpperCase()} from ${url} ...`);
|
|
39
|
+
const response = await fetch(url, {
|
|
40
|
+
headers: { Authorization: `Bearer ${accessToken}`, 'X-club': club.toUpperCase() },
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (!response.ok) {
|
|
44
|
+
throw new Error(`Fetching entity types failed: ${response.status} ${await response.text()}`);
|
|
45
|
+
}
|
|
46
|
+
return response.text();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { fetchScriptTypes, fetchAllEntityTypes, VALID_KINDS };
|
package/src/publish.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/** Uploads the zipped module build to the tabbr backend and returns the parsed JSON response. */
|
|
4
|
+
async function publish({ apiHost, club, moduleId, exposedModule, exportName, icon, title, zipBuffer, accessToken }) {
|
|
5
|
+
const form = new FormData();
|
|
6
|
+
form.append('file', new Blob([zipBuffer], { type: 'application/zip' }), 'module.zip');
|
|
7
|
+
form.append('moduleId', moduleId);
|
|
8
|
+
form.append('exposedModule', exposedModule);
|
|
9
|
+
form.append('exportName', exportName);
|
|
10
|
+
if (icon) form.append('icon', icon);
|
|
11
|
+
if (title) form.append('title', title);
|
|
12
|
+
|
|
13
|
+
const url = `${apiHost}/api/custommodule`;
|
|
14
|
+
console.log(`Publishing ${moduleId} to club ${club.toUpperCase()} at ${url} ...`);
|
|
15
|
+
const response = await fetch(url, {
|
|
16
|
+
method: 'POST',
|
|
17
|
+
// X-club selects the club: the server publishes into the club CurrentClubFilter
|
|
18
|
+
// (tabbr-api-server) resolves from this header, the same club its ADMIN check runs against.
|
|
19
|
+
headers: { Authorization: `Bearer ${accessToken}`, 'X-club': club.toUpperCase() },
|
|
20
|
+
body: form,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
if (!response.ok) {
|
|
24
|
+
throw new Error(`Publish failed: ${response.status} ${await response.text()}`);
|
|
25
|
+
}
|
|
26
|
+
return response.json();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = { publish };
|