Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,18 +220,24 @@ linearctl attachment delete <id> --json # [destructive]

```bash
# Upload a file (optionally attach to an issue)
linearctl file upload <path> [--issue <id>] --json
linearctl file upload <path> [--issue <id>] [--transfer-timeout <seconds>] --json

# Get a signed URL for an attachment
linearctl file url <attachment-id> [--expires-in <seconds>] --json

# Download a file
linearctl file download <url> [--output <path>] --json
linearctl file download <url> [--output <path>] [--transfer-timeout <seconds>] --json
```

If `file upload --issue` uploads successfully but attachment creation fails, its failure output retains `assetUrl`, `fileName`, `contentType`, and `size`, including on thrown transport failures. Reuse that asset with `attachment create` instead of uploading again. See [workflow failures and recovery](output-modes.md#composite-workflow-failures).

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 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.

## Auth

Expand Down
8 changes: 5 additions & 3 deletions skills/linearctl/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,12 @@ Raw GraphQL should not be used merely because it is possible. It is the fallback
- `linearctl attachment delete <id> --json`

### Files
- `linearctl file upload <path> [--issue <id>] --json`
- `linearctl file upload <path> [--issue <id>] [--transfer-timeout <seconds>] --json`
- `linearctl file url <attachment-id> [--expires-in <seconds>] --json`
- `linearctl file download <url> [--output <path>] --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 <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 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
- `linearctl state list [--team <id>] [--all-teams] --json` — list issue workflow states for a team
Expand Down
1 change: 1 addition & 0 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ function toParsedCliArguments(values: Record<string, unknown>, 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"] } : {}),
Expand Down
174 changes: 42 additions & 132 deletions src/commands/file.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -7,7 +10,6 @@ import type { FetchLike } from "../core/transport/graphql.js";
import { emitDryRunResult } from "../core/output/dry-run.js";
import { CommandContext } from "../core/runtime/command-context.js";
import { runTwoStepWorkflow, WorkflowStepError } from "../core/runtime/workflow.js";
import { GraphQLTransportError } from "../core/transport/graphql.js";

export interface FileCommandOptions {
json: boolean;
Expand All @@ -22,6 +24,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;
Expand Down Expand Up @@ -61,97 +66,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<Response> {
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<string, string> | undefined {
if (headers === undefined) {
return undefined;
}

const entries = new Headers(headers).entries();
const safe: Record<string, string> = {};
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) {
Expand Down Expand Up @@ -243,7 +157,8 @@ function buildContext(options: FileCommandOptions): CommandContext {

async function handleFileUpload(
filePath: string,
options: FileCommandOptions
options: FileCommandOptions,
transferOptions: TransferOptions
): Promise<number> {
const resolvedPath = resolve(filePath);
const fileName = basename(resolvedPath);
Expand All @@ -257,15 +172,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 {
Expand Down Expand Up @@ -306,18 +224,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) {
throw new GraphQLTransportError(
`File PUT failed with HTTP ${putResponse.status}`,
"http", putResponse.status, undefined, { status: putResponse.status }
);
}
await streamUploadFile(fetchImpl, uploadUrl, putHeaders, file, size, transferOptions);

return { assetUrl, contentType, fileName, size };
},
Expand Down Expand Up @@ -377,6 +284,8 @@ async function handleFileUpload(
return ctx.emitFailure(error.errors, error.exitCode);
}
return ctx.emitCaughtError(error);
} finally {
await file.close();
}
}

Expand Down Expand Up @@ -436,7 +345,8 @@ async function handleFileUrl(

async function handleFileDownload(
downloadUrl: string,
options: FileCommandOptions
options: FileCommandOptions,
transferOptions: TransferOptions
): Promise<number> {
try {
const parsed = new URL(downloadUrl);
Expand All @@ -456,33 +366,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);
Expand All @@ -494,6 +391,19 @@ export async function handleFileCommand(
options: FileCommandOptions
): Promise<number> {
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];
Expand All @@ -503,7 +413,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") {
Expand All @@ -525,7 +435,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);
Expand Down
Loading
Loading