@localctl/cli 0.5.0-beta.1 → 0.5.0-beta.3

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 CHANGED
@@ -113,8 +113,9 @@ CA trust.
113
113
 
114
114
  ### Linux (experimental)
115
115
 
116
- Linux support is **experimental**: it hasn't been through a full test cycle (CI only runs the
117
- smoke tests there), and `setup`/`doctor` print a warning. It's published on the `beta` dist-tag:
116
+ Linux support is **experimental**: the end-to-end suite passes on the distributions below, but
117
+ only in containers on arm64 (amd64 and bare-metal installs are untested), and `setup`/`doctor`
118
+ print a warning. It's published on the `beta` dist-tag:
118
119
 
119
120
  ```sh
120
121
  npm install -g @localctl/cli@beta
@@ -125,10 +126,15 @@ Targeted distributions, all with systemd and a mkcert-supported trust store:
125
126
  | Family | Distributions | Docker Engine install |
126
127
  |---|---|---|
127
128
  | Debian | Ubuntu, Debian, Linux Mint, Pop!_OS | `localctl setup --install-docker` (Docker's official script) |
128
- | Red Hat | Fedora, RHEL, Rocky, AlmaLinux, CentOS Stream | `localctl setup --install-docker` (Docker's official script) |
129
+ | Red Hat | Fedora, RHEL, Rocky, AlmaLinux, CentOS Stream | `localctl setup --install-docker` (Docker's official script on Fedora, Docker's CentOS repository elsewhere) |
129
130
  | Arch | Arch, Manjaro, EndeavourOS | `localctl setup --install-docker` (`pacman`) |
130
131
  | SUSE | openSUSE Leap/Tumbleweed | `localctl setup --install-docker` (`zypper`) |
131
132
 
133
+ Verified end to end (`npm run test:e2e:distros`, see [Contributing](docs/contributing.md)): Ubuntu
134
+ 22.04 and 24.04, Debian 12, Fedora 41, Rocky 9, openSUSE Leap 15.6 and Arch Linux ARM. Derivatives
135
+ resolve through `ID_LIKE` in `/etc/os-release` (or `/usr/lib/os-release`) but were not run
136
+ themselves.
137
+
132
138
  Other distributions (Alpine, Void, Gentoo, NixOS, immutable/atomic desktops) may work if you
133
139
  bring Docker (or Podman) and Node.js yourself, but aren't targeted. WSL needs the Windows hosts
134
140
  file edited too.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@localctl/cli",
3
- "version": "0.5.0-beta.1",
3
+ "version": "0.5.0-beta.3",
4
4
  "description": "localctl - local Kubernetes dev platform CLI",
5
5
  "keywords": [
6
6
  "kubernetes",
@@ -36,7 +36,9 @@
36
36
  "sync-assets": "node scripts/sync-assets.js",
37
37
  "prepack": "node scripts/sync-assets.js",
38
38
  "test": "node scripts/smoke-test.js",
39
- "test:pack": "node scripts/pack-test.js"
39
+ "test:pack": "node scripts/pack-test.js",
40
+ "test:e2e": "node scripts/e2e/run.js",
41
+ "test:e2e:distros": "node scripts/e2e/distros.js"
40
42
  },
41
43
  "dependencies": {
42
44
  "chalk": "^4.1.2",
@@ -1,4 +1,3 @@
1
- const os = require('os');
2
1
  const fs = require('fs');
3
2
  const path = require('path');
4
3
  const { isRunning } = require('../lib/exec');
@@ -8,7 +7,7 @@ const { teardownInsecureRegistry } = require('../lib/podman');
8
7
  const { clearHosts } = require('../lib/hostsFile');
9
8
  const { listProfiles } = require('../lib/profileStore');
10
9
  const { confirm } = require('../lib/prompt');
11
- const { RUN_DIR, PROFILES_PATH } = require('../lib/paths');
10
+ const { RUN_DIR, PROFILES_PATH, CERTS_DIR, APPS_REGISTRY_PATH } = require('../lib/paths');
12
11
  const logger = require('../lib/logger');
13
12
 
14
13
  // Every Main (cluster-isolation) project this machine knows about - each one owns a real,
@@ -80,8 +79,8 @@ async function uninstall(opts) {
80
79
  fs.rmSync(RUN_DIR, { recursive: true, force: true });
81
80
  }
82
81
 
83
- const certsDir = path.join(os.homedir(), '.localctl', 'certs');
84
- const appsRegistry = path.join(os.homedir(), '.localctl', 'apps.json');
82
+ const certsDir = CERTS_DIR;
83
+ const appsRegistry = APPS_REGISTRY_PATH;
85
84
  if (fs.existsSync(certsDir)) fs.rmSync(certsDir, { recursive: true, force: true });
86
85
  if (fs.existsSync(appsRegistry)) fs.rmSync(appsRegistry, { force: true });
87
86
  if (fs.existsSync(PROFILES_PATH)) fs.rmSync(PROFILES_PATH, { force: true });
@@ -1,6 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const os = require('os');
3
3
  const path = require('path');
4
+ const { HOME_STATE_DIR } = require('./paths');
4
5
 
5
6
  // Addons are searched in two places, in precedence order:
6
7
  // 1. USER_DIR - yours, never touched by a `localctl` update/reinstall. Defaults to
@@ -11,7 +12,7 @@ const path = require('path');
11
12
  // This is what lets someone install this tool without inheriting its author's addon set, and
12
13
  // without forking the tool to add their own - see docs/adding-tools.md.
13
14
  const BUILTIN_DIR = path.join(__dirname, 'addons');
14
- const USER_DIR = process.env.LOCALCTL_ADDONS_DIR || path.join(os.homedir(), '.localctl', 'addons');
15
+ const USER_DIR = process.env.LOCALCTL_ADDONS_DIR || path.join(HOME_STATE_DIR, 'addons');
15
16
 
16
17
  function roots(...segments) {
17
18
  return [path.join(USER_DIR, ...segments), path.join(BUILTIN_DIR, ...segments)];
@@ -8,6 +8,7 @@ const { installIfMissing, installTiltWindows, nssInstallHint } = require('./tool
8
8
  const { ensureInsecureRegistry, ensurePodmanSocket, blockedPrivilegedPorts } = require('./podman');
9
9
  const { DOMAIN } = require('./constants');
10
10
  const logger = require('./logger');
11
+ const { CERTS_DIR } = require('./paths');
11
12
  const { detectDistro, dockerInstallSteps } = require('./linuxDistro');
12
13
 
13
14
  // Cluster-level manifests: bundled under cli/assets/ in the npm package (copied there by
@@ -43,6 +44,12 @@ function clusterExists(clusterName) {
43
44
  // which also repairs clusters created before this fix.
44
45
  function useClusterContext(clusterName) {
45
46
  const context = `k3d-${clusterName}`;
47
+ // The cluster can exist while this kubeconfig knows nothing about it (a deleted or replaced
48
+ // ~/.kube/config, a different KUBECONFIG): ask k3d to write it back instead of failing.
49
+ const known = capture('kubectl', ['config', 'get-contexts', '-o', 'name']).stdout.split('\n');
50
+ if (!known.includes(context)) {
51
+ run('k3d', ['kubeconfig', 'merge', clusterName, '--kubeconfig-merge-default'], { stdio: 'ignore', allowFail: true });
52
+ }
46
53
  run('kubectl', ['config', 'use-context', context], { stdio: 'ignore' });
47
54
  const server = capture('kubectl', [
48
55
  'config', 'view', '--minify', '-o', 'jsonpath={.clusters[0].cluster.server}',
@@ -84,6 +91,80 @@ function buildK3dConfig({ clusterName, httpPort, httpsPort, registryName }) {
84
91
  };
85
92
  }
86
93
 
94
+ // Right after a (re)start the load balancer can accept connections before the API server is
95
+ // answering (EOF / connection refused), so wait for it before the first real kubectl call.
96
+ // Also after an engine restart the server container can get a new IP while the load balancer
97
+ // (nginx, resolves upstreams only at startup) keeps the old one, so the API never answers;
98
+ // restarting the load balancer once re-resolves it. Returns true once the API is ready.
99
+ function waitForClusterApi(engineBin, clusterName, { attempts = 60, quiet = false } = {}) {
100
+ if (!quiet) logger.step('Waiting for the Kubernetes API...');
101
+ let lbRestarted = false;
102
+ for (let i = 0; i < attempts; i += 1) {
103
+ if (capture('kubectl', ['get', '--raw', '/readyz']).status === 0) return true;
104
+ if (i === 5 && !lbRestarted) {
105
+ lbRestarted = true;
106
+ run(engineBin, ['restart', `k3d-${clusterName}-serverlb`], { stdio: 'ignore', allowFail: true });
107
+ }
108
+ sleepSync(2000);
109
+ }
110
+ return false;
111
+ }
112
+
113
+ // k3d's --wait only waits for node readiness, not for Traefik's helm-install job to finish
114
+ // registering its CRDs, so applying TLSStore any earlier races and fails intermittently. On a slow
115
+ // machine (first image pulls, a nested engine) that takes minutes, so poll for the CRD to be
116
+ // *Established* instead of giving up after one fixed wait, and say why if it never is.
117
+ function waitForTraefikCrds(attempts = 150) {
118
+ logger.step('Waiting for Traefik CRDs...');
119
+ let last = 'not found yet';
120
+ for (let i = 0; i < attempts; i += 1) {
121
+ const r = capture('kubectl', [
122
+ 'get', 'crd', 'tlsstores.traefik.io',
123
+ '-o', 'jsonpath={.status.conditions[?(@.type=="Established")].status}',
124
+ ]);
125
+ if (r.status === 0 && r.stdout === 'True') return;
126
+ last = r.status === 0 ? `Established=${r.stdout || 'unknown'}` : (r.stderr.split('\n')[0] || 'not found yet');
127
+ sleepSync(2000);
128
+ }
129
+ throw new Error(
130
+ `Traefik's CRDs (tlsstores.traefik.io) were not ready after ${Math.round((attempts * 2) / 60)} minutes (${last}). ` +
131
+ 'Check "kubectl -n kube-system get pods" - the traefik helm-install jobs are probably still pulling images.',
132
+ );
133
+ }
134
+
135
+ // The CRDs existing does not mean Traefik is serving: on a slow machine its pod can still be
136
+ // starting minutes later, and an app deployed in that window answers with a dropped connection.
137
+ // Waiting here makes "cluster ready" mean the ingress actually accepts traffic.
138
+ function waitForTraefik(attempts = 150) {
139
+ logger.step('Waiting for Traefik to accept traffic...');
140
+ for (let i = 0; i < attempts; i += 1) {
141
+ if (capture('kubectl', ['get', 'deployment', 'traefik', '-n', 'kube-system']).status === 0) break;
142
+ sleepSync(2000);
143
+ }
144
+ const res = capture('kubectl', ['rollout', 'status', 'deployment/traefik', '-n', 'kube-system', '--timeout=300s']);
145
+ if (res.status !== 0) {
146
+ throw new Error(
147
+ `Traefik did not become ready in 5 minutes (${(res.stderr || res.stdout).split('\n')[0]}). ` +
148
+ 'Check "kubectl -n kube-system get pods".',
149
+ );
150
+ }
151
+ }
152
+
153
+ // A CRD can be Established a moment before the API server's discovery serves it, which surfaces as
154
+ // "no matches for kind". Retrying the apply covers that window.
155
+ function applyWithRetry(file, attempts = 15) {
156
+ let res;
157
+ for (let i = 0; i < attempts; i += 1) {
158
+ res = capture('kubectl', ['apply', '-f', file]);
159
+ if (res.status === 0) {
160
+ if (res.stdout) console.log(res.stdout);
161
+ return;
162
+ }
163
+ sleepSync(2000);
164
+ }
165
+ throw new Error(`kubectl apply -f ${path.basename(file)} failed after ${attempts} attempts: ${res.stderr}`);
166
+ }
167
+
87
168
  // Brings up (idempotently) one Main project's cluster: container engine + tool checks, the k3d
