openship 0.3.0 → 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.
- package/README.md +104 -61
- package/dist/bare-ITM45QBX.js +18 -0
- package/dist/bowser-MFQA6OS4.js +2821 -0
- package/dist/chunk-3U4SPZD5.js +241 -0
- package/dist/chunk-46YJFP4S.js +1771 -0
- package/dist/chunk-4CQWT2WL.js +116 -0
- package/dist/chunk-4FABQGQP.js +23 -0
- package/dist/chunk-4LEL3H2Z.js +84 -0
- package/dist/chunk-4MG3BVHE.js +326 -0
- package/dist/chunk-4VNS5WPM.js +42 -0
- package/dist/chunk-5N7VUGL6.js +2094 -0
- package/dist/chunk-5ZCKQ2MI.js +1360 -0
- package/dist/chunk-6ERIMTBC.js +1412 -0
- package/dist/chunk-7UOB5GGQ.js +132 -0
- package/dist/chunk-BUBSSKO6.js +1384 -0
- package/dist/chunk-E6W2JMMU.js +341 -0
- package/dist/chunk-E77LJWJK.js +1535 -0
- package/dist/chunk-F4DFVG6Q.js +573 -0
- package/dist/chunk-HFYDYK4D.js +1092 -0
- package/dist/chunk-JR2OCMJA.js +46 -0
- package/dist/chunk-JUJBBF5U.js +746 -0
- package/dist/chunk-JZLNHGLW.js +19145 -0
- package/dist/chunk-KD7VO4LG.js +2617 -0
- package/dist/chunk-LBFH7THC.js +435 -0
- package/dist/chunk-NB2VEIWG.js +129 -0
- package/dist/chunk-OJE6YWPQ.js +2866 -0
- package/dist/chunk-QKFRZNKN.js +4941 -0
- package/dist/chunk-RBZPFTHV.js +13354 -0
- package/dist/chunk-S6DSRLNE.js +121 -0
- package/dist/chunk-TEYJYSMT.js +409 -0
- package/dist/chunk-U2DPZSXA.js +112 -0
- package/dist/chunk-WJPCBU7Z.js +3147 -0
- package/dist/chunk-XIFRMKYT.js +13 -0
- package/dist/chunk-XVSAUO2I.js +7946 -0
- package/dist/chunk-Y37KHU7L.js +31 -0
- package/dist/chunk-YL67EWYZ.js +416 -0
- package/dist/chunk-ZKJOFWBJ.js +1879 -0
- package/dist/cloud-JMPHO3CW.js +7 -0
- package/dist/cloud-W5G6YXAY.js +24 -0
- package/dist/dist-L3MPOWCA.js +65 -0
- package/dist/dist-es-4C322O5D.js +22 -0
- package/dist/dist-es-GTAMF674.js +87 -0
- package/dist/dist-es-KE56MWYO.js +169 -0
- package/dist/dist-es-NRZIQY5B.js +484 -0
- package/dist/dist-es-WZCR7DHQ.js +317 -0
- package/dist/dist-es-XQCOTGC7.js +376 -0
- package/dist/dist-es-ZW3HFQUM.js +69 -0
- package/dist/docker-3FVJMFC3.js +12 -0
- package/dist/docker-edge-executor-ZIWAZWPL.js +9 -0
- package/dist/edge-import-53M5DGWZ.js +39 -0
- package/dist/event-streams-7LGDL56D.js +42 -0
- package/dist/executor-J7BHW5HF.js +26 -0
- package/dist/index.js +2450 -3484
- package/dist/loadSso-N273C5I7.js +577 -0
- package/dist/nginx-YZWKYKQU.js +16 -0
- package/dist/noop-RRNCIUI5.js +7 -0
- package/dist/openresty-lua-HAO4EUDY.js +32 -0
- package/dist/server/index.js +230533 -217891
- package/dist/server/migrations/0057_complete_prism.sql +18 -0
- package/dist/server/migrations/0058_add_server_is_local.sql +1 -0
- package/dist/server/migrations/0059_legal_fixer.sql +18 -0
- package/dist/server/migrations/0060_add_route_strategy_host_port.sql +3 -0
- package/dist/server/migrations/0061_notification_default_multi_kind.sql +3 -0
- package/dist/server/migrations/0062_add_incoming_webhook.sql +21 -0
- package/dist/server/migrations/0063_add_migration_logs.sql +1 -0
- package/dist/server/migrations/0064_add_migration_partial.sql +2 -0
- package/dist/server/migrations/0065_custom_app_template.sql +14 -0
- package/dist/server/migrations/0066_ensure_migration_partial_columns.sql +2 -0
- package/dist/server/migrations/0067_webhook_delivery.sql +28 -0
- package/dist/server/migrations/0068_add_migration_target_volumes.sql +1 -0
- package/dist/server/migrations/0069_add_project_composite_routes.sql +1 -0
- package/dist/server/migrations/0070_forward_git_to_server.sql +1 -0
- package/dist/server/migrations/meta/0057_snapshot.json +9326 -0
- package/dist/server/migrations/meta/0058_snapshot.json +9333 -0
- package/dist/server/migrations/meta/0059_snapshot.json +9495 -0
- package/dist/server/migrations/meta/0060_snapshot.json +9515 -0
- package/dist/server/migrations/meta/_journal.json +99 -1
- package/dist/setup-5X3WEEHA.js +14 -0
- package/dist/signin-DR462ERO.js +663 -0
- package/dist/sso-oidc-VBZLJUK5.js +830 -0
- package/dist/sts-EJLZ4Z77.js +1422 -0
- package/package.json +6 -2
|
@@ -0,0 +1,1879 @@
|
|
|
1
|
+
import {
|
|
2
|
+
prepareStackOutput,
|
|
3
|
+
resolveProjectDir,
|
|
4
|
+
resolveStaticOutputPath,
|
|
5
|
+
runLocalBuild
|
|
6
|
+
} from "./chunk-NB2VEIWG.js";
|
|
7
|
+
import {
|
|
8
|
+
probeListeningPort,
|
|
9
|
+
resolveEnvironment
|
|
10
|
+
} from "./chunk-3U4SPZD5.js";
|
|
11
|
+
import {
|
|
12
|
+
BuildCancelledError,
|
|
13
|
+
BuildLogger,
|
|
14
|
+
detectBuildKillHint,
|
|
15
|
+
parseLogLevel,
|
|
16
|
+
runBuildPipeline,
|
|
17
|
+
sq,
|
|
18
|
+
transferLocalDirectory
|
|
19
|
+
} from "./chunk-YL67EWYZ.js";
|
|
20
|
+
import {
|
|
21
|
+
LocalExecutor
|
|
22
|
+
} from "./chunk-U2DPZSXA.js";
|
|
23
|
+
import {
|
|
24
|
+
formatDuration,
|
|
25
|
+
isRemoteConnectionError,
|
|
26
|
+
isRetryableRemoteConnectionError,
|
|
27
|
+
systemDebug
|
|
28
|
+
} from "./chunk-4LEL3H2Z.js";
|
|
29
|
+
import {
|
|
30
|
+
sq as sq2,
|
|
31
|
+
wrapLocalBuildCommand
|
|
32
|
+
} from "./chunk-4CQWT2WL.js";
|
|
33
|
+
import {
|
|
34
|
+
DeployError,
|
|
35
|
+
LANGUAGES,
|
|
36
|
+
STACKS,
|
|
37
|
+
buildOutputTransferExcludes,
|
|
38
|
+
missingOutputDirectoryMessage,
|
|
39
|
+
packageManagerEnsureCommand,
|
|
40
|
+
safeErrorMessage
|
|
41
|
+
} from "./chunk-JZLNHGLW.js";
|
|
42
|
+
|
|
43
|
+
// ../../packages/adapters/src/system/remote-journal.ts
|
|
44
|
+
var OPSH_RUN_VERSION = 1;
|
|
45
|
+
var DEFAULT_JOURNAL_BASE = "/root/.openship";
|
|
46
|
+
var REMOTE_ENV_PREFIX = "export DEBIAN_FRONTEND=noninteractive DPKG_FORCE=confnew && ";
|
|
47
|
+
var OPSH_RUN_SCRIPT = `#!/bin/sh
|
|
48
|
+
# opsh-run \u2014 openship reliable command journal. Managed file; do not edit.
|
|
49
|
+
VERSION=${OPSH_RUN_VERSION}
|
|
50
|
+
|
|
51
|
+
BASE="$OPSH_BASE"
|
|
52
|
+
[ -z "$BASE" ] && BASE=/root/.openship
|
|
53
|
+
OPS="$BASE/ops"
|
|
54
|
+
|
|
55
|
+
if [ "$1" = "--version" ]; then echo "$VERSION"; exit 0; fi
|
|
56
|
+
|
|
57
|
+
if [ "$1" = "--gc" ]; then
|
|
58
|
+
TTLMIN=1440
|
|
59
|
+
[ -n "$2" ] && TTLMIN="$2"
|
|
60
|
+
[ -d "$OPS" ] || exit 0
|
|
61
|
+
for d in "$OPS"/*; do
|
|
62
|
+
[ -d "$d" ] || continue
|
|
63
|
+
if [ -f "$d/exit" ]; then
|
|
64
|
+
[ -n "$(find "$d/exit" -mmin +"$TTLMIN" 2>/dev/null)" ] && rm -rf "$d"
|
|
65
|
+
else
|
|
66
|
+
if [ -n "$(find "$d" -maxdepth 0 -mmin +1440 2>/dev/null)" ]; then
|
|
67
|
+
p="$(cat "$d/pid" 2>/dev/null)"
|
|
68
|
+
{ [ -z "$p" ] || ! kill -0 "$p" 2>/dev/null; } && rm -rf "$d"
|
|
69
|
+
fi
|
|
70
|
+
fi
|
|
71
|
+
done
|
|
72
|
+
exit 0
|
|
73
|
+
fi
|
|
74
|
+
|
|
75
|
+
OPID="$1"
|
|
76
|
+
CMDB64="$2"
|
|
77
|
+
shift 2
|
|
78
|
+
WAIT=25
|
|
79
|
+
ENVPREFIX=""
|
|
80
|
+
while [ $# -gt 0 ]; do
|
|
81
|
+
case "$1" in
|
|
82
|
+
--wait) [ $# -ge 2 ] && WAIT="$2"; shift 2 2>/dev/null || shift ;;
|
|
83
|
+
--env-prefix) [ $# -ge 2 ] && ENVPREFIX="$(printf %s "$2" | openssl base64 -d -A 2>/dev/null)"; shift 2 2>/dev/null || shift ;;
|
|
84
|
+
*) shift ;;
|
|
85
|
+
esac
|
|
86
|
+
done
|
|
87
|
+
|
|
88
|
+
DIR="$OPS/$OPID"
|
|
89
|
+
CMD="$(printf %s "$CMDB64" | openssl base64 -d -A 2>/dev/null)"
|
|
90
|
+
[ -z "$CMD" ] && { echo "OPSH-EIO badcmd"; exit 0; }
|
|
91
|
+
HASH="$(printf %s "$CMD" | openssl dgst -sha256 2>/dev/null | awk '{print $NF}')"
|
|
92
|
+
|
|
93
|
+
if mkdir -p "$OPS" 2>/dev/null && mkdir "$DIR" 2>/dev/null; then
|
|
94
|
+
# Launcher: exactly one invocation per opId reaches here (mkdir is atomic).
|
|
95
|
+
if ! printf %s "$HASH" > "$DIR/cmdhash" 2>/dev/null; then
|
|
96
|
+
rm -rf "$DIR" 2>/dev/null; echo "OPSH-EIO nowrite"; exit 0
|
|
97
|
+
fi
|
|
98
|
+
printf %s "$CMD" > "$DIR/cmd" 2>/dev/null
|
|
99
|
+
# Subshell (not brace group) so a command that calls \`exit\` terminates only
|
|
100
|
+
# the subshell \u2014 the exit code is still captured and journaled.
|
|
101
|
+
RUN="( $ENVPREFIX $CMD ) > $DIR/stdout 2> $DIR/stderr; "'c=$?; echo $c'" > $DIR/exit.tmp && mv $DIR/exit.tmp $DIR/exit"
|
|
102
|
+
# nohup + </dev/null + & detaches from the SSH channel: a dropped connection
|
|
103
|
+
# (channel SIGHUP) can't kill the work. $! stays the running shell's pid.
|
|
104
|
+
nohup sh -c "$RUN" </dev/null >/dev/null 2>&1 &
|
|
105
|
+
echo $! > "$DIR/pid" 2>/dev/null
|
|
106
|
+
else
|
|
107
|
+
# Attach/harvest. Guard against a reused opId carrying a different command.
|
|
108
|
+
[ -d "$DIR" ] || { echo "OPSH-EIO nodir"; exit 0; }
|
|
109
|
+
prev="$(cat "$DIR/cmdhash" 2>/dev/null)"
|
|
110
|
+
[ -n "$prev" ] && [ "$prev" != "$HASH" ] && { echo "OPSH-COLLISION"; exit 0; }
|
|
111
|
+
fi
|
|
112
|
+
|
|
113
|
+
# Wait up to WAIT seconds for the exit file (published atomically AFTER the
|
|
114
|
+
# stdout/stderr fds close \u2192 a visible exit means output is final).
|
|
115
|
+
i=0
|
|
116
|
+
max=$((WAIT * 5))
|
|
117
|
+
while [ ! -f "$DIR/exit" ]; do
|
|
118
|
+
p="$(cat "$DIR/pid" 2>/dev/null)"
|
|
119
|
+
if [ -n "$p" ] && ! kill -0 "$p" 2>/dev/null; then
|
|
120
|
+
[ -f "$DIR/exit" ] && break
|
|
121
|
+
echo "OPSH-DEAD"; exit 0
|
|
122
|
+
fi
|
|
123
|
+
i=$((i + 1))
|
|
124
|
+
if [ "$i" -ge "$max" ]; then echo "OPSH-RUNNING $p"; exit 0; fi
|
|
125
|
+
sleep 0.2
|
|
126
|
+
done
|
|
127
|
+
|
|
128
|
+
CODE="$(cat "$DIR/exit" 2>/dev/null)"
|
|
129
|
+
[ -z "$CODE" ] && CODE=1
|
|
130
|
+
OUT="$(openssl base64 -A < "$DIR/stdout" 2>/dev/null)"
|
|
131
|
+
ERR="$(openssl base64 -A < "$DIR/stderr" 2>/dev/null)"
|
|
132
|
+
echo "OPSH1 $CODE $OUT $ERR"
|
|
133
|
+
exit 0
|
|
134
|
+
`;
|
|
135
|
+
async function ensureRemoteJournal(exec, baseDir) {
|
|
136
|
+
const runner = `${baseDir}/bin/opsh-run`;
|
|
137
|
+
let current = -1;
|
|
138
|
+
try {
|
|
139
|
+
const out = await exec.exec(
|
|
140
|
+
`OPSH_BASE=${sq2(baseDir)} sh ${sq2(runner)} --version 2>/dev/null`,
|
|
141
|
+
{ timeout: 1e4 }
|
|
142
|
+
);
|
|
143
|
+
current = Number.parseInt(out.trim(), 10);
|
|
144
|
+
} catch {
|
|
145
|
+
current = -1;
|
|
146
|
+
}
|
|
147
|
+
if (current !== OPSH_RUN_VERSION) {
|
|
148
|
+
const tmp = `${runner}.tmp`;
|
|
149
|
+
await exec.writeFile(tmp, OPSH_RUN_SCRIPT);
|
|
150
|
+
await exec.exec(
|
|
151
|
+
`mkdir -p ${sq2(`${baseDir}/ops`)} && chmod 0700 ${sq2(tmp)} && mv -f ${sq2(tmp)} ${sq2(runner)}`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
await exec.exec(`OPSH_BASE=${sq2(baseDir)} sh ${sq2(runner)} --gc 2>/dev/null`, {
|
|
156
|
+
timeout: 15e3
|
|
157
|
+
});
|
|
158
|
+
} catch {
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
async function runJournaled(exec, opId, command, opts) {
|
|
162
|
+
const waitSecs = Math.max(1, Math.min(300, opts.waitSecs ?? 25));
|
|
163
|
+
const runner = `${opts.baseDir}/bin/opsh-run`;
|
|
164
|
+
const cmdB64 = Buffer.from(command, "utf8").toString("base64");
|
|
165
|
+
const envPrefix = opts.envPrefix ?? REMOTE_ENV_PREFIX;
|
|
166
|
+
const envB64 = Buffer.from(envPrefix, "utf8").toString("base64");
|
|
167
|
+
let invocation = `OPSH_BASE=${sq2(opts.baseDir)} sh ${sq2(runner)} ${sq2(opId)} ${cmdB64} --wait ${waitSecs}`;
|
|
168
|
+
if (envB64) invocation += ` --env-prefix ${envB64}`;
|
|
169
|
+
const raw = await exec.exec(invocation, { timeout: (waitSecs + 20) * 1e3 });
|
|
170
|
+
return parseFrame(raw);
|
|
171
|
+
}
|
|
172
|
+
function parseFrame(raw) {
|
|
173
|
+
const line = raw.trim().split("\n").map((l) => l.trim()).filter(Boolean).pop() ?? "";
|
|
174
|
+
if (line.startsWith("OPSH1 ")) {
|
|
175
|
+
const parts = line.split(" ");
|
|
176
|
+
const code = Number.parseInt(parts[1] ?? "", 10);
|
|
177
|
+
const decode = (b64) => b64 ? Buffer.from(b64, "base64").toString("utf8") : "";
|
|
178
|
+
return {
|
|
179
|
+
status: "done",
|
|
180
|
+
code: Number.isFinite(code) ? code : 1,
|
|
181
|
+
stdout: decode(parts[2]),
|
|
182
|
+
stderr: decode(parts[3])
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
if (line.startsWith("OPSH-RUNNING")) return { status: "running" };
|
|
186
|
+
if (line === "OPSH-DEAD") return { status: "dead" };
|
|
187
|
+
if (line === "OPSH-COLLISION") return { status: "collision" };
|
|
188
|
+
if (line.startsWith("OPSH-EIO")) {
|
|
189
|
+
return { status: "eio", message: line.slice("OPSH-EIO ".length).trim() || "journal I/O error" };
|
|
190
|
+
}
|
|
191
|
+
return { status: "eio", message: `unrecognized journal frame: ${line.slice(0, 160)}` };
|
|
192
|
+
}
|
|
193
|
+
var OpInterruptedError = class extends Error {
|
|
194
|
+
constructor(message) {
|
|
195
|
+
super(message);
|
|
196
|
+
this.name = "OpInterruptedError";
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
var DEFAULT_RELIABLE_TIMEOUT_MS = 15 * 6e4;
|
|
200
|
+
var DEFAULT_RECONNECT_MIN_MS = 1e3;
|
|
201
|
+
var DEFAULT_RECONNECT_MAX_MS = 3e4;
|
|
202
|
+
function sleep(ms) {
|
|
203
|
+
return new Promise((resolve) => {
|
|
204
|
+
const t = setTimeout(resolve, ms);
|
|
205
|
+
t.unref?.();
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
async function runReliable(acquire, opId, command, opts) {
|
|
209
|
+
const baseDir = opts.baseDir ?? DEFAULT_JOURNAL_BASE;
|
|
210
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_RELIABLE_TIMEOUT_MS;
|
|
211
|
+
const deadline = Date.now() + timeoutMs;
|
|
212
|
+
const minMs = opts.reconnectMinMs ?? DEFAULT_RECONNECT_MIN_MS;
|
|
213
|
+
const maxMs = opts.reconnectMaxMs ?? DEFAULT_RECONNECT_MAX_MS;
|
|
214
|
+
const envPrefix = opts.envPrefix ?? REMOTE_ENV_PREFIX;
|
|
215
|
+
let backoff = minMs;
|
|
216
|
+
for (; ; ) {
|
|
217
|
+
if (Date.now() >= deadline) {
|
|
218
|
+
throw new Error(`Op '${opId}' timed out after ${timeoutMs}ms`);
|
|
219
|
+
}
|
|
220
|
+
let executor;
|
|
221
|
+
try {
|
|
222
|
+
executor = await acquire();
|
|
223
|
+
} catch (err) {
|
|
224
|
+
const wait = Math.min(backoff, deadline - Date.now());
|
|
225
|
+
if (wait <= 0) throw err;
|
|
226
|
+
await sleep(wait);
|
|
227
|
+
backoff = Math.min(backoff * 2, maxMs);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
if (!opts.ensured || !opts.ensured.has(executor)) {
|
|
232
|
+
await ensureRemoteJournal(executor, baseDir);
|
|
233
|
+
opts.ensured?.add(executor);
|
|
234
|
+
}
|
|
235
|
+
const waitSecs = Math.min(
|
|
236
|
+
opts.waitSecs ?? 25,
|
|
237
|
+
Math.max(1, Math.ceil((deadline - Date.now()) / 1e3))
|
|
238
|
+
);
|
|
239
|
+
const r = await runJournaled(executor, opId, command, {
|
|
240
|
+
baseDir,
|
|
241
|
+
waitSecs,
|
|
242
|
+
envPrefix
|
|
243
|
+
});
|
|
244
|
+
switch (r.status) {
|
|
245
|
+
case "done":
|
|
246
|
+
opts.onSuccess?.();
|
|
247
|
+
return { code: r.code ?? 0, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
|
248
|
+
case "running":
|
|
249
|
+
backoff = minMs;
|
|
250
|
+
continue;
|
|
251
|
+
case "dead":
|
|
252
|
+
throw new OpInterruptedError(
|
|
253
|
+
`Op '${opId}' was interrupted and did not complete (no exit recorded).`
|
|
254
|
+
);
|
|
255
|
+
case "collision":
|
|
256
|
+
throw new Error(`Op id '${opId}' was reused with a different command.`);
|
|
257
|
+
case "eio":
|
|
258
|
+
throw new Error(`Remote journal error for op '${opId}': ${r.message ?? "unknown"}`);
|
|
259
|
+
}
|
|
260
|
+
} catch (err) {
|
|
261
|
+
if (err instanceof OpInterruptedError) throw err;
|
|
262
|
+
if (isRetryableRemoteConnectionError(err)) {
|
|
263
|
+
opts.onRetryableDrop?.(err);
|
|
264
|
+
const wait = Math.min(backoff, deadline - Date.now());
|
|
265
|
+
if (wait <= 0) throw err;
|
|
266
|
+
await sleep(wait);
|
|
267
|
+
backoff = Math.min(backoff * 2, maxMs);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
throw err;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
async function execReliable(executor, opId, command, opts = {}) {
|
|
275
|
+
if (executor instanceof LocalExecutor) {
|
|
276
|
+
return executor.exec(command);
|
|
277
|
+
}
|
|
278
|
+
const r = await runReliable(() => Promise.resolve(executor), opId, command, opts);
|
|
279
|
+
if (r.code !== 0) {
|
|
280
|
+
throw new Error(r.stderr.trim() || r.stdout.trim() || `Exit code ${r.code}`);
|
|
281
|
+
}
|
|
282
|
+
return r.stdout.trim();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ../../packages/adapters/src/toolchain/catalog.ts
|
|
286
|
+
function nodeInstallPlan(profile) {
|
|
287
|
+
if (profile.os === "linux" && ["apt", "dnf", "yum"].includes(profile.packageManager)) {
|
|
288
|
+
const installCommands = {
|
|
289
|
+
// NodeSource's `setup_lts.x` pins the apt repo to $(lsb_release -sc), so it
|
|
290
|
+
// breaks on a new/unpublished Ubuntu codename exactly like the OpenResty
|
|
291
|
+
// #86 bug (and can leave a poisoned nodesource.list that fails later apt
|
|
292
|
+
// runs). Their node_XX.x repos use the distro-agnostic `nodistro` suite, so
|
|
293
|
+
// add it directly (no codename, no `curl | bash` of a remote script) and
|
|
294
|
+
// heal a stale list first. Pinned to the current LTS major (bump on new LTS).
|
|
295
|
+
apt: [
|
|
296
|
+
"set -e",
|
|
297
|
+
"rm -f /etc/apt/sources.list.d/nodesource.list",
|
|
298
|
+
"apt-get update -qq && apt-get install -y -qq curl gnupg ca-certificates",
|
|
299
|
+
"curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor --yes -o /usr/share/keyrings/nodesource.gpg",
|
|
300
|
+
'echo "deb [signed-by=/usr/share/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" > /etc/apt/sources.list.d/nodesource.list',
|
|
301
|
+
"apt-get update -qq && apt-get install -y -qq nodejs"
|
|
302
|
+
].join("\n"),
|
|
303
|
+
dnf: "curl -fsSL https://rpm.nodesource.com/setup_lts.x | bash - && dnf install -y nodejs",
|
|
304
|
+
yum: "curl -fsSL https://rpm.nodesource.com/setup_lts.x | bash - && yum install -y nodejs"
|
|
305
|
+
};
|
|
306
|
+
return {
|
|
307
|
+
supported: true,
|
|
308
|
+
installCommand: installCommands[profile.packageManager],
|
|
309
|
+
verifyCommand: "node --version"
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
const fallbacks = {
|
|
313
|
+
brew: "brew install node"
|
|
314
|
+
};
|
|
315
|
+
const fallback = fallbacks[profile.packageManager];
|
|
316
|
+
if (!fallback) {
|
|
317
|
+
return {
|
|
318
|
+
supported: false,
|
|
319
|
+
unsupportedReason: "No supported package manager found for Node.js installation"
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
return {
|
|
323
|
+
supported: true,
|
|
324
|
+
installCommand: fallback,
|
|
325
|
+
verifyCommand: "node --version"
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
function goInstallPlan(profile) {
|
|
329
|
+
if (profile.os === "linux") {
|
|
330
|
+
const arch = profile.arch === "arm64" ? "arm64" : "amd64";
|
|
331
|
+
return {
|
|
332
|
+
supported: true,
|
|
333
|
+
installCommand: [
|
|
334
|
+
`curl -fsSL https://go.dev/dl/go1.22.5.linux-${arch}.tar.gz -o /tmp/go.tar.gz`,
|
|
335
|
+
"rm -rf /usr/local/go",
|
|
336
|
+
"tar -C /usr/local -xzf /tmp/go.tar.gz",
|
|
337
|
+
"rm /tmp/go.tar.gz",
|
|
338
|
+
"echo 'export PATH=$PATH:/usr/local/go/bin' >> /etc/profile.d/go.sh",
|
|
339
|
+
"export PATH=$PATH:/usr/local/go/bin"
|
|
340
|
+
].join(" && "),
|
|
341
|
+
verifyCommand: "/usr/local/go/bin/go version || go version"
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
if (profile.packageManager === "brew") {
|
|
345
|
+
return {
|
|
346
|
+
supported: true,
|
|
347
|
+
installCommand: "brew install go",
|
|
348
|
+
verifyCommand: "go version"
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
return {
|
|
352
|
+
supported: false,
|
|
353
|
+
unsupportedReason: "Go installation is only supported on Linux and macOS (brew)"
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
function rustInstallPlan(_profile) {
|
|
357
|
+
return {
|
|
358
|
+
supported: true,
|
|
359
|
+
installCommand: "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && . $HOME/.cargo/env",
|
|
360
|
+
verifyCommand: ". $HOME/.cargo/env && rustc --version"
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
function pythonInstallPlan(profile) {
|
|
364
|
+
const commands = {
|
|
365
|
+
apt: "apt-get update -qq && apt-get install -y -qq python3 python3-pip python3-venv",
|
|
366
|
+
dnf: "dnf install -y python3 python3-pip",
|
|
367
|
+
yum: "yum install -y python3 python3-pip",
|
|
368
|
+
brew: "brew install python3"
|
|
369
|
+
};
|
|
370
|
+
const installCommand = commands[profile.packageManager];
|
|
371
|
+
if (!installCommand) {
|
|
372
|
+
return {
|
|
373
|
+
supported: false,
|
|
374
|
+
unsupportedReason: "No supported package manager found for Python installation"
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
return {
|
|
378
|
+
supported: true,
|
|
379
|
+
installCommand,
|
|
380
|
+
verifyCommand: "python3 --version"
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
function rubyInstallPlan(profile) {
|
|
384
|
+
const commands = {
|
|
385
|
+
apt: "apt-get update -qq && apt-get install -y -qq ruby-full build-essential",
|
|
386
|
+
dnf: "dnf install -y ruby ruby-devel",
|
|
387
|
+
yum: "yum install -y ruby ruby-devel",
|
|
388
|
+
brew: "brew install ruby"
|
|
389
|
+
};
|
|
390
|
+
const installCommand = commands[profile.packageManager];
|
|
391
|
+
if (!installCommand) {
|
|
392
|
+
return {
|
|
393
|
+
supported: false,
|
|
394
|
+
unsupportedReason: "No supported package manager found for Ruby installation"
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
return {
|
|
398
|
+
supported: true,
|
|
399
|
+
installCommand,
|
|
400
|
+
verifyCommand: "ruby --version"
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
function phpInstallPlan(profile) {
|
|
404
|
+
if (profile.packageManager === "apt") {
|
|
405
|
+
return {
|
|
406
|
+
supported: true,
|
|
407
|
+
installCommand: [
|
|
408
|
+
"apt-get update -qq",
|
|
409
|
+
"apt-get install -y -qq php-cli php-mbstring php-xml php-curl unzip",
|
|
410
|
+
"curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer"
|
|
411
|
+
].join(" && "),
|
|
412
|
+
verifyCommand: "php --version && composer --version"
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
const commands = {
|
|
416
|
+
dnf: "dnf install -y php-cli php-mbstring php-xml php-curl composer",
|
|
417
|
+
yum: "yum install -y php-cli php-mbstring php-xml php-curl",
|
|
418
|
+
brew: "brew install php composer"
|
|
419
|
+
};
|
|
420
|
+
const installCommand = commands[profile.packageManager];
|
|
421
|
+
if (!installCommand) {
|
|
422
|
+
return {
|
|
423
|
+
supported: false,
|
|
424
|
+
unsupportedReason: "No supported package manager found for PHP installation"
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
return {
|
|
428
|
+
supported: true,
|
|
429
|
+
installCommand,
|
|
430
|
+
verifyCommand: "php --version"
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
function javaInstallPlan(profile) {
|
|
434
|
+
const commands = {
|
|
435
|
+
apt: "apt-get update -qq && apt-get install -y -qq openjdk-21-jdk",
|
|
436
|
+
dnf: "dnf install -y java-21-openjdk-devel",
|
|
437
|
+
yum: "yum install -y java-21-openjdk-devel",
|
|
438
|
+
brew: "brew install openjdk@21"
|
|
439
|
+
};
|
|
440
|
+
const installCommand = commands[profile.packageManager];
|
|
441
|
+
if (!installCommand) {
|
|
442
|
+
return {
|
|
443
|
+
supported: false,
|
|
444
|
+
unsupportedReason: "No supported package manager found for Java installation"
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
return {
|
|
448
|
+
supported: true,
|
|
449
|
+
installCommand,
|
|
450
|
+
verifyCommand: "java --version 2>&1"
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
function mavenInstallPlan(profile) {
|
|
454
|
+
const commands = {
|
|
455
|
+
apt: "apt-get update -qq && apt-get install -y -qq maven",
|
|
456
|
+
dnf: "dnf install -y maven",
|
|
457
|
+
yum: "yum install -y maven",
|
|
458
|
+
brew: "brew install maven"
|
|
459
|
+
};
|
|
460
|
+
const installCommand = commands[profile.packageManager];
|
|
461
|
+
if (!installCommand) {
|
|
462
|
+
return {
|
|
463
|
+
supported: false,
|
|
464
|
+
unsupportedReason: "No supported package manager found for Maven installation"
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
return {
|
|
468
|
+
supported: true,
|
|
469
|
+
installCommand,
|
|
470
|
+
verifyCommand: "mvn -version"
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
function gradleInstallPlan(profile) {
|
|
474
|
+
const commands = {
|
|
475
|
+
apt: "apt-get update -qq && apt-get install -y -qq gradle",
|
|
476
|
+
brew: "brew install gradle"
|
|
477
|
+
};
|
|
478
|
+
const installCommand = commands[profile.packageManager];
|
|
479
|
+
if (!installCommand) {
|
|
480
|
+
return {
|
|
481
|
+
supported: false,
|
|
482
|
+
unsupportedReason: "No supported package manager found for Gradle installation (use the ./gradlew wrapper instead)"
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
return {
|
|
486
|
+
supported: true,
|
|
487
|
+
installCommand,
|
|
488
|
+
verifyCommand: "gradle --version"
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
function dotnetInstallPlan(profile) {
|
|
492
|
+
if (profile.os === "linux") {
|
|
493
|
+
return {
|
|
494
|
+
supported: true,
|
|
495
|
+
installCommand: "curl -fsSL https://dot.net/v1/dotnet-install.sh | bash -s -- --channel 8.0 --install-dir /usr/local/share/dotnet && ln -sf /usr/local/share/dotnet/dotnet /usr/local/bin/dotnet",
|
|
496
|
+
verifyCommand: "dotnet --version"
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
if (profile.packageManager === "brew") {
|
|
500
|
+
return {
|
|
501
|
+
supported: true,
|
|
502
|
+
installCommand: "brew install dotnet-sdk",
|
|
503
|
+
verifyCommand: "dotnet --version"
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
return {
|
|
507
|
+
supported: false,
|
|
508
|
+
unsupportedReason: ".NET SDK installation is only supported on Linux and macOS (brew)"
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
function elixirInstallPlan(profile) {
|
|
512
|
+
const commands = {
|
|
513
|
+
apt: "apt-get update -qq && apt-get install -y -qq erlang elixir",
|
|
514
|
+
dnf: "dnf install -y erlang elixir",
|
|
515
|
+
brew: "brew install elixir"
|
|
516
|
+
};
|
|
517
|
+
const installCommand = commands[profile.packageManager];
|
|
518
|
+
if (!installCommand) {
|
|
519
|
+
return {
|
|
520
|
+
supported: false,
|
|
521
|
+
unsupportedReason: "No supported package manager found for Elixir installation"
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
return {
|
|
525
|
+
supported: true,
|
|
526
|
+
installCommand,
|
|
527
|
+
verifyCommand: "elixir --version"
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
function bundlerInstallPlan(_profile) {
|
|
531
|
+
return {
|
|
532
|
+
supported: true,
|
|
533
|
+
installCommand: "gem install bundler",
|
|
534
|
+
verifyCommand: "bundler --version"
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
function composerInstallPlan(profile) {
|
|
538
|
+
if (profile.packageManager === "brew") {
|
|
539
|
+
return {
|
|
540
|
+
supported: true,
|
|
541
|
+
installCommand: "brew install composer",
|
|
542
|
+
verifyCommand: "composer --version"
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
return {
|
|
546
|
+
supported: true,
|
|
547
|
+
installCommand: "curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer",
|
|
548
|
+
verifyCommand: "composer --version"
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
function bunInstallPlan(_profile) {
|
|
552
|
+
return {
|
|
553
|
+
supported: true,
|
|
554
|
+
installCommand: [
|
|
555
|
+
"curl -fsSL https://bun.sh/install | bash -s 'bun-v1.2.0'",
|
|
556
|
+
"ln -sf $HOME/.bun/bin/bun /usr/local/bin/bun"
|
|
557
|
+
].join(" && "),
|
|
558
|
+
verifyCommand: "bun --version"
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
function pipInstallPlan(profile) {
|
|
562
|
+
const commands = {
|
|
563
|
+
apt: "apt-get update -qq && apt-get install -y -qq python3-pip",
|
|
564
|
+
dnf: "dnf install -y python3-pip",
|
|
565
|
+
yum: "yum install -y python3-pip",
|
|
566
|
+
brew: "python3 -m ensurepip --upgrade"
|
|
567
|
+
};
|
|
568
|
+
const installCommand = commands[profile.packageManager];
|
|
569
|
+
if (!installCommand) {
|
|
570
|
+
return {
|
|
571
|
+
supported: false,
|
|
572
|
+
unsupportedReason: "No supported package manager found for pip installation"
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
return {
|
|
576
|
+
supported: true,
|
|
577
|
+
installCommand,
|
|
578
|
+
verifyCommand: "pip3 --version || pip --version"
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
var toolchainCatalog = {
|
|
582
|
+
/**
|
|
583
|
+
* Check recipes - how to detect each tool and parse its version.
|
|
584
|
+
*
|
|
585
|
+
* Every tool in LANGUAGES[lang].requiredTools must have an entry here.
|
|
586
|
+
*/
|
|
587
|
+
checks: {
|
|
588
|
+
node: {
|
|
589
|
+
label: "Node.js",
|
|
590
|
+
versionCommand: "node --version",
|
|
591
|
+
parseVersion: (output) => output.replace(/^v/, "").trim(),
|
|
592
|
+
missingMessage: "Node.js is not installed",
|
|
593
|
+
installable: true
|
|
594
|
+
},
|
|
595
|
+
bun: {
|
|
596
|
+
label: "Bun",
|
|
597
|
+
versionCommand: "bun --version",
|
|
598
|
+
parseVersion: (output) => output.trim(),
|
|
599
|
+
missingMessage: "Bun is not installed",
|
|
600
|
+
installable: true
|
|
601
|
+
},
|
|
602
|
+
npm: {
|
|
603
|
+
label: "npm",
|
|
604
|
+
versionCommand: "npm --version",
|
|
605
|
+
parseVersion: (output) => output.trim(),
|
|
606
|
+
missingMessage: "npm is not installed",
|
|
607
|
+
installable: false,
|
|
608
|
+
providedBy: "node"
|
|
609
|
+
},
|
|
610
|
+
go: {
|
|
611
|
+
label: "Go",
|
|
612
|
+
versionCommand: "go version",
|
|
613
|
+
parseVersion: (output) => output.match(/go(\d+\.\d+(\.\d+)?)/)?.[1] ?? output.trim(),
|
|
614
|
+
missingMessage: "Go is not installed",
|
|
615
|
+
installable: true
|
|
616
|
+
},
|
|
617
|
+
rustc: {
|
|
618
|
+
label: "Rust compiler",
|
|
619
|
+
versionCommand: "rustc --version",
|
|
620
|
+
parseVersion: (output) => output.match(/rustc (\S+)/)?.[1] ?? output.trim(),
|
|
621
|
+
missingMessage: "Rust compiler is not installed",
|
|
622
|
+
installable: true
|
|
623
|
+
},
|
|
624
|
+
cargo: {
|
|
625
|
+
label: "Cargo",
|
|
626
|
+
versionCommand: "cargo --version",
|
|
627
|
+
parseVersion: (output) => output.match(/cargo (\S+)/)?.[1] ?? output.trim(),
|
|
628
|
+
missingMessage: "Cargo is not installed",
|
|
629
|
+
installable: false,
|
|
630
|
+
providedBy: "rustc"
|
|
631
|
+
},
|
|
632
|
+
python3: {
|
|
633
|
+
label: "Python 3",
|
|
634
|
+
versionCommand: "python3 --version",
|
|
635
|
+
parseVersion: (output) => output.match(/Python (\S+)/)?.[1] ?? output.trim(),
|
|
636
|
+
missingMessage: "Python 3 is not installed",
|
|
637
|
+
installable: true
|
|
638
|
+
},
|
|
639
|
+
pip: {
|
|
640
|
+
label: "pip",
|
|
641
|
+
versionCommand: "pip3 --version || pip --version",
|
|
642
|
+
parseVersion: (output) => output.match(/pip (\S+)/)?.[1] ?? output.trim(),
|
|
643
|
+
missingMessage: "pip is not installed",
|
|
644
|
+
installable: true,
|
|
645
|
+
providedBy: "python3"
|
|
646
|
+
},
|
|
647
|
+
ruby: {
|
|
648
|
+
label: "Ruby",
|
|
649
|
+
versionCommand: "ruby --version",
|
|
650
|
+
parseVersion: (output) => output.match(/ruby (\S+)/)?.[1] ?? output.trim(),
|
|
651
|
+
missingMessage: "Ruby is not installed",
|
|
652
|
+
installable: true
|
|
653
|
+
},
|
|
654
|
+
bundler: {
|
|
655
|
+
label: "Bundler",
|
|
656
|
+
versionCommand: "bundler --version",
|
|
657
|
+
parseVersion: (output) => output.match(/(\d+\.\d+\.\d+)/)?.[1] ?? output.trim(),
|
|
658
|
+
missingMessage: "Bundler is not installed",
|
|
659
|
+
installable: true,
|
|
660
|
+
providedBy: "ruby"
|
|
661
|
+
},
|
|
662
|
+
php: {
|
|
663
|
+
label: "PHP",
|
|
664
|
+
versionCommand: "php --version",
|
|
665
|
+
parseVersion: (output) => output.match(/PHP (\S+)/)?.[1] ?? output.trim(),
|
|
666
|
+
missingMessage: "PHP is not installed",
|
|
667
|
+
installable: true
|
|
668
|
+
},
|
|
669
|
+
composer: {
|
|
670
|
+
label: "Composer",
|
|
671
|
+
versionCommand: "composer --version",
|
|
672
|
+
parseVersion: (output) => output.match(/(\d+\.\d+\.\d+)/)?.[1] ?? output.trim(),
|
|
673
|
+
missingMessage: "Composer is not installed",
|
|
674
|
+
installable: true,
|
|
675
|
+
providedBy: "php"
|
|
676
|
+
},
|
|
677
|
+
java: {
|
|
678
|
+
label: "Java",
|
|
679
|
+
versionCommand: "java --version 2>&1 || java -version 2>&1",
|
|
680
|
+
parseVersion: (output) => output.match(/(\d+\.\d+)/)?.[1] ?? output.trim(),
|
|
681
|
+
missingMessage: "Java is not installed",
|
|
682
|
+
installable: true
|
|
683
|
+
},
|
|
684
|
+
javac: {
|
|
685
|
+
label: "Java compiler",
|
|
686
|
+
versionCommand: "javac --version 2>&1 || javac -version 2>&1",
|
|
687
|
+
parseVersion: (output) => output.match(/javac (\S+)/)?.[1] ?? output.trim(),
|
|
688
|
+
missingMessage: "Java compiler (javac) is not installed",
|
|
689
|
+
installable: false,
|
|
690
|
+
providedBy: "java"
|
|
691
|
+
},
|
|
692
|
+
maven: {
|
|
693
|
+
label: "Maven",
|
|
694
|
+
versionCommand: "mvn -version 2>&1",
|
|
695
|
+
parseVersion: (output) => output.match(/Apache Maven (\S+)/)?.[1] ?? output.trim(),
|
|
696
|
+
missingMessage: "Maven is not installed",
|
|
697
|
+
installable: true
|
|
698
|
+
},
|
|
699
|
+
gradle: {
|
|
700
|
+
label: "Gradle",
|
|
701
|
+
versionCommand: "gradle --version 2>&1",
|
|
702
|
+
parseVersion: (output) => output.match(/Gradle (\S+)/)?.[1] ?? output.trim(),
|
|
703
|
+
missingMessage: "Gradle is not installed",
|
|
704
|
+
installable: true
|
|
705
|
+
},
|
|
706
|
+
dotnet: {
|
|
707
|
+
label: ".NET SDK",
|
|
708
|
+
versionCommand: "dotnet --version",
|
|
709
|
+
parseVersion: (output) => output.trim(),
|
|
710
|
+
missingMessage: ".NET SDK is not installed",
|
|
711
|
+
installable: true
|
|
712
|
+
},
|
|
713
|
+
elixir: {
|
|
714
|
+
label: "Elixir",
|
|
715
|
+
versionCommand: "elixir --version 2>&1",
|
|
716
|
+
parseVersion: (output) => output.match(/Elixir (\S+)/)?.[1] ?? output.trim(),
|
|
717
|
+
missingMessage: "Elixir is not installed",
|
|
718
|
+
installable: true
|
|
719
|
+
},
|
|
720
|
+
mix: {
|
|
721
|
+
label: "Mix",
|
|
722
|
+
versionCommand: "mix --version 2>&1",
|
|
723
|
+
parseVersion: (output) => output.match(/Mix (\S+)/)?.[1] ?? output.trim(),
|
|
724
|
+
missingMessage: "Mix is not installed",
|
|
725
|
+
installable: false,
|
|
726
|
+
providedBy: "elixir"
|
|
727
|
+
}
|
|
728
|
+
},
|
|
729
|
+
/**
|
|
730
|
+
* Install plan factories - how to install each tool on a given OS.
|
|
731
|
+
*
|
|
732
|
+
* Only tools with `installable: true` (and no `providedBy`) need an entry.
|
|
733
|
+
* Tools like npm, cargo, mix are installed as part of their parent.
|
|
734
|
+
*/
|
|
735
|
+
installs: {
|
|
736
|
+
node: nodeInstallPlan,
|
|
737
|
+
bun: bunInstallPlan,
|
|
738
|
+
go: goInstallPlan,
|
|
739
|
+
rustc: rustInstallPlan,
|
|
740
|
+
python3: pythonInstallPlan,
|
|
741
|
+
pip: pipInstallPlan,
|
|
742
|
+
ruby: rubyInstallPlan,
|
|
743
|
+
bundler: bundlerInstallPlan,
|
|
744
|
+
php: phpInstallPlan,
|
|
745
|
+
composer: composerInstallPlan,
|
|
746
|
+
java: javaInstallPlan,
|
|
747
|
+
maven: mavenInstallPlan,
|
|
748
|
+
gradle: gradleInstallPlan,
|
|
749
|
+
dotnet: dotnetInstallPlan,
|
|
750
|
+
elixir: elixirInstallPlan
|
|
751
|
+
}
|
|
752
|
+
};
|
|
753
|
+
|
|
754
|
+
// ../../packages/adapters/src/toolchain/checks.ts
|
|
755
|
+
function parseVersionParts(version) {
|
|
756
|
+
const match = version.match(/\d+(?:\.\d+)*/)?.[0] ?? "0";
|
|
757
|
+
return match.split(".").map((part) => parseInt(part, 10) || 0);
|
|
758
|
+
}
|
|
759
|
+
function compareVersions(actual, minimum) {
|
|
760
|
+
const actualParts = parseVersionParts(actual);
|
|
761
|
+
const minimumParts = parseVersionParts(minimum);
|
|
762
|
+
const maxLength = Math.max(actualParts.length, minimumParts.length);
|
|
763
|
+
for (let index = 0; index < maxLength; index += 1) {
|
|
764
|
+
const left = actualParts[index] ?? 0;
|
|
765
|
+
const right = minimumParts[index] ?? 0;
|
|
766
|
+
if (left > right) return 1;
|
|
767
|
+
if (left < right) return -1;
|
|
768
|
+
}
|
|
769
|
+
return 0;
|
|
770
|
+
}
|
|
771
|
+
async function tryExec(executor, command) {
|
|
772
|
+
const startedAt = Date.now();
|
|
773
|
+
systemDebug("toolchain", `exec:start ${command}`);
|
|
774
|
+
try {
|
|
775
|
+
const result = await executor.exec(command, { timeout: 1e4 });
|
|
776
|
+
systemDebug(
|
|
777
|
+
"toolchain",
|
|
778
|
+
`exec:ok ${command} (${formatDuration(startedAt)})`
|
|
779
|
+
);
|
|
780
|
+
return result;
|
|
781
|
+
} catch (err) {
|
|
782
|
+
if (isRemoteConnectionError(err)) {
|
|
783
|
+
systemDebug(
|
|
784
|
+
"toolchain",
|
|
785
|
+
`exec:abort ${command} (${formatDuration(startedAt)}) ${safeErrorMessage(err)}`
|
|
786
|
+
);
|
|
787
|
+
throw err;
|
|
788
|
+
}
|
|
789
|
+
const msg = safeErrorMessage(err);
|
|
790
|
+
systemDebug(
|
|
791
|
+
"toolchain",
|
|
792
|
+
`exec:fail ${command} (${formatDuration(startedAt)}) ${msg}`
|
|
793
|
+
);
|
|
794
|
+
return null;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
async function checkTool(executor, name, opts) {
|
|
798
|
+
const recipe = toolchainCatalog.checks[name];
|
|
799
|
+
if (!recipe) {
|
|
800
|
+
return {
|
|
801
|
+
name,
|
|
802
|
+
label: name,
|
|
803
|
+
installed: false,
|
|
804
|
+
healthy: false,
|
|
805
|
+
message: `Unknown tool: ${name}`
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
const output = await tryExec(executor, recipe.versionCommand);
|
|
809
|
+
if (!output) {
|
|
810
|
+
systemDebug("toolchain", `${name}:missing`);
|
|
811
|
+
return {
|
|
812
|
+
name,
|
|
813
|
+
label: recipe.label,
|
|
814
|
+
installed: false,
|
|
815
|
+
healthy: false,
|
|
816
|
+
message: recipe.missingMessage
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
const version = recipe.parseVersion(output);
|
|
820
|
+
if (opts?.minVersion && compareVersions(version, opts.minVersion) < 0) {
|
|
821
|
+
systemDebug("toolchain", `${name}:outdated v${version} < ${opts.minVersion}`);
|
|
822
|
+
return {
|
|
823
|
+
name,
|
|
824
|
+
label: recipe.label,
|
|
825
|
+
installed: true,
|
|
826
|
+
version,
|
|
827
|
+
requiredVersion: opts.minVersion,
|
|
828
|
+
healthy: false,
|
|
829
|
+
message: `${recipe.label} ${version} is installed but ${opts.minVersion}+ is required`
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
systemDebug("toolchain", `${name}:healthy v${version}`);
|
|
833
|
+
return {
|
|
834
|
+
name,
|
|
835
|
+
label: recipe.label,
|
|
836
|
+
installed: true,
|
|
837
|
+
version,
|
|
838
|
+
requiredVersion: opts?.minVersion,
|
|
839
|
+
healthy: true,
|
|
840
|
+
message: `${recipe.label} ${version}`
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
async function checkTools(executor, toolNames, requiredVersions) {
|
|
844
|
+
const startedAt = Date.now();
|
|
845
|
+
systemDebug("toolchain", `checkTools:start [${toolNames.join(", ")}]`);
|
|
846
|
+
const tools = await Promise.all(
|
|
847
|
+
toolNames.map(
|
|
848
|
+
(name) => checkTool(executor, name, { minVersion: requiredVersions?.[name] })
|
|
849
|
+
)
|
|
850
|
+
);
|
|
851
|
+
const missing = tools.filter((t) => !t.installed).map((t) => t.name);
|
|
852
|
+
const outdated = tools.filter((t) => t.installed && !t.healthy).map((t) => t.name);
|
|
853
|
+
const ready = missing.length === 0 && outdated.length === 0;
|
|
854
|
+
systemDebug(
|
|
855
|
+
"toolchain",
|
|
856
|
+
`checkTools:done (${formatDuration(startedAt)}) ready=${ready} missing=[${missing.join(", ")}] outdated=[${outdated.join(", ")}]`
|
|
857
|
+
);
|
|
858
|
+
return { tools, ready, missing, outdated };
|
|
859
|
+
}
|
|
860
|
+
async function checkToolchain(executor, language, requiredVersions) {
|
|
861
|
+
const lang = LANGUAGES[language];
|
|
862
|
+
if (!lang || lang.requiredTools.length === 0) {
|
|
863
|
+
return { tools: [], ready: true, missing: [], outdated: [] };
|
|
864
|
+
}
|
|
865
|
+
return checkTools(executor, lang.requiredTools, requiredVersions);
|
|
866
|
+
}
|
|
867
|
+
async function checkToolchainForStack(executor, stackId) {
|
|
868
|
+
const stack = STACKS[stackId];
|
|
869
|
+
if (!stack) {
|
|
870
|
+
return { tools: [], ready: true, missing: [], outdated: [] };
|
|
871
|
+
}
|
|
872
|
+
if (stack.requiredTools && stack.requiredTools.length > 0) {
|
|
873
|
+
return checkTools(executor, stack.requiredTools, stack.requiredToolVersions);
|
|
874
|
+
}
|
|
875
|
+
return checkToolchain(executor, stack.language, stack.requiredToolVersions);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// ../../packages/adapters/src/toolchain/installer.ts
|
|
879
|
+
async function installTool(executor, name, onLog, requiredVersion) {
|
|
880
|
+
const startedAt = Date.now();
|
|
881
|
+
const recipe = toolchainCatalog.checks[name];
|
|
882
|
+
if (!recipe) {
|
|
883
|
+
return { tool: name, success: false, error: `Unknown tool: ${name}` };
|
|
884
|
+
}
|
|
885
|
+
if (recipe.providedBy && !toolchainCatalog.installs[name]) {
|
|
886
|
+
const status = await checkTool(executor, name, { minVersion: requiredVersion });
|
|
887
|
+
if (status.healthy) {
|
|
888
|
+
return { tool: name, success: true, version: status.version };
|
|
889
|
+
}
|
|
890
|
+
return {
|
|
891
|
+
tool: name,
|
|
892
|
+
success: false,
|
|
893
|
+
error: `${recipe.label} should be installed by ${recipe.providedBy}`
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
const factory = toolchainCatalog.installs[name];
|
|
897
|
+
if (!factory) {
|
|
898
|
+
return { tool: name, success: false, error: `No installer for ${name}` };
|
|
899
|
+
}
|
|
900
|
+
const profile = await resolveEnvironment(executor);
|
|
901
|
+
const plan = factory(profile);
|
|
902
|
+
if (!plan.supported) {
|
|
903
|
+
const msg = plan.unsupportedReason ?? `${recipe.label} installation not supported on this system`;
|
|
904
|
+
onLog?.({
|
|
905
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
906
|
+
message: msg,
|
|
907
|
+
level: "error"
|
|
908
|
+
});
|
|
909
|
+
return { tool: name, success: false, error: msg };
|
|
910
|
+
}
|
|
911
|
+
systemDebug("toolchain", `install:start ${name}`);
|
|
912
|
+
onLog?.({
|
|
913
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
914
|
+
message: `Installing ${recipe.label}...`,
|
|
915
|
+
level: "info"
|
|
916
|
+
});
|
|
917
|
+
try {
|
|
918
|
+
const { code } = await executor.streamExec(plan.installCommand, (entry) => {
|
|
919
|
+
onLog?.(entry);
|
|
920
|
+
});
|
|
921
|
+
if (code !== 0) {
|
|
922
|
+
if (plan.fallbackInstallCommands?.length) {
|
|
923
|
+
for (const fallback of plan.fallbackInstallCommands) {
|
|
924
|
+
onLog?.({
|
|
925
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
926
|
+
message: `Primary install failed, trying fallback...`,
|
|
927
|
+
level: "warn"
|
|
928
|
+
});
|
|
929
|
+
const fb = await executor.streamExec(fallback, (entry) => onLog?.(entry));
|
|
930
|
+
if (fb.code === 0) break;
|
|
931
|
+
}
|
|
932
|
+
} else {
|
|
933
|
+
throw new Error(`Install command failed with exit code ${code}`);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
if (plan.startCommand) {
|
|
937
|
+
await executor.streamExec(plan.startCommand, (entry) => onLog?.(entry));
|
|
938
|
+
}
|
|
939
|
+
const verifyCmd = plan.verifyCommand ?? recipe.versionCommand;
|
|
940
|
+
const verifyResult = await executor.exec(verifyCmd, { timeout: 1e4 }).catch(() => null);
|
|
941
|
+
if (!verifyResult) {
|
|
942
|
+
throw new Error(`${recipe.label} installed but verification failed`);
|
|
943
|
+
}
|
|
944
|
+
const version = recipe.parseVersion(verifyResult);
|
|
945
|
+
systemDebug("toolchain", `install:done ${name} v${version} (${formatDuration(startedAt)})`);
|
|
946
|
+
onLog?.({
|
|
947
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
948
|
+
message: `${recipe.label} ${version} installed successfully`,
|
|
949
|
+
level: "info"
|
|
950
|
+
});
|
|
951
|
+
return { tool: name, success: true, version };
|
|
952
|
+
} catch (err) {
|
|
953
|
+
const msg = safeErrorMessage(err);
|
|
954
|
+
systemDebug("toolchain", `install:fail ${name} (${formatDuration(startedAt)}) ${msg}`);
|
|
955
|
+
onLog?.({
|
|
956
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
957
|
+
message: `Failed to install ${recipe.label}: ${msg}`,
|
|
958
|
+
level: "error"
|
|
959
|
+
});
|
|
960
|
+
return { tool: name, success: false, error: msg };
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
async function installTools(executor, toolNames, onLog, requiredVersions) {
|
|
964
|
+
const sorted = [...toolNames].sort((a, b) => {
|
|
965
|
+
const aEntry = toolchainCatalog.checks[a];
|
|
966
|
+
const bEntry = toolchainCatalog.checks[b];
|
|
967
|
+
if (aEntry?.providedBy === b) return 1;
|
|
968
|
+
if (bEntry?.providedBy === a) return -1;
|
|
969
|
+
if (aEntry?.installable && !bEntry?.installable) return -1;
|
|
970
|
+
if (!aEntry?.installable && bEntry?.installable) return 1;
|
|
971
|
+
return 0;
|
|
972
|
+
});
|
|
973
|
+
const results = [];
|
|
974
|
+
for (const name of sorted) {
|
|
975
|
+
const status = await checkTool(executor, name, {
|
|
976
|
+
minVersion: requiredVersions?.[name]
|
|
977
|
+
});
|
|
978
|
+
if (status.healthy) {
|
|
979
|
+
onLog?.({
|
|
980
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
981
|
+
message: `${status.label} ${status.version ?? ""} already installed, skipping`,
|
|
982
|
+
level: "info"
|
|
983
|
+
});
|
|
984
|
+
results.push({ tool: name, success: true, version: status.version });
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
987
|
+
const result = await installTool(executor, name, onLog, requiredVersions?.[name]);
|
|
988
|
+
results.push(result);
|
|
989
|
+
}
|
|
990
|
+
return results;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// ../../packages/adapters/src/runtime/supervisor/systemd.ts
|
|
994
|
+
var UNIT_PREFIX = "openship";
|
|
995
|
+
function escapeSystemdEnvValue(value) {
|
|
996
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
|
|
997
|
+
}
|
|
998
|
+
var SystemdSupervisor = class {
|
|
999
|
+
constructor(executor, workDir) {
|
|
1000
|
+
this.executor = executor;
|
|
1001
|
+
this.workDir = workDir;
|
|
1002
|
+
}
|
|
1003
|
+
name = "systemd";
|
|
1004
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
1005
|
+
unitName(deploymentId) {
|
|
1006
|
+
return `${UNIT_PREFIX}-${deploymentId}.service`;
|
|
1007
|
+
}
|
|
1008
|
+
unitPath(deploymentId) {
|
|
1009
|
+
return `/etc/systemd/system/${this.unitName(deploymentId)}`;
|
|
1010
|
+
}
|
|
1011
|
+
artifactFile(id) {
|
|
1012
|
+
return `${this.workDir}/.artifacts/${id}.path`;
|
|
1013
|
+
}
|
|
1014
|
+
async writeArtifactPath(id, path) {
|
|
1015
|
+
await this.executor.mkdir(`${this.workDir}/.artifacts`);
|
|
1016
|
+
await this.executor.writeFile(this.artifactFile(id), path);
|
|
1017
|
+
}
|
|
1018
|
+
async readArtifactPath(id) {
|
|
1019
|
+
try {
|
|
1020
|
+
const content = await this.executor.readFile(this.artifactFile(id));
|
|
1021
|
+
return content.trim() || null;
|
|
1022
|
+
} catch {
|
|
1023
|
+
return null;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
async removeArtifactPath(id) {
|
|
1027
|
+
await this.executor.rm(this.artifactFile(id));
|
|
1028
|
+
}
|
|
1029
|
+
/**
|
|
1030
|
+
* Build a systemd unit file contents string.
|
|
1031
|
+
*
|
|
1032
|
+
* Uses Type=exec so systemd tracks the actual process (not the shell wrapper).
|
|
1033
|
+
* Environment vars are set via Environment= directives (one per line), which
|
|
1034
|
+
* avoids any SHELL quoting issues - but systemd does its own parsing, so each
|
|
1035
|
+
* assignment is double-quoted and escaped (see escapeSystemdEnvValue).
|
|
1036
|
+
*/
|
|
1037
|
+
buildUnitFile(opts) {
|
|
1038
|
+
const envLines = Object.entries(opts.env).filter(([key]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key)).map(([k, v]) => `Environment="${k}=${escapeSystemdEnvValue(v)}"`).join("\n");
|
|
1039
|
+
return `[Unit]
|
|
1040
|
+
Description=Openship deployment ${opts.deploymentId}
|
|
1041
|
+
After=network.target
|
|
1042
|
+
|
|
1043
|
+
[Service]
|
|
1044
|
+
Type=exec
|
|
1045
|
+
WorkingDirectory=${opts.workDir}
|
|
1046
|
+
ExecStart=/bin/sh -lc ${sq(opts.startCommand)}
|
|
1047
|
+
${envLines}
|
|
1048
|
+
Restart=on-failure
|
|
1049
|
+
RestartSec=3
|
|
1050
|
+
StandardOutput=journal
|
|
1051
|
+
StandardError=journal
|
|
1052
|
+
SyslogIdentifier=${UNIT_PREFIX}-${opts.deploymentId}
|
|
1053
|
+
|
|
1054
|
+
[Install]
|
|
1055
|
+
WantedBy=multi-user.target
|
|
1056
|
+
`;
|
|
1057
|
+
}
|
|
1058
|
+
// ── Lifecycle ────────────────────────────────────────────────────────
|
|
1059
|
+
async deploy(opts) {
|
|
1060
|
+
const unitContent = this.buildUnitFile(opts);
|
|
1061
|
+
const unitPath = this.unitPath(opts.deploymentId);
|
|
1062
|
+
const unitName = this.unitName(opts.deploymentId);
|
|
1063
|
+
await this.executor.writeFile(unitPath, unitContent);
|
|
1064
|
+
await this.executor.exec("systemctl daemon-reload");
|
|
1065
|
+
await execReliable(
|
|
1066
|
+
this.executor,
|
|
1067
|
+
`deploy:${opts.deploymentId}:activate`,
|
|
1068
|
+
`systemctl enable --now ${sq(unitName)}`
|
|
1069
|
+
);
|
|
1070
|
+
await this.writeArtifactPath(opts.deploymentId, opts.workDir);
|
|
1071
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
1072
|
+
if (!await this.isRunning(opts.deploymentId)) {
|
|
1073
|
+
let hint = "";
|
|
1074
|
+
try {
|
|
1075
|
+
const tail = await this.executor.exec(
|
|
1076
|
+
`journalctl -u ${sq(unitName)} -n 10 --no-pager 2>/dev/null`
|
|
1077
|
+
);
|
|
1078
|
+
hint = tail.trim();
|
|
1079
|
+
} catch {
|
|
1080
|
+
}
|
|
1081
|
+
if (hint.includes("EADDRINUSE")) {
|
|
1082
|
+
const occupant = await probeListeningPort(this.executor, opts.port);
|
|
1083
|
+
throw new DeployError(
|
|
1084
|
+
`Port ${opts.port} is already in use` + (occupant ? ` by ${occupant.command}` : "") + ". Stop the existing process before deploying.",
|
|
1085
|
+
"PORT_IN_USE",
|
|
1086
|
+
{
|
|
1087
|
+
port: opts.port,
|
|
1088
|
+
pid: occupant?.pid,
|
|
1089
|
+
command: occupant?.command,
|
|
1090
|
+
rawCommand: occupant?.rawCommand,
|
|
1091
|
+
systemdUnit: occupant?.systemdUnit,
|
|
1092
|
+
systemdDescription: occupant?.systemdDescription,
|
|
1093
|
+
deploymentId: occupant?.deploymentId,
|
|
1094
|
+
isManagedDeployment: occupant?.isManagedDeployment
|
|
1095
|
+
}
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
const msg = hint ? `Process exited immediately after start. Last output:
|
|
1099
|
+
${hint}` : "Process exited immediately after start (no output captured)";
|
|
1100
|
+
throw new Error(msg);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
async stop(deploymentId) {
|
|
1104
|
+
const unitName = this.unitName(deploymentId);
|
|
1105
|
+
try {
|
|
1106
|
+
await this.executor.exec(`systemctl stop ${sq(unitName)} 2>/dev/null || true`);
|
|
1107
|
+
} catch {
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
async start(deploymentId) {
|
|
1111
|
+
const unitName = this.unitName(deploymentId);
|
|
1112
|
+
await this.executor.exec(`systemctl start ${sq(unitName)}`);
|
|
1113
|
+
}
|
|
1114
|
+
async restart(deploymentId) {
|
|
1115
|
+
const unitName = this.unitName(deploymentId);
|
|
1116
|
+
await this.executor.exec(`systemctl restart ${sq(unitName)}`);
|
|
1117
|
+
}
|
|
1118
|
+
async destroy(deploymentId) {
|
|
1119
|
+
const unitName = this.unitName(deploymentId);
|
|
1120
|
+
const unitPath = this.unitPath(deploymentId);
|
|
1121
|
+
await this.executor.exec(
|
|
1122
|
+
`systemctl disable --now ${sq(unitName)} 2>/dev/null || true`
|
|
1123
|
+
);
|
|
1124
|
+
await this.executor.rm(unitPath);
|
|
1125
|
+
await this.executor.exec("systemctl daemon-reload");
|
|
1126
|
+
const artifactPath = await this.readArtifactPath(deploymentId);
|
|
1127
|
+
if (artifactPath) {
|
|
1128
|
+
await this.executor.rm(artifactPath);
|
|
1129
|
+
}
|
|
1130
|
+
await this.removeArtifactPath(deploymentId);
|
|
1131
|
+
}
|
|
1132
|
+
async isRunning(deploymentId) {
|
|
1133
|
+
const unitName = this.unitName(deploymentId);
|
|
1134
|
+
try {
|
|
1135
|
+
const result = await this.executor.exec(
|
|
1136
|
+
`systemctl is-active ${sq(unitName)} 2>/dev/null || true`
|
|
1137
|
+
);
|
|
1138
|
+
return result.trim() === "active";
|
|
1139
|
+
} catch {
|
|
1140
|
+
return false;
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
async getLogs(deploymentId, tail) {
|
|
1144
|
+
const unitName = this.unitName(deploymentId);
|
|
1145
|
+
const tailArg = tail ? `-n ${tail}` : `-n 200`;
|
|
1146
|
+
try {
|
|
1147
|
+
const output = await this.executor.exec(
|
|
1148
|
+
`journalctl -u ${sq(unitName)} ${tailArg} --no-pager -o short-iso 2>/dev/null`
|
|
1149
|
+
);
|
|
1150
|
+
return output.split("\n").filter(Boolean).map((line) => {
|
|
1151
|
+
const match = line.match(/^(\S+)\s+\S+\s+\S+\s+(.*)/);
|
|
1152
|
+
return {
|
|
1153
|
+
timestamp: match?.[1] ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
1154
|
+
message: match?.[2] ?? line,
|
|
1155
|
+
level: parseLogLevel(line)
|
|
1156
|
+
};
|
|
1157
|
+
});
|
|
1158
|
+
} catch {
|
|
1159
|
+
return [];
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
async streamLogs(deploymentId, onLog, opts) {
|
|
1163
|
+
const unitName = this.unitName(deploymentId);
|
|
1164
|
+
const tailN = opts?.tail ?? 100;
|
|
1165
|
+
let stopped = false;
|
|
1166
|
+
const promise = this.executor.streamExec(
|
|
1167
|
+
`journalctl -u ${sq(unitName)} -n ${tailN} -f --no-pager -o short-iso 2>/dev/null`,
|
|
1168
|
+
(entry) => {
|
|
1169
|
+
if (stopped) return;
|
|
1170
|
+
const match = entry.message.match(/^(\S+)\s+\S+\s+\S+\s+(.*)/);
|
|
1171
|
+
const cleaned = match ? { ...entry, timestamp: match[1], message: match[2] + "\r\n" } : { ...entry, message: entry.message + "\r\n" };
|
|
1172
|
+
onLog(cleaned);
|
|
1173
|
+
}
|
|
1174
|
+
);
|
|
1175
|
+
promise.catch(() => {
|
|
1176
|
+
});
|
|
1177
|
+
return () => {
|
|
1178
|
+
stopped = true;
|
|
1179
|
+
this.executor.exec(`pkill -f ${sq(`journalctl.*${unitName}`)} 2>/dev/null || true`).catch(() => {
|
|
1180
|
+
});
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
};
|
|
1184
|
+
|
|
1185
|
+
// ../../packages/adapters/src/runtime/supervisor/nohup.ts
|
|
1186
|
+
var NohupSupervisor = class {
|
|
1187
|
+
constructor(executor, workDir) {
|
|
1188
|
+
this.executor = executor;
|
|
1189
|
+
this.workDir = workDir;
|
|
1190
|
+
}
|
|
1191
|
+
name = "nohup";
|
|
1192
|
+
// ── Path helpers ─────────────────────────────────────────────────────
|
|
1193
|
+
pidFile(id) {
|
|
1194
|
+
return `${this.workDir}/.pids/${id}.pid`;
|
|
1195
|
+
}
|
|
1196
|
+
logFile(id) {
|
|
1197
|
+
return `${this.workDir}/.logs/${id}.log`;
|
|
1198
|
+
}
|
|
1199
|
+
artifactFile(id) {
|
|
1200
|
+
return `${this.workDir}/.artifacts/${id}.path`;
|
|
1201
|
+
}
|
|
1202
|
+
// ── PID helpers ──────────────────────────────────────────────────────
|
|
1203
|
+
async readPid(id) {
|
|
1204
|
+
try {
|
|
1205
|
+
const content = await this.executor.readFile(this.pidFile(id));
|
|
1206
|
+
const pid = parseInt(content.trim(), 10);
|
|
1207
|
+
return isNaN(pid) ? null : pid;
|
|
1208
|
+
} catch {
|
|
1209
|
+
return null;
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
async writePid(id, pid) {
|
|
1213
|
+
await this.executor.mkdir(`${this.workDir}/.pids`);
|
|
1214
|
+
await this.executor.writeFile(this.pidFile(id), String(pid));
|
|
1215
|
+
}
|
|
1216
|
+
async removePid(id) {
|
|
1217
|
+
await this.executor.rm(this.pidFile(id));
|
|
1218
|
+
}
|
|
1219
|
+
async isAlive(pid) {
|
|
1220
|
+
try {
|
|
1221
|
+
await this.executor.exec(`kill -0 ${pid} 2>/dev/null`);
|
|
1222
|
+
return true;
|
|
1223
|
+
} catch {
|
|
1224
|
+
return false;
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
// ── Artifact path tracking ───────────────────────────────────────────
|
|
1228
|
+
async readArtifactPath(id) {
|
|
1229
|
+
try {
|
|
1230
|
+
const content = await this.executor.readFile(this.artifactFile(id));
|
|
1231
|
+
return content.trim() || null;
|
|
1232
|
+
} catch {
|
|
1233
|
+
return null;
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
async writeArtifactPath(id, path) {
|
|
1237
|
+
await this.executor.mkdir(`${this.workDir}/.artifacts`);
|
|
1238
|
+
await this.executor.writeFile(this.artifactFile(id), path);
|
|
1239
|
+
}
|
|
1240
|
+
async removeArtifactPath(id) {
|
|
1241
|
+
await this.executor.rm(this.artifactFile(id));
|
|
1242
|
+
}
|
|
1243
|
+
// ── Lifecycle ────────────────────────────────────────────────────────
|
|
1244
|
+
async deploy(opts) {
|
|
1245
|
+
await this.executor.mkdir(`${this.workDir}/.logs`);
|
|
1246
|
+
const envExports = Object.entries(opts.env).filter(([key]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key)).map(([k, v]) => `export ${k}=${sq(v)}`).join("; ");
|
|
1247
|
+
const logPath = this.logFile(opts.deploymentId);
|
|
1248
|
+
const shellBody = envExports ? `${envExports}; cd ${opts.workDir} && ${opts.startCommand}` : `cd ${opts.workDir} && ${opts.startCommand}`;
|
|
1249
|
+
const runner = `nohup sh -lc ${sq(shellBody)} >> ${sq(logPath)} 2>&1 < /dev/null`;
|
|
1250
|
+
const hasSetsid = await this.executor.exec("command -v setsid >/dev/null 2>&1 && echo y || echo n");
|
|
1251
|
+
const detach = hasSetsid.trim() === "y" ? `setsid ${runner}` : runner;
|
|
1252
|
+
const spawnCmd = `{ ${detach} & echo $!; }`;
|
|
1253
|
+
const pidStr = await execReliable(
|
|
1254
|
+
this.executor,
|
|
1255
|
+
`deploy:${opts.deploymentId}:spawn`,
|
|
1256
|
+
spawnCmd
|
|
1257
|
+
);
|
|
1258
|
+
const pid = parseInt(pidStr.trim(), 10);
|
|
1259
|
+
if (isNaN(pid) || pid <= 0) {
|
|
1260
|
+
throw new Error("Deploy failed: could not capture process ID");
|
|
1261
|
+
}
|
|
1262
|
+
await this.writePid(opts.deploymentId, pid);
|
|
1263
|
+
await this.writeArtifactPath(opts.deploymentId, opts.workDir);
|
|
1264
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
1265
|
+
if (!await this.isAlive(pid)) {
|
|
1266
|
+
let hint = "";
|
|
1267
|
+
try {
|
|
1268
|
+
const tail = await this.executor.exec(`tail -n 10 ${sq(logPath)}`);
|
|
1269
|
+
hint = tail.trim();
|
|
1270
|
+
} catch {
|
|
1271
|
+
}
|
|
1272
|
+
if (hint.includes("EADDRINUSE")) {
|
|
1273
|
+
const occupant = await probeListeningPort(this.executor, opts.port);
|
|
1274
|
+
throw new DeployError(
|
|
1275
|
+
`Port ${opts.port} is already in use` + (occupant ? ` by ${occupant.command}` : "") + ". Stop the existing process before deploying.",
|
|
1276
|
+
"PORT_IN_USE",
|
|
1277
|
+
{ port: opts.port, pid: occupant?.pid, command: occupant?.command }
|
|
1278
|
+
);
|
|
1279
|
+
}
|
|
1280
|
+
const msg = hint ? `Process exited immediately after spawn. Last output:
|
|
1281
|
+
${hint}` : "Process exited immediately after spawn (no output captured)";
|
|
1282
|
+
throw new Error(msg);
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
async stop(deploymentId) {
|
|
1286
|
+
const pid = await this.readPid(deploymentId);
|
|
1287
|
+
if (!pid) return;
|
|
1288
|
+
if (await this.isAlive(pid)) {
|
|
1289
|
+
await this.executor.exec(`kill -- -${pid} 2>/dev/null || kill ${pid} 2>/dev/null || true`);
|
|
1290
|
+
const deadline = Date.now() + 1e4;
|
|
1291
|
+
while (Date.now() < deadline && await this.isAlive(pid)) {
|
|
1292
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
1293
|
+
}
|
|
1294
|
+
if (await this.isAlive(pid)) {
|
|
1295
|
+
await this.executor.exec(`kill -9 -- -${pid} 2>/dev/null || kill -9 ${pid} 2>/dev/null || true`);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
async start(_deploymentId) {
|
|
1300
|
+
throw new Error(
|
|
1301
|
+
"Nohup processes cannot be started after stopping. Trigger a new deployment."
|
|
1302
|
+
);
|
|
1303
|
+
}
|
|
1304
|
+
async restart(deploymentId) {
|
|
1305
|
+
await this.stop(deploymentId);
|
|
1306
|
+
}
|
|
1307
|
+
async destroy(deploymentId) {
|
|
1308
|
+
await this.stop(deploymentId);
|
|
1309
|
+
await this.removePid(deploymentId);
|
|
1310
|
+
await this.executor.rm(this.logFile(deploymentId));
|
|
1311
|
+
const artifactPath = await this.readArtifactPath(deploymentId);
|
|
1312
|
+
if (artifactPath) {
|
|
1313
|
+
await this.executor.rm(artifactPath);
|
|
1314
|
+
}
|
|
1315
|
+
await this.removeArtifactPath(deploymentId);
|
|
1316
|
+
}
|
|
1317
|
+
async isRunning(deploymentId) {
|
|
1318
|
+
const pid = await this.readPid(deploymentId);
|
|
1319
|
+
if (!pid) return false;
|
|
1320
|
+
return this.isAlive(pid);
|
|
1321
|
+
}
|
|
1322
|
+
async getLogs(deploymentId, tail) {
|
|
1323
|
+
const logPath = this.logFile(deploymentId);
|
|
1324
|
+
try {
|
|
1325
|
+
const lines = tail ? await this.executor.exec(`tail -n ${tail} ${sq(logPath)}`) : await this.executor.readFile(logPath);
|
|
1326
|
+
return lines.split("\n").filter(Boolean).map((line) => ({
|
|
1327
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1328
|
+
message: line,
|
|
1329
|
+
level: parseLogLevel(line)
|
|
1330
|
+
}));
|
|
1331
|
+
} catch {
|
|
1332
|
+
return [];
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
async streamLogs(deploymentId, onLog, opts) {
|
|
1336
|
+
const logPath = this.logFile(deploymentId);
|
|
1337
|
+
const tailN = opts?.tail ?? 100;
|
|
1338
|
+
let stopped = false;
|
|
1339
|
+
if (!await this.executor.exists(logPath).catch(() => true)) {
|
|
1340
|
+
onLog({
|
|
1341
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1342
|
+
message: "No supervisor log for this deployment \u2014 the process is supervised elsewhere (container logs, or `openship logs` on the host). Streaming anyway in case it starts writing.\r\n",
|
|
1343
|
+
level: "info"
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
const promise = this.executor.streamExec(
|
|
1347
|
+
`mkdir -p ${sq(`${this.workDir}/.logs`)} 2>/dev/null; touch ${sq(logPath)} 2>/dev/null; tail -n ${tailN} -F ${sq(logPath)} 2>/dev/null`,
|
|
1348
|
+
(entry) => {
|
|
1349
|
+
if (!stopped) onLog({ ...entry, message: entry.message + "\r\n" });
|
|
1350
|
+
}
|
|
1351
|
+
);
|
|
1352
|
+
promise.catch(() => {
|
|
1353
|
+
});
|
|
1354
|
+
return () => {
|
|
1355
|
+
stopped = true;
|
|
1356
|
+
this.executor.exec(`pkill -f ${sq(`tail.*${logPath}`)} 2>/dev/null || true`).catch(() => {
|
|
1357
|
+
});
|
|
1358
|
+
};
|
|
1359
|
+
}
|
|
1360
|
+
};
|
|
1361
|
+
|
|
1362
|
+
// ../../packages/adapters/src/runtime/supervisor/detect.ts
|
|
1363
|
+
async function detectSupervisor(executor, workDir) {
|
|
1364
|
+
const hasSystemd = await probeSystemd(executor);
|
|
1365
|
+
if (hasSystemd) {
|
|
1366
|
+
return new SystemdSupervisor(executor, workDir);
|
|
1367
|
+
}
|
|
1368
|
+
return new NohupSupervisor(executor, workDir);
|
|
1369
|
+
}
|
|
1370
|
+
async function probeSystemd(executor) {
|
|
1371
|
+
try {
|
|
1372
|
+
const result = await executor.exec(
|
|
1373
|
+
`[ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1 && echo y || echo n`
|
|
1374
|
+
);
|
|
1375
|
+
return result.trim() === "y";
|
|
1376
|
+
} catch {
|
|
1377
|
+
return false;
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// ../../packages/adapters/src/runtime/bare.ts
|
|
1382
|
+
var DEFAULT_WORK_DIR = "/opt/openship";
|
|
1383
|
+
var DEFAULT_BUILD_TIMEOUT = 10 * 60 * 1e3;
|
|
1384
|
+
var STATIC_RELEASE_BASE = "/opt/openship/static";
|
|
1385
|
+
var BareRuntime = class {
|
|
1386
|
+
name = "bare";
|
|
1387
|
+
capabilities = /* @__PURE__ */ new Set([
|
|
1388
|
+
"build",
|
|
1389
|
+
"deploy",
|
|
1390
|
+
"stop",
|
|
1391
|
+
"start",
|
|
1392
|
+
"restart",
|
|
1393
|
+
"destroy",
|
|
1394
|
+
"runtimeLogs",
|
|
1395
|
+
"streamLogs",
|
|
1396
|
+
"containerIp",
|
|
1397
|
+
"rollback",
|
|
1398
|
+
"inContainerExec"
|
|
1399
|
+
]);
|
|
1400
|
+
workDir;
|
|
1401
|
+
buildTimeout;
|
|
1402
|
+
executor;
|
|
1403
|
+
systemManager;
|
|
1404
|
+
/** True if we created the executor ourselves (must dispose on cleanup) */
|
|
1405
|
+
ownsExecutor;
|
|
1406
|
+
/** Track active builds by sessionId for cancellation */
|
|
1407
|
+
activeBuilds = /* @__PURE__ */ new Map();
|
|
1408
|
+
/** Process lifecycle delegate - resolved lazily on first deploy/stop/etc. */
|
|
1409
|
+
_supervisor = null;
|
|
1410
|
+
_supervisorPromise = null;
|
|
1411
|
+
constructor(opts) {
|
|
1412
|
+
this.workDir = opts?.workDir ?? DEFAULT_WORK_DIR;
|
|
1413
|
+
this.buildTimeout = opts?.buildTimeout ?? DEFAULT_BUILD_TIMEOUT;
|
|
1414
|
+
if (opts?.executor) {
|
|
1415
|
+
this.executor = opts.executor;
|
|
1416
|
+
this.ownsExecutor = false;
|
|
1417
|
+
} else {
|
|
1418
|
+
this.executor = new LocalExecutor();
|
|
1419
|
+
this.ownsExecutor = true;
|
|
1420
|
+
}
|
|
1421
|
+
this.systemManager = opts?.systemManager ?? null;
|
|
1422
|
+
}
|
|
1423
|
+
/** The underlying command executor (local or SSH). Exposed so the
|
|
1424
|
+
* backup subsystem's bare executor can stream commands over the same
|
|
1425
|
+
* connection (mirrors how DockerRuntime exposes its client). */
|
|
1426
|
+
get commandExecutor() {
|
|
1427
|
+
return this.executor;
|
|
1428
|
+
}
|
|
1429
|
+
/** A bare deployment is a host process, so "inside the instance" == the host.
|
|
1430
|
+
* The host executor already sees the process's listeners (shared netns). */
|
|
1431
|
+
async inContainerExecutor() {
|
|
1432
|
+
return this.executor;
|
|
1433
|
+
}
|
|
1434
|
+
/** Get or lazily initialise the process supervisor. */
|
|
1435
|
+
async supervisor() {
|
|
1436
|
+
if (this._supervisor) return this._supervisor;
|
|
1437
|
+
if (!this._supervisorPromise) {
|
|
1438
|
+
this._supervisorPromise = detectSupervisor(this.executor, this.workDir).then((s) => {
|
|
1439
|
+
this._supervisor = s;
|
|
1440
|
+
return s;
|
|
1441
|
+
});
|
|
1442
|
+
}
|
|
1443
|
+
return this._supervisorPromise;
|
|
1444
|
+
}
|
|
1445
|
+
supports(cap) {
|
|
1446
|
+
return this.capabilities.has(cap);
|
|
1447
|
+
}
|
|
1448
|
+
async dispose() {
|
|
1449
|
+
if (this.ownsExecutor) {
|
|
1450
|
+
await this.executor.dispose();
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
// ─── Path helpers ────────────────────────────────────────────────────
|
|
1454
|
+
projectDir(projectId) {
|
|
1455
|
+
return `${this.workDir}/${projectId}`;
|
|
1456
|
+
}
|
|
1457
|
+
buildDir(sessionId) {
|
|
1458
|
+
return `${this.workDir}/.builds/${sessionId}`;
|
|
1459
|
+
}
|
|
1460
|
+
releaseDir(deploymentId) {
|
|
1461
|
+
return `${this.workDir}/releases/${deploymentId}`;
|
|
1462
|
+
}
|
|
1463
|
+
async promoteBuildArtifact(artifactPath, deploymentId, previousDeploymentId) {
|
|
1464
|
+
const releaseDir = this.releaseDir(deploymentId);
|
|
1465
|
+
if (artifactPath === releaseDir) return releaseDir;
|
|
1466
|
+
await this.executor.mkdir(`${this.workDir}/releases`);
|
|
1467
|
+
await this.executor.rm(releaseDir);
|
|
1468
|
+
const previousReleaseDir = previousDeploymentId ? this.releaseDir(previousDeploymentId) : void 0;
|
|
1469
|
+
if (previousReleaseDir && await this.executor.exists(previousReleaseDir)) {
|
|
1470
|
+
try {
|
|
1471
|
+
await execReliable(
|
|
1472
|
+
this.executor,
|
|
1473
|
+
`deploy:${deploymentId}:promote-rsync`,
|
|
1474
|
+
`rsync -a --delete --link-dest=${sq(previousReleaseDir)} ${sq(artifactPath)}/ ${sq(releaseDir)}/`
|
|
1475
|
+
);
|
|
1476
|
+
await this.executor.rm(artifactPath).catch(() => {
|
|
1477
|
+
});
|
|
1478
|
+
return releaseDir;
|
|
1479
|
+
} catch {
|
|
1480
|
+
await this.executor.rm(releaseDir).catch(() => {
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
await execReliable(
|
|
1485
|
+
this.executor,
|
|
1486
|
+
`deploy:${deploymentId}:promote-mv`,
|
|
1487
|
+
`mv ${sq(artifactPath)} ${sq(releaseDir)}`
|
|
1488
|
+
);
|
|
1489
|
+
return releaseDir;
|
|
1490
|
+
}
|
|
1491
|
+
// ── File transfer ──────────────────────────────────────────────────────
|
|
1492
|
+
/**
|
|
1493
|
+
* Transfer files from a local path on the API server into the build/deploy dir.
|
|
1494
|
+
*
|
|
1495
|
+
* Delegates entirely to the executor - LocalExecutor does cp,
|
|
1496
|
+
* SshExecutor does tar+pipe. No branching here.
|
|
1497
|
+
*/
|
|
1498
|
+
async transferFiles(localPath, remotePath, logger) {
|
|
1499
|
+
await transferLocalDirectory(
|
|
1500
|
+
localPath,
|
|
1501
|
+
{
|
|
1502
|
+
kind: "executor",
|
|
1503
|
+
executor: this.executor,
|
|
1504
|
+
path: remotePath
|
|
1505
|
+
},
|
|
1506
|
+
logger
|
|
1507
|
+
);
|
|
1508
|
+
}
|
|
1509
|
+
// ── Build lifecycle ────────────────────────────────────────────────────
|
|
1510
|
+
async build(config, logger) {
|
|
1511
|
+
const log = logger ?? new BuildLogger();
|
|
1512
|
+
const buildLocally = config.buildStrategy === "local" && !(this.executor instanceof LocalExecutor);
|
|
1513
|
+
const abort = new AbortController();
|
|
1514
|
+
this.activeBuilds.set(config.sessionId, abort);
|
|
1515
|
+
try {
|
|
1516
|
+
if (buildLocally) {
|
|
1517
|
+
return await this.buildLocally(config, log, abort);
|
|
1518
|
+
}
|
|
1519
|
+
return await this.buildOnTarget(config, log, abort);
|
|
1520
|
+
} finally {
|
|
1521
|
+
this.activeBuilds.delete(config.sessionId);
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
/** Build on the API host, then transfer output to the target server. */
|
|
1525
|
+
async buildLocally(config, log, abort) {
|
|
1526
|
+
log.log("Build strategy: local (build on API host, transfer to server)\n");
|
|
1527
|
+
const remoteDir = this.buildDir(config.sessionId);
|
|
1528
|
+
const stackDef = STACKS[config.stack];
|
|
1529
|
+
let standaloneStartCommand;
|
|
1530
|
+
let result;
|
|
1531
|
+
try {
|
|
1532
|
+
result = await runLocalBuild({
|
|
1533
|
+
config,
|
|
1534
|
+
logger: log,
|
|
1535
|
+
abort: abort.signal,
|
|
1536
|
+
preflight: async (cfg, plog, localExec) => {
|
|
1537
|
+
await this.ensureToolchain(localExec, cfg.stack, plog);
|
|
1538
|
+
plog.log("Checking runtime tools on target server...\n");
|
|
1539
|
+
await this.ensureToolchain(this.executor, cfg.stack, plog);
|
|
1540
|
+
if (this.systemManager) {
|
|
1541
|
+
plog.log("Ensuring rsync is installed on target server...\n");
|
|
1542
|
+
await this.systemManager.ensureComponents(["rsync"], (entry) => plog.callback(entry));
|
|
1543
|
+
}
|
|
1544
|
+
},
|
|
1545
|
+
transferOutput: async (buildDir) => {
|
|
1546
|
+
await this.executor.rm(remoteDir);
|
|
1547
|
+
await this.executor.mkdir(remoteDir);
|
|
1548
|
+
const selfContained = await prepareStackOutput(
|
|
1549
|
+
config.stack,
|
|
1550
|
+
resolveProjectDir(buildDir, config.rootDirectory)
|
|
1551
|
+
);
|
|
1552
|
+
if (selfContained) {
|
|
1553
|
+
log.log("Detected self-contained build output \u2014 shipping the bundle (no install on target).\n");
|
|
1554
|
+
await transferLocalDirectory(
|
|
1555
|
+
selfContained.bundleDir,
|
|
1556
|
+
{ kind: "executor", executor: this.executor, path: remoteDir },
|
|
1557
|
+
log,
|
|
1558
|
+
{ excludes: [] }
|
|
1559
|
+
// ship everything, incl. traced node_modules
|
|
1560
|
+
);
|
|
1561
|
+
standaloneStartCommand = selfContained.startCommand;
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
if (stackDef?.productionPaths?.length) {
|
|
1565
|
+
log.log(`Transferring production paths: ${stackDef.productionPaths.join(", ")}
|
|
1566
|
+
`);
|
|
1567
|
+
await transferLocalDirectory(
|
|
1568
|
+
buildDir,
|
|
1569
|
+
{ kind: "executor", executor: this.executor, path: remoteDir },
|
|
1570
|
+
log,
|
|
1571
|
+
{ includes: [...stackDef.productionPaths] }
|
|
1572
|
+
);
|
|
1573
|
+
} else {
|
|
1574
|
+
await transferLocalDirectory(
|
|
1575
|
+
buildDir,
|
|
1576
|
+
{ kind: "executor", executor: this.executor, path: remoteDir },
|
|
1577
|
+
log,
|
|
1578
|
+
{
|
|
1579
|
+
excludes: buildOutputTransferExcludes(stackDef),
|
|
1580
|
+
alsoInclude: stackDef?.outputDirectory ? [stackDef.outputDirectory] : void 0
|
|
1581
|
+
}
|
|
1582
|
+
);
|
|
1583
|
+
}
|
|
1584
|
+
const installCmd = config.installCommand?.trim();
|
|
1585
|
+
if (installCmd) {
|
|
1586
|
+
const pmEnsure = packageManagerEnsureCommand(config.packageManager);
|
|
1587
|
+
const fullInstall = pmEnsure ? `${pmEnsure} && ${installCmd}` : installCmd;
|
|
1588
|
+
log.log("Installing production dependencies on target...\n");
|
|
1589
|
+
const { code } = await this.executor.streamExec(
|
|
1590
|
+
`cd ${sq(remoteDir)} && ${fullInstall}`,
|
|
1591
|
+
log.callback
|
|
1592
|
+
);
|
|
1593
|
+
if (code !== 0) {
|
|
1594
|
+
throw new Error("Failed to install production dependencies on target");
|
|
1595
|
+
}
|
|
1596
|
+
log.log("Production dependencies installed.\n");
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
});
|
|
1600
|
+
} catch (err) {
|
|
1601
|
+
const msg = safeErrorMessage(err);
|
|
1602
|
+
log.log(`Failed to transfer local build output: ${msg}`, "error");
|
|
1603
|
+
return {
|
|
1604
|
+
sessionId: config.sessionId,
|
|
1605
|
+
status: "failed",
|
|
1606
|
+
imageRef: remoteDir,
|
|
1607
|
+
errorMessage: `Failed to transfer build output: ${msg}`
|
|
1608
|
+
};
|
|
1609
|
+
}
|
|
1610
|
+
return {
|
|
1611
|
+
sessionId: config.sessionId,
|
|
1612
|
+
status: result.status,
|
|
1613
|
+
imageRef: remoteDir,
|
|
1614
|
+
durationMs: result.durationMs,
|
|
1615
|
+
errorMessage: result.errorMessage,
|
|
1616
|
+
startCommand: standaloneStartCommand
|
|
1617
|
+
};
|
|
1618
|
+
}
|
|
1619
|
+
/** Build directly on the target machine via the executor. */
|
|
1620
|
+
async buildOnTarget(config, log, abort) {
|
|
1621
|
+
log.log("Build strategy: server (build on target)\n");
|
|
1622
|
+
const dir = this.buildDir(config.sessionId);
|
|
1623
|
+
await this.executor.rm(dir);
|
|
1624
|
+
await this.executor.mkdir(dir);
|
|
1625
|
+
const buildEnv = {
|
|
1626
|
+
projectDir: dir,
|
|
1627
|
+
exec: async (command, logCb) => {
|
|
1628
|
+
if (abort.signal.aborted) throw new BuildCancelledError();
|
|
1629
|
+
const effectiveCommand = this.executor instanceof LocalExecutor ? wrapLocalBuildCommand(command) : command;
|
|
1630
|
+
const { code, output } = await this.executor.streamExec(effectiveCommand, logCb);
|
|
1631
|
+
if (abort.signal.aborted) throw new BuildCancelledError();
|
|
1632
|
+
if (code !== 0) {
|
|
1633
|
+
const hint = detectBuildKillHint(output);
|
|
1634
|
+
throw new Error(
|
|
1635
|
+
`Command failed with exit code ${code}${hint ? ` - ${hint}` : ""}`
|
|
1636
|
+
);
|
|
1637
|
+
}
|
|
1638
|
+
},
|
|
1639
|
+
preflight: async (cfg, plog) => {
|
|
1640
|
+
if (abort.signal.aborted) throw new BuildCancelledError();
|
|
1641
|
+
await this.ensureToolchain(this.executor, cfg.stack, plog);
|
|
1642
|
+
if (cfg.localPath) {
|
|
1643
|
+
await this.transferFiles(cfg.localPath, dir, plog);
|
|
1644
|
+
}
|
|
1645
|
+
},
|
|
1646
|
+
// Out-of-band secret write (SSH key + known_hosts) — goes through the
|
|
1647
|
+
// executor's file channel, never the streamed `exec`, so key bytes never
|
|
1648
|
+
// reach the build log. Works for both local and SSH executors.
|
|
1649
|
+
writeSecretFile: (p, content) => this.executor.writeFile(p, content)
|
|
1650
|
+
};
|
|
1651
|
+
const result = await runBuildPipeline(buildEnv, config, log);
|
|
1652
|
+
return {
|
|
1653
|
+
sessionId: config.sessionId,
|
|
1654
|
+
status: result.status,
|
|
1655
|
+
imageRef: dir,
|
|
1656
|
+
durationMs: result.durationMs,
|
|
1657
|
+
errorMessage: result.errorMessage
|
|
1658
|
+
};
|
|
1659
|
+
}
|
|
1660
|
+
/**
|
|
1661
|
+
* Check that the target executor has the required toolchain for a stack,
|
|
1662
|
+
* and install any missing or outdated tools.
|
|
1663
|
+
*/
|
|
1664
|
+
async ensureToolchain(executor, stack, plog) {
|
|
1665
|
+
const toolcheck = await checkToolchainForStack(executor, stack);
|
|
1666
|
+
if (toolcheck.ready) return;
|
|
1667
|
+
const requiredTools = toolcheck.tools.filter((tool) => !tool.healthy);
|
|
1668
|
+
plog.log("Installing build tools (one-time server setup)\u2026\n");
|
|
1669
|
+
plog.log(`${requiredTools.map((tool) => tool.message).join("\n")}
|
|
1670
|
+
`);
|
|
1671
|
+
const results = await installTools(
|
|
1672
|
+
executor,
|
|
1673
|
+
requiredTools.map((tool) => tool.name),
|
|
1674
|
+
plog.callback,
|
|
1675
|
+
Object.fromEntries(
|
|
1676
|
+
requiredTools.filter((tool) => tool.requiredVersion).map((tool) => [tool.name, tool.requiredVersion])
|
|
1677
|
+
)
|
|
1678
|
+
);
|
|
1679
|
+
const failed = results.filter((r) => !r.success);
|
|
1680
|
+
if (failed.length > 0) {
|
|
1681
|
+
throw new Error(
|
|
1682
|
+
`Failed to install required tools: ${failed.map((f) => `${f.tool} (${f.error})`).join(", ")}`
|
|
1683
|
+
);
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
async cancelBuild(sessionId) {
|
|
1687
|
+
const abort = this.activeBuilds.get(sessionId);
|
|
1688
|
+
if (abort) {
|
|
1689
|
+
abort.abort();
|
|
1690
|
+
this.activeBuilds.delete(sessionId);
|
|
1691
|
+
}
|
|
1692
|
+
const dir = sq(this.buildDir(sessionId));
|
|
1693
|
+
const scan = (sig) => `for p in /proc/[0-9]*; do c=$(readlink "$p/cwd" 2>/dev/null); case "$c" in ${dir}|${dir}/*) kill -${sig} "\${p##*/}" 2>/dev/null || true;; esac; done`;
|
|
1694
|
+
await this.executor.exec(`${scan("TERM")}; sleep 2; ${scan("KILL")}`).catch(() => {
|
|
1695
|
+
});
|
|
1696
|
+
}
|
|
1697
|
+
async getBuildLogs(sessionId) {
|
|
1698
|
+
void sessionId;
|
|
1699
|
+
return [];
|
|
1700
|
+
}
|
|
1701
|
+
// ── Deploy lifecycle ───────────────────────────────────────────────────
|
|
1702
|
+
async deploy(config, _onLog) {
|
|
1703
|
+
if (config.adopt) {
|
|
1704
|
+
const occupant = await probeListeningPort(this.executor, config.port);
|
|
1705
|
+
_onLog?.({
|
|
1706
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1707
|
+
level: occupant ? "info" : "warn",
|
|
1708
|
+
message: occupant ? `[adopt] attached to process on port ${config.port}: ${occupant.command}
|
|
1709
|
+
` : `[adopt] no process is listening on port ${config.port} yet \u2014 routing will attach when it comes up
|
|
1710
|
+
`
|
|
1711
|
+
});
|
|
1712
|
+
return {
|
|
1713
|
+
deploymentId: config.deploymentId,
|
|
1714
|
+
containerId: config.deploymentId,
|
|
1715
|
+
status: "running"
|
|
1716
|
+
};
|
|
1717
|
+
}
|
|
1718
|
+
const stagedDir = config.imageRef ?? this.projectDir(config.projectId);
|
|
1719
|
+
const workDir = config.imageRef ? await this.promoteBuildArtifact(
|
|
1720
|
+
stagedDir,
|
|
1721
|
+
config.deploymentId,
|
|
1722
|
+
config.previousDeploymentId
|
|
1723
|
+
) : stagedDir;
|
|
1724
|
+
const sv = await this.supervisor();
|
|
1725
|
+
const env = {
|
|
1726
|
+
...Object.fromEntries(
|
|
1727
|
+
Object.entries(config.envVars ?? {}).map(([k, v]) => [k, String(v)])
|
|
1728
|
+
),
|
|
1729
|
+
PORT: String(config.port),
|
|
1730
|
+
NODE_ENV: config.environment === "production" ? "production" : "development"
|
|
1731
|
+
};
|
|
1732
|
+
try {
|
|
1733
|
+
await sv.deploy({
|
|
1734
|
+
deploymentId: config.deploymentId,
|
|
1735
|
+
projectId: config.projectId,
|
|
1736
|
+
workDir,
|
|
1737
|
+
startCommand: config.startCommand || "npm start",
|
|
1738
|
+
port: config.port,
|
|
1739
|
+
env
|
|
1740
|
+
});
|
|
1741
|
+
} catch (err) {
|
|
1742
|
+
if (workDir !== stagedDir) {
|
|
1743
|
+
await sv.destroy(config.deploymentId).catch(() => {
|
|
1744
|
+
});
|
|
1745
|
+
await this.executor.rm(workDir).catch(() => {
|
|
1746
|
+
});
|
|
1747
|
+
}
|
|
1748
|
+
throw err;
|
|
1749
|
+
}
|
|
1750
|
+
return {
|
|
1751
|
+
deploymentId: config.deploymentId,
|
|
1752
|
+
containerId: config.deploymentId,
|
|
1753
|
+
status: "running"
|
|
1754
|
+
};
|
|
1755
|
+
}
|
|
1756
|
+
async deployStatic(config) {
|
|
1757
|
+
const stagedDir = config.imageRef ?? this.projectDir(config.projectId);
|
|
1758
|
+
const workDir = config.imageRef ? await this.promoteBuildArtifact(
|
|
1759
|
+
stagedDir,
|
|
1760
|
+
config.deploymentId,
|
|
1761
|
+
config.previousDeploymentId
|
|
1762
|
+
) : stagedDir;
|
|
1763
|
+
const staticRoot = this.resolveStaticRoot(workDir, config.outputDirectory);
|
|
1764
|
+
if (!await this.executor.exists(staticRoot)) {
|
|
1765
|
+
if (workDir !== stagedDir) {
|
|
1766
|
+
await this.executor.rm(workDir).catch(() => {
|
|
1767
|
+
});
|
|
1768
|
+
}
|
|
1769
|
+
throw new Error(missingOutputDirectoryMessage(config.outputDirectory));
|
|
1770
|
+
}
|
|
1771
|
+
return {
|
|
1772
|
+
deploymentId: config.deploymentId,
|
|
1773
|
+
containerId: workDir,
|
|
1774
|
+
status: "running"
|
|
1775
|
+
};
|
|
1776
|
+
}
|
|
1777
|
+
resolveStaticRoot(containerId, outputDirectory) {
|
|
1778
|
+
return resolveStaticOutputPath(containerId, outputDirectory);
|
|
1779
|
+
}
|
|
1780
|
+
async stop(containerId) {
|
|
1781
|
+
const sv = await this.supervisor();
|
|
1782
|
+
await sv.stop(containerId);
|
|
1783
|
+
}
|
|
1784
|
+
async start(containerId) {
|
|
1785
|
+
const sv = await this.supervisor();
|
|
1786
|
+
if (await sv.isRunning(containerId)) return;
|
|
1787
|
+
await sv.start(containerId);
|
|
1788
|
+
}
|
|
1789
|
+
async restart(containerId) {
|
|
1790
|
+
const sv = await this.supervisor();
|
|
1791
|
+
await sv.restart(containerId);
|
|
1792
|
+
}
|
|
1793
|
+
async destroy(containerId) {
|
|
1794
|
+
if (containerId.includes("/")) {
|
|
1795
|
+
await this.executor.rm(containerId);
|
|
1796
|
+
return;
|
|
1797
|
+
}
|
|
1798
|
+
const sv = await this.supervisor();
|
|
1799
|
+
await sv.destroy(containerId);
|
|
1800
|
+
}
|
|
1801
|
+
// ── Rollback primitives ──────────────────────────────────────────────
|
|
1802
|
+
//
|
|
1803
|
+
// Bare semantics:
|
|
1804
|
+
// The release dir at workDir/releases/<deploymentId> IS the artifact.
|
|
1805
|
+
// The supervisor unit is the activation. Rollback flips which
|
|
1806
|
+
// release the supervisor unit serves by stop/start sequencing.
|
|
1807
|
+
//
|
|
1808
|
+
// makeActive — stop `from`'s supervisor unit, then start `to`'s.
|
|
1809
|
+
// The release dirs are stable on disk; we're just changing which
|
|
1810
|
+
// unit is running. Matches the user's "mv path + reload" mental
|
|
1811
|
+
// model — the path doesn't physically move, but the active one
|
|
1812
|
+
// swaps via the supervisor.
|
|
1813
|
+
// archive — stop the supervisor unit. Release dir stays on disk
|
|
1814
|
+
// (the actual rollback-restorable artifact).
|
|
1815
|
+
// purge — destroy the supervisor unit + rm -rf the release dir.
|
|
1816
|
+
async makeActive(input) {
|
|
1817
|
+
if (input.from?.containerId) {
|
|
1818
|
+
try {
|
|
1819
|
+
await this.stop(input.from.containerId);
|
|
1820
|
+
} catch {
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
if (!input.to.containerId) {
|
|
1824
|
+
throw new Error(
|
|
1825
|
+
`Cannot make deployment ${input.to.id} active: supervisor unit is gone. Artifact has been purged.`
|
|
1826
|
+
);
|
|
1827
|
+
}
|
|
1828
|
+
await this.start(input.to.containerId);
|
|
1829
|
+
return { containerId: input.to.containerId };
|
|
1830
|
+
}
|
|
1831
|
+
async archive(deployment) {
|
|
1832
|
+
if (!deployment.containerId) return;
|
|
1833
|
+
try {
|
|
1834
|
+
await this.stop(deployment.containerId);
|
|
1835
|
+
} catch {
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
async purge(deployment) {
|
|
1839
|
+
if (deployment.containerId) {
|
|
1840
|
+
try {
|
|
1841
|
+
await this.destroy(deployment.containerId);
|
|
1842
|
+
} catch {
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
try {
|
|
1846
|
+
await this.executor.rm(this.releaseDir(deployment.id));
|
|
1847
|
+
} catch {
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
// ── Observability ──────────────────────────────────────────────────────
|
|
1851
|
+
async getContainerInfo(containerId) {
|
|
1852
|
+
const sv = await this.supervisor();
|
|
1853
|
+
const running = await sv.isRunning(containerId);
|
|
1854
|
+
return {
|
|
1855
|
+
containerId,
|
|
1856
|
+
status: running ? "running" : "stopped"
|
|
1857
|
+
};
|
|
1858
|
+
}
|
|
1859
|
+
async getRuntimeLogs(containerId, tail) {
|
|
1860
|
+
const sv = await this.supervisor();
|
|
1861
|
+
return sv.getLogs(containerId, tail);
|
|
1862
|
+
}
|
|
1863
|
+
async streamRuntimeLogs(containerId, onLog, opts) {
|
|
1864
|
+
const sv = await this.supervisor();
|
|
1865
|
+
return sv.streamLogs(containerId, onLog, opts);
|
|
1866
|
+
}
|
|
1867
|
+
async getUsage(_containerId) {
|
|
1868
|
+
return { cpuPercent: 0, memoryMb: 0, diskMb: 0, networkRxBytes: 0, networkTxBytes: 0 };
|
|
1869
|
+
}
|
|
1870
|
+
// ── Network ────────────────────────────────────────────────────────────
|
|
1871
|
+
async getContainerIp(_containerId) {
|
|
1872
|
+
return "127.0.0.1";
|
|
1873
|
+
}
|
|
1874
|
+
};
|
|
1875
|
+
|
|
1876
|
+
export {
|
|
1877
|
+
STATIC_RELEASE_BASE,
|
|
1878
|
+
BareRuntime
|
|
1879
|
+
};
|