linear-sdk-cli 0.2.4 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +36 -16
  2. package/MIGRATING.md +7 -6
  3. package/README.md +120 -31
  4. package/package.json +5 -3
  5. package/skills/linear-sdk-cli/SKILL.md +43 -8
  6. package/skills/linear-sdk-cli/references/api.md +1 -1
  7. package/skills/linear-sdk-cli/references/attachment.md +1 -1
  8. package/skills/linear-sdk-cli/references/auth.md +26 -7
  9. package/skills/linear-sdk-cli/references/commands.md +1 -1
  10. package/skills/linear-sdk-cli/references/comment.md +1 -1
  11. package/skills/linear-sdk-cli/references/completion.md +1 -1
  12. package/skills/linear-sdk-cli/references/config.md +5 -1
  13. package/skills/linear-sdk-cli/references/cycle.md +1 -1
  14. package/skills/linear-sdk-cli/references/document.md +1 -1
  15. package/skills/linear-sdk-cli/references/favorite.md +1 -1
  16. package/skills/linear-sdk-cli/references/initiative-update.md +1 -1
  17. package/skills/linear-sdk-cli/references/initiative.md +1 -1
  18. package/skills/linear-sdk-cli/references/issue.md +1 -1
  19. package/skills/linear-sdk-cli/references/label.md +1 -1
  20. package/skills/linear-sdk-cli/references/milestone.md +1 -1
  21. package/skills/linear-sdk-cli/references/notification.md +1 -1
  22. package/skills/linear-sdk-cli/references/open.md +1 -1
  23. package/skills/linear-sdk-cli/references/organization.md +1 -1
  24. package/skills/linear-sdk-cli/references/project-update.md +1 -1
  25. package/skills/linear-sdk-cli/references/project.md +1 -1
  26. package/skills/linear-sdk-cli/references/roadmap.md +1 -1
  27. package/skills/linear-sdk-cli/references/schema.md +1 -1
  28. package/skills/linear-sdk-cli/references/state.md +1 -1
  29. package/skills/linear-sdk-cli/references/team.md +1 -1
  30. package/skills/linear-sdk-cli/references/user.md +1 -1
  31. package/skills/linear-sdk-cli/references/webhook.md +1 -1
  32. package/skills/linear-sdk-cli/references/whoami.md +1 -1
  33. package/skills/linear-sdk-cli/scripts/generate-docs.ts +3 -1
  34. package/src/client.ts +57 -2
  35. package/src/commands/meta.ts +240 -65
  36. package/src/config.ts +319 -16
  37. package/src/context.ts +2 -0
  38. package/src/index.ts +19 -0
  39. package/src/lib/introspect.ts +1 -0
  40. package/src/lib/options.ts +1 -0
  41. package/src/lib/output-shapes.ts +21 -2
  42. package/src/oauth.ts +582 -0
