@localctl/cli 0.4.2 โ 0.5.0-beta.2
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 +55 -3
- package/package.json +9 -2
- package/src/commands/doctor.js +16 -1
- package/src/commands/setup.js +11 -1
- package/src/commands/uninstall.js +6 -5
- package/src/lib/addonDirs.js +2 -1
- package/src/lib/clusterProvision.js +211 -36
- package/src/lib/engine.js +13 -3
- package/src/lib/exec.js +6 -3
- package/src/lib/hostsFile.js +4 -2
- package/src/lib/linuxDistro.js +122 -0
- package/src/lib/paths.js +5 -1
- package/src/lib/podman.js +93 -3
- package/src/lib/toolInstall.js +141 -2
package/README.md
CHANGED
|
@@ -73,14 +73,15 @@ JSON file per app.
|
|
|
73
73
|
|
|
74
74
|
| Requirement | Notes |
|
|
75
75
|
|---|---|
|
|
76
|
-
| **macOS** or **
|
|
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
78
|
| **Container engine** | [Podman Desktop](https://podman-desktop.io/) (or Docker Desktop / Rancher Desktop), running. See [supported container engines](#supported-container-engines). |
|
|
79
|
-
| **Homebrew** or **winget** | Used once by `localctl setup` to install missing tools. |
|
|
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
82
|
On Windows, Tilt isn't on winget, so it's downloaded from its GitHub release into
|
|
83
|
-
|
|
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).
|
|
84
85
|
|
|
85
86
|
### Supported container engines
|
|
86
87
|
|
|
@@ -89,12 +90,63 @@ On Windows, Tilt isn't on winget, so it's downloaded from its GitHub release int
|
|
|
89
90
|
| [Podman Desktop](https://podman-desktop.io/) | โ
Tested |
|
|
90
91
|
| [Docker Desktop](https://www.docker.com/products/docker-desktop/) | ๐งช Experimental โ setup and app deploy verified on Windows |
|
|
91
92
|
| [Rancher Desktop](https://rancherdesktop.io/) | ๐งช Experimental โ not yet tested |
|
|
93
|
+
| Docker Engine / native Podman (Linux) | ๐งช Experimental โ see [Linux](#linux-experimental) |
|
|
92
94
|
|
|
93
95
|
Experimental engines are detected and should work, since k3d only needs a Docker-compatible API,
|
|
94
96
|
but they haven't been through a full test cycle yet. `localctl setup` and `localctl doctor` print
|
|
95
97
|
a warning when one is in use. If you try one, please
|
|
96
98
|
[report how it went](https://github.com/psilvmoreira/localctl/issues/new).
|
|
97
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
|
+
|
|
98
150
|
## Installation
|
|
99
151
|
|
|
100
152
|
```sh
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@localctl/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0-beta.2",
|
|
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",
|
|
@@ -48,6 +50,11 @@
|
|
|
48
50
|
"node": ">=18"
|
|
49
51
|
},
|
|
50
52
|
"localctl": {
|
|
53
|
+
"platforms": {
|
|
54
|
+
"darwin": "tested",
|
|
55
|
+
"win32": "tested",
|
|
56
|
+
"linux": "experimental"
|
|
57
|
+
},
|
|
51
58
|
"containerEngines": {
|
|
52
59
|
"podman": "tested",
|
|
53
60
|
"docker": "experimental",
|
package/src/commands/doctor.js
CHANGED
|
@@ -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
|
});
|
package/src/commands/setup.js
CHANGED
|
@@ -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
|
};
|
|
@@ -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,
|
|
@@ -24,7 +23,7 @@ async function uninstall(opts) {
|
|
|
24
23
|
console.log('This will remove everything "localctl setup"/"localctl profiles" set up:');
|
|
25
24
|
console.log(` - Every project's k3d cluster (${projects.map((p) => p.cluster).join(', ')}) and everything deployed in them`);
|
|
26
25
|
console.log(' - Their local image registry containers');
|
|
27
|
-
console.log(' - Podman
|
|
26
|
+
console.log(' - Podman insecure-registry config (Podman only, if present)');
|
|
28
27
|
console.log(' - The *.local.test block in your hosts file');
|
|
29
28
|
console.log(' - Any background dev loop `localctl app up` left running, and its state');
|
|
30
29
|
console.log(' - ~/.localctl/certs, the app registry state, and the project registry (~/.localctl/profiles.json)');
|
|
@@ -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 =
|
|
84
|
-
const appsRegistry =
|
|
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 });
|
|
@@ -94,6 +93,8 @@ async function uninstall(opts) {
|
|
|
94
93
|
if (process.platform === 'win32') {
|
|
95
94
|
console.log(' winget uninstall k3d.k3d Kubernetes.kubectl FiloSottile.mkcert Helm.Helm # only if nothing else needs them');
|
|
96
95
|
console.log(' Remove-Item -Recurse -Force ~\\.localctl\\tools # tilt, if localctl installed it');
|
|
96
|
+
} else if (process.platform === 'linux') {
|
|
97
|
+
console.log(' rm -rf ~/.localctl/tools # k3d, kubectl, tilt, mkcert and helm, if localctl installed them');
|
|
97
98
|
} else {
|
|
98
99
|
console.log(' brew uninstall k3d kubectl tilt mkcert helm # only if nothing else needs them');
|
|
99
100
|
}
|
package/src/lib/addonDirs.js
CHANGED
|
@@ -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(
|
|
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)];
|
|
@@ -3,11 +3,13 @@ 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 { CERTS_DIR } = require('./paths');
|
|
12
|
+
const { detectDistro, dockerInstallSteps } = require('./linuxDistro');
|
|
11
13
|
|
|
12
14
|
// Cluster-level manifests: bundled under cli/assets/ in the npm package (copied there by
|
|
13
15
|
// scripts/sync-assets.js at pack time), or read straight from the repo root in a git checkout.
|
|
@@ -16,6 +18,15 @@ const MANIFESTS_DIR = fs.existsSync(BUNDLED_MANIFESTS_DIR)
|
|
|
16
18
|
? BUNDLED_MANIFESTS_DIR
|
|
17
19
|
: path.resolve(__dirname, '..', '..', '..', 'cluster', 'manifests');
|
|
18
20
|
|
|
21
|
+
function isWsl() {
|
|
22
|
+
if (process.platform !== 'linux') return false;
|
|
23
|
+
try {
|
|
24
|
+
return /microsoft/i.test(fs.readFileSync('/proc/version', 'utf8'));
|
|
25
|
+
} catch (e) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
19
30
|
function registryNameFor(clusterName) {
|
|
20
31
|
return `${clusterName}-registry`;
|
|
21
32
|
}
|
|
@@ -27,16 +38,163 @@ function clusterExists(clusterName) {
|
|
|
27
38
|
.includes(clusterName);
|
|
28
39
|
}
|
|
29
40
|
|
|
41
|
+
// k3d writes the API server as https://0.0.0.0:<port> into kubeconfig. Linux and Windows treat
|
|
42
|
+
// 0.0.0.0 as "this host", but macOS refuses to connect to it ("can't assign requested address"),
|
|
43
|
+
// so every kubectl call fails. Selects the cluster's context and points it at 127.0.0.1 instead,
|
|
44
|
+
// which also repairs clusters created before this fix.
|
|
45
|
+
function useClusterContext(clusterName) {
|
|
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
|
+
}
|
|
53
|
+
run('kubectl', ['config', 'use-context', context], { stdio: 'ignore' });
|
|
54
|
+
const server = capture('kubectl', [
|
|
55
|
+
'config', 'view', '--minify', '-o', 'jsonpath={.clusters[0].cluster.server}',
|
|
56
|
+
]).stdout;
|
|
57
|
+
if (server.includes('//0.0.0.0:')) {
|
|
58
|
+
run('kubectl', [
|
|
59
|
+
'config', 'set-cluster', context, `--server=${server.replace('//0.0.0.0:', '//127.0.0.1:')}`,
|
|
60
|
+
], { stdio: 'ignore' });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Generated fresh per call, not a static file: every Main project needs its own cluster name,
|
|
65
|
+
// ports, and registry mirror name baked in.
|
|
66
|
+
function buildK3dConfig({ clusterName, httpPort, httpsPort, registryName }) {
|
|
67
|
+
return {
|
|
68
|
+
apiVersion: 'k3d.io/v1alpha5',
|
|
69
|
+
kind: 'Simple',
|
|
70
|
+
metadata: { name: clusterName },
|
|
71
|
+
servers: 1,
|
|
72
|
+
agents: 0,
|
|
73
|
+
ports: [
|
|
74
|
+
{ port: `${httpPort}:80`, nodeFilters: ['loadbalancer'] },
|
|
75
|
+
{ port: `${httpsPort}:443`, nodeFilters: ['loadbalancer'] },
|
|
76
|
+
],
|
|
77
|
+
options: {
|
|
78
|
+
k3s: {
|
|
79
|
+
// metrics-server only feeds `kubectl top` and HPAs, which a local dev loop doesn't use;
|
|
80
|
+
// dropping it saves memory. servicelb must stay: it's what opens :80/:443 on the node for
|
|
81
|
+
// Traefik's LoadBalancer Service, so disabling it would break every ingress.
|
|
82
|
+
extraArgs: [{ arg: '--disable=metrics-server', nodeFilters: ['server:*'] }],
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
// Plain container + mirror config, not k3d's built-in `registries.create`: that hard-codes
|
|
86
|
+
// attaching to a network literally named "bridge", which Docker has by default but Podman
|
|
87
|
+
// does not, breaking cluster creation there.
|
|
88
|
+
registries: {
|
|
89
|
+
config: yaml.dump({ mirrors: { [`${registryName}:5000`]: { endpoint: [`http://${registryName}:5000`] } } }),
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
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
|
+
|
|
30
168
|
// Brings up (idempotently) one Main project's cluster: container engine + tool checks, the k3d
|
|
31
169
|
// cluster itself (own ports, since two clusters can't both bind host :80/:443), its own local
|
|
32
170
|
// registry container, and the shared *.local.test TLS cert wired into its Traefik. Used by both
|
|
33
171
|
// `localctl setup` (the "default" profile, unchanged ports/names from before multi-project
|
|
34
172
|
// support) and `localctl profiles new`/`switch` (any other Main project).
|
|
35
173
|
function provisionCluster({ clusterName, httpPort, httpsPort, registryPort }) {
|
|
174
|
+
warnIfExperimentalPlatform();
|
|
175
|
+
if (isWsl()) {
|
|
176
|
+
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.');
|
|
177
|
+
}
|
|
36
178
|
logger.step('Detecting container engine...');
|
|
37
179
|
const detected = detectEngine();
|
|
38
180
|
if (!detected) {
|
|
39
|
-
|
|
181
|
+
let linuxHint = '';
|
|
182
|
+
if (process.platform === 'linux') {
|
|
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).';
|
|
189
|
+
} else {
|
|
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/';
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
throw new Error(`No running container engine found. Start Docker Desktop, Podman, or Rancher Desktop and re-run.${linuxHint}`);
|
|
40
198
|
}
|
|
41
199
|
const { engine, bin: engineBin } = detected;
|
|
42
200
|
logger.success(`Using container engine: ${engine} (${engineBin} CLI)`);
|
|
@@ -47,6 +205,14 @@ function provisionCluster({ clusterName, httpPort, httpsPort, registryPort }) {
|
|
|
47
205
|
// exist, but re-running it per registry port is cheap and idempotent.
|
|
48
206
|
if (engine === 'podman') {
|
|
49
207
|
ensureInsecureRegistry(registryPort);
|
|
208
|
+
if (process.platform === 'linux') {
|
|
209
|
+
const socketProblem = ensurePodmanSocket();
|
|
210
|
+
if (socketProblem) logger.warn(socketProblem);
|
|
211
|
+
const blocked = blockedPrivilegedPorts([httpPort, httpsPort]);
|
|
212
|
+
if (blocked.length) {
|
|
213
|
+
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).`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
50
216
|
}
|
|
51
217
|
|
|
52
218
|
installIfMissing('k3d', 'k3d', 'k3d.k3d');
|
|
@@ -54,39 +220,53 @@ function provisionCluster({ clusterName, httpPort, httpsPort, registryPort }) {
|
|
|
54
220
|
installIfMissing('tilt', 'tilt', installTiltWindows);
|
|
55
221
|
installIfMissing('mkcert', 'mkcert', 'FiloSottile.mkcert');
|
|
56
222
|
installIfMissing('helm', 'helm', 'Helm.Helm');
|
|
223
|
+
// mkcert can only add its CA to Firefox's trust store when NSS's certutil is present. It's
|
|
224
|
+
// optional (Safari/Chrome don't need it), so a failed or impossible install - e.g. no Homebrew -
|
|
225
|
+
// only warns. macOS installs it automatically; Linux needs sudo, so it only prints the command.
|
|
226
|
+
if (!commandExists('certutil')) {
|
|
227
|
+
if (process.platform === 'darwin') {
|
|
228
|
+
if (commandExists('brew')) {
|
|
229
|
+
try {
|
|
230
|
+
installIfMissing('certutil', 'nss');
|
|
231
|
+
} catch (e) {
|
|
232
|
+
logger.warn(`Couldn't install nss (${e.message}). Firefox won't trust the local CA until you run "brew install nss && mkcert -install".`);
|
|
233
|
+
}
|
|
234
|
+
} else {
|
|
235
|
+
logger.warn('Homebrew not found, skipping nss. Firefox won\'t trust the local CA until certutil is installed and "mkcert -install" is re-run.');
|
|
236
|
+
logger.info('Tip: install Homebrew (https://brew.sh) so localctl can install missing tools for you, then run "brew install nss".');
|
|
237
|
+
}
|
|
238
|
+
} else if (process.platform === 'linux') {
|
|
239
|
+
logger.warn(`certutil not found, so Firefox/Chromium won't trust the local CA. Install it with: ${nssInstallHint()} - then re-run "mkcert -install".`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
57
242
|
|
|
58
243
|
const registryName = registryNameFor(clusterName);
|
|
59
244
|
|
|
60
245
|
if (clusterExists(clusterName)) {
|
|
61
246
|
logger.success(`k3d cluster '${clusterName}' already exists`);
|
|
247
|
+
// A reboot or engine restart leaves the cluster stopped; `start` is a no-op when it's running.
|
|
248
|
+
run('k3d', ['cluster', 'start', clusterName, '--wait'], { stdio: 'ignore', allowFail: true });
|
|
62
249
|
} else {
|
|
63
250
|
logger.step(`Creating k3d cluster '${clusterName}'...`);
|
|
64
|
-
|
|
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
|
-
};
|
|
251
|
+
const k3dConfig = buildK3dConfig({ clusterName, httpPort, httpsPort, registryName });
|
|
83
252
|
const tmpConfig = path.join(os.tmpdir(), `localctl-k3d-config-${clusterName}.yaml`);
|
|
84
253
|
fs.writeFileSync(tmpConfig, yaml.dump(k3dConfig));
|
|
85
254
|
run('k3d', ['cluster', 'create', '--config', tmpConfig, '--wait']);
|
|
86
255
|
fs.rmSync(tmpConfig, { force: true });
|
|
87
256
|
logger.success('Cluster created');
|
|
88
257
|
}
|
|
89
|
-
|
|
258
|
+
useClusterContext(clusterName);
|
|
259
|
+
|
|
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
|
+
);
|
|
269
|
+
}
|
|
90
270
|
|
|
91
271
|
const registryNetwork = `k3d-${clusterName}`;
|
|
92
272
|
const existingNames = capture(engineBin, ['ps', '-a', '--format', '{{.Names}}']).stdout.split('\n');
|
|
@@ -110,7 +290,7 @@ function provisionCluster({ clusterName, httpPort, httpsPort, registryPort }) {
|
|
|
110
290
|
// domain everywhere; only the namespace/subdomain prefix or the cluster differs).
|
|
111
291
|
logger.step(`Setting up local TLS for *.${DOMAIN}...`);
|
|
112
292
|
run('mkcert', ['-install']);
|
|
113
|
-
const certDir =
|
|
293
|
+
const certDir = CERTS_DIR;
|
|
114
294
|
fs.mkdirSync(certDir, { recursive: true });
|
|
115
295
|
const certFile = path.join(certDir, 'local-dev.pem');
|
|
116
296
|
const keyFile = path.join(certDir, 'local-dev-key.pem');
|
|
@@ -127,15 +307,10 @@ function provisionCluster({ clusterName, httpPort, httpsPort, registryPort }) {
|
|
|
127
307
|
|
|
128
308
|
// k3d's --wait only waits for node readiness, not for Traefik's helm-install job to finish
|
|
129
309
|
// registering its CRDs, so applying TLSStore any earlier races and fails intermittently.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
}
|
|
135
|
-
run('kubectl', ['wait', '--for=condition=Established', 'crd/tlsstores.traefik.io', '--timeout=60s'], { stdio: 'ignore' });
|
|
136
|
-
|
|
137
|
-
run('kubectl', ['apply', '-f', path.join(MANIFESTS_DIR, 'tls-store.yaml')]);
|
|
138
|
-
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();
|
|
139
314
|
logger.success('TLS configured, default cert wired into Traefik');
|
|
140
315
|
}
|
|
141
316
|
|
|
@@ -165,7 +340,7 @@ function ensureClusterUp(clusterName, profileName) {
|
|
|
165
340
|
const fix = profileName === 'default' ? 'localctl setup' : `localctl profiles switch ${profileName}`;
|
|
166
341
|
throw new Error(`No cluster running for project "${profileName}". Run "${fix}" to start it.`);
|
|
167
342
|
}
|
|
168
|
-
|
|
343
|
+
useClusterContext(clusterName);
|
|
169
344
|
}
|
|
170
345
|
|
|
171
|
-
module.exports = { provisionCluster, teardownCluster, clusterExists, registryNameFor, ensureClusterUp };
|
|
346
|
+
module.exports = { buildK3dConfig, useClusterContext, waitForClusterApi, 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
|
|
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
|
-
|
|
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 };
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
package/src/lib/hostsFile.js
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
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();
|
|
@@ -0,0 +1,122 @@
|
|
|
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 = 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
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (release === null) return { id: 'unknown', family: 'unknown' };
|
|
31
|
+
const value = (key) => {
|
|
32
|
+
const m = release.match(new RegExp(`^${key}=(.*)$`, 'm'));
|
|
33
|
+
return m ? m[1].replace(/"/g, '').trim() : '';
|
|
34
|
+
};
|
|
35
|
+
const id = value('ID') || 'unknown';
|
|
36
|
+
const ids = [id, ...value('ID_LIKE').split(/\s+/)].filter(Boolean);
|
|
37
|
+
const match = FAMILY_BY_ID.find(([, names]) => names.some((n) => ids.includes(n)));
|
|
38
|
+
return { id, family: match ? match[0] : 'unknown' };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function hasSystemd() {
|
|
42
|
+
return fs.existsSync('/run/systemd/system');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isRoot() {
|
|
46
|
+
return typeof process.getuid === 'function' && process.getuid() === 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// What `localctl setup --install-docker` would do, as printable steps. Shown in the "no engine"
|
|
50
|
+
// error too, so the user can run them by hand. Returns null for families without a known recipe.
|
|
51
|
+
function dockerInstallSteps(family, id) {
|
|
52
|
+
const sudo = isRoot() ? '' : 'sudo ';
|
|
53
|
+
const user = os.userInfo().username;
|
|
54
|
+
let install;
|
|
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') {
|
|
66
|
+
install = [`curl -fsSL https://get.docker.com -o get-docker.sh && ${sudo}sh get-docker.sh`];
|
|
67
|
+
} else if (family === 'arch') {
|
|
68
|
+
install = [`${sudo}pacman -S --noconfirm docker`];
|
|
69
|
+
} else if (family === 'suse') {
|
|
70
|
+
install = [`${sudo}zypper --non-interactive install docker`];
|
|
71
|
+
} else {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
const steps = [...install];
|
|
75
|
+
if (hasSystemd()) steps.push(`${sudo}systemctl enable --now docker`);
|
|
76
|
+
if (!isRoot()) steps.push(`${sudo}usermod -aG docker ${user}`);
|
|
77
|
+
return steps;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Runs dockerInstallSteps() for real. The download goes to a temp file first (never piped into a
|
|
81
|
+
// shell) and Docker's official script is the only remote code involved. Needs sudo, so it only
|
|
82
|
+
// ever runs on an explicit `--install-docker`.
|
|
83
|
+
function installDocker() {
|
|
84
|
+
const { family, id } = detectDistro();
|
|
85
|
+
if (!dockerInstallSteps(family, id)) {
|
|
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.`);
|
|
87
|
+
}
|
|
88
|
+
const viaScript = family === 'debian' || (family === 'rhel' && id === 'fedora');
|
|
89
|
+
if (!commandExists('curl') && viaScript) {
|
|
90
|
+
throw new Error('curl is needed to download the Docker install script. Install it and re-run.');
|
|
91
|
+
}
|
|
92
|
+
logger.step(`Installing Docker Engine (${id}). This needs sudo and will prompt for your password...`);
|
|
93
|
+
const sudo = isRoot() ? [] : ['sudo'];
|
|
94
|
+
const exec = (cmd, args) => run(sudo.length ? sudo[0] : cmd, sudo.length ? [cmd, ...args] : args);
|
|
95
|
+
|
|
96
|
+
if (viaScript) {
|
|
97
|
+
const script = path.join(os.tmpdir(), 'localctl-get-docker.sh');
|
|
98
|
+
run('curl', ['-fsSL', 'https://get.docker.com', '-o', script]);
|
|
99
|
+
try {
|
|
100
|
+
exec('sh', [script]);
|
|
101
|
+
} finally {
|
|
102
|
+
fs.rmSync(script, { force: true });
|
|
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']);
|
|
108
|
+
} else if (family === 'arch') {
|
|
109
|
+
exec('pacman', ['-S', '--noconfirm', 'docker']);
|
|
110
|
+
} else if (family === 'suse') {
|
|
111
|
+
exec('zypper', ['--non-interactive', 'install', 'docker']);
|
|
112
|
+
}
|
|
113
|
+
if (hasSystemd()) exec('systemctl', ['enable', '--now', 'docker']);
|
|
114
|
+
if (!isRoot()) exec('usermod', ['-aG', 'docker', os.userInfo().username]);
|
|
115
|
+
|
|
116
|
+
logger.success('Docker installed.');
|
|
117
|
+
if (!isRoot()) {
|
|
118
|
+
logger.warn('Group membership only applies to new sessions: log out and back in (or run "newgrp docker"), then re-run "localctl setup".');
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
module.exports = { detectDistro, hasSystemd, dockerInstallSteps, installDocker };
|
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
|
-
|
|
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,
|
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)
|
|
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)
|
|
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
|
-
|
|
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
|
+
};
|
package/src/lib/toolInstall.js
CHANGED
|
@@ -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 };
|