linear-sdk-cli 0.1.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/.claude-plugin/marketplace.json +20 -0
- package/.claude-plugin/plugin.json +9 -0
- package/CHANGELOG.md +807 -0
- package/COVERAGE.md +495 -0
- package/LICENSE +21 -0
- package/MIGRATING.md +220 -0
- package/README.md +524 -0
- package/package.json +87 -0
- package/skills/linear-sdk-cli/SKILL.md +534 -0
- package/skills/linear-sdk-cli/references/api.md +27 -0
- package/skills/linear-sdk-cli/references/attachment.md +82 -0
- package/skills/linear-sdk-cli/references/auth.md +174 -0
- package/skills/linear-sdk-cli/references/commands.md +37 -0
- package/skills/linear-sdk-cli/references/comment.md +154 -0
- package/skills/linear-sdk-cli/references/completion.md +17 -0
- package/skills/linear-sdk-cli/references/config.md +99 -0
- package/skills/linear-sdk-cli/references/cycle.md +131 -0
- package/skills/linear-sdk-cli/references/document.md +180 -0
- package/skills/linear-sdk-cli/references/favorite.md +78 -0
- package/skills/linear-sdk-cli/references/initiative-update.md +67 -0
- package/skills/linear-sdk-cli/references/initiative.md +256 -0
- package/skills/linear-sdk-cli/references/issue.md +928 -0
- package/skills/linear-sdk-cli/references/label.md +112 -0
- package/skills/linear-sdk-cli/references/milestone.md +139 -0
- package/skills/linear-sdk-cli/references/notification.md +123 -0
- package/skills/linear-sdk-cli/references/open.md +29 -0
- package/skills/linear-sdk-cli/references/organization.md +99 -0
- package/skills/linear-sdk-cli/references/project-update.md +67 -0
- package/skills/linear-sdk-cli/references/project.md +234 -0
- package/skills/linear-sdk-cli/references/roadmap.md +150 -0
- package/skills/linear-sdk-cli/references/schema.md +21 -0
- package/skills/linear-sdk-cli/references/state.md +61 -0
- package/skills/linear-sdk-cli/references/team.md +227 -0
- package/skills/linear-sdk-cli/references/user.md +103 -0
- package/skills/linear-sdk-cli/references/webhook.md +151 -0
- package/skills/linear-sdk-cli/references/whoami.md +26 -0
- package/skills/linear-sdk-cli/scripts/generate-docs.ts +339 -0
- package/src/bin/linear.ts +97 -0
- package/src/cli.ts +232 -0
- package/src/client.ts +172 -0
- package/src/commands/agent-session.ts +121 -0
- package/src/commands/api.ts +317 -0
- package/src/commands/attach.ts +90 -0
- package/src/commands/attachment.ts +74 -0
- package/src/commands/comment.ts +225 -0
- package/src/commands/completion.ts +69 -0
- package/src/commands/cycle.ts +125 -0
- package/src/commands/discover.ts +170 -0
- package/src/commands/document.ts +211 -0
- package/src/commands/favorite.ts +72 -0
- package/src/commands/initiative-update.ts +47 -0
- package/src/commands/initiative.ts +261 -0
- package/src/commands/issue.ts +1263 -0
- package/src/commands/label.ts +119 -0
- package/src/commands/meta.ts +534 -0
- package/src/commands/milestone.ts +163 -0
- package/src/commands/notification.ts +110 -0
- package/src/commands/open.ts +84 -0
- package/src/commands/organization.ts +79 -0
- package/src/commands/project-update.ts +47 -0
- package/src/commands/project.ts +339 -0
- package/src/commands/roadmap.ts +131 -0
- package/src/commands/state.ts +58 -0
- package/src/commands/team.ts +243 -0
- package/src/commands/user.ts +107 -0
- package/src/commands/webhook.ts +156 -0
- package/src/config.ts +1236 -0
- package/src/context.ts +117 -0
- package/src/git.ts +174 -0
- package/src/index.ts +10 -0
- package/src/lib/action.ts +26 -0
- package/src/lib/body.ts +172 -0
- package/src/lib/errors.ts +195 -0
- package/src/lib/introspect.ts +150 -0
- package/src/lib/keyring.ts +270 -0
- package/src/lib/mutation.ts +53 -0
- package/src/lib/open.ts +32 -0
- package/src/lib/options.ts +640 -0
- package/src/lib/output-shapes.ts +562 -0
- package/src/lib/pagination.ts +145 -0
- package/src/lib/prompt.ts +91 -0
- package/src/lib/resolve.ts +596 -0
- package/src/lib/shape.ts +273 -0
- package/src/lib/status-update.ts +91 -0
- package/src/lib/upload.ts +329 -0
- package/src/output/color.ts +52 -0
- package/src/output/format.ts +238 -0
- package/src/output/markdown.ts +133 -0
- package/src/output/pager.ts +131 -0
- package/src/output/sanitize.ts +76 -0
- package/src/output/table.ts +198 -0
- package/src/services/agent-session.ts +289 -0
- package/src/services/attachment.ts +198 -0
- package/src/services/comment.ts +262 -0
- package/src/services/cycle.ts +224 -0
- package/src/services/document.ts +357 -0
- package/src/services/favorite.ts +147 -0
- package/src/services/initiative-update.ts +59 -0
- package/src/services/initiative.ts +449 -0
- package/src/services/issue.ts +1153 -0
- package/src/services/label.ts +193 -0
- package/src/services/milestone.ts +262 -0
- package/src/services/notification.ts +190 -0
- package/src/services/organization.ts +144 -0
- package/src/services/project-update.ts +36 -0
- package/src/services/project.ts +494 -0
- package/src/services/roadmap.ts +177 -0
- package/src/services/state.ts +127 -0
- package/src/services/team.ts +386 -0
- package/src/services/user.ts +137 -0
- package/src/services/webhook.ts +203 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `linear label` (alias `lb`) — work with issue labels.
|
|
3
|
+
*
|
|
4
|
+
* Labels are either workspace-level (no team) or scoped to a single team. The
|
|
5
|
+
* `list` command optionally narrows to a team (defaulting to the configured
|
|
6
|
+
* team); create/update/delete take a label name or id.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Command } from "commander";
|
|
10
|
+
import { action } from "../lib/action.js";
|
|
11
|
+
import { confirmDestructive, promptInput } from "../lib/prompt.js";
|
|
12
|
+
import type { Context } from "../context.js";
|
|
13
|
+
import * as svc from "../services/label.js";
|
|
14
|
+
import type { Column } from "../output/table.js";
|
|
15
|
+
|
|
16
|
+
const ROW_COLUMNS: Column<svc.LabelRow>[] = [
|
|
17
|
+
{ key: "name", header: "Name", value: (r) => r.name, max: 30 },
|
|
18
|
+
{ key: "color", header: "Color", value: (r) => r.color },
|
|
19
|
+
{ key: "team", header: "Team", value: (r) => r.team?.key ?? "—" },
|
|
20
|
+
{ key: "isGroup", header: "Group", value: (r) => (r.isGroup ? "yes" : "no") },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export function registerLabel(program: Command): void {
|
|
24
|
+
const label = program.command("label").alias("lb").description("Work with issue labels");
|
|
25
|
+
|
|
26
|
+
// list --------------------------------------------------------------------
|
|
27
|
+
label
|
|
28
|
+
.command("list [team]")
|
|
29
|
+
.alias("ls")
|
|
30
|
+
.description("List the labels usable in a team (its own plus workspace-level ones)")
|
|
31
|
+
.option("--all-teams", "list every label in the workspace, ignoring the team scope")
|
|
32
|
+
.addHelpText(
|
|
33
|
+
"after",
|
|
34
|
+
[
|
|
35
|
+
"",
|
|
36
|
+
"Examples:",
|
|
37
|
+
" linear label list # labels valid on the default team's issues",
|
|
38
|
+
" linear label list ENG # the same for ENG",
|
|
39
|
+
" linear label list --all-teams # every label in the workspace",
|
|
40
|
+
" linear label list --json | jq -c '.[] | select(.team == null) | .name' # workspace-level only",
|
|
41
|
+
].join("\n"),
|
|
42
|
+
)
|
|
43
|
+
.action(
|
|
44
|
+
action(async (ctx: Context, opts, teamArg?: string) => {
|
|
45
|
+
const rows = await svc.listLabels(ctx.client, teamArg, ctx.limit, ctx.defaultTeam, {
|
|
46
|
+
allTeams: !!opts.allTeams,
|
|
47
|
+
});
|
|
48
|
+
ctx.output.list(rows, ROW_COLUMNS, rows);
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
// create ------------------------------------------------------------------
|
|
53
|
+
label
|
|
54
|
+
.command("create")
|
|
55
|
+
.alias("new")
|
|
56
|
+
.description("Create a label (scoped to --team if set, else workspace-level)")
|
|
57
|
+
.option("--name <name>", "label name")
|
|
58
|
+
.option("--color <hex>", "label color (e.g. #EB5757)")
|
|
59
|
+
.option("-d, --description <text>", "label description")
|
|
60
|
+
.option("--shared", "create a workspace-level (shared) label even when a default team is set")
|
|
61
|
+
.option("--parent <name>", "parent label (creates a sub-label)")
|
|
62
|
+
.action(
|
|
63
|
+
action(async (ctx: Context, opts) => {
|
|
64
|
+
let name: string | undefined = opts.name;
|
|
65
|
+
if (!name) name = await promptInput(ctx, "Name:", { required: true });
|
|
66
|
+
const created = await svc.createLabel(
|
|
67
|
+
ctx.client,
|
|
68
|
+
{
|
|
69
|
+
name,
|
|
70
|
+
color: opts.color,
|
|
71
|
+
description: opts.description,
|
|
72
|
+
// Scope to the global --team (ctx.defaultTeam) unless --shared.
|
|
73
|
+
team: opts.shared ? undefined : ctx.defaultTeam,
|
|
74
|
+
parent: opts.parent,
|
|
75
|
+
},
|
|
76
|
+
ctx.defaultTeam,
|
|
77
|
+
);
|
|
78
|
+
ctx.output.emit({ id: created.id, name: created.name, color: created.color }, () =>
|
|
79
|
+
ctx.output.success(`Created label ${created.name}`),
|
|
80
|
+
);
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// update ------------------------------------------------------------------
|
|
85
|
+
label
|
|
86
|
+
.command("update <id>")
|
|
87
|
+
.alias("edit")
|
|
88
|
+
.description("Update a label (by name or id)")
|
|
89
|
+
.option("--name <name>", "new label name")
|
|
90
|
+
.option("--color <hex>", "new label color (e.g. #EB5757)")
|
|
91
|
+
.option("-d, --description <text>", "new label description")
|
|
92
|
+
.action(
|
|
93
|
+
action(async (ctx: Context, opts, idArg: string) => {
|
|
94
|
+
const updated = await svc.updateLabel(ctx.client, idArg, {
|
|
95
|
+
name: opts.name,
|
|
96
|
+
color: opts.color,
|
|
97
|
+
description: opts.description,
|
|
98
|
+
});
|
|
99
|
+
ctx.output.emit({ id: updated.id, name: updated.name, color: updated.color }, () =>
|
|
100
|
+
ctx.output.success(`Updated label ${updated.name}`),
|
|
101
|
+
);
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
// delete ------------------------------------------------------------------
|
|
106
|
+
label
|
|
107
|
+
.command("delete <id>")
|
|
108
|
+
.alias("rm")
|
|
109
|
+
.description("Delete a label (by name or id)")
|
|
110
|
+
.action(
|
|
111
|
+
action(async (ctx: Context, _opts, idArg: string) => {
|
|
112
|
+
if (!(await confirmDestructive(ctx, `Delete label ${idArg}?`))) return;
|
|
113
|
+
const deleted = await svc.deleteLabel(ctx.client, idArg);
|
|
114
|
+
ctx.output.emit({ id: deleted.id, name: deleted.name, deleted: true }, () =>
|
|
115
|
+
ctx.output.success(`Deleted label ${deleted.name}`),
|
|
116
|
+
);
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Top-level meta commands: whoami, auth (login/list/default/token/status/
|
|
3
|
+
* logout/migrate), config.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Command } from "commander";
|
|
7
|
+
import { LinearClient } from "@linear/sdk";
|
|
8
|
+
import { action } from "../lib/action.js";
|
|
9
|
+
import { withRetry } from "../client.js";
|
|
10
|
+
import { resolve } from "node:path";
|
|
11
|
+
import {
|
|
12
|
+
writeCredential,
|
|
13
|
+
probeKeyringCredential,
|
|
14
|
+
adoptKeyringCredential,
|
|
15
|
+
removeCredential,
|
|
16
|
+
setDefaultWorkspace,
|
|
17
|
+
listCredentials,
|
|
18
|
+
migrateCredentials,
|
|
19
|
+
redactKey,
|
|
20
|
+
resolveConfig,
|
|
21
|
+
defaultProjectConfigPath,
|
|
22
|
+
assertSettableKey,
|
|
23
|
+
setConfigKey,
|
|
24
|
+
initProjectConfig,
|
|
25
|
+
SETTABLE_KEYS,
|
|
26
|
+
type SettableKey,
|
|
27
|
+
} from "../config.js";
|
|
28
|
+
import { keyring } from "../lib/keyring.js";
|
|
29
|
+
import { readStdinSync } from "../lib/body.js";
|
|
30
|
+
import { authError, normalizeError, usageError } from "../lib/errors.js";
|
|
31
|
+
import { confirmDestructive, promptSecret, promptSelect } from "../lib/prompt.js";
|
|
32
|
+
import { listTeams } from "../services/team.js";
|
|
33
|
+
import { ISSUE_SORTS } from "../services/issue.js";
|
|
34
|
+
import { firstTeam, type Context } from "../context.js";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The `whoami` handler, shared by the top-level `linear whoami` and the
|
|
38
|
+
* reference CLI's spelling `linear auth whoami` so the two cannot drift.
|
|
39
|
+
*/
|
|
40
|
+
const whoamiAction = action(async (ctx: Context) => {
|
|
41
|
+
const me = await withRetry(() => ctx.client.viewer);
|
|
42
|
+
const org = await withRetry(() => ctx.client.organization);
|
|
43
|
+
ctx.output.detail(
|
|
44
|
+
{
|
|
45
|
+
id: me.id,
|
|
46
|
+
name: me.name,
|
|
47
|
+
displayName: me.displayName,
|
|
48
|
+
email: me.email,
|
|
49
|
+
admin: me.admin,
|
|
50
|
+
organization: { id: org.id, name: org.name, urlKey: org.urlKey },
|
|
51
|
+
},
|
|
52
|
+
[
|
|
53
|
+
["Name", me.name],
|
|
54
|
+
["Display name", me.displayName],
|
|
55
|
+
["Email", me.email],
|
|
56
|
+
["Admin", me.admin],
|
|
57
|
+
["User ID", me.id],
|
|
58
|
+
["Organization", `${org.name} (${org.urlKey})`],
|
|
59
|
+
],
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
interface AuthIdentity {
|
|
64
|
+
user: { id: string; name: string; email: string };
|
|
65
|
+
organization: { id: string; name: string; urlKey: string };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface AuthValidationClient {
|
|
69
|
+
viewer: Promise<{ id: string; name: string; email: string }>;
|
|
70
|
+
organization: Promise<{ id: string; name: string; urlKey: string }>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type AuthValidationClientFactory = (apiKey: string) => AuthValidationClient;
|
|
74
|
+
|
|
75
|
+
const defaultAuthValidationClientFactory: AuthValidationClientFactory = (apiKey) =>
|
|
76
|
+
new LinearClient({ apiKey }) as unknown as AuthValidationClient;
|
|
77
|
+
let authValidationClientFactory = defaultAuthValidationClientFactory;
|
|
78
|
+
|
|
79
|
+
/** Test seam for command-level auth tests; production always uses LinearClient. */
|
|
80
|
+
export function setAuthValidationClientFactoryForTests(
|
|
81
|
+
factory: AuthValidationClientFactory | undefined,
|
|
82
|
+
): void {
|
|
83
|
+
authValidationClientFactory = factory ?? defaultAuthValidationClientFactory;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Merge the local and global login spellings without silently choosing one. */
|
|
87
|
+
export function selectLoginKey(
|
|
88
|
+
localKey: string | undefined,
|
|
89
|
+
globalKey: string | undefined,
|
|
90
|
+
): string | undefined {
|
|
91
|
+
if (localKey !== undefined && globalKey !== undefined) {
|
|
92
|
+
throw usageError("Pass only one of --key or --api-key to `auth login`.");
|
|
93
|
+
}
|
|
94
|
+
return localKey ?? globalKey;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Validate a credential and prove that an explicitly requested slug matches it. */
|
|
98
|
+
export async function validateAuthCredential(
|
|
99
|
+
apiKey: string,
|
|
100
|
+
requestedWorkspace?: string,
|
|
101
|
+
): Promise<AuthIdentity> {
|
|
102
|
+
const client = authValidationClientFactory(apiKey);
|
|
103
|
+
let me;
|
|
104
|
+
let org;
|
|
105
|
+
try {
|
|
106
|
+
me = await withRetry(() => client.viewer);
|
|
107
|
+
org = await withRetry(() => client.organization);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
const normalized = normalizeError(err);
|
|
110
|
+
if (normalized.code === "auth" || normalized.code === "forbidden") {
|
|
111
|
+
throw authError("That API key was rejected by Linear.");
|
|
112
|
+
}
|
|
113
|
+
throw normalized;
|
|
114
|
+
}
|
|
115
|
+
if (requestedWorkspace && requestedWorkspace !== org.urlKey) {
|
|
116
|
+
throw usageError(
|
|
117
|
+
`--workspace '${requestedWorkspace}' does not match the key's workspace '${org.urlKey}'. Store credentials under their Linear workspace slug.`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
user: { id: me.id, name: me.name, email: me.email },
|
|
122
|
+
organization: { id: org.id, name: org.name, urlKey: org.urlKey },
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function explicitWorkspaceSlug(input: string): string {
|
|
127
|
+
const slug = input.trim();
|
|
128
|
+
if (!/^[a-z0-9](?:[a-z0-9-]{0,98}[a-z0-9])?$/.test(slug)) {
|
|
129
|
+
throw usageError(
|
|
130
|
+
"Workspace slug must be its lowercase Linear URL key (letters, numbers, and hyphens).",
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
return slug;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function registerMeta(program: Command): void {
|
|
137
|
+
program.command("whoami").description("Show the authenticated user").action(whoamiAction);
|
|
138
|
+
|
|
139
|
+
const auth = program.command("auth").description("Manage authentication");
|
|
140
|
+
|
|
141
|
+
// whoami ------------------------------------------------------------------
|
|
142
|
+
// The reference CLI nests this under `auth`; ours is top-level. Both spellings
|
|
143
|
+
// run the identical handler. (Note it is NOT an alias of `auth status`, which
|
|
144
|
+
// reports where the *key* came from and never names the user — the reference's
|
|
145
|
+
// `auth whoami` prints user + workspace, exactly like our top-level `whoami`.)
|
|
146
|
+
auth.command("whoami").description("Show the authenticated user").action(whoamiAction);
|
|
147
|
+
|
|
148
|
+
// login -------------------------------------------------------------------
|
|
149
|
+
auth
|
|
150
|
+
.command("login")
|
|
151
|
+
.description("Validate and store a Linear API key for a workspace")
|
|
152
|
+
.option("--key <key>", "API key (otherwise prompted; '-' reads it from stdin)")
|
|
153
|
+
.option("--plaintext", "Store the key in the config file (0600) instead of the system keyring")
|
|
154
|
+
.action(
|
|
155
|
+
// The global `--workspace <slug>` selects the slug to store under
|
|
156
|
+
// (default: derived from the key's organization urlKey).
|
|
157
|
+
action(async (ctx: Context, opts) => {
|
|
158
|
+
// `--key` still works for scripts. When prompted, the key is masked —
|
|
159
|
+
// it is a credential, and it must not reach the screen or scrollback.
|
|
160
|
+
// Nothing below echoes it either: the receipt reports the user and
|
|
161
|
+
// where the key went, never the value.
|
|
162
|
+
let key: string | undefined = selectLoginKey(opts.key, ctx.options.apiKey);
|
|
163
|
+
if (key === "-") {
|
|
164
|
+
key = readStdinSync();
|
|
165
|
+
} else if (key) {
|
|
166
|
+
// argv is world-readable (`ps`) and lands in shell history; say so
|
|
167
|
+
// once, quietly, and carry on — the script author chose it.
|
|
168
|
+
ctx.output.warn(
|
|
169
|
+
"An API key on the command line is visible to other processes and shell history; prefer the prompt or `--key -` (stdin).",
|
|
170
|
+
);
|
|
171
|
+
} else {
|
|
172
|
+
key = await promptSecret(ctx, "Linear API key:", { required: true });
|
|
173
|
+
}
|
|
174
|
+
key = key.trim();
|
|
175
|
+
if (!key) throw usageError("No API key provided.");
|
|
176
|
+
// Validate before persisting and learn the workspace slug.
|
|
177
|
+
const identity = await validateAuthCredential(key, ctx.options.workspace);
|
|
178
|
+
const { user: me, organization: org } = identity;
|
|
179
|
+
const slug: string = org.urlKey;
|
|
180
|
+
const { path, storage, keyringLabel } = writeCredential(slug, key, {
|
|
181
|
+
plaintext: opts.plaintext === true,
|
|
182
|
+
});
|
|
183
|
+
const where =
|
|
184
|
+
storage === "keychain"
|
|
185
|
+
? `Credential saved to the ${keyringLabel} (service linear-cli, account '${slug}'); ${path} records the workspace.`
|
|
186
|
+
: opts.plaintext
|
|
187
|
+
? `Credential saved to ${path} (plaintext, 0600).`
|
|
188
|
+
: `Credential saved to ${path} (plaintext, 0600 — no system keyring on this platform).`;
|
|
189
|
+
ctx.output.emit(
|
|
190
|
+
{
|
|
191
|
+
success: true,
|
|
192
|
+
workspace: slug,
|
|
193
|
+
user: { id: me.id, name: me.name, email: me.email },
|
|
194
|
+
storage,
|
|
195
|
+
path,
|
|
196
|
+
},
|
|
197
|
+
() =>
|
|
198
|
+
ctx.output.success(
|
|
199
|
+
`Authenticated as ${me.name} <${me.email}> for workspace '${slug}'. ${where}`,
|
|
200
|
+
),
|
|
201
|
+
);
|
|
202
|
+
}),
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
// adopt ------------------------------------------------------------------
|
|
206
|
+
auth
|
|
207
|
+
.command("adopt <slug>")
|
|
208
|
+
.description("Adopt an existing named credential from the shared OS keyring")
|
|
209
|
+
.action(
|
|
210
|
+
action(async (ctx: Context, _opts, input: string) => {
|
|
211
|
+
const slug = explicitWorkspaceSlug(input);
|
|
212
|
+
if (ctx.options.apiKey !== undefined) {
|
|
213
|
+
throw usageError(
|
|
214
|
+
"`auth adopt` reads only the named OS keyring entry; remove --api-key (or use `auth login --key -` to supply a key on stdin).",
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
if (ctx.options.workspace && ctx.options.workspace !== slug) {
|
|
218
|
+
throw usageError(
|
|
219
|
+
`The positional workspace '${slug}' conflicts with --workspace '${ctx.options.workspace}'.`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Probe one exact account only. No keyring enumeration and no secret
|
|
224
|
+
// on argv, stdout, stderr, or disk.
|
|
225
|
+
const found = probeKeyringCredential(slug);
|
|
226
|
+
const identity = await validateAuthCredential(found.apiKey, slug);
|
|
227
|
+
const adopted = adoptKeyringCredential(slug, found.apiKey);
|
|
228
|
+
ctx.output.emit(
|
|
229
|
+
{
|
|
230
|
+
success: true,
|
|
231
|
+
workspace: slug,
|
|
232
|
+
user: identity.user,
|
|
233
|
+
storage: adopted.storage,
|
|
234
|
+
path: adopted.path,
|
|
235
|
+
},
|
|
236
|
+
() =>
|
|
237
|
+
ctx.output.success(
|
|
238
|
+
`Adopted workspace '${slug}' from the ${adopted.keyringLabel}; ${adopted.path} now records the workspace without storing the secret.`,
|
|
239
|
+
),
|
|
240
|
+
);
|
|
241
|
+
}),
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
// list --------------------------------------------------------------------
|
|
245
|
+
auth
|
|
246
|
+
.command("list")
|
|
247
|
+
.alias("ls")
|
|
248
|
+
.description("List configured workspace credentials")
|
|
249
|
+
.action(
|
|
250
|
+
action(async (ctx: Context) => {
|
|
251
|
+
const entries = listCredentials();
|
|
252
|
+
ctx.output.list(
|
|
253
|
+
entries,
|
|
254
|
+
[
|
|
255
|
+
{ key: "slug", header: "Workspace", value: (e) => e.slug },
|
|
256
|
+
{ key: "isDefault", header: "Default", value: (e) => (e.isDefault ? "yes" : "") },
|
|
257
|
+
{ key: "storage", header: "Storage", value: (e) => e.storage },
|
|
258
|
+
],
|
|
259
|
+
entries,
|
|
260
|
+
);
|
|
261
|
+
}),
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
// default -----------------------------------------------------------------
|
|
265
|
+
auth
|
|
266
|
+
.command("default <slug>")
|
|
267
|
+
.description("Set the default workspace credential")
|
|
268
|
+
.action(
|
|
269
|
+
action(async (ctx: Context, _opts, slug: string) => {
|
|
270
|
+
const path = setDefaultWorkspace(slug);
|
|
271
|
+
ctx.output.emit({ success: true, default_workspace: slug, path }, () =>
|
|
272
|
+
ctx.output.success(`Default workspace set to '${slug}'.`),
|
|
273
|
+
);
|
|
274
|
+
}),
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
// token -------------------------------------------------------------------
|
|
278
|
+
auth
|
|
279
|
+
.command("token")
|
|
280
|
+
.description("Print the resolved API key for the active workspace (for scripting)")
|
|
281
|
+
.action(
|
|
282
|
+
action(async (ctx: Context) => {
|
|
283
|
+
const c = ctx.config;
|
|
284
|
+
if (!c.apiKey) {
|
|
285
|
+
// Surface the precise selection error (ambiguous / unstored slug) if any.
|
|
286
|
+
throw (
|
|
287
|
+
c.apiKeyError ??
|
|
288
|
+
authError("No API key resolved. Run `linear auth login` or pass --workspace/--api-key.")
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
// This command intentionally prints the secret — that is its purpose.
|
|
292
|
+
ctx.output.emit({ apiKey: c.apiKey, workspace: c.credentialWorkspace ?? null }, () =>
|
|
293
|
+
process.stdout.write(c.apiKey + "\n"),
|
|
294
|
+
);
|
|
295
|
+
}),
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
// status ------------------------------------------------------------------
|
|
299
|
+
auth
|
|
300
|
+
.command("status")
|
|
301
|
+
.description("Show where the API key is resolved from (key redacted)")
|
|
302
|
+
.action(
|
|
303
|
+
action(async (ctx) => {
|
|
304
|
+
const c = ctx.config;
|
|
305
|
+
const backend = keyring();
|
|
306
|
+
ctx.output.detail(
|
|
307
|
+
{
|
|
308
|
+
authenticated: !!c.apiKey,
|
|
309
|
+
source: c.apiKeySource,
|
|
310
|
+
workspace: c.credentialWorkspace ?? null,
|
|
311
|
+
key: redactKey(c.apiKey),
|
|
312
|
+
keyring: backend?.name ?? null,
|
|
313
|
+
},
|
|
314
|
+
[
|
|
315
|
+
["Authenticated", !!c.apiKey],
|
|
316
|
+
["Source", c.apiKeySource],
|
|
317
|
+
["Workspace", c.credentialWorkspace ?? "(none)"],
|
|
318
|
+
["Key", redactKey(c.apiKey)],
|
|
319
|
+
["Keyring", backend ? backend.label : "(none on this platform)"],
|
|
320
|
+
],
|
|
321
|
+
);
|
|
322
|
+
// A plaintext key on a machine that has a keyring is worth one nudge
|
|
323
|
+
// (a human one — the JSON already carries `source` and `keyring`).
|
|
324
|
+
if (c.apiKeySource === "user" && backend && !ctx.output.json) {
|
|
325
|
+
ctx.output.info(`Run \`linear auth migrate\` to move it into the ${backend.label}.`);
|
|
326
|
+
}
|
|
327
|
+
}),
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
// migrate -----------------------------------------------------------------
|
|
331
|
+
auth
|
|
332
|
+
.command("migrate")
|
|
333
|
+
.description("Move plaintext credentials from the config file into the system keyring")
|
|
334
|
+
.action(
|
|
335
|
+
action(async (ctx: Context) => {
|
|
336
|
+
const { migrated, path, keyringLabel } = migrateCredentials();
|
|
337
|
+
ctx.output.emit({ success: true, migrated, path }, () => {
|
|
338
|
+
if (migrated.length === 0) {
|
|
339
|
+
ctx.output.info(`Nothing to migrate: no plaintext keys in ${path}.`);
|
|
340
|
+
} else {
|
|
341
|
+
ctx.output.success(
|
|
342
|
+
`Moved ${migrated.length} credential${migrated.length === 1 ? "" : "s"} into the ${keyringLabel}: ${migrated.join(", ")}. ${path} now holds only the workspace list.`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
}),
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
// logout ------------------------------------------------------------------
|
|
350
|
+
auth
|
|
351
|
+
.command("logout")
|
|
352
|
+
.description("Remove a stored workspace credential (select with --workspace <slug>)")
|
|
353
|
+
.action(
|
|
354
|
+
// Targets the global `--workspace <slug>`. When omitted, the sole
|
|
355
|
+
// configured workspace is used; if several exist, this errors.
|
|
356
|
+
action(async (ctx: Context) => {
|
|
357
|
+
let slug: string | undefined = ctx.options.workspace;
|
|
358
|
+
if (!slug) {
|
|
359
|
+
const configured = listCredentials();
|
|
360
|
+
if (configured.length === 1) slug = configured[0]!.slug;
|
|
361
|
+
else if (configured.length === 0)
|
|
362
|
+
throw usageError("No workspace credentials are configured.");
|
|
363
|
+
else
|
|
364
|
+
throw usageError(
|
|
365
|
+
`Multiple workspaces are configured (${configured
|
|
366
|
+
.map((e) => e.slug)
|
|
367
|
+
.join(", ")}). Pass --workspace <slug> to choose which to remove.`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
if (!(await confirmDestructive(ctx, `Remove credential for workspace '${slug}'?`))) return;
|
|
371
|
+
const removed = removeCredential(slug);
|
|
372
|
+
ctx.output.emit({ success: true, workspace: slug, removed }, () =>
|
|
373
|
+
removed
|
|
374
|
+
? ctx.output.success(`Removed workspace '${slug}'.`)
|
|
375
|
+
: ctx.output.info(`No credential for workspace '${slug}' to remove.`),
|
|
376
|
+
);
|
|
377
|
+
}),
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
const config = program
|
|
381
|
+
.command("config")
|
|
382
|
+
.description("Show the resolved configuration, or write a project config");
|
|
383
|
+
|
|
384
|
+
// show (default) ----------------------------------------------------------
|
|
385
|
+
config
|
|
386
|
+
.command("show", { isDefault: true })
|
|
387
|
+
.description(
|
|
388
|
+
"Show the resolved configuration and where each value came from (secrets redacted)",
|
|
389
|
+
)
|
|
390
|
+
.action(
|
|
391
|
+
action(async (ctx) => {
|
|
392
|
+
// Re-resolve to expose sources/paths beyond what Context keeps.
|
|
393
|
+
const c = resolveConfig({
|
|
394
|
+
flags: {
|
|
395
|
+
apiKey: ctx.options.apiKey,
|
|
396
|
+
// Still the *flag* value (so `teamSource` stays honest), just
|
|
397
|
+
// narrowed: `--team` is repeatable on the issue queries.
|
|
398
|
+
team: firstTeam(ctx.options.team),
|
|
399
|
+
workspace: ctx.options.workspace,
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
// Each setting names the tier it came from — and the file, when it was
|
|
403
|
+
// one — so "why is my team X" has a one-line answer.
|
|
404
|
+
const from = (key: keyof typeof c.origins): string => {
|
|
405
|
+
const o = c.origins[key];
|
|
406
|
+
if (o.source === "none") return "default";
|
|
407
|
+
return o.path ? `${o.source}: ${o.path}` : o.source;
|
|
408
|
+
};
|
|
409
|
+
const show = (
|
|
410
|
+
key: keyof typeof c.origins,
|
|
411
|
+
value: string | undefined,
|
|
412
|
+
): string | undefined => (value === undefined ? undefined : `${value} (${from(key)})`);
|
|
413
|
+
ctx.output.detail(
|
|
414
|
+
{
|
|
415
|
+
apiKey: redactKey(c.apiKey),
|
|
416
|
+
apiKeySource: c.apiKeySource,
|
|
417
|
+
credentialWorkspace: c.credentialWorkspace ?? null,
|
|
418
|
+
team: c.team ?? null,
|
|
419
|
+
workspace: c.workspace ?? null,
|
|
420
|
+
sort: c.sort,
|
|
421
|
+
origins: c.origins,
|
|
422
|
+
userConfigPath: c.userConfigPath,
|
|
423
|
+
projectConfigPath: c.projectConfigPath ?? null,
|
|
424
|
+
globalConfigPath: c.globalConfigPath ?? null,
|
|
425
|
+
},
|
|
426
|
+
[
|
|
427
|
+
["API key", `${redactKey(c.apiKey)} (${c.apiKeySource})`],
|
|
428
|
+
["Credential workspace", c.credentialWorkspace ?? "(none)"],
|
|
429
|
+
["Team", show("team", c.team)],
|
|
430
|
+
["Workspace", show("workspace", c.workspace)],
|
|
431
|
+
["Sort", show("sort", c.sort)],
|
|
432
|
+
["User config", c.userConfigPath],
|
|
433
|
+
["Project config", c.projectConfigPath],
|
|
434
|
+
["Global config", c.globalConfigPath],
|
|
435
|
+
],
|
|
436
|
+
);
|
|
437
|
+
}),
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
// init --------------------------------------------------------------------
|
|
441
|
+
config
|
|
442
|
+
.command("init")
|
|
443
|
+
.description("Write a project .linear.toml (at the git root, or here outside a repository)")
|
|
444
|
+
.option("--team <key>", "default team key (otherwise chosen from a list)")
|
|
445
|
+
.option("--sort <order>", `default issue-list sort (${ISSUE_SORTS.join(" | ")})`)
|
|
446
|
+
.option("--path <file>", "write this file instead of <git root>/.linear.toml")
|
|
447
|
+
.option("--force", "overwrite an existing file")
|
|
448
|
+
.action(
|
|
449
|
+
action(async (ctx: Context, opts) => {
|
|
450
|
+
const path = opts.path ? resolve(opts.path) : defaultProjectConfigPath();
|
|
451
|
+
// Team is the one setting that matters; without --team, offer the real
|
|
452
|
+
// list (a prompt, so it needs a terminal — scripts pass the flag).
|
|
453
|
+
let team: string | undefined = opts.team ?? firstTeam(ctx.options.team);
|
|
454
|
+
if (!team) {
|
|
455
|
+
if (!ctx.isTTY) {
|
|
456
|
+
throw usageError("Pass --team <key>; there is no terminal to choose one from.");
|
|
457
|
+
}
|
|
458
|
+
const teams = await listTeams(ctx.client, Infinity);
|
|
459
|
+
if (teams.length === 0) throw usageError("This workspace has no teams to choose from.");
|
|
460
|
+
team = await promptSelect(
|
|
461
|
+
ctx,
|
|
462
|
+
"Default team for this repository:",
|
|
463
|
+
teams
|
|
464
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
465
|
+
.map((t) => ({ name: `${t.name} (${t.key})`, value: t.key })),
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
const settings: Partial<Record<SettableKey, string>> = {
|
|
469
|
+
team: validateSetting("team", team),
|
|
470
|
+
};
|
|
471
|
+
if (opts.sort !== undefined) settings.sort = validateSetting("sort", opts.sort);
|
|
472
|
+
initProjectConfig(path, settings, { force: opts.force === true });
|
|
473
|
+
ctx.output.emit({ success: true, path, ...settings }, () =>
|
|
474
|
+
ctx.output.success(
|
|
475
|
+
`Wrote ${path} (team ${settings.team}${settings.sort ? `, sort ${settings.sort}` : ""}).`,
|
|
476
|
+
),
|
|
477
|
+
);
|
|
478
|
+
}),
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
// set ---------------------------------------------------------------------
|
|
482
|
+
config
|
|
483
|
+
.command("set <key> <value>")
|
|
484
|
+
.description(`Set one project setting (${SETTABLE_KEYS.join(", ")}) in the project config`)
|
|
485
|
+
.option("--user", "write the user config (~/.config/linear/config.toml) instead")
|
|
486
|
+
.option("--path <file>", "write this file instead of the project config in effect")
|
|
487
|
+
.action(
|
|
488
|
+
action(async (ctx: Context, opts, key: string, value: string) => {
|
|
489
|
+
assertSettableKey(key);
|
|
490
|
+
const clean = validateSetting(key, value);
|
|
491
|
+
let path: string;
|
|
492
|
+
let mode: number | undefined;
|
|
493
|
+
if (opts.user && opts.path) throw usageError("Pass --user or --path, not both.");
|
|
494
|
+
if (opts.user) {
|
|
495
|
+
path = ctx.config.userConfigPath;
|
|
496
|
+
mode = 0o600; // it holds credentials
|
|
497
|
+
} else if (opts.path) {
|
|
498
|
+
path = resolve(opts.path);
|
|
499
|
+
} else {
|
|
500
|
+
// The file discovery would read — so the change is visible from
|
|
501
|
+
// here — or a fresh .linear.toml at the git root if there is none.
|
|
502
|
+
path = ctx.config.projectConfigPath ?? defaultProjectConfigPath();
|
|
503
|
+
}
|
|
504
|
+
const spelling = setConfigKey(path, key, clean, { mode });
|
|
505
|
+
ctx.output.emit({ success: true, path, key: spelling, value: clean }, () =>
|
|
506
|
+
ctx.output.success(`Set ${spelling} = ${JSON.stringify(clean)} in ${path}`),
|
|
507
|
+
);
|
|
508
|
+
}),
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Check a value for a project setting the way the reader will judge it, so a
|
|
514
|
+
* bad value is refused here rather than on the next `issue list`.
|
|
515
|
+
*/
|
|
516
|
+
function validateSetting(key: SettableKey, value: string): string {
|
|
517
|
+
const v = value.trim();
|
|
518
|
+
if (!v) throw usageError(`A value for '${key}' is required.`);
|
|
519
|
+
switch (key) {
|
|
520
|
+
case "sort":
|
|
521
|
+
if (!(ISSUE_SORTS as readonly string[]).includes(v)) {
|
|
522
|
+
throw usageError(`Invalid sort '${v}'. Valid values: ${ISSUE_SORTS.join(", ")}.`);
|
|
523
|
+
}
|
|
524
|
+
return v;
|
|
525
|
+
case "team":
|
|
526
|
+
// A team key: letters and digits, as Linear issues them (TES, ENG2).
|
|
527
|
+
if (!/^[A-Za-z][A-Za-z0-9]*$/.test(v)) {
|
|
528
|
+
throw usageError(`'${v}' does not look like a team key (letters and digits, e.g. TES).`);
|
|
529
|
+
}
|
|
530
|
+
return v.toUpperCase();
|
|
531
|
+
default:
|
|
532
|
+
return v;
|
|
533
|
+
}
|
|
534
|
+
}
|