linear-sdk-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/.claude-plugin/marketplace.json +20 -0
  2. package/.claude-plugin/plugin.json +9 -0
  3. package/CHANGELOG.md +807 -0
  4. package/COVERAGE.md +495 -0
  5. package/LICENSE +21 -0
  6. package/MIGRATING.md +220 -0
  7. package/README.md +524 -0
  8. package/package.json +87 -0
  9. package/skills/linear-sdk-cli/SKILL.md +534 -0
  10. package/skills/linear-sdk-cli/references/api.md +27 -0
  11. package/skills/linear-sdk-cli/references/attachment.md +82 -0
  12. package/skills/linear-sdk-cli/references/auth.md +174 -0
  13. package/skills/linear-sdk-cli/references/commands.md +37 -0
  14. package/skills/linear-sdk-cli/references/comment.md +154 -0
  15. package/skills/linear-sdk-cli/references/completion.md +17 -0
  16. package/skills/linear-sdk-cli/references/config.md +99 -0
  17. package/skills/linear-sdk-cli/references/cycle.md +131 -0
  18. package/skills/linear-sdk-cli/references/document.md +180 -0
  19. package/skills/linear-sdk-cli/references/favorite.md +78 -0
  20. package/skills/linear-sdk-cli/references/initiative-update.md +67 -0
  21. package/skills/linear-sdk-cli/references/initiative.md +256 -0
  22. package/skills/linear-sdk-cli/references/issue.md +928 -0
  23. package/skills/linear-sdk-cli/references/label.md +112 -0
  24. package/skills/linear-sdk-cli/references/milestone.md +139 -0
  25. package/skills/linear-sdk-cli/references/notification.md +123 -0
  26. package/skills/linear-sdk-cli/references/open.md +29 -0
  27. package/skills/linear-sdk-cli/references/organization.md +99 -0
  28. package/skills/linear-sdk-cli/references/project-update.md +67 -0
  29. package/skills/linear-sdk-cli/references/project.md +234 -0
  30. package/skills/linear-sdk-cli/references/roadmap.md +150 -0
  31. package/skills/linear-sdk-cli/references/schema.md +21 -0
  32. package/skills/linear-sdk-cli/references/state.md +61 -0
  33. package/skills/linear-sdk-cli/references/team.md +227 -0
  34. package/skills/linear-sdk-cli/references/user.md +103 -0
  35. package/skills/linear-sdk-cli/references/webhook.md +151 -0
  36. package/skills/linear-sdk-cli/references/whoami.md +26 -0
  37. package/skills/linear-sdk-cli/scripts/generate-docs.ts +339 -0
  38. package/src/bin/linear.ts +97 -0
  39. package/src/cli.ts +232 -0
  40. package/src/client.ts +172 -0
  41. package/src/commands/agent-session.ts +121 -0
  42. package/src/commands/api.ts +317 -0
  43. package/src/commands/attach.ts +90 -0
  44. package/src/commands/attachment.ts +74 -0
  45. package/src/commands/comment.ts +225 -0
  46. package/src/commands/completion.ts +69 -0
  47. package/src/commands/cycle.ts +125 -0
  48. package/src/commands/discover.ts +170 -0
  49. package/src/commands/document.ts +211 -0
  50. package/src/commands/favorite.ts +72 -0
  51. package/src/commands/initiative-update.ts +47 -0
  52. package/src/commands/initiative.ts +261 -0
  53. package/src/commands/issue.ts +1263 -0
  54. package/src/commands/label.ts +119 -0
  55. package/src/commands/meta.ts +534 -0
  56. package/src/commands/milestone.ts +163 -0
  57. package/src/commands/notification.ts +110 -0
  58. package/src/commands/open.ts +84 -0
  59. package/src/commands/organization.ts +79 -0
  60. package/src/commands/project-update.ts +47 -0
  61. package/src/commands/project.ts +339 -0
  62. package/src/commands/roadmap.ts +131 -0
  63. package/src/commands/state.ts +58 -0
  64. package/src/commands/team.ts +243 -0
  65. package/src/commands/user.ts +107 -0
  66. package/src/commands/webhook.ts +156 -0
  67. package/src/config.ts +1236 -0
  68. package/src/context.ts +117 -0
  69. package/src/git.ts +174 -0
  70. package/src/index.ts +10 -0
  71. package/src/lib/action.ts +26 -0
  72. package/src/lib/body.ts +172 -0
  73. package/src/lib/errors.ts +195 -0
  74. package/src/lib/introspect.ts +150 -0
  75. package/src/lib/keyring.ts +270 -0
  76. package/src/lib/mutation.ts +53 -0
  77. package/src/lib/open.ts +32 -0
  78. package/src/lib/options.ts +640 -0
  79. package/src/lib/output-shapes.ts +562 -0
  80. package/src/lib/pagination.ts +145 -0
  81. package/src/lib/prompt.ts +91 -0
  82. package/src/lib/resolve.ts +596 -0
  83. package/src/lib/shape.ts +273 -0
  84. package/src/lib/status-update.ts +91 -0
  85. package/src/lib/upload.ts +329 -0
  86. package/src/output/color.ts +52 -0
  87. package/src/output/format.ts +238 -0
  88. package/src/output/markdown.ts +133 -0
  89. package/src/output/pager.ts +131 -0
  90. package/src/output/sanitize.ts +76 -0
  91. package/src/output/table.ts +198 -0
  92. package/src/services/agent-session.ts +289 -0
  93. package/src/services/attachment.ts +198 -0
  94. package/src/services/comment.ts +262 -0
  95. package/src/services/cycle.ts +224 -0
  96. package/src/services/document.ts +357 -0
  97. package/src/services/favorite.ts +147 -0
  98. package/src/services/initiative-update.ts +59 -0
  99. package/src/services/initiative.ts +449 -0
  100. package/src/services/issue.ts +1153 -0
  101. package/src/services/label.ts +193 -0
  102. package/src/services/milestone.ts +262 -0
  103. package/src/services/notification.ts +190 -0
  104. package/src/services/organization.ts +144 -0
  105. package/src/services/project-update.ts +36 -0
  106. package/src/services/project.ts +494 -0
  107. package/src/services/roadmap.ts +177 -0
  108. package/src/services/state.ts +127 -0
  109. package/src/services/team.ts +386 -0
  110. package/src/services/user.ts +137 -0
  111. package/src/services/webhook.ts +203 -0
