@localctl/cli 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +431 -0
  3. package/assets/manifests/namespace-addons.yaml +4 -0
  4. package/assets/manifests/tls-store.yaml +8 -0
  5. package/bin/localctl.js +2 -0
  6. package/package.json +54 -0
  7. package/schema/app.schema.json +127 -0
  8. package/src/commands/addons.js +56 -0
  9. package/src/commands/doctor.js +43 -0
  10. package/src/commands/down.js +35 -0
  11. package/src/commands/exec.js +15 -0
  12. package/src/commands/generateManifests.js +12 -0
  13. package/src/commands/hosts.js +23 -0
  14. package/src/commands/logs.js +45 -0
  15. package/src/commands/new.js +55 -0
  16. package/src/commands/profiles.js +180 -0
  17. package/src/commands/prune.js +49 -0
  18. package/src/commands/registryInfo.js +18 -0
  19. package/src/commands/reload.js +44 -0
  20. package/src/commands/secrets.js +75 -0
  21. package/src/commands/setup.js +36 -0
  22. package/src/commands/status.js +136 -0
  23. package/src/commands/uninstall.js +105 -0
  24. package/src/commands/up.js +123 -0
  25. package/src/index.js +36 -0
  26. package/src/lib/addonDirs.js +43 -0
  27. package/src/lib/addonLoader.js +190 -0
  28. package/src/lib/addons/cluster/logging/addon.yaml +46 -0
  29. package/src/lib/addons/cluster/logging/config/all-logs.json +21 -0
  30. package/src/lib/addons/cluster/monitoring/addon.yaml +33 -0
  31. package/src/lib/addons/mongo/addon.yaml +18 -0
  32. package/src/lib/addons/postgres/addon.yaml +39 -0
  33. package/src/lib/addons/redis/addon.yaml +29 -0
  34. package/src/lib/appSecrets.js +60 -0
  35. package/src/lib/clusterAddons.js +59 -0
  36. package/src/lib/clusterProvision.js +170 -0
  37. package/src/lib/configSchema.js +103 -0
  38. package/src/lib/constants.js +16 -0
  39. package/src/lib/engine.js +20 -0
  40. package/src/lib/exec.js +77 -0
  41. package/src/lib/hostsFile.js +79 -0
  42. package/src/lib/loadConfig.js +51 -0
  43. package/src/lib/logger.js +9 -0
  44. package/src/lib/manifestGen.js +210 -0
  45. package/src/lib/paths.js +41 -0
  46. package/src/lib/podman.js +72 -0
  47. package/src/lib/profileStore.js +134 -0
  48. package/src/lib/projectContext.js +32 -0
  49. package/src/lib/prompt.js +97 -0
  50. package/src/lib/toolInstall.js +30 -0
  51. package/src/lib/ui.js +50 -0
  52. package/src/lib/workloadReadiness.js +35 -0
  53. package/templates/Tiltfile.tmpl +35 -0
