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/CHANGELOG.md +17 -0
- package/COVERAGE.md +9 -5
- package/README.md +62 -11
- package/package.json +6 -6
- package/skills/linear-sdk-cli/SKILL.md +38 -11
- package/skills/linear-sdk-cli/references/auth.md +15 -11
- package/src/bin/linear.ts +6 -7
- package/src/cli.ts +88 -12
- package/src/client.ts +46 -18
- package/src/commands/issue.ts +7 -1
- package/src/commands/meta.ts +113 -15
- package/src/config.ts +276 -19
- package/src/index.ts +2 -0
- package/src/lib/authorization.ts +7 -4
- package/src/lib/errors.ts +2 -0
- package/src/oauth.ts +32 -0
- package/src/output/format.ts +7 -2
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
|
|
19
|
-
*
|
|
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.
|
|
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
|
|
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 ("
|
|
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
|
|
1302
|
-
export function removeOAuthCredential(
|
|
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
|
|
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)))
|
|
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 =
|
|
1563
|
+
const obj = initialObj!;
|
|
1332
1564
|
const ws = workspacesTable(obj);
|
|
1333
1565
|
const table = ws[slug];
|
|
1334
|
-
const hadOAuthMarker =
|
|
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
|
|
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)
|
|
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:
|
|
1442
|
-
|
|
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([
|
|
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
package/src/lib/authorization.ts
CHANGED
|
@@ -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:
|
|
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
|
}
|
package/src/output/format.ts
CHANGED
|
@@ -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(
|
|
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
|
);
|