@localctl/cli 0.2.0 → 0.4.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 (52) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +398 -431
  3. package/assets/manifests/namespace-addons.yaml +4 -4
  4. package/assets/manifests/tls-store.yaml +8 -8
  5. package/bin/localctl.js +2 -2
  6. package/package.json +61 -54
  7. package/src/commands/addons.js +56 -56
  8. package/src/commands/doctor.js +44 -43
  9. package/src/commands/down.js +35 -35
  10. package/src/commands/exec.js +15 -15
  11. package/src/commands/generateManifests.js +12 -12
  12. package/src/commands/hosts.js +23 -23
  13. package/src/commands/logs.js +45 -45
  14. package/src/commands/profiles.js +180 -180
  15. package/src/commands/prune.js +49 -49
  16. package/src/commands/registryInfo.js +18 -18
  17. package/src/commands/reload.js +44 -44
  18. package/src/commands/secrets.js +75 -75
  19. package/src/commands/setup.js +36 -36
  20. package/src/commands/status.js +136 -136
  21. package/src/commands/up.js +123 -123
  22. package/src/index.js +38 -36
  23. package/src/lib/addonDirs.js +43 -43
  24. package/src/lib/addonLoader.js +190 -190
  25. package/src/lib/addons/cluster/logging/addon.yaml +46 -46
  26. package/src/lib/addons/cluster/logging/config/all-logs.json +21 -21
  27. package/src/lib/addons/cluster/monitoring/addon.yaml +33 -33
  28. package/src/lib/addons/mongo/addon.yaml +18 -18
  29. package/src/lib/addons/postgres/addon.yaml +39 -39
  30. package/src/lib/addons/redis/addon.yaml +29 -29
  31. package/src/lib/appSecrets.js +60 -60
  32. package/src/lib/clusterAddons.js +59 -59
  33. package/src/lib/clusterProvision.js +171 -170
  34. package/src/lib/configSchema.js +103 -103
  35. package/src/lib/constants.js +16 -16
  36. package/src/lib/engine.js +36 -20
  37. package/src/lib/exec.js +77 -77
  38. package/src/lib/hostsFile.js +79 -79
  39. package/src/lib/loadConfig.js +51 -51
  40. package/src/lib/logger.js +9 -9
  41. package/src/lib/manifestGen.js +210 -210
  42. package/src/lib/paths.js +41 -41
  43. package/src/lib/podman.js +72 -72
  44. package/src/lib/profileStore.js +134 -134
  45. package/src/lib/projectContext.js +32 -32
  46. package/src/lib/prompt.js +97 -97
  47. package/src/lib/toolInstall.js +30 -30
  48. package/src/lib/ui.js +50 -50
  49. package/src/lib/updateCheck.js +84 -0
  50. package/src/lib/updateCheckWorker.js +46 -0
  51. package/src/lib/workloadReadiness.js +35 -35
  52. package/templates/Tiltfile.tmpl +35 -35
