linear-sdk-cli 0.5.0 → 0.5.2
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 +14 -0
- package/COVERAGE.md +9 -5
- package/README.md +7 -2
- package/package.json +9 -8
- package/skills/linear-sdk-cli/SKILL.md +10 -0
- package/skills/linear-sdk-cli/references/auth.md +3 -1
- package/skills/linear-sdk-cli/references/comment.md +2 -0
- package/skills/linear-sdk-cli/references/issue.md +2 -0
- package/src/cli.ts +1 -0
- package/src/commands/comment.ts +9 -7
- package/src/commands/meta.ts +30 -4
- package/src/commands/open.ts +2 -1
- package/src/config.ts +13 -29
- package/src/context.ts +21 -1
- package/src/lib/action.ts +8 -0
- package/src/lib/output-shapes.ts +1 -0
- package/src/lib/resolve.ts +53 -39
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,20 @@ Release Please generates this file from Conventional Commits. Feature and fix pu
|
|
|
4
4
|
not edit it directly; their squash-commit titles are the release-note source of truth. This project
|
|
5
5
|
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.5.2](https://github.com/zhendalf/linear-sdk-cli/compare/v0.5.1...v0.5.2) (2026-09-13)
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
- **deps:** refresh SDK and toolchain dependencies ([#66](https://github.com/zhendalf/linear-sdk-cli/issues/66)) ([dcc6060](https://github.com/zhendalf/linear-sdk-cli/commit/dcc60603f79a52d64d7265abd658ea4712e99041))
|
|
12
|
+
|
|
13
|
+
## [0.5.1](https://github.com/zhendalf/linear-sdk-cli/compare/v0.5.0...v0.5.1) (2026-09-06)
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
- associate login with projects and make workspace defaults explicit ([#62](https://github.com/zhendalf/linear-sdk-cli/issues/62)) ([faf4028](https://github.com/zhendalf/linear-sdk-cli/commit/faf4028246a947dc13f674d75df2b1ba3c0ac0f3))
|
|
18
|
+
- **comment:** accept create as an add alias ([#57](https://github.com/zhendalf/linear-sdk-cli/issues/57)) ([c8146b9](https://github.com/zhendalf/linear-sdk-cli/commit/c8146b98f60b68b5cc8bd2063fdd49cc9cc91216))
|
|
19
|
+
- reduce label resolution requests and latency ([#64](https://github.com/zhendalf/linear-sdk-cli/issues/64)) ([d1300a5](https://github.com/zhendalf/linear-sdk-cli/commit/d1300a5379e99a466a6111e24fbcfa7b85f21c1f))
|
|
20
|
+
|
|
7
21
|
## [0.5.0](https://github.com/zhendalf/linear-sdk-cli/compare/v0.4.0...v0.5.0) (2026-09-03)
|
|
8
22
|
|
|
9
23
|
### Features
|
package/COVERAGE.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# SDK Coverage Audit
|
|
2
2
|
|
|
3
|
-
Generated by `bun run audit:coverage`. Total LinearClient members: **
|
|
3
|
+
Generated by `bun run audit:coverage`. Total LinearClient members: **476**.
|
|
4
4
|
|
|
5
5
|
| Status | Count | Meaning |
|
|
6
6
|
| -------- | ----- | ----------------------------------------------- |
|
|
7
7
|
| curated | 107 | Has a first-class CLI command |
|
|
8
|
-
| raw-only |
|
|
9
|
-
| excluded |
|
|
8
|
+
| raw-only | 144 | Reachable via `linear api` (no bespoke command) |
|
|
9
|
+
| excluded | 225 | Out of scope (admin/integration/SDK plumbing) |
|
|
10
10
|
|
|
11
11
|
Every member is classified — CI fails otherwise. `raw-only` members are still
|
|
12
12
|
100% reachable through the raw GraphQL escape hatch; they simply lack a tailored command.
|
|
@@ -125,7 +125,7 @@ Every member is classified — CI fails otherwise. `raw-only` members are still
|
|
|
125
125
|
- `workflowState` — state
|
|
126
126
|
- `workflowStates` — state
|
|
127
127
|
|
|
128
|
-
## Excluded (
|
|
128
|
+
## Excluded (225)
|
|
129
129
|
|
|
130
130
|
- `agentActivities` — specialized/raw api only
|
|
131
131
|
- `agentActivity` — specialized/raw api only
|
|
@@ -310,6 +310,7 @@ Every member is classified — CI fails otherwise. `raw-only` members are still
|
|
|
310
310
|
- `ssoUrlFromEmail` — auth/session flows (out of scope)
|
|
311
311
|
- `suspendUser` — admin/enterprise (raw api only)
|
|
312
312
|
- `template` — specialized/raw api only
|
|
313
|
+
- `templateSearch` — specialized/raw api only
|
|
313
314
|
- `templates` — specialized/raw api only
|
|
314
315
|
- `templatesForIntegration` — integration/import (raw api only)
|
|
315
316
|
- `timeSchedule` — specialized/raw api only
|
|
@@ -350,8 +351,9 @@ Every member is classified — CI fails otherwise. `raw-only` members are still
|
|
|
350
351
|
- `userRevokeAllSessions` — admin/enterprise (raw api only)
|
|
351
352
|
- `userRevokeSession` — admin/enterprise (raw api only)
|
|
352
353
|
- `userUnlinkFromIdentityProvider` — admin/enterprise (raw api only)
|
|
354
|
+
- `userViewPreferences` — specialized/raw api only
|
|
353
355
|
|
|
354
|
-
## Raw-only (
|
|
356
|
+
## Raw-only (144)
|
|
355
357
|
|
|
356
358
|
<details><summary>Show</summary>
|
|
357
359
|
|
|
@@ -408,6 +410,7 @@ Every member is classified — CI fails otherwise. `raw-only` members are still
|
|
|
408
410
|
- `emailIntakeAddressRefreshSesDomainStatus`
|
|
409
411
|
- `emailIntakeAddressRotate`
|
|
410
412
|
- `emailUnsubscribe`
|
|
413
|
+
- `inboxNotifications`
|
|
411
414
|
- `initiativeAddLabel`
|
|
412
415
|
- `initiativeFilterSuggestion`
|
|
413
416
|
- `initiativeLabel`
|
|
@@ -472,6 +475,7 @@ Every member is classified — CI fails otherwise. `raw-only` members are still
|
|
|
472
475
|
- `updateAttachment`
|
|
473
476
|
- `updateEmailIntakeAddress`
|
|
474
477
|
- `updateFavorite`
|
|
478
|
+
- `updateInboxNotification`
|
|
475
479
|
- `updateInitiativeLabel`
|
|
476
480
|
- `updateInitiativeRelation`
|
|
477
481
|
- `updateInitiativeToProject`
|
package/README.md
CHANGED
|
@@ -236,6 +236,10 @@ linear --workspace other-org issue list # use a specific workspace for one co
|
|
|
236
236
|
linear auth logout --workspace acme # revoke OAuth, then remove one credential
|
|
237
237
|
```
|
|
238
238
|
|
|
239
|
+
Successful browser and API-key login also save `workspace = "<authenticated slug>"` in the discovered project config, preserving other settings and comments. If no config exists, login creates `.linear.toml` at the Git root (or cwd outside Git). Use `auth login --no-project` for credential-only login. Human output reports the association path; JSON includes `projectConfigPath` (null with `--no-project`). Credentials remain in the global credential store. Environment overrides still take precedence over this association.
|
|
240
|
+
|
|
241
|
+
Global defaults are optional: login, credential adoption, and logout never choose a new one. Use `linear auth default <slug>` to explicitly select a global fallback. Removing that workspace clears its default without promoting another workspace. Existing defaults, including defaults imported from the reference CLI, are preserved on upgrade because older files cannot distinguish a user choice from an automatically assigned default. To opt out of a legacy default, remove the top-level `default_workspace` from the user config and, if present, `default` from the reference CLI's `credentials.toml`; retain the workspace entries.
|
|
242
|
+
|
|
239
243
|
If revocation is intentionally unavailable, `auth logout --local-only` removes only local state.
|
|
240
244
|
If browser login temporarily superseded an existing personal API-key profile for that workspace,
|
|
241
245
|
OAuth logout preserves and reactivates the API key instead of deleting it.
|
|
@@ -244,8 +248,9 @@ OAuth logout preserves and reactivates the API key instead of deleting it.
|
|
|
244
248
|
environment and stored-credential lookup. Otherwise `LINEAR_API_KEY` or `LINEAR_ACCESS_TOKEN` is
|
|
245
249
|
absolute. With no invocation-scoped credential, the workspace is chosen by `--workspace` →
|
|
246
250
|
`LINEAR_WORKSPACE` env → project config `workspace` → `default_workspace` in the user config. With
|
|
247
|
-
one configured workspace it's used automatically; with several and no selection,
|
|
248
|
-
|
|
251
|
+
one configured workspace it's used automatically; with several and no selection, interactive
|
|
252
|
+
commands prompt for an invocation-only choice. Noninteractive and JSON commands fail clearly
|
|
253
|
+
and explain how to select a workspace. Auth repair and discovery commands remain available. An invocation credential does not
|
|
249
254
|
silently inherit the default or project profile's team; pair it with `--workspace` (or
|
|
250
255
|
`LINEAR_WORKSPACE`) naming a configured profile when that profile metadata should apply.
|
|
251
256
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "linear-sdk-cli",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "Ergonomic command-line interface for Linear, built on @linear/sdk",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
"format": "oxfmt .",
|
|
58
58
|
"format:check": "oxfmt --check .",
|
|
59
59
|
"test": "bun test test/unit test/contract",
|
|
60
|
+
"test:performance": "bun test --timeout 15000 test/performance",
|
|
60
61
|
"test:unit": "bun test test/unit",
|
|
61
62
|
"test:contract": "bun test test/contract",
|
|
62
63
|
"test:live": "LINEAR_CLI_LIVE=1 bun test --timeout 60000 test/integration",
|
|
@@ -69,19 +70,19 @@
|
|
|
69
70
|
"janitor": "bun run test/janitor.ts"
|
|
70
71
|
},
|
|
71
72
|
"dependencies": {
|
|
72
|
-
"@inquirer/prompts": "^8.7.
|
|
73
|
-
"@linear/sdk": "^
|
|
73
|
+
"@inquirer/prompts": "^8.7.2",
|
|
74
|
+
"@linear/sdk": "^95.0.0",
|
|
74
75
|
"commander": "^15.0.0",
|
|
75
76
|
"graphql": "^17.0.2",
|
|
76
77
|
"picocolors": "^1.1.1",
|
|
77
78
|
"smol-toml": "^1.8.0"
|
|
78
79
|
},
|
|
79
|
-
"packageManager": "bun@1.4.
|
|
80
|
+
"packageManager": "bun@1.4.2",
|
|
80
81
|
"devDependencies": {
|
|
81
|
-
"@types/bun": "^1.4.
|
|
82
|
-
"@types/node": "^26.
|
|
83
|
-
"oxfmt": "^0.
|
|
84
|
-
"oxlint": "^1.
|
|
82
|
+
"@types/bun": "^1.4.2",
|
|
83
|
+
"@types/node": "^26.5.1",
|
|
84
|
+
"oxfmt": "^0.67.0",
|
|
85
|
+
"oxlint": "^1.82.0",
|
|
85
86
|
"typescript": "^7.0.2"
|
|
86
87
|
}
|
|
87
88
|
}
|
|
@@ -94,6 +94,8 @@ The CLI supports distinct human, hosted-app, and personal-key lifecycles:
|
|
|
94
94
|
linear issue list --workspace acme --json
|
|
95
95
|
```
|
|
96
96
|
|
|
97
|
+
Login associates the authenticated workspace with the discovered project config, or creates `.linear.toml` at the Git root (cwd outside Git). Use `--no-project` for credential-only login; JSON reports `projectConfigPath` or null. Credentials stay global. Workspace selection is `--workspace` → `LINEAR_WORKSPACE` → project config → optional global default. A sole stored workspace is automatic; multiple unselected workspaces prompt only interactively and fail in JSON/noninteractive mode. Login/adoption/logout never assign a new default. Existing legacy defaults remain valid because their original intent is unknown; `auth default` is an explicit convenience.
|
|
98
|
+
|
|
97
99
|
An explicit credential flag overrides environment credentials. If both credential kinds occur at
|
|
98
100
|
the same precedence level, the CLI fails rather than silently choosing an actor. Inspect resolution
|
|
99
101
|
with `linear auth status`. `linear auth token` exports stored API keys only and never OAuth tokens.
|
|
@@ -215,6 +217,14 @@ perfectly with a heredoc:
|
|
|
215
217
|
- `comment add` / `comment update` / `comment reply` → `--body-file <path>`
|
|
216
218
|
- `document create` / `document update` → `--content-file <path>`
|
|
217
219
|
|
|
220
|
+
Comments canonically use `add`, while resources such as attachments use `create`.
|
|
221
|
+
`create` is also accepted as a compatibility alias for comment `add`. For a file-backed
|
|
222
|
+
issue comment, copy this form:
|
|
223
|
+
|
|
224
|
+
```bash
|
|
225
|
+
linear issue comment add LUMI-9340 --body-file comment.md --json
|
|
226
|
+
```
|
|
227
|
+
|
|
218
228
|
```bash
|
|
219
229
|
linear issue create --title "Onboarding bug" --team TES --no-input --json \
|
|
220
230
|
--description-file - <<'EOF'
|
|
@@ -69,7 +69,7 @@ storage: string
|
|
|
69
69
|
|
|
70
70
|
### `linear auth login`
|
|
71
71
|
|
|
72
|
-
Authenticate
|
|
72
|
+
Authenticate and select the workspace for this project
|
|
73
73
|
|
|
74
74
|
```
|
|
75
75
|
linear auth login [options]
|
|
@@ -77,6 +77,7 @@ linear auth login [options]
|
|
|
77
77
|
|
|
78
78
|
| Option | Description |
|
|
79
79
|
| ---------------------- | --------------------------------------------------------------------- |
|
|
80
|
+
| `--no-project` | save credentials without changing the project workspace |
|
|
80
81
|
| `--key <key>` | use a personal API key ('-' reads it from stdin) |
|
|
81
82
|
| `--plaintext` | Store the key in the config file (0600) instead of the system keyring |
|
|
82
83
|
| `--no-browser` | print the authorization URL instead of opening it |
|
|
@@ -97,6 +98,7 @@ storage: string
|
|
|
97
98
|
scopes?: string[]
|
|
98
99
|
expiresAt?: string
|
|
99
100
|
path: string
|
|
101
|
+
projectConfigPath: string | null
|
|
100
102
|
```
|
|
101
103
|
|
|
102
104
|
### `linear auth logout`
|
package/src/cli.ts
CHANGED
package/src/commands/comment.ts
CHANGED
|
@@ -32,12 +32,12 @@ const ROW_COLUMNS: Column<svc.CommentRow>[] = [
|
|
|
32
32
|
/** Options for a mounted copy of the shared verbs. */
|
|
33
33
|
interface MountOptions {
|
|
34
34
|
/** Register the short aliases (`ls`, `edit`, `rm`). Off under `issue comment`. */
|
|
35
|
-
|
|
35
|
+
shortAliases?: boolean;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
function buildList(o: MountOptions): Command {
|
|
39
39
|
const cmd = new Command("list").argument("<issue>").description("List comments on an issue");
|
|
40
|
-
if (o.
|
|
40
|
+
if (o.shortAliases !== false) cmd.alias("ls");
|
|
41
41
|
return cmd.action(
|
|
42
42
|
action(async (ctx: Context, _opts, issueArg: string) => {
|
|
43
43
|
const rows = await svc.listComments(ctx.client, issueArg, ctx.limit);
|
|
@@ -48,6 +48,7 @@ function buildList(o: MountOptions): Command {
|
|
|
48
48
|
|
|
49
49
|
function buildAdd(_o: MountOptions): Command {
|
|
50
50
|
return new Command("add")
|
|
51
|
+
.alias("create")
|
|
51
52
|
.argument("<issue>")
|
|
52
53
|
.argument("[body]")
|
|
53
54
|
.description("Add a comment to an issue (images uploaded with --attach render inline)")
|
|
@@ -129,7 +130,7 @@ function buildUpdate(o: MountOptions): Command {
|
|
|
129
130
|
"prepend a real Linear mention (name, email, me, or id; repeatable)",
|
|
130
131
|
collectArray,
|
|
131
132
|
);
|
|
132
|
-
if (o.
|
|
133
|
+
if (o.shortAliases !== false) cmd.alias("edit");
|
|
133
134
|
return cmd.action(
|
|
134
135
|
action(async (ctx: Context, opts, commentId: string, bodyArg?: string) => {
|
|
135
136
|
// Fetch the comment first: the editor opens ON the current body (so
|
|
@@ -161,7 +162,7 @@ function buildUpdate(o: MountOptions): Command {
|
|
|
161
162
|
|
|
162
163
|
function buildDelete(o: MountOptions): Command {
|
|
163
164
|
const cmd = new Command("delete").argument("<commentId>").description("Delete a comment");
|
|
164
|
-
if (o.
|
|
165
|
+
if (o.shortAliases !== false) cmd.alias("rm");
|
|
165
166
|
return cmd.action(
|
|
166
167
|
action(async (ctx: Context, _opts, commentId: string) => {
|
|
167
168
|
if (!(await confirmDestructive(ctx, `Delete comment ${commentId}?`))) return;
|
|
@@ -250,11 +251,12 @@ export function registerComment(program: Command): void {
|
|
|
250
251
|
* command, which keeps its own `[id] [body]` "add a comment" behavior for every
|
|
251
252
|
* other operand. The short aliases (`ls`, `edit`, `rm`) are deliberately NOT
|
|
252
253
|
* registered here: each subcommand name shadows a one-word comment body under
|
|
253
|
-
* the parent
|
|
254
|
-
*
|
|
254
|
+
* the parent. `create` is the sole additional collision because agents commonly
|
|
255
|
+
* infer it from other resource mutations; accepting that spelling is safer than
|
|
256
|
+
* rejecting an intended comment mutation.
|
|
255
257
|
*/
|
|
256
258
|
export function registerIssueCommentGroup(issueComment: Command): void {
|
|
257
259
|
for (const build of Object.values(SHARED_COMMENT_VERBS)) {
|
|
258
|
-
issueComment.addCommand(build({
|
|
260
|
+
issueComment.addCommand(build({ shortAliases: false }));
|
|
259
261
|
}
|
|
260
262
|
}
|
package/src/commands/meta.ts
CHANGED
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
} from "../config.js";
|
|
32
32
|
import { keyring } from "../lib/keyring.js";
|
|
33
33
|
import { readStdinSync } from "../lib/body.js";
|
|
34
|
-
import { authError, normalizeError, usageError } from "../lib/errors.js";
|
|
34
|
+
import { CliError, authError, normalizeError, usageError } from "../lib/errors.js";
|
|
35
35
|
import { confirmDestructive, promptSelect } from "../lib/prompt.js";
|
|
36
36
|
import { listTeams } from "../services/team.js";
|
|
37
37
|
import { resolveTeam } from "../lib/resolve.js";
|
|
@@ -177,6 +177,21 @@ export async function validateAuthCredential(
|
|
|
177
177
|
};
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
/** Bind a completed login using the same project location rules as `config set`. */
|
|
181
|
+
function saveLoginProject(ctx: Context, workspace: string, project: boolean): string | null {
|
|
182
|
+
if (!project) return null;
|
|
183
|
+
const path = ctx.config.projectConfigPath ?? defaultProjectConfigPath();
|
|
184
|
+
try {
|
|
185
|
+
setConfigKey(path, "workspace", workspace);
|
|
186
|
+
} catch {
|
|
187
|
+
throw new CliError(
|
|
188
|
+
`Credentials saved for workspace '${workspace}', but its project association could not be saved to ${path}. Fix the project file and run \`linear config set workspace ${workspace}\`; login does not need to be repeated.`,
|
|
189
|
+
"runtime",
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
return path;
|
|
193
|
+
}
|
|
194
|
+
|
|
180
195
|
function explicitWorkspaceSlug(input: string): string {
|
|
181
196
|
const slug = input.trim();
|
|
182
197
|
if (!/^[a-z0-9](?:[a-z0-9-]{0,98}[a-z0-9])?$/.test(slug)) {
|
|
@@ -202,7 +217,8 @@ export function registerMeta(program: Command): void {
|
|
|
202
217
|
// login -------------------------------------------------------------------
|
|
203
218
|
auth
|
|
204
219
|
.command("login")
|
|
205
|
-
.description("Authenticate
|
|
220
|
+
.description("Authenticate and select the workspace for this project")
|
|
221
|
+
.option("--no-project", "save credentials without changing the project workspace")
|
|
206
222
|
.option("--key <key>", "use a personal API key ('-' reads it from stdin)")
|
|
207
223
|
.option("--plaintext", "Store the key in the config file (0600) instead of the system keyring")
|
|
208
224
|
.option("--no-browser", "print the authorization URL instead of opening it")
|
|
@@ -241,6 +257,7 @@ export function registerMeta(program: Command): void {
|
|
|
241
257
|
const { path, storage, keyringLabel } = writeCredential(org.urlKey, key, {
|
|
242
258
|
plaintext: opts.plaintext === true,
|
|
243
259
|
});
|
|
260
|
+
const projectConfigPath = saveLoginProject(ctx, org.urlKey, opts.project !== false);
|
|
244
261
|
const where =
|
|
245
262
|
storage === "keychain"
|
|
246
263
|
? `Credential saved to the ${keyringLabel}; ${path} records the workspace.`
|
|
@@ -253,10 +270,11 @@ export function registerMeta(program: Command): void {
|
|
|
253
270
|
user: me,
|
|
254
271
|
storage,
|
|
255
272
|
path,
|
|
273
|
+
projectConfigPath,
|
|
256
274
|
},
|
|
257
275
|
() =>
|
|
258
276
|
ctx.output.success(
|
|
259
|
-
`Authenticated as ${me.name} <${me.email}> for workspace '${org.urlKey}'. ${where}`,
|
|
277
|
+
`Authenticated as ${me.name} <${me.email}> for workspace '${org.urlKey}'. ${where}${projectConfigPath ? ` Project workspace saved to ${projectConfigPath}.` : ""}`,
|
|
260
278
|
),
|
|
261
279
|
);
|
|
262
280
|
return;
|
|
@@ -327,6 +345,11 @@ export function registerMeta(program: Command): void {
|
|
|
327
345
|
user: identity.user,
|
|
328
346
|
};
|
|
329
347
|
const saved = writeOAuthCredential(credential);
|
|
348
|
+
const projectConfigPath = saveLoginProject(
|
|
349
|
+
ctx,
|
|
350
|
+
credential.workspace.urlKey,
|
|
351
|
+
opts.project !== false,
|
|
352
|
+
);
|
|
330
353
|
ctx.output.emit(
|
|
331
354
|
{
|
|
332
355
|
success: true,
|
|
@@ -337,10 +360,11 @@ export function registerMeta(program: Command): void {
|
|
|
337
360
|
scopes: credential.scopes,
|
|
338
361
|
expiresAt: new Date(credential.expiresAt).toISOString(),
|
|
339
362
|
path: saved.path,
|
|
363
|
+
projectConfigPath,
|
|
340
364
|
},
|
|
341
365
|
() =>
|
|
342
366
|
ctx.output.success(
|
|
343
|
-
`Authenticated as ${credential.user.name} <${credential.user.email}> for workspace '${credential.workspace.urlKey}'. OAuth credentials saved to the ${saved.keyringLabel}
|
|
367
|
+
`Authenticated as ${credential.user.name} <${credential.user.email}> for workspace '${credential.workspace.urlKey}'. OAuth credentials saved to the ${saved.keyringLabel}.${projectConfigPath ? ` Project workspace saved to ${projectConfigPath}.` : ""}`,
|
|
344
368
|
),
|
|
345
369
|
);
|
|
346
370
|
} finally {
|
|
@@ -455,6 +479,7 @@ export function registerMeta(program: Command): void {
|
|
|
455
479
|
.action(
|
|
456
480
|
action(async (ctx) => {
|
|
457
481
|
const c = ctx.config;
|
|
482
|
+
if (c.workspaceChoices) throw c.apiKeyError;
|
|
458
483
|
const credential = c.accessToken ?? c.apiKey;
|
|
459
484
|
const credentialType = c.oauthCredential
|
|
460
485
|
? "oauth-user"
|
|
@@ -660,6 +685,7 @@ export function registerMeta(program: Command): void {
|
|
|
660
685
|
if (!ctx.isTTY) {
|
|
661
686
|
throw usageError("Pass --team <key>; there is no terminal to choose one from.");
|
|
662
687
|
}
|
|
688
|
+
await ctx.selectWorkspace();
|
|
663
689
|
const teams = await listTeams(ctx.client, Infinity);
|
|
664
690
|
if (teams.length === 0) throw usageError("This workspace has no teams to choose from.");
|
|
665
691
|
team = await promptSelect(
|
package/src/commands/open.ts
CHANGED
|
@@ -46,11 +46,12 @@ async function resolveOpenTarget(
|
|
|
46
46
|
ctx: Context,
|
|
47
47
|
target: string | undefined,
|
|
48
48
|
): Promise<{ target: OpenTarget; url: string; label: string }> {
|
|
49
|
+
if (target && URL.test(target)) return { target: "url", url: target, label: target };
|
|
50
|
+
await ctx.selectWorkspace();
|
|
49
51
|
if (!target) {
|
|
50
52
|
const org = await getOrganizationDetail(ctx.client);
|
|
51
53
|
return { target: "workspace", url: `https://linear.app/${org.urlKey}`, label: org.name };
|
|
52
54
|
}
|
|
53
|
-
if (URL.test(target)) return { target: "url", url: target, label: target };
|
|
54
55
|
if (target.startsWith("team:")) {
|
|
55
56
|
const team = await resolveTeam(ctx.client, target.slice("team:".length), ctx.defaultTeam);
|
|
56
57
|
const org = await getOrganizationDetail(ctx.client);
|
package/src/config.ts
CHANGED
|
@@ -113,6 +113,8 @@ export interface ResolvedConfig {
|
|
|
113
113
|
* needed (see createClient / `auth token`).
|
|
114
114
|
*/
|
|
115
115
|
apiKeyError?: CliError;
|
|
116
|
+
/** Stored choices when selection is ambiguous; never an implicit default. */
|
|
117
|
+
workspaceChoices?: string[];
|
|
116
118
|
/** Workspace slug whose stored credential was used, if any. */
|
|
117
119
|
credentialWorkspace?: string;
|
|
118
120
|
/** Selected stored profile whose non-secret metadata may participate. */
|
|
@@ -467,6 +469,7 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
|
|
|
467
469
|
let oauthCredential: OAuthUserCredential | undefined;
|
|
468
470
|
let credentialWorkspace: string | undefined;
|
|
469
471
|
let workspaceProfile: string | undefined;
|
|
472
|
+
let workspaceChoices: string[] | undefined;
|
|
470
473
|
let apiKeyError: CliError | undefined;
|
|
471
474
|
|
|
472
475
|
if (flags.apiKey && flags.accessToken) {
|
|
@@ -539,8 +542,9 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
|
|
|
539
542
|
take(slugs[0]!);
|
|
540
543
|
} else if (slugs.length > 1) {
|
|
541
544
|
// Multiple workspaces but no default → ambiguous (deferred).
|
|
545
|
+
workspaceChoices = slugs;
|
|
542
546
|
apiKeyError = new CliError(
|
|
543
|
-
`Multiple workspaces are configured (${slugs.join(", ")}) but none is selected. Pass --workspace <slug
|
|
547
|
+
`Multiple workspaces are configured (${slugs.join(", ")}) but none is selected. Pass --workspace <slug>, run \`linear config set workspace <slug>\` for this project, or explicitly choose \`linear auth default <slug>\`.`,
|
|
544
548
|
"usage",
|
|
545
549
|
);
|
|
546
550
|
}
|
|
@@ -621,6 +625,7 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
|
|
|
621
625
|
apiKeyError,
|
|
622
626
|
credentialWorkspace,
|
|
623
627
|
workspaceProfile,
|
|
628
|
+
workspaceChoices,
|
|
624
629
|
team: teamTiers.find(([, settings]) => settings.team !== undefined)?.[1].team,
|
|
625
630
|
// The display setting walks every tier. Credential selection above uses
|
|
626
631
|
// the flag, env, and project tiers, then the auth-specific user default.
|
|
@@ -1001,10 +1006,6 @@ export function writeOAuthCredential(credential: OAuthUserCredential): WriteOAut
|
|
|
1001
1006
|
table.keyring = true;
|
|
1002
1007
|
table.oauth = true;
|
|
1003
1008
|
ws[slug] = table;
|
|
1004
|
-
if (!asString(obj.default_workspace)) {
|
|
1005
|
-
const reference = readReferenceCredentials(referenceCredentialsPath());
|
|
1006
|
-
if (!reference.defaultWorkspace) obj.default_workspace = slug;
|
|
1007
|
-
}
|
|
1008
1009
|
writeUserObject(path, obj);
|
|
1009
1010
|
} catch (err) {
|
|
1010
1011
|
try {
|
|
@@ -1130,10 +1131,6 @@ export function adoptKeyringCredential(
|
|
|
1130
1131
|
delete table.api_key;
|
|
1131
1132
|
table.keyring = true;
|
|
1132
1133
|
ws[slug] = table;
|
|
1133
|
-
if (!asString(obj.default_workspace)) {
|
|
1134
|
-
const reference = readReferenceCredentials(referenceCredentialsPath());
|
|
1135
|
-
if (!reference.defaultWorkspace) obj.default_workspace = slug;
|
|
1136
|
-
}
|
|
1137
1134
|
writeUserObject(path, obj);
|
|
1138
1135
|
return { path, storage: "keychain", keyringLabel: found.keyringLabel };
|
|
1139
1136
|
});
|
|
@@ -1146,8 +1143,8 @@ export function adoptKeyringCredential(
|
|
|
1146
1143
|
* no keyring, it is written as `api_key` in the 0600 file — the latter without
|
|
1147
1144
|
* complaint, since a platform is not an error. Either way the OTHER form for
|
|
1148
1145
|
* the same slug is dropped from the file, so a re-login moves the key rather
|
|
1149
|
-
* than leaving a plaintext copy behind that would keep winning.
|
|
1150
|
-
*
|
|
1146
|
+
* than leaving a plaintext copy behind that would keep winning. Global defaults
|
|
1147
|
+
* are only written by setDefaultWorkspace; existing legacy defaults are preserved.
|
|
1151
1148
|
*/
|
|
1152
1149
|
export function writeCredential(
|
|
1153
1150
|
slug: string,
|
|
@@ -1177,12 +1174,6 @@ export function writeCredential(
|
|
|
1177
1174
|
}
|
|
1178
1175
|
systemBackend?.delete(oauthAccount(slug));
|
|
1179
1176
|
ws[slug] = table;
|
|
1180
|
-
if (!asString(obj.default_workspace)) {
|
|
1181
|
-
// Leave the reference CLI's default in charge if it has one; writing ours
|
|
1182
|
-
// over it would silently switch a migrating user's workspace.
|
|
1183
|
-
const reference = readReferenceCredentials(referenceCredentialsPath());
|
|
1184
|
-
if (!reference.defaultWorkspace) obj.default_workspace = slug;
|
|
1185
|
-
}
|
|
1186
1177
|
writeUserObject(path, obj);
|
|
1187
1178
|
return { path, storage, keyringLabel: backend?.label };
|
|
1188
1179
|
});
|
|
@@ -1242,7 +1233,7 @@ function removeFromReferenceCredentials(slug: string): boolean {
|
|
|
1242
1233
|
const reference = readReferenceCredentials(path);
|
|
1243
1234
|
if (!reference.workspaces.includes(slug)) return false;
|
|
1244
1235
|
const workspaces = reference.workspaces.filter((s) => s !== slug).sort();
|
|
1245
|
-
const def = reference.defaultWorkspace === slug ?
|
|
1236
|
+
const def = reference.defaultWorkspace === slug ? undefined : reference.defaultWorkspace;
|
|
1246
1237
|
const obj: Record<string, unknown> = {};
|
|
1247
1238
|
if (def) obj.default = def;
|
|
1248
1239
|
obj.workspaces = workspaces;
|
|
@@ -1253,8 +1244,8 @@ function removeFromReferenceCredentials(slug: string): boolean {
|
|
|
1253
1244
|
/**
|
|
1254
1245
|
* Forget a workspace: remove its `[workspaces."<slug>"]` table, its keyring
|
|
1255
1246
|
* entry, and its line in the reference CLI's list — whichever exist. If the
|
|
1256
|
-
* removed slug was the default,
|
|
1257
|
-
* workspace
|
|
1247
|
+
* removed slug was the default, that default is cleared without choosing another
|
|
1248
|
+
* workspace. Returns true if anything was removed.
|
|
1258
1249
|
*/
|
|
1259
1250
|
export interface RemoveCredentialResult {
|
|
1260
1251
|
removed: boolean;
|
|
@@ -1287,9 +1278,7 @@ export function removeCredentialWithMetadata(slug: string): RemoveCredentialResu
|
|
|
1287
1278
|
if (Object.keys(ws).length === 0) delete obj.workspaces;
|
|
1288
1279
|
|
|
1289
1280
|
if (asString(obj.default_workspace) === slug) {
|
|
1290
|
-
|
|
1291
|
-
if (remaining.length > 0) obj.default_workspace = remaining[0]!;
|
|
1292
|
-
else delete obj.default_workspace;
|
|
1281
|
+
delete obj.default_workspace;
|
|
1293
1282
|
writeUserObject(path, obj);
|
|
1294
1283
|
} else if (listed) {
|
|
1295
1284
|
writeUserObject(path, obj);
|
|
@@ -1344,7 +1333,6 @@ export function removeOAuthCredential(slug: string): RemoveOAuthCredentialResult
|
|
|
1344
1333
|
const table = ws[slug];
|
|
1345
1334
|
const hadOAuthMarker = table?.oauth === true;
|
|
1346
1335
|
if (hadOAuthMarker) removed = true;
|
|
1347
|
-
const reference = readReferenceCredentials(referenceCredentialsPath());
|
|
1348
1336
|
const apiKeyFallback = Boolean(asString(table?.api_key)) || keyringApiKey;
|
|
1349
1337
|
|
|
1350
1338
|
if (apiKeyFallback) {
|
|
@@ -1360,11 +1348,7 @@ export function removeOAuthCredential(slug: string): RemoveOAuthCredentialResult
|
|
|
1360
1348
|
if (Object.keys(ws).length === 0) delete obj.workspaces;
|
|
1361
1349
|
|
|
1362
1350
|
if (!apiKeyFallback && asString(obj.default_workspace) === slug) {
|
|
1363
|
-
|
|
1364
|
-
const referenceFallback = reference.workspaces.find((candidate) => candidate !== slug);
|
|
1365
|
-
if (remaining.length > 0) obj.default_workspace = remaining[0]!;
|
|
1366
|
-
else if (referenceFallback) obj.default_workspace = referenceFallback;
|
|
1367
|
-
else delete obj.default_workspace;
|
|
1351
|
+
delete obj.default_workspace;
|
|
1368
1352
|
}
|
|
1369
1353
|
if (hadOAuthMarker || apiKeyFallback) writeUserObject(path, obj);
|
|
1370
1354
|
return {
|
package/src/context.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import type { LinearClient } from "@linear/sdk";
|
|
8
8
|
import { resolveConfig, type ResolvedConfig } from "./config.js";
|
|
9
9
|
import { createClient, setRetryReporter } from "./client.js";
|
|
10
|
+
import { promptSelect } from "./lib/prompt.js";
|
|
10
11
|
import { Output } from "./output/format.js";
|
|
11
12
|
import { isDebugEnabled, shouldUseColor } from "./output/color.js";
|
|
12
13
|
|
|
@@ -46,7 +47,7 @@ export function firstTeam(team: string | string[] | undefined): string | undefin
|
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
export class Context {
|
|
49
|
-
|
|
50
|
+
config: ResolvedConfig;
|
|
50
51
|
readonly output: Output;
|
|
51
52
|
readonly options: GlobalOptions;
|
|
52
53
|
readonly isTTY: boolean;
|
|
@@ -96,6 +97,25 @@ export class Context {
|
|
|
96
97
|
setRetryReporter((line) => this.output.info(line));
|
|
97
98
|
}
|
|
98
99
|
|
|
100
|
+
/** Choose only for this invocation; never persist an interactive fallback. */
|
|
101
|
+
async selectWorkspace(): Promise<void> {
|
|
102
|
+
const choices = this.config.workspaceChoices;
|
|
103
|
+
if (!choices || !this.isTTY) return;
|
|
104
|
+
const workspace = await promptSelect(
|
|
105
|
+
this,
|
|
106
|
+
"Select a workspace for this invocation",
|
|
107
|
+
choices.map((slug) => ({ name: slug, value: slug })),
|
|
108
|
+
);
|
|
109
|
+
this.config = resolveConfig({
|
|
110
|
+
flags: {
|
|
111
|
+
apiKey: this.options.apiKey,
|
|
112
|
+
accessToken: this.options.accessToken,
|
|
113
|
+
team: firstTeam(this.options.team),
|
|
114
|
+
workspace,
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
99
119
|
/** Lazily construct the Linear client (so `--help` never needs a key). */
|
|
100
120
|
get client(): LinearClient {
|
|
101
121
|
if (!this._client) this._client = createClient(this.config);
|
package/src/lib/action.ts
CHANGED
|
@@ -21,6 +21,14 @@ export function action(handler: ActionHandler) {
|
|
|
21
21
|
const localOpts = (args[args.length - 2] ?? {}) as Record<string, any>;
|
|
22
22
|
const positionals = args.slice(0, -2);
|
|
23
23
|
const ctx = new Context(command.optsWithGlobals() as GlobalOptions);
|
|
24
|
+
// Repair and local discovery need no selection. Config/open select within
|
|
25
|
+
// their handlers only when the chosen path needs credentials.
|
|
26
|
+
let group = command;
|
|
27
|
+
while (group.parent?.parent) group = group.parent;
|
|
28
|
+
const offline =
|
|
29
|
+
["config", "open", "commands", "completion"].includes(group.name()) ||
|
|
30
|
+
(group.name() === "auth" && !["whoami", "status", "token"].includes(command.name()));
|
|
31
|
+
if (!offline) await ctx.selectWorkspace();
|
|
24
32
|
await handler(ctx, localOpts, ...positionals);
|
|
25
33
|
};
|
|
26
34
|
}
|
package/src/lib/output-shapes.ts
CHANGED
package/src/lib/resolve.ts
CHANGED
|
@@ -209,46 +209,60 @@ export async function resolveLabelIds(
|
|
|
209
209
|
names: string[],
|
|
210
210
|
teamId?: string,
|
|
211
211
|
): Promise<string[]> {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
212
|
+
// Share in-flight searches only within this call. Later calls must observe
|
|
213
|
+
// renamed/deleted labels, and exact spelling matters for case preference.
|
|
214
|
+
const lookups = new Map<string, Promise<any[]>>();
|
|
215
|
+
const lookup = (name: string): Promise<any[]> => {
|
|
216
|
+
let pending = lookups.get(name);
|
|
217
|
+
if (!pending) {
|
|
218
|
+
pending = (async () => {
|
|
219
|
+
const conn = await withRetry(() =>
|
|
220
|
+
client.issueLabels({
|
|
221
|
+
filter: { name: { eqIgnoreCase: name } } as any,
|
|
222
|
+
first: RESOLVE_PAGE,
|
|
223
|
+
}),
|
|
224
|
+
);
|
|
225
|
+
// Share the complete scan too: concurrent consumers must not advance
|
|
226
|
+
// the same mutable SDK connection independently.
|
|
227
|
+
return scanAll<any>(conn as any, "labels", "linear label list");
|
|
228
|
+
})();
|
|
229
|
+
lookups.set(name, pending);
|
|
217
230
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
231
|
+
return pending;
|
|
232
|
+
};
|
|
233
|
+
return Promise.all(
|
|
234
|
+
names.map(async (name) => {
|
|
235
|
+
if (isUuid(name)) return name;
|
|
236
|
+
const nodes = await lookup(name);
|
|
237
|
+
if (nodes.length === 0)
|
|
238
|
+
throw notFound(`No label matching '${name}'. Run 'linear label list' to see the options.`);
|
|
239
|
+
|
|
240
|
+
// Narrow to this team's labels + workspace-level labels when a team is known.
|
|
241
|
+
// If a team is known and nothing is in scope, that's not-found (do NOT fall
|
|
242
|
+
// back to an out-of-scope label from another team).
|
|
243
|
+
// Label groups are containers, not labels that can be applied to an issue.
|
|
244
|
+
let candidates: any[] = nodes.filter((label: any) => !label.isGroup);
|
|
245
|
+
if (teamId) {
|
|
246
|
+
// The SDK includes teamId in the label response; accessing team fetches it again.
|
|
247
|
+
candidates = candidates.filter((label) => !label.teamId || label.teamId === teamId);
|
|
248
|
+
if (candidates.length === 0)
|
|
249
|
+
throw notFound(`No label '${name}' in this team or workspace.`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (candidates.length === 0)
|
|
253
|
+
throw notFound(`No label matching '${name}'. Run 'linear label list' to see the options.`);
|
|
254
|
+
|
|
255
|
+
// Prefer an exact (case-sensitive) match.
|
|
256
|
+
const exact = candidates.filter((l: any) => l.name === name);
|
|
257
|
+
const finalists = exact.length ? exact : candidates;
|
|
258
|
+
if (finalists.length > 1) {
|
|
259
|
+
throw ambiguous(
|
|
260
|
+
`Multiple labels named '${name}'${teamId ? " in scope" : ""}; pass the label id instead.`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
return finalists[0]!.id;
|
|
264
|
+
}),
|
|
265
|
+
);
|
|
252
266
|
}
|
|
253
267
|
|
|
254
268
|
/**
|