influx-local-cli 0.1.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/src/cli.js ADDED
@@ -0,0 +1,1092 @@
1
+ 'use strict';
2
+
3
+ const { Command } = require('commander');
4
+ const fs = require('fs/promises');
5
+ const path = require('path');
6
+ const pkg = require('../package.json');
7
+ const manager = require('./manager');
8
+ const configMod = require('./config');
9
+ const { FLAVORS, FLAVOR_ORDER, flavorOf, artifactsFor, resolveVersionInput, versionCatalog } = require('./versions');
10
+ const {
11
+ loadConfig,
12
+ saveConfig,
13
+ configExists,
14
+ listInstances,
15
+ assertValidInstanceName,
16
+ readCreds,
17
+ getConfigPath,
18
+ SETTABLE_KEYS,
19
+ miseBin,
20
+ } = configMod;
21
+
22
+ // ANSI color codes as constants (empty when stdout is not a tty or NO_COLOR is
23
+ // set). wrap(text, code) colors a single piece of text.
24
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
25
+ const code = (c) => (useColor ? `\u001b[${c}m` : '');
26
+ const reset = code('0');
27
+ const colors = { cyan: code('36'), bright: code('1'), dim: code('2'), red: code('31'), green: code('32'), yellow: code('33'), magenta: code('35') };
28
+ function wrap(s, c) {
29
+ const col = code(c);
30
+ return useColor ? `${col}${s}${reset}` : String(s);
31
+ }
32
+
33
+ // DSNs carry either "user:password@" (1.x) or a bare "token@" (2.x/3.x) in the
34
+ // userinfo position — mask both shapes.
35
+ function maskSecrets(uri) {
36
+ let out = String(uri).replace(/\/\/([^:@/]+):([^@/]+)@/, '//$1:•••••••@');
37
+ out = out.replace(/\/\/([^:@/]+)@/, '//•••••••@');
38
+ return out;
39
+ }
40
+
41
+ function formatState(state) {
42
+ if (state === 'running') return wrap('running', '32');
43
+ if (state === 'port-conflict') return wrap('port-conflict', '31');
44
+ if (state === 'stale-pid') return wrap('stale-pid', '31');
45
+ return wrap('stopped', '33');
46
+ }
47
+
48
+ function flavorTag(flavorId) {
49
+ const f = FLAVORS[flavorId];
50
+ return f ? f.label : flavorId;
51
+ }
52
+
53
+ // --- First-run wizard -------------------------------------------------------
54
+
55
+ // Scan upward from startPort until a free port is found.
56
+ async function findFreePort(startPort, host, maxAttempts = 50) {
57
+ for (let port = startPort; port < startPort + maxAttempts; port++) {
58
+ if (!(await manager.checkPort(port, host))) return port;
59
+ }
60
+ return startPort;
61
+ }
62
+
63
+ // Minimal arrow-key list selector (↑/↓ to move, Enter to pick, Esc/q/Ctrl+C to
64
+ // abort). Returns the chosen option value, or null when aborted.
65
+ function selectOption(question, options) {
66
+ const readline = require('readline');
67
+ const { stdin, stdout } = process;
68
+ return new Promise((resolve) => {
69
+ readline.emitKeypressEvents(stdin);
70
+ if (stdin.isTTY) stdin.setRawMode(true);
71
+ stdin.resume();
72
+
73
+ let index = 0;
74
+ const totalLines = Math.min(options.length, 20) + 1;
75
+ const cleanup = () => {
76
+ if (stdin.isTTY) stdin.setRawMode(false);
77
+ stdout.write('\n');
78
+ };
79
+ const onKeypress = (str, key) => {
80
+ if (key.name === 'up' || key.name === 'k') {
81
+ index = (index - 1 + options.length) % options.length;
82
+ render(false);
83
+ } else if (key.name === 'down' || key.name === 'j') {
84
+ index = (index + 1) % options.length;
85
+ render(false);
86
+ } else if (key.name === 'return') {
87
+ stdin.removeListener('keypress', onKeypress);
88
+ cleanup();
89
+ resolve(options[index].value);
90
+ } else if (str && /^[1-9]$/.test(str)) {
91
+ const num = parseInt(str, 10);
92
+ if (num <= options.length) {
93
+ index = num - 1;
94
+ render(false);
95
+ }
96
+ } else if ((key.ctrl && key.name === 'c') || key.name === 'escape' || key.name === 'q') {
97
+ stdin.removeListener('keypress', onKeypress);
98
+ cleanup();
99
+ resolve(null);
100
+ }
101
+ };
102
+ function render(initial) {
103
+ if (!initial) stdout.write(`\x1b[${totalLines}A\x1b[0J`);
104
+ stdout.write(`${question}\n`);
105
+ for (let i = 0; i < options.length; i++) {
106
+ stdout.write(
107
+ i === index
108
+ ? ` ${colors.cyan}${colors.bright}> ${options[i].label}${reset}\n`
109
+ : ` ${options[i].label}\n`,
110
+ );
111
+ }
112
+ }
113
+ stdin.on('keypress', onKeypress);
114
+ render(true);
115
+ });
116
+ }
117
+
118
+ // Interactive InfluxDB version picker (the whole point of the --version flag):
119
+ // the catalog grouped by product line, plus an escape hatch for any other
120
+ // published version.
121
+ async function selectVersion(defaultVersion, store) {
122
+ const options = [];
123
+ for (const id of FLAVOR_ORDER) {
124
+ const f = FLAVORS[id];
125
+ for (const version of f.versions) {
126
+ const marks = [];
127
+ if (version === f.defaultVersion) marks.push('default for ' + f.label);
128
+ if (version === defaultVersion) marks.push('current');
129
+ options.push({
130
+ value: version,
131
+ label: `${version} ${colors.dim}${f.label}${marks.length ? ` — ${marks.join(', ')}` : ''}${reset}`,
132
+ });
133
+ }
134
+ }
135
+ options.push({ value: '__custom__', label: `${colors.dim}enter another published version…${reset}` });
136
+
137
+ const picked = await selectOption('Which InfluxDB version should this instance pin?', options);
138
+ if (picked === null) return null;
139
+ if (picked !== '__custom__') return picked;
140
+
141
+ const readline = require('readline').createInterface({ input: process.stdin, output: process.stdout });
142
+ try {
143
+ const raw = (await new Promise((r) => readline.question(` ${colors.bright}Version${reset} (e.g. 2.7.11): `, r))).trim();
144
+ return resolveVersionInput(raw);
145
+ } finally {
146
+ readline.close();
147
+ }
148
+ }
149
+
150
+ // First run of any command targeting an instance: ask the user to configure it
151
+ // interactively. Pressing Enter keeps the default. Returns true when a config
152
+ // was saved, false when there is nothing to configure.
153
+ async function ensureInstanceConfigured(name) {
154
+ if (await configExists(name)) return false;
155
+ if (!process.stdin.isTTY) return false;
156
+
157
+ const config = await loadConfig(name);
158
+ console.log(
159
+ `${colors.cyan}Instance "${name}" has not been configured yet.\nPlease answer the following questions (press ${colors.bright}Enter${reset}${colors.cyan} to use the default value):${reset}`,
160
+ );
161
+
162
+ const readline = require('readline').createInterface({ input: process.stdin, output: process.stdout });
163
+ const ask = (question) => new Promise((resolve) => readline.question(question, resolve));
164
+
165
+ try {
166
+ console.log(` ${colors.dim}available lines: 1.x = InfluxQL, 2.x = Flux, 3.x = Core/SQL${reset}`);
167
+ const versionRaw = (await ask(` ${colors.bright}InfluxDB version${reset} (default: ${config.version}): `)).trim();
168
+ if (versionRaw !== '') config.version = resolveVersionInput(versionRaw);
169
+ config.port = await findFreePort(config.port, config.host);
170
+ const portRaw = (await ask(` ${colors.bright}Port${reset} (default: ${config.port}): `)).trim();
171
+ if (portRaw !== '') {
172
+ const port = parseInt(portRaw, 10);
173
+ if (Number.isNaN(port) || port <= 0 || port > 65535) throw new Error('port must be 1-65535');
174
+ config.port = port;
175
+ }
176
+ const hostRaw = (await ask(` ${colors.bright}Host / bind IP${reset} (default: ${config.host}): `)).trim();
177
+ if (hostRaw !== '') config.host = hostRaw;
178
+ const dbRaw = (await ask(` ${colors.bright}Database${reset} (default: ${config.database}): `)).trim();
179
+ if (dbRaw !== '') config.database = dbRaw;
180
+ } finally {
181
+ readline.close();
182
+ }
183
+
184
+ await saveConfig(name, config);
185
+ console.log(`${colors.green}✓ Configuration for "${name}" saved.${reset}`);
186
+ return true;
187
+ }
188
+
189
+ // Decide which instance a command targets. Explicit name wins; otherwise, when
190
+ // several instances exist on an interactive terminal, ask the user to pick one.
191
+ async function resolveInstanceName(providedName) {
192
+ if (providedName) return providedName;
193
+ if (!process.stdin.isTTY) return 'default';
194
+
195
+ const names = await listInstances();
196
+ if (names.length === 0) return 'default';
197
+ if (names.length === 1) return names[0];
198
+
199
+ const labels = [];
200
+ for (const name of names) {
201
+ const cfg = await loadConfig(name);
202
+ labels.push({
203
+ value: name,
204
+ label: `${name} ${colors.dim}${cfg.version} (${cfg.host}:${cfg.port})${reset}`,
205
+ });
206
+ }
207
+ return selectOption('Select the InfluxDB instance:', labels);
208
+ }
209
+
210
+ async function prepareInstance(providedName) {
211
+ if (providedName !== undefined && providedName !== null) assertValidInstanceName(providedName);
212
+ const name = await resolveInstanceName(providedName);
213
+ if (name === null) return null;
214
+ await ensureInstanceConfigured(name);
215
+ return name;
216
+ }
217
+
218
+ function mergeInstanceArg(name, options) {
219
+ const fromFlag = options && options.instance;
220
+ if (fromFlag !== undefined && name !== undefined && fromFlag !== name) {
221
+ throw new Error('Specify the instance either as an argument or with --instance, not both.');
222
+ }
223
+ return fromFlag !== undefined ? fromFlag : name;
224
+ }
225
+
226
+ function handle(action) {
227
+ return (...args) => {
228
+ action(...args).catch((err) => {
229
+ console.error(`${colors.red}Error: ${err && err.message ? err.message : err}${reset}`);
230
+ process.exitCode = 1;
231
+ });
232
+ };
233
+ }
234
+
235
+ function printConnection(cfg, { showSecrets = false } = {}) {
236
+ console.log(`\n${colors.bright}Connection details${reset}:`);
237
+ console.log(` ${colors.bright}Flavor:${reset} ${flavorTag(cfg.flavor)} — version ${cfg.version} (auth: ${cfg.auth})`);
238
+ console.log(` ${colors.bright}Host:${reset} ${cfg.host}`);
239
+ console.log(` ${colors.bright}Port:${reset} ${cfg.port}`);
240
+ console.log(` ${colors.bright}Endpoint:${reset} http://${cfg.host === '0.0.0.0' ? '127.0.0.1' : cfg.host}:${cfg.port}`);
241
+ if (cfg.flavor === 'v1') {
242
+ console.log(` ${colors.bright}Database:${reset} ${cfg.database} (measurement: ${cfg.measurement})`);
243
+ console.log(` ${colors.bright}Admin user:${reset} ${cfg.adminUser} (ALL PRIVILEGES)`);
244
+ console.log(` ${colors.bright}App user:${reset} ${cfg.appUser} (ALL on ${cfg.database})`);
245
+ } else if (cfg.flavor === 'v2') {
246
+ console.log(` ${colors.bright}Org:${reset} ${cfg.org}`);
247
+ console.log(` ${colors.bright}Bucket:${reset} ${cfg.database}`);
248
+ console.log(` ${colors.bright}Admin user:${reset} ${cfg.adminUser} (operator password + all-access token)`);
249
+ console.log(` ${colors.bright}App token:${reset} read/write on bucket "${cfg.database}"`);
250
+ } else {
251
+ console.log(` ${colors.bright}Database:${reset} ${cfg.database}`);
252
+ console.log(` ${colors.bright}Auth:${reset} single admin token ("_admin")`);
253
+ }
254
+ console.log(` ${colors.bright}Data dir:${reset} ${cfg.dataDir}`);
255
+ if (showSecrets) {
256
+ console.log(` ${colors.bright}creds:${reset} ${cfg.credsPath} (mode 0600)`);
257
+ }
258
+ }
259
+
260
+ function printUris(uris, { showSecrets = false } = {}) {
261
+ if (!uris) return;
262
+ const rows = [
263
+ ['App DSN', uris.appUri],
264
+ ['Admin DSN', uris.adminUri],
265
+ ['Public DSN', uris.publicAppUri],
266
+ ];
267
+ for (const [label, value] of rows) {
268
+ if (!value) continue;
269
+ console.log(` ${colors.bright}${label.padEnd(10)}${reset} ${showSecrets ? value : maskSecrets(value)}`);
270
+ }
271
+ if (!showSecrets && (uris.appUri || uris.adminUri)) {
272
+ console.log(` ${colors.dim}(re-run with --show-secrets to print real secrets)${reset}`);
273
+ }
274
+ }
275
+
276
+ async function printInstanceResult(name, result, options, { verb }) {
277
+ console.log(`${colors.green}✓ Instance "${name}" ${verb}.${reset}`);
278
+ printConnection(result.config, { showSecrets: options.showSecrets });
279
+ const creds = await readCreds(name, result.config.flavor);
280
+ if (creds) {
281
+ const status = await manager.getStatus(name, { detail: false });
282
+ printUris(status.uris, { showSecrets: options.showSecrets });
283
+ }
284
+ }
285
+
286
+ // --- Program ----------------------------------------------------------------
287
+
288
+ function buildProgram() {
289
+ const program = new Command();
290
+ program
291
+ .name('influx-local')
292
+ .description('Create, start and manage local InfluxDB instances (1.x / 2.x / 3.x) without Docker/Podman (powered by mise)')
293
+ .version(pkg.version)
294
+ .showSuggestionAfterError();
295
+
296
+ program
297
+ .command('versions')
298
+ .description('List the InfluxDB versions this tool can install (pin one with "create -v/--influxdb")')
299
+ .option('-f, --flavor <line>', 'Only this line: 1, 2 or 3')
300
+ .option('-j, --json', 'Print machine-readable JSON')
301
+ .action(
302
+ handle(async (options) => {
303
+ const catalog = versionCatalog();
304
+ const filtered = options.flavor
305
+ ? catalog.filter((entry) => entry.flavor === `v${String(options.flavor).replace(/^v/, '')}`)
306
+ : catalog;
307
+ if (filtered.length === 0) throw new Error(`unknown flavor "${options.flavor}" — use 1, 2 or 3`);
308
+ if (options.json) {
309
+ console.log(JSON.stringify(filtered, null, 2));
310
+ return;
311
+ }
312
+ console.log(`${colors.bright}InfluxDB versions available to "influx-local create -v <version>"${reset}`);
313
+ console.log(''.padEnd(78, '-'));
314
+ for (const id of FLAVOR_ORDER) {
315
+ const entries = filtered.filter((entry) => entry.flavor === id);
316
+ if (entries.length === 0) continue;
317
+ const f = FLAVORS[id];
318
+ console.log(` ${colors.bright}${f.label}${reset} ${colors.dim}(auth: ${f.auth}, default port ${f.defaultPort})${reset}`);
319
+ const list = entries
320
+ .map((entry) => (entry.default ? wrap(entry.version, '32') : entry.version))
321
+ .join(' ');
322
+ console.log(` ${list}`);
323
+ const def = entries.find((entry) => entry.default) || entries[0];
324
+ console.log(` ${colors.dim}line alias: -v ${id[1]}.x → ${def.version}${reset}`);
325
+ }
326
+ console.log(''.padEnd(78, '-'));
327
+ console.log(` ${colors.dim}Any other published version works too: create -v 2.7.5${reset}`);
328
+ }),
329
+ );
330
+
331
+ program
332
+ .command('create [name]')
333
+ .description('Create a new (stopped) instance with a pinned InfluxDB version; then run "setup" to provision it')
334
+ .option('-v, --influxdb <version>', 'InfluxDB version to pin (1.8.10, 2.7.11, 3.11.4, or a line alias 1.x/2.x/3.x)')
335
+ .option('-p, --port <port>', 'TCP port (default: first free port for the chosen line)')
336
+ .option('--host <host>', 'Bind address (default: 127.0.0.1)')
337
+ .option('--database <name>', 'Database (1.x/3.x) or bucket (2.x) — default: instance name')
338
+ .option('--measurement <name>', 'Sample measurement/table (1.x/3.x, default: items)')
339
+ .option('--org <name>', 'InfluxDB 2.x organization (default: instance name)')
340
+ .option('--admin-user <name>', 'Admin user (1.x/2.x, default: admin)')
341
+ .option('--app-user <name>', 'App user (1.x, default: app)')
342
+ .option('--admin-password <pw>', 'Admin password (1.x/2.x, default: auto-generated)')
343
+ .option('--app-password <pw>', 'App user password (1.x, default: auto-generated)')
344
+ .action(
345
+ handle(async (name, options) => {
346
+ const interactive = process.stdin.isTTY;
347
+ if (!name) {
348
+ if (!interactive) throw new Error('instance name is required: influx-local create <name>');
349
+ const readline = require('readline').createInterface({ input: process.stdin, output: process.stdout });
350
+ name = await new Promise((r) => readline.question(` ${colors.bright}Instance name${reset}: `, r));
351
+ readline.close();
352
+ }
353
+ name = assertValidInstanceName(String(name).trim());
354
+ if (await configExists(name)) throw new Error(`instance "${name}" already exists`);
355
+
356
+ let version;
357
+ if (options.influxdb !== undefined) {
358
+ version = resolveVersionInput(options.influxdb);
359
+ } else if (interactive) {
360
+ version = await selectVersion(configMod.DEFAULT_VERSION);
361
+ if (version === null) {
362
+ console.log(`${colors.yellow}Aborted.${reset}`);
363
+ return;
364
+ }
365
+ } else {
366
+ version = configMod.DEFAULT_VERSION;
367
+ }
368
+
369
+ const flavor = flavorOf(version);
370
+ const nextPort = await manager.nextFreePort(flavor.defaultPort, options.host || '127.0.0.1');
371
+ let port = options.port !== undefined ? parseInt(options.port, 10) : nextPort;
372
+ if (Number.isNaN(port) || port <= 0 || port > 65535) throw new Error('port must be 1-65535');
373
+
374
+ let host = options.host || '127.0.0.1';
375
+ let database = options.database || name;
376
+ let measurement = options.measurement || 'items';
377
+ let org = options.org || name;
378
+ let adminUser = options.adminUser || 'admin';
379
+ let appUser = options.appUser || 'app';
380
+ let adminPassword = options.adminPassword !== undefined ? options.adminPassword : '';
381
+ let appPassword = options.appPassword !== undefined ? options.appPassword : '';
382
+
383
+ if (interactive) {
384
+ console.log(`${colors.cyan}Creating instance "${name}" — press Enter to accept each default:${reset}`);
385
+ const readline = require('readline').createInterface({ input: process.stdin, output: process.stdout });
386
+ const ask = (q) => new Promise((r) => readline.question(q, r));
387
+ try {
388
+ const pick = async (label, current) => {
389
+ const raw = (await ask(` ${colors.bright}${label}${reset} (default: ${current}): `)).trim();
390
+ return raw === '' ? current : raw;
391
+ };
392
+ if (options.port === undefined) {
393
+ port = parseInt(await pick('Port', nextPort), 10);
394
+ if (Number.isNaN(port) || port <= 0 || port > 65535) throw new Error('port must be 1-65535');
395
+ }
396
+ if (!options.host) host = await pick('Host / bind IP', host);
397
+ if (!options.database) database = await pick(flavor.id === 'v2' ? 'Bucket name' : 'Database name', database);
398
+ if (flavor.capabilities.measurement && !options.measurement) {
399
+ measurement = await pick('Sample measurement', measurement);
400
+ }
401
+ if (flavor.id === 'v2' && !options.org) org = await pick('Organization', org);
402
+ if (flavor.id !== 'v3' && !options.adminUser) adminUser = await pick('Admin user', adminUser);
403
+ if (flavor.id === 'v1' && !options.appUser) appUser = await pick('App user', appUser);
404
+ if (flavor.id !== 'v3' && options.adminPassword === undefined) {
405
+ adminPassword = await pick('Admin password (blank = auto-generate)', 'auto-generate');
406
+ if (adminPassword === 'auto-generate') adminPassword = '';
407
+ }
408
+ if (flavor.id === 'v1' && options.appPassword === undefined) {
409
+ appPassword = await pick('App password (blank = auto-generate)', 'auto-generate');
410
+ if (appPassword === 'auto-generate') appPassword = '';
411
+ }
412
+ } finally {
413
+ readline.close();
414
+ }
415
+ }
416
+
417
+ // 2.x has no app user (access is scoped with tokens) and 3.x has no
418
+ // users at all — keep those keys out of the config.
419
+ const config = {
420
+ version,
421
+ port,
422
+ host,
423
+ database,
424
+ publicHost: '',
425
+ };
426
+ if (flavor.capabilities.measurement) config.measurement = measurement;
427
+ if (flavor.id === 'v2') config.org = org;
428
+ if (flavor.id !== 'v3') {
429
+ config.adminUser = adminUser;
430
+ if (flavor.id === 'v1') config.appUser = appUser;
431
+ }
432
+ configMod.assertValidInstanceName(name);
433
+ await saveConfig(name, config);
434
+ await configMod.saveMiseToml(name, version);
435
+
436
+ const saved = await loadConfig(name);
437
+ if (saved.flavor === 'v1') {
438
+ await configMod.writeCreds(name, 'v1', {
439
+ ADMIN_USER: saved.adminUser,
440
+ ADMIN_PW: adminPassword || configMod.randomPassword(),
441
+ APP_USER: saved.appUser,
442
+ APP_PW: appPassword || configMod.randomPassword(),
443
+ }, saved);
444
+ } else if (saved.flavor === 'v2') {
445
+ await configMod.writeCreds(name, 'v2', {
446
+ ADMIN_USER: saved.adminUser,
447
+ ADMIN_PW: adminPassword || configMod.randomPassword(),
448
+ ADMIN_TOKEN: configMod.randomToken(),
449
+ APP_TOKEN: '',
450
+ ORG: saved.org,
451
+ BUCKET: saved.database,
452
+ }, saved);
453
+ }
454
+
455
+ console.log(`${colors.green}✓ Instance "${name}" created (stopped), pinned to InfluxDB ${version}.${reset}`);
456
+ printConnection(saved);
457
+ if (saved.flavor === 'v3') {
458
+ console.log(` ${colors.dim}The admin token is minted by the server during "setup".${reset}`);
459
+ } else {
460
+ console.log(` ${colors.bright}creds:${reset} ${saved.credsPath} (mode 0600)`);
461
+ }
462
+ console.log(` ${colors.dim}Next: influx-local setup ${name} (installs InfluxDB ${version}, provisions it, starts the server)${reset}`);
463
+ }),
464
+ );
465
+
466
+ program
467
+ .command('setup [name]')
468
+ .alias('bootstrap')
469
+ .description('Install the pinned version if missing, provision it, and start the instance')
470
+ .option('-n, --instance <name>', 'Instance name (alternative to the positional argument)')
471
+ .option('-s, --show-secrets', 'Print real secrets in connection DSNs')
472
+ .action(
473
+ handle(async (name, options) => {
474
+ name = mergeInstanceArg(name, options);
475
+ name = await prepareInstance(name);
476
+ if (name === null) return console.log(`${colors.yellow}Aborted.${reset}`);
477
+ const log = (m) => console.log(m);
478
+ const cfg = await loadConfig(name);
479
+ console.log(`${colors.cyan}Setting up InfluxDB ${cfg.version} instance "${name}"…${reset}`);
480
+ const result = await manager.setupInstance(name, { log });
481
+ if (result.online) {
482
+ console.log(`${colors.green}✓ Instance "${name}" repaired online — no restart needed.${reset}`);
483
+ }
484
+ await printInstanceResult(name, result, options, { verb: result.online ? 'repaired' : 'is ready' });
485
+ }),
486
+ );
487
+
488
+ program
489
+ .command('start [name]')
490
+ .description('Start an InfluxDB instance (auto-installs the pinned version when missing)')
491
+ .option('-n, --instance <name>', 'Instance name (alternative to the positional argument)')
492
+ .option('-s, --show-secrets', 'Print real secrets in connection DSNs')
493
+ .action(
494
+ handle(async (name, options) => {
495
+ name = mergeInstanceArg(name, options);
496
+ name = await prepareInstance(name);
497
+ if (name === null) return console.log(`${colors.yellow}Aborted.${reset}`);
498
+ const cfg = await loadConfig(name);
499
+ await manager.installTools(cfg).catch(() => {});
500
+ const log = (m) => console.log(m);
501
+ console.log(`${colors.cyan}Starting InfluxDB instance "${name}"…${reset}`);
502
+ const result = await manager.startInstance(name, { log });
503
+ if (result.alreadyRunning) {
504
+ console.log(`${colors.yellow}Instance "${name}" is already running (pid ${result.pid}).${reset}`);
505
+ } else {
506
+ console.log(`${colors.green}✓ InfluxDB instance "${name}" started (pid ${result.pid}).${reset}`);
507
+ }
508
+ printConnection(result.config, { showSecrets: options.showSecrets });
509
+ const status = await manager.getStatus(name, { detail: false });
510
+ printUris(status.uris, { showSecrets: options.showSecrets });
511
+ }),
512
+ );
513
+
514
+ program
515
+ .command('stop [name]')
516
+ .description('Stop a local InfluxDB instance')
517
+ .option('-f, --force', 'Terminate immediately instead of waiting for a graceful stop')
518
+ .option('-n, --instance <name>', 'Instance name (alternative to the positional argument)')
519
+ .action(
520
+ handle(async (name, options) => {
521
+ name = mergeInstanceArg(name, options);
522
+ name = await prepareInstance(name);
523
+ if (name === null) return console.log(`${colors.yellow}Aborted.${reset}`);
524
+ const log = (m) => console.log(m);
525
+ console.log(`${colors.cyan}Stopping InfluxDB instance "${name}"…${reset}`);
526
+ const result = await manager.stopInstance(name, { force: options.force, log });
527
+ if (result.alreadyStopped) {
528
+ console.log(`${colors.yellow}Instance "${name}" is already stopped.${reset}`);
529
+ } else if (options.force) {
530
+ console.log(`${colors.yellow}! Instance "${name}" terminated forcefully.${reset}`);
531
+ } else {
532
+ console.log(`${colors.green}✓ Instance "${name}" stopped gracefully.${reset}`);
533
+ }
534
+ }),
535
+ );
536
+
537
+ program
538
+ .command('restart [name]')
539
+ .description('Restart a local InfluxDB instance')
540
+ .option('-f, --force', 'Kill immediately if a graceful stop times out')
541
+ .option('-n, --instance <name>', 'Instance name (alternative to the positional argument)')
542
+ .action(
543
+ handle(async (name, options) => {
544
+ name = mergeInstanceArg(name, options);
545
+ name = await prepareInstance(name);
546
+ if (name === null) return console.log(`${colors.yellow}Aborted.${reset}`);
547
+ const log = (m) => console.log(m);
548
+ console.log(`${colors.cyan}Restarting InfluxDB instance "${name}"…${reset}`);
549
+ await manager.restartInstance(name, { force: options.force, log });
550
+ console.log(`${colors.green}✓ Instance "${name}" restarted.${reset}`);
551
+ }),
552
+ );
553
+
554
+ program
555
+ .command('status [name]')
556
+ .alias('ps')
557
+ .description('Check the status of an InfluxDB instance')
558
+ .option('-n, --instance <name>', 'Instance name (alternative to the positional argument)')
559
+ .option('-j, --json', 'Print machine-readable JSON')
560
+ .option('-s, --show-secrets', 'Print real secrets in connection DSNs')
561
+ .action(
562
+ handle(async (name, options) => {
563
+ name = mergeInstanceArg(name, options);
564
+ name = await prepareInstance(name);
565
+ if (name === null) return console.log(`${colors.yellow}Aborted.${reset}`);
566
+ const status = await manager.getStatus(name);
567
+ if (options.json) {
568
+ const { config, ...plain } = status;
569
+ if (!options.showSecrets && plain.uris) {
570
+ for (const key of Object.keys(plain.uris)) plain.uris[key] = maskSecrets(plain.uris[key]);
571
+ }
572
+ console.log(JSON.stringify(plain, null, 2));
573
+ return;
574
+ }
575
+ console.log(`${colors.bright}Instance:${reset} ${status.name}`);
576
+ console.log(`${colors.bright}Status:${reset} ${formatState(status.state)}${status.pid ? ` (pid ${status.pid})` : ''}`);
577
+ console.log(`${colors.bright}Flavor:${reset} ${status.flavorLabel} — pinned ${status.version}${status.runningVersion ? `, running ${status.runningVersion}` : ''}`);
578
+ console.log(`${colors.bright}Host:${reset} ${status.host}`);
579
+ console.log(`${colors.bright}Port:${reset} ${status.port}`);
580
+ console.log(`${colors.bright}Endpoint:${reset} ${status.endpoint}`);
581
+ if (status.flavor === 'v2') {
582
+ console.log(`${colors.bright}Org/Bucket:${reset} ${status.org} / ${status.database}`);
583
+ console.log(`${colors.bright}Admin user:${reset} ${status.adminUser}`);
584
+ } else {
585
+ console.log(`${colors.bright}Database:${reset} ${status.database}${status.measurement ? ` (measurement: ${status.measurement})` : ''}`);
586
+ if (status.adminUser) console.log(`${colors.bright}Users:${reset} admin=${status.adminUser}${status.appUser ? ` app=${status.appUser}` : ''}`);
587
+ }
588
+ console.log(`${colors.bright}Dir:${reset} ${status.dataDir}`);
589
+ if (status.running) {
590
+ console.log(
591
+ `${colors.bright}Measurements:${reset} ${status.measurements == null ? '?' : status.measurements.join(', ') || '(none yet)'}`,
592
+ );
593
+ printUris(status.uris, { showSecrets: options.showSecrets });
594
+ } else {
595
+ console.log(`${colors.yellow}Start it with: influx-local start ${status.name}${reset}`);
596
+ }
597
+ }),
598
+ );
599
+
600
+ program
601
+ .command('list')
602
+ .alias('ls')
603
+ .description('List all local InfluxDB instances')
604
+ .option('-j, --json', 'Print machine-readable JSON')
605
+ .action(
606
+ handle(async (options) => {
607
+ const statuses = await manager.listStatuses();
608
+ if (options.json) {
609
+ console.log(JSON.stringify(statuses, null, 2));
610
+ return;
611
+ }
612
+ if (statuses.length === 0) {
613
+ console.log(`${colors.yellow}No instances found. Start one with "influx-local create [name]".${reset}`);
614
+ return;
615
+ }
616
+ console.log(`${colors.bright}Local InfluxDB Instances:${reset}`);
617
+ console.log(''.padEnd(78, '-'));
618
+ for (const s of statuses) {
619
+ const nameCell = wrap(s.name.padEnd(12), s.running ? '32' : '1');
620
+ console.log(
621
+ ` ${nameCell} | ${formatState(s.state).padEnd(16)} | ${String(s.version).padEnd(8)} | ${s.host}:${s.port}`,
622
+ );
623
+ }
624
+ console.log(''.padEnd(78, '-'));
625
+ }),
626
+ );
627
+
628
+ program
629
+ .command('rename <name> <newName>')
630
+ .description('Rename an instance (it must be stopped first)')
631
+ .action(
632
+ handle(async (name, newName) => {
633
+ name = await resolveInstanceName(name);
634
+ if (name === null) return console.log(`${colors.yellow}Aborted.${reset}`);
635
+ const log = (m) => console.log(m);
636
+ const result = await manager.renameInstance(name, newName, { log });
637
+ console.log(`${colors.green}✓ Instance renamed: "${name}" -> "${result}".${reset}`);
638
+ }),
639
+ );
640
+
641
+ program
642
+ .command('clone <name> <newName>')
643
+ .alias('duplicate')
644
+ .description("Copy an instance's config + credentials to a new name (picks the next free port)")
645
+ .option('-p, --port <port>', 'Port for the clone (default: next free port)')
646
+ .action(
647
+ handle(async (name, newName, options) => {
648
+ name = await resolveInstanceName(name);
649
+ if (name === null) return console.log(`${colors.yellow}Aborted.${reset}`);
650
+ let port;
651
+ if (options.port !== undefined) {
652
+ port = parseInt(options.port, 10);
653
+ if (Number.isNaN(port) || port <= 0 || port > 65535) throw new Error('port must be 1-65535');
654
+ }
655
+ const log = (m) => console.log(m);
656
+ const result = await manager.cloneInstance(name, newName, { port, log });
657
+ console.log(`${colors.green}✓ Instance cloned: "${result.name}" on port ${result.port} (stopped, InfluxDB ${result.config.version}).${reset}`);
658
+ console.log(` ${colors.dim}Next: influx-local setup ${result.name}${reset}`);
659
+ }),
660
+ );
661
+
662
+ program
663
+ .command('set-password <name>')
664
+ .description('Set an explicit password (1.x: admin/app users, 2.x: operator user)')
665
+ .option('--user <admin|app>', 'Which user to update', 'admin')
666
+ .option('-p, --password <pw>', 'New password (min 6 chars)')
667
+ .action(
668
+ handle(async (name, options) => {
669
+ name = await resolveInstanceName(name);
670
+ if (name === null) return console.log(`${colors.yellow}Aborted.${reset}`);
671
+ let password = options.password;
672
+ if (!password) {
673
+ if (!process.stdin.isTTY) throw new Error('--password is required on a non-interactive terminal');
674
+ const readline = require('readline').createInterface({ input: process.stdin, output: process.stdout });
675
+ password = await new Promise((r) =>
676
+ readline.question(` New password for ${options.user} (min 6 chars): `, r),
677
+ );
678
+ readline.close();
679
+ }
680
+ const log = (m) => console.log(m);
681
+ const result = await manager.setPassword(name, { user: options.user, password, log });
682
+ console.log(
683
+ result.applied
684
+ ? `${colors.green}✓ ${options.user} password updated on the running server.${reset}`
685
+ : `${colors.green}✓ creds.env updated. Server is stopped — run "influx-local setup ${name}" to apply it.${reset}`,
686
+ );
687
+ }),
688
+ );
689
+
690
+ program
691
+ .command('users [name]')
692
+ .description('List users/tokens of an instance (built-in + server-side)')
693
+ .option('-n, --instance <name>', 'Instance name (alternative to the positional argument)')
694
+ .action(
695
+ handle(async (name, options) => {
696
+ name = mergeInstanceArg(name, options);
697
+ name = await resolveInstanceName(name);
698
+ if (name === null) return console.log(`${colors.yellow}Aborted.${reset}`);
699
+ const data = await manager.listUsers(name);
700
+ if (!data.running) {
701
+ console.log(`${colors.yellow}Server is stopped — showing the managed credentials only (start it to read the server).${reset}`);
702
+ } else if (data.error) {
703
+ console.log(`${colors.red}${data.error}${reset}`);
704
+ }
705
+ const width = Math.max(6, ...data.users.map((u) => u.user.length));
706
+ console.log(`${colors.bright}Users of "${name}":${reset}`);
707
+ for (const u of data.users) {
708
+ const roles = (u.roles || []).join(',') || '—';
709
+ const tag = u.builtin ? ` ${wrap('[managed]', '33')}` : '';
710
+ console.log(` ${colors.bright}${u.user.padEnd(width)}${reset} @${String(u.db || '—').padEnd(16)} ${roles}${tag}`);
711
+ }
712
+ }),
713
+ );
714
+
715
+ program
716
+ .command('user-add <name> <username>')
717
+ .description('Create a user on a running instance')
718
+ .option('-p, --password <pw>', 'Password (min 6 chars)')
719
+ .option('--db <admin|database>', 'Target database (1.x only, default: instance database)')
720
+ .option('--privileges <list>', 'Comma-separated 1.x privileges: read,write,all (default: all)')
721
+ .action(
722
+ handle(async (name, username, options) => {
723
+ name = await resolveInstanceName(name);
724
+ if (name === null) return console.log(`${colors.yellow}Aborted.${reset}`);
725
+ if (!options.password) {
726
+ if (!process.stdin.isTTY) throw new Error('--password is required on a non-interactive terminal');
727
+ const readline = require('readline').createInterface({ input: process.stdin, output: process.stdout });
728
+ options.password = await new Promise((r) =>
729
+ readline.question(` Password for "${username}" (min 6 chars): `, r),
730
+ );
731
+ readline.close();
732
+ }
733
+ const privileges = options.privileges ? options.privileges.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
734
+ const log = (m) => console.log(m);
735
+ const res = await manager.addUser(name, {
736
+ username,
737
+ db: options.db,
738
+ password: options.password,
739
+ privileges,
740
+ log,
741
+ });
742
+ console.log(`${colors.green}✓ Created "${res.user}" on ${res.db} (${res.privileges.join(', ')}).${reset}`);
743
+ }),
744
+ );
745
+
746
+ program
747
+ .command('user-password <name> <username>')
748
+ .description('Set an explicit password for any existing user')
749
+ .option('-p, --password <pw>', 'New password (min 6 chars)')
750
+ .action(
751
+ handle(async (name, username, options) => {
752
+ name = await resolveInstanceName(name);
753
+ if (name === null) return console.log(`${colors.yellow}Aborted.${reset}`);
754
+ if (!options.password) {
755
+ if (!process.stdin.isTTY) throw new Error('--password is required on a non-interactive terminal');
756
+ const readline = require('readline').createInterface({ input: process.stdin, output: process.stdout });
757
+ options.password = await new Promise((r) =>
758
+ readline.question(` New password for "${username}" (min 6 chars): `, r),
759
+ );
760
+ readline.close();
761
+ }
762
+ const log = (m) => console.log(m);
763
+ const res = await manager.setAnyPassword(name, { username, password: options.password, log });
764
+ console.log(`${colors.green}✓ Password of "${res.user}" updated.${reset}`);
765
+ }),
766
+ );
767
+
768
+ program
769
+ .command('user-rm <name> <username>')
770
+ .description('Delete a user (managed users are protected)')
771
+ .action(
772
+ handle(async (name, username) => {
773
+ name = await resolveInstanceName(name);
774
+ if (name === null) return console.log(`${colors.yellow}Aborted.${reset}`);
775
+ const log = (m) => console.log(m);
776
+ const res = await manager.removeUser(name, { username, log });
777
+ console.log(`${colors.green}✓ Deleted "${res.user}".${reset}`);
778
+ }),
779
+ );
780
+
781
+ const configCmd = program.command('config').description('Manage instance configuration');
782
+
783
+ configCmd
784
+ .command('show [name]')
785
+ .description('Show configuration for an instance')
786
+ .option('-n, --instance <name>', 'Instance name (alternative to the positional argument)')
787
+ .action(
788
+ handle(async (name, options) => {
789
+ name = mergeInstanceArg(name, options);
790
+ name = await prepareInstance(name);
791
+ if (name === null) return console.log(`${colors.yellow}Aborted.${reset}`);
792
+ const cfg = await loadConfig(name);
793
+ const printable = { name: cfg.name, flavor: cfg.flavor, flavorLabel: cfg.flavorLabel, auth: cfg.auth };
794
+ for (const key of SETTABLE_KEYS) {
795
+ if (key === 'measurement' && !cfg.capabilities.measurement) continue;
796
+ if (key === 'org' && cfg.flavor !== 'v2') continue;
797
+ if ((key === 'adminUser' || key === 'appUser') && cfg.flavor === 'v3') continue;
798
+ if (key === 'appUser' && cfg.flavor !== 'v1') continue;
799
+ printable[key] = cfg[key];
800
+ }
801
+ printable.dataDir = cfg.dataDir;
802
+ printable.miseToml = cfg.miseTomlPath;
803
+ console.log(JSON.stringify(printable, null, 2));
804
+ }),
805
+ );
806
+
807
+ configCmd
808
+ .command('set <name> <key> <value>')
809
+ .description(`Set a configuration parameter (keys: ${SETTABLE_KEYS.join(', ')})`)
810
+ .action(
811
+ handle(async (name, key, value) => {
812
+ name = await prepareInstance(name);
813
+ if (name === null) return console.log(`${colors.yellow}Aborted.${reset}`);
814
+ if (!SETTABLE_KEYS.includes(key)) {
815
+ throw new Error(`Invalid config key "${key}". Valid keys: ${SETTABLE_KEYS.join(', ')}`);
816
+ }
817
+ const cfg = await loadConfig(name);
818
+ if (key === 'port') {
819
+ const port = parseInt(value, 10);
820
+ if (Number.isNaN(port) || port <= 0 || port > 65535) {
821
+ throw new Error('Port must be a valid number between 1 and 65535.');
822
+ }
823
+ cfg.port = port;
824
+ } else if (key === 'version') {
825
+ const next = resolveVersionInput(value);
826
+ const nextFlavor = flavorOf(next);
827
+ if (nextFlavor.id !== cfg.flavor && (await manager.instanceHasData(cfg))) {
828
+ throw new Error(
829
+ `Refusing to switch "${name}" from ${cfg.flavorLabel} to ${nextFlavor.label}: InfluxDB stores its data in a\n` +
830
+ ' line-specific format (1.x meta/data/wal, 2.x bolt/engine, 3.x catalog). Create a new instance instead.',
831
+ );
832
+ }
833
+ cfg.version = next;
834
+ } else {
835
+ if (value === '' && key !== 'publicHost') {
836
+ throw new Error(`Value for "${key}" must not be empty.`);
837
+ }
838
+ cfg[key] = value;
839
+ }
840
+ await saveConfig(name, cfg);
841
+ if (key === 'version') await configMod.saveMiseToml(name, cfg.version);
842
+ console.log(`${colors.green}✓ Saved configuration for "${name}": ${key} = ${cfg[key]}${reset}`);
843
+ if (key === 'version') {
844
+ console.log(`${colors.dim}The mise toolchain for ${cfg.version} is installed on the next setup/install.${reset}`);
845
+ }
846
+ console.log(
847
+ `${colors.yellow}Note: If the instance is currently running, restart it ("influx-local restart ${name}") for changes to take effect.${reset}`,
848
+ );
849
+ }),
850
+ );
851
+
852
+ program
853
+ .command('rotate [name]')
854
+ .description('Rotate the managed secrets on the running server and update creds.env')
855
+ .option('--admin', 'Rotate only the admin credential(s)')
856
+ .option('--app', 'Rotate only the app credential(s) (1.x password / 2.x token)')
857
+ .option('-n, --instance <name>', 'Instance name (alternative to the positional argument)')
858
+ .action(
859
+ handle(async (name, options) => {
860
+ name = mergeInstanceArg(name, options);
861
+ name = await prepareInstance(name);
862
+ if (name === null) return console.log(`${colors.yellow}Aborted.${reset}`);
863
+ const users = options.admin && !options.app ? 'admin' : options.app && !options.admin ? 'app' : 'all';
864
+ const log = (m) => console.log(m);
865
+ console.log(`${colors.cyan}Rotating secrets for "${name}" (${users})…${reset}`);
866
+ const result = await manager.rotateInstance(name, { users, log });
867
+ const which = [result.changed.admin && 'admin', result.changed.app && 'app'].filter(Boolean).join(' + ');
868
+ console.log(`${colors.green}✓ ${which} secret(s) rotated. Old credentials are now rejected.${reset}`);
869
+ }),
870
+ );
871
+
872
+ program
873
+ .command('query [name] [statement]')
874
+ .description('Run one statement (1.x: InfluxQL, 2.x: Flux, 3.x: SQL); reads stdin when omitted')
875
+ .option('--admin', 'Use the admin credential instead of the app credential')
876
+ .option('-n, --instance <name>', 'Instance name (alternative to the positional argument)')
877
+ .action(
878
+ handle(async (name, statement, options) => {
879
+ name = mergeInstanceArg(name, options);
880
+ name = await prepareInstance(name);
881
+ if (name === null) return console.log(`${colors.yellow}Aborted.${reset}`);
882
+ if (!statement) {
883
+ if (process.stdin.isTTY) throw new Error('provide a statement: influx-local query <name> "<statement>"');
884
+ const chunks = [];
885
+ for await (const chunk of process.stdin) chunks.push(chunk);
886
+ statement = Buffer.concat(chunks).toString('utf8').trim();
887
+ }
888
+ if (!statement) throw new Error('empty statement');
889
+ const res = await manager.queryInstance(name, statement, { admin: !!options.admin });
890
+ if (res.stdout) process.stdout.write(res.stdout.endsWith('\n') ? res.stdout : `${res.stdout}\n`);
891
+ if (res.code !== 0) {
892
+ if (res.stderr) process.stderr.write(res.stderr);
893
+ process.exitCode = res.code || 1;
894
+ }
895
+ }),
896
+ );
897
+
898
+ program
899
+ .command('logs [name]')
900
+ .description('Show server logs of an instance')
901
+ .option('-f, --follow', 'Follow log output in real-time')
902
+ .option('-n, --lines <count>', 'Number of lines to show from the tail', (v) => parseInt(v, 10), 200)
903
+ .option('-i, --instance <name>', 'Instance name (alternative to the positional argument)')
904
+ .action(
905
+ handle(async (name, options) => {
906
+ name = mergeInstanceArg(name, options);
907
+ name = await prepareInstance(name);
908
+ if (name === null) return;
909
+ const { text, missing } = await manager.readLogTail(name, options.lines);
910
+ if (missing) {
911
+ console.error(`${colors.red}No logs found yet for "${name}" (nothing has been started).${reset}`);
912
+ process.exitCode = 1;
913
+ return;
914
+ }
915
+ process.stdout.write(text.endsWith('\n') ? text : `${text}\n`);
916
+ if (!options.follow) return;
917
+ console.log(`${colors.dim}\n(following — press Ctrl+C to stop)${reset}`);
918
+ const cfg = await loadConfig(name);
919
+ let size = (await fs.stat(cfg.logPath)).size;
920
+ const interval = setInterval(async () => {
921
+ try {
922
+ const st = await fs.stat(cfg.logPath);
923
+ if (st.size > size) {
924
+ const fd = await fs.open(cfg.logPath, 'r');
925
+ const buf = Buffer.alloc(st.size - size);
926
+ await fd.read(buf, 0, buf.length, size);
927
+ await fd.close();
928
+ process.stdout.write(buf.toString('utf8'));
929
+ size = st.size;
930
+ } else if (st.size < size) {
931
+ size = st.size;
932
+ }
933
+ } catch (err) {}
934
+ }, 500);
935
+ process.on('SIGINT', () => {
936
+ clearInterval(interval);
937
+ process.exit(0);
938
+ });
939
+ }),
940
+ );
941
+
942
+ program
943
+ .command('shell [name]')
944
+ .description('Open the InfluxDB 1.x REPL against the instance (append args after "--")')
945
+ .option('--admin', 'Connect as the admin user instead of the app user')
946
+ .option('-n, --instance <name>', 'Instance name (alternative to the positional argument)')
947
+ .action(
948
+ handle(async (name, options) => {
949
+ name = mergeInstanceArg(name, options);
950
+ name = await prepareInstance(name);
951
+ if (name === null) return console.log(`${colors.yellow}Aborted.${reset}`);
952
+ const code = await manager.shellInstance(name, { admin: options.admin, args: shellExtraArgs });
953
+ if (code !== null && code !== 0) process.exitCode = code;
954
+ }),
955
+ );
956
+
957
+ program
958
+ .command('install [name]')
959
+ .description('Install the instance\'s pinned InfluxDB version through mise (idempotent)')
960
+ .option('-n, --instance <name>', 'Instance name (alternative to the positional argument)')
961
+ .action(
962
+ handle(async (name, options) => {
963
+ name = mergeInstanceArg(name, options);
964
+ name = await resolveInstanceName(name);
965
+ if (name === null) return;
966
+ const cfg = await loadConfig(name);
967
+ const log = (m) => console.log(m);
968
+ console.log(`${colors.cyan}Installing the toolchain for instance "${name}"…${reset}`);
969
+ await manager.installTools(cfg, log);
970
+ console.log(`${colors.green}✓ InfluxDB ${cfg.version} is installed for "${name}".${reset}`);
971
+ }),
972
+ );
973
+
974
+ program
975
+ .command('doctor [name]')
976
+ .description('Diagnose an instance and its environment')
977
+ .option('-n, --instance <name>', 'Instance name (alternative to the positional argument)')
978
+ .option('-j, --json', 'Print machine-readable JSON')
979
+ .action(
980
+ handle(async (name, options) => {
981
+ name = mergeInstanceArg(name, options);
982
+ name = await prepareInstance(name);
983
+ if (name === null) return console.log(`${colors.yellow}Aborted.${reset}`);
984
+ const checks = await manager.doctorInstance(name);
985
+ if (options.json) {
986
+ console.log(JSON.stringify(checks, null, 2));
987
+ return;
988
+ }
989
+ console.log(`${colors.bright}Diagnostics for "${name}":${reset}`);
990
+ for (const c of checks) {
991
+ const mark = wrap(c.ok ? '✓' : '✗', c.ok ? '32' : '31');
992
+ const detail = c.detail ? ` ${colors.dim}— ${c.detail}${reset}` : '';
993
+ console.log(` ${mark} ${c.label}${detail}`);
994
+ }
995
+ if (checks.some((c) => !c.ok)) process.exitCode = 1;
996
+ }),
997
+ );
998
+
999
+ program
1000
+ .command('web')
1001
+ .description('Launch the web dashboard for all instances')
1002
+ .option('-H, --host <address>', 'Address to bind', '127.0.0.1')
1003
+ .option('-p, --port <port>', 'Port to bind', (v) => parseInt(v, 10), 8788)
1004
+ .option('-t, --token <token>', 'Require this token for API access (env: INFLUX_LOCAL_WEB_TOKEN)')
1005
+ .action(
1006
+ handle(async (options) => {
1007
+ const token = options.token || process.env.INFLUX_LOCAL_WEB_TOKEN || '';
1008
+ const loopback = options.host === '127.0.0.1' || options.host === 'localhost' || options.host === '::1';
1009
+ if (!loopback && !token) {
1010
+ throw new Error(
1011
+ `Refusing to expose the dashboard on ${options.host} without a token — it can start/stop instances, rotate secrets and destroy data.\n` +
1012
+ 'Re-run with a token: influx-local web --host 0.0.0.0 --token <secret>\n' +
1013
+ ' (or set INFLUX_LOCAL_WEB_TOKEN)',
1014
+ );
1015
+ }
1016
+ const { startWeb } = require('./web');
1017
+ const { url } = await startWeb({ host: options.host, port: options.port, token });
1018
+ if (process.stdout.isTTY) {
1019
+ console.log(`${colors.green}✓ Web dashboard: ${colors.bright}${url}${reset}`);
1020
+ console.log(`${colors.dim}Open it in your browser (or press Ctrl+C to stop).${reset}`);
1021
+ }
1022
+ }),
1023
+ );
1024
+
1025
+ program
1026
+ .command('destroy [name]')
1027
+ .alias('rm')
1028
+ .description('Stop the instance and delete ALL of its data')
1029
+ .option('-y, --yes', 'Skip the confirmation prompt (for scripts)')
1030
+ .option('-n, --instance <name>', 'Instance name (alternative to the positional argument)')
1031
+ .action((name, options) => {
1032
+ void (async () => {
1033
+ let resolved;
1034
+ try {
1035
+ resolved = mergeInstanceArg(name, options);
1036
+ resolved = await prepareInstance(resolved);
1037
+ if (resolved === null) return console.log(`${colors.yellow}Aborted.${reset}`);
1038
+ } catch (err) {
1039
+ console.error(`${colors.red}Error: ${err.message}${reset}`);
1040
+ process.exitCode = 1;
1041
+ return;
1042
+ }
1043
+ const execute = async () => {
1044
+ try {
1045
+ console.log(`${colors.cyan}Destroying instance "${resolved}"…${reset}`);
1046
+ const log = (m) => console.log(m);
1047
+ await manager.destroyInstance(resolved, { log });
1048
+ console.log(`${colors.green}✓ Instance "${resolved}" has been destroyed.${reset}`);
1049
+ } catch (err) {
1050
+ console.error(`${colors.red}Error: ${err.message}${reset}`);
1051
+ process.exitCode = 1;
1052
+ }
1053
+ };
1054
+ if (options.yes) return execute();
1055
+ if (!process.stdin.isTTY) {
1056
+ console.error(
1057
+ `${colors.red}Error: Refusing to destroy "${resolved}" without confirmation on a non-interactive terminal. Re-run with --yes to proceed.${reset}`,
1058
+ );
1059
+ process.exitCode = 1;
1060
+ return;
1061
+ }
1062
+ const cfg = await loadConfig(resolved);
1063
+ const readline = require('readline').createInterface({ input: process.stdin, output: process.stdout });
1064
+ readline.question(
1065
+ `${colors.red}${colors.bright}WARNING: This will permanently delete ALL data under "${cfg.dataDir}"\n (database files, logs, credentials) — irreversible.\nAre you sure you want to proceed? (y/N): ${reset}`,
1066
+ async (answer) => {
1067
+ readline.close();
1068
+ if (answer.toLowerCase() === 'y') await execute();
1069
+ else console.log('Action cancelled.');
1070
+ },
1071
+ );
1072
+ })();
1073
+ });
1074
+
1075
+ return program;
1076
+ }
1077
+
1078
+ // Args after "--" are forwarded to the influx CLI (used by `shell`).
1079
+ let shellExtraArgs = [];
1080
+ function run(argv) {
1081
+ shellExtraArgs = [];
1082
+ const dashIdx = argv.indexOf('--');
1083
+ let cliArgv = argv;
1084
+ if (dashIdx !== -1) {
1085
+ shellExtraArgs = argv.slice(dashIdx + 1);
1086
+ cliArgv = argv.slice(0, dashIdx);
1087
+ }
1088
+ const program = buildProgram();
1089
+ program.parse(cliArgv);
1090
+ }
1091
+
1092
+ module.exports = { run, resolveVersionInput, versionCatalog, maskSecrets };