@crystalplatforms/adm 0.2.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +154 -161
- package/bin/adm +18 -21
- package/package.json +9 -22
- package/scripts/completion/adm.bash +12 -55
- package/scripts/completion/adm.zsh +21 -58
- package/scripts/install.js +8 -8
- package/src/commands/config.js +66 -0
- package/src/commands/dotfiles.js +49 -32
- package/src/commands/feedback.js +12 -0
- package/src/commands/help.js +33 -0
- package/src/commands/uninstall.js +73 -54
- package/src/commands/version.js +7 -0
- package/src/commands.js +26 -0
- package/src/config/index.js +32 -32
- package/src/data/extensions.json +358 -358
- package/src/engines.js +38 -0
- package/src/install/executor.js +263 -0
- package/src/install/hints.js +94 -0
- package/src/install/npm-prefix.js +110 -0
- package/src/install/plan.js +75 -0
- package/src/install/report.js +46 -0
- package/src/install/run.js +88 -0
- package/src/install/shell-paths.js +129 -0
- package/src/legacy-cleanup.js +96 -0
- package/src/pkg/builders.js +220 -0
- package/src/pkg/managers.js +59 -0
- package/src/pkg/package-names.js +229 -0
- package/src/pkg/script-urls.js +100 -0
- package/src/platform/index.js +129 -0
- package/src/platform/os-release.js +27 -0
- package/src/platform/shell.js +111 -0
- package/src/reports.js +37 -0
- package/src/router.js +157 -0
- package/src/setup/catalog.js +13 -13
- package/src/setup/git-step.js +128 -0
- package/src/setup/loader.js +108 -0
- package/src/setup/prompts.js +259 -0
- package/src/setup/registry.js +48 -0
- package/src/setup/spinner.js +67 -0
- package/src/setup/wizard.js +319 -195
- package/src/upgrade/index.js +499 -0
- package/src/utils/closest-match.js +36 -0
- package/src/utils/dotfiles-sync.js +89 -89
- package/src/utils/file-conflict-handler.js +30 -30
- package/bin/adm-tui +0 -13
- package/src/ai/knowledge.js +0 -61
- package/src/commands/connect.js +0 -76
- package/src/commands/issue.js +0 -80
- package/src/commands/pr.js +0 -55
- package/src/commands/theme.js +0 -103
- package/src/commands/upgrade.js +0 -114
- package/src/installer/git-config.js +0 -34
- package/src/installer/node-installer.js +0 -50
- package/src/installer/package-manager-installer.js +0 -31
- package/src/installer/ssh-setup.js +0 -31
- package/src/installer/system-packages.js +0 -49
- package/src/installers/index.js +0 -66
- package/src/integrations/ai-backend.js +0 -60
- package/src/integrations/ai-providers/registry.js +0 -261
- package/src/integrations/github.js +0 -107
- package/src/integrations/gitlab.js +0 -121
- package/src/plugins/context.js +0 -64
- package/src/plugins/loader.js +0 -50
- package/src/plugins/runner.js +0 -25
- package/src/setup/installer.js +0 -192
- package/src/shell/detector.js +0 -10
- package/src/tui/app-state.js +0 -374
- package/src/tui/app.js +0 -442
- package/src/tui/autocomplete.js +0 -57
- package/src/tui/commands/registry.js +0 -823
- package/src/tui/components/SetupScreen.js +0 -292
- package/src/ui/animations.js +0 -24
- package/src/ui/spinner.js +0 -34
- package/src/ui/theme/cyberpunk.js +0 -14
- package/src/ui/theme/dark.js +0 -14
- package/src/ui/theme/forest.js +0 -14
- package/src/ui/theme/light.js +0 -14
- package/src/ui/theme/monokai.js +0 -14
- package/src/ui/theme/nord.js +0 -14
- package/src/ui/theme.js +0 -49
- package/src/utils/command-history.js +0 -43
- package/src/utils/error-handler.js +0 -69
- package/src/utils/fuzzy-search.js +0 -20
- package/src/utils/github-error-handler.js +0 -26
- package/src/utils/keychain.js +0 -97
- package/src/utils/open-url.js +0 -15
- package/src/utils/terminal-detection.js +0 -26
- package/src/utils/token-encryption.js +0 -96
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
|
|
3
|
+
// A flicker-free checkbox prompt — a small, self-contained replacement for
|
|
4
|
+
// inquirer's checkbox on plain Windows consoles.
|
|
5
|
+
//
|
|
6
|
+
// Why not inquirer@8: its ScreenManager repaints with "erase N lines" and
|
|
7
|
+
// "write content" as TWO separate stdout writes, so terminals without double
|
|
8
|
+
// buffering briefly show an empty area — a visible flash on EVERY keypress.
|
|
9
|
+
// This prompt composes erase + full content into ONE write() per render,
|
|
10
|
+
// uses ASCII-only glyphs ([x]/[ ]), and keeps the cursor hidden while active.
|
|
11
|
+
//
|
|
12
|
+
// Same choice contract as inquirer: { name, value, checked?, disabled? }
|
|
13
|
+
// disabled: true or a reason string — rendered dim, not selectable.
|
|
14
|
+
// Resolves with the array of selected (non-disabled) values.
|
|
15
|
+
|
|
16
|
+
// Erases the previous frame (n lines up + clear-to-bottom) — always paired
|
|
17
|
+
// with the new content in the same write().
|
|
18
|
+
function eraseFrame(lines) {
|
|
19
|
+
if (lines <= 0) return '';
|
|
20
|
+
return `\x1b[${lines - 1}A\r\x1b[J`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function checkboxPrompt(options) {
|
|
24
|
+
const {
|
|
25
|
+
name = '',
|
|
26
|
+
message,
|
|
27
|
+
choices,
|
|
28
|
+
pageSize = 7,
|
|
29
|
+
stdout = process.stdout,
|
|
30
|
+
stdin = process.stdin,
|
|
31
|
+
inputStream, // test hook: Event-emitter standing in for raw stdin
|
|
32
|
+
} = options;
|
|
33
|
+
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
if (!stdin.isTTY && !inputStream) {
|
|
36
|
+
reject(new Error('checkboxPrompt needs a terminal (TTY)'));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── state ───────────────────────────────────────────────────
|
|
41
|
+
const state = choices.map(c => ({
|
|
42
|
+
...c,
|
|
43
|
+
checked: Boolean(c.checked) && !c.disabled,
|
|
44
|
+
isDisabled: Boolean(c.disabled),
|
|
45
|
+
}));
|
|
46
|
+
let index = 0;
|
|
47
|
+
let top = 0;
|
|
48
|
+
let drawnLines = 0;
|
|
49
|
+
let settled = false;
|
|
50
|
+
|
|
51
|
+
const visibleCount = () => Math.min(state.length, Math.max(1, pageSize));
|
|
52
|
+
|
|
53
|
+
const clampScroll = () => {
|
|
54
|
+
const vis = visibleCount();
|
|
55
|
+
if (index < top) top = index;
|
|
56
|
+
if (index >= top + vis) top = index - vis + 1;
|
|
57
|
+
if (top > state.length - vis) top = Math.max(0, state.length - vis);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const firstSelectable = () => state.findIndex(c => !c.isDisabled);
|
|
61
|
+
const lastSelectable = () => {
|
|
62
|
+
for (let i = state.length - 1; i >= 0; i--) if (!state[i].isDisabled) return i;
|
|
63
|
+
return state.length - 1;
|
|
64
|
+
};
|
|
65
|
+
const nextSelectable = (from, dir) => {
|
|
66
|
+
let i = from;
|
|
67
|
+
for (let step = 0; step < state.length; step++) {
|
|
68
|
+
i = (i + dir + state.length) % state.length;
|
|
69
|
+
if (!state[i].isDisabled) return i;
|
|
70
|
+
}
|
|
71
|
+
return from;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// ── rendering (one write per frame) ─────────────────────────
|
|
75
|
+
const content = () => {
|
|
76
|
+
const lines = [];
|
|
77
|
+
lines.push(`? ${message}`);
|
|
78
|
+
clampScroll();
|
|
79
|
+
const vis = visibleCount();
|
|
80
|
+
for (let i = top; i < top + vis; i++) {
|
|
81
|
+
const c = state[i];
|
|
82
|
+
const marker = c.checked ? '[x]' : '[ ]';
|
|
83
|
+
const pointer = i === index ? chalk.red.bold('>') : ' ';
|
|
84
|
+
const label = c.isDisabled
|
|
85
|
+
? chalk.dim(`${c.name}${typeof c.disabled === 'string' ? ` (${c.disabled})` : ' (locked)'}`)
|
|
86
|
+
: c.name;
|
|
87
|
+
const line = `${pointer} ${marker} ${label}`;
|
|
88
|
+
// the active row is red + bold so it clearly stands out
|
|
89
|
+
lines.push(i === index ? chalk.red.bold(line) : line);
|
|
90
|
+
}
|
|
91
|
+
lines.push(chalk.dim(' arrows = move · space = select · enter = done'));
|
|
92
|
+
return lines;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const render = () => {
|
|
96
|
+
const lines = content();
|
|
97
|
+
const chunk = (drawnLines > 0 ? eraseFrame(drawnLines) : '\x1b[?25l') + lines.join('\n');
|
|
98
|
+
drawnLines = lines.length;
|
|
99
|
+
stdout.write(chunk); // THE fix: erase + content in a single atomic write
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const close = () => {
|
|
103
|
+
settled = true;
|
|
104
|
+
if (inputStream) {
|
|
105
|
+
inputStream.removeListener('keypress', onKey);
|
|
106
|
+
} else {
|
|
107
|
+
stdin.removeListener('keypress', onKey);
|
|
108
|
+
if (stdin.setRawMode) stdin.setRawMode(false);
|
|
109
|
+
stdin.pause && stdin.pause();
|
|
110
|
+
stdin.resume && stdin.resume();
|
|
111
|
+
}
|
|
112
|
+
stdout.removeListener('resize', onResize);
|
|
113
|
+
stdout.write(eraseFrame(drawnLines) + '\x1b[?25h'); // remove the block, show cursor
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const finish = () => {
|
|
117
|
+
if (settled) return;
|
|
118
|
+
close();
|
|
119
|
+
resolve(state.filter(c => c.checked && !c.isDisabled).map(c => c.value));
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const abort = () => {
|
|
123
|
+
if (settled) return;
|
|
124
|
+
close();
|
|
125
|
+
const err = new Error('cancelled');
|
|
126
|
+
err.code = 'ADM_CANCELLED';
|
|
127
|
+
reject(err);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// ── keys ────────────────────────────────────────────────────
|
|
131
|
+
const onKey = (ch, key = {}) => {
|
|
132
|
+
const seq = key.name ? key.name : String(ch || '');
|
|
133
|
+
if (key.ctrl && seq === 'c') { abort(); return; }
|
|
134
|
+
switch (seq) {
|
|
135
|
+
case 'up':
|
|
136
|
+
case 'k':
|
|
137
|
+
index = nextSelectable(index, -1);
|
|
138
|
+
break;
|
|
139
|
+
case 'down':
|
|
140
|
+
case 'j':
|
|
141
|
+
index = nextSelectable(index, 1);
|
|
142
|
+
break;
|
|
143
|
+
case 'space':
|
|
144
|
+
if (!state[index].isDisabled) state[index].checked = !state[index].checked;
|
|
145
|
+
break;
|
|
146
|
+
case 'enter':
|
|
147
|
+
case 'return':
|
|
148
|
+
finish();
|
|
149
|
+
return;
|
|
150
|
+
default:
|
|
151
|
+
return; // unknown key → no repaint
|
|
152
|
+
}
|
|
153
|
+
if (!settled) render();
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const onResize = () => {
|
|
157
|
+
if (!settled) { drawnLines = 0; render(); }
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
stdout.on('resize', onResize);
|
|
161
|
+
|
|
162
|
+
if (inputStream) {
|
|
163
|
+
inputStream.on('keypress', onKey);
|
|
164
|
+
} else {
|
|
165
|
+
const readline = require('readline');
|
|
166
|
+
readline.emitKeypressEvents(stdin);
|
|
167
|
+
if (stdin.setRawMode) stdin.setRawMode(true);
|
|
168
|
+
stdin.resume();
|
|
169
|
+
stdin.on('keypress', onKey);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
render();
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// A flicker-free y/n confirm — the same single-write philosophy as
|
|
177
|
+
// checkboxPrompt. Renders "? message (y/n)" on one line; y/n answer
|
|
178
|
+
// immediately, Enter takes the default, Ctrl+C cancels (ADM_CANCELLED).
|
|
179
|
+
// On answer the question is ATOMICALLY replaced (one write) by the
|
|
180
|
+
// caller's `answerLine(confirmed)` done-format line — zero flicker on the
|
|
181
|
+
// transition (owner fix, 0.3.x: the inquirer confirm left its rows on
|
|
182
|
+
// screen and the erase/redraw blink was visible).
|
|
183
|
+
function confirmPrompt(options) {
|
|
184
|
+
const {
|
|
185
|
+
message,
|
|
186
|
+
defaultValue = false,
|
|
187
|
+
answerLine = () => '',
|
|
188
|
+
stdout = process.stdout,
|
|
189
|
+
stdin = process.stdin,
|
|
190
|
+
inputStream,
|
|
191
|
+
} = options;
|
|
192
|
+
|
|
193
|
+
return new Promise((resolve, reject) => {
|
|
194
|
+
if (!stdin.isTTY && !inputStream) {
|
|
195
|
+
reject(new Error('confirmPrompt needs a terminal (TTY)'));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const cols = () => Math.max(20, (stdout.columns || 80) - 1);
|
|
200
|
+
const plain = s => String(s).replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
|
|
201
|
+
const question = `? ${message} ${chalk.dim('(y/n)')}`;
|
|
202
|
+
let rows = Math.max(1, Math.ceil((plain(question).length + 4) / cols()));
|
|
203
|
+
let settled = false;
|
|
204
|
+
|
|
205
|
+
const eraseStr = () => (rows > 1 ? `\x1b[${rows - 1}A` : '') + '\r\x1b[J';
|
|
206
|
+
|
|
207
|
+
const detach = () => {
|
|
208
|
+
if (inputStream) {
|
|
209
|
+
inputStream.removeListener('keypress', onKey);
|
|
210
|
+
} else {
|
|
211
|
+
stdin.removeListener('keypress', onKey);
|
|
212
|
+
if (stdin.setRawMode) stdin.setRawMode(false);
|
|
213
|
+
stdin.pause && stdin.pause();
|
|
214
|
+
stdin.resume && stdin.resume();
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const finish = (value) => {
|
|
219
|
+
if (settled) return;
|
|
220
|
+
settled = true;
|
|
221
|
+
detach();
|
|
222
|
+
// ONE atomic write: hide-cursor cleanup + erase question + done line
|
|
223
|
+
stdout.write(eraseStr() + answerLine(value) + '\n' + '\x1b[?25h');
|
|
224
|
+
resolve(value);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const abort = () => {
|
|
228
|
+
if (settled) return;
|
|
229
|
+
settled = true;
|
|
230
|
+
detach();
|
|
231
|
+
stdout.write(eraseStr() + '\x1b[?25h');
|
|
232
|
+
const err = new Error('cancelled');
|
|
233
|
+
err.code = 'ADM_CANCELLED';
|
|
234
|
+
reject(err);
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const onKey = (ch, key = {}) => {
|
|
238
|
+
const seq = key.name ? key.name : String(ch || '').toLowerCase();
|
|
239
|
+
if (key.ctrl && seq === 'c') { abort(); return; }
|
|
240
|
+
if (seq === 'y') { finish(true); return; }
|
|
241
|
+
if (seq === 'n') { finish(false); return; }
|
|
242
|
+
if (seq === 'enter' || seq === 'return') { finish(defaultValue); return; }
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
stdout.write('\x1b[?25l' + question); // hide cursor + render, one write
|
|
246
|
+
|
|
247
|
+
if (inputStream) {
|
|
248
|
+
inputStream.on('keypress', onKey);
|
|
249
|
+
} else {
|
|
250
|
+
const readline = require('readline');
|
|
251
|
+
readline.emitKeypressEvents(stdin);
|
|
252
|
+
if (stdin.setRawMode) stdin.setRawMode(true);
|
|
253
|
+
stdin.resume();
|
|
254
|
+
stdin.on('keypress', onKey);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
module.exports = { checkboxPrompt, confirmPrompt, eraseFrame };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
|
|
5
|
+
// Installed-tools registry (v0.4.0 groundwork, #30): every real install
|
|
6
|
+
// appends { id, name, method, installedAt } to ~/.adm/installed.json.
|
|
7
|
+
// Same id → the entry is replaced (updated timestamp), never duplicated.
|
|
8
|
+
|
|
9
|
+
function registryFile(opts = {}) {
|
|
10
|
+
const dir = opts.configDir || process.env.ADM_CONFIG_DIR || path.join(os.homedir(), '.adm');
|
|
11
|
+
return path.join(dir, 'installed.json');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function readRegistry(opts = {}) {
|
|
15
|
+
const fsMod = opts.fs || fs;
|
|
16
|
+
try {
|
|
17
|
+
const data = JSON.parse(fsMod.readFileSync(registryFile(opts), 'utf8'));
|
|
18
|
+
return Array.isArray(data) ? data : [];
|
|
19
|
+
} catch {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function writeRegistry(entries, opts = {}) {
|
|
25
|
+
const fsMod = opts.fs || fs;
|
|
26
|
+
const file = registryFile(opts);
|
|
27
|
+
fsMod.mkdirSync(path.dirname(file), { recursive: true });
|
|
28
|
+
fsMod.writeFileSync(file, `${JSON.stringify(entries, null, 2)}\n`, 'utf8');
|
|
29
|
+
return file;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Appends/updates an entry. Returns the registry file path.
|
|
33
|
+
function appendInstalled(entry, opts = {}) {
|
|
34
|
+
const now = opts.now || new Date().toISOString();
|
|
35
|
+
const items = readRegistry(opts);
|
|
36
|
+
const record = {
|
|
37
|
+
id: entry.id,
|
|
38
|
+
name: entry.name || entry.id,
|
|
39
|
+
method: entry.method || null,
|
|
40
|
+
installedAt: now,
|
|
41
|
+
};
|
|
42
|
+
const existing = items.findIndex(e => e.id === record.id);
|
|
43
|
+
if (existing >= 0) items[existing] = record;
|
|
44
|
+
else items.push(record);
|
|
45
|
+
return writeRegistry(items, opts);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { appendInstalled, readRegistry, registryFile };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Minimal in-place spinner — a flicker-free ora substitute for plain
|
|
2
|
+
// Windows consoles.
|
|
3
|
+
//
|
|
4
|
+
// ora repaints with "clear line" + "write frame" as separate writes and uses
|
|
5
|
+
// braille glyphs (double-width on Windows) — the visible result on consoles
|
|
6
|
+
// without double buffering is flicker on every frame. This spinner overwrites
|
|
7
|
+
// the same line with `\r` + a fixed-width ASCII frame in ONE write, so the
|
|
8
|
+
// console never shows an intermediate empty frame.
|
|
9
|
+
const chalk = require('chalk');
|
|
10
|
+
|
|
11
|
+
const FRAMES = ['-', '\\', '|', '/'];
|
|
12
|
+
|
|
13
|
+
function miniSpinner(text, opts = {}) {
|
|
14
|
+
const stdout = opts.stdout || process.stdout;
|
|
15
|
+
const interval = opts.interval || 90;
|
|
16
|
+
let timer = null;
|
|
17
|
+
let frame = 0;
|
|
18
|
+
let current = text;
|
|
19
|
+
let active = false;
|
|
20
|
+
let startedAt = 0;
|
|
21
|
+
|
|
22
|
+
const paint = () => {
|
|
23
|
+
if (!active) return;
|
|
24
|
+
const elapsed = Math.floor((Date.now() - startedAt) / 1000);
|
|
25
|
+
// show elapsed time after 3s — heavy installs look "stuck" otherwise
|
|
26
|
+
const time = elapsed >= 3 ? chalk.dim(` [${elapsed}s]`) : '';
|
|
27
|
+
const line = `${FRAMES[frame % FRAMES.length]} ${current}${time}`;
|
|
28
|
+
stdout.write(`\r${line}`); // single write, same line — no erase, no flash
|
|
29
|
+
frame++;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const stopPaint = () => {
|
|
33
|
+
if (timer) { clearInterval(timer); timer = null; }
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const finish = (mark, msg, color) => {
|
|
37
|
+
stopPaint();
|
|
38
|
+
active = false;
|
|
39
|
+
stdout.write(`\r\x1b[2K${mark} ${msg || current}\n`);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
start(t) {
|
|
44
|
+
if (t) current = t;
|
|
45
|
+
active = true;
|
|
46
|
+
frame = 0;
|
|
47
|
+
startedAt = Date.now();
|
|
48
|
+
paint();
|
|
49
|
+
timer = setInterval(paint, interval);
|
|
50
|
+
// keep the process ticking until stopped (mirrors ora's behavior)
|
|
51
|
+
if (timer.unref) timer.unref();
|
|
52
|
+
return this;
|
|
53
|
+
},
|
|
54
|
+
text(t) { current = t; paint(); return this; },
|
|
55
|
+
succeed(msg) { finish(chalk.green('✓'), msg, 'green'); return this; },
|
|
56
|
+
fail(msg) { finish(chalk.red('✗'), msg, 'red'); return this; },
|
|
57
|
+
info(msg) { finish(chalk.yellow('i'), msg, 'yellow'); return this; },
|
|
58
|
+
stop() {
|
|
59
|
+
stopPaint();
|
|
60
|
+
active = false;
|
|
61
|
+
stdout.write('\r\x1b[2K');
|
|
62
|
+
return this;
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { miniSpinner };
|