@@ -0,0 +1,273 @@
1
+ /**
2
+ * The vocabulary `linear commands --json` uses to say what a command's `--json`
3
+ * output looks like, so an agent driving the CLI cold knows that a list row has
4
+ * `.state.name` and a detail has `.team.key` before it runs anything (TES-610).
5
+ *
6
+ * A shape is a JSON value:
7
+ * - a scalar type name: "string" | "number" | "boolean" | "unknown" | "object",
8
+ * or the same with `|null` ("string|null") when the value may be null;
9
+ * - an object: { key: <shape>, ... } — exactly these keys, never null.
10
+ * A key ending in `?` ("comments?") may be absent; every other key is always present;
11
+ * - an array: [ <shape> ] — one element, the type of every item;
12
+ * - a nullable object/array: { "nullable": <shape> } — the wrapped shape, or null.
13
+ *
14
+ * "unknown" is a JSON value this CLI passes through unchanged; "object" is a
15
+ * map with data-dependent keys.
16
+ *
17
+ * The row/detail shapes are declared next to the TypeScript interfaces they
18
+ * describe, through `shape<T>()`, whose parameter type `ShapeOf<T>` is computed
19
+ * from the interface: a renamed, added, removed, re-typed or de-nulled field is
20
+ * a compile error in the shape constant, so the description cannot quietly
21
+ * drift from the type the service returns. `matchesShape` is the runtime half —
22
+ * the tests hold real emitted objects against the declared shapes, which is
23
+ * what catches a field the interface promises but the query stopped selecting.
24
+ */
25
+
26
+ export type ScalarShape = "string" | "number" | "boolean" | "unknown" | "object";
27
+
28
+ export type FieldShape =
29
+ | ScalarShape
30
+ | `${ScalarShape}|null`
31
+ | readonly [FieldShape]
32
+ | { readonly nullable: FieldShape }
33
+ | { readonly [key: string]: FieldShape };
34
+
35
+ /** How a command's `--json` stdout is shaped. */
36
+ export interface OutputShape {
37
+ /**
38
+ * list — a bare array of `fields`-shaped rows
39
+ * object — a bare object with `fields` (a `view`, `whoami`, `config show`, …)
40
+ * receipt — a mutation's bare object with `fields` (ids + what happened)
41
+ * raw — pass-through JSON whose keys depend on the request (`api`, `schema`)
42
+ * none — the command never prints JSON (`completion`)
43
+ */
44
+ kind: "list" | "object" | "receipt" | "raw" | "none";
45
+ fields?: Record<string, FieldShape>;
46
+ /** What the fields cannot say. */
47
+ note?: string;
48
+ /**
49
+ * A different output under a flag or argument, keyed by what selects it
50
+ * (`"--web"`, `"op=list"`, `"[path]"`): the whole shape printed in that case.
51
+ */
52
+ variants?: Record<string, OutputShape>;
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // ShapeOf<T>: the shape a TypeScript type must be described as.
57
+ // ---------------------------------------------------------------------------
58
+
59
+ type IsAny<T> = 0 extends 1 & T ? true : false;
60
+
61
+ /**
62
+ * The object form of a non-null object type: every key, an optional or
63
+ * `undefined`-able one spelled with a trailing `?` (it may be absent from the
64
+ * JSON — `undefined` is dropped by JSON.stringify — which is not the same as null).
65
+ */
66
+ type ObjectShapeOf<T> = string extends keyof T
67
+ ? "object"
68
+ : {
69
+ [
70
+ K in keyof T as IsAny<T[K]> extends true
71
+ ? K & string
72
+ : undefined extends T[K]
73
+ ? `${K & string}?`
74
+ : K & string
75
+ ]-?: ShapeOf<Exclude<T[K], undefined>>;
76
+ };
77
+
78
+ export type ShapeOf<T> =
79
+ IsAny<T> extends true
80
+ ? "unknown"
81
+ : [T] extends [string]
82
+ ? "string"
83
+ : [T] extends [number]
84
+ ? "number"
85
+ : [T] extends [boolean]
86
+ ? "boolean"
87
+ : [T] extends [Date]
88
+ ? "string"
89
+ : [T] extends [string | null]
90
+ ? "string|null"
91
+ : [T] extends [number | null]
92
+ ? "number|null"
93
+ : [T] extends [boolean | null]
94
+ ? "boolean|null"
95
+ : [T] extends [Date | null]
96
+ ? "string|null"
97
+ : [T] extends [readonly (infer E)[]]
98
+ ? readonly [ShapeOf<E>]
99
+ : [T] extends [readonly (infer E)[] | null]
100
+ ? { readonly nullable: readonly [ShapeOf<E>] }
101
+ : [T] extends [object]
102
+ ? ObjectShapeOf<T>
103
+ : [T] extends [object | null]
104
+ ? { readonly nullable: ObjectShapeOf<NonNullable<T>> }
105
+ : "unknown";
106
+
107
+ /**
108
+ * Declare the shape of `T`. The argument is checked against `ShapeOf<T>`, so
109
+ * it must name every field of `T` with the right type and nothing else.
110
+ */
111
+ export function shape<T>(s: ShapeOf<T>): ShapeOf<T> {
112
+ return s;
113
+ }
114
+
115
+ /** The fields of an object shape (a `ShapeOf<Interface>`), for `OutputShape.fields`. */
116
+ export type ObjectFields = Record<string, FieldShape>;
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Runtime: check a real value against a shape, and render a shape for humans.
120
+ // ---------------------------------------------------------------------------
121
+
122
+ function isNullableWrapper(s: FieldShape): s is { readonly nullable: FieldShape } {
123
+ return (
124
+ typeof s === "object" && !Array.isArray(s) && Object.keys(s).length === 1 && "nullable" in s
125
+ );
126
+ }
127
+
128
+ /**
129
+ * Every way `value` fails to be a `shape`, as `path: problem` strings; empty
130
+ * when it matches. Objects must carry exactly the declared keys: an extra key
131
+ * is as much a drift as a missing one (it is a key the docs do not mention),
132
+ * and a missing non-optional key is a drift even when its type allows null —
133
+ * `undefined` vanishes from JSON, so the documented key would not be there.
134
+ *
135
+ * `strictNullable` also flags a `{ nullable: … }` field that IS null. The
136
+ * sweep uses it: its fake answers every relation, so a relation that still
137
+ * comes out null was not selected or not mapped — a `?? null` in a mapper hides
138
+ * exactly the drift TES-652 was.
139
+ */
140
+ export function matchesShape(
141
+ value: unknown,
142
+ shape: FieldShape,
143
+ path = "$",
144
+ strictNullable = false,
145
+ ): string[] {
146
+ if (typeof shape === "string") {
147
+ const [base, nullable] = shape.endsWith("|null")
148
+ ? [shape.slice(0, -"|null".length) as ScalarShape, true]
149
+ : [shape as ScalarShape, false];
150
+ if (value === undefined) return [`${path}: expected ${shape}, got undefined`];
151
+ if (value === null) return nullable ? [] : [`${path}: expected ${shape}, got null`];
152
+ if (base === "unknown") return [];
153
+ if (base === "object") {
154
+ return typeof value === "object" && !Array.isArray(value)
155
+ ? []
156
+ : [`${path}: expected an object, got ${describe(value)}`];
157
+ }
158
+ return typeof value === base ? [] : [`${path}: expected ${shape}, got ${describe(value)}`];
159
+ }
160
+ if (Array.isArray(shape)) {
161
+ if (!Array.isArray(value)) return [`${path}: expected an array, got ${describe(value)}`];
162
+ return value.flatMap((item, i) =>
163
+ matchesShape(item, shape[0], `${path}[${i}]`, strictNullable),
164
+ );
165
+ }
166
+ if (isNullableWrapper(shape)) {
167
+ if (value === undefined) return [`${path}: expected an object or null, got undefined`];
168
+ if (value === null) {
169
+ return strictNullable ? [`${path}: null, although the source answers every relation`] : [];
170
+ }
171
+ return matchesShape(value, shape.nullable, path, strictNullable);
172
+ }
173
+ // A plain object shape.
174
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
175
+ return [`${path}: expected an object, got ${describe(value)}`];
176
+ }
177
+ const record = value as Record<string, unknown>;
178
+ const problems: string[] = [];
179
+ const declared = new Set<string>();
180
+ for (const [spelled, sub] of Object.entries(shape)) {
181
+ const optional = spelled.endsWith("?");
182
+ const key = optional ? spelled.slice(0, -1) : spelled;
183
+ declared.add(key);
184
+ if (optional && record[key] === undefined) continue;
185
+ problems.push(...matchesShape(record[key], sub, `${path}.${key}`, strictNullable));
186
+ }
187
+ for (const key of Object.keys(record)) {
188
+ if (!declared.has(key) && record[key] !== undefined)
189
+ problems.push(`${path}.${key}: not in the declared shape`);
190
+ }
191
+ return problems;
192
+ }
193
+
194
+ function describe(value: unknown): string {
195
+ if (value === null) return "null";
196
+ if (Array.isArray(value)) return "an array";
197
+ return typeof value;
198
+ }
199
+
200
+ /**
201
+ * A shape as one TypeScript-ish line: `{id: string, state: {name: string, type:
202
+ * string} | null, labels: string[]}` — the human rendering in `linear commands`.
203
+ */
204
+ export function renderShape(shape: FieldShape): string {
205
+ if (typeof shape === "string") return shape.replace("|null", " | null");
206
+ if (Array.isArray(shape)) {
207
+ const inner = renderShape(shape[0]);
208
+ return typeof shape[0] === "string" && !shape[0].includes("|")
209
+ ? `${inner}[]`
210
+ : `Array<${inner}>`;
211
+ }
212
+ if (isNullableWrapper(shape)) return `${renderShape(shape.nullable)} | null`;
213
+ const parts = Object.entries(shape).map(([k, v]) => `${k}: ${renderShape(v)}`);
214
+ return `{${parts.join(", ")}}`;
215
+ }
216
+
217
+ /**
218
+ * A variant that is its base plus some keys (`issue view --comments`, `issue
219
+ * create --start`) is best shown as the difference: the keys it adds or
220
+ * re-types, and whether it drops any. Returns null when the two are unrelated
221
+ * (a different kind, or fewer than half the base keys kept), in which case the
222
+ * variant is shown in full.
223
+ */
224
+ export function variantDelta(
225
+ base: OutputShape,
226
+ variant: OutputShape,
227
+ ): { added: Record<string, FieldShape>; dropped: string[] } | null {
228
+ if (base.kind !== variant.kind || !base.fields || !variant.fields) return null;
229
+ const same = (a: FieldShape, b: FieldShape) => JSON.stringify(a) === JSON.stringify(b);
230
+ const kept = Object.keys(base.fields).filter(
231
+ (k) => k in variant.fields! && same(base.fields![k]!, variant.fields![k]!),
232
+ );
233
+ if (kept.length < Object.keys(base.fields).length / 2) return null;
234
+ const added: Record<string, FieldShape> = {};
235
+ for (const [k, v] of Object.entries(variant.fields)) {
236
+ if (!(k in base.fields) || !same(base.fields[k]!, v)) added[k] = v;
237
+ }
238
+ const dropped = Object.keys(base.fields).filter((k) => !(k in variant.fields!));
239
+ return { added, dropped };
240
+ }
241
+
242
+ /** The lines `linear commands <path>` prints under "Output (--json)". */
243
+ export function renderOutputShape(out: OutputShape, indent = " "): string[] {
244
+ const head =
245
+ out.kind === "list"
246
+ ? "array of objects:"
247
+ : out.kind === "object"
248
+ ? "object:"
249
+ : out.kind === "receipt"
250
+ ? "receipt object:"
251
+ : out.kind === "raw"
252
+ ? "raw JSON (keys depend on the request)"
253
+ : "none (never prints JSON)";
254
+ const lines = [head];
255
+ for (const [key, sub] of Object.entries(out.fields ?? {})) {
256
+ lines.push(`${indent}${key}: ${renderShape(sub)}`);
257
+ }
258
+ if (out.note) lines.push(`${indent}(${out.note})`);
259
+ for (const [when, variant] of Object.entries(out.variants ?? {})) {
260
+ const delta = variantDelta(out, variant);
261
+ if (delta) {
262
+ const drop = delta.dropped.length ? `, without ${delta.dropped.join(", ")}` : "";
263
+ lines.push(`${indent}with ${when}: the same${drop}, plus:`);
264
+ for (const [key, sub] of Object.entries(delta.added)) {
265
+ lines.push(`${indent} ${key}: ${renderShape(sub)}`);
266
+ }
267
+ continue;
268
+ }
269
+ const [vHead, ...vRest] = renderOutputShape(variant, indent + " ");
270
+ lines.push(`${indent}with ${when}: ${vHead}`, ...vRest);
271
+ }
272
+ return lines;
273
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Shared scaffolding for the `project-update` / `initiative-update` groups.
3
+ *
4
+ * Both groups take the same content inputs (`--body` / `--body-file` / `--editor`)
5
+ * and the same `--health` enum, so the option wiring and body resolution live here
6
+ * once instead of being duplicated per group.
7
+ */
8
+
9
+ import { Command, Option } from "commander";
10
+ import { resolveBody } from "./body.js";
11
+ import { usageError } from "./errors.js";
12
+ import { unwrapMutation } from "./mutation.js";
13
+ import type { Context } from "../context.js";
14
+ import type { Column } from "../output/table.js";
15
+
16
+ /** The health values Linear accepts for a status update (Project/InitiativeUpdateHealthType). */
17
+ export const HEALTH_CHOICES = ["onTrack", "atRisk", "offTrack"] as const;
18
+ export type Health = (typeof HEALTH_CHOICES)[number];
19
+
20
+ /** A normalized status-update row, shared by project- and initiative-update. */
21
+ export interface UpdateRow {
22
+ id: string;
23
+ createdAt: string;
24
+ user: string;
25
+ body: string;
26
+ health: string | null;
27
+ }
28
+
29
+ /** The table columns for listing status updates — identical for both groups. */
30
+ export const UPDATE_COLUMNS: Column<UpdateRow>[] = [
31
+ { key: "createdAt", header: "Date", value: (u) => u.createdAt.slice(0, 10) },
32
+ { key: "user", header: "Author", value: (u) => u.user, max: 18 },
33
+ { key: "health", header: "Health", value: (u) => u.health ?? "—", max: 10 },
34
+ { key: "body", header: "Update", value: (u) => u.body.replace(/\n/g, " "), max: 60 },
35
+ ];
36
+
37
+ /**
38
+ * Unwrap and normalize a `create{Project,Initiative}Update` payload into the shared
39
+ * row shape (plus the update's `url`). The `payloadKey` is the payload field that
40
+ * holds the created update (`projectUpdate` / `initiativeUpdate`).
41
+ */
42
+ export async function normalizeUpdatePayload(
43
+ payload: any,
44
+ payloadKey: string,
45
+ ): Promise<UpdateRow & { url: string }> {
46
+ const update: any = await unwrapMutation(payload, payloadKey, "Status update creation");
47
+ const user = await update.user;
48
+ return {
49
+ id: update.id,
50
+ createdAt: update.createdAt?.toISOString?.() ?? String(update.createdAt),
51
+ user: user?.displayName ?? "unknown",
52
+ body: update.body ?? "",
53
+ health: update.health ?? null,
54
+ url: update.url,
55
+ };
56
+ }
57
+
58
+ /** Add the shared `--body` / `--body-file` / `--editor` / `--health` flags to a `create` command. */
59
+ export function addUpdateFlags(cmd: Command): Command {
60
+ return cmd
61
+ .option("--body <text>", "update body (markdown)")
62
+ .option("--body-file <path>", "read body from a file ('-' = stdin)")
63
+ .option("--editor", "compose the body in $EDITOR")
64
+ .addOption(new Option("--health <state>", "status health").choices([...HEALTH_CHOICES]));
65
+ }
66
+
67
+ /**
68
+ * Resolve the body for a status update from the shared flags.
69
+ *
70
+ * A status update must say *something*: a body, or a health. Linear accepts a
71
+ * health-only update — the UI's "mark on track" with no text — but the API
72
+ * still insists on a body field ("Exactly one of body or bodyData must be
73
+ * defined", verified live) and stores the empty string for it, so `--health`
74
+ * alone is a complete update with `""` as its body. With neither, there is
75
+ * nothing to post, and that is a usage error.
76
+ */
77
+ export function resolveUpdateBody(ctx: Context, opts: Record<string, any>): string {
78
+ const body = resolveBody({
79
+ arg: opts.body,
80
+ file: opts.bodyFile,
81
+ interactive: !!opts.editor && ctx.isTTY,
82
+ editorRequested: !!opts.editor,
83
+ });
84
+ if (body === undefined || body.trim() === "") {
85
+ if (opts.health) return "";
86
+ throw usageError(
87
+ "An update needs a body or a health. Pass --body, --body-file, --editor, or --health.",
88
+ );
89
+ }
90
+ return body;
91
+ }
@@ -0,0 +1,329 @@
1
+ /**
2
+ * File upload — the two-step flow behind `issue attach` and `comment add
3
+ * --attach` (TES-602).
4
+ *
5
+ * 1. `fileUpload(contentType, filename, size, { makePublic })` asks Linear for
6
+ * a signed storage URL. The payload is `{ success, uploadFile }`, checked
7
+ * like every other mutation (`unwrapMutation`): `success: false` or a
8
+ * missing `uploadFile` is a refusal, not a URL.
9
+ * 2. HTTP `PUT` the bytes to `uploadFile.uploadUrl` with the
10
+ * `uploadFile.headers[]` Linear returned, verbatim, plus the Content-Type
11
+ * the URL was signed for. The URL is signed over `content-type;host;
12
+ * x-goog-content-length-range` (probed on the QA workspace, SDK 89), so a
13
+ * wrong or missing header is a 403 from the storage backend, not from
14
+ * Linear — and the returned array does not include Content-Type itself,
15
+ * which is why it is set here from the type we declared in step 1. Linear's
16
+ * own upload guide does the same.
17
+ *
18
+ * The signed `uploadUrl` is a bearer credential (anyone holding it can write
19
+ * that object for the next 60 s). It is never logged, never returned, and is
20
+ * redacted from any error text — the storage backend echoes it in
21
+ * `SignatureDoesNotMatch` bodies, and a transport error can carry it too.
22
+ *
23
+ * Privacy posture, matching schpet/linear-cli 2.5 and the Linear web app:
24
+ * uploads are private (workspace members only, `uploads.linear.app`) unless
25
+ * `public` is asked for; a public upload lands on `public.linear.app`, readable
26
+ * by anyone with the URL, and is only valid for raster images — Linear itself
27
+ * answers "Public uploads are only supported for images (excluding SVG)" to
28
+ * anything else. That rule is enforced here BEFORE any network work, so a
29
+ * mixed batch cannot publish some files and then fail on the rest.
30
+ */
31
+
32
+ import { accessSync, constants, readFileSync, statSync } from "node:fs";
33
+ import { basename, extname } from "node:path";
34
+ import type { LinearClient } from "@linear/sdk";
35
+ import { withRetry } from "../client.js";
36
+ import { CliError, usageError } from "./errors.js";
37
+ import { unwrapMutation } from "./mutation.js";
38
+
39
+ /** MIME type by extension. Anything else is `application/octet-stream`. */
40
+ const MIME_TYPES: Record<string, string> = {
41
+ // images
42
+ ".png": "image/png",
43
+ ".jpg": "image/jpeg",
44
+ ".jpeg": "image/jpeg",
45
+ ".gif": "image/gif",
46
+ ".webp": "image/webp",
47
+ ".svg": "image/svg+xml",
48
+ ".bmp": "image/bmp",
49
+ ".tif": "image/tiff",
50
+ ".tiff": "image/tiff",
51
+ ".ico": "image/x-icon",
52
+ ".heic": "image/heic",
53
+ ".avif": "image/avif",
54
+ // documents
55
+ ".pdf": "application/pdf",
56
+ ".doc": "application/msword",
57
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
58
+ ".xls": "application/vnd.ms-excel",
59
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
60
+ ".ppt": "application/vnd.ms-powerpoint",
61
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
62
+ // text
63
+ ".txt": "text/plain",
64
+ ".log": "text/plain",
65
+ ".md": "text/markdown",
66
+ ".markdown": "text/markdown",
67
+ ".csv": "text/csv",
68
+ ".tsv": "text/tab-separated-values",
69
+ ".html": "text/html",
70
+ ".htm": "text/html",
71
+ ".css": "text/css",
72
+ ".xml": "text/xml",
73
+ ".yaml": "text/yaml",
74
+ ".yml": "text/yaml",
75
+ ".toml": "text/toml",
76
+ ".json": "application/json",
77
+ // code
78
+ ".js": "text/javascript",
79
+ ".mjs": "text/javascript",
80
+ ".cjs": "text/javascript",
81
+ ".jsx": "text/javascript",
82
+ ".ts": "text/typescript",
83
+ ".tsx": "text/typescript",
84
+ ".sh": "text/x-shellscript",
85
+ ".bash": "text/x-shellscript",
86
+ ".py": "text/x-python",
87
+ ".rb": "text/x-ruby",
88
+ ".go": "text/x-go",
89
+ ".rs": "text/x-rust",
90
+ ".java": "text/x-java",
91
+ ".c": "text/x-c",
92
+ ".h": "text/x-c",
93
+ ".cpp": "text/x-c++",
94
+ ".hpp": "text/x-c++",
95
+ ".swift": "text/x-swift",
96
+ ".kt": "text/x-kotlin",
97
+ // archives
98
+ ".zip": "application/zip",
99
+ ".tar": "application/x-tar",
100
+ ".gz": "application/gzip",
101
+ ".tgz": "application/gzip",
102
+ ".7z": "application/x-7z-compressed",
103
+ ".rar": "application/vnd.rar",
104
+ // audio / video
105
+ ".mp3": "audio/mpeg",
106
+ ".wav": "audio/wav",
107
+ ".ogg": "audio/ogg",
108
+ ".m4a": "audio/mp4",
109
+ ".mp4": "video/mp4",
110
+ ".webm": "video/webm",
111
+ ".mov": "video/quicktime",
112
+ ".avi": "video/x-msvideo",
113
+ // other
114
+ ".wasm": "application/wasm",
115
+ };
116
+
117
+ /**
118
+ * The content types Linear will serve from a public URL: raster images only.
119
+ * SVG is an image but can carry script, and the API refuses it publicly.
120
+ */
121
+ const PUBLIC_TYPES = new Set([
122
+ "image/png",
123
+ "image/jpeg",
124
+ "image/gif",
125
+ "image/webp",
126
+ "image/bmp",
127
+ "image/tiff",
128
+ "image/heic",
129
+ "image/avif",
130
+ ]);
131
+
132
+ /** MIME type from the file's extension; `application/octet-stream` when unknown. */
133
+ export function mimeType(path: string): string {
134
+ return MIME_TYPES[extname(path).toLowerCase()] ?? "application/octet-stream";
135
+ }
136
+
137
+ /** `image/*` — what renders inline in a comment as `![name](url)`. */
138
+ export function isImage(contentType: string): boolean {
139
+ return contentType.startsWith("image/");
140
+ }
141
+
142
+ /** Whether Linear allows this content type on a public URL. */
143
+ export function canBePublic(contentType: string): boolean {
144
+ return PUBLIC_TYPES.has(contentType);
145
+ }
146
+
147
+ export interface UploadOptions {
148
+ /**
149
+ * Upload to a public, unauthenticated URL. Off by default (private —
150
+ * workspace members only), and only valid for raster images.
151
+ */
152
+ public?: boolean;
153
+ /** The `fetch` to PUT with; injectable for tests. Defaults to the global. */
154
+ fetch?: typeof fetch;
155
+ }
156
+
157
+ /** A file that passed `validateUploads`: what will be declared to `fileUpload`. */
158
+ export interface UploadCandidate {
159
+ path: string;
160
+ filename: string;
161
+ contentType: string;
162
+ size: number;
163
+ }
164
+
165
+ export interface UploadResult {
166
+ /** The permanent URL Linear serves the file from (private unless `public`). */
167
+ assetUrl: string;
168
+ filename: string;
169
+ contentType: string;
170
+ size: number;
171
+ /** Whether the file went to a public, world-readable URL. */
172
+ public: boolean;
173
+ }
174
+
175
+ /**
176
+ * Check every path BEFORE any network work: it exists, is a regular file, is
177
+ * readable, and — with `public` — is a type Linear will serve publicly. Called
178
+ * on the whole batch up front so a typo in file 3 does not leave files 1–2
179
+ * uploaded and orphaned, and so a mixed `--public` batch is refused whole
180
+ * rather than half-published. Returns what each upload will declare.
181
+ */
182
+ export function validateUploads(paths: string[], opts: UploadOptions = {}): UploadCandidate[] {
183
+ return paths.map((path) => {
184
+ let size: number;
185
+ try {
186
+ const st = statSync(path);
187
+ if (!st.isFile()) throw usageError(`'${path}' is not a regular file.`);
188
+ size = st.size;
189
+ accessSync(path, constants.R_OK);
190
+ } catch (err) {
191
+ if (err instanceof CliError) throw err;
192
+ const code = (err as NodeJS.ErrnoException).code;
193
+ if (code === "ENOENT") throw usageError(`File not found: '${path}'.`);
194
+ if (code === "EACCES") throw usageError(`Cannot read '${path}': permission denied.`);
195
+ throw usageError(`Cannot read '${path}': ${(err as Error).message}`);
196
+ }
197
+ const contentType = mimeType(path);
198
+ if (opts.public && !canBePublic(contentType)) {
199
+ throw usageError(
200
+ `Cannot upload '${basename(path)}' (${contentType}) to a public URL: Linear only serves raster ` +
201
+ "images (png, jpeg, gif, webp, bmp, tiff) publicly. Drop --public to upload it privately.",
202
+ );
203
+ }
204
+ return { path, filename: basename(path), contentType, size };
205
+ });
206
+ }
207
+
208
+ /**
209
+ * Upload one file: signed URL from Linear, then PUT the bytes to it. Private
210
+ * unless `opts.public`. Throws a usage error before any network work when the
211
+ * file is missing/unreadable or `public` is asked for a non-image.
212
+ */
213
+ export async function uploadFile(
214
+ client: LinearClient,
215
+ path: string,
216
+ opts: UploadOptions = {},
217
+ ): Promise<UploadResult> {
218
+ const [file] = validateUploads([path], opts);
219
+ const { filename, contentType, size } = file!;
220
+ const makePublic = opts.public === true;
221
+
222
+ // Read the bytes before asking for the URL: it is only valid for ~60 s, and
223
+ // a slow disk should not eat into that.
224
+ const bytes = readFileSync(path);
225
+
226
+ const upload = await unwrapMutation(
227
+ withRetry(() => client.fileUpload(contentType, filename, size, { makePublic })),
228
+ "uploadFile",
229
+ `Upload of ${filename}`,
230
+ );
231
+ const uploadUrl: string = upload.uploadUrl;
232
+
233
+ // Content-Type first (the URL is signed over it), then Linear's headers
234
+ // verbatim — through `Headers.set`, so a returned Content-Type replaces the
235
+ // default rather than being appended to it as a second value.
236
+ const headers = new Headers();
237
+ headers.set("Content-Type", contentType);
238
+ for (const h of upload.headers ?? []) headers.set(h.key, h.value);
239
+
240
+ const doFetch = opts.fetch ?? globalThis.fetch;
241
+ let response: Response;
242
+ try {
243
+ response = await doFetch(uploadUrl, { method: "PUT", headers, body: bytes });
244
+ } catch (err) {
245
+ const why = err instanceof Error ? err.message : String(err);
246
+ throw new CliError(`Upload of ${filename} failed: ${redact(why, uploadUrl)}`, "network");
247
+ }
248
+ if (!response.ok) {
249
+ const text = redact(await response.text().catch(() => ""), uploadUrl);
250
+ const why = storageErrorSummary(text);
251
+ throw new CliError(
252
+ `Upload of ${filename} failed: storage returned ${response.status} ${response.statusText}${why ? `: ${why}` : ""}`,
253
+ "api",
254
+ { status: response.status, statusText: response.statusText, body: text.slice(0, 2000) },
255
+ );
256
+ }
257
+
258
+ return { assetUrl: upload.assetUrl, filename, contentType, size, public: makePublic };
259
+ }
260
+
261
+ /**
262
+ * One line from a storage error body (already redacted). GCS answers in XML —
263
+ * `<Code>SignatureDoesNotMatch</Code><Message>…</Message><Details>…</Details>`
264
+ * followed by the whole canonical request — so pick the code and the most
265
+ * specific sentence; anything else is collapsed and cut short.
266
+ */
267
+ function storageErrorSummary(text: string): string {
268
+ const tag = (name: string) => text.match(new RegExp(`<${name}>([^<]*)</${name}>`))?.[1]?.trim();
269
+ const code = tag("Code");
270
+ if (code) {
271
+ const why = tag("Details") || tag("Message");
272
+ return why ? `${code} — ${why}` : code;
273
+ }
274
+ return text.replace(/\s+/g, " ").trim().slice(0, 300);
275
+ }
276
+
277
+ /**
278
+ * Strip the signed URL from text that may echo it: the whole URL, its query
279
+ * string on its own, and any `…Signature=<hex>` parameter, however the
280
+ * backend or transport spelled it.
281
+ */
282
+ function redact(text: string, uploadUrl: string): string {
283
+ let out = text.split(uploadUrl).join("[signed upload URL]");
284
+ try {
285
+ const u = new URL(uploadUrl);
286
+ if (u.search.length > 1) out = out.split(u.search).join("?[redacted]");
287
+ for (const [key, value] of u.searchParams) {
288
+ if (/signature|credential/i.test(key) && value) out = out.split(value).join("[redacted]");
289
+ }
290
+ } catch {
291
+ // not a parseable URL — the plain replacement above is all there is to do
292
+ }
293
+ return out.replace(/([?&][\w-]*Signature=)[^&\s"'<>]+/gi, "$1[redacted]");
294
+ }
295
+
296
+ /** Markdown for a comment body: images embed inline, anything else links. */
297
+ export function formatEmbed(
298
+ r: Pick<UploadResult, "filename" | "assetUrl" | "contentType">,
299
+ ): string {
300
+ return `${isImage(r.contentType) ? "!" : ""}[${r.filename}](${r.assetUrl})`;
301
+ }
302
+
303
+ /**
304
+ * A comment body with its uploads embedded: the body, a blank line, then one
305
+ * embed per line. An empty body is just the embeds. Shared by `comment add
306
+ * --attach` and `issue attach --comment` so both read the same way.
307
+ */
308
+ export function appendEmbeds(
309
+ body: string | undefined,
310
+ uploads: Array<Pick<UploadResult, "filename" | "assetUrl" | "contentType">>,
311
+ ): string {
312
+ const embeds = uploads.map(formatEmbed).join("\n");
313
+ if (!embeds) return body ?? "";
314
+ return body?.trim() ? `${body}\n\n${embeds}` : embeds;
315
+ }
316
+
317
+ /** `70 B`, `12 KB`, `1.5 KB`, `3 MB` — for the human receipt. */
318
+ export function formatBytes(n: number): string {
319
+ if (n < 1024) return `${n} B`;
320
+ const units = ["KB", "MB", "GB", "TB"];
321
+ let v = n / 1024;
322
+ let i = 0;
323
+ while (v >= 1024 && i < units.length - 1) {
324
+ v /= 1024;
325
+ i++;
326
+ }
327
+ const rounded = v >= 10 ? Math.round(v) : Math.round(v * 10) / 10;
328
+ return `${rounded} ${units[i]}`;
329
+ }