linear-sdk-cli 0.1.3 → 0.2.3
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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +96 -58
- package/COVERAGE.md +5 -5
- package/MIGRATING.md +71 -69
- package/README.md +115 -90
- package/package.json +6 -7
- package/skills/README.md +15 -0
- package/skills/linear-sdk-cli/SKILL.md +27 -3
- package/skills/linear-sdk-cli/references/api.md +9 -9
- package/skills/linear-sdk-cli/references/auth.md +18 -0
- package/skills/linear-sdk-cli/references/commands.md +2 -2
- package/skills/linear-sdk-cli/references/comment.md +9 -6
- package/skills/linear-sdk-cli/references/config.md +9 -11
- package/skills/linear-sdk-cli/references/issue.md +72 -18
- package/skills/linear-sdk-cli/references/open.md +29 -0
- package/skills/linear-sdk-cli/references/team.md +16 -0
- package/skills/linear-sdk-cli/scripts/generate-docs.ts +6 -2
- package/skills/linear-sdk-cli-maintenance/SKILL.md +97 -0
- package/src/bin/linear.ts +16 -6
- package/src/cli.ts +3 -1
- package/src/commands/agent-session.ts +13 -5
- package/src/commands/api.ts +15 -9
- package/src/commands/attach.ts +15 -4
- package/src/commands/comment.ts +50 -11
- package/src/commands/completion.ts +2 -6
- package/src/commands/cycle.ts +6 -1
- package/src/commands/discover.ts +6 -2
- package/src/commands/document.ts +4 -1
- package/src/commands/initiative.ts +5 -1
- package/src/commands/issue.ts +392 -91
- package/src/commands/meta.ts +139 -31
- package/src/commands/milestone.ts +4 -1
- package/src/commands/notification.ts +15 -6
- package/src/commands/open.ts +84 -0
- package/src/commands/project.ts +17 -4
- package/src/commands/team.ts +13 -0
- package/src/commands/user.ts +2 -2
- package/src/commands/webhook.ts +12 -2
- package/src/config.ts +501 -184
- package/src/context.ts +13 -4
- package/src/git.ts +28 -3
- package/src/lib/body.ts +26 -2
- package/src/lib/errors.ts +33 -8
- package/src/lib/introspect.ts +76 -7
- package/src/lib/keyring.ts +51 -9
- package/src/lib/mentions.ts +49 -0
- package/src/lib/open.ts +32 -0
- package/src/lib/options.ts +108 -12
- package/src/lib/output-shapes.ts +81 -9
- package/src/lib/pagination.ts +50 -17
- package/src/lib/resolve.ts +40 -7
- package/src/lib/shape.ts +16 -12
- package/src/lib/upload.ts +3 -1
- package/src/output/color.ts +52 -0
- package/src/output/format.ts +96 -18
- package/src/output/markdown.ts +133 -0
- package/src/output/pager.ts +131 -0
- package/src/output/sanitize.ts +1 -1
- package/src/output/table.ts +7 -7
- package/src/services/agent-session.ts +11 -3
- package/src/services/attachment.ts +14 -5
- package/src/services/comment.ts +37 -9
- package/src/services/cycle.ts +11 -4
- package/src/services/document.ts +29 -10
- package/src/services/favorite.ts +15 -9
- package/src/services/initiative-update.ts +16 -15
- package/src/services/initiative.ts +15 -10
- package/src/services/issue.ts +263 -19
- package/src/services/label.ts +12 -2
- package/src/services/milestone.ts +8 -5
- package/src/services/notification.ts +16 -2
- package/src/services/organization.ts +23 -18
- package/src/services/project.ts +55 -34
- package/src/services/roadmap.ts +7 -6
- package/src/services/state.ts +9 -3
- package/src/services/team.ts +67 -24
- package/src/services/user.ts +2 -2
- package/src/services/webhook.ts +28 -17
package/src/context.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { LinearClient } from "@linear/sdk";
|
|
|
8
8
|
import { resolveConfig, type ResolvedConfig } from "./config.js";
|
|
9
9
|
import { createClient, setRetryReporter } from "./client.js";
|
|
10
10
|
import { Output } from "./output/format.js";
|
|
11
|
+
import { isDebugEnabled, shouldUseColor } from "./output/color.js";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* The globals as the parser actually stores them.
|
|
@@ -57,10 +58,17 @@ export class Context {
|
|
|
57
58
|
// of the config (the default team for creates, cycle lookups, …) is
|
|
58
59
|
// single-valued, so those commands read the full list off their own opts
|
|
59
60
|
// and the config sees the first key.
|
|
60
|
-
flags: {
|
|
61
|
+
flags: {
|
|
62
|
+
apiKey: options.apiKey,
|
|
63
|
+
team: firstTeam(options.team),
|
|
64
|
+
workspace: options.workspace,
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
const color = shouldUseColor({
|
|
68
|
+
disabled: options.noAnsi,
|
|
69
|
+
json: options.json,
|
|
70
|
+
isTTY: process.stdout.isTTY === true,
|
|
61
71
|
});
|
|
62
|
-
const color =
|
|
63
|
-
options.noAnsi !== true && options.json !== true && process.stdout.isTTY === true;
|
|
64
72
|
// Four independent reasons not to prompt, and any one of them is enough.
|
|
65
73
|
// The first two are the user saying so; the last two are the situation
|
|
66
74
|
// saying so. `--json` counts because JSON is what a script or an agent
|
|
@@ -77,8 +85,9 @@ export class Context {
|
|
|
77
85
|
json: options.json === true,
|
|
78
86
|
color,
|
|
79
87
|
quiet: options.quiet === true,
|
|
80
|
-
debug: options.debug
|
|
88
|
+
debug: isDebugEnabled(options.debug),
|
|
81
89
|
fields: options.fields,
|
|
90
|
+
isTTY: process.stdout.isTTY === true,
|
|
82
91
|
});
|
|
83
92
|
// Rate-limit waits are status, so they go through the same sink as every
|
|
84
93
|
// other status line: stderr, silenced by --quiet, never on JSON stdout.
|
package/src/git.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { CliError } from "./lib/errors.js";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Extract a Linear issue identifier (e.g. `TES-123`) from a git branch name.
|
|
@@ -74,7 +75,11 @@ export function buildTrailer(identifier: string, opts: { references?: boolean }
|
|
|
74
75
|
* word directly before the id and links — and closes — the issue. The URL line is
|
|
75
76
|
* for the humans reading the log.
|
|
76
77
|
*/
|
|
77
|
-
export function buildTrailers(
|
|
78
|
+
export function buildTrailers(
|
|
79
|
+
identifier: string,
|
|
80
|
+
url: string,
|
|
81
|
+
opts: { references?: boolean } = {},
|
|
82
|
+
): string {
|
|
78
83
|
return `Linear-issue: ${buildTrailer(identifier, opts)}\nLinear-issue-url: ${url}`;
|
|
79
84
|
}
|
|
80
85
|
|
|
@@ -136,14 +141,34 @@ export interface CheckoutResult {
|
|
|
136
141
|
created: boolean;
|
|
137
142
|
}
|
|
138
143
|
|
|
144
|
+
/** Run the mutating checkout command and retain git's explanation on failure. */
|
|
145
|
+
function gitCheckout(args: string[], cwd?: string): void {
|
|
146
|
+
try {
|
|
147
|
+
execFileSync("git", args, {
|
|
148
|
+
cwd,
|
|
149
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
150
|
+
encoding: "utf8",
|
|
151
|
+
});
|
|
152
|
+
} catch (err) {
|
|
153
|
+
const e = err as NodeJS.ErrnoException & { status?: number; stderr?: Buffer | string };
|
|
154
|
+
if (e.code === "ENOENT")
|
|
155
|
+
throw new CliError("Git is required to check out an issue branch.", "runtime");
|
|
156
|
+
const stderr = e.stderr ? e.stderr.toString().trim() : "";
|
|
157
|
+
throw new CliError(
|
|
158
|
+
stderr || `git ${args.join(" ")} exited with code ${e.status ?? 1}.`,
|
|
159
|
+
"runtime",
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
139
164
|
/** Checkout `branch`, creating it if it does not exist. */
|
|
140
165
|
export function checkoutBranch(branch: string, cwd?: string): CheckoutResult {
|
|
141
166
|
const exists =
|
|
142
167
|
git(["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], cwd) !== undefined;
|
|
143
168
|
if (exists) {
|
|
144
|
-
|
|
169
|
+
gitCheckout(["checkout", branch], cwd);
|
|
145
170
|
return { branch, created: false };
|
|
146
171
|
}
|
|
147
|
-
|
|
172
|
+
gitCheckout(["checkout", "-b", branch], cwd);
|
|
148
173
|
return { branch, created: true };
|
|
149
174
|
}
|
package/src/lib/body.ts
CHANGED
|
@@ -30,7 +30,7 @@ export interface BodyInputs {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
export function resolveBody(inputs: BodyInputs): string | undefined {
|
|
33
|
-
if (inputs.arg !== undefined) return inputs.arg;
|
|
33
|
+
if (inputs.arg !== undefined) return validateInlineBody(inputs.arg);
|
|
34
34
|
if (inputs.file !== undefined) return readBodyFile(inputs.file);
|
|
35
35
|
if (inputs.interactive) return openEditor(inputs.template ?? "");
|
|
36
36
|
if (inputs.editorRequested) {
|
|
@@ -42,6 +42,27 @@ export function resolveBody(inputs: BodyInputs): string | undefined {
|
|
|
42
42
|
return undefined;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* A quoted shell argument does not interpret `\n`. This is an especially easy
|
|
47
|
+
* mistake for an agent composing Markdown in one command: Linear then stores
|
|
48
|
+
* and renders the two literal characters, often across an entire issue body.
|
|
49
|
+
*
|
|
50
|
+
* Do not silently unescape them. A literal backslash-n can be intentional in
|
|
51
|
+
* source code, JSON, or a regular expression, and changing it would corrupt
|
|
52
|
+
* user data. File/stdin input is the unambiguous route for both real newlines
|
|
53
|
+
* and intentional escape syntax.
|
|
54
|
+
*/
|
|
55
|
+
export function validateInlineBody(body: string): string {
|
|
56
|
+
if (body.includes("\\n")) {
|
|
57
|
+
throw usageError(
|
|
58
|
+
"Inline text contains a literal \\n sequence; shell quotes do not turn it into a line break. " +
|
|
59
|
+
"Use this command's *-file option with '-' and provide the Markdown on stdin. " +
|
|
60
|
+
"If the literal \\n is intentional, file input preserves it unchanged.",
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return body;
|
|
64
|
+
}
|
|
65
|
+
|
|
45
66
|
function readBodyFile(file: string): string {
|
|
46
67
|
if (file === "-") return readStdinSync();
|
|
47
68
|
try {
|
|
@@ -66,7 +87,10 @@ export function readStdinSync(): string {
|
|
|
66
87
|
* so it is split shell-style (quotes and backslashes honoured) into argv, and
|
|
67
88
|
* the file is appended. Exported for tests.
|
|
68
89
|
*/
|
|
69
|
-
export function editorCommand(env: NodeJS.ProcessEnv = process.env): {
|
|
90
|
+
export function editorCommand(env: NodeJS.ProcessEnv = process.env): {
|
|
91
|
+
raw: string;
|
|
92
|
+
argv: string[];
|
|
93
|
+
} {
|
|
70
94
|
const raw = env.VISUAL || env.EDITOR || "vi";
|
|
71
95
|
return { raw, argv: shellSplit(raw) };
|
|
72
96
|
}
|
package/src/lib/errors.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Error handling: a single `CliError` type the whole app throws, plus a
|
|
3
3
|
* normalizer that maps Linear SDK errors into stable codes + exit codes.
|
|
4
4
|
*
|
|
5
|
-
* Exit codes (documented in
|
|
5
|
+
* Exit codes (documented in README.md):
|
|
6
6
|
* 0 ok · 1 runtime/API · 2 usage · 3 not-found/ambiguous · 4 auth · 5 rate-limited
|
|
7
7
|
*/
|
|
8
8
|
|
|
@@ -50,21 +50,28 @@ export class CliError extends Error {
|
|
|
50
50
|
readonly exitCode: ExitCodeValue;
|
|
51
51
|
/** Extra detail surfaced only with --debug (e.g. raw GraphQL errors). */
|
|
52
52
|
readonly detail?: unknown;
|
|
53
|
+
/** A separately actionable hint; machine callers never have to parse prose. */
|
|
54
|
+
readonly suggestion?: string;
|
|
53
55
|
|
|
54
|
-
constructor(message: string, code: ErrorCode = "runtime", detail?: unknown) {
|
|
56
|
+
constructor(message: string, code: ErrorCode = "runtime", detail?: unknown, suggestion?: string) {
|
|
55
57
|
super(message);
|
|
56
58
|
this.name = "CliError";
|
|
57
59
|
this.code = code;
|
|
58
60
|
this.exitCode = EXIT_BY_CODE[code];
|
|
59
61
|
this.detail = detail;
|
|
62
|
+
this.suggestion = suggestion;
|
|
60
63
|
}
|
|
61
64
|
}
|
|
62
65
|
|
|
63
66
|
/** Convenience constructors for the common cases. */
|
|
64
|
-
export const usageError = (m: string) =>
|
|
65
|
-
|
|
66
|
-
export const
|
|
67
|
-
|
|
67
|
+
export const usageError = (m: string, suggestion?: string) =>
|
|
68
|
+
new CliError(m, "usage", undefined, suggestion);
|
|
69
|
+
export const notFound = (m: string, suggestion?: string) =>
|
|
70
|
+
new CliError(m, "not_found", undefined, suggestion);
|
|
71
|
+
export const ambiguous = (m: string, suggestion?: string) =>
|
|
72
|
+
new CliError(m, "ambiguous", undefined, suggestion);
|
|
73
|
+
export const authError = (m: string, suggestion?: string) =>
|
|
74
|
+
new CliError(m, "auth", undefined, suggestion);
|
|
68
75
|
|
|
69
76
|
/**
|
|
70
77
|
* Map an unknown thrown value (typically a Linear SDK error) into a CliError
|
|
@@ -77,7 +84,10 @@ export function normalizeError(err: unknown): CliError {
|
|
|
77
84
|
if (err instanceof Error) {
|
|
78
85
|
const name = err.constructor?.name ?? err.name ?? "";
|
|
79
86
|
const anyErr = err as Record<string, any>;
|
|
80
|
-
const gqlErrors: any[] =
|
|
87
|
+
const gqlErrors: any[] =
|
|
88
|
+
[anyErr.errors, anyErr.response?.errors, anyErr.raw?.response?.errors].find(
|
|
89
|
+
(errors) => Array.isArray(errors) && errors.length > 0,
|
|
90
|
+
) ?? [];
|
|
81
91
|
const type: string | undefined =
|
|
82
92
|
anyErr.type ?? gqlErrors[0]?.extensions?.type ?? gqlErrors[0]?.extensions?.code;
|
|
83
93
|
const message = pickMessage(err, gqlErrors);
|
|
@@ -91,7 +101,9 @@ export function normalizeError(err: unknown): CliError {
|
|
|
91
101
|
// semantically it is a not-found — reclassify so `view <bad-id>` exits 3.
|
|
92
102
|
if (
|
|
93
103
|
(code === "validation" || code === "api") &&
|
|
94
|
-
/could ?n[o']t find|not found|does not exist|no such|referenced \w+\.?$/i.test(
|
|
104
|
+
/could ?n[o']t find|not found|does not exist|no such|referenced \w+\.?$/i.test(
|
|
105
|
+
messageCandidates(err, gqlErrors).join("; "),
|
|
106
|
+
)
|
|
95
107
|
) {
|
|
96
108
|
code = "not_found";
|
|
97
109
|
}
|
|
@@ -162,9 +174,22 @@ function classify(name: string, type: string | undefined): ErrorCode {
|
|
|
162
174
|
}
|
|
163
175
|
|
|
164
176
|
function pickMessage(err: Error, gqlErrors: any[]): string {
|
|
177
|
+
const presentable = gqlErrors
|
|
178
|
+
.map((e) => e?.extensions?.userPresentableMessage)
|
|
179
|
+
.find((message) => typeof message === "string" && message.trim().length > 0);
|
|
180
|
+
if (presentable) return presentable;
|
|
181
|
+
|
|
165
182
|
const fromGql = gqlErrors
|
|
166
183
|
.map((e) => e?.message)
|
|
167
184
|
.filter(Boolean)
|
|
168
185
|
.join("; ");
|
|
169
186
|
return fromGql || err.message || "Unknown error";
|
|
170
187
|
}
|
|
188
|
+
|
|
189
|
+
/** All message forms are retained for classification even when a nicer one is displayed. */
|
|
190
|
+
function messageCandidates(err: Error, gqlErrors: any[]): string[] {
|
|
191
|
+
return [
|
|
192
|
+
err.message,
|
|
193
|
+
...gqlErrors.flatMap((error) => [error?.message, error?.extensions?.userPresentableMessage]),
|
|
194
|
+
].filter((message): message is string => typeof message === "string" && message.length > 0);
|
|
195
|
+
}
|
package/src/lib/introspect.ts
CHANGED
|
@@ -6,6 +6,13 @@
|
|
|
6
6
|
import type { Command } from "commander";
|
|
7
7
|
import type { OutputShape } from "./shape.js";
|
|
8
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";
|
|
9
16
|
|
|
10
17
|
export interface CommandArgument {
|
|
11
18
|
name: string;
|
|
@@ -16,6 +23,18 @@ export interface CommandArgument {
|
|
|
16
23
|
export interface CommandOption {
|
|
17
24
|
flags: string;
|
|
18
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;
|
|
19
38
|
}
|
|
20
39
|
|
|
21
40
|
export interface CommandNode {
|
|
@@ -58,17 +77,37 @@ export function walkCommands(root: Command): CommandNode[] {
|
|
|
58
77
|
}
|
|
59
78
|
|
|
60
79
|
function describe(cmd: Command, path: string[]): CommandNode {
|
|
61
|
-
const args: CommandArgument[] =
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
+
})) ?? [];
|
|
66
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());
|
|
67
92
|
const options: CommandOption[] = cmd.options
|
|
68
93
|
.filter((o: any) => !o.hidden)
|
|
69
|
-
.map((o: any) =>
|
|
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
|
+
});
|
|
70
110
|
|
|
71
|
-
const joined = path.join(" ");
|
|
72
111
|
const output = outputShapeOf(joined);
|
|
73
112
|
return {
|
|
74
113
|
path: joined,
|
|
@@ -79,3 +118,33 @@ function describe(cmd: Command, path: string[]): CommandNode {
|
|
|
79
118
|
...(output ? { output } : {}),
|
|
80
119
|
};
|
|
81
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
|
+
}
|
package/src/lib/keyring.ts
CHANGED
|
@@ -71,7 +71,9 @@ function runSecurity(args: string[], input?: string) {
|
|
|
71
71
|
*/
|
|
72
72
|
function quoteForSecurity(token: string, what: string): string {
|
|
73
73
|
if (/[\r\n]/.test(token)) {
|
|
74
|
-
throw new KeyringError(
|
|
74
|
+
throw new KeyringError(
|
|
75
|
+
`The ${what} contains a line break and cannot be stored in the keychain.`,
|
|
76
|
+
);
|
|
75
77
|
}
|
|
76
78
|
return `"${token.replace(/[\\"]/g, (c) => `\\${c}`)}"`;
|
|
77
79
|
}
|
|
@@ -98,7 +100,16 @@ export const macosKeychain: KeyringBackend = {
|
|
|
98
100
|
// never an argv element. (`add-generic-password -w` as the LAST option
|
|
99
101
|
// prompts instead — but on /dev/tty, so it hangs in a real terminal.)
|
|
100
102
|
const line =
|
|
101
|
-
[
|
|
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";
|
|
102
113
|
const r = runSecurity(["-i"], line);
|
|
103
114
|
if (r.status !== 0) {
|
|
104
115
|
throw new KeyringError(
|
|
@@ -150,7 +161,15 @@ export const linuxSecretService: KeyringBackend = {
|
|
|
150
161
|
|
|
151
162
|
set(account, secret) {
|
|
152
163
|
const r = runSecretTool(
|
|
153
|
-
[
|
|
164
|
+
[
|
|
165
|
+
"store",
|
|
166
|
+
"--label",
|
|
167
|
+
`${KEYRING_SERVICE}: ${account}`,
|
|
168
|
+
"service",
|
|
169
|
+
KEYRING_SERVICE,
|
|
170
|
+
"account",
|
|
171
|
+
account,
|
|
172
|
+
],
|
|
154
173
|
secret,
|
|
155
174
|
);
|
|
156
175
|
if (r.status !== 0) {
|
|
@@ -178,12 +197,24 @@ export const linuxSecretService: KeyringBackend = {
|
|
|
178
197
|
// Selection
|
|
179
198
|
// ---------------------------------------------------------------------------
|
|
180
199
|
|
|
181
|
-
/**
|
|
182
|
-
let
|
|
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;
|
|
183
205
|
|
|
184
206
|
function detect(): KeyringBackend | null {
|
|
185
207
|
if (process.platform === "darwin") {
|
|
186
|
-
|
|
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;
|
|
187
218
|
}
|
|
188
219
|
if (process.platform === "linux") {
|
|
189
220
|
// With no arguments secret-tool prints usage and exits 2; a completed run
|
|
@@ -200,8 +231,13 @@ function detect(): KeyringBackend | null {
|
|
|
200
231
|
* error condition, just a platform.
|
|
201
232
|
*/
|
|
202
233
|
export function keyring(): KeyringBackend | null {
|
|
203
|
-
if (
|
|
204
|
-
|
|
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;
|
|
205
241
|
}
|
|
206
242
|
|
|
207
243
|
/**
|
|
@@ -209,7 +245,13 @@ export function keyring(): KeyringBackend | null {
|
|
|
209
245
|
* never touch the developer's real Keychain. `undefined` restores detection.
|
|
210
246
|
*/
|
|
211
247
|
export function setKeyringBackend(backend: KeyringBackend | null | undefined): void {
|
|
212
|
-
|
|
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
|
+
}
|
|
213
255
|
}
|
|
214
256
|
|
|
215
257
|
/** An in-memory backend for tests. */
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { LinearClient } from "@linear/sdk";
|
|
2
|
+
import { withRetry } from "../client.js";
|
|
3
|
+
import { usageError } from "./errors.js";
|
|
4
|
+
import { resolveUserId } from "./resolve.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Prepend explicit Linear mentions to a Markdown body.
|
|
8
|
+
*
|
|
9
|
+
* Linear stores a real user mention as `@[label](user-id)`. Ordinary `@name`
|
|
10
|
+
* text is deliberately not parsed: it is prose unless the caller also passes
|
|
11
|
+
* `--mention <user>`. Keeping the intent in an option prevents an innocent
|
|
12
|
+
* name in a pasted body from notifying somebody.
|
|
13
|
+
*/
|
|
14
|
+
export async function prependMentions(
|
|
15
|
+
client: LinearClient,
|
|
16
|
+
body: string,
|
|
17
|
+
references: string[] = [],
|
|
18
|
+
): Promise<string> {
|
|
19
|
+
if (references.length === 0) return body;
|
|
20
|
+
|
|
21
|
+
const mentions: string[] = [];
|
|
22
|
+
const seen = new Set<string>();
|
|
23
|
+
for (const raw of references) {
|
|
24
|
+
const reference = normalizeMentionReference(raw);
|
|
25
|
+
const id = await resolveUserId(client, reference);
|
|
26
|
+
if (seen.has(id)) continue;
|
|
27
|
+
seen.add(id);
|
|
28
|
+
|
|
29
|
+
const user = await withRetry(() => client.user(id));
|
|
30
|
+
const label = escapeMentionLabel(user.displayName);
|
|
31
|
+
mentions.push(`@[${label}](${id})`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const prefix = mentions.join(" ");
|
|
35
|
+
return body ? `${prefix}\n\n${body}` : prefix;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Accept the natural `@ada` spelling without confusing it with an email. */
|
|
39
|
+
export function normalizeMentionReference(raw: string): string {
|
|
40
|
+
const reference = raw.trim();
|
|
41
|
+
if (!reference) throw usageError("--mention requires a non-empty user reference.");
|
|
42
|
+
if (reference.startsWith("@") && reference !== "@me") return reference.slice(1);
|
|
43
|
+
return reference;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Escape the Markdown-link label without changing Linear's mention grammar. */
|
|
47
|
+
export function escapeMentionLabel(label: string): string {
|
|
48
|
+
return label.replace(/\\/g, "\\\\").replace(/\]/g, "\\]");
|
|
49
|
+
}
|
package/src/lib/open.ts
ADDED
|
@@ -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
|
+
}
|