Skip to content

feat: enhance dashboard with interactive filtering and environment inheritance#408

Open
deepanmpc wants to merge 8 commits intosantifer:mainfrom
deepanmpc:main
Open

feat: enhance dashboard with interactive filtering and environment inheritance#408
deepanmpc wants to merge 8 commits intosantifer:mainfrom
deepanmpc:main

Conversation

@deepanmpc
Copy link
Copy Markdown

@deepanmpc deepanmpc commented Apr 21, 2026

"The 'Welcome' workflow failure is due to hyphenated inputs in the current main branch. This PR updates them to underscores (repo_token, pr_message, issue_message) as required by actions/first-interaction@v3. Note that
pull_request_target will continue to show this error until these changes are merged into the base branch."

Summary by CodeRabbit

  • New Features

    • ATS-optimized resume editor with multiple workflows for tailoring, updating, and generating resumes
    • Web-based dashboard for managing applications and career operations tracking
    • DOCX and PDF resume generation with unified output
  • Improvements

    • Updated resume template typography and styling for better presentation
    • Refined PDF page margins for optimal document layout
  • Documentation

    • Added documentation for new resume editor command
    • Updated resume generation and PDF formatting guidelines

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 21, 2026

📝 Walkthrough

Walkthrough

This PR adds a comprehensive career operations resume management system featuring a new Gemini CLI command for ATS-optimized resume editing, a web-based dashboard for application tracking, backend resume generation in DOCX/PDF formats, updated templates and system prompts, and supporting configuration files.

Changes

