@omen.foundation/node-microservice-runtime 0.1.104 → 0.1.106

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.
Files changed (2) hide show
  1. package/package.json +61 -61
  2. package/scripts/publish-service.mjs +1585 -1551
@@ -1,1551 +1,1585 @@
1
- #!/usr/bin/env node
2
-
3
- import fs from 'node:fs/promises';
4
- import fssync from 'node:fs';
5
- import path from 'node:path';
6
- import os from 'node:os';
7
- import crypto from 'node:crypto';
8
- import { fileURLToPath } from 'node:url';
9
- import { fetch } from 'undici';
10
- import tar from 'tar-stream';
11
- import dotenv from 'dotenv';
12
- import { runCommand } from './lib/cli-utils.mjs';
13
-
14
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
- const DEFAULT_NODE_VERSION = '20';
16
- const MANIFEST_MEDIA_TYPE = 'application/vnd.docker.distribution.manifest.v2+json';
17
- const CONFIG_MEDIA_TYPE = 'application/vnd.docker.container.image.v1+json';
18
- const LAYER_MEDIA_TYPE = 'application/vnd.docker.image.rootfs.diff.tar.gzip';
19
-
20
- // ANSI color codes for colorful output
21
- const colors = {
22
- reset: '\x1b[0m',
23
- bright: '\x1b[1m',
24
- dim: '\x1b[2m',
25
- green: '\x1b[32m',
26
- yellow: '\x1b[33m',
27
- blue: '\x1b[34m',
28
- magenta: '\x1b[35m',
29
- cyan: '\x1b[36m',
30
- red: '\x1b[31m',
31
- };
32
-
33
- // Progress bar manager
34
- class ProgressBar {
35
- constructor(totalSteps) {
36
- this.totalSteps = totalSteps;
37
- this.currentStep = 0;
38
- this.currentTask = '';
39
- this.startTime = Date.now();
40
- }
41
-
42
- start(task) {
43
- this.currentTask = task;
44
- this.currentStep++;
45
- this.update();
46
- }
47
-
48
- complete(task) {
49
- this.currentTask = task;
50
- this.update();
51
- process.stdout.write('\n');
52
- }
53
-
54
- update() {
55
- const percentage = Math.round((this.currentStep / this.totalSteps) * 100);
56
- const barWidth = 30;
57
- const filled = Math.round((percentage / 100) * barWidth);
58
- const empty = barWidth - filled;
59
- const bar = '█'.repeat(filled) + '░'.repeat(empty);
60
-
61
- const elapsed = ((Date.now() - this.startTime) / 1000).toFixed(1);
62
-
63
- // Clear line and write progress
64
- process.stdout.write(`\r${colors.cyan}${bar}${colors.reset} ${colors.bright}${percentage}%${colors.reset} ${colors.dim}${this.currentTask}${colors.reset} ${colors.dim}(${elapsed}s)${colors.reset}`);
65
- }
66
-
67
- success(message) {
68
- process.stdout.write(`\r${colors.green}✓${colors.reset} ${message}\n`);
69
- }
70
-
71
- info(message) {
72
- process.stdout.write(`\r${colors.blue}ℹ${colors.reset} ${message}\n`);
73
- }
74
-
75
- error(message) {
76
- process.stdout.write(`\r${colors.red}✗${colors.reset} ${message}\n`);
77
- }
78
- }
79
-
80
- function parseArgs(argv) {
81
- const args = {
82
- entry: 'dist/main.js',
83
- openapi: 'beam_openApi.json',
84
- envFile: undefined,
85
- cid: process.env.BEAMABLE_CID || process.env.CID,
86
- pid: process.env.BEAMABLE_PID || process.env.PID,
87
- host: process.env.BEAMABLE_HOST || process.env.HOST,
88
- namePrefix: process.env.BEAMABLE_NAME_PREFIX || process.env.NAME_PREFIX,
89
- token: process.env.BEAMABLE_TOKEN || process.env.BEAMABLE_ACCESS_TOKEN || process.env.ACCESS_TOKEN,
90
- gamePid: process.env.BEAMABLE_GAME_PID,
91
- comments: process.env.BEAMABLE_PUBLISH_COMMENTS,
92
- service: process.env.BEAMABLE_SERVICE_ID,
93
- dockerTag: process.env.BEAMABLE_DOCKER_TAG,
94
- nodeVersion: process.env.BEAMABLE_NODE_VERSION || DEFAULT_NODE_VERSION,
95
- skipValidate: false,
96
- apiHost: process.env.BEAMABLE_API_HOST,
97
- };
98
-
99
- const queue = [...argv];
100
- while (queue.length > 0) {
101
- const current = queue.shift();
102
- switch (current) {
103
- case '--entry':
104
- args.entry = queue.shift();
105
- break;
106
- case '--openapi':
107
- args.openapi = queue.shift();
108
- break;
109
- case '--env-file':
110
- args.envFile = queue.shift();
111
- break;
112
- case '--cid':
113
- args.cid = queue.shift();
114
- break;
115
- case '--pid':
116
- args.pid = queue.shift();
117
- break;
118
- case '--host':
119
- args.host = queue.shift();
120
- break;
121
- case '--routing-key':
122
- case '--name-prefix':
123
- args.namePrefix = queue.shift();
124
- break;
125
- case '--token':
126
- args.token = queue.shift();
127
- break;
128
- case '--game-pid':
129
- args.gamePid = queue.shift();
130
- break;
131
- case '--comments':
132
- args.comments = queue.shift();
133
- break;
134
- case '--service':
135
- args.service = queue.shift();
136
- break;
137
- case '--docker-tag':
138
- args.dockerTag = queue.shift();
139
- break;
140
- case '--node-version':
141
- args.nodeVersion = queue.shift();
142
- break;
143
- case '--skip-validate':
144
- args.skipValidate = true;
145
- break;
146
- case '--api-host':
147
- args.apiHost = queue.shift();
148
- break;
149
- default:
150
- throw new Error(`Unknown argument: ${current}`);
151
- }
152
- }
153
-
154
- if (process.env.npm_config_env_file) {
155
- args.envFile = process.env.npm_config_env_file;
156
- }
157
-
158
- return args;
159
- }
160
-
161
- function ensure(value, message) {
162
- if (!value) {
163
- throw new Error(message);
164
- }
165
- return value;
166
- }
167
-
168
- function md5Hex(input) {
169
- return crypto.createHash('md5').update(input).digest('hex');
170
- }
171
-
172
- function sha256Digest(buffer) {
173
- return `sha256:${crypto.createHash('sha256').update(buffer).digest('hex')}`;
174
- }
175
-
176
- function shortDigest(fullDigest) {
177
- if (!fullDigest) {
178
- return '';
179
- }
180
- const parts = fullDigest.split(':');
181
- const hash = parts.length > 1 ? parts[1] : parts[0];
182
- return hash.substring(0, 12);
183
- }
184
-
185
- function normalizeApiHost(host) {
186
- if (!host) {
187
- return undefined;
188
- }
189
- if (host.startsWith('wss://')) {
190
- return `https://${host.substring('wss://'.length).replace(/\/socket$/, '')}`;
191
- }
192
- if (host.startsWith('ws://')) {
193
- return `http://${host.substring('ws://'.length).replace(/\/socket$/, '')}`;
194
- }
195
- return host.replace(/\/$/, '');
196
- }
197
-
198
- async function readJson(filePath) {
199
- const content = await fs.readFile(filePath, 'utf8');
200
- return JSON.parse(content);
201
- }
202
-
203
- async function copyDirectory(source, destination) {
204
- const entries = await fs.readdir(source, { withFileTypes: true });
205
- await fs.mkdir(destination, { recursive: true });
206
- for (const entry of entries) {
207
- const srcPath = path.join(source, entry.name);
208
- const destPath = path.join(destination, entry.name);
209
- if (entry.isDirectory()) {
210
- await copyDirectory(srcPath, destPath);
211
- } else if (entry.isFile()) {
212
- await fs.copyFile(srcPath, destPath);
213
- }
214
- }
215
- }
216
-
217
- async function readDockerImageTar(tarPath) {
218
- const files = new Map();
219
- const extract = tar.extract();
220
-
221
- await new Promise((resolve, reject) => {
222
- extract.on('entry', (header, stream, next) => {
223
- const chunks = [];
224
- stream.on('data', (chunk) => chunks.push(chunk));
225
- stream.on('end', () => {
226
- files.set(header.name, Buffer.concat(chunks));
227
- next();
228
- });
229
- stream.on('error', reject);
230
- });
231
- extract.on('finish', resolve);
232
- extract.on('error', reject);
233
-
234
- fssync.createReadStream(tarPath).pipe(extract);
235
- });
236
-
237
- const manifestBuffer = files.get('manifest.json');
238
- if (!manifestBuffer) {
239
- throw new Error('Docker image archive missing manifest.json');
240
- }
241
-
242
- const manifestJson = JSON.parse(manifestBuffer.toString());
243
- if (!Array.isArray(manifestJson) || manifestJson.length === 0) {
244
- throw new Error('Unexpected manifest.json structure.');
245
- }
246
-
247
- const manifestEntry = manifestJson[0];
248
- const configName = manifestEntry.Config || manifestEntry.config;
249
- const layerNames = manifestEntry.Layers || manifestEntry.layers;
250
-
251
- if (!configName || !layerNames) {
252
- throw new Error('Manifest entry missing Config or Layers.');
253
- }
254
-
255
- const configBuffer = files.get(configName);
256
- if (!configBuffer) {
257
- throw new Error(`Config blob missing in archive: ${configName}`);
258
- }
259
-
260
- const layers = layerNames.map((layerName) => {
261
- const buffer = files.get(layerName);
262
- if (!buffer) {
263
- throw new Error(`Layer missing in archive: ${layerName}`);
264
- }
265
- return { name: layerName, buffer };
266
- });
267
-
268
- return { manifestEntry, configBuffer, layers };
269
- }
270
-
271
- async function checkBlobExists(baseUrl, digest, headers) {
272
- const url = new URL(`blobs/${digest}`, baseUrl);
273
- const response = await fetch(url, { method: 'HEAD', headers, redirect: 'manual' });
274
-
275
- if (response.status === 307 && response.headers.get('location')) {
276
- const redirected = response.headers.get('location');
277
- const nextBase = redirected.startsWith('http') ? redirected : new URL(redirected, baseUrl).href;
278
- return checkBlobExists(nextBase, digest, headers);
279
- }
280
-
281
- return response.status === 200;
282
- }
283
-
284
- async function verifyManifestExists(baseUrl, tag, headers) {
285
- const url = new URL(`manifests/${tag}`, baseUrl);
286
- const response = await fetch(url, { method: 'HEAD', headers, redirect: 'manual' });
287
-
288
- if (response.status === 307 && response.headers.get('location')) {
289
- const redirected = response.headers.get('location');
290
- const nextBase = redirected.startsWith('http') ? redirected : new URL(redirected, baseUrl).href;
291
- return verifyManifestExists(nextBase, tag, headers);
292
- }
293
-
294
- return response.status === 200;
295
- }
296
-
297
- async function prepareUploadLocation(baseUrl, headers) {
298
- const url = new URL('blobs/uploads/', baseUrl);
299
- // Match C# CLI exactly: StringContent("") sets Content-Type and Content-Length
300
- const requestHeaders = {
301
- ...headers,
302
- 'Content-Type': 'text/plain; charset=utf-8',
303
- 'Content-Length': '0',
304
- };
305
-
306
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
307
- // console.error(`[beamo-node] [SUBSTEP: Prepare Upload Location]`);
308
- // console.error(`[beamo-node] URL: ${url}`);
309
- // console.error(`[beamo-node] Method: POST`);
310
- // console.error(`[beamo-node] Headers:`, JSON.stringify(requestHeaders, null, 2));
311
- // }
312
-
313
- let response;
314
- try {
315
- response = await fetch(url, {
316
- method: 'POST',
317
- headers: requestHeaders,
318
- body: '', // Empty body
319
- });
320
- } catch (error) {
321
- // Network/SSL errors happen before HTTP response
322
- const errorMsg = error instanceof Error ? error.message : String(error);
323
- const errorDetails = {
324
- url: url.toString(),
325
- error: errorMsg,
326
- ...(error instanceof Error && error.stack ? { stack: error.stack } : {}),
327
- ...(error instanceof Error && error.cause ? { cause: error.cause } : {}),
328
- };
329
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
330
- // console.error('[beamo-node] Network error:', errorDetails);
331
- // }
332
- throw new Error(`Network error preparing upload location: ${errorMsg}. URL: ${url.toString()}`);
333
- }
334
-
335
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
336
- // console.error(`[beamo-node] Response Status: ${response.status}`);
337
- // console.error(`[beamo-node] Response Headers:`, JSON.stringify(Object.fromEntries(response.headers.entries()), null, 2));
338
- // }
339
-
340
- if (!response.ok) {
341
- const text = await response.text();
342
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
343
- // console.error('[beamo-node] Upload location failed', {
344
- // status: response.status,
345
- // statusText: response.statusText,
346
- // headers: Object.fromEntries(response.headers.entries()),
347
- // body: text.substring(0, 500),
348
- // });
349
- // }
350
- throw new Error(`Failed to prepare upload location: ${response.status} ${text}`);
351
- }
352
- const location = response.headers.get('location');
353
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
354
- // console.error(`[beamo-node] Upload Location: ${location}`);
355
- // }
356
- return location;
357
- }
358
-
359
- async function uploadBlob(baseUrl, digest, buffer, headers) {
360
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
361
- // console.error(`[beamo-node] [SUBSTEP: Upload Blob]`);
362
- // console.error(`[beamo-node] Digest: ${digest}`);
363
- // console.error(`[beamo-node] Size: ${buffer.length} bytes`);
364
- // }
365
-
366
- const exists = await checkBlobExists(baseUrl, digest, headers);
367
- if (exists) {
368
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
369
- // console.error(`[beamo-node] Blob already exists, skipping upload`);
370
- // }
371
- return { digest, size: buffer.length };
372
- }
373
-
374
- const location = await prepareUploadLocation(baseUrl, headers);
375
- if (!location) {
376
- throw new Error('Registry did not provide an upload location.');
377
- }
378
-
379
- // Match C# CLI: NormalizeWithDigest forces HTTPS and default port (-1 means use default for scheme)
380
- const locationUrl = new URL(location.startsWith('http') ? location : new URL(location, baseUrl).href);
381
- // Force HTTPS and remove explicit port (use default port 443 for HTTPS)
382
- locationUrl.protocol = 'https:';
383
- locationUrl.port = ''; // Empty string means use default port for the scheme
384
- locationUrl.searchParams.set('digest', digest);
385
- const uploadUrl = locationUrl;
386
-
387
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
388
- // console.error(`[beamo-node] Upload URL: ${uploadUrl}`);
389
- // console.error(`[beamo-node] Method: PUT`);
390
- // const uploadHeaders = { ...headers, 'Content-Type': 'application/octet-stream' };
391
- // console.error(`[beamo-node] Upload Headers:`, JSON.stringify(uploadHeaders, null, 2));
392
- // }
393
-
394
- const response = await fetch(uploadUrl, {
395
- method: 'PUT',
396
- headers: { ...headers, 'Content-Type': 'application/octet-stream' },
397
- body: buffer,
398
- });
399
-
400
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
401
- // console.error(`[beamo-node] Response Status: ${response.status}`);
402
- // console.error(`[beamo-node] Response Headers:`, JSON.stringify(Object.fromEntries(response.headers.entries()), null, 2));
403
- // }
404
-
405
- if (!response.ok) {
406
- const text = await response.text();
407
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
408
- // console.error(`[beamo-node] Response Body: ${text.substring(0, 500)}`);
409
- // }
410
- throw new Error(`Failed to upload blob ${digest}: ${response.status} ${text}`);
411
- }
412
-
413
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
414
- // console.error(`[beamo-node] Blob upload successful`);
415
- // }
416
-
417
- return { digest, size: buffer.length };
418
- }
419
-
420
- async function uploadManifest(baseUrl, manifestJson, shortImageId, headers) {
421
- // Match C# CLI: upload manifest using the short imageId as the tag
422
- // The backend looks up images using this short imageId tag
423
- const manifestJsonString = JSON.stringify(manifestJson);
424
- const url = new URL(`manifests/${shortImageId}`, baseUrl);
425
- const requestHeaders = { ...headers, 'Content-Type': MANIFEST_MEDIA_TYPE };
426
-
427
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
428
- // console.error(`[beamo-node] [SUBSTEP: Upload Manifest to Registry]`);
429
- // console.error(`[beamo-node] URL: ${url}`);
430
- // console.error(`[beamo-node] Method: PUT`);
431
- // console.error(`[beamo-node] Tag: ${shortImageId}`);
432
- // console.error(`[beamo-node] Headers:`, JSON.stringify(requestHeaders, null, 2));
433
- // console.error(`[beamo-node] Manifest JSON:`, manifestJsonString);
434
- // }
435
-
436
- const response = await fetch(url, {
437
- method: 'PUT',
438
- headers: requestHeaders,
439
- body: manifestJsonString,
440
- });
441
-
442
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
443
- // console.error(`[beamo-node] Response Status: ${response.status}`);
444
- // console.error(`[beamo-node] Response Headers:`, JSON.stringify(Object.fromEntries(response.headers.entries()), null, 2));
445
- // }
446
-
447
- if (!response.ok) {
448
- const text = await response.text();
449
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
450
- // console.error(`[beamo-node] Response Body: ${text.substring(0, 500)}`);
451
- // }
452
- throw new Error(`Failed to upload manifest: ${response.status} ${text}`);
453
- }
454
-
455
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
456
- // console.error(`[beamo-node] Manifest upload successful`);
457
- // }
458
- }
459
-
460
- async function fetchJson(url, options = {}) {
461
- const response = await fetch(url, options);
462
- if (!response.ok) {
463
- const text = await response.text();
464
- const error = new Error(`Request failed ${response.status}: ${text}`);
465
- error.status = response.status;
466
- throw error;
467
- }
468
- return response.json();
469
- }
470
-
471
- async function resolveGamePid(apiHost, token, cid, pid, explicitGamePid) {
472
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
473
- // console.error(`[beamo-node] [STEP: Resolve Game PID]`);
474
- // console.error(`[beamo-node] Explicit Game PID: ${explicitGamePid || '(none)'}`);
475
- // console.error(`[beamo-node] Realm PID: ${pid}`);
476
- // console.error(`[beamo-node] NOTE: Always resolving root project (Game ID) from API, ignoring explicit value`);
477
- // }
478
-
479
- // Always resolve the root project from the API (matching C# CLI's FindRoot().Pid)
480
- // The explicit game PID might be incorrect (could be realm PID instead of root)
481
- const scope = pid ? `${cid}.${pid}` : cid;
482
- try {
483
- const url = new URL(`/basic/realms/game`, apiHost);
484
- url.searchParams.set('rootPID', pid);
485
- const requestHeaders = {
486
- Authorization: `Bearer ${token}`,
487
- Accept: 'application/json',
488
- ...(scope ? { 'X-BEAM-SCOPE': scope } : {}),
489
- };
490
-
491
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
492
- // console.error(`[beamo-node] Fetching game PID from: ${url}`);
493
- // console.error(`[beamo-node] Headers:`, JSON.stringify(requestHeaders, null, 2));
494
- // }
495
-
496
- const body = await fetchJson(url, { headers: requestHeaders });
497
-
498
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
499
- // console.error(`[beamo-node] Response:`, JSON.stringify(body, null, 2));
500
- // }
501
-
502
- const projects = Array.isArray(body?.projects) ? body.projects : [];
503
- if (projects.length === 0) {
504
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
505
- // console.error(`[beamo-node] No projects found, using realm PID: ${pid}`);
506
- // }
507
- return pid;
508
- }
509
-
510
- // Match C# CLI FindRoot() logic: walk up parent chain until Parent == null (root)
511
- const byPid = new Map(projects.map((project) => [project.pid, project]));
512
- let current = byPid.get(pid);
513
- if (!current) {
514
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
515
- // console.error(`[beamo-node] Realm PID not found in projects, using realm PID: ${pid}`);
516
- // }
517
- return pid;
518
- }
519
-
520
- const visited = new Set();
521
- // Walk up parent chain until we find root (parent == null or isRoot == true)
522
- while (current && (current.parent != null && current.parent !== '' && !current.isRoot) && !visited.has(current.parent)) {
523
- visited.add(current.pid);
524
- current = byPid.get(current.parent);
525
- if (!current) {
526
- // Parent not found in projects list, current is as far as we can go
527
- break;
528
- }
529
- }
530
- // current is now the root (or the original if no parent chain)
531
- const resolved = current?.pid ?? pid;
532
-
533
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
534
- // console.error(`[beamo-node] Resolved Game PID (root): ${resolved}`);
535
- // if (explicitGamePid && explicitGamePid !== resolved) {
536
- // console.error(`[beamo-node] ⚠️ WARNING: Explicit Game PID (${explicitGamePid}) does not match resolved root (${resolved})`);
537
- // console.error(`[beamo-node] Using resolved root (${resolved}) for imageNameMD5 calculation`);
538
- // }
539
- // }
540
- return resolved;
541
- } catch (error) {
542
- // Debug logging only
543
- return pid;
544
- }
545
- }
546
-
547
- async function getScopedAccessToken(apiHost, cid, pid, refreshToken, fallbackToken) {
548
- const scope = pid ? `${cid}.${pid}` : cid;
549
- if (!refreshToken) {
550
- return { accessToken: fallbackToken, refreshToken };
551
- }
552
-
553
- try {
554
- const response = await fetch(new URL('/basic/auth/token', apiHost), {
555
- method: 'POST',
556
- headers: {
557
- Accept: 'application/json',
558
- 'Content-Type': 'application/json',
559
- ...(scope ? { 'X-BEAM-SCOPE': scope } : {}),
560
- },
561
- body: JSON.stringify({
562
- grant_type: 'refresh_token',
563
- refresh_token: refreshToken,
564
- }),
565
- });
566
-
567
- if (!response.ok) {
568
- const text = await response.text();
569
- throw new Error(`Refresh token request failed: ${response.status} ${text}`);
570
- }
571
-
572
- const body = await response.json();
573
- const accessToken = body.access_token ?? fallbackToken;
574
- const nextRefresh = body.refresh_token ?? refreshToken;
575
- return { accessToken, refreshToken: nextRefresh };
576
- } catch (error) {
577
- // Debug logging only
578
- return { accessToken: fallbackToken, refreshToken };
579
- }
580
- }
581
-
582
- async function getRegistryUrl(apiHost, token, cid, pid) {
583
- const scope = pid ? `${cid}.${pid}` : cid;
584
- const url = new URL('/basic/beamo/registry', apiHost);
585
- const headers = {
586
- Authorization: `Bearer ${token}`,
587
- Accept: 'application/json',
588
- ...(scope ? { 'X-BEAM-SCOPE': scope } : {}),
589
- };
590
-
591
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
592
- // console.error(`[beamo-node] [STEP: Get Registry URL]`);
593
- // console.error(`[beamo-node] URL: ${url}`);
594
- // console.error(`[beamo-node] Headers:`, JSON.stringify(headers, null, 2));
595
- // console.error(`[beamo-node] Scope: ${scope}`);
596
- // }
597
-
598
- const body = await fetchJson(url, { headers });
599
-
600
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
601
- // console.error(`[beamo-node] Response:`, JSON.stringify(body, null, 2));
602
- // }
603
-
604
- const uri = body.uri || body.registry || body.url;
605
- if (!uri) {
606
- throw new Error('Registry URI response missing "uri" field.');
607
- }
608
- // Match C# CLI exactly: GetDockerImageRegistryUri() returns scheme://host/v2/ (Host property strips port)
609
- const normalized = uri.includes('://') ? uri : `https://${uri}`;
610
- const parsed = new URL(normalized);
611
- // parsedUri.Host in C# is just the hostname (no port), so we use hostname here
612
- const registryUrl = `${parsed.protocol}//${parsed.hostname}/v2/`;
613
-
614
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
615
- // console.error(`[beamo-node] Normalized Registry URL: ${registryUrl}`);
616
- // }
617
-
618
- return registryUrl;
619
- }
620
-
621
- async function uploadDockerImage({
622
- apiHost,
623
- registryUrl,
624
- cid,
625
- pid,
626
- gamePid,
627
- token,
628
- serviceId,
629
- uniqueName,
630
- imageTarPath,
631
- fullImageId,
632
- progress,
633
- }) {
634
- const baseUrl = `${registryUrl}${uniqueName}/`;
635
- // Match C# CLI and backend: use realm PID in headers (backend checks registry using rc.projectId)
636
- // uniqueName uses gamePid (matches backend's rc.gameId), but headers use realm PID
637
- const headers = {
638
- 'x-ks-clientid': cid,
639
- 'x-ks-projectid': pid, // Use realm PID (matches backend's rc.projectId in DockerRegistryClient)
640
- 'x-ks-token': token, // Access token from login
641
- };
642
-
643
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
644
- // console.error(`[beamo-node] [STEP: Upload Docker Image]`);
645
- // console.error(`[beamo-node] Base URL: ${baseUrl}`);
646
- // console.error(`[beamo-node] Registry URL: ${registryUrl}`);
647
- // console.error(`[beamo-node] Unique Name: ${uniqueName}`);
648
- // console.error(`[beamo-node] Service ID: ${serviceId}`);
649
- // console.error(`[beamo-node] CID: ${cid}`);
650
- // console.error(`[beamo-node] Realm PID: ${pid}`);
651
- // console.error(`[beamo-node] Game PID: ${gamePid}`);
652
- // console.error(`[beamo-node] Full Image ID: ${fullImageId}`);
653
- // console.error(`[beamo-node] Upload Headers:`, JSON.stringify(headers, null, 2));
654
- // }
655
-
656
- const { manifestEntry, configBuffer, layers } = await readDockerImageTar(imageTarPath);
657
-
658
- // Upload config
659
- if (progress) {
660
- process.stdout.write(`\r${colors.blue}↑${colors.reset} Uploading config...`);
661
- }
662
- const configDigestValue = sha256Digest(configBuffer);
663
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
664
- // console.error(`[beamo-node] [SUBSTEP: Upload Config]`);
665
- // console.error(`[beamo-node] Config Digest: ${configDigestValue}`);
666
- // console.error(`[beamo-node] Config Size: ${configBuffer.length} bytes`);
667
- // }
668
- const configDigest = await uploadBlob(baseUrl, configDigestValue, configBuffer, headers);
669
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
670
- // console.error(`[beamo-node] Config Upload Result:`, JSON.stringify(configDigest, null, 2));
671
- // }
672
-
673
- // Upload layers with progress
674
- const layerDescriptors = [];
675
- const totalLayers = layers.length;
676
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
677
- // console.error(`[beamo-node] [SUBSTEP: Upload Layers]`);
678
- // console.error(`[beamo-node] Total Layers: ${totalLayers}`);
679
- // }
680
- for (let i = 0; i < layers.length; i++) {
681
- if (progress) {
682
- process.stdout.write(`\r${colors.blue}↑${colors.reset} Uploading layers (${i + 1}/${totalLayers})...`);
683
- }
684
- const layerDigestValue = sha256Digest(layers[i].buffer);
685
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
686
- // console.error(`[beamo-node] Layer ${i + 1}/${totalLayers}:`);
687
- // console.error(`[beamo-node] Digest: ${layerDigestValue}`);
688
- // console.error(`[beamo-node] Size: ${layers[i].buffer.length} bytes`);
689
- // }
690
- const descriptor = await uploadBlob(baseUrl, layerDigestValue, layers[i].buffer, headers);
691
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
692
- // console.error(`[beamo-node] Upload Result:`, JSON.stringify(descriptor, null, 2));
693
- // }
694
- layerDescriptors.push({
695
- digest: descriptor.digest,
696
- size: descriptor.size,
697
- mediaType: LAYER_MEDIA_TYPE,
698
- });
699
- }
700
-
701
- const uploadManifestJson = {
702
- schemaVersion: 2,
703
- mediaType: MANIFEST_MEDIA_TYPE,
704
- config: {
705
- mediaType: CONFIG_MEDIA_TYPE,
706
- digest: configDigest.digest,
707
- size: configDigest.size,
708
- },
709
- layers: layerDescriptors,
710
- };
711
-
712
- // Upload manifest using short imageId as tag (matching C# CLI behavior)
713
- if (progress) {
714
- process.stdout.write(`\r${colors.blue}↑${colors.reset} Uploading manifest...`);
715
- }
716
- const shortImageId = shortDigest(fullImageId);
717
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
718
- // console.error(`[beamo-node] [SUBSTEP: Upload Manifest]`);
719
- // console.error(`[beamo-node] Short Image ID: ${shortImageId}`);
720
- // console.error(`[beamo-node] Manifest JSON:`, JSON.stringify(uploadManifestJson, null, 2));
721
- // }
722
- await uploadManifest(baseUrl, uploadManifestJson, shortImageId, headers);
723
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
724
- // console.error(`[beamo-node] Manifest Upload Complete`);
725
- // }
726
- if (progress) {
727
- process.stdout.write('\r');
728
- }
729
- }
730
-
731
- async function fetchCurrentManifest(apiHost, token, cid, pid) {
732
- const response = await fetch(new URL('/api/beamo/manifests/current', apiHost), {
733
- headers: {
734
- Authorization: `Bearer ${token}`,
735
- Accept: 'application/json',
736
- 'X-BEAM-SCOPE': `${cid}.${pid}`,
737
- },
738
- });
739
- if (response.status === 404) {
740
- // No existing manifest (first publish) - return null
741
- return null;
742
- }
743
- if (!response.ok) {
744
- const text = await response.text();
745
- throw new Error(`Failed to fetch current manifest: ${response.status} ${text}`);
746
- }
747
- return response.json();
748
- }
749
-
750
- async function discoverStorageObjects(srcDir, cwd = process.cwd()) {
751
- const storageObjects = [];
752
- try {
753
- // Resolve src path relative to current working directory (where publish is run from)
754
- const srcPath = path.isAbsolute(srcDir) ? srcDir : path.resolve(cwd, srcDir || 'src');
755
-
756
- console.log(`[beamo-node] Searching for @StorageObject decorators in: ${srcPath}`);
757
-
758
- // Check if directory exists
759
- try {
760
- const stats = await fs.stat(srcPath);
761
- if (!stats.isDirectory()) {
762
- console.warn(`[beamo-node] Warning: ${srcPath} is not a directory`);
763
- return storageObjects;
764
- }
765
- } catch (error) {
766
- console.warn(`[beamo-node] Warning: Directory ${srcPath} does not exist`);
767
- return storageObjects;
768
- }
769
-
770
- const files = await getAllTypeScriptFiles(srcPath);
771
-
772
- if (files.length === 0) {
773
- console.warn(`[beamo-node] Warning: No TypeScript files found in ${srcPath}`);
774
- return storageObjects;
775
- }
776
-
777
- console.log(`[beamo-node] Scanning ${files.length} TypeScript file(s) for @StorageObject decorators...`);
778
-
779
- for (const file of files) {
780
- const content = await fs.readFile(file, 'utf-8');
781
- // Match @StorageObject('StorageName') pattern - handle both single and double quotes
782
- // Also match multiline patterns where decorator might be on a different line
783
- const storageRegex = /@StorageObject\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
784
- let match;
785
- while ((match = storageRegex.exec(content)) !== null) {
786
- const storageName = match[1];
787
- if (storageName && !storageObjects.find(s => s.id === storageName)) {
788
- storageObjects.push({
789
- id: storageName,
790
- enabled: true,
791
- checksum: null,
792
- archived: false,
793
- });
794
- console.log(`[beamo-node] ✓ Discovered storage object: ${storageName} (from ${path.relative(cwd, file)})`);
795
- }
796
- }
797
- }
798
-
799
- if (storageObjects.length === 0) {
800
- console.log(`[beamo-node] No @StorageObject decorators found in ${files.length} file(s) in ${srcPath}`);
801
- console.log(`[beamo-node] Make sure your storage classes are decorated with @StorageObject('StorageName')`);
802
- }
803
- } catch (error) {
804
- console.warn(`[beamo-node] Error discovering storage objects: ${error instanceof Error ? error.message : String(error)}`);
805
- if (error instanceof Error && error.stack) {
806
- console.warn(`[beamo-node] Stack: ${error.stack}`);
807
- }
808
- // If we can't discover storage, that's okay - we'll just use existing ones
809
- }
810
- return storageObjects;
811
- }
812
-
813
- async function discoverFederationComponents(srcDir) {
814
- const components = [];
815
- try {
816
- const srcPath = path.resolve(srcDir || 'src');
817
- const files = await getAllTypeScriptFiles(srcPath);
818
-
819
- for (const file of files) {
820
- const content = await fs.readFile(file, 'utf-8');
821
-
822
- // Match @FederatedInventory({ identity: IdentityClass }) pattern
823
- const federatedInventoryRegex = /@FederatedInventory\s*\(\s*\{\s*identity:\s*(\w+)\s*\}\s*\)/g;
824
- let match;
825
- while ((match = federatedInventoryRegex.exec(content)) !== null) {
826
- const identityClassName = match[1];
827
-
828
- // Find the identity class definition in the same file or other files
829
- // Look for: class IdentityClass implements FederationIdentity { getUniqueName(): string { return 'name'; } }
830
- // Use multiline matching to handle class definitions that span multiple lines
831
- const identityClassRegex = new RegExp(
832
- `class\\s+${identityClassName}[^{]*\\{[\\s\\S]*?getUniqueName\\(\\)[\\s\\S]*?return\\s+['"]([^'"]+)['"]`,
833
- 's'
834
- );
835
- let identityMatch = identityClassRegex.exec(content);
836
-
837
- // If not found in current file, search other files
838
- if (!identityMatch) {
839
- for (const otherFile of files) {
840
- if (otherFile !== file) {
841
- const otherContent = await fs.readFile(otherFile, 'utf-8');
842
- identityMatch = identityClassRegex.exec(otherContent);
843
- if (identityMatch) {
844
- break;
845
- }
846
- }
847
- }
848
- }
849
-
850
- if (identityMatch) {
851
- const identityName = identityMatch[1];
852
- // Add both IFederatedInventory and IFederatedLogin components
853
- const inventoryComponent = `IFederatedInventory/${identityName}`;
854
- const loginComponent = `IFederatedLogin/${identityName}`;
855
- if (!components.includes(inventoryComponent)) {
856
- components.push(inventoryComponent);
857
- }
858
- if (!components.includes(loginComponent)) {
859
- components.push(loginComponent);
860
- }
861
- }
862
- }
863
- }
864
- } catch (error) {
865
- // If we can't discover components, that's okay - we'll just use existing ones
866
- // Debug logging only
867
- }
868
- return components;
869
- }
870
-
871
- async function getAllTypeScriptFiles(dir) {
872
- const files = [];
873
- try {
874
- const entries = await fs.readdir(dir, { withFileTypes: true });
875
- for (const entry of entries) {
876
- const fullPath = path.join(dir, entry.name);
877
- if (entry.isDirectory() && entry.name !== 'node_modules' && !entry.name.startsWith('.')) {
878
- files.push(...await getAllTypeScriptFiles(fullPath));
879
- } else if (entry.isFile() && (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx'))) {
880
- files.push(fullPath);
881
- }
882
- }
883
- } catch (error) {
884
- // Ignore errors reading directories
885
- }
886
- return files;
887
- }
888
-
889
- async function updateManifest({
890
- apiHost,
891
- token,
892
- cid,
893
- pid,
894
- serviceId,
895
- shortImageId, // This is now the full image ID (sha256:...) for backend verification
896
- comments,
897
- existingManifest,
898
- discoveredStorage,
899
- discoveredComponents,
900
- discoveredDependencies,
901
- }) {
902
- const serviceReferences = existingManifest?.serviceReferences?.Value
903
- ?? existingManifest?.serviceReferences
904
- ?? existingManifest?.manifest
905
- ?? [];
906
- const storageRefsRaw = existingManifest?.storageReferences?.Value
907
- ?? existingManifest?.storageReferences
908
- ?? [];
909
- const existingStorage = Array.isArray(storageRefsRaw)
910
- ? storageRefsRaw.map((reference) => ({
911
- id: reference.id?.Value ?? reference.id,
912
- storageType: reference.storageType?.Value ?? reference.storageType ?? 'mongov1',
913
- enabled: reference.enabled?.Value ?? reference.enabled ?? true,
914
- checksum: reference.checksum?.Value ?? reference.checksum,
915
- archived: reference.archived?.Value ?? reference.archived ?? false,
916
- }))
917
- : [];
918
-
919
- // Merge discovered storage with existing storage
920
- // If a storage object exists in both, keep the existing one (preserves checksum, etc.)
921
- // But ensure storageType is always set to 'mongov1' for MongoDB storage
922
- const storageMap = new Map();
923
- existingStorage.forEach(s => {
924
- // Normalize storageType: update 'mongo' to 'mongov1' if present
925
- const normalizedStorage = {
926
- ...s,
927
- storageType: s.storageType === 'mongo' ? 'mongov1' : (s.storageType || 'mongov1'),
928
- };
929
- storageMap.set(s.id, normalizedStorage);
930
- });
931
- discoveredStorage.forEach(s => {
932
- if (!storageMap.has(s.id)) {
933
- storageMap.set(s.id, {
934
- ...s,
935
- storageType: 'mongov1', // All discovered storage uses MongoDB
936
- });
937
- } else {
938
- // Update existing storage to ensure storageType is 'mongov1'
939
- const existing = storageMap.get(s.id);
940
- storageMap.set(s.id, {
941
- ...existing,
942
- storageType: 'mongov1',
943
- });
944
- }
945
- });
946
- const storageReferences = Array.from(storageMap.values());
947
-
948
- // Extract existing components and dependencies for the service
949
- const existingServiceRef = serviceReferences.find(
950
- (ref) => (ref.serviceName?.Value ?? ref.serviceName) === serviceId
951
- );
952
- const existingComponents = existingServiceRef?.components?.Value
953
- ?? existingServiceRef?.components
954
- ?? [];
955
- const existingDependencies = existingServiceRef?.dependencies?.Value
956
- ?? existingServiceRef?.dependencies
957
- ?? [];
958
-
959
- // Components are ServiceComponent objects with {name: string}
960
- // Merge discovered with existing (preserve existing, add new)
961
- const componentsMap = new Map();
962
- // Add existing components
963
- if (Array.isArray(existingComponents)) {
964
- existingComponents.forEach(comp => {
965
- const name = comp.name?.Value ?? comp.name ?? comp;
966
- if (typeof name === 'string') {
967
- componentsMap.set(name, { name });
968
- }
969
- });
970
- }
971
- // Add discovered components (will overwrite existing if same name)
972
- (discoveredComponents || []).forEach(compName => {
973
- componentsMap.set(compName, { name: compName });
974
- });
975
- const components = Array.from(componentsMap.values());
976
-
977
- // Dependencies are objects with {id, storageType}, need to merge by id
978
- const dependenciesMap = new Map();
979
- // Add existing dependencies
980
- if (Array.isArray(existingDependencies)) {
981
- existingDependencies.forEach(dep => {
982
- const id = dep.id?.Value ?? dep.id ?? dep;
983
- if (typeof id === 'string') {
984
- let storageType = dep.storageType?.Value ?? dep.storageType ?? 'mongov1';
985
- // Normalize: update 'mongo' to 'mongov1' to match C# microservices
986
- if (storageType === 'mongo') {
987
- storageType = 'mongov1';
988
- }
989
- dependenciesMap.set(id, { id, storageType });
990
- }
991
- });
992
- }
993
- // Add discovered dependencies (will overwrite existing if same id)
994
- (discoveredDependencies || []).forEach(dep => {
995
- dependenciesMap.set(dep.id, dep);
996
- });
997
- const dependencies = Array.from(dependenciesMap.values());
998
-
999
- let updated = false;
1000
- const mappedServices = serviceReferences.map((reference) => {
1001
- const name = reference.serviceName?.Value ?? reference.serviceName;
1002
- if (name === serviceId) {
1003
- updated = true;
1004
- return {
1005
- serviceName: serviceId,
1006
- enabled: true,
1007
- templateId: reference.templateId?.Value ?? reference.templateId ?? 'small',
1008
- containerHealthCheckPort: reference.containerHealthCheckPort?.Value ?? reference.containerHealthCheckPort ?? 6565,
1009
- imageId: shortImageId,
1010
- imageCpuArch: reference.imageCpuArch?.Value ?? reference.imageCpuArch ?? 'linux/amd64',
1011
- logProvider: reference.logProvider?.Value ?? reference.logProvider ?? 'Clickhouse',
1012
- dependencies,
1013
- components,
1014
- };
1015
- }
1016
- return {
1017
- serviceName: name,
1018
- enabled: reference.enabled?.Value ?? reference.enabled ?? true,
1019
- templateId: reference.templateId?.Value ?? reference.templateId ?? 'small',
1020
- containerHealthCheckPort: reference.containerHealthCheckPort?.Value ?? reference.containerHealthCheckPort ?? 6565,
1021
- imageId: reference.imageId?.Value ?? reference.imageId ?? shortImageId,
1022
- imageCpuArch: reference.imageCpuArch?.Value ?? reference.imageCpuArch ?? 'linux/amd64',
1023
- logProvider: reference.logProvider?.Value ?? reference.logProvider ?? 'Clickhouse',
1024
- dependencies: reference.dependencies?.Value ?? reference.dependencies ?? [],
1025
- components: reference.components?.Value ?? reference.components ?? [],
1026
- };
1027
- });
1028
-
1029
- if (!updated) {
1030
- mappedServices.push({
1031
- serviceName: serviceId,
1032
- enabled: true,
1033
- templateId: 'small',
1034
- containerHealthCheckPort: 6565,
1035
- imageId: shortImageId,
1036
- imageCpuArch: 'linux/amd64',
1037
- logProvider: 'Clickhouse',
1038
- dependencies,
1039
- components,
1040
- });
1041
- }
1042
-
1043
- const requestBody = {
1044
- autoDeploy: true,
1045
- comments: comments ?? '',
1046
- manifest: mappedServices,
1047
- storageReferences,
1048
- };
1049
-
1050
- // Log storage references being sent to backend
1051
- if (storageReferences.length > 0) {
1052
- console.log(`[beamo-node] Publishing ${storageReferences.length} storage reference(s) in manifest:`);
1053
- storageReferences.forEach(s => {
1054
- console.log(`[beamo-node] - ${s.id} (type: ${s.storageType || 'mongov1'}, enabled: ${s.enabled !== false})`);
1055
- });
1056
- } else {
1057
- console.warn(`[beamo-node] Warning: No storage references in manifest. Database will not be created automatically.`);
1058
- }
1059
-
1060
- const publishUrl = new URL('/basic/beamo/manifest', apiHost);
1061
- const publishHeaders = {
1062
- Authorization: `Bearer ${token}`,
1063
- Accept: 'application/json',
1064
- 'Content-Type': 'application/json',
1065
- 'X-BEAM-SCOPE': `${cid}.${pid}`,
1066
- };
1067
-
1068
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
1069
- // console.error(`[beamo-node] [STEP: Publish Manifest to Backend]`);
1070
- // console.error(`[beamo-node] URL: ${publishUrl}`);
1071
- // console.error(`[beamo-node] Method: POST`);
1072
- // console.error(`[beamo-node] Headers:`, JSON.stringify(publishHeaders, null, 2));
1073
- // console.error(`[beamo-node] Service: ${serviceId}`);
1074
- // console.error(`[beamo-node] CID: ${cid}`);
1075
- // console.error(`[beamo-node] Realm PID (from X-BEAM-SCOPE): ${pid}`);
1076
- // console.error(`[beamo-node] Short Image ID: ${shortImageId}`);
1077
- // console.error(`[beamo-node] Expected Backend Check:`);
1078
- // console.error(`[beamo-node] - Backend will calculate imageNameMD5 using: rc.cid, rc.gameId, serviceName`);
1079
- // console.error(`[beamo-node] - Backend will check: {registryURI}/{imageNameMD5}/manifests/{imageId}`);
1080
- // console.error(`[beamo-node] - Backend will use headers: X-KS-PROJECTID: rc.projectId (from X-BEAM-SCOPE)`);
1081
- // console.error(`[beamo-node] - NOTE: rc.gameId might differ from realm PID if backend resolves it differently`);
1082
- // console.error(`[beamo-node] Request Body:`, JSON.stringify(requestBody, null, 2));
1083
- // console.error(`[beamo-node] Service Entry in Manifest:`, JSON.stringify(mappedServices.find(s => s.serviceName === serviceId), null, 2));
1084
- // }
1085
-
1086
- const response = await fetch(publishUrl, {
1087
- method: 'POST',
1088
- headers: publishHeaders,
1089
- body: JSON.stringify(requestBody),
1090
- });
1091
-
1092
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
1093
- // console.error(`[beamo-node] Response Status: ${response.status}`);
1094
- // console.error(`[beamo-node] Response Headers:`, JSON.stringify(Object.fromEntries(response.headers.entries()), null, 2));
1095
- // }
1096
-
1097
- if (!response.ok) {
1098
- const text = await response.text();
1099
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
1100
- // console.error(`[beamo-node] Response Body: ${text}`);
1101
- // console.error(`[beamo-node] Full Request Body (for debugging):`, JSON.stringify(requestBody, null, 2));
1102
- // }
1103
- throw new Error(`Failed to publish manifest: ${response.status} ${text}`);
1104
- }
1105
-
1106
- const responseBody = await response.json();
1107
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
1108
- // console.error(`[beamo-node] Response Body:`, JSON.stringify(responseBody, null, 2));
1109
- // console.error(`[beamo-node] Manifest published successfully`);
1110
- // }
1111
- }
1112
-
1113
- async function prepareDockerContext({ entry, distDir, openapiPath, packageJson, packageLock, nodeVersion }) {
1114
- const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'beam-node-ms-'));
1115
- const contextDir = path.join(tempRoot, 'context');
1116
- const appDir = path.join(contextDir, 'app');
1117
- await fs.mkdir(appDir, { recursive: true });
1118
-
1119
- // Read and modify package.json to handle file: dependencies
1120
- const pkg = JSON.parse(await fs.readFile(packageJson, 'utf8'));
1121
- const modifiedPkg = { ...pkg };
1122
-
1123
- // No need to handle file: dependencies anymore - the runtime is published to npm
1124
- // Just copy the package.json as-is
1125
- await fs.copyFile(packageJson, path.join(appDir, 'package.json'));
1126
-
1127
- try {
1128
- await fs.copyFile(packageLock, path.join(appDir, 'package-lock.json'));
1129
- } catch {
1130
- // ignore missing package-lock
1131
- }
1132
-
1133
- await copyDirectory(distDir, path.join(appDir, 'dist'));
1134
- try {
1135
- await fs.copyFile(openapiPath, path.join(appDir, 'beam_openApi.json'));
1136
- } catch {
1137
- await fs.writeFile(path.join(appDir, 'beam_openApi.json'), '{}\n');
1138
- }
1139
-
1140
- // Copy beam.env file if it exists (for developer-defined environment variables)
1141
- let beamEnvFile = null;
1142
- const beamEnvPath = path.join(path.dirname(packageJson), 'beam.env');
1143
- const beamEnvHiddenPath = path.join(path.dirname(packageJson), '.beam.env');
1144
- try {
1145
- if (await fs.access(beamEnvPath).then(() => true).catch(() => false)) {
1146
- await fs.copyFile(beamEnvPath, path.join(appDir, 'beam.env'));
1147
- beamEnvFile = 'beam.env';
1148
- console.log('Included beam.env file in Docker image');
1149
- } else if (await fs.access(beamEnvHiddenPath).then(() => true).catch(() => false)) {
1150
- await fs.copyFile(beamEnvHiddenPath, path.join(appDir, '.beam.env'));
1151
- beamEnvFile = '.beam.env';
1152
- console.log('Included .beam.env file in Docker image');
1153
- }
1154
- } catch {
1155
- // beam.env is optional, ignore if not found
1156
- }
1157
-
1158
- const dockerfile = `# syntax=docker/dockerfile:1
1159
- ARG NODE_VERSION=${nodeVersion}
1160
- FROM node:${nodeVersion}-alpine
1161
-
1162
- WORKDIR /beam/service
1163
-
1164
- COPY app/package*.json ./
1165
- # Install dependencies (runtime is now on npm, so no special handling needed)
1166
- RUN npm install --omit=dev && npm cache clean --force
1167
-
1168
- # Pre-install OpenTelemetry collector binary and config to avoid runtime download delay (~12 seconds)
1169
- # This is stored in /opt/beam/collectors/ which persists and is checked first by the runtime
1170
- RUN mkdir -p /opt/beam/collectors/1.0.1 && \\
1171
- apk add --no-cache wget gzip && \\
1172
- wget https://collectors.beamable.com/version/1.0.1/collector-linux-amd64.gz -O /tmp/collector.gz && \\
1173
- gunzip /tmp/collector.gz && \\
1174
- mv /tmp/collector /opt/beam/collectors/1.0.1/collector-linux-amd64 && \\
1175
- chmod +x /opt/beam/collectors/1.0.1/collector-linux-amd64 && \\
1176
- wget https://collectors.beamable.com/version/1.0.1/clickhouse-config.yaml.gz -O /tmp/config.gz && \\
1177
- gunzip /tmp/config.gz && \\
1178
- mv /tmp/config /opt/beam/collectors/1.0.1/clickhouse-config.yaml && \\
1179
- rm -f /tmp/collector.gz /tmp/config.gz && \\
1180
- apk del wget gzip
1181
-
1182
- COPY app/dist ./dist
1183
- COPY app/beam_openApi.json ./beam_openApi.json${beamEnvFile ? `\nCOPY app/${beamEnvFile} ./${beamEnvFile}` : ''}
1184
-
1185
- # Expose health check port (matches C# microservice behavior)
1186
- EXPOSE 6565
1187
-
1188
- ENV NODE_ENV=production
1189
-
1190
- # Add startup script to log what's happening and catch errors
1191
- RUN echo '#!/bin/sh' > /beam/service/start.sh && \\
1192
- echo 'echo "Starting Node.js microservice..."' >> /beam/service/start.sh && \\
1193
- echo 'echo "Working directory: $(pwd)"' >> /beam/service/start.sh && \\
1194
- echo 'echo "Node version: $(node --version)"' >> /beam/service/start.sh && \\
1195
- echo 'echo "Files in dist:"' >> /beam/service/start.sh && \\
1196
- echo 'ls -la dist/ || echo "dist directory not found!"' >> /beam/service/start.sh && \\
1197
- echo 'echo "Starting main.js..."' >> /beam/service/start.sh && \\
1198
- echo 'exec node dist/main.js' >> /beam/service/start.sh && \\
1199
- chmod +x /beam/service/start.sh
1200
-
1201
- # Use ENTRYPOINT with startup script to ensure we see what's happening
1202
- ENTRYPOINT ["/beam/service/start.sh"]
1203
-
1204
- # Debug option: uncomment the line below and comment the ENTRYPOINT above
1205
- # to keep the container alive for debugging (like C# Dockerfile does)
1206
- # ENTRYPOINT ["tail", "-f", "/dev/null"]
1207
- `;
1208
- await fs.writeFile(path.join(contextDir, 'Dockerfile'), dockerfile, 'utf8');
1209
-
1210
- return { tempRoot, contextDir };
1211
- }
1212
-
1213
- async function main() {
1214
- // Parse --env-file first so we can load .env before parseArgs reads environment variables
1215
- // This ensures .env file values are used instead of stale system environment variables
1216
- const rawArgs = process.argv.slice(2);
1217
- let envFile = undefined;
1218
- for (let i = 0; i < rawArgs.length; i++) {
1219
- if (rawArgs[i] === '--env-file' && i + 1 < rawArgs.length) {
1220
- envFile = rawArgs[i + 1];
1221
- break;
1222
- }
1223
- }
1224
-
1225
- // Load .env file first with override to ensure it takes precedence
1226
- if (envFile) {
1227
- dotenv.config({ path: path.resolve(envFile), override: true });
1228
- } else if (process.env.npm_config_env_file) {
1229
- dotenv.config({ path: path.resolve(process.env.npm_config_env_file), override: true });
1230
- }
1231
-
1232
- // Now parse all args - env vars from .env will be available
1233
- const args = parseArgs(rawArgs);
1234
-
1235
- const pkg = await readJson(path.resolve('package.json'));
1236
- const beamableConfig = pkg.beamable || {};
1237
-
1238
- const serviceId = args.service || beamableConfig.beamoId || pkg.name;
1239
- ensure(serviceId, 'Service identifier is required. Provide --service or set beamable.beamoId in package.json.');
1240
-
1241
- const cid = ensure(args.cid || beamableConfig.cid || process.env.CID, 'CID is required (set CID env var or --cid).');
1242
- const pid = ensure(args.pid || beamableConfig.pid || process.env.PID, 'PID is required (set PID env var or --pid).');
1243
- const host = args.host || beamableConfig.host || process.env.HOST || 'wss://api.beamable.com/socket';
1244
- const apiHost = normalizeApiHost(args.apiHost || beamableConfig.apiHost || process.env.BEAMABLE_API_HOST || host);
1245
- const token = ensure(args.token || process.env.ACCESS_TOKEN || process.env.BEAMABLE_TOKEN, 'Access token is required (set BEAMABLE_TOKEN env var or --token).');
1246
-
1247
- const configuredGamePid = args.gamePid || beamableConfig.gamePid || process.env.BEAMABLE_GAME_PID;
1248
- const refreshToken = args.refreshToken || process.env.BEAMABLE_REFRESH_TOKEN || process.env.REFRESH_TOKEN;
1249
-
1250
- if (!apiHost) {
1251
- throw new Error('API host could not be determined. Set BEAMABLE_API_HOST or provide --api-host.');
1252
- }
1253
-
1254
- // Initialize progress bar (8 main steps)
1255
- const progress = new ProgressBar(8);
1256
- console.log(`${colors.bright}${colors.cyan}Publishing ${serviceId}...${colors.reset}\n`);
1257
-
1258
- // Step 1: Build
1259
- progress.start('Building project');
1260
- if (!args.skipValidate) {
1261
- const validateScript = path.resolve(__dirname, 'validate-service.mjs');
1262
- const validateArgs = ['--entry', args.entry, '--output', args.openapi, '--cid', cid, '--pid', pid, '--host', host];
1263
- if (args.envFile) {
1264
- validateArgs.push('--env-file', args.envFile);
1265
- }
1266
- if (args.namePrefix) {
1267
- validateArgs.push('--routing-key', args.namePrefix);
1268
- }
1269
- validateArgs.push('--skip-build');
1270
- await runCommand('npm', ['run', 'build'], { silent: true });
1271
- await runCommand(process.execPath, [validateScript, ...validateArgs], { shell: false, silent: true });
1272
- } else {
1273
- await runCommand('npm', ['run', 'build'], { silent: true });
1274
- }
1275
- progress.complete('Build complete');
1276
-
1277
- const packageJsonPath = path.resolve('package.json');
1278
- const packageLockPath = path.resolve('package-lock.json');
1279
- const distDir = path.resolve('dist');
1280
- const openapiPath = path.resolve(args.openapi);
1281
- const entryFile = path.resolve(args.entry);
1282
-
1283
- await fs.access(entryFile);
1284
- await fs.access(distDir);
1285
-
1286
- let tempRoot;
1287
- try {
1288
- // Step 2: Prepare Docker context
1289
- progress.start('Preparing Docker context');
1290
- const context = await prepareDockerContext({
1291
- entry: entryFile,
1292
- distDir,
1293
- openapiPath,
1294
- packageJson: packageJsonPath,
1295
- packageLock: packageLockPath,
1296
- nodeVersion: args.nodeVersion,
1297
- });
1298
- tempRoot = context.tempRoot;
1299
- const { contextDir } = context;
1300
- progress.complete('Docker context prepared');
1301
-
1302
- // Step 3: Build Docker image
1303
- progress.start('Building Docker image');
1304
- const dockerTag = args.dockerTag || `${serviceId.toLowerCase().replace(/[^a-z0-9-_]/g, '-')}:${Date.now()}`;
1305
- await runCommand('docker', ['build', '-t', dockerTag, contextDir], { cwd: contextDir, silent: true });
1306
- progress.complete('Docker image built');
1307
-
1308
- // Step 4: Extract image ID and save
1309
- progress.start('Preparing image for upload');
1310
- const inspect = await runCommand('docker', ['image', 'inspect', '--format', '{{.Id}}', dockerTag], { capture: true });
1311
- const fullImageId = inspect.stdout.trim();
1312
- const imageTarPath = path.join(tempRoot, `${serviceId.replace(/[^a-z0-9-_]/gi, '_')}.tar`);
1313
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
1314
- // // Calculate short digest manually (shortDigest function defined later)
1315
- // const longImageId = fullImageId.includes(':') ? fullImageId.split(':')[1] : fullImageId;
1316
- // const shortImageIdPreview = longImageId.substring(0, 12);
1317
- // console.error(`[beamo-node] [STEP: Extract Image ID]`);
1318
- // console.error(`[beamo-node] Docker Tag: ${dockerTag}`);
1319
- // console.error(`[beamo-node] Full Image ID: ${fullImageId}`);
1320
- // console.error(`[beamo-node] Short Image ID (preview): ${shortImageIdPreview}`);
1321
- // console.error(`[beamo-node] Image Tar Path: ${imageTarPath}`);
1322
- // }
1323
- await runCommand('docker', ['image', 'save', dockerTag, '-o', imageTarPath], { silent: true });
1324
- progress.complete('Image prepared');
1325
-
1326
- // Step 5: Authenticate and get registry
1327
- progress.start('Authenticating');
1328
- const resolvedGamePid = await resolveGamePid(apiHost, token, cid, pid, configuredGamePid);
1329
-
1330
- // Verify token is valid (401 means invalid token, 403 might just be permission issue)
1331
- try {
1332
- const testUrl = new URL('/basic/accounts/me', apiHost);
1333
- const testResponse = await fetch(testUrl, {
1334
- headers: {
1335
- Authorization: `Bearer ${token}`,
1336
- Accept: 'application/json',
1337
- 'X-BEAM-SCOPE': `${cid}.${pid}`,
1338
- },
1339
- });
1340
- if (testResponse.status === 401) {
1341
- throw new Error(`Token validation failed: ${testResponse.status} ${await testResponse.text()}`);
1342
- }
1343
- } catch (error) {
1344
- if (error.message.includes('401')) {
1345
- throw new Error(`Token validation failed: ${error.message}. Please run "beamo-node login" again.`);
1346
- }
1347
- }
1348
-
1349
- // Match C# CLI: GetDockerImageRegistryUri() uses realm PID from context (X-BEAM-SCOPE)
1350
- // uniqueName uses gamePid (matches backend's rc.gameId), but registry URL uses realm PID
1351
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
1352
- // console.error(`[beamo-node] [STEP: Calculate Registry Path]`);
1353
- // console.error(`[beamo-node] CID: ${cid}`);
1354
- // console.error(`[beamo-node] Realm PID: ${pid}`);
1355
- // console.error(`[beamo-node] Resolved Game PID: ${resolvedGamePid}`);
1356
- // console.error(`[beamo-node] Service ID: ${serviceId}`);
1357
- // }
1358
- const registryUrl = await getRegistryUrl(apiHost, token, cid, pid);
1359
- const uniqueNameInput = `${cid}_${resolvedGamePid}_${serviceId}`;
1360
- const uniqueName = md5Hex(uniqueNameInput).substring(0, 30);
1361
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
1362
- // console.error(`[beamo-node] Unique Name Input: ${uniqueNameInput}`);
1363
- // console.error(`[beamo-node] Unique Name (MD5 first 30 chars): ${uniqueName}`);
1364
- // console.error(`[beamo-node] Registry URL: ${registryUrl}`);
1365
- // }
1366
- progress.complete('Authenticated');
1367
-
1368
- // Step 6: Upload Docker image
1369
- progress.start('Uploading Docker image to registry');
1370
- await uploadDockerImage({
1371
- apiHost,
1372
- registryUrl,
1373
- cid,
1374
- pid,
1375
- gamePid: resolvedGamePid,
1376
- token,
1377
- serviceId,
1378
- uniqueName,
1379
- imageTarPath,
1380
- fullImageId,
1381
- progress,
1382
- });
1383
- progress.complete('Image uploaded');
1384
-
1385
- // Verify image exists in registry before proceeding
1386
- const shortImageId = shortDigest(fullImageId);
1387
- const baseUrl = `${registryUrl}${uniqueName}/`;
1388
- const verifyHeaders = {
1389
- 'x-ks-clientid': cid,
1390
- 'x-ks-projectid': pid, // Use realm PID (matches backend's rc.projectId when checking registry)
1391
- 'x-ks-token': token,
1392
- };
1393
-
1394
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
1395
- // console.error(`[beamo-node] [STEP: Post-Upload Verification]`);
1396
- // console.error(`[beamo-node] Full Image ID: ${fullImageId}`);
1397
- // console.error(`[beamo-node] Short Image ID: ${shortImageId}`);
1398
- // console.error(`[beamo-node] Base URL: ${baseUrl}`);
1399
- // console.error(`[beamo-node] Verification URL: ${baseUrl}manifests/${shortImageId}`);
1400
- // console.error(`[beamo-node] Verify Headers:`, JSON.stringify(verifyHeaders, null, 2));
1401
- // }
1402
-
1403
- // Wait a moment for registry to propagate
1404
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
1405
- // console.error(`[beamo-node] Waiting 3 seconds for registry propagation...`);
1406
- // }
1407
- await new Promise(resolve => setTimeout(resolve, 3000));
1408
-
1409
- const imageExists = await verifyManifestExists(baseUrl, shortImageId, verifyHeaders);
1410
- if (!imageExists) {
1411
- throw new Error(`Image verification failed: manifest with tag ${shortImageId} not found in registry at ${baseUrl}manifests/${shortImageId}. The image may not have uploaded successfully.`);
1412
- }
1413
-
1414
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
1415
- // console.error(`[beamo-node] ✓ Image verification passed`);
1416
- // }
1417
-
1418
- // CRITICAL: Perform exact backend check simulation
1419
- // The backend uses HEAD request with specific headers - let's verify it works
1420
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
1421
- // console.error(`[beamo-node] [STEP: Backend Check Simulation]`);
1422
- // console.error(`[beamo-node] Simulating exact backend check...`);
1423
- // const backendCheckUrl = `${registryUrl}${uniqueName}/manifests/${shortImageId}`;
1424
- // console.error(`[beamo-node] Backend will check: ${backendCheckUrl}`);
1425
- // console.error(`[beamo-node] Backend will use headers:`, JSON.stringify(verifyHeaders, null, 2));
1426
- // try {
1427
- // const backendCheckResponse = await fetch(backendCheckUrl, {
1428
- // method: 'HEAD',
1429
- // headers: verifyHeaders,
1430
- // });
1431
- // console.error(`[beamo-node] Backend simulation response status: ${backendCheckResponse.status}`);
1432
- // if (backendCheckResponse.status !== 200) {
1433
- // console.error(`[beamo-node] ⚠️ Backend simulation FAILED - status ${backendCheckResponse.status}`);
1434
- // const responseText = await backendCheckResponse.text().catch(() => '');
1435
- // console.error(`[beamo-node] Response: ${responseText}`);
1436
- // } else {
1437
- // console.error(`[beamo-node] ✓ Backend simulation passed`);
1438
- // }
1439
- // } catch (error) {
1440
- // console.error(`[beamo-node] ⚠️ Backend simulation error: ${error.message}`);
1441
- // }
1442
- // }
1443
-
1444
- // CRITICAL: Before publishing, verify using the backend's expected gameId
1445
- // The backend resolves rc.gameId from the realm hierarchy, which might differ from our resolvedGamePid
1446
- // We need to check what the backend will actually use
1447
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
1448
- // console.error(`[beamo-node] [STEP: Pre-Publish Backend Simulation]`);
1449
- // console.error(`[beamo-node] Simulating backend's imageNameMD5 calculation...`);
1450
- // console.error(`[beamo-node] Backend will use: rc.cid, rc.gameId (resolved from realm hierarchy), serviceName`);
1451
- // console.error(`[beamo-node] Our calculation used: cid=${cid}, gamePid=${resolvedGamePid}, serviceName=${serviceId}`);
1452
- // console.error(`[beamo-node] Our uniqueName: ${uniqueName}`);
1453
- // console.error(`[beamo-node] WARNING: If backend's rc.gameId differs from our resolvedGamePid, the check will fail!`);
1454
- // }
1455
-
1456
- // Try to resolve what the backend's rc.gameId will be by making the same API call the backend would make
1457
- // The backend resolves gameId from the realm hierarchy when processing X-BEAM-SCOPE
1458
- // try {
1459
- // const backendGamePidCheck = await resolveGamePid(apiHost, token, cid, pid, null); // Force resolution
1460
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
1461
- // console.error(`[beamo-node] Backend's expected gameId (resolved from realm hierarchy): ${backendGamePidCheck}`);
1462
- // if (backendGamePidCheck !== resolvedGamePid) {
1463
- // console.error(`[beamo-node] ⚠️ MISMATCH DETECTED!`);
1464
- // console.error(`[beamo-node] Our resolvedGamePid: ${resolvedGamePid}`);
1465
- // console.error(`[beamo-node] Backend's expected gameId: ${backendGamePidCheck}`);
1466
- // console.error(`[beamo-node] This will cause imageNameMD5 mismatch!`);
1467
- // const backendUniqueNameInput = `${cid}_${backendGamePidCheck}_${serviceId}`;
1468
- // const backendUniqueName = md5Hex(backendUniqueNameInput).substring(0, 30);
1469
- // console.error(`[beamo-node] Backend will check: ${registryUrl}${backendUniqueName}/manifests/${shortImageId}`);
1470
- // console.error(`[beamo-node] But we uploaded to: ${registryUrl}${uniqueName}/manifests/${shortImageId}`);
1471
- // } else {
1472
- // console.error(`[beamo-node] ✓ Game PID matches - backend should find the image`);
1473
- // }
1474
- // }
1475
- // } catch (error) {
1476
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
1477
- // console.error(`[beamo-node] Could not verify backend gameId resolution: ${error.message}`);
1478
- // }
1479
- // }
1480
-
1481
- // Step 7: Discover storage, components, and dependencies
1482
- progress.start('Discovering storage objects and components');
1483
- // shortImageId already defined above from verification step
1484
- const existingManifest = await fetchCurrentManifest(apiHost, token, cid, pid);
1485
- const discoveredStorage = await discoverStorageObjects('src', process.cwd());
1486
- const discoveredComponents = await discoverFederationComponents('src');
1487
- // Dependencies are ServiceDependencyReference objects with id and storageType
1488
- // storageType should be "mongov1" for MongoDB storage objects (matching C# microservices)
1489
- const discoveredDependencies = discoveredStorage.map(s => ({
1490
- id: s.id,
1491
- storageType: 'mongov1', // MongoDB storage type (matches ServiceStorageReference in backend)
1492
- }));
1493
-
1494
- if (discoveredStorage.length > 0) {
1495
- console.log(`[beamo-node] Discovered ${discoveredStorage.length} storage object(s): ${discoveredStorage.map(s => s.id).join(', ')}`);
1496
- }
1497
-
1498
- progress.complete('Storage and components discovered');
1499
-
1500
- // Step 8: Publish manifest
1501
- progress.start('Publishing manifest');
1502
- await updateManifest({
1503
- apiHost,
1504
- token,
1505
- cid,
1506
- pid,
1507
- serviceId,
1508
- shortImageId,
1509
- comments: args.comments,
1510
- existingManifest,
1511
- discoveredStorage,
1512
- discoveredComponents,
1513
- discoveredDependencies,
1514
- });
1515
- progress.complete('Manifest published');
1516
-
1517
- // Success message
1518
- console.log(`\n${colors.green}${colors.bright}✓ Publish complete!${colors.reset}`);
1519
- console.log(`${colors.dim} Service:${colors.reset} ${serviceId}`);
1520
- console.log(`${colors.dim} Image ID:${colors.reset} ${fullImageId}`);
1521
- console.log(`${colors.dim} Registry:${colors.reset} ${registryUrl}${uniqueName}`);
1522
- } finally {
1523
- if (tempRoot) {
1524
- await fs.rm(tempRoot, { recursive: true, force: true });
1525
- }
1526
- }
1527
- }
1528
-
1529
- main().catch(async (error) => {
1530
- // Show clean error message
1531
- console.error(`\n${colors.red}${colors.bright}✗ Publish failed${colors.reset}`);
1532
- if (error instanceof Error) {
1533
- console.error(`${colors.red}${error.message}${colors.reset}`);
1534
- // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
1535
- // console.error(`\n${colors.dim}Stack:${colors.reset} ${error.stack}`);
1536
- // if (error.cause) {
1537
- // console.error(`${colors.dim}Cause:${colors.reset} ${error.cause}`);
1538
- // }
1539
- // if (error.stdout) {
1540
- // console.error(`${colors.dim}stdout:${colors.reset} ${error.stdout}`);
1541
- // }
1542
- // if (error.stderr) {
1543
- // console.error(`${colors.dim}stderr:${colors.reset} ${error.stderr}`);
1544
- // }
1545
- // console.error(`\n${colors.dim}Full error:${colors.reset}`, JSON.stringify(error, Object.getOwnPropertyNames(error), 2));
1546
- // }
1547
- } else {
1548
- console.error(`${colors.red}${error}${colors.reset}`);
1549
- }
1550
- process.exit(1);
1551
- });
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs/promises';
4
+ import fssync from 'node:fs';
5
+ import path from 'node:path';
6
+ import os from 'node:os';
7
+ import crypto from 'node:crypto';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { fetch } from 'undici';
10
+ import tar from 'tar-stream';
11
+ import dotenv from 'dotenv';
12
+ import { runCommand } from './lib/cli-utils.mjs';
13
+
14
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
+ const DEFAULT_NODE_VERSION = '20';
16
+ const MANIFEST_MEDIA_TYPE = 'application/vnd.docker.distribution.manifest.v2+json';
17
+ const CONFIG_MEDIA_TYPE = 'application/vnd.docker.container.image.v1+json';
18
+ const LAYER_MEDIA_TYPE = 'application/vnd.docker.image.rootfs.diff.tar.gzip';
19
+
20
+ // ANSI color codes for colorful output
21
+ const colors = {
22
+ reset: '\x1b[0m',
23
+ bright: '\x1b[1m',
24
+ dim: '\x1b[2m',
25
+ green: '\x1b[32m',
26
+ yellow: '\x1b[33m',
27
+ blue: '\x1b[34m',
28
+ magenta: '\x1b[35m',
29
+ cyan: '\x1b[36m',
30
+ red: '\x1b[31m',
31
+ };
32
+
33
+ // Progress bar manager
34
+ class ProgressBar {
35
+ constructor(totalSteps) {
36
+ this.totalSteps = totalSteps;
37
+ this.currentStep = 0;
38
+ this.currentTask = '';
39
+ this.startTime = Date.now();
40
+ }
41
+
42
+ start(task) {
43
+ this.currentTask = task;
44
+ this.currentStep++;
45
+ this.update();
46
+ }
47
+
48
+ complete(task) {
49
+ this.currentTask = task;
50
+ this.update();
51
+ process.stdout.write('\n');
52
+ }
53
+
54
+ update() {
55
+ const percentage = Math.round((this.currentStep / this.totalSteps) * 100);
56
+ const barWidth = 30;
57
+ const filled = Math.round((percentage / 100) * barWidth);
58
+ const empty = barWidth - filled;
59
+ const bar = '█'.repeat(filled) + '░'.repeat(empty);
60
+
61
+ const elapsed = ((Date.now() - this.startTime) / 1000).toFixed(1);
62
+
63
+ // Clear line and write progress
64
+ process.stdout.write(`\r${colors.cyan}${bar}${colors.reset} ${colors.bright}${percentage}%${colors.reset} ${colors.dim}${this.currentTask}${colors.reset} ${colors.dim}(${elapsed}s)${colors.reset}`);
65
+ }
66
+
67
+ success(message) {
68
+ process.stdout.write(`\r${colors.green}✓${colors.reset} ${message}\n`);
69
+ }
70
+
71
+ info(message) {
72
+ process.stdout.write(`\r${colors.blue}ℹ${colors.reset} ${message}\n`);
73
+ }
74
+
75
+ error(message) {
76
+ process.stdout.write(`\r${colors.red}✗${colors.reset} ${message}\n`);
77
+ }
78
+ }
79
+
80
+ function parseArgs(argv) {
81
+ const args = {
82
+ entry: 'dist/main.js',
83
+ openapi: 'beam_openApi.json',
84
+ envFile: undefined,
85
+ cid: process.env.BEAMABLE_CID || process.env.CID,
86
+ pid: process.env.BEAMABLE_PID || process.env.PID,
87
+ host: process.env.BEAMABLE_HOST || process.env.HOST,
88
+ namePrefix: process.env.BEAMABLE_NAME_PREFIX || process.env.NAME_PREFIX,
89
+ token: process.env.BEAMABLE_TOKEN || process.env.BEAMABLE_ACCESS_TOKEN || process.env.ACCESS_TOKEN,
90
+ gamePid: process.env.BEAMABLE_GAME_PID,
91
+ comments: process.env.BEAMABLE_PUBLISH_COMMENTS,
92
+ service: process.env.BEAMABLE_SERVICE_ID,
93
+ dockerTag: process.env.BEAMABLE_DOCKER_TAG,
94
+ nodeVersion: process.env.BEAMABLE_NODE_VERSION || DEFAULT_NODE_VERSION,
95
+ skipValidate: false,
96
+ apiHost: process.env.BEAMABLE_API_HOST,
97
+ };
98
+
99
+ const queue = [...argv];
100
+ while (queue.length > 0) {
101
+ const current = queue.shift();
102
+ switch (current) {
103
+ case '--entry':
104
+ args.entry = queue.shift();
105
+ break;
106
+ case '--openapi':
107
+ args.openapi = queue.shift();
108
+ break;
109
+ case '--env-file':
110
+ args.envFile = queue.shift();
111
+ break;
112
+ case '--cid':
113
+ args.cid = queue.shift();
114
+ break;
115
+ case '--pid':
116
+ args.pid = queue.shift();
117
+ break;
118
+ case '--host':
119
+ args.host = queue.shift();
120
+ break;
121
+ case '--routing-key':
122
+ case '--name-prefix':
123
+ args.namePrefix = queue.shift();
124
+ break;
125
+ case '--token':
126
+ args.token = queue.shift();
127
+ break;
128
+ case '--game-pid':
129
+ args.gamePid = queue.shift();
130
+ break;
131
+ case '--comments':
132
+ args.comments = queue.shift();
133
+ break;
134
+ case '--service':
135
+ args.service = queue.shift();
136
+ break;
137
+ case '--docker-tag':
138
+ args.dockerTag = queue.shift();
139
+ break;
140
+ case '--node-version':
141
+ args.nodeVersion = queue.shift();
142
+ break;
143
+ case '--skip-validate':
144
+ args.skipValidate = true;
145
+ break;
146
+ case '--api-host':
147
+ args.apiHost = queue.shift();
148
+ break;
149
+ default:
150
+ throw new Error(`Unknown argument: ${current}`);
151
+ }
152
+ }
153
+
154
+ if (process.env.npm_config_env_file) {
155
+ args.envFile = process.env.npm_config_env_file;
156
+ }
157
+
158
+ return args;
159
+ }
160
+
161
+ function ensure(value, message) {
162
+ if (!value) {
163
+ throw new Error(message);
164
+ }
165
+ return value;
166
+ }
167
+
168
+ function md5Hex(input) {
169
+ return crypto.createHash('md5').update(input).digest('hex');
170
+ }
171
+
172
+ function sha256Digest(buffer) {
173
+ return `sha256:${crypto.createHash('sha256').update(buffer).digest('hex')}`;
174
+ }
175
+
176
+ function shortDigest(fullDigest) {
177
+ if (!fullDigest) {
178
+ return '';
179
+ }
180
+ const parts = fullDigest.split(':');
181
+ const hash = parts.length > 1 ? parts[1] : parts[0];
182
+ return hash.substring(0, 12);
183
+ }
184
+
185
+ function normalizeApiHost(host) {
186
+ if (!host) {
187
+ return undefined;
188
+ }
189
+ if (host.startsWith('wss://')) {
190
+ return `https://${host.substring('wss://'.length).replace(/\/socket$/, '')}`;
191
+ }
192
+ if (host.startsWith('ws://')) {
193
+ return `http://${host.substring('ws://'.length).replace(/\/socket$/, '')}`;
194
+ }
195
+ return host.replace(/\/$/, '');
196
+ }
197
+
198
+ async function readJson(filePath) {
199
+ const content = await fs.readFile(filePath, 'utf8');
200
+ return JSON.parse(content);
201
+ }
202
+
203
+ async function copyDirectory(source, destination) {
204
+ const entries = await fs.readdir(source, { withFileTypes: true });
205
+ await fs.mkdir(destination, { recursive: true });
206
+ for (const entry of entries) {
207
+ const srcPath = path.join(source, entry.name);
208
+ const destPath = path.join(destination, entry.name);
209
+ if (entry.isDirectory()) {
210
+ await copyDirectory(srcPath, destPath);
211
+ } else if (entry.isFile()) {
212
+ await fs.copyFile(srcPath, destPath);
213
+ }
214
+ }
215
+ }
216
+
217
+ async function readDockerImageTar(tarPath) {
218
+ const files = new Map();
219
+ const extract = tar.extract();
220
+
221
+ await new Promise((resolve, reject) => {
222
+ extract.on('entry', (header, stream, next) => {
223
+ const chunks = [];
224
+ stream.on('data', (chunk) => chunks.push(chunk));
225
+ stream.on('end', () => {
226
+ files.set(header.name, Buffer.concat(chunks));
227
+ next();
228
+ });
229
+ stream.on('error', reject);
230
+ });
231
+ extract.on('finish', resolve);
232
+ extract.on('error', reject);
233
+
234
+ fssync.createReadStream(tarPath).pipe(extract);
235
+ });
236
+
237
+ const manifestBuffer = files.get('manifest.json');
238
+ if (!manifestBuffer) {
239
+ throw new Error('Docker image archive missing manifest.json');
240
+ }
241
+
242
+ const manifestJson = JSON.parse(manifestBuffer.toString());
243
+ if (!Array.isArray(manifestJson) || manifestJson.length === 0) {
244
+ throw new Error('Unexpected manifest.json structure.');
245
+ }
246
+
247
+ const manifestEntry = manifestJson[0];
248
+ const configName = manifestEntry.Config || manifestEntry.config;
249
+ const layerNames = manifestEntry.Layers || manifestEntry.layers;
250
+
251
+ if (!configName || !layerNames) {
252
+ throw new Error('Manifest entry missing Config or Layers.');
253
+ }
254
+
255
+ const configBuffer = files.get(configName);
256
+ if (!configBuffer) {
257
+ throw new Error(`Config blob missing in archive: ${configName}`);
258
+ }
259
+
260
+ const layers = layerNames.map((layerName) => {
261
+ const buffer = files.get(layerName);
262
+ if (!buffer) {
263
+ throw new Error(`Layer missing in archive: ${layerName}`);
264
+ }
265
+ return { name: layerName, buffer };
266
+ });
267
+
268
+ return { manifestEntry, configBuffer, layers };
269
+ }
270
+
271
+ async function checkBlobExists(baseUrl, digest, headers) {
272
+ const url = new URL(`blobs/${digest}`, baseUrl);
273
+ const response = await fetch(url, { method: 'HEAD', headers, redirect: 'manual' });
274
+
275
+ if (response.status === 307 && response.headers.get('location')) {
276
+ const redirected = response.headers.get('location');
277
+ const nextBase = redirected.startsWith('http') ? redirected : new URL(redirected, baseUrl).href;
278
+ return checkBlobExists(nextBase, digest, headers);
279
+ }
280
+
281
+ return response.status === 200;
282
+ }
283
+
284
+ async function verifyManifestExists(baseUrl, tag, headers) {
285
+ const url = new URL(`manifests/${tag}`, baseUrl);
286
+ const response = await fetch(url, { method: 'HEAD', headers, redirect: 'manual' });
287
+
288
+ if (response.status === 307 && response.headers.get('location')) {
289
+ const redirected = response.headers.get('location');
290
+ const nextBase = redirected.startsWith('http') ? redirected : new URL(redirected, baseUrl).href;
291
+ return verifyManifestExists(nextBase, tag, headers);
292
+ }
293
+
294
+ return response.status === 200;
295
+ }
296
+
297
+ async function prepareUploadLocation(baseUrl, headers) {
298
+ const url = new URL('blobs/uploads/', baseUrl);
299
+ // Match C# CLI exactly: StringContent("") sets Content-Type and Content-Length
300
+ const requestHeaders = {
301
+ ...headers,
302
+ 'Content-Type': 'text/plain; charset=utf-8',
303
+ 'Content-Length': '0',
304
+ };
305
+
306
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
307
+ // console.error(`[beamo-node] [SUBSTEP: Prepare Upload Location]`);
308
+ // console.error(`[beamo-node] URL: ${url}`);
309
+ // console.error(`[beamo-node] Method: POST`);
310
+ // console.error(`[beamo-node] Headers:`, JSON.stringify(requestHeaders, null, 2));
311
+ // }
312
+
313
+ let response;
314
+ try {
315
+ response = await fetch(url, {
316
+ method: 'POST',
317
+ headers: requestHeaders,
318
+ body: '', // Empty body
319
+ });
320
+ } catch (error) {
321
+ // Network/SSL errors happen before HTTP response
322
+ const errorMsg = error instanceof Error ? error.message : String(error);
323
+ const errorDetails = {
324
+ url: url.toString(),
325
+ error: errorMsg,
326
+ ...(error instanceof Error && error.stack ? { stack: error.stack } : {}),
327
+ ...(error instanceof Error && error.cause ? { cause: error.cause } : {}),
328
+ };
329
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
330
+ // console.error('[beamo-node] Network error:', errorDetails);
331
+ // }
332
+ throw new Error(`Network error preparing upload location: ${errorMsg}. URL: ${url.toString()}`);
333
+ }
334
+
335
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
336
+ // console.error(`[beamo-node] Response Status: ${response.status}`);
337
+ // console.error(`[beamo-node] Response Headers:`, JSON.stringify(Object.fromEntries(response.headers.entries()), null, 2));
338
+ // }
339
+
340
+ if (!response.ok) {
341
+ const text = await response.text();
342
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
343
+ // console.error('[beamo-node] Upload location failed', {
344
+ // status: response.status,
345
+ // statusText: response.statusText,
346
+ // headers: Object.fromEntries(response.headers.entries()),
347
+ // body: text.substring(0, 500),
348
+ // });
349
+ // }
350
+ throw new Error(`Failed to prepare upload location: ${response.status} ${text}`);
351
+ }
352
+ const location = response.headers.get('location');
353
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
354
+ // console.error(`[beamo-node] Upload Location: ${location}`);
355
+ // }
356
+ return location;
357
+ }
358
+
359
+ async function uploadBlob(baseUrl, digest, buffer, headers) {
360
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
361
+ // console.error(`[beamo-node] [SUBSTEP: Upload Blob]`);
362
+ // console.error(`[beamo-node] Digest: ${digest}`);
363
+ // console.error(`[beamo-node] Size: ${buffer.length} bytes`);
364
+ // }
365
+
366
+ const exists = await checkBlobExists(baseUrl, digest, headers);
367
+ if (exists) {
368
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
369
+ // console.error(`[beamo-node] Blob already exists, skipping upload`);
370
+ // }
371
+ return { digest, size: buffer.length };
372
+ }
373
+
374
+ const location = await prepareUploadLocation(baseUrl, headers);
375
+ if (!location) {
376
+ throw new Error('Registry did not provide an upload location.');
377
+ }
378
+
379
+ // Match C# CLI: NormalizeWithDigest forces HTTPS and default port (-1 means use default for scheme)
380
+ const locationUrl = new URL(location.startsWith('http') ? location : new URL(location, baseUrl).href);
381
+ // Force HTTPS and remove explicit port (use default port 443 for HTTPS)
382
+ locationUrl.protocol = 'https:';
383
+ locationUrl.port = ''; // Empty string means use default port for the scheme
384
+ locationUrl.searchParams.set('digest', digest);
385
+ const uploadUrl = locationUrl;
386
+
387
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
388
+ // console.error(`[beamo-node] Upload URL: ${uploadUrl}`);
389
+ // console.error(`[beamo-node] Method: PUT`);
390
+ // const uploadHeaders = { ...headers, 'Content-Type': 'application/octet-stream' };
391
+ // console.error(`[beamo-node] Upload Headers:`, JSON.stringify(uploadHeaders, null, 2));
392
+ // }
393
+
394
+ const response = await fetch(uploadUrl, {
395
+ method: 'PUT',
396
+ headers: { ...headers, 'Content-Type': 'application/octet-stream' },
397
+ body: buffer,
398
+ });
399
+
400
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
401
+ // console.error(`[beamo-node] Response Status: ${response.status}`);
402
+ // console.error(`[beamo-node] Response Headers:`, JSON.stringify(Object.fromEntries(response.headers.entries()), null, 2));
403
+ // }
404
+
405
+ if (!response.ok) {
406
+ const text = await response.text();
407
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
408
+ // console.error(`[beamo-node] Response Body: ${text.substring(0, 500)}`);
409
+ // }
410
+ throw new Error(`Failed to upload blob ${digest}: ${response.status} ${text}`);
411
+ }
412
+
413
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
414
+ // console.error(`[beamo-node] Blob upload successful`);
415
+ // }
416
+
417
+ return { digest, size: buffer.length };
418
+ }
419
+
420
+ async function uploadManifest(baseUrl, manifestJson, shortImageId, headers) {
421
+ // Match C# CLI: upload manifest using the short imageId as the tag
422
+ // The backend looks up images using this short imageId tag
423
+ const manifestJsonString = JSON.stringify(manifestJson);
424
+ const url = new URL(`manifests/${shortImageId}`, baseUrl);
425
+ const requestHeaders = { ...headers, 'Content-Type': MANIFEST_MEDIA_TYPE };
426
+
427
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
428
+ // console.error(`[beamo-node] [SUBSTEP: Upload Manifest to Registry]`);
429
+ // console.error(`[beamo-node] URL: ${url}`);
430
+ // console.error(`[beamo-node] Method: PUT`);
431
+ // console.error(`[beamo-node] Tag: ${shortImageId}`);
432
+ // console.error(`[beamo-node] Headers:`, JSON.stringify(requestHeaders, null, 2));
433
+ // console.error(`[beamo-node] Manifest JSON:`, manifestJsonString);
434
+ // }
435
+
436
+ const response = await fetch(url, {
437
+ method: 'PUT',
438
+ headers: requestHeaders,
439
+ body: manifestJsonString,
440
+ });
441
+
442
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
443
+ // console.error(`[beamo-node] Response Status: ${response.status}`);
444
+ // console.error(`[beamo-node] Response Headers:`, JSON.stringify(Object.fromEntries(response.headers.entries()), null, 2));
445
+ // }
446
+
447
+ if (!response.ok) {
448
+ const text = await response.text();
449
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
450
+ // console.error(`[beamo-node] Response Body: ${text.substring(0, 500)}`);
451
+ // }
452
+ throw new Error(`Failed to upload manifest: ${response.status} ${text}`);
453
+ }
454
+
455
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
456
+ // console.error(`[beamo-node] Manifest upload successful`);
457
+ // }
458
+ }
459
+
460
+ async function fetchJson(url, options = {}) {
461
+ const response = await fetch(url, options);
462
+ if (!response.ok) {
463
+ const text = await response.text();
464
+ const error = new Error(`Request failed ${response.status}: ${text}`);
465
+ error.status = response.status;
466
+ throw error;
467
+ }
468
+ return response.json();
469
+ }
470
+
471
+ async function resolveGamePid(apiHost, token, cid, pid, explicitGamePid) {
472
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
473
+ // console.error(`[beamo-node] [STEP: Resolve Game PID]`);
474
+ // console.error(`[beamo-node] Explicit Game PID: ${explicitGamePid || '(none)'}`);
475
+ // console.error(`[beamo-node] Realm PID: ${pid}`);
476
+ // console.error(`[beamo-node] NOTE: Always resolving root project (Game ID) from API, ignoring explicit value`);
477
+ // }
478
+
479
+ // Always resolve the root project from the API (matching C# CLI's FindRoot().Pid)
480
+ // The explicit game PID might be incorrect (could be realm PID instead of root)
481
+ const scope = pid ? `${cid}.${pid}` : cid;
482
+ try {
483
+ const url = new URL(`/basic/realms/game`, apiHost);
484
+ url.searchParams.set('rootPID', pid);
485
+ const requestHeaders = {
486
+ Authorization: `Bearer ${token}`,
487
+ Accept: 'application/json',
488
+ ...(scope ? { 'X-BEAM-SCOPE': scope } : {}),
489
+ };
490
+
491
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
492
+ // console.error(`[beamo-node] Fetching game PID from: ${url}`);
493
+ // console.error(`[beamo-node] Headers:`, JSON.stringify(requestHeaders, null, 2));
494
+ // }
495
+
496
+ const body = await fetchJson(url, { headers: requestHeaders });
497
+
498
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
499
+ // console.error(`[beamo-node] Response:`, JSON.stringify(body, null, 2));
500
+ // }
501
+
502
+ const projects = Array.isArray(body?.projects) ? body.projects : [];
503
+ if (projects.length === 0) {
504
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
505
+ // console.error(`[beamo-node] No projects found, using realm PID: ${pid}`);
506
+ // }
507
+ return pid;
508
+ }
509
+
510
+ // Match C# CLI FindRoot() logic: walk up parent chain until Parent == null (root)
511
+ const byPid = new Map(projects.map((project) => [project.pid, project]));
512
+ let current = byPid.get(pid);
513
+ if (!current) {
514
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
515
+ // console.error(`[beamo-node] Realm PID not found in projects, using realm PID: ${pid}`);
516
+ // }
517
+ return pid;
518
+ }
519
+
520
+ const visited = new Set();
521
+ // Walk up parent chain until we find root (parent == null or isRoot == true)
522
+ while (current && (current.parent != null && current.parent !== '' && !current.isRoot) && !visited.has(current.parent)) {
523
+ visited.add(current.pid);
524
+ current = byPid.get(current.parent);
525
+ if (!current) {
526
+ // Parent not found in projects list, current is as far as we can go
527
+ break;
528
+ }
529
+ }
530
+ // current is now the root (or the original if no parent chain)
531
+ const resolved = current?.pid ?? pid;
532
+
533
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
534
+ // console.error(`[beamo-node] Resolved Game PID (root): ${resolved}`);
535
+ // if (explicitGamePid && explicitGamePid !== resolved) {
536
+ // console.error(`[beamo-node] ⚠️ WARNING: Explicit Game PID (${explicitGamePid}) does not match resolved root (${resolved})`);
537
+ // console.error(`[beamo-node] Using resolved root (${resolved}) for imageNameMD5 calculation`);
538
+ // }
539
+ // }
540
+ return resolved;
541
+ } catch (error) {
542
+ // Debug logging only
543
+ return pid;
544
+ }
545
+ }
546
+
547
+ async function getScopedAccessToken(apiHost, cid, pid, refreshToken, fallbackToken) {
548
+ const scope = pid ? `${cid}.${pid}` : cid;
549
+ if (!refreshToken) {
550
+ return { accessToken: fallbackToken, refreshToken };
551
+ }
552
+
553
+ try {
554
+ const response = await fetch(new URL('/basic/auth/token', apiHost), {
555
+ method: 'POST',
556
+ headers: {
557
+ Accept: 'application/json',
558
+ 'Content-Type': 'application/json',
559
+ ...(scope ? { 'X-BEAM-SCOPE': scope } : {}),
560
+ },
561
+ body: JSON.stringify({
562
+ grant_type: 'refresh_token',
563
+ refresh_token: refreshToken,
564
+ }),
565
+ });
566
+
567
+ if (!response.ok) {
568
+ const text = await response.text();
569
+ throw new Error(`Refresh token request failed: ${response.status} ${text}`);
570
+ }
571
+
572
+ const body = await response.json();
573
+ const accessToken = body.access_token ?? fallbackToken;
574
+ const nextRefresh = body.refresh_token ?? refreshToken;
575
+ return { accessToken, refreshToken: nextRefresh };
576
+ } catch (error) {
577
+ // Debug logging only
578
+ return { accessToken: fallbackToken, refreshToken };
579
+ }
580
+ }
581
+
582
+ async function getRegistryUrl(apiHost, token, cid, pid) {
583
+ const scope = pid ? `${cid}.${pid}` : cid;
584
+ const url = new URL('/basic/beamo/registry', apiHost);
585
+ const headers = {
586
+ Authorization: `Bearer ${token}`,
587
+ Accept: 'application/json',
588
+ ...(scope ? { 'X-BEAM-SCOPE': scope } : {}),
589
+ };
590
+
591
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
592
+ // console.error(`[beamo-node] [STEP: Get Registry URL]`);
593
+ // console.error(`[beamo-node] URL: ${url}`);
594
+ // console.error(`[beamo-node] Headers:`, JSON.stringify(headers, null, 2));
595
+ // console.error(`[beamo-node] Scope: ${scope}`);
596
+ // }
597
+
598
+ const body = await fetchJson(url, { headers });
599
+
600
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
601
+ // console.error(`[beamo-node] Response:`, JSON.stringify(body, null, 2));
602
+ // }
603
+
604
+ const uri = body.uri || body.registry || body.url;
605
+ if (!uri) {
606
+ throw new Error('Registry URI response missing "uri" field.');
607
+ }
608
+ // Match C# CLI exactly: GetDockerImageRegistryUri() returns scheme://host/v2/ (Host property strips port)
609
+ const normalized = uri.includes('://') ? uri : `https://${uri}`;
610
+ const parsed = new URL(normalized);
611
+ // parsedUri.Host in C# is just the hostname (no port), so we use hostname here
612
+ const registryUrl = `${parsed.protocol}//${parsed.hostname}/v2/`;
613
+
614
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
615
+ // console.error(`[beamo-node] Normalized Registry URL: ${registryUrl}`);
616
+ // }
617
+
618
+ return registryUrl;
619
+ }
620
+
621
+ async function uploadDockerImage({
622
+ apiHost,
623
+ registryUrl,
624
+ cid,
625
+ pid,
626
+ gamePid,
627
+ token,
628
+ serviceId,
629
+ uniqueName,
630
+ imageTarPath,
631
+ fullImageId,
632
+ progress,
633
+ }) {
634
+ const baseUrl = `${registryUrl}${uniqueName}/`;
635
+ // Match C# CLI and backend: use realm PID in headers (backend checks registry using rc.projectId)
636
+ // uniqueName uses gamePid (matches backend's rc.gameId), but headers use realm PID
637
+ const headers = {
638
+ 'x-ks-clientid': cid,
639
+ 'x-ks-projectid': pid, // Use realm PID (matches backend's rc.projectId in DockerRegistryClient)
640
+ 'x-ks-token': token, // Access token from login
641
+ };
642
+
643
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
644
+ // console.error(`[beamo-node] [STEP: Upload Docker Image]`);
645
+ // console.error(`[beamo-node] Base URL: ${baseUrl}`);
646
+ // console.error(`[beamo-node] Registry URL: ${registryUrl}`);
647
+ // console.error(`[beamo-node] Unique Name: ${uniqueName}`);
648
+ // console.error(`[beamo-node] Service ID: ${serviceId}`);
649
+ // console.error(`[beamo-node] CID: ${cid}`);
650
+ // console.error(`[beamo-node] Realm PID: ${pid}`);
651
+ // console.error(`[beamo-node] Game PID: ${gamePid}`);
652
+ // console.error(`[beamo-node] Full Image ID: ${fullImageId}`);
653
+ // console.error(`[beamo-node] Upload Headers:`, JSON.stringify(headers, null, 2));
654
+ // }
655
+
656
+ const { manifestEntry, configBuffer, layers } = await readDockerImageTar(imageTarPath);
657
+
658
+ // Upload config
659
+ if (progress) {
660
+ process.stdout.write(`\r${colors.blue}↑${colors.reset} Uploading config...`);
661
+ }
662
+ const configDigestValue = sha256Digest(configBuffer);
663
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
664
+ // console.error(`[beamo-node] [SUBSTEP: Upload Config]`);
665
+ // console.error(`[beamo-node] Config Digest: ${configDigestValue}`);
666
+ // console.error(`[beamo-node] Config Size: ${configBuffer.length} bytes`);
667
+ // }
668
+ const configDigest = await uploadBlob(baseUrl, configDigestValue, configBuffer, headers);
669
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
670
+ // console.error(`[beamo-node] Config Upload Result:`, JSON.stringify(configDigest, null, 2));
671
+ // }
672
+
673
+ // Upload layers with progress
674
+ const layerDescriptors = [];
675
+ const totalLayers = layers.length;
676
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
677
+ // console.error(`[beamo-node] [SUBSTEP: Upload Layers]`);
678
+ // console.error(`[beamo-node] Total Layers: ${totalLayers}`);
679
+ // }
680
+ for (let i = 0; i < layers.length; i++) {
681
+ if (progress) {
682
+ process.stdout.write(`\r${colors.blue}↑${colors.reset} Uploading layers (${i + 1}/${totalLayers})...`);
683
+ }
684
+ const layerDigestValue = sha256Digest(layers[i].buffer);
685
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
686
+ // console.error(`[beamo-node] Layer ${i + 1}/${totalLayers}:`);
687
+ // console.error(`[beamo-node] Digest: ${layerDigestValue}`);
688
+ // console.error(`[beamo-node] Size: ${layers[i].buffer.length} bytes`);
689
+ // }
690
+ const descriptor = await uploadBlob(baseUrl, layerDigestValue, layers[i].buffer, headers);
691
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
692
+ // console.error(`[beamo-node] Upload Result:`, JSON.stringify(descriptor, null, 2));
693
+ // }
694
+ layerDescriptors.push({
695
+ digest: descriptor.digest,
696
+ size: descriptor.size,
697
+ mediaType: LAYER_MEDIA_TYPE,
698
+ });
699
+ }
700
+
701
+ const uploadManifestJson = {
702
+ schemaVersion: 2,
703
+ mediaType: MANIFEST_MEDIA_TYPE,
704
+ config: {
705
+ mediaType: CONFIG_MEDIA_TYPE,
706
+ digest: configDigest.digest,
707
+ size: configDigest.size,
708
+ },
709
+ layers: layerDescriptors,
710
+ };
711
+
712
+ // Upload manifest using short imageId as tag (matching C# CLI behavior)
713
+ if (progress) {
714
+ process.stdout.write(`\r${colors.blue}↑${colors.reset} Uploading manifest...`);
715
+ }
716
+ const shortImageId = shortDigest(fullImageId);
717
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
718
+ // console.error(`[beamo-node] [SUBSTEP: Upload Manifest]`);
719
+ // console.error(`[beamo-node] Short Image ID: ${shortImageId}`);
720
+ // console.error(`[beamo-node] Manifest JSON:`, JSON.stringify(uploadManifestJson, null, 2));
721
+ // }
722
+ await uploadManifest(baseUrl, uploadManifestJson, shortImageId, headers);
723
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
724
+ // console.error(`[beamo-node] Manifest Upload Complete`);
725
+ // }
726
+ if (progress) {
727
+ process.stdout.write('\r');
728
+ }
729
+ }
730
+
731
+ async function fetchCurrentManifest(apiHost, token, cid, pid) {
732
+ const response = await fetch(new URL('/api/beamo/manifests/current', apiHost), {
733
+ headers: {
734
+ Authorization: `Bearer ${token}`,
735
+ Accept: 'application/json',
736
+ 'X-BEAM-SCOPE': `${cid}.${pid}`,
737
+ },
738
+ });
739
+ if (response.status === 404) {
740
+ // No existing manifest (first publish) - return null
741
+ return null;
742
+ }
743
+ if (!response.ok) {
744
+ const text = await response.text();
745
+ throw new Error(`Failed to fetch current manifest: ${response.status} ${text}`);
746
+ }
747
+ return response.json();
748
+ }
749
+
750
+ async function discoverStorageObjects(srcDir, cwd = process.cwd()) {
751
+ const storageObjects = [];
752
+ try {
753
+ // Resolve src path relative to current working directory (where publish is run from)
754
+ const srcPath = path.isAbsolute(srcDir) ? srcDir : path.resolve(cwd, srcDir || 'src');
755
+
756
+ console.log(`[beamo-node] Searching for @StorageObject decorators in: ${srcPath}`);
757
+
758
+ // Check if directory exists
759
+ try {
760
+ const stats = await fs.stat(srcPath);
761
+ if (!stats.isDirectory()) {
762
+ console.warn(`[beamo-node] Warning: ${srcPath} is not a directory`);
763
+ return storageObjects;
764
+ }
765
+ } catch (error) {
766
+ console.warn(`[beamo-node] Warning: Directory ${srcPath} does not exist`);
767
+ return storageObjects;
768
+ }
769
+
770
+ const files = await getAllTypeScriptFiles(srcPath);
771
+
772
+ if (files.length === 0) {
773
+ console.warn(`[beamo-node] Warning: No TypeScript files found in ${srcPath}`);
774
+ return storageObjects;
775
+ }
776
+
777
+ console.log(`[beamo-node] Scanning ${files.length} TypeScript file(s) for @StorageObject decorators...`);
778
+
779
+ for (const file of files) {
780
+ const content = await fs.readFile(file, 'utf-8');
781
+ // Match @StorageObject('StorageName') pattern - handle both single and double quotes
782
+ // Also match multiline patterns where decorator might be on a different line
783
+ const storageRegex = /@StorageObject\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
784
+ let match;
785
+ while ((match = storageRegex.exec(content)) !== null) {
786
+ const storageName = match[1];
787
+ if (storageName && !storageObjects.find(s => s.id === storageName)) {
788
+ storageObjects.push({
789
+ id: storageName,
790
+ enabled: true,
791
+ templateId: 'small', // Match C# CLI default (DeploymentService.cs line 1620)
792
+ checksum: null,
793
+ archived: false,
794
+ });
795
+ console.log(`[beamo-node] ✓ Discovered storage object: ${storageName} (from ${path.relative(cwd, file)})`);
796
+ }
797
+ }
798
+ }
799
+
800
+ if (storageObjects.length === 0) {
801
+ console.log(`[beamo-node] No @StorageObject decorators found in ${files.length} file(s) in ${srcPath}`);
802
+ console.log(`[beamo-node] Make sure your storage classes are decorated with @StorageObject('StorageName')`);
803
+ }
804
+ } catch (error) {
805
+ console.warn(`[beamo-node] Error discovering storage objects: ${error instanceof Error ? error.message : String(error)}`);
806
+ if (error instanceof Error && error.stack) {
807
+ console.warn(`[beamo-node] Stack: ${error.stack}`);
808
+ }
809
+ // If we can't discover storage, that's okay - we'll just use existing ones
810
+ }
811
+ return storageObjects;
812
+ }
813
+
814
+ async function discoverFederationComponents(srcDir) {
815
+ const components = [];
816
+ try {
817
+ const srcPath = path.resolve(srcDir || 'src');
818
+ const files = await getAllTypeScriptFiles(srcPath);
819
+
820
+ for (const file of files) {
821
+ const content = await fs.readFile(file, 'utf-8');
822
+
823
+ // Match @FederatedInventory({ identity: IdentityClass }) pattern
824
+ const federatedInventoryRegex = /@FederatedInventory\s*\(\s*\{\s*identity:\s*(\w+)\s*\}\s*\)/g;
825
+ let match;
826
+ while ((match = federatedInventoryRegex.exec(content)) !== null) {
827
+ const identityClassName = match[1];
828
+
829
+ // Find the identity class definition in the same file or other files
830
+ // Look for: class IdentityClass implements FederationIdentity { getUniqueName(): string { return 'name'; } }
831
+ // Use multiline matching to handle class definitions that span multiple lines
832
+ const identityClassRegex = new RegExp(
833
+ `class\\s+${identityClassName}[^{]*\\{[\\s\\S]*?getUniqueName\\(\\)[\\s\\S]*?return\\s+['"]([^'"]+)['"]`,
834
+ 's'
835
+ );
836
+ let identityMatch = identityClassRegex.exec(content);
837
+
838
+ // If not found in current file, search other files
839
+ if (!identityMatch) {
840
+ for (const otherFile of files) {
841
+ if (otherFile !== file) {
842
+ const otherContent = await fs.readFile(otherFile, 'utf-8');
843
+ identityMatch = identityClassRegex.exec(otherContent);
844
+ if (identityMatch) {
845
+ break;
846
+ }
847
+ }
848
+ }
849
+ }
850
+
851
+ if (identityMatch) {
852
+ const identityName = identityMatch[1];
853
+ // Add both IFederatedInventory and IFederatedLogin components
854
+ const inventoryComponent = `IFederatedInventory/${identityName}`;
855
+ const loginComponent = `IFederatedLogin/${identityName}`;
856
+ if (!components.includes(inventoryComponent)) {
857
+ components.push(inventoryComponent);
858
+ }
859
+ if (!components.includes(loginComponent)) {
860
+ components.push(loginComponent);
861
+ }
862
+ }
863
+ }
864
+ }
865
+ } catch (error) {
866
+ // If we can't discover components, that's okay - we'll just use existing ones
867
+ // Debug logging only
868
+ }
869
+ return components;
870
+ }
871
+
872
+ async function getAllTypeScriptFiles(dir) {
873
+ const files = [];
874
+ try {
875
+ const entries = await fs.readdir(dir, { withFileTypes: true });
876
+ for (const entry of entries) {
877
+ const fullPath = path.join(dir, entry.name);
878
+ if (entry.isDirectory() && entry.name !== 'node_modules' && !entry.name.startsWith('.')) {
879
+ files.push(...await getAllTypeScriptFiles(fullPath));
880
+ } else if (entry.isFile() && (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx'))) {
881
+ files.push(fullPath);
882
+ }
883
+ }
884
+ } catch (error) {
885
+ // Ignore errors reading directories
886
+ }
887
+ return files;
888
+ }
889
+
890
+ async function updateManifest({
891
+ apiHost,
892
+ token,
893
+ cid,
894
+ pid,
895
+ serviceId,
896
+ shortImageId, // This is now the full image ID (sha256:...) for backend verification
897
+ comments,
898
+ existingManifest,
899
+ discoveredStorage,
900
+ discoveredComponents,
901
+ discoveredDependencies,
902
+ }) {
903
+ const serviceReferences = existingManifest?.serviceReferences?.Value
904
+ ?? existingManifest?.serviceReferences
905
+ ?? existingManifest?.manifest
906
+ ?? [];
907
+ const storageRefsRaw = existingManifest?.storageReferences?.Value
908
+ ?? existingManifest?.storageReferences
909
+ ?? [];
910
+ const existingStorage = Array.isArray(storageRefsRaw)
911
+ ? storageRefsRaw.map((reference) => ({
912
+ id: reference.id?.Value ?? reference.id,
913
+ storageType: reference.storageType?.Value ?? reference.storageType ?? 'mongov1',
914
+ enabled: reference.enabled?.Value ?? reference.enabled ?? true,
915
+ // templateId and archived are optional - only include if present
916
+ ...(reference.templateId ? { templateId: reference.templateId?.Value ?? reference.templateId } : {}),
917
+ ...(reference.archived !== undefined ? { archived: reference.archived?.Value ?? reference.archived ?? false } : {}),
918
+ // Note: checksum is computed by backend, we don't send it
919
+ }))
920
+ : [];
921
+
922
+ // Merge discovered storage with existing storage
923
+ // If a storage object exists in both, keep the existing one (preserves checksum, etc.)
924
+ // But ensure storageType is always set to 'mongov1' for MongoDB storage
925
+ const storageMap = new Map();
926
+ existingStorage.forEach(s => {
927
+ // Normalize storageType: update 'mongo' to 'mongov1' if present
928
+ const normalizedStorage = {
929
+ ...s,
930
+ storageType: s.storageType === 'mongo' ? 'mongov1' : (s.storageType || 'mongov1'),
931
+ };
932
+ storageMap.set(s.id, normalizedStorage);
933
+ });
934
+ discoveredStorage.forEach(s => {
935
+ if (!storageMap.has(s.id)) {
936
+ // New storage - create ServiceStorageReference matching backend case class:
937
+ // case class ServiceStorageReference(id: String, storageType: String, enabled: Boolean, templateId: Option[String] = None, archived: Option[Boolean] = None)
938
+ // Match C# CLI behavior: always set templateId to "small" (DeploymentService.cs line 1620)
939
+ const newStorage = {
940
+ id: s.id,
941
+ storageType: 'mongov1', // All discovered storage uses MongoDB
942
+ enabled: true,
943
+ templateId: s.templateId || 'small', // Default to "small" like C# CLI
944
+ ...(s.archived !== undefined ? { archived: s.archived } : {}),
945
+ };
946
+ storageMap.set(s.id, newStorage);
947
+ console.log(`[beamo-node] Adding new storage to manifest: ${s.id} (type: ${newStorage.storageType}, enabled: ${newStorage.enabled})`);
948
+ } else {
949
+ // Update existing storage to ensure storageType is 'mongov1' and format is correct
950
+ // Match C# CLI behavior: always set templateId to "small" if not present (DeploymentService.cs line 1620)
951
+ const existing = storageMap.get(s.id);
952
+ const updatedStorage = {
953
+ id: existing.id,
954
+ storageType: 'mongov1',
955
+ enabled: existing.enabled !== false, // Ensure enabled is true unless explicitly false
956
+ templateId: existing.templateId || 'small', // Default to "small" like C# CLI
957
+ ...(existing.archived !== undefined ? { archived: existing.archived } : {}),
958
+ };
959
+ storageMap.set(s.id, updatedStorage);
960
+ console.log(`[beamo-node] Updating existing storage in manifest: ${s.id} (type: mongov1, enabled: ${updatedStorage.enabled})`);
961
+ }
962
+ });
963
+ // Convert to array and remove any extra fields (like checksum) that backend doesn't expect
964
+ // Ensure templateId is always set to "small" (matching C# CLI behavior)
965
+ const storageReferences = Array.from(storageMap.values()).map(s => ({
966
+ id: s.id,
967
+ storageType: s.storageType,
968
+ enabled: s.enabled,
969
+ templateId: s.templateId || 'small', // Always include templateId, default to "small" like C# CLI
970
+ ...(s.archived !== undefined ? { archived: s.archived } : {}),
971
+ }));
972
+
973
+ // Extract existing components and dependencies for the service
974
+ const existingServiceRef = serviceReferences.find(
975
+ (ref) => (ref.serviceName?.Value ?? ref.serviceName) === serviceId
976
+ );
977
+ const existingComponents = existingServiceRef?.components?.Value
978
+ ?? existingServiceRef?.components
979
+ ?? [];
980
+ const existingDependencies = existingServiceRef?.dependencies?.Value
981
+ ?? existingServiceRef?.dependencies
982
+ ?? [];
983
+
984
+ // Components are ServiceComponent objects with {name: string}
985
+ // Merge discovered with existing (preserve existing, add new)
986
+ const componentsMap = new Map();
987
+ // Add existing components
988
+ if (Array.isArray(existingComponents)) {
989
+ existingComponents.forEach(comp => {
990
+ const name = comp.name?.Value ?? comp.name ?? comp;
991
+ if (typeof name === 'string') {
992
+ componentsMap.set(name, { name });
993
+ }
994
+ });
995
+ }
996
+ // Add discovered components (will overwrite existing if same name)
997
+ (discoveredComponents || []).forEach(compName => {
998
+ componentsMap.set(compName, { name: compName });
999
+ });
1000
+ const components = Array.from(componentsMap.values());
1001
+
1002
+ // Dependencies are objects with {id, storageType}, need to merge by id
1003
+ const dependenciesMap = new Map();
1004
+ // Add existing dependencies
1005
+ if (Array.isArray(existingDependencies)) {
1006
+ existingDependencies.forEach(dep => {
1007
+ const id = dep.id?.Value ?? dep.id ?? dep;
1008
+ if (typeof id === 'string') {
1009
+ let storageType = dep.storageType?.Value ?? dep.storageType ?? 'mongov1';
1010
+ // Normalize: update 'mongo' to 'mongov1' to match C# microservices
1011
+ if (storageType === 'mongo') {
1012
+ storageType = 'mongov1';
1013
+ }
1014
+ dependenciesMap.set(id, { id, storageType });
1015
+ }
1016
+ });
1017
+ }
1018
+ // Add discovered dependencies (will overwrite existing if same id)
1019
+ (discoveredDependencies || []).forEach(dep => {
1020
+ dependenciesMap.set(dep.id, dep);
1021
+ });
1022
+ const dependencies = Array.from(dependenciesMap.values());
1023
+
1024
+ let updated = false;
1025
+ const mappedServices = serviceReferences.map((reference) => {
1026
+ const name = reference.serviceName?.Value ?? reference.serviceName;
1027
+ if (name === serviceId) {
1028
+ updated = true;
1029
+ return {
1030
+ serviceName: serviceId,
1031
+ enabled: true,
1032
+ templateId: reference.templateId?.Value ?? reference.templateId ?? 'small',
1033
+ containerHealthCheckPort: reference.containerHealthCheckPort?.Value ?? reference.containerHealthCheckPort ?? 6565,
1034
+ imageId: shortImageId,
1035
+ imageCpuArch: reference.imageCpuArch?.Value ?? reference.imageCpuArch ?? 'linux/amd64',
1036
+ logProvider: reference.logProvider?.Value ?? reference.logProvider ?? 'Clickhouse',
1037
+ dependencies,
1038
+ components,
1039
+ };
1040
+ }
1041
+ return {
1042
+ serviceName: name,
1043
+ enabled: reference.enabled?.Value ?? reference.enabled ?? true,
1044
+ templateId: reference.templateId?.Value ?? reference.templateId ?? 'small',
1045
+ containerHealthCheckPort: reference.containerHealthCheckPort?.Value ?? reference.containerHealthCheckPort ?? 6565,
1046
+ imageId: reference.imageId?.Value ?? reference.imageId ?? shortImageId,
1047
+ imageCpuArch: reference.imageCpuArch?.Value ?? reference.imageCpuArch ?? 'linux/amd64',
1048
+ logProvider: reference.logProvider?.Value ?? reference.logProvider ?? 'Clickhouse',
1049
+ dependencies: reference.dependencies?.Value ?? reference.dependencies ?? [],
1050
+ components: reference.components?.Value ?? reference.components ?? [],
1051
+ };
1052
+ });
1053
+
1054
+ if (!updated) {
1055
+ mappedServices.push({
1056
+ serviceName: serviceId,
1057
+ enabled: true,
1058
+ templateId: 'small',
1059
+ containerHealthCheckPort: 6565,
1060
+ imageId: shortImageId,
1061
+ imageCpuArch: 'linux/amd64',
1062
+ logProvider: 'Clickhouse',
1063
+ dependencies,
1064
+ components,
1065
+ });
1066
+ }
1067
+
1068
+ const requestBody = {
1069
+ autoDeploy: true,
1070
+ comments: comments ?? '',
1071
+ manifest: mappedServices,
1072
+ storageReferences,
1073
+ };
1074
+
1075
+ // Log storage references being sent to backend
1076
+ if (storageReferences.length > 0) {
1077
+ console.log(`[beamo-node] Publishing ${storageReferences.length} storage reference(s) in manifest:`);
1078
+ storageReferences.forEach(s => {
1079
+ const details = [
1080
+ `id: ${s.id}`,
1081
+ `storageType: ${s.storageType || 'mongov1'}`,
1082
+ `enabled: ${s.enabled !== false}`,
1083
+ ...(s.templateId ? [`templateId: ${s.templateId}`] : []),
1084
+ ...(s.archived !== undefined ? [`archived: ${s.archived}`] : []),
1085
+ ].join(', ');
1086
+ console.log(`[beamo-node] - ${details}`);
1087
+ });
1088
+ console.log(`[beamo-node] Storage references JSON: ${JSON.stringify(storageReferences, null, 2)}`);
1089
+ } else {
1090
+ console.warn(`[beamo-node] ⚠️ WARNING: No storage references in manifest. Database will NOT be created automatically.`);
1091
+ console.warn(`[beamo-node] Make sure you have @StorageObject('StorageName') decorators in your code.`);
1092
+ }
1093
+
1094
+ const publishUrl = new URL('/basic/beamo/manifest', apiHost);
1095
+ const publishHeaders = {
1096
+ Authorization: `Bearer ${token}`,
1097
+ Accept: 'application/json',
1098
+ 'Content-Type': 'application/json',
1099
+ 'X-BEAM-SCOPE': `${cid}.${pid}`,
1100
+ };
1101
+
1102
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
1103
+ // console.error(`[beamo-node] [STEP: Publish Manifest to Backend]`);
1104
+ // console.error(`[beamo-node] URL: ${publishUrl}`);
1105
+ // console.error(`[beamo-node] Method: POST`);
1106
+ // console.error(`[beamo-node] Headers:`, JSON.stringify(publishHeaders, null, 2));
1107
+ // console.error(`[beamo-node] Service: ${serviceId}`);
1108
+ // console.error(`[beamo-node] CID: ${cid}`);
1109
+ // console.error(`[beamo-node] Realm PID (from X-BEAM-SCOPE): ${pid}`);
1110
+ // console.error(`[beamo-node] Short Image ID: ${shortImageId}`);
1111
+ // console.error(`[beamo-node] Expected Backend Check:`);
1112
+ // console.error(`[beamo-node] - Backend will calculate imageNameMD5 using: rc.cid, rc.gameId, serviceName`);
1113
+ // console.error(`[beamo-node] - Backend will check: {registryURI}/{imageNameMD5}/manifests/{imageId}`);
1114
+ // console.error(`[beamo-node] - Backend will use headers: X-KS-PROJECTID: rc.projectId (from X-BEAM-SCOPE)`);
1115
+ // console.error(`[beamo-node] - NOTE: rc.gameId might differ from realm PID if backend resolves it differently`);
1116
+ // console.error(`[beamo-node] Request Body:`, JSON.stringify(requestBody, null, 2));
1117
+ // console.error(`[beamo-node] Service Entry in Manifest:`, JSON.stringify(mappedServices.find(s => s.serviceName === serviceId), null, 2));
1118
+ // }
1119
+
1120
+ const response = await fetch(publishUrl, {
1121
+ method: 'POST',
1122
+ headers: publishHeaders,
1123
+ body: JSON.stringify(requestBody),
1124
+ });
1125
+
1126
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
1127
+ // console.error(`[beamo-node] Response Status: ${response.status}`);
1128
+ // console.error(`[beamo-node] Response Headers:`, JSON.stringify(Object.fromEntries(response.headers.entries()), null, 2));
1129
+ // }
1130
+
1131
+ if (!response.ok) {
1132
+ const text = await response.text();
1133
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
1134
+ // console.error(`[beamo-node] Response Body: ${text}`);
1135
+ // console.error(`[beamo-node] Full Request Body (for debugging):`, JSON.stringify(requestBody, null, 2));
1136
+ // }
1137
+ throw new Error(`Failed to publish manifest: ${response.status} ${text}`);
1138
+ }
1139
+
1140
+ const responseBody = await response.json();
1141
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
1142
+ // console.error(`[beamo-node] Response Body:`, JSON.stringify(responseBody, null, 2));
1143
+ // console.error(`[beamo-node] ✓ Manifest published successfully`);
1144
+ // }
1145
+ }
1146
+
1147
+ async function prepareDockerContext({ entry, distDir, openapiPath, packageJson, packageLock, nodeVersion }) {
1148
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'beam-node-ms-'));
1149
+ const contextDir = path.join(tempRoot, 'context');
1150
+ const appDir = path.join(contextDir, 'app');
1151
+ await fs.mkdir(appDir, { recursive: true });
1152
+
1153
+ // Read and modify package.json to handle file: dependencies
1154
+ const pkg = JSON.parse(await fs.readFile(packageJson, 'utf8'));
1155
+ const modifiedPkg = { ...pkg };
1156
+
1157
+ // No need to handle file: dependencies anymore - the runtime is published to npm
1158
+ // Just copy the package.json as-is
1159
+ await fs.copyFile(packageJson, path.join(appDir, 'package.json'));
1160
+
1161
+ try {
1162
+ await fs.copyFile(packageLock, path.join(appDir, 'package-lock.json'));
1163
+ } catch {
1164
+ // ignore missing package-lock
1165
+ }
1166
+
1167
+ await copyDirectory(distDir, path.join(appDir, 'dist'));
1168
+ try {
1169
+ await fs.copyFile(openapiPath, path.join(appDir, 'beam_openApi.json'));
1170
+ } catch {
1171
+ await fs.writeFile(path.join(appDir, 'beam_openApi.json'), '{}\n');
1172
+ }
1173
+
1174
+ // Copy beam.env file if it exists (for developer-defined environment variables)
1175
+ let beamEnvFile = null;
1176
+ const beamEnvPath = path.join(path.dirname(packageJson), 'beam.env');
1177
+ const beamEnvHiddenPath = path.join(path.dirname(packageJson), '.beam.env');
1178
+ try {
1179
+ if (await fs.access(beamEnvPath).then(() => true).catch(() => false)) {
1180
+ await fs.copyFile(beamEnvPath, path.join(appDir, 'beam.env'));
1181
+ beamEnvFile = 'beam.env';
1182
+ console.log('Included beam.env file in Docker image');
1183
+ } else if (await fs.access(beamEnvHiddenPath).then(() => true).catch(() => false)) {
1184
+ await fs.copyFile(beamEnvHiddenPath, path.join(appDir, '.beam.env'));
1185
+ beamEnvFile = '.beam.env';
1186
+ console.log('Included .beam.env file in Docker image');
1187
+ }
1188
+ } catch {
1189
+ // beam.env is optional, ignore if not found
1190
+ }
1191
+
1192
+ const dockerfile = `# syntax=docker/dockerfile:1
1193
+ ARG NODE_VERSION=${nodeVersion}
1194
+ FROM node:${nodeVersion}-alpine
1195
+
1196
+ WORKDIR /beam/service
1197
+
1198
+ COPY app/package*.json ./
1199
+ # Install dependencies (runtime is now on npm, so no special handling needed)
1200
+ RUN npm install --omit=dev && npm cache clean --force
1201
+
1202
+ # Pre-install OpenTelemetry collector binary and config to avoid runtime download delay (~12 seconds)
1203
+ # This is stored in /opt/beam/collectors/ which persists and is checked first by the runtime
1204
+ RUN mkdir -p /opt/beam/collectors/1.0.1 && \\
1205
+ apk add --no-cache wget gzip && \\
1206
+ wget https://collectors.beamable.com/version/1.0.1/collector-linux-amd64.gz -O /tmp/collector.gz && \\
1207
+ gunzip /tmp/collector.gz && \\
1208
+ mv /tmp/collector /opt/beam/collectors/1.0.1/collector-linux-amd64 && \\
1209
+ chmod +x /opt/beam/collectors/1.0.1/collector-linux-amd64 && \\
1210
+ wget https://collectors.beamable.com/version/1.0.1/clickhouse-config.yaml.gz -O /tmp/config.gz && \\
1211
+ gunzip /tmp/config.gz && \\
1212
+ mv /tmp/config /opt/beam/collectors/1.0.1/clickhouse-config.yaml && \\
1213
+ rm -f /tmp/collector.gz /tmp/config.gz && \\
1214
+ apk del wget gzip
1215
+
1216
+ COPY app/dist ./dist
1217
+ COPY app/beam_openApi.json ./beam_openApi.json${beamEnvFile ? `\nCOPY app/${beamEnvFile} ./${beamEnvFile}` : ''}
1218
+
1219
+ # Expose health check port (matches C# microservice behavior)
1220
+ EXPOSE 6565
1221
+
1222
+ ENV NODE_ENV=production
1223
+
1224
+ # Add startup script to log what's happening and catch errors
1225
+ RUN echo '#!/bin/sh' > /beam/service/start.sh && \\
1226
+ echo 'echo "Starting Node.js microservice..."' >> /beam/service/start.sh && \\
1227
+ echo 'echo "Working directory: $(pwd)"' >> /beam/service/start.sh && \\
1228
+ echo 'echo "Node version: $(node --version)"' >> /beam/service/start.sh && \\
1229
+ echo 'echo "Files in dist:"' >> /beam/service/start.sh && \\
1230
+ echo 'ls -la dist/ || echo "dist directory not found!"' >> /beam/service/start.sh && \\
1231
+ echo 'echo "Starting main.js..."' >> /beam/service/start.sh && \\
1232
+ echo 'exec node dist/main.js' >> /beam/service/start.sh && \\
1233
+ chmod +x /beam/service/start.sh
1234
+
1235
+ # Use ENTRYPOINT with startup script to ensure we see what's happening
1236
+ ENTRYPOINT ["/beam/service/start.sh"]
1237
+
1238
+ # Debug option: uncomment the line below and comment the ENTRYPOINT above
1239
+ # to keep the container alive for debugging (like C# Dockerfile does)
1240
+ # ENTRYPOINT ["tail", "-f", "/dev/null"]
1241
+ `;
1242
+ await fs.writeFile(path.join(contextDir, 'Dockerfile'), dockerfile, 'utf8');
1243
+
1244
+ return { tempRoot, contextDir };
1245
+ }
1246
+
1247
+ async function main() {
1248
+ // Parse --env-file first so we can load .env before parseArgs reads environment variables
1249
+ // This ensures .env file values are used instead of stale system environment variables
1250
+ const rawArgs = process.argv.slice(2);
1251
+ let envFile = undefined;
1252
+ for (let i = 0; i < rawArgs.length; i++) {
1253
+ if (rawArgs[i] === '--env-file' && i + 1 < rawArgs.length) {
1254
+ envFile = rawArgs[i + 1];
1255
+ break;
1256
+ }
1257
+ }
1258
+
1259
+ // Load .env file first with override to ensure it takes precedence
1260
+ if (envFile) {
1261
+ dotenv.config({ path: path.resolve(envFile), override: true });
1262
+ } else if (process.env.npm_config_env_file) {
1263
+ dotenv.config({ path: path.resolve(process.env.npm_config_env_file), override: true });
1264
+ }
1265
+
1266
+ // Now parse all args - env vars from .env will be available
1267
+ const args = parseArgs(rawArgs);
1268
+
1269
+ const pkg = await readJson(path.resolve('package.json'));
1270
+ const beamableConfig = pkg.beamable || {};
1271
+
1272
+ const serviceId = args.service || beamableConfig.beamoId || pkg.name;
1273
+ ensure(serviceId, 'Service identifier is required. Provide --service or set beamable.beamoId in package.json.');
1274
+
1275
+ const cid = ensure(args.cid || beamableConfig.cid || process.env.CID, 'CID is required (set CID env var or --cid).');
1276
+ const pid = ensure(args.pid || beamableConfig.pid || process.env.PID, 'PID is required (set PID env var or --pid).');
1277
+ const host = args.host || beamableConfig.host || process.env.HOST || 'wss://api.beamable.com/socket';
1278
+ const apiHost = normalizeApiHost(args.apiHost || beamableConfig.apiHost || process.env.BEAMABLE_API_HOST || host);
1279
+ const token = ensure(args.token || process.env.ACCESS_TOKEN || process.env.BEAMABLE_TOKEN, 'Access token is required (set BEAMABLE_TOKEN env var or --token).');
1280
+
1281
+ const configuredGamePid = args.gamePid || beamableConfig.gamePid || process.env.BEAMABLE_GAME_PID;
1282
+ const refreshToken = args.refreshToken || process.env.BEAMABLE_REFRESH_TOKEN || process.env.REFRESH_TOKEN;
1283
+
1284
+ if (!apiHost) {
1285
+ throw new Error('API host could not be determined. Set BEAMABLE_API_HOST or provide --api-host.');
1286
+ }
1287
+
1288
+ // Initialize progress bar (8 main steps)
1289
+ const progress = new ProgressBar(8);
1290
+ console.log(`${colors.bright}${colors.cyan}Publishing ${serviceId}...${colors.reset}\n`);
1291
+
1292
+ // Step 1: Build
1293
+ progress.start('Building project');
1294
+ if (!args.skipValidate) {
1295
+ const validateScript = path.resolve(__dirname, 'validate-service.mjs');
1296
+ const validateArgs = ['--entry', args.entry, '--output', args.openapi, '--cid', cid, '--pid', pid, '--host', host];
1297
+ if (args.envFile) {
1298
+ validateArgs.push('--env-file', args.envFile);
1299
+ }
1300
+ if (args.namePrefix) {
1301
+ validateArgs.push('--routing-key', args.namePrefix);
1302
+ }
1303
+ validateArgs.push('--skip-build');
1304
+ await runCommand('npm', ['run', 'build'], { silent: true });
1305
+ await runCommand(process.execPath, [validateScript, ...validateArgs], { shell: false, silent: true });
1306
+ } else {
1307
+ await runCommand('npm', ['run', 'build'], { silent: true });
1308
+ }
1309
+ progress.complete('Build complete');
1310
+
1311
+ const packageJsonPath = path.resolve('package.json');
1312
+ const packageLockPath = path.resolve('package-lock.json');
1313
+ const distDir = path.resolve('dist');
1314
+ const openapiPath = path.resolve(args.openapi);
1315
+ const entryFile = path.resolve(args.entry);
1316
+
1317
+ await fs.access(entryFile);
1318
+ await fs.access(distDir);
1319
+
1320
+ let tempRoot;
1321
+ try {
1322
+ // Step 2: Prepare Docker context
1323
+ progress.start('Preparing Docker context');
1324
+ const context = await prepareDockerContext({
1325
+ entry: entryFile,
1326
+ distDir,
1327
+ openapiPath,
1328
+ packageJson: packageJsonPath,
1329
+ packageLock: packageLockPath,
1330
+ nodeVersion: args.nodeVersion,
1331
+ });
1332
+ tempRoot = context.tempRoot;
1333
+ const { contextDir } = context;
1334
+ progress.complete('Docker context prepared');
1335
+
1336
+ // Step 3: Build Docker image
1337
+ progress.start('Building Docker image');
1338
+ const dockerTag = args.dockerTag || `${serviceId.toLowerCase().replace(/[^a-z0-9-_]/g, '-')}:${Date.now()}`;
1339
+ await runCommand('docker', ['build', '-t', dockerTag, contextDir], { cwd: contextDir, silent: true });
1340
+ progress.complete('Docker image built');
1341
+
1342
+ // Step 4: Extract image ID and save
1343
+ progress.start('Preparing image for upload');
1344
+ const inspect = await runCommand('docker', ['image', 'inspect', '--format', '{{.Id}}', dockerTag], { capture: true });
1345
+ const fullImageId = inspect.stdout.trim();
1346
+ const imageTarPath = path.join(tempRoot, `${serviceId.replace(/[^a-z0-9-_]/gi, '_')}.tar`);
1347
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
1348
+ // // Calculate short digest manually (shortDigest function defined later)
1349
+ // const longImageId = fullImageId.includes(':') ? fullImageId.split(':')[1] : fullImageId;
1350
+ // const shortImageIdPreview = longImageId.substring(0, 12);
1351
+ // console.error(`[beamo-node] [STEP: Extract Image ID]`);
1352
+ // console.error(`[beamo-node] Docker Tag: ${dockerTag}`);
1353
+ // console.error(`[beamo-node] Full Image ID: ${fullImageId}`);
1354
+ // console.error(`[beamo-node] Short Image ID (preview): ${shortImageIdPreview}`);
1355
+ // console.error(`[beamo-node] Image Tar Path: ${imageTarPath}`);
1356
+ // }
1357
+ await runCommand('docker', ['image', 'save', dockerTag, '-o', imageTarPath], { silent: true });
1358
+ progress.complete('Image prepared');
1359
+
1360
+ // Step 5: Authenticate and get registry
1361
+ progress.start('Authenticating');
1362
+ const resolvedGamePid = await resolveGamePid(apiHost, token, cid, pid, configuredGamePid);
1363
+
1364
+ // Verify token is valid (401 means invalid token, 403 might just be permission issue)
1365
+ try {
1366
+ const testUrl = new URL('/basic/accounts/me', apiHost);
1367
+ const testResponse = await fetch(testUrl, {
1368
+ headers: {
1369
+ Authorization: `Bearer ${token}`,
1370
+ Accept: 'application/json',
1371
+ 'X-BEAM-SCOPE': `${cid}.${pid}`,
1372
+ },
1373
+ });
1374
+ if (testResponse.status === 401) {
1375
+ throw new Error(`Token validation failed: ${testResponse.status} ${await testResponse.text()}`);
1376
+ }
1377
+ } catch (error) {
1378
+ if (error.message.includes('401')) {
1379
+ throw new Error(`Token validation failed: ${error.message}. Please run "beamo-node login" again.`);
1380
+ }
1381
+ }
1382
+
1383
+ // Match C# CLI: GetDockerImageRegistryUri() uses realm PID from context (X-BEAM-SCOPE)
1384
+ // uniqueName uses gamePid (matches backend's rc.gameId), but registry URL uses realm PID
1385
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
1386
+ // console.error(`[beamo-node] [STEP: Calculate Registry Path]`);
1387
+ // console.error(`[beamo-node] CID: ${cid}`);
1388
+ // console.error(`[beamo-node] Realm PID: ${pid}`);
1389
+ // console.error(`[beamo-node] Resolved Game PID: ${resolvedGamePid}`);
1390
+ // console.error(`[beamo-node] Service ID: ${serviceId}`);
1391
+ // }
1392
+ const registryUrl = await getRegistryUrl(apiHost, token, cid, pid);
1393
+ const uniqueNameInput = `${cid}_${resolvedGamePid}_${serviceId}`;
1394
+ const uniqueName = md5Hex(uniqueNameInput).substring(0, 30);
1395
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
1396
+ // console.error(`[beamo-node] Unique Name Input: ${uniqueNameInput}`);
1397
+ // console.error(`[beamo-node] Unique Name (MD5 first 30 chars): ${uniqueName}`);
1398
+ // console.error(`[beamo-node] Registry URL: ${registryUrl}`);
1399
+ // }
1400
+ progress.complete('Authenticated');
1401
+
1402
+ // Step 6: Upload Docker image
1403
+ progress.start('Uploading Docker image to registry');
1404
+ await uploadDockerImage({
1405
+ apiHost,
1406
+ registryUrl,
1407
+ cid,
1408
+ pid,
1409
+ gamePid: resolvedGamePid,
1410
+ token,
1411
+ serviceId,
1412
+ uniqueName,
1413
+ imageTarPath,
1414
+ fullImageId,
1415
+ progress,
1416
+ });
1417
+ progress.complete('Image uploaded');
1418
+
1419
+ // Verify image exists in registry before proceeding
1420
+ const shortImageId = shortDigest(fullImageId);
1421
+ const baseUrl = `${registryUrl}${uniqueName}/`;
1422
+ const verifyHeaders = {
1423
+ 'x-ks-clientid': cid,
1424
+ 'x-ks-projectid': pid, // Use realm PID (matches backend's rc.projectId when checking registry)
1425
+ 'x-ks-token': token,
1426
+ };
1427
+
1428
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
1429
+ // console.error(`[beamo-node] [STEP: Post-Upload Verification]`);
1430
+ // console.error(`[beamo-node] Full Image ID: ${fullImageId}`);
1431
+ // console.error(`[beamo-node] Short Image ID: ${shortImageId}`);
1432
+ // console.error(`[beamo-node] Base URL: ${baseUrl}`);
1433
+ // console.error(`[beamo-node] Verification URL: ${baseUrl}manifests/${shortImageId}`);
1434
+ // console.error(`[beamo-node] Verify Headers:`, JSON.stringify(verifyHeaders, null, 2));
1435
+ // }
1436
+
1437
+ // Wait a moment for registry to propagate
1438
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
1439
+ // console.error(`[beamo-node] Waiting 3 seconds for registry propagation...`);
1440
+ // }
1441
+ await new Promise(resolve => setTimeout(resolve, 3000));
1442
+
1443
+ const imageExists = await verifyManifestExists(baseUrl, shortImageId, verifyHeaders);
1444
+ if (!imageExists) {
1445
+ throw new Error(`Image verification failed: manifest with tag ${shortImageId} not found in registry at ${baseUrl}manifests/${shortImageId}. The image may not have uploaded successfully.`);
1446
+ }
1447
+
1448
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
1449
+ // console.error(`[beamo-node] Image verification passed`);
1450
+ // }
1451
+
1452
+ // CRITICAL: Perform exact backend check simulation
1453
+ // The backend uses HEAD request with specific headers - let's verify it works
1454
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
1455
+ // console.error(`[beamo-node] [STEP: Backend Check Simulation]`);
1456
+ // console.error(`[beamo-node] Simulating exact backend check...`);
1457
+ // const backendCheckUrl = `${registryUrl}${uniqueName}/manifests/${shortImageId}`;
1458
+ // console.error(`[beamo-node] Backend will check: ${backendCheckUrl}`);
1459
+ // console.error(`[beamo-node] Backend will use headers:`, JSON.stringify(verifyHeaders, null, 2));
1460
+ // try {
1461
+ // const backendCheckResponse = await fetch(backendCheckUrl, {
1462
+ // method: 'HEAD',
1463
+ // headers: verifyHeaders,
1464
+ // });
1465
+ // console.error(`[beamo-node] Backend simulation response status: ${backendCheckResponse.status}`);
1466
+ // if (backendCheckResponse.status !== 200) {
1467
+ // console.error(`[beamo-node] ⚠️ Backend simulation FAILED - status ${backendCheckResponse.status}`);
1468
+ // const responseText = await backendCheckResponse.text().catch(() => '');
1469
+ // console.error(`[beamo-node] Response: ${responseText}`);
1470
+ // } else {
1471
+ // console.error(`[beamo-node] ✓ Backend simulation passed`);
1472
+ // }
1473
+ // } catch (error) {
1474
+ // console.error(`[beamo-node] ⚠️ Backend simulation error: ${error.message}`);
1475
+ // }
1476
+ // }
1477
+
1478
+ // CRITICAL: Before publishing, verify using the backend's expected gameId
1479
+ // The backend resolves rc.gameId from the realm hierarchy, which might differ from our resolvedGamePid
1480
+ // We need to check what the backend will actually use
1481
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
1482
+ // console.error(`[beamo-node] [STEP: Pre-Publish Backend Simulation]`);
1483
+ // console.error(`[beamo-node] Simulating backend's imageNameMD5 calculation...`);
1484
+ // console.error(`[beamo-node] Backend will use: rc.cid, rc.gameId (resolved from realm hierarchy), serviceName`);
1485
+ // console.error(`[beamo-node] Our calculation used: cid=${cid}, gamePid=${resolvedGamePid}, serviceName=${serviceId}`);
1486
+ // console.error(`[beamo-node] Our uniqueName: ${uniqueName}`);
1487
+ // console.error(`[beamo-node] WARNING: If backend's rc.gameId differs from our resolvedGamePid, the check will fail!`);
1488
+ // }
1489
+
1490
+ // Try to resolve what the backend's rc.gameId will be by making the same API call the backend would make
1491
+ // The backend resolves gameId from the realm hierarchy when processing X-BEAM-SCOPE
1492
+ // try {
1493
+ // const backendGamePidCheck = await resolveGamePid(apiHost, token, cid, pid, null); // Force resolution
1494
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
1495
+ // console.error(`[beamo-node] Backend's expected gameId (resolved from realm hierarchy): ${backendGamePidCheck}`);
1496
+ // if (backendGamePidCheck !== resolvedGamePid) {
1497
+ // console.error(`[beamo-node] ⚠️ MISMATCH DETECTED!`);
1498
+ // console.error(`[beamo-node] Our resolvedGamePid: ${resolvedGamePid}`);
1499
+ // console.error(`[beamo-node] Backend's expected gameId: ${backendGamePidCheck}`);
1500
+ // console.error(`[beamo-node] This will cause imageNameMD5 mismatch!`);
1501
+ // const backendUniqueNameInput = `${cid}_${backendGamePidCheck}_${serviceId}`;
1502
+ // const backendUniqueName = md5Hex(backendUniqueNameInput).substring(0, 30);
1503
+ // console.error(`[beamo-node] Backend will check: ${registryUrl}${backendUniqueName}/manifests/${shortImageId}`);
1504
+ // console.error(`[beamo-node] But we uploaded to: ${registryUrl}${uniqueName}/manifests/${shortImageId}`);
1505
+ // } else {
1506
+ // console.error(`[beamo-node] ✓ Game PID matches - backend should find the image`);
1507
+ // }
1508
+ // }
1509
+ // } catch (error) {
1510
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
1511
+ // console.error(`[beamo-node] Could not verify backend gameId resolution: ${error.message}`);
1512
+ // }
1513
+ // }
1514
+
1515
+ // Step 7: Discover storage, components, and dependencies
1516
+ progress.start('Discovering storage objects and components');
1517
+ // shortImageId already defined above from verification step
1518
+ const existingManifest = await fetchCurrentManifest(apiHost, token, cid, pid);
1519
+ const discoveredStorage = await discoverStorageObjects('src', process.cwd());
1520
+ const discoveredComponents = await discoverFederationComponents('src');
1521
+ // Dependencies are ServiceDependencyReference objects with id and storageType
1522
+ // storageType should be "mongov1" for MongoDB storage objects (matching C# microservices)
1523
+ const discoveredDependencies = discoveredStorage.map(s => ({
1524
+ id: s.id,
1525
+ storageType: 'mongov1', // MongoDB storage type (matches ServiceStorageReference in backend)
1526
+ }));
1527
+
1528
+ if (discoveredStorage.length > 0) {
1529
+ console.log(`[beamo-node] Discovered ${discoveredStorage.length} storage object(s): ${discoveredStorage.map(s => s.id).join(', ')}`);
1530
+ }
1531
+
1532
+ progress.complete('Storage and components discovered');
1533
+
1534
+ // Step 8: Publish manifest
1535
+ progress.start('Publishing manifest');
1536
+ await updateManifest({
1537
+ apiHost,
1538
+ token,
1539
+ cid,
1540
+ pid,
1541
+ serviceId,
1542
+ shortImageId,
1543
+ comments: args.comments,
1544
+ existingManifest,
1545
+ discoveredStorage,
1546
+ discoveredComponents,
1547
+ discoveredDependencies,
1548
+ });
1549
+ progress.complete('Manifest published');
1550
+
1551
+ // Success message
1552
+ console.log(`\n${colors.green}${colors.bright}✓ Publish complete!${colors.reset}`);
1553
+ console.log(`${colors.dim} Service:${colors.reset} ${serviceId}`);
1554
+ console.log(`${colors.dim} Image ID:${colors.reset} ${fullImageId}`);
1555
+ console.log(`${colors.dim} Registry:${colors.reset} ${registryUrl}${uniqueName}`);
1556
+ } finally {
1557
+ if (tempRoot) {
1558
+ await fs.rm(tempRoot, { recursive: true, force: true });
1559
+ }
1560
+ }
1561
+ }
1562
+
1563
+ main().catch(async (error) => {
1564
+ // Show clean error message
1565
+ console.error(`\n${colors.red}${colors.bright}✗ Publish failed${colors.reset}`);
1566
+ if (error instanceof Error) {
1567
+ console.error(`${colors.red}${error.message}${colors.reset}`);
1568
+ // if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
1569
+ // console.error(`\n${colors.dim}Stack:${colors.reset} ${error.stack}`);
1570
+ // if (error.cause) {
1571
+ // console.error(`${colors.dim}Cause:${colors.reset} ${error.cause}`);
1572
+ // }
1573
+ // if (error.stdout) {
1574
+ // console.error(`${colors.dim}stdout:${colors.reset} ${error.stdout}`);
1575
+ // }
1576
+ // if (error.stderr) {
1577
+ // console.error(`${colors.dim}stderr:${colors.reset} ${error.stderr}`);
1578
+ // }
1579
+ // console.error(`\n${colors.dim}Full error:${colors.reset}`, JSON.stringify(error, Object.getOwnPropertyNames(error), 2));
1580
+ // }
1581
+ } else {
1582
+ console.error(`${colors.red}${error}${colors.reset}`);
1583
+ }
1584
+ process.exit(1);
1585
+ });