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.
@@ -0,0 +1,27 @@
1
+ import { homedir } from 'node:os'
2
+ import { isAbsolute, join } from 'node:path'
3
+ import { CliError } from './errors.js'
4
+ export const DEFAULT_API = 'https://api.hirify.me'
5
+ export function serverURL(input) {
6
+ let url
7
+ try { url = new URL(input) } catch { throw new CliError('invalid_url', 'The server address is not a valid URL.') }
8
+ if (url.username || url.password || url.hash || url.search) throw new CliError('invalid_url', 'The server address must not contain credentials, a query, or a fragment.')
9
+ const loopback = ['127.0.0.1', '[::1]', 'localhost'].includes(url.hostname)
10
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) throw new CliError('insecure_url', 'Use an HTTPS server address. HTTP is supported only for local loopback servers.')
11
+ if (url.pathname !== '/') throw new CliError('invalid_url', 'HIRIFY_API must be an origin without a path.')
12
+ return url.origin
13
+ }
14
+ export function resolveConfig(env = process.env) {
15
+ const base = env.XDG_CONFIG_HOME || join(homedir(), '.config')
16
+ if (!isAbsolute(base)) throw new CliError('config_path', 'XDG_CONFIG_HOME must be an absolute path.')
17
+ const api = serverURL(env.HIRIFY_API || DEFAULT_API)
18
+ return { api, dir: join(base, 'hirify'), envKey: env.HIRIFY_KEY || '', env, version: null }
19
+ }
20
+ export function trustedURL(input, origin, { path = false } = {}) {
21
+ if (typeof input !== 'string' || !input || /[\s\\]/.test(input)) throw new CliError('untrusted_url', 'Hirify returned an invalid address.')
22
+ if (path && (!input.startsWith('/') || input.startsWith('//'))) throw new CliError('untrusted_url', 'Hirify returned an invalid operation path.')
23
+ let url
24
+ try { url = new URL(input, origin) } catch { throw new CliError('untrusted_url', 'Hirify returned an invalid address.') }
25
+ if (url.origin !== origin || url.username || url.password || url.hash) throw new CliError('untrusted_url', 'Hirify returned an address outside the selected server. No credentials were sent there.')
26
+ return url.href
27
+ }
@@ -0,0 +1,20 @@
1
+ /** Errors carry a stable machine code; only the entrypoint decides how to exit. */
2
+ export class CliError extends Error {
3
+ constructor(code, message, { exitCode = 1, status, retryable = false, requestId, action } = {}) {
4
+ super(message)
5
+ this.name = 'CliError'
6
+ Object.assign(this, { code, exitCode, status, retryable, requestId, action })
7
+ }
8
+ }
9
+ export function fail(message, exitCode = 1) { throw new CliError('command_failed', message, { exitCode }) }
10
+ export function aborted(signal) {
11
+ if (signal?.aborted) throw signal.reason instanceof CliError ? signal.reason : new CliError('cancelled', 'The command was cancelled.', { exitCode: 130 })
12
+ }
13
+ export function safeText(value) {
14
+ return String(value).replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '').replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '')
15
+ }
16
+ export function redact(value, secrets = [], terminal = true) {
17
+ let text = terminal ? safeText(value) : String(value)
18
+ for (const secret of secrets) if (typeof secret === 'string' && secret.length) text = text.split(secret).join('[redacted]')
19
+ return text.replace(/\b(Bearer\s+)[^\s"']+/gi, '$1[redacted]').replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/g, '$1[redacted]@')
20
+ }
@@ -0,0 +1,113 @@
1
+ import http from 'node:http'
2
+ import https from 'node:https'
3
+ import { HttpProxyAgent } from 'http-proxy-agent'
4
+ import { HttpsProxyAgent } from 'https-proxy-agent'
5
+ import proxyEnv from 'proxy-from-env'
6
+ import { CliError, aborted } from './errors.js'
7
+
8
+ export function retryDelay(value, now = Date.now()) {
9
+ if (!value) return 0
10
+ const seconds = Number(value)
11
+ return Number.isFinite(seconds) ? Math.max(0, seconds * 1000) : Math.max(0, Date.parse(value) - now) || 0
12
+ }
13
+ export function delay(ms, signal) {
14
+ aborted(signal)
15
+ return new Promise((resolve, reject) => {
16
+ const finish = () => { clearTimeout(timer); signal?.removeEventListener('abort', cancel); resolve() }
17
+ const cancel = () => { clearTimeout(timer); signal?.removeEventListener('abort', cancel); reject(signal.reason) }
18
+ const timer = setTimeout(finish, ms)
19
+ signal?.addEventListener('abort', cancel, { once: true })
20
+ })
21
+ }
22
+ export function createHttp({ signal, version, event = () => {}, timeoutMs = 30000, limit = 8 * 1024 * 1024 }) {
23
+ const active = new Set()
24
+ async function once(input, { method = 'GET', headers = {}, body, timeout = timeoutMs, maxBytes = limit } = {}) {
25
+ aborted(signal)
26
+ const url = new URL(input)
27
+ const started = Date.now()
28
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) throw new CliError('untrusted_url', 'Unsupported request address.')
29
+ let agent
30
+ const proxy = proxyEnv.getProxyForUrl(url.href)
31
+ if (proxy) {
32
+ let p
33
+ try { p = new URL(proxy) } catch { throw new CliError('proxy_config', 'The proxy address is invalid.') }
34
+ if (!['http:', 'https:'].includes(p.protocol)) throw new CliError('proxy_config', 'Use an HTTP or HTTPS proxy.')
35
+ agent = url.protocol === 'https:' ? new HttpsProxyAgent(p, { rejectUnauthorized: true }) : new HttpProxyAgent(p, { rejectUnauthorized: true })
36
+ }
37
+ return new Promise((resolve, reject) => {
38
+ let settled = false
39
+ let req
40
+ const finish = (error, result) => {
41
+ if (settled) return
42
+ settled = true
43
+ clearTimeout(timer)
44
+ signal?.removeEventListener('abort', cancel)
45
+ if (req) active.delete(req)
46
+ agent?.destroy()
47
+ event('http', { code: error?.code || 'response', status: result?.status, duration_ms: Date.now() - started })
48
+ if (error) reject(error); else resolve(result)
49
+ }
50
+ const cancel = () => { req?.destroy(); finish(signal.reason || new CliError('cancelled', 'The command was cancelled.', { exitCode: 130 })) }
51
+ const timer = setTimeout(() => { req?.destroy(); finish(new CliError('network_timeout', 'The server did not answer in time.', { retryable: method === 'GET' })) }, timeout)
52
+ signal?.addEventListener('abort', cancel, { once: true })
53
+ try {
54
+ req = (url.protocol === 'https:' ? https : http).request(url, {
55
+ method, agent, rejectUnauthorized: true,
56
+ headers: { Accept: 'application/json', 'User-Agent': `hirify-cli/${version}`, 'Accept-Encoding': 'identity', ...headers },
57
+ }, res => {
58
+ const chunks = []; let size = 0
59
+ res.on('data', chunk => {
60
+ size += chunk.length
61
+ if (size > maxBytes) { req.destroy(); finish(new CliError('response_too_large', 'The server response exceeded the supported size.')) }
62
+ else chunks.push(chunk)
63
+ })
64
+ res.on('error', () => finish(new CliError('response_incomplete', 'The connection ended before the response was complete.')))
65
+ res.on('end', () => {
66
+ const status = res.statusCode
67
+ const headers = new Headers()
68
+ for (const [key, value] of Object.entries(res.headers)) if (value !== undefined) headers.set(key, Array.isArray(value) ? value.join(', ') : value)
69
+ if (status >= 300 && status < 400) return finish(new CliError('redirect_refused', 'The server redirected this request. No credentials were forwarded.', { status }))
70
+ const text = Buffer.concat(chunks).toString('utf8')
71
+ let body = null
72
+ try { body = JSON.parse(text) } catch {}
73
+ finish(null, { status, ok: status >= 200 && status < 300, headers, text, body })
74
+ })
75
+ })
76
+ active.add(req)
77
+ req.on('error', error => {
78
+ const c = error.code || error.cause?.code
79
+ const code = /CERT|TLS|SSL|SELF_SIGNED|UNABLE_TO_VERIFY/.test(c || '') ? 'tls_error' : proxy ? 'proxy_error' : c === 'ENOTFOUND' || c === 'EAI_AGAIN' ? 'dns_error' : 'network_error'
80
+ finish(new CliError(code, code === 'tls_error' ? 'The TLS certificate could not be verified. Check your trusted CA configuration.' : 'Could not reach the server. Check your connection and proxy settings.', { retryable: method === 'GET' }))
81
+ })
82
+ if (signal?.aborted) return cancel()
83
+ req.end(body)
84
+ } catch { finish(new CliError('network_error', 'Could not start the request. Check your server and proxy settings.')) }
85
+ })
86
+ }
87
+ async function request(url, options = {}) {
88
+ const attempts = options.safe ? 3 : 1
89
+ for (let i = 0; i < attempts; i++) {
90
+ try {
91
+ const res = await once(url, options)
92
+ if (i + 1 < attempts && [429, 502, 503, 504].includes(res.status)) {
93
+ const wait = retryDelay(res.headers.get('retry-after')) || (100 * 2 ** i + Math.random() * 100)
94
+ if (wait <= 2000) { await delay(wait, signal); continue }
95
+ }
96
+ return res
97
+ } catch (error) {
98
+ if (i + 1 >= attempts || !['network_error', 'dns_error'].includes(error.code)) throw error
99
+ await delay(100 * 2 ** i + Math.random() * 100, signal)
100
+ }
101
+ }
102
+ }
103
+ return { request, close: () => { for (const req of active) req.destroy(); active.clear() } }
104
+ }
105
+ export function requireJSON(response, { mutation = false } = {}) {
106
+ const type = response.headers.get('content-type') || ''
107
+ if (!/\b(?:application\/json|application\/[\w.+-]+\+json)\b/i.test(type) || response.body === null || typeof response.body !== 'object') {
108
+ throw new CliError(mutation ? 'outcome_unknown' : 'protocol_error', mutation
109
+ ? 'The server did not return a readable confirmation. The action may have completed. Check its result before trying again.'
110
+ : 'The server returned an unreadable response.', { status: response.status })
111
+ }
112
+ return response.body
113
+ }
@@ -0,0 +1,39 @@
1
+ import { readFileSync, statSync } from 'node:fs'
2
+ import { CliError, aborted } from './errors.js'
3
+ const LIMIT = 1024 * 1024
4
+ function decodeInput(bytes) {
5
+ try { return new TextDecoder('utf-8', { fatal: true }).decode(bytes) }
6
+ catch { throw new CliError('input_encoding', 'Input must be UTF-8 text. Save the file as UTF-8 before retrying.') }
7
+ }
8
+ async function readInput(path, signal) {
9
+ aborted(signal)
10
+ if (path !== '-') {
11
+ try { if (!statSync(path).isFile() || statSync(path).size > LIMIT) throw new Error(); return decodeInput(readFileSync(path)) }
12
+ catch (error) { if (error instanceof CliError) throw error; throw new CliError('input_file', 'Could not read the input file. Use a regular UTF-8 file no larger than 1 MiB.') }
13
+ }
14
+ if (process.stdin.isTTY) throw new CliError('interaction_required', 'Pipe the input to stdin, or use a file path.')
15
+ return new Promise((resolve, reject) => {
16
+ const chunks = []; let size = 0
17
+ const clean = () => { process.stdin.off('data', data); process.stdin.off('end', end); process.stdin.off('error', error); signal?.removeEventListener('abort', cancel); process.stdin.pause() }
18
+ const error = () => { clean(); reject(new CliError('input_file', 'Could not read standard input.')) }
19
+ const data = chunk => { size += chunk.length; if (size > LIMIT) { clean(); reject(new CliError('input_too_large', 'Standard input exceeds 1 MiB.')) } else chunks.push(chunk) }
20
+ const end = () => { clean(); try { resolve(decodeInput(Buffer.concat(chunks))) } catch (error) { reject(error) } }
21
+ const cancel = () => { clean(); reject(signal.reason) }
22
+ process.stdin.on('data', data); process.stdin.once('end', end); process.stdin.once('error', error); signal?.addEventListener('abort', cancel, { once: true })
23
+ if (signal?.aborted) cancel()
24
+ })
25
+ }
26
+ export async function prepareInput(command, signal) {
27
+ for (const [source, target] of [['data-file', 'data'], ['cover-file', 'cover']]) {
28
+ if (command.values[source] !== undefined) {
29
+ const text = await readInput(command.values[source], signal)
30
+ if (target === 'data') {
31
+ let data; try { data = JSON.parse(text) } catch { throw new CliError('invalid_arguments', 'The input file must contain a JSON object.') }
32
+ if (!data || typeof data !== 'object' || Array.isArray(data)) throw new CliError('invalid_arguments', 'The input file must contain a JSON object.')
33
+ }
34
+ command.args = command.args.filter(v => !v.startsWith(`--${source}=`)); command.args.push(`--${target}=${text}`)
35
+ }
36
+ }
37
+ if (command.name === 'auth' && command.values.stdin) command.words = [(await readInput('-', signal)).trim()]
38
+ if (command.name === 'auth' && (!command.words[0]?.trim() || /[\r\n]/.test(command.words[0]))) throw new CliError('invalid_arguments', 'Provide one nonempty API key.')
39
+ }
@@ -0,0 +1,65 @@
1
+ import { createServer } from 'node:http'
2
+ import { timingSafeEqual } from 'node:crypto'
3
+ import { CliError, aborted } from './errors.js'
4
+
5
+ export async function startCallbackServer({ state, issuer, port = 0, signal, timeoutMs = 300000, serverFactory = createServer }) {
6
+ aborted(signal)
7
+ let settle, reject, settled = false, closePromise
8
+ const sockets = new Set()
9
+ const received = new Promise((resolve, fail) => { settle = resolve; reject = fail })
10
+ // The consumer may still be registering the public client when cancellation arrives.
11
+ received.catch(() => {})
12
+ let address
13
+ const server = serverFactory({ maxHeaderSize: 8192 }, (req, res) => {
14
+ const reply = (status, text, html = false) => {
15
+ res.writeHead(status, { 'Content-Type': html ? 'text/html; charset=utf-8' : 'text/plain; charset=utf-8', 'Cache-Control': 'no-store', 'Content-Security-Policy': "default-src 'none'", 'Referrer-Policy': 'no-referrer', Connection: 'close' })
16
+ res.end(text)
17
+ }
18
+ let url
19
+ try { url = new URL(req.url, address) } catch { return reply(400, 'Invalid request.') }
20
+ if (req.method !== 'GET') return reply(405, 'Use GET.')
21
+ if (!req.url.startsWith('/') || url.origin !== new URL(address).origin || req.headers.host !== new URL(address).host) return reply(400, 'Invalid callback address.')
22
+ if (url.pathname !== '/callback') return reply(404, 'Not found.')
23
+ const params = url.searchParams
24
+ const value = params.get('state') || ''
25
+ const validState = Buffer.byteLength(value) === Buffer.byteLength(state) && timingSafeEqual(Buffer.from(value), Buffer.from(state))
26
+ const code = params.get('code'), error = params.get('error')
27
+ if (settled || !validState || params.getAll('state').length !== 1 || params.getAll('code').length > 1 || params.getAll('error').length > 1 || Boolean(code) === Boolean(error) || (params.has('iss') && (params.getAll('iss').length !== 1 || params.get('iss') !== issuer))) return reply(400, 'This callback does not match the pending sign-in.')
28
+ settled = true
29
+ reply(200, '<!doctype html><html lang="en"><meta charset="utf-8"><title>Hirify</title><h1>Confirmation received</h1><p>Return to your terminal to see the sign-in result.</p></html>', true)
30
+ settle({ code, error })
31
+ })
32
+ server.requestTimeout = 5000
33
+ server.headersTimeout = 5000
34
+ server.keepAliveTimeout = 1000
35
+ server.on('connection', socket => { sockets.add(socket); socket.on('close', () => sockets.delete(socket)); socket.setTimeout(5000, () => socket.destroy()) })
36
+ server.on('clientError', (_, socket) => socket.destroy())
37
+ const listen = host => new Promise((resolve, fail) => {
38
+ const error = e => { server.off('listening', ready); fail(e) }
39
+ const ready = () => { server.off('error', error); resolve() }
40
+ server.once('error', error); server.once('listening', ready); server.listen(port, host)
41
+ })
42
+ let host = '127.0.0.1'
43
+ try { await listen(host) } catch (e) {
44
+ if (!['EADDRNOTAVAIL', 'EAFNOSUPPORT'].includes(e.code)) throw new CliError('callback_bind_failed', 'Could not listen for the browser callback. Choose another --callback-port or use an API key.')
45
+ host = '::1'
46
+ try { await listen(host) } catch { throw new CliError('callback_bind_failed', 'No loopback address is available. Use HIRIFY_KEY instead.') }
47
+ }
48
+ address = `http://${host === '::1' ? '[::1]' : host}:${server.address().port}`
49
+ const close = () => {
50
+ if (closePromise) return closePromise
51
+ clearTimeout(timer); signal?.removeEventListener('abort', cancel)
52
+ closePromise = new Promise(resolve => {
53
+ const force = setTimeout(() => { for (const socket of sockets) socket.destroy() }, 200)
54
+ server.close(() => { clearTimeout(force); resolve() })
55
+ server.closeIdleConnections?.()
56
+ })
57
+ return closePromise
58
+ }
59
+ const cancel = () => { if (!settled) { settled = true; reject(signal.reason || new CliError('cancelled', 'Sign-in was cancelled.', { exitCode: 130 })) }; void close() }
60
+ const timer = setTimeout(() => { if (!settled) { settled = true; reject(new CliError('login_timeout', 'No matching browser confirmation arrived in time. Run hirify login again.')) }; void close() }, timeoutMs)
61
+ server.on('error', () => { if (!settled) { settled = true; reject(new CliError('callback_error', 'The local callback server stopped. Run hirify login again.')) }; void close() })
62
+ signal?.addEventListener('abort', cancel, { once: true })
63
+ if (signal?.aborted) cancel()
64
+ return { redirectUri: `${address}/callback`, received, close }
65
+ }
@@ -0,0 +1,103 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import { fileURLToPath, pathToFileURL } from 'node:url'
4
+ import { parse, help, createOutput } from './cli.js'
5
+ import { CliError } from './errors.js'
6
+ const ownRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url))))
7
+
8
+ export async function main(argv = process.argv.slice(2), { baseRoot = ownRoot, baseVersion, selected = false, skipUpdate = false } = {}) {
9
+ let command
10
+ let output = createOutput({ errorJSON: argv.includes('--error-format=json') || argv.some((x, i) => x === '--error-format' && argv[i + 1] === 'json') })
11
+ let cleanup = async () => {}
12
+ let next
13
+ let exitCode = 0
14
+ try {
15
+ command = parse(argv)
16
+ if (command.help) { output.console.log(help(command.topic)); await output.flush(); return 0 }
17
+ const pkg = JSON.parse(readFileSync(join(ownRoot, 'package.json'), 'utf8'))
18
+ baseVersion ||= pkg.version
19
+ const { resolveConfig } = await import('./config.js')
20
+ // A broken configuration must not make the offline version command unavailable.
21
+ let config
22
+ try { config = resolveConfig() } catch (error) { if (command.name !== 'version') throw error; config = { dir: '', env: process.env, api: '' } }
23
+ config.version = pkg.version
24
+ const { createUpdater, activeInstallation } = await import('./update.js')
25
+ if (!selected && config.dir) {
26
+ const active = activeInstallation(config.dir, baseRoot, baseVersion, config.env.HIRIFY_VERSION_PIN)
27
+ if (active && active.root !== ownRoot) {
28
+ const module = await import(pathToFileURL(join(active.root, 'bin', 'lib', 'main.js')).href)
29
+ return await module.main(argv, { baseRoot, baseVersion, selected: true, skipUpdate })
30
+ }
31
+ }
32
+ if (config.env.HIRIFY_VERSION_PIN && config.env.HIRIFY_VERSION_PIN !== pkg.version && command.name !== 'update') throw new CliError('version_pin_unavailable', 'The pinned CLI version is not active. Run hirify update <exact-version> or install that exact npm version.')
33
+ if (command.name === 'version') { if (command.json) output.json({ version: pkg.version }); else output.console.log(pkg.version); await output.flush(); return 0 }
34
+ const controller = new AbortController()
35
+ const signal = controller.signal
36
+ const interrupt = () => controller.abort(new CliError('cancelled', 'The command was cancelled.', { exitCode: 130 }))
37
+ const terminate = () => controller.abort(new CliError('cancelled', 'The command was terminated.', { exitCode: 143 }))
38
+ process.once('SIGINT', interrupt); process.once('SIGTERM', terminate)
39
+ const timer = setTimeout(() => controller.abort(new CliError('command_timeout', 'The command deadline was reached. Use --timeout <seconds> if more time is needed.')), command.timeout)
40
+ cleanup = async () => { clearTimeout(timer); process.off('SIGINT', interrupt); process.off('SIGTERM', terminate) }
41
+ const secrets = new Set([config.envKey].filter(Boolean))
42
+ output.dispose()
43
+ output = createOutput({ json: command.json, errorJSON: command.errorJSON, secrets })
44
+ const events = []
45
+ const event = (phase, data = {}) => {
46
+ const item = { phase, code: data.code || 'unknown', ...(Number.isInteger(data.status) ? { status: data.status } : {}), ...(Number.isFinite(data.duration_ms) ? { duration_ms: data.duration_ms } : {}) }
47
+ if (events.length < 100) events.push(item)
48
+ if (command.values.debug || config.env.HIRIFY_DEBUG) output.progress(JSON.stringify(item))
49
+ }
50
+ const [{ createHttp }, { createStore }, { createAuth }, { createApi }, { createCommands }, { prepareInput }] = await Promise.all([import('./http.js'), import('./store.js'), import('./auth.js'), import('./api.js'), import('./commands.js'), import('./input.js')])
51
+ const http = createHttp({ signal, version: pkg.version, event })
52
+ cleanup = async () => { clearTimeout(timer); process.off('SIGINT', interrupt); process.off('SIGTERM', terminate); http.close() }
53
+ await prepareInput(command, signal)
54
+ const store = createStore(config, { signal })
55
+ const auth = createAuth({ config, store, http, signal, output, event, secrets })
56
+ const api = createApi({ config, http, auth, output })
57
+ const updater = createUpdater({ config, http, signal, output, event, packageRoot: ownRoot, baseRoot, baseVersion })
58
+ const local = ['auth', 'auth status', 'logout', 'doctor', 'skill', 'update'].includes(command.name)
59
+ if (!skipUpdate && !local) next = await updater.automatic()
60
+ if (next) {
61
+ // Dispose every timer and signal handler before handing off, at most once per invocation.
62
+ } else if (command.name === 'login') {
63
+ const result = await auth.login({ force: Boolean(command.values.force), noBrowser: Boolean(command.values['no-browser']), port: Number(command.values['callback-port'] || 0), consentMs: Math.min(command.timeout, 300000) })
64
+ if (command.json) output.json(result)
65
+ else { output.console.log(result.already_signed_in ? 'Already signed in. Use hirify login --force to sign in again.' : 'Signed in. Access saved on this computer.'); if (result.environment_override || result.source === 'environment') output.progress('HIRIFY_KEY is set and takes precedence over the saved sign-in.'); output.console.log('Your plan and allowances: hirify account show') }
66
+ } else if (command.name === 'auth') {
67
+ secrets.add(command.words[0])
68
+ await store.commit({ kind: 'key', access_token: command.words[0] })
69
+ if (command.json) output.json({ signed_in: true, source: 'key', environment_override: Boolean(config.envKey) })
70
+ else output.console.log('Key saved on this computer.')
71
+ if (config.envKey) output.progress('HIRIFY_KEY is set and takes precedence over the saved key.')
72
+ } else if (command.name === 'logout') {
73
+ const had = await store.logout()
74
+ if (command.json) output.json({ signed_out: true, environment_override: Boolean(config.envKey) })
75
+ else output.console.log(had ? 'Signed out on this computer.' : 'Nobody is signed in here.')
76
+ if (config.envKey) output.progress('HIRIFY_KEY is still set. Remove it from the environment to stop using that key.')
77
+ } else if (command.name === 'auth status') {
78
+ const status = auth.status()
79
+ if (command.json) output.json(status); else output.console.log(`Sign-in source: ${status.source}\nServer: ${status.issuer}${status.renewal_uncertain ? '\nRenewal needs a new sign-in: hirify login --force' : ''}`)
80
+ } else if (command.name === 'doctor') {
81
+ const { environment } = await import('./browser.js')
82
+ let status
83
+ try { status = auth.status() } catch (error) { status = { error: error.code } }
84
+ const info = { schema_version: 1, cli_version: pkg.version, node: process.version, platform: process.platform, architecture: process.arch, server: config.api, config_source: config.env.XDG_CONFIG_HOME ? 'XDG_CONFIG_HOME' : 'home', auth: status, environment: environment(), proxy_configured: Boolean(config.env.HTTPS_PROXY || config.env.https_proxy || config.env.HTTP_PROXY || config.env.http_proxy), custom_ca: Boolean(config.env.NODE_EXTRA_CA_CERTS), auto_update_disabled: config.env.HIRIFY_NO_AUTO_UPDATE === '1', managed_version: ownRoot !== baseRoot, installation: await updater.installation() }
85
+ output.json(info)
86
+ } else if (command.name === 'update') {
87
+ if (command.values.rollback) { const result = await updater.rollback(); output.json(result); return 0 }
88
+ const target = await updater.check(command.words[0] || config.env.HIRIFY_VERSION_PIN || 'latest')
89
+ if (command.values.check) output.json({ current: pkg.version, ...target, integrity: undefined, registry: undefined, installation: await updater.installation() })
90
+ else { const result = await updater.install(target, { pin: Boolean(command.words[0] || config.env.HIRIFY_VERSION_PIN) }); if (command.json) output.json(result); else output.console.log(`Hirify CLI ${result.version} is ready for the next command.${result.pinned ? ' This version is pinned.' : ''}`) }
91
+ } else {
92
+ const commands = createCommands({ api, output, config, signal, event, fields: command.values.fields })
93
+ if (command.name === 'skill' && command.json) output.json({ install: 'npx skills add hirifyme/hirify-cli' })
94
+ else await commands[command.name](command.args, command.words)
95
+ }
96
+ } catch (error) { exitCode = output.error(error) }
97
+ finally { await cleanup(); await output.flush().catch(() => { exitCode = 1 }); output.dispose() }
98
+ if (next) {
99
+ const module = await import(pathToFileURL(join(next.root, 'bin', 'lib', 'main.js')).href)
100
+ return module.main(argv, { baseRoot, baseVersion, selected: true, skipUpdate: true })
101
+ }
102
+ return exitCode
103
+ }
@@ -0,0 +1,62 @@
1
+ export function serverMessage(body) {
2
+ const firstLine = (value) => {
3
+ if (typeof value === 'string' && value.trim()) return value.trim()
4
+ if (Array.isArray(value)) {
5
+ for (const item of value) {
6
+ const line = firstLine(item)
7
+ if (line) return line
8
+ }
9
+ }
10
+ if (value && typeof value === 'object') {
11
+ for (const item of Object.values(value)) {
12
+ const line = firstLine(item)
13
+ if (line) return line
14
+ }
15
+ }
16
+ return null
17
+ }
18
+
19
+ return firstLine(body?.error?.details) || firstLine(body?.error?.message) || firstLine(body?.message)
20
+ }
21
+
22
+ /**
23
+ * The one-line report for a policy restriction: a canonical 403 that names itself
24
+ * `access_restricted`. It is kept apart from the quota and rate-limit walls on purpose,
25
+ * because those clear on their own and this one does not. The CLI states what the server
26
+ * sent - the message it wrote, when the restriction lifts, where to ask for a review - and
27
+ * adds no rule of its own about why it was applied.
28
+ */
29
+ export function accessRestrictedMessage(body) {
30
+ const err = (body && typeof body.error === 'object' && body.error) || {}
31
+ const line = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null)
32
+ const parts = [line(err.message) || line(body?.message) || 'access on this account is restricted (403).']
33
+ const until = line(err.restricted_until)
34
+ if (until) parts.push(`It stays in place until ${until}.`)
35
+ const appeal = line(err.appeal_url)
36
+ if (appeal) parts.push(`To ask us to look at it again: ${appeal}`)
37
+ return parts.join('\n ')
38
+ }
39
+
40
+ /**
41
+ * The report for a blocking notice: a canonical 409 that names itself `action_required`.
42
+ * Unlike a `meta.notice` on a success, this one stands in place of the answer - the server is
43
+ * holding the result until the person confirms - so the CLI shows the text the server wrote and
44
+ * the one command that clears it, and reads nothing into the policy behind it. That command is
45
+ * the acknowledgement capability the server lists in its manifest, reached the same way as any
46
+ * other, so no route to it is written here. It carries the notice's own id and its first
47
+ * advertised action, both as the server sent them, so the printed line runs as it stands.
48
+ */
49
+ export function actionRequiredMessage(body) {
50
+ const notice = (body && typeof body.error === 'object' && body.error && typeof body.error.notice === 'object' && body.error.notice) || {}
51
+ const line = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null)
52
+ const parts = [line(notice.message) || 'this needs your confirmation before it can go ahead (409).']
53
+
54
+ const id = line(notice.id)
55
+ const action = Array.isArray(notice.actions) ? notice.actions.map(line).find(Boolean) : null
56
+ const call = 'hirify api call security.notices.ack'
57
+ parts.push(id && action
58
+ ? `To continue, save this JSON to a file and run ${call} --data-file <file>:\n${JSON.stringify({ id, action })}`
59
+ : `To continue, acknowledge this notice: ${call}`)
60
+ return parts.join('\n ')
61
+ }
62
+
@@ -0,0 +1,118 @@
1
+ import { mkdirSync, lstatSync, readFileSync, openSync, writeFileSync, fsyncSync, closeSync, renameSync, unlinkSync, chmodSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { randomUUID, createHash } from 'node:crypto'
4
+ import { execFile } from 'node:child_process'
5
+ import lockfile from 'proper-lockfile'
6
+ import { CliError, aborted } from './errors.js'
7
+ import { DEFAULT_API, serverURL } from './config.js'
8
+ import { delay } from './http.js'
9
+
10
+ function stat(path) { try { return lstatSync(path) } catch (e) { if (e.code === 'ENOENT') return null; throw e } }
11
+ function check(path, directory = false) {
12
+ const s = stat(path)
13
+ if (!s) return null
14
+ if (s.isSymbolicLink() || (directory ? !s.isDirectory() : !s.isFile())) throw new CliError('unsafe_storage', 'The credential location must be a regular file in a private directory, not a link.')
15
+ if (process.platform !== 'win32' && s.uid !== process.getuid()) throw new CliError('unsafe_storage', 'The credential location belongs to another user.')
16
+ return s
17
+ }
18
+ export async function makePrivate(path, directory = false, signal) {
19
+ aborted(signal)
20
+ check(path, directory)
21
+ if (process.platform !== 'win32') { chmodSync(path, directory ? 0o700 : 0o600); return }
22
+ // Set an exact DACL for the current SID; POSIX mode bits do not secure Windows files.
23
+ // Use .NET directly: PowerShell 7 can pass a module path incompatible with Windows PowerShell.
24
+ const script = `$ErrorActionPreference='Stop'; $sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User; $p=$env:HIRIFY_SECURE_PATH; $d=[System.IO.Directory]::Exists($p); if($d){$acl=[System.Security.AccessControl.DirectorySecurity]::new(); $rule=[System.Security.AccessControl.FileSystemAccessRule]::new($sid,[System.Security.AccessControl.FileSystemRights]::FullControl,[System.Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit',[System.Security.AccessControl.PropagationFlags]::None,[System.Security.AccessControl.AccessControlType]::Allow)}else{$acl=[System.Security.AccessControl.FileSecurity]::new(); $rule=[System.Security.AccessControl.FileSystemAccessRule]::new($sid,[System.Security.AccessControl.FileSystemRights]::FullControl,[System.Security.AccessControl.AccessControlType]::Allow)}; $acl.SetOwner($sid); $acl.SetAccessRuleProtection($true,$false); $acl.AddAccessRule($rule); if($d){[System.IO.Directory]::SetAccessControl($p,$acl)}else{[System.IO.File]::SetAccessControl($p,$acl)}`
25
+ await new Promise((resolve, reject) => execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], { env: { ...process.env, HIRIFY_SECURE_PATH: path }, signal, timeout: 10000, windowsHide: true }, error => error ? reject(signal?.aborted ? signal.reason : new CliError('storage_permissions', 'Could not set private Windows access permissions.')) : resolve()))
26
+ }
27
+ export function createStore(config, { signal } = {}) {
28
+ const file = join(config.dir, 'auth.json')
29
+ const legacy = join(config.dir, 'key')
30
+ let compromised = false
31
+ let secured = false
32
+ async function ensure() {
33
+ try { mkdirSync(config.dir, { recursive: true, mode: 0o700 }); await makePrivate(config.dir, true, signal) }
34
+ catch (e) { if (signal?.aborted) throw signal.reason; if (e instanceof CliError) throw e; throw new CliError('storage_unavailable', 'Could not create a private credential directory. Check its permissions.') }
35
+ }
36
+ function readRaw() {
37
+ try {
38
+ const stateStat = check(file)
39
+ if (stateStat) {
40
+ const raw = readFileSync(file, 'utf8')
41
+ let state
42
+ try { state = JSON.parse(raw) } catch { throw new CliError('state_corrupt', 'The saved sign-in is damaged. Run hirify login --force to replace it, or hirify logout to remove it.') }
43
+ if (!state || typeof state !== 'object' || Array.isArray(state) || (state.schema_version !== undefined && state.schema_version !== 1)) throw new CliError('state_unsupported', 'The saved sign-in format is not supported by this version.')
44
+ if (state.kind !== 'signed-out' && (!['key', 'oauth'].includes(state.kind) || typeof state.access_token !== 'string' || !state.access_token.trim())) throw new CliError('state_corrupt', 'The saved sign-in is incomplete. Run hirify login --force.')
45
+ if (state.kind === 'oauth' && ((state.expires_at !== null && state.expires_at !== undefined && (!Number.isFinite(state.expires_at) || state.expires_at < 0)) || (state.refresh_token != null && typeof state.refresh_token !== 'string') || typeof state.client_id !== 'string')) throw new CliError('state_corrupt', 'The saved sign-in is incomplete. Run hirify login --force.')
46
+ return { ...state, generation: state.generation || `legacy:${createHash('sha256').update(raw).digest('hex')}` }
47
+ }
48
+ if (check(legacy)) {
49
+ const key = readFileSync(legacy, 'utf8').trim()
50
+ if (key) return { kind: 'key', issuer: DEFAULT_API, access_token: key, generation: `legacy:${createHash('sha256').update(key).digest('hex')}` }
51
+ }
52
+ return { kind: 'signed-out', generation: 'absent' }
53
+ } catch (e) { if (e instanceof CliError) throw e; throw new CliError('storage_unreadable', 'Could not read the saved sign-in. Check the credential directory permissions.') }
54
+ }
55
+ function read({ allowPending = false } = {}) {
56
+ const state = readRaw()
57
+ if (state.kind === 'signed-out') return null
58
+ const issuer = state.issuer || DEFAULT_API
59
+ if (serverURL(issuer) !== config.api) throw new CliError('issuer_mismatch', 'This sign-in belongs to another server. Use an explicit HIRIFY_KEY or sign in to the selected server with hirify login --force.')
60
+ if (state.refresh_pending && !allowPending) throw new CliError('refresh_uncertain', 'The previous token renewal did not finish safely. Run hirify login --force before continuing.')
61
+ return { ...state, issuer }
62
+ }
63
+ async function write(state) {
64
+ if (compromised) throw new CliError('storage_lock_lost', 'The sign-in lock was lost. No credentials were committed.')
65
+ await ensure(); check(file)
66
+ const temp = join(config.dir, `.auth-${randomUUID()}.tmp`)
67
+ let fd
68
+ try {
69
+ fd = openSync(temp, 'wx', 0o600)
70
+ writeFileSync(fd, JSON.stringify({ ...state, schema_version: 1 }, null, 2) + '\n')
71
+ fsyncSync(fd); closeSync(fd); fd = undefined
72
+ if (process.platform === 'win32') await makePrivate(temp, false, signal)
73
+ for (let attempt = 0; ; attempt++) {
74
+ try { renameSync(temp, file); break }
75
+ catch (error) { if (process.platform !== 'win32' || !['EPERM', 'EACCES', 'EBUSY'].includes(error.code) || attempt >= 4) throw error; await delay(50 * (attempt + 1), signal) }
76
+ }
77
+ if (process.platform !== 'win32') { const dir = openSync(config.dir, 'r'); try { fsyncSync(dir) } finally { closeSync(dir) } }
78
+ if (check(legacy)) unlinkSync(legacy)
79
+ } catch (e) { if (e instanceof CliError) throw e; throw new CliError('storage_write_failed', 'Could not save the sign-in safely. Check disk space and directory permissions. You may need to sign in again.') }
80
+ finally { if (fd !== undefined) closeSync(fd); try { unlinkSync(temp) } catch {} }
81
+ }
82
+ async function locked(fn) {
83
+ await ensure(); aborted(signal)
84
+ const lockPath = join(config.dir, '.session.lock')
85
+ let release
86
+ compromised = false
87
+ while (!release) {
88
+ aborted(signal)
89
+ try { release = await lockfile.lock(file, { realpath: false, lockfilePath: lockPath, stale: 120000, update: 10000, onCompromised: () => { compromised = true } }) }
90
+ catch (e) { if (e.code !== 'ELOCKED') throw new CliError('storage_lock_failed', 'Could not lock the saved sign-in. Check directory permissions.'); await delay(40 + Math.random() * 80, signal) }
91
+ }
92
+ try { aborted(signal); return await fn() } finally { await release().catch(() => {}) }
93
+ }
94
+ async function commit(state, expected) {
95
+ return locked(async () => {
96
+ const current = readRaw()
97
+ if (expected !== undefined && current.generation !== expected) throw new CliError('session_changed', 'The saved sign-in changed while this command was running. No older credentials were restored.')
98
+ const next = { ...state, issuer: config.api, generation: randomUUID(), refresh_pending: false }
99
+ await write(next); return next
100
+ })
101
+ }
102
+ async function logout() {
103
+ return locked(async () => {
104
+ let had = true
105
+ try { had = readRaw().kind !== 'signed-out' } catch (e) { if (!['state_corrupt', 'state_unsupported'].includes(e.code)) throw e }
106
+ await write({ kind: 'signed-out', generation: randomUUID() }); return had
107
+ })
108
+ }
109
+ async function beginLogin({ force = false } = {}) {
110
+ return locked(async () => {
111
+ let state
112
+ try { state = readRaw() } catch (e) { if (!force || !['state_corrupt', 'state_unsupported'].includes(e.code)) throw e; await write({ kind: 'signed-out', generation: randomUUID() }); state = readRaw() }
113
+ return state.generation
114
+ })
115
+ }
116
+ async function secure() { if (secured) return; await ensure(); for (const path of [file, legacy]) if (check(path)) await makePrivate(path, false, signal); secured = true }
117
+ return { file, read, readRaw, write, locked, commit, logout, beginLogin, secure }
118
+ }