@localctl/cli 0.2.0 → 0.3.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,134 +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
- };
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
+ };
@@ -1,32 +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 };
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 };
package/src/lib/prompt.js CHANGED
@@ -1,97 +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 };
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 };
@@ -1,30 +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 };
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 };