ai-profile-switch 2.2.0 → 2.2.1
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/README.ko.md +51 -121
- package/README.md +47 -117
- package/package.json +1 -1
- package/src/commands/add.mjs +19 -6
- package/src/commands/init.mjs +44 -45
- package/src/commands/install-shell.mjs +4 -3
- package/src/commands/language.mjs +38 -0
- package/src/commands/list.mjs +13 -6
- package/src/commands/login.mjs +43 -0
- package/src/commands/manage.mjs +107 -51
- package/src/commands/mcp/index.mjs +7 -6
- package/src/commands/migrate.mjs +19 -18
- package/src/commands/remove.mjs +7 -6
- package/src/commands/rename.mjs +15 -10
- package/src/commands/share.mjs +16 -14
- package/src/commands/shell.mjs +76 -15
- package/src/commands/update.mjs +15 -12
- package/src/commands/use.mjs +5 -3
- package/src/index.mjs +14 -36
- package/src/lib/account.mjs +47 -0
- package/src/lib/config.mjs +8 -1
- package/src/lib/constants.mjs +4 -0
- package/src/lib/i18n.mjs +65 -0
- package/src/lib/profile.mjs +30 -19
- package/src/lib/prompt.mjs +77 -38
- package/src/lib/shared.mjs +5 -4
- package/src/lib/ui.mjs +54 -29
- package/src/locales/en.mjs +265 -0
- package/src/locales/ko.mjs +265 -0
package/src/lib/profile.mjs
CHANGED
|
@@ -11,7 +11,8 @@ import {
|
|
|
11
11
|
} from './constants.mjs';
|
|
12
12
|
import { createHash } from 'node:crypto';
|
|
13
13
|
import { addProfileToMeta, removeProfileFromMeta, readMeta, writeMeta } from './config.mjs';
|
|
14
|
-
import { warn } from './ui.mjs';
|
|
14
|
+
import { userError, warn } from './ui.mjs';
|
|
15
|
+
import { t } from './i18n.mjs';
|
|
15
16
|
|
|
16
17
|
export function pathExists(path) {
|
|
17
18
|
try { lstatSync(path); return true; } catch (err) {
|
|
@@ -31,23 +32,21 @@ export function createLink(target, link, isDir, copySource = resolve(dirname(lin
|
|
|
31
32
|
} catch (err) {
|
|
32
33
|
if (!IS_WINDOWS || isDir || !['EPERM', 'ENOTSUP'].includes(err.code)) throw err;
|
|
33
34
|
copyFileSync(copySource, link);
|
|
34
|
-
warn(
|
|
35
|
-
warn('
|
|
35
|
+
warn(t('profile.copiedInstead', { link }));
|
|
36
|
+
warn(t('profile.devMode'));
|
|
36
37
|
}
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
export function validateProfileName(name) {
|
|
40
|
-
if (!name) return '
|
|
41
|
+
if (!name) return t('profile.empty');
|
|
41
42
|
if (name.length > PROFILE_NAME_MAX_LENGTH) {
|
|
42
|
-
return
|
|
43
|
+
return t('profile.tooLong', { max: PROFILE_NAME_MAX_LENGTH });
|
|
43
44
|
}
|
|
44
|
-
if (RESERVED_NAMES.includes(name)) return
|
|
45
|
+
if (RESERVED_NAMES.includes(name)) return t('profile.reserved', { name });
|
|
45
46
|
if (IS_WINDOWS && /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(name)) {
|
|
46
|
-
return
|
|
47
|
-
}
|
|
48
|
-
if (!PROFILE_NAME_REGEX.test(name)) {
|
|
49
|
-
return 'Only lowercase letters, numbers, and hyphens allowed (must start/end with letter or number)';
|
|
47
|
+
return t('profile.reservedWindows', { name });
|
|
50
48
|
}
|
|
49
|
+
if (!PROFILE_NAME_REGEX.test(name)) return t('profile.invalidName');
|
|
51
50
|
return null;
|
|
52
51
|
}
|
|
53
52
|
|
|
@@ -87,13 +86,16 @@ export function assertRegisteredProfile(name) {
|
|
|
87
86
|
const error = validateProfileName(name);
|
|
88
87
|
if (error) throw new Error(error);
|
|
89
88
|
const meta = readMeta();
|
|
90
|
-
if (!meta.profiles.includes(name))
|
|
89
|
+
if (!meta.profiles.includes(name)) {
|
|
90
|
+
throw userError(t('profile.notFound', { name }), [
|
|
91
|
+
...(meta.profiles.length ? [{ label: t('hint.profiles'), text: meta.profiles.join(', ') }] : []),
|
|
92
|
+
{ label: t('hint.create'), command: `cpf add ${name}` },
|
|
93
|
+
]);
|
|
94
|
+
}
|
|
91
95
|
const dir = profileDir(name);
|
|
92
|
-
if (!pathExists(dir)) throw new Error(
|
|
96
|
+
if (!pathExists(dir)) throw new Error(t('profile.missingDir', { name }));
|
|
93
97
|
const entry = lstatSync(dir);
|
|
94
|
-
if (entry.isSymbolicLink() || !entry.isDirectory()) {
|
|
95
|
-
throw new Error(`Profile "${name}" must be a directory, not a symbolic link or file`);
|
|
96
|
-
}
|
|
98
|
+
if (entry.isSymbolicLink() || !entry.isDirectory()) throw new Error(t('profile.notDirectory', { name }));
|
|
97
99
|
return meta;
|
|
98
100
|
}
|
|
99
101
|
|
|
@@ -103,16 +105,25 @@ export function removeProfile(name) {
|
|
|
103
105
|
removeProfileFromMeta(name);
|
|
104
106
|
}
|
|
105
107
|
|
|
106
|
-
|
|
108
|
+
// Checks a rename without changing anything, so callers can warn only when it can go ahead.
|
|
109
|
+
export function checkRename(oldName, newName) {
|
|
107
110
|
const meta = assertRegisteredProfile(oldName);
|
|
108
111
|
const error = validateProfileName(newName);
|
|
109
112
|
if (error) throw new Error(error);
|
|
113
|
+
if (oldName !== newName && (meta.profiles.includes(newName) || pathExists(profileDir(newName)))) {
|
|
114
|
+
throw userError(t('profile.exists', { name: newName }), [
|
|
115
|
+
{ label: t('hint.profiles'), text: meta.profiles.join(', ') },
|
|
116
|
+
{ label: t('hint.otherName'), command: `cpf rename ${oldName} ${newName}-2` },
|
|
117
|
+
]);
|
|
118
|
+
}
|
|
119
|
+
return meta;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function renameProfile(oldName, newName) {
|
|
123
|
+
const meta = checkRename(oldName, newName);
|
|
110
124
|
if (oldName === newName) return false;
|
|
111
125
|
const oldDir = profileDir(oldName);
|
|
112
126
|
const newDir = profileDir(newName);
|
|
113
|
-
if (meta.profiles.includes(newName) || pathExists(newDir)) {
|
|
114
|
-
throw new Error(`Profile "${newName}" already exists`);
|
|
115
|
-
}
|
|
116
127
|
|
|
117
128
|
renameSync(oldDir, newDir);
|
|
118
129
|
try {
|
package/src/lib/prompt.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createInterface, emitKeypressEvents } from 'node:readline';
|
|
2
2
|
import { PassThrough } from 'node:stream';
|
|
3
|
-
import { color, cursorControl, sym } from './ui.mjs';
|
|
3
|
+
import { color, cursorControl, displayWidth, sym } from './ui.mjs';
|
|
4
|
+
import { t } from './i18n.mjs';
|
|
4
5
|
|
|
5
6
|
function rl() {
|
|
6
7
|
return createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -29,17 +30,18 @@ function readKeys(onKey) {
|
|
|
29
30
|
};
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
// hint is shown only while asking, such as "now: side · empty to cancel".
|
|
34
|
+
export async function text(message, defaultVal, hint) {
|
|
35
|
+
const suffix = hint ? ` ${color.dim(hint)}` : defaultVal !== undefined ? color.dim(` (${defaultVal})`) : '';
|
|
34
36
|
const r = rl();
|
|
35
37
|
const cursor = cursorControl();
|
|
36
38
|
return new Promise((resolve) => {
|
|
37
|
-
r.question(`${color.cyan(
|
|
39
|
+
r.question(`${color.cyan(sym.step)} ${message}${suffix}\n${color.dim(sym.rail)} ${color.dim(sym.arrow)} `, (answer) => {
|
|
38
40
|
r.close();
|
|
39
41
|
const result = answer.trim() || defaultVal || '';
|
|
40
|
-
// Replace 2-line prompt with single completed line:
|
|
42
|
+
// Replace 2-line prompt with single completed line: ◇ message … answer
|
|
41
43
|
if (cursor.enabled) {
|
|
42
|
-
process.stdout.write(`${cursor.up(2)}\r${cursor.clearLine}${color.green(sym.
|
|
44
|
+
process.stdout.write(`${cursor.up(2)}\r${cursor.clearLine}${color.green(sym.stepDone)} ${message} ${color.dim(sym.dots)} ${result ? color.cyan(result) : color.dim(t('prompt.cancelled'))}\n${cursor.clearDown}`);
|
|
43
45
|
}
|
|
44
46
|
resolve(result);
|
|
45
47
|
});
|
|
@@ -63,11 +65,12 @@ export async function confirm(message, defaultVal = true) {
|
|
|
63
65
|
const cursor = cursorControl();
|
|
64
66
|
|
|
65
67
|
const render = (final = false) => {
|
|
66
|
-
const prefix = final ? color.green(sym.
|
|
68
|
+
const prefix = final ? color.green(sym.stepDone) : color.cyan(sym.step);
|
|
67
69
|
const sep = final ? color.dim(` ${sym.dots} `) : color.dim(` ${sym.arrow} `);
|
|
68
|
-
const no = !selected ? color.cyan('
|
|
69
|
-
const yes = selected ? color.cyan('
|
|
70
|
-
|
|
70
|
+
const no = !selected ? color.cyan(t('prompt.no')) : color.dim(t('prompt.no'));
|
|
71
|
+
const yes = selected ? color.cyan(t('prompt.yes')) : color.dim(t('prompt.yes'));
|
|
72
|
+
const answer = final ? (selected ? yes : no) : `${no} ${color.dim('/')} ${yes}`;
|
|
73
|
+
process.stdout.write(`\r${cursor.clearLine}${prefix} ${message}${sep}${answer}`);
|
|
71
74
|
};
|
|
72
75
|
|
|
73
76
|
render();
|
|
@@ -82,7 +85,8 @@ export async function confirm(message, defaultVal = true) {
|
|
|
82
85
|
} else if (character === 'n' || character === 'N') {
|
|
83
86
|
selected = false;
|
|
84
87
|
render();
|
|
85
|
-
} else if (key.name === 'return' || key.name === 'enter') {
|
|
88
|
+
} else if (key.name === 'return' || key.name === 'enter' || key.name === 'escape') {
|
|
89
|
+
if (key.name === 'escape') selected = false;
|
|
86
90
|
stop();
|
|
87
91
|
render(true);
|
|
88
92
|
process.stdout.write('\n');
|
|
@@ -96,52 +100,87 @@ export async function confirm(message, defaultVal = true) {
|
|
|
96
100
|
});
|
|
97
101
|
}
|
|
98
102
|
|
|
99
|
-
|
|
103
|
+
// Choices are strings or { label, value, hint, details }. hint sits after the label,
|
|
104
|
+
// details are extra dim lines under it. numbered adds 1-9 keys that pick at once.
|
|
105
|
+
// body lines sit between the question and the choices; back says Esc goes back.
|
|
106
|
+
export async function select(message, choices, {
|
|
107
|
+
output = process.stdout, initial = 0, numbered = false, done, body = [], back = false,
|
|
108
|
+
} = {}) {
|
|
109
|
+
const items = choices.map(choice => (typeof choice === 'string' ? { label: choice, value: choice } : choice));
|
|
100
110
|
return new Promise((resolve) => {
|
|
101
|
-
let selected = Math.max(0, Math.min(initial,
|
|
102
|
-
let
|
|
111
|
+
let selected = Math.max(0, Math.min(initial, items.length - 1));
|
|
112
|
+
let height = 0;
|
|
103
113
|
const cursor = cursorControl(output);
|
|
114
|
+
const rail = color.dim(sym.rail);
|
|
115
|
+
const indent = ' '.repeat(numbered ? 5 : 2);
|
|
116
|
+
const keys = numbered && items.length > 1 && items.length <= 9
|
|
117
|
+
? t(back ? 'prompt.keysNumberedBack' : 'prompt.keysNumbered', { arrows: sym.arrows, max: items.length })
|
|
118
|
+
: t(back ? 'prompt.keysBack' : 'prompt.keys', { arrows: sym.arrows });
|
|
119
|
+
|
|
120
|
+
const rows = () => {
|
|
121
|
+
const lines = [];
|
|
122
|
+
items.forEach((item, i) => {
|
|
123
|
+
const number = numbered ? `${i + 1} ` : '';
|
|
124
|
+
const head = i === selected
|
|
125
|
+
? color.cyan(`${sym.cursor} ${number}${item.label}`)
|
|
126
|
+
: ` ${numbered ? `${color.dim(String(i + 1))} ` : ''}${item.label}`;
|
|
127
|
+
lines.push(`${rail} ${head}${item.hint ? ` ${item.hint}` : ''}`);
|
|
128
|
+
for (const detail of item.details ?? []) lines.push(`${rail} ${indent}${detail}`);
|
|
129
|
+
if (item.details?.length) lines.push(rail);
|
|
130
|
+
});
|
|
131
|
+
lines.push(`${rail} ${color.dim(keys)}`, color.dim(sym.railEnd));
|
|
132
|
+
return lines;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// A line wider than the window wraps, so count the screen rows it really takes.
|
|
136
|
+
const screenRows = lines => lines.reduce((sum, line) => sum
|
|
137
|
+
+ Math.max(1, Math.ceil(displayWidth(line) / (output.columns || Infinity))), 0);
|
|
138
|
+
const header = [`${color.cyan(sym.step)} ${message}`, ...body.map(line => `${rail} ${line}`)];
|
|
104
139
|
|
|
105
140
|
const render = () => {
|
|
106
|
-
if (
|
|
107
|
-
|
|
141
|
+
if (height) output.write(cursor.up(height));
|
|
142
|
+
const lines = rows();
|
|
143
|
+
for (const line of lines) output.write(`${cursor.clearLine}${line}\n`);
|
|
144
|
+
height = screenRows(lines);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const finish = (value, summary) => {
|
|
148
|
+
stop();
|
|
149
|
+
if (cursor.enabled) {
|
|
150
|
+
output.write(`${cursor.up(height + screenRows(header))}\r${cursor.clearLine}${summary}\n${cursor.clearDown}`);
|
|
108
151
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
152
|
+
resolve(value);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const pick = () => {
|
|
156
|
+
const item = items[selected];
|
|
157
|
+
const summary = done ? done(item.value) : `${message} ${color.dim(sym.dots)} ${color.cyan(item.label.trim())}`;
|
|
158
|
+
finish(item.value, `${color.green(sym.stepDone)} ${summary}`);
|
|
115
159
|
};
|
|
116
160
|
|
|
117
161
|
if (!process.stdin.isTTY) {
|
|
118
|
-
// Non-interactive: pick
|
|
119
|
-
resolve(
|
|
162
|
+
// Non-interactive: pick the initial choice without rendering the menu
|
|
163
|
+
resolve(items[selected].value);
|
|
120
164
|
return;
|
|
121
165
|
}
|
|
122
166
|
|
|
123
|
-
output.write(`${
|
|
167
|
+
for (const line of header) output.write(`${line}\n`);
|
|
124
168
|
render();
|
|
125
169
|
|
|
126
170
|
const stop = readKeys((character, key) => {
|
|
127
171
|
if (key.name === 'up') {
|
|
128
|
-
selected = (selected - 1 +
|
|
172
|
+
selected = (selected - 1 + items.length) % items.length;
|
|
129
173
|
render();
|
|
130
174
|
} else if (key.name === 'down') {
|
|
131
|
-
selected = (selected + 1) %
|
|
175
|
+
selected = (selected + 1) % items.length;
|
|
132
176
|
render();
|
|
177
|
+
} else if (numbered && /^[1-9]$/.test(character ?? '') && Number(character) <= items.length) {
|
|
178
|
+
selected = Number(character) - 1;
|
|
179
|
+
pick();
|
|
133
180
|
} else if (key.name === 'return' || key.name === 'enter') {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if (cursor.enabled) {
|
|
138
|
-
output.write(`${cursor.up(choices.length + 1)}\r${cursor.clearLine}${color.green(sym.check)} ${message} ${color.dim(sym.dots)} ${color.cyan(value)}\n${cursor.clearDown}`);
|
|
139
|
-
}
|
|
140
|
-
resolve(value);
|
|
141
|
-
} else if (key.ctrl && key.name === 'c') {
|
|
142
|
-
stop();
|
|
143
|
-
output.write('\n');
|
|
144
|
-
resolve(null);
|
|
181
|
+
pick();
|
|
182
|
+
} else if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
|
|
183
|
+
finish(null, `${color.dim(sym.stepDone)} ${message} ${color.dim(t('prompt.cancelled'))}`);
|
|
145
184
|
}
|
|
146
185
|
});
|
|
147
186
|
});
|
package/src/lib/shared.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
SHARED_DIR, SHAREABLE_ENTRIES, SHAREABLE_FILES, ACCOUNT_SKILL_ENTRIES,
|
|
7
7
|
} from './constants.mjs';
|
|
8
8
|
import { readMeta, getActiveProfile, getSharedEntries, setSharedEntries } from './config.mjs';
|
|
9
|
+
import { t } from './i18n.mjs';
|
|
9
10
|
import {
|
|
10
11
|
pathExists, createLink, copyEntry, identicalEntries, emptyEntry, linkTarget, canonicalPath,
|
|
11
12
|
profileDir, listProfiles,
|
|
@@ -93,7 +94,7 @@ export function syncShared(names = listProfiles(), options) {
|
|
|
93
94
|
|
|
94
95
|
function validateEntry(entry) {
|
|
95
96
|
if (!SHAREABLE_ENTRIES.includes(entry)) {
|
|
96
|
-
throw new Error(
|
|
97
|
+
throw new Error(t('share.cannot', { entry, list: SHAREABLE_ENTRIES.join(', ') }));
|
|
97
98
|
}
|
|
98
99
|
}
|
|
99
100
|
|
|
@@ -120,9 +121,9 @@ function seedShared(entry, from) {
|
|
|
120
121
|
export function shareEntry(entry, from = getActiveProfile()) {
|
|
121
122
|
validateEntry(entry);
|
|
122
123
|
const meta = readMeta();
|
|
123
|
-
if (!meta.shareSettings) throw new Error('
|
|
124
|
+
if (!meta.shareSettings) throw new Error(t('share.needsSettings'));
|
|
124
125
|
const entries = meta.shared ?? [];
|
|
125
|
-
if (entries.includes(entry)) throw new Error(
|
|
126
|
+
if (entries.includes(entry)) throw new Error(t('share.already', { entry }));
|
|
126
127
|
mkdirSync(SHARED_DIR, { recursive: true });
|
|
127
128
|
if (!pathExists(join(SHARED_DIR, entry))) seedShared(entry, from);
|
|
128
129
|
setSharedEntries([...entries, entry]);
|
|
@@ -133,7 +134,7 @@ export function shareEntry(entry, from = getActiveProfile()) {
|
|
|
133
134
|
export function unshareEntry(entry) {
|
|
134
135
|
validateEntry(entry);
|
|
135
136
|
const entries = getSharedEntries();
|
|
136
|
-
if (!entries.includes(entry)) throw new Error(
|
|
137
|
+
if (!entries.includes(entry)) throw new Error(t('share.notShared', { entry }));
|
|
137
138
|
const sharedPath = join(SHARED_DIR, entry);
|
|
138
139
|
for (const name of listProfiles()) {
|
|
139
140
|
if (entry === 'skills') {
|
package/src/lib/ui.mjs
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
// ANSI colors, terminal capability detection, and
|
|
1
|
+
// ANSI colors, terminal capability detection, and the left-rail layout helpers.
|
|
2
2
|
// Single source for any module that writes to stdout/stderr.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
const colorAllowed = !process.env.NO_COLOR && process.env.TERM !== 'dumb';
|
|
5
|
+
export let hasColor = Boolean(process.stdout.isTTY || process.env.FORCE_COLOR) && colorAllowed;
|
|
6
|
+
|
|
7
|
+
// The claude picker draws on stderr while its stdout is captured by the shell.
|
|
8
|
+
export function colorFor(stream) {
|
|
9
|
+
hasColor = Boolean(stream.isTTY || process.env.FORCE_COLOR) && colorAllowed;
|
|
10
|
+
}
|
|
7
11
|
|
|
8
12
|
// On legacy Windows consoles (cmd.exe without UTF-8 codepage), Unicode box
|
|
9
13
|
// characters may not render. Detect via the WT_SESSION env var (set by
|
|
@@ -15,17 +19,16 @@ export const supportsUnicode = Boolean(
|
|
|
15
19
|
);
|
|
16
20
|
|
|
17
21
|
const ESC = '\x1b[';
|
|
18
|
-
const RESET = hasColor ? `${ESC}0m` : '';
|
|
19
22
|
const c = (code) => hasColor ? `${ESC}${code}m` : '';
|
|
20
23
|
|
|
21
24
|
export const color = {
|
|
22
|
-
bold: (s) => `${c('1')}${s}${
|
|
23
|
-
dim: (s) => `${c('2')}${s}${
|
|
24
|
-
green: (s) => `${c('32')}${s}${
|
|
25
|
-
yellow: (s) => `${c('33')}${s}${
|
|
26
|
-
blue: (s) => `${c('34')}${s}${
|
|
27
|
-
cyan: (s) => `${c('36')}${s}${
|
|
28
|
-
red: (s) => `${c('31')}${s}${
|
|
25
|
+
bold: (s) => `${c('1')}${s}${c('0')}`,
|
|
26
|
+
dim: (s) => `${c('2')}${s}${c('0')}`,
|
|
27
|
+
green: (s) => `${c('32')}${s}${c('0')}`,
|
|
28
|
+
yellow: (s) => `${c('33')}${s}${c('0')}`,
|
|
29
|
+
blue: (s) => `${c('34')}${s}${c('0')}`,
|
|
30
|
+
cyan: (s) => `${c('36')}${s}${c('0')}`,
|
|
31
|
+
red: (s) => `${c('31')}${s}${c('0')}`,
|
|
29
32
|
};
|
|
30
33
|
|
|
31
34
|
export function cursorControl(output = process.stdout) {
|
|
@@ -50,24 +53,17 @@ export const sym = {
|
|
|
50
53
|
bulletOn: supportsUnicode ? '●' : '*',
|
|
51
54
|
bulletOff: supportsUnicode ? '○' : 'o',
|
|
52
55
|
dash: supportsUnicode ? '──' : '--',
|
|
56
|
+
arrows: supportsUnicode ? '↑↓' : 'Up/Down',
|
|
57
|
+
// Left rail that groups one flow: ┌ start, │ body, ◆ question, ◇ answered, └ end.
|
|
58
|
+
railTop: supportsUnicode ? '┌' : '+',
|
|
59
|
+
rail: supportsUnicode ? '│' : '|',
|
|
60
|
+
railEnd: supportsUnicode ? '└' : '`',
|
|
61
|
+
step: supportsUnicode ? '◆' : '*',
|
|
62
|
+
stepDone: supportsUnicode ? '◇' : 'o',
|
|
63
|
+
barOn: supportsUnicode ? '▰' : '#',
|
|
64
|
+
barOff: supportsUnicode ? '▱' : '-',
|
|
53
65
|
};
|
|
54
66
|
|
|
55
|
-
const BOX_TL = supportsUnicode ? '╭' : '+';
|
|
56
|
-
const BOX_TR = supportsUnicode ? '╮' : '+';
|
|
57
|
-
const BOX_BL = supportsUnicode ? '╰' : '+';
|
|
58
|
-
const BOX_BR = supportsUnicode ? '╯' : '+';
|
|
59
|
-
const BOX_H = supportsUnicode ? '─' : '-';
|
|
60
|
-
const BOX_V = supportsUnicode ? '│' : '|';
|
|
61
|
-
|
|
62
|
-
export function box(lines) {
|
|
63
|
-
const maxLen = Math.max(...lines.map(stripAnsi).map(l => l.length));
|
|
64
|
-
const pad = (s) => s + ' '.repeat(maxLen - stripAnsi(s).length);
|
|
65
|
-
const top = ` ${BOX_TL}${BOX_H.repeat(maxLen + 2)}${BOX_TR}`;
|
|
66
|
-
const bottom = ` ${BOX_BL}${BOX_H.repeat(maxLen + 2)}${BOX_BR}`;
|
|
67
|
-
const body = lines.map(l => ` ${BOX_V} ${pad(l)} ${BOX_V}`).join('\n');
|
|
68
|
-
return `${top}\n${body}\n${bottom}`;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
67
|
export function success(msg) {
|
|
72
68
|
process.stdout.write(` ${color.green(sym.check)} ${msg}\n`);
|
|
73
69
|
}
|
|
@@ -76,8 +72,31 @@ export function warn(msg) {
|
|
|
76
72
|
process.stdout.write(` ${color.yellow(sym.warn)} ${msg}\n`);
|
|
77
73
|
}
|
|
78
74
|
|
|
79
|
-
|
|
75
|
+
// Hints are { label, text } or { label, command } lines telling what to do next.
|
|
76
|
+
export function error(msg, hints = []) {
|
|
80
77
|
process.stderr.write(` ${color.red(sym.cross)} ${msg}\n`);
|
|
78
|
+
for (const hint of hints) {
|
|
79
|
+
process.stderr.write(` ${color.dim(hint.label)} ${hint.command ? color.cyan(hint.command) : hint.text}\n`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function userError(message, hints = []) {
|
|
84
|
+
return Object.assign(new Error(message), { hints });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function intro(title, note = '', output = process.stdout) {
|
|
88
|
+
output.write(`${color.dim(sym.railTop)} ${color.bold(title)}${note ? ` ${color.dim(note)}` : ''}\n${color.dim(sym.rail)}\n`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function outro(message, output = process.stdout) {
|
|
92
|
+
output.write(`${color.dim(sym.railEnd)} ${message}\n`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The number is always printed so the bar never relies on color alone.
|
|
96
|
+
export function meter(percent, width = 10) {
|
|
97
|
+
const filled = Math.round(percent / 100 * width);
|
|
98
|
+
const paint = percent >= 80 ? color.red : percent >= 50 ? color.yellow : color.green;
|
|
99
|
+
return `${paint(sym.barOn.repeat(filled))}${color.dim(sym.barOff.repeat(width - filled))} ${String(percent).padStart(3)}%`;
|
|
81
100
|
}
|
|
82
101
|
|
|
83
102
|
export function info(msg) {
|
|
@@ -87,3 +106,9 @@ export function info(msg) {
|
|
|
87
106
|
export function stripAnsi(s) {
|
|
88
107
|
return s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
89
108
|
}
|
|
109
|
+
|
|
110
|
+
// Hangul and other wide characters take two terminal columns.
|
|
111
|
+
export function displayWidth(text) {
|
|
112
|
+
return [...stripAnsi(text)].reduce((width, char) => width
|
|
113
|
+
+ (/[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\uff00-\uff60]/.test(char) ? 2 : 1), 0);
|
|
114
|
+
}
|