@omen.foundation/node-microservice-runtime 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/.env +13 -0
  2. package/dist/auth.cjs +97 -0
  3. package/dist/auth.d.ts +14 -0
  4. package/dist/auth.d.ts.map +1 -0
  5. package/dist/auth.js +93 -0
  6. package/dist/auth.js.map +1 -0
  7. package/dist/cli/index.d.ts +3 -0
  8. package/dist/cli/index.d.ts.map +1 -0
  9. package/dist/cli/index.js +588 -0
  10. package/dist/cli/index.js.map +1 -0
  11. package/dist/decorators.cjs +181 -0
  12. package/dist/decorators.d.ts +23 -0
  13. package/dist/decorators.d.ts.map +1 -0
  14. package/dist/decorators.js +155 -0
  15. package/dist/decorators.js.map +1 -0
  16. package/dist/dependency.cjs +165 -0
  17. package/dist/dependency.d.ts +56 -0
  18. package/dist/dependency.d.ts.map +1 -0
  19. package/dist/dependency.js +162 -0
  20. package/dist/dependency.js.map +1 -0
  21. package/dist/dev.cjs +34 -0
  22. package/dist/dev.d.ts +9 -0
  23. package/dist/dev.d.ts.map +1 -0
  24. package/dist/dev.js +32 -0
  25. package/dist/dev.js.map +1 -0
  26. package/dist/discovery.cjs +79 -0
  27. package/dist/discovery.d.ts +20 -0
  28. package/dist/discovery.d.ts.map +1 -0
  29. package/dist/discovery.js +75 -0
  30. package/dist/discovery.js.map +1 -0
  31. package/dist/docs.cjs +206 -0
  32. package/dist/docs.d.ts +30 -0
  33. package/dist/docs.d.ts.map +1 -0
  34. package/dist/docs.js +209 -0
  35. package/dist/docs.js.map +1 -0
  36. package/dist/env.cjs +106 -0
  37. package/dist/env.d.ts +4 -0
  38. package/dist/env.d.ts.map +1 -0
  39. package/dist/env.js +108 -0
  40. package/dist/env.js.map +1 -0
  41. package/dist/errors.cjs +58 -0
  42. package/dist/errors.d.ts +26 -0
  43. package/dist/errors.d.ts.map +1 -0
  44. package/dist/errors.js +48 -0
  45. package/dist/errors.js.map +1 -0
  46. package/dist/federation.cjs +356 -0
  47. package/dist/federation.d.ts +108 -0
  48. package/dist/federation.d.ts.map +1 -0
  49. package/dist/federation.js +341 -0
  50. package/dist/federation.js.map +1 -0
  51. package/dist/index.cjs +42 -0
  52. package/dist/index.d.ts +13 -0
  53. package/dist/index.d.ts.map +1 -0
  54. package/dist/index.js +10 -0
  55. package/dist/index.js.map +1 -0
  56. package/dist/inventory.cjs +361 -0
  57. package/dist/inventory.d.ts +116 -0
  58. package/dist/inventory.d.ts.map +1 -0
  59. package/dist/inventory.js +351 -0
  60. package/dist/inventory.js.map +1 -0
  61. package/dist/logger.cjs +62 -0
  62. package/dist/logger.d.ts +9 -0
  63. package/dist/logger.d.ts.map +1 -0
  64. package/dist/logger.js +29 -0
  65. package/dist/logger.js.map +1 -0
  66. package/dist/message.cjs +19 -0
  67. package/dist/message.d.ts +5 -0
  68. package/dist/message.d.ts.map +1 -0
  69. package/dist/message.js +15 -0
  70. package/dist/message.js.map +1 -0
  71. package/dist/requester.cjs +100 -0
  72. package/dist/requester.d.ts +20 -0
  73. package/dist/requester.d.ts.map +1 -0
  74. package/dist/requester.js +99 -0
  75. package/dist/requester.js.map +1 -0
  76. package/dist/routing.cjs +39 -0
  77. package/dist/routing.d.ts +2 -0
  78. package/dist/routing.d.ts.map +1 -0
  79. package/dist/routing.js +36 -0
  80. package/dist/routing.js.map +1 -0
  81. package/dist/runtime.cjs +735 -0
  82. package/dist/runtime.d.ts +40 -0
  83. package/dist/runtime.d.ts.map +1 -0
  84. package/dist/runtime.js +825 -0
  85. package/dist/runtime.js.map +1 -0
  86. package/dist/services.cjs +346 -0
  87. package/dist/services.d.ts +46 -0
  88. package/dist/services.d.ts.map +1 -0
  89. package/dist/services.js +343 -0
  90. package/dist/services.js.map +1 -0
  91. package/dist/storage.cjs +147 -0
  92. package/dist/storage.d.ts +46 -0
  93. package/dist/storage.d.ts.map +1 -0
  94. package/dist/storage.js +144 -0
  95. package/dist/storage.js.map +1 -0
  96. package/dist/types.cjs +2 -0
  97. package/dist/types.d.ts +108 -0
  98. package/dist/types.d.ts.map +1 -0
  99. package/dist/types.js +2 -0
  100. package/dist/types.js.map +1 -0
  101. package/dist/utils/urls.cjs +55 -0
  102. package/dist/utils/urls.d.ts +5 -0
  103. package/dist/utils/urls.d.ts.map +1 -0
  104. package/dist/utils/urls.js +50 -0
  105. package/dist/utils/urls.js.map +1 -0
  106. package/dist/websocket.cjs +142 -0
  107. package/dist/websocket.d.ts +33 -0
  108. package/dist/websocket.d.ts.map +1 -0
  109. package/dist/websocket.js +139 -0
  110. package/dist/websocket.js.map +1 -0
  111. package/env.sample +13 -0
  112. package/package.json +49 -0
  113. package/scripts/generate-openapi.mjs +114 -0
  114. package/scripts/lib/cli-utils.mjs +58 -0
  115. package/scripts/prepare-cjs.mjs +44 -0
  116. package/scripts/publish-service.mjs +1126 -0
  117. package/scripts/validate-service.mjs +103 -0
  118. package/scripts/ws-test.mjs +25 -0
  119. package/src/auth.ts +117 -0
  120. package/src/cli/index.ts +699 -0
  121. package/src/decorators.ts +207 -0
  122. package/src/dependency.ts +211 -0
  123. package/src/dev.ts +17 -0
  124. package/src/discovery.ts +88 -0
  125. package/src/docs.ts +262 -0
  126. package/src/env.ts +125 -0
  127. package/src/errors.ts +55 -0
  128. package/src/federation.ts +559 -0
  129. package/src/index.ts +51 -0
  130. package/src/inventory.ts +491 -0
  131. package/src/logger.ts +38 -0
  132. package/src/message.ts +19 -0
  133. package/src/requester.ts +126 -0
  134. package/src/routing.ts +42 -0
  135. package/src/runtime.ts +967 -0
  136. package/src/services.ts +459 -0
  137. package/src/storage.ts +206 -0
  138. package/src/types/beamable-sdk-api.d.ts +5 -0
  139. package/src/types.ts +117 -0
  140. package/src/utils/urls.ts +53 -0
  141. package/src/websocket.ts +170 -0
  142. package/tsconfig.base.json +31 -0
  143. package/tsconfig.build.json +10 -0
  144. package/tsconfig.cjs.json +16 -0
  145. package/tsconfig.dev.json +14 -0