Cohort / File(s) Summary
Gemini CLI Integration
.gemini/commands/career-ops-resume.toml, GEMINI.md
Added ATS-optimized resume editor CLI command with templated workflow supporting Tailor/Update/Generate actions via interactive prompts. Command routes to resume generation scripts with context-aware parameter passing.
Resume Generation Engine
resume_template.mjs, generate-resume-all.mjs, generate-walmart-cv.mjs
Implemented DOCX/PDF resume generation pipeline: resume_template.mjs provides structured DOCX output with sections (header, summary, experience, skills, projects); generate-resume-all.mjs orchestrates unified DOCX+PDF generation with HTML templating and placeholder substitution; generate-walmart-cv.mjs generates company-specific CV variants.
Resume Templates & Modes
templates/cv-template.html, modes/resume-editor.md, modes/pdf.md
Refactored resume template from self-hosted fonts to Google Fonts; added system prompt defining ATS formatting rules, section order, and content quality standards; updated PDF generation mode with JSON-based workflow and typography/layout specifications.
Web Dashboard
web-dashboard/server.mjs, web-dashboard/public/index.html, web-dashboard/public/app.js, web-dashboard/public/style.css, dashboard.html
Built full-stack dashboard: Express backend serving /api/applications, /api/reports/detail, /api/terminal-send endpoints; dark-themed client with application tracker table, command center, terminal integration, and report viewer; standalone dashboard.html prototype with inline styles/scripts.
Package & Dependencies
package.json
Added ESM module resolution, new pdf and docx npm scripts; added docx, express, marked dependencies.
Configuration & Metadata
.gitignore, .github/workflows/welcome.yml
Expanded .gitignore to cover generated files (*.pdf, *.docx), content directories (interview-prep/**, data/**, output/**, reports/**), and config files (portals.yml, .envrc). Updated GitHub Actions workflow input keys from kebab-case to snake-case.
Removed Content
interview-prep/story-bank.md
Deleted story bank template and master STAR+R stories documentation.
PDF Rendering
generate-pdf.mjs
Reduced page margins from 0.6in to 0.4in on all sides.

Sequence Diagrams

sequenceDiagram
    actor User
    participant CLI as Gemini CLI
    participant resume-editor as Resume Editor Mode
    participant generator as generate-resume-all.mjs
    participant docx as resume_template.mjs
    participant pdf as generate-pdf.mjs
    participant Filesystem as Output Files

    User->>CLI: /career-ops-resume (Action: Tailor, JD)
    CLI->>resume-editor: Load prompt & context
    resume-editor->>generator: Route to tailoring workflow
    generator->>docx: Call generateResume(tailoredData)
    docx->>docx: Build DOCX sections
    docx->>Filesystem: Write resume.docx
    generator->>generator: Read template, substitute {{placeholders}}
    generator->>Filesystem: Write resume_temp.html
    generator->>pdf: execSync PDF generation
    pdf->>Filesystem: Write resume.pdf
    generator-->>User: Completion logged
Loading
sequenceDiagram
    actor User
    participant Browser
    participant Server as web-dashboard/server.mjs
    participant Filesystem
    participant Terminal as macOS Terminal/iTerm

    User->>Browser: Load dashboard
    Browser->>Server: GET /api/applications
    Server->>Filesystem: Read data/applications.md
    Filesystem-->>Server: Markdown table
    Server->>Server: Parse table to JSON
    Server-->>Browser: Application array
    Browser->>Browser: Render table & stats
    User->>Browser: Enter JD, click Send
    Browser->>Server: POST /api/terminal-send {command}
    Server->>Server: Build AppleScript
    Server->>Terminal: osascript execute
    Terminal-->>Server: Command sent
    Server-->>Browser: { success: true }
    User->>Browser: Click table row
    Browser->>Server: GET /api/reports/detail?path=...
    Server->>Filesystem: Read markdown report
    Filesystem-->>Server: Report content
    Server-->>Browser: Markdown text
    Browser->>Browser: marked(html) + sanitize + display
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Suggested labels

🔴 core-architecture, 🔧 scripts, 📦 dependencies

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title refers to dashboard enhancements and filtering but diverges significantly from the actual major changes: new resume generation workflows, ATS-optimized configurations, and multiple new scripts. Consider a more comprehensive title such as 'feat: add resume generation pipeline and dashboard improvements' to better reflect the scope of changes across resume tooling, CLI commands, and UI components.
Docstring Coverage ⚠️ Warning Docstring coverage is 18.52% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@deepanmpc deepanmpc changed the title Title: feat: enhance dashboard with interactive filtering and environment inheritance feat: enhance dashboard with interactive filtering and environment inheritance Apr 21, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 27

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
templates/cv-template.html (1)

296-306: ⚠️ Potential issue | 🔴 Critical

Remove the duplicated trailing HTML fragment.

The template already closes at Lines 296-298, then Lines 299-306 add stray text and unmatched closing tags. This makes generated resumes malformed and matches the HTMLHint tag-pair failures.

🐛 Proposed fix
 </div>
 </body>
 </html>
-L}}</a>{{/if}}
-    </div>
-  </div>
-  {{/if}}
-
-</div>
-</body>
-</html>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@templates/cv-template.html` around lines 296 - 306, Remove the duplicated
trailing HTML fragment that follows the legitimate closing tags; specifically
delete the stray block containing "L}}</a>{{/if}}" and the extra
"</div></body></html>" copy at the end of the template so only the original
closing "</div></body></html>" (the one that properly ends the main template
block) remains; check the template rendering helpers around the "{{/if}}" and
ensure no unmatched Handlebars tags remain (e.g., the fragment after the
existing closing tags should be removed from the template).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.gemini/commands/career-ops-resume.toml:
- Line 13: The Gemini command is referencing the wrong resume template filename
("Read file: resume_template.js") so the editor may load a missing/stale module;
update the command entry in the career-ops-resume configuration to reference the
actual module "resume_template.mjs" everywhere it currently says
"resume_template.js" (search for the literal string "resume_template.js" in the
.gemini/commands/career-ops-resume.toml entry and replace it with
"resume_template.mjs" so the resume editor loads the correct module).
- Around line 16-20: The Generate path currently always invokes "node
generate-resume-all.mjs --data ./output/resume_data.json" which requires a
generated JSON that may not exist; update the resume workflow so that when
Action == 'Generate' it runs "node generate-resume-all.mjs" (no --data) by
default, and only appends "--data ./output/resume_data.json" when that file
exists or an explicit data input is provided; change the decision logic that
handles Action values ('Tailor','Update','Generate') to conditionally include
the --data flag rather than mandating ./output/resume_data.json for Generate.

In `@dashboard.html`:
- Around line 864-867: Remove the stray "tml>" fragment that appears after the
closing </html> tag in dashboard.html (the stray token shown as "tml>") so the
document ends cleanly with a single </html> and no trailing characters; locate
the closing </html> and delete the extra "tml>" token to fix the corrupted
trailing content.
- Around line 847-856: The runAutoPipeline validation path currently calls
logToTerminal('error', 'No JD or URL provided.', 'error') which causes the
command echo to show "/career-ops error"; update the validation branch in
runAutoPipeline so it does not pass "error" as the command — instead call
logToTerminal with a neutral/empty command label (e.g. '' or null) and the
message as the second argument and 'error' as the third, or use a dedicated
helper that logs only the message; change the call near the jd-input read in
runAutoPipeline and ensure any copyToClipboard/command construction only runs
after validation succeeds.
- Around line 1-867: dashboard.html is a stale, standalone mock that duplicates
the real dashboard under web-dashboard and contains hardcoded content and a
deprecated clipboard call; remove dashboard.html (preferred) or rename/move it
to a design-mocks folder and add a clear header comment marking it as a mock,
and while doing so update the deprecated copyToClipboard function (and any uses
from executeCmd, runAutoPipeline, trackAction) to use
navigator.clipboard.writeText with proper Promise handling/fallback instead of
document.execCommand('copy').

In `@generate-pdf.mjs`:
- Around line 153-158: The PDF margin contract is inconsistent: update either
the margin values in the margin object inside generate-pdf.mjs (currently
top/right/bottom/left: '0.4in') to match the documented 15mm sides and 12mm
top/bottom, or update modes/pdf.md to reflect 0.4in on all sides; edit the
generate-pdf.mjs margin object (the margin property) or the modes/pdf.md margin
section so both places use the same units and numeric values to avoid
conflicting resume specs.

In `@generate-resume-all.mjs`:
- Around line 40-66: The code hardcodes PAGE_WIDTH = '210mm' and always uses
--format=a4 when calling generate-pdf, and it performs {{KEY}} substitution
before the {{`#if` key}} block so non-string falsy values get stringified; add a
CLI option (--format or --paper) that sets a paperFormat variable (values 'a4'
or 'letter'), derive defaults.PAGE_WIDTH from that (e.g., '210mm' for a4,
'8.5in' for letter), and pass the chosen format through to the execSync call
that invokes generate-pdf.mjs; also reorder the template processing so the regex
handling for {{`#if`\s+(\w+)}}([\s\S]*?){{\/if}} runs before the loop that
replaces {{KEY}} placeholders (use finalPlaceholders for truthiness checks) to
avoid stringifying falsy values.
- Around line 72-83: The try/catch logs a PDF generation error but still prints
a success message and returns exit code 0, and it uses execSync with a
concatenated command (vulnerable to spaces/injection). Replace the execSync call
that runs generate-pdf.mjs with execFileSync('node', ['generate-pdf.mjs',
tempHtmlPath, pdfPath, '--format=a4'], { stdio: 'inherit' }) to avoid shell
interpolation, and on catching errors from that call ensure you call
process.exit(1) (or rethrow) so the script exits non‑zero and do not print the
"All formats generated" message when pdf generation fails; update the block that
references pdfPath, tempHtmlPath, and execSync accordingly.

In `@generate-walmart-cv.mjs`:
- Around line 3-70: The file currently hardcodes personal resume data (variables
profile, summary, competencies, education, experience, projects, skills) in
generate-walmart-cv.mjs; replace these inline literals with a config/loader that
reads user data from an external, non-committed source (e.g., cv.md,
config/profile.yml, modes/_profile.md or an ignored output/*.json) and populate
the same symbols (profile, summary, etc.) from that parsed input; ensure the
loader fails loudly if the external file is missing and add output/*.json to
.gitignore so user data is not tracked.
- Around line 1-2: Replace CommonJS require usage with ESM imports: import the
filesystem functions using ESM (e.g., import fs from 'fs' or import { readFile }
from 'fs/promises') and change the template file read to use the imported ESM
API so the top-level const template =
fs.readFileSync('templates/cv-template.html', 'utf8') is replaced with an
ESM-compatible read (or asynchronous read). Also remove all hardcoded
user-specific values and the hardcoded output filename in this module (search
for symbols like template, any variables named
name/email/phone/location/education/experience/projects/skills, and any output
filename constant) and instead load them from an external configuration file
(JSON/YAML) excluded from version control (e.g., via a loadConfig or readConfig
call), passing that config into the CV generation logic so the script contains
no embedded personal data and uses ESM imports throughout.

In `@modes/pdf.md`:
- Around line 20-23: Update the documentation to reference the correct ESM
template filename: change any mention of resume_template.js to
resume_template.mjs, and note that npm run pdf invokes generate-resume-all.mjs
which imports ./resume_template.mjs so agents should prepare JSON matching
resume_template.mjs's structure and pass it to the --data argument as described.

In `@modes/resume-editor.md`:
- Around line 21-42: Update the documentation references from resume_template.js
to the actual module name resume_template.mjs and correct the asserted constant:
reference PAGE_MARGIN (in DXA) as the single source-of-truth instead of
MARGIN_INCHES; also note that generate-resume-all.mjs imports
./resume_template.mjs so any instruction about editing the template should point
to resume_template.mjs and the PAGE_MARGIN symbol.
- Around line 128-135: The snippet uses CommonJS require which fails because
resume_template.mjs is ESM; replace the require call with an ESM import of the
module including the .mjs extension and call generateResume with await since
generateResume is async (e.g., import { generateResume } from
'./resume_template.mjs' and await generateResume(updatedData,
'./output/candidate_tailored.docx')); ensure the calling context supports
top-level await or wrap in an async function so the file write completion is
awaited.

In `@package.json`:
- Around line 12-13: The package.json "docx" script points at
resume_template.mjs but the repo sets "type": "module" while resume_template.mjs
still uses CommonJS (require(), require.main, module.exports), causing npm run
docx to fail; fix by converting resume_template.mjs to ESM (replace require()
with import, use import.meta.url instead of require.main, replace module.exports
with export default or named exports, and adapt any synchronous require-based
logic to ESM imports) and keep the script "docx": "node resume_template.mjs", or
alternatively rename the file to resume_template.cjs and update the package.json
"docx" script to reference resume_template.cjs so Node will run it as CommonJS.

In `@resume_template.mjs`:
- Around line 467-528: generateHTML currently dereferences data.contact,
data.contact.linkedin and data.contact.github unconditionally and injects raw
values into HTML; make the contact fields null-safe (use optional chaining /
fallback strings when accessing data.contact, data.contact.linkedin.url/label
and data.contact.github.url/label) and ensure every string inserted into the
templates is passed through an HTML-escape helper (e.g., esc()) — add or use an
esc function and apply it to NAME, PHONE, EMAIL, LOCATION, LINKEDIN_URL,
LINKEDIN_DISPLAY, PORTFOLIO_URL, PORTFOLIO_DISPLAY, SUMMARY_TEXT and all
interpolated fields inside EDUCATION, EXPERIENCE, SKILLS, PROJECTS,
CERTIFICATIONS (degrees, institutions, dates, gpa, coursework, titles,
companies, bullets, tech, cert names/issuers) so missing contact objects won’t
crash and special characters won’t break the markup.
- Around line 22-28: The file is using CommonJS in an .mjs ESM module; replace
the require() imports (Document, Packer, Paragraph, TextRun, AlignmentType,
LevelFormat, BorderStyle, HeadingLevel, ExternalHyperlink, TabStopType,
TabStopPosition, WidthType, UnderlineType, fs, path) with equivalent top-level
import statements, add import { fileURLToPath } from 'url' and define const
__filename = fileURLToPath(import.meta.url) to recreate __filename, change the
CLI entry check from require.main === module to a process.argv-based check (e.g.
comparing path.resolve(process.argv[1]) to __filename), and replace the CommonJS
export (module.exports = { generateResume, generateHTML, SAMPLE_DATA }) with
named ESM exports (export { generateResume, generateHTML, SAMPLE_DATA }) so
functions like generateResume, generateHTML and the SAMPLE_DATA constant can be
imported by generate-resume-all.mjs.

In `@templates/cv-template.html`:
- Around line 11-13: The template currently hardcodes a named resume ("Deepan's
AIML Resume v2") and personal coding-profile URLs—remove these user-specific
values from templates/cv-template.html and replace them with template
placeholders (for example {{resume_title}}, {{profiles.github}},
{{profiles.linkedin}} or similar) that will be populated from cv.md,
config/profile.yml, or the generated resume data; update any other occurrences
of the same hardcoded strings (the repeated profile URLs block) to use the same
placeholders and ensure the rendering code reads those fields from the
resume/config input rather than embedding user-specific links in the shared
template.
- Around line 7-9: The template includes network-dependent Google Font links
(the <link> tags with hrefs "https://fonts.googleapis.com" and
"https://fonts.gstatic.com" and the stylesheet loading "Inter") which can break
PDF generation offline/CI; remove those <link> tags and update the CSS
font-family declarations that reference "Inter" to use a bundled/local font via
`@font-face` (pointing to a checked-in font file) or a system font stack (e.g.,
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial,
sans-serif) so rendering no longer depends on external network requests during
Playwright PDF generation.

In `@web-dashboard/public/app.js`:
- Around line 3-12: loadDashboard currently calls response.json() and passes the
result to renderTable/renderStats without validating the HTTP status or payload
shape; update loadDashboard to check response.ok after fetch, attempt to parse
JSON with try/catch (handle non-JSON responses), verify the parsed body is an
array (Array.isArray) before assigning to allAppData, and if any check fails
call showToast with a descriptive error and use a safe fallback (e.g., empty
array) when invoking renderTable and renderStats so UI code
(renderTable/renderStats) never receives invalid data.
- Around line 98-107: The terminalSend function currently calls await
response.json() unconditionally and doesn't handle fetch/network errors; wrap
the fetch/response parsing in a try/catch in terminalSend, first check
response.ok and inspect response.headers.get('content-type') to decide whether
to call response.json() or response.text(), and showToast with the text or
parsed error accordingly; on network/CORS failures catch the error and
showToast('Terminal Error: ' + error.message) so no unhandled rejections occur
and plain-text 400 responses (e.g., "No command") are displayed.
- Around line 117-128: Rename the openReport(path, ...) parameter to
openReport(reportPath, ...) and update all internal references (including the
fetch call that builds `/api/reports/detail?path=${encodeURIComponent(...)}` and
the DOM updates to 'report-title', 'report-content', and 'report-panel'); add a
strict client-side validation/allowlist before calling fetch — e.g., reject any
reportPath that contains '..' or starts with '/' or that fails a safe filename
regex (like only alphanumerics, dots, dashes, underscores) or, better, map
displayed report links to an explicit knownReports list and look up the
canonical filename from that list — only then call fetch with
encodeURIComponent(reportPath). Ensure the rename and validation are applied
wherever openReport is invoked so the server no longer receives untrusted/../
paths from the client.

In `@web-dashboard/public/index.html`:
- Around line 136-137: Pin the marked CDN to a fixed known-good version (match
the major from package.json used by openReport in app.js) instead of the
unversioned URL and add Subresource Integrity (integrity) and
crossorigin="anonymous" attributes for both the marked and dompurify <script>
tags; regenerate and paste the correct SRI hashes for each exact file you
reference (verify the hash against the CDN file) so the dashboard cannot load a
tampered script while ensuring openReport continues to render markdown against
the pinned marked release.
- Around line 220-240: Remove the corrupted/trailing duplicate HTML after the
first closing </html> so only one DOM and one script load remain;	delete the
stray "ton>" fragment and the duplicated block that redefines ids report-panel,
report-title, report-content, toast and the second <script src="app.js">,
leaving a single well-formed closing </body></html>. Ensure only one inclusion
of app.js and that functions referenced in that script (openReport, closeReport,
showToast, loadDashboard) target the unique elements with ids
report-panel/report-title/report-content/toast.

In `@web-dashboard/public/style.css`:
- Around line 59-80: Add visible keyboard focus states for interactive controls
by defining :focus and preferably :focus-visible styles that mirror or
complement existing hover/active styles; update the CSS for .nav-btn (alongside
the other interactive classes noted) to include a clear focus outline or
box-shadow and sufficient contrast (e.g., visible outline color or a subtle
background color change) while keeping rounded corners and transitions
consistent with .nav-btn:hover and .nav-btn.active; ensure the focus style is
applied to keyboard focus (use :focus-visible if supported) and does not rely on
hover-only cues so keyboard users can track nav buttons, terminal actions,
omnibox results, and the close button.

In `@web-dashboard/server.mjs`:
- Around line 60-93: The POST handler for '/api/terminal-send' is vulnerable to
shell and AppleScript injection because it interpolates user input into a
single-quoted osascript command (cleanCmd/script) and uses exec with a shell;
fix by: (1) refuse non-macOS early (check process.platform !== 'darwin' and
return 400), (2) stop using exec with a shell and call execFile('osascript',
['-e', script], ...) so the script is passed as one argv, (3) never interpolate
user input into the AppleScript source — pass the user command as an argument to
osascript (e.g., execFile('osascript', ['-e', scriptUsingItem1, '--', command])
and reference item 1 of argv in the AppleScript), (4) validate and limit
req.body.command (max length, allowed charset/prefix like '/career-ops '), and
(5) bind the server to 127.0.0.1 and add an auth token/origin check before
executing (update app.listen binding and the '/api/terminal-send' handler
accordingly).
- Around line 17-31: The current app.get('/api/applications') handler uses
blocking fs.existsSync/fs.readFileSync and a fragile row filter that drops any
row containing '---', and it can throw unhandled errors; change it to use
fs.promises.readFile inside an async handler with try/catch (handle ENOENT by
returning an empty array), replace the simplistic filter(line =>
line.startsWith('|') && !line.includes('---')) with a separator-detecting regex
like /^\|\s*[:\-\s|]+\|\s*$/ to exclude only the Markdown separator row while
keeping legitimate content, keep the parsing logic that builds headers and the
data object (the headers.forEach(...) and values mapping) but defensively bound
indexes, and on any parse or I/O failure return JSON errors (e.g.,
res.status(500).json({ error: error.message })) instead of letting the request
throw.
- Around line 33-49: Reject absolute incoming paths early (use path.isAbsolute
on reportPath), then compute absolutePath as you already do and resolve any
symlinks with fs.realpathSync(absolutePath) before further checks; compute
realReports = fs.realpathSync(reportsDir) and realData =
fs.realpathSync(dataDir) and use path.relative(realReports, realPath) and
path.relative(realData, realPath) to ensure the resolved path is a true
descendant (relative must not start with '..' and must not be equal to '..');
keep the existing extension check on the resolved path and return 400/403/404 as
appropriate if the checks fail.

---

Outside diff comments:
In `@templates/cv-template.html`:
- Around line 296-306: Remove the duplicated trailing HTML fragment that follows
the legitimate closing tags; specifically delete the stray block containing
"L}}</a>{{/if}}" and the extra "</div></body></html>" copy at the end of the
template so only the original closing "</div></body></html>" (the one that
properly ends the main template block) remains; check the template rendering
helpers around the "{{/if}}" and ensure no unmatched Handlebars tags remain
(e.g., the fragment after the existing closing tags should be removed from the
template).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 24d9caa9-9096-4805-88de-374767ac8ba6

📥 Commits

Reviewing files that changed from the base of the PR and between b8a3a12 and 35d2f19.

📒 Files selected for processing (19)
  • .gemini/commands/career-ops-resume.toml
  • .github/workflows/welcome.yml
  • .gitignore
  • GEMINI.md
  • dashboard.html
  • generate-pdf.mjs
  • generate-resume-all.mjs
  • generate-walmart-cv.mjs
  • interview-prep/.gitkeep
  • interview-prep/story-bank.md
  • modes/pdf.md
  • modes/resume-editor.md
  • package.json
  • resume_template.mjs
  • templates/cv-template.html
  • web-dashboard/public/app.js
  • web-dashboard/public/index.html
  • web-dashboard/public/style.css
  • web-dashboard/server.mjs
💤 Files with no reviewable changes (1)
  • interview-prep/story-bank.md

- Read file: modes/_shared.md
- Read file: modes/resume-editor.md
- Read file: cv.md
- Read file: resume_template.js
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reference the actual resume template module.

The repo wiring uses resume_template.mjs, but this command tells Gemini to read resume_template.js, so the resume editor may load a missing/stale file.

🐛 Proposed fix
-- Read file: resume_template.js
+- Read file: resume_template.mjs
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- Read file: resume_template.js
- Read file: resume_template.mjs
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.gemini/commands/career-ops-resume.toml at line 13, The Gemini command is
referencing the wrong resume template filename ("Read file: resume_template.js")
so the editor may load a missing/stale module; update the command entry in the
career-ops-resume configuration to reference the actual module
"resume_template.mjs" everywhere it currently says "resume_template.js" (search
for the literal string "resume_template.js" in the
.gemini/commands/career-ops-resume.toml entry and replace it with
"resume_template.mjs" so the resume editor loads the correct module).

Comment on lines +16 to +20
Execute the resume editing workflow based on the action:
- If Action is 'Tailor': Tailor the resume to the JD provided in Context.
- If Action is 'Update': Add the new projects/experience from Context.
- If Action is 'Generate': Generate a fresh resume from existing data. Run: node generate-resume-all.mjs --data ./output/resume_data.json

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Do not require a generated JSON file for the default Generate path.

./output/resume_data.json is ignored/generated and may not exist. Either instruct the agent to create it before running this command, or run node generate-resume-all.mjs without --data when generating from existing defaults.

🛠️ Proposed fix
-- If Action is 'Generate': Generate a fresh resume from existing data. Run: node generate-resume-all.mjs --data ./output/resume_data.json
+- If Action is 'Generate': Generate a fresh resume from existing data. If ./output/resume_data.json exists, run: node generate-resume-all.mjs --data ./output/resume_data.json. Otherwise run: node generate-resume-all.mjs.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Execute the resume editing workflow based on the action:
- If Action is 'Tailor': Tailor the resume to the JD provided in Context.
- If Action is 'Update': Add the new projects/experience from Context.
- If Action is 'Generate': Generate a fresh resume from existing data. Run: node generate-resume-all.mjs --data ./output/resume_data.json
Execute the resume editing workflow based on the action:
- If Action is 'Tailor': Tailor the resume to the JD provided in Context.
- If Action is 'Update': Add the new projects/experience from Context.
- If Action is 'Generate': Generate a fresh resume from existing data. If ./output/resume_data.json exists, run: node generate-resume-all.mjs --data ./output/resume_data.json. Otherwise run: node generate-resume-all.mjs.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.gemini/commands/career-ops-resume.toml around lines 16 - 20, The Generate
path currently always invokes "node generate-resume-all.mjs --data
./output/resume_data.json" which requires a generated JSON that may not exist;
update the resume workflow so that when Action == 'Generate' it runs "node
generate-resume-all.mjs" (no --data) by default, and only appends "--data
./output/resume_data.json" when that file exists or an explicit data input is
provided; change the decision logic that handles Action values
('Tailor','Update','Generate') to conditionally include the --data flag rather
than mandating ./output/resume_data.json for Generate.

Comment thread dashboard.html
Comment on lines +1 to +867
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>career-ops | dashboard</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Syne:wght@500;600;700&display=swap');

:root {
--bg: #0C0C0C;
--panel-bg: #141414;
--stat-bg: #1A1A1A;
--border: rgba(255, 255, 255, 0.08);
--border-hover: rgba(255, 255, 255, 0.15);
--txt-primary: #FFFFFF;
--txt-secondary: #A0A0A0;
--txt-muted: #666666;
--accent: #3B82F6;
--accent-muted: rgba(59, 130, 246, 0.15);
--green: #4ADE80;
--green-muted: rgba(74, 222, 128, 0.15);
--amber: #FBBF24;
--amber-muted: rgba(251, 191, 36, 0.15);
--red: #F87171;
--mono: 'DM Mono', monospace;
--head: 'Syne', sans-serif;
}

* { box-sizing: border-box; margin: 0; padding: 0; }

body {
background-color: var(--bg);
color: var(--txt-primary);
font-family: var(--head);
-webkit-font-smoothing: antialiased;
overflow-x: hidden;
padding: 2rem;
}

/* --- LAYOUT --- */
.container {
max-width: 1200px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
}

/* --- HEADER --- */
header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 1rem;
border-bottom: 0.5px solid var(--border);
}

