@tolinax/ayoune-cli 2026.9.0 → 2026.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/lib/commands/_registry.js +17 -0
  2. package/lib/commands/createSelfHostUpdateCommand.js +1 -20
  3. package/lib/commands/createSetupCommand.js +57 -5
  4. package/lib/commands/functions/_shared.js +38 -0
  5. package/lib/commands/functions/_validateSource.js +50 -0
  6. package/lib/commands/functions/create.js +109 -0
  7. package/lib/commands/functions/delete.js +40 -0
  8. package/lib/commands/functions/deploy.js +91 -0
  9. package/lib/commands/functions/get.js +31 -0
  10. package/lib/commands/functions/index.js +48 -0
  11. package/lib/commands/functions/invoke.js +75 -0
  12. package/lib/commands/functions/list.js +41 -0
  13. package/lib/commands/functions/logs.js +76 -0
  14. package/lib/commands/functions/rollback.js +44 -0
  15. package/lib/commands/functions/versions.js +32 -0
  16. package/lib/commands/local/_context.js +42 -0
  17. package/lib/commands/local/down.js +50 -0
  18. package/lib/commands/local/exec.js +45 -0
  19. package/lib/commands/local/index.js +40 -0
  20. package/lib/commands/local/logs.js +38 -0
  21. package/lib/commands/local/ps.js +41 -0
  22. package/lib/commands/local/pull.js +40 -0
  23. package/lib/commands/local/restart.js +31 -0
  24. package/lib/commands/local/up.js +80 -0
  25. package/lib/commands/provision/_detectTools.js +52 -0
  26. package/lib/commands/provision/_stateFile.js +36 -0
  27. package/lib/commands/provision/_wizard.js +60 -0
  28. package/lib/commands/provision/aws.js +107 -0
  29. package/lib/commands/provision/azure.js +113 -0
  30. package/lib/commands/provision/destroy.js +119 -0
  31. package/lib/commands/provision/digitalocean.js +82 -0
  32. package/lib/commands/provision/gcp.js +118 -0
  33. package/lib/commands/provision/hetzner.js +220 -0
  34. package/lib/commands/provision/index.js +44 -0
  35. package/lib/commands/provision/status.js +44 -0
  36. package/lib/helpers/dockerCompose.js +143 -0
  37. package/package.json +1 -1
