From e429ef048603cb9ad561f27db9d0ae70c4d5ce43 Mon Sep 17 00:00:00 2001 From: Q Date: Fri, 4 Sep 2026 18:34:29 -0500 Subject: [PATCH 1/2] Stream file transfers with cancellation and atomic downloads --- docs/commands.md | 12 +- skills/linearctl/SKILL.md | 8 +- src/cli/main.ts | 1 + src/commands/file.ts | 170 +++------ src/core/io/file-transfer.ts | 209 +++++++++++ src/core/registry/commands.ts | 8 +- src/core/registry/option-catalog.ts | 1 + src/core/registry/types.ts | 1 + src/generated/embedded-skills.ts | 2 +- src/generated/manifest/curated-commands.json | 4 +- tests/cli/main.test.ts | 18 + tests/commands/file.test.ts | 37 +- tests/core/io/file-transfer.test.ts | 349 +++++++++++++++++++ 13 files changed, 676 insertions(+), 144 deletions(-) create mode 100644 src/core/io/file-transfer.ts create mode 100644 tests/core/io/file-transfer.test.ts diff --git a/docs/commands.md b/docs/commands.md index a8542a7..dae2339 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -218,16 +218,22 @@ linearctl attachment delete --json # [destructive] ```bash # Upload a file (optionally attach to an issue) -linearctl file upload [--issue ] --json +linearctl file upload [--issue ] [--transfer-timeout ] --json # Get a signed URL for an attachment linearctl file url [--expires-in ] --json # Download a file -linearctl file download [--output ] --json +linearctl file download [--output ] [--transfer-timeout ] --json ``` -File upload and download requests use manual redirect handling. Same-host redirects keep signed upload headers and Linear authorization. Cross-host redirects are followed only after dropping those headers. +File upload and download stream with backpressure instead of buffering entire files. Upload sizes come from local file metadata; upload sources must be regular files and should not be modified during a transfer. + +`--transfer-timeout` sets a total transfer deadline in whole seconds (default **120**, range 1–2147483). The deadline starts with the first PUT/GET and covers all redirects and response-body consumption, including stalled bodies and download writes. It does not change the separate GraphQL request timeout or retry policy; file transfers are not automatically retried. Ctrl-C (SIGINT) or SIGTERM cancels an active transfer and cleans up local resources. Timeout/cancellation returns exit 1. + +Downloads overwrite existing destinations **only after successful completion**, using a private staging directory beside the output and an atomic rename on the same filesystem. Transfer, write, or rename failures remove staging files and leave the existing destination unchanged; the parent directory must already exist and be writable. A destination symlink is replaced, not followed. The new file uses private permissions (0600 on POSIX); existing permissions/metadata are not retained. Forced termination (SIGKILL), crashes, or power loss can leave staging directories; atomic replacement is not a crash-durability guarantee. + +Requests and redirects must use HTTPS, with at most five redirects. Downloads must start at `uploads.linear.app`. Same-host redirects keep signed upload headers and Linear authorization. Cross-host redirects drop sensitive headers, even if a later redirect returns to the original host. Redirected PUTs replay the file from the beginning. ## Auth diff --git a/skills/linearctl/SKILL.md b/skills/linearctl/SKILL.md index 31c8a0d..ae4c93d 100644 --- a/skills/linearctl/SKILL.md +++ b/skills/linearctl/SKILL.md @@ -107,10 +107,12 @@ Raw GraphQL should not be used merely because it is possible. It is the fallback - `linearctl attachment delete --json` ### Files -- `linearctl file upload [--issue ] --json` +- `linearctl file upload [--issue ] [--transfer-timeout ] --json` - `linearctl file url [--expires-in ] --json` -- `linearctl file download [--output ] --json` -- Upload/download use manual redirect handling. Same-host redirects keep signed upload headers or Linear authorization; cross-host redirects are followed only after dropping those headers. +- `linearctl file download [--output ] [--transfer-timeout ] --json` +- Upload/download stream with backpressure. Upload sources must be regular files; do not modify them during transfer. `--transfer-timeout` is a total PUT/GET deadline in whole seconds (default 120, range 1–2147483), including redirects, response bodies, and download writes; GraphQL requests keep their separate timeout/retry policy. Transfers are not automatically retried. Ctrl-C/SIGINT or SIGTERM cancels an active transfer; timeout/cancellation returns exit 1. +- Downloads stage beside the destination and atomically overwrite it only after success. Transfer/write/rename failures clean up staging and preserve existing contents. The parent must exist and be writable. Destination symlinks are replaced rather than followed; new files have private permissions (0600 on POSIX), not the old metadata. SIGKILL/crashes can leave staging directories; this is not a crash-durability guarantee. +- Requests and redirects require HTTPS, with at most five redirects; downloads must start at `uploads.linear.app`. Same-host redirects keep signed upload headers or Linear authorization; cross-host redirects drop sensitive headers permanently. Redirected PUTs replay the file from byte zero. ### Workflow states - `linearctl state list [--team ] [--all-teams] --json` — list issue workflow states for a team diff --git a/src/cli/main.ts b/src/cli/main.ts index 1125aa0..9a2ebcc 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -281,6 +281,7 @@ function toParsedCliArguments(values: Record, positionals: stri ...(typeof values.url === "string" ? { url: values.url } : {}), ...(typeof values.output === "string" ? { output: values.output } : {}), ...(typeof values["expires-in"] === "string" ? { expiresIn: values["expires-in"] } : {}), + ...(typeof values["transfer-timeout"] === "string" ? { transferTimeout: values["transfer-timeout"] } : {}), ...(typeof values.query === "string" ? { query: values.query } : {}), ...(typeof values.search === "string" ? { search: values.search, ...(typeof values.query === "string" ? {} : { query: values.search }) } : {}), ...(typeof values["filter-json"] === "string" ? { filterJson: values["filter-json"] } : {}), diff --git a/src/commands/file.ts b/src/commands/file.ts index 20692be..d1a9131 100644 --- a/src/commands/file.ts +++ b/src/commands/file.ts @@ -1,4 +1,7 @@ -import { readFile, writeFile } from "node:fs/promises"; +import { open } from "node:fs/promises"; +import type { FileHandle } from "node:fs/promises"; +import { downloadFile, uploadFile as streamUploadFile } from "../core/io/file-transfer.js"; +import type { TransferOptions } from "../core/io/file-transfer.js"; import { basename, resolve } from "node:path"; import { ExitCode } from "../core/errors/exit-codes.js"; import { emitValidationError } from "../core/output/validation-error.js"; @@ -20,6 +23,9 @@ export interface FileCommandOptions { issue?: string; output?: string; expiresIn?: string; + transferTimeout?: string; + /** Optional cancellation for embedded callers, in addition to SIGINT/SIGTERM. */ + signal?: AbortSignal; // retry flags noRetry?: boolean; maxRetries?: number; @@ -59,97 +65,6 @@ function contentTypeFromExtension(filename: string): string { return CONTENT_TYPE_MAP[ext] ?? "application/octet-stream"; } -function isRedirectStatus(status: number): boolean { - return status === 301 || status === 302 || status === 303 || status === 307 || status === 308; -} - -function resolveRedirectUrl(currentUrl: string, location: string | null): string | undefined { - if (location === null || location.trim() === "") { - return undefined; - } - - try { - return new URL(location, currentUrl).toString(); - } catch { - return undefined; - } -} - -const MAX_FILE_REDIRECTS = 5; -const CROSS_HOST_HEADER_ALLOWLIST = new Set([ - "accept", - "accept-language", - "content-language", - "content-type" -]); - -async function fetchWithHostValidatedRedirects( - fetchImpl: FetchLike, - url: string, - init: RequestInit -): Promise { - let currentUrl = url; - const originalUrl = new URL(url); - if (originalUrl.protocol !== "https:") { - throw new Error("File request URL must use HTTPS."); - } - const originalHost = originalUrl.host; - let currentInit: RequestInit = init; - - for (let redirectCount = 0; redirectCount <= MAX_FILE_REDIRECTS; redirectCount++) { - const response = await fetchImpl(currentUrl, { - ...currentInit, - redirect: "manual" - }); - - if (!isRedirectStatus(response.status)) { - return response; - } - - if (redirectCount === MAX_FILE_REDIRECTS) { - throw new Error("File request exceeded the redirect limit."); - } - - const nextUrl = resolveRedirectUrl(currentUrl, response.headers.get("location")); - if (nextUrl === undefined) { - throw new Error(`File request redirected without a valid Location header.`); - } - - const parsedNextUrl = new URL(nextUrl); - if (parsedNextUrl.protocol !== "https:") { - // Credentials or uploaded content may accompany this request — never - // allow a redirect to downgrade to plaintext HTTP. - throw new Error(`File request redirected to non-HTTPS protocol: ${parsedNextUrl.protocol}`); - } - - if (parsedNextUrl.host !== originalHost) { - const safeHeaders = safeCrossHostHeaders(currentInit.headers); - const { headers: _headers, ...rest } = currentInit; - currentInit = safeHeaders === undefined ? rest : { ...rest, headers: safeHeaders }; - } - - currentUrl = nextUrl; - } - - throw new Error("File request exceeded the redirect limit."); -} - -function safeCrossHostHeaders(headers: HeadersInit | undefined): Record | undefined { - if (headers === undefined) { - return undefined; - } - - const entries = new Headers(headers).entries(); - const safe: Record = {}; - for (const [key, value] of entries) { - if (CROSS_HOST_HEADER_ALLOWLIST.has(key.toLowerCase())) { - safe[key] = value; - } - } - - return Object.keys(safe).length === 0 ? undefined : safe; -} - const FILE_UPLOAD_MUTATION = ` mutation FileUpload($contentType: String!, $filename: String!, $size: Int!) { fileUpload(contentType: $contentType, filename: $filename, size: $size) { @@ -241,7 +156,8 @@ function buildContext(options: FileCommandOptions): CommandContext { async function handleFileUpload( filePath: string, - options: FileCommandOptions + options: FileCommandOptions, + transferOptions: TransferOptions ): Promise { const resolvedPath = resolve(filePath); const fileName = basename(resolvedPath); @@ -255,15 +171,18 @@ async function handleFileUpload( return emitDryRunResult("upload", "file", input, options); } - let fileBytes: Buffer; + let file: FileHandle | undefined; + let size: number; try { - fileBytes = await readFile(resolvedPath); + file = await open(resolvedPath, "r"); + const metadata = await file.stat(); + if (!metadata.isFile()) throw new Error("not a regular file"); + size = metadata.size; } catch { - return emitValidationError(`cannot read file: ${resolvedPath}`, options); + await file?.close(); + return emitValidationError(`cannot read regular file: ${resolvedPath}`, options); } - const size = fileBytes.length; - const ctx = buildContext(options); try { @@ -301,15 +220,7 @@ async function handleFileUpload( putHeaders["Content-Type"] = contentType; } - const putResponse = await fetchWithHostValidatedRedirects(fetchImpl, uploadUrl, { - method: "PUT", - headers: putHeaders, - body: fileBytes as unknown as BodyInit - }); - - if (!putResponse.ok) { - return ctx.emitFailure([{ category: "general", message: `File PUT failed with HTTP ${putResponse.status}` }]); - } + await streamUploadFile(fetchImpl, uploadUrl, putHeaders, file, size, transferOptions); const result: Record = { assetUrl, @@ -359,6 +270,8 @@ async function handleFileUpload( return ExitCode.Success; } catch (error) { return ctx.emitCaughtError(error); + } finally { + await file.close(); } } @@ -418,7 +331,8 @@ async function handleFileUrl( async function handleFileDownload( downloadUrl: string, - options: FileCommandOptions + options: FileCommandOptions, + transferOptions: TransferOptions ): Promise { try { const parsed = new URL(downloadUrl); @@ -438,33 +352,20 @@ async function handleFileDownload( const profile = await ctx.resolveProfile(); const fetchImpl = options.fetchImpl ?? fetch; - const response = await fetchWithHostValidatedRedirects(fetchImpl, downloadUrl, { - method: "GET", - headers: { - authorization: authorizationHeader(profile.credentials) - } - }); - - if (!response.ok) { - return ctx.emitFailure([{ category: "general", message: `Download failed with HTTP ${response.status}` }]); - } - - const arrayBuffer = await response.arrayBuffer(); - const bytes = Buffer.from(arrayBuffer); - const urlPath = new URL(downloadUrl).pathname; const derivedName = basename(urlPath) || "download"; const outputPath = resolve(options.output ?? derivedName); + const size = await downloadFile(fetchImpl, downloadUrl, { + authorization: authorizationHeader(profile.credentials) + }, outputPath, transferOptions); - await writeFile(outputPath, bytes); - - const result = { path: outputPath, size: bytes.length }; + const result = { path: outputPath, size }; if (options.json || options.jsonEnvelope) { return ctx.emitSuccess(result); } - process.stdout.write(`Downloaded ${outputPath} (${bytes.length} bytes)\n`); + process.stdout.write(`Downloaded ${outputPath} (${size} bytes)\n`); return ExitCode.Success; } catch (error) { return ctx.emitCaughtError(error); @@ -476,6 +377,19 @@ export async function handleFileCommand( options: FileCommandOptions ): Promise { const [subcommand, ...rest] = positionals; + const transferOptions: TransferOptions = { + ...(options.signal === undefined ? {} : { signal: options.signal }) + }; + if (options.transferTimeout !== undefined) { + if (subcommand !== "upload" && subcommand !== "download") { + return emitValidationError("--transfer-timeout only applies to file upload/download.", options); + } + const seconds = Number(options.transferTimeout); + if (!Number.isInteger(seconds) || seconds < 1 || seconds > 2_147_483) { + return emitValidationError("--transfer-timeout must be an integer between 1 and 2147483 seconds.", options); + } + transferOptions.timeoutMs = seconds * 1000; + } if (subcommand === "upload") { const filePath = rest[0]; @@ -485,7 +399,7 @@ export async function handleFileCommand( if (rest.length > 1) { return emitValidationError("file upload accepts exactly one path.", options); } - return handleFileUpload(filePath, options); + return handleFileUpload(filePath, options, transferOptions); } if (subcommand === "url") { @@ -507,7 +421,7 @@ export async function handleFileCommand( if (rest.length > 1) { return emitValidationError("file download accepts exactly one URL.", options); } - return handleFileDownload(downloadUrl, options); + return handleFileDownload(downloadUrl, options, transferOptions); } return emitValidationError("unknown file subcommand. Use: upload, url, download", options); diff --git a/src/core/io/file-transfer.ts b/src/core/io/file-transfer.ts new file mode 100644 index 0000000..68f8087 --- /dev/null +++ b/src/core/io/file-transfer.ts @@ -0,0 +1,209 @@ +import { createWriteStream } from "node:fs"; +import { mkdtemp, rename, rm } from "node:fs/promises"; +import type { FileHandle } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { Readable, Transform, Writable } from "node:stream"; +import { finished, pipeline } from "node:stream/promises"; +import { DEFAULT_REQUEST_TIMEOUT_MS } from "../transport/graphql.js"; +import type { FetchLike } from "../transport/graphql.js"; + +export interface TransferOptions { + timeoutMs?: number; + signal?: AbortSignal; +} + +/** One deadline for all hops and body I/O, not a fresh timeout per request. */ +async function withTransfer(options: TransferOptions, run: (signal: AbortSignal) => Promise): Promise { + const timeoutMs = options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + const controller = new AbortController(); + const cancel = () => controller.abort(new Error("File transfer cancelled.")); + const timer = setTimeout(() => { + controller.abort(new Error(`File transfer timed out after ${timeoutMs / 1000}s.`)); + }, timeoutMs); + options.signal?.addEventListener("abort", cancel, { once: true }); + process.on("SIGINT", cancel); + process.on("SIGTERM", cancel); + try { + if (options.signal?.aborted) cancel(); + controller.signal.throwIfAborted(); + return await run(controller.signal); + } catch (error) { + if (controller.signal.aborted) throw controller.signal.reason; + throw error; + } finally { + clearTimeout(timer); + options.signal?.removeEventListener("abort", cancel); + process.off("SIGINT", cancel); + process.off("SIGTERM", cancel); + } +} + +const MAX_FILE_REDIRECTS = 5; +const CROSS_HOST_HEADER_ALLOWLIST = new Set([ + "accept", "accept-language", "content-language", "content-type" +]); + +function safeCrossHostHeaders(headers: HeadersInit | undefined): Record | undefined { + const safe: Record = {}; + for (const [key, value] of new Headers(headers)) { + if (CROSS_HOST_HEADER_ALLOWLIST.has(key)) safe[key] = value; + } + return Object.keys(safe).length === 0 ? undefined : safe; +} + +// Discard unused bodies without waiting for a remote peer (or a custom stream's +// cancel callback) to finish. Fetch cancellation also closes the underlying I/O. +function discardBody(response: Response): void { + void response.body?.cancel().catch(() => {}); +} + +function uploadStream(file: FileHandle, size: number, signal: AbortSignal): Readable { + return Readable.from((async function* () { + let position = 0; + while (position < size) { + signal.throwIfAborted(); + const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, size - position)); + const { bytesRead } = await file.read(buffer, 0, buffer.length, position); + signal.throwIfAborted(); + if (bytesRead === 0) throw new Error("Upload file became shorter during transfer."); + position += bytesRead; + yield buffer.subarray(0, bytesRead); + } + })(), { objectMode: false, signal }); +} + +async function fetchWithHostValidatedRedirects( + fetchImpl: FetchLike, + url: string, + init: RequestInit & { signal: AbortSignal }, + upload?: { file: FileHandle; size: number } +): Promise { + let currentUrl = url; + const originalUrl = new URL(url); + if (originalUrl.protocol !== "https:") throw new Error("File request URL must use HTTPS."); + let headers = init.headers; + + for (let redirectCount = 0; redirectCount <= MAX_FILE_REDIRECTS; redirectCount++) { + init.signal.throwIfAborted(); + // A consumed stream cannot be reused after a redirect. Keep the opened + // file descriptor, but restart at byte zero for every PUT. + const body = upload === undefined ? undefined : uploadStream(upload.file, upload.size, init.signal); + const bodyDone = body === undefined ? undefined : finished(body, { cleanup: true }).catch(() => {}); + let response: Response; + try { + const { headers: _headers, ...rest } = init; + const request: RequestInit & { duplex?: "half" } = { + ...rest, + ...(headers === undefined ? {} : { headers }), + redirect: "manual", + ...(body === undefined ? {} : { + body: body as unknown as BodyInit, + duplex: "half", + // This is local metadata, not a credential copied across hosts. + headers: { ...Object.fromEntries(new Headers(headers)), "content-length": String(upload!.size) } + }) + }; + response = await fetchImpl(currentUrl, request); + } finally { + body?.destroy(); + await bodyDone; + } + + if (![301, 302, 303, 307, 308].includes(response.status)) return response; + discardBody(response); + if (redirectCount === MAX_FILE_REDIRECTS) throw new Error("File request exceeded the redirect limit."); + const location = response.headers.get("location"); + let nextUrl: URL; + try { + if (!location?.trim()) throw new Error(); + nextUrl = new URL(location, currentUrl); + } catch { + throw new Error("File request redirected without a valid Location header."); + } + if (nextUrl.protocol !== "https:") { + throw new Error(`File request redirected to non-HTTPS protocol: ${nextUrl.protocol}`); + } + if (nextUrl.host !== originalUrl.host) headers = safeCrossHostHeaders(headers); + currentUrl = nextUrl.toString(); + } + throw new Error("File request exceeded the redirect limit."); +} + +function responseStream(response: Response): Readable { + if (response.body === null) return Readable.from([]); + const reader = response.body.getReader(); + // Read only when the Node pipeline asks for more. Use an explicit adapter: + // Bun's Readable.fromWeb can lose web-stream errors and leave I/O hanging. + return new Readable({ + read() { + void reader.read().then( + ({ done, value }) => { this.push(done ? null : value); }, + (error: Error) => { this.destroy(error); } + ); + }, + destroy(error, callback) { + // Cancellation must not wait for a stalled source's cancel callback. + void reader.cancel(error).catch(() => {}); + reader.releaseLock(); + callback(error); + } + }); +} + +export async function uploadFile( + fetchImpl: FetchLike, url: string, headers: HeadersInit, + file: FileHandle, size: number, options: TransferOptions +): Promise { + await withTransfer(options, async (signal) => { + const response = await fetchWithHostValidatedRedirects(fetchImpl, url, { + method: "PUT", headers, signal + }, { file, size }); + if (!response.ok) { + discardBody(response); + throw new Error(`File PUT failed with HTTP ${response.status}`); + } + // Consume even a PUT response incrementally, under the same deadline. + await pipeline(responseStream(response), new Writable({ + write(_chunk, _encoding, callback) { callback(); } + }), { signal }); + }); +} + +export async function downloadFile( + fetchImpl: FetchLike, url: string, headers: HeadersInit, + outputPath: string, options: TransferOptions +): Promise { + return withTransfer(options, async (signal) => { + const response = await fetchWithHostValidatedRedirects(fetchImpl, url, { + method: "GET", headers, signal + }); + let stagingDirectory: string | undefined; + try { + if (!response.ok) throw new Error(`Download failed with HTTP ${response.status}`); + signal.throwIfAborted(); + // A private directory beside the destination gives exclusive staging on + // the same filesystem, including when the destination is a symlink. + stagingDirectory = await mkdtemp(join(dirname(outputPath), ".linearctl-download-")); + const stagingPath = join(stagingDirectory, "data"); + let size = 0; + const counter = new Transform({ + transform(chunk: Buffer, _encoding, callback) { + size += chunk.length; + callback(null, chunk); + } + }); + await pipeline( + responseStream(response), counter, + createWriteStream(stagingPath, { flags: "wx", mode: 0o600 }), + { signal } + ); + signal.throwIfAborted(); + // No unlink-first fallback: a failed rename must preserve the destination. + await rename(stagingPath, outputPath); + return size; + } finally { + discardBody(response); + if (stagingDirectory !== undefined) await rm(stagingDirectory, { recursive: true, force: true }); + } + }); +} diff --git a/src/core/registry/commands.ts b/src/core/registry/commands.ts index 38ffb8f..4baa15d 100644 --- a/src/core/registry/commands.ts +++ b/src/core/registry/commands.ts @@ -334,12 +334,12 @@ export const COMMAND_REGISTRY: readonly CommandRegistration[] = [ ...OPTION_GROUPS.global, ...OPTION_GROUPS.dryRun, ...OPTION_GROUPS.retry, - "issue", "output", "expires-in", + "issue", "output", "expires-in", "transfer-timeout", ], subcommands: { - upload: { usage: "linearctl file upload [--issue ] [--json]" }, + upload: { usage: "linearctl file upload [--issue ] [--transfer-timeout ] [--json]" }, url: { usage: "linearctl file url [--expires-in ] [--json]" }, - download: { usage: "linearctl file download [--output ] [--json]" }, + download: { usage: "linearctl file download [--output ] [--transfer-timeout ] [--json]" }, }, handler: handleFileCommand, buildOptions: (args, env) => ({ @@ -347,7 +347,7 @@ export const COMMAND_REGISTRY: readonly CommandRegistration[] = [ dryRun: args.dryRun, noRetry: args.noRetry, ...pickFields(args, "maxRetries"), - ...pickFields(args, "issue", "output", "expiresIn"), + ...pickFields(args, "issue", "output", "expiresIn", "transferTimeout"), }), }, diff --git a/src/core/registry/option-catalog.ts b/src/core/registry/option-catalog.ts index ac06ad5..3114ba0 100644 --- a/src/core/registry/option-catalog.ts +++ b/src/core/registry/option-catalog.ts @@ -88,6 +88,7 @@ export const OPTION_CATALOG: Record = { url: { type: "string" }, output: { type: "string" }, "expires-in": { type: "string" }, + "transfer-timeout": { type: "string" }, ids: { type: "string" }, "oauth-client-id": { type: "string" }, "callback-port": { type: "string" }, diff --git a/src/core/registry/types.ts b/src/core/registry/types.ts index 65a4b2b..2fda4ab 100644 --- a/src/core/registry/types.ts +++ b/src/core/registry/types.ts @@ -95,6 +95,7 @@ export interface ParsedCliArguments { url?: string; output?: string; expiresIn?: string; + transferTimeout?: string; query?: string; filterJson?: string; createdAfter?: string; diff --git a/src/generated/embedded-skills.ts b/src/generated/embedded-skills.ts index cc70122..5b0c9fa 100644 --- a/src/generated/embedded-skills.ts +++ b/src/generated/embedded-skills.ts @@ -2,7 +2,7 @@ export const EMBEDDED_SKILLS: Record = { "linearctl": { filename: "linearctl.md", - content: "---\nname: linearctl\ndescription: Agent-first CLI for the Linear API — curated commands, generated API, and raw GraphQL with stable JSON output contracts\n---\n\n# linearctl\n\nDefault skill for all Linear CLI usage. Use this skill for any request involving Linear data unless raw GraphQL is explicitly required or the curated/generated layers cannot cover the operation.\n\n## First-time setup\n\nIf the user has not configured the CLI yet, help them bootstrap:\n\n1. Create the config directory: `mkdir -p ~/.config/linear`\n2. Create a credentials file with their API key:\n ```bash\n export LINEAR_API_KEY=lin_api_...\n linearctl auth login --profile --api-key-env LINEAR_API_KEY --set-default\n ```\n3. Set a default team: `linearctl team list --json` to find team keys, then `linearctl team get --set-default`\n4. Verify: `linearctl auth whoami --json`\n\nAPI keys are created at https://linear.app/settings/api. For browser OAuth and unattended client-credentials OAuth, see https://linear.app/settings/api/applications.\n\n## Command routing\n\n1. Use curated commands when they cover the operation.\n2. Otherwise use generated `linearctl api` commands.\n3. Otherwise use `linearctl gql`.\n\nRaw GraphQL should not be used merely because it is possible. It is the fallback for gaps only.\n\n## Available curated commands\n\n### Issues\n- `linearctl issue get --json` / `linearctl issue view --json` — fetch a single issue by identifier (e.g. INF-2975) or UUID; JSON includes `dueDate`, `projectMilestone`, `trashed`, and `archivedAt`, so write/delete verification must check those fields instead of assuming exit 0 means live\n- `linearctl issue list [--search |--query ] [--state [,...] ...] [--status ] [--assignee ] [--team ] [--label ] [--priority <0-4>] [--due-date ] [--cycle ] [--project ] [--created-after ] [--updated-after ] [--completed-after ] [--order-by ] [--all-teams] [--all] [--max |--limit ] [--json]` — list issues with filters; repeated or comma-separated `--state` values are unioned; `--assignee none`/`unassigned` finds unassigned work; `--due-date none` finds issues without a due date; `--status` aliases `--state`; `--search`/`--query` routes to full-text search and composes with the other filters; friendly names resolve case-insensitively\n- `linearctl issue search [|--query ] [--team ] [--all] --json` — full-text search across issues; profile default teams are not applied, so pass `--team` to scope explicitly\n- `linearctl issue create --title --team <id> [--description <text>|--description-file <path|->] [--priority <0-4>] [--estimate <n>] [--due-date <YYYY-MM-DD>] [--assignee <id>] [--label <id>] [--state <id>] [--cycle <id>] [--project <name|id>] [--project-milestone <id>|--milestone <id>] --json` — create an issue\n- `linearctl issue update <identifier> [--title <text>] [--description <text>|--description-file <path|->] [--priority <0-4>] [--estimate <n>] [--due-date <YYYY-MM-DD|none>] [--assignee <id|none>] [--label <name|id>] [--state <id>] [--cycle <id|none>] [--project <name|id|none>] [--project-milestone <id|none>|--milestone <id|none>] [--parent <identifier|none>] --json` — update an issue; `none` clears nullable fields and `--label` adds without replacing existing labels\n- `linearctl issue close <identifier> [--state <name>] --json` — close an issue (transitions to a terminal completed/canceled workflow state; defaults to \"Done\", use --state to pick another)\n- `linearctl issue delete <identifier> --json` — delete/trash an issue by identifier or UUID\n- `linearctl issue assign <identifier> <assignee-id> --json` — assign an issue\n- `linearctl issue attach-slack <identifier> --url <slack-url> [--sync] [--title <text>] --json` — link a Slack thread to an issue (--sync enables bidirectional comment sync)\n- `linearctl issue comment <identifier> (--body <text>|--body-file <path|->) --json` — add a comment to an issue\n\n### Issue relations\n- `linearctl relation list <issue> [--all] [--max <n>] --json` — list both outbound and inbound relations for an issue; each item includes `direction`, `issue`, and `relatedIssue`\n- `linearctl relation create --issue <issue> --related <issue> --type <blocks|duplicate|related|similar> --json` — create a relation after resolving both identifiers; for `duplicate`, `--issue` is the duplicate and `--related` is canonical\n- `linearctl relation delete <relation-id> --json` — delete a relation\n\n### Bulk operations\n- `linearctl issue bulk-update --ids <id1,id2,...> [--state <id>] [--assignee <id|none>] [--priority <0-4>] [--estimate <n>] [--due-date <YYYY-MM-DD|none>] [--label <id>] [--cycle <id|none>] [--project-milestone <id|none>|--milestone <id|none>] --json` — `--label` is additive\n- `linearctl issue bulk-close --ids <id1,id2,...> [--state <name|id>] --json` — transition issues to a completed/canceled workflow state, matching `issue close`\n- `linearctl issue bulk-archive --ids <id1,id2,...> --json` — archive multiple issues\n- `linearctl issue bulk-delete --ids <id1,id2,...> --yes|--confirm --json` — delete/trash multiple issues; `--confirm` is accepted as an alias for `--yes`\n- `linearctl issue bulk-assign --ids <id1,id2,...> --assignee <id> --json`\n\n### Projects\n- `linearctl project get <name|id> --json` — richer single-project detail payload than `project list` (includes progress/health/currentProgress, and milestones with id, name, description, targetDate, sortOrder, createdAt, updatedAt); project names resolve by exact match, unique prefix, or unique substring\n- `linearctl project list [--query <text>|--search <text>|--name <text>] [--team <id>] [--state <status-type> ...] [--all-teams] --json` — includes portfolio fields (`progress`, `health`, `description`, `updatedAt`, `currentProgress`), normalized `milestones` with `name`, `targetDate`, `progress`, and `status`, and milestone truncation metadata (`milestonesPageInfo`, `milestonesTruncated`) in JSON output; human output shows progress, health, description, updated time, and milestone summaries; `--state` values: backlog, planned, started, paused, completed, canceled; repeated `--state` values are unioned; text flags filter project names\n- `linearctl project create --name <name> [--description <text>|--description-file <path|->] [--content <text>|--content-file <path|->] [--team <id>] [--lead <user-id|email|\"me\">] [--status <id|name|type>|--state <name|type>] [--start-date <YYYY-MM-DD>] [--target-date <YYYY-MM-DD>] --json`\n- `linearctl project create-with-issues --name <name> --team <id> --issues-json <json> [--description <text>|--description-file <path|->] [--content <text>|--content-file <path|->] [--lead <user-id|email|\"me\">] [--status <id|name|type>|--state <name|type>] [--start-date <YYYY-MM-DD>] [--target-date <YYYY-MM-DD>] --json` — create a project then batch-create linked issues (reports partial success if issue creation fails after project was created)\n- `linearctl project update <id> [--name <text>] [--description <text>|--description-file <path|->] [--content <text>|--content-file <path|->] [--status <id|name|type>|--state <name|type>] [--lead <user-id|email|\"me\">] [--start-date <YYYY-MM-DD>] [--target-date <YYYY-MM-DD>] --json` — `--status`/`--state` accepts status names, state types, or status IDs; `--state` remains an alias\n- `linearctl project delete <id> --json`\n\n### Project statuses\n- `linearctl project-status list --json` — list workspace-level project statuses\n- `linearctl project-status get <id> --json`\n- `linearctl project-status create --name <name> --status-type <type> --color <hex> --json` (types: backlog, planned, started, paused, completed, canceled)\n- `linearctl project-status delete <id> --json` — archives the status\n\n### Cycles\n- `linearctl cycle get <id> --json` — includes progress/scope fields (`progress`, derived `scopeCount`, `completedScopeCount`, `inProgressScopeCount`, `startedScopeCount`, issue counts, history arrays, and uncompleted issues captured on close)\n- `linearctl cycle list [--team <id>] [--all-teams] --json`\n- `linearctl cycle current [--team <id>] --json` — get the currently active cycle for a team; includes progress/scope fields\n- `linearctl cycle create --team <id> [--name <text>] [--starts-at <date>] [--ends-at <date>] --json`\n- `linearctl cycle update <id> [--name <text>] [--starts-at <date>] [--ends-at <date>] --json`\n- `linearctl cycle archive <id> --json`\n- `linearctl cycle delete <id> --json` — Linear does not hard-delete cycles; this archives the cycle and reports `requestedAction: \"delete\"` / `performedAction: \"archive\"` in JSON and dry-run output\n\n### Teams\n- `linearctl team get <id-or-key> [--set-default] --json` — fetch team; --set-default saves as profile default\n- `linearctl team list --json`\n- `linearctl team members <id-or-key> [--all] --json` — list team members with `id`, `name`, `displayName`, `email`, and `active`\n\n### Users\n- `linearctl user get <id> --json`\n- `linearctl user me --json`\n- `linearctl user list --json`\n\n### Labels\n- `linearctl label get <id> --json`\n- `linearctl label list [--team <id>] [--all-teams] --json`\n- `linearctl label create --name <name> [--description <text>] [--color <hex>] [--team <id>] [--parent <name|id>|--group] --json` — create a group with `--group`, or a child label under a group with `--parent`\n- `linearctl label delete <id> --json`\n\n### Comments\n- `linearctl comment list <issue> --json` / `linearctl comment list --issue <id> --json` — reads the issue's comments connection; with human-readable issue identifiers, a missing parent issue returns exit 4 / `category: \"not-found\"` instead of an empty list\n- `linearctl comment create --issue <id> (--body <text>|--body-file <path|->) --json`\n- `linearctl comment update <id> (--body <text>|--body-file <path|->) --json`\n- `linearctl comment delete <id> --json`\n\n### Attachments\n- `linearctl attachment list --issue <id> --json`\n- `linearctl attachment create --issue <id> --url <url> --title <title> --json`\n- `linearctl attachment delete <id> --json`\n\n### Files\n- `linearctl file upload <path> [--issue <id>] --json`\n- `linearctl file url <attachment-id> [--expires-in <seconds>] --json`\n- `linearctl file download <url> [--output <path>] --json`\n- Upload/download use manual redirect handling. Same-host redirects keep signed upload headers or Linear authorization; cross-host redirects are followed only after dropping those headers.\n\n### Workflow states\n- `linearctl state list [--team <id>] [--all-teams] --json` — list issue workflow states for a team\n- `linearctl state get <id> --json`\n- `linearctl state create --name <name> --team <id> --state-type <type> --json` (types: backlog, unstarted, started, completed, canceled)\n- `linearctl state archive <id|name> [--team <id>] --json`\n- `linearctl state delete <id|name> [--team <id>] --json`\n\n### Skills\n- `linearctl skills install [--json]` — auto-detect agents and install skill files\n- `linearctl skills list --json` — list embedded skills plus install status, paths, and `upToDate` state for Claude Code and Codex at user and project scope; uninspectable targets report a per-target `error` without hiding other results\n\n### Schema\n- `linearctl schema version --json`\n- `linearctl schema pull --json`\n- `linearctl schema check --json`\n- Normal commands run best-effort schema freshness checks after command output, skip help/dry-run paths, cache successful or failed attempts for 24 hours, and warn on stderr when the effective schema metadata is stale. `schema pull` writes `schema.json` and `schema-meta.json`; commands prefer pulled metadata from the profile config directory when present. Configure `[schema] stale_after_days` and `auto_update = true` in the linear config to opt into automatic schema pulls (`schema.autoUpdate`).\n\n### Auth\n- `linearctl auth status --json`\n- `linearctl auth login --profile <name> --api-key-env <ENV>`\n- `linearctl auth login --profile <name> --oauth --oauth-client-id <id>` — browser-based PKCE login\n- `linearctl auth login --profile <name> --oauth-client-credentials --oauth-client-id <id> (--oauth-client-secret-env <ENV>|--oauth-client-secret-stdin)` — non-interactive login; never pass the client secret as an argument and it is not persisted\n- `linearctl auth logout --profile <name>`\n- `linearctl auth switch <profile>`\n- `linearctl auth whoami --json`\n- OAuth refresh, login, and logout serialize credentials-file updates across CLI processes; token errors omit raw response bodies.\n\n### Workspace\n- `linearctl workspace list --json`\n\n## Generated commands\n\nWhen no curated command exists, use `linearctl api <resource> <operation>`:\n- `linearctl --help` — grouped overview of curated resources\n- `linearctl <resource> --help` — full usage lines for one curated resource\n- `linearctl api search <term>` — discover available generated commands\n- `linearctl api <resource> --help` — list operations for a resource\n- `linearctl api <resource> <operation> --help` — show generated operation usage and input flags\n- `linearctl api <resource> <operation> --id <id> --json` — execute a generated command\n- `linearctl api <resource> <operation> --input-json '<json>' --json` — execute with JSON input\n\n## Output modes\n\n- Use `--json` when parsing output programmatically\n- Use `--json-envelope` only when metadata (pagination, rate limits, complexity) is needed\n- Parse-level validation errors also emit failure envelopes when `--json-envelope` is set\n- Use `--jsonl --all` for streaming all list results, or `--jsonl --max <n>` to stream a bounded set; `--jsonl` no longer implies `--all`\n- Do not parse human-readable default output\n- Bulk operations fail non-zero when any item fails. With `--json-envelope`, partial failures return `ok: false`, populate `errors[]`, include per-item `data.succeeded`/`data.failed`, and set `meta.partial: true` when some items succeeded. Per-item failures preserve mapped categories, and the command exit code is selected by category priority: auth, rate-limit, not-found, then general.\n- GraphQL retry is default-on for rate limits (`--max-retries` defaults to 3); pass `--no-retry` to disable it.\n\n## Default team\n\nEach profile can have a default team. When set, list commands (issue, project, cycle, label) automatically filter to that team. `issue search` is the exception: it searches the whole workspace unless `--team` is passed.\n\n- Set it: `linearctl team get <key> --set-default`\n- Override per-command: `--team <other>`\n- Bypass and see all teams: `--all-teams`\n- `--team` and `--all-teams` cannot be used together\n\n## Name resolution\n\nCurated commands resolve friendly names to IDs automatically:\n- `--team \"Infrastructure\"` or `--team INF` resolves to the team's UUID\n- `--assignee \"me\"` resolves to the current user's ID\n- `--assignee \"aborges\"` resolves by Linear displayName\n- `--assignee \"quentin@example.com\"` resolves by email\n- `--assignee none` / `unassigned` filters for no assignee on `issue list` and clears the assignee on `issue update`\n- `--state \"In Progress\"` resolves to the workflow state ID (team-scoped)\n- `--label \"bug\"` resolves to the label ID (team-scoped when possible)\n- `--project \"Terraform Tech Debt\"` resolves by exact project name, unique prefix, or unique substring\n\nIf a value looks like a UUID, it's passed through directly. On ambiguous matches, the CLI errors with candidates. Case-insensitive resolution prefers exact case-sensitive matches first; users prefer email matches, and labels under `--team` prefer team-scoped labels over same-named workspace labels.\n\n## Dry run\n\nUse `--dry-run` on any mutating command to preview what would happen without executing:\n- `linearctl issue create --title \"test\" --team INF --dry-run --json`\n- `linearctl issue bulk-close --ids \"id1,id2\" --dry-run --json`\n- Works on create, update, close, assign, comment, delete, and upload operations\n- Dry runs validate and resolve friendly names before emitting the preview payload\n\n## Pagination\n\n- Default list behavior returns the first page only (up to 50 items)\n- **When results are truncated, a warning is emitted to stderr** — check stderr to know if you have incomplete data\n- Use `--all` to fetch all results (with `--max` or `--limit` to limit)\n- Use `--max <n>` or `--limit <n>` to cap total results\n- Use `--quiet` / `-q` to suppress the truncation warning (useful when piping JSON)\n- Add filters before broad pagination whenever possible\n- Prefer `--jsonl` for large result sets — it streams one object per line; pass `--all` or `--max <n>`\n\n## Profile selection\n\n1. If an explicit profile is specified, use `--profile <name>`\n2. Otherwise rely on `LINEAR_PROFILE` env var\n3. Otherwise run `linearctl auth status` to check the default profile\n4. Do not silently choose among multiple profiles\n\n## Error handling\n\n| Exit code | Meaning | Action |\n|---|---|---|\n| 0 | Success | |\n| 1 | General error | Read stderr for details |\n| 2 | Auth error | Run `linearctl auth status`, re-authenticate if needed |\n| 3 | Rate limit | Wait, reduce result count, add filters |\n| 4 | Not found | Verify identifier/ID |\n| 5 | Validation error | Check flags and input |\n| 6 | Schema drift | Fall back to `linearctl gql`, update CLI |\n\nMissing referenced Linear entities, including issues reported by inline GraphQL errors such as `Could not find referenced Issue`, map to exit 4 / `category: \"not-found\"` consistently across curated get, update, delete, comment-list parent lookup, and bulk paths.\n\n## Anti-patterns\n\n- Do not use `linearctl gql` when curated or generated commands cover the task\n- Do not use `--all` without `--max` unless explicitly asked for everything\n- Do not parse human-mode output programmatically\n- Do not guess profile names\n- Do not pass secrets as CLI arguments\n- Do not retry immediately after rate-limit exhaustion\n- Do not run destructive operations without explicit user confirmation\n" + content: "---\nname: linearctl\ndescription: Agent-first CLI for the Linear API — curated commands, generated API, and raw GraphQL with stable JSON output contracts\n---\n\n# linearctl\n\nDefault skill for all Linear CLI usage. Use this skill for any request involving Linear data unless raw GraphQL is explicitly required or the curated/generated layers cannot cover the operation.\n\n## First-time setup\n\nIf the user has not configured the CLI yet, help them bootstrap:\n\n1. Create the config directory: `mkdir -p ~/.config/linear`\n2. Create a credentials file with their API key:\n ```bash\n export LINEAR_API_KEY=lin_api_...\n linearctl auth login --profile <name> --api-key-env LINEAR_API_KEY --set-default\n ```\n3. Set a default team: `linearctl team list --json` to find team keys, then `linearctl team get <key> --set-default`\n4. Verify: `linearctl auth whoami --json`\n\nAPI keys are created at https://linear.app/settings/api. For browser OAuth and unattended client-credentials OAuth, see https://linear.app/settings/api/applications.\n\n## Command routing\n\n1. Use curated commands when they cover the operation.\n2. Otherwise use generated `linearctl api` commands.\n3. Otherwise use `linearctl gql`.\n\nRaw GraphQL should not be used merely because it is possible. It is the fallback for gaps only.\n\n## Available curated commands\n\n### Issues\n- `linearctl issue get <identifier> --json` / `linearctl issue view <identifier> --json` — fetch a single issue by identifier (e.g. INF-2975) or UUID; JSON includes `dueDate`, `projectMilestone`, `trashed`, and `archivedAt`, so write/delete verification must check those fields instead of assuming exit 0 means live\n- `linearctl issue list [--search <text>|--query <text>] [--state <name>[,<name>...] ...] [--status <name>] [--assignee <name|displayName|email|\"me\"|id|none>] [--team <id|key|name>] [--label <name|id>] [--priority <0-4>] [--due-date <YYYY-MM-DD|none>] [--cycle <id>] [--project <name|id>] [--created-after <date>] [--updated-after <date>] [--completed-after <date>] [--order-by <field>] [--all-teams] [--all] [--max <n>|--limit <n>] [--json]` — list issues with filters; repeated or comma-separated `--state` values are unioned; `--assignee none`/`unassigned` finds unassigned work; `--due-date none` finds issues without a due date; `--status` aliases `--state`; `--search`/`--query` routes to full-text search and composes with the other filters; friendly names resolve case-insensitively\n- `linearctl issue search [<text>|--query <text>] [--team <id|key|name>] [--all] --json` — full-text search across issues; profile default teams are not applied, so pass `--team` to scope explicitly\n- `linearctl issue create --title <title> --team <id> [--description <text>|--description-file <path|->] [--priority <0-4>] [--estimate <n>] [--due-date <YYYY-MM-DD>] [--assignee <id>] [--label <id>] [--state <id>] [--cycle <id>] [--project <name|id>] [--project-milestone <id>|--milestone <id>] --json` — create an issue\n- `linearctl issue update <identifier> [--title <text>] [--description <text>|--description-file <path|->] [--priority <0-4>] [--estimate <n>] [--due-date <YYYY-MM-DD|none>] [--assignee <id|none>] [--label <name|id>] [--state <id>] [--cycle <id|none>] [--project <name|id|none>] [--project-milestone <id|none>|--milestone <id|none>] [--parent <identifier|none>] --json` — update an issue; `none` clears nullable fields and `--label` adds without replacing existing labels\n- `linearctl issue close <identifier> [--state <name>] --json` — close an issue (transitions to a terminal completed/canceled workflow state; defaults to \"Done\", use --state to pick another)\n- `linearctl issue delete <identifier> --json` — delete/trash an issue by identifier or UUID\n- `linearctl issue assign <identifier> <assignee-id> --json` — assign an issue\n- `linearctl issue attach-slack <identifier> --url <slack-url> [--sync] [--title <text>] --json` — link a Slack thread to an issue (--sync enables bidirectional comment sync)\n- `linearctl issue comment <identifier> (--body <text>|--body-file <path|->) --json` — add a comment to an issue\n\n### Issue relations\n- `linearctl relation list <issue> [--all] [--max <n>] --json` — list both outbound and inbound relations for an issue; each item includes `direction`, `issue`, and `relatedIssue`\n- `linearctl relation create --issue <issue> --related <issue> --type <blocks|duplicate|related|similar> --json` — create a relation after resolving both identifiers; for `duplicate`, `--issue` is the duplicate and `--related` is canonical\n- `linearctl relation delete <relation-id> --json` — delete a relation\n\n### Bulk operations\n- `linearctl issue bulk-update --ids <id1,id2,...> [--state <id>] [--assignee <id|none>] [--priority <0-4>] [--estimate <n>] [--due-date <YYYY-MM-DD|none>] [--label <id>] [--cycle <id|none>] [--project-milestone <id|none>|--milestone <id|none>] --json` — `--label` is additive\n- `linearctl issue bulk-close --ids <id1,id2,...> [--state <name|id>] --json` — transition issues to a completed/canceled workflow state, matching `issue close`\n- `linearctl issue bulk-archive --ids <id1,id2,...> --json` — archive multiple issues\n- `linearctl issue bulk-delete --ids <id1,id2,...> --yes|--confirm --json` — delete/trash multiple issues; `--confirm` is accepted as an alias for `--yes`\n- `linearctl issue bulk-assign --ids <id1,id2,...> --assignee <id> --json`\n\n### Projects\n- `linearctl project get <name|id> --json` — richer single-project detail payload than `project list` (includes progress/health/currentProgress, and milestones with id, name, description, targetDate, sortOrder, createdAt, updatedAt); project names resolve by exact match, unique prefix, or unique substring\n- `linearctl project list [--query <text>|--search <text>|--name <text>] [--team <id>] [--state <status-type> ...] [--all-teams] --json` — includes portfolio fields (`progress`, `health`, `description`, `updatedAt`, `currentProgress`), normalized `milestones` with `name`, `targetDate`, `progress`, and `status`, and milestone truncation metadata (`milestonesPageInfo`, `milestonesTruncated`) in JSON output; human output shows progress, health, description, updated time, and milestone summaries; `--state` values: backlog, planned, started, paused, completed, canceled; repeated `--state` values are unioned; text flags filter project names\n- `linearctl project create --name <name> [--description <text>|--description-file <path|->] [--content <text>|--content-file <path|->] [--team <id>] [--lead <user-id|email|\"me\">] [--status <id|name|type>|--state <name|type>] [--start-date <YYYY-MM-DD>] [--target-date <YYYY-MM-DD>] --json`\n- `linearctl project create-with-issues --name <name> --team <id> --issues-json <json> [--description <text>|--description-file <path|->] [--content <text>|--content-file <path|->] [--lead <user-id|email|\"me\">] [--status <id|name|type>|--state <name|type>] [--start-date <YYYY-MM-DD>] [--target-date <YYYY-MM-DD>] --json` — create a project then batch-create linked issues (reports partial success if issue creation fails after project was created)\n- `linearctl project update <id> [--name <text>] [--description <text>|--description-file <path|->] [--content <text>|--content-file <path|->] [--status <id|name|type>|--state <name|type>] [--lead <user-id|email|\"me\">] [--start-date <YYYY-MM-DD>] [--target-date <YYYY-MM-DD>] --json` — `--status`/`--state` accepts status names, state types, or status IDs; `--state` remains an alias\n- `linearctl project delete <id> --json`\n\n### Project statuses\n- `linearctl project-status list --json` — list workspace-level project statuses\n- `linearctl project-status get <id> --json`\n- `linearctl project-status create --name <name> --status-type <type> --color <hex> --json` (types: backlog, planned, started, paused, completed, canceled)\n- `linearctl project-status delete <id> --json` — archives the status\n\n### Cycles\n- `linearctl cycle get <id> --json` — includes progress/scope fields (`progress`, derived `scopeCount`, `completedScopeCount`, `inProgressScopeCount`, `startedScopeCount`, issue counts, history arrays, and uncompleted issues captured on close)\n- `linearctl cycle list [--team <id>] [--all-teams] --json`\n- `linearctl cycle current [--team <id>] --json` — get the currently active cycle for a team; includes progress/scope fields\n- `linearctl cycle create --team <id> [--name <text>] [--starts-at <date>] [--ends-at <date>] --json`\n- `linearctl cycle update <id> [--name <text>] [--starts-at <date>] [--ends-at <date>] --json`\n- `linearctl cycle archive <id> --json`\n- `linearctl cycle delete <id> --json` — Linear does not hard-delete cycles; this archives the cycle and reports `requestedAction: \"delete\"` / `performedAction: \"archive\"` in JSON and dry-run output\n\n### Teams\n- `linearctl team get <id-or-key> [--set-default] --json` — fetch team; --set-default saves as profile default\n- `linearctl team list --json`\n- `linearctl team members <id-or-key> [--all] --json` — list team members with `id`, `name`, `displayName`, `email`, and `active`\n\n### Users\n- `linearctl user get <id> --json`\n- `linearctl user me --json`\n- `linearctl user list --json`\n\n### Labels\n- `linearctl label get <id> --json`\n- `linearctl label list [--team <id>] [--all-teams] --json`\n- `linearctl label create --name <name> [--description <text>] [--color <hex>] [--team <id>] [--parent <name|id>|--group] --json` — create a group with `--group`, or a child label under a group with `--parent`\n- `linearctl label delete <id> --json`\n\n### Comments\n- `linearctl comment list <issue> --json` / `linearctl comment list --issue <id> --json` — reads the issue's comments connection; with human-readable issue identifiers, a missing parent issue returns exit 4 / `category: \"not-found\"` instead of an empty list\n- `linearctl comment create --issue <id> (--body <text>|--body-file <path|->) --json`\n- `linearctl comment update <id> (--body <text>|--body-file <path|->) --json`\n- `linearctl comment delete <id> --json`\n\n### Attachments\n- `linearctl attachment list --issue <id> --json`\n- `linearctl attachment create --issue <id> --url <url> --title <title> --json`\n- `linearctl attachment delete <id> --json`\n\n### Files\n- `linearctl file upload <path> [--issue <id>] [--transfer-timeout <seconds>] --json`\n- `linearctl file url <attachment-id> [--expires-in <seconds>] --json`\n- `linearctl file download <url> [--output <path>] [--transfer-timeout <seconds>] --json`\n- Upload/download stream with backpressure. Upload sources must be regular files; do not modify them during transfer. `--transfer-timeout` is a total PUT/GET deadline in whole seconds (default 120, range 1–2147483), including redirects, response bodies, and download writes; GraphQL requests keep their separate timeout/retry policy. Transfers are not automatically retried. Ctrl-C/SIGINT or SIGTERM cancels an active transfer; timeout/cancellation returns exit 1.\n- Downloads stage beside the destination and atomically overwrite it only after success. Transfer/write/rename failures clean up staging and preserve existing contents. The parent must exist and be writable. Destination symlinks are replaced rather than followed; new files have private permissions (0600 on POSIX), not the old metadata. SIGKILL/crashes can leave staging directories; this is not a crash-durability guarantee.\n- Requests and redirects require HTTPS, with at most five redirects; downloads must start at `uploads.linear.app`. Same-host redirects keep signed upload headers or Linear authorization; cross-host redirects drop sensitive headers permanently. Redirected PUTs replay the file from byte zero.\n\n### Workflow states\n- `linearctl state list [--team <id>] [--all-teams] --json` — list issue workflow states for a team\n- `linearctl state get <id> --json`\n- `linearctl state create --name <name> --team <id> --state-type <type> --json` (types: backlog, unstarted, started, completed, canceled)\n- `linearctl state archive <id|name> [--team <id>] --json`\n- `linearctl state delete <id|name> [--team <id>] --json`\n\n### Skills\n- `linearctl skills install [--json]` — auto-detect agents and install skill files\n- `linearctl skills list --json` — list embedded skills plus install status, paths, and `upToDate` state for Claude Code and Codex at user and project scope; uninspectable targets report a per-target `error` without hiding other results\n\n### Schema\n- `linearctl schema version --json`\n- `linearctl schema pull --json`\n- `linearctl schema check --json`\n- Normal commands run best-effort schema freshness checks after command output, skip help/dry-run paths, cache successful or failed attempts for 24 hours, and warn on stderr when the effective schema metadata is stale. `schema pull` writes `schema.json` and `schema-meta.json`; commands prefer pulled metadata from the profile config directory when present. Configure `[schema] stale_after_days` and `auto_update = true` in the linear config to opt into automatic schema pulls (`schema.autoUpdate`).\n\n### Auth\n- `linearctl auth status --json`\n- `linearctl auth login --profile <name> --api-key-env <ENV>`\n- `linearctl auth login --profile <name> --oauth --oauth-client-id <id>` — browser-based PKCE login\n- `linearctl auth login --profile <name> --oauth-client-credentials --oauth-client-id <id> (--oauth-client-secret-env <ENV>|--oauth-client-secret-stdin)` — non-interactive login; never pass the client secret as an argument and it is not persisted\n- `linearctl auth logout --profile <name>`\n- `linearctl auth switch <profile>`\n- `linearctl auth whoami --json`\n- OAuth refresh, login, and logout serialize credentials-file updates across CLI processes; token errors omit raw response bodies.\n\n### Workspace\n- `linearctl workspace list --json`\n\n## Generated commands\n\nWhen no curated command exists, use `linearctl api <resource> <operation>`:\n- `linearctl --help` — grouped overview of curated resources\n- `linearctl <resource> --help` — full usage lines for one curated resource\n- `linearctl api search <term>` — discover available generated commands\n- `linearctl api <resource> --help` — list operations for a resource\n- `linearctl api <resource> <operation> --help` — show generated operation usage and input flags\n- `linearctl api <resource> <operation> --id <id> --json` — execute a generated command\n- `linearctl api <resource> <operation> --input-json '<json>' --json` — execute with JSON input\n\n## Output modes\n\n- Use `--json` when parsing output programmatically\n- Use `--json-envelope` only when metadata (pagination, rate limits, complexity) is needed\n- Parse-level validation errors also emit failure envelopes when `--json-envelope` is set\n- Use `--jsonl --all` for streaming all list results, or `--jsonl --max <n>` to stream a bounded set; `--jsonl` no longer implies `--all`\n- Do not parse human-readable default output\n- Bulk operations fail non-zero when any item fails. With `--json-envelope`, partial failures return `ok: false`, populate `errors[]`, include per-item `data.succeeded`/`data.failed`, and set `meta.partial: true` when some items succeeded. Per-item failures preserve mapped categories, and the command exit code is selected by category priority: auth, rate-limit, not-found, then general.\n- GraphQL retry is default-on for rate limits (`--max-retries` defaults to 3); pass `--no-retry` to disable it.\n\n## Default team\n\nEach profile can have a default team. When set, list commands (issue, project, cycle, label) automatically filter to that team. `issue search` is the exception: it searches the whole workspace unless `--team` is passed.\n\n- Set it: `linearctl team get <key> --set-default`\n- Override per-command: `--team <other>`\n- Bypass and see all teams: `--all-teams`\n- `--team` and `--all-teams` cannot be used together\n\n## Name resolution\n\nCurated commands resolve friendly names to IDs automatically:\n- `--team \"Infrastructure\"` or `--team INF` resolves to the team's UUID\n- `--assignee \"me\"` resolves to the current user's ID\n- `--assignee \"aborges\"` resolves by Linear displayName\n- `--assignee \"quentin@example.com\"` resolves by email\n- `--assignee none` / `unassigned` filters for no assignee on `issue list` and clears the assignee on `issue update`\n- `--state \"In Progress\"` resolves to the workflow state ID (team-scoped)\n- `--label \"bug\"` resolves to the label ID (team-scoped when possible)\n- `--project \"Terraform Tech Debt\"` resolves by exact project name, unique prefix, or unique substring\n\nIf a value looks like a UUID, it's passed through directly. On ambiguous matches, the CLI errors with candidates. Case-insensitive resolution prefers exact case-sensitive matches first; users prefer email matches, and labels under `--team` prefer team-scoped labels over same-named workspace labels.\n\n## Dry run\n\nUse `--dry-run` on any mutating command to preview what would happen without executing:\n- `linearctl issue create --title \"test\" --team INF --dry-run --json`\n- `linearctl issue bulk-close --ids \"id1,id2\" --dry-run --json`\n- Works on create, update, close, assign, comment, delete, and upload operations\n- Dry runs validate and resolve friendly names before emitting the preview payload\n\n## Pagination\n\n- Default list behavior returns the first page only (up to 50 items)\n- **When results are truncated, a warning is emitted to stderr** — check stderr to know if you have incomplete data\n- Use `--all` to fetch all results (with `--max` or `--limit` to limit)\n- Use `--max <n>` or `--limit <n>` to cap total results\n- Use `--quiet` / `-q` to suppress the truncation warning (useful when piping JSON)\n- Add filters before broad pagination whenever possible\n- Prefer `--jsonl` for large result sets — it streams one object per line; pass `--all` or `--max <n>`\n\n## Profile selection\n\n1. If an explicit profile is specified, use `--profile <name>`\n2. Otherwise rely on `LINEAR_PROFILE` env var\n3. Otherwise run `linearctl auth status` to check the default profile\n4. Do not silently choose among multiple profiles\n\n## Error handling\n\n| Exit code | Meaning | Action |\n|---|---|---|\n| 0 | Success | |\n| 1 | General error | Read stderr for details |\n| 2 | Auth error | Run `linearctl auth status`, re-authenticate if needed |\n| 3 | Rate limit | Wait, reduce result count, add filters |\n| 4 | Not found | Verify identifier/ID |\n| 5 | Validation error | Check flags and input |\n| 6 | Schema drift | Fall back to `linearctl gql`, update CLI |\n\nMissing referenced Linear entities, including issues reported by inline GraphQL errors such as `Could not find referenced Issue`, map to exit 4 / `category: \"not-found\"` consistently across curated get, update, delete, comment-list parent lookup, and bulk paths.\n\n## Anti-patterns\n\n- Do not use `linearctl gql` when curated or generated commands cover the task\n- Do not use `--all` without `--max` unless explicitly asked for everything\n- Do not parse human-mode output programmatically\n- Do not guess profile names\n- Do not pass secrets as CLI arguments\n- Do not retry immediately after rate-limit exhaustion\n- Do not run destructive operations without explicit user confirmation\n" }, "linearctl-raw-gql": { filename: "linearctl-raw-gql.md", diff --git a/src/generated/manifest/curated-commands.json b/src/generated/manifest/curated-commands.json index 1a345de..57883d3 100644 --- a/src/generated/manifest/curated-commands.json +++ b/src/generated/manifest/curated-commands.json @@ -991,7 +991,7 @@ }, { "commandPath": "linearctl file upload", - "usage": "linearctl file upload <path> [--issue <id>] [--json]", + "usage": "linearctl file upload <path> [--issue <id>] [--transfer-timeout <seconds>] [--json]", "layer": "curated", "resource": "file", "operation": "upload", @@ -1025,7 +1025,7 @@ }, { "commandPath": "linearctl file download", - "usage": "linearctl file download <url> [--output <path>] [--json]", + "usage": "linearctl file download <url> [--output <path>] [--transfer-timeout <seconds>] [--json]", "layer": "curated", "resource": "file", "operation": "download", diff --git a/tests/cli/main.test.ts b/tests/cli/main.test.ts index 0ae5bf8..3443c9d 100644 --- a/tests/cli/main.test.ts +++ b/tests/cli/main.test.ts @@ -67,6 +67,24 @@ async function runMainWithThrowingFetch(args: string[]) { } describe("CLI scaffold", () => { + it("documents and parses file transfer timeout flags", async () => { + const { stdout } = await runCli(["file", "--help"]); + expect(stdout).toContain("--transfer-timeout <seconds>"); + const result = await runMainWithThrowingFetch([ + "file", "upload", "missing-file", "--transfer-timeout", "300", "--dry-run", "--json" + ]); + expect(result.code).toBe(0); + expect(result.fetchImpl).not.toHaveBeenCalled(); + }); + + it.each(["0", "-1", "1.5", "NaN", "Infinity", "2147484"])("validates transfer timeout %s before I/O", async (timeout) => { + const result = await runMainWithThrowingFetch([ + "file", "download", "https://uploads.linear.app/file", `--transfer-timeout=${timeout}`, "--json-envelope" + ]); + expect(result.code).toBe(5); + expect(JSON.parse(result.stdout).errors[0].message).toContain("--transfer-timeout must be an integer"); + }); + it("prints top-level agent-facing help", async () => { const { stdout: output } = await runCli(["--help"]); diff --git a/tests/commands/file.test.ts b/tests/commands/file.test.ts index 708f4ca..eedced7 100644 --- a/tests/commands/file.test.ts +++ b/tests/commands/file.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile } from "node:fs/promises"; +import { mkdtemp, readFile, readdir } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { writeFile } from "node:fs/promises"; @@ -115,7 +115,7 @@ describe("handleFileCommand — file upload", () => { expect(parsed.attachment).toBeUndefined(); expect((fetchImpl as ReturnType<typeof vi.fn>).mock.calls[1]![1]!.redirect).toBe("manual"); const putHeaders = (fetchImpl as ReturnType<typeof vi.fn>).mock.calls[1]![1]!.headers as Record<string, string>; - expect(putHeaders["Content-Type"]).toBe("image/png"); + expect(new Headers(putHeaders).get("content-type")).toBe("image/png"); } finally { output.restore(); } @@ -151,7 +151,7 @@ describe("handleFileCommand — file upload", () => { if (callIndex === 2) { expect((init?.headers as Record<string, string>)["x-amz-acl"]).toBe("public-read"); - expect((init?.headers as Record<string, string>)["Content-Type"]).toBe("image/png"); + expect(new Headers(init?.headers).get("content-type")).toBe("image/png"); return new Response("", { status: 307, headers: { location: "https://cdn.example.com/put-here" } @@ -403,6 +403,37 @@ describe("handleFileCommand — file url", () => { }); describe("handleFileCommand — file download", () => { + it("reports cancellation in the failure envelope and preserves an existing output", async () => { + const directory = await mkdtemp(join(tmpdir(), "linear-cli-file-")); + const paths = await writeProfileFiles(directory); + const outputPath = join(directory, "downloaded.txt"); + await writeFile(outputPath, "original"); + const before = await readdir(directory); + const controller = new AbortController(); + const output = captureOutput(); + try { + const exitCode = await handleFileCommand(["download", "https://uploads.linear.app/file"], { + ...baseOptions(paths), + json: false, + jsonEnvelope: true, + output: outputPath, + signal: controller.signal, + fetchImpl: async () => { + controller.abort(); + return new Response("partial"); + } + }); + expect(exitCode).toBe(1); + const envelope = JSON.parse(output.stdout.join("")); + expect(envelope.ok).toBe(false); + expect(envelope.errors[0].message).toContain("cancelled"); + expect(await readFile(outputPath, "utf8")).toBe("original"); + expect(await readdir(directory)).toEqual(before); + } finally { + output.restore(); + } + }); + it("downloads a file and writes to output path", async () => { const directory = await mkdtemp(join(tmpdir(), "linear-cli-file-")); const paths = await writeProfileFiles(directory); diff --git a/tests/core/io/file-transfer.test.ts b/tests/core/io/file-transfer.test.ts new file mode 100644 index 0000000..008fd53 --- /dev/null +++ b/tests/core/io/file-transfer.test.ts @@ -0,0 +1,349 @@ +import { createHash } from "node:crypto"; +import { createReadStream, createWriteStream } from "node:fs"; +import type { WriteStream } from "node:fs"; +import { mkdir, mkdtemp, open, readFile, readdir, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Readable, Writable } from "node:stream"; +import { setTimeout as delay } from "node:timers/promises"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { downloadFile, uploadFile } from "../../../src/core/io/file-transfer.js"; +import type { FetchLike } from "../../../src/core/transport/graphql.js"; + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal<typeof import("node:fs")>(); + return { ...actual, createWriteStream: vi.fn(actual.createWriteStream) }; +}); + +const url = "https://uploads.linear.app/file"; +const headers = { authorization: "secret", "x-signed": "signed", "content-type": "application/octet-stream" }; +let directory: string; +let destination: string; +let listeners: number[]; + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), "linearctl-transfer-")); + destination = join(directory, "destination"); + listeners = [process.listenerCount("SIGINT"), process.listenerCount("SIGTERM")]; +}); +afterEach(async () => { + vi.mocked(createWriteStream).mockReset(); + await rm(directory, { recursive: true, force: true }); + expect([process.listenerCount("SIGINT"), process.listenerCount("SIGTERM")]).toEqual(listeners); +}); + +async function expectPreserved() { + expect(await readFile(destination, "utf8")).toBe("original"); + expect(await readdir(directory)).toEqual(["destination"]); +} + +function stalledResponse(onCancel = vi.fn()): Response { + return new Response(new ReadableStream({ + start(controller) { controller.enqueue(new Uint8Array([1, 2, 3])); }, + cancel: onCancel + })); +} + +function waitForAbort(signal: AbortSignal): Promise<Response> { + return new Promise((_resolve, reject) => { + if (signal.aborted) reject(signal.reason); + else signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + }); +} + +describe("streaming downloads", () => { + it("streams a large body with backpressure and atomically replaces an existing file", async () => { + await writeFile(destination, "original"); + const chunk = Buffer.alloc(64 * 1024, 42); + const count = 256; + let produced = 0; + let release!: () => void; + let started!: () => void; + const gate = new Promise<void>((resolve) => { release = resolve; }); + const firstChunk = new Promise<void>((resolve) => { started = resolve; }); + const response = new Response(new ReadableStream({ + async pull(controller) { + if (produced === 1) { started(); await gate; } + if (produced++ < count) controller.enqueue(chunk); + else controller.close(); + } + })); + response.arrayBuffer = () => { throw new Error("must not buffer"); }; + const transfer = downloadFile(async () => response, url, headers, destination, {}); + await firstChunk; + expect(await readFile(destination, "utf8")).toBe("original"); + release(); + expect(await transfer).toBe(count * chunk.length); + const expected = createHash("sha256"); + for (let i = 0; i < count; i++) expected.update(chunk); + const actual = createHash("sha256"); + for await (const bytes of createReadStream(destination)) actual.update(bytes); + expect(actual.digest("hex")).toBe(expected.digest("hex")); + expect(await readdir(directory)).toEqual(["destination"]); + }); + + it("does not pull an entire response while the disk is backpressured", async () => { + let writes = 0; + let produced = 0; + const controller = new AbortController(); + vi.mocked(createWriteStream).mockImplementationOnce(() => new Writable({ + highWaterMark: 1, + write() { writes++; } // deliberately hold the callback + }) as WriteStream); + const response = new Response(new ReadableStream({ + pull(stream) { produced++; stream.enqueue(Buffer.alloc(64 * 1024)); } + })); + const transfer = downloadFile(async () => response, url, headers, destination, { signal: controller.signal }); + const rejected = expect(transfer).rejects.toThrow("cancelled"); + while (writes === 0) await delay(1); + await delay(10); + expect(produced).toBeLessThan(10); + controller.abort(); + await rejected; + expect(await readdir(directory)).toEqual([]); + }); + + it.each(["timeout", "abort", "SIGINT", "SIGTERM"])("cleans up a stalled body on %s", async (mode) => { + await writeFile(destination, "original"); + const cancel = vi.fn(); + const controller = new AbortController(); + let receivedSignal: AbortSignal | null | undefined; + const transfer = downloadFile(async (_url, init) => { + receivedSignal = init?.signal; + return stalledResponse(cancel); + }, url, headers, destination, { timeoutMs: mode === "timeout" ? 40 : 1000, signal: controller.signal }); + const rejected = expect(transfer).rejects.toThrow(mode === "timeout" ? "timed out" : "cancelled"); + if (mode !== "timeout") { + await delay(20); + if (mode === "abort") controller.abort(); + else process.emit(mode as "SIGINT" | "SIGTERM"); + } + await rejected; + expect(receivedSignal?.aborted).toBe(true); + expect(cancel).toHaveBeenCalledOnce(); + await expectPreserved(); + }); + + it.each(["http", "body", "write"])("preserves the destination after a %s failure", async (mode) => { + await writeFile(destination, "original"); + const cancel = vi.fn(); + if (mode === "write") { + vi.mocked(createWriteStream).mockImplementationOnce(() => new Writable({ + write(_chunk, _encoding, callback) { callback(new Error("disk full")); } + }) as WriteStream); + } + const response = mode === "http" + ? new Response(new ReadableStream({ cancel }), { status: 500 }) + : mode === "body" + ? new Response(new ReadableStream({ + start(stream) { stream.enqueue(Buffer.from("partial")); }, + pull(stream) { stream.error(new Error("connection lost")); } + })) + : stalledResponse(cancel); + await expect(downloadFile(async () => response, url, headers, destination, {})).rejects.toThrow(); + if (mode !== "body") expect(cancel).toHaveBeenCalledOnce(); + await expectPreserved(); + }); + + it("cleans up when the destination cannot be replaced", async () => { + await mkdir(destination); + await writeFile(join(destination, "keep"), "original"); + await expect(downloadFile(async () => new Response("new"), url, headers, destination, {})).rejects.toThrow(); + expect(await readFile(join(destination, "keep"), "utf8")).toBe("original"); + expect(await readdir(directory)).toEqual(["destination"]); + }); + + it.skipIf(process.platform === "win32")("replaces a destination symlink without modifying its target", async () => { + const target = join(directory, "target"); + await writeFile(target, "original"); + await symlink(target, destination); + await downloadFile(async () => new Response("new"), url, headers, destination, {}); + expect(await readFile(target, "utf8")).toBe("original"); + expect(await readFile(destination, "utf8")).toBe("new"); + }); + + it.each(["truncated", "stalled"])("preserves the destination on a native fetch %s body", async (mode) => { + await writeFile(destination, "original"); + const server = createServer((_request, response) => { + response.writeHead(200, { "content-length": "1000" }); + response.write("partial"); + if (mode === "truncated") { + response.end(); + response.socket?.end(); + } + }); + await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address() as { port: number }; + try { + // Runtimes may report a truncated socket immediately or leave its body + // pending until our deadline. Neither case may commit partial bytes. + await expect(downloadFile( + (_url, init) => fetch(`http://127.0.0.1:${address.port}/`, init), + url, headers, destination, { timeoutMs: mode === "truncated" ? 1000 : 100 } + )).rejects.toThrow(); + await expectPreserved(); + } finally { + server.closeAllConnections(); + await new Promise<void>((resolve) => server.close(() => resolve())); + } + }); + + it("does not wait for a stalled stream's cancellation callback", async () => { + await writeFile(destination, "original"); + const response = new Response(new ReadableStream({ + cancel() { return new Promise(() => {}); } + })); + await expect(downloadFile(async () => response, url, headers, destination, { timeoutMs: 20 })).rejects.toThrow("timed out"); + await expectPreserved(); + }); + + it("handles an empty successful body", async () => { + expect(await downloadFile(async () => new Response(null, { status: 204 }), url, headers, destination, {})).toBe(0); + expect((await stat(destination)).size).toBe(0); + }); +}); + +describe("transfer redirects and deadlines", () => { + it("uses one signal/deadline through redirects and never restores stripped credentials", async () => { + const signals: AbortSignal[] = []; + const cancelled = vi.fn(); + const fetchImpl = vi.fn<FetchLike>(async (_url, init) => { + signals.push(init!.signal!); + if (signals.length > 1) { + expect(new Headers(init?.headers).get("authorization")).toBeNull(); + expect(new Headers(init?.headers).get("x-signed")).toBeNull(); + } + if (signals.length === 3) return waitForAbort(init!.signal!); + await delay(10); + return new Response(new ReadableStream({ cancel: cancelled }), { + status: 307, + headers: { location: signals.length === 1 ? "https://cdn.example.com/file" : url } + }); + }); + await expect(downloadFile(fetchImpl, url, headers, destination, { timeoutMs: 60 })).rejects.toThrow("timed out"); + expect(signals).toHaveLength(3); + expect(new Set(signals).size).toBe(1); + expect(cancelled).toHaveBeenCalledTimes(2); + expect(await readdir(directory)).toEqual([]); + }); + + it.each(["limit", "missing", "invalid", "http"])("rejects %s redirects and cancels their bodies", async (mode) => { + const cancel = vi.fn(); + const fetchImpl = vi.fn<FetchLike>(async () => new Response(new ReadableStream({ cancel }), { + status: 302, + headers: mode === "missing" ? {} : { location: mode === "http" ? "http://uploads.linear.app/file" : mode === "invalid" ? "https://[" : "/next" } + })); + await expect(downloadFile(fetchImpl, url, headers, destination, {})).rejects.toThrow( + mode === "limit" ? "redirect limit" : mode === "http" ? "non-HTTPS" : "Location" + ); + expect(fetchImpl).toHaveBeenCalledTimes(mode === "limit" ? 6 : 1); + expect(cancel).toHaveBeenCalledTimes(mode === "limit" ? 6 : 1); + expect(await readdir(directory)).toEqual([]); + }); + + it("does not fetch when already cancelled", async () => { + const fetchImpl = vi.fn<FetchLike>(); + await expect(downloadFile(fetchImpl, url, headers, destination, { signal: AbortSignal.abort() })).rejects.toThrow("cancelled"); + expect(fetchImpl).not.toHaveBeenCalled(); + }); +}); + +describe("streaming uploads", () => { + it("rejects a source that shrinks instead of uploading a truncated file", async () => { + const file = await open(join(directory, "upload"), "w+"); + await file.write(Buffer.from("short")); + try { + await expect(uploadFile(async (_url, init) => { + for await (const _chunk of init?.body as unknown as Readable) { /* consume */ } + return new Response(); + }, url, headers, file, 100, {})).rejects.toThrow("became shorter"); + } finally { + await file.close(); + } + }); + + it("discards unsuccessful PUT responses without waiting for their bodies", async () => { + const file = await open(join(directory, "upload"), "w+"); + const cancel = vi.fn(); + try { + await expect(uploadFile(async () => new Response(new ReadableStream({ cancel }), { status: 403 }), + url, headers, file, 0, {} + )).rejects.toThrow("File PUT failed with HTTP 403"); + expect(cancel).toHaveBeenCalledOnce(); + } finally { + await file.close(); + } + }); + + it("replays large PUT streams across redirects using native fetch and backpressure", async () => { + const path = join(directory, "upload"); + const file = await open(path, "w+"); + const chunk = Buffer.alloc(64 * 1024, 7); + const count = 256; + const expected = createHash("sha256"); + for (let i = 0; i < count; i++) { await file.write(chunk); expected.update(chunk); } + const digest = expected.digest("hex"); + const requests: Array<{ method: string | undefined; size: number; hash: string; auth: string | undefined; signed: string | string[] | undefined; length: string | undefined }> = []; + const server = createServer(async (request, response) => { + let size = 0; + const hash = createHash("sha256"); + for await (const bytes of request) { size += bytes.length; hash.update(bytes); } + requests.push({ method: request.method, size, hash: hash.digest("hex"), auth: request.headers.authorization, signed: request.headers["x-signed"], length: request.headers["content-length"] }); + if (requests.length <= 2) response.writeHead(307, { location: requests.length === 1 ? "/same-host" : "https://cdn.example.com/other-host" }); + response.end(); + }); + await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address() as { port: number }; + const streams: Readable[] = []; + // Only the test adapter changes HTTPS to loopback HTTP, exercising real + // fetch streaming without weakening production URL validation. + const fetchImpl: FetchLike = async (input, init) => { + expect(init?.body).toBeInstanceOf(Readable); + streams.push(init?.body as unknown as Readable); + return fetch(`http://127.0.0.1:${address.port}${new URL(String(input)).pathname}`, init); + }; + try { + await uploadFile(fetchImpl, url, headers, file, count * chunk.length, {}); + expect(requests).toHaveLength(3); + for (const request of requests) { + expect(request.method).toBe("PUT"); + expect(request.size).toBe(count * chunk.length); + expect(request.hash).toBe(digest); + expect(request.length).toBe(String(count * chunk.length)); + } + expect(requests.map((request) => request.auth)).toEqual(["secret", "secret", undefined]); + expect(requests.map((request) => request.signed)).toEqual(["signed", "signed", undefined]); + expect(new Set(streams).size).toBe(3); + expect(streams.every((stream) => stream.destroyed)).toBe(true); + } finally { + await file.close(); + server.closeAllConnections(); + await new Promise<void>((resolve) => server.close(() => resolve())); + } + }); + + it.each(["request", "response", "abort"])("cancels a stalled upload %s and destroys the source", async (mode) => { + const file = await open(join(directory, "upload"), "w+"); + await file.truncate(16 * 1024 * 1024); + let body: Readable | undefined; + const controller = new AbortController(); + try { + const transfer = uploadFile(async (_url, init) => { + body = init?.body as unknown as Readable; + if (mode === "response") { + for await (const _chunk of body) { /* consume request */ } + return stalledResponse(); + } + return waitForAbort(init!.signal!); + }, url, headers, file, 16 * 1024 * 1024, { timeoutMs: mode === "abort" ? 1000 : 60, signal: controller.signal }); + const rejected = expect(transfer).rejects.toThrow(mode === "abort" ? "cancelled" : "timed out"); + if (mode === "abort") { await delay(10); controller.abort(); } + await rejected; + expect(body?.destroyed).toBe(true); + expect(body!.readableLength).toBeLessThanOrEqual(64 * 1024); + } finally { + await file.close(); + } + }); +}); From 7e3625386722aa30670a0960c249cb54f681ba53 Mon Sep 17 00:00:00 2001 From: Q <q@qwrobins.net> Date: Fri, 4 Sep 2026 18:43:07 -0500 Subject: [PATCH 2/2] address greptile review feedback (greploop iteration 1) --- docs/commands.md | 2 +- skills/linearctl/SKILL.md | 2 +- src/core/io/file-transfer.ts | 24 ++++++++-- src/generated/embedded-skills.ts | 2 +- tests/commands/file.test.ts | 9 ++-- tests/core/io/file-transfer.test.ts | 68 +++++++++++++++++++++++++++++ 6 files changed, 97 insertions(+), 10 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index dae2339..68420a6 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -231,7 +231,7 @@ File upload and download stream with backpressure instead of buffering entire fi `--transfer-timeout` sets a total transfer deadline in whole seconds (default **120**, range 1–2147483). The deadline starts with the first PUT/GET and covers all redirects and response-body consumption, including stalled bodies and download writes. It does not change the separate GraphQL request timeout or retry policy; file transfers are not automatically retried. Ctrl-C (SIGINT) or SIGTERM cancels an active transfer and cleans up local resources. Timeout/cancellation returns exit 1. -Downloads overwrite existing destinations **only after successful completion**, using a private staging directory beside the output and an atomic rename on the same filesystem. Transfer, write, or rename failures remove staging files and leave the existing destination unchanged; the parent directory must already exist and be writable. A destination symlink is replaced, not followed. The new file uses private permissions (0600 on POSIX); existing permissions/metadata are not retained. Forced termination (SIGKILL), crashes, or power loss can leave staging directories; atomic replacement is not a crash-durability guarantee. +Downloads overwrite existing destinations **only after successful completion**, using a private staging directory beside the output and an atomic rename on the same filesystem. Transfer, write, or rename failures leave the existing destination unchanged and attempt to remove staging files; the parent directory must already exist and be writable. A destination symlink is replaced, not followed. The new file uses private permissions (0600 on POSIX); existing permissions/metadata are not retained. Staging cleanup is best effort: filesystem cleanup failures do not hide the original transfer error or turn a committed download into a failure. Cleanup failures, forced termination (SIGKILL), crashes, or power loss can leave staging directories; atomic replacement is not a crash-durability guarantee. Requests and redirects must use HTTPS, with at most five redirects. Downloads must start at `uploads.linear.app`. Same-host redirects keep signed upload headers and Linear authorization. Cross-host redirects drop sensitive headers, even if a later redirect returns to the original host. Redirected PUTs replay the file from the beginning. diff --git a/skills/linearctl/SKILL.md b/skills/linearctl/SKILL.md index ae4c93d..9aea517 100644 --- a/skills/linearctl/SKILL.md +++ b/skills/linearctl/SKILL.md @@ -111,7 +111,7 @@ Raw GraphQL should not be used merely because it is possible. It is the fallback - `linearctl file url <attachment-id> [--expires-in <seconds>] --json` - `linearctl file download <url> [--output <path>] [--transfer-timeout <seconds>] --json` - Upload/download stream with backpressure. Upload sources must be regular files; do not modify them during transfer. `--transfer-timeout` is a total PUT/GET deadline in whole seconds (default 120, range 1–2147483), including redirects, response bodies, and download writes; GraphQL requests keep their separate timeout/retry policy. Transfers are not automatically retried. Ctrl-C/SIGINT or SIGTERM cancels an active transfer; timeout/cancellation returns exit 1. -- Downloads stage beside the destination and atomically overwrite it only after success. Transfer/write/rename failures clean up staging and preserve existing contents. The parent must exist and be writable. Destination symlinks are replaced rather than followed; new files have private permissions (0600 on POSIX), not the old metadata. SIGKILL/crashes can leave staging directories; this is not a crash-durability guarantee. +- Downloads stage beside the destination and atomically overwrite it only after success. Transfer/write/rename failures preserve existing contents and attempt staging cleanup; cleanup failures do not change the primary transfer outcome. The parent must exist and be writable. Destination symlinks are replaced rather than followed; new files have private permissions (0600 on POSIX), not the old metadata. Cleanup failures or SIGKILL/crashes can leave staging directories; this is not a crash-durability guarantee. - Requests and redirects require HTTPS, with at most five redirects; downloads must start at `uploads.linear.app`. Same-host redirects keep signed upload headers or Linear authorization; cross-host redirects drop sensitive headers permanently. Redirected PUTs replay the file from byte zero. ### Workflow states diff --git a/src/core/io/file-transfer.ts b/src/core/io/file-transfer.ts index 68f8087..f514bf6 100644 --- a/src/core/io/file-transfer.ts +++ b/src/core/io/file-transfer.ts @@ -88,8 +88,11 @@ async function fetchWithHostValidatedRedirects( // A consumed stream cannot be reused after a redirect. Keep the opened // file descriptor, but restart at byte zero for every PUT. const body = upload === undefined ? undefined : uploadStream(upload.file, upload.size, init.signal); - const bodyDone = body === undefined ? undefined : finished(body, { cleanup: true }).catch(() => {}); - let response: Response; + const bodyDone = body === undefined ? undefined : finished(body, { cleanup: true }); + // Observe early failures while fetch is pending, but retain the original + // promise so a successful HTTP response cannot hide a source failure. + void bodyDone?.catch(() => {}); + let response: Response | undefined; try { const { headers: _headers, ...rest } = init; const request: RequestInit & { duplex?: "half" } = { @@ -104,9 +107,18 @@ async function fetchWithHostValidatedRedirects( }) }; response = await fetchImpl(currentUrl, request); + // Fetch can return headers before the request stream finishes. Only + // redirects/rejections may abandon the body; a 2xx must await clean EOF + // under the same deadline before it can be considered successful. + if (response.ok) await bodyDone; + } catch (error) { + if (response !== undefined) discardBody(response); + throw error; } finally { body?.destroy(); - await bodyDone; + // Early redirects/rejections intentionally stop their request stream. + // Preserve the primary fetch/source error if cleanup also fails. + await bodyDone?.catch(() => {}); } if (![301, 302, 303, 307, 308].includes(response.status)) return response; @@ -203,7 +215,11 @@ export async function downloadFile( return size; } finally { discardBody(response); - if (stagingDirectory !== undefined) await rm(stagingDirectory, { recursive: true, force: true }); + if (stagingDirectory !== undefined) { + // Cleanup cannot reverse an already committed rename, or replace the + // primary transfer error. A filesystem cleanup failure may leave staging. + await rm(stagingDirectory, { recursive: true, force: true }).catch(() => {}); + } } }); } diff --git a/src/generated/embedded-skills.ts b/src/generated/embedded-skills.ts index 5b0c9fa..f2f625c 100644 --- a/src/generated/embedded-skills.ts +++ b/src/generated/embedded-skills.ts @@ -2,7 +2,7 @@ export const EMBEDDED_SKILLS: Record<string, { filename: string; content: string }> = { "linearctl": { filename: "linearctl.md", - content: "---\nname: linearctl\ndescription: Agent-first CLI for the Linear API — curated commands, generated API, and raw GraphQL with stable JSON output contracts\n---\n\n# linearctl\n\nDefault skill for all Linear CLI usage. Use this skill for any request involving Linear data unless raw GraphQL is explicitly required or the curated/generated layers cannot cover the operation.\n\n## First-time setup\n\nIf the user has not configured the CLI yet, help them bootstrap:\n\n1. Create the config directory: `mkdir -p ~/.config/linear`\n2. Create a credentials file with their API key:\n ```bash\n export LINEAR_API_KEY=lin_api_...\n linearctl auth login --profile <name> --api-key-env LINEAR_API_KEY --set-default\n ```\n3. Set a default team: `linearctl team list --json` to find team keys, then `linearctl team get <key> --set-default`\n4. Verify: `linearctl auth whoami --json`\n\nAPI keys are created at https://linear.app/settings/api. For browser OAuth and unattended client-credentials OAuth, see https://linear.app/settings/api/applications.\n\n## Command routing\n\n1. Use curated commands when they cover the operation.\n2. Otherwise use generated `linearctl api` commands.\n3. Otherwise use `linearctl gql`.\n\nRaw GraphQL should not be used merely because it is possible. It is the fallback for gaps only.\n\n## Available curated commands\n\n### Issues\n- `linearctl issue get <identifier> --json` / `linearctl issue view <identifier> --json` — fetch a single issue by identifier (e.g. INF-2975) or UUID; JSON includes `dueDate`, `projectMilestone`, `trashed`, and `archivedAt`, so write/delete verification must check those fields instead of assuming exit 0 means live\n- `linearctl issue list [--search <text>|--query <text>] [--state <name>[,<name>...] ...] [--status <name>] [--assignee <name|displayName|email|\"me\"|id|none>] [--team <id|key|name>] [--label <name|id>] [--priority <0-4>] [--due-date <YYYY-MM-DD|none>] [--cycle <id>] [--project <name|id>] [--created-after <date>] [--updated-after <date>] [--completed-after <date>] [--order-by <field>] [--all-teams] [--all] [--max <n>|--limit <n>] [--json]` — list issues with filters; repeated or comma-separated `--state` values are unioned; `--assignee none`/`unassigned` finds unassigned work; `--due-date none` finds issues without a due date; `--status` aliases `--state`; `--search`/`--query` routes to full-text search and composes with the other filters; friendly names resolve case-insensitively\n- `linearctl issue search [<text>|--query <text>] [--team <id|key|name>] [--all] --json` — full-text search across issues; profile default teams are not applied, so pass `--team` to scope explicitly\n- `linearctl issue create --title <title> --team <id> [--description <text>|--description-file <path|->] [--priority <0-4>] [--estimate <n>] [--due-date <YYYY-MM-DD>] [--assignee <id>] [--label <id>] [--state <id>] [--cycle <id>] [--project <name|id>] [--project-milestone <id>|--milestone <id>] --json` — create an issue\n- `linearctl issue update <identifier> [--title <text>] [--description <text>|--description-file <path|->] [--priority <0-4>] [--estimate <n>] [--due-date <YYYY-MM-DD|none>] [--assignee <id|none>] [--label <name|id>] [--state <id>] [--cycle <id|none>] [--project <name|id|none>] [--project-milestone <id|none>|--milestone <id|none>] [--parent <identifier|none>] --json` — update an issue; `none` clears nullable fields and `--label` adds without replacing existing labels\n- `linearctl issue close <identifier> [--state <name>] --json` — close an issue (transitions to a terminal completed/canceled workflow state; defaults to \"Done\", use --state to pick another)\n- `linearctl issue delete <identifier> --json` — delete/trash an issue by identifier or UUID\n- `linearctl issue assign <identifier> <assignee-id> --json` — assign an issue\n- `linearctl issue attach-slack <identifier> --url <slack-url> [--sync] [--title <text>] --json` — link a Slack thread to an issue (--sync enables bidirectional comment sync)\n- `linearctl issue comment <identifier> (--body <text>|--body-file <path|->) --json` — add a comment to an issue\n\n### Issue relations\n- `linearctl relation list <issue> [--all] [--max <n>] --json` — list both outbound and inbound relations for an issue; each item includes `direction`, `issue`, and `relatedIssue`\n- `linearctl relation create --issue <issue> --related <issue> --type <blocks|duplicate|related|similar> --json` — create a relation after resolving both identifiers; for `duplicate`, `--issue` is the duplicate and `--related` is canonical\n- `linearctl relation delete <relation-id> --json` — delete a relation\n\n### Bulk operations\n- `linearctl issue bulk-update --ids <id1,id2,...> [--state <id>] [--assignee <id|none>] [--priority <0-4>] [--estimate <n>] [--due-date <YYYY-MM-DD|none>] [--label <id>] [--cycle <id|none>] [--project-milestone <id|none>|--milestone <id|none>] --json` — `--label` is additive\n- `linearctl issue bulk-close --ids <id1,id2,...> [--state <name|id>] --json` — transition issues to a completed/canceled workflow state, matching `issue close`\n- `linearctl issue bulk-archive --ids <id1,id2,...> --json` — archive multiple issues\n- `linearctl issue bulk-delete --ids <id1,id2,...> --yes|--confirm --json` — delete/trash multiple issues; `--confirm` is accepted as an alias for `--yes`\n- `linearctl issue bulk-assign --ids <id1,id2,...> --assignee <id> --json`\n\n### Projects\n- `linearctl project get <name|id> --json` — richer single-project detail payload than `project list` (includes progress/health/currentProgress, and milestones with id, name, description, targetDate, sortOrder, createdAt, updatedAt); project names resolve by exact match, unique prefix, or unique substring\n- `linearctl project list [--query <text>|--search <text>|--name <text>] [--team <id>] [--state <status-type> ...] [--all-teams] --json` — includes portfolio fields (`progress`, `health`, `description`, `updatedAt`, `currentProgress`), normalized `milestones` with `name`, `targetDate`, `progress`, and `status`, and milestone truncation metadata (`milestonesPageInfo`, `milestonesTruncated`) in JSON output; human output shows progress, health, description, updated time, and milestone summaries; `--state` values: backlog, planned, started, paused, completed, canceled; repeated `--state` values are unioned; text flags filter project names\n- `linearctl project create --name <name> [--description <text>|--description-file <path|->] [--content <text>|--content-file <path|->] [--team <id>] [--lead <user-id|email|\"me\">] [--status <id|name|type>|--state <name|type>] [--start-date <YYYY-MM-DD>] [--target-date <YYYY-MM-DD>] --json`\n- `linearctl project create-with-issues --name <name> --team <id> --issues-json <json> [--description <text>|--description-file <path|->] [--content <text>|--content-file <path|->] [--lead <user-id|email|\"me\">] [--status <id|name|type>|--state <name|type>] [--start-date <YYYY-MM-DD>] [--target-date <YYYY-MM-DD>] --json` — create a project then batch-create linked issues (reports partial success if issue creation fails after project was created)\n- `linearctl project update <id> [--name <text>] [--description <text>|--description-file <path|->] [--content <text>|--content-file <path|->] [--status <id|name|type>|--state <name|type>] [--lead <user-id|email|\"me\">] [--start-date <YYYY-MM-DD>] [--target-date <YYYY-MM-DD>] --json` — `--status`/`--state` accepts status names, state types, or status IDs; `--state` remains an alias\n- `linearctl project delete <id> --json`\n\n### Project statuses\n- `linearctl project-status list --json` — list workspace-level project statuses\n- `linearctl project-status get <id> --json`\n- `linearctl project-status create --name <name> --status-type <type> --color <hex> --json` (types: backlog, planned, started, paused, completed, canceled)\n- `linearctl project-status delete <id> --json` — archives the status\n\n### Cycles\n- `linearctl cycle get <id> --json` — includes progress/scope fields (`progress`, derived `scopeCount`, `completedScopeCount`, `inProgressScopeCount`, `startedScopeCount`, issue counts, history arrays, and uncompleted issues captured on close)\n- `linearctl cycle list [--team <id>] [--all-teams] --json`\n- `linearctl cycle current [--team <id>] --json` — get the currently active cycle for a team; includes progress/scope fields\n- `linearctl cycle create --team <id> [--name <text>] [--starts-at <date>] [--ends-at <date>] --json`\n- `linearctl cycle update <id> [--name <text>] [--starts-at <date>] [--ends-at <date>] --json`\n- `linearctl cycle archive <id> --json`\n- `linearctl cycle delete <id> --json` — Linear does not hard-delete cycles; this archives the cycle and reports `requestedAction: \"delete\"` / `performedAction: \"archive\"` in JSON and dry-run output\n\n### Teams\n- `linearctl team get <id-or-key> [--set-default] --json` — fetch team; --set-default saves as profile default\n- `linearctl team list --json`\n- `linearctl team members <id-or-key> [--all] --json` — list team members with `id`, `name`, `displayName`, `email`, and `active`\n\n### Users\n- `linearctl user get <id> --json`\n- `linearctl user me --json`\n- `linearctl user list --json`\n\n### Labels\n- `linearctl label get <id> --json`\n- `linearctl label list [--team <id>] [--all-teams] --json`\n- `linearctl label create --name <name> [--description <text>] [--color <hex>] [--team <id>] [--parent <name|id>|--group] --json` — create a group with `--group`, or a child label under a group with `--parent`\n- `linearctl label delete <id> --json`\n\n### Comments\n- `linearctl comment list <issue> --json` / `linearctl comment list --issue <id> --json` — reads the issue's comments connection; with human-readable issue identifiers, a missing parent issue returns exit 4 / `category: \"not-found\"` instead of an empty list\n- `linearctl comment create --issue <id> (--body <text>|--body-file <path|->) --json`\n- `linearctl comment update <id> (--body <text>|--body-file <path|->) --json`\n- `linearctl comment delete <id> --json`\n\n### Attachments\n- `linearctl attachment list --issue <id> --json`\n- `linearctl attachment create --issue <id> --url <url> --title <title> --json`\n- `linearctl attachment delete <id> --json`\n\n### Files\n- `linearctl file upload <path> [--issue <id>] [--transfer-timeout <seconds>] --json`\n- `linearctl file url <attachment-id> [--expires-in <seconds>] --json`\n- `linearctl file download <url> [--output <path>] [--transfer-timeout <seconds>] --json`\n- Upload/download stream with backpressure. Upload sources must be regular files; do not modify them during transfer. `--transfer-timeout` is a total PUT/GET deadline in whole seconds (default 120, range 1–2147483), including redirects, response bodies, and download writes; GraphQL requests keep their separate timeout/retry policy. Transfers are not automatically retried. Ctrl-C/SIGINT or SIGTERM cancels an active transfer; timeout/cancellation returns exit 1.\n- Downloads stage beside the destination and atomically overwrite it only after success. Transfer/write/rename failures clean up staging and preserve existing contents. The parent must exist and be writable. Destination symlinks are replaced rather than followed; new files have private permissions (0600 on POSIX), not the old metadata. SIGKILL/crashes can leave staging directories; this is not a crash-durability guarantee.\n- Requests and redirects require HTTPS, with at most five redirects; downloads must start at `uploads.linear.app`. Same-host redirects keep signed upload headers or Linear authorization; cross-host redirects drop sensitive headers permanently. Redirected PUTs replay the file from byte zero.\n\n### Workflow states\n- `linearctl state list [--team <id>] [--all-teams] --json` — list issue workflow states for a team\n- `linearctl state get <id> --json`\n- `linearctl state create --name <name> --team <id> --state-type <type> --json` (types: backlog, unstarted, started, completed, canceled)\n- `linearctl state archive <id|name> [--team <id>] --json`\n- `linearctl state delete <id|name> [--team <id>] --json`\n\n### Skills\n- `linearctl skills install [--json]` — auto-detect agents and install skill files\n- `linearctl skills list --json` — list embedded skills plus install status, paths, and `upToDate` state for Claude Code and Codex at user and project scope; uninspectable targets report a per-target `error` without hiding other results\n\n### Schema\n- `linearctl schema version --json`\n- `linearctl schema pull --json`\n- `linearctl schema check --json`\n- Normal commands run best-effort schema freshness checks after command output, skip help/dry-run paths, cache successful or failed attempts for 24 hours, and warn on stderr when the effective schema metadata is stale. `schema pull` writes `schema.json` and `schema-meta.json`; commands prefer pulled metadata from the profile config directory when present. Configure `[schema] stale_after_days` and `auto_update = true` in the linear config to opt into automatic schema pulls (`schema.autoUpdate`).\n\n### Auth\n- `linearctl auth status --json`\n- `linearctl auth login --profile <name> --api-key-env <ENV>`\n- `linearctl auth login --profile <name> --oauth --oauth-client-id <id>` — browser-based PKCE login\n- `linearctl auth login --profile <name> --oauth-client-credentials --oauth-client-id <id> (--oauth-client-secret-env <ENV>|--oauth-client-secret-stdin)` — non-interactive login; never pass the client secret as an argument and it is not persisted\n- `linearctl auth logout --profile <name>`\n- `linearctl auth switch <profile>`\n- `linearctl auth whoami --json`\n- OAuth refresh, login, and logout serialize credentials-file updates across CLI processes; token errors omit raw response bodies.\n\n### Workspace\n- `linearctl workspace list --json`\n\n## Generated commands\n\nWhen no curated command exists, use `linearctl api <resource> <operation>`:\n- `linearctl --help` — grouped overview of curated resources\n- `linearctl <resource> --help` — full usage lines for one curated resource\n- `linearctl api search <term>` — discover available generated commands\n- `linearctl api <resource> --help` — list operations for a resource\n- `linearctl api <resource> <operation> --help` — show generated operation usage and input flags\n- `linearctl api <resource> <operation> --id <id> --json` — execute a generated command\n- `linearctl api <resource> <operation> --input-json '<json>' --json` — execute with JSON input\n\n## Output modes\n\n- Use `--json` when parsing output programmatically\n- Use `--json-envelope` only when metadata (pagination, rate limits, complexity) is needed\n- Parse-level validation errors also emit failure envelopes when `--json-envelope` is set\n- Use `--jsonl --all` for streaming all list results, or `--jsonl --max <n>` to stream a bounded set; `--jsonl` no longer implies `--all`\n- Do not parse human-readable default output\n- Bulk operations fail non-zero when any item fails. With `--json-envelope`, partial failures return `ok: false`, populate `errors[]`, include per-item `data.succeeded`/`data.failed`, and set `meta.partial: true` when some items succeeded. Per-item failures preserve mapped categories, and the command exit code is selected by category priority: auth, rate-limit, not-found, then general.\n- GraphQL retry is default-on for rate limits (`--max-retries` defaults to 3); pass `--no-retry` to disable it.\n\n## Default team\n\nEach profile can have a default team. When set, list commands (issue, project, cycle, label) automatically filter to that team. `issue search` is the exception: it searches the whole workspace unless `--team` is passed.\n\n- Set it: `linearctl team get <key> --set-default`\n- Override per-command: `--team <other>`\n- Bypass and see all teams: `--all-teams`\n- `--team` and `--all-teams` cannot be used together\n\n## Name resolution\n\nCurated commands resolve friendly names to IDs automatically:\n- `--team \"Infrastructure\"` or `--team INF` resolves to the team's UUID\n- `--assignee \"me\"` resolves to the current user's ID\n- `--assignee \"aborges\"` resolves by Linear displayName\n- `--assignee \"quentin@example.com\"` resolves by email\n- `--assignee none` / `unassigned` filters for no assignee on `issue list` and clears the assignee on `issue update`\n- `--state \"In Progress\"` resolves to the workflow state ID (team-scoped)\n- `--label \"bug\"` resolves to the label ID (team-scoped when possible)\n- `--project \"Terraform Tech Debt\"` resolves by exact project name, unique prefix, or unique substring\n\nIf a value looks like a UUID, it's passed through directly. On ambiguous matches, the CLI errors with candidates. Case-insensitive resolution prefers exact case-sensitive matches first; users prefer email matches, and labels under `--team` prefer team-scoped labels over same-named workspace labels.\n\n## Dry run\n\nUse `--dry-run` on any mutating command to preview what would happen without executing:\n- `linearctl issue create --title \"test\" --team INF --dry-run --json`\n- `linearctl issue bulk-close --ids \"id1,id2\" --dry-run --json`\n- Works on create, update, close, assign, comment, delete, and upload operations\n- Dry runs validate and resolve friendly names before emitting the preview payload\n\n## Pagination\n\n- Default list behavior returns the first page only (up to 50 items)\n- **When results are truncated, a warning is emitted to stderr** — check stderr to know if you have incomplete data\n- Use `--all` to fetch all results (with `--max` or `--limit` to limit)\n- Use `--max <n>` or `--limit <n>` to cap total results\n- Use `--quiet` / `-q` to suppress the truncation warning (useful when piping JSON)\n- Add filters before broad pagination whenever possible\n- Prefer `--jsonl` for large result sets — it streams one object per line; pass `--all` or `--max <n>`\n\n## Profile selection\n\n1. If an explicit profile is specified, use `--profile <name>`\n2. Otherwise rely on `LINEAR_PROFILE` env var\n3. Otherwise run `linearctl auth status` to check the default profile\n4. Do not silently choose among multiple profiles\n\n## Error handling\n\n| Exit code | Meaning | Action |\n|---|---|---|\n| 0 | Success | |\n| 1 | General error | Read stderr for details |\n| 2 | Auth error | Run `linearctl auth status`, re-authenticate if needed |\n| 3 | Rate limit | Wait, reduce result count, add filters |\n| 4 | Not found | Verify identifier/ID |\n| 5 | Validation error | Check flags and input |\n| 6 | Schema drift | Fall back to `linearctl gql`, update CLI |\n\nMissing referenced Linear entities, including issues reported by inline GraphQL errors such as `Could not find referenced Issue`, map to exit 4 / `category: \"not-found\"` consistently across curated get, update, delete, comment-list parent lookup, and bulk paths.\n\n## Anti-patterns\n\n- Do not use `linearctl gql` when curated or generated commands cover the task\n- Do not use `--all` without `--max` unless explicitly asked for everything\n- Do not parse human-mode output programmatically\n- Do not guess profile names\n- Do not pass secrets as CLI arguments\n- Do not retry immediately after rate-limit exhaustion\n- Do not run destructive operations without explicit user confirmation\n" + content: "---\nname: linearctl\ndescription: Agent-first CLI for the Linear API — curated commands, generated API, and raw GraphQL with stable JSON output contracts\n---\n\n# linearctl\n\nDefault skill for all Linear CLI usage. Use this skill for any request involving Linear data unless raw GraphQL is explicitly required or the curated/generated layers cannot cover the operation.\n\n## First-time setup\n\nIf the user has not configured the CLI yet, help them bootstrap:\n\n1. Create the config directory: `mkdir -p ~/.config/linear`\n2. Create a credentials file with their API key:\n ```bash\n export LINEAR_API_KEY=lin_api_...\n linearctl auth login --profile <name> --api-key-env LINEAR_API_KEY --set-default\n ```\n3. Set a default team: `linearctl team list --json` to find team keys, then `linearctl team get <key> --set-default`\n4. Verify: `linearctl auth whoami --json`\n\nAPI keys are created at https://linear.app/settings/api. For browser OAuth and unattended client-credentials OAuth, see https://linear.app/settings/api/applications.\n\n## Command routing\n\n1. Use curated commands when they cover the operation.\n2. Otherwise use generated `linearctl api` commands.\n3. Otherwise use `linearctl gql`.\n\nRaw GraphQL should not be used merely because it is possible. It is the fallback for gaps only.\n\n## Available curated commands\n\n### Issues\n- `linearctl issue get <identifier> --json` / `linearctl issue view <identifier> --json` — fetch a single issue by identifier (e.g. INF-2975) or UUID; JSON includes `dueDate`, `projectMilestone`, `trashed`, and `archivedAt`, so write/delete verification must check those fields instead of assuming exit 0 means live\n- `linearctl issue list [--search <text>|--query <text>] [--state <name>[,<name>...] ...] [--status <name>] [--assignee <name|displayName|email|\"me\"|id|none>] [--team <id|key|name>] [--label <name|id>] [--priority <0-4>] [--due-date <YYYY-MM-DD|none>] [--cycle <id>] [--project <name|id>] [--created-after <date>] [--updated-after <date>] [--completed-after <date>] [--order-by <field>] [--all-teams] [--all] [--max <n>|--limit <n>] [--json]` — list issues with filters; repeated or comma-separated `--state` values are unioned; `--assignee none`/`unassigned` finds unassigned work; `--due-date none` finds issues without a due date; `--status` aliases `--state`; `--search`/`--query` routes to full-text search and composes with the other filters; friendly names resolve case-insensitively\n- `linearctl issue search [<text>|--query <text>] [--team <id|key|name>] [--all] --json` — full-text search across issues; profile default teams are not applied, so pass `--team` to scope explicitly\n- `linearctl issue create --title <title> --team <id> [--description <text>|--description-file <path|->] [--priority <0-4>] [--estimate <n>] [--due-date <YYYY-MM-DD>] [--assignee <id>] [--label <id>] [--state <id>] [--cycle <id>] [--project <name|id>] [--project-milestone <id>|--milestone <id>] --json` — create an issue\n- `linearctl issue update <identifier> [--title <text>] [--description <text>|--description-file <path|->] [--priority <0-4>] [--estimate <n>] [--due-date <YYYY-MM-DD|none>] [--assignee <id|none>] [--label <name|id>] [--state <id>] [--cycle <id|none>] [--project <name|id|none>] [--project-milestone <id|none>|--milestone <id|none>] [--parent <identifier|none>] --json` — update an issue; `none` clears nullable fields and `--label` adds without replacing existing labels\n- `linearctl issue close <identifier> [--state <name>] --json` — close an issue (transitions to a terminal completed/canceled workflow state; defaults to \"Done\", use --state to pick another)\n- `linearctl issue delete <identifier> --json` — delete/trash an issue by identifier or UUID\n- `linearctl issue assign <identifier> <assignee-id> --json` — assign an issue\n- `linearctl issue attach-slack <identifier> --url <slack-url> [--sync] [--title <text>] --json` — link a Slack thread to an issue (--sync enables bidirectional comment sync)\n- `linearctl issue comment <identifier> (--body <text>|--body-file <path|->) --json` — add a comment to an issue\n\n### Issue relations\n- `linearctl relation list <issue> [--all] [--max <n>] --json` — list both outbound and inbound relations for an issue; each item includes `direction`, `issue`, and `relatedIssue`\n- `linearctl relation create --issue <issue> --related <issue> --type <blocks|duplicate|related|similar> --json` — create a relation after resolving both identifiers; for `duplicate`, `--issue` is the duplicate and `--related` is canonical\n- `linearctl relation delete <relation-id> --json` — delete a relation\n\n### Bulk operations\n- `linearctl issue bulk-update --ids <id1,id2,...> [--state <id>] [--assignee <id|none>] [--priority <0-4>] [--estimate <n>] [--due-date <YYYY-MM-DD|none>] [--label <id>] [--cycle <id|none>] [--project-milestone <id|none>|--milestone <id|none>] --json` — `--label` is additive\n- `linearctl issue bulk-close --ids <id1,id2,...> [--state <name|id>] --json` — transition issues to a completed/canceled workflow state, matching `issue close`\n- `linearctl issue bulk-archive --ids <id1,id2,...> --json` — archive multiple issues\n- `linearctl issue bulk-delete --ids <id1,id2,...> --yes|--confirm --json` — delete/trash multiple issues; `--confirm` is accepted as an alias for `--yes`\n- `linearctl issue bulk-assign --ids <id1,id2,...> --assignee <id> --json`\n\n### Projects\n- `linearctl project get <name|id> --json` — richer single-project detail payload than `project list` (includes progress/health/currentProgress, and milestones with id, name, description, targetDate, sortOrder, createdAt, updatedAt); project names resolve by exact match, unique prefix, or unique substring\n- `linearctl project list [--query <text>|--search <text>|--name <text>] [--team <id>] [--state <status-type> ...] [--all-teams] --json` — includes portfolio fields (`progress`, `health`, `description`, `updatedAt`, `currentProgress`), normalized `milestones` with `name`, `targetDate`, `progress`, and `status`, and milestone truncation metadata (`milestonesPageInfo`, `milestonesTruncated`) in JSON output; human output shows progress, health, description, updated time, and milestone summaries; `--state` values: backlog, planned, started, paused, completed, canceled; repeated `--state` values are unioned; text flags filter project names\n- `linearctl project create --name <name> [--description <text>|--description-file <path|->] [--content <text>|--content-file <path|->] [--team <id>] [--lead <user-id|email|\"me\">] [--status <id|name|type>|--state <name|type>] [--start-date <YYYY-MM-DD>] [--target-date <YYYY-MM-DD>] --json`\n- `linearctl project create-with-issues --name <name> --team <id> --issues-json <json> [--description <text>|--description-file <path|->] [--content <text>|--content-file <path|->] [--lead <user-id|email|\"me\">] [--status <id|name|type>|--state <name|type>] [--start-date <YYYY-MM-DD>] [--target-date <YYYY-MM-DD>] --json` — create a project then batch-create linked issues (reports partial success if issue creation fails after project was created)\n- `linearctl project update <id> [--name <text>] [--description <text>|--description-file <path|->] [--content <text>|--content-file <path|->] [--status <id|name|type>|--state <name|type>] [--lead <user-id|email|\"me\">] [--start-date <YYYY-MM-DD>] [--target-date <YYYY-MM-DD>] --json` — `--status`/`--state` accepts status names, state types, or status IDs; `--state` remains an alias\n- `linearctl project delete <id> --json`\n\n### Project statuses\n- `linearctl project-status list --json` — list workspace-level project statuses\n- `linearctl project-status get <id> --json`\n- `linearctl project-status create --name <name> --status-type <type> --color <hex> --json` (types: backlog, planned, started, paused, completed, canceled)\n- `linearctl project-status delete <id> --json` — archives the status\n\n### Cycles\n- `linearctl cycle get <id> --json` — includes progress/scope fields (`progress`, derived `scopeCount`, `completedScopeCount`, `inProgressScopeCount`, `startedScopeCount`, issue counts, history arrays, and uncompleted issues captured on close)\n- `linearctl cycle list [--team <id>] [--all-teams] --json`\n- `linearctl cycle current [--team <id>] --json` — get the currently active cycle for a team; includes progress/scope fields\n- `linearctl cycle create --team <id> [--name <text>] [--starts-at <date>] [--ends-at <date>] --json`\n- `linearctl cycle update <id> [--name <text>] [--starts-at <date>] [--ends-at <date>] --json`\n- `linearctl cycle archive <id> --json`\n- `linearctl cycle delete <id> --json` — Linear does not hard-delete cycles; this archives the cycle and reports `requestedAction: \"delete\"` / `performedAction: \"archive\"` in JSON and dry-run output\n\n### Teams\n- `linearctl team get <id-or-key> [--set-default] --json` — fetch team; --set-default saves as profile default\n- `linearctl team list --json`\n- `linearctl team members <id-or-key> [--all] --json` — list team members with `id`, `name`, `displayName`, `email`, and `active`\n\n### Users\n- `linearctl user get <id> --json`\n- `linearctl user me --json`\n- `linearctl user list --json`\n\n### Labels\n- `linearctl label get <id> --json`\n- `linearctl label list [--team <id>] [--all-teams] --json`\n- `linearctl label create --name <name> [--description <text>] [--color <hex>] [--team <id>] [--parent <name|id>|--group] --json` — create a group with `--group`, or a child label under a group with `--parent`\n- `linearctl label delete <id> --json`\n\n### Comments\n- `linearctl comment list <issue> --json` / `linearctl comment list --issue <id> --json` — reads the issue's comments connection; with human-readable issue identifiers, a missing parent issue returns exit 4 / `category: \"not-found\"` instead of an empty list\n- `linearctl comment create --issue <id> (--body <text>|--body-file <path|->) --json`\n- `linearctl comment update <id> (--body <text>|--body-file <path|->) --json`\n- `linearctl comment delete <id> --json`\n\n### Attachments\n- `linearctl attachment list --issue <id> --json`\n- `linearctl attachment create --issue <id> --url <url> --title <title> --json`\n- `linearctl attachment delete <id> --json`\n\n### Files\n- `linearctl file upload <path> [--issue <id>] [--transfer-timeout <seconds>] --json`\n- `linearctl file url <attachment-id> [--expires-in <seconds>] --json`\n- `linearctl file download <url> [--output <path>] [--transfer-timeout <seconds>] --json`\n- Upload/download stream with backpressure. Upload sources must be regular files; do not modify them during transfer. `--transfer-timeout` is a total PUT/GET deadline in whole seconds (default 120, range 1–2147483), including redirects, response bodies, and download writes; GraphQL requests keep their separate timeout/retry policy. Transfers are not automatically retried. Ctrl-C/SIGINT or SIGTERM cancels an active transfer; timeout/cancellation returns exit 1.\n- Downloads stage beside the destination and atomically overwrite it only after success. Transfer/write/rename failures preserve existing contents and attempt staging cleanup; cleanup failures do not change the primary transfer outcome. The parent must exist and be writable. Destination symlinks are replaced rather than followed; new files have private permissions (0600 on POSIX), not the old metadata. Cleanup failures or SIGKILL/crashes can leave staging directories; this is not a crash-durability guarantee.\n- Requests and redirects require HTTPS, with at most five redirects; downloads must start at `uploads.linear.app`. Same-host redirects keep signed upload headers or Linear authorization; cross-host redirects drop sensitive headers permanently. Redirected PUTs replay the file from byte zero.\n\n### Workflow states\n- `linearctl state list [--team <id>] [--all-teams] --json` — list issue workflow states for a team\n- `linearctl state get <id> --json`\n- `linearctl state create --name <name> --team <id> --state-type <type> --json` (types: backlog, unstarted, started, completed, canceled)\n- `linearctl state archive <id|name> [--team <id>] --json`\n- `linearctl state delete <id|name> [--team <id>] --json`\n\n### Skills\n- `linearctl skills install [--json]` — auto-detect agents and install skill files\n- `linearctl skills list --json` — list embedded skills plus install status, paths, and `upToDate` state for Claude Code and Codex at user and project scope; uninspectable targets report a per-target `error` without hiding other results\n\n### Schema\n- `linearctl schema version --json`\n- `linearctl schema pull --json`\n- `linearctl schema check --json`\n- Normal commands run best-effort schema freshness checks after command output, skip help/dry-run paths, cache successful or failed attempts for 24 hours, and warn on stderr when the effective schema metadata is stale. `schema pull` writes `schema.json` and `schema-meta.json`; commands prefer pulled metadata from the profile config directory when present. Configure `[schema] stale_after_days` and `auto_update = true` in the linear config to opt into automatic schema pulls (`schema.autoUpdate`).\n\n### Auth\n- `linearctl auth status --json`\n- `linearctl auth login --profile <name> --api-key-env <ENV>`\n- `linearctl auth login --profile <name> --oauth --oauth-client-id <id>` — browser-based PKCE login\n- `linearctl auth login --profile <name> --oauth-client-credentials --oauth-client-id <id> (--oauth-client-secret-env <ENV>|--oauth-client-secret-stdin)` — non-interactive login; never pass the client secret as an argument and it is not persisted\n- `linearctl auth logout --profile <name>`\n- `linearctl auth switch <profile>`\n- `linearctl auth whoami --json`\n- OAuth refresh, login, and logout serialize credentials-file updates across CLI processes; token errors omit raw response bodies.\n\n### Workspace\n- `linearctl workspace list --json`\n\n## Generated commands\n\nWhen no curated command exists, use `linearctl api <resource> <operation>`:\n- `linearctl --help` — grouped overview of curated resources\n- `linearctl <resource> --help` — full usage lines for one curated resource\n- `linearctl api search <term>` — discover available generated commands\n- `linearctl api <resource> --help` — list operations for a resource\n- `linearctl api <resource> <operation> --help` — show generated operation usage and input flags\n- `linearctl api <resource> <operation> --id <id> --json` — execute a generated command\n- `linearctl api <resource> <operation> --input-json '<json>' --json` — execute with JSON input\n\n## Output modes\n\n- Use `--json` when parsing output programmatically\n- Use `--json-envelope` only when metadata (pagination, rate limits, complexity) is needed\n- Parse-level validation errors also emit failure envelopes when `--json-envelope` is set\n- Use `--jsonl --all` for streaming all list results, or `--jsonl --max <n>` to stream a bounded set; `--jsonl` no longer implies `--all`\n- Do not parse human-readable default output\n- Bulk operations fail non-zero when any item fails. With `--json-envelope`, partial failures return `ok: false`, populate `errors[]`, include per-item `data.succeeded`/`data.failed`, and set `meta.partial: true` when some items succeeded. Per-item failures preserve mapped categories, and the command exit code is selected by category priority: auth, rate-limit, not-found, then general.\n- GraphQL retry is default-on for rate limits (`--max-retries` defaults to 3); pass `--no-retry` to disable it.\n\n## Default team\n\nEach profile can have a default team. When set, list commands (issue, project, cycle, label) automatically filter to that team. `issue search` is the exception: it searches the whole workspace unless `--team` is passed.\n\n- Set it: `linearctl team get <key> --set-default`\n- Override per-command: `--team <other>`\n- Bypass and see all teams: `--all-teams`\n- `--team` and `--all-teams` cannot be used together\n\n## Name resolution\n\nCurated commands resolve friendly names to IDs automatically:\n- `--team \"Infrastructure\"` or `--team INF` resolves to the team's UUID\n- `--assignee \"me\"` resolves to the current user's ID\n- `--assignee \"aborges\"` resolves by Linear displayName\n- `--assignee \"quentin@example.com\"` resolves by email\n- `--assignee none` / `unassigned` filters for no assignee on `issue list` and clears the assignee on `issue update`\n- `--state \"In Progress\"` resolves to the workflow state ID (team-scoped)\n- `--label \"bug\"` resolves to the label ID (team-scoped when possible)\n- `--project \"Terraform Tech Debt\"` resolves by exact project name, unique prefix, or unique substring\n\nIf a value looks like a UUID, it's passed through directly. On ambiguous matches, the CLI errors with candidates. Case-insensitive resolution prefers exact case-sensitive matches first; users prefer email matches, and labels under `--team` prefer team-scoped labels over same-named workspace labels.\n\n## Dry run\n\nUse `--dry-run` on any mutating command to preview what would happen without executing:\n- `linearctl issue create --title \"test\" --team INF --dry-run --json`\n- `linearctl issue bulk-close --ids \"id1,id2\" --dry-run --json`\n- Works on create, update, close, assign, comment, delete, and upload operations\n- Dry runs validate and resolve friendly names before emitting the preview payload\n\n## Pagination\n\n- Default list behavior returns the first page only (up to 50 items)\n- **When results are truncated, a warning is emitted to stderr** — check stderr to know if you have incomplete data\n- Use `--all` to fetch all results (with `--max` or `--limit` to limit)\n- Use `--max <n>` or `--limit <n>` to cap total results\n- Use `--quiet` / `-q` to suppress the truncation warning (useful when piping JSON)\n- Add filters before broad pagination whenever possible\n- Prefer `--jsonl` for large result sets — it streams one object per line; pass `--all` or `--max <n>`\n\n## Profile selection\n\n1. If an explicit profile is specified, use `--profile <name>`\n2. Otherwise rely on `LINEAR_PROFILE` env var\n3. Otherwise run `linearctl auth status` to check the default profile\n4. Do not silently choose among multiple profiles\n\n## Error handling\n\n| Exit code | Meaning | Action |\n|---|---|---|\n| 0 | Success | |\n| 1 | General error | Read stderr for details |\n| 2 | Auth error | Run `linearctl auth status`, re-authenticate if needed |\n| 3 | Rate limit | Wait, reduce result count, add filters |\n| 4 | Not found | Verify identifier/ID |\n| 5 | Validation error | Check flags and input |\n| 6 | Schema drift | Fall back to `linearctl gql`, update CLI |\n\nMissing referenced Linear entities, including issues reported by inline GraphQL errors such as `Could not find referenced Issue`, map to exit 4 / `category: \"not-found\"` consistently across curated get, update, delete, comment-list parent lookup, and bulk paths.\n\n## Anti-patterns\n\n- Do not use `linearctl gql` when curated or generated commands cover the task\n- Do not use `--all` without `--max` unless explicitly asked for everything\n- Do not parse human-mode output programmatically\n- Do not guess profile names\n- Do not pass secrets as CLI arguments\n- Do not retry immediately after rate-limit exhaustion\n- Do not run destructive operations without explicit user confirmation\n" }, "linearctl-raw-gql": { filename: "linearctl-raw-gql.md", diff --git a/tests/commands/file.test.ts b/tests/commands/file.test.ts index eedced7..d4aeb01 100644 --- a/tests/commands/file.test.ts +++ b/tests/commands/file.test.ts @@ -2,6 +2,7 @@ import { mkdtemp, readFile, readdir } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { writeFile } from "node:fs/promises"; +import type { Readable } from "node:stream"; import { describe, expect, it, vi } from "vitest"; import { handleFileCommand } from "../../src/commands/file.js"; import { writeCredentialsFile } from "../../src/core/auth/credentials.js"; @@ -71,7 +72,7 @@ describe("handleFileCommand — file upload", () => { await writeFile(testFile, Buffer.from("fake-png-bytes")); let callIndex = 0; - const fetchImpl = vi.fn(async (url: string | URL | Request) => { + const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url; callIndex++; @@ -95,6 +96,7 @@ describe("handleFileCommand — file upload", () => { } expect(urlStr).toBe("https://storage.example.com/put-here"); + for await (const _chunk of init?.body as unknown as Readable) { /* consume PUT */ } return new Response("", { status: 200 }); }) as FetchLike; @@ -161,6 +163,7 @@ describe("handleFileCommand — file upload", () => { const redirectedHeaders = init?.headers as Record<string, string>; expect(redirectedHeaders["content-type"]).toBe("image/png"); expect(redirectedHeaders["x-amz-acl"]).toBeUndefined(); + for await (const _chunk of init?.body as unknown as Readable) { /* consume PUT */ } return new Response("", { status: 200 }); @@ -238,7 +241,7 @@ describe("handleFileCommand — file upload", () => { await writeFile(testFile, Buffer.from("fake-pdf")); let callIndex = 0; - const fetchImpl = vi.fn(async () => { + const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { callIndex++; if (callIndex === 1) { @@ -260,7 +263,7 @@ describe("handleFileCommand — file upload", () => { } if (callIndex === 2) { - // PUT response + for await (const _chunk of init?.body as unknown as Readable) { /* consume PUT */ } return new Response("", { status: 200 }); } diff --git a/tests/core/io/file-transfer.test.ts b/tests/core/io/file-transfer.test.ts index 008fd53..e884a42 100644 --- a/tests/core/io/file-transfer.test.ts +++ b/tests/core/io/file-transfer.test.ts @@ -16,6 +16,11 @@ vi.mock("node:fs", async (importOriginal) => { return { ...actual, createWriteStream: vi.fn(actual.createWriteStream) }; }); +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal<typeof import("node:fs/promises")>(); + return { ...actual, rm: vi.fn(actual.rm) }; +}); + const url = "https://uploads.linear.app/file"; const headers = { authorization: "secret", "x-signed": "signed", "content-type": "application/octet-stream" }; let directory: string; @@ -23,12 +28,14 @@ let destination: string; let listeners: number[]; beforeEach(async () => { + vi.mocked(rm).mockClear(); directory = await mkdtemp(join(tmpdir(), "linearctl-transfer-")); destination = join(directory, "destination"); listeners = [process.listenerCount("SIGINT"), process.listenerCount("SIGTERM")]; }); afterEach(async () => { vi.mocked(createWriteStream).mockReset(); + vi.mocked(rm).mockReset(); await rm(directory, { recursive: true, force: true }); expect([process.listenerCount("SIGINT"), process.listenerCount("SIGTERM")]).toEqual(listeners); }); @@ -146,6 +153,22 @@ describe("streaming downloads", () => { await expectPreserved(); }); + it.each(["success", "failure"])("keeps the primary %s outcome when staging cleanup fails", async (mode) => { + await writeFile(destination, "original"); + const primaryError = new Error("connection lost"); + vi.mocked(rm).mockRejectedValueOnce(new Error("cleanup denied")); + const response = mode === "success" ? new Response("new") : new Response(new ReadableStream({ + start(stream) { stream.enqueue(Buffer.from("partial")); }, + pull(stream) { stream.error(primaryError); } + })); + const transfer = downloadFile(async () => response, url, headers, destination, {}); + if (mode === "success") await expect(transfer).resolves.toBe(3); + else await expect(transfer).rejects.toBe(primaryError); + expect(await readFile(destination, "utf8")).toBe(mode === "success" ? "new" : "original"); + expect(rm).toHaveBeenCalledOnce(); + expect((await readdir(directory)).some((entry) => entry.startsWith(".linearctl-download-"))).toBe(true); + }); + it("cleans up when the destination cannot be replaced", async () => { await mkdir(destination); await writeFile(join(destination, "keep"), "original"); @@ -250,6 +273,51 @@ describe("transfer redirects and deadlines", () => { }); describe("streaming uploads", () => { + it("waits for complete request consumption after early 2xx headers", async () => { + const file = await open(join(directory, "upload"), "w+"); + await file.truncate(256 * 1024); + let body: Readable | undefined; + let settled = false; + try { + const transfer = uploadFile(async (_url, init) => { + body = init?.body as unknown as Readable; + return new Response(); + }, url, headers, file, 256 * 1024, {}); + void transfer.then(() => { settled = true; }, () => { settled = true; }); + await delay(10); + expect(settled).toBe(false); + expect(body?.destroyed).toBe(false); + let size = 0; + for await (const chunk of body!) size += chunk.length; + await transfer; + expect(size).toBe(256 * 1024); + } finally { + await file.close(); + } + }); + + it.each(["short", "read-error", "stalled"])("does not accept early 2xx headers with a %s source", async (mode) => { + const file = await open(join(directory, "upload"), "w+"); + await file.write(Buffer.from("short")); + if (mode === "read-error") vi.spyOn(file, "read").mockRejectedValueOnce(new Error("file read failed")); + const cancel = vi.fn(); + let body: Readable | undefined; + try { + await expect(uploadFile(async (_url, init) => { + body = init?.body as unknown as Readable; + if (mode !== "stalled") body.resume(); + // Deliberately resolve fetch without awaiting its request body. + return new Response(new ReadableStream({ cancel })); + }, url, headers, file, 100, { timeoutMs: 50 })).rejects.toThrow( + mode === "short" ? "became shorter" : mode === "read-error" ? "file read failed" : "timed out" + ); + expect(body?.destroyed).toBe(true); + expect(cancel).toHaveBeenCalledOnce(); + } finally { + await file.close(); + } + }); + it("rejects a source that shrinks instead of uploading a truncated file", async () => { const file = await open(join(directory, "upload"), "w+"); await file.write(Buffer.from("short"));