-
Notifications
You must be signed in to change notification settings - Fork 43
/
Copy pathindex.ts
314 lines (269 loc) · 10.5 KB
/
index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
import type commands from '../../cmds/index.js';
import type { CommandOptions } from '../baseCommand.js';
import type { OptionDefinition } from 'command-line-usage';
import fs from 'node:fs';
import path from 'node:path';
import chalk from 'chalk';
import prompts from 'prompts';
import { simpleGit } from 'simple-git';
import configstore from '../configstore.js';
import { getMajorPkgVersion } from '../getPkgVersion.js';
import isCI, { isNpmScript, isTest } from '../isCI.js';
import { debug, info } from '../logger.js';
import promptTerminal from '../promptWrapper.js';
import { cleanFileName, validateFilePath } from '../validatePromptInput.js';
import yamlBase from './baseFile.js';
/**
* Generates the key for storing info in `configstore` object.
* @param repoRoot The root of the repo
*/
export const getConfigStoreKey = (repoRoot: string) => `createGHA.${repoRoot}`;
/**
* The directory where GitHub Actions workflow files are stored.
*
* This is the same across all repositories on GitHub.
*
* @see {@link https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#about-yaml-syntax-for-workflows}
*/
const GITHUB_WORKFLOW_DIR = '.github/workflows';
const GITHUB_SECRET_NAME = 'README_API_KEY';
export const git = simpleGit();
/**
* Removes any non-file-friendly characters and adds
* the full path + file extension for GitHub Workflow files.
* @param fileName raw file name to clean up
*/
export const getGHAFileName = (fileName: string) => {
return path.join(GITHUB_WORKFLOW_DIR, `${cleanFileName(fileName).toLowerCase()}.yml`);
};
/**
* Returns a redacted `key` if the current command uses authentication.
* Otherwise, returns `false`.
*/
function getKey(args: OptionDefinition[], opts: CommandOptions): string | false {
if (args.some(arg => arg.name === 'key')) {
return `••••••••••••${opts.key?.slice(-5) || ''}`;
}
return false;
}
/**
* Constructs the command string that we pass into the workflow file.
*/
function constructCmdString(command: keyof typeof commands, args: OptionDefinition[], opts: CommandOptions): string {
const optsString = args
.sort(arg => (arg.defaultOption ? -1 : 0))
.map(arg => {
// @ts-expect-error by this point it's safe to assume that
// the argument names match the opts object.
const val = opts[arg.name];
// if default option, return the value
if (arg.defaultOption) return val;
// obfuscate the key in a GitHub secret
if (arg.name === 'key') return `--key=$\{{ secrets.${GITHUB_SECRET_NAME} }}`;
// remove the GitHub flag
if (arg.name === 'github') return false;
// if a boolean value, return the flag
if (arg.type === Boolean && val) return `--${arg.name}`;
if (val) return `--${arg.name}=${val}`;
return false;
})
.filter(Boolean)
.join(' ');
return `${command} ${optsString}`.trim();
}
/**
* Function to return various git attributes needed for running GitHub Action
*/
export async function getGitData() {
// Expressions to search raw output of `git remote show origin`
const headRegEx = /^ {2}HEAD branch: /g;
const headLineRegEx = /^ {2}HEAD branch:.*/gm;
const isRepo = await git.checkIsRepo().catch(e => {
debug(`error running git repo check: ${e.message}`);
return false;
});
debug(`[getGitData] isRepo result: ${isRepo}`);
let containsGitHubRemote;
let defaultBranch;
const rawRemotes = await git.remote([]).catch(e => {
debug(`[getGitData] error grabbing git remotes: ${e.message}`);
return '';
});
debug(`[getGitData] rawRemotes result: ${rawRemotes}`);
if (rawRemotes) {
const remote = rawRemotes.split('\n')[0];
debug(`[getGitData] remote result: ${remote}`);
const rawRemote = await git.remote(['show', remote]).catch(e => {
debug(`[getGitData] error accessing remote: ${e.message}`);
return '';
});
debug(`[getGitData] rawRemote result: ${rawRemote}`);
// Extract head branch from git output
const rawHead = headLineRegEx.exec(rawRemote as string)?.[0];
debug(`[getGitData] rawHead result: ${rawHead}`);
if (rawHead) defaultBranch = rawHead.replace(headRegEx, '');
// Extract the word 'github' from git output
const remotesList = (await git.remote(['-v'])) as string;
debug(`[getGitData] remotesList result: ${remotesList}`);
// This is a bit hairy but we want to keep it fairly general here
// in case of GitHub Enterprise, etc.
containsGitHubRemote = /github/.test(remotesList);
}
debug(`[getGitData] containsGitHubRemote result: ${containsGitHubRemote}`);
debug(`[getGitData] defaultBranch result: ${defaultBranch}`);
const repoRoot = await git.revparse(['--show-toplevel']).catch(e => {
debug(`[getGitData] error grabbing git root: ${e.message}`);
return '';
});
debug(`[getGitData] repoRoot result: ${repoRoot}`);
return { containsGitHubRemote, defaultBranch, isRepo, repoRoot };
}
/**
* Post-command flow for creating a GitHub Actions workflow file.
*
*/
export default async function createGHA(
msg: string,
command: keyof typeof commands,
args: OptionDefinition[],
opts: CommandOptions,
) {
debug(`running GHA onboarding for ${command} command`);
debug(`opts used in createGHA: ${JSON.stringify(opts)}`);
// if in a CI environment,
// don't even bother running the git commands
if (!opts.github && (isCI() || isNpmScript())) {
debug('not running GHA onboarding workflow in CI and/or npm script, exiting');
return msg;
}
const { containsGitHubRemote, defaultBranch, isRepo, repoRoot } = await getGitData();
const configVal = configstore.get(getConfigStoreKey(repoRoot));
debug(`repo value in config: ${configVal}`);
const majorPkgVersion = await getMajorPkgVersion();
debug(`major pkg version: ${majorPkgVersion}`);
if (!opts.github) {
if (
// not a repo
!isRepo ||
// user has previously declined to set up GHA for current repo and `rdme` package version
configVal === majorPkgVersion ||
// is a repo, but does not contain a GitHub remote
(isRepo && !containsGitHubRemote) ||
// not testing this function
(isTest() && !process.env.TEST_RDME_CREATEGHA)
) {
debug('not running GHA onboarding workflow, exiting');
// We return the original command message and pretend this command flow never happened.
return msg;
}
}
if (msg) info(msg, { includeEmojiPrefix: false });
if (opts.github) {
info(chalk.bold("\n🚀 Let's get you set up with GitHub Actions! 🚀\n"), { includeEmojiPrefix: false });
} else {
info(
[
'',
chalk.bold("🐙 Looks like you're running this command in a GitHub Repository! 🐙"),
'',
`🚀 With a few quick clicks, you can run this \`${command}\` command via GitHub Actions (${chalk.underline(
'https://github.com/features/actions',
)})`,
'',
`✨ This means it will run ${chalk.italic('automagically')} with every push to a branch of your choice!`,
'',
].join('\n'),
{ includeEmojiPrefix: false },
);
}
const previousWorkingDirectory = process.cwd();
if (repoRoot && repoRoot !== previousWorkingDirectory) {
process.chdir(repoRoot);
debug(`switching working directory from ${previousWorkingDirectory} to ${process.cwd()}`);
}
prompts.override({ shouldCreateGHA: opts.github });
const { branch, filePath, shouldCreateGHA }: { branch: string; filePath: string; shouldCreateGHA: boolean } =
await promptTerminal(
[
{
message: 'Would you like to add a GitHub Actions workflow?',
name: 'shouldCreateGHA',
type: 'confirm',
initial: true,
},
{
message: 'What GitHub branch should this workflow run on?',
name: 'branch',
type: 'text',
initial: defaultBranch || 'main',
},
{
message: 'What would you like to name the GitHub Actions workflow file?',
name: 'filePath',
type: 'text',
initial: cleanFileName(`rdme-${command}`),
format: prev => getGHAFileName(prev),
validate: value => validateFilePath(value, getGHAFileName),
},
],
{
// @ts-expect-error answers is definitely an object,
// despite TS insisting that it's an array.
// link: https://github.com/terkelg/prompts#optionsonsubmit
onSubmit: (p, a, answers: { shouldCreateGHA: boolean }) => !answers.shouldCreateGHA,
},
);
if (!shouldCreateGHA) {
// if the user says no, we don't want to bug them again
// for this repo and version of `rdme
configstore.set(getConfigStoreKey(repoRoot), majorPkgVersion);
throw new Error(
'GitHub Actions workflow creation cancelled. If you ever change your mind, you can run this command again with the `--github` flag.',
);
}
const data = {
branch,
cleanCommand: cleanFileName(command),
command,
commandString: constructCmdString(command, args, opts),
rdmeVersion: `v${majorPkgVersion}`,
timestamp: new Date().toISOString(),
};
debug(`data for resolver: ${JSON.stringify(data)}`);
let output = yamlBase;
Object.keys(data).forEach(key => {
output = output.replace(new RegExp(`{{${key}}}`, 'g'), data[key as keyof typeof data]);
});
if (!fs.existsSync(GITHUB_WORKFLOW_DIR)) {
debug('GHA workflow directory does not exist, creating');
fs.mkdirSync(GITHUB_WORKFLOW_DIR, { recursive: true });
}
fs.writeFileSync(filePath, output);
const success = [chalk.green('\nYour GitHub Actions workflow file has been created! ✨\n')];
const key = getKey(args, opts);
if (key) {
success.push(
chalk.bold('Almost done! Just a couple more steps:'),
`1. Push your newly created file (${chalk.underline(filePath)}) to GitHub 🚀`,
`2. Create a GitHub secret called ${chalk.bold(
GITHUB_SECRET_NAME,
)} and populate the value with your ReadMe API key (${key}) 🔑`,
'',
`🔐 Check out GitHub's docs for more info on creating encrypted secrets (${chalk.underline(
'https://docs.github.com/actions/security-guides/encrypted-secrets#creating-encrypted-secrets-for-a-repository',
)})`,
);
} else {
success.push(
`${chalk.bold('Almost done!')} Push your newly created file (${chalk.underline(
filePath,
)}) to GitHub and you're all set 🚀`,
);
}
success.push(
'',
`🦉 If you have any more questions, feel free to drop us a line! ${chalk.underline('support@readme.io')}`,
'',
);
return Promise.resolve(success.join('\n'));
}