hirify-cli 0.4.2 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/bin/hirify.js +176 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,6 +5,9 @@ account already has, search across the board, a vacancy in full, and the contact
|
|
|
5
5
|
|
|
6
6
|
Node 18 or newer. No dependencies.
|
|
7
7
|
|
|
8
|
+
A global installation checks for a new release when it starts and updates itself before running
|
|
9
|
+
the command. Set `HIRIFY_NO_AUTO_UPDATE=1` only when the installed version must stay fixed.
|
|
10
|
+
|
|
8
11
|
Install it globally with `npm install -g hirify-cli`, or run it without installing through `npx`
|
|
9
12
|
as shown below. In either case, the command is `hirify` after a global install.
|
|
10
13
|
|
package/bin/hirify.js
CHANGED
|
@@ -15,7 +15,7 @@ import { homedir } from 'node:os'
|
|
|
15
15
|
import { join, dirname } from 'node:path'
|
|
16
16
|
import { fileURLToPath } from 'node:url'
|
|
17
17
|
import { createServer } from 'node:http'
|
|
18
|
-
import { spawn } from 'node:child_process'
|
|
18
|
+
import { execFileSync, spawn, spawnSync } from 'node:child_process'
|
|
19
19
|
import { randomBytes, createHash } from 'node:crypto'
|
|
20
20
|
|
|
21
21
|
const API = process.env.HIRIFY_API || 'https://api.hirify.me'
|
|
@@ -61,10 +61,14 @@ const VERSION = (() => {
|
|
|
61
61
|
}
|
|
62
62
|
})()
|
|
63
63
|
const USER_AGENT = `hirify-cli/${VERSION}`
|
|
64
|
+
const PACKAGE_NAME = 'hirify-cli'
|
|
65
|
+
const PACKAGE_LATEST = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`
|
|
66
|
+
const NPM = process.platform === 'win32' ? 'npm.cmd' : 'npm'
|
|
64
67
|
|
|
65
68
|
const HELP = `hirify - job search for AI agents
|
|
66
69
|
|
|
67
70
|
hirify intro what this can do, and in what order
|
|
71
|
+
hirify version installed version
|
|
68
72
|
|
|
69
73
|
hirify login sign in through your browser [--force]
|
|
70
74
|
hirify logout sign out on this computer
|
|
@@ -114,6 +118,88 @@ const EXIT_MANIFEST_UNSUPPORTED = 2
|
|
|
114
118
|
|
|
115
119
|
const die = (msg, code = EXIT_ERROR) => { console.error(`hirify: ${msg}`); process.exit(code) }
|
|
116
120
|
|
|
121
|
+
/** Compare the public numeric part of two npm versions without adding a dependency. */
|
|
122
|
+
function newerVersion(candidate, current) {
|
|
123
|
+
const parts = (value) => {
|
|
124
|
+
const match = String(value).match(/^(\d+)\.(\d+)\.(\d+)(?:-|$)/)
|
|
125
|
+
return match ? match.slice(1).map(Number) : null
|
|
126
|
+
}
|
|
127
|
+
const next = parts(candidate)
|
|
128
|
+
const installed = parts(current)
|
|
129
|
+
if (!next || !installed) return false
|
|
130
|
+
for (let i = 0; i < 3; i++) {
|
|
131
|
+
if (next[i] !== installed[i]) return next[i] > installed[i]
|
|
132
|
+
}
|
|
133
|
+
return false
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A package launched from npx already follows the requested npm version. Only a global
|
|
138
|
+
* installation is ours to update: local project dependencies and the npx cache must not be
|
|
139
|
+
* changed behind their owner. `npm root -g` is asked only when this file is under node_modules,
|
|
140
|
+
* so a checkout and the test suite take no extra process or network trip.
|
|
141
|
+
*/
|
|
142
|
+
function isGlobalInstall() {
|
|
143
|
+
const script = fileURLToPath(import.meta.url)
|
|
144
|
+
if (!script.includes(`${join('node_modules', PACKAGE_NAME)}`)) return false
|
|
145
|
+
try {
|
|
146
|
+
const root = execFileSync(NPM, ['root', '-g'], {
|
|
147
|
+
encoding: 'utf8',
|
|
148
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
149
|
+
timeout: 2000,
|
|
150
|
+
}).trim()
|
|
151
|
+
return script.startsWith(join(root, PACKAGE_NAME) + '/') || script.startsWith(join(root, PACKAGE_NAME) + '\\')
|
|
152
|
+
} catch {
|
|
153
|
+
return false
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Update a global installation before running the requested command. A failed registry check is
|
|
159
|
+
* silent because job search must keep working offline. A failed install leaves one exact recovery
|
|
160
|
+
* command and then continues with the installed version.
|
|
161
|
+
*/
|
|
162
|
+
async function autoUpdate() {
|
|
163
|
+
if (process.env.HIRIFY_NO_AUTO_UPDATE === '1' || !isGlobalInstall()) return false
|
|
164
|
+
|
|
165
|
+
const controller = new AbortController()
|
|
166
|
+
const timeout = setTimeout(() => controller.abort(), 2000)
|
|
167
|
+
let latest
|
|
168
|
+
try {
|
|
169
|
+
const response = await fetch(PACKAGE_LATEST, {
|
|
170
|
+
headers: { Accept: 'application/json', 'User-Agent': USER_AGENT },
|
|
171
|
+
signal: controller.signal,
|
|
172
|
+
})
|
|
173
|
+
if (!response.ok) return false
|
|
174
|
+
latest = (await response.json())?.version
|
|
175
|
+
} catch {
|
|
176
|
+
return false
|
|
177
|
+
} finally {
|
|
178
|
+
clearTimeout(timeout)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (!newerVersion(latest, VERSION)) return false
|
|
182
|
+
|
|
183
|
+
console.error(`Updating Hirify CLI from ${VERSION} to ${latest}...`)
|
|
184
|
+
const update = spawnSync(NPM, ['install', '-g', `${PACKAGE_NAME}@latest`], {
|
|
185
|
+
encoding: 'utf8',
|
|
186
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
187
|
+
timeout: 120000,
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
if (update.status !== 0) {
|
|
191
|
+
console.error(`Hirify CLI ${latest} is available. Automatic update could not complete.`)
|
|
192
|
+
console.error(`Run: npm install -g ${PACKAGE_NAME}@latest`)
|
|
193
|
+
return false
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
console.error(`Hirify CLI updated to ${latest}.`)
|
|
197
|
+
const rerun = spawnSync(process.execPath, [fileURLToPath(import.meta.url), ...process.argv.slice(2)], {
|
|
198
|
+
stdio: 'inherit',
|
|
199
|
+
})
|
|
200
|
+
process.exit(rerun.status ?? EXIT_ERROR)
|
|
201
|
+
}
|
|
202
|
+
|
|
117
203
|
// Options that stand on their own. Everything else takes the next word as its value, and
|
|
118
204
|
// that rule is what lets `search` carry a filter the CLI has never heard of: the list of
|
|
119
205
|
// filters lives on the server, and a CLI that keeps its own copy is a CLI that lags.
|
|
@@ -185,14 +271,73 @@ const options = (args) => {
|
|
|
185
271
|
const count = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null)
|
|
186
272
|
|
|
187
273
|
const out = (data, text) => {
|
|
188
|
-
if (process.argv.includes('--json'))
|
|
189
|
-
|
|
274
|
+
if (process.argv.includes('--json')) {
|
|
275
|
+
// --json hands over the whole envelope, so a `meta.notice` on it travels structured and
|
|
276
|
+
// untouched. The human notice below is only for text mode.
|
|
277
|
+
console.log(JSON.stringify(data, null, 2))
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
text()
|
|
281
|
+
printNotice(data?.meta?.notice)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Print a structured notice the server attached to a successful answer (`meta.notice`). The
|
|
286
|
+
* call still succeeded, so this comes after the useful result, not in place of it. The CLI
|
|
287
|
+
* shows the text the server wrote and reads nothing into the policy behind it: it knows the
|
|
288
|
+
* notice object, not what produced it. No notice, or one with no message, prints nothing.
|
|
289
|
+
*/
|
|
290
|
+
function printNotice(notice) {
|
|
291
|
+
const message = typeof notice?.message === 'string' ? notice.message.trim() : ''
|
|
292
|
+
if (!message) return
|
|
293
|
+
console.log(`\nNotice: ${message}`)
|
|
190
294
|
}
|
|
191
295
|
|
|
192
296
|
// Text our own server wrote for the agent to read: application rules, address rejections.
|
|
193
297
|
// Passing it through beats paraphrasing, because paraphrasing drifts from the real rule.
|
|
194
298
|
const serverMessage = (body) => (typeof body?.message === 'string' && body.message ? body.message : null)
|
|
195
299
|
|
|
300
|
+
/**
|
|
301
|
+
* The one-line report for a policy restriction: a canonical 403 that names itself
|
|
302
|
+
* `access_restricted`. It is kept apart from the quota and rate-limit walls on purpose,
|
|
303
|
+
* because those clear on their own and this one does not. The CLI states what the server
|
|
304
|
+
* sent - the message it wrote, when the restriction lifts, where to ask for a review - and
|
|
305
|
+
* adds no rule of its own about why it was applied.
|
|
306
|
+
*/
|
|
307
|
+
function accessRestrictedMessage(body) {
|
|
308
|
+
const err = (body && typeof body.error === 'object' && body.error) || {}
|
|
309
|
+
const line = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null)
|
|
310
|
+
const parts = [line(err.message) || line(body?.message) || 'access on this account is restricted (403).']
|
|
311
|
+
const until = line(err.restricted_until)
|
|
312
|
+
if (until) parts.push(`It stays in place until ${until}.`)
|
|
313
|
+
const appeal = line(err.appeal_url)
|
|
314
|
+
if (appeal) parts.push(`To ask us to look at it again: ${appeal}`)
|
|
315
|
+
return parts.join('\n ')
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* The report for a blocking notice: a canonical 409 that names itself `action_required`.
|
|
320
|
+
* Unlike a `meta.notice` on a success, this one stands in place of the answer - the server is
|
|
321
|
+
* holding the result until the person confirms - so the CLI shows the text the server wrote and
|
|
322
|
+
* the one command that clears it, and reads nothing into the policy behind it. That command is
|
|
323
|
+
* the acknowledgement capability the server lists in its manifest, reached the same way as any
|
|
324
|
+
* other, so no route to it is written here. It carries the notice's own id and its first
|
|
325
|
+
* advertised action, both as the server sent them, so the printed line runs as it stands.
|
|
326
|
+
*/
|
|
327
|
+
function actionRequiredMessage(body) {
|
|
328
|
+
const notice = (body && typeof body.error === 'object' && body.error && typeof body.error.notice === 'object' && body.error.notice) || {}
|
|
329
|
+
const line = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null)
|
|
330
|
+
const parts = [line(notice.message) || 'this needs your confirmation before it can go ahead (409).']
|
|
331
|
+
|
|
332
|
+
const id = line(notice.id)
|
|
333
|
+
const action = Array.isArray(notice.actions) ? notice.actions.map(line).find(Boolean) : null
|
|
334
|
+
const call = 'hirify api call security.notices.ack'
|
|
335
|
+
parts.push(id && action
|
|
336
|
+
? `To continue, acknowledge this notice: ${call} --data '${JSON.stringify({ id, action })}'`
|
|
337
|
+
: `To continue, acknowledge this notice: ${call}`)
|
|
338
|
+
return parts.join('\n ')
|
|
339
|
+
}
|
|
340
|
+
|
|
196
341
|
const b64url = (buf) => buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
197
342
|
const now = () => Math.floor(Date.now() / 1000)
|
|
198
343
|
|
|
@@ -298,6 +443,27 @@ async function api(path, { method = 'GET', payload = null, allow = [], raw = fal
|
|
|
298
443
|
return { status: res.status, body, text }
|
|
299
444
|
}
|
|
300
445
|
|
|
446
|
+
// A policy restriction is a canonical 403 that names itself `access_restricted`. It is not a
|
|
447
|
+
// used-up allowance and not a rate limit, and retrying cannot lift it: the CLI reports it once
|
|
448
|
+
// and never retries. Checked ahead of `allow` so a caller that reads its own 403s still cannot
|
|
449
|
+
// take this one for an ordinary refusal. The clone leaves the body readable for the paths below.
|
|
450
|
+
if (res.status === 403) {
|
|
451
|
+
const restriction = await res.clone().json().catch(() => null)
|
|
452
|
+
if (restriction?.error?.code === 'access_restricted') die(accessRestrictedMessage(restriction))
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// A blocking notice is a canonical 409 that names itself `action_required`. Unlike a
|
|
456
|
+
// `meta.notice` on a success, it stands in place of the answer: the server is holding the
|
|
457
|
+
// result until the person acknowledges the notice, so the CLI reports it, exits unsuccessfully,
|
|
458
|
+
// and never retries - retrying cannot clear it. Checked ahead of `allow`, like the restriction
|
|
459
|
+
// above, so a caller that reads its own 409s cannot take this one for an ordinary conflict; a
|
|
460
|
+
// 409 without this code is left to the handling below, unchanged. The clone leaves the body
|
|
461
|
+
// readable for those paths.
|
|
462
|
+
if (res.status === 409) {
|
|
463
|
+
const blocked = await res.clone().json().catch(() => null)
|
|
464
|
+
if (blocked?.error?.code === 'action_required') die(actionRequiredMessage(blocked))
|
|
465
|
+
}
|
|
466
|
+
|
|
301
467
|
if (allow.includes(res.status)) {
|
|
302
468
|
return {
|
|
303
469
|
status: res.status,
|
|
@@ -830,6 +996,10 @@ function cmdLogout() {
|
|
|
830
996
|
: 'Nobody is signed in here.')
|
|
831
997
|
}
|
|
832
998
|
|
|
999
|
+
function cmdVersion() {
|
|
1000
|
+
console.log(VERSION)
|
|
1001
|
+
}
|
|
1002
|
+
|
|
833
1003
|
// ── commands ───────────────────────────────────────────────────────────────
|
|
834
1004
|
async function cmdAccountShow() {
|
|
835
1005
|
const body = await callCapability('account.status')
|
|
@@ -1648,7 +1818,7 @@ function cmdSkill() {
|
|
|
1648
1818
|
// Commands that are not something you do to a thing. Signing in is not an operation on a
|
|
1649
1819
|
// vacancy or a feed, and `intro` is the first thing anyone runs, so it stays one word.
|
|
1650
1820
|
const PLAIN = {
|
|
1651
|
-
intro: cmdIntro, skill: cmdSkill,
|
|
1821
|
+
intro: cmdIntro, skill: cmdSkill, version: cmdVersion,
|
|
1652
1822
|
login: cmdLogin, logout: cmdLogout, auth: cmdAuth,
|
|
1653
1823
|
}
|
|
1654
1824
|
|
|
@@ -1667,6 +1837,8 @@ const NOUNS = {
|
|
|
1667
1837
|
|
|
1668
1838
|
const [noun, ...args] = process.argv.slice(2)
|
|
1669
1839
|
|
|
1840
|
+
await autoUpdate()
|
|
1841
|
+
|
|
1670
1842
|
if (!noun || noun === '--help' || noun === '-h' || noun === 'help') { console.log(HELP); process.exit(EXIT_OK) }
|
|
1671
1843
|
|
|
1672
1844
|
// `Object.hasOwn`, not a plain lookup: every object inherits `toString` and `constructor`,
|
package/package.json
CHANGED