linear-sdk-cli 0.7.2 → 0.8.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.
@@ -10,6 +10,7 @@ import { withRetry } from "../client.js";
10
10
  import { resolve } from "node:path";
11
11
  import {
12
12
  writeCredential,
13
+ writeAppCredential,
13
14
  probeKeyringCredential,
14
15
  adoptKeyringCredential,
15
16
  removeCredentialWithMetadata,
@@ -43,6 +44,8 @@ import {
43
44
  credentialType as resolvedCredentialType,
44
45
  } from "../lib/authorization.js";
45
46
  import {
47
+ ClientCredentialsTokenProvider,
48
+ clientCredentialsScopes,
46
49
  DEFAULT_OAUTH_SCOPES,
47
50
  buildAuthorizationUrl,
48
51
  createPkceRequest,
@@ -229,12 +232,25 @@ export function registerMeta(program: Command): void {
229
232
  .description("Authenticate and select the workspace for this project")
230
233
  .option("--no-project", "save credentials without changing the project workspace")
231
234
  .option("--key <key>", "use a personal API key ('-' reads it from stdin)")
232
- .option("--plaintext", "Store the key in the config file (0600) instead of the system keyring")
235
+ .option(
236
+ "--plaintext",
237
+ "Store API-key or app credentials in the private user config file (0600)",
238
+ )
233
239
  .option("--no-browser", "print the authorization URL instead of opening it")
234
240
  .option("--read-only", "request read-only OAuth access")
235
241
  .option("--admin", "explicitly add the OAuth admin scope")
236
242
  .option("--timeout <seconds>", "seconds to wait for the loopback callback", "120")
237
- .option("--client-id <id>", "OAuth client ID (defaults to the packaged CLI app)")
243
+ .option("--app", "sign in as an app using client credentials")
244
+ .option("--client-credentials", "alias for --app")
245
+ .option("--scope <scopes>", "comma-separated app scopes (default: read,write)")
246
+ .option(
247
+ "--client-secret <secret>",
248
+ "app client secret ('-' reads stdin; defaults to LINEAR_CLIENT_SECRET)",
249
+ )
250
+ .option(
251
+ "--client-id <id>",
252
+ "OAuth client ID (app: LINEAR_CLIENT_ID; browser: packaged CLI app)",
253
+ )
238
254
  .option("--redirect-uri <uri>", "registered HTTP loopback callback URI")
239
255
  .action(
240
256
  action(async (ctx: Context, opts) => {
@@ -243,6 +259,84 @@ export function registerMeta(program: Command): void {
243
259
  "`auth login` cannot persist an injected access token. Remove --access-token and use browser OAuth, or use --key - for a personal API key.",
244
260
  );
245
261
  }
262
+ if (opts.app || opts.clientCredentials) {
263
+ if (
264
+ opts.key !== undefined ||
265
+ ctx.options.apiKey !== undefined ||
266
+ opts.redirectUri ||
267
+ opts.browser === false
268
+ ) {
269
+ throw usageError(
270
+ "--app cannot be combined with personal API-key or browser callback options.",
271
+ );
272
+ }
273
+ if (opts.admin) throw usageError("App actors cannot request the admin scope.");
274
+ if (!opts.plaintext && !keyring())
275
+ throw new CliError(
276
+ "App authentication requires a system keyring; use --plaintext for explicit private-file storage.",
277
+ "runtime",
278
+ );
279
+ const clientId = String(
280
+ opts.clientId ??
281
+ process.env.LINEAR_CLIENT_ID ??
282
+ process.env.LINEAR_OAUTH_CLIENT_ID ??
283
+ "",
284
+ ).trim();
285
+ let clientSecret = String(opts.clientSecret ?? process.env.LINEAR_CLIENT_SECRET ?? "");
286
+ if (clientSecret === "-") clientSecret = readStdinSync().trim();
287
+ else if (clientSecret && opts.clientSecret !== undefined)
288
+ ctx.output.warn(
289
+ "A client secret on the command line is visible in shell history; prefer --client-secret - (stdin).",
290
+ );
291
+ if (!clientId || !clientSecret)
292
+ throw usageError("--app requires --client-id and --client-secret ('-' reads stdin).");
293
+ if (opts.readOnly && opts.scope !== undefined)
294
+ throw usageError("--read-only cannot be combined with --scope.");
295
+ const scopes = clientCredentialsScopes(
296
+ opts.readOnly ? "read" : (opts.scope ?? process.env.LINEAR_CLIENT_SCOPES),
297
+ );
298
+ const provider = new ClientCredentialsTokenProvider({ clientId, clientSecret, scopes });
299
+ const token = await provider.getAccessToken();
300
+ const identity = await validateAuthCredential(
301
+ token.accessToken,
302
+ ctx.options.workspace,
303
+ "oauth-access-token",
304
+ );
305
+ const saved = writeAppCredential(
306
+ {
307
+ version: 1,
308
+ kind: "oauth-app",
309
+ clientId,
310
+ clientSecret,
311
+ scopes: token.scope.split(/[ ,]+/).filter(Boolean),
312
+ workspace: identity.organization,
313
+ },
314
+ { plaintext: opts.plaintext === true },
315
+ );
316
+ const projectConfigPath = saveLoginProject(
317
+ ctx,
318
+ identity.organization.urlKey,
319
+ opts.project !== false,
320
+ );
321
+ ctx.output.emit(
322
+ {
323
+ success: true,
324
+ credentialType: "oauth-app",
325
+ workspace: identity.organization.urlKey,
326
+ user: identity.user,
327
+ storage: saved.storage,
328
+ path: saved.path,
329
+ projectConfigPath,
330
+ },
331
+ () =>
332
+ ctx.output.success(
333
+ `Authenticated as an app for workspace '${identity.organization.urlKey}'. ${saved.storage === "file" ? `Client credentials saved to ${saved.path} (plaintext, 0600).` : `Client credentials saved to the ${saved.keyringLabel}.`}`,
334
+ ),
335
+ );
336
+ return;
337
+ }
338
+ if (opts.clientSecret !== undefined || opts.scope !== undefined)
339
+ throw usageError("--client-secret and --scope require --app or --client-credentials.");
246
340
  let key: string | undefined = selectLoginKey(opts.key, ctx.options.apiKey);
247
341
  if (key !== undefined) {
248
342
  if (
@@ -291,7 +385,7 @@ export function registerMeta(program: Command): void {
291
385
 
292
386
  if (opts.plaintext) {
293
387
  throw usageError(
294
- "OAuth credentials are keyring-only; --plaintext requires --key or --api-key.",
388
+ "Browser OAuth is keyring-only; --plaintext requires --key, --api-key, or --client-credentials.",
295
389
  );
296
390
  }
297
391
  if (opts.readOnly && opts.admin) {
@@ -462,7 +556,7 @@ export function registerMeta(program: Command): void {
462
556
  .action(
463
557
  action(async (ctx: Context) => {
464
558
  const c = ctx.config;
465
- if (c.accessToken) {
559
+ if (c.accessToken || c.appCredential) {
466
560
  throw usageError(
467
561
  "`auth token` exports stored API keys only. OAuth access tokens are never exported; use the authenticated command directly.",
468
562
  );
@@ -490,19 +584,21 @@ export function registerMeta(program: Command): void {
490
584
  const c = ctx.config;
491
585
  if (c.workspaceChoices) throw c.apiKeyError;
492
586
  const credential = c.accessToken ?? c.apiKey;
587
+ const authenticated = Boolean(credential || c.appCredential);
588
+ const redacted = c.appCredential ? "(client credentials redacted)" : redactKey(credential);
493
589
  const credentialType = resolvedCredentialType(c);
494
590
  const authorization = authorizationCapabilities(c);
495
- const source = c.accessToken ? c.accessTokenSource : c.apiKeySource;
591
+ const source = c.accessToken || c.appCredential ? c.accessTokenSource : c.apiKeySource;
496
592
  const backend = keyring();
497
593
  ctx.output.detail(
498
594
  {
499
- authenticated: !!credential,
595
+ authenticated,
500
596
  credentialType,
501
597
  source,
502
598
  workspace: c.credentialWorkspace ?? null,
503
- key: redactKey(credential),
599
+ key: redacted,
504
600
  keyring: backend?.name ?? null,
505
- scopes: c.oauthCredential?.scopes ?? null,
601
+ scopes: (c.oauthCredential ?? c.appCredential)?.scopes ?? null,
506
602
  expiresAt: c.oauthCredential
507
603
  ? new Date(c.oauthCredential.expiresAt).toISOString()
508
604
  : null,
@@ -511,13 +607,13 @@ export function registerMeta(program: Command): void {
511
607
  scopeNote: authorization.note,
512
608
  },
513
609
  [
514
- ["Authenticated", !!credential],
610
+ ["Authenticated", authenticated],
515
611
  ["Credential type", credentialType ?? "(none)"],
516
612
  ["Source", source],
517
613
  ["Workspace", c.credentialWorkspace ?? "(none)"],
518
- ["Credential", redactKey(credential)],
614
+ ["Credential", redacted],
519
615
  ["Keyring", backend ? backend.label : "(none on this platform)"],
520
- ["Scopes", c.oauthCredential?.scopes.join(", ")],
616
+ ["Scopes", (c.oauthCredential ?? c.appCredential)?.scopes.join(", ")],
521
617
  ["Scope visibility", authorization.scopeVisibility],
522
618
  ["Admin scope", authorization.adminScope ?? "unknown"],
523
619
  ["Scope note", authorization.note],
@@ -589,9 +685,11 @@ export function registerMeta(program: Command): void {
589
685
  }
590
686
  }
591
687
  const local =
592
- entry?.credentialType === "oauth-user"
593
- ? removeOAuthCredential(slug)
594
- : { ...removeCredentialWithMetadata(slug), fallbackCredentialType: null };
688
+ entry?.credentialType === "oauth-app"
689
+ ? removeOAuthCredential(slug, "app")
690
+ : entry?.credentialType === "oauth-user"
691
+ ? removeOAuthCredential(slug)
692
+ : { ...removeCredentialWithMetadata(slug), fallbackCredentialType: null };
595
693
  ctx.output.emit(
596
694
  {
597
695
  success: true,
@@ -745,7 +843,7 @@ export function registerMeta(program: Command): void {
745
843
  const profileWorkspace = opts.user && key === "team" ? ctx.options.workspace : undefined;
746
844
  if (profileWorkspace) {
747
845
  let team = clean;
748
- if (ctx.config.apiKey || ctx.config.accessToken) {
846
+ if (ctx.config.apiKey || ctx.config.accessToken || ctx.config.appCredential) {
749
847
  team = await workspaceTeamValidator(ctx, profileWorkspace, clean);
750
848
  }
751
849
  path = setWorkspaceTeam(profileWorkspace, team);