@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,56 @@
1
+ const { listAddonNames } = require('../lib/addonDirs');
2
+ const { enableClusterAddon, disableClusterAddon, clusterAddonInstances } = require('../lib/clusterAddons');
3
+ const { ensureClusterUp } = require('../lib/clusterProvision');
4
+ const { getActive, clusterNameFor } = require('../lib/profileStore');
5
+ const ui = require('../lib/ui');
6
+ const logger = require('../lib/logger');
7
+
8
+ // Cluster-wide addons apply to whichever project is currently active (see "localctl profiles
9
+ // switch") - there's no per-app directory to resolve one from, unlike `app` commands.
10
+ function ensureActiveClusterUp() {
11
+ const active = getActive();
12
+ ensureClusterUp(clusterNameFor(active), active);
13
+ }
14
+
15
+ module.exports = (program) => {
16
+ const addons = program.command('addons').description('Manage cluster-wide addons (logging, etc.) in the active project');
17
+
18
+ addons
19
+ .command('list')
20
+ .description('List available cluster-wide addons')
21
+ .action(() => {
22
+ listAddonNames('cluster').forEach((n) => console.log(n));
23
+ });
24
+
25
+ addons
26
+ .command('enable <name>')
27
+ .description('Enable a cluster-wide addon')
28
+ .action((name) => {
29
+ ensureActiveClusterUp();
30
+ enableClusterAddon(name);
31
+ logger.success(`Addon "${name}" applied.`);
32
+ });
33
+
34
+ addons
35
+ .command('disable <name>')
36
+ .description('Remove a cluster-wide addon')
37
+ .action((name) => {
38
+ ensureActiveClusterUp();
39
+ disableClusterAddon(name);
40
+ logger.success(`Addon "${name}" removed.`);
41
+ });
42
+
43
+ addons
44
+ .command('status')
45
+ .description('Show every addon instance in the cluster - infrastructure helpers, separate from your apps')
46
+ .action(() => {
47
+ ensureActiveClusterUp();
48
+ const records = clusterAddonInstances();
49
+ if (records.length === 0) {
50
+ logger.info('No addon instances found in the cluster.');
51
+ return;
52
+ }
53
+ const rows = records.map((r) => [r.release, r.owner || ui.dim('(cluster-wide)'), r.namespace, r.chart, r.ready]);
54
+ ui.printTable(['ADDON', 'OWNER', 'NAMESPACE', 'CHART', 'READY'], rows);
55
+ });
56
+ };
@@ -0,0 +1,43 @@
1
+ const { commandExists, capture } = require('../lib/exec');
2
+ const { detectEngine } = require('../lib/engine');
3
+ const { getActive, clusterNameFor } = require('../lib/profileStore');
4
+ const ui = require('../lib/ui');
5
+ const logger = require('../lib/logger');
6
+
7
+ function check(label, fn) {
8
+ try {
9
+ const passed = fn();
10
+ console.log(` ${passed ? ui.check : ui.cross} ${label}`);
11
+ return passed;
12
+ } catch (e) {
13
+ console.log(` ${ui.cross} ${label} - ${ui.dim(e.message)}`);
14
+ return false;
15
+ }
16
+ }
17
+
18
+ module.exports = (program) => {
19
+ program
20
+ .command('doctor')
21
+ .description('Check the local environment for missing/broken pieces')
22
+ .action(() => {
23
+ logger.step('Checking environment...');
24
+ const detected = detectEngine();
25
+ check(`container engine available${detected ? ` (${detected.engine})` : ''}`, () => Boolean(detected));
26
+ if (detected && detected.engine === 'podman') {
27
+ logger.info('Podman detected: "localctl app up" will disable BuildKit automatically for image builds.');
28
+ }
29
+ check('k3d installed', () => commandExists('k3d'));
30
+ check('kubectl installed', () => commandExists('kubectl'));
31
+ check('tilt installed', () => commandExists('tilt'));
32
+ check('mkcert installed', () => commandExists('mkcert'));
33
+ check('helm installed', () => commandExists('helm'));
34
+ const active = getActive();
35
+ const activeCluster = clusterNameFor(active);
36
+ check(`active project's cluster exists ("${active}" -> ${activeCluster})`, () => {
37
+ const res = capture('k3d', ['cluster', 'list']);
38
+ return res.stdout.split('\n').some((line) => line.trim().split(/\s+/)[0] === activeCluster);
39
+ });
40
+ check('kubectl context reachable', () => capture('kubectl', ['cluster-info']).status === 0);
41
+ 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.`);
42
+ });
43
+ };
@@ -0,0 +1,35 @@
1
+ const fs = require('fs');
2
+ const { loadConfig } = require('../lib/loadConfig');
3
+ const { run, isRunning, sleepSync } = require('../lib/exec');
4
+ const { ensureClusterUp } = require('../lib/clusterProvision');
5
+ const { runFiles } = require('../lib/paths');
6
+ const logger = require('../lib/logger');
7
+
8
+ module.exports = (program) => {
9
+ program
10
+ .command('down')
11
+ .description('Tear down the app deployed from the current directory (runs "tilt down")')
12
+ .action(() => {
13
+ const cfg = loadConfig();
14
+ ensureClusterUp(cfg.clusterName, cfg.profile);
15
+ const { pidFile, portFile, logFile } = runFiles(cfg.name);
16
+
17
+ // If `localctl up` started a detached Tilt process for this app, it needs to be stopped
18
+ // first - otherwise it keeps live-syncing/reconciling against resources `tilt down` is
19
+ // about to delete out from under it.
20
+ if (fs.existsSync(pidFile)) {
21
+ const pid = parseInt(fs.readFileSync(pidFile, 'utf8'), 10);
22
+ if (Number.isFinite(pid) && isRunning(pid)) {
23
+ logger.step(`Stopping background Tilt process (pid ${pid})...`);
24
+ process.kill(pid, 'SIGTERM');
25
+ for (let i = 0; i < 20 && isRunning(pid); i += 1) sleepSync(250);
26
+ }
27
+ fs.rmSync(pidFile, { force: true });
28
+ }
29
+ fs.rmSync(portFile, { force: true });
30
+ fs.rmSync(logFile, { force: true });
31
+
32
+ run('tilt', ['down']);
33
+ logger.success(`"${cfg.name}" torn down. Run "localctl hosts sync" if you want to clean up its subdomain too.`);
34
+ });
35
+ };
@@ -0,0 +1,15 @@
1
+ const { loadConfig } = require('../lib/loadConfig');
2
+ const { ensureClusterUp } = require('../lib/clusterProvision');
3
+ const { run } = require('../lib/exec');
4
+
5
+ module.exports = (program) => {
6
+ program
7
+ .command('exec [cmd...]')
8
+ .description('Shell into the running app\'s pod (kubectl exec) - defaults to "sh" if no command is given')
9
+ .action((cmd) => {
10
+ const cfg = loadConfig();
11
+ ensureClusterUp(cfg.clusterName, cfg.profile);
12
+ const command = cmd && cmd.length ? cmd : ['sh'];
13
+ run('kubectl', ['exec', '-it', `deploy/${cfg.name}`, '-n', cfg.namespace, '--', ...command]);
14
+ });
15
+ };
@@ -0,0 +1,12 @@
1
+ const { loadConfig } = require('../lib/loadConfig');
2
+ const { generateManifests } = require('../lib/manifestGen');
3
+
4
+ module.exports = (program) => {
5
+ program
6
+ .command('generate-manifests')
7
+ .description('[internal, called by Tiltfile] Render k8s manifests for the app in the current directory to stdout')
8
+ .action(() => {
9
+ const cfg = loadConfig();
10
+ process.stdout.write(generateManifests(cfg));
11
+ });
12
+ };
@@ -0,0 +1,23 @@
1
+ const { syncHosts, clearHosts, listApps } = require('../lib/hostsFile');
2
+ const { DOMAIN } = require('../lib/constants');
3
+
4
+ module.exports = (program) => {
5
+ const hosts = program.command('hosts').description(`Manage local hosts file entries for *.${DOMAIN} apps`);
6
+
7
+ hosts
8
+ .command('sync')
9
+ .description('Sync the hosts file with all registered app subdomains')
10
+ .action(() => syncHosts());
11
+
12
+ hosts
13
+ .command('list')
14
+ .description('List registered subdomains')
15
+ .action(() => {
16
+ listApps().forEach((s) => console.log(`${s}.${DOMAIN}`));
17
+ });
18
+
19
+ hosts
20
+ .command('clear')
21
+ .description(`[used by uninstall] Remove the entire managed *.${DOMAIN} block from the hosts file`)
22
+ .action(() => clearHosts());
23
+ };
@@ -0,0 +1,45 @@
1
+ const fs = require('fs');
2
+ const { run } = require('../lib/exec');
3
+ const { loadConfig } = require('../lib/loadConfig');
4
+ const { ensureClusterUp } = require('../lib/clusterProvision');
5
+ const { getActive, clusterNameFor } = require('../lib/profileStore');
6
+ const { CONFIG_PATH } = require('../lib/paths');
7
+
8
+ module.exports = (program) => {
9
+ program
10
+ .command('logs [name]')
11
+ .option('-n, --namespace <ns>', 'namespace override (only needed outside an app\'s own repo, or if it disagrees with what "localctl app status" shows)')
12
+ .option('-f, --follow', 'follow log output')
13
+ .description('Tail logs for a deployed app - run inside the app\'s repo to resolve it (and its project\'s namespace prefix) automatically, or pass a name')
14
+ .action((name, opts) => {
15
+ let targetName = name;
16
+ let namespace = opts.namespace;
17
+
18
+ if (!namespace) {
19
+ if (!targetName && fs.existsSync(CONFIG_PATH)) {
20
+ // Directory-resolved, same as `app status`/`up`/`down` - this is what a namespace-scoped
21
+ // project needs: its real namespace is "<scope>-<name>", not the bare app name a plain
22
+ // `-n <name>` default would guess.
23
+ const cfg = loadConfig();
24
+ ensureClusterUp(cfg.clusterName, cfg.profile);
25
+ targetName = cfg.name;
26
+ namespace = cfg.namespace;
27
+ } else if (targetName) {
28
+ // An explicit name from outside its own repo - no directory to resolve from, so this
29
+ // stays a best-effort guess (namespace = name) against the active project's cluster,
30
+ // same fallback `app status -a <name>` uses.
31
+ const active = getActive();
32
+ ensureClusterUp(clusterNameFor(active), active);
33
+ namespace = targetName;
34
+ }
35
+ }
36
+
37
+ if (!targetName) {
38
+ throw new Error('Run this from inside an app repo, or pass a name (localctl app logs <name>).');
39
+ }
40
+
41
+ const args = ['logs', `deploy/${targetName}`, '-n', namespace];
42
+ if (opts.follow) args.push('-f');
43
+ run('kubectl', args);
44
+ });
45
+ };
@@ -0,0 +1,55 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { askAll } = require('../lib/prompt');
4
+ const logger = require('../lib/logger');
5
+
6
+ module.exports = (program) => {
7
+ program
8
+ .command('new')
9
+ .description('Scaffold .local/config.json and a Tiltfile in the current app repo')
10
+ .action(async () => {
11
+ const cwd = process.cwd();
12
+ const localDir = path.join(cwd, '.local');
13
+ const configPath = path.join(localDir, 'config.json');
14
+
15
+ if (fs.existsSync(configPath)) {
16
+ logger.warn('.local/config.json already exists, aborting.');
17
+ return;
18
+ }
19
+
20
+ const defaultName = path.basename(cwd).toLowerCase().replace(/[^a-z0-9-]/g, '-');
21
+ const [name, portAnswer] = await askAll([
22
+ { text: 'App name', def: defaultName },
23
+ { text: 'App port', def: '3000' },
24
+ ]);
25
+
26
+ const port = parseInt(portAnswer, 10);
27
+
28
+ fs.mkdirSync(localDir, { recursive: true });
29
+
30
+ const config = {
31
+ $schema: '../../localctl/schema/app.schema.json',
32
+ name,
33
+ subdomain: name,
34
+ port,
35
+ build: { context: '.', dockerfile: 'Dockerfile' },
36
+ env: {},
37
+ replicas: 1,
38
+ debug: { enabled: false },
39
+ dependencies: [],
40
+ sync: [{ localPath: './src', remotePath: '/app/src' }],
41
+ tls: true,
42
+ };
43
+ fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
44
+
45
+ const tiltTemplatePath = path.join(__dirname, '..', '..', 'templates', 'Tiltfile.tmpl');
46
+ const tiltTemplate = fs.readFileSync(tiltTemplatePath, 'utf8');
47
+ const tiltfilePath = path.join(cwd, 'Tiltfile');
48
+ if (!fs.existsSync(tiltfilePath)) {
49
+ fs.writeFileSync(tiltfilePath, tiltTemplate);
50
+ }
51
+
52
+ logger.success(`Created .local/config.json and Tiltfile for "${name}".`);
53
+ logger.info('Edit .local/config.json (env, dependencies, debug), then run "localctl app up".');
54
+ });
55
+ };
@@ -0,0 +1,180 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { run, getFreePort } = require('../lib/exec');
4
+ const { provisionCluster, teardownCluster, clusterExists } = require('../lib/clusterProvision');
5
+ const { enableClusterAddon } = require('../lib/clusterAddons');
6
+ const {
7
+ getProfile, upsertProfile, listProfiles, getActive, setActive, mainProjectFor, childrenOf,
8
+ } = require('../lib/profileStore');
9
+ const { DEFAULT_HTTP_PORT, DEFAULT_HTTPS_PORT, REGISTRY_HOST_PORT } = require('../lib/constants');
10
+ const { makePrompter } = require('../lib/prompt');
11
+ const ui = require('../lib/ui');
12
+ const logger = require('../lib/logger');
13
+
14
+ // Tears down every OTHER Main project's cluster that's currently up - used when switching to (or
15
+ // creating) an `exclusive` Main project, since two clusters bound to the same host :80/:443 can't
16
+ // run at once anyway.
17
+ function teardownOtherMains(exceptMainName) {
18
+ Object.entries(listProfiles()).forEach(([name, def]) => {
19
+ if (def.isolation === 'cluster' && name !== exceptMainName && clusterExists(def.cluster)) {
20
+ logger.step(`Tearing down "${name}"'s cluster (exclusive project switch)...`);
21
+ teardownCluster(def.cluster);
22
+ }
23
+ });
24
+ }
25
+
26
+ function bringUpMain(mainDef) {
27
+ provisionCluster({
28
+ clusterName: mainDef.cluster,
29
+ httpPort: mainDef.httpPort || DEFAULT_HTTP_PORT,
30
+ httpsPort: mainDef.httpsPort || DEFAULT_HTTPS_PORT,
31
+ registryPort: mainDef.registryPort || REGISTRY_HOST_PORT,
32
+ });
33
+ }
34
+
35
+ async function newProfile(name) {
36
+ if (getProfile(name)) {
37
+ logger.warn(`"${name}" is already registered - this will overwrite its definition (any cluster it already owns is left running as-is).`);
38
+ }
39
+
40
+ const { ask, close } = makePrompter();
41
+ try {
42
+ const kindAns = await ask('Own cluster (Main project), or share an existing project\'s cluster (namespace-scoped)? [cluster/namespace]', 'cluster');
43
+ const namespaceScoped = kindAns.trim().toLowerCase().startsWith('n');
44
+
45
+ if (namespaceScoped) {
46
+ const mains = Object.entries(listProfiles()).filter(([, d]) => d.isolation === 'cluster').map(([n]) => n);
47
+ const parent = await ask(`Parent Main project (${mains.join(', ')})`, getActive());
48
+ const parentDef = getProfile(parent);
49
+ if (!parentDef || parentDef.isolation !== 'cluster') {
50
+ throw new Error(`"${parent}" isn't a registered Main project. Run "localctl profiles list".`);
51
+ }
52
+ const namespacePrefix = await ask('Namespace/subdomain prefix for this project\'s apps', name);
53
+ upsertProfile(name, { isolation: 'namespace', parent, namespace: namespacePrefix });
54
+ logger.success(`Registered "${name}", namespace-scoped under "${parent}" (apps get a "${namespacePrefix}-" prefix).`);
55
+
56
+ if (!clusterExists(parentDef.cluster)) {
57
+ logger.warn(`Parent project "${parent}"'s cluster isn't up yet - run "localctl profiles switch ${parent}" before deploying here.`);
58
+ } else {
59
+ run('kubectl', ['config', 'use-context', `k3d-${parentDef.cluster}`], { stdio: 'ignore' });
60
+ }
61
+ } else {
62
+ const exclusiveAns = await ask('Tear down other Main projects\' clusters whenever you switch to this one? [Y/n]', 'y');
63
+ const exclusive = !/^n(o)?$/i.test(exclusiveAns);
64
+ const def = { isolation: 'cluster', cluster: `local-dev-${name}`, exclusive };
65
+ if (!exclusive) {
66
+ // Two clusters can't both bind host :80/:443 - a non-exclusive (concurrent-with-others)
67
+ // Main project gets its own free ports instead, at the cost of URLs needing a :port suffix.
68
+ def.httpPort = await getFreePort();
69
+ def.httpsPort = await getFreePort();
70
+ def.registryPort = await getFreePort();
71
+ logger.info(`Assigned its own ports (won't collide with an exclusive project's :80/:443): http ${def.httpPort}, https ${def.httpsPort}, registry ${def.registryPort}.`);
72
+ }
73
+ upsertProfile(name, def);
74
+ logger.success(`Registered Main project "${name}" (cluster "${def.cluster}").`);
75
+
76
+ if (exclusive) teardownOtherMains(name);
77
+ logger.step(`Bringing up "${name}"'s cluster...`);
78
+ bringUpMain(def);
79
+
80
+ const addonAns = await ask('Cluster-wide addons to enable here now, comma-separated (blank for none)', '');
81
+ addonAns.split(',').map((s) => s.trim()).filter(Boolean).forEach((addonName) => {
82
+ logger.step(`Enabling addon "${addonName}"...`);
83
+ enableClusterAddon(addonName);
84
+ });
85
+ }
86
+
87
+ setActive(name);
88
+
89
+ const localDir = path.join(process.cwd(), '.local');
90
+ fs.mkdirSync(localDir, { recursive: true });
91
+ fs.writeFileSync(path.join(localDir, 'project.json'), `${JSON.stringify({ profile: name }, null, 2)}\n`);
92
+ logger.success(`Wrote .local/project.json here - every app under ${process.cwd()} now belongs to "${name}" unless a subfolder overrides it.`);
93
+ } finally {
94
+ close();
95
+ }
96
+ }
97
+
98
+ function switchProfile(name) {
99
+ const target = getProfile(name);
100
+ if (!target) {
101
+ throw new Error(`Unknown profile "${name}". Run "localctl profiles list", or "localctl profiles new ${name}" to create it.`);
102
+ }
103
+
104
+ const { name: mainName, def: mainDef } = mainProjectFor(name);
105
+ if (mainDef.exclusive) teardownOtherMains(mainName);
106
+
107
+ if (!clusterExists(mainDef.cluster)) {
108
+ logger.step(`Bringing up "${mainName}"'s cluster...`);
109
+ bringUpMain(mainDef);
110
+ } else {
111
+ run('kubectl', ['config', 'use-context', `k3d-${mainDef.cluster}`], { stdio: 'ignore' });
112
+ }
113
+
114
+ setActive(name);
115
+ logger.success(`Switched to "${name}"${name !== mainName ? ` (namespace-scoped under "${mainName}")` : ''}.`);
116
+ }
117
+
118
+ function listProfilesCmd() {
119
+ const profiles = listProfiles();
120
+ const active = getActive();
121
+
122
+ Object.entries(profiles)
123
+ .filter(([, def]) => def.isolation === 'cluster')
124
+ .forEach(([name, def]) => {
125
+ const up = clusterExists(def.cluster);
126
+ const marker = name === active ? ui.ok('*') : ' ';
127
+ const portsNote = def.exclusive ? '' : ` http:${def.httpPort} https:${def.httpsPort}`;
128
+ console.log(
129
+ `${marker} ${ui.bold(name)} [cluster: ${def.cluster}, ${up ? ui.ok('up') : ui.dim('down')}, ` +
130
+ `${def.exclusive ? 'exclusive' : 'concurrent'}${portsNote}]`,
131
+ );
132
+ childrenOf(name).forEach((child) => {
133
+ const childMarker = child === active ? ui.ok('*') : ' ';
134
+ const childDef = profiles[child];
135
+ console.log(` ${childMarker} ${child} [namespace: ${childDef.namespace || child}-*]`);
136
+ });
137
+ });
138
+ }
139
+
140
+ function statusCmd() {
141
+ const active = getActive();
142
+ const def = getProfile(active);
143
+ const { name: mainName, def: mainDef } = mainProjectFor(active);
144
+
145
+ console.log(ui.bold(active));
146
+ console.log(` isolation: ${def.isolation}`);
147
+ if (def.isolation === 'namespace') {
148
+ console.log(` parent: ${def.parent}`);
149
+ console.log(` namespace: ${def.namespace || active}-*`);
150
+ }
151
+ console.log(` cluster: ${mainDef.cluster} (${clusterExists(mainDef.cluster) ? ui.ok('up') : ui.dim('down')})`);
152
+ if (!mainDef.exclusive) {
153
+ console.log(` ports: http ${mainDef.httpPort} / https ${mainDef.httpsPort} / registry ${mainDef.registryPort}`);
154
+ }
155
+ console.log(` exclusive: ${mainDef.exclusive ? 'yes - switching away tears this cluster down' : 'no - can run alongside other projects'}`);
156
+ }
157
+
158
+ module.exports = (program) => {
159
+ const profiles = program.command('profiles').description('Manage projects: isolated clusters or namespace-scoped slices of one');
160
+
161
+ profiles
162
+ .command('new <name>')
163
+ .description('Create a project - its own cluster, or namespace-scoped under an existing one - and mark the current directory as belonging to it')
164
+ .action(newProfile);
165
+
166
+ profiles
167
+ .command('list')
168
+ .description('List every project this machine knows about, nested under its Main (cluster) project')
169
+ .action(listProfilesCmd);
170
+
171
+ profiles
172
+ .command('switch <name>')
173
+ .description('Switch the active project - brings its cluster up if needed, applies its exclusive-teardown setting')
174
+ .action(switchProfile);
175
+
176
+ profiles
177
+ .command('status')
178
+ .description('Show the currently active project')
179
+ .action(statusCmd);
180
+ };
@@ -0,0 +1,49 @@
1
+ const { loadConfig } = require('../lib/loadConfig');
2
+ const { ensureClusterUp } = require('../lib/clusterProvision');
3
+ const { run, capture } = require('../lib/exec');
4
+ const { getList } = require('../lib/workloadReadiness');
5
+ const { confirm } = require('../lib/prompt');
6
+ const logger = require('../lib/logger');
7
+
8
+ module.exports = (program) => {
9
+ program
10
+ .command('prune')
11
+ .option('-y, --yes', 'skip the confirmation prompt')
12
+ .description('Permanently delete an already-torn-down app\'s leftover data (dependency PVCs, secrets) - "app down" deliberately leaves these alone')
13
+ .action(async (opts) => {
14
+ const cfg = loadConfig();
15
+ ensureClusterUp(cfg.clusterName, cfg.profile);
16
+
17
+ if (capture('kubectl', ['get', 'deployment', cfg.name, '-n', cfg.namespace]).status === 0) {
18
+ throw new Error(`"${cfg.name}" is still deployed - run "localctl app down" first, then "localctl app prune" to also wipe its data.`);
19
+ }
20
+ if (capture('kubectl', ['get', 'namespace', cfg.namespace]).status !== 0) {
21
+ logger.info(`Nothing to prune - namespace "${cfg.namespace}" doesn't exist.`);
22
+ return;
23
+ }
24
+
25
+ const pvcs = getList(['get', 'pvc', '-n', cfg.namespace]);
26
+ const secrets = getList(['get', 'secret', '-n', cfg.namespace]).filter((s) => s.type !== 'kubernetes.io/service-account-token');
27
+ if (pvcs.length === 0 && secrets.length === 0) {
28
+ logger.info(`Nothing left to prune in "${cfg.namespace}".`);
29
+ return;
30
+ }
31
+
32
+ console.log(`This will permanently delete everything left in namespace "${cfg.namespace}":`);
33
+ pvcs.forEach((p) => console.log(` - PersistentVolumeClaim/${p.metadata.name} (data loss)`));
34
+ secrets.forEach((s) => console.log(` - Secret/${s.metadata.name}`));
35
+ console.log('');
36
+
37
+ if (!opts.yes) {
38
+ const answer = await confirm('Continue? [y/N] ');
39
+ if (!/^y(es)?$/i.test(answer)) {
40
+ console.log('Aborted.');
41
+ process.exitCode = 1;
42
+ return;
43
+ }
44
+ }
45
+
46
+ run('kubectl', ['delete', 'namespace', cfg.namespace]);
47
+ logger.success(`Namespace "${cfg.namespace}" and everything in it deleted.`);
48
+ });
49
+ };
@@ -0,0 +1,18 @@
1
+ const { loadConfig } = require('../lib/loadConfig');
2
+ const { portsFor, mainProjectFor } = require('../lib/profileStore');
3
+ const { registryNameFor } = require('../lib/clusterProvision');
4
+
5
+ module.exports = (program) => {
6
+ program
7
+ .command('registry-info')
8
+ .description('[internal, called by Tiltfile] Print this app\'s registry push/pull addresses as JSON')
9
+ .action(() => {
10
+ const cfg = loadConfig();
11
+ const { registryPort } = portsFor(cfg.profile);
12
+ const { def } = mainProjectFor(cfg.profile);
13
+ process.stdout.write(JSON.stringify({
14
+ pushHost: `localhost:${registryPort}`,
15
+ clusterHost: `${registryNameFor(def.cluster)}:5000`,
16
+ }));
17
+ });
18
+ };
@@ -0,0 +1,44 @@
1
+ const fs = require('fs');
2
+ const { CONFIG_PATH, runFiles } = require('../lib/paths');
3
+ const { run, isRunning } = require('../lib/exec');
4
+ const logger = require('../lib/logger');
5
+
6
+ function resolveAppName(opts) {
7
+ if (opts.app) return opts.app;
8
+ if (fs.existsSync(CONFIG_PATH)) {
9
+ try {
10
+ return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')).name;
11
+ } catch (e) {
12
+ // fall through
13
+ }
14
+ }
15
+ return null;
16
+ }
17
+
18
+ module.exports = (program) => {
19
+ program
20
+ .command('reload')
21
+ .option('-a, --app <name>', 'reload a specific app by name, regardless of the current directory')
22
+ .description('Force an immediate rebuild/redeploy of a running background dev loop, without waiting on a file change')
23
+ .action((opts) => {
24
+ const name = resolveAppName(opts);
25
+ if (!name) {
26
+ throw new Error('Run this from inside an app repo, or pass -a <name>.');
27
+ }
28
+
29
+ const { pidFile, portFile } = runFiles(name);
30
+ if (!fs.existsSync(pidFile) || !fs.existsSync(portFile)) {
31
+ throw new Error(`No background dev loop tracked for "${name}". Run "localctl app up" first (reload only works in detached mode, not "-f/--foreground").`);
32
+ }
33
+
34
+ const pid = parseInt(fs.readFileSync(pidFile, 'utf8'), 10);
35
+ if (!Number.isFinite(pid) || !isRunning(pid)) {
36
+ throw new Error(`"${name}"'s dev loop isn't running. Run "localctl app up" to start it.`);
37
+ }
38
+
39
+ const port = fs.readFileSync(portFile, 'utf8').trim();
40
+ logger.step(`Triggering an immediate rebuild/redeploy for "${name}"...`);
41
+ run('tilt', ['trigger', '--port', port, name]);
42
+ logger.success(`"${name}" reload triggered - check "localctl app logs ${name} -f" for progress.`);
43
+ });
44
+ };
@@ -0,0 +1,75 @@
1
+ const { loadConfig } = require('../lib/loadConfig');
2
+ const { ensureClusterUp } = require('../lib/clusterProvision');
3
+ const { setSecret, unsetSecret, listKeys, readAll } = require('../lib/appSecrets');
4
+ const { promptHidden } = require('../lib/prompt');
5
+ const ui = require('../lib/ui');
6
+ const logger = require('../lib/logger');
7
+
8
+ module.exports = (program) => {
9
+ const secrets = program.command('secrets').description('Manage this app\'s own secrets (API keys, tokens - not addon-generated credentials)');
10
+
11
+ secrets
12
+ .command('set <key> [value]')
13
+ .description('Set a secret value (prompts, hidden, if not given) - stored as a real k8s Secret, never in .local/config.json')
14
+ .action(async (key, value) => {
15
+ const cfg = loadConfig();
16
+ ensureClusterUp(cfg.clusterName, cfg.profile);
17
+ if (!cfg.secrets.includes(key)) {
18
+ logger.warn(`"${key}" isn't listed in .local/config.json's "secrets" array - it won't be injected into the app until you add it there too.`);
19
+ }
20
+ const finalValue = value || (await promptHidden(`Value for ${key}`));
21
+ if (!finalValue) throw new Error('No value given.');
22
+ setSecret(cfg, key, finalValue);
23
+ logger.success(`Set "${key}". Run "localctl app up"/"localctl app reload" to pick it up.`);
24
+ });
25
+
26
+ secrets
27
+ .command('unset <key>')
28
+ .description('Remove a secret value')
29
+ .action((key) => {
30
+ const cfg = loadConfig();
31
+ ensureClusterUp(cfg.clusterName, cfg.profile);
32
+ const removed = unsetSecret(cfg, key);
33
+ if (removed) logger.success(`Removed "${key}".`);
34
+ else logger.info(`"${key}" wasn't set.`);
35
+ });
36
+
37
+ secrets
38
+ .command('list')
39
+ .description('List which secret keys are set (not their values)')
40
+ .action(() => {
41
+ const cfg = loadConfig();
42
+ ensureClusterUp(cfg.clusterName, cfg.profile);
43
+ const set = new Set(listKeys(cfg));
44
+ if (cfg.secrets.length === 0 && set.size === 0) {
45
+ logger.info('No secrets declared or set for this app.');
46
+ return;
47
+ }
48
+ const rows = [...new Set([...cfg.secrets, ...set])].map((key) => [
49
+ key,
50
+ set.has(key) ? ui.ok('set') : ui.warn('missing'),
51
+ cfg.secrets.includes(key) ? '' : ui.dim('(not in config.json)'),
52
+ ]);
53
+ ui.printTable(['KEY', 'STATUS', ''], rows);
54
+ });
55
+
56
+ secrets
57
+ .command('show [key]')
58
+ .description('Reveal actual secret value(s) - use with care, prints them to your terminal')
59
+ .action((key) => {
60
+ const cfg = loadConfig();
61
+ ensureClusterUp(cfg.clusterName, cfg.profile);
62
+ const data = readAll(cfg);
63
+ if (key) {
64
+ if (!(key in data)) throw new Error(`"${key}" isn't set. Run "localctl app secrets set ${key}".`);
65
+ console.log(data[key]);
66
+ return;
67
+ }
68
+ if (Object.keys(data).length === 0) {
69
+ logger.info('No secrets set for this app.');
70
+ return;
71
+ }
72
+ logger.warn('Revealing real secret values - avoid leaving these in your terminal scrollback/screen share.');
73
+ Object.entries(data).forEach(([k, v]) => console.log(`${k}=${v}`));
74
+ });
75
+ };