@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.
- package/LICENSE +21 -0
- package/README.md +431 -0
- package/assets/manifests/namespace-addons.yaml +4 -0
- package/assets/manifests/tls-store.yaml +8 -0
- package/bin/localctl.js +2 -0
- package/package.json +54 -0
- package/schema/app.schema.json +127 -0
- package/src/commands/addons.js +56 -0
- package/src/commands/doctor.js +43 -0
- package/src/commands/down.js +35 -0
- package/src/commands/exec.js +15 -0
- package/src/commands/generateManifests.js +12 -0
- package/src/commands/hosts.js +23 -0
- package/src/commands/logs.js +45 -0
- package/src/commands/new.js +55 -0
- package/src/commands/profiles.js +180 -0
- package/src/commands/prune.js +49 -0
- package/src/commands/registryInfo.js +18 -0
- package/src/commands/reload.js +44 -0
- package/src/commands/secrets.js +75 -0
- package/src/commands/setup.js +36 -0
- package/src/commands/status.js +136 -0
- package/src/commands/uninstall.js +105 -0
- package/src/commands/up.js +123 -0
- package/src/index.js +36 -0
- package/src/lib/addonDirs.js +43 -0
- package/src/lib/addonLoader.js +190 -0
- package/src/lib/addons/cluster/logging/addon.yaml +46 -0
- package/src/lib/addons/cluster/logging/config/all-logs.json +21 -0
- package/src/lib/addons/cluster/monitoring/addon.yaml +33 -0
- package/src/lib/addons/mongo/addon.yaml +18 -0
- package/src/lib/addons/postgres/addon.yaml +39 -0
- package/src/lib/addons/redis/addon.yaml +29 -0
- package/src/lib/appSecrets.js +60 -0
- package/src/lib/clusterAddons.js +59 -0
- package/src/lib/clusterProvision.js +170 -0
- package/src/lib/configSchema.js +103 -0
- package/src/lib/constants.js +16 -0
- package/src/lib/engine.js +20 -0
- package/src/lib/exec.js +77 -0
- package/src/lib/hostsFile.js +79 -0
- package/src/lib/loadConfig.js +51 -0
- package/src/lib/logger.js +9 -0
- package/src/lib/manifestGen.js +210 -0
- package/src/lib/paths.js +41 -0
- package/src/lib/podman.js +72 -0
- package/src/lib/profileStore.js +134 -0
- package/src/lib/projectContext.js +32 -0
- package/src/lib/prompt.js +97 -0
- package/src/lib/toolInstall.js +30 -0
- package/src/lib/ui.js +50 -0
- package/src/lib/workloadReadiness.js +35 -0
- package/templates/Tiltfile.tmpl +35 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
// Same lookup shape as git finding .git, or Claude finding .claude: walk up from a directory
|
|
5
|
+
// looking for the nearest `.local/project.json`, at that directory or any ancestor. This lets a
|
|
6
|
+
// single project.json at a parent folder govern every app subfolder underneath it, or a single
|
|
7
|
+
// `.local` folder hold both the project.json and an app's own config.json together - whichever
|
|
8
|
+
// layout a given repo uses.
|
|
9
|
+
function findProjectFile(startDir) {
|
|
10
|
+
let dir = startDir;
|
|
11
|
+
for (;;) {
|
|
12
|
+
const candidate = path.join(dir, '.local', 'project.json');
|
|
13
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
14
|
+
const parent = path.dirname(dir);
|
|
15
|
+
if (parent === dir) return null; // reached the filesystem root
|
|
16
|
+
dir = parent;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Which profile governs `startDir` - the name from the nearest ancestor's project.json, or
|
|
21
|
+
// "default" if none exists anywhere above it. This is independent of app config resolution
|
|
22
|
+
// (CONFIG_PATH is always just cwd's own .local/config.json) - only the profile/cluster scope
|
|
23
|
+
// walks up.
|
|
24
|
+
function resolveProfileName(startDir = process.cwd()) {
|
|
25
|
+
const file = findProjectFile(startDir);
|
|
26
|
+
if (!file) return 'default';
|
|
27
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
28
|
+
if (!raw.profile) throw new Error(`${file} is missing a "profile" field.`);
|
|
29
|
+
return raw.profile;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { findProjectFile, resolveProfileName };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
const readline = require('readline');
|
|
2
|
+
|
|
3
|
+
// Deliberately not using rl.question() in a loop: with piped (non-TTY) stdin, all lines can
|
|
4
|
+
// arrive before a later rl.question() call attaches its listener, silently dropping answers.
|
|
5
|
+
// Async-iterating the readline interface instead queues lines correctly either way.
|
|
6
|
+
async function askAll(questions) {
|
|
7
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false });
|
|
8
|
+
const answers = [];
|
|
9
|
+
let idx = 0;
|
|
10
|
+
const prompt = (q) => process.stdout.write(`${q.text}${q.def ? ` (${q.def})` : ''}: `);
|
|
11
|
+
|
|
12
|
+
prompt(questions[0]);
|
|
13
|
+
for await (const line of rl) {
|
|
14
|
+
answers.push(line.trim() || questions[idx].def);
|
|
15
|
+
idx += 1;
|
|
16
|
+
if (idx >= questions.length) break;
|
|
17
|
+
prompt(questions[idx]);
|
|
18
|
+
}
|
|
19
|
+
rl.close();
|
|
20
|
+
return answers;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Same underlying fix as askAll, but for a wizard whose later questions depend on an earlier
|
|
24
|
+
// answer (so a fixed up-front question list won't do). Manually driving the readline interface's
|
|
25
|
+
// async iterator - rather than attaching a fresh 'line' listener per question - is what actually
|
|
26
|
+
// queues lines correctly under piped stdin; ad hoc listeners are what askAll's comment warns about.
|
|
27
|
+
function makePrompter() {
|
|
28
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false });
|
|
29
|
+
const iterator = rl[Symbol.asyncIterator]();
|
|
30
|
+
|
|
31
|
+
async function ask(text, def) {
|
|
32
|
+
process.stdout.write(`${text}${def ? ` (${def})` : ''}: `);
|
|
33
|
+
const { value, done } = await iterator.next();
|
|
34
|
+
if (done) return def || '';
|
|
35
|
+
return value.trim() || def || '';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { ask, close: () => rl.close() };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Same resolve-before-close ordering as the fix in the old uninstall.js confirm(): rl.close()
|
|
42
|
+
// synchronously re-enters the 'close' handler below, which would resolve('') first and win the
|
|
43
|
+
// race if this ran second. Shared now by `uninstall` and `app prune`.
|
|
44
|
+
function confirm(question) {
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false });
|
|
47
|
+
process.stdout.write(question);
|
|
48
|
+
rl.on('line', (line) => {
|
|
49
|
+
resolve(line.trim());
|
|
50
|
+
rl.close();
|
|
51
|
+
});
|
|
52
|
+
rl.on('close', () => resolve(''));
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Reads one line without echoing it back - for secret values, so they don't land in your shell's
|
|
57
|
+
// scrollback or a screen-share. Piped/non-TTY stdin (tests, CI) can't be hidden anyway, so it
|
|
58
|
+
// just falls back to a plain read there instead of failing.
|
|
59
|
+
function promptHidden(question) {
|
|
60
|
+
return new Promise((resolve) => {
|
|
61
|
+
if (!process.stdin.isTTY) {
|
|
62
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false });
|
|
63
|
+
rl.on('line', (line) => {
|
|
64
|
+
resolve(line.trim());
|
|
65
|
+
rl.close();
|
|
66
|
+
});
|
|
67
|
+
rl.on('close', () => resolve(''));
|
|
68
|
+
process.stdout.write(`${question}: `);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
process.stdout.write(`${question}: `);
|
|
73
|
+
process.stdin.setRawMode(true);
|
|
74
|
+
process.stdin.resume();
|
|
75
|
+
process.stdin.setEncoding('utf8');
|
|
76
|
+
let value = '';
|
|
77
|
+
const onData = (char) => {
|
|
78
|
+
if (char === '\n' || char === '\r' || char === '') {
|
|
79
|
+
process.stdin.setRawMode(false);
|
|
80
|
+
process.stdin.pause();
|
|
81
|
+
process.stdin.removeListener('data', onData);
|
|
82
|
+
process.stdout.write('\n');
|
|
83
|
+
resolve(value);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (char === '') process.exit(130); // Ctrl-C
|
|
87
|
+
if (char === '' || char === '\b') {
|
|
88
|
+
value = value.slice(0, -1);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
value += char;
|
|
92
|
+
};
|
|
93
|
+
process.stdin.on('data', onData);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = { askAll, makePrompter, confirm, promptHidden };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const { commandExists, run } = require('./exec');
|
|
2
|
+
const logger = require('./logger');
|
|
3
|
+
|
|
4
|
+
// macId: Homebrew formula name. winId: winget package id.
|
|
5
|
+
function installIfMissing(bin, macId, winId) {
|
|
6
|
+
if (commandExists(bin)) {
|
|
7
|
+
logger.success(`${bin} already installed`);
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
if (process.platform === 'darwin') {
|
|
11
|
+
if (!commandExists('brew')) {
|
|
12
|
+
throw new Error('Homebrew is required to install missing tools. Install from https://brew.sh and re-run.');
|
|
13
|
+
}
|
|
14
|
+
logger.step(`Installing ${macId} via brew...`);
|
|
15
|
+
run('brew', ['install', macId]);
|
|
16
|
+
} else if (process.platform === 'win32') {
|
|
17
|
+
if (!commandExists('winget')) {
|
|
18
|
+
throw new Error('winget is required to install missing tools (ships with modern Windows / App Installer).');
|
|
19
|
+
}
|
|
20
|
+
logger.step(`Installing ${bin} via winget...`);
|
|
21
|
+
run('winget', [
|
|
22
|
+
'install', '--id', winId, '-e', '--source', 'winget',
|
|
23
|
+
'--accept-package-agreements', '--accept-source-agreements',
|
|
24
|
+
]);
|
|
25
|
+
} else {
|
|
26
|
+
throw new Error(`"${bin}" is missing and automatic install isn't supported on this platform. Install it manually.`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = { installIfMissing };
|
package/src/lib/ui.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
|
|
3
|
+
// eslint-disable-next-line no-control-regex
|
|
4
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
5
|
+
|
|
6
|
+
function visibleLength(str) {
|
|
7
|
+
return String(str).replace(ANSI_RE, '').length;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function padVisible(str, width) {
|
|
11
|
+
const pad = Math.max(0, width - visibleLength(str));
|
|
12
|
+
return str + ' '.repeat(pad);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Column widths computed from visible (non-ANSI) length, so colored cells still line up.
|
|
16
|
+
function printTable(headers, rows) {
|
|
17
|
+
const widths = headers.map((h, i) =>
|
|
18
|
+
Math.max(visibleLength(h), ...rows.map((r) => visibleLength(r[i])), 0),
|
|
19
|
+
);
|
|
20
|
+
console.log(headers.map((h, i) => padVisible(chalk.bold(h), widths[i])).join(' ').trimEnd());
|
|
21
|
+
rows.forEach((row) => {
|
|
22
|
+
console.log(row.map((cell, i) => padVisible(String(cell), widths[i])).join(' ').trimEnd());
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const ok = (s) => chalk.green(s);
|
|
27
|
+
const warn = (s) => chalk.yellow(s);
|
|
28
|
+
const dim = (s) => chalk.gray(s);
|
|
29
|
+
|
|
30
|
+
// `ready`/`desired` replica counts -> a colored "x/y" string. Used anywhere a workload's
|
|
31
|
+
// readiness is summarized (app status, addon status) so the coloring rule stays in one place.
|
|
32
|
+
function readyLabel(ready, desired) {
|
|
33
|
+
const text = `${ready}/${desired}`;
|
|
34
|
+
return ready >= desired && desired > 0 ? ok(text) : warn(text);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = {
|
|
38
|
+
printTable,
|
|
39
|
+
visibleLength,
|
|
40
|
+
padVisible,
|
|
41
|
+
readyLabel,
|
|
42
|
+
ok,
|
|
43
|
+
warn,
|
|
44
|
+
err: (s) => chalk.red(s),
|
|
45
|
+
dim,
|
|
46
|
+
bold: (s) => chalk.bold(s),
|
|
47
|
+
url: (s) => chalk.cyan.underline(s),
|
|
48
|
+
check: chalk.green('✔'),
|
|
49
|
+
cross: chalk.red('✖'),
|
|
50
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const { capture } = require('./exec');
|
|
2
|
+
|
|
3
|
+
function getList(args) {
|
|
4
|
+
const res = capture('kubectl', [...args, '-o', 'json']);
|
|
5
|
+
if (res.status !== 0) return [];
|
|
6
|
+
try {
|
|
7
|
+
return JSON.parse(res.stdout).items || [];
|
|
8
|
+
} catch (e) {
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Deployment/StatefulSet/DaemonSet all expose ready/desired counts, just under slightly
|
|
14
|
+
// different field names depending on kind.
|
|
15
|
+
function readyCounts(workload) {
|
|
16
|
+
const status = workload.status || {};
|
|
17
|
+
const ready = status.readyReplicas ?? status.numberReady ?? 0;
|
|
18
|
+
const desired = (workload.spec || {}).replicas ?? status.desiredNumberScheduled ?? 0;
|
|
19
|
+
return { ready, desired };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Every workload (any kind) in a namespace whose name starts with `prefix`, with ready/desired
|
|
23
|
+
// counts - used to summarize a release's health without needing to know in advance which
|
|
24
|
+
// workload kind(s) the underlying Helm chart happens to use (this is exactly the fragility that
|
|
25
|
+
// bit `localctl status`'s dependency listing once already: Bitnami's Postgres/Redis charts use a
|
|
26
|
+
// StatefulSet, not the Deployment a hand-rolled addon would have used).
|
|
27
|
+
function workloadsWithPrefix(namespace, prefix) {
|
|
28
|
+
const kinds = ['deployment', 'statefulset', 'daemonset'];
|
|
29
|
+
return kinds
|
|
30
|
+
.flatMap((kind) => getList(['get', kind, '-n', namespace]))
|
|
31
|
+
.filter((w) => w.metadata.name.startsWith(prefix))
|
|
32
|
+
.map((w) => ({ name: w.metadata.name, ...readyCounts(w) }));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = { getList, readyCounts, workloadsWithPrefix };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Generated by `localctl app new`.
|
|
2
|
+
# Most configuration lives in .local/config.json - edit that instead of this file
|
|
3
|
+
# unless you need custom Tilt behavior (extra resources, manual k8s_yaml, etc).
|
|
4
|
+
|
|
5
|
+
config_json = read_json('.local/config.json')
|
|
6
|
+
|
|
7
|
+
# Images are built and pushed from your machine to this app's project registry (localhost:5050
|
|
8
|
+
# for the default project - port 5050, not 5000, to dodge macOS Control Center's claim on 5000 -
|
|
9
|
+
# or a project-specific port for another Main project, see docs/architecture.md), but the cluster
|
|
10
|
+
# pulls them back using the registry's in-network name. Resolved per app via `localctl app
|
|
11
|
+
# registry-info` instead of hardcoded, since a project on its own cluster has its own registry.
|
|
12
|
+
registry_info = decode_json(str(local(['localctl', 'app', 'registry-info'], quiet=True)))
|
|
13
|
+
default_registry(registry_info['pushHost'], host_from_cluster=registry_info['clusterHost'])
|
|
14
|
+
|
|
15
|
+
docker_build(
|
|
16
|
+
config_json['name'],
|
|
17
|
+
config_json['build'].get('context', '.'),
|
|
18
|
+
dockerfile=config_json['build'].get('dockerfile', 'Dockerfile'),
|
|
19
|
+
live_update=[
|
|
20
|
+
sync(s['localPath'], s['remotePath'])
|
|
21
|
+
for s in config_json.get('sync', [])
|
|
22
|
+
],
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Manifests are generated by the localctl CLI from .local/config.json so there is a single
|
|
26
|
+
# source of truth for the app's k8s definition.
|
|
27
|
+
k8s_yaml(local(['localctl', 'app', 'generate-manifests']))
|
|
28
|
+
|
|
29
|
+
port_forwards = ['%s:%s' % (config_json['port'], config_json['port'])]
|
|
30
|
+
debug_cfg = config_json.get('debug', {})
|
|
31
|
+
if debug_cfg.get('enabled'):
|
|
32
|
+
dp = debug_cfg['port']
|
|
33
|
+
port_forwards.append('%s:%s' % (dp, dp))
|
|
34
|
+
|
|
35
|
+
k8s_resource(config_json['name'], port_forwards=port_forwards)
|