@forceuser/git-profile-switcher 0.1.4

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.
@@ -0,0 +1,604 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { createPromptSession, isPromptCancelError, isPromptInterruptError, } from "#cli/prompts";
7
+ import { applyGitProfileConfig, findMatchingRule, readActiveGitIdentity } from "#git/config";
8
+ import { createProfileExportBundle, mergeProfileStoreData, parseProfileImportBundle, } from "#profiles/transfer";
9
+ import { PROFILE_PROMPT_COLOR_CODES, PROFILE_PROMPT_COLORS, } from "#profiles/types";
10
+ import { getPromptStatus, renderPromptStatus } from "#prompt/status";
11
+ import { detectShell, installShellAll, installShellCompletion, installShellPrompt, installShellSession, uninstallShellAll, uninstallShellCompletion, uninstallShellPrompt, uninstallShellSession, } from "#shell/integration";
12
+ const PACKAGE_NAME = "@forceuser/git-profile-switcher";
13
+ const DEFAULT_TRANSFER_FILE_NAME = "gip-profiles.json";
14
+ export async function runTui(input) {
15
+ if (!input.input.isTTY || !input.output.isTTY) {
16
+ throw new Error("TUI requires an interactive terminal.");
17
+ }
18
+ const prompts = createPromptSession(input.input, input.output);
19
+ const context = { ...input, prompts, menuSelection: new Map() };
20
+ try {
21
+ await runMenu(context, "Main menu", [
22
+ { label: "Profiles", run: () => profilesMenu(context) },
23
+ { label: "Directory rules", run: () => rulesMenu(context) },
24
+ { label: "Diagnostics and prompt", run: () => diagnosticsMenu(context) },
25
+ { label: "Import and export", run: () => migrationMenu(context) },
26
+ { label: "Shell integration", run: () => shellMenu(context) },
27
+ { label: "Quit", run: async () => "quit" },
28
+ ]);
29
+ }
30
+ finally {
31
+ prompts.close();
32
+ }
33
+ }
34
+ async function profilesMenu(context) {
35
+ for (;;) {
36
+ const data = await context.repository.read();
37
+ const options = [
38
+ { type: "add", label: "[Add profile]" },
39
+ ...data.profiles.map((profile) => ({ type: "profile", profile })),
40
+ { type: "back", label: "[Back]" },
41
+ ];
42
+ let item;
43
+ try {
44
+ item = await context.prompts.selectOne({
45
+ prompt: "Profiles",
46
+ emptyMessage: "No profile actions available.",
47
+ options,
48
+ renderOption: renderProfileMenuItem,
49
+ getValue: (option) => (option.type === "profile" ? option.profile.name : option.label),
50
+ defaultIndex: getRememberedIndex(context, "profiles", options, (option) => (option.type === "profile" ? option.profile.name : option.label), getPromptStatus({ data }).profileName ?? "[Add profile]"),
51
+ });
52
+ if (item.type !== "back") {
53
+ rememberSelection(context, "profiles", item.type === "profile" ? item.profile.name : item.label);
54
+ }
55
+ }
56
+ catch (error) {
57
+ if (isPromptInterruptError(error)) {
58
+ return "quit";
59
+ }
60
+ if (isPromptCancelError(error)) {
61
+ return "continue";
62
+ }
63
+ throw error;
64
+ }
65
+ if (item.type === "back") {
66
+ return "continue";
67
+ }
68
+ if (item.type === "add") {
69
+ await runCancellableAction(() => addProfile(context));
70
+ continue;
71
+ }
72
+ const result = await runCancellableAction(() => profileActionsMenu(context, item.profile));
73
+ if (result === "quit") {
74
+ return "quit";
75
+ }
76
+ }
77
+ }
78
+ async function rulesMenu(context) {
79
+ for (;;) {
80
+ const data = await context.repository.read();
81
+ const activeRuleId = getPromptStatus({ data }).ruleId;
82
+ const options = [
83
+ { type: "add", label: "[Add rule]" },
84
+ ...data.rules.map((rule) => ({ type: "rule", rule })),
85
+ { type: "back", label: "[Back]" },
86
+ ];
87
+ let item;
88
+ try {
89
+ item = await context.prompts.selectOne({
90
+ prompt: "Directory rules",
91
+ emptyMessage: "No directory rule actions available.",
92
+ options,
93
+ renderOption: renderRuleMenuItem,
94
+ getValue: (option) => (option.type === "rule" ? option.rule.id : option.label),
95
+ defaultIndex: getRememberedIndex(context, "rules", options, (option) => (option.type === "rule" ? option.rule.id : option.label), activeRuleId ?? "[Add rule]"),
96
+ });
97
+ if (item.type !== "back") {
98
+ rememberSelection(context, "rules", item.type === "rule" ? item.rule.id : item.label);
99
+ }
100
+ }
101
+ catch (error) {
102
+ if (isPromptInterruptError(error)) {
103
+ return "quit";
104
+ }
105
+ if (isPromptCancelError(error)) {
106
+ return "continue";
107
+ }
108
+ throw error;
109
+ }
110
+ if (item.type === "back") {
111
+ return "continue";
112
+ }
113
+ if (item.type === "add") {
114
+ await runCancellableAction(() => addRule(context));
115
+ continue;
116
+ }
117
+ const result = await runCancellableAction(() => ruleActionsMenu(context, item.rule));
118
+ if (result === "quit") {
119
+ return "quit";
120
+ }
121
+ }
122
+ }
123
+ async function diagnosticsMenu(context) {
124
+ return await runMenu(context, "Diagnostics and prompt", [
125
+ { label: "Doctor for current directory", run: () => doctor(context, process.cwd()) },
126
+ { label: "Doctor for another directory", run: () => doctorPromptedDirectory(context) },
127
+ { label: "Preview prompt status", run: () => previewPrompt(context) },
128
+ { label: "Show storage paths", run: () => showPaths(context) },
129
+ { label: "Back", run: async () => "back" },
130
+ ]);
131
+ }
132
+ async function migrationMenu(context) {
133
+ return await runMenu(context, "Import and export", [
134
+ { label: "Export profiles and rules", run: () => exportProfiles(context) },
135
+ { label: "Import profiles and rules", run: () => importProfiles(context) },
136
+ { label: "Back", run: async () => "back" },
137
+ ]);
138
+ }
139
+ async function shellMenu(context) {
140
+ return await runMenu(context, "Shell integration", [
141
+ { label: "Install all shell blocks", run: () => installShellBundle(context, "all") },
142
+ { label: "Install completion block", run: () => installShellBundle(context, "completion") },
143
+ { label: "Install shell wrapper block", run: () => installShellBundle(context, "shell") },
144
+ { label: "Install prompt block", run: () => installShellBundle(context, "prompt") },
145
+ { label: "Uninstall all shell blocks", run: () => uninstallShellBundle(context, "all") },
146
+ { label: "Uninstall completion block", run: () => uninstallShellBundle(context, "completion") },
147
+ { label: "Uninstall shell wrapper block", run: () => uninstallShellBundle(context, "shell") },
148
+ { label: "Uninstall prompt block", run: () => uninstallShellBundle(context, "prompt") },
149
+ {
150
+ label: "Install package globally and shell blocks",
151
+ run: () => packageInstallOrUpdate(context, "install"),
152
+ },
153
+ {
154
+ label: "Update global package and shell blocks",
155
+ run: () => packageInstallOrUpdate(context, "update"),
156
+ },
157
+ { label: "Back", run: async () => "back" },
158
+ ]);
159
+ }
160
+ async function runMenu(context, title, actions) {
161
+ for (;;) {
162
+ let action;
163
+ try {
164
+ action = await context.prompts.selectOne({
165
+ prompt: title,
166
+ emptyMessage: "No menu actions available.",
167
+ options: actions,
168
+ renderOption: (option) => option.label,
169
+ getValue: (option) => option.label,
170
+ defaultIndex: getRememberedIndex(context, title, actions, (option) => option.label, actions[0]?.label),
171
+ });
172
+ if (!isBackMenuValue(action.label)) {
173
+ rememberSelection(context, title, action.label);
174
+ }
175
+ }
176
+ catch (error) {
177
+ if (isPromptInterruptError(error)) {
178
+ return "quit";
179
+ }
180
+ if (isPromptCancelError(error)) {
181
+ return "continue";
182
+ }
183
+ throw error;
184
+ }
185
+ const result = await runCancellableAction(action.run);
186
+ if (result === "quit") {
187
+ return "quit";
188
+ }
189
+ if (result === "back") {
190
+ return "continue";
191
+ }
192
+ }
193
+ }
194
+ async function runCancellableAction(action) {
195
+ try {
196
+ return await action();
197
+ }
198
+ catch (error) {
199
+ if (isPromptInterruptError(error)) {
200
+ return "quit";
201
+ }
202
+ if (isPromptCancelError(error)) {
203
+ return "continue";
204
+ }
205
+ throw error;
206
+ }
207
+ }
208
+ async function profileActionsMenu(context, profile) {
209
+ return await runMenu(context, `Profile: ${profile.name}`, [
210
+ {
211
+ label: `[Use profile here ${process.cwd()}]`,
212
+ run: () => useSelectedProfileForCurrentDirectory(context, profile),
213
+ },
214
+ { label: "[Edit profile]", run: () => editProfile(context, profile) },
215
+ { label: "[Set prompt color]", run: () => setProfilePromptColor(context, profile) },
216
+ { label: "[Remove profile]", run: () => removeSelectedProfile(context, profile) },
217
+ { label: "[Back]", run: async () => "back" },
218
+ ]);
219
+ }
220
+ async function addProfile(context) {
221
+ const name = await context.prompts.askRequired("Profile name: ");
222
+ const userName = await context.prompts.askRequired("Git user.name: ");
223
+ const userEmail = await context.prompts.askRequired("Git user.email: ");
224
+ const profile = await context.repository.upsertProfile({ name, userName, userEmail });
225
+ context.output.write(`Saved profile ${profile.name}: ${profile.userName} <${profile.userEmail}>\n`);
226
+ return pause(context);
227
+ }
228
+ async function editProfile(context, profile) {
229
+ context.output.write(`Editing ${profile.name}. Leave a value blank to keep the current one.\n`);
230
+ const userName = await context.prompts.ask(`Git user.name [${profile.userName}]: `);
231
+ const userEmail = await context.prompts.ask(`Git user.email [${profile.userEmail}]: `);
232
+ const nextProfile = await context.repository.upsertProfile({
233
+ name: profile.name,
234
+ userName: userName || profile.userName,
235
+ userEmail: userEmail || profile.userEmail,
236
+ });
237
+ context.output.write(`Saved profile ${nextProfile.name}: ${nextProfile.userName} <${nextProfile.userEmail}>\n`);
238
+ await applyConfig(context, false);
239
+ return pause(context);
240
+ }
241
+ async function setProfilePromptColor(context, profile) {
242
+ const promptColor = await selectProfilePromptColor(context, profile.promptColor ?? null);
243
+ const nextProfile = await context.repository.setProfilePromptColor({
244
+ name: profile.name,
245
+ promptColor,
246
+ });
247
+ context.output.write(nextProfile.promptColor
248
+ ? `Set prompt color for ${nextProfile.name} to ${nextProfile.promptColor}.\n`
249
+ : `Cleared prompt color for ${nextProfile.name}.\n`);
250
+ return pause(context);
251
+ }
252
+ async function removeSelectedProfile(context, profile) {
253
+ const confirmed = await yesNo(context, `Remove profile ${profile.name}?`, false);
254
+ if (!confirmed) {
255
+ return "continue";
256
+ }
257
+ const removed = await context.repository.removeProfile(profile.name);
258
+ context.output.write(removed ? `Removed profile ${profile.name}.\n` : `Profile not found: ${profile.name}\n`);
259
+ await applyConfig(context, false);
260
+ return pause(context);
261
+ }
262
+ async function addRule(context) {
263
+ const data = await context.repository.read();
264
+ const profile = await selectProfile(context, data.profiles, "Choose profile for rule", getPromptStatus({ data }).profileName);
265
+ if (!profile) {
266
+ return "continue";
267
+ }
268
+ const directory = await context.prompts.askRequired("Directory: ");
269
+ const rule = await context.repository.addRule({
270
+ profileName: profile.name,
271
+ directory,
272
+ homeDir: context.paths.homeDir,
273
+ });
274
+ context.output.write(`Saved rule ${rule.id}: ${rule.profileName} -> ${rule.directory}\n`);
275
+ return pause(context);
276
+ }
277
+ async function ruleActionsMenu(context, rule) {
278
+ return await runMenu(context, `Rule: ${rule.profileName} -> ${rule.directory}`, [
279
+ { label: "[Remove rule]", run: () => removeSelectedRule(context, rule) },
280
+ { label: "[Back]", run: async () => "back" },
281
+ ]);
282
+ }
283
+ async function removeSelectedRule(context, rule) {
284
+ const confirmed = await yesNo(context, `Remove rule ${rule.profileName} -> ${rule.directory}?`, false);
285
+ if (!confirmed) {
286
+ return "continue";
287
+ }
288
+ const removed = await context.repository.removeRule(rule.id);
289
+ context.output.write(removed ? `Removed rule ${rule.id}.\n` : `Rule not found: ${rule.id}\n`);
290
+ await applyConfig(context, false);
291
+ return pause(context);
292
+ }
293
+ async function useSelectedProfileForCurrentDirectory(context, profile) {
294
+ const rule = await context.repository.setDirectoryProfile({
295
+ profileName: profile.name,
296
+ directory: process.cwd(),
297
+ homeDir: context.paths.homeDir,
298
+ });
299
+ context.output.write(`Using profile ${rule.profileName} for ${rule.directory}\n`);
300
+ await applyConfig(context, false);
301
+ return pause(context);
302
+ }
303
+ async function applyConfig(context, pauseAfter = true) {
304
+ const data = await context.repository.read();
305
+ const result = await applyGitProfileConfig({
306
+ data,
307
+ globalGitConfigPath: context.paths.globalGitConfigPath,
308
+ generatedGitConfigDir: context.paths.generatedGitConfigDir,
309
+ });
310
+ context.output.write(`Generated ${result.generatedFiles.length} profile config file(s).\n`);
311
+ writeGitConfigResult(context, result);
312
+ return pauseAfter ? pause(context) : "continue";
313
+ }
314
+ async function doctorPromptedDirectory(context) {
315
+ const directory = await context.prompts.askRequired("Directory: ");
316
+ return doctor(context, directory);
317
+ }
318
+ async function doctor(context, directory) {
319
+ const data = await context.repository.read();
320
+ const cwd = resolve(directory);
321
+ const rule = findMatchingRule(data, cwd);
322
+ const identity = readActiveGitIdentity(cwd);
323
+ context.output.write(`Directory: ${cwd}\n`);
324
+ context.output.write(`Git user.name: ${identity.userName ?? "[unset]"}\n`);
325
+ context.output.write(`Git user.email: ${identity.userEmail ?? "[unset]"}\n`);
326
+ context.output.write(rule
327
+ ? `Managed rule: ${rule.id} (${rule.profileName} -> ${rule.directory})\n`
328
+ : "Managed rule: [none]\n");
329
+ return pause(context);
330
+ }
331
+ async function previewPrompt(context) {
332
+ const format = await selectPromptFormat(context);
333
+ const data = await context.repository.read();
334
+ const rendered = renderPromptStatus(getPromptStatus({ data }), format);
335
+ context.output.write(rendered ? `${rendered}\n` : "Prompt status is empty.\n");
336
+ return pause(context);
337
+ }
338
+ async function showPaths(context) {
339
+ for (const [key, value] of Object.entries(context.paths)) {
340
+ context.output.write(`${key}: ${value}\n`);
341
+ }
342
+ return pause(context);
343
+ }
344
+ async function exportProfiles(context) {
345
+ const defaultOutputPath = join(context.paths.homeDir, DEFAULT_TRANSFER_FILE_NAME);
346
+ const outputPath = (await context.prompts.ask(`Output path [${defaultOutputPath}]: `)) || defaultOutputPath;
347
+ const data = await context.repository.read();
348
+ const scope = await selectTransferScope(context, "Export scope");
349
+ const exportData = scope === "profiles-only" ? { ...data, rules: [] } : data;
350
+ const json = `${JSON.stringify(createProfileExportBundle(exportData), null, 2)}\n`;
351
+ if (outputPath === "-") {
352
+ context.output.write(json);
353
+ return pause(context);
354
+ }
355
+ const path = resolve(outputPath);
356
+ await mkdir(dirname(path), { recursive: true });
357
+ await writeFile(path, json, { mode: 0o600 });
358
+ context.output.write(`Exported ${exportData.profiles.length} profile(s) and ${exportData.rules.length} rule(s) to ${path}\n`);
359
+ return pause(context);
360
+ }
361
+ async function importProfiles(context) {
362
+ const defaultInputPath = join(context.paths.homeDir, DEFAULT_TRANSFER_FILE_NAME);
363
+ const inputPath = (await context.prompts.ask(`Input path [${defaultInputPath}]: `)) || defaultInputPath;
364
+ const importModes = ["merge", "replace"];
365
+ const mode = await context.prompts.selectOne({
366
+ prompt: "Import mode",
367
+ emptyMessage: "No import modes available.",
368
+ options: importModes,
369
+ renderOption: (option) => option === "merge" ? "Merge with local profiles" : "Replace local profiles",
370
+ getValue: (option) => option,
371
+ defaultIndex: getRememberedIndex(context, "Import mode", importModes, (option) => option),
372
+ });
373
+ rememberSelection(context, "Import mode", mode);
374
+ const scope = await selectTransferScope(context, "Import scope");
375
+ const applyAfter = await yesNo(context, "Run apply after import?", true);
376
+ const path = resolve(inputPath);
377
+ const incoming = parseProfileImportBundle(JSON.parse(await readFile(path, "utf8")));
378
+ const importData = scope === "profiles-only" ? { ...incoming, rules: [] } : incoming;
379
+ const current = await context.repository.read();
380
+ const next = mode === "replace" ? importData : mergeProfileStoreData(current, importData);
381
+ await context.repository.save(next);
382
+ context.output.write(`Imported ${importData.profiles.length} profile(s) and ${importData.rules.length} rule(s) from ${path} with ${mode} mode.\n`);
383
+ if (applyAfter) {
384
+ await applyConfig(context, false);
385
+ }
386
+ return pause(context);
387
+ }
388
+ async function installShellBundle(context, target) {
389
+ const shell = await selectShell(context);
390
+ const configPath = await optionalConfigPath(context);
391
+ const promptFormat = target === "completion" || target === "shell" ? "auto" : await selectPromptFormat(context);
392
+ const result = target === "all"
393
+ ? await installShellAll({ shell, configPath, promptFormat })
394
+ : target === "completion"
395
+ ? await installShellCompletion({ shell, configPath })
396
+ : target === "shell"
397
+ ? await installShellSession({ shell, configPath })
398
+ : await installShellPrompt({ shell, configPath, promptFormat });
399
+ context.output.write(`Installed ${target} shell integration for ${shell}: ${result.path}\n`);
400
+ writeShellResult(context, result);
401
+ return pause(context);
402
+ }
403
+ async function uninstallShellBundle(context, target) {
404
+ const shell = await selectShell(context);
405
+ const configPath = await optionalConfigPath(context);
406
+ const result = target === "all"
407
+ ? await uninstallShellAll({ shell, configPath })
408
+ : target === "completion"
409
+ ? await uninstallShellCompletion({ shell, configPath })
410
+ : target === "shell"
411
+ ? await uninstallShellSession({ shell, configPath })
412
+ : await uninstallShellPrompt({ shell, configPath });
413
+ context.output.write(`Uninstalled ${target} shell integration for ${shell}: ${result.path}\n`);
414
+ writeShellResult(context, result);
415
+ return pause(context);
416
+ }
417
+ async function packageInstallOrUpdate(context, mode) {
418
+ const shell = await selectShell(context);
419
+ const configPath = await optionalConfigPath(context);
420
+ const promptFormat = await selectPromptFormat(context);
421
+ const packageSpec = mode === "update" ? `${PACKAGE_NAME}@latest` : readCurrentPackageSpec();
422
+ context.output.write(`Installing ${packageSpec} globally with npm...\n`);
423
+ await runNpmInstallGlobal(packageSpec);
424
+ const result = await installShellAll({ shell, configPath, promptFormat });
425
+ context.output.write(`${mode === "update" ? "Updated" : "Installed"} package and shell integration for ${shell}: ${result.path}\n`);
426
+ writeShellResult(context, result);
427
+ return pause(context);
428
+ }
429
+ async function selectProfile(context, profiles, title, defaultProfileName) {
430
+ if (profiles.length === 0) {
431
+ context.output.write("No profiles yet. Add one first.\n");
432
+ await pause(context);
433
+ return null;
434
+ }
435
+ const profile = await context.prompts.selectOne({
436
+ prompt: title,
437
+ emptyMessage: "No profiles yet. Add one first.",
438
+ options: profiles,
439
+ renderOption: renderProfileLine,
440
+ getValue: (profile) => profile.name,
441
+ defaultIndex: getRememberedIndex(context, title, profiles, (profile) => profile.name, defaultProfileName),
442
+ });
443
+ rememberSelection(context, title, profile.name);
444
+ return profile;
445
+ }
446
+ async function selectShell(context) {
447
+ const detected = detectShell();
448
+ const shells = ["zsh", "bash", "fish"];
449
+ const shell = await context.prompts.selectOne({
450
+ prompt: "Choose shell",
451
+ emptyMessage: "No shells available.",
452
+ options: shells,
453
+ renderOption: (shell) => (shell === detected ? `${shell} (detected)` : shell),
454
+ getValue: (shell) => shell,
455
+ defaultIndex: getRememberedIndex(context, "Choose shell", shells, (shell) => shell, detected),
456
+ });
457
+ rememberSelection(context, "Choose shell", shell);
458
+ return shell;
459
+ }
460
+ async function selectPromptFormat(context) {
461
+ const formats = ["auto", "profile", "identity"];
462
+ const format = await context.prompts.selectOne({
463
+ prompt: "Prompt format",
464
+ emptyMessage: "No prompt formats available.",
465
+ options: formats,
466
+ renderOption: (format) => format,
467
+ getValue: (format) => format,
468
+ defaultIndex: getRememberedIndex(context, "Prompt format", formats, (format) => format),
469
+ });
470
+ rememberSelection(context, "Prompt format", format);
471
+ return format;
472
+ }
473
+ async function selectTransferScope(context, prompt) {
474
+ const scopes = [
475
+ "profiles-and-rules",
476
+ "profiles-only",
477
+ ];
478
+ const scope = await context.prompts.selectOne({
479
+ prompt,
480
+ emptyMessage: "No transfer scopes available.",
481
+ options: scopes,
482
+ renderOption: (option) => option === "profiles-and-rules" ? "Profiles and directory rules" : "Profiles only",
483
+ getValue: (option) => option,
484
+ defaultIndex: getRememberedIndex(context, prompt, scopes, (option) => option),
485
+ });
486
+ rememberSelection(context, prompt, scope);
487
+ return scope;
488
+ }
489
+ async function selectProfilePromptColor(context, currentColor) {
490
+ const options = ["", ...PROFILE_PROMPT_COLORS];
491
+ const defaultIndex = currentColor ? Math.max(0, options.indexOf(currentColor)) : 0;
492
+ const choice = await context.prompts.selectOne({
493
+ prompt: "Profile prompt color",
494
+ emptyMessage: "No prompt colors available.",
495
+ options,
496
+ renderOption: (color) => {
497
+ const label = color ? renderPromptColorLabel(color) : "[no color]";
498
+ return color === currentColor || (!color && !currentColor) ? `${label} (current)` : label;
499
+ },
500
+ getValue: (color) => color || "no-color",
501
+ defaultIndex,
502
+ });
503
+ return choice === "" ? null : choice;
504
+ }
505
+ async function yesNo(context, prompt, defaultValue) {
506
+ const answer = await context.prompts.selectOne({
507
+ prompt,
508
+ emptyMessage: "No choices available.",
509
+ options: ["yes", "no"],
510
+ renderOption: (option) => option,
511
+ getValue: (option) => option,
512
+ defaultIndex: defaultValue ? 0 : 1,
513
+ });
514
+ return answer === "yes";
515
+ }
516
+ async function optionalConfigPath(context) {
517
+ const value = await context.prompts.ask("Shell config path (blank for default): ");
518
+ return value || undefined;
519
+ }
520
+ async function pause(context) {
521
+ await context.prompts.ask("Press Enter to continue...");
522
+ return "continue";
523
+ }
524
+ function writeGitConfigResult(context, result) {
525
+ context.output.write(result.changed
526
+ ? `Updated ${result.globalGitConfigPath}.\n`
527
+ : `${result.globalGitConfigPath} was already up to date.\n`);
528
+ }
529
+ function writeShellResult(context, result) {
530
+ context.output.write(result.changed ? "Updated shell config.\n" : "Shell config was already up to date.\n");
531
+ }
532
+ function readCurrentPackageSpec() {
533
+ const version = readPackageVersion();
534
+ return version ? `${PACKAGE_NAME}@${version}` : PACKAGE_NAME;
535
+ }
536
+ function getRememberedIndex(context, menuKey, options, getValue, fallbackValue) {
537
+ const rememberedValue = context.menuSelection.get(menuKey);
538
+ const value = rememberedValue ?? fallbackValue;
539
+ const index = value ? options.findIndex((option) => getValue(option) === value) : -1;
540
+ return index >= 0 ? index : 0;
541
+ }
542
+ function rememberSelection(context, menuKey, value) {
543
+ context.menuSelection.set(menuKey, value);
544
+ }
545
+ function isBackMenuValue(value) {
546
+ return value === "Back" || value === "[Back]";
547
+ }
548
+ function renderProfileMenuItem(item) {
549
+ if (item.type === "profile") {
550
+ return renderProfileLine(item.profile);
551
+ }
552
+ return item.label;
553
+ }
554
+ function renderProfileLine(profile) {
555
+ const swatch = profile.promptColor ? `${renderPromptColorSwatch(profile.promptColor)} ` : "";
556
+ return `${swatch}${profile.name}\t${profile.userName} <${profile.userEmail}>`;
557
+ }
558
+ function renderPromptColorLabel(promptColor) {
559
+ return `${renderPromptColorSwatch(promptColor)} ${promptColor}`;
560
+ }
561
+ function renderPromptColorSwatch(promptColor) {
562
+ return `\x1b[${PROFILE_PROMPT_COLOR_CODES[promptColor]}m■\x1b[0m`;
563
+ }
564
+ function renderRuleMenuItem(item) {
565
+ if (item.type === "rule") {
566
+ return `${item.rule.profileName}\t${item.rule.directory}`;
567
+ }
568
+ return item.label;
569
+ }
570
+ function readPackageVersion() {
571
+ for (const packageJsonPath of getPackageJsonCandidates()) {
572
+ if (!existsSync(packageJsonPath)) {
573
+ continue;
574
+ }
575
+ const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8"));
576
+ if (parsed.name === PACKAGE_NAME) {
577
+ return parsed.version ?? null;
578
+ }
579
+ }
580
+ return null;
581
+ }
582
+ function getPackageJsonCandidates() {
583
+ const here = dirname(fileURLToPath(import.meta.url));
584
+ return [
585
+ join(here, "..", "..", "package.json"),
586
+ join(here, "..", "..", "..", "package.json"),
587
+ join(process.cwd(), "package.json"),
588
+ ];
589
+ }
590
+ function runNpmInstallGlobal(packageSpec) {
591
+ return new Promise((resolvePromise, reject) => {
592
+ const child = spawn("npm", ["install", "-g", packageSpec], {
593
+ stdio: "inherit",
594
+ });
595
+ child.on("error", reject);
596
+ child.on("close", (status) => {
597
+ if (status === 0) {
598
+ resolvePromise();
599
+ return;
600
+ }
601
+ reject(new Error(`npm install -g ${packageSpec} exited with ${status}`));
602
+ });
603
+ });
604
+ }