@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 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 };