openship 0.2.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +104 -61
  2. package/dist/bare-ITM45QBX.js +18 -0
  3. package/dist/bowser-MFQA6OS4.js +2821 -0
  4. package/dist/chunk-3U4SPZD5.js +241 -0
  5. package/dist/chunk-46YJFP4S.js +1771 -0
  6. package/dist/chunk-4CQWT2WL.js +116 -0
  7. package/dist/chunk-4FABQGQP.js +23 -0
  8. package/dist/chunk-4LEL3H2Z.js +84 -0
  9. package/dist/chunk-4MG3BVHE.js +326 -0
  10. package/dist/chunk-4VNS5WPM.js +42 -0
  11. package/dist/chunk-5N7VUGL6.js +2094 -0
  12. package/dist/chunk-5ZCKQ2MI.js +1360 -0
  13. package/dist/chunk-6ERIMTBC.js +1412 -0
  14. package/dist/chunk-7UOB5GGQ.js +132 -0
  15. package/dist/chunk-BUBSSKO6.js +1384 -0
  16. package/dist/chunk-E6W2JMMU.js +341 -0
  17. package/dist/chunk-E77LJWJK.js +1535 -0
  18. package/dist/chunk-F4DFVG6Q.js +573 -0
  19. package/dist/chunk-HFYDYK4D.js +1092 -0
  20. package/dist/chunk-JR2OCMJA.js +46 -0
  21. package/dist/chunk-JUJBBF5U.js +746 -0
  22. package/dist/chunk-JZLNHGLW.js +19145 -0
  23. package/dist/chunk-KD7VO4LG.js +2617 -0
  24. package/dist/chunk-LBFH7THC.js +435 -0
  25. package/dist/chunk-NB2VEIWG.js +129 -0
  26. package/dist/chunk-OJE6YWPQ.js +2866 -0
  27. package/dist/chunk-QKFRZNKN.js +4941 -0
  28. package/dist/chunk-RBZPFTHV.js +13354 -0
  29. package/dist/chunk-S6DSRLNE.js +121 -0
  30. package/dist/chunk-TEYJYSMT.js +409 -0
  31. package/dist/chunk-U2DPZSXA.js +112 -0
  32. package/dist/chunk-WJPCBU7Z.js +3147 -0
  33. package/dist/chunk-XIFRMKYT.js +13 -0
  34. package/dist/chunk-XVSAUO2I.js +7946 -0
  35. package/dist/chunk-Y37KHU7L.js +31 -0
  36. package/dist/chunk-YL67EWYZ.js +416 -0
  37. package/dist/chunk-ZKJOFWBJ.js +1879 -0
  38. package/dist/cloud-JMPHO3CW.js +7 -0
  39. package/dist/cloud-W5G6YXAY.js +24 -0
  40. package/dist/dist-L3MPOWCA.js +65 -0
  41. package/dist/dist-es-4C322O5D.js +22 -0
  42. package/dist/dist-es-GTAMF674.js +87 -0
  43. package/dist/dist-es-KE56MWYO.js +169 -0
  44. package/dist/dist-es-NRZIQY5B.js +484 -0
  45. package/dist/dist-es-WZCR7DHQ.js +317 -0
  46. package/dist/dist-es-XQCOTGC7.js +376 -0
  47. package/dist/dist-es-ZW3HFQUM.js +69 -0
  48. package/dist/docker-3FVJMFC3.js +12 -0
  49. package/dist/docker-edge-executor-ZIWAZWPL.js +9 -0
  50. package/dist/edge-import-53M5DGWZ.js +39 -0
  51. package/dist/event-streams-7LGDL56D.js +42 -0
  52. package/dist/executor-J7BHW5HF.js +26 -0
  53. package/dist/index.js +2459 -3461
  54. package/dist/loadSso-N273C5I7.js +577 -0
  55. package/dist/nginx-YZWKYKQU.js +16 -0
  56. package/dist/noop-RRNCIUI5.js +7 -0
  57. package/dist/openresty-lua-HAO4EUDY.js +32 -0
  58. package/dist/server/index.js +217916 -205055
  59. package/dist/server/migrations/0057_complete_prism.sql +18 -0
  60. package/dist/server/migrations/0058_add_server_is_local.sql +1 -0
  61. package/dist/server/migrations/0059_legal_fixer.sql +18 -0
  62. package/dist/server/migrations/0060_add_route_strategy_host_port.sql +3 -0
  63. package/dist/server/migrations/0061_notification_default_multi_kind.sql +3 -0
  64. package/dist/server/migrations/0062_add_incoming_webhook.sql +21 -0
  65. package/dist/server/migrations/0063_add_migration_logs.sql +1 -0
  66. package/dist/server/migrations/0064_add_migration_partial.sql +2 -0
  67. package/dist/server/migrations/0065_custom_app_template.sql +14 -0
  68. package/dist/server/migrations/0066_ensure_migration_partial_columns.sql +2 -0
  69. package/dist/server/migrations/0067_webhook_delivery.sql +28 -0
  70. package/dist/server/migrations/0068_add_migration_target_volumes.sql +1 -0
  71. package/dist/server/migrations/0069_add_project_composite_routes.sql +1 -0
  72. package/dist/server/migrations/0070_forward_git_to_server.sql +1 -0
  73. package/dist/server/migrations/meta/0057_snapshot.json +9326 -0
  74. package/dist/server/migrations/meta/0058_snapshot.json +9333 -0
  75. package/dist/server/migrations/meta/0059_snapshot.json +9495 -0
  76. package/dist/server/migrations/meta/0060_snapshot.json +9515 -0
  77. package/dist/server/migrations/meta/_journal.json +99 -1
  78. package/dist/setup-5X3WEEHA.js +14 -0
  79. package/dist/signin-DR462ERO.js +663 -0
  80. package/dist/sso-oidc-VBZLJUK5.js +830 -0
  81. package/dist/sts-EJLZ4Z77.js +1422 -0
  82. package/package.json +6 -2
