@stats-forge/github-stats-forge-cli 0.0.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/LICENSE +21 -0
- package/README.md +119 -0
- package/build/cards.d.ts +47 -0
- package/build/cards.d.ts.map +1 -0
- package/build/cards.js +295 -0
- package/build/index.d.ts +3 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +192 -0
- package/build/prompts.d.ts +58 -0
- package/build/prompts.d.ts.map +1 -0
- package/build/prompts.js +125 -0
- package/build/query.d.ts +37 -0
- package/build/query.d.ts.map +1 -0
- package/build/query.js +55 -0
- package/build/saved-card.d.ts +39 -0
- package/build/saved-card.d.ts.map +1 -0
- package/build/saved-card.js +68 -0
- package/build/spinner.d.ts +24 -0
- package/build/spinner.d.ts.map +1 -0
- package/build/spinner.js +44 -0
- package/build/tokens.d.ts +35 -0
- package/build/tokens.d.ts.map +1 -0
- package/build/tokens.js +57 -0
- package/package.json +59 -0
- package/src/cards.ts +343 -0
- package/src/index.ts +243 -0
- package/src/prompts.ts +164 -0
- package/src/query.ts +70 -0
- package/src/saved-card.ts +106 -0
- package/src/spinner.ts +58 -0
- package/src/tokens.ts +71 -0
package/build/index.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { relative, resolve } from 'node:path';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
import { CardConfig } from '@stats-forge/github-stats-forge-core';
|
|
6
|
+
import { cards, findCard } from './cards.js';
|
|
7
|
+
import { askRequired, askSavePath, askToken, navigateOptions, pickCard } from './prompts.js';
|
|
8
|
+
import { defaultFileName, toQuery } from './query.js';
|
|
9
|
+
import { readSavedCard, savedCardExists, toAnswers, writeSavedCard } from './saved-card.js';
|
|
10
|
+
import { withSpinner } from './spinner.js';
|
|
11
|
+
import { DEFAULT_ENV_FILE, loadEnvFile, resolveTokens } from './tokens.js';
|
|
12
|
+
const HELP = `github-stats-forge — render a GitHub stats card to a local SVG
|
|
13
|
+
|
|
14
|
+
Usage
|
|
15
|
+
github-stats-forge [options]
|
|
16
|
+
|
|
17
|
+
Options
|
|
18
|
+
-c, --card <id> Skip the card prompt: ${cards.map((card) => card.id).join(', ')}
|
|
19
|
+
-o, --out <file> Where to write the card (default: named after the card)
|
|
20
|
+
--config <file> Options to load, and where "Save these options" writes
|
|
21
|
+
-g, --generate Render what --config holds and exit, without the menu
|
|
22
|
+
--pat <token> GitHub token; repeat for several
|
|
23
|
+
--env-file <file> Env file to read PAT_1, PAT_2, … from (default: ${DEFAULT_ENV_FILE})
|
|
24
|
+
-h, --help Show this
|
|
25
|
+
-v, --version Show the version
|
|
26
|
+
|
|
27
|
+
The token can also come from PAT_1 in the environment, or be typed when asked.
|
|
28
|
+
`;
|
|
29
|
+
/**
|
|
30
|
+
* @returns The flags this run was given.
|
|
31
|
+
*/
|
|
32
|
+
const readFlags = () => parseArgs({
|
|
33
|
+
options: {
|
|
34
|
+
card: { type: 'string', short: 'c' },
|
|
35
|
+
out: { type: 'string', short: 'o' },
|
|
36
|
+
config: { type: 'string' },
|
|
37
|
+
generate: { type: 'boolean', short: 'g', default: false },
|
|
38
|
+
pat: { type: 'string', multiple: true, default: [] },
|
|
39
|
+
'env-file': { type: 'string' },
|
|
40
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
41
|
+
version: { type: 'boolean', short: 'v', default: false },
|
|
42
|
+
},
|
|
43
|
+
allowPositionals: false,
|
|
44
|
+
}).values;
|
|
45
|
+
/**
|
|
46
|
+
* Renders a card and writes it next to wherever the run was started.
|
|
47
|
+
*
|
|
48
|
+
* @param card The card to render.
|
|
49
|
+
* @param query Its params.
|
|
50
|
+
* @param config Tokens the fetchers use.
|
|
51
|
+
* @param out Where to write it; named after the card when absent.
|
|
52
|
+
* @returns The file written, or the code that says why nothing was.
|
|
53
|
+
*/
|
|
54
|
+
const renderAndWrite = async (card, query, config, out) => {
|
|
55
|
+
const result = await withSpinner(`Rendering the ${card.id} card`, () => card.render(query, config));
|
|
56
|
+
if (result.status === 'error') {
|
|
57
|
+
const { code, message, secondaryMessage, param } = result.error;
|
|
58
|
+
process.stderr.write([
|
|
59
|
+
`Could not render the ${card.id} card.`,
|
|
60
|
+
` ${message}${secondaryMessage ? `: ${secondaryMessage}` : ''}`,
|
|
61
|
+
` code: ${code}${param ? `, param: ${param}` : ''}`,
|
|
62
|
+
result.retryable ? ' This one may work on a retry.' : '',
|
|
63
|
+
]
|
|
64
|
+
.filter(Boolean)
|
|
65
|
+
.join('\n') + '\n');
|
|
66
|
+
return { failed: code };
|
|
67
|
+
}
|
|
68
|
+
const file = resolve(process.cwd(), out ?? defaultFileName(card, query));
|
|
69
|
+
await writeFile(file, result.content, 'utf8');
|
|
70
|
+
const written = relative(process.cwd(), file);
|
|
71
|
+
process.stdout.write(`Wrote ${written}\n`);
|
|
72
|
+
return { written };
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Renders one card and writes it next to wherever the run was started.
|
|
76
|
+
*
|
|
77
|
+
* @returns Nothing; the process exits non-zero when the card could not be rendered.
|
|
78
|
+
*/
|
|
79
|
+
const main = async () => {
|
|
80
|
+
const flags = readFlags();
|
|
81
|
+
if (flags.help) {
|
|
82
|
+
process.stdout.write(HELP);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (flags.version) {
|
|
86
|
+
// Same relative position from `src/` and from `build/`.
|
|
87
|
+
const manifest = new URL('../package.json', import.meta.url);
|
|
88
|
+
const { version } = JSON.parse(await readFile(manifest, 'utf8'));
|
|
89
|
+
process.stdout.write(`${version}\n`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
// An explicit `--env-file` must exist; the default one is a convenience.
|
|
93
|
+
const envFile = flags['env-file'];
|
|
94
|
+
loadEnvFile(envFile ?? DEFAULT_ENV_FILE, envFile !== undefined);
|
|
95
|
+
// A saved file names its own card and carries its answers, so it skips both prompts.
|
|
96
|
+
const saved = flags.config !== undefined && savedCardExists(flags.config)
|
|
97
|
+
? await readSavedCard(flags.config)
|
|
98
|
+
: undefined;
|
|
99
|
+
if (flags.generate && !saved) {
|
|
100
|
+
throw new Error('--generate renders a saved card, so it needs --config pointing at one.');
|
|
101
|
+
}
|
|
102
|
+
/*
|
|
103
|
+
* Every other path asks something.
|
|
104
|
+
* Without a terminal the prompt would hang on a stdin that never answers,
|
|
105
|
+
* so say what to pass instead.
|
|
106
|
+
*/
|
|
107
|
+
if (!flags.generate && !process.stdin.isTTY) {
|
|
108
|
+
throw new Error('github-stats-forge asks questions, so it needs a terminal. Render a saved card instead: --config <file> --generate');
|
|
109
|
+
}
|
|
110
|
+
if (saved && flags.card !== undefined && flags.card !== saved.card.id) {
|
|
111
|
+
throw new Error(`${flags.config ?? ''} holds a ${saved.card.id} card, but --card asked for ${flags.card}.`);
|
|
112
|
+
}
|
|
113
|
+
const card = saved?.card ?? (flags.card === undefined ? await pickCard() : findCard(flags.card));
|
|
114
|
+
if (!card) {
|
|
115
|
+
throw new Error(`No card called "${flags.card ?? ''}". Try one of: ${cards
|
|
116
|
+
.map((known) => known.id)
|
|
117
|
+
.join(', ')}`);
|
|
118
|
+
}
|
|
119
|
+
let tokens = resolveTokens(flags.pat, process.env);
|
|
120
|
+
if (card.needsToken && tokens.length === 0 && flags.generate) {
|
|
121
|
+
throw new Error(`The ${card.id} card reads the GitHub API, so it needs a token. Pass --pat, or put PAT_1 in ${DEFAULT_ENV_FILE}.`);
|
|
122
|
+
}
|
|
123
|
+
if (card.needsToken && tokens.length === 0) {
|
|
124
|
+
const typed = await askToken();
|
|
125
|
+
if (!typed) {
|
|
126
|
+
throw new Error(`The ${card.id} card reads the GitHub API, so it needs a token. Pass --pat, or put PAT_1 in ${DEFAULT_ENV_FILE}.`);
|
|
127
|
+
}
|
|
128
|
+
tokens = [{ name: 'prompt', value: typed }];
|
|
129
|
+
}
|
|
130
|
+
const config = new CardConfig({ pats: tokens });
|
|
131
|
+
// `--generate` renders what the file holds and stops: no menu, nothing to answer.
|
|
132
|
+
if (saved && flags.generate) {
|
|
133
|
+
const outcome = await renderAndWrite(card, saved.params, config, flags.out);
|
|
134
|
+
if ('failed' in outcome) {
|
|
135
|
+
process.exitCode = 1;
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const menu = {
|
|
140
|
+
answers: saved ? toAnswers(card, saved.params) : await askRequired(card),
|
|
141
|
+
};
|
|
142
|
+
let savePath = flags.config;
|
|
143
|
+
/*
|
|
144
|
+
* The menu stays open after a render:
|
|
145
|
+
* a card is rarely right the first time, and the whole point of the option
|
|
146
|
+
* list is to change one thing and look again.
|
|
147
|
+
*/
|
|
148
|
+
let status;
|
|
149
|
+
let lastFailed = false;
|
|
150
|
+
for (;;) {
|
|
151
|
+
const action = await navigateOptions(card, menu, status);
|
|
152
|
+
if (action === 'quit') {
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
const query = toQuery(menu.answers);
|
|
156
|
+
if (action === 'save') {
|
|
157
|
+
const path = savePath ?? (await askSavePath(defaultFileName(card, query).replace(/\.svg$/, '.json')));
|
|
158
|
+
if (path) {
|
|
159
|
+
savePath = path;
|
|
160
|
+
const written = await writeSavedCard(path, card, query);
|
|
161
|
+
status = `saved ${relative(process.cwd(), written)} — load it again with --config`;
|
|
162
|
+
}
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const outcome = await renderAndWrite(card, query, config, flags.out);
|
|
166
|
+
if ('failed' in outcome) {
|
|
167
|
+
// Left on the menu, since a rejected param is one edit away from working.
|
|
168
|
+
status = `${outcome.failed} — fix it and generate again`;
|
|
169
|
+
lastFailed = true;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
status = `wrote ${outcome.written} — edit an option and generate again`;
|
|
173
|
+
lastFailed = false;
|
|
174
|
+
}
|
|
175
|
+
// Leaving straight after a failure still reports one.
|
|
176
|
+
if (lastFailed) {
|
|
177
|
+
process.exitCode = 1;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
try {
|
|
181
|
+
await main();
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
// A cancelled prompt is a normal way to leave, not a crash.
|
|
185
|
+
if (err instanceof Error && err.name === 'ExitPromptError') {
|
|
186
|
+
process.exitCode = 130;
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
190
|
+
process.exitCode = 1;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { CardKind, CardOption } from './cards.js';
|
|
2
|
+
import type { Answer } from './query.js';
|
|
3
|
+
/**
|
|
4
|
+
* @file The navigation itself.
|
|
5
|
+
*
|
|
6
|
+
* A card first, then its required params, then a menu of every other option:
|
|
7
|
+
* pick one, answer it, and land back on the menu with the answer beside it.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* @returns The card to render.
|
|
11
|
+
*/
|
|
12
|
+
export declare const pickCard: () => Promise<CardKind>;
|
|
13
|
+
/** How a trip through the option menu ended. */
|
|
14
|
+
export type MenuChoice = 'generate' | 'save' | 'quit';
|
|
15
|
+
/** What the menu carries between trips through it. */
|
|
16
|
+
export interface Menu {
|
|
17
|
+
/** Answers so far, edited in place. */
|
|
18
|
+
answers: Map<string, Answer>;
|
|
19
|
+
/**
|
|
20
|
+
* Where the cursor sat when the menu was last left.
|
|
21
|
+
* Reopening lands on it, so editing one option after another does not mean
|
|
22
|
+
* scrolling back down each time.
|
|
23
|
+
*/
|
|
24
|
+
cursor?: CardOption | MenuChoice | undefined;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Walks a card's options until the run asks for the card, or to leave.
|
|
28
|
+
*
|
|
29
|
+
* The menu is edited in place, so reopening it after a render keeps every answer
|
|
30
|
+
* and the cursor exactly where they were.
|
|
31
|
+
*
|
|
32
|
+
* @param card The card being built.
|
|
33
|
+
* @param menu Answers so far, and where the cursor sat.
|
|
34
|
+
* @param status What happened last time round, shown in the menu's own line.
|
|
35
|
+
* @returns Whether to render the card or to stop.
|
|
36
|
+
*/
|
|
37
|
+
export declare const navigateOptions: (card: CardKind, menu: Menu, status?: string) => Promise<MenuChoice>;
|
|
38
|
+
/**
|
|
39
|
+
* Asks for the params the card cannot render without.
|
|
40
|
+
*
|
|
41
|
+
* @param card The card being built.
|
|
42
|
+
* @returns The answers, one per required param.
|
|
43
|
+
*/
|
|
44
|
+
export declare const askRequired: (card: CardKind) => Promise<Map<string, Answer>>;
|
|
45
|
+
/**
|
|
46
|
+
* Asks where to write the options, when no `--config` said.
|
|
47
|
+
*
|
|
48
|
+
* @param suggestion The path offered by default.
|
|
49
|
+
* @returns The path, or `undefined` when the run changed its mind.
|
|
50
|
+
*/
|
|
51
|
+
export declare const askSavePath: (suggestion: string) => Promise<string | undefined>;
|
|
52
|
+
/**
|
|
53
|
+
* Asks for a token, when nothing supplied one.
|
|
54
|
+
*
|
|
55
|
+
* @returns The token, or `undefined` when the run declined to give one.
|
|
56
|
+
*/
|
|
57
|
+
export declare const askToken: () => Promise<string | undefined>;
|
|
58
|
+
//# sourceMappingURL=prompts.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"prompts.d.ts","sourceRoot":"","sources":["../src/prompts.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAEvD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AAGzC;;;;;GAKG;AAEH;;GAEG;AACH,eAAO,MAAM,QAAQ,QAAa,OAAO,CAAC,QAAQ,CAI9C,CAAC;AA+BL,gDAAgD;AAChD,MAAM,MAAM,UAAU,GAAG,UAAU,GAAG,MAAM,GAAG,MAAM,CAAC;AAEtD,sDAAsD;AACtD,MAAM,WAAW,IAAI;IACnB,uCAAuC;IACvC,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC7B;;;;OAIG;IACH,MAAM,CAAC,EAAE,UAAU,GAAG,UAAU,GAAG,SAAS,CAAC;CAC9C;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,eAAe,GAC1B,MAAM,QAAQ,EACd,MAAM,IAAI,EACV,SAAS,MAAM,KACd,OAAO,CAAC,UAAU,CAkCpB,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,WAAW,GAAU,MAAM,QAAQ,KAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAY7E,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,WAAW,GAAU,YAAY,MAAM,KAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAMhF,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,QAAQ,QAAa,OAAO,CAAC,MAAM,GAAG,SAAS,CAM3D,CAAC"}
|
package/build/prompts.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { confirm, input, password, select } from '@inquirer/prompts';
|
|
2
|
+
import { cards } from './cards.js';
|
|
3
|
+
import { describeAnswer } from './query.js';
|
|
4
|
+
/**
|
|
5
|
+
* @file The navigation itself.
|
|
6
|
+
*
|
|
7
|
+
* A card first, then its required params, then a menu of every other option:
|
|
8
|
+
* pick one, answer it, and land back on the menu with the answer beside it.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* @returns The card to render.
|
|
12
|
+
*/
|
|
13
|
+
export const pickCard = async () => select({
|
|
14
|
+
message: 'Which card?',
|
|
15
|
+
choices: cards.map((card) => ({ name: card.label, value: card })),
|
|
16
|
+
});
|
|
17
|
+
/**
|
|
18
|
+
* Asks for one option, seeded with whatever it already holds.
|
|
19
|
+
*
|
|
20
|
+
* @param option The option to ask for.
|
|
21
|
+
* @param current What it holds now.
|
|
22
|
+
* @returns The answer, or `undefined` when it was cleared.
|
|
23
|
+
*/
|
|
24
|
+
const askOption = async (option, current) => {
|
|
25
|
+
const message = option.hint ? `${option.label} (${option.hint})` : option.label;
|
|
26
|
+
if (option.kind === 'boolean') {
|
|
27
|
+
return confirm({ message, default: current === true });
|
|
28
|
+
}
|
|
29
|
+
if (option.kind === 'choice') {
|
|
30
|
+
const choices = [
|
|
31
|
+
{ name: '— leave unset —', value: undefined },
|
|
32
|
+
...(option.choices ?? []).map((value) => ({ name: value, value })),
|
|
33
|
+
];
|
|
34
|
+
return select({ message, choices, default: current });
|
|
35
|
+
}
|
|
36
|
+
const answer = await input({
|
|
37
|
+
message,
|
|
38
|
+
default: current === undefined ? undefined : String(current),
|
|
39
|
+
});
|
|
40
|
+
return answer.trim() === '' ? undefined : answer.trim();
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Walks a card's options until the run asks for the card, or to leave.
|
|
44
|
+
*
|
|
45
|
+
* The menu is edited in place, so reopening it after a render keeps every answer
|
|
46
|
+
* and the cursor exactly where they were.
|
|
47
|
+
*
|
|
48
|
+
* @param card The card being built.
|
|
49
|
+
* @param menu Answers so far, and where the cursor sat.
|
|
50
|
+
* @param status What happened last time round, shown in the menu's own line.
|
|
51
|
+
* @returns Whether to render the card or to stop.
|
|
52
|
+
*/
|
|
53
|
+
export const navigateOptions = async (card, menu, status) => {
|
|
54
|
+
// The label carries a description after an em dash; the menu wants the name.
|
|
55
|
+
const [name = card.id] = card.label.split(' — ');
|
|
56
|
+
for (;;) {
|
|
57
|
+
const choice = await select({
|
|
58
|
+
message: status ? `${name} — ${status}` : `${name} — set an option, or generate`,
|
|
59
|
+
pageSize: 15,
|
|
60
|
+
// Matched by reference against the values below, so the option objects work.
|
|
61
|
+
default: menu.cursor,
|
|
62
|
+
choices: [
|
|
63
|
+
{ name: 'Generate the card', value: 'generate' },
|
|
64
|
+
{ name: 'Save these options', value: 'save' },
|
|
65
|
+
{ name: 'Quit', value: 'quit' },
|
|
66
|
+
...card.options.map((option) => ({
|
|
67
|
+
name: `${option.label.padEnd(38)} ${describeAnswer(option, menu.answers.get(option.name))}`,
|
|
68
|
+
value: option,
|
|
69
|
+
})),
|
|
70
|
+
],
|
|
71
|
+
});
|
|
72
|
+
menu.cursor = choice;
|
|
73
|
+
if (choice === 'generate' || choice === 'save' || choice === 'quit') {
|
|
74
|
+
return choice;
|
|
75
|
+
}
|
|
76
|
+
const answer = await askOption(choice, menu.answers.get(choice.name));
|
|
77
|
+
if (answer === undefined) {
|
|
78
|
+
menu.answers.delete(choice.name);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
menu.answers.set(choice.name, answer);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* Asks for the params the card cannot render without.
|
|
87
|
+
*
|
|
88
|
+
* @param card The card being built.
|
|
89
|
+
* @returns The answers, one per required param.
|
|
90
|
+
*/
|
|
91
|
+
export const askRequired = async (card) => {
|
|
92
|
+
const answers = new Map();
|
|
93
|
+
for (const option of card.required) {
|
|
94
|
+
answers.set(option.name, await input({
|
|
95
|
+
message: option.label,
|
|
96
|
+
validate: (value) => value.trim() !== '' || 'Required',
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
return answers;
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* Asks where to write the options, when no `--config` said.
|
|
103
|
+
*
|
|
104
|
+
* @param suggestion The path offered by default.
|
|
105
|
+
* @returns The path, or `undefined` when the run changed its mind.
|
|
106
|
+
*/
|
|
107
|
+
export const askSavePath = async (suggestion) => {
|
|
108
|
+
const answer = await input({
|
|
109
|
+
message: 'Save the options to',
|
|
110
|
+
default: suggestion,
|
|
111
|
+
});
|
|
112
|
+
return answer.trim() === '' ? undefined : answer.trim();
|
|
113
|
+
};
|
|
114
|
+
/**
|
|
115
|
+
* Asks for a token, when nothing supplied one.
|
|
116
|
+
*
|
|
117
|
+
* @returns The token, or `undefined` when the run declined to give one.
|
|
118
|
+
*/
|
|
119
|
+
export const askToken = async () => {
|
|
120
|
+
const value = await password({
|
|
121
|
+
message: 'GitHub personal access token (input hidden)',
|
|
122
|
+
mask: '*',
|
|
123
|
+
});
|
|
124
|
+
return value.trim() === '' ? undefined : value.trim();
|
|
125
|
+
};
|
package/build/query.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { CardKind, CardOption } from './cards.js';
|
|
2
|
+
/**
|
|
3
|
+
* @file Answers in, query params out.
|
|
4
|
+
*
|
|
5
|
+
* The core handlers take exactly what a query string carries — strings — so an
|
|
6
|
+
* answer becomes one here, and an unanswered option is simply absent.
|
|
7
|
+
*/
|
|
8
|
+
/** What a prompt answered, before it becomes a query param. */
|
|
9
|
+
export type Answer = string | number | boolean | Array<string> | undefined;
|
|
10
|
+
/**
|
|
11
|
+
* @param value What the prompt returned.
|
|
12
|
+
* @returns The query string form, or `undefined` when there is nothing to send.
|
|
13
|
+
*/
|
|
14
|
+
export declare const toParam: (value: Answer) => string | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* @param answers What each option was answered with.
|
|
17
|
+
* @returns The query the card handler is called with.
|
|
18
|
+
*/
|
|
19
|
+
export declare const toQuery: (answers: ReadonlyMap<string, Answer>) => Record<string, string>;
|
|
20
|
+
/**
|
|
21
|
+
* How an answer reads back in the option menu.
|
|
22
|
+
*
|
|
23
|
+
* @param option The option it answers.
|
|
24
|
+
* @param value What it was answered with.
|
|
25
|
+
* @returns The value as the menu shows it.
|
|
26
|
+
*/
|
|
27
|
+
export declare const describeAnswer: (option: CardOption, value: Answer) => string;
|
|
28
|
+
/**
|
|
29
|
+
* The file a card is written to when `--out` is not given.
|
|
30
|
+
* Named after the card and whoever it is about, so a directory of them stays readable.
|
|
31
|
+
*
|
|
32
|
+
* @param card The card being rendered.
|
|
33
|
+
* @param query The answers it was rendered from.
|
|
34
|
+
* @returns A file name, ending in `.svg`.
|
|
35
|
+
*/
|
|
36
|
+
export declare const defaultFileName: (card: CardKind, query: Record<string, string>) => string;
|
|
37
|
+
//# sourceMappingURL=query.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"query.d.ts","sourceRoot":"","sources":["../src/query.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAEvD;;;;;GAKG;AAEH,+DAA+D;AAC/D,MAAM,MAAM,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,KAAK,CAAC,MAAM,CAAC,GAAG,SAAS,CAAC;AAE3E;;;GAGG;AACH,eAAO,MAAM,OAAO,GAAI,OAAO,MAAM,KAAG,MAAM,GAAG,SAQhD,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,OAAO,GAAI,SAAS,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,KAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CASnF,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,GAAI,QAAQ,UAAU,EAAE,OAAO,MAAM,KAAG,MAMlE,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,eAAe,GAAI,MAAM,QAAQ,EAAE,OAAO,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAG,MAK/E,CAAC"}
|
package/build/query.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param value What the prompt returned.
|
|
3
|
+
* @returns The query string form, or `undefined` when there is nothing to send.
|
|
4
|
+
*/
|
|
5
|
+
export const toParam = (value) => {
|
|
6
|
+
if (value === undefined || value === '') {
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
if (Array.isArray(value)) {
|
|
10
|
+
return value.length > 0 ? value.join(',') : undefined;
|
|
11
|
+
}
|
|
12
|
+
return String(value);
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* @param answers What each option was answered with.
|
|
16
|
+
* @returns The query the card handler is called with.
|
|
17
|
+
*/
|
|
18
|
+
export const toQuery = (answers) => {
|
|
19
|
+
const query = {};
|
|
20
|
+
for (const [name, value] of answers) {
|
|
21
|
+
const param = toParam(value);
|
|
22
|
+
if (param !== undefined) {
|
|
23
|
+
query[name] = param;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return query;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* How an answer reads back in the option menu.
|
|
30
|
+
*
|
|
31
|
+
* @param option The option it answers.
|
|
32
|
+
* @param value What it was answered with.
|
|
33
|
+
* @returns The value as the menu shows it.
|
|
34
|
+
*/
|
|
35
|
+
export const describeAnswer = (option, value) => {
|
|
36
|
+
const param = toParam(value);
|
|
37
|
+
if (param === undefined) {
|
|
38
|
+
return '—';
|
|
39
|
+
}
|
|
40
|
+
return option.kind === 'boolean' ? (value === true ? 'yes' : 'no') : param;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* The file a card is written to when `--out` is not given.
|
|
44
|
+
* Named after the card and whoever it is about, so a directory of them stays readable.
|
|
45
|
+
*
|
|
46
|
+
* @param card The card being rendered.
|
|
47
|
+
* @param query The answers it was rendered from.
|
|
48
|
+
* @returns A file name, ending in `.svg`.
|
|
49
|
+
*/
|
|
50
|
+
export const defaultFileName = (card, query) => {
|
|
51
|
+
const subject = query['username'] ?? query['id'] ?? 'card';
|
|
52
|
+
const repo = query['repo'];
|
|
53
|
+
const parts = [card.id, subject, repo].filter(Boolean).join('-');
|
|
54
|
+
return `${parts.replace(/[^\w.-]/g, '-')}.svg`;
|
|
55
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { CardKind } from './cards.js';
|
|
2
|
+
import type { Answer } from './query.js';
|
|
3
|
+
/**
|
|
4
|
+
* @param path File to look for, relative to the working directory.
|
|
5
|
+
* @returns Whether there is something there to load.
|
|
6
|
+
*/
|
|
7
|
+
export declare const savedCardExists: (path: string) => boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Reads a card back off disk.
|
|
10
|
+
*
|
|
11
|
+
* @param path File to read, relative to the working directory.
|
|
12
|
+
* @returns The card it names, and its params.
|
|
13
|
+
* @throws {Error} When the file is not a card this version can render.
|
|
14
|
+
*/
|
|
15
|
+
export declare const readSavedCard: (path: string) => Promise<{
|
|
16
|
+
card: CardKind;
|
|
17
|
+
params: Record<string, string>;
|
|
18
|
+
}>;
|
|
19
|
+
/**
|
|
20
|
+
* Writes a card down, so the same one can be rendered again later.
|
|
21
|
+
*
|
|
22
|
+
* @param path File to write, relative to the working directory.
|
|
23
|
+
* @param card The card being rendered.
|
|
24
|
+
* @param params Its params, as they reach the endpoint.
|
|
25
|
+
* @returns The path written to.
|
|
26
|
+
*/
|
|
27
|
+
export declare const writeSavedCard: (path: string, card: CardKind, params: Record<string, string>) => Promise<string>;
|
|
28
|
+
/**
|
|
29
|
+
* Turns saved params back into answers the menu can show and edit.
|
|
30
|
+
*
|
|
31
|
+
* Everything on a query string is a string;
|
|
32
|
+
* a boolean option becomes one again so its prompt opens on the right answer.
|
|
33
|
+
*
|
|
34
|
+
* @param card The card the params belong to.
|
|
35
|
+
* @param params The saved params.
|
|
36
|
+
* @returns The answers, ready for the menu.
|
|
37
|
+
*/
|
|
38
|
+
export declare const toAnswers: (card: CardKind, params: Record<string, string>) => Map<string, Answer>;
|
|
39
|
+
//# sourceMappingURL=saved-card.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"saved-card.d.ts","sourceRoot":"","sources":["../src/saved-card.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAE3C,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AAiBzC;;;GAGG;AACH,eAAO,MAAM,eAAe,GAAI,MAAM,MAAM,KAAG,OAAmD,CAAC;AAEnG;;;;;;GAMG;AACH,eAAO,MAAM,aAAa,GACxB,MAAM,MAAM,KACX,OAAO,CAAC;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,CAyB5D,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc,GACzB,MAAM,MAAM,EACZ,MAAM,QAAQ,EACd,QAAQ,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAC7B,OAAO,CAAC,MAAM,CAKhB,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,SAAS,GAAI,MAAM,QAAQ,EAAE,QAAQ,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAW5F,CAAC"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { findCard } from './cards.js';
|
|
5
|
+
/**
|
|
6
|
+
* @param path File to look for, relative to the working directory.
|
|
7
|
+
* @returns Whether there is something there to load.
|
|
8
|
+
*/
|
|
9
|
+
export const savedCardExists = (path) => existsSync(resolve(process.cwd(), path));
|
|
10
|
+
/**
|
|
11
|
+
* Reads a card back off disk.
|
|
12
|
+
*
|
|
13
|
+
* @param path File to read, relative to the working directory.
|
|
14
|
+
* @returns The card it names, and its params.
|
|
15
|
+
* @throws {Error} When the file is not a card this version can render.
|
|
16
|
+
*/
|
|
17
|
+
export const readSavedCard = async (path) => {
|
|
18
|
+
const file = resolve(process.cwd(), path);
|
|
19
|
+
let parsed;
|
|
20
|
+
try {
|
|
21
|
+
parsed = JSON.parse(await readFile(file, 'utf8'));
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
throw new Error(`${file} is not readable as JSON`, { cause: err });
|
|
25
|
+
}
|
|
26
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
27
|
+
throw new Error(`${file} does not hold a saved card`);
|
|
28
|
+
}
|
|
29
|
+
const { card: id, params } = parsed;
|
|
30
|
+
const card = typeof id === 'string' ? findCard(id) : undefined;
|
|
31
|
+
if (!card) {
|
|
32
|
+
throw new Error(`${file} names no card this version renders: ${id ?? '(nothing)'}`);
|
|
33
|
+
}
|
|
34
|
+
// A param that is not a string could not have come off a query string.
|
|
35
|
+
const entries = Object.entries(params ?? {}).filter((entry) => typeof entry[1] === 'string');
|
|
36
|
+
return { card, params: Object.fromEntries(entries) };
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Writes a card down, so the same one can be rendered again later.
|
|
40
|
+
*
|
|
41
|
+
* @param path File to write, relative to the working directory.
|
|
42
|
+
* @param card The card being rendered.
|
|
43
|
+
* @param params Its params, as they reach the endpoint.
|
|
44
|
+
* @returns The path written to.
|
|
45
|
+
*/
|
|
46
|
+
export const writeSavedCard = async (path, card, params) => {
|
|
47
|
+
const file = resolve(process.cwd(), path);
|
|
48
|
+
const saved = { card: card.id, params };
|
|
49
|
+
await writeFile(file, `${JSON.stringify(saved, null, 2)}\n`, 'utf8');
|
|
50
|
+
return file;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Turns saved params back into answers the menu can show and edit.
|
|
54
|
+
*
|
|
55
|
+
* Everything on a query string is a string;
|
|
56
|
+
* a boolean option becomes one again so its prompt opens on the right answer.
|
|
57
|
+
*
|
|
58
|
+
* @param card The card the params belong to.
|
|
59
|
+
* @param params The saved params.
|
|
60
|
+
* @returns The answers, ready for the menu.
|
|
61
|
+
*/
|
|
62
|
+
export const toAnswers = (card, params) => {
|
|
63
|
+
const kinds = new Map([...card.required, ...card.options].map((option) => [option.name, option.kind]));
|
|
64
|
+
return new Map(Object.entries(params).map(([name, value]) => [
|
|
65
|
+
name,
|
|
66
|
+
kinds.get(name) === 'boolean' ? value === 'true' : value,
|
|
67
|
+
]));
|
|
68
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The wait between the last prompt and the file.
|
|
3
|
+
*
|
|
4
|
+
* Rendering a card means fetching from GitHub, which takes long enough that a
|
|
5
|
+
* still terminal reads as a hung one.
|
|
6
|
+
*/
|
|
7
|
+
/** What the spinner writes to; `process.stderr`, or a fake in a test. */
|
|
8
|
+
export interface SpinnerStream {
|
|
9
|
+
isTTY?: boolean | undefined;
|
|
10
|
+
write: (chunk: string) => unknown;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Runs `work`, spinning until it settles.
|
|
14
|
+
*
|
|
15
|
+
* Written to stderr, so stdout carries only the result.
|
|
16
|
+
* Without a TTY — a pipe, a CI log — the label is printed once and nothing animates.
|
|
17
|
+
*
|
|
18
|
+
* @param label What the wait is for.
|
|
19
|
+
* @param work The wait itself.
|
|
20
|
+
* @param stream Where the spinner is drawn.
|
|
21
|
+
* @returns Whatever `work` answered with.
|
|
22
|
+
*/
|
|
23
|
+
export declare const withSpinner: <T>(label: string, work: () => Promise<T>, stream?: SpinnerStream) => Promise<T>;
|
|
24
|
+
//# sourceMappingURL=spinner.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"spinner.d.ts","sourceRoot":"","sources":["../src/spinner.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAWH,yEAAyE;AACzE,MAAM,WAAW,aAAa;IAC5B,KAAK,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC5B,KAAK,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC;CACnC;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,WAAW,GAAU,CAAC,EACjC,OAAO,MAAM,EACb,MAAM,MAAM,OAAO,CAAC,CAAC,CAAC,EACtB,SAAQ,aAA8B,KACrC,OAAO,CAAC,CAAC,CAoBX,CAAC"}
|
package/build/spinner.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The wait between the last prompt and the file.
|
|
3
|
+
*
|
|
4
|
+
* Rendering a card means fetching from GitHub, which takes long enough that a
|
|
5
|
+
* still terminal reads as a hung one.
|
|
6
|
+
*/
|
|
7
|
+
/** Braille frames: one cell wide, so the line never reflows. */
|
|
8
|
+
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
9
|
+
const INTERVAL_MS = 80;
|
|
10
|
+
const HIDE_CURSOR = '\u001b[?25l';
|
|
11
|
+
const SHOW_CURSOR = '\u001b[?25h';
|
|
12
|
+
/** Return to the start of the line and wipe what was on it. */
|
|
13
|
+
const CLEAR_LINE = '\r\u001b[K';
|
|
14
|
+
/**
|
|
15
|
+
* Runs `work`, spinning until it settles.
|
|
16
|
+
*
|
|
17
|
+
* Written to stderr, so stdout carries only the result.
|
|
18
|
+
* Without a TTY — a pipe, a CI log — the label is printed once and nothing animates.
|
|
19
|
+
*
|
|
20
|
+
* @param label What the wait is for.
|
|
21
|
+
* @param work The wait itself.
|
|
22
|
+
* @param stream Where the spinner is drawn.
|
|
23
|
+
* @returns Whatever `work` answered with.
|
|
24
|
+
*/
|
|
25
|
+
export const withSpinner = async (label, work, stream = process.stderr) => {
|
|
26
|
+
if (!stream.isTTY) {
|
|
27
|
+
stream.write(`${label}…\n`);
|
|
28
|
+
return work();
|
|
29
|
+
}
|
|
30
|
+
let frame = 0;
|
|
31
|
+
stream.write(HIDE_CURSOR);
|
|
32
|
+
const tick = setInterval(() => {
|
|
33
|
+
stream.write(`${CLEAR_LINE}${FRAMES[frame % FRAMES.length] ?? ''} ${label}`);
|
|
34
|
+
frame += 1;
|
|
35
|
+
}, INTERVAL_MS);
|
|
36
|
+
try {
|
|
37
|
+
return await work();
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
clearInterval(tick);
|
|
41
|
+
// The line goes back to the shell as it was found, whichever way this ended.
|
|
42
|
+
stream.write(`${CLEAR_LINE}${SHOW_CURSOR}`);
|
|
43
|
+
}
|
|
44
|
+
};
|