@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.
@@ -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 }}
@@ -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
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ require('../src/index.js');
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
+ });