linear-sdk-cli 0.5.2 → 0.6.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 +3 -3
- package/skills/linear-sdk-cli/SKILL.md +26 -0
- package/skills/linear-sdk-cli/references/auth.md +4 -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 +4 -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 +6 -1
- package/src/lib/authorization.ts +91 -0
- package/src/lib/errors.ts +11 -2
- package/src/lib/options.ts +9 -0
- package/src/lib/output-shapes.ts +42 -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,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,
|
|
@@ -29,6 +30,10 @@ export function action(handler: ActionHandler) {
|
|
|
29
30
|
["config", "open", "commands", "completion"].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
|
|
package/src/lib/options.ts
CHANGED
|
@@ -399,6 +399,10 @@ export const FIELDS_COMMANDS: ReadonlySet<string> = new Set([
|
|
|
399
399
|
"project list",
|
|
400
400
|
"project view",
|
|
401
401
|
"project milestones",
|
|
402
|
+
"project-status list",
|
|
403
|
+
"project-status view",
|
|
404
|
+
"project-label list",
|
|
405
|
+
"project-label view",
|
|
402
406
|
"project-update list",
|
|
403
407
|
"roadmap list",
|
|
404
408
|
"roadmap view",
|
|
@@ -444,6 +448,8 @@ export const LIMIT_COMMANDS: ReadonlySet<string> = new Set([
|
|
|
444
448
|
"organization invites",
|
|
445
449
|
"project list",
|
|
446
450
|
"project milestones",
|
|
451
|
+
"project-status list",
|
|
452
|
+
"project-label list",
|
|
447
453
|
"project-update list",
|
|
448
454
|
"roadmap list",
|
|
449
455
|
"state list",
|
|
@@ -510,7 +516,10 @@ export const YES_COMMANDS: ReadonlySet<string> = new Set([
|
|
|
510
516
|
"milestone delete",
|
|
511
517
|
"notification archive",
|
|
512
518
|
"project archive",
|
|
519
|
+
"project-status archive",
|
|
513
520
|
"project delete",
|
|
521
|
+
"project-label retire",
|
|
522
|
+
"project-label delete",
|
|
514
523
|
"roadmap delete",
|
|
515
524
|
"team delete",
|
|
516
525
|
"webhook delete",
|
package/src/lib/output-shapes.ts
CHANGED
|
@@ -51,6 +51,11 @@ import {
|
|
|
51
51
|
PROJECT_MILESTONE_ROW_SHAPE,
|
|
52
52
|
PROJECT_ROW_SHAPE,
|
|
53
53
|
} from "../services/project.js";
|
|
54
|
+
import {
|
|
55
|
+
PROJECT_STATUS_DETAIL_SHAPE,
|
|
56
|
+
PROJECT_STATUS_ROW_SHAPE,
|
|
57
|
+
} from "../services/project-status.js";
|
|
58
|
+
import { PROJECT_LABEL_DETAIL_SHAPE, PROJECT_LABEL_ROW_SHAPE } from "../services/project-label.js";
|
|
54
59
|
import { ROADMAP_DETAIL_SHAPE, ROADMAP_ROW_SHAPE } from "../services/roadmap.js";
|
|
55
60
|
import { STATE_DETAIL_SHAPE, STATE_ROW_SHAPE } from "../services/state.js";
|
|
56
61
|
import {
|
|
@@ -140,6 +145,14 @@ const UPDATE_RECEIPT_SHAPE = shape<UpdateRow & { url: string }>({
|
|
|
140
145
|
url: "string",
|
|
141
146
|
});
|
|
142
147
|
|
|
148
|
+
const AUTHORIZATION_CAPABILITIES: ObjectFields = {
|
|
149
|
+
credentialType: "string|null",
|
|
150
|
+
scopeVisibility: "string",
|
|
151
|
+
scopes: { nullable: ["string"] },
|
|
152
|
+
adminScope: "boolean|null",
|
|
153
|
+
note: "string",
|
|
154
|
+
};
|
|
155
|
+
|
|
143
156
|
/** `whoami` / `auth whoami`. */
|
|
144
157
|
const WHOAMI_SHAPE: ObjectFields = {
|
|
145
158
|
id: "string",
|
|
@@ -147,6 +160,7 @@ const WHOAMI_SHAPE: ObjectFields = {
|
|
|
147
160
|
displayName: "string",
|
|
148
161
|
email: "string",
|
|
149
162
|
admin: "boolean",
|
|
163
|
+
authorization: AUTHORIZATION_CAPABILITIES,
|
|
150
164
|
organization: { id: "string", name: "string", urlKey: "string" },
|
|
151
165
|
};
|
|
152
166
|
|
|
@@ -348,6 +362,9 @@ export const OUTPUT_SHAPES: Record<string, OutputShape | null> = {
|
|
|
348
362
|
keyring: "string|null",
|
|
349
363
|
scopes: { nullable: ["string"] },
|
|
350
364
|
expiresAt: "string|null",
|
|
365
|
+
scopeVisibility: "string",
|
|
366
|
+
adminScope: "boolean|null",
|
|
367
|
+
scopeNote: "string",
|
|
351
368
|
}),
|
|
352
369
|
"auth token": object({ apiKey: "string", workspace: "string|null" }),
|
|
353
370
|
"auth whoami": WHOAMI,
|
|
@@ -620,6 +637,31 @@ export const OUTPUT_SHAPES: Record<string, OutputShape | null> = {
|
|
|
620
637
|
"project update": receipt(NAMED_URL_RECEIPT),
|
|
621
638
|
"project view": object(PROJECT_DETAIL_SHAPE),
|
|
622
639
|
|
|
640
|
+
"project-status": null,
|
|
641
|
+
"project-status archive": receipt({ id: "string", name: "string", archived: "boolean" }),
|
|
642
|
+
"project-status create": receipt({
|
|
643
|
+
id: "string",
|
|
644
|
+
name: "string",
|
|
645
|
+
type: "string",
|
|
646
|
+
}),
|
|
647
|
+
"project-status list": list(PROJECT_STATUS_ROW_SHAPE),
|
|
648
|
+
"project-status unarchive": receipt({ id: "string", name: "string", archived: "boolean" }),
|
|
649
|
+
"project-status update": receipt({
|
|
650
|
+
id: "string",
|
|
651
|
+
name: "string",
|
|
652
|
+
type: "string",
|
|
653
|
+
}),
|
|
654
|
+
"project-status view": object(PROJECT_STATUS_DETAIL_SHAPE),
|
|
655
|
+
|
|
656
|
+
"project-label": null,
|
|
657
|
+
"project-label create": receipt({ id: "string", name: "string", color: "string" }),
|
|
658
|
+
"project-label delete": receipt(NAMED_DELETED),
|
|
659
|
+
"project-label list": list(PROJECT_LABEL_ROW_SHAPE),
|
|
660
|
+
"project-label restore": receipt({ id: "string", name: "string", restored: "boolean" }),
|
|
661
|
+
"project-label retire": receipt({ id: "string", name: "string", retired: "boolean" }),
|
|
662
|
+
"project-label update": receipt({ id: "string", name: "string", color: "string" }),
|
|
663
|
+
"project-label view": object(PROJECT_LABEL_DETAIL_SHAPE),
|
|
664
|
+
|
|
623
665
|
"project-update": null,
|
|
624
666
|
"project-update create": receipt(UPDATE_RECEIPT_SHAPE),
|
|
625
667
|
"project-update list": list(UPDATE_ROW_SHAPE),
|
package/src/lib/resolve.ts
CHANGED
|
@@ -315,10 +315,15 @@ export async function resolveProjectLabelIds(
|
|
|
315
315
|
client: LinearClient,
|
|
316
316
|
names: string[],
|
|
317
317
|
): Promise<string[]> {
|
|
318
|
-
const
|
|
318
|
+
const labels: any[] = [];
|
|
319
319
|
for (const name of names) {
|
|
320
320
|
if (isUuid(name)) {
|
|
321
|
-
|
|
321
|
+
const label: any = await withRetry(() => (client as any).projectLabel(name));
|
|
322
|
+
if (label.isGroup)
|
|
323
|
+
throw usageError(`Project label '${label.name}' is a group and cannot be assigned.`);
|
|
324
|
+
if (label.retiredAt || label.retiredById)
|
|
325
|
+
throw usageError(`Project label '${label.name}' is retired and cannot be assigned.`);
|
|
326
|
+
labels.push(label);
|
|
322
327
|
continue;
|
|
323
328
|
}
|
|
324
329
|
const conn: any = await withRetry(() =>
|
|
@@ -327,21 +332,33 @@ export async function resolveProjectLabelIds(
|
|
|
327
332
|
first: RESOLVE_PAGE,
|
|
328
333
|
}),
|
|
329
334
|
);
|
|
330
|
-
const nodes = await scanAll<any>(conn, "project labels", "linear project
|
|
331
|
-
const candidates = nodes.filter((l) => !l.isGroup);
|
|
335
|
+
const nodes = await scanAll<any>(conn, "project labels", "linear project-label list");
|
|
336
|
+
const candidates = nodes.filter((l) => !l.isGroup && !l.retiredAt && !l.retiredById);
|
|
332
337
|
if (candidates.length === 0)
|
|
333
338
|
throw notFound(
|
|
334
|
-
`No project label matching '${name}'. Run 'linear project
|
|
339
|
+
`No project label matching '${name}'. Run 'linear project-label list' to see the options.`,
|
|
335
340
|
);
|
|
336
341
|
|
|
337
342
|
const exact = candidates.filter((l) => l.name === name);
|
|
338
343
|
const finalists = exact.length ? exact : candidates;
|
|
339
344
|
if (finalists.length > 1)
|
|
340
345
|
throw ambiguous(`Multiple project labels named '${name}'; pass the label id instead.`);
|
|
341
|
-
|
|
346
|
+
labels.push(finalists[0]!);
|
|
347
|
+
}
|
|
348
|
+
const byParent = new Map<string, any>();
|
|
349
|
+
for (const label of labels) {
|
|
350
|
+
const parentId = label.parentId;
|
|
351
|
+
if (!parentId) continue;
|
|
352
|
+
const other = byParent.get(parentId);
|
|
353
|
+
if (other && other.id !== label.id) {
|
|
354
|
+
throw usageError(
|
|
355
|
+
`Project labels '${other.name}' and '${label.name}' belong to the same mutually exclusive group; choose one.`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
byParent.set(parentId, label);
|
|
342
359
|
}
|
|
343
360
|
// The same label named twice (or by name and id) must not be sent twice.
|
|
344
|
-
return [...new Set(
|
|
361
|
+
return [...new Set(labels.map((label) => label.id))];
|
|
345
362
|
}
|
|
346
363
|
|
|
347
364
|
/** First workflow state of a given type (lowest position) within a team. */
|
package/src/output/format.ts
CHANGED
|
@@ -217,6 +217,7 @@ export class Output {
|
|
|
217
217
|
// absent entirely without --debug.
|
|
218
218
|
const error: Record<string, unknown> = { message: err.message, code: err.code };
|
|
219
219
|
if (err.suggestion !== undefined) error.suggestion = err.suggestion;
|
|
220
|
+
if (err.details !== undefined) error.details = err.details;
|
|
220
221
|
if (showDetail) error.detail = err.detail;
|
|
221
222
|
process.stderr.write(JSON.stringify({ error }) + "\n");
|
|
222
223
|
return;
|