.logo-group {
display: flex;
align-items: center;
gap: 12px;
}

.logo-box {
width: 32px;
height: 32px;
background: var(--txt-primary);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
color: var(--bg);
font-weight: 900;
font-size: 18px;
}

.logo-text h1 {
font-size: 18px;
font-weight: 700;
letter-spacing: -0.02em;
}

.logo-text p {
font-family: var(--mono);
font-size: 11px;
color: var(--txt-muted);
margin-top: 2px;
}

.status-pill {
display: flex;
align-items: center;
gap: 8px;
background: var(--panel-bg);
border: 0.5px solid var(--border);
padding: 6px 12px;
border-radius: 100px;
font-family: var(--mono);
font-size: 11px;
color: var(--txt-secondary);
}

.status-dot {
width: 8px;
height: 8px;
background: var(--green);
border-radius: 50%;
box-shadow: 0 0 8px var(--green);
}

/* --- STATS --- */
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}

.stat-card {
background: var(--panel-bg);
border: 0.5px solid var(--border);
padding: 1.25rem;
border-radius: 12px;
position: relative;
}

.stat-value {
font-size: 28px;
font-weight: 700;
line-height: 1;
}

.stat-label {
font-size: 11px;
color: var(--txt-muted);
font-family: var(--mono);
margin-top: 8px;
text-transform: uppercase;
letter-spacing: 0.05em;
}