@@ -0,0 +1,118 @@
1
+ // `ay provision gcp` — deploy aYOUne to Google Kubernetes Engine via the
2
+ // marketplace gke-app spec at infrastructure/marketplace/gcp/gke-app.yaml.
3
+ import path from "path";
4
+ import { existsSync } from "fs";
5
+ import chalk from "chalk";
6
+ import { spawn } from "child_process";
7
+ import { spinner } from "../../../index.js";
8
+ import { cliError } from "../../helpers/cliError.js";
9
+ import { EXIT_GENERAL_ERROR } from "../../exitCodes.js";
10
+ import { requireTool } from "./_detectTools.js";
11
+ import { runBaseWizard } from "./_wizard.js";
12
+ import { writeState } from "./_stateFile.js";
13
+ const GCP_REGIONS = ["us-central1", "us-east1", "europe-west1", "europe-west3", "asia-southeast1"];
14
+ const GKE_MACHINE_TYPES = ["e2-standard-2", "e2-standard-4", "n2-standard-4", "n2-standard-8"];
15
+ function streamCmd(cmd, args) {
16
+ return new Promise((resolve) => {
17
+ const child = spawn(cmd, args, {
18
+ stdio: "inherit",
19
+ shell: process.platform === "win32",
20
+ });
21
+ child.on("exit", (code) => resolve(code !== null && code !== void 0 ? code : 0));
22
+ child.on("error", () => resolve(1));
23
+ });
24
+ }
25
+ export function addGcpSubcommand(prov) {
26
+ prov
27
+ .command("gcp")
28
+ .description("Provision aYOUne to Google Kubernetes Engine")
29
+ .option("--project <id>", "GCP project ID")
30
+ .option("--cluster <name>", "GKE cluster name", "ayoune")
31
+ .option("--region <region>", "GCP region")
32
+ .option("--dry-run", "Print the commands without running them", false)
33
+ .action(async (options) => {
34
+ var _a, _b;
35
+ try {
36
+ requireTool("gcloud");
37
+ const spec = findMarketplaceFile("gcp", "gke-app.yaml");
38
+ if (!spec) {
39
+ cliError("Could not find infrastructure/marketplace/gcp/gke-app.yaml — run from the monorepo.", EXIT_GENERAL_ERROR);
40
+ }
41
+ const base = await runBaseWizard({
42
+ regionChoices: GCP_REGIONS,
43
+ defaultRegion: (_a = options.region) !== null && _a !== void 0 ? _a : "europe-west1",
44
+ instanceChoices: GKE_MACHINE_TYPES,
45
+ defaultInstance: "e2-standard-4",
46
+ });
47
+ const inquirer = (await import("inquirer")).default;
48
+ const projectAns = await inquirer.prompt([
49
+ {
50
+ type: "input",
51
+ name: "project",
52
+ message: "GCP project ID:",
53
+ default: options.project,
54
+ when: !options.project,
55
+ validate: (v) => v.length > 0 || "Required",
56
+ },
57
+ ]);
58
+ const project = (_b = options.project) !== null && _b !== void 0 ? _b : projectAns.project;
59
+ const createCmd = [
60
+ "container",
61
+ "clusters",
62
+ "create",
63
+ options.cluster,
64
+ "--region",
65
+ base.region,
66
+ "--project",
67
+ project,
68
+ "--machine-type",
69
+ base.instanceSize,
70
+ "--num-nodes",
71
+ "3",
72
+ "--release-channel",
73
+ "regular",
74
+ ];
75
+ if (options.dryRun) {
76
+ console.log(chalk.yellow("\n --dry-run set — would run:"));
77
+ console.log(chalk.dim(` gcloud ${createCmd.join(" ")}`));
78
+ console.log(chalk.dim(` kubectl apply -f ${spec}`));
79
+ return;
80
+ }
81
+ spinner.start({ text: `Creating GKE cluster ${options.cluster}...`, color: "cyan" });
82
+ const clusterCode = await streamCmd("gcloud", createCmd);
83
+ if (clusterCode !== 0)
84
+ cliError("Cluster creation failed", EXIT_GENERAL_ERROR);
85
+ spinner.success({ text: "Cluster ready" });
86
+ spinner.start({ text: "Applying GKE app spec...", color: "cyan" });
87
+ const applyCode = await streamCmd("kubectl", ["apply", "-f", spec]);
88
+ if (applyCode !== 0)
89
+ cliError("kubectl apply failed", EXIT_GENERAL_ERROR);
90
+ spinner.success({ text: "App deployed" });
91
+ const file = await writeState({
92
+ provider: "gcp",
93
+ createdAt: new Date().toISOString(),
94
+ updatedAt: new Date().toISOString(),
95
+ region: base.region,
96
+ domain: base.domain,
97
+ modules: base.modules,
98
+ params: { project, cluster: options.cluster, machineType: base.instanceSize },
99
+ });
100
+ console.log(chalk.dim(` State file: ${file}`));
101
+ }
102
+ catch (e) {
103
+ cliError(e.message || "GCP provisioning failed", EXIT_GENERAL_ERROR);
104
+ }
105
+ });
106
+ }
107
+ function findMarketplaceFile(provider, file) {
108
+ let dir = process.cwd();
109
+ while (true) {
110
+ const candidate = path.join(dir, "infrastructure", "marketplace", provider, file);
111
+ if (existsSync(candidate))
112
+ return candidate;
113
+ const parent = path.dirname(dir);
114
+ if (parent === dir)
115
+ return null;
116
+ dir = parent;
117
+ }
118
+ }
@@ -0,0 +1,220 @@
1
+ // `ay provision hetzner` — provision aYOUne to Hetzner Cloud via Terraform.
2
+ //
3
+ // Wraps `infrastructure/marketplace/hetzner/terraform/`. Steps:
4
+ // 1. Verify terraform is installed.
5
+ // 2. Run the shared wizard for domain/license/modules/region/instance.
6
+ // 3. Ask for the Hetzner-specific Cloud API token + node count.
7
+ // 4. Render a `terraform.tfvars` from the answers in a temp working dir
8
+ // (we copy the marketplace terraform files there to avoid polluting
9
+ // the monorepo's existing tfstate).
10
+ // 5. Run `terraform init && terraform plan` (always) and `terraform apply`
11
+ // (unless --dry-run).
12
+ // 6. Capture `terraform output -json` and write provision state.
13
+ import path from "path";
14
+ import os from "os";
15
+ import { mkdir, writeFile, copyFile, readdir } from "fs/promises";
16
+ import { existsSync } from "fs";
17
+ import chalk from "chalk";
18
+ import { spinner } from "../../../index.js";
19
+ import { cliError } from "../../helpers/cliError.js";
20
+ import { EXIT_GENERAL_ERROR } from "../../exitCodes.js";
21
+ import { runCommand } from "../../helpers/dockerCompose.js";
22
+ import { requireTool } from "./_detectTools.js";
23
+ import { runBaseWizard } from "./_wizard.js";
24
+ import { writeState } from "./_stateFile.js";
25
+ import { spawn } from "child_process";
26
+ const HETZNER_REGIONS = ["nbg1", "fsn1", "hel1", "ash", "hil", "sin"];
27
+ const HETZNER_INSTANCE_SIZES = ["cax21", "cax31", "cax41", "ccx13", "ccx23", "ccx33"];
28
+ /** Stream a command (NOT docker compose) to the user's terminal. */
29
+ function streamCmd(cmd, args, cwd) {
30
+ return new Promise((resolve) => {
31
+ const child = spawn(cmd, args, {
32
+ cwd,
33
+ stdio: "inherit",
34
+ shell: process.platform === "win32",
35
+ });
36
+ child.on("exit", (code) => resolve(code !== null && code !== void 0 ? code : 0));
37
+ child.on("error", () => resolve(1));
38
+ });
39
+ }
40
+ export function addHetznerSubcommand(prov) {
41
+ prov
42
+ .command("hetzner")
43
+ .description("Provision aYOUne on Hetzner Cloud via Terraform")
44
+ .option("--token <token>", "Hetzner Cloud API token (or set HCLOUD_TOKEN)")
45
+ .option("--region <region>", `Hetzner location (${HETZNER_REGIONS.join(", ")})`, "nbg1")
46
+ .option("--size <size>", `Server type (${HETZNER_INSTANCE_SIZES.join(", ")})`, "cax21")
47
+ .option("--servers <n>", "Number of worker nodes", (v) => parseInt(v, 10), 3)
48
+ .option("--dry-run", "Render terraform.tfvars and run `terraform plan` only", false)
49
+ .option("-y, --yes", "Skip the final apply confirmation prompt", false)
50
+ .action(async (options) => {
51
+ var _a;
52
+ try {
53
+ requireTool("terraform");
54
+ const sourceDir = findHetznerTerraformDir();
55
+ if (!sourceDir) {
56
+ cliError("Could not locate infrastructure/marketplace/hetzner/terraform/ — run from the monorepo or use `ay setup` first.", EXIT_GENERAL_ERROR);
57
+ }
58
+ // Wizard for the cross-cutting params.
59
+ const base = await runBaseWizard({
60
+ defaultRegion: options.region,
61
+ regionChoices: HETZNER_REGIONS,
62
+ defaultInstance: options.size,
63
+ instanceChoices: HETZNER_INSTANCE_SIZES,
64
+ });
65
+ // Hetzner-specific.
66
+ const inquirer = (await import("inquirer")).default;
67
+ const hcloudToken = options.token || process.env.HCLOUD_TOKEN;
68
+ const extra = await inquirer.prompt([
69
+ {
70
+ type: "password",
71
+ name: "hcloudToken",
72
+ message: "Hetzner Cloud API token:",
73
+ default: hcloudToken,
74
+ when: !hcloudToken,
75
+ validate: (v) => v.length > 10 || "Token required",
76
+ },
77
+ {
78
+ type: "number",
79
+ name: "serverCount",
80
+ message: "Number of worker nodes:",
81
+ default: options.servers,
82
+ },
83
+ ]);
84
+ const token = hcloudToken || extra.hcloudToken;
85
+ const serverCount = (_a = extra.serverCount) !== null && _a !== void 0 ? _a : options.servers;
86
+ // Build a fresh working dir under ~/.ayoune/provision/hetzner-<ts>/.
87
+ const workDir = path.join(os.homedir(), ".ayoune", "provision", `hetzner-${Date.now()}`);
88
+ await mkdir(workDir, { recursive: true });
89
+ spinner.start({ text: "Copying Terraform module...", color: "cyan" });
90
+ await copyTerraformFiles(sourceDir, workDir);
91
+ const tfvars = renderTfvars({
92
+ token,
93
+ region: base.region,
94
+ instanceSize: base.instanceSize,
95
+ serverCount,
96
+ domain: base.domain,
97
+ contactEmail: base.contactEmail,
98
+ modules: base.modules,
99
+ licenseKey: base.licenseKey,
100
+ });
101
+ await writeFile(path.join(workDir, "terraform.tfvars"), tfvars, "utf-8");
102
+ spinner.success({ text: `Working dir ready: ${workDir}` });
103
+ // terraform init
104
+ spinner.start({ text: "Running `terraform init`...", color: "cyan" });
105
+ const initCode = await streamCmd("terraform", ["init", "-input=false"], workDir);
106
+ if (initCode !== 0)
107
+ cliError("terraform init failed", EXIT_GENERAL_ERROR);
108
+ spinner.success({ text: "Terraform initialized" });
109
+ // terraform plan
110
+ spinner.start({ text: "Running `terraform plan`...", color: "cyan" });
111
+ const planCode = await streamCmd("terraform", ["plan", "-input=false", "-out=tfplan"], workDir);
112
+ if (planCode !== 0)
113
+ cliError("terraform plan failed", EXIT_GENERAL_ERROR);
114
+ spinner.success({ text: "Plan generated" });
115
+ if (options.dryRun) {
116
+ console.log(chalk.yellow("\n --dry-run set — skipping `terraform apply`."));
117
+ console.log(chalk.dim(` To apply later: cd ${workDir} && terraform apply tfplan\n`));
118
+ return;
119
+ }
120
+ if (!options.yes) {
121
+ const { ok } = await inquirer.prompt([
122
+ {
123
+ type: "confirm",
124
+ name: "ok",
125
+ message: "Apply the plan now? (this will create real cloud resources)",
126
+ default: false,
127
+ },
128
+ ]);
129
+ if (!ok) {
130
+ console.log(chalk.dim(" Aborted. Working dir kept at " + workDir));
131
+ return;
132
+ }
133
+ }
134
+ // terraform apply
135
+ spinner.start({ text: "Running `terraform apply`...", color: "cyan" });
136
+ const applyCode = await streamCmd("terraform", ["apply", "-input=false", "tfplan"], workDir);
137
+ if (applyCode !== 0)
138
+ cliError("terraform apply failed", EXIT_GENERAL_ERROR);
139
+ spinner.success({ text: "Apply complete" });
140
+ // Capture outputs.
141
+ const outputsRaw = runCommand("terraform output -json", { cwd: workDir, silent: false });
142
+ let outputs = {};
143
+ try {
144
+ outputs = JSON.parse(outputsRaw);
145
+ }
146
+ catch (_b) {
147
+ outputs = { raw: outputsRaw };
148
+ }
149
+ const file = await writeState({
150
+ provider: "hetzner",
151
+ createdAt: new Date().toISOString(),
152
+ updatedAt: new Date().toISOString(),
153
+ region: base.region,
154
+ domain: base.domain,
155
+ modules: base.modules,
156
+ outputs,
157
+ params: {
158
+ workDir,
159
+ instanceSize: base.instanceSize,
160
+ serverCount,
161
+ },
162
+ });
163
+ console.log(chalk.green("\n aYOUne deployed to Hetzner Cloud!"));
164
+ console.log(chalk.dim(` State file: ${file}`));
165
+ console.log(chalk.dim(` Working dir: ${workDir}`));
166
+ console.log(chalk.dim(" Next: point your domain at the load balancer IP from the outputs."));
167
+ }
168
+ catch (e) {
169
+ cliError(e.message || "Hetzner provisioning failed", EXIT_GENERAL_ERROR);
170
+ }
171
+ });
172
+ }
173
+ function findHetznerTerraformDir() {
174
+ // Walk up looking for infrastructure/marketplace/hetzner/terraform/main.tf.
175
+ let dir = process.cwd();
176
+ while (true) {
177
+ const candidate = path.join(dir, "infrastructure", "marketplace", "hetzner", "terraform", "main.tf");
178
+ if (existsSync(candidate))
179
+ return path.dirname(candidate);
180
+ const parent = path.dirname(dir);
181
+ if (parent === dir)
182
+ return null;
183
+ dir = parent;
184
+ }
185
+ }
186
+ async function copyTerraformFiles(srcDir, destDir) {
187
+ const entries = await readdir(srcDir, { withFileTypes: true });
188
+ for (const entry of entries) {
189
+ // Skip state, plans, and credentials — those are per-deployment.
190
+ if (entry.name.endsWith(".tfstate") ||
191
+ entry.name.endsWith(".tfstate.backup") ||
192
+ entry.name === "tfplan" ||
193
+ entry.name === "tfplan-lb" ||
194
+ entry.name === "terraform.tfvars" ||
195
+ entry.name === "gar-key.json" ||
196
+ entry.name === "kubeconfig.yaml" ||
197
+ entry.name === ".terraform" ||
198
+ entry.isDirectory()) {
199
+ continue;
200
+ }
201
+ await copyFile(path.join(srcDir, entry.name), path.join(destDir, entry.name));
202
+ }
203
+ }
204
+ function renderTfvars(input) {
205
+ const lines = [
206
+ `# Generated by \`ay provision hetzner\` on ${new Date().toISOString()}`,
207
+ `hcloud_token = "${escape(input.token)}"`,
208
+ `location = "${input.region}"`,
209
+ `server_type = "${input.instanceSize}"`,
210
+ `server_count = ${input.serverCount}`,
211
+ `domain = "${input.domain}"`,
212
+ `contact_email = "${input.contactEmail}"`,
213
+ `license_key = "${escape(input.licenseKey)}"`,
214
+ `modules = [${input.modules.map((m) => `"${m}"`).join(", ")}]`,
215
+ ];
216
+ return lines.join("\n") + "\n";
217
+ }
218
+ function escape(v) {
219
+ return v.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
220
+ }
@@ -0,0 +1,44 @@
1
+ // `ay provision` parent command — wraps the provider templates in
2
+ // `infrastructure/marketplace/{aws,gcp,azure,digitalocean,hetzner}/` so
3
+ // end-customers can deploy aYOUne to a cloud account from a single CLI.
4
+ //
5
+ // Each provider has different artefacts (CloudFormation, ARM, Helm app spec,
6
+ // Terraform), so we use a thin provider-strategy pattern: each `*.ts` file
7
+ // in this directory exports `addXxxSubcommand(provision, root)` that knows
8
+ // how to invoke the provider-native binary against the relevant template.
9
+ //
10
+ // The CLI does NOT bundle aws-cli / gcloud / az / doctl / terraform — it
11
+ // detects them via `_detectTools.ts` and prints install instructions if
12
+ // missing. Provision state (parameters, outputs) is persisted to
13
+ // `~/.ayoune/provision/<provider>.json` via `_stateFile.ts` so `status` and
14
+ // `destroy` can find their way back without re-prompting.
15
+ import { addHetznerSubcommand } from "./hetzner.js";
16
+ import { addAwsSubcommand } from "./aws.js";
17
+ import { addGcpSubcommand } from "./gcp.js";
18
+ import { addAzureSubcommand } from "./azure.js";
19
+ import { addDigitalOceanSubcommand } from "./digitalocean.js";
20
+ import { addStatusSubcommand } from "./status.js";
21
+ import { addDestroySubcommand } from "./destroy.js";
22
+ export function createProvisionCommand(program) {
23
+ const prov = program
24
+ .command("provision")
25
+ .alias("prov")
26
+ .description("Provision aYOUne to a cloud provider (AWS / GCP / Azure / DigitalOcean / Hetzner)")
27
+ .addHelpText("after", `
28
+ Examples:
29
+ ay provision hetzner Wizard: provision aYOUne on Hetzner Cloud (Terraform)
30
+ ay provision aws --dry-run Wizard: preview AWS CloudFormation deployment
31
+ ay provision gcp Wizard: deploy to Google Kubernetes Engine
32
+ ay provision azure Wizard: deploy via ARM template
33
+ ay provision digitalocean Wizard: deploy via App Platform spec
34
+ ay provision status hetzner Show current provisioning state
35
+ ay provision destroy hetzner Tear down a provisioned environment`);
36
+ addHetznerSubcommand(prov);
37
+ addAwsSubcommand(prov);
38
+ addGcpSubcommand(prov);
39
+ addAzureSubcommand(prov);
40
+ addDigitalOceanSubcommand(prov);
41
+ addStatusSubcommand(prov);
42
+ addDestroySubcommand(prov);
43
+ }
44
+ export default createProvisionCommand;
@@ -0,0 +1,44 @@
1
+ // `ay provision status [provider]` — show provisioning state for one or
2
+ // every provider, read from `~/.ayoune/provision/<provider>.json`.
3
+ import chalk from "chalk";
4
+ import { cliError } from "../../helpers/cliError.js";
5
+ import { EXIT_GENERAL_ERROR } from "../../exitCodes.js";
6
+ import { readState } from "./_stateFile.js";
7
+ const PROVIDERS = ["hetzner", "aws", "gcp", "azure", "digitalocean"];
8
+ export function addStatusSubcommand(prov) {
9
+ prov
10
+ .command("status [provider]")
11
+ .description("Show provisioning state for one or all providers")
12
+ .action(async (provider) => {
13
+ var _a, _b, _c;
14
+ try {
15
+ const targets = provider
16
+ ? [provider]
17
+ : PROVIDERS;
18
+ let any = false;
19
+ for (const p of targets) {
20
+ const state = await readState(p);
21
+ if (!state)
22
+ continue;
23
+ any = true;
24
+ console.log("");
25
+ console.log(chalk.cyan.bold(` ${p}`));
26
+ console.log(chalk.dim(` domain: ${(_a = state.domain) !== null && _a !== void 0 ? _a : "?"}`));
27
+ console.log(chalk.dim(` region: ${(_b = state.region) !== null && _b !== void 0 ? _b : "?"}`));
28
+ console.log(chalk.dim(` modules: ${((_c = state.modules) !== null && _c !== void 0 ? _c : []).join(", ") || "?"}`));
29
+ console.log(chalk.dim(` created: ${state.createdAt}`));
30
+ console.log(chalk.dim(` updated: ${state.updatedAt}`));
31
+ if (state.params) {
32
+ console.log(chalk.dim(` params: ${JSON.stringify(state.params)}`));
33
+ }
34
+ }
35
+ if (!any) {
36
+ console.log(chalk.yellow(`\n No provisioning state found${provider ? ` for "${provider}"` : ""}.`));
37
+ console.log(chalk.dim(" Run `ay provision <provider>` to provision a new environment.\n"));
38
+ }
39
+ }
40
+ catch (e) {
41
+ cliError(e.message || "Failed to read provisioning state", EXIT_GENERAL_ERROR);
42
+ }
43
+ });
44
+ }
@@ -0,0 +1,143 @@
1
+ // Cross-platform Docker Compose helper.
2
+ //
3
+ // Two consumers today: `ay self-host-update` (compose vs k8s detection +
4
+ // pull/up loops) and `ay local` (the new dev-environment wrapper that
5
+ // replaces `infrastructure/local/dev.ps1`). Both need the same primitives:
6
+ // - run a shell command and capture stdout (`runCommand`)
7
+ // - detect whether we're on a docker-compose host or a kubernetes host
8
+ // (`detectRuntime`)
9
+ // - run docker compose with the right base flags (`runCompose`,
10
+ // `streamCompose`)
11
+ //
12
+ // `ay local` always passes through the project's `infrastructure/local/`
13
+ // override files when run from inside the monorepo, so the same `dev.ps1`
14
+ // composition is reproduced on macOS/Linux without any PowerShell. When run
15
+ // outside the monorepo (i.e. an end-user's customer install), it falls back
16
+ // to the local directory's `docker-compose.yml`.
17
+ import { execSync, spawn } from "child_process";
18
+ import { existsSync } from "fs";
19
+ import path from "path";
20
+ /**
21
+ * Run a shell command synchronously and return its trimmed stdout.
22
+ * Returns "" on failure when `silent` is true (the default), so callers can
23
+ * use truthiness to detect "command available / command produced output".
24
+ */
25
+ export function runCommand(cmd, opts = {}) {
26
+ const { cwd, timeout = 30000, silent = true } = opts;
27
+ try {
28
+ return execSync(cmd, { encoding: "utf-8", timeout, cwd }).trim();
29
+ }
30
+ catch (e) {
31
+ if (silent)
32
+ return "";
33
+ throw e;
34
+ }
35
+ }
36
+ /**
37
+ * Detect what self-hosting runtime is available on this machine.
38
+ * Prefers Docker Compose, falls back to Kubernetes (kubectl), then unknown.
39
+ */
40
+ export function detectRuntime() {
41
+ const composeResult = runCommand("docker compose version 2>&1");
42
+ if (composeResult.includes("Docker Compose"))
43
+ return "compose";
44
+ const kubectlResult = runCommand("kubectl version --client 2>&1");
45
+ if (kubectlResult.includes("Client Version"))
46
+ return "kubernetes";
47
+ return "unknown";
48
+ }
49
+ /**
50
+ * Locate the monorepo's `infrastructure/local/` directory by walking up from
51
+ * `start`. Returns null when not found (i.e. the user is running `ay local`
52
+ * from outside the monorepo on a customer install — in that case we just use
53
+ * the current directory's docker-compose.yml).
54
+ */
55
+ export function findLocalInfraDir(start = process.cwd()) {
56
+ let dir = path.resolve(start);
57
+ while (true) {
58
+ const candidate = path.join(dir, "infrastructure", "local", "docker-compose.local.yml");
59
+ if (existsSync(candidate))
60
+ return path.dirname(candidate);
61
+ const parent = path.dirname(dir);
62
+ if (parent === dir)
63
+ return null;
64
+ dir = parent;
65
+ }
66
+ }
67
+ /**
68
+ * Build a ComposeContext that mirrors what `infrastructure/local/dev.ps1`
69
+ * passes to `docker compose`. Returns null when no monorepo infra dir is
70
+ * found — caller decides whether to fall back to plain `docker compose` in
71
+ * the current directory or to error out.
72
+ */
73
+ export function resolveLocalContext(start = process.cwd()) {
74
+ const localDir = findLocalInfraDir(start);
75
+ if (!localDir)
76
+ return null;
77
+ const rootDir = path.resolve(localDir, "..", "..");
78
+ const files = [
79
+ path.join(rootDir, "docker-compose.yml"),
80
+ path.join(localDir, "docker-compose.local.yml"),
81
+ ].filter((f) => existsSync(f));
82
+ const devActive = path.join(localDir, "docker-compose.dev-active.yml");
83
+ if (existsSync(devActive))
84
+ files.push(devActive);
85
+ const envFile = path.join(localDir, ".env.local");
86
+ return {
87
+ projectName: "ayoune",
88
+ cwd: rootDir,
89
+ files,
90
+ envFile: existsSync(envFile) ? envFile : undefined,
91
+ };
92
+ }
93
+ /**
94
+ * Build the array of args that prefix every `docker compose` invocation
95
+ * (project name, -f files, --env-file). Used by both runCompose and streamCompose.
96
+ */
97
+ export function buildComposeBaseArgs(ctx) {
98
+ const args = [];
99
+ if (ctx.projectName)
100
+ args.push("-p", ctx.projectName);
101
+ for (const file of ctx.files)
102
+ args.push("-f", file);
103
+ if (ctx.envFile)
104
+ args.push("--env-file", ctx.envFile);
105
+ return args;
106
+ }
107
+ /**
108
+ * Run `docker compose <args>` synchronously, returning trimmed stdout.
109
+ * Used for short non-streaming commands (ps, pull --dry-run, etc.).
110
+ */
111
+ export function runCompose(ctx, args, opts = {}) {
112
+ const base = buildComposeBaseArgs(ctx);
113
+ const cmd = ["docker", "compose", ...base, ...args].map(shellQuote).join(" ");
114
+ return runCommand(cmd, { cwd: ctx.cwd, ...opts });
115
+ }
116
+ /**
117
+ * Stream `docker compose <args>` to the user's terminal (inherits stdio).
118
+ * Resolves with the exit code so callers can decide whether to spinner.error.
119
+ * Used for long-running interactive commands (up, down, logs -f, exec).
120
+ */
121
+ export function streamCompose(ctx, args, opts = {}) {
122
+ const base = buildComposeBaseArgs(ctx);
123
+ return new Promise((resolve) => {
124
+ const child = spawn("docker", ["compose", ...base, ...args], {
125
+ cwd: ctx.cwd,
126
+ stdio: "inherit",
127
+ shell: process.platform === "win32",
128
+ ...opts.extraSpawnOptions,
129
+ });
130
+ child.on("exit", (code) => resolve(code !== null && code !== void 0 ? code : 0));
131
+ child.on("error", () => resolve(1));
132
+ });
133
+ }
134
+ /**
135
+ * Quote a single shell token. Whitespace, quotes, glob characters trigger
136
+ * double-quoting; we keep the quoting cheap because docker compose paths and
137
+ * profile names are tame in practice.
138
+ */
139
+ function shellQuote(token) {
140
+ if (/^[A-Za-z0-9_./:=@+-]+$/.test(token))
141
+ return token;
142
+ return `"${token.replace(/"/g, '\\"')}"`;
143
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tolinax/ayoune-cli",
3
- "version": "2026.9.0",
3
+ "version": "2026.10.0",
4
4
  "description": "CLI for the aYOUne Business-as-a-Service platform",
5
5
  "type": "module",
6
6
  "main": "./index.js",