@celilo/cli 0.3.16 → 0.3.18
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/package.json +1 -1
- package/src/api-clients/proxmox.ts +77 -45
- package/src/cli/command-registry.ts +23 -35
- package/src/cli/commands/completion.ts +12 -11
- package/src/cli/commands/module-check.ts +158 -0
- package/src/cli/commands/module-import-routing.test.ts +52 -0
- package/src/cli/commands/module-import.ts +70 -27
- package/src/cli/commands/module-publish.test.ts +3 -90
- package/src/cli/commands/module-publish.ts +14 -118
- package/src/cli/commands/proxmox-template-selection.test.ts +150 -0
- package/src/cli/commands/proxmox-template-selection.ts +258 -0
- package/src/cli/commands/service-add-proxmox.ts +49 -127
- package/src/cli/commands/service-reconfigure.ts +36 -79
- package/src/cli/commands/service-verify.ts +20 -79
- package/src/cli/commands/{doctor.test.ts → system-doctor.test.ts} +1 -1
- package/src/cli/commands/{doctor.ts → system-doctor.ts} +93 -18
- package/src/cli/commands/system-update.ts +1 -1
- package/src/cli/completion.ts +29 -8
- package/src/cli/index.ts +25 -30
- package/src/manifest/schema.ts +9 -1
- package/src/module/import.ts +4 -2
- package/src/registry/client.ts +14 -1
- package/src/services/bus-interview.ts +13 -1
- package/src/services/bus-secret-flow.test.ts +94 -0
- package/src/services/config-interview.ts +66 -6
- package/src/services/module-deploy.ts +19 -1
- package/src/services/module-validator/capability-versions.test.ts +90 -0
- package/src/services/module-validator/capability-versions.ts +115 -0
- package/src/services/module-validator/contract-version.test.ts +24 -0
- package/src/services/module-validator/contract-version.ts +69 -0
- package/src/services/module-validator/git-hygiene.test.ts +141 -0
- package/src/services/module-validator/git-hygiene.ts +144 -0
- package/src/services/module-validator/index.test.ts +67 -0
- package/src/services/module-validator/index.ts +74 -0
- package/src/services/module-validator/manifest-schema.ts +42 -0
- package/src/services/module-validator/types.ts +43 -0
- package/src/services/module-validator/typescript-build.test.ts +58 -0
- package/src/services/module-validator/typescript-build.ts +115 -0
- package/src/services/module-validator/workspace-deps.test.ts +137 -0
- package/src/services/module-validator/workspace-deps.ts +187 -0
- package/src/services/terminal-responder.ts +75 -0
- package/src/system/prereqs.test.ts +374 -0
- package/src/system/prereqs.ts +377 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace-dep checker tests.
|
|
3
|
+
*
|
|
4
|
+
* These are unit tests — npm metadata is stubbed via the injected
|
|
5
|
+
* fetcher. The orchestrator's integration test (in index.test.ts)
|
|
6
|
+
* exercises the same code path against fixture modules.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from 'bun:test';
|
|
10
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import type { NpmMetadata } from './types';
|
|
14
|
+
import { checkWorkspaceDeps } from './workspace-deps';
|
|
15
|
+
|
|
16
|
+
function makeFixture(packageJson: Record<string, unknown>): string {
|
|
17
|
+
const dir = mkdtempSync(join(tmpdir(), 'celilo-workspace-deps-'));
|
|
18
|
+
writeFileSync(join(dir, 'package.json'), JSON.stringify(packageJson));
|
|
19
|
+
return dir;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function stubNpm(versions: Record<string, string>) {
|
|
23
|
+
return async (name: string): Promise<NpmMetadata | null> => {
|
|
24
|
+
const v = versions[name];
|
|
25
|
+
return v ? { name, latestVersion: v } : null;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('checkWorkspaceDeps', () => {
|
|
30
|
+
test('ok when no @celilo/* deps are declared', async () => {
|
|
31
|
+
const dir = makeFixture({ dependencies: { yaml: '^2.0.0' } });
|
|
32
|
+
try {
|
|
33
|
+
const checks = await checkWorkspaceDeps(dir, stubNpm({}));
|
|
34
|
+
expect(checks).toHaveLength(1);
|
|
35
|
+
expect(checks[0].status).toBe('ok');
|
|
36
|
+
expect(checks[0].message).toContain('no @celilo/* dependencies');
|
|
37
|
+
} finally {
|
|
38
|
+
rmSync(dir, { recursive: true, force: true });
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('ok when no package.json exists', async () => {
|
|
43
|
+
const dir = mkdtempSync(join(tmpdir(), 'celilo-workspace-deps-'));
|
|
44
|
+
try {
|
|
45
|
+
const checks = await checkWorkspaceDeps(dir, stubNpm({}));
|
|
46
|
+
expect(checks[0].status).toBe('ok');
|
|
47
|
+
expect(checks[0].message).toContain('no package.json');
|
|
48
|
+
} finally {
|
|
49
|
+
rmSync(dir, { recursive: true, force: true });
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('ok when installed @celilo dep matches npm latest', async () => {
|
|
54
|
+
const dir = makeFixture({
|
|
55
|
+
dependencies: { '@celilo/capabilities': '^0.2.0' },
|
|
56
|
+
});
|
|
57
|
+
try {
|
|
58
|
+
const checks = await checkWorkspaceDeps(dir, stubNpm({ '@celilo/capabilities': '0.2.0' }));
|
|
59
|
+
expect(checks).toHaveLength(1);
|
|
60
|
+
expect(checks[0].status).toBe('ok');
|
|
61
|
+
} finally {
|
|
62
|
+
rmSync(dir, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('warn for within-major drift (advisory)', async () => {
|
|
67
|
+
const dir = makeFixture({
|
|
68
|
+
dependencies: { '@celilo/capabilities': '^0.2.0' },
|
|
69
|
+
});
|
|
70
|
+
try {
|
|
71
|
+
const checks = await checkWorkspaceDeps(dir, stubNpm({ '@celilo/capabilities': '0.5.1' }));
|
|
72
|
+
expect(checks[0].status).toBe('warn');
|
|
73
|
+
expect(checks[0].suggestedValue).toBe('^0.5.1');
|
|
74
|
+
expect(checks[0].migrationUrl).toBeUndefined();
|
|
75
|
+
} finally {
|
|
76
|
+
rmSync(dir, { recursive: true, force: true });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('fail across a major bump, with migration URL', async () => {
|
|
81
|
+
const dir = makeFixture({
|
|
82
|
+
dependencies: { '@celilo/event-bus': '^0.1.4' },
|
|
83
|
+
});
|
|
84
|
+
try {
|
|
85
|
+
const checks = await checkWorkspaceDeps(dir, stubNpm({ '@celilo/event-bus': '1.0.0' }));
|
|
86
|
+
expect(checks[0].status).toBe('fail');
|
|
87
|
+
expect(checks[0].suggestedValue).toBe('^1.0.0');
|
|
88
|
+
expect(checks[0].migrationUrl).toBe('https://celilo.computer/docs/migrations/event-bus-1');
|
|
89
|
+
} finally {
|
|
90
|
+
rmSync(dir, { recursive: true, force: true });
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('warn when npm is unreachable for every dep', async () => {
|
|
95
|
+
const dir = makeFixture({
|
|
96
|
+
dependencies: { '@celilo/capabilities': '^0.2.0' },
|
|
97
|
+
});
|
|
98
|
+
try {
|
|
99
|
+
// Stub returns null for everything — simulates offline.
|
|
100
|
+
const checks = await checkWorkspaceDeps(dir, async () => null);
|
|
101
|
+
expect(checks).toHaveLength(1);
|
|
102
|
+
expect(checks[0].status).toBe('warn');
|
|
103
|
+
expect(checks[0].message).toContain("couldn't reach npm");
|
|
104
|
+
} finally {
|
|
105
|
+
rmSync(dir, { recursive: true, force: true });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('partial-network: some deps resolved, others skipped', async () => {
|
|
110
|
+
const dir = makeFixture({
|
|
111
|
+
dependencies: {
|
|
112
|
+
'@celilo/capabilities': '^0.2.0',
|
|
113
|
+
'@celilo/event-bus': '^0.1.0',
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
try {
|
|
117
|
+
const checks = await checkWorkspaceDeps(dir, stubNpm({ '@celilo/capabilities': '0.2.0' }));
|
|
118
|
+
// One ok for capabilities, one synthetic warn for the missing one.
|
|
119
|
+
expect(checks.some((c) => c.status === 'ok')).toBe(true);
|
|
120
|
+
expect(checks.some((c) => c.status === 'warn' && c.message.includes('skipped'))).toBe(true);
|
|
121
|
+
} finally {
|
|
122
|
+
rmSync(dir, { recursive: true, force: true });
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('reads devDependencies as well as dependencies', async () => {
|
|
127
|
+
const dir = makeFixture({
|
|
128
|
+
devDependencies: { '@celilo/cli-display': '^0.1.0' },
|
|
129
|
+
});
|
|
130
|
+
try {
|
|
131
|
+
const checks = await checkWorkspaceDeps(dir, stubNpm({ '@celilo/cli-display': '0.1.0' }));
|
|
132
|
+
expect(checks[0].status).toBe('ok');
|
|
133
|
+
} finally {
|
|
134
|
+
rmSync(dir, { recursive: true, force: true });
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { Check, NpmMetadata } from './types';
|
|
4
|
+
|
|
5
|
+
interface PackageJson {
|
|
6
|
+
dependencies?: Record<string, string>;
|
|
7
|
+
devDependencies?: Record<string, string>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Real-npm fetcher used by default. Returns null when the package
|
|
12
|
+
* doesn't exist or the registry is unreachable; the orchestrator turns
|
|
13
|
+
* that into a synthetic offline-skip warning rather than failing.
|
|
14
|
+
*/
|
|
15
|
+
export async function defaultFetchNpmMetadata(packageName: string): Promise<NpmMetadata | null> {
|
|
16
|
+
try {
|
|
17
|
+
const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}`);
|
|
18
|
+
if (!res.ok) return null;
|
|
19
|
+
const body = (await res.json()) as { 'dist-tags'?: { latest?: string } };
|
|
20
|
+
const latest = body['dist-tags']?.latest;
|
|
21
|
+
if (!latest) return null;
|
|
22
|
+
return { name: packageName, latestVersion: latest };
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Strips a leading semver range operator (`^`, `~`, `>=`, etc.) from a
|
|
30
|
+
* version string. We only inspect the first major/minor/patch trio for
|
|
31
|
+
* drift comparisons; range semantics don't matter here.
|
|
32
|
+
*/
|
|
33
|
+
function stripRange(version: string): string {
|
|
34
|
+
return version.replace(/^[\^~>=<\s]+/, '').trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Splits a semver-like version into [major, minor, patch] integers.
|
|
39
|
+
* Returns null if the string doesn't have at least a major component.
|
|
40
|
+
*/
|
|
41
|
+
function parseVersion(version: string): [number, number, number] | null {
|
|
42
|
+
const stripped = stripRange(version);
|
|
43
|
+
const parts = stripped.split(/[+\-.]/, 3);
|
|
44
|
+
if (parts.length === 0) return null;
|
|
45
|
+
const [maj, min = '0', pat = '0'] = parts;
|
|
46
|
+
const M = Number.parseInt(maj, 10);
|
|
47
|
+
if (Number.isNaN(M)) return null;
|
|
48
|
+
return [M, Number.parseInt(min, 10) || 0, Number.parseInt(pat, 10) || 0];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function compareSemver(a: [number, number, number], b: [number, number, number]): number {
|
|
52
|
+
for (let i = 0; i < 3; i++) {
|
|
53
|
+
if (a[i] !== b[i]) return a[i] - b[i];
|
|
54
|
+
}
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Per-`@celilo/*`-dep freshness check.
|
|
60
|
+
*
|
|
61
|
+
* - `ok` — installed version equals the npm latest.
|
|
62
|
+
* - `warn` — installed within the same major as latest but behind. A
|
|
63
|
+
* within-major bump is painless; the user can take it whenever.
|
|
64
|
+
* - `fail` — latest is in a higher major than installed. Bumping is a
|
|
65
|
+
* breaking change; the user needs to read the migration notes.
|
|
66
|
+
*
|
|
67
|
+
* Non-`@celilo/*` deps are not our problem — npm itself flags those.
|
|
68
|
+
*
|
|
69
|
+
* The npm fetch is injectable via the orchestrator; tests pass a stub
|
|
70
|
+
* so the suite isn't network-dependent.
|
|
71
|
+
*/
|
|
72
|
+
export async function checkWorkspaceDeps(
|
|
73
|
+
modulePath: string,
|
|
74
|
+
fetchNpmMetadata: (name: string) => Promise<NpmMetadata | null> = defaultFetchNpmMetadata,
|
|
75
|
+
): Promise<Check[]> {
|
|
76
|
+
const pkgPath = join(modulePath, 'package.json');
|
|
77
|
+
let pkg: PackageJson;
|
|
78
|
+
try {
|
|
79
|
+
const raw = await readFile(pkgPath, 'utf-8');
|
|
80
|
+
pkg = JSON.parse(raw) as PackageJson;
|
|
81
|
+
} catch {
|
|
82
|
+
return [
|
|
83
|
+
{
|
|
84
|
+
category: 'workspace_dep',
|
|
85
|
+
name: 'package.json',
|
|
86
|
+
status: 'ok',
|
|
87
|
+
message: 'no package.json — skipping workspace-dep check',
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const allDeps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
|
|
93
|
+
const celiloDeps = Object.entries(allDeps).filter(([name]) => name.startsWith('@celilo/'));
|
|
94
|
+
|
|
95
|
+
if (celiloDeps.length === 0) {
|
|
96
|
+
return [
|
|
97
|
+
{
|
|
98
|
+
category: 'workspace_dep',
|
|
99
|
+
name: '@celilo/*',
|
|
100
|
+
status: 'ok',
|
|
101
|
+
message: 'package.json declares no @celilo/* dependencies',
|
|
102
|
+
},
|
|
103
|
+
];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const checks: Check[] = [];
|
|
107
|
+
let networkFailures = 0;
|
|
108
|
+
|
|
109
|
+
for (const [name, declared] of celiloDeps) {
|
|
110
|
+
const meta = await fetchNpmMetadata(name);
|
|
111
|
+
if (!meta) {
|
|
112
|
+
networkFailures++;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const installed = parseVersion(declared);
|
|
116
|
+
const latest = parseVersion(meta.latestVersion);
|
|
117
|
+
if (!installed || !latest) {
|
|
118
|
+
checks.push({
|
|
119
|
+
category: 'workspace_dep',
|
|
120
|
+
name,
|
|
121
|
+
status: 'warn',
|
|
122
|
+
message: `couldn't parse versions (${declared} vs ${meta.latestVersion}); skipping`,
|
|
123
|
+
currentValue: declared,
|
|
124
|
+
suggestedValue: meta.latestVersion,
|
|
125
|
+
});
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const cmp = compareSemver(installed, latest);
|
|
130
|
+
if (cmp === 0) {
|
|
131
|
+
checks.push({
|
|
132
|
+
category: 'workspace_dep',
|
|
133
|
+
name,
|
|
134
|
+
status: 'ok',
|
|
135
|
+
message: `${name} ${declared} matches latest ${meta.latestVersion}`,
|
|
136
|
+
currentValue: declared,
|
|
137
|
+
});
|
|
138
|
+
} else if (installed[0] === latest[0]) {
|
|
139
|
+
checks.push({
|
|
140
|
+
category: 'workspace_dep',
|
|
141
|
+
name,
|
|
142
|
+
status: 'warn',
|
|
143
|
+
message: `${name} ${declared} is behind latest ${meta.latestVersion} (within-major bump available)`,
|
|
144
|
+
currentValue: declared,
|
|
145
|
+
suggestedValue: `^${meta.latestVersion}`,
|
|
146
|
+
});
|
|
147
|
+
} else if (installed[0] < latest[0]) {
|
|
148
|
+
const major = latest[0];
|
|
149
|
+
const shortName = name.replace(/^@celilo\//, '');
|
|
150
|
+
checks.push({
|
|
151
|
+
category: 'workspace_dep',
|
|
152
|
+
name,
|
|
153
|
+
status: 'fail',
|
|
154
|
+
message: `${name} ${declared} is across a major bump from latest ${meta.latestVersion} (BREAKING)`,
|
|
155
|
+
currentValue: declared,
|
|
156
|
+
suggestedValue: `^${meta.latestVersion}`,
|
|
157
|
+
migrationUrl: `https://celilo.computer/docs/migrations/${shortName}-${major}`,
|
|
158
|
+
});
|
|
159
|
+
} else {
|
|
160
|
+
checks.push({
|
|
161
|
+
category: 'workspace_dep',
|
|
162
|
+
name,
|
|
163
|
+
status: 'warn',
|
|
164
|
+
message: `${name} ${declared} is ahead of npm latest ${meta.latestVersion} (publishing in progress?)`,
|
|
165
|
+
currentValue: declared,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (networkFailures > 0 && checks.length === 0) {
|
|
171
|
+
checks.push({
|
|
172
|
+
category: 'workspace_dep',
|
|
173
|
+
name: '@celilo/*',
|
|
174
|
+
status: 'warn',
|
|
175
|
+
message: `workspace-dep check skipped: couldn't reach npm for ${networkFailures} package(s)`,
|
|
176
|
+
});
|
|
177
|
+
} else if (networkFailures > 0) {
|
|
178
|
+
checks.push({
|
|
179
|
+
category: 'workspace_dep',
|
|
180
|
+
name: '@celilo/* (partial)',
|
|
181
|
+
status: 'warn',
|
|
182
|
+
message: `${networkFailures} package(s) skipped: couldn't reach npm`,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return checks;
|
|
187
|
+
}
|
|
@@ -325,6 +325,13 @@ async function promptForSecret(payload: SecretRequiredPayload): Promise<string |
|
|
|
325
325
|
: `${payload.module}.${payload.key}:`;
|
|
326
326
|
const style = payload.style ?? 'user_provided';
|
|
327
327
|
|
|
328
|
+
// string-map: Record<string, string> gathered via add-loop, stored as
|
|
329
|
+
// JSON. Bypasses the style switch — string-map secrets are always
|
|
330
|
+
// collected key-by-key, never as a single masked input.
|
|
331
|
+
if (payload.type === 'string-map') {
|
|
332
|
+
return promptForStringMap(payload);
|
|
333
|
+
}
|
|
334
|
+
|
|
328
335
|
if (style === 'user_password') {
|
|
329
336
|
while (true) {
|
|
330
337
|
const value = await promptPassword({
|
|
@@ -368,6 +375,74 @@ async function promptForSecret(payload: SecretRequiredPayload): Promise<string |
|
|
|
368
375
|
return value;
|
|
369
376
|
}
|
|
370
377
|
|
|
378
|
+
/**
|
|
379
|
+
* Add-loop UX for `type: string-map` secrets. Collects key/value pairs
|
|
380
|
+
* one at a time (key via `promptText`, value via `promptPassword` since
|
|
381
|
+
* we're inside the secret responder), then JSON-stringifies the result
|
|
382
|
+
* for storage. The operator never has to type braces, quotes, or commas.
|
|
383
|
+
*
|
|
384
|
+
* Empty key terminates the loop. If `payload.required` and zero entries
|
|
385
|
+
* have been collected, we re-ask rather than ack-ing with an empty map —
|
|
386
|
+
* the alternative would be a successful "ack" that fails downstream
|
|
387
|
+
* validation, which is worse UX.
|
|
388
|
+
*/
|
|
389
|
+
async function promptForStringMap(payload: SecretRequiredPayload): Promise<string | undefined> {
|
|
390
|
+
const keyLabel = payload.key_label ?? 'key';
|
|
391
|
+
const valueLabel = payload.value_label ?? 'value';
|
|
392
|
+
const header = payload.description
|
|
393
|
+
? `${payload.module}.${payload.key} — ${payload.description}`
|
|
394
|
+
: `${payload.module}.${payload.key}`;
|
|
395
|
+
log.message(header);
|
|
396
|
+
log.message(
|
|
397
|
+
`Add ${keyLabel.toLowerCase()} → ${valueLabel.toLowerCase()} entries one at a time. Press Enter on an empty ${keyLabel.toLowerCase()} when done.`,
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
const collected: Record<string, string> = {};
|
|
401
|
+
|
|
402
|
+
while (true) {
|
|
403
|
+
const count = Object.keys(collected).length;
|
|
404
|
+
const keyMessage = count === 0 ? `${keyLabel}:` : `${keyLabel} (or Enter to finish):`;
|
|
405
|
+
|
|
406
|
+
const key = await promptText({
|
|
407
|
+
message: keyMessage,
|
|
408
|
+
validate: () => undefined, // empty = finish
|
|
409
|
+
});
|
|
410
|
+
if (key === undefined) {
|
|
411
|
+
// User cancelled (Ctrl-C). Return undefined so the responder
|
|
412
|
+
// doesn't ack — the deploy stays paused.
|
|
413
|
+
return undefined;
|
|
414
|
+
}
|
|
415
|
+
const trimmedKey = key.trim();
|
|
416
|
+
if (trimmedKey === '') {
|
|
417
|
+
if (count === 0 && payload.required) {
|
|
418
|
+
log.error(
|
|
419
|
+
`At least one ${keyLabel.toLowerCase()} is required. Add an entry or Ctrl-C to cancel.`,
|
|
420
|
+
);
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (collected[trimmedKey] !== undefined) {
|
|
427
|
+
log.warn(`${keyLabel} '${trimmedKey}' was already entered — overwriting previous value.`);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const value = await promptPassword({
|
|
431
|
+
message: `${valueLabel} for '${trimmedKey}':`,
|
|
432
|
+
validate: (val) => (!val || val.trim() === '' ? 'Required' : undefined),
|
|
433
|
+
});
|
|
434
|
+
if (value === undefined) {
|
|
435
|
+
// User cancelled mid-entry; abort the whole interview.
|
|
436
|
+
return undefined;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
collected[trimmedKey] = value;
|
|
440
|
+
log.success(`Added ${trimmedKey}`);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return JSON.stringify(collected);
|
|
444
|
+
}
|
|
445
|
+
|
|
371
446
|
/**
|
|
372
447
|
* Short hint shown in the prompt for non-string types so the operator
|
|
373
448
|
* isn't guessing what shape we want. Returns null for plain strings —
|