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
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// The canonical command surface (single source of truth for both `--help` and
|
|
2
|
+
// shell completion) plus the completion-script generators for bash · zsh · fish.
|
|
3
|
+
//
|
|
4
|
+
// `cli.js` builds its help listing and dispatch from COMMANDS; this module never
|
|
5
|
+
// imports cli.js, so there's no import cycle.
|
|
6
|
+
|
|
7
|
+
import { fail } from "./util.js";
|
|
8
|
+
import { EXIT } from "./exit.js";
|
|
9
|
+
|
|
10
|
+
// One row per command: { name, usage, summary }. `usage` is the display form for
|
|
11
|
+
// help (may carry an arg hint like "logs [-f]"); `name` is the bare verb used for
|
|
12
|
+
// completion. Order is the help display order.
|
|
13
|
+
export const COMMANDS = [
|
|
14
|
+
{ name: "up", usage: "up", summary: "Sign in (first run), pull + boot the runtime; print the URL" },
|
|
15
|
+
{ name: "down", usage: "down", summary: "Stop the runtime (data preserved)" },
|
|
16
|
+
{ name: "status", usage: "status", summary: "Show state, version, URL, and data size" },
|
|
17
|
+
{ name: "ls", usage: "ls", summary: "List all alexandr instances" },
|
|
18
|
+
{ name: "logs", usage: "logs [-f]", summary: "Show kernel logs (-f to follow)" },
|
|
19
|
+
{ name: "connect", usage: "connect", summary: "Print the desktop-app connect link / paste-string" },
|
|
20
|
+
{ name: "link", usage: "link", summary: "Re-link this runtime to your alexandr account (--force re-registers)" },
|
|
21
|
+
{ name: "update", usage: "update", summary: "Update the runtime image (--to <tag>, --rollback)" },
|
|
22
|
+
{ name: "backup", usage: "backup", summary: "Archive the data volume (--out <file>)" },
|
|
23
|
+
{ name: "restore", usage: "restore <f>", summary: "Restore a data-volume archive (--yes)" },
|
|
24
|
+
{ name: "config", usage: "config", summary: "get | set | list | unset (e.g. alexandr config set ai.url …)" },
|
|
25
|
+
{ name: "init", usage: "init", summary: "Write a committable ./alexandr config folder" },
|
|
26
|
+
{ name: "destroy", usage: "destroy", summary: "Remove containers (--volumes also wipes /data)" },
|
|
27
|
+
{ name: "completion", usage: "completion", summary: "Print a shell completion script (bash|zsh|fish)" },
|
|
28
|
+
{ name: "doctor", usage: "doctor", summary: "Check Docker, ports, and configuration" },
|
|
29
|
+
{ name: "version", usage: "version", summary: "Show CLI + runtime versions" },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
// Flags offered everywhere (mirrors the COMMON OPTIONS block in cli.js help).
|
|
33
|
+
const GLOBAL_FLAGS = ["--port", "--domain", "--name", "--dir", "--open", "--offline", "--yes", "--json", "--help"];
|
|
34
|
+
const SHELLS = ["bash", "zsh", "fish"];
|
|
35
|
+
const NAMES = COMMANDS.map((c) => c.name);
|
|
36
|
+
|
|
37
|
+
// Sub-surfaces worth completing one level deep.
|
|
38
|
+
const CONFIG_SUBS = ["get", "set", "list", "unset"];
|
|
39
|
+
|
|
40
|
+
// ---- generators -------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
function bashScript() {
|
|
43
|
+
return `# alexandr completion (bash)
|
|
44
|
+
# Load it for this shell: source <(alexandr completion bash)
|
|
45
|
+
# Make it permanent: echo 'source <(alexandr completion bash)' >> ~/.bashrc
|
|
46
|
+
_alexandr() {
|
|
47
|
+
local cur prev cmd
|
|
48
|
+
cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
49
|
+
if [ "\$COMP_CWORD" -eq 1 ]; then
|
|
50
|
+
COMPREPLY=( \$(compgen -W "${NAMES.join(" ")}" -- "\$cur") )
|
|
51
|
+
return
|
|
52
|
+
fi
|
|
53
|
+
cmd="\${COMP_WORDS[1]}"
|
|
54
|
+
case "\$cmd" in
|
|
55
|
+
completion) COMPREPLY=( \$(compgen -W "${SHELLS.join(" ")}" -- "\$cur") ); return ;;
|
|
56
|
+
config) if [ "\$COMP_CWORD" -eq 2 ]; then COMPREPLY=( \$(compgen -W "${CONFIG_SUBS.join(" ")}" -- "\$cur") ); return; fi ;;
|
|
57
|
+
esac
|
|
58
|
+
if [[ "\$cur" == -* ]]; then
|
|
59
|
+
COMPREPLY=( \$(compgen -W "${GLOBAL_FLAGS.join(" ")}" -- "\$cur") )
|
|
60
|
+
fi
|
|
61
|
+
}
|
|
62
|
+
complete -F _alexandr alexandr
|
|
63
|
+
`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function zshScript() {
|
|
67
|
+
// zsh autoload body form: save as `_alexandr` on your $fpath. The describe
|
|
68
|
+
// entries are `name:description`; our summaries contain no leading colon, so the
|
|
69
|
+
// first colon is always the separator.
|
|
70
|
+
const describe = COMMANDS.map((c) => ` '${c.name}:${c.summary.replace(/'/g, "''")}'`).join("\n");
|
|
71
|
+
const flags = GLOBAL_FLAGS.map((f) => ` '${f}'`).join("\n");
|
|
72
|
+
return `#compdef alexandr
|
|
73
|
+
# alexandr completion (zsh)
|
|
74
|
+
# Install on your $fpath, then restart zsh, e.g.:
|
|
75
|
+
# alexandr completion zsh | sudo tee /usr/local/share/zsh/site-functions/_alexandr
|
|
76
|
+
local -a _alexandr_cmds _alexandr_flags
|
|
77
|
+
_alexandr_cmds=(
|
|
78
|
+
${describe}
|
|
79
|
+
)
|
|
80
|
+
_alexandr_flags=(
|
|
81
|
+
${flags}
|
|
82
|
+
)
|
|
83
|
+
if (( CURRENT == 2 )); then
|
|
84
|
+
_describe -t commands 'alexandr command' _alexandr_cmds
|
|
85
|
+
return
|
|
86
|
+
fi
|
|
87
|
+
case "\${words[2]}" in
|
|
88
|
+
completion) _values 'shell' ${SHELLS.join(" ")}; return ;;
|
|
89
|
+
config) (( CURRENT == 3 )) && { _values 'subcommand' ${CONFIG_SUBS.join(" ")}; return } ;;
|
|
90
|
+
esac
|
|
91
|
+
_describe -t options 'option' _alexandr_flags
|
|
92
|
+
`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function fishScript() {
|
|
96
|
+
const lines = [
|
|
97
|
+
"# alexandr completion (fish)",
|
|
98
|
+
"# Install: alexandr completion fish > ~/.config/fish/completions/alexandr.fish",
|
|
99
|
+
"complete -c alexandr -f",
|
|
100
|
+
];
|
|
101
|
+
for (const c of COMMANDS) {
|
|
102
|
+
lines.push(`complete -c alexandr -n __fish_use_subcommand -a ${c.name} -d '${c.summary.replace(/'/g, "\\'")}'`);
|
|
103
|
+
}
|
|
104
|
+
lines.push("complete -c alexandr -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish'");
|
|
105
|
+
lines.push(`complete -c alexandr -n '__fish_seen_subcommand_from config' -a '${CONFIG_SUBS.join(" ")}'`);
|
|
106
|
+
for (const f of GLOBAL_FLAGS) {
|
|
107
|
+
if (f.startsWith("--")) lines.push(`complete -c alexandr -l ${f.slice(2)}`);
|
|
108
|
+
}
|
|
109
|
+
return `${lines.join("\n")}\n`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const GENERATORS = { bash: bashScript, zsh: zshScript, fish: fishScript };
|
|
113
|
+
|
|
114
|
+
// ---- command ----------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
export async function completion(flags) {
|
|
117
|
+
const shell = flags._[0];
|
|
118
|
+
if (!shell) fail(`usage: alexandr completion <${SHELLS.join("|")}>`, EXIT.USAGE);
|
|
119
|
+
const gen = GENERATORS[shell];
|
|
120
|
+
if (!gen) fail(`Unsupported shell '${shell}'. Supported: ${SHELLS.join(", ")}.`, EXIT.USAGE);
|
|
121
|
+
process.stdout.write(gen());
|
|
122
|
+
}
|
package/src/connect.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Build the connection handoff artifacts the desktop app consumes (see
|
|
2
|
+
// docs/plans/self-hosted-connection.md §3): a clickable deep link and a
|
|
3
|
+
// paste-string. The CLI only PRINTS these — the app owns decoding them.
|
|
4
|
+
// URL-only (account-required-runtimes D5): the box is CP-linked, so the app
|
|
5
|
+
// verifies membership against the account — no token rides the handoff.
|
|
6
|
+
|
|
7
|
+
export function buildConnect({ url }) {
|
|
8
|
+
const deepLink = `alexandr://connect?${new URLSearchParams({ url }).toString()}`;
|
|
9
|
+
const pasteString = `alexandr-connect_${Buffer.from(url, "utf8").toString("base64url")}`;
|
|
10
|
+
return { deepLink, pasteString };
|
|
11
|
+
}
|
package/src/deps.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Best-effort dependency installer: when `alexandr up` finds Docker/Compose missing on a
|
|
2
|
+
// LINUX box (the self-host case), it offers to install them right there — Docker's official
|
|
3
|
+
// convenience script (get.docker.com) + systemd start — instead of failing with a doc link.
|
|
4
|
+
// Interactive-only (a real TTY, an explicit yes), root or sudo. macOS/Windows stay
|
|
5
|
+
// guidance-only: Docker Desktop can't be installed silently, and the product's Mac story
|
|
6
|
+
// ("On this Mac") uses the desktop app's own VM, never Docker.
|
|
7
|
+
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import { log, ok, warn, step, dim, bold } from "./util.js";
|
|
10
|
+
import { select } from "./prompt.js";
|
|
11
|
+
import { exec } from "./docker.js";
|
|
12
|
+
|
|
13
|
+
const GET_DOCKER = "curl -fsSL https://get.docker.com | sh";
|
|
14
|
+
|
|
15
|
+
/** What this host looks like for fixing purposes. Split from the planner so the plan
|
|
16
|
+
* logic is a pure function (unit-testable without a Linux box). */
|
|
17
|
+
export function detectHost() {
|
|
18
|
+
return {
|
|
19
|
+
platform: process.platform,
|
|
20
|
+
isRoot: typeof process.getuid === "function" && process.getuid() === 0,
|
|
21
|
+
hasSystemctl: exec("systemctl", ["--version"]).status === 0,
|
|
22
|
+
hasApt: exec("apt-get", ["--version"]).status === 0,
|
|
23
|
+
hasSudo: exec("sudo", ["--version"]).status === 0,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The fix plan for a set of dockerProblems() on a given host — an ordered list of
|
|
29
|
+
* `{ title, cmd }` shell steps, or null when there's nothing we can responsibly run
|
|
30
|
+
* (non-Linux, or a daemon we can't start without systemd). Pure — no side effects.
|
|
31
|
+
*/
|
|
32
|
+
export function planDependencyFix(problems, host) {
|
|
33
|
+
if (host.platform !== "linux") return null;
|
|
34
|
+
const kinds = new Set(problems.map((p) => p.kind));
|
|
35
|
+
const steps = [];
|
|
36
|
+
if (kinds.has("docker-missing")) {
|
|
37
|
+
// The official script installs Engine + the Compose v2 plugin on every major distro,
|
|
38
|
+
// so one step covers the compose gap too.
|
|
39
|
+
steps.push({ title: "Install Docker Engine + Compose v2 (Docker's official get.docker.com script)", cmd: GET_DOCKER });
|
|
40
|
+
if (host.hasSystemctl) steps.push({ title: "Start the Docker service (now + on boot)", cmd: "systemctl enable --now docker" });
|
|
41
|
+
return steps;
|
|
42
|
+
}
|
|
43
|
+
if (kinds.has("daemon-down")) {
|
|
44
|
+
if (!host.hasSystemctl) return null; // no systemd → we don't know how to start it here
|
|
45
|
+
steps.push({ title: "Start the Docker service (now + on boot)", cmd: "systemctl enable --now docker" });
|
|
46
|
+
}
|
|
47
|
+
if (kinds.has("compose-missing")) {
|
|
48
|
+
steps.push(
|
|
49
|
+
host.hasApt
|
|
50
|
+
? { title: "Install the Compose v2 plugin (apt)", cmd: "apt-get update -qq && apt-get install -y docker-compose-plugin" }
|
|
51
|
+
: { title: "Upgrade Docker to a version with Compose v2 (get.docker.com)", cmd: GET_DOCKER },
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return steps.length ? steps : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Offer to run the fix plan. Returns true when an install was ATTEMPTED (the caller
|
|
59
|
+
* should re-check dockerProblems()), false when declined/not applicable — the caller
|
|
60
|
+
* falls through to the normal failure messages either way.
|
|
61
|
+
*/
|
|
62
|
+
export async function offerDependencyInstall(problems) {
|
|
63
|
+
if (!(process.stdin.isTTY && process.stdout.isTTY)) return false;
|
|
64
|
+
const host = detectHost();
|
|
65
|
+
const steps = planDependencyFix(problems, host);
|
|
66
|
+
if (!steps) return false;
|
|
67
|
+
if (!host.isRoot && !host.hasSudo) {
|
|
68
|
+
warn("Dependencies are missing, but you're not root and sudo isn't available — install Docker manually, then re-run.");
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
const asRoot = (cmd) => (host.isRoot ? cmd : `sudo sh -c '${cmd}'`);
|
|
72
|
+
log(`${bold("alexandr")} ${dim("needs Docker + Compose v2 on this server. It can set that up now:")}`);
|
|
73
|
+
for (const s of steps) log(` ${dim("•")} ${s.title}\n ${dim(asRoot(s.cmd))}`);
|
|
74
|
+
const go = await select("Install the missing dependencies?", [
|
|
75
|
+
{ label: "Yes, install them", hint: host.isRoot ? "runs as root" : "runs via sudo — you may be asked for your password", value: true },
|
|
76
|
+
{ label: "No, I'll handle it myself", value: false },
|
|
77
|
+
]);
|
|
78
|
+
if (!go) return false;
|
|
79
|
+
for (const s of steps) {
|
|
80
|
+
step(`${s.title}…`);
|
|
81
|
+
const r = host.isRoot
|
|
82
|
+
? spawnSync("sh", ["-c", s.cmd], { stdio: "inherit" })
|
|
83
|
+
: spawnSync("sudo", ["sh", "-c", s.cmd], { stdio: "inherit" });
|
|
84
|
+
if ((r.status ?? 1) !== 0) {
|
|
85
|
+
warn(`That step failed (exit ${r.status ?? "?"}) — finish it manually, then re-run \`alexandr up\`.`);
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
ok("Dependencies installed.");
|
|
90
|
+
return true;
|
|
91
|
+
}
|
package/src/docker.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Thin shell over the Docker CLI. We never bundle a runtime — we orchestrate
|
|
2
|
+
// `docker compose` against the published kernel image. Pure Node child_process.
|
|
3
|
+
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
import { EXIT } from "./exit.js";
|
|
8
|
+
|
|
9
|
+
// Run a command, capturing output. Never throws on non-zero exit.
|
|
10
|
+
export function exec(cmd, args, opts = {}) {
|
|
11
|
+
const r = spawnSync(cmd, args, { encoding: "utf8", ...opts });
|
|
12
|
+
return {
|
|
13
|
+
status: r.error ? 127 : r.status ?? 1,
|
|
14
|
+
stdout: (r.stdout || "").trim(),
|
|
15
|
+
stderr: (r.stderr || "").trim(),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const hasDocker = () => exec("docker", ["--version"]).status === 0;
|
|
20
|
+
export const daemonUp = () => exec("docker", ["info"]).status === 0;
|
|
21
|
+
export const composeV2 = () => exec("docker", ["compose", "version"]).status === 0;
|
|
22
|
+
export const legacyComposeOnly = () =>
|
|
23
|
+
!composeV2() && exec("docker-compose", ["--version"]).status === 0;
|
|
24
|
+
|
|
25
|
+
// Base args binding a compose invocation to one instance dir + project name.
|
|
26
|
+
// --project-directory + an explicit -f make the relative ./Caddyfile and ./.env
|
|
27
|
+
// binds resolve correctly even when invoked from elsewhere; -p pins the project
|
|
28
|
+
// so npx-from-tmp and a global install land on the SAME named volumes.
|
|
29
|
+
function baseArgs(dir, projectName) {
|
|
30
|
+
return [
|
|
31
|
+
"compose",
|
|
32
|
+
"--project-directory",
|
|
33
|
+
dir,
|
|
34
|
+
"-f",
|
|
35
|
+
path.join(dir, "docker-compose.yml"), // path.join → native separators (Windows-safe)
|
|
36
|
+
"-p",
|
|
37
|
+
projectName,
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Run a compose subcommand, inheriting stdio by default (streams to the user).
|
|
42
|
+
export function compose(dir, projectName, args, opts = {}) {
|
|
43
|
+
const r = spawnSync("docker", [...baseArgs(dir, projectName), ...args], {
|
|
44
|
+
stdio: opts.stdio ?? "inherit",
|
|
45
|
+
encoding: "utf8",
|
|
46
|
+
env: { ...process.env, ...(opts.env || {}) },
|
|
47
|
+
});
|
|
48
|
+
return { status: r.error ? 127 : r.status ?? 1 };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Run a compose subcommand and capture its output.
|
|
52
|
+
export function composeCapture(dir, projectName, args, env = {}) {
|
|
53
|
+
const r = spawnSync("docker", [...baseArgs(dir, projectName), ...args], {
|
|
54
|
+
encoding: "utf8",
|
|
55
|
+
env: { ...process.env, ...env },
|
|
56
|
+
});
|
|
57
|
+
return {
|
|
58
|
+
status: r.error ? 127 : r.status ?? 1,
|
|
59
|
+
stdout: (r.stdout || "").trim(),
|
|
60
|
+
stderr: (r.stderr || "").trim(),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Pre-flight the three CLI dependencies. Returns a list of problems (empty = ok);
|
|
65
|
+
// each problem carries the exit code its failure class maps to (see exit.js), so
|
|
66
|
+
// callers can surface a specific code instead of a catch-all 1.
|
|
67
|
+
export function dockerProblems() {
|
|
68
|
+
const problems = [];
|
|
69
|
+
if (!hasDocker()) {
|
|
70
|
+
problems.push({
|
|
71
|
+
kind: "docker-missing",
|
|
72
|
+
exit: EXIT.DOCKER_MISSING,
|
|
73
|
+
message: "Docker is not installed or not on your PATH — https://docs.docker.com/get-docker/",
|
|
74
|
+
});
|
|
75
|
+
return problems; // nothing else is checkable without it
|
|
76
|
+
}
|
|
77
|
+
if (!daemonUp()) {
|
|
78
|
+
problems.push({
|
|
79
|
+
kind: "daemon-down",
|
|
80
|
+
exit: EXIT.DOCKER_DAEMON_DOWN,
|
|
81
|
+
message: "The Docker daemon isn't running — start Docker Desktop / the docker service.",
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
if (!composeV2()) {
|
|
85
|
+
problems.push({
|
|
86
|
+
kind: "compose-missing",
|
|
87
|
+
exit: EXIT.COMPOSE_MISSING,
|
|
88
|
+
message: legacyComposeOnly()
|
|
89
|
+
? "Only legacy `docker-compose` found — this CLI needs Compose v2 (`docker compose`). Update Docker."
|
|
90
|
+
: "`docker compose` (v2) is unavailable — update Docker to a version with the compose plugin.",
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return problems;
|
|
94
|
+
}
|
package/src/exit.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// The CLI's exit-code contract: a distinct, stable code per failure class so
|
|
2
|
+
// scripts, CI, and `&&` chains can branch on *why* a command failed — not just
|
|
3
|
+
// that it did. Codes are part of the public surface; keep them in sync with the
|
|
4
|
+
// "Exit codes" table in README.md and docs/plans/standalone-cli.md.
|
|
5
|
+
//
|
|
6
|
+
// 0 ok
|
|
7
|
+
// 1 general / uncategorized error (an unexpected throw)
|
|
8
|
+
// 2 usage — bad invocation: unknown command/subcommand, missing/bad args
|
|
9
|
+
// 3 docker missing — `docker` not installed or not on PATH
|
|
10
|
+
// 4 docker daemon down — installed, but the daemon is unreachable
|
|
11
|
+
// 5 compose missing — Docker present, but no Compose v2 plugin
|
|
12
|
+
// 6 port busy — the requested kernel/HTTP port is already in use
|
|
13
|
+
// 7 no instance — no materialized instance where one is required
|
|
14
|
+
// 8 needs confirmation — a destructive op was run without --yes
|
|
15
|
+
// 9 runtime — a docker/compose operation failed at runtime
|
|
16
|
+
export const EXIT = Object.freeze({
|
|
17
|
+
OK: 0,
|
|
18
|
+
GENERAL: 1,
|
|
19
|
+
USAGE: 2,
|
|
20
|
+
DOCKER_MISSING: 3,
|
|
21
|
+
DOCKER_DAEMON_DOWN: 4,
|
|
22
|
+
COMPOSE_MISSING: 5,
|
|
23
|
+
PORT_BUSY: 6,
|
|
24
|
+
NO_INSTANCE: 7,
|
|
25
|
+
CONFIRMATION: 8,
|
|
26
|
+
RUNTIME: 9,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// The codes a healthy `doctor`/pre-flight may legitimately exit with on a host
|
|
30
|
+
// that simply lacks a working Docker — used by the CI smoke test to tell a
|
|
31
|
+
// "Docker isn't set up here" exit apart from an actual CLI crash.
|
|
32
|
+
export const DOCTOR_OK_EXITS = Object.freeze([
|
|
33
|
+
EXIT.OK,
|
|
34
|
+
EXIT.DOCKER_MISSING,
|
|
35
|
+
EXIT.DOCKER_DAEMON_DOWN,
|
|
36
|
+
EXIT.COMPOSE_MISSING,
|
|
37
|
+
]);
|
package/src/instance.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Resolve which instance a command targets, materialize its files, and read/write
|
|
2
|
+
// its .env. Two modes: a global instance under ~/.alexandr (the npx default) or a
|
|
3
|
+
// project instance in ./alexandr (committable, created by `alexandr init`).
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import crypto from "node:crypto";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
// templates/ ships inside the package; locate it relative to THIS file (never
|
|
12
|
+
// process.cwd() or __dirname — neither is correct for a published ESM package).
|
|
13
|
+
const TEMPLATES = path.resolve(fileURLToPath(import.meta.url), "..", "..", "templates");
|
|
14
|
+
const GLOBAL_ROOT = path.join(os.homedir(), ".alexandr");
|
|
15
|
+
const ENV_FILE = ".env";
|
|
16
|
+
const sha8 = (s) => crypto.createHash("sha256").update(s).digest("hex").slice(0, 8);
|
|
17
|
+
|
|
18
|
+
// Decide the target instance from flags. Precedence:
|
|
19
|
+
// --dir <d> → project instance at <d>
|
|
20
|
+
// ./alexandr/docker-compose.yml → project instance in cwd
|
|
21
|
+
// --name <n> → global instance ~/.alexandr/<n>
|
|
22
|
+
// (default) → global instance ~/.alexandr/workspace
|
|
23
|
+
export function resolveInstance(flags = {}) {
|
|
24
|
+
const cwdProject = path.resolve(process.cwd(), "alexandr");
|
|
25
|
+
if (flags.dir) {
|
|
26
|
+
const dir = path.resolve(String(flags.dir));
|
|
27
|
+
return { dir, mode: "project", name: path.basename(dir), projectName: `alexandr-${sha8(dir)}` };
|
|
28
|
+
}
|
|
29
|
+
if (fs.existsSync(path.join(cwdProject, "docker-compose.yml"))) {
|
|
30
|
+
return { dir: cwdProject, mode: "project", name: path.basename(path.dirname(cwdProject)), projectName: `alexandr-${sha8(cwdProject)}` };
|
|
31
|
+
}
|
|
32
|
+
const name = flags.name ? String(flags.name) : "workspace";
|
|
33
|
+
const dir = path.join(GLOBAL_ROOT, name);
|
|
34
|
+
return { dir, mode: "global", name, projectName: name === "workspace" ? "alexandr" : `alexandr-${name}` };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const isMaterialized = (dir) => fs.existsSync(path.join(dir, "docker-compose.yml"));
|
|
38
|
+
|
|
39
|
+
// Copy the compose + Caddyfile templates into the instance dir and ensure a .env
|
|
40
|
+
// exists. Idempotent for the managed files; never clobbers an existing .env.
|
|
41
|
+
export function materialize(dir) {
|
|
42
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
43
|
+
for (const f of ["docker-compose.yml", "Caddyfile"]) {
|
|
44
|
+
fs.copyFileSync(path.join(TEMPLATES, f), path.join(dir, f));
|
|
45
|
+
}
|
|
46
|
+
const envPath = path.join(dir, ENV_FILE);
|
|
47
|
+
if (!fs.existsSync(envPath)) fs.copyFileSync(path.join(TEMPLATES, "env.example"), envPath);
|
|
48
|
+
return envPath;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Parse the .env into a flat map. Commented lines are treated as unset.
|
|
52
|
+
export function readEnv(dir) {
|
|
53
|
+
const p = path.join(dir, ENV_FILE);
|
|
54
|
+
const out = {};
|
|
55
|
+
if (!fs.existsSync(p)) return out;
|
|
56
|
+
for (const line of fs.readFileSync(p, "utf8").split("\n")) {
|
|
57
|
+
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)$/);
|
|
58
|
+
if (m) out[m[1]] = m[2];
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// True only if KEY is present, uncommented, and non-empty (empty ≠ set).
|
|
64
|
+
export const envHas = (dir, key) => {
|
|
65
|
+
const v = readEnv(dir)[key];
|
|
66
|
+
return v !== undefined && v !== "";
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Set KEY=value, replacing the first existing (commented or not) line, else append.
|
|
70
|
+
export function setEnv(dir, key, value) {
|
|
71
|
+
const p = path.join(dir, ENV_FILE);
|
|
72
|
+
let lines = fs.existsSync(p) ? fs.readFileSync(p, "utf8").replace(/\n$/, "").split("\n") : [];
|
|
73
|
+
const re = new RegExp(`^\\s*#?\\s*${key}\\s*=`);
|
|
74
|
+
let done = false;
|
|
75
|
+
for (let i = 0; i < lines.length; i++) {
|
|
76
|
+
if (re.test(lines[i])) {
|
|
77
|
+
lines[i] = `${key}=${value}`;
|
|
78
|
+
done = true;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (!done) lines.push(`${key}=${value}`);
|
|
83
|
+
if (lines.length === 1 && lines[0] === "") lines = [`${key}=${value}`];
|
|
84
|
+
fs.writeFileSync(p, `${lines.join("\n")}\n`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// "Unset" by commenting the line out — preserves it as documentation, and keeps
|
|
88
|
+
// empty (which the kernel reads as "explicitly off") distinct from absent.
|
|
89
|
+
export function unsetEnv(dir, key) {
|
|
90
|
+
const p = path.join(dir, ENV_FILE);
|
|
91
|
+
if (!fs.existsSync(p)) return;
|
|
92
|
+
const re = new RegExp(`^\\s*${key}\\s*=`);
|
|
93
|
+
const lines = fs.readFileSync(p, "utf8").replace(/\n$/, "").split("\n").map((l) => (re.test(l) ? `# ${l}` : l));
|
|
94
|
+
fs.writeFileSync(p, `${lines.join("\n")}\n`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The loopback kernel port for this instance (host side), honoring the .env.
|
|
98
|
+
export function kernelPort(dir) {
|
|
99
|
+
const v = readEnv(dir).ALEXANDR_KERNEL_PORT;
|
|
100
|
+
const n = v ? Number(v) : 3030;
|
|
101
|
+
return Number.isFinite(n) && n > 0 ? n : 3030;
|
|
102
|
+
}
|