.stat-badge {
position: absolute;
top: 1.25rem;
right: 1.25rem;
font-family: var(--mono);
font-size: 10px;
padding: 2px 8px;
border-radius: 4px;
}

/* --- MAIN GRID --- */
.main-grid {
display: grid;
grid-template-columns: 1fr 300px;
gap: 1.5rem;
}

/* --- COMMAND CENTER (LEFT) --- */
.command-center {
display: flex;
flex-direction: column;
gap: 1.5rem;
}

.panel {
background: var(--panel-bg);
border: 0.5px solid var(--border);
border-radius: 14px;
overflow: hidden;
}

.panel-header {
padding: 1rem 1.25rem;
border-bottom: 0.5px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}

.panel-title {
font-size: 13px;
font-weight: 600;
color: var(--txt-secondary);
}

/* JD INPUT */
.jd-input-section {
padding: 1.25rem;
border-bottom: 0.5px solid var(--border);
}

.jd-wrapper {
display: flex;
gap: 12px;
}

textarea {
flex: 1;
background: var(--stat-bg);
border: 0.5px solid var(--border);
border-radius: 8px;
padding: 12px;
color: var(--txt-primary);
font-family: var(--mono);
font-size: 12px;
resize: none;
height: 48px;
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1), border-color 0.2s;
outline: none;
}

textarea:focus {
height: 120px;
border-color: var(--accent);
}

.run-btn {
background: var(--txt-primary);
color: var(--bg);
border: none;
border-radius: 8px;
padding: 0 1.5rem;
font-family: var(--head);
font-weight: 700;
cursor: pointer;
transition: transform 0.1s, opacity 0.2s;
}

.run-btn:hover { opacity: 0.9; }
.run-btn:active { transform: scale(0.96); }

/* MODES GRID */
.modes-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
padding: 1.25rem;
}

.mode-tile {
background: var(--stat-bg);
border: 0.5px solid var(--border);
padding: 1rem;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s;
position: relative;
}

.mode-tile:hover {
border-color: var(--border-hover);
background: #202020;
transform: translateY(-2px);
}

.mode-icon {
font-size: 18px;
margin-bottom: 8px;
}

.mode-name {
font-family: var(--mono);
font-size: 12px;
font-weight: 500;
}

.mode-desc {
font-size: 10px;
color: var(--txt-muted);
margin-top: 4px;
line-height: 1.3;
}

.mode-badge {
position: absolute;
top: 10px;
right: 10px;
font-size: 9px;
font-family: var(--mono);
padding: 1px 5px;
border-radius: 3px;
}

/* TRACKER TABLE */
.tracker-section {
padding: 0 1.25rem 1.25rem;
}

table {
width: 100%;
border-collapse: collapse;
font-family: var(--mono);
font-size: 11px;
}

th {
text-align: left;
color: var(--txt-muted);
font-weight: 400;
padding: 12px 0;
border-bottom: 0.5px solid var(--border);
text-transform: uppercase;
letter-spacing: 0.05em;
}

tr {
border-bottom: 0.5px solid var(--border);
transition: background 0.1s;
cursor: pointer;
}

tr:last-child { border-bottom: none; }
tr:hover { background: rgba(255, 255, 255, 0.03); }

td { padding: 14px 0; vertical-align: middle; }

.co-role { font-family: var(--head); }
.co-name { font-weight: 600; font-size: 13px; }
.role-name { font-size: 11px; color: var(--txt-secondary); margin-top: 2px; }

.score-pill { font-weight: 700; font-size: 12px; }

.status-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: 500;
}

/* --- TERMINAL --- */
.terminal {
background: #000000;
border: 0.5px solid var(--border);
border-radius: 12px;
margin-top: 1rem;
overflow: hidden;
}

.term-header {
padding: 10px 14px;
background: #111;
border-bottom: 0.5px solid var(--border);
display: flex;
align-items: center;
gap: 8px;
}

