@stats-forge/github-stats-forge-cli 0.0.2 → 0.2.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/README.md +4 -4
- package/build/cards.d.ts +3 -8
- package/build/cards.d.ts.map +1 -1
- package/build/cards.js +74 -25
- package/build/index.js +9 -12
- package/build/prompts.d.ts +5 -12
- package/build/prompts.d.ts.map +1 -1
- package/build/prompts.js +16 -15
- package/build/query.d.ts +2 -8
- package/build/query.d.ts.map +1 -1
- package/build/query.js +3 -9
- package/build/saved-card.d.ts +10 -15
- package/build/saved-card.d.ts.map +1 -1
- package/build/saved-card.js +31 -23
- package/build/spinner.d.ts +0 -3
- package/build/spinner.d.ts.map +1 -1
- package/build/spinner.js +3 -6
- package/build/tokens.d.ts +5 -8
- package/build/tokens.d.ts.map +1 -1
- package/build/tokens.js +5 -8
- package/package.json +8 -8
- package/src/cards.ts +86 -32
- package/src/index.ts +30 -21
- package/src/prompts.ts +21 -19
- package/src/query.ts +4 -10
- package/src/saved-card.ts +40 -33
- package/src/spinner.ts +3 -6
- package/src/tokens.ts +6 -9
package/src/prompts.ts
CHANGED
|
@@ -1,21 +1,19 @@
|
|
|
1
|
-
import { confirm, input, password, select } from '@inquirer/prompts';
|
|
1
|
+
import { checkbox, confirm, input, password, select } from '@inquirer/prompts';
|
|
2
2
|
|
|
3
|
-
import type { CardKind, CardOption } from './cards.
|
|
4
|
-
import { cards } from './cards.
|
|
5
|
-
import type { Answer } from './query.
|
|
6
|
-
import { describeAnswer } from './query.
|
|
3
|
+
import type { CardKind, CardOption } from './cards.ts';
|
|
4
|
+
import { cards } from './cards.ts';
|
|
5
|
+
import type { Answer } from './query.ts';
|
|
6
|
+
import { describeAnswer } from './query.ts';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* @file The navigation itself.
|
|
10
10
|
*
|
|
11
|
-
* A card first, then its required
|
|
11
|
+
* A card first, then its required options, then a menu of every other option:
|
|
12
12
|
* pick one, answer it, and land back on the menu with the answer beside it.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
/**
|
|
16
|
-
|
|
17
|
-
*/
|
|
18
|
-
export const pickCard = async (): Promise<CardKind> =>
|
|
15
|
+
/** @returns The card to render. */
|
|
16
|
+
export const pickCard = (): Promise<CardKind> =>
|
|
19
17
|
select({
|
|
20
18
|
message: 'Which card?',
|
|
21
19
|
choices: cards.map((card) => ({ name: card.label, value: card })),
|
|
@@ -24,8 +22,6 @@ export const pickCard = async (): Promise<CardKind> =>
|
|
|
24
22
|
/**
|
|
25
23
|
* Asks for one option, seeded with whatever it already holds.
|
|
26
24
|
*
|
|
27
|
-
* @param option The option to ask for.
|
|
28
|
-
* @param current What it holds now.
|
|
29
25
|
* @returns The answer, or `undefined` when it was cleared.
|
|
30
26
|
*/
|
|
31
27
|
const askOption = async (option: CardOption, current: Answer): Promise<Answer> => {
|
|
@@ -35,6 +31,16 @@ const askOption = async (option: CardOption, current: Answer): Promise<Answer> =
|
|
|
35
31
|
return confirm({ message, default: current === true });
|
|
36
32
|
}
|
|
37
33
|
|
|
34
|
+
if (option.kind === 'list' && option.choices) {
|
|
35
|
+
const chosen = new Set(Array.isArray(current) ? current : []);
|
|
36
|
+
const picked = await checkbox({
|
|
37
|
+
message,
|
|
38
|
+
pageSize: 15,
|
|
39
|
+
choices: option.choices.map((value) => ({ value, checked: chosen.has(value) })),
|
|
40
|
+
});
|
|
41
|
+
return picked.length > 0 ? picked : undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
38
44
|
if (option.kind === 'choice') {
|
|
39
45
|
const choices = [
|
|
40
46
|
{ name: '— leave unset —', value: undefined as Answer },
|
|
@@ -71,9 +77,6 @@ export interface Menu {
|
|
|
71
77
|
* The menu is edited in place, so reopening it after a render keeps every answer
|
|
72
78
|
* and the cursor exactly where they were.
|
|
73
79
|
*
|
|
74
|
-
* @param card The card being built.
|
|
75
|
-
* @param menu Answers so far, and where the cursor sat.
|
|
76
|
-
* @param status What happened last time round, shown in the menu's own line.
|
|
77
80
|
* @returns Whether to render the card or to stop.
|
|
78
81
|
*/
|
|
79
82
|
export const navigateOptions = async (
|
|
@@ -89,7 +92,8 @@ export const navigateOptions = async (
|
|
|
89
92
|
message: status ? `${name} — ${status}` : `${name} — set an option, or generate`,
|
|
90
93
|
pageSize: 15,
|
|
91
94
|
// Matched by reference against the values below, so the option objects work.
|
|
92
|
-
default
|
|
95
|
+
// `default` does not accept an explicit undefined, so an unset cursor omits it.
|
|
96
|
+
...(menu.cursor !== undefined && { default: menu.cursor }),
|
|
93
97
|
choices: [
|
|
94
98
|
{ name: 'Generate the card', value: 'generate' as const },
|
|
95
99
|
{ name: 'Save these options', value: 'save' as const },
|
|
@@ -117,9 +121,8 @@ export const navigateOptions = async (
|
|
|
117
121
|
};
|
|
118
122
|
|
|
119
123
|
/**
|
|
120
|
-
* Asks for the
|
|
124
|
+
* Asks for the options the card cannot render without.
|
|
121
125
|
*
|
|
122
|
-
* @param card The card being built.
|
|
123
126
|
* @returns The answers, one per required param.
|
|
124
127
|
*/
|
|
125
128
|
export const askRequired = async (card: CardKind): Promise<Map<string, Answer>> => {
|
|
@@ -139,7 +142,6 @@ export const askRequired = async (card: CardKind): Promise<Map<string, Answer>>
|
|
|
139
142
|
/**
|
|
140
143
|
* Asks where to write the options, when no `--config` said.
|
|
141
144
|
*
|
|
142
|
-
* @param suggestion The path offered by default.
|
|
143
145
|
* @returns The path, or `undefined` when the run changed its mind.
|
|
144
146
|
*/
|
|
145
147
|
export const askSavePath = async (suggestion: string): Promise<string | undefined> => {
|
package/src/query.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CardKind, CardOption } from './cards.
|
|
1
|
+
import type { CardKind, CardOption } from './cards.ts';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* @file Answers in, query params out.
|
|
@@ -11,7 +11,6 @@ import type { CardKind, CardOption } from './cards.js';
|
|
|
11
11
|
export type Answer = string | number | boolean | Array<string> | undefined;
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
* @param value What the prompt returned.
|
|
15
14
|
* @returns The query string form, or `undefined` when there is nothing to send.
|
|
16
15
|
*/
|
|
17
16
|
export const toParam = (value: Answer): string | undefined => {
|
|
@@ -25,7 +24,6 @@ export const toParam = (value: Answer): string | undefined => {
|
|
|
25
24
|
};
|
|
26
25
|
|
|
27
26
|
/**
|
|
28
|
-
* @param answers What each option was answered with.
|
|
29
27
|
* @returns The query the card handler is called with.
|
|
30
28
|
*/
|
|
31
29
|
export const toQuery = (answers: ReadonlyMap<string, Answer>): Record<string, string> => {
|
|
@@ -42,8 +40,6 @@ export const toQuery = (answers: ReadonlyMap<string, Answer>): Record<string, st
|
|
|
42
40
|
/**
|
|
43
41
|
* How an answer reads back in the option menu.
|
|
44
42
|
*
|
|
45
|
-
* @param option The option it answers.
|
|
46
|
-
* @param value What it was answered with.
|
|
47
43
|
* @returns The value as the menu shows it.
|
|
48
44
|
*/
|
|
49
45
|
export const describeAnswer = (option: CardOption, value: Answer): string => {
|
|
@@ -55,16 +51,14 @@ export const describeAnswer = (option: CardOption, value: Answer): string => {
|
|
|
55
51
|
};
|
|
56
52
|
|
|
57
53
|
/**
|
|
58
|
-
* The file a card is written to when `--out` is not given.
|
|
54
|
+
* The `.svg` file a card is written to when `--out` is not given.
|
|
59
55
|
* Named after the card and whoever it is about, so a directory of them stays readable.
|
|
60
56
|
*
|
|
61
|
-
* @param card The card being rendered.
|
|
62
|
-
* @param query The answers it was rendered from.
|
|
63
57
|
* @returns A file name, ending in `.svg`.
|
|
64
58
|
*/
|
|
65
59
|
export const defaultFileName = (card: CardKind, query: Record<string, string>): string => {
|
|
66
60
|
const subject = query['username'] ?? query['id'] ?? 'card';
|
|
67
|
-
const repo = query
|
|
61
|
+
const { repo } = query;
|
|
68
62
|
const parts = [card.id, subject, repo].filter(Boolean).join('-');
|
|
69
|
-
return `${parts.
|
|
63
|
+
return `${parts.replaceAll(/[^\w.-]/g, '-')}.svg`;
|
|
70
64
|
};
|
package/src/saved-card.ts
CHANGED
|
@@ -2,27 +2,26 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { resolve } from 'node:path';
|
|
4
4
|
|
|
5
|
-
import type { CardKind } from './cards.
|
|
6
|
-
import { findCard } from './cards.
|
|
7
|
-
import type { Answer } from './query.
|
|
5
|
+
import type { CardKind } from './cards.ts';
|
|
6
|
+
import { findCard } from './cards.ts';
|
|
7
|
+
import type { Answer } from './query.ts';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* @file A card, written down.
|
|
11
11
|
*
|
|
12
|
-
* The file holds what a query string would hold — the card and its
|
|
12
|
+
* The file holds what a query string would hold — the card and its options as
|
|
13
13
|
* strings — so it reads like the URL it stands for, and can be edited by hand.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
/** A card and the answers it was rendered from: the shape of the file. */
|
|
17
17
|
interface SavedCard {
|
|
18
|
-
/** Which card
|
|
18
|
+
/** Which card, by the id the catalog gives it. */
|
|
19
19
|
card: string;
|
|
20
|
-
/** The
|
|
21
|
-
|
|
20
|
+
/** The options, exactly as they reach the endpoint. */
|
|
21
|
+
options: Record<string, string>;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
* @param path File to look for, relative to the working directory.
|
|
26
25
|
* @returns Whether there is something there to load.
|
|
27
26
|
*/
|
|
28
27
|
export const savedCardExists = (path: string): boolean => existsSync(resolve(process.cwd(), path));
|
|
@@ -30,77 +29,85 @@ export const savedCardExists = (path: string): boolean => existsSync(resolve(pro
|
|
|
30
29
|
/**
|
|
31
30
|
* Reads a card back off disk.
|
|
32
31
|
*
|
|
33
|
-
* @param path File to read, relative to the working directory.
|
|
34
|
-
* @returns The card it names, and its params.
|
|
35
32
|
* @throws {Error} When the file is not a card this version can render.
|
|
33
|
+
*
|
|
34
|
+
* @returns The card it names, and its options.
|
|
36
35
|
*/
|
|
37
36
|
export const readSavedCard = async (
|
|
38
37
|
path: string,
|
|
39
|
-
): Promise<{ card: CardKind;
|
|
38
|
+
): Promise<{ card: CardKind; options: Record<string, string> }> => {
|
|
40
39
|
const file = resolve(process.cwd(), path);
|
|
41
40
|
let parsed: unknown;
|
|
42
41
|
try {
|
|
43
42
|
parsed = JSON.parse(await readFile(file, 'utf8'));
|
|
44
|
-
} catch (
|
|
45
|
-
throw new Error(`${file} is not readable as JSON`, { cause:
|
|
43
|
+
} catch (error) {
|
|
44
|
+
throw new Error(`${file} is not readable as JSON`, { cause: error });
|
|
46
45
|
}
|
|
47
46
|
|
|
48
47
|
if (typeof parsed !== 'object' || parsed === null) {
|
|
49
48
|
throw new Error(`${file} does not hold a saved card`);
|
|
50
49
|
}
|
|
51
50
|
|
|
52
|
-
const { card: id,
|
|
51
|
+
const { card: id, options } = parsed as Partial<SavedCard>;
|
|
53
52
|
const card = typeof id === 'string' ? findCard(id) : undefined;
|
|
54
53
|
if (!card) {
|
|
55
54
|
throw new Error(`${file} names no card this version renders: ${id ?? '(nothing)'}`);
|
|
56
55
|
}
|
|
57
56
|
|
|
58
|
-
//
|
|
59
|
-
const entries = Object.entries(
|
|
57
|
+
// An option that is not a string could not have come off a query string.
|
|
58
|
+
const entries = Object.entries(options ?? {}).filter(
|
|
60
59
|
(entry): entry is [string, string] => typeof entry[1] === 'string',
|
|
61
60
|
);
|
|
62
61
|
|
|
63
|
-
return { card,
|
|
62
|
+
return { card, options: Object.fromEntries(entries) };
|
|
64
63
|
};
|
|
65
64
|
|
|
66
65
|
/**
|
|
67
66
|
* Writes a card down, so the same one can be rendered again later.
|
|
68
67
|
*
|
|
69
|
-
* @param path File to write, relative to the working directory.
|
|
70
|
-
* @param card The card being rendered.
|
|
71
|
-
* @param params Its params, as they reach the endpoint.
|
|
72
68
|
* @returns The path written to.
|
|
73
69
|
*/
|
|
74
70
|
export const writeSavedCard = async (
|
|
75
71
|
path: string,
|
|
76
72
|
card: CardKind,
|
|
77
|
-
|
|
73
|
+
options: Record<string, string>,
|
|
78
74
|
): Promise<string> => {
|
|
79
75
|
const file = resolve(process.cwd(), path);
|
|
80
|
-
const saved: SavedCard = { card: card.id,
|
|
76
|
+
const saved: SavedCard = { card: card.id, options };
|
|
81
77
|
await writeFile(file, `${JSON.stringify(saved, null, 2)}\n`, 'utf8');
|
|
82
78
|
return file;
|
|
83
79
|
};
|
|
84
80
|
|
|
85
81
|
/**
|
|
86
|
-
* Turns saved
|
|
82
|
+
* Turns saved options back into answers the menu can show and edit.
|
|
87
83
|
*
|
|
88
84
|
* Everything on a query string is a string;
|
|
89
|
-
* a boolean
|
|
85
|
+
* a boolean becomes one again, and a list splits back into its values,
|
|
86
|
+
* so each prompt opens on the answer it was saved with.
|
|
90
87
|
*
|
|
91
|
-
* @param card The card the params belong to.
|
|
92
|
-
* @param params The saved params.
|
|
93
88
|
* @returns The answers, ready for the menu.
|
|
94
89
|
*/
|
|
95
|
-
export const toAnswers = (card: CardKind,
|
|
90
|
+
export const toAnswers = (card: CardKind, options: Record<string, string>): Map<string, Answer> => {
|
|
96
91
|
const kinds = new Map(
|
|
97
92
|
[...card.required, ...card.options].map((option) => [option.name, option.kind]),
|
|
98
93
|
);
|
|
99
94
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
95
|
+
const toAnswer = (name: string, value: string): Answer => {
|
|
96
|
+
switch (kinds.get(name)) {
|
|
97
|
+
case 'boolean': {
|
|
98
|
+
return value === 'true';
|
|
99
|
+
}
|
|
100
|
+
case 'list': {
|
|
101
|
+
return value
|
|
102
|
+
.split(',')
|
|
103
|
+
.map((item) => item.trim())
|
|
104
|
+
.filter(Boolean);
|
|
105
|
+
}
|
|
106
|
+
default: {
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
return new Map(Object.entries(options).map(([name, value]) => [name, toAnswer(name, value)]));
|
|
106
113
|
};
|
package/src/spinner.ts
CHANGED
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
10
10
|
const INTERVAL_MS = 80;
|
|
11
11
|
|
|
12
|
-
const HIDE_CURSOR = '\
|
|
13
|
-
const SHOW_CURSOR = '\
|
|
12
|
+
const HIDE_CURSOR = '\u001B[?25l';
|
|
13
|
+
const SHOW_CURSOR = '\u001B[?25h';
|
|
14
14
|
/** Return to the start of the line and wipe what was on it. */
|
|
15
|
-
const CLEAR_LINE = '\r\
|
|
15
|
+
const CLEAR_LINE = '\r\u001B[K';
|
|
16
16
|
|
|
17
17
|
/** What the spinner writes to; `process.stderr`, or a fake in a test. */
|
|
18
18
|
export interface SpinnerStream {
|
|
@@ -26,9 +26,6 @@ export interface SpinnerStream {
|
|
|
26
26
|
* Written to stderr, so stdout carries only the result.
|
|
27
27
|
* Without a TTY — a pipe, a CI log — the label is printed once and nothing animates.
|
|
28
28
|
*
|
|
29
|
-
* @param label What the wait is for.
|
|
30
|
-
* @param work The wait itself.
|
|
31
|
-
* @param stream Where the spinner is drawn.
|
|
32
29
|
* @returns Whatever `work` answered with.
|
|
33
30
|
*/
|
|
34
31
|
export const withSpinner = async <T>(
|
package/src/tokens.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
3
|
|
|
4
|
-
import type { PersonalAccessToken } from '@stats-forge/github-stats-forge-core';
|
|
4
|
+
import type { PersonalAccessToken } from '@stats-forge/github-stats-forge-core/api';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* @file Where the GitHub token comes from.
|
|
@@ -17,10 +17,9 @@ export const DEFAULT_ENV_FILE = '.env';
|
|
|
17
17
|
/**
|
|
18
18
|
* Loads an env file into `process.env`, the way `node --env-file` would.
|
|
19
19
|
*
|
|
20
|
-
* @param path File to load, relative to the working directory.
|
|
21
|
-
* @param required Whether a missing file is an error.
|
|
22
|
-
* @returns Whether anything was loaded.
|
|
23
20
|
* @throws {Error} When `required` and the file is not there.
|
|
21
|
+
*
|
|
22
|
+
* @returns Whether anything was loaded.
|
|
24
23
|
*/
|
|
25
24
|
export const loadEnvFile = (path: string, required: boolean): boolean => {
|
|
26
25
|
const absolute = resolve(process.cwd(), path);
|
|
@@ -35,9 +34,9 @@ export const loadEnvFile = (path: string, required: boolean): boolean => {
|
|
|
35
34
|
};
|
|
36
35
|
|
|
37
36
|
/**
|
|
38
|
-
* The tokens an env holds, under the `PAT_1`, `PAT_2`, … names core reads
|
|
37
|
+
* The tokens an env holds, under the `PAT_1`, `PAT_2`, … names core reads,
|
|
38
|
+
* in name order and skipping any that are empty.
|
|
39
39
|
*
|
|
40
|
-
* @param env Environment to read.
|
|
41
40
|
* @returns The tokens, in name order, skipping any that are empty.
|
|
42
41
|
*/
|
|
43
42
|
export const tokensFromEnv = (
|
|
@@ -45,7 +44,7 @@ export const tokensFromEnv = (
|
|
|
45
44
|
): Array<PersonalAccessToken> =>
|
|
46
45
|
Object.keys(env)
|
|
47
46
|
.filter((name) => /^PAT_\d+$/.test(name))
|
|
48
|
-
.
|
|
47
|
+
.toSorted()
|
|
49
48
|
.flatMap((name) => {
|
|
50
49
|
const value = env[name];
|
|
51
50
|
return value ? [{ name, value }] : [];
|
|
@@ -54,8 +53,6 @@ export const tokensFromEnv = (
|
|
|
54
53
|
/**
|
|
55
54
|
* The tokens a run will use.
|
|
56
55
|
*
|
|
57
|
-
* @param flags Tokens passed as `--pat`, which win over the environment.
|
|
58
|
-
* @param env Environment to read, once any env file has been loaded into it.
|
|
59
56
|
* @returns The tokens, empty when the run has none yet.
|
|
60
57
|
*/
|
|
61
58
|
export const resolveTokens = (
|