@@ -13,8 +13,11 @@ import {
13
13
  probeKeyringCredential,
14
14
  adoptKeyringCredential,
15
15
  removeCredential,
16
+ removeOAuthCredential,
16
17
  setDefaultWorkspace,
17
18
  listCredentials,
19
+ readOAuthCredential,
20
+ writeOAuthCredential,
18
21
  migrateCredentials,
19
22
  redactKey,
20
23
  resolveConfig,
@@ -28,10 +31,24 @@ import {
28
31
  import { keyring } from "../lib/keyring.js";
29
32
  import { readStdinSync } from "../lib/body.js";
30
33
  import { authError, normalizeError, usageError } from "../lib/errors.js";
31
- import { confirmDestructive, promptSecret, promptSelect } from "../lib/prompt.js";
34
+ import { confirmDestructive, promptSelect } from "../lib/prompt.js";
32
35
  import { listTeams } from "../services/team.js";
33
36
  import { ISSUE_SORTS } from "../services/issue.js";
34
37
  import { firstTeam, type Context } from "../context.js";
38
+ import { openUrl } from "../lib/open.js";
39
+ import {
40
+ DEFAULT_OAUTH_SCOPES,
41
+ buildAuthorizationUrl,
42
+ createPkceRequest,
43
+ exchangeAuthorizationCode,
44
+ revokeOAuthToken,
45
+ startLoopbackCallback,
46
+ type OAuthUserCredential,
47
+ } from "../oauth.js";
48
+
49
+ export const DEFAULT_OAUTH_REDIRECT_URI = "http://127.0.0.1:43821/oauth/callback";
50
+ /** Public PKCE client identity. This is not a credential; no client secret is shipped or used. */
51
+ export const DEFAULT_OAUTH_CLIENT_ID = "eda60862e4bb8b8af82cfb3193b65c2f";
35
52
 
36
53
  /**
37
54
  * The `whoami` handler, shared by the top-level `linear whoami` and the
@@ -70,10 +87,15 @@ interface AuthValidationClient {
70
87
  organization: Promise<{ id: string; name: string; urlKey: string }>;
71
88
  }
72
89
 
73
- export type AuthValidationClientFactory = (apiKey: string) => AuthValidationClient;
90
+ export type AuthValidationClientFactory = (
91
+ credential: string,
92
+ kind?: "api-key" | "oauth-access-token",
93
+ ) => AuthValidationClient;
74
94
 
75
- const defaultAuthValidationClientFactory: AuthValidationClientFactory = (apiKey) =>
76
- new LinearClient({ apiKey }) as unknown as AuthValidationClient;
95
+ const defaultAuthValidationClientFactory: AuthValidationClientFactory = (credential, kind) =>
96
+ new LinearClient(
97
+ kind === "oauth-access-token" ? { accessToken: credential } : { apiKey: credential },
98
+ ) as unknown as AuthValidationClient;
77
99
  let authValidationClientFactory = defaultAuthValidationClientFactory;
78
100
 
79
101
  /** Test seam for command-level auth tests; production always uses LinearClient. */
@@ -96,10 +118,11 @@ export function selectLoginKey(
96
118
 
97
119
  /** Validate a credential and prove that an explicitly requested slug matches it. */
98
120
  export async function validateAuthCredential(
99
- apiKey: string,
121
+ credential: string,
100
122
  requestedWorkspace?: string,
123
+ kind: "api-key" | "oauth-access-token" = "api-key",
101
124
  ): Promise<AuthIdentity> {
102
- const client = authValidationClientFactory(apiKey);
125
+ const client = authValidationClientFactory(credential, kind);
103
126
  let me;
104
127
  let org;
105
128
  try {
@@ -108,13 +131,17 @@ export async function validateAuthCredential(
108
131
  } catch (err) {
109
132
  const normalized = normalizeError(err);
110
133
  if (normalized.code === "auth" || normalized.code === "forbidden") {
111
- throw authError("That API key was rejected by Linear.");
134
+ throw authError(
135
+ kind === "api-key"
136
+ ? "That API key was rejected by Linear."
137
+ : "Linear rejected the OAuth access token.",
138
+ );
112
139
  }
113
140
  throw normalized;
114
141
  }
115
142
  if (requestedWorkspace && requestedWorkspace !== org.urlKey) {
116
143
  throw usageError(
117
- `--workspace '${requestedWorkspace}' does not match the key's workspace '${org.urlKey}'. Store credentials under their Linear workspace slug.`,
144
+ `--workspace '${requestedWorkspace}' does not match the credential's workspace '${org.urlKey}'. Store credentials under their Linear workspace slug.`,
118
145
  );
119
146
  }
120
147
  return {
@@ -148,57 +175,150 @@ export function registerMeta(program: Command): void {
148
175
  // login -------------------------------------------------------------------
149
176
  auth
150
177
  .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)")
178
+ .description("Authenticate in the browser with OAuth, or store a personal API key")
179
+ .option("--key <key>", "use a personal API key ('-' reads it from stdin)")
153
180
  .option("--plaintext", "Store the key in the config file (0600) instead of the system keyring")
181
+ .option("--no-browser", "print the authorization URL instead of opening it")
182
+ .option("--read-only", "request read-only OAuth access")
183
+ .option("--admin", "explicitly add the OAuth admin scope")
184
+ .option("--timeout <seconds>", "seconds to wait for the loopback callback", "120")
185
+ .option("--client-id <id>", "OAuth client ID (defaults to the packaged CLI app)")
186
+ .option("--redirect-uri <uri>", "registered HTTP loopback callback URI")
154
187
  .action(
155
- // The global `--workspace <slug>` selects the slug to store under
156
- // (default: derived from the key's organization urlKey).
157
188
  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.
189
+ if (ctx.options.accessToken !== undefined) {
190
+ throw usageError(
191
+ "`auth login` cannot persist an injected access token. Remove --access-token and use browser OAuth, or use --key - for a personal API key.",
192
+ );
193
+ }
162
194
  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).",
195
+ if (key !== undefined) {
196
+ if (
197
+ opts.readOnly ||
198
+ opts.admin ||
199
+ opts.browser === false ||
200
+ opts.redirectUri ||
201
+ opts.clientId
202
+ ) {
203
+ throw usageError("OAuth-only options cannot be combined with --key or --api-key.");
204
+ }
205
+ if (key === "-") key = readStdinSync();
206
+ else
207
+ ctx.output.warn(
208
+ "An API key on the command line is visible to other processes and shell history; prefer --key - (stdin).",
209
+ );
210
+ key = key.trim();
211
+ if (!key) throw usageError("No API key provided.");
212
+ const identity = await validateAuthCredential(key, ctx.options.workspace);
213
+ const { user: me, organization: org } = identity;
214
+ const { path, storage, keyringLabel } = writeCredential(org.urlKey, key, {
215
+ plaintext: opts.plaintext === true,
216
+ });
217
+ const where =
218
+ storage === "keychain"
219
+ ? `Credential saved to the ${keyringLabel}; ${path} records the workspace.`
220
+ : `Credential saved to ${path} (plaintext, 0600).`;
221
+ ctx.output.emit(
222
+ {
223
+ success: true,
224
+ credentialType: "api-key",
225
+ workspace: org.urlKey,
226
+ user: me,
227
+ storage,
228
+ path,
229
+ },
230
+ () =>
231
+ ctx.output.success(
232
+ `Authenticated as ${me.name} <${me.email}> for workspace '${org.urlKey}'. ${where}`,
233
+ ),
170
234
  );
171
- } else {
172
- key = await promptSecret(ctx, "Linear API key:", { required: true });
235
+ return;
173
236
  }
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
- ),
237
+
238
+ if (opts.plaintext) {
239
+ throw usageError(
240
+ "OAuth credentials are keyring-only; --plaintext requires --key or --api-key.",
241
+ );
242
+ }
243
+ if (opts.readOnly && opts.admin) {
244
+ throw usageError("--read-only cannot be combined with --admin.");
245
+ }
246
+ const timeoutSeconds = Number(opts.timeout);
247
+ if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) {
248
+ throw usageError("--timeout must be a positive number of seconds.");
249
+ }
250
+ const clientId = String(
251
+ opts.clientId ?? process.env.LINEAR_OAUTH_CLIENT_ID ?? DEFAULT_OAUTH_CLIENT_ID,
252
+ ).trim();
253
+ const redirectUri = String(
254
+ opts.redirectUri ?? process.env.LINEAR_OAUTH_REDIRECT_URI ?? DEFAULT_OAUTH_REDIRECT_URI,
201
255
  );
256
+ const scopes = opts.readOnly
257
+ ? ["read"]
258
+ : opts.admin
259
+ ? [...DEFAULT_OAUTH_SCOPES, "admin"]
260
+ : [...DEFAULT_OAUTH_SCOPES];
261
+ const pkce = createPkceRequest();
262
+ const callback = await startLoopbackCallback({
263
+ redirectUri,
264
+ state: pkce.state,
265
+ timeoutMs: timeoutSeconds * 1000,
266
+ });
267
+ try {
268
+ const authorizationUrl = buildAuthorizationUrl({
269
+ clientId,
270
+ redirectUri: callback.redirectUri,
271
+ scopes,
272
+ challenge: pkce.challenge,
273
+ state: pkce.state,
274
+ });
275
+ if (opts.browser === false) {
276
+ ctx.output.info(`Open this URL to authenticate with Linear:\n${authorizationUrl}`);
277
+ } else {
278
+ ctx.output.info("Opening Linear in your browser…");
279
+ await openUrl(authorizationUrl);
280
+ }
281
+ const code = await callback.wait;
282
+ const token = await exchangeAuthorizationCode({
283
+ code,
284
+ verifier: pkce.verifier,
285
+ clientId,
286
+ redirectUri: callback.redirectUri,
287
+ });
288
+ const identity = await validateAuthCredential(
289
+ token.accessToken,
290
+ ctx.options.workspace,
291
+ "oauth-access-token",
292
+ );
293
+ const credential: OAuthUserCredential = {
294
+ version: 1,
295
+ kind: "oauth-user",
296
+ actor: "user",
297
+ ...token,
298
+ clientId,
299
+ workspace: identity.organization,
300
+ user: identity.user,
301
+ };
302
+ const saved = writeOAuthCredential(credential);
303
+ ctx.output.emit(
304
+ {
305
+ success: true,
306
+ credentialType: "oauth-user",
307
+ workspace: credential.workspace.urlKey,
308
+ user: credential.user,
309
+ storage: saved.storage,
310
+ scopes: credential.scopes,
311
+ expiresAt: new Date(credential.expiresAt).toISOString(),
312
+ path: saved.path,
313
+ },
314
+ () =>
315
+ ctx.output.success(
316
+ `Authenticated as ${credential.user.name} <${credential.user.email}> for workspace '${credential.workspace.urlKey}'. OAuth credentials saved to the ${saved.keyringLabel}.`,
317
+ ),
318
+ );
319
+ } finally {
320
+ await callback.close();
321
+ }
202
322
  }),
203
323
  );
204
324
 
@@ -254,6 +374,7 @@ export function registerMeta(program: Command): void {
254
374
  [
255
375
  { key: "slug", header: "Workspace", value: (e) => e.slug },
256
376
  { key: "isDefault", header: "Default", value: (e) => (e.isDefault ? "yes" : "") },
377
+ { key: "credentialType", header: "Type", value: (e) => e.credentialType },
257
378
  { key: "storage", header: "Storage", value: (e) => e.storage },
258
379
  ],
259
380
  entries,
@@ -281,6 +402,11 @@ export function registerMeta(program: Command): void {
281
402
  .action(
282
403
  action(async (ctx: Context) => {
283
404
  const c = ctx.config;
405
+ if (c.accessToken) {
406
+ throw usageError(
407
+ "`auth token` exports stored API keys only. OAuth access tokens are never exported; use the authenticated command directly.",
408
+ );
409
+ }
284
410
  if (!c.apiKey) {
285
411
  // Surface the precise selection error (ambiguous / unstored slug) if any.
286
412
  throw (
@@ -298,25 +424,45 @@ export function registerMeta(program: Command): void {
298
424
  // status ------------------------------------------------------------------
299
425
  auth
300
426
  .command("status")
301
- .description("Show where the API key is resolved from (key redacted)")
427
+ .description("Show where the active credential is resolved from (value redacted)")
302
428
  .action(
303
429
  action(async (ctx) => {
304
430
  const c = ctx.config;
431
+ const credential = c.accessToken ?? c.apiKey;
432
+ const credentialType = c.oauthCredential
433
+ ? "oauth-user"
434
+ : c.accessToken
435
+ ? "oauth-access-token"
436
+ : c.apiKey
437
+ ? "api-key"
438
+ : null;
439
+ const source = c.accessToken ? c.accessTokenSource : c.apiKeySource;
305
440
  const backend = keyring();
306
441
  ctx.output.detail(
307
442
  {
308
- authenticated: !!c.apiKey,
309
- source: c.apiKeySource,
443
+ authenticated: !!credential,
444
+ credentialType,
445
+ source,
310
446
  workspace: c.credentialWorkspace ?? null,
311
- key: redactKey(c.apiKey),
447
+ key: redactKey(credential),
312
448
  keyring: backend?.name ?? null,
449
+ scopes: c.oauthCredential?.scopes ?? null,
450
+ expiresAt: c.oauthCredential
451
+ ? new Date(c.oauthCredential.expiresAt).toISOString()
452
+ : null,
313
453
  },
314
454
  [
315
- ["Authenticated", !!c.apiKey],
316
- ["Source", c.apiKeySource],
455
+ ["Authenticated", !!credential],
456
+ ["Credential type", credentialType ?? "(none)"],
457
+ ["Source", source],
317
458
  ["Workspace", c.credentialWorkspace ?? "(none)"],
318
- ["Key", redactKey(c.apiKey)],
459
+ ["Credential", redactKey(credential)],
319
460
  ["Keyring", backend ? backend.label : "(none on this platform)"],
461
+ ["Scopes", c.oauthCredential?.scopes.join(", ")],
462
+ [
463
+ "Expires",
464
+ c.oauthCredential ? new Date(c.oauthCredential.expiresAt).toISOString() : undefined,
465
+ ],
320
466
  ],
321
467
  );
322
468
  // A plaintext key on a machine that has a keyring is worth one nudge
@@ -350,10 +496,11 @@ export function registerMeta(program: Command): void {
350
496
  auth
351
497
  .command("logout")
352
498
  .description("Remove a stored workspace credential (select with --workspace <slug>)")
499
+ .option("--local-only", "remove local credentials without OAuth revocation")
353
500
  .action(
354
501
  // Targets the global `--workspace <slug>`. When omitted, the sole
355
502
  // configured workspace is used; if several exist, this errors.
356
- action(async (ctx: Context) => {
503
+ action(async (ctx: Context, opts) => {
357
504
  let slug: string | undefined = ctx.options.workspace;
358
505
  if (!slug) {
359
506
  const configured = listCredentials();
@@ -368,11 +515,35 @@ export function registerMeta(program: Command): void {
368
515
  );
369
516
  }
370
517
  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.`),
518
+ const entry = listCredentials().find((candidate) => candidate.slug === slug);
519
+ let revocation: "revoked" | "already-revoked" | "skipped" = "skipped";
520
+ if (entry?.credentialType === "oauth-user" && opts.localOnly !== true) {
521
+ const oauth = readOAuthCredential(slug);
522
+ if (oauth) {
523
+ revocation = await revokeOAuthToken({
524
+ token: oauth.refreshToken,
525
+ tokenTypeHint: "refresh_token",
526
+ });
527
+ }
528
+ }
529
+ const local =
530
+ entry?.credentialType === "oauth-user"
531
+ ? removeOAuthCredential(slug)
532
+ : { removed: removeCredential(slug), fallbackCredentialType: null };
533
+ ctx.output.emit(
534
+ {
535
+ success: true,
536
+ workspace: slug,
537
+ removed: local.removed,
538
+ revocation,
539
+ fallbackCredentialType: local.fallbackCredentialType,
540
+ },
541
+ () =>
542
+ local.removed
543
+ ? ctx.output.success(
544
+ `Removed workspace '${slug}'.${revocation === "revoked" ? " OAuth access was revoked." : revocation === "already-revoked" ? " OAuth access was already revoked." : ""}${local.fallbackCredentialType ? " The existing personal API-key profile is active again." : ""}`,
545
+ )
546
+ : ctx.output.info(`No credential for workspace '${slug}' to remove.`),
376
547
  );
377
548
  }),
378
549
  );
@@ -393,6 +564,7 @@ export function registerMeta(program: Command): void {
393
564
  const c = resolveConfig({
394
565
  flags: {
395
566
  apiKey: ctx.options.apiKey,
567
+ accessToken: ctx.options.accessToken,
396
568
  // Still the *flag* value (so `teamSource` stays honest), just
397
569
  // narrowed: `--team` is repeatable on the issue queries.
398
570
  team: firstTeam(ctx.options.team),
@@ -414,6 +586,8 @@ export function registerMeta(program: Command): void {
414
586
  {
415
587
  apiKey: redactKey(c.apiKey),
416
588
  apiKeySource: c.apiKeySource,
589
+ accessToken: redactKey(c.accessToken),
590
+ accessTokenSource: c.accessTokenSource,
417
591
  credentialWorkspace: c.credentialWorkspace ?? null,
418
592
  team: c.team ?? null,
419
593
  workspace: c.workspace ?? null,
@@ -425,6 +599,7 @@ export function registerMeta(program: Command): void {
425
599
  },
426
600
  [
427
601
  ["API key", `${redactKey(c.apiKey)} (${c.apiKeySource})`],
602
+ ["OAuth access token", `${redactKey(c.accessToken)} (${c.accessTokenSource})`],
428
603
  ["Credential workspace", c.credentialWorkspace ?? "(none)"],
429
604
  ["Team", show("team", c.team)],
430
605
  ["Workspace", show("workspace", c.workspace)],