@jaimanm/resumake 1.0.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/.github/workflows/cli-tests.yml +27 -0
- package/.github/workflows/npm-publish.yml +30 -0
- package/CONTRIBUTING.md +31 -0
- package/LICENSE +21 -0
- package/README.md +35 -0
- package/bin/index.js +2 -0
- package/package.json +26 -0
- package/src/commands/config.js +101 -0
- package/src/commands/dev.js +27 -0
- package/src/commands/setup.js +189 -0
- package/src/index.js +28 -0
- package/tests/index.test.js +179 -0
- package/tests/setup.test.js +86 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
name: CLI Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- develop
|
|
7
|
+
- 'feature/*'
|
|
8
|
+
- 'bugfix/*'
|
|
9
|
+
- 'fix/*'
|
|
10
|
+
- 'bug/*'
|
|
11
|
+
pull_request:
|
|
12
|
+
branches:
|
|
13
|
+
- main
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
test:
|
|
17
|
+
runs-on: ubuntu-latest
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v4
|
|
20
|
+
- name: Setup Node.js
|
|
21
|
+
uses: actions/setup-node@v4
|
|
22
|
+
with:
|
|
23
|
+
node-version: '20'
|
|
24
|
+
- name: Install dependencies
|
|
25
|
+
run: npm ci
|
|
26
|
+
- name: Run tests
|
|
27
|
+
run: npm test
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
name: Publish Package to npmjs
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
publish:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
permissions:
|
|
11
|
+
contents: read
|
|
12
|
+
id-token: write
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
|
|
16
|
+
- uses: actions/setup-node@v4
|
|
17
|
+
with:
|
|
18
|
+
node-version: '20'
|
|
19
|
+
registry-url: 'https://registry.npmjs.org/'
|
|
20
|
+
|
|
21
|
+
- name: Install dependencies
|
|
22
|
+
run: npm ci
|
|
23
|
+
|
|
24
|
+
- name: Run tests before publishing
|
|
25
|
+
run: npm test
|
|
26
|
+
|
|
27
|
+
- name: Publish to NPM with Provenance
|
|
28
|
+
run: npm publish --provenance --access public
|
|
29
|
+
env:
|
|
30
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
package/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Contributing Guidelines
|
|
2
|
+
|
|
3
|
+
First off, thanks for taking the time to contribute!
|
|
4
|
+
|
|
5
|
+
All types of contributions are encouraged and valued. See the Table of Contents for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved.
|
|
6
|
+
|
|
7
|
+
## Development Workflow
|
|
8
|
+
|
|
9
|
+
We strictly adhere to a Git Flow process. **Direct pushes to the `main` branch are strictly prohibited**, even for repository administrators and AI assistants.
|
|
10
|
+
|
|
11
|
+
To contribute, you must follow these exact steps:
|
|
12
|
+
|
|
13
|
+
1. **Create an Issue:**
|
|
14
|
+
Before writing code, create an issue describing the bug you intend to fix or the feature you intend to build.
|
|
15
|
+
|
|
16
|
+
2. **Branch off from `main`:**
|
|
17
|
+
Use a semantic naming convention for your branch:
|
|
18
|
+
- `feat/description` for new features
|
|
19
|
+
- `fix/description` for bug fixes
|
|
20
|
+
- `chore/description` for non-functional tasks (docs, CI/CD, etc.)
|
|
21
|
+
|
|
22
|
+
3. **Write Tests:**
|
|
23
|
+
If you are contributing to the Node.js CLI logic in `src-cli`, ensure that your logic is fully covered by Jest tests in `src-cli/tests/index.test.js`. Execute `npm test` inside `src-cli` to verify.
|
|
24
|
+
|
|
25
|
+
4. **Submit a Pull Request:**
|
|
26
|
+
Push your branch and open a Pull Request against `main`. Ensure your PR description links back to the issue it resolves (e.g., "Closes #5").
|
|
27
|
+
|
|
28
|
+
5. **Wait for CI / Review:**
|
|
29
|
+
The `main` branch is protected. All GitHub Action status checks (such as the `test` workflow) must pass before a merge is permitted.
|
|
30
|
+
|
|
31
|
+
By following these guidelines, we ensure that the `main` branch remains perfectly stable for end-users relying on our CLI tool!
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jaiman Munshi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# ResuMake
|
|
2
|
+
|
|
3
|
+
A magical, fully-automated LaTeX resume workflow. Write your resume in LaTeX, and automatically compile it, release it, and sync it to Google Drive every time you push.
|
|
4
|
+
|
|
5
|
+
## Why ResuMake?
|
|
6
|
+
The core philosophy behind ResuMake is to treat your resume like a professional software engineering project:
|
|
7
|
+
1. **Local Live-Editing:** Use `resumake dev` to instantly see your PDF update as you make local LaTeX code changes.
|
|
8
|
+
2. **Version Control for Code:** Push your changes to GitHub to maintain a perfect, branched version history of the raw LaTeX code.
|
|
9
|
+
3. **Version Control for PDFs:** GitHub Actions automatically compiles and creates historical GitHub Releases, giving you version control for the actual compiled PDFs.
|
|
10
|
+
4. **Frictionless "Production" Access:** The CI/CD pipeline syncs the compiled releases directly to your Google Drive Desktop. You always have immediate, one-click access to your most up-to-date "production" PDF when applying for jobs, without manually downloading or moving files.
|
|
11
|
+
|
|
12
|
+
## Features
|
|
13
|
+
- 🚀 **Interactive CLI Setup:** Automatically scaffolds a private GitHub repo and sets up Google Drive Sync.
|
|
14
|
+
- 🔄 **Live Reloading:** Watch mode that automatically recompiles your PDFs instantly on file save.
|
|
15
|
+
- ☁️ **Cloud CI/CD & Sync:** Uses GitHub actions to safely store your compiled PDF resume in Google Drive and create GitHub releases!
|
|
16
|
+
- 🎨 **Multiple Templates:** Choose from Modular Multi-Resume, Awesome-CV, and Jake's Resume!
|
|
17
|
+
|
|
18
|
+
## Getting Started
|
|
19
|
+
|
|
20
|
+
1. Install the CLI globally via NPM:
|
|
21
|
+
```bash
|
|
22
|
+
npm install -g @jaimanm/resumake
|
|
23
|
+
```
|
|
24
|
+
2. Run the interactive setup wizard anywhere on your machine:
|
|
25
|
+
```bash
|
|
26
|
+
resumake setup
|
|
27
|
+
```
|
|
28
|
+
3. Follow the CLI instructions to authenticate with Google Drive, pick a template, and it will scaffold your new private resume repository!
|
|
29
|
+
|
|
30
|
+
## Local Development
|
|
31
|
+
After setup, navigate to your **newly created private repository** and run:
|
|
32
|
+
```bash
|
|
33
|
+
resumake dev
|
|
34
|
+
```
|
|
35
|
+
This will start a watcher that instantly rebuilds your `.pdf` using local LaTeX tools every time you save a `.tex` file.
|
package/bin/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jaimanm/resumake",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "CLI to scaffold a magical LaTeX resume environment",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"resumake": "./bin/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "jest"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [],
|
|
13
|
+
"author": "",
|
|
14
|
+
"license": "ISC",
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"jest": "^29.0.0"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"chalk": "^4.1.2",
|
|
20
|
+
"chokidar": "^3.6.0",
|
|
21
|
+
"commander": "^15.0.0",
|
|
22
|
+
"execa": "^5.1.1",
|
|
23
|
+
"inquirer": "^8.2.7",
|
|
24
|
+
"ora": "^5.4.1"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
const inquirer = require('inquirer');
|
|
2
|
+
const execa = require('execa');
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const ora = require('ora');
|
|
7
|
+
|
|
8
|
+
module.exports = async function configCommand() {
|
|
9
|
+
const answers = await inquirer.prompt([
|
|
10
|
+
{
|
|
11
|
+
type: 'list',
|
|
12
|
+
name: 'setting',
|
|
13
|
+
message: 'What would you like to configure?',
|
|
14
|
+
choices: [
|
|
15
|
+
{ name: 'Google Drive Sync Folder', value: 'driveFolder' },
|
|
16
|
+
{ name: 'Exported PDF Filename', value: 'pdfName' }
|
|
17
|
+
]
|
|
18
|
+
}
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
const { setting } = answers;
|
|
22
|
+
const spinner = ora();
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
if (setting === 'driveFolder') {
|
|
26
|
+
const actionPath = path.join(process.cwd(), '.github/workflows/build-resume.yml');
|
|
27
|
+
if (!fs.existsSync(actionPath)) {
|
|
28
|
+
console.log(chalk.red('Error: Could not find .github/workflows/build-resume.yml. Are you in the project root?'));
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let actionContent = fs.readFileSync(actionPath, 'utf8');
|
|
33
|
+
const driveMatch = actionContent.match(/rclone sync PDF_Exports\/ gdrive:(.*?)\//);
|
|
34
|
+
const currentDriveFolder = driveMatch ? driveMatch[1] : 'Resumes';
|
|
35
|
+
|
|
36
|
+
const { driveFolder } = await inquirer.prompt([
|
|
37
|
+
{
|
|
38
|
+
type: 'input',
|
|
39
|
+
name: 'driveFolder',
|
|
40
|
+
message: 'What folder in Google Drive should your PDFs sync to?',
|
|
41
|
+
default: currentDriveFolder
|
|
42
|
+
}
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
if (driveFolder === currentDriveFolder) {
|
|
46
|
+
console.log(chalk.green('\nConfiguration is already up to date. No changes made.'));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
spinner.start('Updating configuration and syncing to GitHub...');
|
|
51
|
+
actionContent = actionContent.replace(new RegExp(`gdrive:${currentDriveFolder}\/`, 'g'), `gdrive:${driveFolder}/`);
|
|
52
|
+
actionContent = actionContent.replace(new RegExp(`Google Drive '${currentDriveFolder}' folder`, 'g'), `Google Drive '${driveFolder}' folder`);
|
|
53
|
+
fs.writeFileSync(actionPath, actionContent);
|
|
54
|
+
|
|
55
|
+
await execa('git', ['add', actionPath]);
|
|
56
|
+
await execa('git', ['commit', '-m', `chore: update google drive sync folder to ${driveFolder}`]);
|
|
57
|
+
await execa('git', ['push']);
|
|
58
|
+
|
|
59
|
+
spinner.succeed(`Successfully updated Google Drive sync folder to "${driveFolder}"!`);
|
|
60
|
+
|
|
61
|
+
} else if (setting === 'pdfName') {
|
|
62
|
+
const buildPath = path.join(process.cwd(), 'build_all.sh');
|
|
63
|
+
if (!fs.existsSync(buildPath)) {
|
|
64
|
+
console.log(chalk.red('Error: Could not find build_all.sh. Are you in the project root?'));
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let buildContent = fs.readFileSync(buildPath, 'utf8');
|
|
69
|
+
const jobNameMatch = buildContent.match(/-jobname="(.*?)"/);
|
|
70
|
+
const currentPdfName = jobNameMatch ? jobNameMatch[1] : 'Resume';
|
|
71
|
+
|
|
72
|
+
const { pdfName } = await inquirer.prompt([
|
|
73
|
+
{
|
|
74
|
+
type: 'input',
|
|
75
|
+
name: 'pdfName',
|
|
76
|
+
message: 'What should your output PDF be named (without .pdf)?',
|
|
77
|
+
default: currentPdfName
|
|
78
|
+
}
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
if (pdfName === currentPdfName) {
|
|
82
|
+
console.log(chalk.green('\nConfiguration is already up to date. No changes made.'));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
spinner.start('Updating build script and syncing to GitHub...');
|
|
87
|
+
buildContent = buildContent.replace(/-jobname=".*?"/g, `-jobname="${pdfName}"`);
|
|
88
|
+
fs.writeFileSync(buildPath, buildContent);
|
|
89
|
+
|
|
90
|
+
await execa('git', ['add', buildPath]);
|
|
91
|
+
await execa('git', ['commit', '-m', `chore: update output PDF filename to ${pdfName}.pdf`]);
|
|
92
|
+
await execa('git', ['push']);
|
|
93
|
+
|
|
94
|
+
spinner.succeed(`Successfully updated Exported PDF Filename to "${pdfName}.pdf"!`);
|
|
95
|
+
}
|
|
96
|
+
} catch (err) {
|
|
97
|
+
if (spinner.isSpinning) spinner.fail('Failed to update configuration.');
|
|
98
|
+
console.error(chalk.red(err.message));
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const chokidar = require('chokidar');
|
|
2
|
+
const execa = require('execa');
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
|
|
6
|
+
module.exports = async function devCommand() {
|
|
7
|
+
console.log(chalk.cyan('Starting live-development server...'));
|
|
8
|
+
|
|
9
|
+
const watcher = chokidar.watch('**/*.tex', {
|
|
10
|
+
ignored: /(^|[\/\\])\../,
|
|
11
|
+
persistent: true
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
watcher
|
|
15
|
+
.on('ready', () => console.log(chalk.green('Watching for changes in .tex files...')))
|
|
16
|
+
.on('change', async fileChanged => {
|
|
17
|
+
console.log(chalk.yellow(`\nFile ${fileChanged} has been changed. Rebuilding...`));
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
await execa('./build_all.sh');
|
|
21
|
+
console.log(chalk.green('Build successful!'));
|
|
22
|
+
} catch (error) {
|
|
23
|
+
console.log(chalk.red('Build failed! See build.log for details.'));
|
|
24
|
+
fs.writeFileSync('build.log', (error.stdout || '') + '\n' + (error.stderr || ''));
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
const inquirer = require('inquirer');
|
|
2
|
+
const execa = require('execa');
|
|
3
|
+
const ora = require('ora');
|
|
4
|
+
const chalk = require('chalk');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
|
|
7
|
+
async function checkDependencies() {
|
|
8
|
+
const missing = [];
|
|
9
|
+
try { await execa('git', ['--version']); } catch { missing.push('git'); }
|
|
10
|
+
try { await execa('gh', ['--version']); } catch { missing.push('gh'); }
|
|
11
|
+
try { await execa('rclone', ['--version']); } catch { missing.push('rclone'); }
|
|
12
|
+
|
|
13
|
+
if (missing.length > 0) {
|
|
14
|
+
console.log(chalk.yellow(`Missing required dependencies: ${missing.join(', ')}`));
|
|
15
|
+
const { install } = await inquirer.prompt([{
|
|
16
|
+
type: 'confirm',
|
|
17
|
+
name: 'install',
|
|
18
|
+
message: 'Would you like to automatically install them via Homebrew now?',
|
|
19
|
+
default: true
|
|
20
|
+
}]);
|
|
21
|
+
|
|
22
|
+
if (install) {
|
|
23
|
+
const spinner = ora('Installing dependencies...').start();
|
|
24
|
+
try {
|
|
25
|
+
await execa('brew', ['install', ...missing]);
|
|
26
|
+
spinner.succeed('Dependencies installed!');
|
|
27
|
+
} catch (err) {
|
|
28
|
+
spinner.fail('Failed to install dependencies automatically. Please install them manually.');
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
} else {
|
|
32
|
+
console.log(chalk.red('Cannot proceed without dependencies.'));
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = async function setupCommand() {
|
|
39
|
+
console.log(chalk.cyan('\n🚀 Welcome to the ResuMake Setup Wizard!\n'));
|
|
40
|
+
|
|
41
|
+
await checkDependencies();
|
|
42
|
+
|
|
43
|
+
const path = require('path');
|
|
44
|
+
|
|
45
|
+
// ======================================================================
|
|
46
|
+
// PHASE 1: Collect ALL user inputs before doing anything destructive.
|
|
47
|
+
// Ctrl+C anywhere in this phase is a clean abort with zero side effects.
|
|
48
|
+
// ======================================================================
|
|
49
|
+
const answers = await inquirer.prompt([
|
|
50
|
+
{
|
|
51
|
+
type: 'input',
|
|
52
|
+
name: 'targetDir',
|
|
53
|
+
message: 'Where would you like to clone your new resume repository locally?',
|
|
54
|
+
default: '..'
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
type: 'input',
|
|
58
|
+
name: 'repoName',
|
|
59
|
+
message: 'What would you like to name your new private repository?',
|
|
60
|
+
default: 'my-resume',
|
|
61
|
+
validate: async (input, answers) => {
|
|
62
|
+
if (!input) return 'Repository name cannot be empty.';
|
|
63
|
+
const targetPath = path.resolve(answers.targetDir, input);
|
|
64
|
+
if (fs.existsSync(targetPath)) {
|
|
65
|
+
return `A folder named "${input}" already exists at ${targetPath}. Please choose a different name or directory.`;
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
await execa('gh', ['repo', 'view', input]);
|
|
69
|
+
return `Repository "${input}" already exists on your GitHub account. Please choose a different name.`;
|
|
70
|
+
} catch {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
type: 'list',
|
|
77
|
+
name: 'template',
|
|
78
|
+
message: 'Which resume template would you like to use?',
|
|
79
|
+
choices: [
|
|
80
|
+
{ name: 'Modular Multi-Resume (3 versions)', value: 'modular-multi' },
|
|
81
|
+
{ name: 'Standard (Single version)', value: 'standard' },
|
|
82
|
+
{ name: 'Awesome-CV (Professional)', value: 'awesome-cv' },
|
|
83
|
+
{ name: "Jake's Resume (Classic)", value: 'jakes-resume' },
|
|
84
|
+
{ name: "Jake's CV (Classic + Extended Sections)", value: 'jakes-cv' }
|
|
85
|
+
]
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
type: 'input',
|
|
89
|
+
name: 'driveFolder',
|
|
90
|
+
message: 'What folder in Google Drive should your PDFs sync to?',
|
|
91
|
+
default: 'Resumes'
|
|
92
|
+
}
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
console.log(chalk.cyan('\nNow, let\'s set up your Google Drive Sync!'));
|
|
96
|
+
console.log('A browser window will pop open for you to log into Google Drive.');
|
|
97
|
+
|
|
98
|
+
const { ready } = await inquirer.prompt([{
|
|
99
|
+
type: 'confirm',
|
|
100
|
+
name: 'ready',
|
|
101
|
+
message: 'Ready to authenticate?',
|
|
102
|
+
default: true
|
|
103
|
+
}]);
|
|
104
|
+
|
|
105
|
+
const { repoName, template, driveFolder, targetDir } = answers;
|
|
106
|
+
|
|
107
|
+
// ======================================================================
|
|
108
|
+
// PHASE 2: Execute all side effects. No more prompts from here on out.
|
|
109
|
+
// ======================================================================
|
|
110
|
+
console.log(chalk.green(`\nAwesome! Setting up ${repoName} using the ${template} template...\n`));
|
|
111
|
+
|
|
112
|
+
const scaffoldSpinner = ora('Creating GitHub repository and pulling template...').start();
|
|
113
|
+
let remoteUrl;
|
|
114
|
+
try {
|
|
115
|
+
await execa('gh', ['auth', 'status']);
|
|
116
|
+
// Change to target directory before cloning
|
|
117
|
+
const absoluteTargetDir = path.resolve(targetDir);
|
|
118
|
+
if (!fs.existsSync(absoluteTargetDir)) fs.mkdirSync(absoluteTargetDir, { recursive: true });
|
|
119
|
+
process.chdir(absoluteTargetDir);
|
|
120
|
+
|
|
121
|
+
await execa('gh', ['repo', 'create', repoName, '--private', '--clone']);
|
|
122
|
+
process.chdir(repoName);
|
|
123
|
+
|
|
124
|
+
// Get the exact origin URL for future gh CLI calls (fixes multiple remotes bug)
|
|
125
|
+
const urlResult = await execa('git', ['config', '--get', 'remote.origin.url']);
|
|
126
|
+
remoteUrl = urlResult.stdout.trim();
|
|
127
|
+
|
|
128
|
+
await execa('git', ['remote', 'add', 'template', 'https://github.com/jaimanm/resumake.git']);
|
|
129
|
+
await execa('git', ['fetch', 'template']);
|
|
130
|
+
await execa('git', ['reset', '--hard', `template/template/${template}`]);
|
|
131
|
+
|
|
132
|
+
// Customize the GitHub action with the user's preferred Google Drive folder
|
|
133
|
+
const actionPath = '.github/workflows/build-resume.yml';
|
|
134
|
+
if (fs.existsSync(actionPath)) {
|
|
135
|
+
let actionContent = fs.readFileSync(actionPath, 'utf8');
|
|
136
|
+
actionContent = actionContent.replace(/gdrive:Resumes\//g, `gdrive:${driveFolder}/`);
|
|
137
|
+
fs.writeFileSync(actionPath, actionContent);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
await execa('git', ['add', '.']);
|
|
141
|
+
await execa('git', ['commit', '-m', `Scaffold ${template} with custom Drive folder`]);
|
|
142
|
+
scaffoldSpinner.succeed(`Successfully created ${repoName} and scaffolded template!`);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
scaffoldSpinner.fail('Failed to scaffold repository.');
|
|
145
|
+
console.error(chalk.red(err.message));
|
|
146
|
+
if (err.message.includes('auth')) {
|
|
147
|
+
console.log(chalk.yellow('Please run "gh auth login" first to connect your GitHub account.'));
|
|
148
|
+
}
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (ready) {
|
|
153
|
+
const driveSpinner = ora('Waiting for Google Drive authentication...').start();
|
|
154
|
+
try {
|
|
155
|
+
const { stdout, stderr } = await execa('rclone', ['authorize', 'drive']);
|
|
156
|
+
const tokenMatch = (stdout + stderr).match(/\{"access_token".*\}/);
|
|
157
|
+
|
|
158
|
+
if (!tokenMatch) {
|
|
159
|
+
throw new Error('Could not extract token from rclone output.');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const tokenJson = tokenMatch[0];
|
|
163
|
+
const rcloneConfContent = `[gdrive]\ntype = drive\nscope = drive\ntoken = ${tokenJson}`;
|
|
164
|
+
|
|
165
|
+
driveSpinner.text = 'Uploading token securely to GitHub Secrets...';
|
|
166
|
+
await execa('gh', ['secret', 'set', 'RCLONE_CONF', '--body', rcloneConfContent, '-R', remoteUrl]);
|
|
167
|
+
driveSpinner.succeed('Google Drive token successfully secured in GitHub Secrets!');
|
|
168
|
+
} catch (err) {
|
|
169
|
+
driveSpinner.fail('Failed to setup Google Drive sync.');
|
|
170
|
+
console.error(chalk.red(err.message));
|
|
171
|
+
console.log(chalk.yellow('\nPlease try running the setup again, or open an issue on the repository if the problem persists.'));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const pushSpinner = ora('Pushing initial commit to trigger GitHub Action...').start();
|
|
176
|
+
try {
|
|
177
|
+
await execa('git', ['push', '-u', 'origin', 'main', '--force']);
|
|
178
|
+
pushSpinner.succeed('Initial commit pushed! GitHub Actions is now building and syncing your resume.');
|
|
179
|
+
} catch (err) {
|
|
180
|
+
pushSpinner.fail('Failed to push to GitHub.');
|
|
181
|
+
console.error(chalk.red(err.message));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
console.log(chalk.green(`\n🎉 All done! Your magical resume environment is ready.`));
|
|
185
|
+
console.log(chalk.white(`\nNext steps:`));
|
|
186
|
+
console.log(chalk.cyan(` cd ${path.join(targetDir, repoName)}`));
|
|
187
|
+
console.log(chalk.cyan(` resumake dev`));
|
|
188
|
+
console.log(chalk.white(`\nTo live-edit your resume!\n`));
|
|
189
|
+
};
|
package/src/index.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const { Command } = require('commander');
|
|
2
|
+
const setupCommand = require('./commands/setup');
|
|
3
|
+
const devCommand = require('./commands/dev');
|
|
4
|
+
const configCommand = require('./commands/config');
|
|
5
|
+
|
|
6
|
+
const program = new Command();
|
|
7
|
+
|
|
8
|
+
program
|
|
9
|
+
.name('resume-env')
|
|
10
|
+
.description('CLI to automatically setup a magical LaTeX resume environment')
|
|
11
|
+
.version('1.0.0');
|
|
12
|
+
|
|
13
|
+
program
|
|
14
|
+
.command('setup')
|
|
15
|
+
.description('Scaffold a new resume project from a template')
|
|
16
|
+
.action(setupCommand);
|
|
17
|
+
|
|
18
|
+
program
|
|
19
|
+
.command('dev')
|
|
20
|
+
.description('Start the local live-reload development server')
|
|
21
|
+
.action(devCommand);
|
|
22
|
+
|
|
23
|
+
program
|
|
24
|
+
.command('config')
|
|
25
|
+
.description('Configure the CLI settings (e.g., Google Drive sync folder)')
|
|
26
|
+
.action(configCommand);
|
|
27
|
+
|
|
28
|
+
program.parse();
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
const setupCommand = require('../src/commands/setup');
|
|
2
|
+
const devCommand = require('../src/commands/dev');
|
|
3
|
+
const inquirer = require('inquirer');
|
|
4
|
+
const execa = require('execa');
|
|
5
|
+
const chokidar = require('chokidar');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
|
|
8
|
+
jest.mock('inquirer');
|
|
9
|
+
jest.mock('execa');
|
|
10
|
+
jest.mock('chokidar');
|
|
11
|
+
jest.mock('fs');
|
|
12
|
+
|
|
13
|
+
describe('CLI Commands', () => {
|
|
14
|
+
let exitMock, logMock, errorMock, chdirMock;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
exitMock = jest.spyOn(process, 'exit').mockImplementation((code) => {
|
|
18
|
+
throw new Error(`process.exit(${code}) called`);
|
|
19
|
+
});
|
|
20
|
+
chdirMock = jest.spyOn(process, 'chdir').mockImplementation(() => {});
|
|
21
|
+
logMock = jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
22
|
+
errorMock = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
23
|
+
|
|
24
|
+
jest.clearAllMocks();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterAll(() => {
|
|
28
|
+
jest.restoreAllMocks();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('setup.js', () => {
|
|
32
|
+
it('Happy Path: Successfully scaffolds repo, updates action file, and sets Google Drive token', async () => {
|
|
33
|
+
execa.mockResolvedValueOnce({ stdout: 'git version' });
|
|
34
|
+
execa.mockResolvedValueOnce({ stdout: 'gh version' });
|
|
35
|
+
execa.mockResolvedValueOnce({ stdout: 'rclone v1' });
|
|
36
|
+
|
|
37
|
+
inquirer.prompt.mockResolvedValueOnce({
|
|
38
|
+
targetDir: '..',
|
|
39
|
+
repoName: 'my-test-resume',
|
|
40
|
+
template: 'modular-multi',
|
|
41
|
+
driveFolder: 'MyResumes'
|
|
42
|
+
});
|
|
43
|
+
inquirer.prompt.mockResolvedValueOnce({ ready: true });
|
|
44
|
+
|
|
45
|
+
execa.mockResolvedValueOnce({ stdout: 'Logged in' }); // gh auth status
|
|
46
|
+
execa.mockResolvedValueOnce({}); // gh repo create
|
|
47
|
+
execa.mockResolvedValueOnce({ stdout: 'https://github.com/test/repo.git\n' }); // git config --get remote.origin.url
|
|
48
|
+
execa.mockResolvedValueOnce({}); // git remote add
|
|
49
|
+
execa.mockResolvedValueOnce({}); // git fetch
|
|
50
|
+
execa.mockResolvedValueOnce({}); // git reset
|
|
51
|
+
|
|
52
|
+
fs.existsSync.mockImplementation((pathStr) => pathStr.includes('build-resume.yml') ? true : false);
|
|
53
|
+
fs.readFileSync.mockReturnValueOnce('... rclone sync PDF_Exports/ gdrive:Resumes/ ...');
|
|
54
|
+
|
|
55
|
+
execa.mockResolvedValueOnce({}); // git add
|
|
56
|
+
execa.mockResolvedValueOnce({}); // git commit
|
|
57
|
+
|
|
58
|
+
const fakeToken = `{"access_token":"super-secret-token","token_type":"Bearer"}`;
|
|
59
|
+
execa.mockResolvedValueOnce({
|
|
60
|
+
stdout: `Paste the following into your remote machine --->\n${fakeToken}\n<---End paste`,
|
|
61
|
+
stderr: ''
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
execa.mockResolvedValueOnce({}); // gh secret set
|
|
65
|
+
execa.mockResolvedValueOnce({}); // git push
|
|
66
|
+
|
|
67
|
+
await setupCommand();
|
|
68
|
+
|
|
69
|
+
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
|
70
|
+
'.github/workflows/build-resume.yml',
|
|
71
|
+
expect.stringContaining('gdrive:MyResumes/')
|
|
72
|
+
);
|
|
73
|
+
expect(execa).toHaveBeenCalledWith('gh', ['secret', 'set', 'RCLONE_CONF', '--body', expect.stringContaining('super-secret-token'), '-R', 'https://github.com/test/repo.git']);
|
|
74
|
+
expect(exitMock).not.toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('Error Path: Logs warning but continues to push if Google Drive sync fails', async () => {
|
|
78
|
+
execa.mockResolvedValueOnce({ stdout: 'git version' });
|
|
79
|
+
execa.mockResolvedValueOnce({ stdout: 'gh version' });
|
|
80
|
+
execa.mockResolvedValueOnce({ stdout: 'rclone v1' });
|
|
81
|
+
|
|
82
|
+
inquirer.prompt.mockResolvedValueOnce({
|
|
83
|
+
targetDir: '..',
|
|
84
|
+
repoName: 'my-test-resume',
|
|
85
|
+
template: 'modular-multi',
|
|
86
|
+
driveFolder: 'Resumes'
|
|
87
|
+
});
|
|
88
|
+
inquirer.prompt.mockResolvedValueOnce({ ready: true });
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
execa.mockResolvedValueOnce({ stdout: 'Logged in' }); // gh auth status
|
|
92
|
+
execa.mockResolvedValueOnce({}); // gh repo create
|
|
93
|
+
execa.mockResolvedValueOnce({ stdout: 'https://github.com/test/repo.git\n' }); // git config --get remote.origin.url
|
|
94
|
+
execa.mockResolvedValueOnce({}); // git remote add
|
|
95
|
+
execa.mockResolvedValueOnce({}); // git fetch
|
|
96
|
+
execa.mockResolvedValueOnce({}); // git reset
|
|
97
|
+
fs.existsSync.mockReturnValue(false);
|
|
98
|
+
execa.mockResolvedValueOnce({}); // git add
|
|
99
|
+
execa.mockResolvedValueOnce({}); // git commit
|
|
100
|
+
|
|
101
|
+
// Simulate rclone failure
|
|
102
|
+
execa.mockRejectedValueOnce(new Error('rclone crashed'));
|
|
103
|
+
|
|
104
|
+
execa.mockResolvedValueOnce({}); // git push
|
|
105
|
+
|
|
106
|
+
await setupCommand();
|
|
107
|
+
expect(exitMock).not.toHaveBeenCalled();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('Error Path: Triggers Homebrew installer if dependencies are missing', async () => {
|
|
111
|
+
execa.mockRejectedValueOnce(new Error('command not found')); // git
|
|
112
|
+
execa.mockRejectedValueOnce(new Error('command not found')); // gh
|
|
113
|
+
execa.mockRejectedValueOnce(new Error('command not found')); // rclone
|
|
114
|
+
|
|
115
|
+
inquirer.prompt.mockResolvedValueOnce({ install: true });
|
|
116
|
+
|
|
117
|
+
execa.mockResolvedValueOnce({}); // brew install git gh rclone
|
|
118
|
+
|
|
119
|
+
inquirer.prompt.mockResolvedValueOnce({ targetDir: '..', repoName: 'test', template: 'standard', driveFolder: 'Resumes' });
|
|
120
|
+
|
|
121
|
+
execa.mockResolvedValue({ stdout: 'fake-url' }); // Default for all remaining calls
|
|
122
|
+
fs.existsSync.mockReturnValue(false);
|
|
123
|
+
inquirer.prompt.mockResolvedValueOnce({ ready: false }); // Skip drive auth
|
|
124
|
+
|
|
125
|
+
await setupCommand();
|
|
126
|
+
|
|
127
|
+
expect(execa).toHaveBeenCalledWith('brew', ['install', 'git', 'gh', 'rclone']);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('Error Path: Exits if dependencies are missing and user declines install', async () => {
|
|
131
|
+
execa.mockRejectedValueOnce(new Error('command not found')); // git
|
|
132
|
+
execa.mockResolvedValueOnce({ stdout: 'gh version' });
|
|
133
|
+
execa.mockResolvedValueOnce({ stdout: 'rclone v1' });
|
|
134
|
+
|
|
135
|
+
inquirer.prompt.mockResolvedValueOnce({ install: false });
|
|
136
|
+
|
|
137
|
+
await expect(setupCommand()).rejects.toThrow('process.exit(1) called');
|
|
138
|
+
expect(exitMock).toHaveBeenCalledWith(1);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('Error Path: Suggests "gh auth login" if GitHub auth fails', async () => {
|
|
142
|
+
execa.mockResolvedValueOnce({ stdout: 'git version' });
|
|
143
|
+
execa.mockResolvedValueOnce({ stdout: 'gh version' });
|
|
144
|
+
execa.mockResolvedValueOnce({ stdout: 'rclone v1' });
|
|
145
|
+
|
|
146
|
+
inquirer.prompt.mockResolvedValueOnce({ targetDir: '..', repoName: 'test', template: 'standard', driveFolder: 'Resumes' });
|
|
147
|
+
inquirer.prompt.mockResolvedValueOnce({ ready: true });
|
|
148
|
+
|
|
149
|
+
// Fail GitHub auth
|
|
150
|
+
execa.mockRejectedValueOnce(new Error('auth failed'));
|
|
151
|
+
|
|
152
|
+
await expect(setupCommand()).rejects.toThrow('process.exit(1) called');
|
|
153
|
+
expect(exitMock).toHaveBeenCalledWith(1);
|
|
154
|
+
expect(logMock).toHaveBeenCalledWith(expect.stringContaining('gh auth login'));
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('dev.js', () => {
|
|
159
|
+
it('Happy Path: Starts chokidar watcher and rebuilds on file change', async () => {
|
|
160
|
+
let changeCallback;
|
|
161
|
+
const fakeWatcher = {
|
|
162
|
+
on: jest.fn().mockImplementation(function(event, cb) {
|
|
163
|
+
if (event === 'change') changeCallback = cb;
|
|
164
|
+
return this;
|
|
165
|
+
})
|
|
166
|
+
};
|
|
167
|
+
chokidar.watch.mockReturnValue(fakeWatcher);
|
|
168
|
+
|
|
169
|
+
await devCommand();
|
|
170
|
+
|
|
171
|
+
expect(chokidar.watch).toHaveBeenCalledWith('**/*.tex', expect.any(Object));
|
|
172
|
+
|
|
173
|
+
execa.mockResolvedValueOnce({}); // simulate build_all.sh succeeding
|
|
174
|
+
await changeCallback('resume.tex');
|
|
175
|
+
|
|
176
|
+
expect(execa).toHaveBeenCalledWith('./build_all.sh');
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
const inquirer = require('inquirer');
|
|
2
|
+
const execa = require('execa');
|
|
3
|
+
const setupCommand = require('../src/commands/setup');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
|
|
6
|
+
// Mock dependencies
|
|
7
|
+
jest.mock('inquirer');
|
|
8
|
+
jest.mock('execa');
|
|
9
|
+
jest.mock('fs');
|
|
10
|
+
|
|
11
|
+
describe('resumake setup command', () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
jest.clearAllMocks();
|
|
14
|
+
|
|
15
|
+
// Mock successful execution for Git, GH, and Rclone version checks
|
|
16
|
+
execa.mockResolvedValue({ stdout: '', stderr: '' });
|
|
17
|
+
|
|
18
|
+
// Mock specific execa calls (like rclone authorize returning a token)
|
|
19
|
+
execa.mockImplementation((cmd, args) => {
|
|
20
|
+
if (cmd === 'rclone' && args[0] === 'authorize') {
|
|
21
|
+
return Promise.resolve({ stdout: '{"access_token": "mock_token_123"}', stderr: '' });
|
|
22
|
+
}
|
|
23
|
+
if (cmd === 'git' && args[0] === 'config' && args[1] === '--get') {
|
|
24
|
+
return Promise.resolve({ stdout: 'https://github.com/jaimanm/mock-repo.git', stderr: '' });
|
|
25
|
+
}
|
|
26
|
+
return Promise.resolve({ stdout: '', stderr: '' });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Mock user input
|
|
30
|
+
inquirer.prompt.mockImplementation((questions) => {
|
|
31
|
+
// Return answers based on the prompt type/names
|
|
32
|
+
if (questions[0].name === 'targetDir') {
|
|
33
|
+
return Promise.resolve({
|
|
34
|
+
targetDir: '/mock/path',
|
|
35
|
+
repoName: 'my-resume',
|
|
36
|
+
template: 'standard',
|
|
37
|
+
driveFolder: 'Resumes'
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
if (questions[0].name === 'ready') {
|
|
41
|
+
return Promise.resolve({ ready: true });
|
|
42
|
+
}
|
|
43
|
+
return Promise.resolve({});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Mock fs so it doesn't try to read/write real files
|
|
47
|
+
fs.existsSync.mockReturnValue(true);
|
|
48
|
+
fs.readFileSync.mockReturnValue('gdrive:Resumes/');
|
|
49
|
+
fs.writeFileSync.mockReturnValue(true);
|
|
50
|
+
|
|
51
|
+
// Spy on process.chdir to avoid changing test runner directory
|
|
52
|
+
jest.spyOn(process, 'chdir').mockImplementation(() => {});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterAll(() => {
|
|
56
|
+
jest.restoreAllMocks();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('executes the correct shell commands to scaffold the template', async () => {
|
|
60
|
+
// Hide console logs during test
|
|
61
|
+
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
62
|
+
|
|
63
|
+
await setupCommand();
|
|
64
|
+
|
|
65
|
+
// Verify gh repo create was called
|
|
66
|
+
expect(execa).toHaveBeenCalledWith('gh', ['repo', 'create', 'my-resume', '--private', '--clone']);
|
|
67
|
+
|
|
68
|
+
// Verify template was fetched
|
|
69
|
+
expect(execa).toHaveBeenCalledWith('git', ['remote', 'add', 'template', 'https://github.com/jaimanm/resumake.git']);
|
|
70
|
+
expect(execa).toHaveBeenCalledWith('git', ['reset', '--hard', 'template/template/standard']);
|
|
71
|
+
|
|
72
|
+
// Verify rclone was executed
|
|
73
|
+
expect(execa).toHaveBeenCalledWith('rclone', ['authorize', 'drive']);
|
|
74
|
+
|
|
75
|
+
// Verify GitHub Secret was set with the extracted token
|
|
76
|
+
expect(execa).toHaveBeenCalledWith(
|
|
77
|
+
'gh',
|
|
78
|
+
['secret', 'set', 'RCLONE_CONF', '--body', expect.stringContaining('mock_token_123'), '-R', 'https://github.com/jaimanm/mock-repo.git']
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
// Verify final push
|
|
82
|
+
expect(execa).toHaveBeenCalledWith('git', ['push', '-u', 'origin', 'main', '--force']);
|
|
83
|
+
|
|
84
|
+
consoleSpy.mockRestore();
|
|
85
|
+
});
|
|
86
|
+
});
|