hirify-cli 0.4.7 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +167 -51
- package/bin/hirify.js +3 -1883
- package/bin/lib/api.js +130 -0
- package/bin/lib/auth.js +113 -0
- package/bin/lib/browser.js +34 -0
- package/bin/lib/cli.js +126 -0
- package/bin/lib/commands.js +827 -0
- package/bin/lib/config.js +27 -0
- package/bin/lib/errors.js +20 -0
- package/bin/lib/http.js +113 -0
- package/bin/lib/input.js +39 -0
- package/bin/lib/loopback.js +65 -0
- package/bin/lib/main.js +103 -0
- package/bin/lib/messages.js +62 -0
- package/bin/lib/store.js +118 -0
- package/bin/lib/update.js +171 -0
- package/bin/open-browser.js +2 -13
- package/npm-shrinkwrap.json +349 -0
- package/package.json +17 -4
- package/skills/hirify/SKILL.md +51 -76
- package/skills/hirify/reference.md +20 -13
package/bin/lib/api.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { CliError } from './errors.js'
|
|
3
|
+
import { trustedURL } from './config.js'
|
|
4
|
+
import { requireJSON, retryDelay } from './http.js'
|
|
5
|
+
import { accessRestrictedMessage, actionRequiredMessage } from './messages.js'
|
|
6
|
+
const READS = new Set(['account.status', 'vacancies.search', 'feeds.list', 'feeds.vacancies', 'profiles.list', 'webhooks.list', 'filters.guide'])
|
|
7
|
+
const LISTS = new Set(['vacancies.search', 'feeds.list', 'feeds.vacancies', 'profiles.list', 'webhooks.list'])
|
|
8
|
+
const OBJECTS = new Set(['account.status', 'vacancies.read', 'vacancies.reveal', 'applications.apply', 'feeds.create', 'feeds.set_delivery', 'webhooks.create', 'feedback.send', 'filters.guide'])
|
|
9
|
+
const isObject = value => value && typeof value === 'object' && !Array.isArray(value)
|
|
10
|
+
const immutable = value => { if (value && typeof value === 'object') { for (const child of Object.values(value)) immutable(child); Object.freeze(value) }; return value }
|
|
11
|
+
export function validateManifest(doc, origin) {
|
|
12
|
+
if (!isObject(doc) || !Number.isInteger(doc.schema_version) || doc.schema_version < 1) throw new CliError('manifest_invalid', 'Hirify sent a manifest without a supported version.')
|
|
13
|
+
if (doc.schema_version > 1) throw new CliError('manifest_unsupported', 'this Hirify speaks a newer manifest than this CLI can read. Please update the CLI.', { exitCode: 2 })
|
|
14
|
+
if (!Array.isArray(doc.capabilities)) throw new CliError('manifest_invalid', 'Hirify sent a manifest with no list of what it can do.')
|
|
15
|
+
const ids = new Set()
|
|
16
|
+
for (const cap of doc.capabilities) {
|
|
17
|
+
if (!isObject(cap) || typeof cap.id !== 'string' || !/^[a-zA-Z0-9_.-]+$/.test(cap.id) || ids.has(cap.id) || !['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'].includes(cap.method) || typeof cap.path !== 'string' || /[?#]/.test(cap.path)) throw new CliError('manifest_invalid', 'Hirify returned an invalid capability description.')
|
|
18
|
+
ids.add(cap.id); trustedURL(cap.path, origin, { path: true })
|
|
19
|
+
if (cap.inputs !== undefined && !isObject(cap.inputs)) throw new CliError('manifest_invalid', 'Hirify returned invalid input metadata.')
|
|
20
|
+
// Schema v1 servers serialize an empty PHP map as []; nonempty lists are still invalid.
|
|
21
|
+
const locations = cap.inputs?.locations
|
|
22
|
+
const emptyLocations = Array.isArray(locations) && locations.length === 0
|
|
23
|
+
if (locations !== undefined && !emptyLocations && (!isObject(locations) || Object.values(locations).some(where => !['path', 'query', 'body'].includes(where)))) throw new CliError('manifest_invalid', 'Hirify returned invalid input locations.')
|
|
24
|
+
if (cap.inputs?.schema !== undefined && !isObject(cap.inputs.schema)) throw new CliError('manifest_invalid', 'Hirify returned an invalid input schema.')
|
|
25
|
+
}
|
|
26
|
+
return immutable(doc)
|
|
27
|
+
}
|
|
28
|
+
export function responseError(response, { key = false } = {}) {
|
|
29
|
+
const { status, body, headers } = response
|
|
30
|
+
const options = { status, requestId: headers.get('x-request-id') || headers.get('request-id') || undefined }
|
|
31
|
+
if (status === 401) return new CliError('auth_required', key ? 'the key was not accepted (401). It may have been revoked, or copied incompletely.' : 'your sign-in is no longer valid. Please run hirify login --force.', options)
|
|
32
|
+
if (status === 403 && body?.error?.code === 'access_restricted') return new CliError('access_restricted', accessRestrictedMessage(body), options)
|
|
33
|
+
if (status === 409 && body?.error?.code === 'action_required') return new CliError('action_required', actionRequiredMessage(body), options)
|
|
34
|
+
if (status === 403) return new CliError('access_denied', 'no access (403). This needs an active paid plan, or the sign-in is missing a permission.', options)
|
|
35
|
+
if (status === 404) return new CliError('not_found', 'not found (404).', options)
|
|
36
|
+
if (status === 429) {
|
|
37
|
+
const action = body?.quota?.action
|
|
38
|
+
const messages = {
|
|
39
|
+
vacancy_opens: 'you have opened as many vacancies today as the daily allowance covers.\n Feeds and search still work, and so does anything already read today.',
|
|
40
|
+
contact_reveals: 'you have no reveals left right now. Reading still works.',
|
|
41
|
+
apply: 'you have sent as many applications today as the daily allowance covers.\n Reading and revealing still work. What is left: hirify account show',
|
|
42
|
+
}
|
|
43
|
+
const wait = Math.ceil(retryDelay(headers.get('retry-after')) / 1000)
|
|
44
|
+
const message = messages[action] || (body?.quota ? `today's allowance for ${String(action ?? 'that action').replace(/_/g, ' ')} is used up.\n What is left: hirify account show` : `too many requests in a short time.${wait ? ` Please try again in ${wait} seconds.` : ' Please try again in a minute.'}`)
|
|
45
|
+
return new CliError(body?.quota ? 'quota_exhausted' : 'rate_limited', message, { ...options, retryable: !body?.quota })
|
|
46
|
+
}
|
|
47
|
+
return new CliError(status >= 500 ? 'server_error' : 'request_rejected', status >= 500 ? 'something went wrong on our side. Please try again in a minute.' : 'that command could not be completed. Please check it and try again.', options)
|
|
48
|
+
}
|
|
49
|
+
export function createApi({ config, http, auth, output }) {
|
|
50
|
+
let discovery, manifest, lastError
|
|
51
|
+
async function agentDiscovery() {
|
|
52
|
+
if (!discovery) discovery = (async () => {
|
|
53
|
+
const res = await http.request(`${config.api}/.well-known/hirify-agent`, { safe: true, timeout: 5000 })
|
|
54
|
+
if (!res.ok) throw new CliError('discovery_unavailable', 'could not reach Hirify to load what it can do. Please check your connection and try again.', { status: res.status })
|
|
55
|
+
const doc = requireJSON(res)
|
|
56
|
+
if (!isObject(doc)) throw new CliError('discovery_invalid', 'Hirify returned invalid discovery metadata.')
|
|
57
|
+
if (doc.manifest_url) trustedURL(doc.manifest_url, config.api)
|
|
58
|
+
return doc
|
|
59
|
+
})()
|
|
60
|
+
return discovery
|
|
61
|
+
}
|
|
62
|
+
async function authenticated(url, options = {}) {
|
|
63
|
+
// Validate even on retries; changing the configured server never retargets saved tokens.
|
|
64
|
+
const target = trustedURL(url, config.api)
|
|
65
|
+
const token = await auth.access()
|
|
66
|
+
const send = value => http.request(target, { ...options, headers: { ...options.headers, Authorization: `Bearer ${value}` } })
|
|
67
|
+
let res = await send(token)
|
|
68
|
+
if (res.status === 401 && options.safe) {
|
|
69
|
+
const replacement = await auth.retryToken(token)
|
|
70
|
+
if (replacement) res = await send(replacement)
|
|
71
|
+
}
|
|
72
|
+
return res
|
|
73
|
+
}
|
|
74
|
+
async function loadManifest() {
|
|
75
|
+
if (!manifest) manifest = (async () => {
|
|
76
|
+
const doc = await agentDiscovery()
|
|
77
|
+
if (!doc.manifest_url) throw new CliError('manifest_unavailable', 'could not reach Hirify to load what it can do. Please check your connection and try again.')
|
|
78
|
+
const res = await authenticated(doc.manifest_url, { safe: true })
|
|
79
|
+
if (!res.ok) { if (res.status === 401) throw responseError(res, { key: Boolean(config.envKey) }); throw new CliError('manifest_unavailable', 'could not load what Hirify can do right now. Please try again in a minute.', { status: res.status }) }
|
|
80
|
+
return validateManifest(requireJSON(res), config.api)
|
|
81
|
+
})()
|
|
82
|
+
return manifest
|
|
83
|
+
}
|
|
84
|
+
async function resolveCapability(id) {
|
|
85
|
+
const cap = (await loadManifest()).capabilities.find(c => c.id === id)
|
|
86
|
+
if (!cap) throw new CliError('capability_unavailable', `this Hirify does not offer "${id}". It may be an older server, or the operation has moved.`)
|
|
87
|
+
return cap
|
|
88
|
+
}
|
|
89
|
+
async function callCapability(id, { params = {}, query, payload = null, allow = [], raw = false } = {}) {
|
|
90
|
+
const cap = await resolveCapability(id)
|
|
91
|
+
const path = cap.path.replace(/\{([a-zA-Z0-9_]+)\}/g, (_, name) => {
|
|
92
|
+
const value = params[name]
|
|
93
|
+
if (value === undefined || value === null || typeof value === 'object') throw new CliError('invalid_arguments', `this call needs "${name}", and it was not given.`)
|
|
94
|
+
return encodeURIComponent(String(value))
|
|
95
|
+
})
|
|
96
|
+
if (/[{}]/.test(path)) throw new CliError('manifest_invalid', 'Hirify returned an invalid path template.')
|
|
97
|
+
let target = trustedURL(path, config.api, { path: true })
|
|
98
|
+
if (query?.toString()) target += `?${query}`
|
|
99
|
+
const safe = cap.method === 'GET' && READS.has(id) && (!cap.meter || cap.meter === 'none')
|
|
100
|
+
const mutation = !['GET', 'HEAD'].includes(cap.method)
|
|
101
|
+
let replayKey
|
|
102
|
+
if (mutation && cap.retry_policy === 'caller_keyed' && cap.inputs?.locations?.idempotency_key === 'body') {
|
|
103
|
+
replayKey = payload?.idempotency_key || randomUUID()
|
|
104
|
+
payload = { ...(payload || {}), idempotency_key: replayKey }
|
|
105
|
+
if (typeof replayKey !== 'string' || !/^[A-Za-z0-9._:-]{1,128}$/.test(replayKey)) throw new CliError('invalid_arguments', 'Use an idempotency key of 1 to 128 letters, digits, dots, underscores, colons or hyphens.')
|
|
106
|
+
output?.progress(`Request reference: ${replayKey}. Keep this value if the same request needs to be repeated.`)
|
|
107
|
+
}
|
|
108
|
+
let res
|
|
109
|
+
try { res = await authenticated(target, { method: cap.method, safe, body: payload === null ? undefined : JSON.stringify(payload), headers: payload === null ? {} : { 'Content-Type': 'application/json' } }) }
|
|
110
|
+
catch (error) {
|
|
111
|
+
if (mutation && ['network_error', 'network_timeout', 'response_incomplete', 'response_too_large', 'command_timeout'].includes(error.code)) throw new CliError('outcome_unknown', 'No confirmation was received. The action may have completed. Check its result before trying again.')
|
|
112
|
+
if (mutation && error.code === 'cancelled') error.message += ' The action may have completed; check its result before retrying.'
|
|
113
|
+
throw error
|
|
114
|
+
}
|
|
115
|
+
if (res.ok) {
|
|
116
|
+
requireJSON(res, { mutation })
|
|
117
|
+
const expected = allow.filter(code => code >= 200 && code < 300)
|
|
118
|
+
if (!expected.length && (LISTS.has(id) || (OBJECTS.has(id) && cap.method === 'GET'))) expected.push(200)
|
|
119
|
+
const invalid = res.body.ok === false || (expected.length && !expected.includes(res.status)) || (LISTS.has(id) && !Array.isArray(res.body.data)) || (OBJECTS.has(id) && !isObject(res.body.data)) || (id === 'applications.apply' && !res.body.data?.application_id) || (['feeds.create', 'feeds.set_delivery'].includes(id) && !(res.body.data?.id ?? res.body.data?.feed_id)) || (id === 'webhooks.create' && !(res.body.data?.id ?? res.body.data?.endpoint_id)) || (id === 'feedback.send' && !(res.body.data?.reference || res.body.data?.ticket))
|
|
120
|
+
if (invalid) throw new CliError(mutation ? 'outcome_unknown' : 'protocol_error', mutation ? 'The server did not return a valid confirmation. Check the result before trying again.' : 'The server response did not match this operation.', { status: res.status })
|
|
121
|
+
}
|
|
122
|
+
lastError = res.ok ? null : mutation && res.status >= 500 ? new CliError('outcome_unknown', 'The server did not confirm the result. The action may have completed; check before retrying.', { status: res.status }) : responseError(res, { key: Boolean(config.envKey) })
|
|
123
|
+
if (raw) return { ...res, error: lastError }
|
|
124
|
+
if ([403, 409].includes(res.status) && ['access_restricted', 'action_required'].includes(res.body?.error?.code)) throw responseError(res)
|
|
125
|
+
if (allow.includes(res.status)) return { status: res.status, body: res.body, retryAfter: res.headers.get('retry-after'), error: res.ok ? null : responseError(res) }
|
|
126
|
+
if (!res.ok) throw lastError
|
|
127
|
+
return res.body
|
|
128
|
+
}
|
|
129
|
+
return { agentDiscovery, loadManifest, resolveCapability, callCapability, get lastError() { return lastError } }
|
|
130
|
+
}
|
package/bin/lib/auth.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { randomBytes, createHash, randomUUID } from 'node:crypto'
|
|
2
|
+
import { CliError, aborted } from './errors.js'
|
|
3
|
+
import { trustedURL } from './config.js'
|
|
4
|
+
import { requireJSON } from './http.js'
|
|
5
|
+
import { startCallbackServer } from './loopback.js'
|
|
6
|
+
import { environment, openBrowser } from './browser.js'
|
|
7
|
+
export const SCOPES = ['agent:read', 'agent:reveal', 'agent:apply', 'agent:manage', 'agent:feedback', 'offline_access']
|
|
8
|
+
export function tokenResponse(body, previous = {}) {
|
|
9
|
+
if (!body || typeof body.access_token !== 'string' || !body.access_token.trim() || typeof body.token_type !== 'string' || body.token_type.toLowerCase() !== 'bearer' || (body.refresh_token != null && (typeof body.refresh_token !== 'string' || !body.refresh_token)) || (body.scope !== undefined && typeof body.scope !== 'string')) throw new CliError('oauth_protocol', 'The sign-in server returned an invalid token response. No new credentials were saved.')
|
|
10
|
+
if (body.expires_in !== undefined && !['number', 'string'].includes(typeof body.expires_in)) throw new CliError('oauth_protocol', 'The sign-in server returned an invalid token lifetime.')
|
|
11
|
+
const expiry = body.expires_in === undefined ? null : Number(body.expires_in)
|
|
12
|
+
if (expiry !== null && (!Number.isSafeInteger(expiry) || expiry <= 0 || expiry > 315360000)) throw new CliError('oauth_protocol', 'The sign-in server returned an invalid token lifetime.')
|
|
13
|
+
return { ...previous, access_token: body.access_token, refresh_token: body.refresh_token ?? previous.refresh_token ?? null, scope: body.scope ?? previous.scope ?? '', expires_at: expiry === null ? null : Math.floor(Date.now() / 1000) + expiry }
|
|
14
|
+
}
|
|
15
|
+
export function createAuth({ config, store, http, signal, output, event, secrets }) {
|
|
16
|
+
const remember = s => { if (s) { secrets.add(s.access_token); secrets.add(s.refresh_token) }; return s }
|
|
17
|
+
async function form(url, data) {
|
|
18
|
+
const res = await http.request(trustedURL(url, config.api), { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(data).toString() })
|
|
19
|
+
return { ...res, body: requireJSON(res) }
|
|
20
|
+
}
|
|
21
|
+
async function refresh(observed, { force = false } = {}) {
|
|
22
|
+
return store.locked(async () => {
|
|
23
|
+
const current = store.read()
|
|
24
|
+
if (!current || current.kind !== 'oauth') throw new CliError('session_changed', 'The sign-in changed. Run the command again.')
|
|
25
|
+
if (current.generation !== observed.generation || current.access_token !== observed.access_token) return remember(current)
|
|
26
|
+
if (!force && (!current.expires_at || current.expires_at > Date.now() / 1000 + 60)) return remember(current)
|
|
27
|
+
if (!current.refresh_token) throw new CliError('auth_required', 'Your sign-in has expired. Run hirify login --force.')
|
|
28
|
+
const endpoint = trustedURL(current.token_endpoint || `${config.api}/oauth/token`, config.api)
|
|
29
|
+
// A lost refresh response cannot safely be retried with a rotating token.
|
|
30
|
+
await store.write({ ...current, refresh_pending: true })
|
|
31
|
+
let response
|
|
32
|
+
try { response = await form(endpoint, { grant_type: 'refresh_token', client_id: current.client_id, refresh_token: current.refresh_token }) }
|
|
33
|
+
catch (error) { if (signal.aborted) throw signal.reason; event('refresh', { code: error.code }); throw new CliError('refresh_uncertain', 'Token renewal did not finish safely. Run hirify login --force; the old refresh token will not be retried.') }
|
|
34
|
+
if (!response.ok) {
|
|
35
|
+
// Explicit OAuth errors are a known failure, but never silently replay the grant.
|
|
36
|
+
throw new CliError(response.body?.error === 'invalid_grant' ? 'auth_required' : 'refresh_failed', 'The saved sign-in could not be renewed. Run hirify login --force.', { status: response.status })
|
|
37
|
+
}
|
|
38
|
+
const next = remember(tokenResponse(response.body, current))
|
|
39
|
+
next.generation = randomUUID(); next.refresh_pending = false
|
|
40
|
+
await store.write(next); return next
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
async function access() {
|
|
44
|
+
if (config.envKey) { secrets.add(config.envKey); return config.envKey }
|
|
45
|
+
await store.secure()
|
|
46
|
+
let current = remember(store.read({ allowPending: true }))
|
|
47
|
+
if (!current) throw new CliError('auth_required', 'You are not signed in. Run hirify login. For CI or servers, set HIRIFY_KEY or use hirify auth --stdin.')
|
|
48
|
+
if (current.kind === 'oauth' && (current.refresh_pending || (current.expires_at && current.expires_at <= Date.now() / 1000 + 60))) current = await refresh(current)
|
|
49
|
+
return current.access_token
|
|
50
|
+
}
|
|
51
|
+
async function retryToken(rejected) {
|
|
52
|
+
if (config.envKey) return null
|
|
53
|
+
const current = remember(store.read({ allowPending: true }))
|
|
54
|
+
if (!current || current.kind !== 'oauth') return null
|
|
55
|
+
if (current.access_token !== rejected) return current.access_token
|
|
56
|
+
return (await refresh(current, { force: true })).access_token
|
|
57
|
+
}
|
|
58
|
+
async function discover() {
|
|
59
|
+
const res = await http.request(`${config.api}/.well-known/oauth-authorization-server`, { safe: true })
|
|
60
|
+
if (!res.ok) throw new CliError('oauth_discovery', 'Could not load the sign-in settings.', { status: res.status })
|
|
61
|
+
const body = requireJSON(res)
|
|
62
|
+
if (body.issuer !== config.api) throw new CliError('issuer_mismatch', 'The sign-in server returned a different issuer.')
|
|
63
|
+
const result = {}
|
|
64
|
+
for (const key of ['authorization_endpoint', 'token_endpoint', 'registration_endpoint']) result[key] = trustedURL(body[key], config.api)
|
|
65
|
+
if (body.code_challenge_methods_supported && (!Array.isArray(body.code_challenge_methods_supported) || !body.code_challenge_methods_supported.includes('S256'))) throw new CliError('oauth_protocol', 'The sign-in server does not support PKCE S256.')
|
|
66
|
+
const scopes = body.scopes_supported
|
|
67
|
+
if (scopes !== undefined && (!Array.isArray(scopes) || scopes.some(x => typeof x !== 'string'))) throw new CliError('oauth_protocol', 'The sign-in server returned invalid permissions.')
|
|
68
|
+
result.scopes = SCOPES.filter(scope => !scopes || scopes.includes(scope)).join(' ')
|
|
69
|
+
return result
|
|
70
|
+
}
|
|
71
|
+
async function login({ force = false, noBrowser = false, port = 0, consentMs = 300000 } = {}) {
|
|
72
|
+
if (!force) {
|
|
73
|
+
const current = store.read()
|
|
74
|
+
if (current?.kind === 'oauth') { await access(); return { signed_in: true, already_signed_in: true, source: config.envKey ? 'environment' : 'oauth' } }
|
|
75
|
+
}
|
|
76
|
+
const info = environment()
|
|
77
|
+
if (!noBrowser && (!info.interactive || info.remote)) throw new CliError('interaction_required', 'This terminal cannot complete browser sign-in automatically. Use HIRIFY_KEY or hirify auth --stdin. For manual sign-in, use hirify login --no-browser; over SSH, also choose --callback-port and forward that port.')
|
|
78
|
+
if (info.remote && noBrowser && !port) throw new CliError('remote_callback_required', 'Browser callbacks must reach this machine. Choose --callback-port and forward that port over SSH, or use HIRIFY_KEY.')
|
|
79
|
+
const generation = await store.beginLogin({ force })
|
|
80
|
+
const endpoints = await discover()
|
|
81
|
+
const verifier = randomBytes(32).toString('base64url'), state = randomBytes(16).toString('base64url')
|
|
82
|
+
secrets.add(verifier); secrets.add(state)
|
|
83
|
+
const listener = await startCallbackServer({ state, issuer: config.api, port, signal, timeoutMs: consentMs })
|
|
84
|
+
let reminder, launchTask
|
|
85
|
+
try {
|
|
86
|
+
const registration = await http.request(endpoints.registration_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_name: 'Hirify CLI', redirect_uris: [listener.redirectUri] }) })
|
|
87
|
+
const client = requireJSON(registration)
|
|
88
|
+
if (!registration.ok || typeof client.client_id !== 'string' || !client.client_id) throw new CliError('oauth_registration', 'Could not start sign-in with this server.', { status: registration.status })
|
|
89
|
+
const url = new URL(endpoints.authorization_endpoint)
|
|
90
|
+
for (const [key, value] of Object.entries({ response_type: 'code', client_id: client.client_id, redirect_uri: listener.redirectUri, scope: endpoints.scopes, state, code_challenge: createHash('sha256').update(verifier).digest('base64url'), code_challenge_method: 'S256' })) url.searchParams.set(key, value)
|
|
91
|
+
// This intentional sign-in link belongs to the person, never to diagnostic events.
|
|
92
|
+
output.progress('Open this link to confirm access:')
|
|
93
|
+
output.progress(url.href, { secret: true })
|
|
94
|
+
output.progress('The browser must be able to reach the callback on this machine. Waiting for confirmation; press Ctrl+C to cancel.')
|
|
95
|
+
reminder = setInterval(() => output.progress('Still waiting for browser confirmation. Use the sign-in link above, or press Ctrl+C.'), 30000)
|
|
96
|
+
if (!noBrowser && info.canOpen) launchTask = openBrowser(url.href, { signal }).then(result => { event('browser', result); if (['launcher_missing', 'spawn_error', 'nonzero_exit'].includes(result.code)) output.progress('The browser launcher did not complete. Open the link above manually.') })
|
|
97
|
+
const answer = await listener.received
|
|
98
|
+
if (answer.error) throw new CliError(answer.error === 'access_denied' ? 'auth_denied' : 'oauth_error', 'Sign-in was not approved. Nothing was saved. Run hirify login again when ready.')
|
|
99
|
+
secrets.add(answer.code)
|
|
100
|
+
const res = await form(endpoints.token_endpoint, { grant_type: 'authorization_code', client_id: client.client_id, code: answer.code, redirect_uri: listener.redirectUri, code_verifier: verifier })
|
|
101
|
+
if (!res.ok) throw new CliError('oauth_exchange', 'The confirmation could not be exchanged. Run hirify login again.', { status: res.status })
|
|
102
|
+
const session = remember(tokenResponse(res.body, { kind: 'oauth', issuer: config.api, client_id: client.client_id, token_endpoint: endpoints.token_endpoint, scope: endpoints.scopes }))
|
|
103
|
+
aborted(signal)
|
|
104
|
+
await store.commit(session, generation)
|
|
105
|
+
return { signed_in: true, source: 'oauth', environment_override: Boolean(config.envKey) }
|
|
106
|
+
} finally { clearInterval(reminder); await listener.close(); await launchTask }
|
|
107
|
+
}
|
|
108
|
+
return { access, retryToken, login, refresh, discover, status: () => {
|
|
109
|
+
if (config.envKey) return { signed_in: true, source: 'environment', issuer: config.api }
|
|
110
|
+
const state = store.read({ allowPending: true })
|
|
111
|
+
return { signed_in: Boolean(state), source: state?.kind || 'none', issuer: state?.issuer || config.api, expires_at: state?.expires_at ?? null, renewal_uncertain: Boolean(state?.refresh_pending) }
|
|
112
|
+
} }
|
|
113
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
export function environment({ env = process.env, platform = process.platform, isTTY = Boolean(process.stdout.isTTY) } = {}) {
|
|
3
|
+
const remote = Boolean(env.SSH_CONNECTION || env.SSH_TTY)
|
|
4
|
+
const wsl = platform === 'linux' && Boolean(env.WSL_DISTRO_NAME || env.WSL_INTEROP)
|
|
5
|
+
const gui = platform !== 'linux' || Boolean(env.DISPLAY || env.WAYLAND_DISPLAY || wsl)
|
|
6
|
+
return { remote, wsl, interactive: isTTY, gui, canOpen: isTTY && !remote && gui }
|
|
7
|
+
}
|
|
8
|
+
export const canOpenBrowser = options => environment(options).canOpen
|
|
9
|
+
/** A launcher acknowledgement is not proof that a browser window appeared. */
|
|
10
|
+
export async function openBrowser(url, { waitMs = 1500, env = process.env, signal, launch } = {}) {
|
|
11
|
+
if (signal?.aborted) return { code: 'cancelled' }
|
|
12
|
+
let parsed
|
|
13
|
+
try { parsed = new URL(url) } catch { return { code: 'invalid_url' } }
|
|
14
|
+
if (parsed.username || parsed.password || (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && ['127.0.0.1', '[::1]', 'localhost'].includes(parsed.hostname)))) return { code: 'invalid_url' }
|
|
15
|
+
let child
|
|
16
|
+
try {
|
|
17
|
+
if (launch) child = await launch(url)
|
|
18
|
+
else if (env.BROWSER) child = spawn(env.BROWSER, [url], { stdio: 'ignore', detached: true, windowsHide: true })
|
|
19
|
+
else { const { default: open } = await import('open'); child = await open(url) }
|
|
20
|
+
} catch { return { code: 'spawn_error' } }
|
|
21
|
+
return new Promise(resolve => {
|
|
22
|
+
let done = false
|
|
23
|
+
const finish = result => { if (done) return; done = true; clearTimeout(timer); signal?.removeEventListener('abort', cancel); child.off('error', error); child.on('error', () => {}); child.off('exit', exit); child.unref?.(); resolve(result) }
|
|
24
|
+
// Never terminate the user's browser on cancellation; only release our observation.
|
|
25
|
+
const cancel = () => finish({ code: 'cancelled' })
|
|
26
|
+
const error = e => finish({ code: e.code === 'ENOENT' ? 'launcher_missing' : 'spawn_error' })
|
|
27
|
+
const exit = code => finish({ code: code === 0 ? 'launcher_exited' : 'nonzero_exit' })
|
|
28
|
+
const timer = setTimeout(() => finish({ code: 'launcher_unconfirmed' }), waitMs)
|
|
29
|
+
child.once('error', error); child.once('exit', exit)
|
|
30
|
+
signal?.addEventListener('abort', cancel, { once: true })
|
|
31
|
+
if (child.exitCode !== null && child.exitCode !== undefined) exit(child.exitCode)
|
|
32
|
+
else if (signal?.aborted) cancel()
|
|
33
|
+
})
|
|
34
|
+
}
|
package/bin/lib/cli.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { CliError, redact, safeText } from './errors.js'
|
|
2
|
+
const ARGUMENTS = { auth: '[<key>]', update: '[<version>]', 'vacancy search': '[<query>]', 'vacancy read': '<slug>', 'vacancy reveal': '<slug>', 'vacancy apply': '<slug>', 'feed show': '<id>', 'feed create': '<name>', 'feed deliver': '<id>', 'webhook create': '<name> <url>', 'feedback send': '<bug|feature> <title>', 'api call': '<capability>', 'capabilities show': '<capability>' }
|
|
3
|
+
const GLOBAL = ['json', 'fields', 'error-format', 'debug', 'timeout', 'help']
|
|
4
|
+
export const BOOLEAN_FLAGS = new Set(['json', 'debug', 'help', 'force', 'no-browser', 'stdin', 'telegram', 'no-telegram', 'no-webhook', 'check', 'rollback'])
|
|
5
|
+
export const COMMANDS = {
|
|
6
|
+
intro: { args: [0, 0], description: 'what this can do, and in what order' },
|
|
7
|
+
version: { args: [0, 0], description: 'installed version' },
|
|
8
|
+
skill: { args: [0, 0], description: 'install the working rules for your agent' },
|
|
9
|
+
login: { args: [0, 0], flags: ['force', 'no-browser', 'callback-port'], description: 'sign in through your browser' },
|
|
10
|
+
logout: { args: [0, 0], description: 'sign out on this computer' },
|
|
11
|
+
auth: { args: [0, 1], flags: ['stdin'], description: 'save an API key; use --stdin to keep it out of shell history' },
|
|
12
|
+
'auth status': { args: [0, 0], description: 'show the local sign-in source without contacting the server' },
|
|
13
|
+
doctor: { args: [0, 0], description: 'show local diagnostic information without credentials' },
|
|
14
|
+
update: { args: [0, 1], flags: ['check', 'rollback'], description: 'check for an update or install an exact version' },
|
|
15
|
+
'account show': { args: [0, 0], description: 'your plan and allowances' },
|
|
16
|
+
'vacancy search': { args: [0, Infinity], flags: ['limit', 'page'], passthrough: true, description: 'search the board; server filter names are supported' },
|
|
17
|
+
'vacancy read': { args: [1, 1], description: 'read one vacancy in full; uses a vacancy open' },
|
|
18
|
+
'vacancy reveal': { args: [1, 1], description: 'get the contact or link to apply; uses a reveal' },
|
|
19
|
+
'vacancy apply': { args: [1, 1], flags: ['profile', 'cover', 'cover-file'], description: 'send a real application; ask the person first' },
|
|
20
|
+
'feed list': { args: [0, 0], description: 'your saved feeds' },
|
|
21
|
+
'feed show': { args: [1, 1], flags: ['limit', 'page'], description: 'vacancies in a feed' },
|
|
22
|
+
'feed create': { args: [1, 1], flags: ['filters', 'telegram', 'no-telegram', 'webhook'], description: 'save a search and its delivery settings' },
|
|
23
|
+
'feed deliver': { args: [1, 1], flags: ['telegram', 'no-telegram', 'webhook', 'no-webhook'], description: 'change delivery settings' },
|
|
24
|
+
'profile list': { args: [0, 0], description: 'profiles you can apply with' },
|
|
25
|
+
'webhook list': { args: [0, 0], description: 'your delivery endpoints' },
|
|
26
|
+
'webhook create': { args: [2, 2], description: 'create an endpoint: <name> <url>' },
|
|
27
|
+
'filter guide': { args: [0, 0], description: 'supported search filters, from the server' },
|
|
28
|
+
'feedback send': { args: [2, 2], flags: ['body', 'vacancy', 'idempotency-key'], description: 'send bug or feature feedback: <kind> <title> --body <text>' },
|
|
29
|
+
'api call': { args: [1, 1], flags: ['data', 'data-file'], description: 'invoke a capability with a JSON object of inputs' },
|
|
30
|
+
'capabilities list': { args: [0, 0], description: 'list the server capability catalogue' },
|
|
31
|
+
'capabilities show': { args: [1, 1], description: 'show the inputs, effects and metering of a capability' },
|
|
32
|
+
}
|
|
33
|
+
export const CLI_ONLY = new Set([...GLOBAL, 'data-file', 'cover-file'])
|
|
34
|
+
export function tokenize(args) {
|
|
35
|
+
const words = [], options = new Map(); let literal = false
|
|
36
|
+
for (let i = 0; i < args.length; i++) {
|
|
37
|
+
let word = args[i]
|
|
38
|
+
if (literal) { words.push(word); continue }
|
|
39
|
+
if (word === '--') { literal = true; continue }
|
|
40
|
+
if (word === '-h') word = '--help'
|
|
41
|
+
if (word === '--version') { words.push('version'); continue }
|
|
42
|
+
if (!word.startsWith('--')) {
|
|
43
|
+
if (word.startsWith('-') && word !== '-') throw new CliError('invalid_arguments', `Unknown option: ${word}. Use -- before a value beginning with a hyphen.`)
|
|
44
|
+
words.push(word); continue
|
|
45
|
+
}
|
|
46
|
+
const at = word.indexOf('='); const name = word.slice(2, at < 0 ? undefined : at)
|
|
47
|
+
if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(name)) throw new CliError('invalid_arguments', 'Invalid option name.')
|
|
48
|
+
let value
|
|
49
|
+
if (BOOLEAN_FLAGS.has(name)) {
|
|
50
|
+
if (at >= 0) throw new CliError('invalid_arguments', `--${name} does not take a value.`)
|
|
51
|
+
value = true
|
|
52
|
+
} else {
|
|
53
|
+
value = at >= 0 ? word.slice(at + 1) : args[++i]
|
|
54
|
+
if (value === undefined || (at < 0 && value !== '-' && value.startsWith('-'))) throw new CliError('invalid_arguments', `--${name} requires a value. Use --${name}=<value> for a value beginning with a hyphen.`)
|
|
55
|
+
}
|
|
56
|
+
const values = options.get(name) || []; values.push(value); options.set(name, values)
|
|
57
|
+
}
|
|
58
|
+
return { words, options }
|
|
59
|
+
}
|
|
60
|
+
export const positional = args => tokenize(args).words
|
|
61
|
+
export const flag = (args, name) => tokenize(args).options.get(name.replace(/^--/, ''))?.[0] ?? null
|
|
62
|
+
export const options = args => new Map([...tokenize(args).options].map(([k, values]) => [k, values.join(',')]))
|
|
63
|
+
export function parse(argv) {
|
|
64
|
+
// Help is resolved before parsing values, reading config or initializing effects.
|
|
65
|
+
const boundary = argv.indexOf('--'); const before = boundary < 0 ? argv : argv.slice(0, boundary)
|
|
66
|
+
if (!argv.length || before.some(x => ['--help', '-h'].includes(x)) || argv[0] === 'help') return { help: true, topic: argv.filter(x => !x.startsWith('-')).slice(0, 2).join(' ') }
|
|
67
|
+
const parsed = tokenize(argv)
|
|
68
|
+
let name = parsed.words[0]
|
|
69
|
+
if (Object.hasOwn(COMMANDS, `${name} ${parsed.words[1]}`)) name += ` ${parsed.words[1]}`
|
|
70
|
+
const spec = Object.hasOwn(COMMANDS, name) ? COMMANDS[name] : null
|
|
71
|
+
if (!spec) {
|
|
72
|
+
const verbs = Object.keys(COMMANDS).filter(x => x.startsWith(`${name} `)).map(x => x.split(' ')[1])
|
|
73
|
+
throw new CliError('invalid_arguments', verbs.length ? `${parsed.words[1] ? `hirify ${name} has no verb "${parsed.words[1]}".\n` : ''}hirify ${name} takes a verb: ${verbs.join(', ')}` : `unknown command: ${name}. Run hirify --help.`)
|
|
74
|
+
}
|
|
75
|
+
const words = parsed.words.slice(name.split(' ').length)
|
|
76
|
+
if (words.length < spec.args[0] || words.length > spec.args[1]) throw new CliError('invalid_arguments', `hirify ${name} needs ${spec.args[0] === spec.args[1] ? spec.args[0] : `${spec.args[0]} to ${spec.args[1]}`} argument(s). Run hirify ${name} --help.`)
|
|
77
|
+
const known = new Set([...GLOBAL, ...(spec.flags || [])])
|
|
78
|
+
for (const [key, values] of parsed.options) {
|
|
79
|
+
if (!known.has(key) && !spec.passthrough) throw new CliError('invalid_arguments', `Unknown option for ${name}: --${key}.`)
|
|
80
|
+
if (known.has(key) && values.length > 1) throw new CliError('invalid_arguments', `--${key} can be given only once.`)
|
|
81
|
+
}
|
|
82
|
+
const value = key => parsed.options.get(key)?.[0]
|
|
83
|
+
for (const [a, b] of [['telegram', 'no-telegram'], ['webhook', 'no-webhook'], ['cover', 'cover-file'], ['data', 'data-file']]) if (parsed.options.has(a) && parsed.options.has(b)) throw new CliError('invalid_arguments', `Choose either --${a} or --${b}.`)
|
|
84
|
+
for (const key of ['limit', 'page', 'profile', 'webhook', 'callback-port', 'timeout']) {
|
|
85
|
+
if (value(key) !== undefined && (!/^\d+$/.test(value(key)) || !Number.isSafeInteger(Number(value(key))) || Number(value(key)) < 1 || (key === 'callback-port' && Number(value(key)) > 65535) || (key === 'timeout' && Number(value(key)) > 3600))) throw new CliError('invalid_arguments', `--${key} requires a positive whole number${key === 'callback-port' ? ' up to 65535' : ''}.`)
|
|
86
|
+
}
|
|
87
|
+
for (const key of ['filters', 'data']) if (value(key) !== undefined) {
|
|
88
|
+
let obj; try { obj = JSON.parse(value(key)) } catch { throw new CliError('invalid_arguments', `--${key} expects JSON.`) }
|
|
89
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) throw new CliError('invalid_arguments', `--${key} expects a JSON object.`)
|
|
90
|
+
}
|
|
91
|
+
if (parsed.options.has('error-format') && !['json', 'text'].includes(value('error-format'))) throw new CliError('invalid_arguments', '--error-format must be json or text.')
|
|
92
|
+
if (name === 'update' && value('rollback') && (words.length || value('check'))) throw new CliError('invalid_arguments', 'Use --rollback by itself.')
|
|
93
|
+
if (name === 'auth' && Boolean(words.length) === Boolean(value('stdin'))) throw new CliError('invalid_arguments', 'Use hirify auth <key> or hirify auth --stdin.')
|
|
94
|
+
if (name === 'feed deliver' && !['telegram', 'no-telegram', 'webhook', 'no-webhook'].some(key => parsed.options.has(key))) throw new CliError('invalid_arguments', 'Choose at least one delivery setting: --telegram, --no-telegram, --webhook or --no-webhook.')
|
|
95
|
+
if (name === 'auth' && words.length && (!words[0].trim() || /[\r\n]/.test(words[0]))) throw new CliError('invalid_arguments', 'The key must be nonempty and contain no line breaks.')
|
|
96
|
+
if (name === 'feedback send' && (!['bug', 'feature'].includes(words[0]) || !value('body'))) throw new CliError('invalid_arguments', 'Use hirify feedback send bug|feature <title> --body <text>.')
|
|
97
|
+
const args = [...parsed.options].flatMap(([key, values]) => values.map(v => v === true ? `--${key}` : `--${key}=${v}`))
|
|
98
|
+
return { name, words, args, values: Object.fromEntries([...parsed.options].map(([k, v]) => [k, v[0]])), json: Boolean(value('json')), errorJSON: value('error-format') === 'json', timeout: Number(value('timeout') || (name === 'login' ? 360 : name === 'update' ? 300 : 30)) * 1000 }
|
|
99
|
+
}
|
|
100
|
+
export function help(topic = '') {
|
|
101
|
+
const entries = Object.entries(COMMANDS).filter(([name]) => !topic || name === topic || name.startsWith(topic + ' '))
|
|
102
|
+
return `hirify - job search for AI agents\n\n${(entries.length ? entries : Object.entries(COMMANDS)).map(([name, spec]) => ` hirify ${(name + (ARGUMENTS[name] ? ' ' + ARGUMENTS[name] : '')).padEnd(35)} ${spec.description}${spec.flags?.length ? `\n ${spec.flags.map(f => '--' + f + (BOOLEAN_FLAGS.has(f) ? '' : ' <value>')).join(' ')}` : ''}`).join('\n')}\n\nGlobal options: --json, --fields a,b, --error-format json|text, --timeout <seconds>, --debug, --help.\nUse -- to end options; --name=value and --name value are supported.\nLists and search are free. Vacancy read, reveal and apply have separate allowances.\nCheck them with hirify account show. Apply sends a real application: ask the person first.\nHeadless/CI: HIRIFY_KEY or hirify auth --stdin. Manual login: --no-browser.\nSSH: forward the chosen --callback-port to the same loopback port on the CLI host.\nRules for your agent: npx skills add hirifyme/hirify-cli\n`
|
|
103
|
+
}
|
|
104
|
+
export function createOutput({ stdout = process.stdout, stderr = process.stderr, json = false, errorJSON = false, secrets = new Set() } = {}) {
|
|
105
|
+
let closed = false
|
|
106
|
+
const errors = []
|
|
107
|
+
const onError = error => { if (error.code === 'EPIPE') closed = true; else errors.push(error) }
|
|
108
|
+
stdout.on('error', onError); stderr.on('error', onError)
|
|
109
|
+
const write = (stream, value, secret = false) => { if (!closed) stream.write((secret ? safeText(value) : redact(value, [...secrets])) + '\n') }
|
|
110
|
+
const output = {
|
|
111
|
+
jsonMode: json, errorJSON,
|
|
112
|
+
console: { log: (...v) => write(stdout, v.map(String).join(' ')), error: (...v) => write(stderr, v.map(String).join(' ')) },
|
|
113
|
+
json: value => { if (!closed) stdout.write(JSON.stringify(value, (_key, item) => typeof item === 'string' ? redact(item, [...secrets], false) : item, 2) + '\n') },
|
|
114
|
+
dispose: () => { stdout.off('error', onError); stderr.off('error', onError) },
|
|
115
|
+
progress: (value, { secret = false } = {}) => write(stderr, value, secret),
|
|
116
|
+
error: error => {
|
|
117
|
+
const e = error instanceof CliError ? error : new CliError('internal_error', 'The command could not complete. Run hirify doctor and include its output when contacting support.')
|
|
118
|
+
const payload = { schema_version: 1, error: { code: e.code, message: redact(e.message, [...secrets]), retryable: e.retryable, ...(e.status ? { status: e.status } : {}), ...(e.requestId ? { request_id: safeText(e.requestId).slice(0, 128) } : {}) } }
|
|
119
|
+
if (errorJSON) { if (!closed) stderr.write(JSON.stringify(payload, (_key, item) => typeof item === 'string' ? redact(item, [...secrets], false) : item) + '\n') }
|
|
120
|
+
else write(stderr, `hirify: ${payload.error.message}`)
|
|
121
|
+
return e.exitCode
|
|
122
|
+
},
|
|
123
|
+
flush: async () => { if (!closed) await Promise.all([stdout, stderr].map(stream => new Promise(resolve => stream.write('', resolve)))); if (errors.length) throw errors[0] },
|
|
124
|
+
}
|
|
125
|
+
return output
|
|
126
|
+
}
|