linear-sdk-cli 0.4.0 → 0.5.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 +17 -0
- package/COVERAGE.md +8 -3
- package/MIGRATING.md +6 -0
- package/README.md +193 -37
- package/package.json +8 -7
- package/skills/linear-sdk-cli/SKILL.md +11 -0
- package/skills/linear-sdk-cli/references/auth.md +4 -1
- package/skills/linear-sdk-cli/references/comment.md +2 -0
- package/skills/linear-sdk-cli/references/config.md +10 -7
- package/skills/linear-sdk-cli/references/initiative.md +10 -5
- package/skills/linear-sdk-cli/references/issue.md +224 -77
- package/skills/linear-sdk-cli/references/notification.md +3 -3
- package/skills/linear-sdk-cli/references/project.md +9 -4
- package/src/cli.ts +1 -0
- package/src/commands/comment.ts +9 -7
- package/src/commands/initiative.ts +23 -6
- package/src/commands/issue.ts +313 -12
- package/src/commands/meta.ts +85 -13
- package/src/commands/notification.ts +5 -3
- package/src/commands/open.ts +2 -1
- package/src/commands/project.ts +21 -3
- package/src/config.ts +124 -39
- package/src/context.ts +21 -1
- package/src/lib/action.ts +8 -0
- package/src/lib/delegation.ts +240 -0
- package/src/lib/options.ts +17 -3
- package/src/lib/output-shapes.ts +78 -3
- package/src/lib/resolve.ts +53 -39
- package/src/output/lifecycle.ts +10 -0
- package/src/services/initiative.ts +12 -3
- package/src/services/issue.ts +498 -21
- package/src/services/project.ts +14 -3
package/src/commands/meta.ts
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
writeCredential,
|
|
13
13
|
probeKeyringCredential,
|
|
14
14
|
adoptKeyringCredential,
|
|
15
|
-
|
|
15
|
+
removeCredentialWithMetadata,
|
|
16
16
|
removeOAuthCredential,
|
|
17
17
|
setDefaultWorkspace,
|
|
18
18
|
listCredentials,
|
|
@@ -24,15 +24,17 @@ import {
|
|
|
24
24
|
defaultProjectConfigPath,
|
|
25
25
|
assertSettableKey,
|
|
26
26
|
setConfigKey,
|
|
27
|
+
setWorkspaceTeam,
|
|
27
28
|
initProjectConfig,
|
|
28
29
|
SETTABLE_KEYS,
|
|
29
30
|
type SettableKey,
|
|
30
31
|
} from "../config.js";
|
|
31
32
|
import { keyring } from "../lib/keyring.js";
|
|
32
33
|
import { readStdinSync } from "../lib/body.js";
|
|
33
|
-
import { authError, normalizeError, usageError } from "../lib/errors.js";
|
|
34
|
+
import { CliError, authError, normalizeError, usageError } from "../lib/errors.js";
|
|
34
35
|
import { confirmDestructive, promptSelect } from "../lib/prompt.js";
|
|
35
36
|
import { listTeams } from "../services/team.js";
|
|
37
|
+
import { resolveTeam } from "../lib/resolve.js";
|
|
36
38
|
import { ISSUE_SORTS } from "../services/issue.js";
|
|
37
39
|
import { firstTeam, type Context } from "../context.js";
|
|
38
40
|
import { openUrl } from "../lib/open.js";
|
|
@@ -105,6 +107,31 @@ export function setAuthValidationClientFactoryForTests(
|
|
|
105
107
|
authValidationClientFactory = factory ?? defaultAuthValidationClientFactory;
|
|
106
108
|
}
|
|
107
109
|
|
|
110
|
+
type WorkspaceTeamValidator = (ctx: Context, workspace: string, team: string) => Promise<string>;
|
|
111
|
+
|
|
112
|
+
/** Validate that an effective credential and team both belong to the requested workspace. */
|
|
113
|
+
export const validateWorkspaceTeam: WorkspaceTeamValidator = async (ctx, workspace, team) => {
|
|
114
|
+
const [organization, resolved] = await Promise.all([
|
|
115
|
+
withRetry(() => ctx.client.organization),
|
|
116
|
+
resolveTeam(ctx.client, team, undefined),
|
|
117
|
+
]);
|
|
118
|
+
if (organization.urlKey !== workspace) {
|
|
119
|
+
throw usageError(
|
|
120
|
+
`--workspace '${workspace}' does not match the credential's workspace '${organization.urlKey}'.`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return resolved.key;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
let workspaceTeamValidator = validateWorkspaceTeam;
|
|
127
|
+
|
|
128
|
+
/** Test seam for profile-team validation; production always checks Linear. */
|
|
129
|
+
export function setWorkspaceTeamValidatorForTests(
|
|
130
|
+
validator: WorkspaceTeamValidator | undefined,
|
|
131
|
+
): void {
|
|
132
|
+
workspaceTeamValidator = validator ?? validateWorkspaceTeam;
|
|
133
|
+
}
|
|
134
|
+
|
|
108
135
|
/** Merge the local and global login spellings without silently choosing one. */
|
|
109
136
|
export function selectLoginKey(
|
|
110
137
|
localKey: string | undefined,
|
|
@@ -150,6 +177,21 @@ export async function validateAuthCredential(
|
|
|
150
177
|
};
|
|
151
178
|
}
|
|
152
179
|
|
|
180
|
+
/** Bind a completed login using the same project location rules as `config set`. */
|
|
181
|
+
function saveLoginProject(ctx: Context, workspace: string, project: boolean): string | null {
|
|
182
|
+
if (!project) return null;
|
|
183
|
+
const path = ctx.config.projectConfigPath ?? defaultProjectConfigPath();
|
|
184
|
+
try {
|
|
185
|
+
setConfigKey(path, "workspace", workspace);
|
|
186
|
+
} catch {
|
|
187
|
+
throw new CliError(
|
|
188
|
+
`Credentials saved for workspace '${workspace}', but its project association could not be saved to ${path}. Fix the project file and run \`linear config set workspace ${workspace}\`; login does not need to be repeated.`,
|
|
189
|
+
"runtime",
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
return path;
|
|
193
|
+
}
|
|
194
|
+
|
|
153
195
|
function explicitWorkspaceSlug(input: string): string {
|
|
154
196
|
const slug = input.trim();
|
|
155
197
|
if (!/^[a-z0-9](?:[a-z0-9-]{0,98}[a-z0-9])?$/.test(slug)) {
|
|
@@ -175,7 +217,8 @@ export function registerMeta(program: Command): void {
|
|
|
175
217
|
// login -------------------------------------------------------------------
|
|
176
218
|
auth
|
|
177
219
|
.command("login")
|
|
178
|
-
.description("Authenticate
|
|
220
|
+
.description("Authenticate and select the workspace for this project")
|
|
221
|
+
.option("--no-project", "save credentials without changing the project workspace")
|
|
179
222
|
.option("--key <key>", "use a personal API key ('-' reads it from stdin)")
|
|
180
223
|
.option("--plaintext", "Store the key in the config file (0600) instead of the system keyring")
|
|
181
224
|
.option("--no-browser", "print the authorization URL instead of opening it")
|
|
@@ -214,6 +257,7 @@ export function registerMeta(program: Command): void {
|
|
|
214
257
|
const { path, storage, keyringLabel } = writeCredential(org.urlKey, key, {
|
|
215
258
|
plaintext: opts.plaintext === true,
|
|
216
259
|
});
|
|
260
|
+
const projectConfigPath = saveLoginProject(ctx, org.urlKey, opts.project !== false);
|
|
217
261
|
const where =
|
|
218
262
|
storage === "keychain"
|
|
219
263
|
? `Credential saved to the ${keyringLabel}; ${path} records the workspace.`
|
|
@@ -226,10 +270,11 @@ export function registerMeta(program: Command): void {
|
|
|
226
270
|
user: me,
|
|
227
271
|
storage,
|
|
228
272
|
path,
|
|
273
|
+
projectConfigPath,
|
|
229
274
|
},
|
|
230
275
|
() =>
|
|
231
276
|
ctx.output.success(
|
|
232
|
-
`Authenticated as ${me.name} <${me.email}> for workspace '${org.urlKey}'. ${where}`,
|
|
277
|
+
`Authenticated as ${me.name} <${me.email}> for workspace '${org.urlKey}'. ${where}${projectConfigPath ? ` Project workspace saved to ${projectConfigPath}.` : ""}`,
|
|
233
278
|
),
|
|
234
279
|
);
|
|
235
280
|
return;
|
|
@@ -300,6 +345,11 @@ export function registerMeta(program: Command): void {
|
|
|
300
345
|
user: identity.user,
|
|
301
346
|
};
|
|
302
347
|
const saved = writeOAuthCredential(credential);
|
|
348
|
+
const projectConfigPath = saveLoginProject(
|
|
349
|
+
ctx,
|
|
350
|
+
credential.workspace.urlKey,
|
|
351
|
+
opts.project !== false,
|
|
352
|
+
);
|
|
303
353
|
ctx.output.emit(
|
|
304
354
|
{
|
|
305
355
|
success: true,
|
|
@@ -310,10 +360,11 @@ export function registerMeta(program: Command): void {
|
|
|
310
360
|
scopes: credential.scopes,
|
|
311
361
|
expiresAt: new Date(credential.expiresAt).toISOString(),
|
|
312
362
|
path: saved.path,
|
|
363
|
+
projectConfigPath,
|
|
313
364
|
},
|
|
314
365
|
() =>
|
|
315
366
|
ctx.output.success(
|
|
316
|
-
`Authenticated as ${credential.user.name} <${credential.user.email}> for workspace '${credential.workspace.urlKey}'. OAuth credentials saved to the ${saved.keyringLabel}
|
|
367
|
+
`Authenticated as ${credential.user.name} <${credential.user.email}> for workspace '${credential.workspace.urlKey}'. OAuth credentials saved to the ${saved.keyringLabel}.${projectConfigPath ? ` Project workspace saved to ${projectConfigPath}.` : ""}`,
|
|
317
368
|
),
|
|
318
369
|
);
|
|
319
370
|
} finally {
|
|
@@ -428,6 +479,7 @@ export function registerMeta(program: Command): void {
|
|
|
428
479
|
.action(
|
|
429
480
|
action(async (ctx) => {
|
|
430
481
|
const c = ctx.config;
|
|
482
|
+
if (c.workspaceChoices) throw c.apiKeyError;
|
|
431
483
|
const credential = c.accessToken ?? c.apiKey;
|
|
432
484
|
const credentialType = c.oauthCredential
|
|
433
485
|
? "oauth-user"
|
|
@@ -529,7 +581,7 @@ export function registerMeta(program: Command): void {
|
|
|
529
581
|
const local =
|
|
530
582
|
entry?.credentialType === "oauth-user"
|
|
531
583
|
? removeOAuthCredential(slug)
|
|
532
|
-
: {
|
|
584
|
+
: { ...removeCredentialWithMetadata(slug), fallbackCredentialType: null };
|
|
533
585
|
ctx.output.emit(
|
|
534
586
|
{
|
|
535
587
|
success: true,
|
|
@@ -537,11 +589,12 @@ export function registerMeta(program: Command): void {
|
|
|
537
589
|
removed: local.removed,
|
|
538
590
|
revocation,
|
|
539
591
|
fallbackCredentialType: local.fallbackCredentialType,
|
|
592
|
+
teamMetadataRemoved: local.teamMetadataRemoved,
|
|
540
593
|
},
|
|
541
594
|
() =>
|
|
542
595
|
local.removed
|
|
543
596
|
? ctx.output.success(
|
|
544
|
-
`Removed workspace '${slug}'.${revocation === "revoked" ? " OAuth access was revoked." : revocation === "already-revoked" ? " OAuth access was already revoked." : ""}${local.fallbackCredentialType ? " The existing personal API-key profile is active again." : ""}`,
|
|
597
|
+
`Removed workspace '${slug}'.${revocation === "revoked" ? " OAuth access was revoked." : revocation === "already-revoked" ? " OAuth access was already revoked." : ""}${local.fallbackCredentialType ? " The existing personal API-key profile is active again." : ""}${local.teamMetadataRemoved ? " Its default team metadata was removed." : ""}`,
|
|
545
598
|
)
|
|
546
599
|
: ctx.output.info(`No credential for workspace '${slug}' to remove.`),
|
|
547
600
|
);
|
|
@@ -589,6 +642,7 @@ export function registerMeta(program: Command): void {
|
|
|
589
642
|
accessToken: redactKey(c.accessToken),
|
|
590
643
|
accessTokenSource: c.accessTokenSource,
|
|
591
644
|
credentialWorkspace: c.credentialWorkspace ?? null,
|
|
645
|
+
workspaceProfile: c.workspaceProfile ?? null,
|
|
592
646
|
team: c.team ?? null,
|
|
593
647
|
workspace: c.workspace ?? null,
|
|
594
648
|
sort: c.sort,
|
|
@@ -601,6 +655,7 @@ export function registerMeta(program: Command): void {
|
|
|
601
655
|
["API key", `${redactKey(c.apiKey)} (${c.apiKeySource})`],
|
|
602
656
|
["OAuth access token", `${redactKey(c.accessToken)} (${c.accessTokenSource})`],
|
|
603
657
|
["Credential workspace", c.credentialWorkspace ?? "(none)"],
|
|
658
|
+
["Workspace profile", c.workspaceProfile ?? "(none)"],
|
|
604
659
|
["Team", show("team", c.team)],
|
|
605
660
|
["Workspace", show("workspace", c.workspace)],
|
|
606
661
|
["Sort", show("sort", c.sort)],
|
|
@@ -630,6 +685,7 @@ export function registerMeta(program: Command): void {
|
|
|
630
685
|
if (!ctx.isTTY) {
|
|
631
686
|
throw usageError("Pass --team <key>; there is no terminal to choose one from.");
|
|
632
687
|
}
|
|
688
|
+
await ctx.selectWorkspace();
|
|
633
689
|
const teams = await listTeams(ctx.client, Infinity);
|
|
634
690
|
if (teams.length === 0) throw usageError("This workspace has no teams to choose from.");
|
|
635
691
|
team = await promptSelect(
|
|
@@ -656,8 +712,8 @@ export function registerMeta(program: Command): void {
|
|
|
656
712
|
// set ---------------------------------------------------------------------
|
|
657
713
|
config
|
|
658
714
|
.command("set <key> <value>")
|
|
659
|
-
.description(`Set one project setting (${SETTABLE_KEYS.join(", ")})
|
|
660
|
-
.option("--user", "write the user config (
|
|
715
|
+
.description(`Set one project or user setting (${SETTABLE_KEYS.join(", ")})`)
|
|
716
|
+
.option("--user", "write the user config (team + --workspace targets that workspace profile)")
|
|
661
717
|
.option("--path <file>", "write this file instead of the project config in effect")
|
|
662
718
|
.action(
|
|
663
719
|
action(async (ctx: Context, opts, key: string, value: string) => {
|
|
@@ -676,10 +732,26 @@ export function registerMeta(program: Command): void {
|
|
|
676
732
|
// here — or a fresh .linear.toml at the git root if there is none.
|
|
677
733
|
path = ctx.config.projectConfigPath ?? defaultProjectConfigPath();
|
|
678
734
|
}
|
|
679
|
-
const
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
735
|
+
const profileWorkspace = opts.user && key === "team" ? ctx.options.workspace : undefined;
|
|
736
|
+
if (profileWorkspace) {
|
|
737
|
+
let team = clean;
|
|
738
|
+
if (ctx.config.apiKey || ctx.config.accessToken) {
|
|
739
|
+
team = await workspaceTeamValidator(ctx, profileWorkspace, clean);
|
|
740
|
+
}
|
|
741
|
+
path = setWorkspaceTeam(profileWorkspace, team);
|
|
742
|
+
ctx.output.emit(
|
|
743
|
+
{ success: true, path, key: "team", value: team, workspace: profileWorkspace },
|
|
744
|
+
() =>
|
|
745
|
+
ctx.output.success(
|
|
746
|
+
`Set team = ${JSON.stringify(team)} for workspace '${profileWorkspace}' in ${path}`,
|
|
747
|
+
),
|
|
748
|
+
);
|
|
749
|
+
} else {
|
|
750
|
+
const spelling = setConfigKey(path, key, clean, { mode });
|
|
751
|
+
ctx.output.emit({ success: true, path, key: spelling, value: clean }, () =>
|
|
752
|
+
ctx.output.success(`Set ${spelling} = ${JSON.stringify(clean)} in ${path}`),
|
|
753
|
+
);
|
|
754
|
+
}
|
|
683
755
|
}),
|
|
684
756
|
);
|
|
685
757
|
}
|
|
@@ -9,9 +9,11 @@ import type { Context } from "../context.js";
|
|
|
9
9
|
import * as svc from "../services/notification.js";
|
|
10
10
|
import type { Column } from "../output/table.js";
|
|
11
11
|
import { ExitCode } from "../lib/errors.js";
|
|
12
|
+
import { addIncludeArchivedOption } from "../lib/options.js";
|
|
13
|
+
import { lifecycleSuffix } from "../output/lifecycle.js";
|
|
12
14
|
|
|
13
15
|
const ROW_COLUMNS: Column<svc.NotificationRow>[] = [
|
|
14
|
-
{ key: "type", header: "Type", value: (r) => r.type
|
|
16
|
+
{ key: "type", header: "Type", value: (r) => `${r.type}${lifecycleSuffix(r)}`, max: 36 },
|
|
15
17
|
{ key: "subject", header: "Subject", value: (r) => r.subject ?? "—", max: 50 },
|
|
16
18
|
{ key: "read", header: "Read", value: (r) => (r.read ? "✓" : "•") },
|
|
17
19
|
{ key: "createdAt", header: "Created", value: (r) => r.createdAt.slice(0, 10) },
|
|
@@ -24,17 +26,17 @@ export function registerNotification(program: Command): void {
|
|
|
24
26
|
.description("Work with your notifications");
|
|
25
27
|
|
|
26
28
|
// list --------------------------------------------------------------------
|
|
27
|
-
notification
|
|
29
|
+
const list = notification
|
|
28
30
|
.command("list")
|
|
29
31
|
.alias("ls")
|
|
30
32
|
.description("List your notifications")
|
|
31
|
-
.option("--include-archived", "include archived notifications")
|
|
32
33
|
.action(
|
|
33
34
|
action(async (ctx: Context, opts) => {
|
|
34
35
|
const rows = await svc.listNotifications(ctx.client, ctx.limit, !!opts.includeArchived);
|
|
35
36
|
ctx.output.list(rows, ROW_COLUMNS, rows);
|
|
36
37
|
}),
|
|
37
38
|
);
|
|
39
|
+
addIncludeArchivedOption(list);
|
|
38
40
|
|
|
39
41
|
// read --------------------------------------------------------------------
|
|
40
42
|
notification
|
package/src/commands/open.ts
CHANGED
|
@@ -46,11 +46,12 @@ async function resolveOpenTarget(
|
|
|
46
46
|
ctx: Context,
|
|
47
47
|
target: string | undefined,
|
|
48
48
|
): Promise<{ target: OpenTarget; url: string; label: string }> {
|
|
49
|
+
if (target && URL.test(target)) return { target: "url", url: target, label: target };
|
|
50
|
+
await ctx.selectWorkspace();
|
|
49
51
|
if (!target) {
|
|
50
52
|
const org = await getOrganizationDetail(ctx.client);
|
|
51
53
|
return { target: "workspace", url: `https://linear.app/${org.urlKey}`, label: org.name };
|
|
52
54
|
}
|
|
53
|
-
if (URL.test(target)) return { target: "url", url: target, label: target };
|
|
54
55
|
if (target.startsWith("team:")) {
|
|
55
56
|
const team = await resolveTeam(ctx.client, target.slice("team:".length), ctx.defaultTeam);
|
|
56
57
|
const org = await getOrganizationDetail(ctx.client);
|
package/src/commands/project.ts
CHANGED
|
@@ -4,7 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
import { Command, Option } from "commander";
|
|
6
6
|
import { action } from "../lib/action.js";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
parseList,
|
|
9
|
+
parseIntOption,
|
|
10
|
+
addAliasOption,
|
|
11
|
+
readAlias,
|
|
12
|
+
addIncludeArchivedOption,
|
|
13
|
+
} from "../lib/options.js";
|
|
8
14
|
import { usageError } from "../lib/errors.js";
|
|
9
15
|
import { resolveBody } from "../lib/body.js";
|
|
10
16
|
import { confirmDestructive, promptInput } from "../lib/prompt.js";
|
|
@@ -12,10 +18,16 @@ import type { Context } from "../context.js";
|
|
|
12
18
|
import * as svc from "../services/project.js";
|
|
13
19
|
import { formatMilestoneProgress } from "./milestone.js";
|
|
14
20
|
import type { Column } from "../output/table.js";
|
|
21
|
+
import { lifecycleSuffix } from "../output/lifecycle.js";
|
|
15
22
|
|
|
16
23
|
const ROW_COLUMNS: Column<svc.ProjectRow>[] = [
|
|
17
24
|
{ key: "name", header: "Name", value: (r) => r.name, max: 36 },
|
|
18
|
-
{
|
|
25
|
+
{
|
|
26
|
+
key: "state",
|
|
27
|
+
header: "State",
|
|
28
|
+
value: (r) => `${r.status?.name ?? r.state ?? "—"}${lifecycleSuffix(r)}`,
|
|
29
|
+
max: 26,
|
|
30
|
+
},
|
|
19
31
|
{ key: "progress", header: "Progress", value: (r) => formatProgress(r.progress) },
|
|
20
32
|
{ key: "lead", header: "Lead", value: (r) => r.lead?.displayName ?? "—", max: 16 },
|
|
21
33
|
{ key: "target", header: "Target", value: (r) => r.targetDate ?? "—" },
|
|
@@ -72,6 +84,7 @@ export function registerProject(program: Command): void {
|
|
|
72
84
|
team: opts.allTeams ? undefined : (opts.team ?? ctx.defaultTeam),
|
|
73
85
|
allTeams: !!opts.allTeams,
|
|
74
86
|
state: readAlias(opts, "--state", "--status"),
|
|
87
|
+
includeArchived: !!opts.includeArchived,
|
|
75
88
|
},
|
|
76
89
|
ctx.limit,
|
|
77
90
|
ctx.defaultTeam,
|
|
@@ -79,6 +92,7 @@ export function registerProject(program: Command): void {
|
|
|
79
92
|
ctx.output.list(rows, ROW_COLUMNS, rows);
|
|
80
93
|
}),
|
|
81
94
|
);
|
|
95
|
+
addIncludeArchivedOption(list);
|
|
82
96
|
// `--status` is the reference CLI's spelling for the same thing; it shipped
|
|
83
97
|
// earlier as a visible duplicate and now goes through the shared alias
|
|
84
98
|
// mechanism, so `--help` shows one canonical spelling here like everywhere else.
|
|
@@ -94,7 +108,11 @@ export function registerProject(program: Command): void {
|
|
|
94
108
|
const detail = await svc.getProjectDetail(ctx.client, idArg);
|
|
95
109
|
ctx.output.detail(detail, [
|
|
96
110
|
["Project", detail.name],
|
|
97
|
-
[
|
|
111
|
+
[
|
|
112
|
+
"Trashed",
|
|
113
|
+
detail.trashed ? `YES (deleted ${detail.archivedAt ?? "at an unknown time"})` : null,
|
|
114
|
+
],
|
|
115
|
+
["Archived", !detail.trashed && detail.archivedAt ? `YES (${detail.archivedAt})` : null],
|
|
98
116
|
["State", detail.status?.name ?? detail.state],
|
|
99
117
|
["Health", detail.health],
|
|
100
118
|
["Progress", detail.progress !== null ? formatProgress(detail.progress) : null],
|