@@ -1,170 +1,171 @@
1
- const os = require('os');
2
- const fs = require('fs');
3
- const path = require('path');
4
- const yaml = require('js-yaml');
5
- const { run, capture, sleepSync, commandExists } = require('./exec');
6
- const { detectEngine } = require('./engine');
7
- const { installIfMissing } = require('./toolInstall');
8
- const { ensureInsecureRegistry } = require('./podman');
9
- const { DOMAIN } = require('./constants');
10
- const logger = require('./logger');
11
-
12
- // Cluster-level manifests: bundled under cli/assets/ in the npm package (copied there by
13
- // scripts/sync-assets.js at pack time), or read straight from the repo root in a git checkout.
14
- const BUNDLED_MANIFESTS_DIR = path.resolve(__dirname, '..', '..', 'assets', 'manifests');
15
- const MANIFESTS_DIR = fs.existsSync(BUNDLED_MANIFESTS_DIR)
16
- ? BUNDLED_MANIFESTS_DIR
17
- : path.resolve(__dirname, '..', '..', '..', 'cluster', 'manifests');
18
-
19
- function registryNameFor(clusterName) {
20
- return `${clusterName}-registry`;
21
- }
22
-
23
- function clusterExists(clusterName) {
24
- return capture('k3d', ['cluster', 'list']).stdout
25
- .split('\n')
26
- .map((line) => line.trim().split(/\s+/)[0])
27
- .includes(clusterName);
28
- }
29
-
30
- // Brings up (idempotently) one Main project's cluster: container engine + tool checks, the k3d
31
- // cluster itself (own ports, since two clusters can't both bind host :80/:443), its own local
32
- // registry container, and the shared *.local.test TLS cert wired into its Traefik. Used by both
33
- // `localctl setup` (the "default" profile, unchanged ports/names from before multi-project
34
- // support) and `localctl profiles new`/`switch` (any other Main project).
35
- function provisionCluster({ clusterName, httpPort, httpsPort, registryPort }) {
36
- logger.step('Detecting container engine...');
37
- const detected = detectEngine();
38
- if (!detected) {
39
- throw new Error('No running container engine found. Start Docker Desktop, Podman, or Rancher Desktop and re-run.');
40
- }
41
- const { engine, bin: engineBin } = detected;
42
- logger.success(`Using container engine: ${engine} (${engineBin} CLI)`);
43
-
44
- // Podman-specific, and must happen before the cluster/registry exist - see lib/podman.js. The
45
- // insecure-registry trust is VM-wide, so it only needs doing once no matter how many clusters
46
- // exist, but re-running it per registry port is cheap and idempotent.
47
- if (engine === 'podman') {
48
- ensureInsecureRegistry(registryPort);
49
- }
50
-
51
- installIfMissing('k3d', 'k3d', 'k3d-io.k3d');
52
- installIfMissing('kubectl', 'kubernetes-cli', 'Kubernetes.kubectl');
53
- installIfMissing('tilt', 'tilt', 'Tilt.dev.tilt');
54
- installIfMissing('mkcert', 'mkcert', 'FiloSottile.mkcert');
55
- installIfMissing('helm', 'helm', 'Helm.Helm');
56
-
57
- const registryName = registryNameFor(clusterName);
58
-
59
- if (clusterExists(clusterName)) {
60
- logger.success(`k3d cluster '${clusterName}' already exists`);
61
- } else {
62
- logger.step(`Creating k3d cluster '${clusterName}'...`);
63
- // Generated fresh per call, not a static file: every Main project needs its own cluster
64
- // name, ports, and registry mirror name baked in.
65
- const k3dConfig = {
66
- apiVersion: 'k3d.io/v1alpha5',
67
- kind: 'Simple',
68
- metadata: { name: clusterName },
69
- servers: 1,
70
- agents: 0,
71
- ports: [
72
- { port: `${httpPort}:80`, nodeFilters: ['loadbalancer'] },
73
- { port: `${httpsPort}:443`, nodeFilters: ['loadbalancer'] },
74
- ],
75
- // Plain container + mirror config, not k3d's built-in `registries.create`: that hard-codes
76
- // attaching to a network literally named "bridge", which Docker has by default but Podman
77
- // does not, breaking cluster creation there.
78
- registries: {
79
- config: yaml.dump({ mirrors: { [`${registryName}:5000`]: { endpoint: [`http://${registryName}:5000`] } } }),
80
- },
81
- };
82
- const tmpConfig = path.join(os.tmpdir(), `localctl-k3d-config-${clusterName}.yaml`);
83
- fs.writeFileSync(tmpConfig, yaml.dump(k3dConfig));
84
- run('k3d', ['cluster', 'create', '--config', tmpConfig, '--wait']);
85
- fs.rmSync(tmpConfig, { force: true });
86
- logger.success('Cluster created');
87
- }
88
- run('kubectl', ['config', 'use-context', `k3d-${clusterName}`], { stdio: 'ignore' });
89
-
90
- const registryNetwork = `k3d-${clusterName}`;
91
- const existingNames = capture(engineBin, ['ps', '-a', '--format', '{{.Names}}']).stdout.split('\n');
92
- if (existingNames.includes(registryName)) {
93
- logger.success('Registry container already exists');
94
- run(engineBin, ['start', registryName], { stdio: 'ignore', allowFail: true });
95
- } else {
96
- logger.step('Starting local image registry...');
97
- run(engineBin, [
98
- 'run', '-d',
99
- '--name', registryName,
100
- '--network', registryNetwork,
101
- '-p', `0.0.0.0:${registryPort}:5000`,
102
- '--restart=always',
103
- 'registry:2',
104
- ]);
105
- logger.success(`Registry running at localhost:${registryPort} (in-cluster name: ${registryName}:5000)`);
106
- }
107
-
108
- // TLS via mkcert - one *.local.test cert/key, shared across every cluster (it's the same
109
- // domain everywhere; only the namespace/subdomain prefix or the cluster differs).
110
- logger.step(`Setting up local TLS for *.${DOMAIN}...`);
111
- run('mkcert', ['-install']);
112
- const certDir = path.join(os.homedir(), '.localctl', 'certs');
113
- fs.mkdirSync(certDir, { recursive: true });
114
- const certFile = path.join(certDir, 'local-dev.pem');
115
- const keyFile = path.join(certDir, 'local-dev-key.pem');
116
- if (!fs.existsSync(certFile) || !fs.existsSync(keyFile)) {
117
- run('mkcert', ['-cert-file', certFile, '-key-file', keyFile, `*.${DOMAIN}`, DOMAIN]);
118
- }
119
-
120
- const secretYaml = capture('kubectl', [
121
- 'create', 'secret', 'tls', 'local-dev-tls',
122
- `--cert=${certFile}`, `--key=${keyFile}`,
123
- '-n', 'kube-system', '--dry-run=client', '-o', 'yaml',
124
- ]).stdout;
125
- run('kubectl', ['apply', '-f', '-'], { stdio: ['pipe', 'inherit', 'inherit'], input: secretYaml });
126
-
127
- // k3d's --wait only waits for node readiness, not for Traefik's helm-install job to finish
128
- // registering its CRDs, so applying TLSStore any earlier races and fails intermittently.
129
- logger.step('Waiting for Traefik CRDs...');
130
- for (let i = 0; i < 60; i += 1) {
131
- if (capture('kubectl', ['get', 'crd', 'tlsstores.traefik.io']).status === 0) break;
132
- sleepSync(2000);
133
- }
134
- run('kubectl', ['wait', '--for=condition=Established', 'crd/tlsstores.traefik.io', '--timeout=60s'], { stdio: 'ignore' });
135
-
136
- run('kubectl', ['apply', '-f', path.join(MANIFESTS_DIR, 'tls-store.yaml')]);
137
- run('kubectl', ['apply', '-f', path.join(MANIFESTS_DIR, 'namespace-addons.yaml')]);
138
- logger.success('TLS configured, default cert wired into Traefik');
139
- }
140
-
141
- // Reverses provisionCluster() for one Main project: deletes its cluster, registry container,
142
- // network, and image volume. Does NOT touch the Podman VM's insecure-registry trust (that's
143
- // VM-wide, torn down once by `localctl uninstall`, not per-cluster) or *.local.test hosts/certs
144
- // (shared across every cluster).
145
- function teardownCluster(clusterName) {
146
- if (commandExists('k3d')) {
147
- logger.step(`Deleting k3d cluster '${clusterName}'...`);
148
- run('k3d', ['cluster', 'delete', clusterName], { allowFail: true });
149
- }
150
- const detected = detectEngine();
151
- if (detected) {
152
- const { bin: engineBin } = detected;
153
- run(engineBin, ['rm', '-f', '-v', registryNameFor(clusterName)], { stdio: 'ignore', allowFail: true });
154
- run(engineBin, ['network', 'rm', `k3d-${clusterName}`], { stdio: 'ignore', allowFail: true });
155
- run(engineBin, ['volume', 'rm', `k3d-${clusterName}-images`], { stdio: 'ignore', allowFail: true });
156
- }
157
- }
158
-
159
- // Throws with the exact fix command if `clusterName` isn't up yet, otherwise points kubectl at
160
- // it. Called by every app-scoped command before it does anything else, so "cd in and run a
161
- // command" fails with a clear instruction instead of a confusing kubectl error further down.
162
- function ensureClusterUp(clusterName, profileName) {
163
- if (!clusterExists(clusterName)) {
164
- const fix = profileName === 'default' ? 'localctl setup' : `localctl profiles switch ${profileName}`;
165
- throw new Error(`No cluster running for project "${profileName}". Run "${fix}" to start it.`);
166
- }
167
- run('kubectl', ['config', 'use-context', `k3d-${clusterName}`], { stdio: 'ignore' });
168
- }
169
-
170
- module.exports = { provisionCluster, teardownCluster, clusterExists, registryNameFor, ensureClusterUp };
1
+ const os = require('os');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const yaml = require('js-yaml');
5
+ const { run, capture, sleepSync, commandExists } = require('./exec');
6
+ const { detectEngine, warnIfExperimental } = require('./engine');
7
+ const { installIfMissing } = require('./toolInstall');
8
+ const { ensureInsecureRegistry } = require('./podman');
9
+ const { DOMAIN } = require('./constants');
10
+ const logger = require('./logger');
11
+
12
+ // Cluster-level manifests: bundled under cli/assets/ in the npm package (copied there by
13
+ // scripts/sync-assets.js at pack time), or read straight from the repo root in a git checkout.
14
+ const BUNDLED_MANIFESTS_DIR = path.resolve(__dirname, '..', '..', 'assets', 'manifests');
15
+ const MANIFESTS_DIR = fs.existsSync(BUNDLED_MANIFESTS_DIR)
16
+ ? BUNDLED_MANIFESTS_DIR
17
+ : path.resolve(__dirname, '..', '..', '..', 'cluster', 'manifests');
18
+
19
+ function registryNameFor(clusterName) {
20
+ return `${clusterName}-registry`;
21
+ }
22
+
23
+ function clusterExists(clusterName) {
24
+ return capture('k3d', ['cluster', 'list']).stdout
25
+ .split('\n')
26
+ .map((line) => line.trim().split(/\s+/)[0])
27
+ .includes(clusterName);
28
+ }
29
+
30
+ // Brings up (idempotently) one Main project's cluster: container engine + tool checks, the k3d
31
+ // cluster itself (own ports, since two clusters can't both bind host :80/:443), its own local
32
+ // registry container, and the shared *.local.test TLS cert wired into its Traefik. Used by both
33
+ // `localctl setup` (the "default" profile, unchanged ports/names from before multi-project
34
+ // support) and `localctl profiles new`/`switch` (any other Main project).
35
+ function provisionCluster({ clusterName, httpPort, httpsPort, registryPort }) {
36
+ logger.step('Detecting container engine...');
37
+ const detected = detectEngine();
38
+ if (!detected) {
39
+ throw new Error('No running container engine found. Start Docker Desktop, Podman, or Rancher Desktop and re-run.');
40
+ }
41
+ const { engine, bin: engineBin } = detected;
42
+ logger.success(`Using container engine: ${engine} (${engineBin} CLI)`);
43
+ warnIfExperimental(engine);
44
+
45
+ // Podman-specific, and must happen before the cluster/registry exist - see lib/podman.js. The
46
+ // insecure-registry trust is VM-wide, so it only needs doing once no matter how many clusters
47
+ // exist, but re-running it per registry port is cheap and idempotent.
48
+ if (engine === 'podman') {
49
+ ensureInsecureRegistry(registryPort);
50
+ }
51
+
52
+ installIfMissing('k3d', 'k3d', 'k3d-io.k3d');
53
+ installIfMissing('kubectl', 'kubernetes-cli', 'Kubernetes.kubectl');
54
+ installIfMissing('tilt', 'tilt', 'Tilt.dev.tilt');
55
+ installIfMissing('mkcert', 'mkcert', 'FiloSottile.mkcert');
56
+ installIfMissing('helm', 'helm', 'Helm.Helm');
57
+
58
+ const registryName = registryNameFor(clusterName);
59
+
60
+ if (clusterExists(clusterName)) {
61
+ logger.success(`k3d cluster '${clusterName}' already exists`);
62
+ } else {
63
+ 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
+ };
83
+ const tmpConfig = path.join(os.tmpdir(), `localctl-k3d-config-${clusterName}.yaml`);
84
+ fs.writeFileSync(tmpConfig, yaml.dump(k3dConfig));
85
+ run('k3d', ['cluster', 'create', '--config', tmpConfig, '--wait']);
86
+ fs.rmSync(tmpConfig, { force: true });
87
+ logger.success('Cluster created');
88
+ }
89
+ run('kubectl', ['config', 'use-context', `k3d-${clusterName}`], { stdio: 'ignore' });
90
+
91
+ const registryNetwork = `k3d-${clusterName}`;
92
+ const existingNames = capture(engineBin, ['ps', '-a', '--format', '{{.Names}}']).stdout.split('\n');
93
+ if (existingNames.includes(registryName)) {
94
+ logger.success('Registry container already exists');
95
+ run(engineBin, ['start', registryName], { stdio: 'ignore', allowFail: true });
96
+ } else {
97
+ logger.step('Starting local image registry...');
98
+ run(engineBin, [
99
+ 'run', '-d',
100
+ '--name', registryName,
101
+ '--network', registryNetwork,
102
+ '-p', `0.0.0.0:${registryPort}:5000`,
103
+ '--restart=always',
104
+ 'registry:2',
105
+ ]);
106
+ logger.success(`Registry running at localhost:${registryPort} (in-cluster name: ${registryName}:5000)`);
107
+ }
108
+
109
+ // TLS via mkcert - one *.local.test cert/key, shared across every cluster (it's the same
110
+ // domain everywhere; only the namespace/subdomain prefix or the cluster differs).
111
+ logger.step(`Setting up local TLS for *.${DOMAIN}...`);
112
+ run('mkcert', ['-install']);
113
+ const certDir = path.join(os.homedir(), '.localctl', 'certs');
114
+ fs.mkdirSync(certDir, { recursive: true });
115
+ const certFile = path.join(certDir, 'local-dev.pem');
116
+ const keyFile = path.join(certDir, 'local-dev-key.pem');
117
+ if (!fs.existsSync(certFile) || !fs.existsSync(keyFile)) {
118
+ run('mkcert', ['-cert-file', certFile, '-key-file', keyFile, `*.${DOMAIN}`, DOMAIN]);
119
+ }
120
+
121
+ const secretYaml = capture('kubectl', [
122
+ 'create', 'secret', 'tls', 'local-dev-tls',
123
+ `--cert=${certFile}`, `--key=${keyFile}`,
124
+ '-n', 'kube-system', '--dry-run=client', '-o', 'yaml',
125
+ ]).stdout;
126
+ run('kubectl', ['apply', '-f', '-'], { stdio: ['pipe', 'inherit', 'inherit'], input: secretYaml });
127
+
128
+ // k3d's --wait only waits for node readiness, not for Traefik's helm-install job to finish
129
+ // registering its CRDs, so applying TLSStore any earlier races and fails intermittently.
130
+ logger.step('Waiting for Traefik CRDs...');
131
+ for (let i = 0; i < 60; i += 1) {
132
+ if (capture('kubectl', ['get', 'crd', 'tlsstores.traefik.io']).status === 0) break;
133
+ sleepSync(2000);
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')]);
139
+ logger.success('TLS configured, default cert wired into Traefik');
140
+ }
141
+
142
+ // Reverses provisionCluster() for one Main project: deletes its cluster, registry container,
143
+ // network, and image volume. Does NOT touch the Podman VM's insecure-registry trust (that's
144
+ // VM-wide, torn down once by `localctl uninstall`, not per-cluster) or *.local.test hosts/certs
145
+ // (shared across every cluster).
146
+ function teardownCluster(clusterName) {
147
+ if (commandExists('k3d')) {
148
+ logger.step(`Deleting k3d cluster '${clusterName}'...`);
149
+ run('k3d', ['cluster', 'delete', clusterName], { allowFail: true });
150
+ }
151
+ const detected = detectEngine();
152
+ if (detected) {
153
+ const { bin: engineBin } = detected;
154
+ run(engineBin, ['rm', '-f', '-v', registryNameFor(clusterName)], { stdio: 'ignore', allowFail: true });
155
+ run(engineBin, ['network', 'rm', `k3d-${clusterName}`], { stdio: 'ignore', allowFail: true });
156
+ run(engineBin, ['volume', 'rm', `k3d-${clusterName}-images`], { stdio: 'ignore', allowFail: true });
157
+ }
158
+ }
159
+
160
+ // Throws with the exact fix command if `clusterName` isn't up yet, otherwise points kubectl at
161
+ // it. Called by every app-scoped command before it does anything else, so "cd in and run a
162
+ // command" fails with a clear instruction instead of a confusing kubectl error further down.
163
+ function ensureClusterUp(clusterName, profileName) {
164
+ if (!clusterExists(clusterName)) {
165
+ const fix = profileName === 'default' ? 'localctl setup' : `localctl profiles switch ${profileName}`;
166
+ throw new Error(`No cluster running for project "${profileName}". Run "${fix}" to start it.`);
167
+ }
168
+ run('kubectl', ['config', 'use-context', `k3d-${clusterName}`], { stdio: 'ignore' });
169
+ }
170
+
171
+ module.exports = { provisionCluster, teardownCluster, clusterExists, registryNameFor, ensureClusterUp };
@@ -1,103 +1,103 @@
1
- const { z } = require('zod');
2
- const { knownAddonTypes } = require('./manifestGen');
3
-
4
- // Dependency types are discovered from cli/src/lib/addons/<type>/addon.yaml (see
5
- // docs/adding-tools.md) rather than hardcoded here - dropping in a new addon folder is enough to
6
- // make it usable, no code change needed.
7
- const dependencySchema = z.object({
8
- type: z.enum(knownAddonTypes()),
9
- version: z.string().optional(),
10
- database: z.string().optional(),
11
- storage: z.string().optional(),
12
- });
13
-
14
- const debugSchema = z
15
- .object({
16
- enabled: z.boolean().default(false),
17
- port: z.number().int().optional(),
18
- type: z.enum(['node', 'python', 'go', 'java', 'dotnet']).optional(),
19
- })
20
- .default({ enabled: false });
21
-
22
- const syncEntrySchema = z.object({
23
- localPath: z.string(),
24
- remotePath: z.string(),
25
- });
26
-
27
- // An extra container in the same pod as your app - the classic k8s sidecar pattern (a DB admin
28
- // UI next to a database, a log/metrics shipper, a background job runner next to a web app). It
29
- // shares the pod's network namespace with your app automatically - no Service needed for your app
30
- // to reach it, just `localhost:<port>`. See docs/config-schema.md for a worked example.
31
- const sidecarSchema = z.object({
32
- name: z.string().regex(/^[a-z0-9-]+$/, 'must be lowercase alphanumeric + dashes only'),
33
- image: z.string(),
34
- port: z.number().int().optional(),
35
- command: z.array(z.string()).optional(),
36
- args: z.array(z.string()).optional(),
37
- env: z.record(z.string()).default({}),
38
- });
39
-
40
- const resourceSpec = z
41
- .object({
42
- cpu: z.string().optional(),
43
- memory: z.string().optional(),
44
- })
45
- .optional();
46
-
47
- // A single probe - defaults to a plain TCP check on the app's own `port` if `path` isn't given
48
- // (see buildProbe() in manifestGen.js). Applies to the main app container only.
49
- const probeSchema = z.object({
50
- path: z.string().optional(),
51
- initialDelaySeconds: z.number().int().optional(),
52
- periodSeconds: z.number().int().optional(),
53
- });
54
-
55
- const probesSchema = z
56
- .object({
57
- readiness: probeSchema.optional(),
58
- liveness: probeSchema.optional(),
59
- })
60
- .default({});
61
-
62
- const appConfigSchema = z
63
- .object({
64
- name: z
65
- .string()
66
- .regex(/^[a-z0-9-]+$/, 'must be lowercase alphanumeric + dashes only'),
67
- subdomain: z.string().regex(/^[a-z0-9-]+$/).optional(),
68
- namespace: z.string().optional(),
69
- port: z.number().int(),
70
- build: z.object({
71
- context: z.string().default('.'),
72
- dockerfile: z.string().default('Dockerfile'),
73
- }),
74
- env: z.record(z.string()).default({}),
75
- // Names only - values never live here. Set with `localctl app secrets set <KEY>`, which
76
- // stores the value once as a real k8s Secret and injects it via secretKeyRef, the same
77
- // never-plaintext principle addon-provided credentials already follow.
78
- secrets: z
79
- .array(z.string().regex(/^[A-Z][A-Z0-9_]*$/, 'must be UPPER_SNAKE_CASE'))
80
- .default([]),
81
- replicas: z.number().int().min(1).default(1),
82
- resources: z
83
- .object({ requests: resourceSpec, limits: resourceSpec })
84
- .optional(),
85
- debug: debugSchema,
86
- probes: probesSchema,
87
- dependencies: z.array(dependencySchema).default([]),
88
- sidecars: z.array(sidecarSchema).default([]),
89
- sync: z.array(syncEntrySchema).default([]),
90
- tls: z.boolean().default(true),
91
- })
92
- .superRefine((cfg, ctx) => {
93
- const overlap = cfg.secrets.filter((key) => key in cfg.env);
94
- if (overlap.length > 0) {
95
- ctx.addIssue({
96
- code: z.ZodIssueCode.custom,
97
- path: ['secrets'],
98
- message: `${overlap.join(', ')} can't be in both "env" and "secrets" - pick one.`,
99
- });
100
- }
101
- });
102
-
103
- module.exports = { appConfigSchema };
1
+ const { z } = require('zod');
2
+ const { knownAddonTypes } = require('./manifestGen');
3
+
4
+ // Dependency types are discovered from cli/src/lib/addons/<type>/addon.yaml (see
5
+ // docs/adding-tools.md) rather than hardcoded here - dropping in a new addon folder is enough to
6
+ // make it usable, no code change needed.
7
+ const dependencySchema = z.object({
8
+ type: z.enum(knownAddonTypes()),
9
+ version: z.string().optional(),
10
+ database: z.string().optional(),
11
+ storage: z.string().optional(),
12
+ });
13
+
14
+ const debugSchema = z
15
+ .object({
16
+ enabled: z.boolean().default(false),
17
+ port: z.number().int().optional(),
18
+ type: z.enum(['node', 'python', 'go', 'java', 'dotnet']).optional(),
19
+ })
20
+ .default({ enabled: false });
21
+
22
+ const syncEntrySchema = z.object({
23
+ localPath: z.string(),
24
+ remotePath: z.string(),
25
+ });
26
+
27
+ // An extra container in the same pod as your app - the classic k8s sidecar pattern (a DB admin
28
+ // UI next to a database, a log/metrics shipper, a background job runner next to a web app). It
29
+ // shares the pod's network namespace with your app automatically - no Service needed for your app
30
+ // to reach it, just `localhost:<port>`. See docs/config-schema.md for a worked example.
31
+ const sidecarSchema = z.object({
32
+ name: z.string().regex(/^[a-z0-9-]+$/, 'must be lowercase alphanumeric + dashes only'),
33
+ image: z.string(),
34
+ port: z.number().int().optional(),
35
+ command: z.array(z.string()).optional(),
36
+ args: z.array(z.string()).optional(),
37
+ env: z.record(z.string()).default({}),
38
+ });
39
+
40
+ const resourceSpec = z
41
+ .object({
42
+ cpu: z.string().optional(),
43
+ memory: z.string().optional(),
44
+ })
45
+ .optional();
46
+
47
+ // A single probe - defaults to a plain TCP check on the app's own `port` if `path` isn't given
48
+ // (see buildProbe() in manifestGen.js). Applies to the main app container only.
49
+ const probeSchema = z.object({
50
+ path: z.string().optional(),
51
+ initialDelaySeconds: z.number().int().optional(),
52
+ periodSeconds: z.number().int().optional(),
53
+ });
54
+
55
+ const probesSchema = z
56
+ .object({
57
+ readiness: probeSchema.optional(),
58
+ liveness: probeSchema.optional(),
59
+ })
60
+ .default({});
61
+
62
+ const appConfigSchema = z
63
+ .object({
64
+ name: z
65
+ .string()
66
+ .regex(/^[a-z0-9-]+$/, 'must be lowercase alphanumeric + dashes only'),
67
+ subdomain: z.string().regex(/^[a-z0-9-]+$/).optional(),
68
+ namespace: z.string().optional(),
69
+ port: z.number().int(),
70
+ build: z.object({
71
+ context: z.string().default('.'),
72
+ dockerfile: z.string().default('Dockerfile'),
73
+ }),
74
+ env: z.record(z.string()).default({}),
75
+ // Names only - values never live here. Set with `localctl app secrets set <KEY>`, which
76
+ // stores the value once as a real k8s Secret and injects it via secretKeyRef, the same
77
+ // never-plaintext principle addon-provided credentials already follow.
78
+ secrets: z
79
+ .array(z.string().regex(/^[A-Z][A-Z0-9_]*$/, 'must be UPPER_SNAKE_CASE'))
80
+ .default([]),
81
+ replicas: z.number().int().min(1).default(1),
82
+ resources: z
83
+ .object({ requests: resourceSpec, limits: resourceSpec })
84
+ .optional(),
85
+ debug: debugSchema,
86
+ probes: probesSchema,
87
+ dependencies: z.array(dependencySchema).default([]),
88
+ sidecars: z.array(sidecarSchema).default([]),
89
+ sync: z.array(syncEntrySchema).default([]),
90
+ tls: z.boolean().default(true),
91
+ })
92
+ .superRefine((cfg, ctx) => {
93
+ const overlap = cfg.secrets.filter((key) => key in cfg.env);
94
+ if (overlap.length > 0) {
95
+ ctx.addIssue({
96
+ code: z.ZodIssueCode.custom,
97
+ path: ['secrets'],
98
+ message: `${overlap.join(', ')} can't be in both "env" and "secrets" - pick one.`,
99
+ });
100
+ }
101
+ });
102
+
103
+ module.exports = { appConfigSchema };
@@ -1,16 +1,16 @@
1
- module.exports = {
2
- // The "default" profile's cluster name - the single cluster this whole tool managed before
3
- // multi-project support existed. Other Main-level projects get their own `local-dev-<name>`
4
- // cluster (see profileStore.js).
5
- DEFAULT_CLUSTER_NAME: 'local-dev',
6
- DEFAULT_HTTP_PORT: 80,
7
- DEFAULT_HTTPS_PORT: 443,
8
- // .test (RFC 2606) is reserved for testing and guaranteed to never resolve publicly or be
9
- // HSTS-preloaded. Not .dev: it's a real, live gTLD (Google-operated) that's HSTS-preloaded at
10
- // the TLD level - every browser force-HTTPS's *any* .dev domain, including made-up ones, with
11
- // no way to click through a self-signed cert warning, and "local.dev" itself is registered by
12
- // an unrelated third party.
13
- DOMAIN: 'local.test',
14
- REGISTRY_HOST_PORT: '5050',
15
- OBSERVABILITY_NAMESPACE: 'observability',
16
- };
1
+ module.exports = {
2
+ // The "default" profile's cluster name - the single cluster this whole tool managed before
3
+ // multi-project support existed. Other Main-level projects get their own `local-dev-<name>`
4
+ // cluster (see profileStore.js).
5
+ DEFAULT_CLUSTER_NAME: 'local-dev',
6
+ DEFAULT_HTTP_PORT: 80,
7
+ DEFAULT_HTTPS_PORT: 443,
8
+ // .test (RFC 2606) is reserved for testing and guaranteed to never resolve publicly or be
9
+ // HSTS-preloaded. Not .dev: it's a real, live gTLD (Google-operated) that's HSTS-preloaded at
10
+ // the TLD level - every browser force-HTTPS's *any* .dev domain, including made-up ones, with
11
+ // no way to click through a self-signed cert warning, and "local.dev" itself is registered by
12
+ // an unrelated third party.
13
+ DOMAIN: 'local.test',
14
+ REGISTRY_HOST_PORT: '5050',
15
+ OBSERVABILITY_NAMESPACE: 'observability',
16
+ };