@@ -0,0 +1,1126 @@
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 (!args.envFile && 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 prepareUploadLocation(baseUrl, headers) {
285
+ const url = new URL('blobs/uploads/', baseUrl);
286
+ // Debug logging only - removed verbose output
287
+ // Match C# CLI exactly: StringContent("") sets Content-Type and Content-Length
288
+ let response;
289
+ try {
290
+ response = await fetch(url, {
291
+ method: 'POST',
292
+ headers: {
293
+ ...headers,
294
+ 'Content-Type': 'text/plain; charset=utf-8',
295
+ 'Content-Length': '0',
296
+ },
297
+ body: '', // Empty body
298
+ });
299
+ } catch (error) {
300
+ // Network/SSL errors happen before HTTP response
301
+ const errorMsg = error instanceof Error ? error.message : String(error);
302
+ const errorDetails = {
303
+ url: url.toString(),
304
+ error: errorMsg,
305
+ ...(error instanceof Error && error.stack ? { stack: error.stack } : {}),
306
+ ...(error instanceof Error && error.cause ? { cause: error.cause } : {}),
307
+ };
308
+ if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
309
+ console.error('[beamo-node] Network error preparing upload location:', errorDetails);
310
+ }
311
+ throw new Error(`Network error preparing upload location: ${errorMsg}. URL: ${url.toString()}`);
312
+ }
313
+
314
+ if (!response.ok) {
315
+ const text = await response.text();
316
+ if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
317
+ console.error('[beamo-node] Upload location failed', {
318
+ status: response.status,
319
+ statusText: response.statusText,
320
+ headers: Object.fromEntries(response.headers.entries()),
321
+ body: text.substring(0, 500),
322
+ });
323
+ }
324
+ throw new Error(`Failed to prepare upload location: ${response.status} ${text}`);
325
+ }
326
+ return response.headers.get('location');
327
+ }
328
+
329
+ async function uploadBlob(baseUrl, digest, buffer, headers) {
330
+ if (await checkBlobExists(baseUrl, digest, headers)) {
331
+ return { digest, size: buffer.length };
332
+ }
333
+
334
+ const location = await prepareUploadLocation(baseUrl, headers);
335
+ if (!location) {
336
+ throw new Error('Registry did not provide an upload location.');
337
+ }
338
+
339
+ // Match C# CLI: NormalizeWithDigest forces HTTPS and default port (-1 means use default for scheme)
340
+ const locationUrl = new URL(location.startsWith('http') ? location : new URL(location, baseUrl).href);
341
+ // Force HTTPS and remove explicit port (use default port 443 for HTTPS)
342
+ locationUrl.protocol = 'https:';
343
+ locationUrl.port = ''; // Empty string means use default port for the scheme
344
+ locationUrl.searchParams.set('digest', digest);
345
+ const uploadUrl = locationUrl;
346
+
347
+ const response = await fetch(uploadUrl, {
348
+ method: 'PUT',
349
+ headers: { ...headers, 'Content-Type': 'application/octet-stream' },
350
+ body: buffer,
351
+ });
352
+
353
+ if (!response.ok) {
354
+ const text = await response.text();
355
+ throw new Error(`Failed to upload blob ${digest}: ${response.status} ${text}`);
356
+ }
357
+
358
+ return { digest, size: buffer.length };
359
+ }
360
+
361
+ async function uploadManifest(baseUrl, manifestJson, shortImageId, headers) {
362
+ // Match C# CLI: upload manifest using the short imageId as the tag
363
+ // The backend looks up images using this short imageId tag
364
+ const manifestJsonString = JSON.stringify(manifestJson);
365
+ const url = new URL(`manifests/${shortImageId}`, baseUrl);
366
+
367
+ // Debug logging only
368
+ // Debug logging only - removed verbose output
369
+
370
+ const response = await fetch(url, {
371
+ method: 'PUT',
372
+ headers: { ...headers, 'Content-Type': MANIFEST_MEDIA_TYPE },
373
+ body: manifestJsonString,
374
+ });
375
+ if (!response.ok) {
376
+ const text = await response.text();
377
+ throw new Error(`Failed to upload manifest: ${response.status} ${text}`);
378
+ }
379
+ }
380
+
381
+ async function fetchJson(url, options = {}) {
382
+ const response = await fetch(url, options);
383
+ if (!response.ok) {
384
+ const text = await response.text();
385
+ const error = new Error(`Request failed ${response.status}: ${text}`);
386
+ error.status = response.status;
387
+ throw error;
388
+ }
389
+ return response.json();
390
+ }
391
+
392
+ async function resolveGamePid(apiHost, token, cid, pid, explicitGamePid) {
393
+ if (explicitGamePid) {
394
+ return explicitGamePid;
395
+ }
396
+
397
+ const scope = pid ? `${cid}.${pid}` : cid;
398
+ try {
399
+ const url = new URL(`/basic/realms/game`, apiHost);
400
+ url.searchParams.set('rootPID', pid);
401
+ const body = await fetchJson(url, {
402
+ headers: {
403
+ Authorization: `Bearer ${token}`,
404
+ Accept: 'application/json',
405
+ ...(scope ? { 'X-BEAM-SCOPE': scope } : {}),
406
+ },
407
+ });
408
+
409
+ const projects = Array.isArray(body?.projects) ? body.projects : [];
410
+ if (projects.length === 0) {
411
+ return pid;
412
+ }
413
+
414
+ const byPid = new Map(projects.map((project) => [project.pid, project]));
415
+ let current = byPid.get(pid);
416
+ const visited = new Set();
417
+ while (current && current.parent && !visited.has(current.parent)) {
418
+ visited.add(current.pid);
419
+ current = byPid.get(current.parent);
420
+ }
421
+ const resolved = current?.pid ?? pid;
422
+ // Debug logging only
423
+ return resolved;
424
+ } catch (error) {
425
+ // Debug logging only
426
+ return pid;
427
+ }
428
+ }
429
+
430
+ async function getScopedAccessToken(apiHost, cid, pid, refreshToken, fallbackToken) {
431
+ const scope = pid ? `${cid}.${pid}` : cid;
432
+ if (!refreshToken) {
433
+ return { accessToken: fallbackToken, refreshToken };
434
+ }
435
+
436
+ try {
437
+ const response = await fetch(new URL('/basic/auth/token', apiHost), {
438
+ method: 'POST',
439
+ headers: {
440
+ Accept: 'application/json',
441
+ 'Content-Type': 'application/json',
442
+ ...(scope ? { 'X-BEAM-SCOPE': scope } : {}),
443
+ },
444
+ body: JSON.stringify({
445
+ grant_type: 'refresh_token',
446
+ refresh_token: refreshToken,
447
+ }),
448
+ });
449
+
450
+ if (!response.ok) {
451
+ const text = await response.text();
452
+ throw new Error(`Refresh token request failed: ${response.status} ${text}`);
453
+ }
454
+
455
+ const body = await response.json();
456
+ const accessToken = body.access_token ?? fallbackToken;
457
+ const nextRefresh = body.refresh_token ?? refreshToken;
458
+ return { accessToken, refreshToken: nextRefresh };
459
+ } catch (error) {
460
+ // Debug logging only
461
+ return { accessToken: fallbackToken, refreshToken };
462
+ }
463
+ }
464
+
465
+ async function getRegistryUrl(apiHost, token, cid, pid) {
466
+ const scope = pid ? `${cid}.${pid}` : cid;
467
+ const body = await fetchJson(new URL('/basic/beamo/registry', apiHost), {
468
+ headers: {
469
+ Authorization: `Bearer ${token}`,
470
+ Accept: 'application/json',
471
+ ...(scope ? { 'X-BEAM-SCOPE': scope } : {}),
472
+ },
473
+ });
474
+ const uri = body.uri || body.registry || body.url;
475
+ if (!uri) {
476
+ throw new Error('Registry URI response missing "uri" field.');
477
+ }
478
+ // Match C# CLI exactly: GetDockerImageRegistryUri() returns scheme://host/v2/ (Host property strips port)
479
+ const normalized = uri.includes('://') ? uri : `https://${uri}`;
480
+ const parsed = new URL(normalized);
481
+ // parsedUri.Host in C# is just the hostname (no port), so we use hostname here
482
+ return `${parsed.protocol}//${parsed.hostname}/v2/`;
483
+ }
484
+
485
+ async function uploadDockerImage({
486
+ apiHost,
487
+ registryUrl,
488
+ cid,
489
+ pid,
490
+ gamePid,
491
+ token,
492
+ serviceId,
493
+ uniqueName,
494
+ imageTarPath,
495
+ fullImageId,
496
+ progress,
497
+ }) {
498
+ const baseUrl = `${registryUrl}${uniqueName}/`;
499
+ // Match C# CLI exactly: use x-ks-* headers (all lowercase) with CID, PID (realm PID), and access token
500
+ const headers = {
501
+ 'x-ks-clientid': cid,
502
+ 'x-ks-projectid': pid, // Use realm PID (not gamePid) - matches ctx.Pid in C# CLI
503
+ 'x-ks-token': token, // Access token from login
504
+ };
505
+
506
+ const { manifestEntry, configBuffer, layers } = await readDockerImageTar(imageTarPath);
507
+
508
+ // Upload config
509
+ if (progress) {
510
+ process.stdout.write(`\r${colors.blue}↑${colors.reset} Uploading config...`);
511
+ }
512
+ const configDigest = await uploadBlob(baseUrl, sha256Digest(configBuffer), configBuffer, headers);
513
+
514
+ // Upload layers with progress
515
+ const layerDescriptors = [];
516
+ const totalLayers = layers.length;
517
+ for (let i = 0; i < layers.length; i++) {
518
+ if (progress) {
519
+ process.stdout.write(`\r${colors.blue}↑${colors.reset} Uploading layers (${i + 1}/${totalLayers})...`);
520
+ }
521
+ const descriptor = await uploadBlob(baseUrl, sha256Digest(layers[i].buffer), layers[i].buffer, headers);
522
+ layerDescriptors.push({
523
+ digest: descriptor.digest,
524
+ size: descriptor.size,
525
+ mediaType: LAYER_MEDIA_TYPE,
526
+ });
527
+ }
528
+
529
+ const uploadManifestJson = {
530
+ schemaVersion: 2,
531
+ mediaType: MANIFEST_MEDIA_TYPE,
532
+ config: {
533
+ mediaType: CONFIG_MEDIA_TYPE,
534
+ digest: configDigest.digest,
535
+ size: configDigest.size,
536
+ },
537
+ layers: layerDescriptors,
538
+ };
539
+
540
+ // Upload manifest using short imageId as tag (matching C# CLI behavior)
541
+ if (progress) {
542
+ process.stdout.write(`\r${colors.blue}↑${colors.reset} Uploading manifest...`);
543
+ }
544
+ const shortImageId = shortDigest(fullImageId);
545
+ await uploadManifest(baseUrl, uploadManifestJson, shortImageId, headers);
546
+ if (progress) {
547
+ process.stdout.write('\r');
548
+ }
549
+ }
550
+
551
+ async function fetchCurrentManifest(apiHost, token, cid, pid) {
552
+ const response = await fetch(new URL('/api/beamo/manifests/current', apiHost), {
553
+ headers: {
554
+ Authorization: `Bearer ${token}`,
555
+ Accept: 'application/json',
556
+ 'X-BEAM-SCOPE': `${cid}.${pid}`,
557
+ },
558
+ });
559
+ if (response.status === 404) {
560
+ // No existing manifest (first publish) - return null
561
+ return null;
562
+ }
563
+ if (!response.ok) {
564
+ const text = await response.text();
565
+ throw new Error(`Failed to fetch current manifest: ${response.status} ${text}`);
566
+ }
567
+ return response.json();
568
+ }
569
+
570
+ async function discoverStorageObjects(srcDir) {
571
+ const storageObjects = [];
572
+ try {
573
+ const srcPath = path.resolve(srcDir || 'src');
574
+ const files = await getAllTypeScriptFiles(srcPath);
575
+
576
+ for (const file of files) {
577
+ const content = await fs.readFile(file, 'utf-8');
578
+ // Match @StorageObject('StorageName') pattern
579
+ const storageRegex = /@StorageObject\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
580
+ let match;
581
+ while ((match = storageRegex.exec(content)) !== null) {
582
+ const storageName = match[1];
583
+ if (storageName && !storageObjects.find(s => s.id === storageName)) {
584
+ storageObjects.push({
585
+ id: storageName,
586
+ enabled: true,
587
+ checksum: null,
588
+ archived: false,
589
+ });
590
+ }
591
+ }
592
+ }
593
+ } catch (error) {
594
+ // If we can't discover storage, that's okay - we'll just use existing ones
595
+ // Debug logging only
596
+ }
597
+ return storageObjects;
598
+ }
599
+
600
+ async function discoverFederationComponents(srcDir) {
601
+ const components = [];
602
+ try {
603
+ const srcPath = path.resolve(srcDir || 'src');
604
+ const files = await getAllTypeScriptFiles(srcPath);
605
+
606
+ for (const file of files) {
607
+ const content = await fs.readFile(file, 'utf-8');
608
+
609
+ // Match @FederatedInventory({ identity: IdentityClass }) pattern
610
+ const federatedInventoryRegex = /@FederatedInventory\s*\(\s*\{\s*identity:\s*(\w+)\s*\}\s*\)/g;
611
+ let match;
612
+ while ((match = federatedInventoryRegex.exec(content)) !== null) {
613
+ const identityClassName = match[1];
614
+
615
+ // Find the identity class definition in the same file or other files
616
+ // Look for: class IdentityClass implements FederationIdentity { getUniqueName(): string { return 'name'; } }
617
+ // Use multiline matching to handle class definitions that span multiple lines
618
+ const identityClassRegex = new RegExp(
619
+ `class\\s+${identityClassName}[^{]*\\{[\\s\\S]*?getUniqueName\\(\\)[\\s\\S]*?return\\s+['"]([^'"]+)['"]`,
620
+ 's'
621
+ );
622
+ let identityMatch = identityClassRegex.exec(content);
623
+
624
+ // If not found in current file, search other files
625
+ if (!identityMatch) {
626
+ for (const otherFile of files) {
627
+ if (otherFile !== file) {
628
+ const otherContent = await fs.readFile(otherFile, 'utf-8');
629
+ identityMatch = identityClassRegex.exec(otherContent);
630
+ if (identityMatch) {
631
+ break;
632
+ }
633
+ }
634
+ }
635
+ }
636
+
637
+ if (identityMatch) {
638
+ const identityName = identityMatch[1];
639
+ // Add both IFederatedInventory and IFederatedLogin components
640
+ const inventoryComponent = `IFederatedInventory/${identityName}`;
641
+ const loginComponent = `IFederatedLogin/${identityName}`;
642
+ if (!components.includes(inventoryComponent)) {
643
+ components.push(inventoryComponent);
644
+ }
645
+ if (!components.includes(loginComponent)) {
646
+ components.push(loginComponent);
647
+ }
648
+ }
649
+ }
650
+ }
651
+ } catch (error) {
652
+ // If we can't discover components, that's okay - we'll just use existing ones
653
+ // Debug logging only
654
+ }
655
+ return components;
656
+ }
657
+
658
+ async function getAllTypeScriptFiles(dir) {
659
+ const files = [];
660
+ try {
661
+ const entries = await fs.readdir(dir, { withFileTypes: true });
662
+ for (const entry of entries) {
663
+ const fullPath = path.join(dir, entry.name);
664
+ if (entry.isDirectory() && entry.name !== 'node_modules' && !entry.name.startsWith('.')) {
665
+ files.push(...await getAllTypeScriptFiles(fullPath));
666
+ } else if (entry.isFile() && (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx'))) {
667
+ files.push(fullPath);
668
+ }
669
+ }
670
+ } catch (error) {
671
+ // Ignore errors reading directories
672
+ }
673
+ return files;
674
+ }
675
+
676
+ async function updateManifest({
677
+ apiHost,
678
+ token,
679
+ cid,
680
+ pid,
681
+ serviceId,
682
+ shortImageId,
683
+ comments,
684
+ existingManifest,
685
+ discoveredStorage,
686
+ discoveredComponents,
687
+ discoveredDependencies,
688
+ }) {
689
+ const serviceReferences = existingManifest?.serviceReferences?.Value
690
+ ?? existingManifest?.serviceReferences
691
+ ?? existingManifest?.manifest
692
+ ?? [];
693
+ const storageRefsRaw = existingManifest?.storageReferences?.Value
694
+ ?? existingManifest?.storageReferences
695
+ ?? [];
696
+ const existingStorage = Array.isArray(storageRefsRaw)
697
+ ? storageRefsRaw.map((reference) => ({
698
+ id: reference.id?.Value ?? reference.id,
699
+ enabled: reference.enabled?.Value ?? reference.enabled ?? true,
700
+ checksum: reference.checksum?.Value ?? reference.checksum,
701
+ archived: reference.archived?.Value ?? reference.archived ?? false,
702
+ }))
703
+ : [];
704
+
705
+ // Merge discovered storage with existing storage
706
+ // If a storage object exists in both, keep the existing one (preserves checksum, etc.)
707
+ const storageMap = new Map();
708
+ existingStorage.forEach(s => storageMap.set(s.id, s));
709
+ discoveredStorage.forEach(s => {
710
+ if (!storageMap.has(s.id)) {
711
+ storageMap.set(s.id, s);
712
+ }
713
+ });
714
+ const storageReferences = Array.from(storageMap.values());
715
+
716
+ // Extract existing components and dependencies for the service
717
+ const existingServiceRef = serviceReferences.find(
718
+ (ref) => (ref.serviceName?.Value ?? ref.serviceName) === serviceId
719
+ );
720
+ const existingComponents = existingServiceRef?.components?.Value
721
+ ?? existingServiceRef?.components
722
+ ?? [];
723
+ const existingDependencies = existingServiceRef?.dependencies?.Value
724
+ ?? existingServiceRef?.dependencies
725
+ ?? [];
726
+
727
+ // Components are ServiceComponent objects with {name: string}
728
+ // Merge discovered with existing (preserve existing, add new)
729
+ const componentsMap = new Map();
730
+ // Add existing components
731
+ if (Array.isArray(existingComponents)) {
732
+ existingComponents.forEach(comp => {
733
+ const name = comp.name?.Value ?? comp.name ?? comp;
734
+ if (typeof name === 'string') {
735
+ componentsMap.set(name, { name });
736
+ }
737
+ });
738
+ }
739
+ // Add discovered components (will overwrite existing if same name)
740
+ (discoveredComponents || []).forEach(compName => {
741
+ componentsMap.set(compName, { name: compName });
742
+ });
743
+ const components = Array.from(componentsMap.values());
744
+
745
+ // Dependencies are objects with {id, storageType}, need to merge by id
746
+ const dependenciesMap = new Map();
747
+ // Add existing dependencies
748
+ if (Array.isArray(existingDependencies)) {
749
+ existingDependencies.forEach(dep => {
750
+ const id = dep.id?.Value ?? dep.id ?? dep;
751
+ if (typeof id === 'string') {
752
+ const storageType = dep.storageType?.Value ?? dep.storageType ?? 'mongo';
753
+ dependenciesMap.set(id, { id, storageType });
754
+ }
755
+ });
756
+ }
757
+ // Add discovered dependencies (will overwrite existing if same id)
758
+ (discoveredDependencies || []).forEach(dep => {
759
+ dependenciesMap.set(dep.id, dep);
760
+ });
761
+ const dependencies = Array.from(dependenciesMap.values());
762
+
763
+ let updated = false;
764
+ const mappedServices = serviceReferences.map((reference) => {
765
+ const name = reference.serviceName?.Value ?? reference.serviceName;
766
+ if (name === serviceId) {
767
+ updated = true;
768
+ return {
769
+ serviceName: serviceId,
770
+ enabled: true,
771
+ templateId: reference.templateId?.Value ?? reference.templateId ?? 'small',
772
+ containerHealthCheckPort: reference.containerHealthCheckPort?.Value ?? reference.containerHealthCheckPort ?? 6565,
773
+ imageId: shortImageId,
774
+ imageCpuArch: reference.imageCpuArch?.Value ?? reference.imageCpuArch ?? 'linux/amd64',
775
+ logProvider: reference.logProvider?.Value ?? reference.logProvider ?? 'Clickhouse',
776
+ dependencies,
777
+ components,
778
+ };
779
+ }
780
+ return {
781
+ serviceName: name,
782
+ enabled: reference.enabled?.Value ?? reference.enabled ?? true,
783
+ templateId: reference.templateId?.Value ?? reference.templateId ?? 'small',
784
+ containerHealthCheckPort: reference.containerHealthCheckPort?.Value ?? reference.containerHealthCheckPort ?? 6565,
785
+ imageId: reference.imageId?.Value ?? reference.imageId ?? shortImageId,
786
+ imageCpuArch: reference.imageCpuArch?.Value ?? reference.imageCpuArch ?? 'linux/amd64',
787
+ logProvider: reference.logProvider?.Value ?? reference.logProvider ?? 'Clickhouse',
788
+ dependencies: reference.dependencies?.Value ?? reference.dependencies ?? [],
789
+ components: reference.components?.Value ?? reference.components ?? [],
790
+ };
791
+ });
792
+
793
+ if (!updated) {
794
+ mappedServices.push({
795
+ serviceName: serviceId,
796
+ enabled: true,
797
+ templateId: 'small',
798
+ containerHealthCheckPort: 6565,
799
+ imageId: shortImageId,
800
+ imageCpuArch: 'linux/amd64',
801
+ logProvider: 'Clickhouse',
802
+ dependencies,
803
+ components,
804
+ });
805
+ }
806
+
807
+ const requestBody = {
808
+ autoDeploy: true,
809
+ comments: comments ?? '',
810
+ manifest: mappedServices,
811
+ storageReferences,
812
+ };
813
+
814
+ const response = await fetch(new URL('/api/beamo/manifests', apiHost), {
815
+ method: 'POST',
816
+ headers: {
817
+ Authorization: `Bearer ${token}`,
818
+ Accept: 'application/json',
819
+ 'Content-Type': 'application/json',
820
+ 'X-BEAM-SCOPE': `${cid}.${pid}`,
821
+ },
822
+ body: JSON.stringify(requestBody),
823
+ });
824
+
825
+ if (!response.ok) {
826
+ const text = await response.text();
827
+ throw new Error(`Failed to publish manifest: ${response.status} ${text}`);
828
+ }
829
+ }
830
+
831
+ async function prepareDockerContext({ entry, distDir, openapiPath, packageJson, packageLock, nodeVersion }) {
832
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'beam-node-ms-'));
833
+ const contextDir = path.join(tempRoot, 'context');
834
+ const appDir = path.join(contextDir, 'app');
835
+ await fs.mkdir(appDir, { recursive: true });
836
+
837
+ // Read and modify package.json to handle file: dependencies
838
+ const pkg = JSON.parse(await fs.readFile(packageJson, 'utf8'));
839
+ const modifiedPkg = { ...pkg };
840
+
841
+ // Check if there's a file: dependency for @omen.foundation/node-microservice-runtime
842
+ const runtimeDep = modifiedPkg.dependencies?.['@omen.foundation/node-microservice-runtime'];
843
+ if (runtimeDep?.startsWith('file:')) {
844
+ // Copy the Microservice package from node_modules (it should be installed locally)
845
+ // The file: path is relative to the package.json location
846
+ const packageJsonDir = path.dirname(packageJson);
847
+ const filePath = runtimeDep.replace('file:', '');
848
+ const microservicePath = path.resolve(packageJsonDir, filePath);
849
+ const microserviceDest = path.join(appDir, 'node_modules/@omen.foundation/node-microservice-runtime');
850
+
851
+ try {
852
+ await fs.access(microservicePath);
853
+ await fs.mkdir(path.dirname(microserviceDest), { recursive: true });
854
+ await copyDirectory(microservicePath, microserviceDest);
855
+ // Change the dependency to point to the local copy in Docker
856
+ modifiedPkg.dependencies['@omen.foundation/node-microservice-runtime'] = 'file:./node_modules/@omen.foundation/node-microservice-runtime';
857
+ } catch (error) {
858
+ // If the path doesn't exist, we need to install first
859
+ // For now, throw a helpful error
860
+ throw new Error(
861
+ `Cannot find @omen.foundation/node-microservice-runtime at ${microservicePath}. ` +
862
+ `Please run 'npm install' in the microservice project directory first. ` +
863
+ `Original error: ${error instanceof Error ? error.message : String(error)}`
864
+ );
865
+ }
866
+ }
867
+
868
+ await fs.writeFile(path.join(appDir, 'package.json'), JSON.stringify(modifiedPkg, null, 2), 'utf8');
869
+
870
+ try {
871
+ await fs.copyFile(packageLock, path.join(appDir, 'package-lock.json'));
872
+ } catch {
873
+ // ignore missing package-lock
874
+ }
875
+
876
+ await copyDirectory(distDir, path.join(appDir, 'dist'));
877
+ try {
878
+ await fs.copyFile(openapiPath, path.join(appDir, 'beam_openApi.json'));
879
+ } catch {
880
+ await fs.writeFile(path.join(appDir, 'beam_openApi.json'), '{}\n');
881
+ }
882
+
883
+ // Check if we copied node_modules (for file: dependencies)
884
+ const nodeModulesPath = path.join(appDir, 'node_modules/@omen.foundation');
885
+ const hasNodeModules = await fs.access(nodeModulesPath).then(() => true).catch(() => false);
886
+
887
+ const dockerfile = `# syntax=docker/dockerfile:1
888
+ ARG NODE_VERSION=${nodeVersion}
889
+ FROM node:${nodeVersion}-alpine
890
+
891
+ WORKDIR /beam/service
892
+
893
+ COPY app/package*.json ./
894
+ # Install dependencies first (file: dependency removed from package.json)
895
+ RUN npm install --omit=dev && npm cache clean --force
896
+ ${hasNodeModules ? '# Copy pre-installed @omen.foundation package AFTER npm install (so it doesn't get overwritten)\nCOPY app/node_modules/@omen.foundation ./node_modules/@omen.foundation\n' : ''}
897
+
898
+ COPY app/dist ./dist
899
+ COPY app/beam_openApi.json ./beam_openApi.json
900
+
901
+ # Expose health check port (matches C# microservice behavior)
902
+ EXPOSE 6565
903
+
904
+ ENV NODE_ENV=production
905
+
906
+ # Add startup script to log what's happening and catch errors
907
+ RUN echo '#!/bin/sh' > /beam/service/start.sh && \\
908
+ echo 'echo "Starting Node.js microservice..."' >> /beam/service/start.sh && \\
909
+ echo 'echo "Working directory: $(pwd)"' >> /beam/service/start.sh && \\
910
+ echo 'echo "Node version: $(node --version)"' >> /beam/service/start.sh && \\
911
+ echo 'echo "Files in dist:"' >> /beam/service/start.sh && \\
912
+ echo 'ls -la dist/ || echo "dist directory not found!"' >> /beam/service/start.sh && \\
913
+ echo 'echo "Starting main.js..."' >> /beam/service/start.sh && \\
914
+ echo 'exec node dist/main.js' >> /beam/service/start.sh && \\
915
+ chmod +x /beam/service/start.sh
916
+
917
+ # Use ENTRYPOINT with startup script to ensure we see what's happening
918
+ ENTRYPOINT ["/beam/service/start.sh"]
919
+
920
+ # Debug option: uncomment the line below and comment the ENTRYPOINT above
921
+ # to keep the container alive for debugging (like C# Dockerfile does)
922
+ # ENTRYPOINT ["tail", "-f", "/dev/null"]
923
+ `;
924
+ await fs.writeFile(path.join(contextDir, 'Dockerfile'), dockerfile, 'utf8');
925
+
926
+ return { tempRoot, contextDir };
927
+ }
928
+
929
+ async function main() {
930
+ const args = parseArgs(process.argv.slice(2));
931
+
932
+ if (args.envFile) {
933
+ dotenv.config({ path: path.resolve(args.envFile) });
934
+ }
935
+
936
+ const pkg = await readJson(path.resolve('package.json'));
937
+ const beamableConfig = pkg.beamable || {};
938
+
939
+ const serviceId = args.service || beamableConfig.beamoId || pkg.name;
940
+ ensure(serviceId, 'Service identifier is required. Provide --service or set beamable.beamoId in package.json.');
941
+
942
+ const cid = ensure(args.cid || beamableConfig.cid || process.env.CID, 'CID is required (set CID env var or --cid).');
943
+ const pid = ensure(args.pid || beamableConfig.pid || process.env.PID, 'PID is required (set PID env var or --pid).');
944
+ const host = args.host || beamableConfig.host || process.env.HOST || 'wss://api.beamable.com/socket';
945
+ const apiHost = normalizeApiHost(args.apiHost || beamableConfig.apiHost || process.env.BEAMABLE_API_HOST || host);
946
+ const token = ensure(args.token || process.env.ACCESS_TOKEN || process.env.BEAMABLE_TOKEN, 'Access token is required (set BEAMABLE_TOKEN env var or --token).');
947
+
948
+ const configuredGamePid = args.gamePid || beamableConfig.gamePid || process.env.BEAMABLE_GAME_PID;
949
+ const refreshToken = args.refreshToken || process.env.BEAMABLE_REFRESH_TOKEN || process.env.REFRESH_TOKEN;
950
+
951
+ if (!apiHost) {
952
+ throw new Error('API host could not be determined. Set BEAMABLE_API_HOST or provide --api-host.');
953
+ }
954
+
955
+ // Initialize progress bar (8 main steps)
956
+ const progress = new ProgressBar(8);
957
+ console.log(`${colors.bright}${colors.cyan}Publishing ${serviceId}...${colors.reset}\n`);
958
+
959
+ // Step 1: Build
960
+ progress.start('Building project');
961
+ if (!args.skipValidate) {
962
+ const validateScript = path.resolve(__dirname, 'validate-service.mjs');
963
+ const validateArgs = ['--entry', args.entry, '--output', args.openapi, '--cid', cid, '--pid', pid, '--host', host];
964
+ if (args.envFile) {
965
+ validateArgs.push('--env-file', args.envFile);
966
+ }
967
+ if (args.namePrefix) {
968
+ validateArgs.push('--routing-key', args.namePrefix);
969
+ }
970
+ validateArgs.push('--skip-build');
971
+ await runCommand('npm', ['run', 'build'], { silent: true });
972
+ await runCommand(process.execPath, [validateScript, ...validateArgs], { shell: false, silent: true });
973
+ } else {
974
+ await runCommand('npm', ['run', 'build'], { silent: true });
975
+ }
976
+ progress.complete('Build complete');
977
+
978
+ const packageJsonPath = path.resolve('package.json');
979
+ const packageLockPath = path.resolve('package-lock.json');
980
+ const distDir = path.resolve('dist');
981
+ const openapiPath = path.resolve(args.openapi);
982
+ const entryFile = path.resolve(args.entry);
983
+
984
+ await fs.access(entryFile);
985
+ await fs.access(distDir);
986
+
987
+ let tempRoot;
988
+ try {
989
+ // Step 2: Prepare Docker context
990
+ progress.start('Preparing Docker context');
991
+ const context = await prepareDockerContext({
992
+ entry: entryFile,
993
+ distDir,
994
+ openapiPath,
995
+ packageJson: packageJsonPath,
996
+ packageLock: packageLockPath,
997
+ nodeVersion: args.nodeVersion,
998
+ });
999
+ tempRoot = context.tempRoot;
1000
+ const { contextDir } = context;
1001
+ progress.complete('Docker context prepared');
1002
+
1003
+ // Step 3: Build Docker image
1004
+ progress.start('Building Docker image');
1005
+ const dockerTag = args.dockerTag || `${serviceId.toLowerCase().replace(/[^a-z0-9-_]/g, '-')}:${Date.now()}`;
1006
+ await runCommand('docker', ['build', '-t', dockerTag, contextDir], { cwd: contextDir, silent: true });
1007
+ progress.complete('Docker image built');
1008
+
1009
+ // Step 4: Extract image ID and save
1010
+ progress.start('Preparing image for upload');
1011
+ const inspect = await runCommand('docker', ['image', 'inspect', '--format', '{{.Id}}', dockerTag], { capture: true });
1012
+ const fullImageId = inspect.stdout.trim();
1013
+ const imageTarPath = path.join(tempRoot, `${serviceId.replace(/[^a-z0-9-_]/gi, '_')}.tar`);
1014
+ await runCommand('docker', ['image', 'save', dockerTag, '-o', imageTarPath], { silent: true });
1015
+ progress.complete('Image prepared');
1016
+
1017
+ // Step 5: Authenticate and get registry
1018
+ progress.start('Authenticating');
1019
+ const resolvedGamePid = await resolveGamePid(apiHost, token, cid, pid, configuredGamePid);
1020
+
1021
+ // Verify token is valid (401 means invalid token, 403 might just be permission issue)
1022
+ try {
1023
+ const testUrl = new URL('/basic/accounts/me', apiHost);
1024
+ const testResponse = await fetch(testUrl, {
1025
+ headers: {
1026
+ Authorization: `Bearer ${token}`,
1027
+ Accept: 'application/json',
1028
+ 'X-BEAM-SCOPE': `${cid}.${pid}`,
1029
+ },
1030
+ });
1031
+ if (testResponse.status === 401) {
1032
+ throw new Error(`Token validation failed: ${testResponse.status} ${await testResponse.text()}`);
1033
+ }
1034
+ } catch (error) {
1035
+ if (error.message.includes('401')) {
1036
+ throw new Error(`Token validation failed: ${error.message}. Please run "beamo-node login" again.`);
1037
+ }
1038
+ }
1039
+
1040
+ const registryUrl = await getRegistryUrl(apiHost, token, cid, resolvedGamePid);
1041
+ const uniqueName = md5Hex(`${cid}_${resolvedGamePid}_${serviceId}`).substring(0, 30);
1042
+ progress.complete('Authenticated');
1043
+
1044
+ // Step 6: Upload Docker image
1045
+ progress.start('Uploading Docker image to registry');
1046
+ await uploadDockerImage({
1047
+ apiHost,
1048
+ registryUrl,
1049
+ cid,
1050
+ pid,
1051
+ gamePid: resolvedGamePid,
1052
+ token,
1053
+ serviceId,
1054
+ uniqueName,
1055
+ imageTarPath,
1056
+ fullImageId,
1057
+ progress,
1058
+ });
1059
+ progress.complete('Image uploaded');
1060
+
1061
+ // Step 7: Discover storage, components, and dependencies
1062
+ progress.start('Discovering storage objects and components');
1063
+ const shortImageId = shortDigest(fullImageId);
1064
+ const existingManifest = await fetchCurrentManifest(apiHost, token, cid, pid);
1065
+ const discoveredStorage = await discoverStorageObjects('src');
1066
+ const discoveredComponents = await discoverFederationComponents('src');
1067
+ // Dependencies are ServiceDependencyReference objects with id and storageType
1068
+ // storageType is typically "mongo" for MongoDB storage objects
1069
+ const discoveredDependencies = discoveredStorage.map(s => ({
1070
+ id: s.id,
1071
+ storageType: 'mongo', // All storage objects use MongoDB
1072
+ }));
1073
+ progress.complete('Storage and components discovered');
1074
+
1075
+ // Step 8: Publish manifest
1076
+ progress.start('Publishing manifest');
1077
+ await updateManifest({
1078
+ apiHost,
1079
+ token,
1080
+ cid,
1081
+ pid,
1082
+ serviceId,
1083
+ shortImageId,
1084
+ comments: args.comments,
1085
+ existingManifest,
1086
+ discoveredStorage,
1087
+ discoveredComponents,
1088
+ discoveredDependencies,
1089
+ });
1090
+ progress.complete('Manifest published');
1091
+
1092
+ // Success message
1093
+ console.log(`\n${colors.green}${colors.bright}✓ Publish complete!${colors.reset}`);
1094
+ console.log(`${colors.dim} Service:${colors.reset} ${serviceId}`);
1095
+ console.log(`${colors.dim} Image ID:${colors.reset} ${fullImageId}`);
1096
+ console.log(`${colors.dim} Registry:${colors.reset} ${registryUrl}${uniqueName}`);
1097
+ } finally {
1098
+ if (tempRoot) {
1099
+ await fs.rm(tempRoot, { recursive: true, force: true });
1100
+ }
1101
+ }
1102
+ }
1103
+
1104
+ main().catch(async (error) => {
1105
+ // Show clean error message
1106
+ console.error(`\n${colors.red}${colors.bright}✗ Publish failed${colors.reset}`);
1107
+ if (error instanceof Error) {
1108
+ console.error(`${colors.red}${error.message}${colors.reset}`);
1109
+ if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
1110
+ console.error(`\n${colors.dim}Stack:${colors.reset} ${error.stack}`);
1111
+ if (error.cause) {
1112
+ console.error(`${colors.dim}Cause:${colors.reset} ${error.cause}`);
1113
+ }
1114
+ if (error.stdout) {
1115
+ console.error(`${colors.dim}stdout:${colors.reset} ${error.stdout}`);
1116
+ }
1117
+ if (error.stderr) {
1118
+ console.error(`${colors.dim}stderr:${colors.reset} ${error.stderr}`);
1119
+ }
1120
+ console.error(`\n${colors.dim}Full error:${colors.reset}`, JSON.stringify(error, Object.getOwnPropertyNames(error), 2));
1121
+ }
1122
+ } else {
1123
+ console.error(`${colors.red}${error}${colors.reset}`);
1124
+ }
1125
+ process.exit(1);
1126
+ });