mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-11-02 13:12:50 +00:00
fix(freehire-search): reject an unknown --remote mode (#661)
* fix(freehire-search): reject an unknown --remote mode --remote foo was forwarded as work_mode=foo and the search exited 0 with an empty result set, so a typo looked like no matching jobs. Validate against the documented remote|hybrid|onsite values (plus on-site) before dispatch; a bare --remote still means remote. * fix(freehire-search): accept comma-separated --remote modes freehire ORs a comma-separated work_mode, so --remote remote,hybrid is a valid query. Split --remote on commas, normalise each item (on-site becomes onsite) and re-join them. An unknown item such as foo in remote,foo still exits 1 with BAD_ARG naming that item. * changelog: place the #661 entry under [Unreleased] Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4 files changed
+146
-3
No files matched your search
@@ -129,7 +129,7 @@ Facet filters (values come from freehire's controlled vocabularies; comma-separa
|
|||||||
- `--category <cats>` — `backend`, `frontend`, `fullstack`, `devops`, `ml_ai`, `qa`, …
|
- `--category <cats>` — `backend`, `frontend`, `fullstack`, `devops`, `ml_ai`, `qa`, …
|
||||||
- `--skill <names>` — canonical skill(s), e.g. `--skill go,kubernetes`
|
- `--skill <names>` — canonical skill(s), e.g. `--skill go,kubernetes`
|
||||||
- `--company <slug>` — company slug (from a result's `company_slug`)
|
- `--company <slug>` — company slug (from a result's `company_slug`)
|
||||||
- `--remote <mode>` — `remote` | `hybrid` | `onsite` (`work_mode` facet)
|
- `--remote <mode>` — `remote` | `hybrid` | `onsite` (`work_mode` facet). A bare `--remote` means `remote`. Comma-separate for OR (`remote,hybrid`; `on-site` normalises to `onsite`). An unknown item exits 1 with `BAD_ARG`.
|
||||||
- `--facet <key=value>` — any other facet param (repeatable), e.g. `--facet salary_min=100000`
|
- `--facet <key=value>` — any other facet param (repeatable), e.g. `--facet salary_min=100000`
|
||||||
|
|
||||||
> **Location is a facet, not free text.** Unlike `linkedin-search`'s `--location`,
|
> **Location is a facet, not free text.** Unlike `linkedin-search`'s `--location`,
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ FACET FILTERS (values from freehire.me's controlled vocabularies; comma = OR)
|
|||||||
--skill <names> Canonical skill(s), e.g. --skill go,kubernetes
|
--skill <names> Canonical skill(s), e.g. --skill go,kubernetes
|
||||||
--company <slug> Company slug (from a result's company_slug).
|
--company <slug> Company slug (from a result's company_slug).
|
||||||
--remote <mode> remote | hybrid | onsite (work_mode facet).
|
--remote <mode> remote | hybrid | onsite (work_mode facet).
|
||||||
|
A bare --remote means remote. Comma = OR. Unknown values exit 1.
|
||||||
--facet <key=value> Any other facet param (repeatable), e.g. --facet salary_min=100000
|
--facet <key=value> Any other facet param (repeatable), e.g. --facet salary_min=100000
|
||||||
|
|
||||||
DETAIL
|
DETAIL
|
||||||
@@ -117,6 +118,42 @@ Reads are public (no API key). Source: ${baseUrl()} — a personal project,
|
|||||||
best-effort, no SLA. Override with FREEHIRE_API_URL to use a self-hosted backend.
|
best-effort, no SLA. Override with FREEHIRE_API_URL to use a self-hosted backend.
|
||||||
`
|
`
|
||||||
|
|
||||||
|
const WORK_MODES = ["remote", "hybrid", "onsite"] as const
|
||||||
|
type WorkMode = (typeof WORK_MODES)[number]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map --remote to the work_mode facet.
|
||||||
|
* A bare --remote (boolean true) is the documented "remote" shortcut.
|
||||||
|
* Comma-separated values are OR: each item is normalised (`on-site` ->
|
||||||
|
* `onsite`) and re-joined. An unknown item is rejected (the API treats it
|
||||||
|
* as a filter that matches nothing) and `bad` names that item.
|
||||||
|
*/
|
||||||
|
function parseWorkMode(raw: FlagValue): { value?: string; bad?: string } {
|
||||||
|
if (raw === undefined) return {}
|
||||||
|
if (raw === true) return { value: "remote" }
|
||||||
|
if (typeof raw !== "string" || !raw.trim()) return { bad: String(raw) }
|
||||||
|
const modes: WorkMode[] = []
|
||||||
|
for (const item of raw.split(",")) {
|
||||||
|
const token = item.trim()
|
||||||
|
if (!token) continue
|
||||||
|
switch (token.toLowerCase()) {
|
||||||
|
case "remote":
|
||||||
|
modes.push("remote")
|
||||||
|
break
|
||||||
|
case "hybrid":
|
||||||
|
modes.push("hybrid")
|
||||||
|
break
|
||||||
|
case "onsite":
|
||||||
|
case "on-site":
|
||||||
|
modes.push("onsite")
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
return { bad: token }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return modes.length > 0 ? { value: modes.join(",") } : { bad: raw.trim() }
|
||||||
|
}
|
||||||
|
|
||||||
function parseIntFlag(name: string, raw: string | boolean | string[]): number | null {
|
function parseIntFlag(name: string, raw: string | boolean | string[]): number | null {
|
||||||
// Number(), not parseInt(): parseInt truncates, so "--jobage 0.5" became 0,
|
// Number(), not parseInt(): parseInt truncates, so "--jobage 0.5" became 0,
|
||||||
// which fails search.ts's `jobage > 0` guard and silently drops
|
// which fails search.ts's `jobage > 0` guard and silently drops
|
||||||
@@ -234,6 +271,17 @@ async function main(): Promise<number> {
|
|||||||
facets[key] = (facets[key] ?? []).concat(vals)
|
facets[key] = (facets[key] ?? []).concat(vals)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parsedRemote = parseWorkMode(flags.remote)
|
||||||
|
if (parsedRemote.bad !== undefined) {
|
||||||
|
process.stderr.write(
|
||||||
|
JSON.stringify({
|
||||||
|
error: `--remote must be one of remote|hybrid|onsite, got "${parsedRemote.bad}"`,
|
||||||
|
code: "BAD_ARG",
|
||||||
|
}) + "\n",
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
const opts: SearchOpts = {
|
const opts: SearchOpts = {
|
||||||
query: stringFlag(flags.query),
|
query: stringFlag(flags.query),
|
||||||
jobage: flags.jobage ? parseInt(flags.jobage as string, 10) : 9999,
|
jobage: flags.jobage ? parseInt(flags.jobage as string, 10) : 9999,
|
||||||
@@ -249,8 +297,7 @@ async function main(): Promise<number> {
|
|||||||
category: commaList(flags.category),
|
category: commaList(flags.category),
|
||||||
skills: commaList(flags.skill),
|
skills: commaList(flags.skill),
|
||||||
company: stringFlag(flags.company),
|
company: stringFlag(flags.company),
|
||||||
// --remote <mode> takes the given work_mode; a bare --remote means "remote".
|
workMode: parsedRemote.value,
|
||||||
workMode: stringFlag(flags.remote, "remote"),
|
|
||||||
facets,
|
facets,
|
||||||
}
|
}
|
||||||
return runSearch(opts)
|
return runSearch(opts)
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import { runCLI } from "./helpers";
|
||||||
|
|
||||||
|
function parsedStderr(stderr: string): { error?: string; code?: string } {
|
||||||
|
try {
|
||||||
|
return JSON.parse(stderr);
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchRemote(args: string[]): Promise<{ result: Awaited<ReturnType<typeof runCLI>>; requests: URL[] }> {
|
||||||
|
const requests: URL[] = [];
|
||||||
|
const server = Bun.serve({
|
||||||
|
hostname: "127.0.0.1",
|
||||||
|
port: 0,
|
||||||
|
fetch(request) {
|
||||||
|
requests.push(new URL(request.url));
|
||||||
|
return Response.json({ data: [], meta: { total: 0 } });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const result = await runCLI(["search", "-q", "go", "--limit", "1", ...args], {
|
||||||
|
FREEHIRE_API_URL: server.url.toString(),
|
||||||
|
});
|
||||||
|
return { result, requests };
|
||||||
|
} finally {
|
||||||
|
server.stop(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("--remote work_mode validation", () => {
|
||||||
|
test("--remote foo exits 1 with BAD_ARG and does not call the API", async () => {
|
||||||
|
const { result, requests } = await searchRemote(["--remote", "foo"]);
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
expect(result.stdout).toBe("");
|
||||||
|
expect(parsedStderr(result.stderr).code).toBe("BAD_ARG");
|
||||||
|
expect(parsedStderr(result.stderr).error).toContain("--remote");
|
||||||
|
expect(parsedStderr(result.stderr).error).toContain("foo");
|
||||||
|
expect(requests).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("comma-separated modes are normalised and re-joined", async () => {
|
||||||
|
for (const [flag, expected] of [
|
||||||
|
["remote,hybrid", "remote,hybrid"],
|
||||||
|
["remote,on-site", "remote,onsite"],
|
||||||
|
] as const) {
|
||||||
|
const { result, requests } = await searchRemote(["--remote", flag]);
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
expect(result.stderr).toBe("");
|
||||||
|
expect(requests).toHaveLength(1);
|
||||||
|
expect(requests[0].searchParams.get("work_mode")).toBe(expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("--remote remote,foo exits 1 with BAD_ARG naming foo and does not call the API", async () => {
|
||||||
|
const { result, requests } = await searchRemote(["--remote", "remote,foo"]);
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
expect(result.stdout).toBe("");
|
||||||
|
expect(parsedStderr(result.stderr).code).toBe("BAD_ARG");
|
||||||
|
expect(parsedStderr(result.stderr).error).toContain('"foo"');
|
||||||
|
expect(requests).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("documented modes and on-site reach work_mode", async () => {
|
||||||
|
for (const [flag, expected] of [
|
||||||
|
["remote", "remote"],
|
||||||
|
["hybrid", "hybrid"],
|
||||||
|
["onsite", "onsite"],
|
||||||
|
["on-site", "onsite"],
|
||||||
|
["Remote", "remote"],
|
||||||
|
] as const) {
|
||||||
|
const { result, requests } = await searchRemote(["--remote", flag]);
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
expect(result.stderr).toBe("");
|
||||||
|
expect(requests).toHaveLength(1);
|
||||||
|
expect(requests[0].searchParams.get("work_mode")).toBe(expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bare --remote still means work_mode=remote", async () => {
|
||||||
|
const { result, requests } = await searchRemote(["--remote"]);
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
expect(requests).toHaveLength(1);
|
||||||
|
expect(requests[0].searchParams.get("work_mode")).toBe("remote");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("omitting --remote does not send work_mode", async () => {
|
||||||
|
const { result, requests } = await searchRemote([]);
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
expect(requests).toHaveLength(1);
|
||||||
|
expect(requests[0].searchParams.has("work_mode")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -203,6 +203,8 @@ per-file diff commands.
|
|||||||
web search only for that last escalation step, finding the employer's own copy of the posting; company research and salary
|
web search only for that last escalation step, finding the employer's own copy of the posting; company research and salary
|
||||||
lookups stay out of `/rank`.
|
lookups stay out of `/rank`.
|
||||||
|
|
||||||
|
- **`freehire-search` rejects an unknown `--remote` mode instead of searching a facet that matches nothing** (#661, `.agents/skills/freehire-search/cli/src/cli.ts`, `cli/tests/remote-mode.test.ts`) - `--remote foo` was forwarded as `work_mode=foo` and the CLI exited 0 with an empty result set, so a typo looked like "no jobs". The documented `remote` / `hybrid` / `onsite` values, mixed case, and `on-site` now map to the facet; a bare `--remote` still means `remote`. Comma-separated modes are validated per item and re-joined (`remote,hybrid`, `remote,on-site` → `remote,onsite`); an unknown item such as `foo` in `remote,foo` exits 1 with `BAD_ARG` and does not call the API.
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
- **CI no longer leaves the job token in `.git/config` or expands a PR-controlled name into a
|
- **CI no longer leaves the job token in `.git/config` or expands a PR-controlled name into a
|
||||||
|
|||||||
Reference in new issue
Block a user