88
169
  // cluster itself (own ports, since two clusters can't both bind host :80/:443), its own local
89
170
  // registry container, and the shared *.local.test TLS cert wired into its Traefik. Used by both
@@ -99,13 +180,18 @@ function provisionCluster({ clusterName, httpPort, httpsPort, registryPort }) {
99
180
  if (!detected) {
100
181
  let linuxHint = '';
101
182
  if (process.platform === 'linux') {
102
- const steps = dockerInstallSteps(detectDistro().family);
103
- linuxHint = '\nOn Linux: if Docker is installed, make sure the service runs and your user can reach it' +
104
- ' (member of the "docker" group, then log out and in).';
105
- if (steps) {
106
- linuxHint += `\nTo install Docker Engine, run "localctl setup --install-docker", or by hand:\n ${steps.join('\n ')}`;
183
+ if (commandExists('docker') || commandExists('podman')) {
184
+ // Installed but not usable: reinstalling won't help, the daemon or permissions are the issue.
185
+ linuxHint = '\nA container engine is installed but not responding. Start the service' +
186
+ ' (`sudo systemctl enable --now docker`, or `systemctl --user enable --now podman.socket` for Podman;' +
187
+ ' without systemd, start `dockerd` yourself) and make sure your user can reach it' +
188
+ ' (member of the "docker" group, then log out and in).';
107
189
  } else {
108
- linuxHint += '\nInstall Docker Engine for your distro: https://docs.docker.com/engine/install/';
190
+ const { family, id } = detectDistro();
191
+ const steps = dockerInstallSteps(family, id);
192
+ linuxHint = steps
193
+ ? `\nTo install Docker Engine, run "localctl setup --install-docker", or by hand:\n ${steps.join('\n ')}`
194
+ : '\nInstall Docker Engine for your distro: https://docs.docker.com/engine/install/';
109
195
  }
110
196
  }
111
197
  throw new Error(`No running container engine found. Start Docker Desktop, Podman, or Rancher Desktop and re-run.${linuxHint}`);
@@ -171,20 +257,15 @@ function provisionCluster({ clusterName, httpPort, httpsPort, registryPort }) {
171
257
  }
172
258
  useClusterContext(clusterName);
173
259
 
174
- // Right after a (re)start the load balancer can accept connections before the API server is
175
- // answering (EOF / connection refused), so wait for it before the first real kubectl call.
176
- logger.step('Waiting for the Kubernetes API...');
177
- // After an engine restart the server container can get a new IP while the load balancer
178
- // (nginx, resolves upstreams only at startup) keeps the old one, so the API never answers.
179
- // Restarting the load balancer once re-resolves it.
180
- let lbRestarted = false;
181
- for (let i = 0; i < 60; i += 1) {
182
- if (capture('kubectl', ['get', '--raw', '/readyz']).status === 0) break;
183
- if (i === 5 && !lbRestarted) {
184
- lbRestarted = true;
185
- run(engineBin, ['restart', `k3d-${clusterName}-serverlb`], { stdio: 'ignore', allowFail: true });
186
- }
187
- sleepSync(2000);
260
+ // Up to ~5 minutes: k3s can exit once during start-up (a known race between its cloud controller
261
+ // and RBAC bootstrap) and is restarted by the engine, which a 2-minute wait would not outlast on
262
+ // a slow machine. If it never answers, stop here with the reason instead of failing later on an
263
+ // unrelated "EOF" from kubectl apply.
264
+ if (!waitForClusterApi(engineBin, clusterName, { attempts: 150 })) {
265
+ throw new Error(
266
+ `The Kubernetes API of cluster '${clusterName}' did not become ready in 5 minutes. ` +
267
+ `See why with: ${engineBin} logs --tail 50 k3d-${clusterName}-server-0`,
268
+ );
188
269
  }
189
270
 
190
271
  const registryNetwork = `k3d-${clusterName}`;
@@ -209,7 +290,7 @@ function provisionCluster({ clusterName, httpPort, httpsPort, registryPort }) {
209
290
  // domain everywhere; only the namespace/subdomain prefix or the cluster differs).
210
291
  logger.step(`Setting up local TLS for *.${DOMAIN}...`);
211
292
  run('mkcert', ['-install']);
212
- const certDir = path.join(os.homedir(), '.localctl', 'certs');
293
+ const certDir = CERTS_DIR;
213
294
  fs.mkdirSync(certDir, { recursive: true });
214
295
  const certFile = path.join(certDir, 'local-dev.pem');
215
296
  const keyFile = path.join(certDir, 'local-dev-key.pem');
@@ -226,15 +307,10 @@ function provisionCluster({ clusterName, httpPort, httpsPort, registryPort }) {
226
307
 
227
308
  // k3d's --wait only waits for node readiness, not for Traefik's helm-install job to finish
228
309
  // registering its CRDs, so applying TLSStore any earlier races and fails intermittently.
229
- logger.step('Waiting for Traefik CRDs...');
230
- for (let i = 0; i < 60; i += 1) {
231
- if (capture('kubectl', ['get', 'crd', 'tlsstores.traefik.io']).status === 0) break;
232
- sleepSync(2000);
233
- }
234
- run('kubectl', ['wait', '--for=condition=Established', 'crd/tlsstores.traefik.io', '--timeout=60s'], { stdio: 'ignore' });
235
-
236
- run('kubectl', ['apply', '-f', path.join(MANIFESTS_DIR, 'tls-store.yaml')]);
237
- run('kubectl', ['apply', '-f', path.join(MANIFESTS_DIR, 'namespace-addons.yaml')]);
310
+ waitForTraefikCrds();
311
+ applyWithRetry(path.join(MANIFESTS_DIR, 'tls-store.yaml'));
312
+ applyWithRetry(path.join(MANIFESTS_DIR, 'namespace-addons.yaml'));
313
+ waitForTraefik();
238
314
  logger.success('TLS configured, default cert wired into Traefik');
239
315
  }
240
316
 
@@ -267,4 +343,4 @@ function ensureClusterUp(clusterName, profileName) {
267
343
  useClusterContext(clusterName);
268
344
  }
269
345
 
270
- module.exports = { buildK3dConfig, provisionCluster, teardownCluster, clusterExists, registryNameFor, ensureClusterUp };
346
+ module.exports = { buildK3dConfig, useClusterContext, waitForClusterApi, provisionCluster, teardownCluster, clusterExists, registryNameFor, ensureClusterUp };
package/src/lib/exec.js CHANGED
@@ -21,9 +21,12 @@ function capture(cmd, args, opts = {}) {
21
21
  }
22
22
 
23
23
  function commandExists(cmd) {
24
- const probe = process.platform === 'win32' ? 'where' : 'which';
25
- const res = spawnSync(probe, [cmd], { stdio: 'ignore' });
26
- return res.status === 0;
24
+ if (process.platform === 'win32') {
25
+ return spawnSync('where', [cmd], { stdio: 'ignore' }).status === 0;
26
+ }
27
+ // `command -v` is a POSIX shell builtin; `which` is a separate program that minimal installs
28
+ // (Fedora/openSUSE server images, containers) don't ship.
29
+ return spawnSync('sh', ['-c', 'command -v "$0"', cmd], { stdio: 'ignore' }).status === 0;
27
30
  }
28
31
 
29
32
  // Blocking sleep with no native dependency - used for poll loops (e.g. waiting on a CRD to
@@ -8,8 +8,10 @@ const { DOMAIN } = require('./constants');
8
8
 
9
9
  const MARK_START = '# >>> localctl managed block >>>';
10
10
  const MARK_END = '# <<< localctl managed block <<<';
11
- const HOSTS_PATH =
12
- process.platform === 'win32' ? 'C:\\Windows\\System32\\drivers\\etc\\hosts' : '/etc/hosts';
11
+ // $LOCALCTL_HOSTS_FILE points it at another file - used by the e2e suite so tests never touch
12
+ // (or need sudo for) the real hosts file.
13
+ const HOSTS_PATH = process.env.LOCALCTL_HOSTS_FILE ||
14
+ (process.platform === 'win32' ? 'C:\\Windows\\System32\\drivers\\etc\\hosts' : '/etc/hosts');
13
15
 
14
16
  function registerApp(subdomain) {
15
17
  ensureHomeState();
@@ -17,12 +17,17 @@ const FAMILY_BY_ID = [
17
17
  // Reads ID and ID_LIKE from os-release, so derivatives resolve to their parent (Mint, Pop!_OS ->
18
18
  // debian; Rocky, Alma -> rhel; Manjaro -> arch).
19
19
  function detectDistro() {
20
- let release = '';
21
- try {
22
- release = fs.readFileSync('/etc/os-release', 'utf8');
23
- } catch (e) {
24
- return { id: 'unknown', family: 'unknown' };
20
+ let release = null;
21
+ // /usr/lib/os-release is the documented fallback when /etc/os-release is missing.
22
+ for (const file of ['/etc/os-release', '/usr/lib/os-release']) {
23
+ try {
24
+ release = fs.readFileSync(file, 'utf8');
25
+ break;
26
+ } catch (e) {
27
+ // try the next location
28
+ }
25
29
  }
30
+ if (release === null) return { id: 'unknown', family: 'unknown' };
26
31
  const value = (key) => {
27
32
  const m = release.match(new RegExp(`^${key}=(.*)$`, 'm'));
28
33
  return m ? m[1].replace(/"/g, '').trim() : '';
@@ -43,11 +48,21 @@ function isRoot() {
43
48
 
44
49
  // What `localctl setup --install-docker` would do, as printable steps. Shown in the "no engine"
45
50
  // error too, so the user can run them by hand. Returns null for families without a known recipe.
46
- function dockerInstallSteps(family) {
51
+ function dockerInstallSteps(family, id) {
47
52
  const sudo = isRoot() ? '' : 'sudo ';
48
53
  const user = os.userInfo().username;
49
54
  let install;
50
- if (family === 'debian' || family === 'rhel') {
55
+ if (family === 'rhel' && id !== 'fedora') {
56
+ // Docker's convenience script derives the repo path from the distro ID, and publishes
57
+ // .../linux/centos and .../linux/rhel but not .../linux/rocky or .../linux/almalinux: it adds
58
+ // a repo whose packages don't exist ("Unable to find a match: docker-ce"). The CentOS repo is
59
+ // what Docker documents for the RHEL rebuilds.
60
+ install = [
61
+ `${sudo}dnf -y install dnf-plugins-core`,
62
+ `${sudo}dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo`,
63
+ `${sudo}dnf -y install docker-ce docker-ce-cli containerd.io`,
64
+ ];
65
+ } else if (family === 'debian' || family === 'rhel') {
51
66
  install = [`curl -fsSL https://get.docker.com -o get-docker.sh && ${sudo}sh get-docker.sh`];
52
67
  } else if (family === 'arch') {
53
68
  install = [`${sudo}pacman -S --noconfirm docker`];
@@ -67,17 +82,18 @@ function dockerInstallSteps(family) {
67
82
  // ever runs on an explicit `--install-docker`.
68
83
  function installDocker() {
69
84
  const { family, id } = detectDistro();
70
- if (!dockerInstallSteps(family)) {
85
+ if (!dockerInstallSteps(family, id)) {
71
86
  throw new Error(`No automatic Docker install for "${id}". Install Docker Engine for your distro (https://docs.docker.com/engine/install/) and re-run.`);
72
87
  }
73
- if (!commandExists('curl') && (family === 'debian' || family === 'rhel')) {
88
+ const viaScript = family === 'debian' || (family === 'rhel' && id === 'fedora');
89
+ if (!commandExists('curl') && viaScript) {
74
90
  throw new Error('curl is needed to download the Docker install script. Install it and re-run.');
75
91
  }
76
92
  logger.step(`Installing Docker Engine (${id}). This needs sudo and will prompt for your password...`);
77
93
  const sudo = isRoot() ? [] : ['sudo'];
78
94
  const exec = (cmd, args) => run(sudo.length ? sudo[0] : cmd, sudo.length ? [cmd, ...args] : args);
79
95
 
80
- if (family === 'debian' || family === 'rhel') {
96
+ if (viaScript) {
81
97
  const script = path.join(os.tmpdir(), 'localctl-get-docker.sh');
82
98
  run('curl', ['-fsSL', 'https://get.docker.com', '-o', script]);
83
99
  try {
@@ -85,6 +101,10 @@ function installDocker() {
85
101
  } finally {
86
102
  fs.rmSync(script, { force: true });
87
103
  }
104
+ } else if (family === 'rhel') {
105
+ exec('dnf', ['-y', 'install', 'dnf-plugins-core']);
106
+ exec('dnf', ['config-manager', '--add-repo', 'https://download.docker.com/linux/centos/docker-ce.repo']);
107
+ exec('dnf', ['-y', 'install', 'docker-ce', 'docker-ce-cli', 'containerd.io']);
88
108
  } else if (family === 'arch') {
89
109
  exec('pacman', ['-S', '--noconfirm', 'docker']);
90
110
  } else if (family === 'suse') {
package/src/lib/paths.js CHANGED
@@ -5,7 +5,10 @@ const fs = require('fs');
5
5
  const APP_DIR = process.cwd();
6
6
  const LOCAL_DIR = path.join(APP_DIR, '.local');
7
7
  const CONFIG_PATH = path.join(LOCAL_DIR, 'config.json');
8
- const HOME_STATE_DIR = path.join(os.homedir(), '.localctl');
8
+ // $LOCALCTL_HOME relocates all machine-level state (profiles, apps, certs, run files). The e2e
9
+ // suite uses it to run against a throwaway directory instead of your real ~/.localctl.
10
+ const HOME_STATE_DIR = process.env.LOCALCTL_HOME || path.join(os.homedir(), '.localctl');
11
+ const CERTS_DIR = path.join(HOME_STATE_DIR, 'certs');
9
12
  const APPS_REGISTRY_PATH = path.join(HOME_STATE_DIR, 'apps.json');
10
13
  const PROFILES_PATH = path.join(HOME_STATE_DIR, 'profiles.json');
11
14
  const RUN_DIR = path.join(HOME_STATE_DIR, 'run');
@@ -33,6 +36,7 @@ module.exports = {
33
36
  LOCAL_DIR,
34
37
  CONFIG_PATH,
35
38
  HOME_STATE_DIR,
39
+ CERTS_DIR,
36
40
  APPS_REGISTRY_PATH,
37
41
  PROFILES_PATH,
38
42
  RUN_DIR,