@@ -0,0 +1,3147 @@
1
+ import {
2
+ checkGit,
3
+ installGit
4
+ } from "./chunk-46YJFP4S.js";
5
+ import {
6
+ createDockerBuildContext,
7
+ normalizeDockerRelativePath,
8
+ resolveDockerfileCandidates
9
+ } from "./chunk-HFYDYK4D.js";
10
+ import {
11
+ prepareStackOutput,
12
+ resolveProjectDir,
13
+ resolveStaticOutputPath,
14
+ runLocalBuild
15
+ } from "./chunk-NB2VEIWG.js";
16
+ import {
17
+ BuildLogger,
18
+ injectGitToken,
19
+ runBuildPipeline,
20
+ sq,
21
+ transferLocalDirectory
22
+ } from "./chunk-YL67EWYZ.js";
23
+ import {
24
+ isRuntimeNotFoundError
25
+ } from "./chunk-4LEL3H2Z.js";
26
+ import {
27
+ STACKS,
28
+ SYSTEM,
29
+ TRANSFER_EXCLUDES,
30
+ buildOutputTransferExcludes,
31
+ missingOutputDirectoryMessage,
32
+ packageManagerEnsureCommand,
33
+ safeErrorMessage
34
+ } from "./chunk-JZLNHGLW.js";
35
+
36
+ // ../../packages/adapters/src/runtime/cloud.ts
37
+ import { readFile } from "fs/promises";
38
+ import { join, posix } from "path";
39
+
40
+ // ../../packages/adapters/src/types.ts
41
+ var DEFAULT_RESOURCE_CONFIG = {
42
+ cpuCores: 0.5,
43
+ memoryMb: 512,
44
+ diskMb: 5120
45
+ };
46
+ function cloudCpus(cpuCores) {
47
+ return Math.max(0.25, cpuCores);
48
+ }
49
+
50
+ // ../../packages/adapters/src/runtime/cloud.ts
51
+ import { PassThrough, Writable } from "stream";
52
+
53
+ // ../../packages/adapters/src/dockerfile/parser.ts
54
+ var KNOWN_KEYWORDS = /* @__PURE__ */ new Set([
55
+ "ADD",
56
+ "ARG",
57
+ "CMD",
58
+ "COPY",
59
+ "ENTRYPOINT",
60
+ "ENV",
61
+ "EXPOSE",
62
+ "FROM",
63
+ "HEALTHCHECK",
64
+ "LABEL",
65
+ "RUN",
66
+ "SHELL",
67
+ "STOPSIGNAL",
68
+ "USER",
69
+ "VOLUME",
70
+ "WORKDIR",
71
+ "ONBUILD",
72
+ "MAINTAINER"
73
+ ]);
74
+ var FLAGGED_KEYWORDS = /* @__PURE__ */ new Set([
75
+ "ADD",
76
+ "COPY",
77
+ "FROM",
78
+ "RUN"
79
+ ]);
80
+ function isCommentLine(line) {
81
+ return line.trimStart().startsWith("#");
82
+ }
83
+ function endsWithContinuation(line) {
84
+ let slashCount = 0;
85
+ for (let i = line.length - 1; i >= 0 && line[i] === "\\"; i--) {
86
+ slashCount++;
87
+ }
88
+ return slashCount % 2 === 1;
89
+ }
90
+ function joinLogicalLines(source) {
91
+ const result = [];
92
+ const lines = source.replace(/\r\n/g, "\n").split("\n");
93
+ let current = "";
94
+ let startLine = 0;
95
+ for (let i = 0; i < lines.length; i++) {
96
+ const lineNo = i + 1;
97
+ const stripped = lines[i].trimEnd();
98
+ if (!current && (!stripped.trim() || isCommentLine(stripped))) {
99
+ continue;
100
+ }
101
+ if (current && isCommentLine(stripped)) {
102
+ continue;
103
+ }
104
+ if (!current) {
105
+ startLine = lineNo;
106
+ }
107
+ if (endsWithContinuation(stripped)) {
108
+ current += `${stripped.slice(0, -1).trimEnd()} `;
109
+ continue;
110
+ }
111
+ current += stripped;
112
+ const raw = current.trim();
113
+ if (raw) {
114
+ result.push({ line: startLine, raw });
115
+ }
116
+ current = "";
117
+ }
118
+ if (current.trim()) {
119
+ result.push({ line: startLine, raw: current.trim() });
120
+ }
121
+ return result;
122
+ }
123
+ function parseLeadingFlags(value) {
124
+ const flags = {};
125
+ let rest = value.trimStart();
126
+ while (rest.startsWith("--")) {
127
+ const match = rest.match(/^--([A-Za-z0-9_.-]+)(?:=("[^"]*"|'[^']*'|\S+))?(?=\s|$)/);
128
+ if (!match) break;
129
+ const [, name, rawValue] = match;
130
+ flags[name] = rawValue === void 0 ? true : rawValue.replace(/^["']|["']$/g, "");
131
+ rest = rest.slice(match[0].length).trimStart();
132
+ }
133
+ return { flags, rest };
134
+ }
135
+ function toKeyword(value) {
136
+ const upper = value.toUpperCase();
137
+ return KNOWN_KEYWORDS.has(upper) ? upper : "OTHER";
138
+ }
139
+ function parseDockerfile(source) {
140
+ const diagnostics = [];
141
+ const instructions = [];
142
+ for (const logical of joinLogicalLines(source)) {
143
+ const match = logical.raw.match(/^([A-Za-z][A-Za-z0-9_-]*)\s*(.*)$/);
144
+ if (!match) {
145
+ diagnostics.push({
146
+ severity: "warning",
147
+ line: logical.line,
148
+ message: `Could not parse Dockerfile instruction: ${logical.raw}`
149
+ });
150
+ continue;
151
+ }
152
+ const originalKeyword = match[1];
153
+ const keyword = toKeyword(originalKeyword);
154
+ const rawValue = match[2] ?? "";
155
+ const parsed = FLAGGED_KEYWORDS.has(keyword) ? parseLeadingFlags(rawValue) : { flags: {}, rest: rawValue.trimStart() };
156
+ instructions.push({
157
+ keyword,
158
+ originalKeyword,
159
+ value: parsed.rest,
160
+ flags: parsed.flags,
161
+ line: logical.line,
162
+ raw: logical.raw
163
+ });
164
+ }
165
+ return { instructions, warnings: diagnostics };
166
+ }
167
+
168
+ // ../../packages/adapters/src/dockerfile/compiler.ts
169
+ var DEFAULT_WORKDIR = "/";
170
+ var PATHLIKE_RE = /^[A-Za-z0-9_@%+=:,./-]+$/;
171
+ function diagnostic(instruction, severity, message) {
172
+ return {
173
+ severity,
174
+ instruction: instruction.keyword,
175
+ line: instruction.line,
176
+ message
177
+ };
178
+ }
179
+ function splitFirstEquals(value) {
180
+ const index = value.indexOf("=");
181
+ if (index === -1) {
182
+ return [value, null];
183
+ }
184
+ return [value.slice(0, index), value.slice(index + 1)];
185
+ }
186
+ function parseJsonArray(value) {
187
+ const trimmed = value.trim();
188
+ if (!trimmed.startsWith("[")) {
189
+ return null;
190
+ }
191
+ try {
192
+ const parsed = JSON.parse(trimmed);
193
+ if (!Array.isArray(parsed)) {
194
+ return null;
195
+ }
196
+ return parsed.map((part) => String(part));
197
+ } catch {
198
+ return null;
199
+ }
200
+ }
201
+ function splitWords(value) {
202
+ const words = [];
203
+ let current = "";
204
+ let inSingle = false;
205
+ let inDouble = false;
206
+ let escaped = false;
207
+ for (const char of value.trim()) {
208
+ if (escaped) {
209
+ current += char;
210
+ escaped = false;
211
+ continue;
212
+ }
213
+ if (char === "\\") {
214
+ escaped = true;
215
+ continue;
216
+ }
217
+ if (char === "'" && !inDouble) {
218
+ inSingle = !inSingle;
219
+ continue;
220
+ }
221
+ if (char === '"' && !inSingle) {
222
+ inDouble = !inDouble;
223
+ continue;
224
+ }
225
+ if (/\s/.test(char) && !inSingle && !inDouble) {
226
+ if (current) {
227
+ words.push(current);
228
+ current = "";
229
+ }
230
+ continue;
231
+ }
232
+ current += char;
233
+ }
234
+ if (escaped) {
235
+ current += "\\";
236
+ }
237
+ if (current) {
238
+ words.push(current);
239
+ }
240
+ return words;
241
+ }
242
+ function shellQuote(value) {
243
+ if (PATHLIKE_RE.test(value)) {
244
+ return value;
245
+ }
246
+ return `'${value.replace(/'/g, "'\\''")}'`;
247
+ }
248
+ function commandToShell(command) {
249
+ if (command.form === "exec") {
250
+ return command.value.map(shellQuote).join(" ");
251
+ }
252
+ return command.value;
253
+ }
254
+ function toWorkspaceCommand(instruction) {
255
+ const exec = parseJsonArray(instruction.value);
256
+ if (exec) {
257
+ return {
258
+ form: "exec",
259
+ value: exec,
260
+ raw: instruction.value
261
+ };
262
+ }
263
+ return {
264
+ form: "shell",
265
+ value: instruction.value.trim(),
266
+ raw: instruction.value
267
+ };
268
+ }
269
+ function combineStartCommand(entrypoint, cmd) {
270
+ if (!entrypoint && !cmd) {
271
+ return void 0;
272
+ }
273
+ if (!entrypoint) {
274
+ return commandToShell(cmd);
275
+ }
276
+ if (!cmd) {
277
+ return commandToShell(entrypoint);
278
+ }
279
+ if (entrypoint.form === "shell") {
280
+ return commandToShell(entrypoint);
281
+ }
282
+ const entrypointShell = commandToShell(entrypoint);
283
+ const cmdShell = commandToShell(cmd);
284
+ return cmdShell ? `${entrypointShell} ${cmdShell}` : entrypointShell;
285
+ }
286
+ function normalizeWorkdir(current, next) {
287
+ const value = next.trim();
288
+ if (!value) {
289
+ return current;
290
+ }
291
+ const raw = value.startsWith("/") ? value : `${current.replace(/\/+$/g, "")}/${value}`;
292
+ const parts = [];
293
+ for (const part of raw.split("/")) {
294
+ if (!part || part === ".") {
295
+ continue;
296
+ }
297
+ if (part === "..") {
298
+ parts.pop();
299
+ continue;
300
+ }
301
+ parts.push(part);
302
+ }
303
+ return `/${parts.join("/")}`;
304
+ }
305
+ function parseKeyValueList(value) {
306
+ const tokens = splitWords(value);
307
+ const result = {};
308
+ if (tokens.length === 0) {
309
+ return result;
310
+ }
311
+ if (!tokens[0].includes("=") && tokens.length >= 2) {
312
+ result[tokens[0]] = tokens.slice(1).join(" ");
313
+ return result;
314
+ }
315
+ for (const token of tokens) {
316
+ const [key, tokenValue] = splitFirstEquals(token);
317
+ if (!key || tokenValue === null) {
318
+ continue;
319
+ }
320
+ result[key] = tokenValue;
321
+ }
322
+ return result;
323
+ }
324
+ function parseFromInstruction(instruction) {
325
+ const parts = splitWords(instruction.value);
326
+ const platformFlag = instruction.flags.platform;
327
+ const platform = typeof platformFlag === "string" ? platformFlag : void 0;
328
+ if (parts.length === 0) {
329
+ return {
330
+ baseImage: "",
331
+ platform,
332
+ diagnostic: diagnostic(instruction, "error", "FROM is missing a base image.")
333
+ };
334
+ }
335
+ const baseImage = parts[0];
336
+ let name;
337
+ let warning;
338
+ if (parts.length >= 3 && parts[1]?.toUpperCase() === "AS") {
339
+ name = parts[2];
340
+ if (parts.length > 3) {
341
+ warning = diagnostic(instruction, "warning", "Ignoring extra tokens after FROM stage alias.");
342
+ }
343
+ } else if (parts.length > 1) {
344
+ warning = diagnostic(
345
+ instruction,
346
+ "warning",
347
+ "FROM contains extra tokens that are not part of a stage alias."
348
+ );
349
+ }
350
+ return { baseImage, name, platform, diagnostic: warning };
351
+ }
352
+ function createStage(index, from, defaultWorkdir) {
353
+ return {
354
+ index,
355
+ name: from.name,
356
+ baseImage: from.baseImage,
357
+ platform: from.platform,
358
+ workdir: defaultWorkdir,
359
+ env: {},
360
+ args: {},
361
+ labels: {},
362
+ copies: [],
363
+ runs: [],
364
+ steps: [],
365
+ exposedPorts: [],
366
+ unsupported: []
367
+ };
368
+ }
369
+ function parseCopyLikeInstruction(instruction, kind, workdir) {
370
+ const diagnostics = [];
371
+ const parts = parseJsonArray(instruction.value) ?? splitWords(instruction.value);
372
+ if (parts.length < 2) {
373
+ return {
374
+ diagnostics: [
375
+ diagnostic(
376
+ instruction,
377
+ "error",
378
+ `${instruction.keyword} needs at least one source and one destination.`
379
+ )
380
+ ]
381
+ };
382
+ }
383
+ const from = typeof instruction.flags.from === "string" ? instruction.flags.from : void 0;
384
+ const sources = parts.slice(0, -1);
385
+ const destination = parts[parts.length - 1] ?? "";
386
+ if (kind === "add") {
387
+ diagnostics.push(
388
+ diagnostic(
389
+ instruction,
390
+ "warning",
391
+ "ADD is recorded, but remote URLs and archive extraction need explicit workspace execution support."
392
+ )
393
+ );
394
+ }
395
+ for (const flag of ["chown", "chmod", "link"]) {
396
+ if (instruction.flags[flag] !== void 0) {
397
+ diagnostics.push(
398
+ diagnostic(
399
+ instruction,
400
+ "warning",
401
+ `${instruction.keyword} --${flag} is Docker-specific metadata and needs explicit workspace handling.`
402
+ )
403
+ );
404
+ }
405
+ }
406
+ return {
407
+ step: {
408
+ kind,
409
+ sources,
410
+ destination,
411
+ from,
412
+ workdir,
413
+ flags: instruction.flags,
414
+ line: instruction.line,
415
+ raw: instruction.value
416
+ },
417
+ diagnostics
418
+ };
419
+ }
420
+ function parseExposeInstruction(instruction) {
421
+ const ports = [];
422
+ const diagnostics = [];
423
+ for (const token of splitWords(instruction.value)) {
424
+ const match = token.match(/^(\d+)(?:\/(tcp|udp))?$/i);
425
+ if (!match) {
426
+ diagnostics.push(
427
+ diagnostic(instruction, "warning", `Could not parse EXPOSE value "${token}".`)
428
+ );
429
+ continue;
430
+ }
431
+ ports.push({
432
+ port: Number(match[1]),
433
+ protocol: match[2]?.toLowerCase() ?? "tcp"
434
+ });
435
+ }
436
+ return { ports, diagnostics };
437
+ }
438
+ function parseArgInstruction(instruction, buildArgs) {
439
+ const [rawKey, rawDefault] = splitFirstEquals(instruction.value.trim());
440
+ const key = rawKey.trim();
441
+ if (!key) {
442
+ return {
443
+ diagnostic: diagnostic(instruction, "warning", "ARG is missing a name.")
444
+ };
445
+ }
446
+ return {
447
+ key,
448
+ value: buildArgs[key] ?? rawDefault
449
+ };
450
+ }
451
+ function currentOrDiagnostic(currentStage, instruction) {
452
+ if (currentStage) {
453
+ return { stage: currentStage };
454
+ }
455
+ return {
456
+ diagnostic: diagnostic(
457
+ instruction,
458
+ instruction.keyword === "ARG" ? "warning" : "error",
459
+ `${instruction.keyword} appears before the first FROM stage.`
460
+ )
461
+ };
462
+ }
463
+ function selectFinalStage(stages, targetStage) {
464
+ if (stages.length === 0) {
465
+ return null;
466
+ }
467
+ if (!targetStage) {
468
+ return stages[stages.length - 1] ?? null;
469
+ }
470
+ return stages.find((stage) => stage.name === targetStage || String(stage.index) === targetStage) ?? null;
471
+ }
472
+ function isPreviousStageReference(stages, currentStage, ref) {
473
+ const numeric = Number(ref);
474
+ if (Number.isInteger(numeric) && String(numeric) === ref) {
475
+ return numeric >= 0 && numeric < currentStage.index;
476
+ }
477
+ return stages.some((stage) => stage.index < currentStage.index && stage.name === ref);
478
+ }
479
+ function findPreviousStageReference(stages, ref) {
480
+ const numeric = Number(ref);
481
+ if (Number.isInteger(numeric) && String(numeric) === ref) {
482
+ return stages[numeric] ?? null;
483
+ }
484
+ return stages.find((stage) => stage.name === ref) ?? null;
485
+ }
486
+ function inheritStageImageConfig(target, base) {
487
+ target.workdir = base.workdir;
488
+ target.env = { ...base.env };
489
+ target.labels = { ...base.labels };
490
+ target.exposedPorts = [...base.exposedPorts];
491
+ target.cmd = base.cmd;
492
+ target.entrypoint = base.entrypoint;
493
+ target.startCommand = base.startCommand;
494
+ target.user = base.user;
495
+ target.shell = base.shell ? [...base.shell] : void 0;
496
+ }
497
+ function validateStageArtifactCopies(stages) {
498
+ const diagnostics = [];
499
+ for (const stage of stages) {
500
+ for (const copy of stage.copies) {
501
+ if (!copy.from) continue;
502
+ if (isPreviousStageReference(stages, stage, copy.from)) continue;
503
+ diagnostics.push({
504
+ severity: "unsupported",
505
+ instruction: copy.kind === "copy" ? "COPY" : "ADD",
506
+ line: copy.line,
507
+ message: `${copy.kind.toUpperCase()} --from=${copy.from} references an external image, current stage, or later stage. Cloud Dockerfile builds currently support --from only for previous stages in the same Dockerfile.`
508
+ });
509
+ }
510
+ }
511
+ return diagnostics;
512
+ }
513
+ function compileDockerfileParseResult(parseResult, options = {}) {
514
+ const defaultWorkdir = normalizeWorkdir("/", options.defaultWorkdir ?? DEFAULT_WORKDIR);
515
+ const buildArgs = options.buildArgs ?? {};
516
+ const diagnostics = [...parseResult.warnings];
517
+ const globalArgs = {};
518
+ const stages = [];
519
+ let currentStage = null;
520
+ for (const instruction of parseResult.instructions) {
521
+ if (instruction.keyword === "ARG" && !currentStage) {
522
+ const parsedArg = parseArgInstruction(instruction, buildArgs);
523
+ if (parsedArg.diagnostic) {
524
+ diagnostics.push(parsedArg.diagnostic);
525
+ continue;
526
+ }
527
+ if (parsedArg.key) {
528
+ globalArgs[parsedArg.key] = parsedArg.value ?? null;
529
+ }
530
+ continue;
531
+ }
532
+ if (instruction.keyword === "FROM") {
533
+ const parsedFrom = parseFromInstruction(instruction);
534
+ if (parsedFrom.diagnostic) {
535
+ diagnostics.push(parsedFrom.diagnostic);
536
+ }
537
+ currentStage = createStage(stages.length, parsedFrom, defaultWorkdir);
538
+ const baseStage = findPreviousStageReference(stages, parsedFrom.baseImage);
539
+ if (baseStage) {
540
+ inheritStageImageConfig(currentStage, baseStage);
541
+ }
542
+ stages.push(currentStage);
543
+ continue;
544
+ }
545
+ const stageResult = currentOrDiagnostic(currentStage, instruction);
546
+ if (!stageResult.stage) {
547
+ if (stageResult.diagnostic) {
548
+ diagnostics.push(stageResult.diagnostic);
549
+ }
550
+ continue;
551
+ }
552
+ const stage = stageResult.stage;
553
+ switch (instruction.keyword) {
554
+ case "ARG": {
555
+ const parsedArg = parseArgInstruction(instruction, buildArgs);
556
+ if (parsedArg.diagnostic) {
557
+ diagnostics.push(parsedArg.diagnostic);
558
+ } else if (parsedArg.key) {
559
+ stage.args[parsedArg.key] = parsedArg.value ?? null;
560
+ }
561
+ break;
562
+ }
563
+ case "ENV":
564
+ Object.assign(stage.env, parseKeyValueList(instruction.value));
565
+ break;
566
+ case "LABEL":
567
+ Object.assign(stage.labels, parseKeyValueList(instruction.value));
568
+ break;
569
+ case "WORKDIR":
570
+ stage.workdir = normalizeWorkdir(stage.workdir, instruction.value);
571
+ break;
572
+ case "COPY":
573
+ case "ADD": {
574
+ const parsedCopy = parseCopyLikeInstruction(
575
+ instruction,
576
+ instruction.keyword === "COPY" ? "copy" : "add",
577
+ stage.workdir
578
+ );
579
+ if (parsedCopy.step) {
580
+ stage.copies.push(parsedCopy.step);
581
+ stage.steps.push({ type: "copy", copy: parsedCopy.step });
582
+ }
583
+ diagnostics.push(...parsedCopy.diagnostics);
584
+ break;
585
+ }
586
+ case "RUN": {
587
+ const command = toWorkspaceCommand(instruction);
588
+ const runStep = {
589
+ command: commandToShell(command),
590
+ form: command.form,
591
+ workdir: stage.workdir,
592
+ env: { ...stage.env },
593
+ args: { ...stage.args },
594
+ flags: instruction.flags,
595
+ line: instruction.line,
596
+ raw: instruction.value
597
+ };
598
+ if (command.form === "exec") {
599
+ runStep.exec = command.value;
600
+ }
601
+ stage.runs.push(runStep);
602
+ stage.steps.push({ type: "run", run: runStep });
603
+ for (const flag of Object.keys(instruction.flags)) {
604
+ diagnostics.push(
605
+ diagnostic(
606
+ instruction,
607
+ "warning",
608
+ `RUN --${flag} is a Docker BuildKit feature and needs explicit workspace support.`
609
+ )
610
+ );
611
+ }
612
+ break;
613
+ }
614
+ case "EXPOSE": {
615
+ const exposed = parseExposeInstruction(instruction);
616
+ stage.exposedPorts.push(...exposed.ports);
617
+ diagnostics.push(...exposed.diagnostics);
618
+ break;
619
+ }
620
+ case "CMD":
621
+ stage.cmd = toWorkspaceCommand(instruction);
622
+ stage.startCommand = combineStartCommand(stage.entrypoint, stage.cmd);
623
+ break;
624
+ case "ENTRYPOINT":
625
+ stage.entrypoint = toWorkspaceCommand(instruction);
626
+ stage.startCommand = combineStartCommand(stage.entrypoint, stage.cmd);
627
+ break;
628
+ case "USER":
629
+ stage.user = instruction.value.trim();
630
+ break;
631
+ case "SHELL": {
632
+ const shell = parseJsonArray(instruction.value);
633
+ if (shell) {
634
+ stage.shell = shell;
635
+ diagnostics.push(
636
+ diagnostic(
637
+ instruction,
638
+ "warning",
639
+ "SHELL changes how later shell-form RUN/CMD instructions execute; the plan records it but does not emulate shell behavior."
640
+ )
641
+ );
642
+ } else {
643
+ const item = diagnostic(
644
+ instruction,
645
+ "unsupported",
646
+ "SHELL must use JSON array form to be planned."
647
+ );
648
+ diagnostics.push(item);
649
+ stage.unsupported.push(item);
650
+ }
651
+ break;
652
+ }
653
+ case "HEALTHCHECK":
654
+ case "ONBUILD":
655
+ case "STOPSIGNAL":
656
+ case "VOLUME": {
657
+ const item = diagnostic(
658
+ instruction,
659
+ "unsupported",
660
+ `${instruction.keyword} is runtime/image metadata and cannot be represented as workspace build commands yet.`
661
+ );
662
+ diagnostics.push(item);
663
+ stage.unsupported.push(item);
664
+ break;
665
+ }
666
+ case "MAINTAINER":
667
+ diagnostics.push(
668
+ diagnostic(
669
+ instruction,
670
+ "warning",
671
+ "MAINTAINER is deprecated and ignored by the workspace plan."
672
+ )
673
+ );
674
+ break;
675
+ case "OTHER": {
676
+ const item = diagnostic(
677
+ instruction,
678
+ "unsupported",
679
+ `Unsupported Dockerfile instruction "${instruction.originalKeyword}".`
680
+ );
681
+ diagnostics.push(item);
682
+ stage.unsupported.push(item);
683
+ break;
684
+ }
685
+ }
686
+ }
687
+ if (stages.length === 0) {
688
+ diagnostics.push({
689
+ severity: "error",
690
+ message: "Dockerfile does not contain a FROM instruction."
691
+ });
692
+ }
693
+ const finalStage = selectFinalStage(stages, options.targetStage);
694
+ if (options.targetStage && !finalStage) {
695
+ diagnostics.push({
696
+ severity: "error",
697
+ message: `Target Dockerfile stage "${options.targetStage}" was not found.`
698
+ });
699
+ }
700
+ diagnostics.push(...validateStageArtifactCopies(stages));
701
+ const runtime = finalStage ? {
702
+ baseImage: finalStage.baseImage,
703
+ workdir: finalStage.workdir,
704
+ env: { ...finalStage.env },
705
+ exposedPort: finalStage.exposedPorts[0]?.port,
706
+ exposedPorts: [...finalStage.exposedPorts],
707
+ startCommand: finalStage.startCommand,
708
+ user: finalStage.user
709
+ } : null;
710
+ const hasStageArtifactCopies = stages.some((stage) => stage.copies.some((copy) => copy.from));
711
+ const requiresDockerSemantics = diagnostics.some((item) => item.severity === "unsupported") || stages.length > 1 || hasStageArtifactCopies;
712
+ return {
713
+ source: "dockerfile",
714
+ globalArgs,
715
+ stages,
716
+ finalStage,
717
+ runtime,
718
+ diagnostics,
719
+ isMultiStage: stages.length > 1,
720
+ requiresDockerSemantics
721
+ };
722
+ }
723
+ function compileDockerfileToWorkspacePlan(source, options) {
724
+ return compileDockerfileParseResult(parseDockerfile(source), options);
725
+ }
726
+
727
+ // ../../packages/adapters/src/runtime/cloud/compose.ts
728
+ function now() {
729
+ return (/* @__PURE__ */ new Date()).toISOString();
730
+ }
731
+ function toEnvArray(env) {
732
+ return Object.entries(env).map(([k, v]) => `${k}=${v}`);
733
+ }
734
+ function firstContainerPort(portSpecs) {
735
+ for (const spec of portSpecs) {
736
+ const clean = spec.trim();
737
+ if (!clean) continue;
738
+ const parts = clean.split(":");
739
+ const raw = parts.length >= 2 ? parts[parts.length - 1] : parts[0];
740
+ const match = raw?.match(/^(\d+)(?:\/(?:tcp|udp))?$/i);
741
+ if (match) return Number(match[1]);
742
+ }
743
+ return void 0;
744
+ }
745
+ function restartPolicyForWorkload(policy) {
746
+ const base = policy?.split(":")[0];
747
+ if (base === "no" || base === "never") return "never";
748
+ if (base === "on-failure") return "on-failure";
749
+ return "always";
750
+ }
751
+ function exposeTarget(port, serviceName, slug, domain = SYSTEM.DOMAINS.CLOUD_DOMAIN) {
752
+ const service = `service "${serviceName}" on port ${port}`;
753
+ return slug ? `${service} for slug "${slug}" (${slug}.${domain})` : service;
754
+ }
755
+ function errorMessage(err) {
756
+ return safeErrorMessage(err);
757
+ }
758
+ async function withCloudOperationTimeout(operation, label, timeoutMs = 3e5) {
759
+ let timer;
760
+ try {
761
+ return await Promise.race([
762
+ operation,
763
+ new Promise((_, reject) => {
764
+ timer = setTimeout(() => {
765
+ reject(new Error(`${label} timed out after ${Math.round(timeoutMs / 1e3)}s`));
766
+ }, timeoutMs);
767
+ })
768
+ ]);
769
+ } finally {
770
+ if (timer) clearTimeout(timer);
771
+ }
772
+ }
773
+ var CloudComposeSupport = class {
774
+ constructor(deps) {
775
+ this.deps = deps;
776
+ }
777
+ groups = /* @__PURE__ */ new Map();
778
+ async ensureServiceGroup(config) {
779
+ const id = `cloud-compose:${config.deploymentId}`;
780
+ if (!this.groups.has(id)) {
781
+ this.groups.set(id, {
782
+ id,
783
+ resources: config.resources,
784
+ services: /* @__PURE__ */ new Map()
785
+ });
786
+ }
787
+ return { id };
788
+ }
789
+ /** Seed an already-running peer into the group WITHOUT deploying it, so the
790
+ * next finalizeGroup rewrites the full mesh (/etc/hosts + private links)
791
+ * including it. Used by the decoupled single-service add — only the new
792
+ * service is deployed, but its peers must remain reachable by name. */
793
+ registerExistingWorkload(group, service) {
794
+ const groupState = this.groups.get(group.id);
795
+ if (!groupState) return;
796
+ if (groupState.services.has(service.serviceName)) return;
797
+ const ports = [
798
+ ...new Set(
799
+ (service.portSpecs ?? []).map((item) => firstContainerPort([item])).filter((item) => typeof item === "number")
800
+ )
801
+ ];
802
+ groupState.services.set(service.serviceName, {
803
+ serviceName: service.serviceName,
804
+ workspaceId: service.workspaceId,
805
+ ip: service.ip,
806
+ ports
807
+ });
808
+ }
809
+ async deployServiceWorkload(group, config, onLog) {
810
+ const log = onLog ?? (() => {
811
+ });
812
+ const groupState = this.groups.get(group.id) ?? {
813
+ id: group.id,
814
+ services: /* @__PURE__ */ new Map()
815
+ };
816
+ this.groups.set(group.id, groupState);
817
+ const builtArtifact = this.deps.builtArtifacts.get(config.image);
818
+ let workspaceId;
819
+ try {
820
+ workspaceId = builtArtifact?.workspaceId ?? await this.createImageServiceWorkspace(config, log);
821
+ const ws = this.deps.workspace(workspaceId);
822
+ if (builtArtifact?.workspaceId) {
823
+ await this.shrinkToRuntimeTier(ws, config, log);
824
+ }
825
+ await withCloudOperationTimeout(
826
+ ws.lifecycle.makePermanent(),
827
+ `Making cloud service "${config.serviceName}" permanent`
828
+ ).catch((err) => {
829
+ throw new Error(`Failed to make service workspace permanent: ${errorMessage(err)}`);
830
+ });
831
+ const runtimeEnv = {
832
+ ...builtArtifact?.runtime.env ?? {},
833
+ ...config.environment
834
+ };
835
+ const port = config.publicPort ?? firstContainerPort(config.ports) ?? builtArtifact?.runtime.exposedPort;
836
+ const workdir = builtArtifact?.runtime.workdir ?? "/";
837
+ const startCommand = config.command ?? builtArtifact?.runtime.startCommand;
838
+ log({
839
+ timestamp: now(),
840
+ message: `Deploying cloud service "${config.serviceName}" in workspace ${workspaceId}...
841
+ `,
842
+ level: "info"
843
+ });
844
+ if (startCommand) {
845
+ log({
846
+ timestamp: now(),
847
+ message: `Creating workload for service "${config.serviceName}"...
848
+ `,
849
+ level: "info"
850
+ });
851
+ await withCloudOperationTimeout(
852
+ ws.workloads.delete("app").catch(() => {
853
+ }),
854
+ `Replacing workload for service "${config.serviceName}"`
855
+ );
856
+ await withCloudOperationTimeout(
857
+ ws.workloads.create({
858
+ id: "app",
859
+ name: "app",
860
+ cmd: ["sh", "-c", `cd ${sq(workdir)} && ${startCommand}`],
861
+ working_dir: workdir,
862
+ env: [...toEnvArray(runtimeEnv), ...port ? [`PORT=${port}`] : []],
863
+ restart_policy: restartPolicyForWorkload(config.restart),
864
+ max_restarts: 10
865
+ }),
866
+ `Creating workload for service "${config.serviceName}"`
867
+ );
868
+ log({
869
+ timestamp: now(),
870
+ message: `Workload for service "${config.serviceName}" is ready.
871
+ `,
872
+ level: "info"
873
+ });
874
+ } else {
875
+ log({
876
+ timestamp: now(),
877
+ message: `No command configured for "${config.serviceName}". Using the workspace image default process.
878
+ `,
879
+ level: "warn"
880
+ });
881
+ }
882
+ if (config.expose && port) {
883
+ if (config.customDomain) {
884
+ log({
885
+ timestamp: now(),
886
+ message: `Opening ${exposeTarget(port, config.serviceName, config.publicSlug)}...
887
+ `,
888
+ level: "info"
889
+ });
890
+ await withCloudOperationTimeout(
891
+ ws.network.update({ ingress_ports: [port] }),
892
+ `Opening network port for service "${config.serviceName}"`
893
+ ).catch((err) => {
894
+ throw new Error(
895
+ `Failed to open ${exposeTarget(port, config.serviceName, config.publicSlug)}: ${errorMessage(err)}`
896
+ );
897
+ });
898
+ log({
899
+ timestamp: now(),
900
+ message: `Opened ${exposeTarget(port, config.serviceName, config.publicSlug)}.
901
+ `,
902
+ level: "info"
903
+ });
904
+ log({
905
+ timestamp: now(),
906
+ message: `Connecting custom domain "${config.customDomain}" for service "${config.serviceName}"...
907
+ `,
908
+ level: "info"
909
+ });
910
+ await withCloudOperationTimeout(
911
+ ws.domains.connect({ domain: config.customDomain, port }),
912
+ `Connecting custom domain for service "${config.serviceName}"`
913
+ ).catch((err) => {
914
+ throw new Error(
915
+ `Failed to connect custom domain "${config.customDomain}" for service "${config.serviceName}" on port ${port}: ${errorMessage(err)}`
916
+ );
917
+ });
918
+ log({
919
+ timestamp: now(),
920
+ message: `Custom domain "${config.customDomain}" connected for service "${config.serviceName}".
921
+ `,
922
+ level: "info"
923
+ });
924
+ } else if (config.publicSlug) {
925
+ log({
926
+ timestamp: now(),
927
+ message: `Exposing ${exposeTarget(port, config.serviceName, config.publicSlug)}...
928
+ `,
929
+ level: "info"
930
+ });
931
+ await withCloudOperationTimeout(
932
+ ws.publicAccess.expose({
933
+ port,
934
+ domain: SYSTEM.DOMAINS.CLOUD_DOMAIN,
935
+ slug: config.publicSlug
936
+ }),
937
+ `Exposing public access for service "${config.serviceName}"`
938
+ ).catch((err) => {
939
+ throw new Error(
940
+ `Failed to expose ${exposeTarget(port, config.serviceName, config.publicSlug)}: ${errorMessage(err)}`
941
+ );
942
+ });
943
+ log({
944
+ timestamp: now(),
945
+ message: `Exposed ${exposeTarget(port, config.serviceName, config.publicSlug)}.
946
+ `,
947
+ level: "info"
948
+ });
949
+ }
950
+ }
951
+ const ip = await this.resolveWorkspaceIpWithRetry(ws);
952
+ const ports = [
953
+ .../* @__PURE__ */ new Set([
954
+ ...config.ports.map((item) => firstContainerPort([item])).filter((item) => typeof item === "number"),
955
+ ...port ? [port] : []
956
+ ])
957
+ ];
958
+ groupState.services.set(config.serviceName, {
959
+ serviceName: config.serviceName,
960
+ workspaceId,
961
+ ip: ip ?? void 0,
962
+ ports
963
+ });
964
+ await this.syncServiceDiscovery(groupState, log);
965
+ log({
966
+ timestamp: now(),
967
+ message: `Cloud service "${config.serviceName}" started${ip ? ` at ${ip}` : ""}.
968
+ `,
969
+ level: "info"
970
+ });
971
+ return {
972
+ containerId: workspaceId,
973
+ status: "running",
974
+ ip: ip ?? void 0,
975
+ hostPort: port
976
+ };
977
+ } catch (err) {
978
+ if (workspaceId) {
979
+ groupState.services.delete(config.serviceName);
980
+ await this.deps.workspace(workspaceId).delete().catch(() => {
981
+ });
982
+ }
983
+ throw err;
984
+ }
985
+ }
986
+ /**
987
+ * Shrink a source-built server's workspace from the BUILD tier (4cpu/8GB/10GB)
988
+ * down to its production/runtime tier. The build workspace is reused as the
989
+ * runtime, so without this every deployed app holds build-sized resources
990
+ * permanently and saturates the cloud pool — new builds then fail to place
991
+ * (CREATE_FAILED).
992
+ *
993
+ * Mandatory by design: a resize failure THROWS and fails the deploy. Leaving a
994
+ * service oversized is not an acceptable escape hatch — it silently poisons the
995
+ * shared pool. (cpu + memory are the pool/cost constraint; disk isn't in the
996
+ * deploy config and shrinking a data volume is unreliable, so it's left alone.)
997
+ */
998
+ async shrinkToRuntimeTier(ws, config, log) {
999
+ const cpus = cloudCpus(config.resources?.cpuCores ?? DEFAULT_RESOURCE_CONFIG.cpuCores);
1000
+ const memory_mb = config.resources?.memoryMb ?? DEFAULT_RESOURCE_CONFIG.memoryMb;
1001
+ try {
1002
+ await withCloudOperationTimeout(
1003
+ ws.resources.update({ cpus, memory_mb, apply: true }),
1004
+ `Resizing service "${config.serviceName}" to its runtime tier`
1005
+ );
1006
+ } catch (err) {
1007
+ throw new Error(
1008
+ `Failed to shrink service "${config.serviceName}" from the build tier to its runtime tier: ${errorMessage(err)}`
1009
+ );
1010
+ }
1011
+ log({
1012
+ timestamp: now(),
1013
+ message: `Sized "${config.serviceName}" to its runtime tier (${cpus} vCPU \xB7 ${memory_mb} MB).
1014
+ `,
1015
+ level: "info"
1016
+ });
1017
+ }
1018
+ async createImageServiceWorkspace(config, onLog) {
1019
+ if (config.previousWorkspaceId) {
1020
+ try {
1021
+ const existing = this.deps.workspace(config.previousWorkspaceId);
1022
+ await existing.get();
1023
+ onLog({
1024
+ timestamp: now(),
1025
+ message: `Reusing existing workspace ${config.previousWorkspaceId} for service "${config.serviceName}" (preserves its disk/data).
1026
+ `,
1027
+ level: "info"
1028
+ });
1029
+ return config.previousWorkspaceId;
1030
+ } catch {
1031
+ onLog({
1032
+ timestamp: now(),
1033
+ message: `Previous workspace for "${config.serviceName}" is gone \u2014 creating a fresh one (its prior data is not recoverable).
1034
+ `,
1035
+ level: "warn"
1036
+ });
1037
+ }
1038
+ }
1039
+ const diskMb = config.volumes.length ? Math.max(DEFAULT_RESOURCE_CONFIG.diskMb, 20480) : DEFAULT_RESOURCE_CONFIG.diskMb;
1040
+ const resources = {
1041
+ cpuCores: config.resources?.cpuCores ?? DEFAULT_RESOURCE_CONFIG.cpuCores,
1042
+ memoryMb: config.resources?.memoryMb ?? DEFAULT_RESOURCE_CONFIG.memoryMb,
1043
+ diskMb
1044
+ };
1045
+ const sharedOrBind = config.volumes.filter((v) => v.includes(":/") && v.startsWith("/"));
1046
+ if (sharedOrBind.length) {
1047
+ onLog({
1048
+ timestamp: now(),
1049
+ message: `Warning: bind-mount volumes are not supported on cloud and were ignored for "${config.serviceName}": ${sharedOrBind.join(", ")}
1050
+ `,
1051
+ level: "warn"
1052
+ });
1053
+ }
1054
+ onLog({
1055
+ timestamp: now(),
1056
+ message: `Creating cloud service "${config.serviceName}" from image "${config.image}"...
1057
+ `,
1058
+ level: "info"
1059
+ });
1060
+ let wsData;
1061
+ try {
1062
+ wsData = await this.deps.client.workspaces.create({
1063
+ name: `${config.slug}-${config.serviceName}`.slice(0, 60),
1064
+ image: config.image,
1065
+ mode: "permanent",
1066
+ config: {
1067
+ cpus: cloudCpus(resources.cpuCores),
1068
+ memory_mb: resources.memoryMb,
1069
+ disk_size_mb: resources.diskMb,
1070
+ env: toEnvArray(config.environment)
1071
+ }
1072
+ });
1073
+ } catch (err) {
1074
+ const message = safeErrorMessage(err);
1075
+ onLog({
1076
+ timestamp: now(),
1077
+ message: `Failed to create cloud service "${config.serviceName}" from image "${config.image}": ${message}
1078
+ `,
1079
+ level: "error"
1080
+ });
1081
+ throw err;
1082
+ }
1083
+ return wsData.id;
1084
+ }
1085
+ async resolveWorkspaceIp(ws) {
1086
+ try {
1087
+ const raw = await ws.apiAccess.rawToken();
1088
+ if (raw?.ip) return raw.ip;
1089
+ } catch {
1090
+ }
1091
+ try {
1092
+ const network = await ws.network.get();
1093
+ if (network.ip) return network.ip;
1094
+ } catch {
1095
+ }
1096
+ try {
1097
+ const data = await ws.get();
1098
+ return data.ip ?? null;
1099
+ } catch {
1100
+ return null;
1101
+ }
1102
+ }
1103
+ /**
1104
+ * Oblien may assign the private IP a beat after the workspace starts. Poll
1105
+ * (bounded) so a transient null never permanently drops a service from the
1106
+ * mesh — the original bug was resolving once with no retry.
1107
+ */
1108
+ async resolveWorkspaceIpWithRetry(ws, attempts = 10, delayMs = 1e3) {
1109
+ for (let attempt = 0; attempt < attempts; attempt++) {
1110
+ const ip = await this.resolveWorkspaceIp(ws);
1111
+ if (ip) return ip;
1112
+ if (attempt < attempts - 1) {
1113
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
1114
+ }
1115
+ }
1116
+ return null;
1117
+ }
1118
+ async syncServiceDiscovery(group, onLog) {
1119
+ const services = [...group.services.values()].filter((service) => service.ip);
1120
+ if (services.length === 0) return;
1121
+ const workspaceIds = [...new Set(services.map((service) => service.workspaceId))];
1122
+ const hostsLines = services.map(
1123
+ (service) => `${service.ip} ${service.serviceName} # openship-compose:${group.id}`
1124
+ );
1125
+ const hostsBlock = hostsLines.join("\n");
1126
+ for (const service of services) {
1127
+ const ws = this.deps.workspace(service.workspaceId);
1128
+ const privateLinks = workspaceIds.filter(
1129
+ (workspaceId) => workspaceId !== service.workspaceId
1130
+ );
1131
+ const currentNetwork = await ws.network.get().catch(() => null);
1132
+ const currentIngress = Array.isArray(
1133
+ currentNetwork?.ingress_ports
1134
+ ) ? currentNetwork.ingress_ports : [];
1135
+ const ingressPorts = [.../* @__PURE__ */ new Set([...currentIngress, ...service.ports])];
1136
+ if (ingressPorts.length > 0 || privateLinks.length > 0) {
1137
+ await ws.network.update({
1138
+ ...privateLinks.length ? { private_link_ids: privateLinks } : {},
1139
+ ...ingressPorts.length ? { ingress_ports: ingressPorts } : {}
1140
+ }).catch((err) => {
1141
+ onLog({
1142
+ timestamp: now(),
1143
+ message: `Warning: failed to link private network for "${service.serviceName}": ${err instanceof Error ? err.message : err}
1144
+ `,
1145
+ level: "warn"
1146
+ });
1147
+ });
1148
+ }
1149
+ try {
1150
+ const rt = await ws.runtime();
1151
+ const script = `set -e
1152
+ tmp=$(mktemp)
1153
+ grep -v ' # openship-compose:${group.id}' /etc/hosts > "$tmp" || true
1154
+ cat >> "$tmp" <<'EOF'
1155
+ ${hostsBlock}
1156
+ EOF
1157
+ cat "$tmp" > /etc/hosts
1158
+ rm -f "$tmp"`;
1159
+ await this.deps.execAndStream(rt, ["sh", "-c", script], onLog);
1160
+ } catch (err) {
1161
+ onLog({
1162
+ timestamp: now(),
1163
+ message: `Warning: failed to update service discovery for "${service.serviceName}": ${err instanceof Error ? err.message : err}
1164
+ `,
1165
+ level: "warn"
1166
+ });
1167
+ }
1168
+ }
1169
+ }
1170
+ /**
1171
+ * Final convergence pass — run ONCE after every service in the group has
1172
+ * deployed. The per-service `syncServiceDiscovery` is incremental and only
1173
+ * ever sees the IPs known at that moment; a service whose private IP hadn't
1174
+ * been assigned during its own deploy would otherwise stay permanently absent
1175
+ * from the mesh. This re-resolves any still-missing IP (bounded retry) and
1176
+ * rewrites the FULL /etc/hosts + private links + ingress across the whole
1177
+ * group, so the finished stack has every peer reachable by name.
1178
+ *
1179
+ * Note: we deliberately do NOT blanket-restart workloads here — that would
1180
+ * take stateful services (Postgres, …) down needlessly, and standard clients
1181
+ * resolve DNS per connection, so they read the completed /etc/hosts without a
1182
+ * restart. (A boot-time-cache app is the rare exception.)
1183
+ */
1184
+ async finalizeGroup(groupId, onLog) {
1185
+ const log = onLog ?? (() => {
1186
+ });
1187
+ const group = this.groups.get(groupId);
1188
+ if (!group) return;
1189
+ for (const service of group.services.values()) {
1190
+ if (service.ip) continue;
1191
+ const ip = await this.resolveWorkspaceIpWithRetry(
1192
+ this.deps.workspace(service.workspaceId)
1193
+ );
1194
+ if (ip) {
1195
+ service.ip = ip;
1196
+ } else {
1197
+ log({
1198
+ timestamp: now(),
1199
+ message: `Warning: service "${service.serviceName}" never received a private IP \u2014 peers cannot reach it by name.
1200
+ `,
1201
+ level: "warn"
1202
+ });
1203
+ }
1204
+ }
1205
+ await this.syncServiceDiscovery(group, log);
1206
+ }
1207
+ };
1208
+
1209
+ // ../../packages/adapters/src/runtime/cloud.ts
1210
+ var DOCKERFILE_SOURCE_IMAGE = "node:22";
1211
+ var WORKSPACE_STREAM_EXEC_TIMEOUT_MS = 15 * 60 * 1e3;
1212
+ function formatCloudError(err) {
1213
+ const base = safeErrorMessage(err);
1214
+ if (!err || typeof err !== "object") return base;
1215
+ const e = err;
1216
+ const tags = [];
1217
+ const codePart = [e.code, typeof e.status === "number" ? `HTTP ${e.status}` : null].filter(Boolean).join(" ");
1218
+ if (codePart) tags.push(codePart);
1219
+ if (e.requestId) tags.push(`requestId=${e.requestId}`);
1220
+ let detail = "";
1221
+ if (e.details != null) {
1222
+ try {
1223
+ detail = typeof e.details === "string" ? e.details : JSON.stringify(e.details);
1224
+ } catch {
1225
+ }
1226
+ }
1227
+ return `${base}${tags.length ? ` [${tags.join(", ")}]` : ""}${detail ? ` \u2014 ${detail.slice(0, 800)}` : ""}`;
1228
+ }
1229
+ function now2() {
1230
+ return (/* @__PURE__ */ new Date()).toISOString();
1231
+ }
1232
+ function applyTail(entries, tail) {
1233
+ if (typeof tail !== "number") return entries;
1234
+ if (tail <= 0) return [];
1235
+ return entries.slice(-tail);
1236
+ }
1237
+ function summarizeCommandOutput(output) {
1238
+ const lines = output.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
1239
+ const lastLine = lines.at(-1) ?? "";
1240
+ return lastLine.length > 300 ? `${lastLine.slice(0, 297)}...` : lastLine;
1241
+ }
1242
+ function exposeTarget2(port, slug, domain = SYSTEM.DOMAINS.CLOUD_DOMAIN) {
1243
+ return slug ? `port ${port} for slug "${slug}" (${slug}.${domain})` : `port ${port}`;
1244
+ }
1245
+ function primaryPublicEndpoint(config) {
1246
+ return config.publicEndpoints?.[0];
1247
+ }
1248
+ function endpointSlug(endpoint) {
1249
+ const slug = endpoint?.domain?.trim();
1250
+ return endpoint?.domainType === "free" && slug ? slug : void 0;
1251
+ }
1252
+ function endpointCustomDomain(endpoint) {
1253
+ const domain = endpoint?.customDomain?.trim();
1254
+ return endpoint?.domainType === "custom" && domain ? domain : void 0;
1255
+ }
1256
+ function fallbackRuntimeName(config) {
1257
+ const raw = config.runtimeName ?? `${config.projectId.slice(0, 20)}-${config.deploymentId.slice(0, 8)}`;
1258
+ const normalized = raw.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
1259
+ return normalized || `deploy-${config.deploymentId.slice(0, 8)}`;
1260
+ }
1261
+ function toEnvArray2(env) {
1262
+ return Object.entries(env).map(([k, v]) => `${k}=${v}`);
1263
+ }
1264
+ function validEnvKey(key) {
1265
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(key);
1266
+ }
1267
+ function envExportPrefix(env) {
1268
+ const parts = Object.entries(env).filter(([key]) => validEnvKey(key)).map(([key, value]) => `export ${key}=${sq(value ?? "")}`);
1269
+ return parts.length ? `${parts.join(" && ")} && ` : "";
1270
+ }
1271
+ function joinWorkspacePath(base, ...parts) {
1272
+ return posix.normalize(posix.join(base, ...parts.filter(Boolean)));
1273
+ }
1274
+ function resolveVmPath(workdir, target) {
1275
+ if (!target || target === ".") return workdir || "/";
1276
+ if (target.startsWith("/")) return posix.normalize(target);
1277
+ return posix.normalize(posix.join(workdir || "/", target));
1278
+ }
1279
+ function hasGlob(value) {
1280
+ return /[*?[\]]/.test(value);
1281
+ }
1282
+ function sourceExpression(baseDir, source) {
1283
+ const normalized = source === "." ? "." : source.replace(/^\/+/, "");
1284
+ const full = normalized === "." ? `${baseDir}/.` : `${baseDir}/${normalized}`;
1285
+ if (hasGlob(normalized)) {
1286
+ if (!/^[A-Za-z0-9_@%+=:,./*?[\]-]+$/.test(normalized)) {
1287
+ throw new Error(`Unsafe glob source in Dockerfile COPY: ${source}`);
1288
+ }
1289
+ return full;
1290
+ }
1291
+ return sq(full);
1292
+ }
1293
+ function dockerCopyShellCommands(opts) {
1294
+ const { sources, destination, destinationIsDir, destinationTarget } = opts;
1295
+ const destinationExpr = sq(destination);
1296
+ const destinationTargetExpr = sq(destinationTarget);
1297
+ return sources.flatMap((source) => [
1298
+ `for src in ${source}; do`,
1299
+ ' if [ ! -e "$src" ]; then',
1300
+ ' echo "COPY source not found: $src" >&2',
1301
+ " exit 1",
1302
+ " fi",
1303
+ ' if [ -d "$src" ]; then',
1304
+ destinationIsDir ? ` mkdir -p ${destinationExpr}` : ` rm -rf ${destinationExpr} && mkdir -p ${destinationExpr}`,
1305
+ // Docker COPY copies directory contents, not the directory wrapper.
1306
+ ` cp -a "$src/." ${destinationExpr}/`,
1307
+ " else",
1308
+ ` mkdir -p ${destinationTargetExpr}`,
1309
+ ` cp -a "$src" ${destinationExpr}`,
1310
+ " fi",
1311
+ "done"
1312
+ ]);
1313
+ }
1314
+ function stageArtifactDownloadPath(source) {
1315
+ const normalized = source.trim();
1316
+ if (!normalized || normalized === ".") {
1317
+ return "/";
1318
+ }
1319
+ if (normalized.startsWith("/")) {
1320
+ return posix.normalize(normalized);
1321
+ }
1322
+ return posix.normalize(`/${normalized}`);
1323
+ }
1324
+ function substituteDockerArgs(value, args) {
1325
+ return value.replace(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g, (match, key) => {
1326
+ const replacement = args[key];
1327
+ return replacement === void 0 || replacement === null ? match : replacement;
1328
+ });
1329
+ }
1330
+ function stageBaseLabel(stageBaseImage, requestedBaseImage, stageAlias) {
1331
+ if (stageAlias) {
1332
+ return `${requestedBaseImage} (stage alias -> ${stageBaseImage})`;
1333
+ }
1334
+ return stageBaseImage === requestedBaseImage ? requestedBaseImage : `${requestedBaseImage} (resolved to ${stageBaseImage})`;
1335
+ }
1336
+ function resolveCopyDestinationPath(copy, sourcePath, destination, destinationIsDir) {
1337
+ if (!destinationIsDir) {
1338
+ return destination;
1339
+ }
1340
+ const basename = posix.basename(sourcePath.replace(/\/+$/g, "")) || ".";
1341
+ return posix.normalize(posix.join(destination, basename));
1342
+ }
1343
+ var PAGE_CONTAINER_PREFIX = "page:";
1344
+ async function provisionCloudWorkspace(client, config, logger) {
1345
+ logger?.log(`Creating workspace from image "${config.image}"...
1346
+ `);
1347
+ let wsData;
1348
+ try {
1349
+ wsData = await client.workspaces.create({
1350
+ name: config.name,
1351
+ image: config.image,
1352
+ mode: config.mode,
1353
+ config: {
1354
+ cpus: cloudCpus(config.resources.cpuCores),
1355
+ memory_mb: config.resources.memoryMb,
1356
+ disk_size_mb: config.resources.diskMb,
1357
+ env: toEnvArray2(config.env ?? {})
1358
+ }
1359
+ });
1360
+ } catch (err) {
1361
+ logger?.log(`Failed to create workspace from image "${config.image}": ${formatCloudError(err)}
1362
+ `, "error");
1363
+ throw err;
1364
+ }
1365
+ const ws = client.workspace(wsData.id);
1366
+ try {
1367
+ if (config.mode === "temporary" && config.ttl) {
1368
+ try {
1369
+ await ws.lifecycle.makeTemporary({ ttl: config.ttl, ttl_action: "remove", remove_on_exit: true });
1370
+ } catch {
1371
+ }
1372
+ }
1373
+ logger?.log("Connecting to build environment...\n");
1374
+ let rt;
1375
+ for (let attempt = 0; attempt < 3; attempt++) {
1376
+ try {
1377
+ rt = await ws.runtime();
1378
+ break;
1379
+ } catch (err) {
1380
+ if (attempt < 2) await new Promise((r) => setTimeout(r, 2e3));
1381
+ else throw err;
1382
+ }
1383
+ }
1384
+ if (!rt) throw new Error("Failed to connect to build environment");
1385
+ logger?.log("Build environment ready\n");
1386
+ return { workspaceId: wsData.id, runtime: rt };
1387
+ } catch (err) {
1388
+ await ws.delete().catch(() => {
1389
+ });
1390
+ logger?.log(`Failed to prepare workspace "${wsData.id}": ${formatCloudError(err)}
1391
+ `, "error");
1392
+ throw err;
1393
+ }
1394
+ }
1395
+ var CloudRuntime = class {
1396
+ name = "cloud";
1397
+ capabilities = /* @__PURE__ */ new Set([
1398
+ "build",
1399
+ "deploy",
1400
+ "multiServiceDeploy",
1401
+ "stop",
1402
+ "start",
1403
+ "restart",
1404
+ "destroy",
1405
+ "containerInfo",
1406
+ "runtimeLogs",
1407
+ "streamLogs",
1408
+ "usage",
1409
+ "containerIp",
1410
+ "rollback",
1411
+ "serviceShell",
1412
+ "inContainerExec"
1413
+ ]);
1414
+ /**
1415
+ * Services map to Oblien workspaces, not Docker containers, so host-level
1416
+ * compose extras have no equivalent. `healthcheck` is dropped-with-a-warning
1417
+ * here (A1); host-level keys — caps/devices/privileged/sysctls/ulimits/
1418
+ * logging/dns — join this set as they're added in later phases.
1419
+ */
1420
+ unsupportedComposeKeys = /* @__PURE__ */ new Set([
1421
+ "healthcheck"
1422
+ ]);
1423
+ client;
1424
+ // Optional admin-scoped proxy. Set when this runtime is constructed
1425
+ // by a local/desktop instance whose `client` is a namespace token —
1426
+ // admin-scoped operations (e.g. creating pages on shared `.opsh.io`)
1427
+ // get handed off to the SaaS through this callback. SaaS instances
1428
+ // construct CloudRuntime with master creds and leave it null;
1429
+ // `this.client` already has the needed scope there.
1430
+ adminProxy;
1431
+ builtArtifacts = /* @__PURE__ */ new Map();
1432
+ activeBuilds = /* @__PURE__ */ new Map();
1433
+ compose;
1434
+ constructor(client, opts) {
1435
+ this.client = client;
1436
+ this.adminProxy = opts?.adminProxy;
1437
+ this.compose = new CloudComposeSupport({
1438
+ client,
1439
+ builtArtifacts: this.builtArtifacts,
1440
+ workspace: (workspaceId) => this.ws(workspaceId),
1441
+ provisionWorkspace: (config, logger) => this.provisionWorkspace(config, logger),
1442
+ execAndStream: (runtime, command, onLog, timeoutSeconds) => this.execAndStream(runtime, command, onLog, timeoutSeconds)
1443
+ });
1444
+ }
1445
+ supports(cap) {
1446
+ return this.capabilities.has(cap);
1447
+ }
1448
+ async dispose() {
1449
+ }
1450
+ /** Get a scoped workspace handle. */
1451
+ ws(workspaceId) {
1452
+ return this.client.workspace(workspaceId);
1453
+ }
1454
+ /**
1455
+ * Connect a custom domain to `workspaceId`, REASSIGNING it from a previous
1456
+ * deployment if needed.
1457
+ *
1458
+ * Each cloud deploy is a fresh workspace, but the custom domain is still
1459
+ * bound to the PREVIOUS (still-live) deployment's workspace during cutover —
1460
+ * so a plain connect returns "Domain is unavailable". That's a false
1461
+ * conflict when the project is re-pointing its OWN domain. We look up the
1462
+ * domain's current owner: if it's already this workspace, we're done
1463
+ * (idempotent); if it's another workspace we own (the prior deploy), we
1464
+ * disconnect it there and retry. A domain owned by anything else (another
1465
+ * account's route, a page, etc.) still errors — no silent takeover.
1466
+ */
1467
+ async connectCustomDomainReassigning(workspaceId, domain, port, log) {
1468
+ const ws = this.ws(workspaceId);
1469
+ try {
1470
+ await ws.domains.connect({ domain, port });
1471
+ return;
1472
+ } catch (err) {
1473
+ const owner = await this.findDomainOwner(domain).catch(() => null);
1474
+ if (owner && owner.owner_id === workspaceId) return;
1475
+ if (!owner || owner.owner_type !== "workspace") throw err;
1476
+ log?.({
1477
+ timestamp: now2(),
1478
+ level: "info",
1479
+ message: `Reassigning ${domain} from the previous deployment...
1480
+ `
1481
+ });
1482
+ await this.ws(owner.owner_id).domains.disconnect().catch(() => {
1483
+ });
1484
+ await ws.domains.connect({ domain, port });
1485
+ }
1486
+ }
1487
+ /** Find the current owner route for a hostname among the caller's routes. */
1488
+ async findDomainOwner(domain) {
1489
+ const target = domain.trim().toLowerCase();
1490
+ const res = await this.client.domain.routes();
1491
+ const routes = res?.data ?? [];
1492
+ const match = routes.find(
1493
+ (r) => (r.hostname ?? "").toLowerCase() === target || (r.domain ?? "").toLowerCase() === target
1494
+ );
1495
+ return match ? { owner_id: match.owner_id, owner_type: match.owner_type } : null;
1496
+ }
1497
+ createActiveBuild(sessionId) {
1498
+ const activeBuild = {
1499
+ abort: new AbortController(),
1500
+ workspaceIds: /* @__PURE__ */ new Set()
1501
+ };
1502
+ this.activeBuilds.set(sessionId, activeBuild);
1503
+ return activeBuild;
1504
+ }
1505
+ trackActiveBuildWorkspace(sessionId, workspaceId) {
1506
+ this.activeBuilds.get(sessionId)?.workspaceIds.add(workspaceId);
1507
+ }
1508
+ untrackActiveBuildWorkspace(sessionId, workspaceId) {
1509
+ this.activeBuilds.get(sessionId)?.workspaceIds.delete(workspaceId);
1510
+ }
1511
+ // ── Build lifecycle ────────────────────────────────────────────────────
1512
+ async build(config, logger) {
1513
+ const log = logger ?? new BuildLogger();
1514
+ const activeBuild = this.createActiveBuild(config.sessionId);
1515
+ try {
1516
+ if (config.stack === "docker" || config.dockerfilePath) {
1517
+ const result2 = await this.buildDockerfileWorkspace(config, log);
1518
+ return activeBuild.abort.signal.aborted ? { ...result2, status: "cancelled", errorMessage: void 0 } : result2;
1519
+ }
1520
+ const buildLocally = config.buildStrategy === "local";
1521
+ let wsId;
1522
+ let rt;
1523
+ try {
1524
+ if (config.cloudWorkspaceId) {
1525
+ log.log("Attaching to uploaded workspace...\n");
1526
+ wsId = config.cloudWorkspaceId;
1527
+ rt = await this.adoptWorkspaceRuntime(wsId);
1528
+ this.trackActiveBuildWorkspace(config.sessionId, wsId);
1529
+ log.log("Build environment ready\n");
1530
+ } else {
1531
+ const provisioned = await this.provisionBuildWorkspace(config, log);
1532
+ wsId = provisioned.workspaceId;
1533
+ rt = provisioned.runtime;
1534
+ this.trackActiveBuildWorkspace(config.sessionId, wsId);
1535
+ }
1536
+ } catch (err) {
1537
+ const msg = formatCloudError(err);
1538
+ log.log(`Failed to provision build environment: ${msg}`, "error");
1539
+ return {
1540
+ sessionId: config.sessionId,
1541
+ status: activeBuild.abort.signal.aborted ? "cancelled" : "failed",
1542
+ durationMs: 0,
1543
+ errorMessage: activeBuild.abort.signal.aborted ? void 0 : `Failed to provision build environment: ${msg}`
1544
+ };
1545
+ }
1546
+ if (buildLocally) {
1547
+ log.log("Build strategy: local (build on API host, upload to cloud)\n");
1548
+ const stackDef = STACKS[config.stack];
1549
+ let standaloneStartCommand;
1550
+ let result2;
1551
+ try {
1552
+ result2 = await runLocalBuild({
1553
+ config,
1554
+ logger: log,
1555
+ abort: activeBuild.abort.signal,
1556
+ transferOutput: async (buildDir) => {
1557
+ if (activeBuild.abort.signal.aborted) {
1558
+ throw new Error("Build cancelled");
1559
+ }
1560
+ const selfContained = await prepareStackOutput(
1561
+ config.stack,
1562
+ resolveProjectDir(buildDir, config.rootDirectory)
1563
+ );
1564
+ if (selfContained) {
1565
+ log.log("Detected self-contained build output \u2014 uploading the bundle (no install on cloud).\n");
1566
+ await transferLocalDirectory(
1567
+ selfContained.bundleDir,
1568
+ { kind: "cloud-runtime", runtime: rt, path: "/app" },
1569
+ log,
1570
+ { excludes: [] }
1571
+ );
1572
+ standaloneStartCommand = selfContained.startCommand;
1573
+ return;
1574
+ }
1575
+ if (stackDef?.productionPaths?.length) {
1576
+ log.log(`Transferring production paths: ${stackDef.productionPaths.join(", ")}
1577
+ `);
1578
+ await transferLocalDirectory(
1579
+ buildDir,
1580
+ { kind: "cloud-runtime", runtime: rt, path: "/app" },
1581
+ log,
1582
+ { includes: [...stackDef.productionPaths] }
1583
+ );
1584
+ } else {
1585
+ await transferLocalDirectory(
1586
+ buildDir,
1587
+ { kind: "cloud-runtime", runtime: rt, path: "/app" },
1588
+ log,
1589
+ {
1590
+ excludes: buildOutputTransferExcludes(stackDef),
1591
+ alsoInclude: stackDef?.outputDirectory ? [stackDef.outputDirectory] : void 0
1592
+ }
1593
+ );
1594
+ }
1595
+ if (activeBuild.abort.signal.aborted) {
1596
+ throw new Error("Build cancelled");
1597
+ }
1598
+ const installCmd = config.installCommand?.trim();
1599
+ if (installCmd) {
1600
+ log.log("Installing production dependencies on cloud...\n");
1601
+ const pmEnsure = packageManagerEnsureCommand(config.packageManager);
1602
+ const fullInstall = pmEnsure ? `${pmEnsure} && ${installCmd}` : installCmd;
1603
+ await this.execAndStream(rt, ["sh", "-c", `cd /app && ${fullInstall}`], log.callback);
1604
+ }
1605
+ }
1606
+ });
1607
+ } catch (err) {
1608
+ const msg = safeErrorMessage(err);
1609
+ log.log(`Failed to upload local build output: ${msg}`, "error");
1610
+ return {
1611
+ sessionId: config.sessionId,
1612
+ status: activeBuild.abort.signal.aborted ? "cancelled" : "failed",
1613
+ imageRef: wsId,
1614
+ errorMessage: activeBuild.abort.signal.aborted ? void 0 : `Failed to upload local build output: ${msg}`
1615
+ };
1616
+ }
1617
+ return {
1618
+ sessionId: config.sessionId,
1619
+ status: activeBuild.abort.signal.aborted ? "cancelled" : result2.status,
1620
+ imageRef: wsId,
1621
+ durationMs: result2.durationMs,
1622
+ errorMessage: activeBuild.abort.signal.aborted ? void 0 : result2.errorMessage,
1623
+ startCommand: standaloneStartCommand
1624
+ };
1625
+ }
1626
+ log.log("Build strategy: server (build in cloud workspace)\n");
1627
+ const buildEnv = {
1628
+ projectDir: "/app",
1629
+ // A provisioned workspace gets env baked in at create time (native), so
1630
+ // the pipeline skips the shell export prefix. An ADOPTED upload
1631
+ // workspace was created before the deploy's env vars were known, so it
1632
+ // has none — the pipeline must inject them inline instead.
1633
+ hasNativeEnv: !config.cloudWorkspaceId,
1634
+ exec: async (command, logCb) => {
1635
+ if (activeBuild.abort.signal.aborted) {
1636
+ throw new Error("Build cancelled");
1637
+ }
1638
+ await this.execAndStream(rt, ["sh", "-c", command], logCb);
1639
+ if (activeBuild.abort.signal.aborted) {
1640
+ throw new Error("Build cancelled");
1641
+ }
1642
+ },
1643
+ preflight: async (cfg, plog) => {
1644
+ if (activeBuild.abort.signal.aborted) {
1645
+ throw new Error("Build cancelled");
1646
+ }
1647
+ if (cfg.sourceStaged) {
1648
+ return;
1649
+ }
1650
+ if (!cfg.localPath) {
1651
+ await this.ensureWorkspaceGit(rt, plog, "build workspace");
1652
+ return;
1653
+ }
1654
+ await transferLocalDirectory(
1655
+ cfg.localPath,
1656
+ {
1657
+ kind: "cloud-runtime",
1658
+ runtime: rt,
1659
+ path: "/app"
1660
+ },
1661
+ plog
1662
+ );
1663
+ }
1664
+ };
1665
+ const result = await runBuildPipeline(buildEnv, config, log);
1666
+ return {
1667
+ sessionId: config.sessionId,
1668
+ status: activeBuild.abort.signal.aborted ? "cancelled" : result.status,
1669
+ imageRef: wsId,
1670
+ durationMs: result.durationMs,
1671
+ errorMessage: activeBuild.abort.signal.aborted ? void 0 : result.errorMessage
1672
+ };
1673
+ } finally {
1674
+ this.activeBuilds.delete(config.sessionId);
1675
+ }
1676
+ }
1677
+ async buildDockerfileWorkspace(config, log) {
1678
+ const startedAt = Date.now();
1679
+ const createdWorkspaceIds = [];
1680
+ let finalWorkspaceId;
1681
+ let source;
1682
+ try {
1683
+ log.log("Build strategy: Dockerfile plan (build in Oblien workspaces)\n");
1684
+ source = await this.resolveDockerfileBuildSource(config, log);
1685
+ const plan = compileDockerfileToWorkspacePlan(source.dockerfile);
1686
+ const blocking = plan.diagnostics.filter(
1687
+ (item) => item.severity === "error" || item.severity === "unsupported"
1688
+ );
1689
+ if (blocking.length > 0) {
1690
+ throw new Error(
1691
+ blocking.map((item) => item.line ? `line ${item.line}: ${item.message}` : item.message).join("\n")
1692
+ );
1693
+ }
1694
+ if (plan.stages.length === 0 || !plan.finalStage || !plan.runtime) {
1695
+ throw new Error("Dockerfile did not produce a deployable final stage.");
1696
+ }
1697
+ const unsafe = this.getUnsupportedDockerfilePlanFeatures(plan);
1698
+ if (unsafe.length > 0) {
1699
+ throw new Error(unsafe.join("\n"));
1700
+ }
1701
+ for (const warning of plan.diagnostics.filter((item) => item.severity === "warning")) {
1702
+ log.log(
1703
+ `${warning.line ? `Dockerfile line ${warning.line}: ` : ""}${warning.message}
1704
+ `,
1705
+ "warn"
1706
+ );
1707
+ }
1708
+ const built = await this.executeDockerfilePlan({
1709
+ config,
1710
+ plan,
1711
+ source,
1712
+ logger: log,
1713
+ onWorkspaceCreated: (workspaceId) => {
1714
+ createdWorkspaceIds.push(workspaceId);
1715
+ this.trackActiveBuildWorkspace(config.sessionId, workspaceId);
1716
+ }
1717
+ });
1718
+ finalWorkspaceId = built.workspaceId;
1719
+ this.builtArtifacts.set(built.workspaceId, {
1720
+ workspaceId: built.workspaceId,
1721
+ runtime: built.runtime
1722
+ });
1723
+ log.log(
1724
+ `Dockerfile runtime plan: workdir ${built.runtime.workdir}${built.runtime.exposedPort ? ` \xB7 port ${built.runtime.exposedPort}` : " \xB7 no EXPOSE port"}${built.runtime.startCommand ? ` \xB7 start ${built.runtime.startCommand}` : " \xB7 no start command"}
1725
+ `
1726
+ );
1727
+ return {
1728
+ sessionId: config.sessionId,
1729
+ status: "deploying",
1730
+ imageRef: built.workspaceId,
1731
+ durationMs: Date.now() - startedAt
1732
+ };
1733
+ } catch (err) {
1734
+ for (const workspaceId of createdWorkspaceIds) {
1735
+ if (workspaceId === finalWorkspaceId) continue;
1736
+ await this.ws(workspaceId).delete().catch(() => {
1737
+ });
1738
+ this.untrackActiveBuildWorkspace(config.sessionId, workspaceId);
1739
+ }
1740
+ const message = safeErrorMessage(err);
1741
+ log.log(`Dockerfile build failed: ${message}
1742
+ `, "error");
1743
+ return {
1744
+ sessionId: config.sessionId,
1745
+ status: "failed",
1746
+ durationMs: Date.now() - startedAt,
1747
+ errorMessage: message
1748
+ };
1749
+ } finally {
1750
+ await source?.cleanup().catch(() => {
1751
+ });
1752
+ }
1753
+ }
1754
+ async resolveDockerfileBuildSource(config, logger) {
1755
+ if (config.dockerfileContent?.trim()) {
1756
+ logger.log("Using Dockerfile content from source metadata.\n");
1757
+ return {
1758
+ kind: "remote",
1759
+ contextRelativePath: normalizeDockerRelativePath(config.rootDirectory),
1760
+ dockerfile: config.dockerfileContent,
1761
+ cleanup: async () => {
1762
+ }
1763
+ };
1764
+ }
1765
+ if (!config.localPath) {
1766
+ return this.resolveRemoteDockerfileBuildSource(config, logger);
1767
+ }
1768
+ logger.log("Preparing local Dockerfile source...\n");
1769
+ const context = await createDockerBuildContext(config, {
1770
+ requireRepositoryDockerfile: true,
1771
+ onLog: logger.callback
1772
+ });
1773
+ const dockerfilePath = join(context.contextDir, ...context.dockerfileName.split("/"));
1774
+ const dockerfile = await readFile(dockerfilePath, "utf-8");
1775
+ const contextRoot = join(
1776
+ context.contextDir,
1777
+ ...normalizeDockerRelativePath(context.rootDirectory).split("/").filter(Boolean)
1778
+ );
1779
+ return {
1780
+ kind: "local",
1781
+ contextRoot,
1782
+ dockerfile,
1783
+ cleanup: context.cleanup
1784
+ };
1785
+ }
1786
+ async resolveRemoteDockerfileBuildSource(config, logger) {
1787
+ const candidates = resolveDockerfileCandidates(config.rootDirectory, config.dockerfilePath);
1788
+ const sourceLabel = config.commitSha ? `${config.branch}@${config.commitSha.slice(0, 7)}` : config.branch;
1789
+ const checkoutRef = config.commitSha ?? "FETCH_HEAD";
1790
+ const sourceDir = "/openship/dockerfile-source";
1791
+ let sourceWorkspaceId;
1792
+ try {
1793
+ logger.log(`Resolving Dockerfile source in cloud workspace (${sourceLabel})...
1794
+ `);
1795
+ const provisioned = await this.provisionWorkspace(
1796
+ {
1797
+ name: `${config.slug ?? config.projectId}-source`.slice(0, 60),
1798
+ image: DOCKERFILE_SOURCE_IMAGE,
1799
+ mode: "temporary",
1800
+ resources: config.resources,
1801
+ env: config.envVars,
1802
+ ttl: "10m"
1803
+ },
1804
+ logger
1805
+ );
1806
+ sourceWorkspaceId = provisioned.workspaceId;
1807
+ this.trackActiveBuildWorkspace(config.sessionId, provisioned.workspaceId);
1808
+ await this.ensureWorkspaceGit(provisioned.runtime, logger, "Dockerfile source workspace");
1809
+ const executor = this.workspaceExecutor(provisioned.runtime);
1810
+ const cloneUrl = injectGitToken(config.repoUrl, config.gitToken);
1811
+ const fetchCommand = [
1812
+ "set -e",
1813
+ `rm -rf ${sq(sourceDir)}`,
1814
+ `mkdir -p ${sq(sourceDir)}`,
1815
+ `cd ${sq(sourceDir)}`,
1816
+ "git init -q",
1817
+ `git remote add origin ${sq(cloneUrl)}`,
1818
+ // GIT_TERMINAL_PROMPT=0 + GIT_ASKPASS=/bin/echo prevent the
1819
+ // process from hanging on a credential prompt when a non-tty
1820
+ // build runner has no token; --progress forces git to emit
1821
+ // progress lines to stderr so they reach the build log stream.
1822
+ `GIT_TERMINAL_PROMPT=0 GIT_ASKPASS=/bin/echo git -c credential.helper= fetch --progress --depth ${config.commitSha ? "50" : "1"} origin ${sq(config.branch)}`,
1823
+ `git -c credential.helper= -c advice.detachedHead=false checkout -q ${sq(checkoutRef)}`,
1824
+ 'echo "Dockerfile source fetch ready."'
1825
+ ].join("\n");
1826
+ const fetchResult = await executor.streamExec(fetchCommand, logger.callback);
1827
+ if (fetchResult.code !== 0) {
1828
+ throw new Error(
1829
+ summarizeCommandOutput(fetchResult.output) || "Failed to fetch Dockerfile source"
1830
+ );
1831
+ }
1832
+ for (const candidate of candidates) {
1833
+ logger.log(`Checking Dockerfile candidate: ${candidate}
1834
+ `);
1835
+ const dockerfile = await executor.exec(
1836
+ [
1837
+ `cd ${sq(sourceDir)}`,
1838
+ `test -f ${sq(candidate)}`,
1839
+ `cat ${sq(candidate)}`
1840
+ ].join(" && "),
1841
+ { timeout: 3e4 }
1842
+ ).catch(() => null);
1843
+ if (dockerfile !== null) {
1844
+ logger.log(`Dockerfile source resolved: ${candidate}
1845
+ `);
1846
+ const workspaceId = sourceWorkspaceId;
1847
+ sourceWorkspaceId = void 0;
1848
+ return {
1849
+ kind: "remote",
1850
+ contextRelativePath: normalizeDockerRelativePath(config.rootDirectory),
1851
+ dockerfile,
1852
+ cleanup: async () => {
1853
+ await this.ws(workspaceId).delete().catch(() => {
1854
+ });
1855
+ this.untrackActiveBuildWorkspace(config.sessionId, workspaceId);
1856
+ }
1857
+ };
1858
+ }
1859
+ }
1860
+ const discovered = await executor.exec(
1861
+ `cd ${sq(sourceDir)} && find . -maxdepth 5 -type f -iname Dockerfile | sed 's#^./##' | head -20`,
1862
+ { timeout: 3e4 }
1863
+ ).then((value) => value.trim()).catch(() => "");
1864
+ throw new Error(
1865
+ `No Dockerfile found. Checked: ${candidates.join(", ")}${discovered ? `. Found: ${discovered.replace(/\n/g, ", ")}` : ""}`
1866
+ );
1867
+ } finally {
1868
+ if (sourceWorkspaceId) {
1869
+ await this.ws(sourceWorkspaceId).delete().catch(() => {
1870
+ });
1871
+ this.untrackActiveBuildWorkspace(config.sessionId, sourceWorkspaceId);
1872
+ }
1873
+ }
1874
+ }
1875
+ getUnsupportedDockerfilePlanFeatures(plan) {
1876
+ const errors = [];
1877
+ for (const stage of plan.stages) {
1878
+ if (stage.shell && stage.shell.join("\0") !== ["/bin/sh", "-c"].join("\0")) {
1879
+ errors.push(
1880
+ `Dockerfile stage "${stage.name ?? stage.index}" uses SHELL ${JSON.stringify(stage.shell)}, which is not supported by cloud Dockerfile builds yet.`
1881
+ );
1882
+ }
1883
+ for (const run of stage.runs) {
1884
+ const flags = Object.keys(run.flags);
1885
+ if (flags.length > 0) {
1886
+ errors.push(
1887
+ `Dockerfile line ${run.line}: RUN flags (${flags.map((flag) => `--${flag}`).join(", ")}) are not supported by cloud Dockerfile builds yet.`
1888
+ );
1889
+ }
1890
+ }
1891
+ for (const copy of stage.copies) {
1892
+ if (copy.kind === "add") {
1893
+ errors.push(
1894
+ `Dockerfile line ${copy.line}: ADD is not supported by cloud Dockerfile builds yet. Use COPY for local files.`
1895
+ );
1896
+ }
1897
+ const flags = Object.keys(copy.flags).filter((flag) => flag !== "from");
1898
+ if (flags.length > 0) {
1899
+ errors.push(
1900
+ `Dockerfile line ${copy.line}: ${copy.kind.toUpperCase()} flags (${flags.map((flag) => `--${flag}`).join(", ")}) are not supported by cloud Dockerfile builds yet.`
1901
+ );
1902
+ }
1903
+ }
1904
+ }
1905
+ return errors;
1906
+ }
1907
+ async executeDockerfilePlan(opts) {
1908
+ const { config, plan, source, logger, onWorkspaceCreated } = opts;
1909
+ const plannedStageRefs = /* @__PURE__ */ new Map();
1910
+ const resolvedStageBaseImages = /* @__PURE__ */ new Map();
1911
+ const stageBaseLabels = /* @__PURE__ */ new Map();
1912
+ let workspaceBaseImage;
1913
+ let workspaceId;
1914
+ try {
1915
+ for (const stage of plan.stages) {
1916
+ const stageName = stage.name ?? String(stage.index);
1917
+ const allArgs = { ...plan.globalArgs, ...stage.args };
1918
+ const requestedBaseImage = substituteDockerArgs(stage.baseImage, allArgs);
1919
+ const baseStageRef = plannedStageRefs.get(requestedBaseImage);
1920
+ const baseImage = baseStageRef ? resolvedStageBaseImages.get(baseStageRef.index) : requestedBaseImage;
1921
+ if (!baseImage || baseImage.includes("$")) {
1922
+ throw new Error(
1923
+ `Dockerfile stage "${stageName}" has unresolved base image "${stage.baseImage}".`
1924
+ );
1925
+ }
1926
+ const baseLabel = stageBaseLabel(baseImage, requestedBaseImage, baseStageRef?.name);
1927
+ if (workspaceBaseImage && workspaceBaseImage !== baseImage) {
1928
+ throw new Error(
1929
+ `Dockerfile stage "${stageName}" uses base image "${baseLabel}", but cloud Dockerfile builds run one workspace per service and only support one base image per Dockerfile. Use stage aliases that resolve to "${workspaceBaseImage}" or deploy this service with Docker support.`
1930
+ );
1931
+ }
1932
+ workspaceBaseImage ??= baseImage;
1933
+ stageBaseLabels.set(stage.index, baseLabel);
1934
+ resolvedStageBaseImages.set(stage.index, baseImage);
1935
+ plannedStageRefs.set(String(stage.index), stage);
1936
+ if (stage.name) plannedStageRefs.set(stage.name, stage);
1937
+ }
1938
+ if (!workspaceBaseImage) {
1939
+ throw new Error("Dockerfile did not define a base image.");
1940
+ }
1941
+ const provisioned = await this.provisionWorkspace(
1942
+ {
1943
+ name: `${config.slug ?? config.projectId}-dockerfile`.slice(0, 60),
1944
+ image: workspaceBaseImage,
1945
+ mode: "temporary",
1946
+ resources: config.resources,
1947
+ env: config.envVars,
1948
+ ttl: "15m"
1949
+ },
1950
+ logger
1951
+ );
1952
+ workspaceId = provisioned.workspaceId;
1953
+ onWorkspaceCreated(provisioned.workspaceId);
1954
+ if (plan.stages.some(
1955
+ (stage) => stage.steps.some((step) => step.type === "copy" && !step.copy.from)
1956
+ )) {
1957
+ await this.transferDockerfileContext(config, source, provisioned.runtime, logger);
1958
+ }
1959
+ const completedStageRefs = /* @__PURE__ */ new Map();
1960
+ for (const stage of plan.stages) {
1961
+ const stageName = stage.name ?? String(stage.index);
1962
+ const baseLabel = stageBaseLabels.get(stage.index) ?? workspaceBaseImage;
1963
+ logger.log(`Preparing Dockerfile stage "${stageName}" from ${baseLabel}...
1964
+ `);
1965
+ await this.execAndStream(
1966
+ provisioned.runtime,
1967
+ ["sh", "-c", `mkdir -p ${sq(stage.workdir || "/")}`],
1968
+ logger.callback
1969
+ );
1970
+ for (const step of stage.steps) {
1971
+ if (step.type === "copy") {
1972
+ await this.applyDockerfileCopyStep({
1973
+ copy: step.copy,
1974
+ stageRefs: completedStageRefs,
1975
+ runtime: provisioned.runtime,
1976
+ logger
1977
+ });
1978
+ } else {
1979
+ await this.applyDockerfileRunStep({
1980
+ run: step.run,
1981
+ runtime: provisioned.runtime,
1982
+ logger,
1983
+ env: {
1984
+ ...config.envVars,
1985
+ ...plan.globalArgs,
1986
+ ...stage.args,
1987
+ ...step.run.env
1988
+ }
1989
+ });
1990
+ }
1991
+ }
1992
+ completedStageRefs.set(String(stage.index), stage);
1993
+ if (stage.name) completedStageRefs.set(stage.name, stage);
1994
+ }
1995
+ if (!plan.runtime) {
1996
+ throw new Error("Dockerfile final stage workspace was not created.");
1997
+ }
1998
+ return { workspaceId: provisioned.workspaceId, runtime: plan.runtime };
1999
+ } catch (err) {
2000
+ if (workspaceId) {
2001
+ await this.ws(workspaceId).delete().catch(() => {
2002
+ });
2003
+ }
2004
+ throw err;
2005
+ }
2006
+ }
2007
+ async transferDockerfileContext(config, source, runtime, logger) {
2008
+ if (source.kind === "local") {
2009
+ await transferLocalDirectory(
2010
+ source.contextRoot,
2011
+ { kind: "cloud-runtime", runtime, path: "/openship/context" },
2012
+ logger,
2013
+ { excludes: [...TRANSFER_EXCLUDES] }
2014
+ );
2015
+ return;
2016
+ }
2017
+ await this.cloneDockerfileContext(config, source, runtime, logger);
2018
+ }
2019
+ async cloneDockerfileContext(config, source, targetRuntime, logger) {
2020
+ const repoRoot = "/openship/repo";
2021
+ const contextRoot = "/openship/context";
2022
+ const contextRelativePath = normalizeDockerRelativePath(source.contextRelativePath);
2023
+ if (contextRelativePath.startsWith("..")) {
2024
+ throw new Error("Dockerfile build context escapes the repository source.");
2025
+ }
2026
+ const cloneUrl = injectGitToken(config.repoUrl, config.gitToken);
2027
+ const depthArgs = config.commitSha ? "--depth 50 " : "--depth 1 ";
2028
+ const cloneTarget = contextRelativePath ? repoRoot : contextRoot;
2029
+ const contextSource = contextRelativePath ? joinWorkspacePath(repoRoot, contextRelativePath) : contextRoot;
2030
+ const prepareContextCommands = contextRelativePath ? [
2031
+ `echo "Copying Dockerfile context ${contextRelativePath}..."`,
2032
+ `mkdir -p ${sq(contextRoot)}`,
2033
+ `cp -a ${sq(`${contextSource}/.`)} ${sq(contextRoot)}/`
2034
+ ] : ['echo "Using repository root as Dockerfile context."'];
2035
+ const cloneCommand = [
2036
+ "set -e",
2037
+ `rm -rf ${sq(repoRoot)} ${sq(contextRoot)}`,
2038
+ "mkdir -p /openship",
2039
+ // See fetchCommand above for env-var rationale; --progress keeps
2040
+ // the clone visible in the streamed log even though stdout/stderr
2041
+ // are pipes, not a tty.
2042
+ `GIT_TERMINAL_PROMPT=0 GIT_ASKPASS=/bin/echo git -c credential.helper= clone --progress ${depthArgs}--branch ${sq(config.branch)} ${sq(cloneUrl)} ${sq(cloneTarget)}`
2043
+ ].join("\n");
2044
+ const checkoutCommand = config.commitSha ? `cd ${sq(cloneTarget)} && git -c credential.helper= -c advice.detachedHead=false checkout ${sq(config.commitSha)}` : "";
2045
+ const prepareCommand = [
2046
+ "set -e",
2047
+ `rm -rf ${sq(joinWorkspacePath(cloneTarget, ".git"))}`,
2048
+ ...prepareContextCommands,
2049
+ 'echo "Dockerfile context prepared."'
2050
+ ].join("\n");
2051
+ logger.log(`Preparing Dockerfile build workspace for repository clone (branch: ${config.branch})...
2052
+ `);
2053
+ await this.ensureWorkspaceGit(targetRuntime, logger, "Dockerfile build workspace");
2054
+ logger.log(`Cloning Dockerfile context in build workspace (branch: ${config.branch})...
2055
+ `);
2056
+ await this.execAndStream(targetRuntime, ["sh", "-c", cloneCommand], logger.callback, 900);
2057
+ if (checkoutCommand) {
2058
+ await this.execAndStream(targetRuntime, ["sh", "-c", checkoutCommand], logger.callback, 300);
2059
+ }
2060
+ await this.execAndStream(targetRuntime, ["sh", "-c", prepareCommand], logger.callback, 300);
2061
+ logger.log("Dockerfile context ready.\n");
2062
+ }
2063
+ async applyDockerfileRunStep(opts) {
2064
+ const { run, runtime, logger, env } = opts;
2065
+ const command = `mkdir -p ${sq(run.workdir)} && cd ${sq(run.workdir)} && ${envExportPrefix(env)}${run.command}`;
2066
+ logger.log(`[Dockerfile] RUN ${run.command}
2067
+ `);
2068
+ await this.execAndStream(runtime, ["sh", "-c", command], logger.callback);
2069
+ }
2070
+ async applyDockerfileCopyStep(opts) {
2071
+ const { copy, stageRefs, runtime, logger } = opts;
2072
+ let sourceBase = "/openship/context";
2073
+ let sourcePaths = copy.sources;
2074
+ let copyFromStage = false;
2075
+ if (copy.from) {
2076
+ const sourceStage = stageRefs.get(copy.from);
2077
+ if (!sourceStage) {
2078
+ throw new Error(
2079
+ `COPY --from=${copy.from} references an external image, current stage, later stage, or unknown stage. Cloud Dockerfile builds support --from only for previous stages in the same Dockerfile.`
2080
+ );
2081
+ }
2082
+ if (copy.sources.some(hasGlob)) {
2083
+ throw new Error(
2084
+ `COPY --from=${copy.from} uses glob sources (${copy.sources.join(", ")}), which are not supported in cloud Dockerfile stage artifact copies yet.`
2085
+ );
2086
+ }
2087
+ const downloadPaths = copy.sources.map(stageArtifactDownloadPath);
2088
+ const sourceLabel = downloadPaths.join(" ");
2089
+ logger.log(`[Dockerfile] COPY artifacts from stage "${copy.from}": ${sourceLabel}
2090
+ `);
2091
+ copyFromStage = true;
2092
+ sourceBase = "";
2093
+ sourcePaths = downloadPaths;
2094
+ }
2095
+ const destination = resolveVmPath(copy.workdir, copy.destination);
2096
+ const destinationIsDir = copy.sources.length > 1 || copy.destination.endsWith("/") || copy.destination === "." || copy.destination.endsWith("/.");
2097
+ const destinationTarget = destinationIsDir ? destination : posix.dirname(destination);
2098
+ const sourceExprs = copyFromStage ? sourcePaths.map((source) => sq(source)) : sourcePaths.map((source) => sourceExpression(sourceBase, source));
2099
+ if (copyFromStage) {
2100
+ const samePathCopies = sourcePaths.map((source) => ({
2101
+ source: posix.normalize(source),
2102
+ target: posix.normalize(
2103
+ resolveCopyDestinationPath(copy, source, destination, destinationIsDir)
2104
+ )
2105
+ }));
2106
+ if (samePathCopies.every(({ source, target }) => source === target)) {
2107
+ const verifyCommand = samePathCopies.map(({ source }) => `test -e ${sq(source)}`).join(" && ");
2108
+ await this.execAndStream(runtime, ["sh", "-c", verifyCommand], logger.callback);
2109
+ logger.log("[Dockerfile] COPY artifacts already available in current workspace.\n");
2110
+ return;
2111
+ }
2112
+ }
2113
+ const command = [
2114
+ "set -e",
2115
+ `mkdir -p ${sq(destinationTarget)}`,
2116
+ ...dockerCopyShellCommands({
2117
+ sources: sourceExprs,
2118
+ destination,
2119
+ destinationIsDir,
2120
+ destinationTarget
2121
+ })
2122
+ ].join("\n");
2123
+ logger.log(
2124
+ `[Dockerfile] ${copy.kind.toUpperCase()} ${copy.sources.join(" ")} ${copy.destination}
2125
+ `
2126
+ );
2127
+ await this.execAndStream(runtime, ["sh", "-c", command], logger.callback);
2128
+ }
2129
+ /**
2130
+ * Provision a temporary cloud workspace for a build.
2131
+ *
2132
+ * This is the cloud-specific "prepare" phase:
2133
+ * 1. Create workspace (image + resources + env vars)
2134
+ * 2. Set TTL for auto-cleanup
2135
+ * 3. Acquire runtime token (enable API server + get JWT)
2136
+ *
2137
+ * Output streams to the terminal via logger so the user sees
2138
+ * progress before the numbered build steps begin.
2139
+ */
2140
+ async provisionWorkspace(config, logger) {
2141
+ return provisionCloudWorkspace(this.client, config, logger);
2142
+ }
2143
+ /**
2144
+ * Folder-upload flow: attach to a workspace the browser already uploaded its
2145
+ * source into and acquire its runtime handle. Mirrors the connect-with-retry
2146
+ * in `provisionBuildWorkspace`, but creates nothing — the workspace already
2147
+ * exists (provisioned when the upload session was opened).
2148
+ */
2149
+ async adoptWorkspaceRuntime(workspaceId) {
2150
+ const ws = this.ws(workspaceId);
2151
+ let rt;
2152
+ for (let attempt = 0; attempt < 3; attempt++) {
2153
+ try {
2154
+ rt = await ws.runtime();
2155
+ break;
2156
+ } catch (err) {
2157
+ if (attempt < 2) await new Promise((r) => setTimeout(r, 2e3));
2158
+ else throw err;
2159
+ }
2160
+ }
2161
+ if (!rt) throw new Error("Failed to connect to uploaded workspace");
2162
+ return rt;
2163
+ }
2164
+ async provisionBuildWorkspace(config, logger) {
2165
+ logger.log("Provisioning build environment...\n");
2166
+ const envArray = Object.entries(config.envVars).map(([k, v]) => `${k}=${v}`);
2167
+ logger.log(`Creating build workspace from image "${config.buildImage}"...
2168
+ `);
2169
+ let wsData;
2170
+ try {
2171
+ wsData = await this.client.workspaces.create({
2172
+ name: config.slug ?? `build-${config.projectId.slice(0, 20)}`,
2173
+ image: config.buildImage,
2174
+ mode: "temporary",
2175
+ config: {
2176
+ cpus: cloudCpus(config.resources.cpuCores),
2177
+ memory_mb: config.resources.memoryMb,
2178
+ disk_size_mb: config.resources.diskMb,
2179
+ env: envArray
2180
+ }
2181
+ });
2182
+ } catch (err) {
2183
+ const message = formatCloudError(err);
2184
+ logger.log(
2185
+ `Failed to create build workspace from image "${config.buildImage}": ${message}
2186
+ `,
2187
+ "error"
2188
+ );
2189
+ throw err;
2190
+ }
2191
+ const ws = this.ws(wsData.id);
2192
+ try {
2193
+ try {
2194
+ await ws.lifecycle.makeTemporary({
2195
+ ttl: "15m",
2196
+ ttl_action: "remove",
2197
+ remove_on_exit: true
2198
+ });
2199
+ } catch {
2200
+ }
2201
+ logger.log("Connecting to build environment...\n");
2202
+ let rt;
2203
+ for (let attempt = 0; attempt < 3; attempt++) {
2204
+ try {
2205
+ rt = await ws.runtime();
2206
+ break;
2207
+ } catch (err) {
2208
+ if (attempt < 2) {
2209
+ await new Promise((r) => setTimeout(r, 2e3));
2210
+ } else {
2211
+ throw err;
2212
+ }
2213
+ }
2214
+ }
2215
+ if (!rt) throw new Error("Failed to connect to build environment");
2216
+ logger.log("Build environment ready\n");
2217
+ return { workspaceId: wsData.id, runtime: rt };
2218
+ } catch (err) {
2219
+ await ws.delete().catch(() => {
2220
+ });
2221
+ const message = formatCloudError(err);
2222
+ logger.log(`Failed to prepare build workspace "${wsData.id}": ${message}
2223
+ `, "error");
2224
+ throw err;
2225
+ }
2226
+ }
2227
+ async cancelBuild(sessionId) {
2228
+ const activeBuild = this.activeBuilds.get(sessionId);
2229
+ if (!activeBuild) {
2230
+ return;
2231
+ }
2232
+ activeBuild.abort.abort();
2233
+ const workspaceIds = [...activeBuild.workspaceIds];
2234
+ await Promise.allSettled(
2235
+ workspaceIds.map(async (workspaceId) => {
2236
+ await this.destroy(workspaceId).catch(() => {
2237
+ });
2238
+ activeBuild.workspaceIds.delete(workspaceId);
2239
+ })
2240
+ );
2241
+ }
2242
+ async getBuildLogs(sessionId) {
2243
+ void sessionId;
2244
+ return [];
2245
+ }
2246
+ // ── Deploy lifecycle ───────────────────────────────────────────────────
2247
+ async deploy(config, onLog) {
2248
+ const workspaceId = config.imageRef;
2249
+ if (!workspaceId) {
2250
+ return {
2251
+ deploymentId: config.deploymentId,
2252
+ status: "failed"
2253
+ };
2254
+ }
2255
+ const ws = this.ws(workspaceId);
2256
+ const log = onLog ?? (() => {
2257
+ });
2258
+ try {
2259
+ await ws.lifecycle.makePermanent();
2260
+ } catch (err) {
2261
+ throw new Error(
2262
+ `Failed to make workspace permanent: ${err instanceof Error ? err.message : err}`
2263
+ );
2264
+ }
2265
+ const prodResources = config.resources ?? DEFAULT_RESOURCE_CONFIG;
2266
+ let resized = false;
2267
+ for (let attempt = 1; attempt <= 3 && !resized; attempt++) {
2268
+ try {
2269
+ await ws.resources.update({
2270
+ cpus: cloudCpus(prodResources.cpuCores),
2271
+ memory_mb: prodResources.memoryMb,
2272
+ apply: true
2273
+ });
2274
+ resized = true;
2275
+ log({
2276
+ timestamp: now2(),
2277
+ level: "info",
2278
+ message: `Resized workspace to ${prodResources.cpuCores} vCPU \xB7 ${prodResources.memoryMb} MB.
2279
+ `
2280
+ });
2281
+ } catch (err) {
2282
+ if (attempt === 3) {
2283
+ throw new Error(
2284
+ `Failed to shrink workspace from the build tier to ${prodResources.cpuCores} vCPU \xB7 ${prodResources.memoryMb} MB after 3 attempts \u2014 refusing to promote a build-sized workspace: ${safeErrorMessage(err)}`
2285
+ );
2286
+ }
2287
+ log({
2288
+ timestamp: now2(),
2289
+ level: "warn",
2290
+ message: `Resize attempt ${attempt} failed, retrying\u2026 ${safeErrorMessage(err)}
2291
+ `
2292
+ });
2293
+ await new Promise((r) => setTimeout(r, 1e3 * attempt));
2294
+ }
2295
+ }
2296
+ const builtArtifact = this.builtArtifacts.get(workspaceId);
2297
+ const prodPaths = config.productionPaths;
2298
+ const workDir = builtArtifact?.runtime.workdir ?? (prodPaths?.length ? "/app/production" : "/app");
2299
+ if (prodPaths?.length) {
2300
+ try {
2301
+ const rt = await ws.runtime();
2302
+ const logCb = onLog ?? (() => {
2303
+ });
2304
+ const safePaths = prodPaths.filter((p) => {
2305
+ const normalized = p.replace(/\/+/g, "/").replace(/^\/|\/$/g, "");
2306
+ return normalized.length > 0 && normalized !== ".." && !normalized.startsWith("../") && !normalized.includes("/../") && !normalized.includes("\\");
2307
+ });
2308
+ if (safePaths.length === 0) {
2309
+ throw new Error("No valid production paths after sanitization");
2310
+ }
2311
+ const sq2 = (s) => s.replace(/'/g, "'\\''");
2312
+ const moveLines = safePaths.map((p) => {
2313
+ const e = sq2(p);
2314
+ return `if [ -e '/app/${e}' ]; then
2315
+ d=$(dirname '${e}')
2316
+ mkdir -p "/app/.staging/$d"
2317
+ mv '/app/${e}' '/app/.staging/${e}'
2318
+ echo ' moved /app/${e}'
2319
+ else
2320
+ echo ' skip /app/${e} (not found)'
2321
+ fi`;
2322
+ }).join("\n");
2323
+ const script = `set -e
2324
+ cleanup() { echo "Cleaning up staging dir"; rm -rf /app/.staging; }
2325
+ echo "Preparing production directory..."
2326
+ rm -rf /app/.staging
2327
+ mkdir -p /app/.staging
2328
+ ${moveLines}
2329
+ if [ "$(ls -A /app/.staging)" ]; then
2330
+ rm -rf /app/production
2331
+ mv /app/.staging /app/production
2332
+ echo "Production directory ready"
2333
+ else
2334
+ cleanup
2335
+ echo "ERROR: no files were moved - check production paths"
2336
+ exit 1
2337
+ fi`;
2338
+ await this.execAndStream(rt, ["sh", "-c", script], logCb);
2339
+ } catch (err) {
2340
+ throw new Error(
2341
+ `Failed to prepare production directory: ${err instanceof Error ? err.message : err}`
2342
+ );
2343
+ }
2344
+ }
2345
+ const startCommand = builtArtifact?.runtime.startCommand || config.startCommand || "npm start";
2346
+ const envArray = toEnvArray2({
2347
+ ...builtArtifact?.runtime.env ?? {},
2348
+ ...config.envVars
2349
+ });
2350
+ const restartPolicy = config.restartPolicy === "no" ? "never" : config.restartPolicy ?? "always";
2351
+ try {
2352
+ await ws.workloads.create({
2353
+ name: "app",
2354
+ cmd: ["sh", "-c", `cd ${workDir} && ${startCommand}`],
2355
+ working_dir: workDir,
2356
+ env: [...envArray, `PORT=${config.port}`],
2357
+ restart_policy: restartPolicy,
2358
+ max_restarts: 10
2359
+ });
2360
+ } catch (err) {
2361
+ throw new Error(`Failed to create workload: ${err instanceof Error ? err.message : err}`);
2362
+ }
2363
+ let url;
2364
+ const primaryEndpoint = primaryPublicEndpoint(config);
2365
+ const primarySlug = endpointSlug(primaryEndpoint);
2366
+ const primaryCustomDomain = endpointCustomDomain(primaryEndpoint);
2367
+ const primaryPort = primaryEndpoint?.port ?? config.port;
2368
+ if (primaryCustomDomain) {
2369
+ try {
2370
+ await ws.network.update({ ingress_ports: [primaryPort] });
2371
+ await this.connectCustomDomainReassigning(
2372
+ workspaceId,
2373
+ primaryCustomDomain,
2374
+ primaryPort,
2375
+ log
2376
+ );
2377
+ url = `https://${primaryCustomDomain}`;
2378
+ } catch (err) {
2379
+ throw new Error(
2380
+ `Failed to connect custom domain ${primaryCustomDomain}: ${safeErrorMessage(err)}`
2381
+ );
2382
+ }
2383
+ } else if (primarySlug) {
2384
+ try {
2385
+ log({
2386
+ timestamp: now2(),
2387
+ level: "info",
2388
+ message: `Exposing ${exposeTarget2(primaryPort, primarySlug)}...
2389
+ `
2390
+ });
2391
+ const exposeResult = await ws.publicAccess.expose({
2392
+ port: primaryPort,
2393
+ domain: SYSTEM.DOMAINS.CLOUD_DOMAIN,
2394
+ slug: primarySlug
2395
+ });
2396
+ url = exposeResult.url;
2397
+ } catch (err) {
2398
+ throw new Error(
2399
+ `Failed to expose ${exposeTarget2(primaryPort, primarySlug)}: ${safeErrorMessage(err)}`
2400
+ );
2401
+ }
2402
+ } else {
2403
+ log({
2404
+ timestamp: now2(),
2405
+ level: "info",
2406
+ message: "No public endpoint configured; skipping cloud public exposure.\n"
2407
+ });
2408
+ }
2409
+ return {
2410
+ deploymentId: config.deploymentId,
2411
+ containerId: workspaceId,
2412
+ url: url ?? void 0,
2413
+ status: "running"
2414
+ };
2415
+ }
2416
+ /**
2417
+ * Deploy a static site via Oblien Pages.
2418
+ *
2419
+ * Flow:
2420
+ * 1. Create a page from the workspace build output → files copied to edge
2421
+ * 2. Page goes live immediately on CDN
2422
+ * 3. Delete the workspace - page is independent, no VM needed
2423
+ *
2424
+ * The "containerId" in the result is the page ID (for future updates/teardown).
2425
+ */
2426
+ async deployStatic(config) {
2427
+ const workspaceId = config.imageRef;
2428
+ if (!workspaceId) {
2429
+ return { deploymentId: config.deploymentId, status: "failed" };
2430
+ }
2431
+ const primaryEndpoint = primaryPublicEndpoint(config);
2432
+ const primarySlug = endpointSlug(primaryEndpoint);
2433
+ const primaryCustomDomain = endpointCustomDomain(primaryEndpoint);
2434
+ const pageSlug = primarySlug ?? fallbackRuntimeName(config);
2435
+ const wantFree = !primaryCustomDomain && !!primarySlug;
2436
+ const outputDirError = () => new Error(missingOutputDirectoryMessage(config.outputDirectory, pageSlug));
2437
+ const isOutputPathError = (err) => {
2438
+ const msg = safeErrorMessage(err).toLowerCase();
2439
+ return msg.includes("path is required") || msg.includes("folder inside vm") || msg.includes("no such") || msg.includes("not found") || msg.includes("does not exist");
2440
+ };
2441
+ const trimmedOutput = config.outputDirectory?.trim();
2442
+ if (!trimmedOutput || trimmedOutput === "/") {
2443
+ throw outputDirError();
2444
+ }
2445
+ const outputPath = resolveStaticOutputPath("/app", config.outputDirectory);
2446
+ console.log(
2447
+ `Deploying static site from workspace ${workspaceId}, output path ${outputPath}...`
2448
+ );
2449
+ const createFresh = async () => {
2450
+ if (primaryCustomDomain) {
2451
+ let pg2;
2452
+ try {
2453
+ const result = await this.client.pages.create({
2454
+ workspace_id: workspaceId,
2455
+ path: outputPath,
2456
+ name: config.projectName ?? pageSlug,
2457
+ slug: pageSlug
2458
+ });
2459
+ pg2 = result.page;
2460
+ } catch (err) {
2461
+ if (isOutputPathError(err)) throw outputDirError();
2462
+ throw new Error(
2463
+ `Failed to create static page for slug "${pageSlug}" with custom domain "${primaryCustomDomain}": ${safeErrorMessage(err)}`
2464
+ );
2465
+ }
2466
+ await this.client.pages.connectDomain(pg2.slug, { domain: primaryCustomDomain }).catch(() => {
2467
+ });
2468
+ return { ...pg2, url: pg2.url ?? `https://${primaryCustomDomain}` };
2469
+ }
2470
+ if (wantFree) {
2471
+ const createOnSharedZone = this.adminProxy?.createPage ?? ((input) => this.client.pages.create(input));
2472
+ try {
2473
+ const result = await createOnSharedZone({
2474
+ workspace_id: workspaceId,
2475
+ path: outputPath,
2476
+ name: config.projectName ?? pageSlug,
2477
+ slug: pageSlug,
2478
+ domain: SYSTEM.DOMAINS.CLOUD_DOMAIN
2479
+ });
2480
+ return result.page;
2481
+ } catch (err) {
2482
+ if (isOutputPathError(err)) throw outputDirError();
2483
+ throw new Error(
2484
+ `Failed to create static page for slug "${pageSlug}" (${pageSlug}.opsh.io): ${safeErrorMessage(err)}`
2485
+ );
2486
+ }
2487
+ }
2488
+ let pg;
2489
+ try {
2490
+ const result = await this.client.pages.create({
2491
+ workspace_id: workspaceId,
2492
+ path: outputPath,
2493
+ name: config.projectName ?? pageSlug,
2494
+ slug: pageSlug
2495
+ });
2496
+ pg = result.page;
2497
+ } catch (err) {
2498
+ if (isOutputPathError(err)) throw outputDirError();
2499
+ throw new Error(`Failed to create static page for slug "${pageSlug}": ${safeErrorMessage(err)}`);
2500
+ }
2501
+ return { ...pg, url: void 0 };
2502
+ };
2503
+ let page;
2504
+ const existingPage = (await this.client.pages.get(pageSlug).catch(() => null))?.page ?? null;
2505
+ const bindingMatches = !!existingPage && (primaryCustomDomain ? existingPage.custom_domain === primaryCustomDomain : wantFree ? existingPage.domain === SYSTEM.DOMAINS.CLOUD_DOMAIN : true);
2506
+ if (existingPage && bindingMatches) {
2507
+ try {
2508
+ const result = await this.client.pages.deploy(pageSlug, {
2509
+ workspace_id: workspaceId,
2510
+ path: outputPath
2511
+ });
2512
+ page = result.page;
2513
+ } catch (err) {
2514
+ throw new Error(`Failed to redeploy static page for slug "${pageSlug}": ${safeErrorMessage(err)}`);
2515
+ }
2516
+ } else {
2517
+ if (existingPage) {
2518
+ await this.client.pages.delete(pageSlug).catch(() => {
2519
+ });
2520
+ }
2521
+ page = await createFresh();
2522
+ }
2523
+ await this.ws(workspaceId).delete().catch(() => {
2524
+ });
2525
+ return {
2526
+ deploymentId: config.deploymentId,
2527
+ containerId: `${PAGE_CONTAINER_PREFIX}${page.slug}`,
2528
+ url: page.url ?? void 0,
2529
+ status: "running"
2530
+ };
2531
+ }
2532
+ async stop(containerId) {
2533
+ if (containerId.startsWith(PAGE_CONTAINER_PREFIX)) {
2534
+ const slug = containerId.slice(5);
2535
+ if (this.adminProxy?.disablePage) {
2536
+ await this.adminProxy.disablePage(slug);
2537
+ } else {
2538
+ await this.client.pages.disable(slug);
2539
+ }
2540
+ } else {
2541
+ await this.ws(containerId).stop();
2542
+ }
2543
+ }
2544
+ async start(containerId) {
2545
+ if (containerId.startsWith(PAGE_CONTAINER_PREFIX)) {
2546
+ const slug = containerId.slice(5);
2547
+ if (this.adminProxy?.enablePage) {
2548
+ await this.adminProxy.enablePage(slug);
2549
+ } else {
2550
+ await this.client.pages.enable(slug);
2551
+ }
2552
+ } else {
2553
+ await this.ws(containerId).start();
2554
+ }
2555
+ }
2556
+ async restart(containerId) {
2557
+ if (containerId.startsWith(PAGE_CONTAINER_PREFIX)) {
2558
+ return;
2559
+ }
2560
+ await this.ws(containerId).restart();
2561
+ }
2562
+ async destroy(containerId) {
2563
+ try {
2564
+ if (containerId.startsWith(PAGE_CONTAINER_PREFIX)) {
2565
+ const slug = containerId.slice(5);
2566
+ if (this.adminProxy?.deletePage) {
2567
+ await this.adminProxy.deletePage(slug);
2568
+ } else {
2569
+ await this.client.pages.delete(slug);
2570
+ }
2571
+ } else {
2572
+ await this.ws(containerId).delete();
2573
+ this.builtArtifacts.delete(containerId);
2574
+ }
2575
+ } catch (err) {
2576
+ if (isRuntimeNotFoundError(err)) {
2577
+ this.builtArtifacts.delete(containerId);
2578
+ return;
2579
+ }
2580
+ throw err;
2581
+ }
2582
+ }
2583
+ // ── Rollback primitives ──────────────────────────────────────────────
2584
+ //
2585
+ // Cloud semantics — internal to Oblien, no external object store:
2586
+ //
2587
+ // archive — `snapshots.createArchive` captures the workspace disk
2588
+ // as a point-in-time Oblien archive (file + metadata
2589
+ // stored next to the workspace), then `ws.stop` powers
2590
+ // down compute. Compute billing pauses; the workspace
2591
+ // slot + archive remain claimed.
2592
+ // makeActive — stops the outgoing workspace, starts the target.
2593
+ // Workspace IDs are stable across stop/start, so no new
2594
+ // containerId is minted.
2595
+ // purge — deletes every archive of the workspace (with their
2596
+ // files) then deletes the workspace itself. Past this
2597
+ // point rollback is impossible.
2598
+ //
2599
+ // Why archive PLUS stop, not archive INSTEAD OF the running workspace:
2600
+ // Oblien's archives are workspace-scoped (`/workspace/{id}/archives`).
2601
+ // Deleting the workspace orphans the archive — there's no
2602
+ // `workspaces.create({ from_archive })` to bring it back. So keeping
2603
+ // the workspace alive in stopped state is what makes rollback work,
2604
+ // and the explicit `createArchive` call gives us a defense-in-depth
2605
+ // snapshot we can restore over the same workspace if its disk drifts.
2606
+ //
2607
+ // Pages (static deployments, containerId starts with "page:") archive
2608
+ // via `pages.disable` and purge via `pages.delete`. No tar.gz involved.
2609
+ //
2610
+ // All three primitives are idempotent.
2611
+ async makeActive(input) {
2612
+ if (input.from?.containerId) {
2613
+ try {
2614
+ await this.stop(input.from.containerId);
2615
+ } catch {
2616
+ }
2617
+ }
2618
+ if (!input.to.containerId) {
2619
+ throw new Error(
2620
+ `Cannot make deployment ${input.to.id} active: workspace is gone. Artifact has been purged.`
2621
+ );
2622
+ }
2623
+ await this.start(input.to.containerId);
2624
+ return { containerId: input.to.containerId };
2625
+ }
2626
+ async archive(deployment) {
2627
+ if (deployment.containerId?.startsWith(PAGE_CONTAINER_PREFIX)) {
2628
+ const slug = deployment.containerId.slice(5);
2629
+ try {
2630
+ if (this.adminProxy?.disablePage) {
2631
+ await this.adminProxy.disablePage(slug);
2632
+ } else {
2633
+ await this.client.pages.disable(slug);
2634
+ }
2635
+ } catch {
2636
+ }
2637
+ return;
2638
+ }
2639
+ if (!deployment.containerId) return;
2640
+ const ws = this.ws(deployment.containerId);
2641
+ try {
2642
+ await ws.snapshots.createArchive({});
2643
+ } catch (err) {
2644
+ console.warn(
2645
+ `[CloudRuntime.archive] snapshots.createArchive failed for ${deployment.id}:`,
2646
+ err instanceof Error ? err.message : err
2647
+ );
2648
+ }
2649
+ try {
2650
+ await this.stop(deployment.containerId);
2651
+ } catch {
2652
+ }
2653
+ }
2654
+ async purge(deployment) {
2655
+ if (deployment.containerId?.startsWith(PAGE_CONTAINER_PREFIX)) {
2656
+ const slug = deployment.containerId.slice(5);
2657
+ try {
2658
+ if (this.adminProxy?.deletePage) {
2659
+ await this.adminProxy.deletePage(slug);
2660
+ } else {
2661
+ await this.client.pages.delete(slug);
2662
+ }
2663
+ } catch {
2664
+ }
2665
+ return;
2666
+ }
2667
+ if (!deployment.containerId) return;
2668
+ try {
2669
+ await this.ws(deployment.containerId).snapshots.deleteAllArchives({
2670
+ delete_files: true
2671
+ });
2672
+ } catch (err) {
2673
+ console.warn(
2674
+ `[CloudRuntime.purge] deleteAllArchives failed for ${deployment.id}:`,
2675
+ err instanceof Error ? err.message : err
2676
+ );
2677
+ }
2678
+ try {
2679
+ await this.destroy(deployment.containerId);
2680
+ } catch {
2681
+ }
2682
+ }
2683
+ // ── Observability ──────────────────────────────────────────────────────
2684
+ async getContainerInfo(containerId) {
2685
+ const ws = this.ws(containerId);
2686
+ let data;
2687
+ try {
2688
+ data = await ws.get();
2689
+ } catch (err) {
2690
+ if (isRuntimeNotFoundError(err)) {
2691
+ return { containerId, status: "missing" };
2692
+ }
2693
+ throw err;
2694
+ }
2695
+ const statusMap = {
2696
+ running: "running",
2697
+ stopped: "stopped",
2698
+ starting: "deploying",
2699
+ stopping: "stopped",
2700
+ creating: "building",
2701
+ error: "failed"
2702
+ };
2703
+ let status = statusMap[data.status] ?? "stopped";
2704
+ if (status === "running") {
2705
+ try {
2706
+ const workloads = await ws.workloads.list();
2707
+ if (workloads && workloads.length > 0) {
2708
+ const states = workloads.map(
2709
+ (w) => String(
2710
+ w.status ?? w.states ?? ""
2711
+ ).toLowerCase()
2712
+ );
2713
+ if (states.some((s) => s.includes("run"))) status = "running";
2714
+ else if (states.some((s) => /(fail|exit|error|crash|dead)/.test(s))) status = "failed";
2715
+ else status = "deploying";
2716
+ }
2717
+ } catch {
2718
+ }
2719
+ }
2720
+ let ip = data.ip;
2721
+ try {
2722
+ const raw = await ws.apiAccess.rawToken();
2723
+ if (raw?.ip) ip = raw.ip;
2724
+ } catch {
2725
+ }
2726
+ return { containerId, status, ip };
2727
+ }
2728
+ async getRuntimeLogs(containerId, tail) {
2729
+ try {
2730
+ const result = await this.ws(containerId).workloads.logs("app");
2731
+ const raw = result;
2732
+ if (typeof raw.logs === "string") {
2733
+ const logStr = raw.logs;
2734
+ return applyTail(
2735
+ logStr.split("\n").filter(Boolean).map((line) => {
2736
+ const match = line.match(/^\[([^\]]+)\]\s+(stdout|stderr):\s?(.*)/);
2737
+ if (match) {
2738
+ return {
2739
+ timestamp: match[1],
2740
+ message: match[3],
2741
+ level: match[2] === "stderr" ? "warn" : "info"
2742
+ };
2743
+ }
2744
+ return { timestamp: now2(), message: line, level: "info" };
2745
+ }),
2746
+ tail
2747
+ );
2748
+ }
2749
+ const lines = Array.isArray(raw.logs) ? raw.logs : Array.isArray(raw.entries) ? raw.entries : Array.isArray(result) ? result : [];
2750
+ if (lines.length === 0) return [];
2751
+ return applyTail(
2752
+ lines.map((line) => {
2753
+ if (typeof line === "string") {
2754
+ return { timestamp: now2(), message: line, level: "info" };
2755
+ }
2756
+ const entry = line;
2757
+ const message = entry.message ?? entry.data ?? String(line);
2758
+ return {
2759
+ timestamp: entry.timestamp ?? now2(),
2760
+ message,
2761
+ level: entry.stream === "stderr" ? "warn" : "info"
2762
+ };
2763
+ }).filter((e) => e.message),
2764
+ tail
2765
+ );
2766
+ } catch {
2767
+ const result = await this.ws(containerId).logs.get({
2768
+ source: "cmd",
2769
+ tail_lines: tail ?? 100
2770
+ });
2771
+ const lines = result.logs;
2772
+ if (!Array.isArray(lines)) return [];
2773
+ return applyTail(
2774
+ lines.map((line) => {
2775
+ if (typeof line === "string") {
2776
+ return { timestamp: now2(), message: line, level: "info" };
2777
+ }
2778
+ const entry = line;
2779
+ return {
2780
+ timestamp: entry.timestamp ?? now2(),
2781
+ message: entry.message ?? String(line),
2782
+ level: "info"
2783
+ };
2784
+ }),
2785
+ tail
2786
+ );
2787
+ }
2788
+ }
2789
+ async streamRuntimeLogs(containerId, onLog, opts) {
2790
+ let cancelled = false;
2791
+ const emitText = (text, level, timestamp) => {
2792
+ if (!text) return;
2793
+ const rawData = Buffer.from(text).toString("base64");
2794
+ onLog({ timestamp: timestamp ?? now2(), message: text, level, rawData });
2795
+ };
2796
+ const run = async () => {
2797
+ try {
2798
+ const replayTail = opts?.tail ?? 100;
2799
+ if (replayTail > 0) {
2800
+ try {
2801
+ const history = await this.getRuntimeLogs(containerId, replayTail);
2802
+ for (const entry of history) {
2803
+ if (cancelled) return;
2804
+ if (!entry.message) continue;
2805
+ const rawData = entry.rawData ?? Buffer.from(entry.message).toString("base64");
2806
+ onLog({ ...entry, rawData });
2807
+ }
2808
+ } catch {
2809
+ }
2810
+ }
2811
+ if (cancelled) return;
2812
+ try {
2813
+ const stream = this.ws(containerId).workloads.logsStream("app");
2814
+ for await (const event of stream) {
2815
+ if (cancelled) break;
2816
+ const ev = event;
2817
+ const text = ev.message ?? event.data ?? "";
2818
+ emitText(text, event.stream === "stderr" ? "warn" : "info", event.timestamp);
2819
+ }
2820
+ } catch {
2821
+ if (cancelled) return;
2822
+ try {
2823
+ const stream = this.ws(containerId).logs.streamCmd({
2824
+ tail_lines: opts?.tail ?? 100
2825
+ });
2826
+ for await (const event of stream) {
2827
+ if (cancelled) break;
2828
+ emitText(event.message, "info", event.timestamp);
2829
+ }
2830
+ } catch {
2831
+ }
2832
+ }
2833
+ } catch {
2834
+ }
2835
+ };
2836
+ void run();
2837
+ return () => {
2838
+ cancelled = true;
2839
+ };
2840
+ }
2841
+ async getUsage(containerId) {
2842
+ const result = await this.ws(containerId).metrics.stats();
2843
+ const stats = result;
2844
+ return {
2845
+ cpuPercent: stats.cpu_usage ?? 0,
2846
+ memoryMb: stats.memory_usage ?? 0,
2847
+ diskMb: stats.disk_usage ?? 0,
2848
+ networkRxBytes: stats.network?.rx ?? 0,
2849
+ networkTxBytes: stats.network?.tx ?? 0
2850
+ };
2851
+ }
2852
+ // ── Network ────────────────────────────────────────────────────────────
2853
+ async getContainerIp(containerId) {
2854
+ const data = await this.ws(containerId).get();
2855
+ return data.ip ?? null;
2856
+ }
2857
+ /**
2858
+ * Open an interactive PTY shell inside an Oblien workspace. Powers
2859
+ * the in-dashboard service terminal — see apps/api/src/modules/service-terminal/.
2860
+ *
2861
+ * Two-step Oblien API:
2862
+ * 1. `rt.terminal.create({shell, cols, rows})` → registers a
2863
+ * terminal session in the workspace and returns its numeric id.
2864
+ * 2. `rt.ws()` → opens a multiplexed WebSocket carrying binary
2865
+ * stdin/stdout (prefixed with the terminal id byte) plus JSON
2866
+ * control frames (resize, exit).
2867
+ *
2868
+ * We bridge that WS into the same ShellSession contract as
2869
+ * SshExecutor and DockerRuntime so the upstream WS bridge in
2870
+ * service-terminal.controller.ts is identical across runtimes.
2871
+ *
2872
+ * Note on workspace id: for the cloud runtime, `containerId` IS the
2873
+ * workspace id (see how cloud.ts already passes containerId into
2874
+ * `this.ws(containerId)` throughout this file).
2875
+ */
2876
+ async openServiceShell(containerId, opts) {
2877
+ const cols = clampShellWindow(opts?.cols, 80, 1, 1e3);
2878
+ const rows = clampShellWindow(opts?.rows, 24, 1, 500);
2879
+ const shellPath = "/bin/sh";
2880
+ const ws = this.ws(containerId);
2881
+ const rt = await ws.runtime();
2882
+ const session = await rt.terminal.create({
2883
+ shell: shellPath,
2884
+ cols,
2885
+ rows
2886
+ });
2887
+ const terminalId = String(session.id);
2888
+ const wsConn = rt.ws({ reconnect: false });
2889
+ const stdout = new PassThrough();
2890
+ const stderr = new PassThrough();
2891
+ const closeListeners = [];
2892
+ let closed = false;
2893
+ const fireClose = (code, signal) => {
2894
+ if (closed) return;
2895
+ closed = true;
2896
+ try {
2897
+ wsConn.close();
2898
+ } catch {
2899
+ }
2900
+ stdout.end();
2901
+ stderr.end();
2902
+ for (const cb of closeListeners) {
2903
+ try {
2904
+ cb(code, signal);
2905
+ } catch {
2906
+ }
2907
+ }
2908
+ };
2909
+ wsConn.onTerminalOutput((id, data) => {
2910
+ if (id === terminalId) stdout.write(Buffer.from(data));
2911
+ });
2912
+ wsConn.onTerminalExit((id, code) => {
2913
+ if (id === terminalId) fireClose(code ?? null);
2914
+ });
2915
+ wsConn.onClose(() => fireClose(null));
2916
+ wsConn.onError(() => fireClose(null));
2917
+ wsConn.connect();
2918
+ const stdin = new Writable({
2919
+ write: (chunk, _enc, cb) => {
2920
+ try {
2921
+ wsConn.writeTerminalInput(
2922
+ terminalId,
2923
+ chunk instanceof Buffer ? new Uint8Array(chunk) : chunk
2924
+ );
2925
+ cb();
2926
+ } catch (err) {
2927
+ cb(err);
2928
+ }
2929
+ },
2930
+ final(cb) {
2931
+ try {
2932
+ wsConn.close();
2933
+ } catch {
2934
+ }
2935
+ cb();
2936
+ }
2937
+ });
2938
+ return {
2939
+ stdin,
2940
+ stdout,
2941
+ stderr,
2942
+ setWindow: (c, r) => {
2943
+ const sc = clampShellWindow(c, 80, 1, 1e3);
2944
+ const sr = clampShellWindow(r, 24, 1, 500);
2945
+ try {
2946
+ wsConn.resizeTerminal(terminalId, sc, sr);
2947
+ } catch {
2948
+ }
2949
+ },
2950
+ close: () => {
2951
+ rt.terminal.close(terminalId).catch(() => void 0).finally(() => fireClose(null));
2952
+ },
2953
+ onClose: (cb) => {
2954
+ closeListeners.push(cb);
2955
+ }
2956
+ };
2957
+ }
2958
+ // ── Compose / multi-service ────────────────────────────────────────────
2959
+ async ensureServiceGroup(config) {
2960
+ return this.compose.ensureServiceGroup(config);
2961
+ }
2962
+ async deployServiceWorkload(group, config, onLog) {
2963
+ return this.compose.deployServiceWorkload(group, config, onLog);
2964
+ }
2965
+ async finalizeServiceGroup(group, onLog) {
2966
+ return this.compose.finalizeGroup(group.id, onLog);
2967
+ }
2968
+ registerExistingWorkload(group, service) {
2969
+ this.compose.registerExistingWorkload(group, service);
2970
+ }
2971
+ // ── Account ────────────────────────────────────────────────────────────
2972
+ /** Check cloud credentials and account status. Throws on failure. */
2973
+ async getQuota() {
2974
+ return this.client.workspaces.getQuota();
2975
+ }
2976
+ // ── Domain / Slug checks ───────────────────────────────────────────────
2977
+ /**
2978
+ * Check whether a subdomain slug is available on opsh.io.
2979
+ * Uses Oblien's standalone `domain.checkSlug()` - no workspace needed.
2980
+ */
2981
+ async checkSlug(slug, domain = SYSTEM.DOMAINS.CLOUD_DOMAIN) {
2982
+ const result = await this.client.domain.checkSlug({ slug, domain });
2983
+ return { available: result.available, url: result.url };
2984
+ }
2985
+ /**
2986
+ * Verify DNS records for a custom domain.
2987
+ * Uses Oblien's standalone `domain.verify()` - no workspace needed.
2988
+ */
2989
+ async verifyDomain(domain, resourceId) {
2990
+ const result = await this.client.domain.verify({ domain, resource_id: resourceId });
2991
+ return {
2992
+ verified: result.verified,
2993
+ cname: result.cname,
2994
+ ownership: result.ownership,
2995
+ errors: result.errors,
2996
+ requiredRecords: {
2997
+ cname: result.required_records.cname,
2998
+ txt: result.required_records.txt
2999
+ }
3000
+ };
3001
+ }
3002
+ // ── Edge routing ───────────────────────────────────────────────────────
3003
+ /**
3004
+ * Atomically set a hostname's edge routing table — the CLOUD counterpart to
3005
+ * the self-hosted OpenResty route registration. Compile a project's
3006
+ * `RoutingConfig` with `compileRoutingToOblien` (static Page at `/`, backend
3007
+ * workspace proxied at `/api/*`, redirects/headers), then hand the result
3008
+ * here. Versioned + applied with no redeploy, so it also backs live edits
3009
+ * from the Routing/Domains tab.
3010
+ */
3011
+ async setDomainRoutes(hostname, input) {
3012
+ return this.client.routes.set(hostname, input);
3013
+ }
3014
+ // ── Private helpers ────────────────────────────────────────────────────
3015
+ /** Command runner INSIDE the workspace named by `containerId` (the stored
3016
+ * workspace id) — reuses the same runtime-token path as `openServiceShell`.
3017
+ * Powers the advisory post-deploy port probe. */
3018
+ async inContainerExecutor(containerId) {
3019
+ const rt = await this.ws(containerId).runtime();
3020
+ return this.workspaceExecutor(rt);
3021
+ }
3022
+ workspaceExecutor(rt) {
3023
+ const run = async (command, onLog, timeoutMs) => {
3024
+ let output = "";
3025
+ const collect = (entry) => {
3026
+ output += entry.message;
3027
+ onLog?.(entry);
3028
+ };
3029
+ try {
3030
+ await this.execAndStream(
3031
+ rt,
3032
+ ["sh", "-c", command],
3033
+ collect,
3034
+ timeoutMs ? Math.ceil(timeoutMs / 1e3) : void 0
3035
+ );
3036
+ return { code: 0, output };
3037
+ } catch (err) {
3038
+ const message = safeErrorMessage(err);
3039
+ return { code: 1, output: output || message };
3040
+ }
3041
+ };
3042
+ return {
3043
+ exec: async (command, opts) => {
3044
+ const result = await run(command, void 0, opts?.timeout);
3045
+ if (result.code !== 0) {
3046
+ throw new Error(result.output);
3047
+ }
3048
+ return result.output;
3049
+ },
3050
+ streamExec: async (command, onLog) => run(command, onLog, WORKSPACE_STREAM_EXEC_TIMEOUT_MS),
3051
+ writeFile: async () => {
3052
+ throw new Error("Workspace file writes are not supported by this executor");
3053
+ },
3054
+ readFile: async () => {
3055
+ throw new Error("Workspace file reads are not supported by this executor");
3056
+ },
3057
+ exists: async () => {
3058
+ throw new Error("Workspace file checks are not supported by this executor");
3059
+ },
3060
+ mkdir: async () => {
3061
+ throw new Error("Workspace mkdir is not supported by this executor");
3062
+ },
3063
+ rm: async () => {
3064
+ throw new Error("Workspace rm is not supported by this executor");
3065
+ },
3066
+ transferIn: async () => {
3067
+ throw new Error("Workspace transferIn is not supported by this executor");
3068
+ },
3069
+ dispose: async () => {
3070
+ }
3071
+ };
3072
+ }
3073
+ async ensureWorkspaceGit(rt, logger, label = "workspace") {
3074
+ const executor = this.workspaceExecutor(rt);
3075
+ const git = await checkGit(executor);
3076
+ if (git.healthy) {
3077
+ logger.log(`Git ready in ${label}.
3078
+ `);
3079
+ return;
3080
+ }
3081
+ const result = await installGit(executor, logger.callback, { label });
3082
+ if (!result.success) {
3083
+ const rechecked = await checkGit(executor);
3084
+ if (rechecked.healthy) {
3085
+ logger.log(`Git ready in ${label}.
3086
+ `);
3087
+ return;
3088
+ }
3089
+ throw new Error(result.error ?? rechecked.message ?? "Git installation failed");
3090
+ }
3091
+ }
3092
+ /**
3093
+ * Execute a command via the runtime exec API and stream output to the log callback.
3094
+ * Oblien returns stdout/stderr as native base64 - we pass it through directly
3095
+ * via rawData so session-manager forwards it to the frontend without re-encoding.
3096
+ * message is set to decoded text for DB storage / display.
3097
+ * Throws on non-zero exit code.
3098
+ */
3099
+ async execAndStream(rt, cmd, onLog, timeoutSeconds) {
3100
+ const params = timeoutSeconds ? { timeoutSeconds } : void 0;
3101
+ const stream = rt.exec.stream(cmd, params);
3102
+ const recentOutput = [];
3103
+ const MAX_OUTPUT_LINES = 50;
3104
+ const emit = (b64, level) => {
3105
+ const message = Buffer.from(b64, "base64").toString("utf-8");
3106
+ onLog({ timestamp: now2(), message, level, rawData: b64 });
3107
+ recentOutput.push(message);
3108
+ if (recentOutput.length > MAX_OUTPUT_LINES) recentOutput.shift();
3109
+ };
3110
+ let exitCode;
3111
+ for await (const event of stream) {
3112
+ switch (event.event) {
3113
+ case "stdout":
3114
+ emit(event.data, "info");
3115
+ break;
3116
+ case "stderr":
3117
+ emit(event.data, "warn");
3118
+ break;
3119
+ case "exit":
3120
+ exitCode = event.exit_code;
3121
+ break;
3122
+ case "output":
3123
+ if (event.stdout) emit(event.stdout, "info");
3124
+ if (event.stderr) emit(event.stderr, "warn");
3125
+ break;
3126
+ }
3127
+ }
3128
+ if (exitCode !== void 0 && exitCode !== 0) {
3129
+ const output = recentOutput.join("").trim();
3130
+ const summary = summarizeCommandOutput(output);
3131
+ const detail = summary ? `: ${summary}` : "";
3132
+ throw new Error(`Command failed with exit code ${exitCode}${detail}`);
3133
+ }
3134
+ }
3135
+ };
3136
+ function clampShellWindow(value, fallback, min, max) {
3137
+ const n = Number.isFinite(value) ? Number(value) : fallback;
3138
+ if (n < min) return min;
3139
+ if (n > max) return max;
3140
+ return Math.floor(n);
3141
+ }
3142
+
3143
+ export {
3144
+ PAGE_CONTAINER_PREFIX,
3145
+ provisionCloudWorkspace,
3146
+ CloudRuntime
3147
+ };