Uzun sure acik kalan IDE'de "Telefon ekle" ilk istegi olu bir keep-alive baglantisinda kaybedebiliyordu; bulusma kaydi yayinlanamayinca kod yolu bes dakika boyunca "Kodla baglanma su an kullanilamiyor" kaliyordu (QR calisiyordu). - publishPairOffer: en cok 4 deneme (0,5 / 1,5 / 3 sn); ag hatasi, 5xx ve 429 yeniden denenir, 422 kalicidir; yaniti kaybolan denemeden sonraki 409 kaydin yerinde oldugu anlamina gelir (iptal jetonu yoktur, kayit kendiliginden biter). - Pencerede "Yeniden dene": ayni kod ve QR ile kaydi yeniden yayinlar. - Role oturumu kaydi ve "Giris yap" baslangici icin tek ek deneme (zaman asimi yeniden denenmez). - Gunluge yalniz "pair offer published / failed after N tries" duser; kod, lookup ve jeton yazilmaz. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
669 lines
20 KiB
TypeScript
669 lines
20 KiB
TypeScript
/**
|
||
* aiturk-login.ts
|
||
*
|
||
* One-click TurkServis sign-in for AITURK IDE (device-code flow).
|
||
*
|
||
* The IDE shows a short code, the browser opens the turkservis.online approval
|
||
* page, and once the user approves, the server hands out two API keys exactly
|
||
* once: one for the IDE's TurkServis provider and one for the AITURK CLI.
|
||
*
|
||
* Security contract (do not relax):
|
||
* - Keys (`tsk_…`) and the device code (`tsdl_…`) never leave the main
|
||
* process: they are not returned to the renderer, not logged, not put into
|
||
* error messages, process arguments or URLs.
|
||
* - The CLI receives its key on stdin only, is launched from an absolute
|
||
* path without a shell, with a fixed argument list.
|
||
* - Network calls go only to https://turkservis.online, refuse redirects and
|
||
* time out after 20 s.
|
||
*
|
||
* All I/O is injected so the logic is unit-testable without Electron, a
|
||
* network or a real CLI (see aiturk-login.test.ts).
|
||
*/
|
||
|
||
export const AITURK_LOGIN_ORIGIN = 'https://turkservis.online'
|
||
export const AITURK_LOGIN_FETCH_TIMEOUT_MS = 20_000
|
||
/** Wait before the single second try of `device-auth/start` after a thrown network error. */
|
||
export const AITURK_LOGIN_START_RETRY_DELAY_MS = 500
|
||
export const AITURK_CLI_TIMEOUT_MS = 60_000
|
||
export const AITURK_CLI_KEY_HOLD_MS = 5 * 60_000
|
||
|
||
const AITURK_PROVIDER_ID = 'turkservis'
|
||
const AITURK_PROVIDER_NAME = 'TurkServis · AITURK'
|
||
const AITURK_PROVIDER_BASE_URL = 'https://ai.turkservis.online/v1'
|
||
|
||
const VERIFICATION_URI_RE = /^https:\/\/turkservis\.online\/account\/aiturk\/device-login\/[0-9a-f-]{36}$/
|
||
const DEVICE_CODE_RE = /^tsdl_[A-Za-z0-9_-]{43}$/
|
||
const USER_CODE_RE = /^[A-HJ-NP-Z2-9]{8}$/
|
||
const API_KEY_RE = /^tsk_[A-Za-z0-9._-]{8,500}$/
|
||
const MAX_EXPIRES_IN_S = 3600
|
||
|
||
export type AiturkCliResult = 'ok' | 'missing' | 'outdated' | 'other-account' | 'unknown-account' | 'failed'
|
||
|
||
export type AiturkLoginErrorCode =
|
||
| 'busy'
|
||
| 'rate-limited'
|
||
| 'unavailable'
|
||
| 'invalid-response'
|
||
| 'browser-failed'
|
||
| 'provider-failed'
|
||
| 'no-models'
|
||
| 'nothing-to-overwrite'
|
||
|
||
/** Error whose message is ONLY a fixed code — never upstream text, so a key
|
||
* echoed by a server or backend cannot ride an error into the renderer/log. */
|
||
export class AiturkLoginError extends Error {
|
||
code: AiturkLoginErrorCode
|
||
|
||
constructor(code: AiturkLoginErrorCode) {
|
||
super(`aiturk-login:${code}`)
|
||
this.name = 'AiturkLoginError'
|
||
this.code = code
|
||
}
|
||
}
|
||
|
||
export interface AiturkLoginScope {
|
||
profile?: null | string
|
||
connectionId?: null | string
|
||
}
|
||
|
||
export interface AiturkLoginStartResult {
|
||
userCode: string
|
||
verificationUri: string
|
||
expiresAt: number
|
||
}
|
||
|
||
export type AiturkLoginPollResult =
|
||
| { state: 'pending' }
|
||
| { state: 'expired' }
|
||
| { state: 'connected'; cli: AiturkCliResult }
|
||
| { state: 'error'; error: AiturkLoginErrorCode }
|
||
|
||
export interface AiturkFetchResponse {
|
||
status: number
|
||
json: () => Promise<unknown>
|
||
}
|
||
|
||
export type AiturkFetch = (
|
||
url: string,
|
||
init: { method: string; headers: Record<string, string>; body?: string; redirect: 'error'; signal?: AbortSignal }
|
||
) => Promise<AiturkFetchResponse>
|
||
|
||
export interface AiturkBackendRequest {
|
||
path: string
|
||
method: string
|
||
body: unknown
|
||
profile?: string
|
||
connectionId?: string
|
||
}
|
||
|
||
/** Outcome of one CLI launch: 'missing' when the CLI is not installed, the
|
||
* numeric exit code otherwise, null when it could not run / timed out. */
|
||
export type AiturkCliExit = 'missing' | null | number
|
||
|
||
export interface AiturkLoginDeps {
|
||
fetch: AiturkFetch
|
||
/** Open a URL in the system browser. `false` means it could not be opened. */
|
||
openExternal: (url: string) => boolean | Promise<boolean | void> | void
|
||
hostname: () => string
|
||
/** Same route the renderer's hermesApi uses (hermes:api handler internals). */
|
||
backendRequest: (request: AiturkBackendRequest) => Promise<unknown>
|
||
/** Run `aiturk login --key-stdin [--keep-other-account]`, key on stdin. */
|
||
runCli: (cliKey: string, options: { keepOtherAccount: boolean }) => Promise<AiturkCliExit>
|
||
now: () => number
|
||
setTimer?: (fn: () => void, ms: number) => unknown
|
||
clearTimer?: (handle: unknown) => void
|
||
timeoutSignal?: (ms: number) => AbortSignal | undefined
|
||
}
|
||
|
||
interface Session {
|
||
deviceCode: string
|
||
userCode: string
|
||
verificationUri: string
|
||
expiresAt: number
|
||
scope: { profile?: string; connectionId?: string }
|
||
}
|
||
|
||
function deviceName(raw: unknown): string {
|
||
// Printable, 1–60 chars. A hostname is not a secret, but keep it tidy.
|
||
// eslint-disable-next-line no-control-regex
|
||
const cleaned = String(raw ?? '').replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 60)
|
||
|
||
return cleaned || 'AITURK IDE'
|
||
}
|
||
|
||
function cleanScope(scope: unknown): { profile?: string; connectionId?: string } {
|
||
const source = (scope && typeof scope === 'object' ? scope : {}) as AiturkLoginScope
|
||
const profile = typeof source.profile === 'string' ? source.profile.trim() : ''
|
||
const connectionId = typeof source.connectionId === 'string' ? source.connectionId.trim() : ''
|
||
|
||
return {
|
||
...(profile ? { profile } : {}),
|
||
...(connectionId ? { connectionId } : {})
|
||
}
|
||
}
|
||
|
||
function cliResultFromExit(exit: AiturkCliExit, keepOtherAccount: boolean): AiturkCliResult {
|
||
if (exit === 'missing') {
|
||
return 'missing'
|
||
}
|
||
|
||
if (exit === 0) {
|
||
return 'ok'
|
||
}
|
||
|
||
if (exit === 2) {
|
||
return 'outdated'
|
||
}
|
||
|
||
if (exit === 3 && keepOtherAccount) {
|
||
return 'other-account'
|
||
}
|
||
|
||
if (exit === 4) {
|
||
return 'unknown-account'
|
||
}
|
||
|
||
return 'failed'
|
||
}
|
||
|
||
export function createAiturkLogin(deps: AiturkLoginDeps) {
|
||
const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms))
|
||
const clearTimer = deps.clearTimer ?? (handle => clearTimeout(handle as ReturnType<typeof setTimeout>))
|
||
|
||
const timeoutSignal =
|
||
deps.timeoutSignal ?? (ms => (typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(ms) : undefined))
|
||
|
||
let session: null | Session = null
|
||
// In-flight "approved → save provider → hand key to CLI" work. Concurrent
|
||
// polls share it so the one-time key is fetched and consumed exactly once.
|
||
let finishing: null | Promise<AiturkLoginPollResult> = null
|
||
// Final outcome of the last login, replayed to later polls without a fetch.
|
||
let settled: null | AiturkLoginPollResult = null
|
||
// CLI key kept ONLY while the user decides whether to replace another
|
||
// account's CLI login. Main-process memory, dropped after 5 minutes.
|
||
let heldCli: null | { key: string; expiresAt: number; timer: unknown } = null
|
||
|
||
function dropHeldCli() {
|
||
if (heldCli) {
|
||
clearTimer(heldCli.timer)
|
||
heldCli = null
|
||
}
|
||
}
|
||
|
||
function holdCli(key: string) {
|
||
dropHeldCli()
|
||
|
||
const timer = setTimer(() => {
|
||
heldCli = null
|
||
}, AITURK_CLI_KEY_HOLD_MS)
|
||
|
||
// Never keep the app alive just for this timer.
|
||
;(timer as { unref?: () => void } | null)?.unref?.()
|
||
heldCli = { key, expiresAt: deps.now() + AITURK_CLI_KEY_HOLD_MS, timer }
|
||
}
|
||
|
||
async function post(path: string, bearer: null | string, body?: unknown): Promise<AiturkFetchResponse> {
|
||
return deps.fetch(`${AITURK_LOGIN_ORIGIN}${path}`, {
|
||
method: 'POST',
|
||
headers: {
|
||
Accept: 'application/json',
|
||
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
||
...(bearer ? { Authorization: `Bearer ${bearer}` } : {})
|
||
},
|
||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||
redirect: 'error',
|
||
signal: timeoutSignal(AITURK_LOGIN_FETCH_TIMEOUT_MS)
|
||
})
|
||
}
|
||
|
||
async function cancelOnServer(deviceCode: string) {
|
||
try {
|
||
await post('/v1/aiturk/device-auth/cancel', deviceCode)
|
||
} catch {
|
||
// Best effort: the code expires on its own within 10 minutes.
|
||
}
|
||
}
|
||
|
||
async function start(scope?: AiturkLoginScope): Promise<AiturkLoginStartResult> {
|
||
if (finishing) {
|
||
throw new AiturkLoginError('busy')
|
||
}
|
||
|
||
// One login at a time: a new start replaces (and revokes) the previous one.
|
||
const previous = session
|
||
session = null
|
||
settled = null
|
||
dropHeldCli()
|
||
|
||
if (previous) {
|
||
await cancelOnServer(previous.deviceCode)
|
||
}
|
||
|
||
let response: AiturkFetchResponse | undefined
|
||
|
||
// A long-idle process can fail its first request on a dead pooled
|
||
// connection. Only a THROWN network error (no HTTP answer, so no code was
|
||
// handed to us) is tried once more; an HTTP answer is never retried, so
|
||
// one start cannot knowingly leave two codes behind. A timeout already
|
||
// used its whole budget and is not retried.
|
||
for (let attempt = 1; response === undefined; attempt++) {
|
||
try {
|
||
response = await post('/v1/aiturk/device-auth/start', null, {
|
||
client: 'ide',
|
||
device_name: deviceName(deps.hostname()),
|
||
also_cli: true
|
||
})
|
||
} catch (error) {
|
||
const name = (error as null | { name?: unknown })?.name
|
||
|
||
if (attempt >= 2 || name === 'TimeoutError' || name === 'AbortError') {
|
||
throw new AiturkLoginError('unavailable')
|
||
}
|
||
|
||
await new Promise<void>(resolve => void setTimer(resolve, AITURK_LOGIN_START_RETRY_DELAY_MS))
|
||
}
|
||
}
|
||
|
||
if (response.status === 429) {
|
||
throw new AiturkLoginError('rate-limited')
|
||
}
|
||
|
||
if (response.status !== 201 && response.status !== 200) {
|
||
throw new AiturkLoginError('unavailable')
|
||
}
|
||
|
||
let data: any
|
||
|
||
try {
|
||
data = await response.json()
|
||
} catch {
|
||
throw new AiturkLoginError('invalid-response')
|
||
}
|
||
|
||
const deviceCode = typeof data?.deviceCode === 'string' ? data.deviceCode : ''
|
||
const userCode = typeof data?.userCode === 'string' ? data.userCode : ''
|
||
const verificationUri = typeof data?.verificationUri === 'string' ? data.verificationUri : ''
|
||
const expiresIn = Number(data?.expiresIn)
|
||
const codeOk = DEVICE_CODE_RE.test(deviceCode)
|
||
|
||
if (
|
||
!codeOk ||
|
||
!USER_CODE_RE.test(userCode) ||
|
||
!VERIFICATION_URI_RE.test(verificationUri) ||
|
||
!Number.isFinite(expiresIn) ||
|
||
expiresIn <= 0
|
||
) {
|
||
// Never open a URL that is not exactly our approval page.
|
||
if (codeOk) {
|
||
await cancelOnServer(deviceCode)
|
||
}
|
||
|
||
throw new AiturkLoginError('invalid-response')
|
||
}
|
||
|
||
if (session || finishing) {
|
||
// A concurrent start won the race while we were waiting on the network.
|
||
await cancelOnServer(deviceCode)
|
||
|
||
throw new AiturkLoginError('busy')
|
||
}
|
||
|
||
const expiresAt = deps.now() + Math.min(expiresIn, MAX_EXPIRES_IN_S) * 1000
|
||
session = { deviceCode, userCode, verificationUri, expiresAt, scope: cleanScope(scope) }
|
||
|
||
let opened: unknown
|
||
|
||
try {
|
||
opened = await deps.openExternal(verificationUri)
|
||
} catch {
|
||
opened = false
|
||
}
|
||
|
||
if (opened === false) {
|
||
session = null
|
||
await cancelOnServer(deviceCode)
|
||
|
||
throw new AiturkLoginError('browser-failed')
|
||
}
|
||
|
||
return { userCode, verificationUri, expiresAt }
|
||
}
|
||
|
||
async function saveProvider(apiKey: string, scope: Session['scope']) {
|
||
const endpoint = {
|
||
id: AITURK_PROVIDER_ID,
|
||
name: AITURK_PROVIDER_NAME,
|
||
base_url: AITURK_PROVIDER_BASE_URL,
|
||
api_key: apiKey,
|
||
discover_models: true,
|
||
make_default: true
|
||
}
|
||
|
||
let checked: any
|
||
|
||
try {
|
||
// Mirrors the renderer's validateCustomEndpoint (no profile scope) and
|
||
// saveCustomEndpoint (profile-scoped) calls.
|
||
checked = await deps.backendRequest({
|
||
...(scope.connectionId ? { connectionId: scope.connectionId } : {}),
|
||
path: '/api/providers/custom-endpoints/validate',
|
||
method: 'POST',
|
||
body: { ...endpoint, model: '' }
|
||
})
|
||
} catch {
|
||
throw new AiturkLoginError('provider-failed')
|
||
}
|
||
|
||
if (!checked?.ok) {
|
||
throw new AiturkLoginError('provider-failed')
|
||
}
|
||
|
||
const models = Array.isArray(checked.models) ? checked.models.filter((m: unknown) => typeof m === 'string' && m) : []
|
||
|
||
if (!models.length) {
|
||
throw new AiturkLoginError('no-models')
|
||
}
|
||
|
||
let saved: any
|
||
|
||
try {
|
||
saved = await deps.backendRequest({
|
||
...scope,
|
||
path: '/api/providers/custom-endpoints',
|
||
method: 'POST',
|
||
body: { ...endpoint, model: models[0], models }
|
||
})
|
||
} catch {
|
||
throw new AiturkLoginError('provider-failed')
|
||
}
|
||
|
||
if (saved?.ok === false) {
|
||
throw new AiturkLoginError('provider-failed')
|
||
}
|
||
}
|
||
|
||
async function handCliKey(cliKey: string, keepOtherAccount: boolean): Promise<AiturkCliResult> {
|
||
let exit: AiturkCliExit
|
||
|
||
try {
|
||
exit = await deps.runCli(cliKey, { keepOtherAccount })
|
||
} catch {
|
||
exit = null
|
||
}
|
||
|
||
return cliResultFromExit(exit, keepOtherAccount)
|
||
}
|
||
|
||
async function finish(key: string, cliKey: string, scope: Session['scope']): Promise<AiturkLoginPollResult> {
|
||
try {
|
||
await saveProvider(key, scope)
|
||
} catch (error) {
|
||
return { state: 'error', error: error instanceof AiturkLoginError ? error.code : 'provider-failed' }
|
||
}
|
||
|
||
let cli: AiturkCliResult = 'failed'
|
||
|
||
if (cliKey) {
|
||
cli = await handCliKey(cliKey, true)
|
||
|
||
if (cli === 'other-account') {
|
||
holdCli(cliKey)
|
||
}
|
||
}
|
||
|
||
return { state: 'connected', cli }
|
||
}
|
||
|
||
async function poll(): Promise<AiturkLoginPollResult> {
|
||
if (finishing) {
|
||
return finishing
|
||
}
|
||
|
||
if (settled) {
|
||
return settled
|
||
}
|
||
|
||
const current = session
|
||
|
||
if (!current) {
|
||
return { state: 'expired' }
|
||
}
|
||
|
||
if (deps.now() >= current.expiresAt) {
|
||
session = null
|
||
|
||
return { state: 'expired' }
|
||
}
|
||
|
||
let response: AiturkFetchResponse
|
||
|
||
try {
|
||
response = await post('/v1/aiturk/device-auth/poll', current.deviceCode)
|
||
} catch {
|
||
// Transient network trouble: keep waiting until the code expires.
|
||
return { state: 'pending' }
|
||
}
|
||
|
||
if (session !== current) {
|
||
// Cancelled or replaced while the request was in flight.
|
||
return settled ?? (finishing ? finishing : { state: 'expired' })
|
||
}
|
||
|
||
if (response.status === 401) {
|
||
session = null
|
||
|
||
return { state: 'expired' }
|
||
}
|
||
|
||
if (response.status !== 200) {
|
||
return { state: 'pending' }
|
||
}
|
||
|
||
let data: any
|
||
|
||
try {
|
||
data = await response.json()
|
||
} catch {
|
||
return { state: 'pending' }
|
||
}
|
||
|
||
if (session !== current) {
|
||
return settled ?? (finishing ? finishing : { state: 'expired' })
|
||
}
|
||
|
||
if (data?.state !== 'approved') {
|
||
return { state: 'pending' }
|
||
}
|
||
|
||
// The server returns the keys exactly once; the device code is spent.
|
||
session = null
|
||
|
||
const key = typeof data.key === 'string' && API_KEY_RE.test(data.key) ? data.key : ''
|
||
const cliKey = typeof data.cliKey === 'string' && API_KEY_RE.test(data.cliKey) ? data.cliKey : ''
|
||
|
||
if (!key) {
|
||
settled = { state: 'error', error: 'invalid-response' }
|
||
|
||
return settled
|
||
}
|
||
|
||
finishing = finish(key, cliKey, current.scope)
|
||
.catch((): AiturkLoginPollResult => ({ state: 'error', error: 'provider-failed' }))
|
||
.then(result => {
|
||
settled = result
|
||
finishing = null
|
||
|
||
return result
|
||
})
|
||
|
||
return finishing
|
||
}
|
||
|
||
async function cancel(): Promise<{ ok: true }> {
|
||
const current = session
|
||
session = null
|
||
dropHeldCli()
|
||
|
||
if (!finishing) {
|
||
settled = null
|
||
}
|
||
|
||
if (current) {
|
||
await cancelOnServer(current.deviceCode)
|
||
}
|
||
|
||
return { ok: true }
|
||
}
|
||
|
||
/** User confirmed: replace the other account stored in the AITURK CLI. */
|
||
async function overwriteCli(): Promise<{ cli: AiturkCliResult }> {
|
||
const held = heldCli
|
||
|
||
if (!held || deps.now() >= held.expiresAt) {
|
||
dropHeldCli()
|
||
|
||
throw new AiturkLoginError('nothing-to-overwrite')
|
||
}
|
||
|
||
// Single use: drop it before launching so it cannot be replayed.
|
||
dropHeldCli()
|
||
|
||
const cli = await handCliKey(held.key, false)
|
||
|
||
if (settled?.state === 'connected') {
|
||
settled = { state: 'connected', cli }
|
||
}
|
||
|
||
return { cli }
|
||
}
|
||
|
||
return { start, poll, cancel, overwriteCli }
|
||
}
|
||
|
||
// ── CLI runner ───────────────────────────────────────────────────────────────
|
||
|
||
export interface AiturkCliRunnerDeps {
|
||
/** child_process.spawn (injected for tests). */
|
||
spawn: (command: string, args: string[], options: Record<string, unknown>) => any
|
||
existsSync: (path: string) => boolean
|
||
env: Record<string, string | undefined>
|
||
platform?: string
|
||
timeoutMs?: number
|
||
setTimer?: (fn: () => void, ms: number) => unknown
|
||
clearTimer?: (handle: unknown) => void
|
||
}
|
||
|
||
// cmd.exe expands %VAR% and treats " specially even inside quotes; a path that
|
||
// carries either (or a control char) is refused rather than risk reparsing.
|
||
// eslint-disable-next-line no-control-regex
|
||
const UNSAFE_CMD_PATH_RE = /["%^!\u0000-\u001f]/
|
||
const WINDOWS_ABSOLUTE_RE = /^[A-Za-z]:\\/
|
||
|
||
/** `%LOCALAPPDATA%\TurkServis\AITURK\program\bin\aiturk.cmd`, or null. */
|
||
export function resolveAiturkCliPath(deps: Pick<AiturkCliRunnerDeps, 'env' | 'existsSync' | 'platform'>): null | string {
|
||
if ((deps.platform ?? process.platform) !== 'win32') {
|
||
return null
|
||
}
|
||
|
||
const localAppData = String(deps.env.LOCALAPPDATA || '').replace(/[\\/]+$/, '')
|
||
|
||
if (!WINDOWS_ABSOLUTE_RE.test(localAppData)) {
|
||
return null
|
||
}
|
||
|
||
const candidate = `${localAppData}\\TurkServis\\AITURK\\program\\bin\\aiturk.cmd`
|
||
|
||
try {
|
||
return deps.existsSync(candidate) ? candidate : null
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Build the CLI runner used by createAiturkLogin. The key goes to the child's
|
||
* stdin and nowhere else: the argument list is a fixed array with no user
|
||
* input, there is no `shell: true`, and stdout/stderr are discarded.
|
||
*/
|
||
export function createAiturkCliRunner(deps: AiturkCliRunnerDeps) {
|
||
const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms))
|
||
const clearTimer = deps.clearTimer ?? (handle => clearTimeout(handle as ReturnType<typeof setTimeout>))
|
||
const timeoutMs = deps.timeoutMs ?? AITURK_CLI_TIMEOUT_MS
|
||
|
||
return function runCli(cliKey: string, options: { keepOtherAccount: boolean }): Promise<AiturkCliExit> {
|
||
const cliPath = resolveAiturkCliPath(deps)
|
||
|
||
if (!cliPath) {
|
||
return Promise.resolve('missing')
|
||
}
|
||
|
||
const systemRoot = String(deps.env.SystemRoot || deps.env.SYSTEMROOT || 'C:\\Windows').replace(/[\\/]+$/, '')
|
||
|
||
if (UNSAFE_CMD_PATH_RE.test(cliPath) || !WINDOWS_ABSOLUTE_RE.test(systemRoot) || UNSAFE_CMD_PATH_RE.test(systemRoot)) {
|
||
return Promise.resolve(null)
|
||
}
|
||
|
||
const flags = options.keepOtherAccount ? 'login --key-stdin --keep-other-account' : 'login --key-stdin'
|
||
|
||
// A .cmd cannot be spawned directly without a shell; run it through an
|
||
// absolute cmd.exe. With /s, cmd strips the outer quote pair and runs the
|
||
// rest verbatim, so the (validated, quoted) path survives spaces.
|
||
const args = ['/d', '/s', '/c', `""${cliPath}" ${flags}"`]
|
||
|
||
return new Promise(resolve => {
|
||
let done = false
|
||
let timer: unknown = null
|
||
let child: any
|
||
|
||
const settle = (value: AiturkCliExit) => {
|
||
if (done) {
|
||
return
|
||
}
|
||
|
||
done = true
|
||
|
||
if (timer !== null) {
|
||
clearTimer(timer)
|
||
}
|
||
|
||
resolve(value)
|
||
}
|
||
|
||
try {
|
||
child = deps.spawn(`${systemRoot}\\System32\\cmd.exe`, args, {
|
||
shell: false,
|
||
windowsHide: true,
|
||
windowsVerbatimArguments: true,
|
||
stdio: ['pipe', 'ignore', 'ignore']
|
||
})
|
||
} catch {
|
||
settle(null)
|
||
|
||
return
|
||
}
|
||
|
||
timer = setTimer(() => {
|
||
try {
|
||
child.kill()
|
||
} catch {
|
||
// Already gone.
|
||
}
|
||
|
||
settle(null)
|
||
}, timeoutMs)
|
||
|
||
child.once('error', () => settle(null))
|
||
child.once('close', (code: null | number) => settle(typeof code === 'number' ? code : null))
|
||
|
||
try {
|
||
// A CLI that exits before reading stdin raises EPIPE here; ignore it,
|
||
// the exit code is the answer.
|
||
child.stdin.on('error', () => {})
|
||
child.stdin.end(`${cliKey}\n`)
|
||
} catch {
|
||
// Same as above.
|
||
}
|
||
})
|
||
}
|
||
}
|