@@ -0,0 +1,77 @@
1
+ const { spawnSync, spawn } = require('child_process');
2
+ const fs = require('fs');
3
+ const net = require('net');
4
+
5
+ function run(cmd, args, opts = {}) {
6
+ const res = spawnSync(cmd, args, { stdio: 'inherit', ...opts });
7
+ if (res.error) throw res.error;
8
+ if (res.status !== 0 && !opts.allowFail) {
9
+ throw new Error(`Command failed (${res.status}): ${cmd} ${args.join(' ')}`);
10
+ }
11
+ return res;
12
+ }
13
+
14
+ function capture(cmd, args, opts = {}) {
15
+ const res = spawnSync(cmd, args, { encoding: 'utf8', ...opts });
16
+ return {
17
+ status: res.status,
18
+ stdout: (res.stdout || '').trim(),
19
+ stderr: (res.stderr || '').trim(),
20
+ };
21
+ }
22
+
23
+ function commandExists(cmd) {
24
+ const probe = process.platform === 'win32' ? 'where' : 'which';
25
+ const res = spawnSync(probe, [cmd], { stdio: 'ignore' });
26
+ return res.status === 0;
27
+ }
28
+
29
+ // Blocking sleep with no native dependency - used for poll loops (e.g. waiting on a CRD to
30
+ // register) where an async setTimeout would need restructuring the whole command as a promise
31
+ // chain for no real benefit.
32
+ function sleepSync(ms) {
33
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
34
+ }
35
+
36
+ // Starts a fully detached child that outlives this process (and the terminal it's running in) -
37
+ // used for `localctl up --detach`. stdout/stderr go to logFile since there's no terminal to
38
+ // inherit once the parent exits. Returns the child's pid.
39
+ function spawnDetached(cmd, args, { cwd, env, logFile } = {}) {
40
+ const out = fs.openSync(logFile, 'a');
41
+ const child = spawn(cmd, args, {
42
+ cwd,
43
+ env,
44
+ detached: true,
45
+ stdio: ['ignore', out, out],
46
+ });
47
+ child.unref();
48
+ return child.pid;
49
+ }
50
+
51
+ // True if a process with this pid is currently running (doesn't actually signal it).
52
+ function isRunning(pid) {
53
+ try {
54
+ process.kill(pid, 0);
55
+ return true;
56
+ } catch (e) {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ // Asks the OS for a free TCP port (binds to port 0, reads back what it picked). Used to give each
62
+ // app's Tilt instance its own web UI port - Tilt otherwise always binds the same default port,
63
+ // so two apps running `localctl up` at once (the whole point of detached mode) collide outright,
64
+ // and the resulting error doesn't explain why.
65
+ function getFreePort() {
66
+ return new Promise((resolve, reject) => {
67
+ const srv = net.createServer();
68
+ srv.unref();
69
+ srv.on('error', reject);
70
+ srv.listen(0, '127.0.0.1', () => {
71
+ const { port } = srv.address();
72
+ srv.close(() => resolve(port));
73
+ });
74
+ });
75
+ }
76
+
77
+ module.exports = { run, capture, commandExists, sleepSync, spawnDetached, isRunning, getFreePort };
@@ -0,0 +1,79 @@
1
+ const fs = require('fs');
2
+ const os = require('os');
3
+ const path = require('path');
4
+ const { spawnSync } = require('child_process');
5
+ const logger = require('./logger');
6
+ const { ensureHomeState, APPS_REGISTRY_PATH } = require('./paths');
7
+ const { DOMAIN } = require('./constants');
8
+
9
+ const MARK_START = '# >>> localctl managed block >>>';
10
+ const MARK_END = '# <<< localctl managed block <<<';
11
+ const HOSTS_PATH =
12
+ process.platform === 'win32' ? 'C:\\Windows\\System32\\drivers\\etc\\hosts' : '/etc/hosts';
13
+
14
+ function registerApp(subdomain) {
15
+ ensureHomeState();
16
+ const registry = JSON.parse(fs.readFileSync(APPS_REGISTRY_PATH, 'utf8'));
17
+ registry[subdomain] = true;
18
+ fs.writeFileSync(APPS_REGISTRY_PATH, JSON.stringify(registry, null, 2));
19
+ }
20
+
21
+ function listApps() {
22
+ ensureHomeState();
23
+ const registry = JSON.parse(fs.readFileSync(APPS_REGISTRY_PATH, 'utf8'));
24
+ return Object.keys(registry);
25
+ }
26
+
27
+ function writeHosts(next, current) {
28
+ if (next === current) {
29
+ logger.info('Hosts file already up to date.');
30
+ return;
31
+ }
32
+
33
+ try {
34
+ fs.writeFileSync(HOSTS_PATH, next);
35
+ logger.success(`Updated ${HOSTS_PATH}`);
36
+ } catch (e) {
37
+ if (e.code !== 'EACCES' && e.code !== 'EPERM') throw e;
38
+ const tmp = path.join(os.tmpdir(), 'localctl-hosts-new');
39
+ fs.writeFileSync(tmp, next);
40
+ 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`);
43
+ } else {
44
+ logger.warn('Need sudo to update the hosts file.');
45
+ const res = spawnSync('sudo', ['cp', tmp, HOSTS_PATH], { stdio: 'inherit' });
46
+ if (res.status === 0) logger.success(`Updated ${HOSTS_PATH}`);
47
+ else logger.warn(`Run manually: sudo cp ${tmp} ${HOSTS_PATH}`);
48
+ }
49
+ }
50
+ }
51
+
52
+ function syncHosts() {
53
+ const apps = listApps();
54
+ const lines = [
55
+ MARK_START,
56
+ ...apps.map((s) => `127.0.0.1 ${s}.${DOMAIN}`),
57
+ `127.0.0.1 ${DOMAIN}`,
58
+ MARK_END,
59
+ ];
60
+ const block = lines.join(os.EOL);
61
+
62
+ const current = fs.readFileSync(HOSTS_PATH, 'utf8');
63
+ const re = new RegExp(`${MARK_START}[\\s\\S]*?${MARK_END}`, 'm');
64
+ const next = re.test(current)
65
+ ? current.replace(re, block)
66
+ : `${current.trimEnd()}${os.EOL}${block}${os.EOL}`;
67
+
68
+ writeHosts(next, current);
69
+ }
70
+
71
+ // Used by uninstall: strips the whole managed block, leaving the rest of the hosts file alone.
72
+ function clearHosts() {
73
+ const current = fs.readFileSync(HOSTS_PATH, 'utf8');
74
+ const re = new RegExp(`(${os.EOL}|\\n)?${MARK_START}[\\s\\S]*?${MARK_END}(${os.EOL}|\\n)?`, 'm');
75
+ const next = current.replace(re, os.EOL);
76
+ writeHosts(next, current);
77
+ }
78
+
79
+ module.exports = { syncHosts, clearHosts, registerApp, listApps };
@@ -0,0 +1,51 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { appConfigSchema } = require('./configSchema');
4
+ const { CONFIG_PATH } = require('./paths');
5
+ const { resolveProfileName } = require('./projectContext');
6
+ const { getProfile, clusterNameFor, namespaceScopeFor } = require('./profileStore');
7
+
8
+ function loadConfig(configPath = CONFIG_PATH) {
9
+ if (!fs.existsSync(configPath)) {
10
+ throw new Error(`No config found at ${configPath}. Run "localctl app new" first.`);
11
+ }
12
+ const raw = JSON.parse(fs.readFileSync(configPath, 'utf8'));
13
+ const parsed = appConfigSchema.safeParse(raw);
14
+ if (!parsed.success) {
15
+ const issues = parsed.error.issues
16
+ .map((i) => ` - ${i.path.join('.') || '(root)'}: ${i.message}`)
17
+ .join('\n');
18
+ throw new Error(`Invalid .local/config.json:\n${issues}`);
19
+ }
20
+ const cfg = parsed.data;
21
+ cfg.namespace = cfg.namespace || cfg.name;
22
+ cfg.subdomain = cfg.subdomain || cfg.name;
23
+
24
+ // Which project this app belongs to - walking up from the app's own directory for the nearest
25
+ // .local/project.json, same lookup as git finding .git (see projectContext.js). No
26
+ // project.json anywhere above it -> the app belongs to "default", today's single cluster,
27
+ // unchanged.
28
+ const appDir = path.dirname(path.dirname(configPath));
29
+ const profileName = resolveProfileName(appDir);
30
+ const profile = getProfile(profileName);
31
+ if (!profile) {
32
+ throw new Error(
33
+ `Profile "${profileName}" (from .local/project.json) isn't registered on this machine. ` +
34
+ `Run "localctl profiles new ${profileName}" first.`,
35
+ );
36
+ }
37
+
38
+ // A namespace-level project shares its parent's cluster, isolated only by a namespace/subdomain
39
+ // prefix - a Main (cluster-isolation) project owns its whole cluster, nothing to prefix.
40
+ const scope = namespaceScopeFor(profileName);
41
+ if (scope) {
42
+ cfg.namespace = `${scope}-${cfg.namespace}`;
43
+ cfg.subdomain = `${scope}-${cfg.subdomain}`;
44
+ }
45
+
46
+ cfg.profile = profileName;
47
+ cfg.clusterName = clusterNameFor(profileName);
48
+ return cfg;
49
+ }
50
+
51
+ module.exports = { loadConfig };
@@ -0,0 +1,9 @@
1
+ const chalk = require('chalk');
2
+
3
+ module.exports = {
4
+ info: (msg) => console.log(chalk.cyan('info'), msg),
5
+ warn: (msg) => console.log(chalk.yellow('warn'), msg),
6
+ error: (msg) => console.error(chalk.red('error'), msg),
7
+ success: (msg) => console.log(chalk.green('ok'), msg),
8
+ step: (msg) => console.log(chalk.magenta('>'), msg),
9
+ };
@@ -0,0 +1,210 @@
1
+ const yaml = require('js-yaml');
2
+ const { DOMAIN } = require('./constants');
3
+ const { loadAddon } = require('./addonLoader');
4
+ const { findAddonDir, listAddonNames } = require('./addonDirs');
5
+ const { secretNameFor } = require('./appSecrets');
6
+
7
+ function buildNamespace(cfg) {
8
+ return { apiVersion: 'v1', kind: 'Namespace', metadata: { name: cfg.namespace } };
9
+ }
10
+
11
+ // A sidecar - an extra container sharing the pod's network namespace with the main app container.
12
+ // No Service needed for the app to reach it: same pod means `localhost:<port>` already works.
13
+ function buildSidecarContainer(sidecar) {
14
+ const container = { name: sidecar.name, image: sidecar.image };
15
+ if (sidecar.command) container.command = sidecar.command;
16
+ if (sidecar.args) container.args = sidecar.args;
17
+ if (sidecar.port) container.ports = [{ name: sidecar.name, containerPort: sidecar.port }];
18
+ const envVars = Object.entries(sidecar.env || {}).map(([name, value]) => ({ name, value: String(value) }));
19
+ if (envVars.length) container.env = envVars;
20
+ return container;
21
+ }
22
+
23
+ // Defaults to a plain TCP check on the app's own port when nothing more specific is configured -
24
+ // zero-config, but still real: `kubectl rollout status` succeeding used to only mean "the process
25
+ // started," not "it's actually accepting connections." An explicit `path` upgrades it to an HTTP
26
+ // check instead.
27
+ function buildProbe(probeCfg, port, defaults) {
28
+ const check = probeCfg && probeCfg.path
29
+ ? { httpGet: { path: probeCfg.path, port } }
30
+ : { tcpSocket: { port } };
31
+ return {
32
+ ...check,
33
+ initialDelaySeconds: (probeCfg && probeCfg.initialDelaySeconds) ?? defaults.initialDelaySeconds,
34
+ periodSeconds: (probeCfg && probeCfg.periodSeconds) ?? defaults.periodSeconds,
35
+ };
36
+ }
37
+
38
+ // Blocks the main container from starting until a dependency's real Service is actually accepting
39
+ // connections - without this, a dependency that isn't ready yet when the app's own container
40
+ // starts is a race the app has to handle itself (or crash-loop until it wins it).
41
+ function buildWaitInitContainer(depType, connect) {
42
+ return {
43
+ name: `wait-for-${depType}`,
44
+ image: 'busybox:1.36',
45
+ command: ['sh', '-c', `until nc -z -w1 ${connect.host} ${connect.port}; do echo "waiting for ${depType} (${connect.host}:${connect.port})..."; sleep 1; done`],
46
+ };
47
+ }
48
+
49
+ function buildDeployment(cfg, envVars, initContainers) {
50
+ const ports = [{ name: 'http', containerPort: cfg.port }];
51
+ if (cfg.debug.enabled && cfg.debug.port) ports.push({ name: 'debug', containerPort: cfg.debug.port });
52
+
53
+ return {
54
+ apiVersion: 'apps/v1',
55
+ kind: 'Deployment',
56
+ metadata: {
57
+ name: cfg.name,
58
+ namespace: cfg.namespace,
59
+ labels: { app: cfg.name, 'localctl.dev/managed': 'true' },
60
+ },
61
+ spec: {
62
+ replicas: cfg.replicas,
63
+ selector: { matchLabels: { app: cfg.name } },
64
+ template: {
65
+ metadata: { labels: { app: cfg.name } },
66
+ spec: {
67
+ initContainers: initContainers.length ? initContainers : undefined,
68
+ containers: [
69
+ {
70
+ name: cfg.name,
71
+ // Rewritten to the local registry by Tilt's default_registry()/docker_build().
72
+ image: cfg.name,
73
+ ports,
74
+ env: envVars,
75
+ readinessProbe: buildProbe(cfg.probes.readiness, cfg.port, { initialDelaySeconds: 3, periodSeconds: 10 }),
76
+ livenessProbe: buildProbe(cfg.probes.liveness, cfg.port, { initialDelaySeconds: 10, periodSeconds: 20 }),
77
+ resources: cfg.resources
78
+ ? { requests: cfg.resources.requests, limits: cfg.resources.limits }
79
+ : undefined,
80
+ },
81
+ ...cfg.sidecars.map(buildSidecarContainer),
82
+ ],
83
+ },
84
+ },
85
+ },
86
+ };
87
+ }
88
+
89
+ function buildService(cfg) {
90
+ const ports = [{ name: 'http', port: cfg.port, targetPort: cfg.port }];
91
+ if (cfg.debug.enabled && cfg.debug.port) {
92
+ ports.push({ name: 'debug', port: cfg.debug.port, targetPort: cfg.debug.port });
93
+ }
94
+ return {
95
+ apiVersion: 'v1',
96
+ kind: 'Service',
97
+ metadata: {
98
+ name: cfg.name,
99
+ namespace: cfg.namespace,
100
+ labels: { app: cfg.name, 'localctl.dev/managed': 'true' },
101
+ },
102
+ spec: { selector: { app: cfg.name }, ports },
103
+ };
104
+ }
105
+
106
+ function buildIngress(cfg) {
107
+ const host = `${cfg.subdomain}.${DOMAIN}`;
108
+ const ingress = {
109
+ apiVersion: 'networking.k8s.io/v1',
110
+ kind: 'Ingress',
111
+ metadata: {
112
+ name: cfg.name,
113
+ namespace: cfg.namespace,
114
+ labels: { app: cfg.name, 'localctl.dev/managed': 'true' },
115
+ annotations: {
116
+ 'traefik.ingress.kubernetes.io/router.entrypoints': cfg.tls ? 'websecure' : 'web',
117
+ },
118
+ },
119
+ spec: {
120
+ rules: [
121
+ {
122
+ host,
123
+ http: {
124
+ paths: [
125
+ {
126
+ path: '/',
127
+ pathType: 'Prefix',
128
+ backend: { service: { name: cfg.name, port: { number: cfg.port } } },
129
+ },
130
+ ],
131
+ },
132
+ },
133
+ ],
134
+ },
135
+ };
136
+ // Empty secretName: Traefik falls back to the cluster's default TLSStore certificate
137
+ // (the *.local.test cert installed by bin/bootstrap.sh / bootstrap.ps1).
138
+ if (cfg.tls) ingress.spec.tls = [{ hosts: [host] }];
139
+ return ingress;
140
+ }
141
+
142
+ // Every addon type is a folder named <type>/addon.yaml, found in your own addons dir
143
+ // (~/.localctl/addons, or $LOCALCTL_ADDONS_DIR) if present, else the ones bundled with this CLI
144
+ // (see docs/adding-tools.md) - there's no hardcoded list to keep in sync, dropping in a new folder
145
+ // is enough to make a new dependency type usable, and a name in your own dir overrides a built-in
146
+ // one of the same name.
147
+ function knownAddonTypes() {
148
+ return listAddonNames();
149
+ }
150
+
151
+ function generateManifests(cfg) {
152
+ const literalEnv = { ...cfg.env };
153
+ const secretEnv = {}; // envVarName -> { secret, key }
154
+ const addonYamls = [];
155
+ const initContainers = [];
156
+
157
+ for (const dep of cfg.dependencies) {
158
+ const addonDir = findAddonDir(dep.type);
159
+ if (!addonDir) {
160
+ throw new Error(
161
+ `Unknown dependency type "${dep.type}" (no addon.yaml found for it in ~/.localctl/addons ` +
162
+ `or the CLI's built-in addons). Known types: ${knownAddonTypes().join(', ')}. See ` +
163
+ 'docs/adding-tools.md to add a new one.',
164
+ );
165
+ }
166
+ const release = `${cfg.name}-${dep.type}`;
167
+ const { manifestYaml, env, envFromSecret, connect } = loadAddon(addonDir, {
168
+ release,
169
+ namespace: cfg.namespace,
170
+ addonName: dep.type,
171
+ owner: cfg.name,
172
+ chartVersionOverride: dep.version,
173
+ extraVars: {
174
+ database: dep.database || cfg.name,
175
+ storage: dep.storage || '1Gi',
176
+ },
177
+ });
178
+ addonYamls.push(manifestYaml);
179
+ Object.assign(literalEnv, env);
180
+ Object.assign(secretEnv, envFromSecret);
181
+ if (connect) initContainers.push(buildWaitInitContainer(dep.type, connect));
182
+ }
183
+ // cfg.env (what the developer explicitly set) always wins over an addon's auto-injected default.
184
+ Object.assign(literalEnv, cfg.env);
185
+
186
+ // Your own secrets (API keys, third-party tokens - not addon-generated credentials): declared by
187
+ // name only in config, values set separately via `localctl app secrets set`, injected the same
188
+ // never-plaintext way as an addon's. configSchema.js already rejects a key appearing in both
189
+ // "env" and "secrets", so no collision to resolve here.
190
+ for (const key of cfg.secrets) {
191
+ secretEnv[key] = { secret: secretNameFor(cfg), key };
192
+ }
193
+
194
+ const envVars = [
195
+ ...Object.entries(literalEnv).map(([name, value]) => ({ name, value: String(value) })),
196
+ ...Object.entries(secretEnv).map(([name, ref]) => ({
197
+ name,
198
+ valueFrom: { secretKeyRef: { name: ref.secret, key: ref.key } },
199
+ })),
200
+ ];
201
+
202
+ const docs = [buildNamespace(cfg), buildDeployment(cfg, envVars, initContainers), buildService(cfg), buildIngress(cfg)];
203
+ const rendered = docs.map((d) => yaml.dump(d)).join('---\n');
204
+ // trimEnd() before joining: addon YAML doesn't reliably end with a trailing newline (see
205
+ // addonLoader.js), and joining on a bare '---\n' in that case glues the separator directly onto
206
+ // the previous piece's last line with no line break, corrupting the YAML.
207
+ return [rendered, ...addonYamls].map((doc) => doc.trimEnd()).join('\n---\n');
208
+ }
209
+
210
+ module.exports = { generateManifests, knownAddonTypes };
@@ -0,0 +1,41 @@
1
+ const path = require('path');
2
+ const os = require('os');
3
+ const fs = require('fs');
4
+
5
+ const APP_DIR = process.cwd();
6
+ const LOCAL_DIR = path.join(APP_DIR, '.local');
7
+ const CONFIG_PATH = path.join(LOCAL_DIR, 'config.json');
8
+ const HOME_STATE_DIR = path.join(os.homedir(), '.localctl');
9
+ const APPS_REGISTRY_PATH = path.join(HOME_STATE_DIR, 'apps.json');
10
+ const PROFILES_PATH = path.join(HOME_STATE_DIR, 'profiles.json');
11
+ const RUN_DIR = path.join(HOME_STATE_DIR, 'run');
12
+
13
+ function ensureHomeState() {
14
+ if (!fs.existsSync(HOME_STATE_DIR)) fs.mkdirSync(HOME_STATE_DIR, { recursive: true });
15
+ if (!fs.existsSync(APPS_REGISTRY_PATH)) fs.writeFileSync(APPS_REGISTRY_PATH, '{}');
16
+ }
17
+
18
+ // One {pidFile, portFile, logFile} set per app name, used by `localctl up` (detached mode),
19
+ // `localctl down`, and `localctl reload` to track/clean up/talk to a background Tilt process.
20
+ // portFile holds the random port that app's Tilt instance's web/API server is bound to (each app
21
+ // gets its own - see up.js/getFreePort), needed to address it with `tilt trigger --port <n>`.
22
+ function runFiles(name) {
23
+ if (!fs.existsSync(RUN_DIR)) fs.mkdirSync(RUN_DIR, { recursive: true });
24
+ return {
25
+ pidFile: path.join(RUN_DIR, `${name}.pid`),
26
+ portFile: path.join(RUN_DIR, `${name}.port`),
27
+ logFile: path.join(RUN_DIR, `${name}.log`),
28
+ };
29
+ }
30
+
31
+ module.exports = {
32
+ APP_DIR,
33
+ LOCAL_DIR,
34
+ CONFIG_PATH,
35
+ HOME_STATE_DIR,
36
+ APPS_REGISTRY_PATH,
37
+ PROFILES_PATH,
38
+ RUN_DIR,
39
+ ensureHomeState,
40
+ runFiles,
41
+ };
@@ -0,0 +1,72 @@
1
+ const { run, capture } = require('./exec');
2
+ const logger = require('./logger');
3
+
4
+ const CONF_PATH = '/etc/containers/registries.conf.d/999-local-dev.conf';
5
+
6
+ function machineName() {
7
+ const res = capture('podman', ['machine', 'list', '--format', '{{.Name}}']);
8
+ const [first] = res.stdout.split('\n').filter(Boolean);
9
+ return first || null;
10
+ }
11
+
12
+ function registryBlock(hostPort) {
13
+ return [
14
+ '[[registry]]',
15
+ `location = "localhost:${hostPort}"`,
16
+ 'insecure = true',
17
+ '',
18
+ '[[registry]]',
19
+ `location = "127.0.0.1:${hostPort}"`,
20
+ 'insecure = true',
21
+ '',
22
+ ].join('\n');
23
+ }
24
+
25
+ // Podman's daemon runs inside a lightweight VM on macOS/Windows - registries.conf lives inside
26
+ // that VM, not on the host, so a host-side config file can't mark our (deliberately TLS-less)
27
+ // local registry as insecure. Done before the cluster/registry even exist, because applying it
28
+ // needs a VM restart, which would otherwise kill and require restarting every container already
29
+ // running.
30
+ //
31
+ // Appends this port's block to whatever's already there instead of overwriting the file: a
32
+ // second Main project (its own registry port) provisioned after the first would otherwise replace
33
+ // the first project's trust entry outright, silently breaking its push access the next time
34
+ // something tried to use it. One file, one block per port ever provisioned on this machine.
35
+ function ensureInsecureRegistry(hostPort) {
36
+ const machine = machineName();
37
+ if (!machine) return;
38
+
39
+ const current = capture('podman', ['machine', 'ssh', machine, `cat ${CONF_PATH} 2>/dev/null`]).stdout;
40
+ if (current.includes(`location = "localhost:${hostPort}"`)) {
41
+ logger.success(`Podman VM already trusts localhost:${hostPort} as an insecure registry`);
42
+ return;
43
+ }
44
+
45
+ const next = `${current.trimEnd()}\n\n${registryBlock(hostPort)}`.trim().concat('\n');
46
+ logger.step(`Marking localhost:${hostPort} as an insecure registry inside the Podman VM...`);
47
+ run('podman', ['machine', 'ssh', machine, 'sudo mkdir -p /etc/containers/registries.conf.d']);
48
+ run('podman', ['machine', 'ssh', machine, `sudo tee ${CONF_PATH} > /dev/null`], {
49
+ stdio: ['pipe', 'inherit', 'inherit'],
50
+ input: next,
51
+ });
52
+ logger.step('Restarting Podman machine to apply...');
53
+ run('podman', ['machine', 'stop', machine], { stdio: 'ignore' });
54
+ run('podman', ['machine', 'start', machine], { stdio: 'ignore' });
55
+ logger.success('Podman machine restarted');
56
+ }
57
+
58
+ function teardownInsecureRegistry() {
59
+ const machine = machineName();
60
+ if (!machine) return;
61
+
62
+ const exists = capture('podman', ['machine', 'ssh', machine, `test -f ${CONF_PATH} && echo yes`]).stdout;
63
+ if (exists.trim() !== 'yes') return;
64
+
65
+ logger.step('Removing insecure-registry config from the Podman VM...');
66
+ run('podman', ['machine', 'ssh', machine, `sudo rm -f ${CONF_PATH}`], { stdio: 'ignore', allowFail: true });
67
+ run('podman', ['machine', 'stop', machine], { stdio: 'ignore', allowFail: true });
68
+ run('podman', ['machine', 'start', machine], { stdio: 'ignore', allowFail: true });
69
+ logger.success('Podman VM config removed and machine restarted');
70
+ }
71
+
72
+ module.exports = { ensureInsecureRegistry, teardownInsecureRegistry };
@@ -0,0 +1,134 @@
1
+ const fs = require('fs');
2
+ const { HOME_STATE_DIR, PROFILES_PATH } = require('./paths');
3
+ const { DEFAULT_CLUSTER_NAME, DEFAULT_HTTP_PORT, DEFAULT_HTTPS_PORT, REGISTRY_HOST_PORT } = require('./constants');
4
+
5
+ // ~/.localctl/profiles.json is the single source of truth for every project this machine knows
6
+ // about, and which one ad-hoc/global commands (doctor, hosts, addons) currently target.
7
+ //
8
+ // Two kinds of profile, distinguished by `isolation`:
9
+ // - "cluster" (a "Main project"): owns a real, separate k3d cluster/registry - `cluster`,
10
+ // `exclusive`, and (for a non-exclusive one, which needs its own ports since two clusters
11
+ // can't both bind host :80/:443) `httpPort`/`httpsPort`/`registryPort` all live here.
12
+ // - "namespace" (nested under exactly one Main project via `parent`): shares that Main
13
+ // project's cluster/registry/cert, isolated only by a namespace + subdomain prefix
14
+ // (`namespace`, defaulting to its own name).
15
+ //
16
+ // "default" always exists - it's today's single always-there cluster, `local-dev`.
17
+ function defaultRegistry() {
18
+ return {
19
+ active: 'default',
20
+ profiles: {
21
+ default: { isolation: 'cluster', cluster: DEFAULT_CLUSTER_NAME, exclusive: true },
22
+ },
23
+ };
24
+ }
25
+
26
+ function ensureRegistry() {
27
+ if (!fs.existsSync(HOME_STATE_DIR)) fs.mkdirSync(HOME_STATE_DIR, { recursive: true });
28
+ if (!fs.existsSync(PROFILES_PATH)) save(defaultRegistry());
29
+ }
30
+
31
+ function load() {
32
+ ensureRegistry();
33
+ return JSON.parse(fs.readFileSync(PROFILES_PATH, 'utf8'));
34
+ }
35
+
36
+ function save(data) {
37
+ fs.writeFileSync(PROFILES_PATH, JSON.stringify(data, null, 2));
38
+ }
39
+
40
+ function getProfile(name) {
41
+ return load().profiles[name] || null;
42
+ }
43
+
44
+ function upsertProfile(name, def) {
45
+ const data = load();
46
+ data.profiles[name] = def;
47
+ save(data);
48
+ }
49
+
50
+ function listProfiles() {
51
+ return load().profiles;
52
+ }
53
+
54
+ function getActive() {
55
+ return load().active;
56
+ }
57
+
58
+ function setActive(name) {
59
+ const data = load();
60
+ if (!data.profiles[name]) throw new Error(`Unknown profile "${name}". Run "localctl profiles list".`);
61
+ data.active = name;
62
+ save(data);
63
+ }
64
+
65
+ // Climbs `parent` links up to the Main (isolation: "cluster") project that actually owns a
66
+ // cluster - a namespace-level project's own entry never has one.
67
+ function mainProjectFor(name) {
68
+ const { profiles } = load();
69
+ let cur = name;
70
+ const seen = new Set();
71
+ for (;;) {
72
+ if (seen.has(cur)) throw new Error(`Circular "parent" chain detected starting at profile "${cur}".`);
73
+ seen.add(cur);
74
+ const def = profiles[cur];
75
+ if (!def) {
76
+ throw new Error(`Unknown profile "${cur}". Run "localctl profiles list", or "localctl profiles new ${cur}" to create it.`);
77
+ }
78
+ if (def.isolation === 'cluster') return { name: cur, def };
79
+ cur = def.parent;
80
+ }
81
+ }
82
+
83
+ function clusterNameFor(name) {
84
+ return mainProjectFor(name).def.cluster;
85
+ }
86
+
87
+ function portsFor(name) {
88
+ const { def } = mainProjectFor(name);
89
+ return {
90
+ httpPort: def.httpPort || DEFAULT_HTTP_PORT,
91
+ httpsPort: def.httpsPort || DEFAULT_HTTPS_PORT,
92
+ registryPort: def.registryPort || REGISTRY_HOST_PORT,
93
+ };
94
+ }
95
+
96
+ // The namespace/subdomain prefix a namespace-level project's apps get scoped under - null for a
97
+ // Main project itself (it owns the whole cluster, nothing to prefix).
98
+ function namespaceScopeFor(name) {
99
+ const def = getProfile(name);
100
+ if (!def) throw new Error(`Unknown profile "${name}". Run "localctl profiles list".`);
101
+ if (def.isolation === 'cluster') return null;
102
+ return def.namespace || name;
103
+ }
104
+
105
+ // Every namespace-level project nested under a given Main project, for `profiles list`.
106
+ function childrenOf(mainName) {
107
+ const { profiles } = load();
108
+ return Object.entries(profiles)
109
+ .filter(([, def]) => def.isolation === 'namespace' && def.parent === mainName)
110
+ .map(([name]) => name);
111
+ }
112
+
113
+ function removeProfile(name) {
114
+ const data = load();
115
+ delete data.profiles[name];
116
+ if (data.active === name) data.active = 'default';
117
+ save(data);
118
+ }
119
+
120
+ module.exports = {
121
+ load,
122
+ save,
123
+ getProfile,
124
+ upsertProfile,
125
+ removeProfile,
126
+ listProfiles,
127
+ getActive,
128
+ setActive,
129
+ mainProjectFor,
130
+ clusterNameFor,
131
+ portsFor,
132
+ namespaceScopeFor,
133
+ childrenOf,
134
+ };