linear-sdk-cli 0.5.3 → 0.7.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.
- package/CHANGELOG.md +17 -0
- package/README.md +10 -1
- package/package.json +1 -1
- package/skills/linear-sdk-cli/SKILL.md +29 -0
- package/skills/linear-sdk-cli/references/auth.md +4 -0
- package/skills/linear-sdk-cli/references/changelog.md +24 -0
- package/skills/linear-sdk-cli/references/project-label.md +170 -0
- package/skills/linear-sdk-cli/references/project-status.md +158 -0
- package/skills/linear-sdk-cli/references/project.md +35 -35
- package/skills/linear-sdk-cli/references/whoami.md +1 -0
- package/src/cli.ts +6 -0
- package/src/commands/changelog.ts +108 -0
- package/src/commands/meta.ts +18 -8
- package/src/commands/project-label.ts +144 -0
- package/src/commands/project-status.ts +166 -0
- package/src/commands/project.ts +10 -2
- package/src/lib/action.ts +7 -2
- package/src/lib/authorization.ts +91 -0
- package/src/lib/errors.ts +11 -2
- package/src/lib/options.ts +11 -1
- package/src/lib/output-shapes.ts +49 -0
- package/src/lib/resolve.ts +24 -7
- package/src/output/format.ts +1 -0
- package/src/services/project-label.ts +238 -0
- package/src/services/project-status.ts +225 -0
- package/src/services/project.ts +5 -25
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/** Show release notes from the CHANGELOG.md bundled with the CLI package. */
|
|
2
|
+
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
import { action } from "../lib/action.js";
|
|
7
|
+
import { CliError } from "../lib/errors.js";
|
|
8
|
+
import type { Context } from "../context.js";
|
|
9
|
+
|
|
10
|
+
export interface ChangelogSection {
|
|
11
|
+
title: string;
|
|
12
|
+
items: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ChangelogRelease {
|
|
16
|
+
version: string;
|
|
17
|
+
date: string;
|
|
18
|
+
url: string;
|
|
19
|
+
sections: ChangelogSection[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const CHANGELOG_PATH = fileURLToPath(new URL("../../CHANGELOG.md", import.meta.url));
|
|
23
|
+
const DEFAULT_RELEASE_COUNT = 3;
|
|
24
|
+
|
|
25
|
+
/** Parse the Release Please Markdown format into a stable machine-readable shape. */
|
|
26
|
+
export function parseChangelog(markdown: string): ChangelogRelease[] {
|
|
27
|
+
const releases: ChangelogRelease[] = [];
|
|
28
|
+
const releasePattern = /^## \[([^\]]+)]\(([^)]+)\) \((\d{4}-\d{2}-\d{2})\)\s*$/gm;
|
|
29
|
+
const matches = [...markdown.matchAll(releasePattern)];
|
|
30
|
+
|
|
31
|
+
for (const [index, match] of matches.entries()) {
|
|
32
|
+
const bodyStart = (match.index ?? 0) + match[0].length;
|
|
33
|
+
const bodyEnd = matches[index + 1]?.index ?? markdown.length;
|
|
34
|
+
const body = markdown.slice(bodyStart, bodyEnd);
|
|
35
|
+
const sections: ChangelogSection[] = [];
|
|
36
|
+
const sectionPattern = /^### (.+)\s*$/gm;
|
|
37
|
+
const sectionMatches = [...body.matchAll(sectionPattern)];
|
|
38
|
+
|
|
39
|
+
for (const [sectionIndex, sectionMatch] of sectionMatches.entries()) {
|
|
40
|
+
const itemsStart = (sectionMatch.index ?? 0) + sectionMatch[0].length;
|
|
41
|
+
const itemsEnd = sectionMatches[sectionIndex + 1]?.index ?? body.length;
|
|
42
|
+
const itemsBody = body.slice(itemsStart, itemsEnd).trim();
|
|
43
|
+
const items = itemsBody
|
|
44
|
+
.split(/\n(?=- )/)
|
|
45
|
+
.map((item) => item.replace(/^- /, "").replace(/\n\s+/g, " ").trim())
|
|
46
|
+
.filter(Boolean);
|
|
47
|
+
sections.push({ title: sectionMatch[1]!.trim(), items });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
releases.push({
|
|
51
|
+
version: match[1]!,
|
|
52
|
+
url: match[2]!,
|
|
53
|
+
date: match[3]!,
|
|
54
|
+
sections,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return releases;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function registerChangelog(program: Command): void {
|
|
62
|
+
program
|
|
63
|
+
.command("changelog")
|
|
64
|
+
.description("Show recent CLI release notes")
|
|
65
|
+
.addHelpText(
|
|
66
|
+
"after",
|
|
67
|
+
[
|
|
68
|
+
"",
|
|
69
|
+
"Examples:",
|
|
70
|
+
" linear changelog # latest three releases",
|
|
71
|
+
" linear changelog --limit 1 # latest release only",
|
|
72
|
+
" linear changelog --all # complete bundled history",
|
|
73
|
+
" linear changelog --json # structured release notes",
|
|
74
|
+
].join("\n"),
|
|
75
|
+
)
|
|
76
|
+
.action(action(runChangelog));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function runChangelog(ctx: Context): void {
|
|
80
|
+
let markdown: string;
|
|
81
|
+
try {
|
|
82
|
+
markdown = readFileSync(CHANGELOG_PATH, "utf8");
|
|
83
|
+
} catch (error) {
|
|
84
|
+
throw new CliError(`Cannot read the bundled changelog: ${(error as Error).message}`, "runtime");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const releases = parseChangelog(markdown);
|
|
88
|
+
const count =
|
|
89
|
+
ctx.options.all || ctx.options.limit === 0
|
|
90
|
+
? releases.length
|
|
91
|
+
: (ctx.options.limit ?? DEFAULT_RELEASE_COUNT);
|
|
92
|
+
const recent = releases.slice(0, count);
|
|
93
|
+
|
|
94
|
+
ctx.output.emit(recent, () => {
|
|
95
|
+
const rendered = recent
|
|
96
|
+
.map((release) => {
|
|
97
|
+
const sections = release.sections
|
|
98
|
+
.map(
|
|
99
|
+
(section) =>
|
|
100
|
+
`### ${section.title}\n\n${section.items.map((item) => `- ${item}`).join("\n")}`,
|
|
101
|
+
)
|
|
102
|
+
.join("\n\n");
|
|
103
|
+
return `## [${release.version}](${release.url}) (${release.date})\n\n${sections}`;
|
|
104
|
+
})
|
|
105
|
+
.join("\n\n");
|
|
106
|
+
ctx.output.markdown(rendered);
|
|
107
|
+
});
|
|
108
|
+
}
|
package/src/commands/meta.ts
CHANGED
|
@@ -38,6 +38,10 @@ import { resolveTeam } from "../lib/resolve.js";
|
|
|
38
38
|
import { ISSUE_SORTS } from "../services/issue.js";
|
|
39
39
|
import { firstTeam, type Context } from "../context.js";
|
|
40
40
|
import { openUrl } from "../lib/open.js";
|
|
41
|
+
import {
|
|
42
|
+
authorizationCapabilities,
|
|
43
|
+
credentialType as resolvedCredentialType,
|
|
44
|
+
} from "../lib/authorization.js";
|
|
41
45
|
import {
|
|
42
46
|
DEFAULT_OAUTH_SCOPES,
|
|
43
47
|
buildAuthorizationUrl,
|
|
@@ -59,6 +63,7 @@ export const DEFAULT_OAUTH_CLIENT_ID = "eda60862e4bb8b8af82cfb3193b65c2f";
|
|
|
59
63
|
const whoamiAction = action(async (ctx: Context) => {
|
|
60
64
|
const me = await withRetry(() => ctx.client.viewer);
|
|
61
65
|
const org = await withRetry(() => ctx.client.organization);
|
|
66
|
+
const authorization = authorizationCapabilities(ctx.config);
|
|
62
67
|
ctx.output.detail(
|
|
63
68
|
{
|
|
64
69
|
id: me.id,
|
|
@@ -66,13 +71,17 @@ const whoamiAction = action(async (ctx: Context) => {
|
|
|
66
71
|
displayName: me.displayName,
|
|
67
72
|
email: me.email,
|
|
68
73
|
admin: me.admin,
|
|
74
|
+
authorization,
|
|
69
75
|
organization: { id: org.id, name: org.name, urlKey: org.urlKey },
|
|
70
76
|
},
|
|
71
77
|
[
|
|
72
78
|
["Name", me.name],
|
|
73
79
|
["Display name", me.displayName],
|
|
74
80
|
["Email", me.email],
|
|
75
|
-
["
|
|
81
|
+
["Workspace admin", me.admin],
|
|
82
|
+
["Credential type", authorization.credentialType ?? "(none)"],
|
|
83
|
+
["Credential scopes", authorization.scopes?.join(", ") ?? "unknown"],
|
|
84
|
+
["Credential admin scope", authorization.adminScope ?? "unknown"],
|
|
76
85
|
["User ID", me.id],
|
|
77
86
|
["Organization", `${org.name} (${org.urlKey})`],
|
|
78
87
|
],
|
|
@@ -481,13 +490,8 @@ export function registerMeta(program: Command): void {
|
|
|
481
490
|
const c = ctx.config;
|
|
482
491
|
if (c.workspaceChoices) throw c.apiKeyError;
|
|
483
492
|
const credential = c.accessToken ?? c.apiKey;
|
|
484
|
-
const credentialType = c
|
|
485
|
-
|
|
486
|
-
: c.accessToken
|
|
487
|
-
? "oauth-access-token"
|
|
488
|
-
: c.apiKey
|
|
489
|
-
? "api-key"
|
|
490
|
-
: null;
|
|
493
|
+
const credentialType = resolvedCredentialType(c);
|
|
494
|
+
const authorization = authorizationCapabilities(c);
|
|
491
495
|
const source = c.accessToken ? c.accessTokenSource : c.apiKeySource;
|
|
492
496
|
const backend = keyring();
|
|
493
497
|
ctx.output.detail(
|
|
@@ -502,6 +506,9 @@ export function registerMeta(program: Command): void {
|
|
|
502
506
|
expiresAt: c.oauthCredential
|
|
503
507
|
? new Date(c.oauthCredential.expiresAt).toISOString()
|
|
504
508
|
: null,
|
|
509
|
+
scopeVisibility: authorization.scopeVisibility,
|
|
510
|
+
adminScope: authorization.adminScope,
|
|
511
|
+
scopeNote: authorization.note,
|
|
505
512
|
},
|
|
506
513
|
[
|
|
507
514
|
["Authenticated", !!credential],
|
|
@@ -511,6 +518,9 @@ export function registerMeta(program: Command): void {
|
|
|
511
518
|
["Credential", redactKey(credential)],
|
|
512
519
|
["Keyring", backend ? backend.label : "(none on this platform)"],
|
|
513
520
|
["Scopes", c.oauthCredential?.scopes.join(", ")],
|
|
521
|
+
["Scope visibility", authorization.scopeVisibility],
|
|
522
|
+
["Admin scope", authorization.adminScope ?? "unknown"],
|
|
523
|
+
["Scope note", authorization.note],
|
|
514
524
|
[
|
|
515
525
|
"Expires",
|
|
516
526
|
c.oauthCredential ? new Date(c.oauthCredential.expiresAt).toISOString() : undefined,
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/** `linear project-label` — manage labels that apply to projects, not issues. */
|
|
2
|
+
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import type { Context } from "../context.js";
|
|
5
|
+
import { action } from "../lib/action.js";
|
|
6
|
+
import { confirmDestructive, promptInput } from "../lib/prompt.js";
|
|
7
|
+
import type { Column } from "../output/table.js";
|
|
8
|
+
import * as svc from "../services/project-label.js";
|
|
9
|
+
|
|
10
|
+
const COLUMNS: Column<svc.ProjectLabelRow>[] = [
|
|
11
|
+
{ key: "name", header: "Name", value: (label) => label.name, max: 30 },
|
|
12
|
+
{ key: "color", header: "Color", value: (label) => label.color },
|
|
13
|
+
{ key: "group", header: "Group", value: (label) => (label.isGroup ? "yes" : "—") },
|
|
14
|
+
{ key: "parent", header: "Parent", value: (label) => label.parent?.name ?? "—" },
|
|
15
|
+
{ key: "retired", header: "Retired", value: (label) => (label.retiredAt ? "yes" : "—") },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const receipt = (label: { id: string; name: string; color: string }) => ({
|
|
19
|
+
id: label.id,
|
|
20
|
+
name: label.name,
|
|
21
|
+
color: label.color,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export function registerProjectLabel(program: Command): void {
|
|
25
|
+
const labels = program
|
|
26
|
+
.command("project-label")
|
|
27
|
+
.alias("pl")
|
|
28
|
+
.description("Manage project labels (distinct from issue labels)");
|
|
29
|
+
|
|
30
|
+
labels
|
|
31
|
+
.command("list")
|
|
32
|
+
.alias("ls")
|
|
33
|
+
.description("List project labels")
|
|
34
|
+
.option("--include-retired", "include labels retired from new project assignment")
|
|
35
|
+
.action(
|
|
36
|
+
action(async (ctx: Context, opts) => {
|
|
37
|
+
const rows = await svc.listProjectLabels(ctx.client, ctx.limit, !!opts.includeRetired);
|
|
38
|
+
ctx.output.list(rows, COLUMNS, rows);
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
labels
|
|
43
|
+
.command("view <name-or-id>")
|
|
44
|
+
.description("View a project label or label group by exact name or id")
|
|
45
|
+
.action(
|
|
46
|
+
action(async (ctx: Context, _opts, input: string) => {
|
|
47
|
+
const label = await svc.getProjectLabel(ctx.client, input);
|
|
48
|
+
ctx.output.emit(label, () =>
|
|
49
|
+
ctx.output.detail(label.name, [
|
|
50
|
+
["ID", label.id],
|
|
51
|
+
["Color", label.color],
|
|
52
|
+
["Description", label.description],
|
|
53
|
+
["Group", label.isGroup ? "yes" : "no"],
|
|
54
|
+
["Parent", label.parent?.name ?? null],
|
|
55
|
+
["Retired", label.retiredAt],
|
|
56
|
+
[
|
|
57
|
+
"Children",
|
|
58
|
+
label.children.length ? label.children.map((child) => child.name).join(", ") : null,
|
|
59
|
+
],
|
|
60
|
+
]),
|
|
61
|
+
);
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
labels
|
|
66
|
+
.command("create")
|
|
67
|
+
.alias("new")
|
|
68
|
+
.description("Create a project label or label group")
|
|
69
|
+
.option("--name <name>", "project label name")
|
|
70
|
+
.option("--color <hex>", "label color (six-digit hex, e.g. #5E6AD2)")
|
|
71
|
+
.option("-d, --description <text>", "label description")
|
|
72
|
+
.option("--group", "create a non-assignable label group")
|
|
73
|
+
.option("--parent <name-or-id>", "put the label in this group")
|
|
74
|
+
.action(
|
|
75
|
+
action(async (ctx: Context, opts) => {
|
|
76
|
+
let name: string | undefined = opts.name;
|
|
77
|
+
if (!name) name = await promptInput(ctx, "Name:", { required: true });
|
|
78
|
+
const label = await svc.createProjectLabel(ctx.client, { ...opts, name });
|
|
79
|
+
ctx.output.emit(receipt(label), () =>
|
|
80
|
+
ctx.output.success(`Created project label ${label.name}`),
|
|
81
|
+
);
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
labels
|
|
86
|
+
.command("update <name-or-id>")
|
|
87
|
+
.alias("edit")
|
|
88
|
+
.description("Update a project label by exact name or id")
|
|
89
|
+
.option("--name <name>", "new name")
|
|
90
|
+
.option("--color <hex>", "new color (six-digit hex, e.g. #5E6AD2)")
|
|
91
|
+
.option("-d, --description <text>", "new description")
|
|
92
|
+
.option("--parent <name-or-id>", "move the label into this group")
|
|
93
|
+
.option("--clear-parent", "remove the label from its group")
|
|
94
|
+
.action(
|
|
95
|
+
action(async (ctx: Context, opts, input: string) => {
|
|
96
|
+
const label = await svc.updateProjectLabel(ctx.client, input, opts);
|
|
97
|
+
ctx.output.emit(receipt(label), () =>
|
|
98
|
+
ctx.output.success(`Updated project label ${label.name}`),
|
|
99
|
+
);
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
labels
|
|
104
|
+
.command("retire <name-or-id>")
|
|
105
|
+
.description("Retire a label so it cannot be assigned to new projects")
|
|
106
|
+
.action(
|
|
107
|
+
action(async (ctx: Context, _opts, input: string) => {
|
|
108
|
+
const current = await svc.getProjectLabel(ctx.client, input);
|
|
109
|
+
if (!(await confirmDestructive(ctx, `Retire project label ${current.name}?`))) return;
|
|
110
|
+
const label = await svc.retireProjectLabel(ctx.client, current.id);
|
|
111
|
+
ctx.output.emit({ id: label.id, name: label.name, retired: true }, () =>
|
|
112
|
+
ctx.output.success(`Retired project label ${label.name}`),
|
|
113
|
+
);
|
|
114
|
+
}),
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
labels
|
|
118
|
+
.command("restore <name-or-id>")
|
|
119
|
+
.alias("unretire")
|
|
120
|
+
.description("Restore a retired project label")
|
|
121
|
+
.action(
|
|
122
|
+
action(async (ctx: Context, _opts, input: string) => {
|
|
123
|
+
const label = await svc.restoreProjectLabel(ctx.client, input);
|
|
124
|
+
ctx.output.emit({ id: label.id, name: label.name, restored: true }, () =>
|
|
125
|
+
ctx.output.success(`Restored project label ${label.name}`),
|
|
126
|
+
);
|
|
127
|
+
}),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
labels
|
|
131
|
+
.command("delete <name-or-id>")
|
|
132
|
+
.alias("rm")
|
|
133
|
+
.description("Permanently delete a project label")
|
|
134
|
+
.action(
|
|
135
|
+
action(async (ctx: Context, _opts, input: string) => {
|
|
136
|
+
const current = await svc.getProjectLabel(ctx.client, input);
|
|
137
|
+
if (!(await confirmDestructive(ctx, `Delete project label ${current.name}?`))) return;
|
|
138
|
+
const label = await svc.deleteProjectLabel(ctx.client, current.id);
|
|
139
|
+
ctx.output.emit({ id: label.id, name: label.name, deleted: true }, () =>
|
|
140
|
+
ctx.output.success(`Deleted project label ${label.name}`),
|
|
141
|
+
);
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/** `linear project-status` — manage workspace project-status definitions. */
|
|
2
|
+
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import type { Context } from "../context.js";
|
|
5
|
+
import { action } from "../lib/action.js";
|
|
6
|
+
import { resolveBody } from "../lib/body.js";
|
|
7
|
+
import { usageError } from "../lib/errors.js";
|
|
8
|
+
import { confirmDestructive, promptInput } from "../lib/prompt.js";
|
|
9
|
+
import type { Column } from "../output/table.js";
|
|
10
|
+
import * as svc from "../services/project-status.js";
|
|
11
|
+
|
|
12
|
+
const ROW_COLUMNS: Column<svc.ProjectStatusRow>[] = [
|
|
13
|
+
{ key: "name", header: "Name", value: (r) => r.name, max: 28 },
|
|
14
|
+
{ key: "type", header: "Type", value: (r) => r.type },
|
|
15
|
+
{ key: "position", header: "Pos", value: (r) => r.position },
|
|
16
|
+
{ key: "color", header: "Color", value: (r) => r.color },
|
|
17
|
+
{ key: "indefinite", header: "Indefinite", value: (r) => (r.indefinite ? "yes" : "no") },
|
|
18
|
+
{ key: "archived", header: "Archived", value: (r) => (r.archivedAt ? "yes" : "no") },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export function registerProjectStatus(program: Command): void {
|
|
22
|
+
const status = program
|
|
23
|
+
.command("project-status")
|
|
24
|
+
.alias("project-statuses")
|
|
25
|
+
.description("Manage workspace project-status definitions");
|
|
26
|
+
|
|
27
|
+
status
|
|
28
|
+
.command("list")
|
|
29
|
+
.alias("ls")
|
|
30
|
+
.description("List workspace project statuses")
|
|
31
|
+
.option("--include-archived", "include archived project statuses")
|
|
32
|
+
.action(
|
|
33
|
+
action(async (ctx: Context, opts) => {
|
|
34
|
+
const rows = await svc.listProjectStatuses(ctx.client, ctx.limit, !!opts.includeArchived);
|
|
35
|
+
ctx.output.list(rows, ROW_COLUMNS, rows);
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
status
|
|
40
|
+
.command("view <name-or-id>")
|
|
41
|
+
.description("Show a project status by exact name or id")
|
|
42
|
+
.action(
|
|
43
|
+
action(async (ctx: Context, _opts, input: string) => {
|
|
44
|
+
const detail = await svc.getProjectStatusDetail(ctx.client, input);
|
|
45
|
+
ctx.output.detail(detail, [
|
|
46
|
+
["Project status", detail.name],
|
|
47
|
+
["ID", detail.id],
|
|
48
|
+
["Type", detail.type],
|
|
49
|
+
["Position", detail.position],
|
|
50
|
+
["Color", detail.color],
|
|
51
|
+
["Indefinite", detail.indefinite],
|
|
52
|
+
["Archived", detail.archivedAt],
|
|
53
|
+
["Description", detail.description ? `\n${detail.description}` : null],
|
|
54
|
+
["Created", detail.createdAt],
|
|
55
|
+
["Updated", detail.updatedAt],
|
|
56
|
+
]);
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
status
|
|
61
|
+
.command("create")
|
|
62
|
+
.alias("new")
|
|
63
|
+
.description("Create a workspace project status (explicit because duplicate names are valid)")
|
|
64
|
+
.option("--name <name>", "status name")
|
|
65
|
+
.option("-d, --description <text>", "status description")
|
|
66
|
+
.option("--description-file <path>", "read description from a file ('-' = stdin)")
|
|
67
|
+
.option("--color <hex>", "six-digit hex color (e.g. #5E6AD2)")
|
|
68
|
+
.option("--position <number>", "position within the status type", parseNumber)
|
|
69
|
+
.option("--type <type>", `status type (${svc.PROJECT_STATUS_TYPES.join("|")})`)
|
|
70
|
+
.option("--indefinite", "allow projects to remain in this status indefinitely")
|
|
71
|
+
.action(
|
|
72
|
+
action(async (ctx: Context, opts) => {
|
|
73
|
+
let name: string | undefined = opts.name;
|
|
74
|
+
if (!name) name = await promptInput(ctx, "Name:", { required: true });
|
|
75
|
+
const created = await svc.createProjectStatus(ctx.client, {
|
|
76
|
+
name,
|
|
77
|
+
description: resolveBody({
|
|
78
|
+
arg: opts.description,
|
|
79
|
+
file: opts.descriptionFile,
|
|
80
|
+
interactive: false,
|
|
81
|
+
}),
|
|
82
|
+
color: opts.color,
|
|
83
|
+
position: opts.position,
|
|
84
|
+
type: opts.type,
|
|
85
|
+
indefinite: opts.indefinite,
|
|
86
|
+
});
|
|
87
|
+
ctx.output.emit(statusReceipt(created), () =>
|
|
88
|
+
ctx.output.success(`Created project status ${created.name}`),
|
|
89
|
+
);
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
status
|
|
94
|
+
.command("update <name-or-id>")
|
|
95
|
+
.alias("edit")
|
|
96
|
+
.description("Update a project status by exact name or id")
|
|
97
|
+
.option("--name <name>", "new status name")
|
|
98
|
+
.option("-d, --description <text>", "new status description")
|
|
99
|
+
.option("--description-file <path>", "read description from a file ('-' = stdin)")
|
|
100
|
+
.option("--color <hex>", "six-digit hex color (e.g. #5E6AD2)")
|
|
101
|
+
.option("--position <number>", "position within the status type", parseNumber)
|
|
102
|
+
.option("--type <type>", `status type (${svc.PROJECT_STATUS_TYPES.join("|")})`)
|
|
103
|
+
.option("--indefinite", "allow projects to remain in this status indefinitely")
|
|
104
|
+
.option("--no-indefinite", "do not allow projects to remain indefinitely")
|
|
105
|
+
.action(
|
|
106
|
+
action(async (ctx: Context, opts, input: string) => {
|
|
107
|
+
const updated = await svc.updateProjectStatus(ctx.client, input, {
|
|
108
|
+
name: opts.name,
|
|
109
|
+
description: resolveBody({
|
|
110
|
+
arg: opts.description,
|
|
111
|
+
file: opts.descriptionFile,
|
|
112
|
+
interactive: false,
|
|
113
|
+
}),
|
|
114
|
+
color: opts.color,
|
|
115
|
+
position: opts.position,
|
|
116
|
+
type: opts.type,
|
|
117
|
+
indefinite: opts.indefinite,
|
|
118
|
+
});
|
|
119
|
+
ctx.output.emit(statusReceipt(updated), () =>
|
|
120
|
+
ctx.output.success(`Updated project status ${updated.name}`),
|
|
121
|
+
);
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
status
|
|
126
|
+
.command("archive <name-or-id>")
|
|
127
|
+
.description(
|
|
128
|
+
"Archive a project status (requires no active projects and another status of its type)",
|
|
129
|
+
)
|
|
130
|
+
.action(
|
|
131
|
+
action(async (ctx: Context, _opts, input: string) => {
|
|
132
|
+
const detail = await svc.getProjectStatusDetail(ctx.client, input);
|
|
133
|
+
if (!(await confirmDestructive(ctx, `Archive project status ${detail.name}?`))) return;
|
|
134
|
+
const archived = await svc.archiveProjectStatus(ctx.client, detail.id);
|
|
135
|
+
ctx.output.emit({ id: archived.id, name: archived.name, archived: true }, () =>
|
|
136
|
+
ctx.output.success(`Archived project status ${archived.name}`),
|
|
137
|
+
);
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
status
|
|
142
|
+
.command("unarchive <name-or-id>")
|
|
143
|
+
.description("Unarchive a project status by exact name or id")
|
|
144
|
+
.action(
|
|
145
|
+
action(async (ctx: Context, _opts, input: string) => {
|
|
146
|
+
const restored = await svc.unarchiveProjectStatus(ctx.client, input);
|
|
147
|
+
ctx.output.emit({ id: restored.id, name: restored.name, archived: false }, () =>
|
|
148
|
+
ctx.output.success(`Unarchived project status ${restored.name}`),
|
|
149
|
+
);
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function statusReceipt(status: any) {
|
|
155
|
+
return {
|
|
156
|
+
id: status.id,
|
|
157
|
+
name: status.name,
|
|
158
|
+
type: status.type,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function parseNumber(value: string): number {
|
|
163
|
+
const parsed = Number(value);
|
|
164
|
+
if (!Number.isFinite(parsed)) throw usageError(`Invalid number '${value}'.`);
|
|
165
|
+
return parsed;
|
|
166
|
+
}
|
package/src/commands/project.ts
CHANGED
|
@@ -165,7 +165,11 @@ export function registerProject(program: Command): void {
|
|
|
165
165
|
.option("--start <date>", "planned start date (YYYY-MM-DD)")
|
|
166
166
|
.option("--target <date>", "planned target date (YYYY-MM-DD)")
|
|
167
167
|
.option("-P, --priority <0-4>", "priority (0 none, 1 urgent … 4 low)", parseIntOption)
|
|
168
|
-
.option(
|
|
168
|
+
.option(
|
|
169
|
+
"-l, --label <name>",
|
|
170
|
+
"project label from `project-label list` (repeatable / comma-separated)",
|
|
171
|
+
parseList,
|
|
172
|
+
)
|
|
169
173
|
.option("--icon <name>", "Linear icon name, capitalized (e.g. Rocket)")
|
|
170
174
|
.option("--color <hex>", "project color (e.g. #EB5757)")
|
|
171
175
|
.addHelpText(
|
|
@@ -243,7 +247,11 @@ export function registerProject(program: Command): void {
|
|
|
243
247
|
.option("--start <date>", "planned start date (YYYY-MM-DD)")
|
|
244
248
|
.option("--target <date>", "planned target date (YYYY-MM-DD)")
|
|
245
249
|
.option("-P, --priority <0-4>", "priority (0 none, 1 urgent … 4 low)", parseIntOption)
|
|
246
|
-
.option(
|
|
250
|
+
.option(
|
|
251
|
+
"-l, --label <name>",
|
|
252
|
+
"replace project labels from `project-label list` (repeatable / comma-separated)",
|
|
253
|
+
parseList,
|
|
254
|
+
)
|
|
247
255
|
.option("--icon <name>", "Linear icon name, capitalized (e.g. Rocket)")
|
|
248
256
|
.option("--color <hex>", "project color (e.g. #EB5757)")
|
|
249
257
|
// `-t/--team` is a global on every other command, so it is the flag a user
|
package/src/lib/action.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import type { Command } from "commander";
|
|
10
10
|
import { Context, type GlobalOptions } from "../context.js";
|
|
11
|
+
import { enrichAuthorizationError } from "./authorization.js";
|
|
11
12
|
|
|
12
13
|
export type ActionHandler = (
|
|
13
14
|
ctx: Context,
|
|
@@ -26,9 +27,13 @@ export function action(handler: ActionHandler) {
|
|
|
26
27
|
let group = command;
|
|
27
28
|
while (group.parent?.parent) group = group.parent;
|
|
28
29
|
const offline =
|
|
29
|
-
["config", "open", "commands", "completion"].includes(group.name()) ||
|
|
30
|
+
["config", "open", "commands", "completion", "changelog"].includes(group.name()) ||
|
|
30
31
|
(group.name() === "auth" && !["whoami", "status", "token"].includes(command.name()));
|
|
31
32
|
if (!offline) await ctx.selectWorkspace();
|
|
32
|
-
|
|
33
|
+
try {
|
|
34
|
+
await handler(ctx, localOpts, ...positionals);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
throw enrichAuthorizationError(error, ctx.config);
|
|
37
|
+
}
|
|
33
38
|
};
|
|
34
39
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { ResolvedConfig } from "../config.js";
|
|
2
|
+
import { CliError, normalizeError } from "./errors.js";
|
|
3
|
+
|
|
4
|
+
export type CredentialType = "api-key" | "oauth-access-token" | "oauth-user" | null;
|
|
5
|
+
export type ScopeVisibility = "known" | "unknown" | "unavailable";
|
|
6
|
+
|
|
7
|
+
export interface AuthorizationCapabilities {
|
|
8
|
+
credentialType: CredentialType;
|
|
9
|
+
scopeVisibility: ScopeVisibility;
|
|
10
|
+
scopes: string[] | null;
|
|
11
|
+
adminScope: boolean | null;
|
|
12
|
+
note: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function credentialType(config: ResolvedConfig): CredentialType {
|
|
16
|
+
if (config.oauthCredential) return "oauth-user";
|
|
17
|
+
if (config.accessToken) return "oauth-access-token";
|
|
18
|
+
if (config.apiKey) return "api-key";
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Report only capabilities the CLI can establish without probing a mutation.
|
|
24
|
+
* Stored OAuth grants carry their requested scopes. Linear exposes no safe
|
|
25
|
+
* read-only scope introspection for personal API keys or injected access tokens.
|
|
26
|
+
*/
|
|
27
|
+
export function authorizationCapabilities(config: ResolvedConfig): AuthorizationCapabilities {
|
|
28
|
+
const type = credentialType(config);
|
|
29
|
+
if (config.oauthCredential) {
|
|
30
|
+
const scopes = [...config.oauthCredential.scopes];
|
|
31
|
+
return {
|
|
32
|
+
credentialType: type,
|
|
33
|
+
scopeVisibility: "known",
|
|
34
|
+
scopes,
|
|
35
|
+
adminScope: scopes.includes("admin"),
|
|
36
|
+
note: "Stored OAuth grant scopes are known; workspace role is a separate authorization check.",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
if (type) {
|
|
40
|
+
return {
|
|
41
|
+
credentialType: type,
|
|
42
|
+
scopeVisibility: "unknown",
|
|
43
|
+
scopes: null,
|
|
44
|
+
adminScope: null,
|
|
45
|
+
note:
|
|
46
|
+
type === "api-key"
|
|
47
|
+
? "Linear does not expose personal API-key scopes for read-only introspection; workspace role alone does not prove admin-scope access."
|
|
48
|
+
: "Injected OAuth token scopes are not available to the CLI; workspace role alone does not prove admin-scope access.",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
credentialType: null,
|
|
53
|
+
scopeVisibility: "unavailable",
|
|
54
|
+
scopes: null,
|
|
55
|
+
adminScope: null,
|
|
56
|
+
note: "No active credential is available to inspect.",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function requiredScope(error: CliError): string | undefined {
|
|
61
|
+
const text = [error.message, JSON.stringify(error.detail ?? "")].join(" ");
|
|
62
|
+
if (/admin[^.]{0,40}scope|scope[^.]{0,40}admin/i.test(text)) return "admin";
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Add safe, stable authorization context to scope-related forbidden failures. */
|
|
67
|
+
export function enrichAuthorizationError(error: unknown, config: ResolvedConfig): CliError {
|
|
68
|
+
const normalized = normalizeError(error);
|
|
69
|
+
if (normalized.code !== "forbidden") return normalized;
|
|
70
|
+
const required = requiredScope(normalized);
|
|
71
|
+
if (!required) return normalized;
|
|
72
|
+
|
|
73
|
+
const capabilities = authorizationCapabilities(config);
|
|
74
|
+
const remediation =
|
|
75
|
+
capabilities.credentialType === "oauth-user"
|
|
76
|
+
? "Re-authenticate with `linear auth login --admin`; member admin status alone is insufficient."
|
|
77
|
+
: "Use a credential authorized for the admin scope; member admin status alone is insufficient.";
|
|
78
|
+
return new CliError(
|
|
79
|
+
normalized.message,
|
|
80
|
+
normalized.code,
|
|
81
|
+
normalized.detail,
|
|
82
|
+
normalized.suggestion ?? remediation,
|
|
83
|
+
{
|
|
84
|
+
requiredScope: required,
|
|
85
|
+
credentialType: capabilities.credentialType,
|
|
86
|
+
scopeVisibility: capabilities.scopeVisibility,
|
|
87
|
+
grantedScopes: capabilities.scopes,
|
|
88
|
+
remediation,
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
}
|
package/src/lib/errors.ts
CHANGED
|
@@ -52,14 +52,23 @@ export class CliError extends Error {
|
|
|
52
52
|
readonly detail?: unknown;
|
|
53
53
|
/** A separately actionable hint; machine callers never have to parse prose. */
|
|
54
54
|
readonly suggestion?: string;
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
/** Stable, safe machine-readable context; unlike `detail`, this needs no --debug. */
|
|
56
|
+
readonly details?: Record<string, unknown>;
|
|
57
|
+
|
|
58
|
+
constructor(
|
|
59
|
+
message: string,
|
|
60
|
+
code: ErrorCode = "runtime",
|
|
61
|
+
detail?: unknown,
|
|
62
|
+
suggestion?: string,
|
|
63
|
+
details?: Record<string, unknown>,
|
|
64
|
+
) {
|
|
57
65
|
super(message);
|
|
58
66
|
this.name = "CliError";
|
|
59
67
|
this.code = code;
|
|
60
68
|
this.exitCode = EXIT_BY_CODE[code];
|
|
61
69
|
this.detail = detail;
|
|
62
70
|
this.suggestion = suggestion;
|
|
71
|
+
this.details = details;
|
|
63
72
|
}
|
|
64
73
|
}
|
|
65
74
|
|