@tabbr/module-cli 0.1.0 → 0.2.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/src/auth.js CHANGED
@@ -1,144 +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 };
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 };
@@ -0,0 +1,74 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const DEFAULT_ENTRY = 'backend/src/index.ts';
7
+
8
+ /** Matches BackendModuleRuntimeService's MODULE_GLOBAL on the server - a `export function foo() {}`
9
+ * in the module's TS ends up reachable as the global `__module.foo` once bundled this way. */
10
+ const GLOBAL_NAME = '__module';
11
+
12
+ /**
13
+ * Bundles a module workspace's backend part (if configured) into a single, dependency-free
14
+ * `bundle.js`, using esbuild so module authors can write normal TypeScript (types, imports,
15
+ * `export function`) even though nothing beyond that single file is ever loaded at runtime - the
16
+ * server evaluates it in a sandboxed GraalVM context with no module resolution of its own (see
17
+ * `BackendModuleRuntimeService`).
18
+ *
19
+ * Returns `null` when the workspace has no backend part configured, so `build.js` can skip it
20
+ * entirely for a frontend-only module.
21
+ */
22
+ async function buildBackend(cwd, config) {
23
+ const backendConfig = config.backend;
24
+ if (!backendConfig) {
25
+ return null;
26
+ }
27
+ const entry = backendConfig.entry || DEFAULT_ENTRY;
28
+ const entryPath = path.join(cwd, entry);
29
+ if (!fs.existsSync(entryPath)) {
30
+ if (backendConfig.entry) {
31
+ // Explicitly configured but missing - fail loudly rather than silently skipping.
32
+ throw new Error(`Backend entry ${entryPath} does not exist (check "backend.entry" in tabbr.config.json).`);
33
+ }
34
+ return null;
35
+ }
36
+
37
+ let esbuild;
38
+ try {
39
+ // Resolved from the workspace being built, not from wherever this CLI happens to be installed -
40
+ // esbuild is the module author's devDependency, and the CLI may be hoisted, copied or linked.
41
+ esbuild = require(require.resolve('esbuild', { paths: [cwd] }));
42
+ } catch (err) {
43
+ throw new Error(
44
+ 'esbuild is required to build this module\'s backend part. Run "npm install --save-dev esbuild" in your module workspace.'
45
+ );
46
+ }
47
+
48
+ console.log(`Building backend module ${entryPath} ...`);
49
+ const result = await esbuild.build({
50
+ entryPoints: [entryPath],
51
+ bundle: true,
52
+ write: false,
53
+ format: 'iife',
54
+ globalName: GLOBAL_NAME,
55
+ target: 'es2022',
56
+ platform: 'neutral',
57
+ logLevel: 'silent',
58
+ });
59
+ if (!result.outputFiles || result.outputFiles.length === 0) {
60
+ throw new Error(`esbuild produced no output for ${entryPath}`);
61
+ }
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) };
72
+ }
73
+
74
+ module.exports = { buildBackend, DEFAULT_ENTRY };
package/src/build.js CHANGED
@@ -1,91 +1,121 @@
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 };
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
+ const { buildBackend } = require('./backend-build');
8
+
9
+ /** Reads angular.json and resolves the browser output directory for the workspace's one project. */
10
+ function resolveOutputDir(cwd) {
11
+ const angularJsonPath = path.join(cwd, 'angular.json');
12
+ if (!fs.existsSync(angularJsonPath)) {
13
+ throw new Error(`No angular.json found in ${cwd} - run this from an Angular workspace root (or pass --cwd).`);
14
+ }
15
+ const angularJson = JSON.parse(fs.readFileSync(angularJsonPath, 'utf-8'));
16
+ const projectName = Object.keys(angularJson.projects)[0];
17
+ const project = angularJson.projects[projectName];
18
+ // native-federation's build target wraps the real Angular application builder under
19
+ // "build-original" (see tabbr-ui/angular.json and tabbr-custom-module-poc/angular.json) -
20
+ // that's where the actual outputPath lives.
21
+ const buildTarget = project.architect['build-original'] || project.architect['build'];
22
+ const outputPath = buildTarget.options.outputPath;
23
+ const base = typeof outputPath === 'string' ? outputPath : outputPath.base;
24
+ // Angular's application builder always nests the actual browser bundle under a "browser/"
25
+ // subfolder of outputPath - confirmed empirically against this exact builder this session.
26
+ return path.join(cwd, base, 'browser');
27
+ }
28
+
29
+ /**
30
+ * Runs `ng build` in `cwd` by invoking the workspace's own locally-installed Angular CLI entry
31
+ * point directly with the current Node binary.
32
+ *
33
+ * Deliberately NOT `execFileSync('npx', ['ng', 'build'], { shell: true })`, which was the previous
34
+ * implementation and had three problems at once:
35
+ * - Node's DEP0190 deprecation warning (passing an args array together with `shell: true`).
36
+ * - Passing args through a shell at all means they're re-parsed by the shell, so anything less
37
+ * trusted than a hardcoded literal would need manual escaping.
38
+ * - On Windows `npx` is `npx.cmd`, and since the CVE-2024-27980 fix Node refuses to spawn
39
+ * `.cmd`/`.bat` WITHOUT `shell: true` - so dropping the shell alone wasn't an option there.
40
+ * Going straight to `node node_modules/@angular/cli/bin/ng.js` sidesteps all three: no shell, no
41
+ * args re-parsing, no platform-specific launcher script, and it's faster (skips npx resolution).
42
+ *
43
+ * Explicitly `--configuration production`, not a bare `ng build`: a module workspace's
44
+ * angular.json typically sets `defaultConfiguration: "development"` on the native-federation
45
+ * build target (so a plain `ng build`/`ng serve` during local development doesn't need the flag
46
+ * spelled out every time) - which a bare `ng build` here would silently inherit, publishing an
47
+ * unoptimized dev build (unminified, `dev: true`, native-federation's build-notifications
48
+ * live-reload hook baked in) to the club instead of the production one. The live-reload hook in
49
+ * particular then 404s at runtime (it points at a dev server that doesn't exist once statically
50
+ * published) and the unshared-per-pass token identities a dev build produces differently from
51
+ * prod have caused real `NG0201: No provider found for ...` failures in published modules.
52
+ */
53
+ function runNgBuild(cwd) {
54
+ const ngJs = path.join(cwd, 'node_modules', '@angular', 'cli', 'bin', 'ng.js');
55
+ if (!fs.existsSync(ngJs)) {
56
+ throw new Error(
57
+ `No local Angular CLI found at ${ngJs} - run "npm install" in ${cwd} first.`
58
+ );
59
+ }
60
+ console.log(`Building in ${cwd} ...`);
61
+ execFileSync(process.execPath, [ngJs, 'build', '--configuration', 'production'], { cwd, stdio: 'inherit' });
62
+ }
63
+
64
+ /**
65
+ * Zips frontendDir's contents flat at the zip root (remoteEntry.json at the root, as before) plus,
66
+ * when present, the backend bundle/manifest under a `backend/` prefix - one zip, one upload, no
67
+ * separate publish step per side (see `CustomModuleService#publish` on the server, which splits
68
+ * them back apart by that same prefix).
69
+ */
70
+ function zipModule(frontendDir, backend) {
71
+ return new Promise((resolve, reject) => {
72
+ const archive = archiver('zip', { zlib: { level: 9 } });
73
+ const chunks = [];
74
+ archive.on('data', chunk => chunks.push(chunk));
75
+ archive.on('end', () => resolve(Buffer.concat(chunks)));
76
+ archive.on('error', reject);
77
+ if (frontendDir) {
78
+ archive.directory(frontendDir, false);
79
+ }
80
+ if (backend) {
81
+ archive.append(backend.bundleJs, { name: 'backend/bundle.js' });
82
+ archive.append(backend.manifestJson, { name: 'backend/manifest.json' });
83
+ }
84
+ archive.finalize();
85
+ });
86
+ }
87
+
88
+ /**
89
+ * Builds whichever parts of the workspace at `cwd` are present - a frontend (an Angular project,
90
+ * i.e. angular.json exists) and/or a backend (`config.backend` set, see `backend-build.js`) - and
91
+ * returns a single zip Buffer with both. At least one part must be present.
92
+ */
93
+ async function buildAndZip(cwd, config) {
94
+ const hasFrontend = fs.existsSync(path.join(cwd, 'angular.json'));
95
+ let frontendDir = null;
96
+ if (hasFrontend) {
97
+ runNgBuild(cwd);
98
+ frontendDir = resolveOutputDir(cwd);
99
+ const remoteEntryPath = path.join(frontendDir, 'remoteEntry.json');
100
+ if (!fs.existsSync(remoteEntryPath)) {
101
+ throw new Error(
102
+ `Build output at ${frontendDir} has no remoteEntry.json. ` +
103
+ `Is this workspace's federation.config.js missing an "exposes" entry?`
104
+ );
105
+ }
106
+ }
107
+
108
+ const backend = await buildBackend(cwd, config || {});
109
+
110
+ if (!hasFrontend && !backend) {
111
+ throw new Error(
112
+ `Nothing to build in ${cwd}: no angular.json (frontend) and no backend part configured ` +
113
+ `("backend" in tabbr.config.json, entry file missing).`
114
+ );
115
+ }
116
+
117
+ console.log('Zipping module ...');
118
+ return zipModule(frontendDir, backend);
119
+ }
120
+
121
+ module.exports = { buildAndZip, resolveOutputDir };