.dot-red { width: 10px; height: 10px; background: #FF5F56; border-radius: 50%; }
.dot-yellow { width: 10px; height: 10px; background: #FFBD2E; border-radius: 50%; }
.dot-green { width: 10px; height: 10px; background: #27C93F; border-radius: 50%; }

.term-body {
height: 180px;
padding: 1rem;
font-family: var(--mono);
font-size: 12px;
line-height: 1.5;
overflow-y: auto;
color: var(--green);
}

.term-prompt { color: #888; margin-right: 8px; }
.term-cursor {
display: inline-block;
width: 8px;
height: 15px;
background: var(--green);
animation: blink 1s step-end infinite;
vertical-align: middle;
}

@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }

/* --- SIDEBAR --- */
.sidebar {
display: flex;
flex-direction: column;
gap: 1.5rem;
}

.pipe-list {
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 12px;
}

.pipe-item {
display: flex;
align-items: flex-start;
gap: 10px;
font-size: 12px;
}

.pipe-dot { width: 8px; height: 8px; border-radius: 50%; margin-top: 4px; }
.pipe-info .co { font-weight: 600; }
.pipe-info .step { font-family: var(--mono); color: var(--txt-muted); font-size: 10px; margin-top: 2px; }
.pipe-time { font-family: var(--mono); color: var(--txt-muted); font-size: 10px; margin-left: auto; }

.btn-group {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
padding: 0 1.25rem 1.25rem;
}

.small-btn {
background: var(--stat-bg);
border: 0.5px solid var(--border);
color: var(--txt-secondary);
font-family: var(--mono);
font-size: 10px;
padding: 6px;
border-radius: 6px;
cursor: pointer;
text-align: center;
}

.small-btn:hover { background: #252525; color: var(--txt-primary); }

/* SCORE CHART */
.chart { padding: 1.25rem; }
.chart-row { margin-bottom: 10px; }
.chart-label { font-family: var(--mono); font-size: 10px; color: var(--txt-muted); margin-bottom: 4px; display: flex; justify-content: space-between; }
.bar-bg { background: var(--stat-bg); height: 4px; border-radius: 2px; overflow: hidden; }
.bar-fill { height: 100%; border-radius: 2px; transition: width 0.5s; }

.action-list { padding: 1.25rem; display: flex; flex-direction: column; gap: 8px; }
.action-btn {
background: var(--panel-bg);
border: 0.5px solid var(--border);
color: var(--txt-secondary);
padding: 10px 14px;
border-radius: 8px;
font-family: var(--head);
font-size: 12px;
text-align: left;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}
.action-btn:hover { border-color: var(--accent); color: var(--txt-primary); }

/* TOAST */
#toast {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%) translateY(100px);
background: var(--txt-primary);
color: var(--bg);
padding: 12px 24px;
border-radius: 8px;
font-weight: 600;
font-size: 13px;
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
z-index: 1000;
}
#toast.show { transform: translateX(-50%) translateY(0); }

/* SCROLLBAR */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #333; border-radius: 10px; }

/* COLOR CLASSES */
.c-green { color: var(--green); }
.c-amber { color: var(--amber); }
.c-red { color: var(--red); }
.b-blue { background: var(--accent-muted); color: var(--accent); }
.b-amber { background: var(--amber-muted); color: var(--amber); }
.b-green { background: var(--green-muted); color: var(--green); }
.b-gray { background: rgba(255,255,255,0.05); color: var(--txt-muted); }

</style>
</head>
<body>

<div class="container">
<header>
<div class="logo-group">
<div class="logo-box">C</div>
<div class="logo-text">
<h1>career-ops</h1>
<p>/ dashboard · claude code</p>
</div>
</div>
<div class="status-pill">
<div class="status-dot"></div>
ready
</div>
</header>

<div class="stats-row">
<div class="stat-card">
<div class="stat-value">24</div>
<div class="stat-label">jobs evaluated</div>
<span class="stat-badge b-green">↑ 8 this week</span>
</div>
<div class="stat-card">
<div class="stat-value">6</div>
<div class="stat-label">applied</div>
<span class="stat-badge b-amber">3 pending</span>
</div>
<div class="stat-card">
<div class="stat-value">A-</div>
<div class="stat-label">avg score</div>
<span class="stat-badge b-blue">4.3 / 5</span>
</div>
<div class="stat-card">
<div class="stat-value">2</div>
<div class="stat-label">interviews</div>
<span class="stat-badge c-red">ACTION NEEDED</span>
</div>
</div>

<div class="main-grid">
<div class="command-center">
<div class="panel">
<div class="panel-header">
<span class="panel-title">Command Center</span>
<span style="font-family: var(--mono); font-size: 10px; color: var(--txt-muted);">paste JD or URL below · or pick a mode</span>
</div>

<div class="jd-input-section">
<p style="font-family: var(--mono); font-size: 11px; color: var(--txt-muted); margin-bottom: 8px;">/ career-ops — full auto-pipeline</p>
<div class="jd-wrapper">
<textarea id="jd-input" placeholder="Paste a job description or URL here... career-ops auto-detects and runs the full pipeline"></textarea>
<button class="run-btn" onclick="runAutoPipeline()">Run ↗</button>
</div>
</div>

<div class="panel-header" style="border-top: 0.5px solid var(--border);">
<span class="panel-title">All Modes</span>
</div>
<div class="modes-grid">
<div class="mode-tile" onclick="executeCmd('scan')">
<div class="mode-icon">⌕</div>
<div class="mode-name">/scan</div>
<div class="mode-desc">Scan job portals for new offers</div>
<span class="mode-badge b-amber">auto</span>
</div>
<div class="mode-tile" onclick="executeCmd('pdf')">
<div class="mode-icon">↓</div>
<div class="mode-name">/pdf</div>
<div class="mode-desc">Generate ATS-optimized CV</div>
<span class="mode-badge b-blue">output</span>
</div>
<div class="mode-tile" onclick="executeCmd('batch')">
<div class="mode-icon">≡</div>
<div class="mode-name">/batch</div>
<div class="mode-desc">Evaluate multiple offers at once</div>
<span class="mode-badge b-amber">parallel</span>
</div>
<div class="mode-tile" onclick="executeCmd('tracker')">
<div class="mode-icon">◫</div>
<div class="mode-name">/tracker</div>
<div class="mode-desc">View application status overview</div>
<span class="mode-badge b-green">live</span>
</div>
<div class="mode-tile" onclick="executeCmd('apply')">
<div class="mode-icon">✎</div>
<div class="mode-name">/apply</div>
<div class="mode-desc">Fill application forms with AI assistant</div>
<span class="mode-badge b-blue">ai</span>
</div>
<div class="mode-tile" onclick="executeCmd('pipeline')">
<div class="mode-icon">⇢</div>
<div class="mode-name">/pipeline</div>
<div class="mode-desc">Process pending URLs in inbox queue</div>
<span class="mode-badge b-amber">queue</span>
</div>
<div class="mode-tile" onclick="executeCmd('contacto')">
<div class="mode-icon">✉</div>
<div class="mode-name">/contacto</div>
<div class="mode-desc">Generate LinkedIn outreach messages</div>
<span class="mode-badge b-blue">msg</span>
</div>
<div class="mode-tile" onclick="executeCmd('deep')">
<div class="mode-icon">◎</div>
<div class="mode-name">/deep</div>
<div class="mode-desc">Deep research on company culture</div>
<span class="mode-badge b-blue">research</span>
</div>
<div class="mode-tile" onclick="executeCmd('training')">
<div class="mode-icon">▲</div>
<div class="mode-name">/training</div>
<div class="mode-desc">Evaluate course or cert against North Star</div>
<span class="mode-badge b-green">eval</span>
</div>
<div class="mode-tile" onclick="executeCmd('project')">
<div class="mode-icon">◈</div>
<div class="mode-name">/project</div>
<div class="mode-desc">Evaluate portfolio project idea</div>
<span class="mode-badge b-green">eval</span>
</div>
<div class="mode-tile" onclick="executeCmd('interview')">
<div class="mode-icon">♞</div>
<div class="mode-name">/interview</div>
<div class="mode-desc">STAR prep and negotiation scripts</div>
<span class="mode-badge b-amber">prep</span>
</div>
<div class="mode-tile" onclick="executeCmd('help')">
<div class="mode-icon">?</div>
<div class="mode-name">/help</div>
<div class="mode-desc">Show all available commands and help</div>
<span class="mode-badge b-gray">help</span>
</div>
</div>

<div class="panel-header" style="border-top: 0.5px solid var(--border);">
<span class="panel-title">Application Tracker</span>
</div>
<div class="tracker-section">
<table>
<thead>
<tr>
<th>company / role</th>
<th>score</th>
<th style="text-align:center">status</th>
<th style="text-align:right">date</th>
</tr>
</thead>
<tbody>
<tr onclick="trackAction('Walmart')">
<td>
<div class="co-role">
<div class="co-name">Walmart Global Tech</div>
<div class="role-name">Software Engineering Intern</div>
</div>
</td>
<td><span class="score-pill c-green">A+ · 5.0</span></td>
<td style="text-align:center"><span class="status-tag b-blue">interview</span></td>
<td style="text-align:right; color: var(--txt-muted)">Apr 21</td>
</tr>
<tr onclick="trackAction('Anthropic')">
<td>
<div class="co-role">
<div class="co-name">Anthropic</div>
<div class="role-name">Staff Engineer, AI Safety</div>
</div>
</td>
<td><span class="score-pill c-green">A+ · 4.8</span></td>
<td style="text-align:center"><span class="status-tag b-blue">interview</span></td>
<td style="text-align:right; color: var(--txt-muted)">Apr 18</td>
</tr>
<tr onclick="trackAction('Mistral AI')">
<td>
<div class="co-role">
<div class="co-name">Mistral AI</div>
<div class="role-name">Applied Scientist</div>
</div>
</td>
<td><span class="score-pill c-green">A · 4.4</span></td>
<td style="text-align:center"><span class="status-tag b-amber">applied</span></td>
<td style="text-align:right; color: var(--txt-muted)">Apr 15</td>
</tr>
<tr onclick="trackAction('Vercel')">
<td>
<div class="co-role">
<div class="co-name">Vercel</div>
<div class="role-name">Platform Engineer</div>
</div>
</td>
<td><span class="score-pill c-amber">B+ · 3.7</span></td>
<td style="text-align:center"><span class="status-tag b-green">screening</span></td>
<td style="text-align:right; color: var(--txt-muted)">Apr 10</td>
</tr>
<tr onclick="trackAction('DeepMind')">
<td>
<div class="co-role">
<div class="co-name">DeepMind</div>
<div class="role-name">Research Engineer</div>
</div>
</td>
<td><span class="score-pill c-red">C · 2.9</span></td>
<td style="text-align:center"><span class="status-tag b-gray">skipped</span></td>
<td style="text-align:right; color: var(--txt-muted)">Apr 08</td>
</tr>
</tbody>
</table>
</div>
</div>

<div class="terminal">
<div class="term-header">
<div class="dot-red"></div>
<div class="dot-yellow"></div>
<div class="dot-green"></div>
<span style="font-family: var(--mono); font-size: 11px; color: var(--txt-muted); margin-left: auto;">career-ops · output log</span>
</div>
<div class="term-body" id="term-log">
<p><span class="term-prompt">$</span>/career-ops tracker</p>
<p style="color: var(--green);">✓ Loaded 15 tracked applications from data/applications.md</p>
<p style="color: var(--green);">✓ 2 interviews active — Walmart, Anthropic</p>
<p style="color: var(--amber);">⚠ Mistral AI: no response after 6 days — follow up recommended</p>
<p><span class="term-prompt">$</span><span class="term-cursor"></span></p>
</div>
</div>
</div>

<div class="sidebar">
<div class="panel">
<div class="panel-header">
<span class="panel-title">Pipeline Queue</span>
</div>
<div class="pipe-list">
<div class="pipe-item">
<div class="pipe-dot" style="background: var(--accent);"></div>
<div class="pipe-info">
<div class="co">ElevenLabs</div>
<div class="step">evaluating fit...</div>
</div>
<div class="pipe-time">now</div>
</div>
<div class="pipe-item">
<div class="pipe-dot" style="background: var(--amber);"></div>
<div class="pipe-info">
<div class="co">Pinecone</div>
<div class="step">pending PDF gen</div>
</div>
<div class="pipe-time">2m</div>
</div>
<div class="pipe-item">
<div class="pipe-dot" style="background: var(--txt-muted);"></div>
<div class="pipe-info">
<div class="co">Temporal</div>
<div class="step">queued</div>
</div>
<div class="pipe-time">5m</div>
</div>
<div class="pipe-item">
<div class="pipe-dot" style="background: var(--txt-muted);"></div>
<div class="pipe-info">
<div class="co">LangChain</div>
<div class="step">queued</div>
</div>
<div class="pipe-time">7m</div>
</div>
</div>
<div class="btn-group">
<button class="small-btn" onclick="executeCmd('pipeline')">Run all ↗</button>
<button class="small-btn" onclick="executeCmd('batch')">Batch ↗</button>
</div>
</div>

<div class="panel">
<div class="panel-header">
<span class="panel-title">Score Breakdown</span>
</div>
<div class="chart">
<div class="chart-row">
<div class="chart-label"><span>Tech Fit</span> <span>4.6</span></div>
<div class="bar-bg"><div class="bar-fill" style="width: 92%; background: var(--green);"></div></div>
</div>
<div class="chart-row">
<div class="chart-label"><span>Level</span> <span>4.3</span></div>
<div class="bar-bg"><div class="bar-fill" style="width: 86%; background: var(--green);"></div></div>
</div>
<div class="chart-row">
<div class="chart-label"><span>Culture</span> <span>4.0</span></div>
<div class="bar-bg"><div class="bar-fill" style="width: 80%; background: var(--green);"></div></div>
</div>
<div class="chart-row">
<div class="chart-label"><span>Mission</span> <span>4.8</span></div>
<div class="bar-bg"><div class="bar-fill" style="width: 96%; background: var(--accent);"></div></div>
</div>
<div class="chart-row">
<div class="chart-label"><span>Growth</span> <span>3.8</span></div>
<div class="bar-bg"><div class="bar-fill" style="width: 76%; background: var(--amber);"></div></div>
</div>
<div class="chart-row">
<div class="chart-label"><span>Comp</span> <span>3.4</span></div>
<div class="bar-bg"><div class="bar-fill" style="width: 68%; background: var(--amber);"></div></div>
</div>
<div class="chart-row">
<div class="chart-label"><span>Remote</span> <span>3.0</span></div>
<div class="bar-bg"><div class="bar-fill" style="width: 60%; background: var(--amber);"></div></div>
</div>
</div>
<div style="padding: 0 1.25rem 1.25rem;">
<button class="small-btn" style="width: 100%;" onclick="executeCmd('deep')">Deep Research ↗</button>
</div>
</div>

<div class="panel">
<div class="panel-header">
<span class="panel-title">Quick Actions</span>
</div>
<div class="action-list">
<button class="action-btn" onclick="executeCmd('scan')"><span>⌕ Scan portals</span> <span>↗</span></button>
<button class="action-btn" onclick="executeCmd('pdf')"><span>↓ Generate CV</span> <span>↗</span></button>
<button class="action-btn" onclick="executeCmd('contacto')"><span>✉ Outreach msg</span> <span>↗</span></button>
<button class="action-btn" onclick="executeCmd('interview-prep')"><span>♞ Interview prep</span> <span>↗</span></button>
</div>
</div>
</div>
</div>
</div>

<div id="toast">Command copied to clipboard</div>

<script>
const termLog = document.getElementById('term-log');

function logToTerminal(command, output, type = 'success') {
const pCmd = document.createElement('p');
pCmd.innerHTML = `<span class="term-prompt">$</span>/career-ops ${command}`;

const pOut = document.createElement('p');
pOut.style.color = type === 'success' ? 'var(--green)' : type === 'warn' ? 'var(--amber)' : 'var(--red)';
pOut.innerText = output;

// Insert before the last child (which is the prompt + cursor)
termLog.insertBefore(pCmd, termLog.lastElementChild);
termLog.insertBefore(pOut, termLog.lastElementChild);

termLog.scrollTop = termLog.scrollHeight;
}

function showToast(message) {
const toast = document.getElementById('toast');
toast.innerText = message;
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 2000);
}

function copyToClipboard(text) {
const el = document.createElement('textarea');
el.value = text;
document.body.appendChild(el);
el.select();
document.execCommand('copy');
document.body.removeChild(el);
showToast('Command copied to clipboard');
}

function executeCmd(mode) {
const command = `/career-ops ${mode}`;
logToTerminal(mode, `Initializing ${mode} mode...`);
copyToClipboard(command);
}

function runAutoPipeline() {
const input = document.getElementById('jd-input').value.trim();
if (!input) {
logToTerminal('error', 'No JD or URL provided.', 'error');
return;
}
const command = `/career-ops ${input}`;
logToTerminal('auto-pipeline', `Starting auto-pipeline for: ${input.substring(0, 30)}...`);
copyToClipboard(command);
}

function trackAction(company) {
const command = `/career-ops tracker --details "${company}"`;
logToTerminal('tracker', `Fetching detailed status for ${company}...`);
copyToClipboard(command);
}
</script>

</body>
</html>
tml>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Scope concern: dashboard.html appears to duplicate web-dashboard/public/index.html with stale/mocked behavior.

This file is a standalone HTML with hardcoded mock stats, a hardcoded applications table, hardcoded pipeline/score sidebars, and no backend. The real dashboard implemented in this PR lives under web-dashboard/ (public/index.html + public/app.js + server.mjs) and pulls live data from /api/applications, /api/reports/detail, /api/terminal-send. Keeping both will drift quickly and confuses users about which dashboard is canonical.

Please either:

  • Remove dashboard.html entirely (preferred — the functional dashboard is web-dashboard/), or
  • Clearly mark it as a visual mockup (rename to e.g. design-mocks/dashboard-mockup.html) and note that it does not reflect real data.

Also note: copyToClipboard uses document.execCommand('copy') which is deprecated; if kept, migrate to navigator.clipboard.writeText.

🧰 Tools
🪛 HTMLHint (1.9.2)

[warning] 532-532: The type attribute must be present on elements.

(button-type-require)


[warning] 745-745: The type attribute must be present on

elements.

(button-type-require)


[warning] 746-746: The type attribute must be present on

elements.

(button-type-require)


[warning] 785-785: The type attribute must be present on

elements.

(button-type-require)


[warning] 794-794: The type attribute must be present on

elements.

(button-type-require)


[warning] 795-795: The type attribute must be present on

elements.

(button-type-require)


[warning] 796-796: The type attribute must be present on

elements.

(button-type-require)


[warning] 797-797: The type attribute must be present on

elements.

(button-type-require)


[error] 867-867: Special characters must be escaped : [ > ].

(spec-char-escape)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@dashboard.html` around lines 1 - 867, dashboard.html is a stale, standalone
mock that duplicates the real dashboard under web-dashboard and contains
hardcoded content and a deprecated clipboard call; remove dashboard.html
(preferred) or rename/move it to a design-mocks folder and add a clear header
comment marking it as a mock, and while doing so update the deprecated
copyToClipboard function (and any uses from executeCmd, runAutoPipeline,
trackAction) to use navigator.clipboard.writeText with proper Promise
handling/fallback instead of document.execCommand('copy').

Comment thread dashboard.html
Comment on lines +847 to +856
function runAutoPipeline() {
const input = document.getElementById('jd-input').value.trim();
if (!input) {
logToTerminal('error', 'No JD or URL provided.', 'error');
return;
}
const command = `/career-ops ${input}`;
logToTerminal('auto-pipeline', `Starting auto-pipeline for: ${input.substring(0, 30)}...`);
copyToClipboard(command);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Argument mismatch in logToTerminal('error', ...) error path.

logToTerminal(command, output, type) renders $/career-ops ${command}, so the error branch logs $/career-ops error as the command and passes 'No JD or URL provided.' as the output — but the third 'error' argument ends up unused because type is the third positional parameter, which is already 'error', so this particular call is accidentally correct. However the semantics are still wrong: the displayed command line is misleading ("/career-ops error"). Pass a neutral command label or skip the command echo for validation errors.

♻️ Proposed fix
-            logToTerminal('error', 'No JD or URL provided.', 'error');
+            logToTerminal('auto-pipeline', 'No JD or URL provided.', 'error');
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function runAutoPipeline() {
const input = document.getElementById('jd-input').value.trim();
if (!input) {
logToTerminal('error', 'No JD or URL provided.', 'error');
return;
}
const command = `/career-ops ${input}`;
logToTerminal('auto-pipeline', `Starting auto-pipeline for: ${input.substring(0, 30)}...`);
copyToClipboard(command);
}
function runAutoPipeline() {
const input = document.getElementById('jd-input').value.trim();
if (!input) {
logToTerminal('auto-pipeline', 'No JD or URL provided.', 'error');
return;
}
const command = `/career-ops ${input}`;
logToTerminal('auto-pipeline', `Starting auto-pipeline for: ${input.substring(0, 30)}...`);
copyToClipboard(command);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@dashboard.html` around lines 847 - 856, The runAutoPipeline validation path
currently calls logToTerminal('error', 'No JD or URL provided.', 'error') which
causes the command echo to show "/career-ops error"; update the validation
branch in runAutoPipeline so it does not pass "error" as the command — instead
call logToTerminal with a neutral/empty command label (e.g. '' or null) and the
message as the second argument and 'error' as the third, or use a dedicated
helper that logs only the message; change the call near the jd-input read in
runAutoPipeline and ensure any copyToClipboard/command construction only runs
after validation succeeds.

Comment thread dashboard.html
Comment on lines +864 to +867

</body>
</html>
tml>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Corrupted trailing content after </html>.

Line 867 contains a stray tml> fragment after the valid document close on line 866 (also flagged by HTMLHint). Remove it.

🐛 Proposed fix
 </body>
 </html>
-tml>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
</body>
</html>
tml>
</body>
</html>
🧰 Tools
🪛 HTMLHint (1.9.2)

[error] 867-867: Special characters must be escaped : [ > ].

(spec-char-escape)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@dashboard.html` around lines 864 - 867, Remove the stray "tml>" fragment that
appears after the closing </html> tag in dashboard.html (the stray token shown
as "tml>") so the document ends cleanly with a single </html> and no trailing
characters; locate the closing </html> and delete the extra "tml>" token to fix
the corrupted trailing content.

Comment on lines +220 to +240
</html>
ton>
</div>
</div>
</div>
</div>
</div>

<div id="report-panel" class="overlay">
<div class="panel-header">
<h2 id="report-title">Report</h2>
<button onclick="closeReport()" class="close-btn">&times;</button>
</div>
<div id="report-content" class="overlay-body"></div>
</div>

<div id="toast">Command sent</div>

<script src="app.js"></script>
</body>
</html>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Corrupted trailing content after </html> — breaks DOM uniqueness.

After the valid close at line 220, the file contains a stray ton> fragment (line 221, truncated button close) plus a full duplicated block: another #report-panel, #report-title, #report-content, #toast, another <script src="app.js">, and duplicate </body></html>. This produces:

  • Duplicate element IDs (flagged by HTMLHint: report-panel, report-title, report-content, toast), so document.getElementById(...) in app.js will target the first (now possibly hidden-by-second) node and openReport/closeReport/showToast will behave unpredictably.
  • app.js will be included and executed twice (double loadDashboard on window.onload, double fetches, double event wiring).
  • Unparseable HTML per static analysis (unpaired tags, unescaped >).

This looks like a bad merge/paste. Truncate the file at the original </html> on line 220.

🐛 Proposed fix
 </body>
 </html>
-ton>
-                </div>
-            </div>
-        </div>
-    </div>
-</div>
-
-<div id="report-panel" class="overlay">
-    <div class="panel-header">
-        <h2 id="report-title">Report</h2>
-        <button onclick="closeReport()" class="close-btn">&times;</button>
-    </div>
-    <div id="report-content" class="overlay-body"></div>
-</div>
-
-<div id="toast">Command sent</div>
-
-<script src="app.js"></script>
-</body>
-</html>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
</html>
ton>
</div>
</div>
</div>
</div>
</div>
<div id="report-panel" class="overlay">
<div class="panel-header">
<h2 id="report-title">Report</h2>
<button onclick="closeReport()" class="close-btn">&times;</button>
</div>
<div id="report-content" class="overlay-body"></div>
</div>
<div id="toast">Command sent</div>
<script src="app.js"></script>
</body>
</html>
</html>
🧰 Tools
🪛 HTMLHint (1.9.2)

[error] 221-221: Special characters must be escaped : [ > ].

(spec-char-escape)


[error] 222-222: Tag must be paired, no start tag: [ ]

(tag-pair)


[error] 223-223: Tag must be paired, no start tag: [ ]

(tag-pair)


[error] 224-224: Tag must be paired, no start tag: [ ]

(tag-pair)


[error] 225-225: Tag must be paired, no start tag: [ ]

(tag-pair)


[error] 226-226: Tag must be paired, no start tag: [ ]

(tag-pair)


[error] 228-228: The id value [ report-panel ] must be unique.

(id-unique)


[error] 230-230: The id value [ report-title ] must be unique.

(id-unique)


[warning] 231-231: The type attribute must be present on elements.

(button-type-require)


[error] 233-233: The id value [ report-content ] must be unique.

(id-unique)


[error] 236-236: The id value [ toast ] must be unique.

(id-unique)


[error] 239-239: Tag must be paired, no start tag: [ ]

(tag-pair)


[error] 240-240: Tag must be paired, no start tag: [ ]

(tag-pair)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web-dashboard/public/index.html` around lines 220 - 240, Remove the
corrupted/trailing duplicate HTML after the first closing </html> so only one
DOM and one script load remain;	delete the stray "ton>" fragment and the
duplicated block that redefines ids report-panel, report-title, report-content,
toast and the second <script src="app.js">, leaving a single well-formed closing
</body></html>. Ensure only one inclusion of app.js and that functions
referenced in that script (openReport, closeReport, showToast, loadDashboard)
target the unique elements with ids
report-panel/report-title/report-content/toast.

Comment on lines +59 to +80
.nav-btn {
background: none;
border: none;
color: var(--text-secondary);
text-align: left;
padding: 0.75rem 1rem;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
border-radius: 0.5rem;
transition: all 0.2s;
}

.nav-btn:hover {
background-color: #334155;
color: var(--text-primary);
}

.nav-btn.active {
background-color: var(--accent);
color: white;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add visible keyboard focus states for interactive controls.

The dashboard styles hover/active states but not focus states, so keyboard users can lose track of nav buttons, terminal actions, omnibox results, and the close button.

♿ Proposed fix
 .nav-btn:hover {
     background-color: `#334155`;
     color: var(--text-primary);
 }
+
+.nav-btn:focus-visible,
+.terminal-actions button:focus-visible,
+.omnibox-item:focus-visible,
+.close-btn:focus-visible {
+    outline: 2px solid var(--accent);
+    outline-offset: 2px;
+}

Also applies to: 240-249, 292-301, 395-400

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web-dashboard/public/style.css` around lines 59 - 80, Add visible keyboard
focus states for interactive controls by defining :focus and preferably
:focus-visible styles that mirror or complement existing hover/active styles;
update the CSS for .nav-btn (alongside the other interactive classes noted) to
include a clear focus outline or box-shadow and sufficient contrast (e.g.,
visible outline color or a subtle background color change) while keeping rounded
corners and transitions consistent with .nav-btn:hover and .nav-btn.active;
ensure the focus style is applied to keyboard focus (use :focus-visible if
supported) and does not rely on hover-only cues so keyboard users can track nav
buttons, terminal actions, omnibox results, and the close button.

Comment thread web-dashboard/server.mjs
Comment on lines +17 to +31
app.get('/api/applications', (req, res) => {
const filePath = path.join(ROOT_DIR, 'data', 'applications.md');
if (!fs.existsSync(filePath)) return res.json([]);
const content = fs.readFileSync(filePath, 'utf8');
const lines = content.split('\n').map(l => l.trim()).filter(line => line.startsWith('|') && !line.includes('---'));
if (lines.length < 2) return res.json([]);
const headers = lines[0].split('|').map(h => h.trim().toLowerCase()).filter(Boolean);
const data = lines.slice(1).map(line => {
const values = line.split('|').map(v => v.trim()).filter((v, i) => i > 0 && i <= headers.length);
const obj = {};
headers.forEach((header, index) => { obj[header] = values[index] || ''; });
return obj;
});
res.json(data);
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fragile markdown-table parsing and blocking I/O on the request thread.

  • filter(line => line.startsWith('|') && !line.includes('---')) drops any legitimate row containing --- (e.g., a company name like Acme --- Remote or an em-dash separator if re-encoded). Prefer matching the separator row by shape, e.g. /^\|\s*[:\-\s|]+\|\s*$/.
  • fs.existsSync + fs.readFileSync on every request block the event loop. Use fs.promises.readFile with a try/catch on ENOENT.
  • On parse failure, the endpoint currently throws (uncaught) → Express returns 500 with no JSON. app.js#loadDashboard calls response.json() without checking response.ok, so the UI silently breaks. Always return JSON (res.status(500).json({ error })).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web-dashboard/server.mjs` around lines 17 - 31, The current
app.get('/api/applications') handler uses blocking fs.existsSync/fs.readFileSync
and a fragile row filter that drops any row containing '---', and it can throw
unhandled errors; change it to use fs.promises.readFile inside an async handler
with try/catch (handle ENOENT by returning an empty array), replace the
simplistic filter(line => line.startsWith('|') && !line.includes('---')) with a
separator-detecting regex like /^\|\s*[:\-\s|]+\|\s*$/ to exclude only the
Markdown separator row while keeping legitimate content, keep the parsing logic
that builds headers and the data object (the headers.forEach(...) and values
mapping) but defensively bound indexes, and on any parse or I/O failure return
JSON errors (e.g., res.status(500).json({ error: error.message })) instead of
letting the request throw.

Comment thread web-dashboard/server.mjs
Comment on lines +33 to +49
app.get('/api/reports/detail', async (req, res) => {
const reportPath = req.query.path;
if (typeof reportPath !== 'string') return res.status(400).send('Invalid path');

const absolutePath = path.resolve(ROOT_DIR, reportPath);
const reportsDir = path.resolve(ROOT_DIR, 'reports');
const dataDir = path.resolve(ROOT_DIR, 'data');

if (!absolutePath.startsWith(reportsDir) && !absolutePath.startsWith(dataDir)) {
return res.status(403).send('Forbidden: Access outside allowed directories');
}

if (path.extname(absolutePath) !== '.md') {
return res.status(403).send('Forbidden: Only Markdown files are allowed');
}

if (!fs.existsSync(absolutePath)) return res.status(404).send('Report not found');
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Path traversal: startsWith prefix-collision and insufficient validation.

absolutePath.startsWith(reportsDir) lets sibling directories whose names share the prefix slip through. If ROOT_DIR/reports is /x/reports, then a request for path=../reports-secret/leak.md resolves to /x/reports-secret/leak.md, which passes startsWith('/x/reports') and, because it ends in .md, is returned. Use path.relative (or compare with a trailing separator) so only true descendants are allowed. Also reject symlinks via fs.realpath to prevent symlink escape, and reject absolute paths early (path.isAbsolute(reportPath) → 400).

🔒 Proposed fix
-  const absolutePath = path.resolve(ROOT_DIR, reportPath);
-  const reportsDir = path.resolve(ROOT_DIR, 'reports');
-  const dataDir = path.resolve(ROOT_DIR, 'data');
-
-  if (!absolutePath.startsWith(reportsDir) && !absolutePath.startsWith(dataDir)) {
-    return res.status(403).send('Forbidden: Access outside allowed directories');
-  }
+  if (path.isAbsolute(reportPath) || reportPath.includes('\0')) {
+    return res.status(400).send('Invalid path');
+  }
+  const absolutePath = path.resolve(ROOT_DIR, reportPath);
+  const isUnder = (child, parent) => {
+    const rel = path.relative(parent, child);
+    return rel && !rel.startsWith('..') && !path.isAbsolute(rel);
+  };
+  const reportsDir = path.resolve(ROOT_DIR, 'reports');
+  const dataDir = path.resolve(ROOT_DIR, 'data');
+  if (!isUnder(absolutePath, reportsDir) && !isUnder(absolutePath, dataDir)) {
+    return res.status(403).send('Forbidden: Access outside allowed directories');
+  }

Consider also resolving fs.realpathSync(absolutePath) and re-checking, to defeat symlinks inside reports/ or data/ that point outside.

As per coding guidelines for **/*.mjs: "Check for command injection, path traversal, and SSRF."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
app.get('/api/reports/detail', async (req, res) => {
const reportPath = req.query.path;
if (typeof reportPath !== 'string') return res.status(400).send('Invalid path');
const absolutePath = path.resolve(ROOT_DIR, reportPath);
const reportsDir = path.resolve(ROOT_DIR, 'reports');
const dataDir = path.resolve(ROOT_DIR, 'data');
if (!absolutePath.startsWith(reportsDir) && !absolutePath.startsWith(dataDir)) {
return res.status(403).send('Forbidden: Access outside allowed directories');
}
if (path.extname(absolutePath) !== '.md') {
return res.status(403).send('Forbidden: Only Markdown files are allowed');
}
if (!fs.existsSync(absolutePath)) return res.status(404).send('Report not found');
app.get('/api/reports/detail', async (req, res) => {
const reportPath = req.query.path;
if (typeof reportPath !== 'string') return res.status(400).send('Invalid path');
if (path.isAbsolute(reportPath) || reportPath.includes('\0')) {
return res.status(400).send('Invalid path');
}
const absolutePath = path.resolve(ROOT_DIR, reportPath);
const isUnder = (child, parent) => {
const rel = path.relative(parent, child);
return rel && !rel.startsWith('..') && !path.isAbsolute(rel);
};
const reportsDir = path.resolve(ROOT_DIR, 'reports');
const dataDir = path.resolve(ROOT_DIR, 'data');
if (!isUnder(absolutePath, reportsDir) && !isUnder(absolutePath, dataDir)) {
return res.status(403).send('Forbidden: Access outside allowed directories');
}
if (path.extname(absolutePath) !== '.md') {
return res.status(403).send('Forbidden: Only Markdown files are allowed');
}
if (!fs.existsSync(absolutePath)) return res.status(404).send('Report not found');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web-dashboard/server.mjs` around lines 33 - 49, Reject absolute incoming
paths early (use path.isAbsolute on reportPath), then compute absolutePath as
you already do and resolve any symlinks with fs.realpathSync(absolutePath)
before further checks; compute realReports = fs.realpathSync(reportsDir) and
realData = fs.realpathSync(dataDir) and use path.relative(realReports, realPath)
and path.relative(realData, realPath) to ensure the resolved path is a true
descendant (relative must not start with '..' and must not be equal to '..');
keep the existing extension check on the resolved path and return 400/403/404 as
appropriate if the checks fail.

Comment thread web-dashboard/server.mjs
Comment on lines +60 to +93
app.post('/api/terminal-send', (req, res) => {
const { command } = req.body;
if (!command) return res.status(400).send('No command');

// Clean the command for AppleScript
const cleanCmd = command.replace(/"/g, '\\"');

const script = `
tell application "System Events"
set isRunning to (name of processes) contains "iTerm"
end tell
if isRunning then
tell application "iTerm"
activate
tell current session of current window
write text "${cleanCmd}"
end tell
end tell
else
tell application "Terminal"
activate
do script "${cleanCmd}" in front window
end tell
end if
`;

exec(`osascript -e '${script}'`, (err) => {
if (err) {
console.error('AppleScript Error:', err);
return res.status(500).json({ error: err.message });
}
res.json({ success: true });
});
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: command injection in /api/terminal-send.

cleanCmd only escapes double quotes, but the AppleScript is then passed as a single-quoted shell argument: osascript -e '${script}'. A single quote anywhere in the POSTed command terminates the shell quoting and lets the caller append arbitrary shell. Example body:

{ "command": "x'; rm -rf ~; echo '" }

…produces osascript -e '... write text "x'; rm -rf ~; echo '"..."', which executes rm -rf ~. Even if the dashboard is "local-only", it's bound to 0.0.0.0 by default and is a trivial RCE surface (no auth, no origin check, CORS defaults).

Additionally, the AppleScript string itself is interpolated with cleanCmd — backslashes, backticks, and embedded newlines are not handled, so AppleScript injection is also possible even with proper shell quoting.

Fixes (apply all):

  1. Don't shell-out with string interpolation. Use execFile('osascript', ['-e', script]) so the script is a single argv element.
  2. Don't interpolate user input into AppleScript source. Pass it via osascript -e '…' -- "user command" and reference item 1 of argv inside the script, or use a here-document written to a temp file.
  3. Bind the listener to 127.0.0.1 only and add an auth token / origin check.
  4. Validate/limit command length and charset (e.g., must start with /career-ops ).
  5. Reject when process.platform !== 'darwin' instead of silently running osascript (which will fail on Linux/Windows).
🔒 Sketch of a safer implementation
-app.post('/api/terminal-send', (req, res) => {
-  const { command } = req.body;
-  if (!command) return res.status(400).send('No command');
-
-  // Clean the command for AppleScript
-  const cleanCmd = command.replace(/"/g, '\\"');
-
-  const script = `
-    tell application "System Events"
-        set isRunning to (name of processes) contains "iTerm"
-    end tell
-    if isRunning then
-        tell application "iTerm"
-            activate
-            tell current session of current window
-                write text "${cleanCmd}"
-            end tell
-        end tell
-    else
-        tell application "Terminal"
-            activate
-            do script "${cleanCmd}" in front window
-        end tell
-    end if
-  `;
-
-  exec(`osascript -e '${script}'`, (err) => {
+import { execFile } from 'child_process';
+
+app.post('/api/terminal-send', (req, res) => {
+  if (process.platform !== 'darwin') {
+    return res.status(501).json({ error: 'Terminal bridge only supported on macOS' });
+  }
+  const { command } = req.body;
+  if (typeof command !== 'string' || !command.startsWith('/career-ops ') || command.length > 2000) {
+    return res.status(400).json({ error: 'Invalid command' });
+  }
+
+  // Pass the command via argv so AppleScript reads it as data, not code.
+  const script = `
+    on run argv
+      set cmd to item 1 of argv
+      tell application "System Events"
+        set isRunning to (name of processes) contains "iTerm"
+      end tell
+      if isRunning then
+        tell application "iTerm"
+          activate
+          tell current session of current window to write text cmd
+        end tell
+      else
+        tell application "Terminal"
+          activate
+          do script cmd in front window
+        end tell
+      end if
+    end run
+  `;
+
+  execFile('osascript', ['-e', script, '--', command], (err) => {
     if (err) {
       console.error('AppleScript Error:', err);
       return res.status(500).json({ error: err.message });
     }
     res.json({ success: true });
   });
 });

Also app.listen(PORT, '127.0.0.1', …) instead of the default all-interfaces bind.

As per coding guidelines for **/*.mjs: "Check for command injection, path traversal, and SSRF."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web-dashboard/server.mjs` around lines 60 - 93, The POST handler for
'/api/terminal-send' is vulnerable to shell and AppleScript injection because it
interpolates user input into a single-quoted osascript command (cleanCmd/script)
and uses exec with a shell; fix by: (1) refuse non-macOS early (check
process.platform !== 'darwin' and return 400), (2) stop using exec with a shell
and call execFile('osascript', ['-e', script], ...) so the script is passed as
one argv, (3) never interpolate user input into the AppleScript source — pass
the user command as an argument to osascript (e.g., execFile('osascript', ['-e',
scriptUsingItem1, '--', command]) and reference item 1 of argv in the
AppleScript), (4) validate and limit req.body.command (max length, allowed
charset/prefix like '/career-ops '), and (5) bind the server to 127.0.0.1 and
add an auth token/origin check before executing (update app.listen binding and
the '/api/terminal-send' handler accordingly).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant