@localctl/cli 0.4.1 โ†’ 0.5.0-beta.1

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
@@ -73,26 +73,80 @@ JSON file per app.
73
73
 
74
74
  | Requirement | Notes |
75
75
  |---|---|
76
- | **macOS** or **Windows** | Windows commands run in PowerShell. |
76
+ | **macOS**, **Windows** or **Linux** | Windows commands run in PowerShell. Linux support is [experimental](#linux-experimental). |
77
77
  | **Node.js 18+** | Needed to install the CLI from npm. |
78
- | **Container engine** | [Podman Desktop](https://podman-desktop.io/), running. See [supported container engines](#supported-container-engines). |
79
- | **Homebrew** or **winget** | Used once by `localctl setup` to install missing tools. |
78
+ | **Container engine** | [Podman Desktop](https://podman-desktop.io/) (or Docker Desktop / Rancher Desktop), running. See [supported container engines](#supported-container-engines). |
79
+ | **Homebrew** (macOS) or **winget** (Windows) | Used once by `localctl setup` to install missing tools. Linux needs only `curl`. |
80
80
 
81
81
  `localctl setup` installs `k3d`, `kubectl`, `tilt`, `mkcert` and `helm` for you if they're missing.
82
+ On Windows, Tilt isn't on winget, so it's downloaded from its GitHub release into
83
+ `~\.localctl\tools` and added to your user PATH. On Linux, all five tools are downloaded as
84
+ official release binaries into `~/.localctl/tools` (no sudo, no distro packages).
82
85
 
83
86
  ### Supported container engines
84
87
 
85
88
  | Engine | Status |
86
89
  |---|---|
87
90
  | [Podman Desktop](https://podman-desktop.io/) | โœ… Tested |
88
- | [Docker Desktop](https://www.docker.com/products/docker-desktop/) | ๐Ÿงช Experimental โ€” not yet tested |
91
+ | [Docker Desktop](https://www.docker.com/products/docker-desktop/) | ๐Ÿงช Experimental โ€” setup and app deploy verified on Windows |
89
92
  | [Rancher Desktop](https://rancherdesktop.io/) | ๐Ÿงช Experimental โ€” not yet tested |
93
+ | Docker Engine / native Podman (Linux) | ๐Ÿงช Experimental โ€” see [Linux](#linux-experimental) |
90
94
 
91
95
  Experimental engines are detected and should work, since k3d only needs a Docker-compatible API,
92
96
  but they haven't been through a full test cycle yet. `localctl setup` and `localctl doctor` print
93
97
  a warning when one is in use. If you try one, please
94
98
  [report how it went](https://github.com/psilvmoreira/localctl/issues/new).
95
99
 
100
+ ### Required tools
101
+
102
+ What has to be on your machine **before** `localctl setup`, and what `setup` installs for you:
103
+
104
+ | | You install | `localctl setup` installs |
105
+ |---|---|---|
106
+ | **All platforms** | **Node.js 18+** (to install and run the CLI), a **container engine** | `k3d`, `kubectl`, `tilt`, `mkcert`, `helm` |
107
+ | **macOS** | [Homebrew](https://brew.sh) | via `brew` (plus `nss` for Firefox, optional) |
108
+ | **Windows** | `winget` (ships with Windows) | via `winget`, Tilt from its GitHub release |
109
+ | **Linux** | `curl` and `tar`, `sudo` (hosts file, CA trust) | downloaded as release binaries into `~/.localctl/tools` |
110
+
111
+ `localctl setup` needs administrator rights once, for the `*.local.test` hosts entry and the local
112
+ CA trust.
113
+
114
+ ### Linux (experimental)
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:
118
+
119
+ ```sh
120
+ npm install -g @localctl/cli@beta
121
+ ```
122
+
123
+ Targeted distributions, all with systemd and a mkcert-supported trust store:
124
+
125
+ | Family | Distributions | Docker Engine install |
126
+ |---|---|---|
127
+ | 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
+ | Arch | Arch, Manjaro, EndeavourOS | `localctl setup --install-docker` (`pacman`) |
130
+ | SUSE | openSUSE Leap/Tumbleweed | `localctl setup --install-docker` (`zypper`) |
131
+
132
+ Other distributions (Alpine, Void, Gentoo, NixOS, immutable/atomic desktops) may work if you
133
+ bring Docker (or Podman) and Node.js yourself, but aren't targeted. WSL needs the Windows hosts
134
+ file edited too.
135
+
136
+ What's different on Linux:
137
+
138
+ - **Docker Engine** is the recommended engine (root daemon, nothing else to configure). If none is
139
+ running, `localctl setup --install-docker` installs it for the families above. It needs `sudo`,
140
+ and you must **log out and back in** afterwards so your user joins the `docker` group.
141
+ - **Podman** runs natively, no VM. The insecure-registry entry goes in
142
+ `~/.config/containers/registries.conf.d/` (no sudo, no restart). k3d needs Podman's API socket:
143
+ `systemctl --user enable --now podman.socket`. Rootless Podman also can't bind :80/:443 by
144
+ default: `sudo sysctl net.ipv4.ip_unprivileged_port_start=80`.
145
+ - **Firefox/Chromium trust** needs `certutil` (`libnss3-tools` on Debian/Ubuntu, `nss-tools` on
146
+ Fedora). `setup` prints the command for your distro; install it and re-run `mkcert -install`.
147
+
148
+ Hit a problem? [Open an issue](https://github.com/psilvmoreira/localctl/issues/new).
149
+
96
150
  ## Installation
97
151
 
98
152
  ```sh
@@ -106,7 +160,9 @@ the local image registry, trusts the `*.local.test` certificate, and updates you
106
160
  is idempotent: run it again any time to repair your environment.
107
161
 
108
162
  You'll see up to two one-time prompts during setup: **mkcert** asking to trust its local
109
- certificate authority, and **sudo/Administrator** to write the `*.local.test` hosts entries.
163
+ certificate authority, and **admin rights** to write the `*.local.test` hosts entries (your
164
+ `sudo` password on macOS, a UAC prompt on Windows). Approve both: without the hosts entries, apps
165
+ run but their addresses don't resolve.
110
166
 
111
167
  **Pre-releases** are published under the `beta` tag: `npm install -g @localctl/cli@beta`.
112
168
 
@@ -120,7 +176,8 @@ Update available: 0.2.0 -> 0.3.0
120
176
  Run npm install -g @localctl/cli to update.
121
177
  ```
122
178
 
123
- Update with `npm install -g @localctl/cli`. The check never slows a command down, is skipped in
179
+ Update with `npm install -g @localctl/cli`. On a pre-release you're also told about newer
180
+ versions on your own channel (e.g. `beta`). The check never slows a command down, is skipped in
124
181
  CI, and can be turned off with `LOCALCTL_NO_UPDATE_CHECK=1`.
125
182
 
126
183
  <details>
@@ -204,6 +261,16 @@ localctl app down
204
261
 
205
262
  This removes only that app. The shared cluster keeps running for everything else.
206
263
 
264
+ <details>
265
+ <summary><b>App is ready but <code>https://&lt;app&gt;.local.test</code> doesn't open?</b></summary>
266
+
267
+ If `localctl app status` shows `1/1` but the browser can't reach the address, the hosts file
268
+ entry is missing: the `sudo` or UAC prompt was declined or missed. Run `localctl hosts sync` and
269
+ approve the prompt. More cases, with cause and fix:
270
+ [**Troubleshooting**](https://psilvmoreira.github.io/localctl/troubleshooting/).
271
+
272
+ </details>
273
+
207
274
  > **Tip:** no app of your own yet? Clone the repo and try a ready-made example:
208
275
  > [`node-app`](https://github.com/psilvmoreira/localctl/tree/master/examples/node-app) (Node.js + Postgres),
209
276
  > [`python-app`](https://github.com/psilvmoreira/localctl/tree/master/examples/python-app) (Python + Redis), or
@@ -211,7 +278,8 @@ This removes only that app. The shared cluster keeps running for everything else
211
278
 
212
279
  ## Commands
213
280
 
214
- Run `localctl <command> --help` for every option.
281
+ Run `localctl <command> --help` for every option, or see the full
282
+ [**CLI reference**](https://psilvmoreira.github.io/localctl/cli-reference/).
215
283
 
216
284
  <details open>
217
285
  <summary><b>Machine setup</b></summary>
@@ -220,7 +288,7 @@ Run `localctl <command> --help` for every option.
220
288
  |---|---|
221
289
  | `localctl setup` | Install or repair the infrastructure: container engine, cluster, TLS, hosts. Idempotent. |
222
290
  | `localctl doctor` | Health check of the active project's environment. |
223
- | `localctl hosts sync` / `list` | Manage the `*.local.test` entries in your hosts file. |
291
+ | `localctl hosts sync` / `list` | Write or list the `*.local.test` entries in your hosts file. Asks for admin rights when needed. |
224
292
  | `localctl uninstall [-y]` | Remove every cluster, registry, hosts entry, certificate and state file `localctl` created. |
225
293
 
226
294
  </details>
@@ -235,7 +303,7 @@ Run `localctl <command> --help` for every option.
235
303
  | `localctl app down` | Stop the dev loop and remove the app. Keeps its data. |
236
304
  | `localctl app reload [-a <name>]` | Force a rebuild and redeploy without waiting for a file change. |
237
305
  | `localctl app status [-a <name>] [-A]` | One app's detail inside its repository, or a table of every app. |
238
- | `localctl app logs <name> [-f]` | Show or follow an app's logs. |
306
+ | `localctl app logs [name] [-f]` | Show or follow an app's logs. Inside its repository the name is optional. |
239
307
  | `localctl app exec [cmd...]` | Open a shell (default `sh`) in the app's pod. |
240
308
  | `localctl app prune [-y]` | Delete leftover data (volumes, secrets) of apps already taken down. |
241
309
  | `localctl app secrets set <KEY> [value]` | Store a secret as a Kubernetes Secret, never in `config.json`. |
@@ -324,6 +392,7 @@ The full documentation lives at **[psilvmoreira.github.io/localctl](https://psil
324
392
  | [Adding tools](https://psilvmoreira.github.io/localctl/adding-tools/) | Write your own per-app or cluster-wide addon |
325
393
  | [Troubleshooting](https://psilvmoreira.github.io/localctl/troubleshooting/) | Known issues, with cause and fix |
326
394
  | [Uninstalling](https://psilvmoreira.github.io/localctl/uninstalling/) | Full teardown reference |
395
+ | [Contributing](https://psilvmoreira.github.io/localctl/contributing/) | Local setup, tests, pull requests, dependency policy, docs site |
327
396
  | [Releasing](https://psilvmoreira.github.io/localctl/releasing/) | How versions are cut and published |
328
397
 
329
398
  ## Releases and versioning
@@ -334,16 +403,21 @@ The full documentation lives at **[psilvmoreira.github.io/localctl](https://psil
334
403
  with notes and the package tarball.
335
404
  - Versions follow [Semantic Versioning](https://semver.org). Before `1.0.0`, minor versions may
336
405
  contain breaking changes.
337
- - npm releases are published from GitHub Actions with
406
+ - Every pull request shows a **release preview**: the version that merging it would publish,
407
+ including any unreleased commits already on `master`.
408
+ - npm releases are published from GitHub Actions through
409
+ [trusted publishing](https://docs.npmjs.com/trusted-publishers) (no npm tokens) with
338
410
  [provenance](https://docs.npmjs.com/generating-provenance-statements). The npm package page links
339
411
  each version to the exact commit and workflow run that built it.
412
+ - Dependencies and GitHub Actions are kept up to date by Dependabot, with every action pinned to a
413
+ commit SHA.
340
414
 
341
415
  ## Contributing
342
416
 
343
417
  Issues and pull requests are welcome. The full guide (constraints, dependency policy, working on
344
418
  the docs site) is in [**Contributing**](https://psilvmoreira.github.io/localctl/contributing/).
345
419
 
346
- 1. Fork the repository and create a branch.
420
+ 1. Fork the repository and create a branch from an up-to-date `master`.
347
421
  2. Make your change and run the tests:
348
422
  ```sh
349
423
  cd cli
@@ -352,15 +426,19 @@ the docs site) is in [**Contributing**](https://psilvmoreira.github.io/localctl/
352
426
  npm run test:pack # the published package installs and runs
353
427
  ```
354
428
  3. Open a pull request with a [Conventional Commits](https://www.conventionalcommits.org/) title.
355
- The title decides the next version:
429
+ PRs are squash-merged, so the title decides the next version. GitHub pre-fills it from the
430
+ branch name, which fails the title check: edit it.
356
431
 
357
432
  | Title | Release |
358
433
  |---|---|
359
- | `fix: ...` | Patch (`0.2.0` โ†’ `0.2.1`) |
360
- | `feat: ...` | Minor (`0.2.1` โ†’ `0.3.0`) |
361
- | `feat!: ...` | Major (`0.3.0` โ†’ `1.0.0`) |
434
+ | `fix: ...`, `perf: ...` | Patch (`0.4.0` โ†’ `0.4.1`) |
435
+ | `feat: ...` | Minor (`0.4.1` โ†’ `0.5.0`) |
436
+ | `feat!: ...` | Major (`0.5.0` โ†’ `1.0.0`) |
362
437
  | `docs:` `chore:` `ci:` `refactor:` `test:` | No release |
363
438
 
439
+ 4. Check the **release preview** on the `pr-title` check and wait for every check to pass
440
+ (title, tests on Linux/macOS/Windows ร— Node 18/22, docs build, non-empty diff).
441
+
364
442
  <details>
365
443
  <summary><b>Repository layout</b></summary>
366
444
 
@@ -376,8 +454,10 @@ localctl/
376
454
  โ”œโ”€โ”€ cluster/manifests/ cluster-level manifests (TLS store, addon namespace)
377
455
  โ”œโ”€โ”€ schema/ JSON Schema for .local/config.json
378
456
  โ”œโ”€โ”€ examples/ ready-to-run sample apps
379
- โ”œโ”€โ”€ docs/ documentation site (MkDocs Material, deployed to GitHub Pages)
380
- โ””โ”€โ”€ bin/ bootstrap and uninstall scripts for source installs
457
+ โ”œโ”€โ”€ docs/ documentation pages (MkDocs Material, deployed to GitHub Pages)
458
+ โ”œโ”€โ”€ overrides/ docs site template overrides (landing page)
459
+ โ”œโ”€โ”€ bin/ bootstrap and uninstall scripts for source installs
460
+ โ””โ”€โ”€ .github/ CI, release and docs workflows, Dependabot config
381
461
  ```
382
462
 
383
463
  </details>
@@ -390,8 +470,10 @@ npm uninstall -g @localctl/cli # the CLI itself
390
470
  ```
391
471
 
392
472
  `localctl uninstall` asks for confirmation (skip with `-y`) and is safe to re-run. It leaves the
393
- shared tools (`k3d`, `kubectl`, `tilt`, `mkcert`, `node`) and mkcert's root CA installed, since
394
- other projects may use them. It prints the exact commands to remove them too. Installed from
473
+ shared tools (`k3d`, `kubectl`, `tilt`, `mkcert`, `helm`, `node`) and mkcert's root CA installed,
474
+ since other projects may use them. It prints the exact commands to remove them for your OS
475
+ (`brew uninstall ...` on macOS; `winget uninstall ...` plus the `~.localctl ools` folder for
476
+ Tilt on Windows). Installed from
395
477
  source with the bootstrap script? Use `./bin/uninstall.sh` or `./bin/uninstall.ps1` instead.
396
478
  Details: [**Uninstalling**](https://psilvmoreira.github.io/localctl/uninstalling/).
397
479
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@localctl/cli",
3
- "version": "0.4.1",
3
+ "version": "0.5.0-beta.1",
4
4
  "description": "localctl - local Kubernetes dev platform CLI",
5
5
  "keywords": [
6
6
  "kubernetes",
@@ -48,6 +48,11 @@
48
48
  "node": ">=18"
49
49
  },
50
50
  "localctl": {
51
+ "platforms": {
52
+ "darwin": "tested",
53
+ "win32": "tested",
54
+ "linux": "experimental"
55
+ },
51
56
  "containerEngines": {
52
57
  "podman": "tested",
53
58
  "docker": "experimental",
@@ -1,5 +1,7 @@
1
1
  const { commandExists, capture } = require('../lib/exec');
2
- const { detectEngine, warnIfExperimental } = require('../lib/engine');
2
+ const { detectEngine, warnIfExperimental, warnIfExperimentalPlatform } = require('../lib/engine');
3
+ const { ensurePodmanSocket, blockedPrivilegedPorts, engineMemoryMb } = require('../lib/podman');
4
+ const { DEFAULT_HTTP_PORT, DEFAULT_HTTPS_PORT } = require('../lib/constants');
3
5
  const { getActive, clusterNameFor } = require('../lib/profileStore');
4
6
  const ui = require('../lib/ui');
5
7
  const logger = require('../lib/logger');
@@ -21,6 +23,7 @@ module.exports = (program) => {
21
23
  .description('Check the local environment for missing/broken pieces')
22
24
  .action(() => {
23
25
  logger.step('Checking environment...');
26
+ warnIfExperimentalPlatform();
24
27
  const detected = detectEngine();
25
28
  check(`container engine available${detected ? ` (${detected.engine})` : ''}`, () => Boolean(detected));
26
29
  if (detected) warnIfExperimental(detected.engine);
@@ -38,6 +41,18 @@ module.exports = (program) => {
38
41
  const res = capture('k3d', ['cluster', 'list']);
39
42
  return res.stdout.split('\n').some((line) => line.trim().split(/\s+/)[0] === activeCluster);
40
43
  });
44
+ if (process.platform === 'linux' && detected && detected.engine === 'podman') {
45
+ check('podman API socket available (needed by k3d)', () => ensurePodmanSocket() === null);
46
+ check('rootless podman can bind :80/:443', () => blockedPrivilegedPorts([DEFAULT_HTTP_PORT, DEFAULT_HTTPS_PORT]).length === 0);
47
+ }
48
+ if (detected) {
49
+ // One k3s cluster idles around 0.6-0.7 GB, and apps/addons come on top. Below ~3 GB the
50
+ // engine's VM starts swapping and the API server times out.
51
+ const mb = engineMemoryMb(detected.bin);
52
+ if (mb !== null) {
53
+ check(`engine has at least 3 GB of memory (${(mb / 1024).toFixed(1)} GB available)`, () => mb >= 3000);
54
+ }
55
+ }
41
56
  check('kubectl context reachable', () => capture('kubectl', ['cluster-info']).status === 0);
42
57
  logger.step(`Fix any ${ui.cross} above, or run "localctl setup" (or "localctl profiles switch ${active}" for a non-default project) to (re)install/repair.`);
43
58
  });
@@ -3,8 +3,17 @@ const { syncHosts } = require('../lib/hostsFile');
3
3
  const { setActive } = require('../lib/profileStore');
4
4
  const { DEFAULT_CLUSTER_NAME, DEFAULT_HTTP_PORT, DEFAULT_HTTPS_PORT, DOMAIN, REGISTRY_HOST_PORT } = require('../lib/constants');
5
5
  const logger = require('../lib/logger');
6
+ const { detectEngine } = require('../lib/engine');
7
+ const { installDocker } = require('../lib/linuxDistro');
6
8
 
7
- function setup() {
9
+ function setup(opts) {
10
+ if (opts.installDocker) {
11
+ if (process.platform !== 'linux') {
12
+ throw new Error('--install-docker is only available on Linux. Install Docker Desktop or Podman Desktop instead.');
13
+ }
14
+ if (detectEngine()) logger.success('A container engine is already running, nothing to install');
15
+ else installDocker();
16
+ }
8
17
  provisionCluster({
9
18
  clusterName: DEFAULT_CLUSTER_NAME,
10
19
  httpPort: DEFAULT_HTTP_PORT,
@@ -32,5 +41,6 @@ module.exports = (program) => {
32
41
  program
33
42
  .command('setup')
34
43
  .description('One-click local infra setup for the default project: container engine, tools, k3d cluster, TLS, hosts')
44
+ .option('--install-docker', 'Linux only: install Docker Engine first if no container engine is running (needs sudo)')
35
45
  .action(setup);
36
46
  };
@@ -24,7 +24,7 @@ async function uninstall(opts) {
24
24
  console.log('This will remove everything "localctl setup"/"localctl profiles" set up:');
25
25
  console.log(` - Every project's k3d cluster (${projects.map((p) => p.cluster).join(', ')}) and everything deployed in them`);
26
26
  console.log(' - Their local image registry containers');
27
- console.log(' - Podman VM insecure-registry config (Podman only, if present)');
27
+ console.log(' - Podman insecure-registry config (Podman only, if present)');
28
28
  console.log(' - The *.local.test block in your hosts file');
29
29
  console.log(' - Any background dev loop `localctl app up` left running, and its state');
30
30
  console.log(' - ~/.localctl/certs, the app registry state, and the project registry (~/.localctl/profiles.json)');
@@ -94,6 +94,8 @@ async function uninstall(opts) {
94
94
  if (process.platform === 'win32') {
95
95
  console.log(' winget uninstall k3d.k3d Kubernetes.kubectl FiloSottile.mkcert Helm.Helm # only if nothing else needs them');
96
96
  console.log(' Remove-Item -Recurse -Force ~\\.localctl\\tools # tilt, if localctl installed it');
97
+ } else if (process.platform === 'linux') {
98
+ console.log(' rm -rf ~/.localctl/tools # k3d, kubectl, tilt, mkcert and helm, if localctl installed them');
97
99
  } else {
98
100
  console.log(' brew uninstall k3d kubectl tilt mkcert helm # only if nothing else needs them');
99
101
  }
@@ -3,11 +3,12 @@ const fs = require('fs');
3
3
  const path = require('path');
4
4
  const yaml = require('js-yaml');
5
5
  const { run, capture, sleepSync, commandExists } = require('./exec');
6
- const { detectEngine, warnIfExperimental } = require('./engine');
7
- const { installIfMissing, installTiltWindows } = require('./toolInstall');
8
- const { ensureInsecureRegistry } = require('./podman');
6
+ const { detectEngine, warnIfExperimental, warnIfExperimentalPlatform } = require('./engine');
7
+ const { installIfMissing, installTiltWindows, nssInstallHint } = require('./toolInstall');
8
+ const { ensureInsecureRegistry, ensurePodmanSocket, blockedPrivilegedPorts } = require('./podman');
9
9
  const { DOMAIN } = require('./constants');
10
10
  const logger = require('./logger');
11
+ const { detectDistro, dockerInstallSteps } = require('./linuxDistro');
11
12
 
12
13
  // Cluster-level manifests: bundled under cli/assets/ in the npm package (copied there by
13
14
  // scripts/sync-assets.js at pack time), or read straight from the repo root in a git checkout.
@@ -16,6 +17,15 @@ const MANIFESTS_DIR = fs.existsSync(BUNDLED_MANIFESTS_DIR)
16
17
  ? BUNDLED_MANIFESTS_DIR
17
18
  : path.resolve(__dirname, '..', '..', '..', 'cluster', 'manifests');
18
19
 
20
+ function isWsl() {
21
+ if (process.platform !== 'linux') return false;
22
+ try {
23
+ return /microsoft/i.test(fs.readFileSync('/proc/version', 'utf8'));
24
+ } catch (e) {
25
+ return false;
26
+ }
27
+ }
28
+
19
29
  function registryNameFor(clusterName) {
20
30
  return `${clusterName}-registry`;
21
31
  }
@@ -27,16 +37,78 @@ function clusterExists(clusterName) {
27
37
  .includes(clusterName);
28
38
  }
29
39
 
40
+ // k3d writes the API server as https://0.0.0.0:<port> into kubeconfig. Linux and Windows treat
41
+ // 0.0.0.0 as "this host", but macOS refuses to connect to it ("can't assign requested address"),
42
+ // so every kubectl call fails. Selects the cluster's context and points it at 127.0.0.1 instead,
43
+ // which also repairs clusters created before this fix.
44
+ function useClusterContext(clusterName) {
45
+ const context = `k3d-${clusterName}`;
46
+ run('kubectl', ['config', 'use-context', context], { stdio: 'ignore' });
47
+ const server = capture('kubectl', [
48
+ 'config', 'view', '--minify', '-o', 'jsonpath={.clusters[0].cluster.server}',
49
+ ]).stdout;
50
+ if (server.includes('//0.0.0.0:')) {
51
+ run('kubectl', [
52
+ 'config', 'set-cluster', context, `--server=${server.replace('//0.0.0.0:', '//127.0.0.1:')}`,
53
+ ], { stdio: 'ignore' });
54
+ }
55
+ }
56
+
57
+ // Generated fresh per call, not a static file: every Main project needs its own cluster name,
58
+ // ports, and registry mirror name baked in.
59
+ function buildK3dConfig({ clusterName, httpPort, httpsPort, registryName }) {
60
+ return {
61
+ apiVersion: 'k3d.io/v1alpha5',
62
+ kind: 'Simple',
63
+ metadata: { name: clusterName },
64
+ servers: 1,
65
+ agents: 0,
66
+ ports: [
67
+ { port: `${httpPort}:80`, nodeFilters: ['loadbalancer'] },
68
+ { port: `${httpsPort}:443`, nodeFilters: ['loadbalancer'] },
69
+ ],
70
+ options: {
71
+ k3s: {
72
+ // metrics-server only feeds `kubectl top` and HPAs, which a local dev loop doesn't use;
73
+ // dropping it saves memory. servicelb must stay: it's what opens :80/:443 on the node for
74
+ // Traefik's LoadBalancer Service, so disabling it would break every ingress.
75
+ extraArgs: [{ arg: '--disable=metrics-server', nodeFilters: ['server:*'] }],
76
+ },
77
+ },
78
+ // Plain container + mirror config, not k3d's built-in `registries.create`: that hard-codes
79
+ // attaching to a network literally named "bridge", which Docker has by default but Podman
80
+ // does not, breaking cluster creation there.
81
+ registries: {
82
+ config: yaml.dump({ mirrors: { [`${registryName}:5000`]: { endpoint: [`http://${registryName}:5000`] } } }),
83
+ },
84
+ };
85
+ }
86
+
30
87
  // Brings up (idempotently) one Main project's cluster: container engine + tool checks, the k3d
31
88
  // cluster itself (own ports, since two clusters can't both bind host :80/:443), its own local
32
89
  // registry container, and the shared *.local.test TLS cert wired into its Traefik. Used by both
33
90
  // `localctl setup` (the "default" profile, unchanged ports/names from before multi-project
34
91
  // support) and `localctl profiles new`/`switch` (any other Main project).
35
92
  function provisionCluster({ clusterName, httpPort, httpsPort, registryPort }) {
93
+ warnIfExperimentalPlatform();
94
+ if (isWsl()) {
95
+ logger.warn('WSL detected: browsers on Windows read C:\\Windows\\System32\\drivers\\etc\\hosts, not this distro\'s /etc/hosts, and WSL may regenerate /etc/hosts on restart. Add the *.local.test entries to the Windows hosts file too (localctl prints them), or run localctl from Windows instead.');
96
+ }
36
97
  logger.step('Detecting container engine...');
37
98
  const detected = detectEngine();
38
99
  if (!detected) {
39
- throw new Error('No running container engine found. Start Docker Desktop, Podman, or Rancher Desktop and re-run.');
100
+ let linuxHint = '';
101
+ 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 ')}`;
107
+ } else {
108
+ linuxHint += '\nInstall Docker Engine for your distro: https://docs.docker.com/engine/install/';
109
+ }
110
+ }
111
+ throw new Error(`No running container engine found. Start Docker Desktop, Podman, or Rancher Desktop and re-run.${linuxHint}`);
40
112
  }
41
113
  const { engine, bin: engineBin } = detected;
42
114
  logger.success(`Using container engine: ${engine} (${engineBin} CLI)`);
@@ -47,6 +119,14 @@ function provisionCluster({ clusterName, httpPort, httpsPort, registryPort }) {
47
119
  // exist, but re-running it per registry port is cheap and idempotent.
48
120
  if (engine === 'podman') {
49
121
  ensureInsecureRegistry(registryPort);
122
+ if (process.platform === 'linux') {
123
+ const socketProblem = ensurePodmanSocket();
124
+ if (socketProblem) logger.warn(socketProblem);
125
+ const blocked = blockedPrivilegedPorts([httpPort, httpsPort]);
126
+ if (blocked.length) {
127
+ logger.warn(`Rootless Podman can't bind host port(s) ${blocked.join(', ')}. Allow it with: sudo sysctl net.ipv4.ip_unprivileged_port_start=80 (add it to /etc/sysctl.d/ to persist).`);
128
+ }
129
+ }
50
130
  }
51
131
 
52
132
  installIfMissing('k3d', 'k3d', 'k3d.k3d');
@@ -54,39 +134,58 @@ function provisionCluster({ clusterName, httpPort, httpsPort, registryPort }) {
54
134
  installIfMissing('tilt', 'tilt', installTiltWindows);
55
135
  installIfMissing('mkcert', 'mkcert', 'FiloSottile.mkcert');
56
136
  installIfMissing('helm', 'helm', 'Helm.Helm');
137
+ // mkcert can only add its CA to Firefox's trust store when NSS's certutil is present. It's
138
+ // optional (Safari/Chrome don't need it), so a failed or impossible install - e.g. no Homebrew -
139
+ // only warns. macOS installs it automatically; Linux needs sudo, so it only prints the command.
140
+ if (!commandExists('certutil')) {
141
+ if (process.platform === 'darwin') {
142
+ if (commandExists('brew')) {
143
+ try {
144
+ installIfMissing('certutil', 'nss');
145
+ } catch (e) {
146
+ logger.warn(`Couldn't install nss (${e.message}). Firefox won't trust the local CA until you run "brew install nss && mkcert -install".`);
147
+ }
148
+ } else {
149
+ logger.warn('Homebrew not found, skipping nss. Firefox won\'t trust the local CA until certutil is installed and "mkcert -install" is re-run.');
150
+ logger.info('Tip: install Homebrew (https://brew.sh) so localctl can install missing tools for you, then run "brew install nss".');
151
+ }
152
+ } else if (process.platform === 'linux') {
153
+ logger.warn(`certutil not found, so Firefox/Chromium won't trust the local CA. Install it with: ${nssInstallHint()} - then re-run "mkcert -install".`);
154
+ }
155
+ }
57
156
 
58
157
  const registryName = registryNameFor(clusterName);
59
158
 
60
159
  if (clusterExists(clusterName)) {
61
160
  logger.success(`k3d cluster '${clusterName}' already exists`);
161
+ // A reboot or engine restart leaves the cluster stopped; `start` is a no-op when it's running.
162
+ run('k3d', ['cluster', 'start', clusterName, '--wait'], { stdio: 'ignore', allowFail: true });
62
163
  } else {
63
164
  logger.step(`Creating k3d cluster '${clusterName}'...`);
64
- // Generated fresh per call, not a static file: every Main project needs its own cluster
65
- // name, ports, and registry mirror name baked in.
66
- const k3dConfig = {
67
- apiVersion: 'k3d.io/v1alpha5',
68
- kind: 'Simple',
69
- metadata: { name: clusterName },
70
- servers: 1,
71
- agents: 0,
72
- ports: [
73
- { port: `${httpPort}:80`, nodeFilters: ['loadbalancer'] },
74
- { port: `${httpsPort}:443`, nodeFilters: ['loadbalancer'] },
75
- ],
76
- // Plain container + mirror config, not k3d's built-in `registries.create`: that hard-codes
77
- // attaching to a network literally named "bridge", which Docker has by default but Podman
78
- // does not, breaking cluster creation there.
79
- registries: {
80
- config: yaml.dump({ mirrors: { [`${registryName}:5000`]: { endpoint: [`http://${registryName}:5000`] } } }),
81
- },
82
- };
165
+ const k3dConfig = buildK3dConfig({ clusterName, httpPort, httpsPort, registryName });
83
166
  const tmpConfig = path.join(os.tmpdir(), `localctl-k3d-config-${clusterName}.yaml`);
84
167
  fs.writeFileSync(tmpConfig, yaml.dump(k3dConfig));
85
168
  run('k3d', ['cluster', 'create', '--config', tmpConfig, '--wait']);
86
169
  fs.rmSync(tmpConfig, { force: true });
87
170
  logger.success('Cluster created');
88
171
  }
89
- run('kubectl', ['config', 'use-context', `k3d-${clusterName}`], { stdio: 'ignore' });
172
+ useClusterContext(clusterName);
173
+
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);
188
+ }
90
189
 
91
190
  const registryNetwork = `k3d-${clusterName}`;
92
191
  const existingNames = capture(engineBin, ['ps', '-a', '--format', '{{.Names}}']).stdout.split('\n');
@@ -165,7 +264,7 @@ function ensureClusterUp(clusterName, profileName) {
165
264
  const fix = profileName === 'default' ? 'localctl setup' : `localctl profiles switch ${profileName}`;
166
265
  throw new Error(`No cluster running for project "${profileName}". Run "${fix}" to start it.`);
167
266
  }
168
- run('kubectl', ['config', 'use-context', `k3d-${clusterName}`], { stdio: 'ignore' });
267
+ useClusterContext(clusterName);
169
268
  }
170
269
 
171
- module.exports = { provisionCluster, teardownCluster, clusterExists, registryNameFor, ensureClusterUp };
270
+ module.exports = { buildK3dConfig, provisionCluster, teardownCluster, clusterExists, registryNameFor, ensureClusterUp };
package/src/lib/engine.js CHANGED
@@ -4,8 +4,8 @@ const logger = require('./logger');
4
4
  // Support status per engine lives in cli/package.json ("localctl.containerEngines"), so marking an
5
5
  // engine as tested is a one-word config change, not a code change. "tested" means verified end to
6
6
  // end; anything else (or an engine missing from the map) gets the experimental warning.
7
- const { localctl: { containerEngines = {} } = {} } = require('../../package.json');
8
- const ENGINE_LABELS = { docker: 'Docker Desktop', podman: 'Podman', 'rancher-desktop': 'Rancher Desktop' };
7
+ const { localctl: { containerEngines = {}, platforms = {} } = {} } = require('../../package.json');
8
+ const ENGINE_LABELS = { docker: 'Docker', podman: 'Podman', 'rancher-desktop': 'Rancher Desktop' };
9
9
  const ISSUES_URL = 'https://github.com/psilvmoreira/localctl/issues';
10
10
 
11
11
  // Returns { engine, bin } - `engine` names what's actually running (docker/podman/rancher-desktop),
@@ -33,4 +33,14 @@ function warnIfExperimental(engine) {
33
33
  logger.warn(`If something breaks, please report it: ${ISSUES_URL}`);
34
34
  }
35
35
 
36
- module.exports = { detectEngine, warnIfExperimental };
36
+ const PLATFORM_LABELS = { darwin: 'macOS', win32: 'Windows', linux: 'Linux' };
37
+
38
+ // Same idea as warnIfExperimental(), for the operating system: platform status lives in
39
+ // cli/package.json ("localctl.platforms").
40
+ function warnIfExperimentalPlatform() {
41
+ if (!platforms[process.platform] || platforms[process.platform] === 'tested') return;
42
+ logger.warn(`${PLATFORM_LABELS[process.platform] || process.platform} support is experimental - it hasn't been through a full test cycle yet.`);
43
+ logger.warn(`If something breaks, please report it: ${ISSUES_URL}`);
44
+ }
45
+
46
+ module.exports = { detectEngine, warnIfExperimental, warnIfExperimentalPlatform };
@@ -24,6 +24,20 @@ function listApps() {
24
24
  return Object.keys(registry);
25
25
  }
26
26
 
27
+ // The Windows counterpart of `sudo cp`: copies src over the hosts file from an elevated
28
+ // PowerShell, which raises a UAC prompt. Returns false if the prompt is declined or the copy
29
+ // fails. The inner command goes through -EncodedCommand so paths need no nested quoting.
30
+ function copyElevatedWindows(src) {
31
+ const quote = (s) => `'${s.replace(/'/g, "''")}'`;
32
+ const inner = `Copy-Item -LiteralPath ${quote(src)} -Destination ${quote(HOSTS_PATH)} -Force`;
33
+ const encoded = Buffer.from(inner, 'utf16le').toString('base64');
34
+ const res = spawnSync('powershell', [
35
+ '-NoProfile', '-NonInteractive', '-Command',
36
+ `Start-Process powershell -Verb RunAs -Wait -WindowStyle Hidden -ArgumentList '-NoProfile','-NonInteractive','-EncodedCommand','${encoded}'`,
37
+ ], { stdio: 'ignore' });
38
+ return res.status === 0;
39
+ }
40
+
27
41
  function writeHosts(next, current) {
28
42
  if (next === current) {
29
43
  logger.info('Hosts file already up to date.');
@@ -38,8 +52,13 @@ function writeHosts(next, current) {
38
52
  const tmp = path.join(os.tmpdir(), 'localctl-hosts-new');
39
53
  fs.writeFileSync(tmp, next);
40
54
  if (process.platform === 'win32') {
41
- logger.warn('Need Administrator rights to update the hosts file. Run in an elevated PowerShell:');
42
- console.log(` Copy-Item "${tmp}" "${HOSTS_PATH}" -Force`);
55
+ logger.warn('Need Administrator rights to update the hosts file. Approve the Windows prompt to continue.');
56
+ if (copyElevatedWindows(tmp) && fs.readFileSync(HOSTS_PATH, 'utf8') === next) {
57
+ logger.success(`Updated ${HOSTS_PATH}`);
58
+ } else {
59
+ logger.warn('Hosts file not updated, so *.local.test addresses won\'t resolve. Run in an elevated PowerShell:');
60
+ console.log(` Copy-Item "${tmp}" "${HOSTS_PATH}" -Force`);
61
+ }
43
62
  } else {
44
63
  logger.warn('Need sudo to update the hosts file.');
45
64
  const res = spawnSync('sudo', ['cp', tmp, HOSTS_PATH], { stdio: 'inherit' });
@@ -0,0 +1,102 @@
1
+ const fs = require('fs');
2
+ const os = require('os');
3
+ const path = require('path');
4
+ const { run, commandExists } = require('./exec');
5
+ const logger = require('./logger');
6
+
7
+ // Families localctl knows how to give exact commands for. Anything else is "unknown" and gets
8
+ // generic guidance instead of a guess.
9
+ const FAMILY_BY_ID = [
10
+ ['debian', ['debian', 'ubuntu']],
11
+ ['rhel', ['fedora', 'rhel', 'centos', 'rocky', 'almalinux']],
12
+ ['arch', ['arch']],
13
+ ['suse', ['suse', 'opensuse']],
14
+ ['alpine', ['alpine']],
15
+ ];
16
+
17
+ // Reads ID and ID_LIKE from os-release, so derivatives resolve to their parent (Mint, Pop!_OS ->
18
+ // debian; Rocky, Alma -> rhel; Manjaro -> arch).
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' };
25
+ }
26
+ const value = (key) => {
27
+ const m = release.match(new RegExp(`^${key}=(.*)$`, 'm'));
28
+ return m ? m[1].replace(/"/g, '').trim() : '';
29
+ };
30
+ const id = value('ID') || 'unknown';
31
+ const ids = [id, ...value('ID_LIKE').split(/\s+/)].filter(Boolean);
32
+ const match = FAMILY_BY_ID.find(([, names]) => names.some((n) => ids.includes(n)));
33
+ return { id, family: match ? match[0] : 'unknown' };
34
+ }
35
+
36
+ function hasSystemd() {
37
+ return fs.existsSync('/run/systemd/system');
38
+ }
39
+
40
+ function isRoot() {
41
+ return typeof process.getuid === 'function' && process.getuid() === 0;
42
+ }
43
+
44
+ // What `localctl setup --install-docker` would do, as printable steps. Shown in the "no engine"
45
+ // error too, so the user can run them by hand. Returns null for families without a known recipe.
46
+ function dockerInstallSteps(family) {
47
+ const sudo = isRoot() ? '' : 'sudo ';
48
+ const user = os.userInfo().username;
49
+ let install;
50
+ if (family === 'debian' || family === 'rhel') {
51
+ install = [`curl -fsSL https://get.docker.com -o get-docker.sh && ${sudo}sh get-docker.sh`];
52
+ } else if (family === 'arch') {
53
+ install = [`${sudo}pacman -S --noconfirm docker`];
54
+ } else if (family === 'suse') {
55
+ install = [`${sudo}zypper --non-interactive install docker`];
56
+ } else {
57
+ return null;
58
+ }
59
+ const steps = [...install];
60
+ if (hasSystemd()) steps.push(`${sudo}systemctl enable --now docker`);
61
+ if (!isRoot()) steps.push(`${sudo}usermod -aG docker ${user}`);
62
+ return steps;
63
+ }
64
+
65
+ // Runs dockerInstallSteps() for real. The download goes to a temp file first (never piped into a
66
+ // shell) and Docker's official script is the only remote code involved. Needs sudo, so it only
67
+ // ever runs on an explicit `--install-docker`.
68
+ function installDocker() {
69
+ const { family, id } = detectDistro();
70
+ if (!dockerInstallSteps(family)) {
71
+ 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
+ }
73
+ if (!commandExists('curl') && (family === 'debian' || family === 'rhel')) {
74
+ throw new Error('curl is needed to download the Docker install script. Install it and re-run.');
75
+ }
76
+ logger.step(`Installing Docker Engine (${id}). This needs sudo and will prompt for your password...`);
77
+ const sudo = isRoot() ? [] : ['sudo'];
78
+ const exec = (cmd, args) => run(sudo.length ? sudo[0] : cmd, sudo.length ? [cmd, ...args] : args);
79
+
80
+ if (family === 'debian' || family === 'rhel') {
81
+ const script = path.join(os.tmpdir(), 'localctl-get-docker.sh');
82
+ run('curl', ['-fsSL', 'https://get.docker.com', '-o', script]);
83
+ try {
84
+ exec('sh', [script]);
85
+ } finally {
86
+ fs.rmSync(script, { force: true });
87
+ }
88
+ } else if (family === 'arch') {
89
+ exec('pacman', ['-S', '--noconfirm', 'docker']);
90
+ } else if (family === 'suse') {
91
+ exec('zypper', ['--non-interactive', 'install', 'docker']);
92
+ }
93
+ if (hasSystemd()) exec('systemctl', ['enable', '--now', 'docker']);
94
+ if (!isRoot()) exec('usermod', ['-aG', 'docker', os.userInfo().username]);
95
+
96
+ logger.success('Docker installed.');
97
+ if (!isRoot()) {
98
+ logger.warn('Group membership only applies to new sessions: log out and back in (or run "newgrp docker"), then re-run "localctl setup".');
99
+ }
100
+ }
101
+
102
+ module.exports = { detectDistro, hasSystemd, dockerInstallSteps, installDocker };
package/src/lib/podman.js CHANGED
@@ -1,8 +1,46 @@
1
+ const fs = require('fs');
2
+ const os = require('os');
3
+ const path = require('path');
1
4
  const { run, capture } = require('./exec');
2
5
  const logger = require('./logger');
3
6
 
4
7
  const CONF_PATH = '/etc/containers/registries.conf.d/999-local-dev.conf';
5
8
 
9
+ // Native Podman (Linux) has no VM: its registries.conf lives on the host. Rootless Podman reads
10
+ // the per-user drop-in directory (no sudo needed); root reads the system one.
11
+ function nativeConfPath() {
12
+ if (typeof process.getuid === 'function' && process.getuid() === 0) return CONF_PATH;
13
+ const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
14
+ return path.join(configHome, 'containers', 'registries.conf.d', '999-local-dev.conf');
15
+ }
16
+
17
+ // Linux native Podman: no `podman machine`. Containers-image re-reads registries.conf on every
18
+ // invocation, so unlike the VM case there's nothing to restart.
19
+ function ensureInsecureRegistryNative(hostPort) {
20
+ const file = nativeConfPath();
21
+ const current = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
22
+ if (current.includes(`location = "localhost:${hostPort}"`)) {
23
+ logger.success(`Podman already trusts localhost:${hostPort} as an insecure registry`);
24
+ return;
25
+ }
26
+ logger.step(`Marking localhost:${hostPort} as an insecure registry for Podman (${file})...`);
27
+ fs.mkdirSync(path.dirname(file), { recursive: true });
28
+ const next = `${current.trimEnd()}\n\n${registryBlock(hostPort)}`.trim().concat('\n');
29
+ fs.writeFileSync(file, next);
30
+ logger.success('Podman registry config updated');
31
+ }
32
+
33
+ function teardownInsecureRegistryNative() {
34
+ const file = nativeConfPath();
35
+ if (!fs.existsSync(file)) return;
36
+ fs.rmSync(file, { force: true });
37
+ logger.success(`Removed insecure-registry config ${file}`);
38
+ }
39
+
40
+ function isNativePodman() {
41
+ return process.platform === 'linux';
42
+ }
43
+
6
44
  function machineName() {
7
45
  const res = capture('podman', ['machine', 'list', '--format', '{{.Name}}']);
8
46
  const [first] = res.stdout.split('\n').filter(Boolean);
@@ -34,7 +72,10 @@ function registryBlock(hostPort) {
34
72
  // something tried to use it. One file, one block per port ever provisioned on this machine.
35
73
  function ensureInsecureRegistry(hostPort) {
36
74
  const machine = machineName();
37
- if (!machine) return;
75
+ if (!machine) {
76
+ if (isNativePodman()) ensureInsecureRegistryNative(hostPort);
77
+ return;
78
+ }
38
79
 
39
80
  const current = capture('podman', ['machine', 'ssh', machine, `cat ${CONF_PATH} 2>/dev/null`]).stdout;
40
81
  if (current.includes(`location = "localhost:${hostPort}"`)) {
@@ -57,7 +98,10 @@ function ensureInsecureRegistry(hostPort) {
57
98
 
58
99
  function teardownInsecureRegistry() {
59
100
  const machine = machineName();
60
- if (!machine) return;
101
+ if (!machine) {
102
+ if (isNativePodman()) teardownInsecureRegistryNative();
103
+ return;
104
+ }
61
105
 
62
106
  const exists = capture('podman', ['machine', 'ssh', machine, `test -f ${CONF_PATH} && echo yes`]).stdout;
63
107
  if (exists.trim() !== 'yes') return;
@@ -69,4 +113,50 @@ function teardownInsecureRegistry() {
69
113
  logger.success('Podman VM config removed and machine restarted');
70
114
  }
71
115
 
72
- module.exports = { ensureInsecureRegistry, teardownInsecureRegistry };
116
+ // k3d talks to the container engine through the Docker API socket. Docker exposes it by default;
117
+ // Podman only when its socket service is running, and k3d finds it through DOCKER_HOST. Points
118
+ // DOCKER_HOST at Podman's socket when it exists, and returns a problem description (or null).
119
+ function ensurePodmanSocket() {
120
+ if (process.env.DOCKER_HOST) return null;
121
+ const res = capture('podman', ['info', '--format', '{{.Host.RemoteSocket.Path}}']);
122
+ const socket = res.status === 0 ? res.stdout.replace(/^unix:\/\//, '') : '';
123
+ if (socket && fs.existsSync(socket)) {
124
+ process.env.DOCKER_HOST = `unix://${socket}`;
125
+ logger.info(`Using the Podman socket at ${socket} (DOCKER_HOST)`);
126
+ return null;
127
+ }
128
+ return 'Podman\'s API socket isn\'t running, which k3d needs. Start it with: systemctl --user enable --now podman.socket';
129
+ }
130
+
131
+ // Rootless Podman can't bind host ports below net.ipv4.ip_unprivileged_port_start (1024 by
132
+ // default), and the cluster maps host :80/:443. Returns the ports that will fail to bind.
133
+ function blockedPrivilegedPorts(ports) {
134
+ const rootless = capture('podman', ['info', '--format', '{{.Host.Security.Rootless}}']).stdout === 'true';
135
+ if (!rootless) return [];
136
+ let start = 1024;
137
+ try {
138
+ start = parseInt(fs.readFileSync('/proc/sys/net/ipv4/ip_unprivileged_port_start', 'utf8'), 10);
139
+ } catch (e) {
140
+ // keep the default
141
+ }
142
+ return ports.filter((p) => p < start);
143
+ }
144
+
145
+ // Memory (MB) available to containers: the Podman VM on macOS/Windows, the host on native Linux.
146
+ // Returns null when it can't be determined. Docker Desktop's VM size comes from `docker info`.
147
+ function engineMemoryMb(engineBin) {
148
+ const res = capture(engineBin, ['info', '--format', '{{.MemTotal}}']);
149
+ const bytes = parseInt(res.stdout, 10);
150
+ if (res.status === 0 && Number.isFinite(bytes) && bytes > 0) return Math.round(bytes / 1024 / 1024);
151
+ const info = capture(engineBin, ['info', '--format', '{{.Host.MemTotal}}']);
152
+ const hostBytes = parseInt(info.stdout, 10);
153
+ return info.status === 0 && Number.isFinite(hostBytes) && hostBytes > 0 ? Math.round(hostBytes / 1024 / 1024) : null;
154
+ }
155
+
156
+ module.exports = {
157
+ engineMemoryMb,
158
+ ensureInsecureRegistry,
159
+ teardownInsecureRegistry,
160
+ ensurePodmanSocket,
161
+ blockedPrivilegedPorts,
162
+ };
@@ -1,7 +1,9 @@
1
1
  const fs = require('fs');
2
+ const os = require('os');
2
3
  const path = require('path');
3
- const { commandExists, run } = require('./exec');
4
+ const { commandExists, run, capture } = require('./exec');
4
5
  const logger = require('./logger');
6
+ const { detectDistro } = require('./linuxDistro');
5
7
  const { HOME_STATE_DIR } = require('./paths');
6
8
 
7
9
  // Tools that have no package-manager package on a platform get downloaded here instead. The
@@ -65,6 +67,141 @@ function installTiltWindows() {
65
67
  logger.success(`tilt installed in ${TOOLS_DIR} (added to your PATH for new terminals)`);
66
68
  }
67
69
 
70
+
71
+ // ---- Linux ----
72
+ // There's no single package manager to lean on across distros, so missing tools are fetched as
73
+ // the vendors' own release binaries straight into TOOLS_DIR (no sudo, no distro packages). That
74
+ // directory is on this process's PATH via addToolsDirToPath(), so setup carries on immediately.
75
+ // Needs only curl (and tar for tilt/helm), which every mainstream distro ships.
76
+
77
+ const LINUX_ARCH = { x64: 'amd64', arm64: 'arm64' };
78
+
79
+ function linuxArch() {
80
+ const arch = LINUX_ARCH[process.arch];
81
+ if (!arch) throw new Error(`Unsupported CPU architecture "${process.arch}". Install the tool manually.`);
82
+ return arch;
83
+ }
84
+
85
+ function requireLinuxDownloadTools(...tools) {
86
+ const missing = tools.filter((t) => !commandExists(t));
87
+ if (missing.length) {
88
+ throw new Error(`${missing.join(' and ')} ${missing.length > 1 ? 'are' : 'is'} needed to download missing tools. Install ${missing.length > 1 ? 'them' : 'it'} with your package manager and re-run.`);
89
+ }
90
+ }
91
+
92
+ function download(url, dest) {
93
+ logger.info(`Downloading ${url}`);
94
+ run('curl', ['-fsSL', '--retry', '3', '-o', dest, url]);
95
+ }
96
+
97
+ // Final path segment of the URL a redirect lands on, e.g. ".../releases/tag/v0.33.0" -> "v0.33.0".
98
+ function latestGitHubTag(repo) {
99
+ const res = capture('curl', [
100
+ '-fsSLI', '-o', '/dev/null', '-w', '%{url_effective}',
101
+ `https://github.com/${repo}/releases/latest`,
102
+ ]);
103
+ const tag = res.stdout.split('/').pop();
104
+ if (res.status !== 0 || !/^v?\d/.test(tag)) throw new Error(`Couldn't resolve the latest ${repo} release.`);
105
+ return tag;
106
+ }
107
+
108
+ function makeExecutable(file) {
109
+ fs.chmodSync(file, 0o755);
110
+ }
111
+
112
+ function installBinaryLinux(bin, url) {
113
+ fs.mkdirSync(TOOLS_DIR, { recursive: true });
114
+ const dest = path.join(TOOLS_DIR, bin);
115
+ download(url, dest);
116
+ makeExecutable(dest);
117
+ }
118
+
119
+ // Downloads a .tar.gz and moves one file out of it into TOOLS_DIR.
120
+ function installFromTarballLinux(bin, url, innerPath) {
121
+ fs.mkdirSync(TOOLS_DIR, { recursive: true });
122
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), `localctl-${bin}-`));
123
+ try {
124
+ const archive = path.join(tmp, 'archive.tar.gz');
125
+ download(url, archive);
126
+ run('tar', ['-xzf', archive, '-C', tmp]);
127
+ const dest = path.join(TOOLS_DIR, bin);
128
+ fs.copyFileSync(path.join(tmp, innerPath), dest);
129
+ makeExecutable(dest);
130
+ } finally {
131
+ fs.rmSync(tmp, { recursive: true, force: true });
132
+ }
133
+ }
134
+
135
+ const LINUX_INSTALLERS = {
136
+ k3d() {
137
+ installBinaryLinux('k3d', `https://github.com/k3d-io/k3d/releases/latest/download/k3d-linux-${linuxArch()}`);
138
+ },
139
+ kubectl() {
140
+ const version = capture('curl', ['-fsSL', 'https://dl.k8s.io/release/stable.txt']).stdout;
141
+ if (!/^v\d/.test(version)) throw new Error("Couldn't resolve the latest kubectl release.");
142
+ installBinaryLinux('kubectl', `https://dl.k8s.io/release/${version}/bin/linux/${linuxArch()}/kubectl`);
143
+ },
144
+ mkcert() {
145
+ installBinaryLinux('mkcert', `https://dl.filippo.io/mkcert/latest?for=linux/${linuxArch()}`);
146
+ },
147
+ helm() {
148
+ const version = capture('curl', ['-fsSL', 'https://get.helm.sh/helm-latest-version']).stdout;
149
+ if (!/^v\d/.test(version)) throw new Error("Couldn't resolve the latest helm release.");
150
+ const arch = linuxArch();
151
+ installFromTarballLinux('helm', `https://get.helm.sh/helm-${version}-linux-${arch}.tar.gz`, `linux-${arch}/helm`);
152
+ },
153
+ tilt() {
154
+ const version = latestGitHubTag('tilt-dev/tilt').replace(/^v/, '');
155
+ const arch = process.arch === 'arm64' ? 'arm64' : 'x86_64';
156
+ installFromTarballLinux('tilt', `https://github.com/tilt-dev/tilt/releases/download/v${version}/tilt.${version}.linux.${arch}.tar.gz`, 'tilt');
157
+ },
158
+ };
159
+
160
+ // Tools land in TOOLS_DIR, which localctl puts on its own PATH, but a terminal opened later (to
161
+ // run `kubectl` by hand, say) wouldn't see them. Persist it in the shell's rc file, with the same
162
+ // marker bin/bootstrap.sh uses so bin/uninstall.sh cleans it up. bash and zsh only: other shells
163
+ // get the line to add themselves.
164
+ function persistToolsDirOnPath() {
165
+ const shell = path.basename(process.env.SHELL || '');
166
+ const rcName = { zsh: '.zshrc', bash: '.bashrc' }[shell];
167
+ const line = 'export PATH="$HOME/.localctl/tools:$PATH"';
168
+ if (!rcName) {
169
+ logger.info(`Add ${TOOLS_DIR} to your PATH to use these tools from your own terminal: ${line}`);
170
+ return;
171
+ }
172
+ const rc = path.join(os.homedir(), rcName);
173
+ const current = fs.existsSync(rc) ? fs.readFileSync(rc, 'utf8') : '';
174
+ if (current.includes('.localctl/tools')) return;
175
+ fs.appendFileSync(rc, `\n# Added by localctl bootstrap\n${line}\n`);
176
+ logger.info(`Added ${TOOLS_DIR} to PATH in ~/${rcName} - open a new terminal to pick it up.`);
177
+ }
178
+
179
+ function installLinux(bin) {
180
+ const installer = LINUX_INSTALLERS[bin];
181
+ if (!installer) {
182
+ throw new Error(`"${bin}" is missing and automatic install isn't supported for it on Linux. Install it with your package manager.`);
183
+ }
184
+ requireLinuxDownloadTools('curl', ...(bin === 'helm' || bin === 'tilt' ? ['tar'] : []));
185
+ logger.step(`Installing ${bin} from its official release (no sudo, into ${TOOLS_DIR})...`);
186
+ installer();
187
+ addToolsDirToPath();
188
+ persistToolsDirOnPath();
189
+ logger.success(`${bin} installed in ${TOOLS_DIR}`);
190
+ }
191
+
192
+ // Distro-specific command for the NSS tools (certutil), which mkcert needs to trust its CA in
193
+ // Firefox/Chromium. Not installed automatically: it needs sudo, and it's optional.
194
+ function nssInstallHint() {
195
+ const { family } = detectDistro();
196
+ return {
197
+ debian: 'sudo apt install libnss3-tools',
198
+ rhel: 'sudo dnf install nss-tools',
199
+ arch: 'sudo pacman -S nss',
200
+ suse: 'sudo zypper install mozilla-nss-tools',
201
+ alpine: 'sudo apk add nss-tools',
202
+ }[family] || "your distro's NSS tools package (provides certutil)";
203
+ }
204
+
68
205
  // macId: Homebrew formula name. winId: winget package id, or a function that installs the tool
69
206
  // when winget doesn't have it.
70
207
  function installIfMissing(bin, macId, winId) {
@@ -92,9 +229,11 @@ function installIfMissing(bin, macId, winId) {
92
229
  '--accept-package-agreements', '--accept-source-agreements',
93
230
  ]);
94
231
  addWingetLinksToPath();
232
+ } else if (process.platform === 'linux') {
233
+ installLinux(bin);
95
234
  } else {
96
235
  throw new Error(`"${bin}" is missing and automatic install isn't supported on this platform. Install it manually.`);
97
236
  }
98
237
  }
99
238
 
100
- module.exports = { installIfMissing, installTiltWindows, addToolsDirToPath, TOOLS_DIR };
239
+ module.exports = { installIfMissing, installTiltWindows, addToolsDirToPath, nssInstallHint, TOOLS_DIR };