linear-sdk-cli 0.7.1 → 0.8.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/src/config.ts CHANGED
@@ -15,8 +15,9 @@
15
15
  * or from the reference CLI's global file (avoids committing secrets):
16
16
  * Explicit `--access-token` / `--api-key` flags override their environment counterparts.
17
17
  * Invocation OAuth access tokens are stateless (`LINEAR_ACCESS_TOKEN` only). Human browser
18
- * OAuth sessions and API keys may also come from the OS keyring; API keys alone may opt into
19
- * the user config as plaintext.
18
+ * OAuth sessions, app client credentials, and API keys may come from the OS keyring.
19
+ * App credentials require explicit --plaintext to use a private user config file;
20
+ * browser OAuth stays keyring-only. Environment client credentials stay invocation-scoped.
20
21
  * A project `workspace` may select which already-stored credential to use;
21
22
  * it can never provide or override the secret itself.
22
23
  *
@@ -27,7 +28,8 @@
27
28
  * lives in the OS keyring under service `linear-cli` / account `<slug>` — the
28
29
  * reference CLI's convention. A browser OAuth session uses the isolated account
29
30
  * `oauth:<slug>` and a non-secret `oauth = true` marker, so its rotating token record
30
- * cannot be mistaken for an API key. A user migrating from the reference CLI is authenticated
31
+ * cannot be mistaken for an API key. App credentials use `app:<slug>` and an `app = true`
32
+ * marker (or a private app_credentials record with --plaintext); access tokens stay in memory. A user migrating from the reference CLI is authenticated
31
33
  * before they run a single command. Its own workspace list
32
34
  * (`credentials.toml`, a sibling of our file) is read for slugs and the
33
35
  * default, never for keys. Credential selection is lazy: resolution never
@@ -56,7 +58,12 @@ import { execFileSync } from "node:child_process";
56
58
  import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
57
59
  import { CliError } from "./lib/errors.js";
58
60
  import { keyring, KeyringError } from "./lib/keyring.js";
59
- import type { OAuthUserCredential } from "./oauth.js";
61
+ import {
62
+ clientCredentialsScopes,
63
+ type OAuthAppCredential,
64
+ type OAuthAppIdentity,
65
+ type OAuthUserCredential,
66
+ } from "./oauth.js";
60
67
 
