alexandr 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +105 -6
- package/bin.js +8 -13
- package/package.json +15 -5
- package/src/cli.js +83 -0
- package/src/commands.js +576 -0
- package/src/completion.js +122 -0
- package/src/connect.js +11 -0
- package/src/deps.js +91 -0
- package/src/docker.js +94 -0
- package/src/exit.js +37 -0
- package/src/instance.js +102 -0
- package/src/link.js +326 -0
- package/src/probe.js +47 -0
- package/src/prompt.js +116 -0
- package/src/util.js +96 -0
- package/templates/Caddyfile +6 -0
- package/templates/docker-compose.yml +51 -0
- package/templates/env.example +49 -0
package/src/commands.js
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
// The verb implementations. Each receives the parsed flags object.
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
log, ok, warn, step, fail, bold, dim, cyan, green, parseArgs, confirmed,
|
|
11
|
+
isPortFree, openURL,
|
|
12
|
+
} from "./util.js";
|
|
13
|
+
import {
|
|
14
|
+
dockerProblems, compose, composeCapture, exec,
|
|
15
|
+
} from "./docker.js";
|
|
16
|
+
import {
|
|
17
|
+
resolveInstance, isMaterialized, materialize, readEnv, envHas, setEnv,
|
|
18
|
+
unsetEnv, kernelPort,
|
|
19
|
+
} from "./instance.js";
|
|
20
|
+
import { kernelUrl, health, version as kVersion, waitHealthy, waitPosture } from "./probe.js";
|
|
21
|
+
import { buildConnect } from "./connect.js";
|
|
22
|
+
import { ensureLinked, isLinked, runLinkCeremony, unlinkFromAccount } from "./link.js";
|
|
23
|
+
import { select, ask } from "./prompt.js";
|
|
24
|
+
import { offerDependencyInstall } from "./deps.js";
|
|
25
|
+
import { EXIT } from "./exit.js";
|
|
26
|
+
|
|
27
|
+
// `alexandr link` — the explicit re-link/repair verb (`up` links automatically as step one).
|
|
28
|
+
export { link } from "./link.js";
|
|
29
|
+
|
|
30
|
+
const IMAGE_BASE = "ghcr.io/alexandrco/alexandr-kernel";
|
|
31
|
+
|
|
32
|
+
// Docker wants forward slashes in -v host paths on Windows (C:\x → C:/x).
|
|
33
|
+
const hostPath = (p) => p.replace(/\\/g, "/");
|
|
34
|
+
|
|
35
|
+
// Keys whose values are secrets — masked in `config list`.
|
|
36
|
+
const SECRET_KEY = /TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL/;
|
|
37
|
+
|
|
38
|
+
// Friendly config keys → env vars. Raw ALEXANDR_* keys also accepted.
|
|
39
|
+
const CONFIG_ALIASES = {
|
|
40
|
+
port: "ALEXANDR_KERNEL_PORT",
|
|
41
|
+
domain: "ALEXANDR_DOMAIN",
|
|
42
|
+
"public-url": "ALEXANDR_PUBLIC_URL",
|
|
43
|
+
image: "ALEXANDR_IMAGE",
|
|
44
|
+
"ai.url": "ALEXANDR_AI_BASE_URL",
|
|
45
|
+
model: "ALEXANDR_COPILOT_MODEL",
|
|
46
|
+
catalog: "ALEXANDR_CATALOG_URL",
|
|
47
|
+
releases: "ALEXANDR_RELEASES_URL",
|
|
48
|
+
};
|
|
49
|
+
const toEnvKey = (k) => CONFIG_ALIASES[k] || (k.startsWith("ALEXANDR_") ? k : null);
|
|
50
|
+
|
|
51
|
+
function ensureDocker(problems = dockerProblems()) {
|
|
52
|
+
if (problems.length) {
|
|
53
|
+
for (const p of problems) warn(p.message);
|
|
54
|
+
// Exit with the first problem's specific class (docker missing vs daemon
|
|
55
|
+
// down vs compose missing) so callers can branch on the cause.
|
|
56
|
+
fail("Docker isn't ready — run `alexandr doctor` for details.", problems[0].exit);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Does this instance's named data volume exist yet? (No volume = nothing to
|
|
61
|
+
// back up / restore — and `docker run -v vol:/data` would silently create an
|
|
62
|
+
// empty one, which we'd rather not do behind the user's back.)
|
|
63
|
+
const dataVolumeExists = (vol) => exec("docker", ["volume", "inspect", vol]).status === 0;
|
|
64
|
+
|
|
65
|
+
// Tar a data volume to outPath via a throwaway alpine container (the documented
|
|
66
|
+
// recipe — no manual ritual). Shared by `backup` and the pre-update snapshot.
|
|
67
|
+
// Returns true on success. Creates the destination directory if needed.
|
|
68
|
+
function archiveVolume(vol, outPath) {
|
|
69
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
70
|
+
const r = spawnSync(
|
|
71
|
+
"docker",
|
|
72
|
+
["run", "--rm", "-v", `${vol}:/data:ro`, "-v", `${hostPath(path.dirname(outPath))}:/backup`, "alpine",
|
|
73
|
+
"tar", "czf", `/backup/${path.basename(outPath)}`, "-C", "/data", "."],
|
|
74
|
+
{ stdio: "inherit" },
|
|
75
|
+
);
|
|
76
|
+
return r.status === 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isRunning(dir, projectName) {
|
|
80
|
+
const r = composeCapture(dir, projectName, ["ps", "--status", "running", "-q", "kernel"]);
|
|
81
|
+
return r.status === 0 && r.stdout !== "";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The URL a CLIENT should connect to (the "advertised URL"). Precedence:
|
|
85
|
+
// ALEXANDR_PUBLIC_URL — explicit, for a reverse proxy / known-reachable host
|
|
86
|
+
// > https://<ALEXANDR_DOMAIN> — Caddy's "public" profile exposes + TLS-terminates it
|
|
87
|
+
// > http://127.0.0.1:<port> — loopback: reachable ONLY from this machine.
|
|
88
|
+
function clientUrl(dir) {
|
|
89
|
+
const env = readEnv(dir);
|
|
90
|
+
const explicit = (env.ALEXANDR_PUBLIC_URL || "").trim();
|
|
91
|
+
if (explicit) return explicit.replace(/\/+$/, "");
|
|
92
|
+
if (env.ALEXANDR_DOMAIN) return `https://${env.ALEXANDR_DOMAIN}`;
|
|
93
|
+
return kernelUrl(kernelPort(dir));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// A loopback advertised URL is only reachable from the same machine as the runtime.
|
|
97
|
+
const isLoopbackUrl = (url) => /^https?:\/\/(127\.0\.0\.1|localhost|\[?::1\]?)(:|\/|$)/i.test(url);
|
|
98
|
+
|
|
99
|
+
function printConnect(dir) {
|
|
100
|
+
const url = clientUrl(dir);
|
|
101
|
+
const local = isLoopbackUrl(url);
|
|
102
|
+
const { deepLink, pasteString } = buildConnect({ url });
|
|
103
|
+
|
|
104
|
+
log("");
|
|
105
|
+
log(bold(local ? " Open in your browser (this Mac only):" : " Open in your browser:"));
|
|
106
|
+
log(` ${cyan(url)}`);
|
|
107
|
+
log(dim(" Sign in with your alexandr account — this runtime is linked to it, so it's"));
|
|
108
|
+
log(dim(" already in your hub (and in the desktop app)."));
|
|
109
|
+
// Headless fallback: a server with no browser hands the runtime to a Mac via the one-click
|
|
110
|
+
// link / paste-string. URL-only — membership is verified against the account, no token to carry.
|
|
111
|
+
log("");
|
|
112
|
+
log(dim(" Open in the desktop app directly:"));
|
|
113
|
+
log(` ${cyan(deepLink)}`);
|
|
114
|
+
log(dim(` …or paste into Connect Server: ${pasteString}`));
|
|
115
|
+
|
|
116
|
+
if (local) {
|
|
117
|
+
log("");
|
|
118
|
+
warn("This is a local address — it only works on THIS machine.");
|
|
119
|
+
log(dim(" To connect from another computer, expose the runtime with a domain:"));
|
|
120
|
+
log(dim(" alexandr config set domain your-host.example.com # Caddy auto-provisions HTTPS"));
|
|
121
|
+
log(dim(" then re-run `alexandr up`. (Or set ALEXANDR_PUBLIC_URL to an already-reachable URL.)"));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------- up
|
|
126
|
+
// The first-run wizard (create-vite style): a bare `alexandr up` on a fresh instance asks
|
|
127
|
+
// its few questions interactively instead of requiring flags. Runs ONLY when installing —
|
|
128
|
+
// never on a re-run (`up` stays an idempotent restart), never when any shaping flag was
|
|
129
|
+
// passed (scripts keep exact behavior), never without a real TTY (CI/pipes), and `--yes`
|
|
130
|
+
// means "defaults, don't ask". Mutates `flags` so the answers flow through the exact same
|
|
131
|
+
// code path as flags would — the wizard is input, not a second implementation.
|
|
132
|
+
function wizardApplies(flags, fresh) {
|
|
133
|
+
return (
|
|
134
|
+
fresh &&
|
|
135
|
+
!flags.domain && !flags.port && !flags.name && !flags.offline && !flags["no-pull"] &&
|
|
136
|
+
!confirmed(flags) &&
|
|
137
|
+
Boolean(process.stdin.isTTY && process.stdout.isTTY)
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function firstRunWizard(flags) {
|
|
142
|
+
log(`${bold("alexandr")} ${dim("— let's set up this runtime. A few questions (Ctrl-C aborts):")}`);
|
|
143
|
+
log();
|
|
144
|
+
const reach = await select("How should this runtime be reachable?", [
|
|
145
|
+
{ label: "Just this machine", hint: "http://localhost — open it from this computer", value: "local" },
|
|
146
|
+
{ label: "A public domain", hint: "automatic HTTPS via Caddy — needs DNS pointing here", value: "public" },
|
|
147
|
+
]);
|
|
148
|
+
if (reach === "public") {
|
|
149
|
+
flags.domain = await ask("Domain", {
|
|
150
|
+
validate: (v) =>
|
|
151
|
+
/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i.test(v)
|
|
152
|
+
? null
|
|
153
|
+
: "That doesn't look like a domain (e.g. alexandr.example.com) — try again.",
|
|
154
|
+
});
|
|
155
|
+
} else {
|
|
156
|
+
flags.port = await ask("Port", {
|
|
157
|
+
def: "3030",
|
|
158
|
+
validate: (v) => (/^\d{2,5}$/.test(v) ? null : "A port is a number (e.g. 3030) — try again."),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
// Becomes the workspace's display name in the account/desktop app (link.js).
|
|
162
|
+
flags.name = await ask("Workspace name", { def: "My self-hosted workspace" });
|
|
163
|
+
log();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function up(flags) {
|
|
167
|
+
// Install (the missing dependencies), don't just diagnose: on a fresh Linux box, `up`
|
|
168
|
+
// offers to set up Docker + Compose itself (deps.js), then re-checks. Declined /
|
|
169
|
+
// non-Linux / non-TTY → the normal per-cause failure below.
|
|
170
|
+
let problems = dockerProblems();
|
|
171
|
+
if (problems.length && (await offerDependencyInstall(problems))) problems = dockerProblems();
|
|
172
|
+
ensureDocker(problems);
|
|
173
|
+
const inst = resolveInstance(flags);
|
|
174
|
+
const fresh = !isMaterialized(inst.dir);
|
|
175
|
+
materialize(inst.dir);
|
|
176
|
+
// NB: after resolveInstance on purpose — the wizard's `flags.name` is the workspace
|
|
177
|
+
// DISPLAY name for the link ceremony; it must not retarget the instance directory.
|
|
178
|
+
if (wizardApplies(flags, fresh)) await firstRunWizard(flags);
|
|
179
|
+
|
|
180
|
+
if (flags.port) setEnv(inst.dir, "ALEXANDR_KERNEL_PORT", String(flags.port));
|
|
181
|
+
if (flags.domain) setEnv(inst.dir, "ALEXANDR_DOMAIN", String(flags.domain));
|
|
182
|
+
|
|
183
|
+
// Sign-in comes FIRST (account-required-runtimes D3): every runtime must be linked to an
|
|
184
|
+
// account before it serves anyone — an unlinked box boots into a static refusal page. The
|
|
185
|
+
// ceremony is a no-op when the .env already carries the credential trio, so re-running
|
|
186
|
+
// `alexandr up` never re-prompts.
|
|
187
|
+
await ensureLinked(inst, flags);
|
|
188
|
+
const hasDomain = envHas(inst.dir, "ALEXANDR_DOMAIN");
|
|
189
|
+
|
|
190
|
+
// Port pre-flight (clear message instead of an opaque Docker bind error).
|
|
191
|
+
const port = kernelPort(inst.dir);
|
|
192
|
+
if (!isRunning(inst.dir, inst.projectName) && !(await isPortFree(port))) {
|
|
193
|
+
fail(`Port ${port} is already in use. Re-run with --port <n> (e.g. \`alexandr up --port 3040\`).`, EXIT.PORT_BUSY);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const offline = Boolean(flags.offline || flags["no-pull"]);
|
|
197
|
+
const args = [];
|
|
198
|
+
if (hasDomain) args.push("--profile", "public");
|
|
199
|
+
args.push("up", "-d", "--pull", offline ? "never" : "missing");
|
|
200
|
+
|
|
201
|
+
step(`Starting alexandr (${inst.mode} · ${inst.dir})…`);
|
|
202
|
+
const r = compose(inst.dir, inst.projectName, args);
|
|
203
|
+
if (r.status !== 0) fail("`docker compose up` failed — see the output above. Try `alexandr doctor`.", EXIT.RUNTIME);
|
|
204
|
+
|
|
205
|
+
const base = kernelUrl(port);
|
|
206
|
+
step("Waiting for the kernel to come up…");
|
|
207
|
+
let h = await waitHealthy(base);
|
|
208
|
+
if (h?.auth?.posture === "unlinked") {
|
|
209
|
+
// The credential trio in .env is PRESENT but DEAD (the workspace was removed from the
|
|
210
|
+
// account — the CP deleted this instance's record), so ensureLinked sailed through and
|
|
211
|
+
// the box booted straight into the kernel's refusal page. Recover in place: fresh
|
|
212
|
+
// sign-in, fresh trio, restart, and verify the box actually serves before celebrating.
|
|
213
|
+
warn(
|
|
214
|
+
h.auth.postureReason === "revoked"
|
|
215
|
+
? "This runtime's account link was revoked — signing you in to re-link…"
|
|
216
|
+
: "This runtime came up unlinked — signing you in to link it…",
|
|
217
|
+
);
|
|
218
|
+
await runLinkCeremony(inst, flags);
|
|
219
|
+
step("Restarting the runtime with the fresh credentials…");
|
|
220
|
+
const restartArgs = hasDomain ? ["--profile", "public", "up", "-d"] : ["up", "-d"];
|
|
221
|
+
if (compose(inst.dir, inst.projectName, restartArgs).status !== 0) {
|
|
222
|
+
fail("`docker compose up` failed after re-linking — see the output above.", EXIT.RUNTIME);
|
|
223
|
+
}
|
|
224
|
+
h = await waitPosture(base, "cp");
|
|
225
|
+
if (!h) warn("Re-linked, but the runtime didn't come back serving in time. Check `alexandr logs`.");
|
|
226
|
+
} else if (!h) {
|
|
227
|
+
warn("Started, but the kernel didn't report healthy in time. Check `alexandr logs`.");
|
|
228
|
+
}
|
|
229
|
+
if (h) ok(`alexandr is running — ${bold(clientUrl(inst.dir))} (kernel v${h.version ?? "?"})`);
|
|
230
|
+
printConnect(inst.dir);
|
|
231
|
+
if (flags.open) openURL(clientUrl(inst.dir));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ---------------------------------------------------------------- down
|
|
235
|
+
export async function down(flags) {
|
|
236
|
+
ensureDocker();
|
|
237
|
+
const inst = resolveInstance(flags);
|
|
238
|
+
if (!isMaterialized(inst.dir)) fail("No alexandr instance here. Run `alexandr up` first.", EXIT.NO_INSTANCE);
|
|
239
|
+
step("Stopping alexandr (data preserved)…");
|
|
240
|
+
const r = compose(inst.dir, inst.projectName, ["down"]);
|
|
241
|
+
if (r.status === 0) ok("Stopped. Your data volume is untouched — `alexandr up` to resume.");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---------------------------------------------------------------- destroy
|
|
245
|
+
export async function destroy(flags) {
|
|
246
|
+
ensureDocker();
|
|
247
|
+
const inst = resolveInstance(flags);
|
|
248
|
+
if (!isMaterialized(inst.dir)) fail("No alexandr instance here.", EXIT.NO_INSTANCE);
|
|
249
|
+
const wipe = Boolean(flags.volumes || flags.data);
|
|
250
|
+
if (wipe && !confirmed(flags)) {
|
|
251
|
+
fail("`destroy --volumes` deletes /data (workspace DB, apps, blobs) irreversibly. Re-run with --yes to confirm.", EXIT.CONFIRMATION);
|
|
252
|
+
}
|
|
253
|
+
// Optional account-side cleanup: sign in and remove the workspace record too, so the hub
|
|
254
|
+
// doesn't keep a phantom entry. Off by default — destroying the containers never needs it.
|
|
255
|
+
if (flags.unlink && isLinked(inst.dir)) {
|
|
256
|
+
if (await unlinkFromAccount(inst, flags)) ok("Removed from your account.");
|
|
257
|
+
else warn("Couldn't remove it from your account — remove it from your account page instead.");
|
|
258
|
+
}
|
|
259
|
+
step(wipe ? "Removing alexandr AND its data volume…" : "Removing alexandr containers (data preserved)…");
|
|
260
|
+
const r = compose(inst.dir, inst.projectName, wipe ? ["down", "-v"] : ["down"]);
|
|
261
|
+
if (r.status === 0) ok(wipe ? "Destroyed, including /data." : "Containers removed; /data volume kept.");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ---------------------------------------------------------------- status
|
|
265
|
+
export async function status(flags) {
|
|
266
|
+
ensureDocker();
|
|
267
|
+
const inst = resolveInstance(flags);
|
|
268
|
+
if (!isMaterialized(inst.dir)) {
|
|
269
|
+
if (flags.json) log(JSON.stringify({ exists: false }));
|
|
270
|
+
else log("No alexandr instance here. Run `alexandr up`.");
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const running = isRunning(inst.dir, inst.projectName);
|
|
274
|
+
const base = kernelUrl(kernelPort(inst.dir));
|
|
275
|
+
const v = running ? await kVersion(base) : null;
|
|
276
|
+
const h = running ? await health(base) : null;
|
|
277
|
+
const vol = `${inst.projectName}_data`;
|
|
278
|
+
// Cheap existence check by default; exact size only with --size (spawns a container).
|
|
279
|
+
const volExists = dataVolumeExists(vol);
|
|
280
|
+
const size = flags.size && volExists ? volumeSize(vol) : volExists ? "present" : "none";
|
|
281
|
+
|
|
282
|
+
if (flags.json) {
|
|
283
|
+
log(JSON.stringify({
|
|
284
|
+
exists: true, mode: inst.mode, dir: inst.dir, project: inst.projectName,
|
|
285
|
+
running, url: clientUrl(inst.dir), version: v?.version ?? null,
|
|
286
|
+
linked: isLinked(inst.dir), posture: h?.auth?.posture ?? null,
|
|
287
|
+
authActive: h?.auth?.active ?? null, dataVolume: vol, dataSize: size,
|
|
288
|
+
}, null, 2));
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
log(`${bold("alexandr")} (${inst.mode})`);
|
|
292
|
+
log(` state ${running ? green("running") : dim("stopped")}`);
|
|
293
|
+
log(` url ${clientUrl(inst.dir)}`);
|
|
294
|
+
log(` version ${v?.version ?? dim("—")}`);
|
|
295
|
+
if (h?.auth?.posture === "unlinked") {
|
|
296
|
+
warn(` account NOT LINKED${h.auth.postureReason === "revoked" ? " (revoked)" : ""} — the runtime refuses to serve. Run \`alexandr link\`.`);
|
|
297
|
+
} else {
|
|
298
|
+
log(` account ${isLinked(inst.dir) ? "linked" : dim("not linked — run `alexandr link`")}`);
|
|
299
|
+
}
|
|
300
|
+
log(` data ${vol} ${dim(`(${size})`)}`);
|
|
301
|
+
log(` config ${inst.dir}`);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function volumeSize(vol) {
|
|
305
|
+
const r = spawnSync(
|
|
306
|
+
"docker",
|
|
307
|
+
["run", "--rm", "-v", `${vol}:/v`, "alpine", "du", "-sh", "/v"],
|
|
308
|
+
{ encoding: "utf8", timeout: 8000 },
|
|
309
|
+
);
|
|
310
|
+
if (r.status !== 0 || !r.stdout) return "—";
|
|
311
|
+
return r.stdout.trim().split(/\s+/)[0] || "—";
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ---------------------------------------------------------------- ls
|
|
315
|
+
export async function ls(flags) {
|
|
316
|
+
ensureDocker();
|
|
317
|
+
const r = exec("docker", ["compose", "ls", "-a", "--format", "json"]);
|
|
318
|
+
let projects = [];
|
|
319
|
+
try {
|
|
320
|
+
const parsed = JSON.parse(r.stdout || "[]");
|
|
321
|
+
projects = (Array.isArray(parsed) ? parsed : []).filter((p) =>
|
|
322
|
+
(p.Name || "").startsWith("alexandr"),
|
|
323
|
+
);
|
|
324
|
+
} catch {
|
|
325
|
+
/* ignore parse issues */
|
|
326
|
+
}
|
|
327
|
+
if (flags.json) {
|
|
328
|
+
log(JSON.stringify(projects, null, 2));
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (!projects.length) {
|
|
332
|
+
log("No alexandr instances found. Run `alexandr up` to create one.");
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
log(bold("INSTANCE STATE"));
|
|
336
|
+
for (const p of projects) log(`${(p.Name || "").padEnd(15)} ${p.Status || ""}`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ---------------------------------------------------------------- logs
|
|
340
|
+
export async function logs(flags) {
|
|
341
|
+
ensureDocker();
|
|
342
|
+
const inst = resolveInstance(flags);
|
|
343
|
+
if (!isMaterialized(inst.dir)) fail("No alexandr instance here.", EXIT.NO_INSTANCE);
|
|
344
|
+
const args = ["logs", "kernel"];
|
|
345
|
+
if (flags.f || flags.follow) args.splice(1, 0, "-f");
|
|
346
|
+
compose(inst.dir, inst.projectName, args);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ---------------------------------------------------------------- config
|
|
350
|
+
export async function config(flags) {
|
|
351
|
+
const [sub, key, value] = flags._;
|
|
352
|
+
const inst = resolveInstance(flags);
|
|
353
|
+
materialize(inst.dir);
|
|
354
|
+
|
|
355
|
+
if (sub === "list" || !sub) {
|
|
356
|
+
const env = readEnv(inst.dir);
|
|
357
|
+
log(bold(`config — ${inst.dir}/.env`));
|
|
358
|
+
const keys = Object.keys(env);
|
|
359
|
+
if (!keys.length) log(dim(" (nothing set — all defaults)"));
|
|
360
|
+
for (const k of keys) log(` ${k}=${SECRET_KEY.test(k) ? dim("••• (hidden)") : env[k]}`);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (sub === "get") {
|
|
364
|
+
if (!key) fail("usage: alexandr config get <key>", EXIT.USAGE);
|
|
365
|
+
const ek = toEnvKey(key);
|
|
366
|
+
if (!ek) fail(`Unknown config key '${key}'.`, EXIT.USAGE);
|
|
367
|
+
log(readEnv(inst.dir)[ek] ?? "");
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (sub === "set") {
|
|
371
|
+
if (!key || value === undefined) fail("usage: alexandr config set <key> <value>", EXIT.USAGE);
|
|
372
|
+
const ek = toEnvKey(key);
|
|
373
|
+
if (!ek) fail(`Unknown config key '${key}'. Try one of: ${Object.keys(CONFIG_ALIASES).join(", ")}, or a raw ALEXANDR_* var.`, EXIT.USAGE);
|
|
374
|
+
setEnv(inst.dir, ek, String(value));
|
|
375
|
+
ok(`set ${ek}`);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (sub === "unset") {
|
|
379
|
+
if (!key) fail("usage: alexandr config unset <key>", EXIT.USAGE);
|
|
380
|
+
const ek = toEnvKey(key);
|
|
381
|
+
if (!ek) fail(`Unknown config key '${key}'.`, EXIT.USAGE);
|
|
382
|
+
unsetEnv(inst.dir, ek);
|
|
383
|
+
ok(`unset ${ek}`);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
fail(`Unknown config subcommand '${sub}'. Use get | set | list | unset.`, EXIT.USAGE);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ---------------------------------------------------------------- init
|
|
390
|
+
export async function init(flags) {
|
|
391
|
+
const dir = path.resolve(String(flags.dir || "alexandr"));
|
|
392
|
+
if (isMaterialized(dir) && !flags.force) {
|
|
393
|
+
fail(`${dir} already has an alexandr config. Re-run with --force to overwrite.`, EXIT.USAGE);
|
|
394
|
+
}
|
|
395
|
+
materialize(dir);
|
|
396
|
+
// Keep the real .env (secrets) out of git; commit the compose + an example.
|
|
397
|
+
const tpl = path.resolve(fileURLToPath(import.meta.url), "..", "..", "templates", "env.example");
|
|
398
|
+
fs.copyFileSync(tpl, path.join(dir, ".env.example"));
|
|
399
|
+
appendGitignore(dir);
|
|
400
|
+
ok(`Initialized ${dir}`);
|
|
401
|
+
log(dim(" committed: docker-compose.yml · Caddyfile · .env.example"));
|
|
402
|
+
log(dim(" gitignored: .env (created on first `alexandr up`)"));
|
|
403
|
+
log("");
|
|
404
|
+
log(`Next: ${bold("alexandr up")}`);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function appendGitignore(dir) {
|
|
408
|
+
// Find the nearest .git ancestor. If there isn't one, keep the ignore LOCAL to
|
|
409
|
+
// `dir` (a self-contained dir/.gitignore) rather than scribbling on some
|
|
410
|
+
// unrelated parent or the filesystem root.
|
|
411
|
+
let gitRoot = null;
|
|
412
|
+
for (let d = path.dirname(dir); ; d = path.dirname(d)) {
|
|
413
|
+
if (fs.existsSync(path.join(d, ".git"))) { gitRoot = d; break; }
|
|
414
|
+
if (path.dirname(d) === d) break;
|
|
415
|
+
}
|
|
416
|
+
const root = gitRoot || dir;
|
|
417
|
+
const gi = path.join(root, ".gitignore");
|
|
418
|
+
const rel = gitRoot ? path.relative(gitRoot, path.join(dir, ".env")) : ".env";
|
|
419
|
+
const existing = fs.existsSync(gi) ? fs.readFileSync(gi, "utf8") : "";
|
|
420
|
+
if (!existing.split("\n").includes(rel)) {
|
|
421
|
+
fs.writeFileSync(gi, `${existing}${existing && !existing.endsWith("\n") ? "\n" : ""}${rel}\n`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ---------------------------------------------------------------- update
|
|
426
|
+
export async function update(flags) {
|
|
427
|
+
ensureDocker();
|
|
428
|
+
const inst = resolveInstance(flags);
|
|
429
|
+
if (!isMaterialized(inst.dir)) fail("No alexandr instance here. Run `alexandr up` first.", EXIT.NO_INSTANCE);
|
|
430
|
+
|
|
431
|
+
// Legacy migration (account-required-runtimes D8): a pre-link-first box (old static-token
|
|
432
|
+
// .env, or account-free) would update straight onto a kernel that refuses to serve — link it
|
|
433
|
+
// FIRST so the updated box comes up serving.
|
|
434
|
+
if (!isLinked(inst.dir)) {
|
|
435
|
+
step("This runtime predates account-required runtimes — the updated kernel refuses to serve unlinked.");
|
|
436
|
+
await ensureLinked(inst, flags);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const prevImage = readEnv(inst.dir).ALEXANDR_IMAGE || `${IMAGE_BASE}:latest`;
|
|
440
|
+
const base = kernelUrl(kernelPort(inst.dir));
|
|
441
|
+
const before = (await kVersion(base))?.version ?? "?";
|
|
442
|
+
|
|
443
|
+
// Auto-snapshot /data BEFORE touching the image, so the update is reversible by
|
|
444
|
+
// construction (a new kernel may run forward-only migrations a rolled-back image
|
|
445
|
+
// can't read). Skipped with --no-backup, or when there's no data volume yet.
|
|
446
|
+
const vol = `${inst.projectName}_data`;
|
|
447
|
+
if (!flags["no-backup"] && dataVolumeExists(vol)) {
|
|
448
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
449
|
+
const tag = before && before !== "?" ? `v${before}` : "current";
|
|
450
|
+
const out = path.join(inst.dir, "backups", `pre-update-${tag}-${stamp}.tgz`);
|
|
451
|
+
step(`Snapshotting /data before updating → ${out}…`);
|
|
452
|
+
if (!archiveVolume(vol, out)) {
|
|
453
|
+
fail("Pre-update backup failed — not updating. Fix the issue, or re-run with --no-backup to skip.", EXIT.RUNTIME);
|
|
454
|
+
}
|
|
455
|
+
ok(`Snapshot saved: ${out}`);
|
|
456
|
+
log(dim(` Roll back data if needed: alexandr restore "${out}" --yes`));
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (flags.rollback) {
|
|
460
|
+
const ptr = path.join(inst.dir, ".previous-image");
|
|
461
|
+
if (!fs.existsSync(ptr)) fail("No previous image recorded to roll back to.", EXIT.USAGE);
|
|
462
|
+
const target = fs.readFileSync(ptr, "utf8").trim();
|
|
463
|
+
fs.writeFileSync(ptr, prevImage); // swap, so a second rollback returns here
|
|
464
|
+
setEnv(inst.dir, "ALEXANDR_IMAGE", target);
|
|
465
|
+
} else if (flags.to) {
|
|
466
|
+
fs.writeFileSync(path.join(inst.dir, ".previous-image"), prevImage);
|
|
467
|
+
setEnv(inst.dir, "ALEXANDR_IMAGE", `${IMAGE_BASE}:${flags.to}`);
|
|
468
|
+
} else {
|
|
469
|
+
fs.writeFileSync(path.join(inst.dir, ".previous-image"), prevImage);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// `--offline` / `--no-pull` mirrors `up`: skip the registry pull and boot the
|
|
473
|
+
// locally-present image. For airgapped hosts and the dev loop (self-host boxes run
|
|
474
|
+
// source-built local tags that no registry serves — pulling them always fails).
|
|
475
|
+
const offline = Boolean(flags.offline || flags["no-pull"]);
|
|
476
|
+
if (offline) {
|
|
477
|
+
step("Offline update — using the locally-present image (no pull).");
|
|
478
|
+
} else {
|
|
479
|
+
step("Pulling the runtime image…");
|
|
480
|
+
if (compose(inst.dir, inst.projectName, ["pull", "kernel"]).status !== 0) {
|
|
481
|
+
fail("Pull failed — check your network / the image tag.", EXIT.RUNTIME);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
const args = [];
|
|
485
|
+
if (envHas(inst.dir, "ALEXANDR_DOMAIN")) args.push("--profile", "public");
|
|
486
|
+
args.push("up", "-d", "--pull", offline ? "never" : "missing");
|
|
487
|
+
step("Applying the update (data preserved)…");
|
|
488
|
+
if (compose(inst.dir, inst.projectName, args).status !== 0) fail("Update failed — see output above.", EXIT.RUNTIME);
|
|
489
|
+
|
|
490
|
+
const after = (await waitHealthy(base))?.version ?? "?";
|
|
491
|
+
ok(`Updated: ${before} → ${after}`);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ---------------------------------------------------------------- connect
|
|
495
|
+
export async function connect(flags) {
|
|
496
|
+
const inst = resolveInstance(flags);
|
|
497
|
+
if (!isMaterialized(inst.dir)) fail("No alexandr instance here. Run `alexandr up` first.", EXIT.NO_INSTANCE);
|
|
498
|
+
printConnect(inst.dir);
|
|
499
|
+
if (flags.open) openURL(clientUrl(inst.dir));
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// ---------------------------------------------------------------- backup / restore
|
|
503
|
+
export async function backup(flags) {
|
|
504
|
+
ensureDocker();
|
|
505
|
+
const inst = resolveInstance(flags);
|
|
506
|
+
if (!isMaterialized(inst.dir)) fail("No alexandr instance here.", EXIT.NO_INSTANCE);
|
|
507
|
+
const vol = `${inst.projectName}_data`;
|
|
508
|
+
if (!dataVolumeExists(vol)) fail("No data volume to back up yet — run `alexandr up` first.", EXIT.NO_INSTANCE);
|
|
509
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
510
|
+
// Stamp the kernel version into the default name so a folder of archives is
|
|
511
|
+
// self-describing (kernel migrations are forward-only — restoring an archive into
|
|
512
|
+
// an OLDER kernel can break, so knowing each archive's version matters). An explicit
|
|
513
|
+
// --out wins; if the kernel isn't running the version is simply omitted.
|
|
514
|
+
const v = (await kVersion(kernelUrl(kernelPort(inst.dir))))?.version;
|
|
515
|
+
const tag = v ? `v${v}-` : "";
|
|
516
|
+
const out = path.resolve(String(flags.out || `alexandr-backup-${tag}${stamp}.tgz`));
|
|
517
|
+
step(`Backing up ${vol} → ${out}…`);
|
|
518
|
+
if (archiveVolume(vol, out)) ok(`Backup written: ${out}`);
|
|
519
|
+
else fail("Backup failed.", EXIT.RUNTIME);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
export async function restore(flags) {
|
|
523
|
+
ensureDocker();
|
|
524
|
+
const inst = resolveInstance(flags);
|
|
525
|
+
if (!isMaterialized(inst.dir)) fail("No alexandr instance here.", EXIT.NO_INSTANCE);
|
|
526
|
+
const file = flags._[0] && path.resolve(flags._[0]);
|
|
527
|
+
if (!file || !fs.existsSync(file)) fail("usage: alexandr restore <backup.tgz>", EXIT.USAGE);
|
|
528
|
+
if (!confirmed(flags)) fail(`Restore OVERWRITES /data with ${file}. Re-run with --yes to confirm.`, EXIT.CONFIRMATION);
|
|
529
|
+
const vol = `${inst.projectName}_data`;
|
|
530
|
+
step(`Restoring ${file} → ${vol}…`);
|
|
531
|
+
// Pass the filename as a positional ($1), never interpolated into the script —
|
|
532
|
+
// immune to spaces and shell metacharacters in the backup filename.
|
|
533
|
+
const r = spawnSync(
|
|
534
|
+
"docker",
|
|
535
|
+
["run", "--rm", "-v", `${vol}:/data`, "-v", `${hostPath(path.dirname(file))}:/backup`, "alpine",
|
|
536
|
+
"sh", "-c", 'rm -rf /data/* /data/..?* 2>/dev/null; tar xzf "/backup/$1" -C /data', "--", path.basename(file)],
|
|
537
|
+
{ stdio: "inherit" },
|
|
538
|
+
);
|
|
539
|
+
if (r.status === 0) ok("Restored. Run `alexandr up` to (re)start.");
|
|
540
|
+
else fail("Restore failed.", EXIT.RUNTIME);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// ---------------------------------------------------------------- doctor
|
|
544
|
+
export async function doctor() {
|
|
545
|
+
log(bold("alexandr doctor\n"));
|
|
546
|
+
const problems = dockerProblems();
|
|
547
|
+
if (!problems.length) ok("Docker + Compose v2 ready");
|
|
548
|
+
else {
|
|
549
|
+
for (const p of problems) warn(p.message);
|
|
550
|
+
if (process.platform === "linux") log(dim(" • `alexandr up` can install these for you (Docker's official script, root/sudo)."));
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Platform note for the classic WSL daemon-location footgun (fires on native
|
|
554
|
+
// Windows AND inside a WSL distro, where process.platform is "linux").
|
|
555
|
+
if (process.platform === "win32" || process.env.WSL_DISTRO_NAME) {
|
|
556
|
+
log(dim(" • Windows/WSL: run inside WSL2 with Docker Desktop's WSL integration on, so the daemon is reachable from this shell."));
|
|
557
|
+
}
|
|
558
|
+
// Is the default instance present / on a free port?
|
|
559
|
+
const inst = resolveInstance({});
|
|
560
|
+
if (isMaterialized(inst.dir)) {
|
|
561
|
+
const port = kernelPort(inst.dir);
|
|
562
|
+
const free = await isPortFree(port);
|
|
563
|
+
const running = problems.length ? false : isRunning(inst.dir, inst.projectName);
|
|
564
|
+
if (running) ok(`Instance running on :${port}`);
|
|
565
|
+
else if (free) ok(`Instance configured (:${port} free)`);
|
|
566
|
+
else warn(`Port ${port} is in use but the instance isn't running — stop the other process or set --port.`);
|
|
567
|
+
if (isLinked(inst.dir)) ok("Linked to an alexandr account");
|
|
568
|
+
else warn("Not linked to an account — the runtime refuses to serve until it is (run `alexandr up` or `alexandr link`).");
|
|
569
|
+
if (envHas(inst.dir, "ALEXANDR_AUTH_TOKEN")) {
|
|
570
|
+
warn("ALEXANDR_AUTH_TOKEN is set but RETIRED — it no longer opens anything; remove it (`alexandr config unset ALEXANDR_AUTH_TOKEN`).");
|
|
571
|
+
}
|
|
572
|
+
} else {
|
|
573
|
+
log(dim(" • No instance yet — run `alexandr up`."));
|
|
574
|
+
}
|
|
575
|
+
if (problems.length) process.exit(problems[0].exit);
|
|
576
|
+
}
|