linear-sdk-cli 0.1.0 → 0.1.1
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/CHANGELOG.md +46 -46
- package/COVERAGE.md +5 -5
- package/MIGRATING.md +69 -71
- package/README.md +88 -96
- package/package.json +3 -4
- package/skills/linear-sdk-cli/SKILL.md +3 -13
- package/skills/linear-sdk-cli/references/api.md +9 -9
- package/skills/linear-sdk-cli/references/auth.md +0 -18
- package/skills/linear-sdk-cli/references/commands.md +2 -2
- package/skills/linear-sdk-cli/references/config.md +11 -9
- package/skills/linear-sdk-cli/references/issue.md +12 -63
- package/skills/linear-sdk-cli/references/team.md +0 -16
- package/skills/linear-sdk-cli/scripts/generate-docs.ts +2 -6
- package/src/bin/linear.ts +6 -16
- package/src/cli.ts +1 -3
- package/src/commands/agent-session.ts +5 -13
- package/src/commands/api.ts +9 -15
- package/src/commands/attach.ts +4 -15
- package/src/commands/comment.ts +2 -6
- package/src/commands/completion.ts +6 -2
- package/src/commands/cycle.ts +1 -6
- package/src/commands/discover.ts +2 -6
- package/src/commands/document.ts +1 -4
- package/src/commands/initiative.ts +1 -5
- package/src/commands/issue.ts +88 -376
- package/src/commands/meta.ts +31 -139
- package/src/commands/milestone.ts +1 -4
- package/src/commands/notification.ts +6 -15
- package/src/commands/project.ts +4 -17
- package/src/commands/team.ts +0 -13
- package/src/commands/webhook.ts +2 -12
- package/src/config.ts +183 -500
- package/src/context.ts +4 -13
- package/src/git.ts +3 -28
- package/src/lib/body.ts +2 -26
- package/src/lib/errors.ts +7 -32
- package/src/lib/introspect.ts +7 -76
- package/src/lib/keyring.ts +9 -51
- package/src/lib/options.ts +12 -108
- package/src/lib/output-shapes.ts +9 -81
- package/src/lib/pagination.ts +17 -50
- package/src/lib/resolve.ts +7 -40
- package/src/lib/shape.ts +12 -16
- package/src/lib/upload.ts +1 -3
- package/src/output/format.ts +18 -96
- package/src/output/table.ts +7 -7
- package/src/services/agent-session.ts +3 -11
- package/src/services/attachment.ts +5 -14
- package/src/services/comment.ts +4 -12
- package/src/services/cycle.ts +4 -11
- package/src/services/document.ts +10 -29
- package/src/services/favorite.ts +9 -15
- package/src/services/initiative-update.ts +15 -16
- package/src/services/initiative.ts +10 -15
- package/src/services/issue.ts +18 -262
- package/src/services/label.ts +2 -12
- package/src/services/milestone.ts +5 -8
- package/src/services/notification.ts +2 -16
- package/src/services/organization.ts +18 -23
- package/src/services/project.ts +34 -55
- package/src/services/roadmap.ts +6 -7
- package/src/services/state.ts +3 -9
- package/src/services/team.ts +24 -67
- package/src/services/user.ts +2 -2
- package/src/services/webhook.ts +17 -28
- package/skills/linear-sdk-cli/references/open.md +0 -29
- package/src/commands/open.ts +0 -84
- package/src/lib/open.ts +0 -32
- package/src/output/color.ts +0 -52
- package/src/output/markdown.ts +0 -133
- package/src/output/pager.ts +0 -131
package/src/context.ts
CHANGED
|
@@ -8,7 +8,6 @@ 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";
|
|
12
11
|
|
|
13
12
|
/**
|
|
14
13
|
* The globals as the parser actually stores them.
|
|
@@ -58,17 +57,10 @@ export class Context {
|
|
|
58
57
|
// of the config (the default team for creates, cycle lookups, …) is
|
|
59
58
|
// single-valued, so those commands read the full list off their own opts
|
|
60
59
|
// and the config sees the first key.
|
|
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,
|
|
60
|
+
flags: { apiKey: options.apiKey, team: firstTeam(options.team), workspace: options.workspace },
|
|
71
61
|
});
|
|
62
|
+
const color =
|
|
63
|
+
options.noAnsi !== true && options.json !== true && process.stdout.isTTY === true;
|
|
72
64
|
// Four independent reasons not to prompt, and any one of them is enough.
|
|
73
65
|
// The first two are the user saying so; the last two are the situation
|
|
74
66
|
// saying so. `--json` counts because JSON is what a script or an agent
|
|
@@ -85,9 +77,8 @@ export class Context {
|
|
|
85
77
|
json: options.json === true,
|
|
86
78
|
color,
|
|
87
79
|
quiet: options.quiet === true,
|
|
88
|
-
debug:
|
|
80
|
+
debug: options.debug === true,
|
|
89
81
|
fields: options.fields,
|
|
90
|
-
isTTY: process.stdout.isTTY === true,
|
|
91
82
|
});
|
|
92
83
|
// Rate-limit waits are status, so they go through the same sink as every
|
|
93
84
|
// other status line: stderr, silenced by --quiet, never on JSON stdout.
|
package/src/git.ts
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { execFileSync } from "node:child_process";
|
|
8
|
-
import { CliError } from "./lib/errors.js";
|
|
9
8
|
|
|
10
9
|
/**
|
|
11
10
|
* Extract a Linear issue identifier (e.g. `TES-123`) from a git branch name.
|
|
@@ -75,11 +74,7 @@ export function buildTrailer(identifier: string, opts: { references?: boolean }
|
|
|
75
74
|
* word directly before the id and links — and closes — the issue. The URL line is
|
|
76
75
|
* for the humans reading the log.
|
|
77
76
|
*/
|
|
78
|
-
export function buildTrailers(
|
|
79
|
-
identifier: string,
|
|
80
|
-
url: string,
|
|
81
|
-
opts: { references?: boolean } = {},
|
|
82
|
-
): string {
|
|
77
|
+
export function buildTrailers(identifier: string, url: string, opts: { references?: boolean } = {}): string {
|
|
83
78
|
return `Linear-issue: ${buildTrailer(identifier, opts)}\nLinear-issue-url: ${url}`;
|
|
84
79
|
}
|
|
85
80
|
|
|
@@ -141,34 +136,14 @@ export interface CheckoutResult {
|
|
|
141
136
|
created: boolean;
|
|
142
137
|
}
|
|
143
138
|
|
|
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
|
-
|
|
164
139
|
/** Checkout `branch`, creating it if it does not exist. */
|
|
165
140
|
export function checkoutBranch(branch: string, cwd?: string): CheckoutResult {
|
|
166
141
|
const exists =
|
|
167
142
|
git(["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], cwd) !== undefined;
|
|
168
143
|
if (exists) {
|
|
169
|
-
|
|
144
|
+
execFileSync("git", ["checkout", branch], { cwd, stdio: "ignore" });
|
|
170
145
|
return { branch, created: false };
|
|
171
146
|
}
|
|
172
|
-
|
|
147
|
+
execFileSync("git", ["checkout", "-b", branch], { cwd, stdio: "ignore" });
|
|
173
148
|
return { branch, created: true };
|
|
174
149
|
}
|
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
|
|
33
|
+
if (inputs.arg !== undefined) return 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,27 +42,6 @@ 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
|
-
|
|
66
45
|
function readBodyFile(file: string): string {
|
|
67
46
|
if (file === "-") return readStdinSync();
|
|
68
47
|
try {
|
|
@@ -87,10 +66,7 @@ export function readStdinSync(): string {
|
|
|
87
66
|
* so it is split shell-style (quotes and backslashes honoured) into argv, and
|
|
88
67
|
* the file is appended. Exported for tests.
|
|
89
68
|
*/
|
|
90
|
-
export function editorCommand(env: NodeJS.ProcessEnv = process.env): {
|
|
91
|
-
raw: string;
|
|
92
|
-
argv: string[];
|
|
93
|
-
} {
|
|
69
|
+
export function editorCommand(env: NodeJS.ProcessEnv = process.env): { raw: string; argv: string[] } {
|
|
94
70
|
const raw = env.VISUAL || env.EDITOR || "vi";
|
|
95
71
|
return { raw, argv: shellSplit(raw) };
|
|
96
72
|
}
|
package/src/lib/errors.ts
CHANGED
|
@@ -50,28 +50,21 @@ 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;
|
|
55
53
|
|
|
56
|
-
constructor(message: string, code: ErrorCode = "runtime", detail?: unknown
|
|
54
|
+
constructor(message: string, code: ErrorCode = "runtime", detail?: unknown) {
|
|
57
55
|
super(message);
|
|
58
56
|
this.name = "CliError";
|
|
59
57
|
this.code = code;
|
|
60
58
|
this.exitCode = EXIT_BY_CODE[code];
|
|
61
59
|
this.detail = detail;
|
|
62
|
-
this.suggestion = suggestion;
|
|
63
60
|
}
|
|
64
61
|
}
|
|
65
62
|
|
|
66
63
|
/** Convenience constructors for the common cases. */
|
|
67
|
-
export const usageError = (m: string,
|
|
68
|
-
|
|
69
|
-
export const
|
|
70
|
-
|
|
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);
|
|
64
|
+
export const usageError = (m: string) => new CliError(m, "usage");
|
|
65
|
+
export const notFound = (m: string) => new CliError(m, "not_found");
|
|
66
|
+
export const ambiguous = (m: string) => new CliError(m, "ambiguous");
|
|
67
|
+
export const authError = (m: string) => new CliError(m, "auth");
|
|
75
68
|
|
|
76
69
|
/**
|
|
77
70
|
* Map an unknown thrown value (typically a Linear SDK error) into a CliError
|
|
@@ -84,10 +77,7 @@ export function normalizeError(err: unknown): CliError {
|
|
|
84
77
|
if (err instanceof Error) {
|
|
85
78
|
const name = err.constructor?.name ?? err.name ?? "";
|
|
86
79
|
const anyErr = err as Record<string, 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
|
-
) ?? [];
|
|
80
|
+
const gqlErrors: any[] = anyErr.errors ?? anyErr.raw?.response?.errors ?? [];
|
|
91
81
|
const type: string | undefined =
|
|
92
82
|
anyErr.type ?? gqlErrors[0]?.extensions?.type ?? gqlErrors[0]?.extensions?.code;
|
|
93
83
|
const message = pickMessage(err, gqlErrors);
|
|
@@ -101,9 +91,7 @@ export function normalizeError(err: unknown): CliError {
|
|
|
101
91
|
// semantically it is a not-found — reclassify so `view <bad-id>` exits 3.
|
|
102
92
|
if (
|
|
103
93
|
(code === "validation" || code === "api") &&
|
|
104
|
-
/could ?n[o']t find|not found|does not exist|no such|referenced \w+\.?$/i.test(
|
|
105
|
-
messageCandidates(err, gqlErrors).join("; "),
|
|
106
|
-
)
|
|
94
|
+
/could ?n[o']t find|not found|does not exist|no such|referenced \w+\.?$/i.test(message)
|
|
107
95
|
) {
|
|
108
96
|
code = "not_found";
|
|
109
97
|
}
|
|
@@ -174,22 +162,9 @@ function classify(name: string, type: string | undefined): ErrorCode {
|
|
|
174
162
|
}
|
|
175
163
|
|
|
176
164
|
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
|
-
|
|
182
165
|
const fromGql = gqlErrors
|
|
183
166
|
.map((e) => e?.message)
|
|
184
167
|
.filter(Boolean)
|
|
185
168
|
.join("; ");
|
|
186
169
|
return fromGql || err.message || "Unknown error";
|
|
187
170
|
}
|
|
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,13 +6,6 @@
|
|
|
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";
|
|
16
9
|
|
|
17
10
|
export interface CommandArgument {
|
|
18
11
|
name: string;
|
|
@@ -23,18 +16,6 @@ export interface CommandArgument {
|
|
|
23
16
|
export interface CommandOption {
|
|
24
17
|
flags: string;
|
|
25
18
|
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
19
|
}
|
|
39
20
|
|
|
40
21
|
export interface CommandNode {
|
|
@@ -77,37 +58,17 @@ export function walkCommands(root: Command): CommandNode[] {
|
|
|
77
58
|
}
|
|
78
59
|
|
|
79
60
|
function describe(cmd: Command, path: string[]): CommandNode {
|
|
80
|
-
const args: CommandArgument[] =
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
})) ?? [];
|
|
61
|
+
const args: CommandArgument[] = (cmd as any).registeredArguments?.map((a: any) => ({
|
|
62
|
+
name: a.name(),
|
|
63
|
+
required: a.required === true,
|
|
64
|
+
variadic: a.variadic === true,
|
|
65
|
+
})) ?? [];
|
|
86
66
|
|
|
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
67
|
const options: CommandOption[] = cmd.options
|
|
93
68
|
.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
|
-
});
|
|
69
|
+
.map((o: any) => ({ flags: o.flags as string, description: (o.description as string) ?? "" }));
|
|
110
70
|
|
|
71
|
+
const joined = path.join(" ");
|
|
111
72
|
const output = outputShapeOf(joined);
|
|
112
73
|
return {
|
|
113
74
|
path: joined,
|
|
@@ -118,33 +79,3 @@ function describe(cmd: Command, path: string[]): CommandNode {
|
|
|
118
79
|
...(output ? { output } : {}),
|
|
119
80
|
};
|
|
120
81
|
}
|
|
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,9 +71,7 @@ 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(
|
|
75
|
-
`The ${what} contains a line break and cannot be stored in the keychain.`,
|
|
76
|
-
);
|
|
74
|
+
throw new KeyringError(`The ${what} contains a line break and cannot be stored in the keychain.`);
|
|
77
75
|
}
|
|
78
76
|
return `"${token.replace(/[\\"]/g, (c) => `\\${c}`)}"`;
|
|
79
77
|
}
|
|
@@ -100,16 +98,7 @@ export const macosKeychain: KeyringBackend = {
|
|
|
100
98
|
// never an argv element. (`add-generic-password -w` as the LAST option
|
|
101
99
|
// prompts instead — but on /dev/tty, so it hangs in a real terminal.)
|
|
102
100
|
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";
|
|
101
|
+
["add-generic-password", "-a", quoteForSecurity(account, "workspace slug"), "-s", KEYRING_SERVICE, "-U", "-w", quoteForSecurity(secret, "API key")].join(" ") + "\n";
|
|
113
102
|
const r = runSecurity(["-i"], line);
|
|
114
103
|
if (r.status !== 0) {
|
|
115
104
|
throw new KeyringError(
|
|
@@ -161,15 +150,7 @@ export const linuxSecretService: KeyringBackend = {
|
|
|
161
150
|
|
|
162
151
|
set(account, secret) {
|
|
163
152
|
const r = runSecretTool(
|
|
164
|
-
[
|
|
165
|
-
"store",
|
|
166
|
-
"--label",
|
|
167
|
-
`${KEYRING_SERVICE}: ${account}`,
|
|
168
|
-
"service",
|
|
169
|
-
KEYRING_SERVICE,
|
|
170
|
-
"account",
|
|
171
|
-
account,
|
|
172
|
-
],
|
|
153
|
+
["store", "--label", `${KEYRING_SERVICE}: ${account}`, "service", KEYRING_SERVICE, "account", account],
|
|
173
154
|
secret,
|
|
174
155
|
);
|
|
175
156
|
if (r.status !== 0) {
|
|
@@ -197,24 +178,12 @@ export const linuxSecretService: KeyringBackend = {
|
|
|
197
178
|
// Selection
|
|
198
179
|
// ---------------------------------------------------------------------------
|
|
199
180
|
|
|
200
|
-
/**
|
|
201
|
-
let
|
|
202
|
-
/** Automatic detection is cached only while HOME is unchanged. */
|
|
203
|
-
let detectedBackend: KeyringBackend | null | undefined;
|
|
204
|
-
let detectedHome: string | undefined;
|
|
181
|
+
/** `undefined` = not decided yet; `null` = decided: no keyring here. */
|
|
182
|
+
let selected: KeyringBackend | null | undefined;
|
|
205
183
|
|
|
206
184
|
function detect(): KeyringBackend | null {
|
|
207
185
|
if (process.platform === "darwin") {
|
|
208
|
-
|
|
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;
|
|
186
|
+
return existsSync(SECURITY) ? macosKeychain : null;
|
|
218
187
|
}
|
|
219
188
|
if (process.platform === "linux") {
|
|
220
189
|
// With no arguments secret-tool prints usage and exits 2; a completed run
|
|
@@ -231,13 +200,8 @@ function detect(): KeyringBackend | null {
|
|
|
231
200
|
* error condition, just a platform.
|
|
232
201
|
*/
|
|
233
202
|
export function keyring(): KeyringBackend | null {
|
|
234
|
-
if (
|
|
235
|
-
|
|
236
|
-
if (detectedBackend === undefined || detectedHome !== home) {
|
|
237
|
-
detectedBackend = detect();
|
|
238
|
-
detectedHome = home;
|
|
239
|
-
}
|
|
240
|
-
return detectedBackend;
|
|
203
|
+
if (selected === undefined) selected = detect();
|
|
204
|
+
return selected;
|
|
241
205
|
}
|
|
242
206
|
|
|
243
207
|
/**
|
|
@@ -245,13 +209,7 @@ export function keyring(): KeyringBackend | null {
|
|
|
245
209
|
* never touch the developer's real Keychain. `undefined` restores detection.
|
|
246
210
|
*/
|
|
247
211
|
export function setKeyringBackend(backend: KeyringBackend | null | undefined): void {
|
|
248
|
-
|
|
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
|
-
}
|
|
212
|
+
selected = backend;
|
|
255
213
|
}
|
|
256
214
|
|
|
257
215
|
/** An in-memory backend for tests. */
|
package/src/lib/options.ts
CHANGED
|
@@ -6,24 +6,12 @@
|
|
|
6
6
|
import { Command, Option } from "commander";
|
|
7
7
|
import { usageError, type CliError } from "./errors.js";
|
|
8
8
|
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
* (`2`, `true`, `null`, arrays/objects); ordinary text stays a string.
|
|
12
|
-
*/
|
|
13
|
-
export function collectKeyVal(
|
|
14
|
-
value: string,
|
|
15
|
-
previous: Record<string, unknown> = {},
|
|
16
|
-
): Record<string, unknown> {
|
|
9
|
+
/** Repeatable `--var k=v` collector → { k: v }. */
|
|
10
|
+
export function collectKeyVal(value: string, previous: Record<string, string> = {}): Record<string, string> {
|
|
17
11
|
const eq = value.indexOf("=");
|
|
18
12
|
if (eq === -1) throw usageError(`Expected key=value, got '${value}'`);
|
|
19
13
|
const key = value.slice(0, eq);
|
|
20
|
-
|
|
21
|
-
const raw = value.slice(eq + 1);
|
|
22
|
-
try {
|
|
23
|
-
previous[key] = JSON.parse(raw);
|
|
24
|
-
} catch {
|
|
25
|
-
previous[key] = raw;
|
|
26
|
-
}
|
|
14
|
+
previous[key] = value.slice(eq + 1);
|
|
27
15
|
return previous;
|
|
28
16
|
}
|
|
29
17
|
|
|
@@ -388,7 +376,6 @@ export const FIELDS_COMMANDS: ReadonlySet<string> = new Set([
|
|
|
388
376
|
"state view",
|
|
389
377
|
"team list",
|
|
390
378
|
"team view",
|
|
391
|
-
"team id",
|
|
392
379
|
"team members",
|
|
393
380
|
"team states",
|
|
394
381
|
"team labels",
|
|
@@ -436,65 +423,6 @@ export const LIMIT_COMMANDS: ReadonlySet<string> = new Set([
|
|
|
436
423
|
"webhook list",
|
|
437
424
|
]);
|
|
438
425
|
|
|
439
|
-
/** Commands where the global team changes resolution, filtering, or a mutation. */
|
|
440
|
-
export const TEAM_COMMANDS: ReadonlySet<string> = new Set([
|
|
441
|
-
"config show",
|
|
442
|
-
"config init",
|
|
443
|
-
"issue view",
|
|
444
|
-
"issue list",
|
|
445
|
-
"issue mine",
|
|
446
|
-
"issue search",
|
|
447
|
-
"issue create",
|
|
448
|
-
"issue update",
|
|
449
|
-
"team view",
|
|
450
|
-
"team members",
|
|
451
|
-
"team states",
|
|
452
|
-
"team labels",
|
|
453
|
-
"team cycles",
|
|
454
|
-
"team update",
|
|
455
|
-
"project list",
|
|
456
|
-
"project create",
|
|
457
|
-
// Registered locally by project update so it can reject the dangerous
|
|
458
|
-
// singular spelling with a tailored `--teams` explanation.
|
|
459
|
-
"project update",
|
|
460
|
-
"cycle list",
|
|
461
|
-
"cycle view",
|
|
462
|
-
"cycle current",
|
|
463
|
-
"cycle create",
|
|
464
|
-
"cycle update",
|
|
465
|
-
"state list",
|
|
466
|
-
"state view",
|
|
467
|
-
"label list",
|
|
468
|
-
"label create",
|
|
469
|
-
"document list",
|
|
470
|
-
"document create",
|
|
471
|
-
"document update",
|
|
472
|
-
"webhook create",
|
|
473
|
-
]);
|
|
474
|
-
|
|
475
|
-
/** Commands whose confirmation prompt is bypassed by `--yes`. */
|
|
476
|
-
export const YES_COMMANDS: ReadonlySet<string> = new Set([
|
|
477
|
-
"attachment delete",
|
|
478
|
-
"auth logout",
|
|
479
|
-
"comment delete",
|
|
480
|
-
"issue comment delete",
|
|
481
|
-
"document delete",
|
|
482
|
-
"favorite remove",
|
|
483
|
-
"initiative archive",
|
|
484
|
-
"initiative remove-project",
|
|
485
|
-
"initiative delete",
|
|
486
|
-
"issue archive",
|
|
487
|
-
"issue delete",
|
|
488
|
-
"label delete",
|
|
489
|
-
"milestone delete",
|
|
490
|
-
"notification archive",
|
|
491
|
-
"project archive",
|
|
492
|
-
"project delete",
|
|
493
|
-
"roadmap delete",
|
|
494
|
-
"team delete",
|
|
495
|
-
"webhook delete",
|
|
496
|
-
]);
|
|
497
|
-
|
|
498
426
|
/** `project list` for the `list` command under `project` — the path without the program name. */
|
|
499
427
|
export function subcommandPath(cmd: Command): string {
|
|
500
428
|
const names: string[] = [];
|
|
@@ -533,9 +461,7 @@ export function assertGlobalsApply(cmd: Command): void {
|
|
|
533
461
|
const files = fileOptionsOf(cmd);
|
|
534
462
|
throw usageError(
|
|
535
463
|
`--fields does not apply to \`linear ${path}\`: it prints a receipt, not a table or detail block to select fields from.` +
|
|
536
|
-
(files.length
|
|
537
|
-
? ` To read from a file, use ${files.join(" or ")} (-f is --fields here, not a file).`
|
|
538
|
-
: ""),
|
|
464
|
+
(files.length ? ` To read from a file, use ${files.join(" or ")} (-f is --fields here, not a file).` : ""),
|
|
539
465
|
);
|
|
540
466
|
}
|
|
541
467
|
if (!LIMIT_COMMANDS.has(path)) {
|
|
@@ -551,12 +477,6 @@ export function assertGlobalsApply(cmd: Command): void {
|
|
|
551
477
|
throw usageError(`--all does not apply to \`linear ${path}\`: it is not a paged query.`);
|
|
552
478
|
}
|
|
553
479
|
}
|
|
554
|
-
if (merged.team !== undefined && !TEAM_COMMANDS.has(path)) {
|
|
555
|
-
throw usageError(`--team does not apply to \`linear ${path}\`.`);
|
|
556
|
-
}
|
|
557
|
-
if (merged.yes === true && !YES_COMMANDS.has(path)) {
|
|
558
|
-
throw usageError(`--yes does not apply to \`linear ${path}\`: it has no confirmation prompt.`);
|
|
559
|
-
}
|
|
560
480
|
}
|
|
561
481
|
|
|
562
482
|
/** Opt-outs for the shared filter sets; `issue mine` is fixed to the viewer. */
|
|
@@ -582,10 +502,9 @@ export interface FilterOptionSet {
|
|
|
582
502
|
*/
|
|
583
503
|
export function addCoreFilterOptions(cmd: Command, set: FilterOptionSet = {}): Command {
|
|
584
504
|
cmd.addOption(
|
|
585
|
-
new Option(
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
).argParser(collectArray),
|
|
505
|
+
new Option("-t, --team <key>", "filter by team key (repeatable; default: configured team)").argParser(
|
|
506
|
+
collectArray,
|
|
507
|
+
),
|
|
589
508
|
);
|
|
590
509
|
cmd.addOption(
|
|
591
510
|
new Option("-s, --state <name>", "filter by workflow state name/type (repeatable)").argParser(
|
|
@@ -602,19 +521,11 @@ export function addCoreFilterOptions(cmd: Command, set: FilterOptionSet = {}): C
|
|
|
602
521
|
new Option("--project-label <name>", "filter by the project's label (excludes --project)"),
|
|
603
522
|
)
|
|
604
523
|
.addOption(new Option("--milestone <name>", "filter by project milestone"))
|
|
605
|
-
.addOption(
|
|
606
|
-
|
|
607
|
-
)
|
|
608
|
-
.addOption(
|
|
609
|
-
new Option("-P, --priority <0-4>", "filter by priority").argParser(parsePriorityFilter),
|
|
610
|
-
)
|
|
524
|
+
.addOption(new Option("-l, --label <name>", "filter by label (repeat to narrow)").argParser(parseList))
|
|
525
|
+
.addOption(new Option("-P, --priority <0-4>", "filter by priority").argParser(parsePriorityFilter))
|
|
611
526
|
.addOption(new Option(CYCLE_FLAG, CYCLE_DESC))
|
|
612
|
-
.addOption(
|
|
613
|
-
|
|
614
|
-
)
|
|
615
|
-
.addOption(
|
|
616
|
-
new Option("--updated-after <date>", "only issues updated at/after a date (YYYY-MM-DD)"),
|
|
617
|
-
)
|
|
527
|
+
.addOption(new Option("--created-after <date>", "only issues created at/after a date (YYYY-MM-DD)"))
|
|
528
|
+
.addOption(new Option("--updated-after <date>", "only issues updated at/after a date (YYYY-MM-DD)"))
|
|
618
529
|
.addOption(new Option("--all-teams", "search every team, ignoring the default team"))
|
|
619
530
|
.addOption(new Option("--include-archived", "include archived issues"));
|
|
620
531
|
}
|
|
@@ -627,14 +538,7 @@ export function addCoreFilterOptions(cmd: Command, set: FilterOptionSet = {}): C
|
|
|
627
538
|
export function addFilterOptions(cmd: Command, set: FilterOptionSet = {}): Command {
|
|
628
539
|
addCoreFilterOptions(cmd, set)
|
|
629
540
|
.addOption(new Option("--query <text>", "full-text search"))
|
|
630
|
-
.addOption(
|
|
631
|
-
new Option("--sort <field>", "sort order").choices([
|
|
632
|
-
"priority",
|
|
633
|
-
"manual",
|
|
634
|
-
"updated",
|
|
635
|
-
"created",
|
|
636
|
-
]),
|
|
637
|
-
);
|
|
541
|
+
.addOption(new Option("--sort <field>", "sort order").choices(["priority", "updated", "created"]));
|
|
638
542
|
// `--search` is the reference CLI's spelling of the same filter.
|
|
639
543
|
return addAliasOption(cmd, "--search <text>", "--query");
|
|
640
544
|
}
|