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,150 @@
1
+ /**
2
+ * Walk a commander program tree into a flat, machine-readable description of
3
+ * every (sub)command — used by `linear commands` for discovery by agents.
4
+ */
5
+
6
+ import type { Command } from "commander";
7
+ import type { OutputShape } from "./shape.js";
8
+ import { outputShapeOf } from "./output-shapes.js";
9
+ import {
10
+ FIELDS_COMMANDS,
11
+ LIMIT_COMMANDS,
12
+ TEAM_COMMANDS,
13
+ YES_COMMANDS,
14
+ globalOptionKeys,
15
+ } from "./options.js";
16
+
17
+ export interface CommandArgument {
18
+ name: string;
19
+ required: boolean;
20
+ variadic: boolean;
21
+ }
22
+
23
+ export interface CommandOption {
24
+ flags: string;
25
+ description: string;
26
+ /** Commander's destination key (e.g. `includeArchived`). */
27
+ attribute: string;
28
+ /** Whether the option's value is required/optional after the flag. */
29
+ valueRequired: boolean;
30
+ valueOptional: boolean;
31
+ variadic: boolean;
32
+ choices?: string[];
33
+ defaultValue?: unknown;
34
+ /** True for the flags inherited from the root CLI contract. */
35
+ global: boolean;
36
+ /** Whether this global has an effect on this command. Local options are always applicable. */
37
+ applicable: boolean;
38
+ }
39
+
40
+ export interface CommandNode {
41
+ /** Space-joined command path, e.g. "issue create". */
42
+ path: string;
43
+ description: string;
44
+ aliases: string[];
45
+ arguments: CommandArgument[];
46
+ options: CommandOption[];
47
+ /**
48
+ * What the command prints under `--json` (TES-610): the row/object keys with
49
+ * their types, from `lib/output-shapes.ts`. Absent on a group that only holds
50
+ * subcommands and prints nothing of its own.
51
+ */
52
+ output?: OutputShape;
53
+ }
54
+
55
+ /**
56
+ * Produce a flat array of command nodes for `root` and all descendants. The
57
+ * root program itself is skipped; only its (sub)commands are emitted. Help and
58
+ * version pseudo-options are omitted from each command's option list. Sorted by
59
+ * path for stable output.
60
+ */
61
+ export function walkCommands(root: Command): CommandNode[] {
62
+ const out: CommandNode[] = [];
63
+
64
+ const visit = (cmd: Command, prefix: string[]): void => {
65
+ for (const sub of cmd.commands) {
66
+ // Commander models help as a hidden command; skip it.
67
+ if (sub.name() === "help") continue;
68
+ const path = [...prefix, sub.name()];
69
+ out.push(describe(sub, path));
70
+ visit(sub, path);
71
+ }
72
+ };
73
+
74
+ visit(root, []);
75
+ out.sort((a, b) => a.path.localeCompare(b.path));
76
+ return out;
77
+ }
78
+
79
+ function describe(cmd: Command, path: string[]): CommandNode {
80
+ const args: CommandArgument[] =
81
+ (cmd as any).registeredArguments?.map((a: any) => ({
82
+ name: a.name(),
83
+ required: a.required === true,
84
+ variadic: a.variadic === true,
85
+ })) ?? [];
86
+
87
+ const joined = path.join(" ");
88
+ const defaultName = (cmd as any)._defaultCommandName as string | undefined;
89
+ const effectivePath = defaultName ? `${joined} ${defaultName}` : joined;
90
+ const runnable = Boolean((cmd as any)._actionHandler) || defaultName !== undefined;
91
+ const globalKeys = new Set(globalOptionKeys());
92
+ const options: CommandOption[] = cmd.options
93
+ .filter((o: any) => !o.hidden)
94
+ .map((o: any) => {
95
+ const attribute = o.attributeName() as string;
96
+ const global = globalKeys.has(attribute) && isGlobalFlag(o.long);
97
+ return {
98
+ flags: o.flags as string,
99
+ description: (o.description as string) ?? "",
100
+ attribute,
101
+ valueRequired: o.required === true,
102
+ valueOptional: o.optional === true,
103
+ variadic: o.variadic === true,
104
+ ...(Array.isArray(o.argChoices) ? { choices: [...o.argChoices] as string[] } : {}),
105
+ ...(o.defaultValue !== undefined ? { defaultValue: o.defaultValue as unknown } : {}),
106
+ global,
107
+ applicable: global ? globalApplies(effectivePath, o.long, runnable) : true,
108
+ };
109
+ });
110
+
111
+ const output = outputShapeOf(joined);
112
+ return {
113
+ path: joined,
114
+ description: cmd.description() ?? "",
115
+ aliases: cmd.aliases(),
116
+ arguments: args,
117
+ options,
118
+ ...(output ? { output } : {}),
119
+ };
120
+ }
121
+
122
+ const GLOBAL_LONGS = new Set([
123
+ "--json",
124
+ "--no-ansi",
125
+ "--api-key",
126
+ "--workspace",
127
+ "--team",
128
+ "--limit",
129
+ "--all",
130
+ "--fields",
131
+ "--yes",
132
+ "--quiet",
133
+ "--no-input",
134
+ "--debug",
135
+ ]);
136
+
137
+ function isGlobalFlag(long: string | undefined): boolean {
138
+ return long !== undefined && GLOBAL_LONGS.has(long);
139
+ }
140
+
141
+ function globalApplies(path: string, long: string | undefined, hasAction: boolean): boolean {
142
+ if (!hasAction) return false;
143
+ if (long === "--fields") return FIELDS_COMMANDS.has(path);
144
+ if (long === "--limit" || long === "--all") return LIMIT_COMMANDS.has(path);
145
+ if (long === "--team") return TEAM_COMMANDS.has(path);
146
+ if (long === "--yes") return YES_COMMANDS.has(path);
147
+ // The remaining globals control the shared parser/context/error/output
148
+ // boundary and are intentionally safe on every runnable command.
149
+ return true;
150
+ }
@@ -0,0 +1,270 @@
1
+ /**
2
+ * OS keyring access for stored API keys.
3
+ *
4
+ * The service name and the account convention are the reference CLI's
5
+ * (schpet/linear-cli 2.x): service `linear-cli`, account = workspace slug. That
6
+ * is deliberate — a user migrating from it already has an entry, and we find it
7
+ * without a re-login. The flip side is that the entry is SHARED: `auth logout`
8
+ * here removes it for both CLIs, and `auth login` here overwrites theirs.
9
+ *
10
+ * Backends are synchronous (spawnSync) because config resolution is
11
+ * synchronous and runs before any command body. Only macOS Keychain and the
12
+ * Linux Secret Service (`secret-tool`) are implemented; everywhere else — and
13
+ * wherever the tool is missing — `keyring()` returns null and the callers fall
14
+ * back to the plaintext file without complaint.
15
+ *
16
+ * The secret never travels on argv (visible to every process via `ps`): on
17
+ * macOS it goes through `security -i`, which reads its command line from stdin;
18
+ * on Linux `secret-tool store` reads the secret from stdin by design.
19
+ */
20
+
21
+ import { spawnSync } from "node:child_process";
22
+ import { existsSync } from "node:fs";
23
+ import { CliError } from "./errors.js";
24
+
25
+ export const KEYRING_SERVICE = "linear-cli";
26
+
27
+ export type KeyringName = "keychain" | "secret-service";
28
+
29
+ export interface KeyringBackend {
30
+ /** How the backend is named in receipts and `auth status`. */
31
+ readonly name: KeyringName;
32
+ /** Human label for the store, e.g. "macOS Keychain". */
33
+ readonly label: string;
34
+ /** Read the secret for an account; null when there is no entry. */
35
+ get(account: string): string | null;
36
+ /** Create or replace the entry. */
37
+ set(account: string, secret: string): void;
38
+ /** Remove the entry. Returns false when there was none. */
39
+ delete(account: string): boolean;
40
+ }
41
+
42
+ /** Thrown when the keyring tool ran but failed for a reason other than "no such item". */
43
+ export class KeyringError extends CliError {
44
+ constructor(message: string) {
45
+ super(message, "runtime");
46
+ this.name = "KeyringError";
47
+ }
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // macOS: /usr/bin/security
52
+ // ---------------------------------------------------------------------------
53
+
54
+ const SECURITY = "/usr/bin/security";
55
+ /** `security` exit status for errSecItemNotFound. */
56
+ const ERR_SEC_ITEM_NOT_FOUND = 44;
57
+
58
+ function runSecurity(args: string[], input?: string) {
59
+ const r = spawnSync(SECURITY, args, { encoding: "utf8", input });
60
+ if (r.error) {
61
+ throw new KeyringError(`Could not run ${SECURITY}: ${r.error.message}`);
62
+ }
63
+ return { status: r.status ?? -1, stdout: r.stdout ?? "", stderr: (r.stderr ?? "").trim() };
64
+ }
65
+
66
+ /**
67
+ * Quote one token for `security -i`, whose line parser understands double
68
+ * quotes with backslash escapes (verified: `-a "x\"y"` stores account `x"y`).
69
+ * A newline would end the command mid-way, so it is refused outright rather
70
+ * than escaped — no workspace slug or Linear key contains one anyway.
71
+ */
72
+ function quoteForSecurity(token: string, what: string): string {
73
+ if (/[\r\n]/.test(token)) {
74
+ throw new KeyringError(
75
+ `The ${what} contains a line break and cannot be stored in the keychain.`,
76
+ );
77
+ }
78
+ return `"${token.replace(/[\\"]/g, (c) => `\\${c}`)}"`;
79
+ }
80
+
81
+ export const macosKeychain: KeyringBackend = {
82
+ name: "keychain",
83
+ label: "macOS Keychain",
84
+
85
+ get(account) {
86
+ const r = runSecurity(["find-generic-password", "-a", account, "-s", KEYRING_SERVICE, "-w"]);
87
+ if (r.status === 0) {
88
+ // `-w` prints the secret and a trailing newline.
89
+ const secret = r.stdout.replace(/\r?\n$/, "");
90
+ return secret.length > 0 ? secret : null;
91
+ }
92
+ if (r.status === ERR_SEC_ITEM_NOT_FOUND) return null;
93
+ throw new KeyringError(
94
+ `security find-generic-password failed (exit ${r.status})${r.stderr ? `: ${r.stderr}` : ""}`,
95
+ );
96
+ },
97
+
98
+ set(account, secret) {
99
+ // Interactive mode reads the whole command from stdin, so the secret is
100
+ // never an argv element. (`add-generic-password -w` as the LAST option
101
+ // prompts instead — but on /dev/tty, so it hangs in a real terminal.)
102
+ const line =
103
+ [
104
+ "add-generic-password",
105
+ "-a",
106
+ quoteForSecurity(account, "workspace slug"),
107
+ "-s",
108
+ KEYRING_SERVICE,
109
+ "-U",
110
+ "-w",
111
+ quoteForSecurity(secret, "API key"),
112
+ ].join(" ") + "\n";
113
+ const r = runSecurity(["-i"], line);
114
+ if (r.status !== 0) {
115
+ throw new KeyringError(
116
+ `security add-generic-password failed (exit ${r.status})${r.stderr ? `: ${r.stderr}` : ""}`,
117
+ );
118
+ }
119
+ },
120
+
121
+ delete(account) {
122
+ const r = runSecurity(["delete-generic-password", "-a", account, "-s", KEYRING_SERVICE]);
123
+ if (r.status === 0) return true;
124
+ if (r.status === ERR_SEC_ITEM_NOT_FOUND) return false;
125
+ throw new KeyringError(
126
+ `security delete-generic-password failed (exit ${r.status})${r.stderr ? `: ${r.stderr}` : ""}`,
127
+ );
128
+ },
129
+ };
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Linux: secret-tool (libsecret) — the reference CLI's backend, same attributes
133
+ // ---------------------------------------------------------------------------
134
+
135
+ function runSecretTool(args: string[], input?: string) {
136
+ const r = spawnSync("secret-tool", args, { encoding: "utf8", input });
137
+ if (r.error) {
138
+ throw new KeyringError(
139
+ `Could not run secret-tool (install libsecret, e.g. \`apt install libsecret-tools\`): ${r.error.message}`,
140
+ );
141
+ }
142
+ return { status: r.status ?? -1, stdout: r.stdout ?? "", stderr: (r.stderr ?? "").trim() };
143
+ }
144
+
145
+ export const linuxSecretService: KeyringBackend = {
146
+ name: "secret-service",
147
+ label: "system keyring (Secret Service)",
148
+
149
+ get(account) {
150
+ const r = runSecretTool(["lookup", "service", KEYRING_SERVICE, "account", account]);
151
+ if (r.status === 0) {
152
+ // secret-tool writes the secret verbatim (no newline) when piped.
153
+ return r.stdout.length > 0 ? r.stdout : null;
154
+ }
155
+ // Exit 1 with a silent stderr is "no match"; anything on stderr is a failure.
156
+ if (r.status === 1 && r.stderr === "") return null;
157
+ throw new KeyringError(
158
+ `secret-tool lookup failed (exit ${r.status})${r.stderr ? `: ${r.stderr}` : ""}`,
159
+ );
160
+ },
161
+
162
+ set(account, secret) {
163
+ const r = runSecretTool(
164
+ [
165
+ "store",
166
+ "--label",
167
+ `${KEYRING_SERVICE}: ${account}`,
168
+ "service",
169
+ KEYRING_SERVICE,
170
+ "account",
171
+ account,
172
+ ],
173
+ secret,
174
+ );
175
+ if (r.status !== 0) {
176
+ throw new KeyringError(
177
+ `secret-tool store failed (exit ${r.status})${r.stderr ? `: ${r.stderr}` : ""}`,
178
+ );
179
+ }
180
+ },
181
+
182
+ delete(account) {
183
+ // `clear` succeeds whether or not an item matched, so probe first to
184
+ // report the truth.
185
+ const existed = this.get(account) !== null;
186
+ const r = runSecretTool(["clear", "service", KEYRING_SERVICE, "account", account]);
187
+ if (r.status !== 0) {
188
+ throw new KeyringError(
189
+ `secret-tool clear failed (exit ${r.status})${r.stderr ? `: ${r.stderr}` : ""}`,
190
+ );
191
+ }
192
+ return existed;
193
+ },
194
+ };
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // Selection
198
+ // ---------------------------------------------------------------------------
199
+
200
+ /** Explicit test/application override; `undefined` means automatic detection. */
201
+ let overrideBackend: KeyringBackend | null | undefined;
202
+ /** Automatic detection is cached only while HOME is unchanged. */
203
+ let detectedBackend: KeyringBackend | null | undefined;
204
+ let detectedHome: string | undefined;
205
+
206
+ function detect(): KeyringBackend | null {
207
+ if (process.platform === "darwin") {
208
+ if (!existsSync(SECURITY)) return null;
209
+ // `security add-generic-password` opens a system dialog when the process
210
+ // has no default user keychain (common with an isolated HOME in tests,
211
+ // containers, and launch agents). Probe first so that environment is
212
+ // treated like every other machine without a usable keyring and callers
213
+ // can fall back to the 0600 credential file without surprising UI.
214
+ const probe = spawnSync(SECURITY, ["default-keychain", "-d", "user"], {
215
+ encoding: "utf8",
216
+ });
217
+ return !probe.error && probe.status === 0 ? macosKeychain : null;
218
+ }
219
+ if (process.platform === "linux") {
220
+ // With no arguments secret-tool prints usage and exits 2; a completed run
221
+ // (any status) proves the executable is on PATH.
222
+ const r = spawnSync("secret-tool", [], { encoding: "utf8" });
223
+ return r.error ? null : linuxSecretService;
224
+ }
225
+ return null;
226
+ }
227
+
228
+ /**
229
+ * The keyring for this machine, or null when there is none — in which case
230
+ * callers keep the plaintext file and say nothing, because that is not an
231
+ * error condition, just a platform.
232
+ */
233
+ export function keyring(): KeyringBackend | null {
234
+ if (overrideBackend !== undefined) return overrideBackend;
235
+ const home = process.env.HOME;
236
+ if (detectedBackend === undefined || detectedHome !== home) {
237
+ detectedBackend = detect();
238
+ detectedHome = home;
239
+ }
240
+ return detectedBackend;
241
+ }
242
+
243
+ /**
244
+ * Test seam: install a fake backend (or `null` for "no keyring") so unit tests
245
+ * never touch the developer's real Keychain. `undefined` restores detection.
246
+ */
247
+ export function setKeyringBackend(backend: KeyringBackend | null | undefined): void {
248
+ overrideBackend = backend;
249
+ if (backend === undefined) {
250
+ // Tests routinely swap HOME to an isolated fixture. Do not let a backend
251
+ // detected against the real login keychain escape into that environment.
252
+ detectedBackend = undefined;
253
+ detectedHome = undefined;
254
+ }
255
+ }
256
+
257
+ /** An in-memory backend for tests. */
258
+ export function memoryKeyring(initial: Record<string, string> = {}): KeyringBackend & {
259
+ readonly store: Map<string, string>;
260
+ } {
261
+ const store = new Map(Object.entries(initial));
262
+ return {
263
+ name: "keychain",
264
+ label: "test keyring",
265
+ store,
266
+ get: (a) => store.get(a) ?? null,
267
+ set: (a, s) => void store.set(a, s),
268
+ delete: (a) => store.delete(a),
269
+ };
270
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Mutation payload unwrapping — the one place that decides a mutation worked.
3
+ *
4
+ * Every Linear mutation answers with a payload carrying a non-null
5
+ * `success: Boolean!`; most also carry the entity they touched
6
+ * (`{ success, issue }`, `{ success, projectMilestone }`, …) while a few carry
7
+ * nothing but bookkeeping (`{ success, lastSyncId }` — deletes, archives,
8
+ * subscribe/unsubscribe, notification updates). A service that ignores
9
+ * `success`, or that substitutes the pre-mutation entity when the payload has
10
+ * none, reports a success it never checked: the CLI prints `Updated TES-1` and
11
+ * exits 0 while nothing changed. Both forms go through here instead.
12
+ *
13
+ * `success: false` is the API refusing the write, not the user mistyping, so it
14
+ * is an `api` error (exit 1 — runtime), not a usage error (exit 2).
15
+ */
16
+
17
+ import { CliError } from "./errors.js";
18
+
19
+ /** The shape every Linear mutation payload has in common. */
20
+ export interface MutationPayload {
21
+ success: boolean;
22
+ }
23
+
24
+ const failed = (action: string, why: string) => new CliError(`${action} failed: ${why}.`, "api");
25
+
26
+ /**
27
+ * Assert that a mutation reported success, for the payloads that carry no
28
+ * entity of their own. Returns the payload so a caller can read `lastSyncId`.
29
+ */
30
+ export async function assertMutation<P extends MutationPayload>(
31
+ payload: P | Promise<P>,
32
+ action: string,
33
+ ): Promise<P> {
34
+ const p = await payload;
35
+ if (p?.success !== true) throw failed(action, "the API reported success: false");
36
+ return p;
37
+ }
38
+
39
+ /**
40
+ * Assert success *and* return the entity the payload is supposed to carry.
41
+ * A missing entity on a successful payload is still a failure — we have nothing
42
+ * to report back, and the pre-mutation entity would be a lie.
43
+ */
44
+ export async function unwrapMutation<P extends MutationPayload, K extends keyof P>(
45
+ payload: P | Promise<P>,
46
+ key: K,
47
+ action: string,
48
+ ): Promise<NonNullable<Awaited<P[K]>>> {
49
+ const p = await assertMutation(payload, action);
50
+ const entity = await p[key];
51
+ if (entity == null) throw failed(action, `the API returned no ${String(key)}`);
52
+ return entity as NonNullable<Awaited<P[K]>>;
53
+ }
@@ -0,0 +1,32 @@
1
+ /** Open a Linear URL in the system browser or the macOS Linear app. */
2
+
3
+ import { execFile } from "node:child_process";
4
+ import { CliError, usageError } from "./errors.js";
5
+
6
+ export async function openUrl(url: string, opts: { app?: boolean } = {}): Promise<void> {
7
+ if (opts.app && process.platform !== "darwin") {
8
+ throw usageError("--app currently requires macOS with Linear.app installed; use --web here.");
9
+ }
10
+ const command =
11
+ process.platform === "darwin"
12
+ ? "open"
13
+ : process.platform === "win32"
14
+ ? "rundll32.exe"
15
+ : "xdg-open";
16
+ const args =
17
+ process.platform === "darwin"
18
+ ? opts.app
19
+ ? ["-a", "Linear", url]
20
+ : [url]
21
+ : process.platform === "win32"
22
+ ? ["url.dll,FileProtocolHandler", url]
23
+ : [url];
24
+
25
+ await new Promise<void>((resolve, reject) => {
26
+ execFile(command, args, { encoding: "utf8" }, (err, _stdout, stderr) => {
27
+ if (!err) return resolve();
28
+ const detail = stderr.trim();
29
+ reject(new CliError(detail || `Failed to open ${url}: ${err.message}`, "runtime"));
30
+ });
31
+ });
32
+ }