61
68
  export interface RawSettings {
62
69
  team?: string;
@@ -105,6 +112,7 @@ export interface ResolvedConfig {
105
112
  accessTokenSource: ConfigSource;
106
113
  /** Keyring-backed human OAuth session. Never serialized outside the keyring. */
107
114
  oauthCredential?: OAuthUserCredential;
115
+ appCredential?: OAuthAppIdentity;
108
116
  /**
109
117
  * Deferred credential-resolution error. Resolution is total (it never throws
110
118
  * for selection or ambiguous identity problems) so commands that REPAIR auth state — `auth list`,
@@ -189,6 +197,8 @@ interface WorkspaceEntry {
189
197
  apiKey?: string;
190
198
  /** This workspace uses the isolated `oauth:<slug>` keyring account. */
191
199
  oauth?: boolean;
200
+ app?: boolean;
201
+ appFileCredential?: unknown;
192
202
  /** Non-secret default team for this workspace. */
193
203
  team?: string;
194
204
  }
@@ -345,6 +355,8 @@ function readUserConfig(path: string): UserConfig {
345
355
  workspaces[slug] = {
346
356
  apiKey: asString(table.api_key),
347
357
  oauth: table.oauth === true,
358
+ app: table.app === true,
359
+ appFileCredential: table.app_credentials,
348
360
  team: asString(table.team),
349
361
  };
350
362
  }
@@ -371,11 +383,29 @@ function readUserConfig(path: string): UserConfig {
371
383
  function lookupCredential(
372
384
  user: UserConfig,
373
385
  slug: string,
386
+ userPath: string,
374
387
  ):
375
388
  | { apiKey: string; source: "user" | "keychain" }
376
389
  | { oauthCredential: OAuthUserCredential; source: "keychain" }
390
+ | { appCredential: OAuthAppCredential; source: "user" | "keychain" }
377
391
  | { error: CliError }
378
392
  | undefined {
393
+ if (user.workspaces[slug]?.app || user.workspaces[slug]?.appFileCredential !== undefined) {
394
+ try {
395
+ const fileCredential = user.workspaces[slug]?.appFileCredential;
396
+ const appCredential =
397
+ fileCredential !== undefined
398
+ ? parseAppCredential(slug, fileCredential)
399
+ : readAppCredential(slug, userPath);
400
+ return appCredential
401
+ ? { appCredential, source: fileCredential !== undefined ? "user" : "keychain" }
402
+ : undefined;
403
+ } catch (err) {
404
+ return {
405
+ error: err instanceof CliError ? err : new CliError("Could not read app credentials."),
406
+ };
407
+ }
408
+ }
379
409
  if (user.workspaces[slug]?.oauth) {
380
410
  try {
381
411
  const credential = readOAuthCredential(slug);
@@ -467,11 +497,13 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
467
497
  let accessToken: string | undefined;
468
498
  let accessTokenSource: ConfigSource = "none";
469
499
  let oauthCredential: OAuthUserCredential | undefined;
500
+ let appCredential: OAuthAppIdentity | undefined;
470
501
  let credentialWorkspace: string | undefined;
471
502
  let workspaceProfile: string | undefined;
472
503
  let workspaceChoices: string[] | undefined;
473
504
  let apiKeyError: CliError | undefined;
474
505
 
506
+ const hasClientEnv = env.LINEAR_CLIENT_ID !== undefined || env.LINEAR_CLIENT_SECRET !== undefined;
475
507
  if (flags.apiKey && flags.accessToken) {
476
508
  apiKeyError = new CliError(
477
509
  "Pass only one of --api-key or --access-token; they may identify different Linear actors.",
@@ -484,6 +516,36 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
484
516
  } else if (flags.apiKey) {
485
517
  apiKey = flags.apiKey;
486
518
  apiKeySource = "flag";
519
+ } else if (hasClientEnv && (envSettings.apiKey || envSettings.accessToken)) {
520
+ apiKeyError = new CliError(
521
+ "Client credentials and an API key/access token are both set in the environment. Unset one credential type, or select one with --api-key/--access-token.",
522
+ "usage",
523
+ );
524
+ } else if (hasClientEnv) {
525
+ const clientId = env.LINEAR_CLIENT_ID?.trim();
526
+ const clientSecret = env.LINEAR_CLIENT_SECRET;
527
+ if (!clientId || !clientSecret) {
528
+ apiKeyError = new CliError(
529
+ "Environment app authentication requires both LINEAR_CLIENT_ID and LINEAR_CLIENT_SECRET.",
530
+ "usage",
531
+ );
532
+ } else {
533
+ try {
534
+ appCredential = {
535
+ version: 1,
536
+ kind: "oauth-app",
537
+ clientId,
538
+ clientSecret,
539
+ scopes: clientCredentialsScopes(env.LINEAR_CLIENT_SCOPES),
540
+ };
541
+ accessTokenSource = "env";
542
+ } catch (err) {
543
+ apiKeyError =
544
+ err instanceof CliError
545
+ ? err
546
+ : new CliError("Invalid client credentials scopes.", "usage");
547
+ }
548
+ }
487
549
  } else if (envSettings.apiKey && envSettings.accessToken) {
488
550
  apiKeyError = new CliError(
489
551
  "Both LINEAR_API_KEY/LINEAR_API_TOKEN and LINEAR_ACCESS_TOKEN are set. Unset one so the Linear actor is unambiguous.",
@@ -511,7 +573,7 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
511
573
  // entry may be all the user has (the reference CLI's, say, with its list
512
574
  // file gone) — but a listed slug with nothing behind it is a stale entry.
513
575
  const take = (slug: string) => {
514
- const found = lookupCredential(user, slug);
576
+ const found = lookupCredential(user, slug, userPath);
515
577
  if (!found) {
516
578
  apiKeyError = new CliError(
517
579
  `No stored credential for workspace '${slug}'. Run \`linear auth list\` to see configured workspaces, or \`linear auth login --workspace ${slug}\`.`,
@@ -520,7 +582,10 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
520
582
  } else if ("error" in found) {
521
583
  apiKeyError = found.error;
522
584
  } else {
523
- if ("oauthCredential" in found) {
585
+ if ("appCredential" in found) {
586
+ appCredential = found.appCredential;
587
+ accessTokenSource = found.source;
588
+ } else if ("oauthCredential" in found) {
524
589
  oauthCredential = found.oauthCredential;
525
590
  accessToken = found.oauthCredential.accessToken;
526
591
  accessTokenSource = found.source;
@@ -622,6 +687,7 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
622
687
  accessToken,
623
688
  accessTokenSource,
624
689
  oauthCredential,
690
+ appCredential,
625
691
  apiKeyError,
626
692
  credentialWorkspace,
627
693
  workspaceProfile,
@@ -882,6 +948,8 @@ interface WorkspaceTable {
882
948
  api_key?: string;
883
949
  keyring?: boolean;
884
950
  oauth?: boolean;
951
+ app?: boolean;
952
+ app_credentials?: OAuthAppCredential;
885
953
  team?: string;
886
954
  }
887
955
 
@@ -899,6 +967,151 @@ export interface WriteCredentialResult {
899
967
  keyringLabel?: string;
900
968
  }
901
969
 
970
+ /** Validate records without including credential values in any error. */
971
+ function parseAppCredential(slug: string, value: unknown): OAuthAppCredential {
972
+ try {
973
+ const c = value as OAuthAppCredential;
974
+ if (
975
+ !c ||
976
+ c.version !== 1 ||
977
+ c.kind !== "oauth-app" ||
978
+ typeof c.clientId !== "string" ||
979
+ !c.clientId.trim() ||
980
+ typeof c.clientSecret !== "string" ||
981
+ !c.clientSecret ||
982
+ !Array.isArray(c.scopes) ||
983
+ !c.scopes.length ||
984
+ c.scopes.some((scope) => typeof scope !== "string" || !scope.trim()) ||
985
+ c.scopes.includes("admin") ||
986
+ !c.workspace ||
987
+ c.workspace.urlKey !== slug ||
988
+ typeof c.workspace.id !== "string" ||
989
+ typeof c.workspace.name !== "string"
990
+ )
991
+ throw new Error();
992
+ return c;
993
+ } catch {
994
+ throw new CliError("Stored app credentials are invalid. Sign in again.", "auth");
995
+ }
996
+ }
997
+
998
+ /** Read only the named user's credential profile; project files never supply secrets. */
999
+ export function readAppCredential(
1000
+ slug: string,
1001
+ path = userConfigPath(),
1002
+ ): OAuthAppCredential | undefined {
1003
+ const user = readUserConfig(path);
1004
+ const fileCredential = user.workspaces[slug]?.appFileCredential;
1005
+ if (fileCredential !== undefined) return parseAppCredential(slug, fileCredential);
1006
+ const backend = keyring();
1007
+ if (!backend)
1008
+ throw new CliError(
1009
+ "App authentication requires a system keyring or an explicit --plaintext login.",
1010
+ "runtime",
1011
+ );
1012
+ let raw: string | null;
1013
+ try {
1014
+ raw = backend.get(`app:${slug}`);
1015
+ } catch {
1016
+ throw new CliError("Could not read app credentials from the system keyring.", "runtime");
1017
+ }
1018
+ if (!raw) return undefined;
1019
+ let value: unknown;
1020
+ try {
1021
+ value = JSON.parse(raw);
1022
+ } catch {
1023
+ throw new CliError("Stored app credentials are invalid. Sign in again.", "auth");
1024
+ }
1025
+ return parseAppCredential(slug, value);
1026
+ }
1027
+
1028
+ /** Persist client credentials only; tokens never enter the credential store. */
1029
+ export function writeAppCredential(
1030
+ credential: OAuthAppCredential,
1031
+ opts: { plaintext?: boolean } = {},
1032
+ ): WriteCredentialResult {
1033
+ const backend = keyring();
1034
+ if (!opts.plaintext && !backend)
1035
+ throw new CliError(
1036
+ "App authentication requires a system keyring. Use --plaintext to explicitly store client credentials in the private user config.",
1037
+ "runtime",
1038
+ );
1039
+ const path = userConfigPath();
1040
+ const slug = credential.workspace.urlKey;
1041
+ if (opts.plaintext) {
1042
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
1043
+ chmodSync(dirname(path), 0o700);
1044
+ }
1045
+ return withUserConfigLock(path, () => {
1046
+ const obj = readUserObject(path);
1047
+ const ws = workspacesTable(obj);
1048
+ const table: WorkspaceTable = { ...(ws[slug] ?? {}) };
1049
+ // Explicit file storage does not probe a possibly unusable desktop keyring, unless
1050
+ // replacing a profile whose existing secrets must be removed from that keyring.
1051
+ const needsKeyring = !opts.plaintext || table.oauth || (table.app && !table.app_credentials);
1052
+ const activeBackend = needsKeyring ? backend : null;
1053
+ if (needsKeyring && !activeBackend)
1054
+ throw new CliError(
1055
+ "A system keyring is required to replace the existing keyring profile. Remove it on the original machine first.",
1056
+ "runtime",
1057
+ );
1058
+ let previous: string | null = null;
1059
+ let previousOther: string | null = null;
1060
+ try {
1061
+ if (activeBackend) {
1062
+ previous = activeBackend.get(`app:${slug}`);
1063
+ previousOther = activeBackend.get(oauthAccount(slug));
1064
+ }
1065
+ } catch {
1066
+ throw new CliError(
1067
+ "Could not read existing app credentials from the system keyring.",
1068
+ "runtime",
1069
+ );
1070
+ }
1071
+ try {
1072
+ table.app = true;
1073
+ delete table.oauth;
1074
+ if (opts.plaintext) {
1075
+ const keyringFallback = activeBackend
1076
+ ? activeBackend.get(slug) !== null
1077
+ : ws[slug]?.keyring &&
1078
+ (!ws[slug]?.app || ws[slug]?.app_credentials !== undefined) &&
1079
+ !ws[slug]?.oauth;
1080
+ table.app_credentials = credential;
1081
+ if (keyringFallback) table.keyring = true;
1082
+ else delete table.keyring;
1083
+ activeBackend?.delete(`app:${slug}`);
1084
+ } else {
1085
+ activeBackend!.set(`app:${slug}`, JSON.stringify(credential));
1086
+ delete table.app_credentials;
1087
+ table.keyring = true;
1088
+ }
1089
+ activeBackend?.delete(oauthAccount(slug));
1090
+ ws[slug] = table;
1091
+ writeUserObject(path, obj);
1092
+ } catch {
1093
+ try {
1094
+ if (activeBackend) {
1095
+ if (previous === null) activeBackend.delete(`app:${slug}`);
1096
+ else activeBackend.set(`app:${slug}`, previous);
1097
+ if (previousOther !== null) activeBackend.set(oauthAccount(slug), previousOther);
1098
+ }
1099
+ } catch {
1100
+ /* Keep errors free of credential contents. */
1101
+ }
1102
+ throw new CliError(
1103
+ "Could not store app credentials in the selected credential store.",
1104
+ "runtime",
1105
+ );
1106
+ }
1107
+ return {
1108
+ path,
1109
+ storage: opts.plaintext ? "file" : "keychain",
1110
+ keyringLabel: opts.plaintext ? undefined : backend!.label,
1111
+ };
1112
+ });
1113
+ }
1114
+
902
1115
  export const OAUTH_KEYRING_ACCOUNT_PREFIX = "oauth:";
903
1116
 
904
1117
  function oauthAccount(slug: string): string {
@@ -990,8 +1203,10 @@ export function writeOAuthCredential(credential: OAuthUserCredential): WriteOAut
990
1203
  return withUserConfigLock(path, () => {
991
1204
  const account = oauthAccount(slug);
992
1205
  let previous: string | null;
1206
+ let previousOther: string | null;
993
1207
  try {
994
1208
  previous = backend.get(account);
1209
+ previousOther = backend.get(`app:${slug}`);
995
1210
  backend.set(account, JSON.stringify(credential));
996
1211
  } catch {
997
1212
  throw new CliError(
@@ -1005,12 +1220,16 @@ export function writeOAuthCredential(credential: OAuthUserCredential): WriteOAut
1005
1220
  const table: WorkspaceTable = { ...(ws[slug] ?? {}) };
1006
1221
  table.keyring = true;
1007
1222
  table.oauth = true;
1223
+ delete table.app;
1224
+ delete table.app_credentials;
1008
1225
  ws[slug] = table;
1226
+ backend.delete(`app:${slug}`);
1009
1227
  writeUserObject(path, obj);
1010
1228
  } catch (err) {
1011
1229
  try {
1012
1230
  if (previous === null) backend.delete(account);
1013
1231
  else backend.set(account, previous);
1232
+ if (previousOther !== null) backend.set(`app:${slug}`, previousOther);
1014
1233
  } catch {
1015
1234
  // The primary error is the failed config commit; do not expose secrets from a rollback error.
1016
1235
  }
@@ -1165,14 +1384,19 @@ export function writeCredential(
1165
1384
  delete table.api_key;
1166
1385
  table.keyring = true;
1167
1386
  delete table.oauth;
1387
+ delete table.app;
1388
+ delete table.app_credentials;
1168
1389
  storage = "keychain";
1169
1390
  } else {
1170
1391
  delete table.keyring;
1171
1392
  delete table.oauth;
1393
+ delete table.app;
1394
+ delete table.app_credentials;
1172
1395
  table.api_key = apiKey;
1173
1396
  storage = "file";
1174
1397
  }
1175
1398
  systemBackend?.delete(oauthAccount(slug));
1399
+ systemBackend?.delete(`app:${slug}`);
1176
1400
  ws[slug] = table;
1177
1401
  writeUserObject(path, obj);
1178
1402
  return { path, storage, keyringLabel: backend?.label };
@@ -1263,6 +1487,7 @@ export function removeCredentialWithMetadata(slug: string): RemoveCredentialResu
1263
1487
  if (backend) {
1264
1488
  if (backend.delete(slug)) removed = true;
1265
1489
  if (backend.delete(oauthAccount(slug))) removed = true;
1490
+ if (backend.delete(`app:${slug}`)) removed = true;
1266
1491
  }
1267
1492
  if (removeFromReferenceCredentials(slug)) removed = true;
1268
1493
 
@@ -1298,20 +1523,27 @@ export interface RemoveOAuthCredentialResult {
1298
1523
  teamMetadataRemoved: boolean;
1299
1524
  }
1300
1525
 
1301
- /** Remove only the human OAuth lifecycle, restoring a pre-existing API-key profile if present. */
1302
- export function removeOAuthCredential(slug: string): RemoveOAuthCredentialResult {
1526
+ /** Remove the selected OAuth lifecycle, restoring a pre-existing API-key profile if present. */
1527
+ export function removeOAuthCredential(
1528
+ slug: string,
1529
+ kind: "oauth" | "app" = "oauth",
1530
+ ): RemoveOAuthCredentialResult {
1303
1531
  const path = userConfigPath();
1304
1532
  return withUserConfigLock(path, () => {
1305
- const backend = keyring();
1533
+ const initialObj = existsSync(path) ? readUserObject(path) : undefined;
1534
+ const initialTable = initialObj ? workspacesTable(initialObj)[slug] : undefined;
1535
+ const fileApp = kind === "app" && initialTable?.app_credentials !== undefined;
1536
+ const backend = fileApp && (!initialTable?.keyring || initialTable.api_key) ? null : keyring();
1306
1537
  let oauthRaw: string | null = null;
1307
1538
  let keyringApiKey = false;
1308
1539
  let removed = false;
1309
1540
  let teamMetadataRemoved = false;
1310
1541
  if (backend) {
1311
1542
  try {
1312
- oauthRaw = backend.get(oauthAccount(slug));
1543
+ if (!fileApp) oauthRaw = backend.get(kind === "app" ? `app:${slug}` : oauthAccount(slug));
1313
1544
  keyringApiKey = backend.get(slug) !== null;
1314
- if (oauthRaw && backend.delete(oauthAccount(slug))) removed = true;
1545
+ if (oauthRaw && backend.delete(kind === "app" ? `app:${slug}` : oauthAccount(slug)))
1546
+ removed = true;
1315
1547
  } catch {
1316
1548
  throw new CliError(
1317
1549
  `Could not remove OAuth credentials for workspace '${sanitize(slug)}' from the ${backend.label}.`,
@@ -1328,16 +1560,18 @@ export function removeOAuthCredential(slug: string): RemoveOAuthCredentialResult
1328
1560
  };
1329
1561
  }
1330
1562
  try {
1331
- const obj = readUserObject(path);
1563
+ const obj = initialObj!;
1332
1564
  const ws = workspacesTable(obj);
1333
1565
  const table = ws[slug];
1334
- const hadOAuthMarker = table?.oauth === true;
1566
+ const hadOAuthMarker =
1567
+ table?.[kind] === true || (kind === "app" && table?.app_credentials !== undefined);
1335
1568
  if (hadOAuthMarker) removed = true;
1336
1569
  const apiKeyFallback = Boolean(asString(table?.api_key)) || keyringApiKey;
1337
1570
 
1338
1571
  if (apiKeyFallback) {
1339
1572
  const fallback: WorkspaceTable = { ...(table ?? {}) };
1340
- delete fallback.oauth;
1573
+ delete fallback[kind];
1574
+ if (kind === "app") delete fallback.app_credentials;
1341
1575
  if (asString(fallback.api_key)) delete fallback.keyring;
1342
1576
  else fallback.keyring = true;
1343
1577
  ws[slug] = fallback;
@@ -1358,7 +1592,8 @@ export function removeOAuthCredential(slug: string): RemoveOAuthCredentialResult
1358
1592
  };
1359
1593
  } catch (err) {
1360
1594
  try {
1361
- if (backend && oauthRaw) backend.set(oauthAccount(slug), oauthRaw);
1595
+ if (backend && oauthRaw)
1596
+ backend.set(kind === "app" ? `app:${slug}` : oauthAccount(slug), oauthRaw);
1362
1597
  } catch {
1363
1598
  // Preserve the primary config error without exposing keyring or credential details.
1364
1599
  }
@@ -1428,7 +1663,7 @@ export interface CredentialEntry {
1428
1663
  isDefault: boolean;
1429
1664
  /** Where the secret is kept. */
1430
1665
  storage: CredentialStorage;
1431
- credentialType: "api-key" | "oauth-user";
1666
+ credentialType: "api-key" | "oauth-user" | "oauth-app";
1432
1667
  }
1433
1668
 
1434
1669
  /** List configured credentials: one entry per stored workspace. */
@@ -1438,8 +1673,20 @@ export function listCredentials(env: NodeJS.ProcessEnv = process.env): Credentia
1438
1673
  return Object.entries(user.workspaces).map(([slug, entry]) => ({
1439
1674
  slug,
1440
1675
  isDefault: user.defaultWorkspace === slug,
1441
- storage: entry.apiKey ? "file" : "keychain",
1442
- credentialType: entry.oauth ? "oauth-user" : "api-key",
1676
+ storage:
1677
+ entry.appFileCredential !== undefined
1678
+ ? "file"
1679
+ : entry.app || entry.oauth
1680
+ ? "keychain"
1681
+ : entry.apiKey
1682
+ ? "file"
1683
+ : "keychain",
1684
+ credentialType:
1685
+ entry.app || entry.appFileCredential !== undefined
1686
+ ? "oauth-app"
1687
+ : entry.oauth
1688
+ ? "oauth-user"
1689
+ : "api-key",
1443
1690
  }));
1444
1691
  }
1445
1692
 
@@ -1476,7 +1723,17 @@ const KEY_ALIASES: Record<SettableKey, readonly string[]> = {
1476
1723
  };
1477
1724
 
1478
1725
  /** Keys that must never be written to a project file, and why. */
1479
- const SECRET_KEYS = new Set(["api_key", "workspaces", "default_workspace", "keyring", "oauth"]);
1726
+ const SECRET_KEYS = new Set([
1727
+ "api_key",
1728
+ "workspaces",
1729
+ "default_workspace",
1730
+ "keyring",
1731
+ "oauth",
1732
+ "app",
1733
+ "app_credentials",
1734
+ "client_id",
1735
+ "client_secret",
1736
+ ]);
1480
1737
 
1481
1738
  /**
1482
1739
  * Where `config init` writes and `config set` falls back to when no project
package/src/index.ts CHANGED
@@ -22,6 +22,8 @@ export {
22
22
  type GetAccessTokenOptions,
23
23
  type OAuthAccessToken,
24
24
  type OAuthUserCredential,
25
+ type OAuthAppCredential,
26
+ type OAuthAppIdentity,
25
27
  type OAuthUserTokenProviderOptions,
26
28
  type PkceRequest,
27
29
  } from "./oauth.js";
@@ -1,7 +1,7 @@
1
1
  import type { ResolvedConfig } from "../config.js";
2
2
  import { CliError, normalizeError } from "./errors.js";
3
3
 
4
- export type CredentialType = "api-key" | "oauth-access-token" | "oauth-user" | null;
4
+ export type CredentialType = "api-key" | "oauth-access-token" | "oauth-user" | "oauth-app" | null;
5
5
  export type ScopeVisibility = "known" | "unknown" | "unavailable";
6
6
 
7
7
  export interface AuthorizationCapabilities {
@@ -13,6 +13,7 @@ export interface AuthorizationCapabilities {
13
13
  }
14
14
 
15
15
  export function credentialType(config: ResolvedConfig): CredentialType {
16
+ if (config.appCredential) return "oauth-app";
16
17
  if (config.oauthCredential) return "oauth-user";
17
18
  if (config.accessToken) return "oauth-access-token";
18
19
  if (config.apiKey) return "api-key";
@@ -26,14 +27,16 @@ export function credentialType(config: ResolvedConfig): CredentialType {
26
27
  */
27
28
  export function authorizationCapabilities(config: ResolvedConfig): AuthorizationCapabilities {
28
29
  const type = credentialType(config);
29
- if (config.oauthCredential) {
30
- const scopes = [...config.oauthCredential.scopes];
30
+ if (config.oauthCredential || config.appCredential) {
31
+ const scopes = [...(config.oauthCredential ?? config.appCredential)!.scopes];
31
32
  return {
32
33
  credentialType: type,
33
34
  scopeVisibility: "known",
34
35
  scopes,
35
36
  adminScope: scopes.includes("admin"),
36
- note: "Stored OAuth grant scopes are known; workspace role is a separate authorization check.",
37
+ note: config.appCredential
38
+ ? "Configured app scopes are known; the token exchange and workspace permissions determine granted access."
39
+ : "Stored OAuth grant scopes are known; workspace role is a separate authorization check.",
37
40
  };
38
41
  }
39
42
  if (type) {
package/src/lib/errors.ts CHANGED
@@ -93,6 +93,8 @@ export function normalizeError(err: unknown): CliError {
93
93
  if (err instanceof Error) {
94
94
  const name = err.constructor?.name ?? err.name ?? "";
95
95
  const anyErr = err as Record<string, any>;
96
+ // The SDK wraps errors from our token boundary. Preserve their safe message and exit code.
97
+ if (anyErr.raw instanceof CliError) return anyErr.raw;
96
98
  const gqlErrors: any[] =
97
99
  [anyErr.errors, anyErr.response?.errors, anyErr.raw?.response?.errors].find(
98
100
  (errors) => Array.isArray(errors) && errors.length > 0,
package/src/oauth.ts CHANGED
@@ -56,6 +56,38 @@ export interface OAuthUserCredential {
56
56
  user: OAuthUserIdentity;
57
57
  }
58
58
 
59
+ /** Persistent app identity. Tokens are acquired per invocation and never stored. */
60
+ export interface OAuthAppCredential {
61
+ version: 1;
62
+ kind: "oauth-app";
63
+ clientId: string;
64
+ clientSecret: string;
65
+ scopes: string[];
66
+ workspace: OAuthWorkspaceIdentity;
67
+ }
68
+
69
+ /** Environment-backed app identity has no persisted workspace binding. */
70
+ export type OAuthAppIdentity = Omit<OAuthAppCredential, "workspace"> & {
71
+ workspace?: OAuthWorkspaceIdentity;
72
+ };
73
+
74
+ /** Scope parsing shared by explicit app login and environment-only authentication. */
75
+ export function clientCredentialsScopes(value?: string): string[] {
76
+ const scopes =
77
+ value === undefined ? [...DEFAULT_OAUTH_SCOPES] : value.split(",").map((scope) => scope.trim());
78
+ if (!scopes.length || scopes.some((scope) => !scope)) {
79
+ throw new OAuthTokenError(
80
+ "Client credentials scopes must be a non-empty comma-separated list.",
81
+ undefined,
82
+ "usage",
83
+ );
84
+ }
85
+ if (scopes.includes("admin")) {
86
+ throw new OAuthTokenError("App actors cannot request the admin scope.", undefined, "usage");
87
+ }
88
+ return [...new Set(scopes)];
89
+ }
90
+
59
91
  export interface GetAccessTokenOptions {
60
92
  forceRefresh?: boolean;
61
93
  }
@@ -94,16 +94,21 @@ export class Output {
94
94
  const options: ListOutputOptions = Array.isArray(jsonRowsOrOptions)
95
95
  ? { jsonRows: jsonRowsOrOptions, ...legacyOptions }
96
96
  : (jsonRowsOrOptions ?? {});
97
+ // Validate projection before emitting any status text: failures must leave
98
+ // stderr available for a single machine-readable error envelope.
99
+ const projected = this.opts.json
100
+ ? projectFields(options.jsonRows ?? rows, this.opts.fields)
101
+ : undefined;
102
+ const cols = this.opts.json ? columns : selectColumns(columns, this.opts.fields, rows[0]);
97
103
  if (hasMoreResults(rows)) {
98
104
  this.info(
99
105
  `Showing ${rows.length} results; more exist. Use --all or increase --limit to see them.`,
100
106
  );
101
107
  }
102
108
  if (this.opts.json) {
103
- this.writeJson(projectFields(options.jsonRows ?? rows, this.opts.fields));
109
+ this.writeJson(projected);
104
110
  return;
105
111
  }
106
- const cols = selectColumns(columns, this.opts.fields, rows[0]);
107
112
  process.stdout.write(
108
113
  renderTable(rows, cols, { color: this.opts.color, empty: options.empty }) + "\n",
109
114
  );