@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.
- package/package.json +61 -61
- 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
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
console.log(`[beamo-node]
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
const
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
//
|
|
830
|
-
//
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
const
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
//
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
?? existingManifest?.
|
|
905
|
-
??
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
??
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
//
|
|
939
|
-
const
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
const
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
}
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
//
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
//
|
|
1108
|
-
// console.error(`[beamo-node]
|
|
1109
|
-
// console.error(`[beamo-node]
|
|
1110
|
-
// }
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
const
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
}
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
if (
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
const
|
|
1278
|
-
const
|
|
1279
|
-
const
|
|
1280
|
-
|
|
1281
|
-
const
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
await runCommand(
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
//
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
//
|
|
1350
|
-
//
|
|
1351
|
-
//
|
|
1352
|
-
// console.error(`[beamo-node]
|
|
1353
|
-
// console.error(`[beamo-node]
|
|
1354
|
-
// console.error(`[beamo-node]
|
|
1355
|
-
// console.error(`[beamo-node]
|
|
1356
|
-
//
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
//
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
//
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
//
|
|
1396
|
-
// console.error(`[beamo-node]
|
|
1397
|
-
// console.error(`[beamo-node]
|
|
1398
|
-
// console.error(`[beamo-node]
|
|
1399
|
-
//
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
//
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
//
|
|
1429
|
-
//
|
|
1430
|
-
//
|
|
1431
|
-
//
|
|
1432
|
-
//
|
|
1433
|
-
//
|
|
1434
|
-
//
|
|
1435
|
-
//
|
|
1436
|
-
|
|
1437
|
-
//
|
|
1438
|
-
//
|
|
1439
|
-
//
|
|
1440
|
-
//
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
//
|
|
1449
|
-
// console.error(`[beamo-node]
|
|
1450
|
-
//
|
|
1451
|
-
|
|
1452
|
-
//
|
|
1453
|
-
//
|
|
1454
|
-
//
|
|
1455
|
-
|
|
1456
|
-
//
|
|
1457
|
-
//
|
|
1458
|
-
//
|
|
1459
|
-
//
|
|
1460
|
-
//
|
|
1461
|
-
//
|
|
1462
|
-
//
|
|
1463
|
-
//
|
|
1464
|
-
//
|
|
1465
|
-
//
|
|
1466
|
-
//
|
|
1467
|
-
//
|
|
1468
|
-
// const
|
|
1469
|
-
// console.error(`[beamo-node]
|
|
1470
|
-
//
|
|
1471
|
-
//
|
|
1472
|
-
//
|
|
1473
|
-
//
|
|
1474
|
-
// }
|
|
1475
|
-
//
|
|
1476
|
-
//
|
|
1477
|
-
|
|
1478
|
-
//
|
|
1479
|
-
//
|
|
1480
|
-
|
|
1481
|
-
//
|
|
1482
|
-
|
|
1483
|
-
//
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
//
|
|
1488
|
-
//
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
if (
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
//
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
//
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
//
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
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
|
+
});
|