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.
- package/CHANGELOG.md +36 -16
- package/MIGRATING.md +7 -6
- package/README.md +120 -31
- package/package.json +5 -3
- package/skills/linear-sdk-cli/SKILL.md +43 -8
- package/skills/linear-sdk-cli/references/api.md +1 -1
- package/skills/linear-sdk-cli/references/attachment.md +1 -1
- package/skills/linear-sdk-cli/references/auth.md +26 -7
- package/skills/linear-sdk-cli/references/commands.md +1 -1
- package/skills/linear-sdk-cli/references/comment.md +1 -1
- package/skills/linear-sdk-cli/references/completion.md +1 -1
- package/skills/linear-sdk-cli/references/config.md +5 -1
- package/skills/linear-sdk-cli/references/cycle.md +1 -1
- package/skills/linear-sdk-cli/references/document.md +1 -1
- package/skills/linear-sdk-cli/references/favorite.md +1 -1
- package/skills/linear-sdk-cli/references/initiative-update.md +1 -1
- package/skills/linear-sdk-cli/references/initiative.md +1 -1
- package/skills/linear-sdk-cli/references/issue.md +1 -1
- package/skills/linear-sdk-cli/references/label.md +1 -1
- package/skills/linear-sdk-cli/references/milestone.md +1 -1
- package/skills/linear-sdk-cli/references/notification.md +1 -1
- package/skills/linear-sdk-cli/references/open.md +1 -1
- package/skills/linear-sdk-cli/references/organization.md +1 -1
- package/skills/linear-sdk-cli/references/project-update.md +1 -1
- package/skills/linear-sdk-cli/references/project.md +1 -1
- package/skills/linear-sdk-cli/references/roadmap.md +1 -1
- package/skills/linear-sdk-cli/references/schema.md +1 -1
- package/skills/linear-sdk-cli/references/state.md +1 -1
- package/skills/linear-sdk-cli/references/team.md +1 -1
- package/skills/linear-sdk-cli/references/user.md +1 -1
- package/skills/linear-sdk-cli/references/webhook.md +1 -1
- package/skills/linear-sdk-cli/references/whoami.md +1 -1
- package/skills/linear-sdk-cli/scripts/generate-docs.ts +3 -1
- package/src/client.ts +57 -2
- package/src/commands/meta.ts +240 -65
- package/src/config.ts +319 -16
- package/src/context.ts +2 -0
- package/src/index.ts +19 -0
- package/src/lib/introspect.ts +1 -0
- package/src/lib/options.ts +1 -0
- package/src/lib/output-shapes.ts +21 -2
- package/src/oauth.ts +582 -0
package/src/config.ts
CHANGED
|
@@ -10,18 +10,23 @@
|
|
|
10
10
|
* each directory — every location schpet/linear-cli 2.5 reads (it checks
|
|
11
11
|
* cwd and the git root; we check every directory between, which is a
|
|
12
12
|
* superset), in its order.
|
|
13
|
-
* -
|
|
13
|
+
* - Authentication secrets have a STRICTER boundary and are never read from a project file
|
|
14
14
|
* or from the reference CLI's global file (avoids committing secrets):
|
|
15
|
-
*
|
|
15
|
+
* Explicit `--access-token` / `--api-key` flags override their environment counterparts.
|
|
16
|
+
* Invocation OAuth access tokens are stateless (`LINEAR_ACCESS_TOKEN` only). Human browser
|
|
17
|
+
* OAuth sessions and API keys may also come from the OS keyring; API keys alone may opt into
|
|
18
|
+
* the user config as plaintext.
|
|
16
19
|
* A project `workspace` may select which already-stored credential to use;
|
|
17
20
|
* it can never provide or override the secret itself.
|
|
18
21
|
*
|
|
19
22
|
* Multi-workspace credentials live under quoted `[workspaces."<slug>"]` tables
|
|
20
23
|
* in the user config, with an optional top-level `default_workspace`. A table
|
|
21
24
|
* either carries the key itself (`api_key = "…"`, plaintext, 0600 file) or
|
|
22
|
-
* marks the slug as keyring-backed (`keyring = true`), in which case
|
|
25
|
+
* marks the slug as keyring-backed (`keyring = true`), in which case an API-key secret
|
|
23
26
|
* lives in the OS keyring under service `linear-cli` / account `<slug>` — the
|
|
24
|
-
* reference CLI's convention
|
|
27
|
+
* reference CLI's convention. A browser OAuth session uses the isolated account
|
|
28
|
+
* `oauth:<slug>` and a non-secret `oauth = true` marker, so its rotating token record
|
|
29
|
+
* cannot be mistaken for an API key. A user migrating from the reference CLI is authenticated
|
|
25
30
|
* before they run a single command. Its own workspace list
|
|
26
31
|
* (`credentials.toml`, a sibling of our file) is read for slugs and the
|
|
27
32
|
* default, never for keys. Credential selection is lazy: resolution never
|
|
@@ -50,18 +55,20 @@ import { execFileSync } from "node:child_process";
|
|
|
50
55
|
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
|
51
56
|
import { CliError } from "./lib/errors.js";
|
|
52
57
|
import { keyring, KeyringError } from "./lib/keyring.js";
|
|
58
|
+
import type { OAuthUserCredential } from "./oauth.js";
|
|
53
59
|
|
|
54
60
|
export interface RawSettings {
|
|
55
61
|
team?: string;
|
|
56
62
|
workspace?: string;
|
|
57
63
|
sort?: string;
|
|
58
64
|
apiKey?: string;
|
|
65
|
+
accessToken?: string;
|
|
59
66
|
}
|
|
60
67
|
|
|
61
68
|
/**
|
|
62
69
|
* Where a value came from. `user` is our `~/.config/linear/config.toml`;
|
|
63
70
|
* `global` is the reference CLI's `~/.config/linear/linear.toml`, read for
|
|
64
|
-
* non-secret settings only; `keychain` is the OS keyring (API key
|
|
71
|
+
* non-secret settings only; `keychain` is the OS keyring (API key or human OAuth session).
|
|
65
72
|
*/
|
|
66
73
|
export type ConfigSource = "flag" | "env" | "project" | "user" | "global" | "keychain" | "none";
|
|
67
74
|
|
|
@@ -82,9 +89,14 @@ export type SettingOrigins = Record<"team" | "workspace" | "sort", SettingOrigin
|
|
|
82
89
|
export interface ResolvedConfig {
|
|
83
90
|
apiKey?: string;
|
|
84
91
|
apiKeySource: ConfigSource;
|
|
92
|
+
/** OAuth access token supplied for this invocation; never read from or written to config. */
|
|
93
|
+
accessToken?: string;
|
|
94
|
+
accessTokenSource: ConfigSource;
|
|
95
|
+
/** Keyring-backed human OAuth session. Never serialized outside the keyring. */
|
|
96
|
+
oauthCredential?: OAuthUserCredential;
|
|
85
97
|
/**
|
|
86
|
-
* Deferred credential-
|
|
87
|
-
* for selection problems) so commands that REPAIR auth state — `auth list`,
|
|
98
|
+
* Deferred credential-resolution error. Resolution is total (it never throws
|
|
99
|
+
* for selection or ambiguous identity problems) so commands that REPAIR auth state — `auth list`,
|
|
88
100
|
* `auth default`, `auth logout`, `auth login --workspace new-org` — can still
|
|
89
101
|
* build a Context. The error is surfaced only when an API client is actually
|
|
90
102
|
* needed (see createClient / `auth token`).
|
|
@@ -160,6 +172,8 @@ export function referenceCredentialsPath(env: NodeJS.ProcessEnv = process.env):
|
|
|
160
172
|
interface WorkspaceEntry {
|
|
161
173
|
/** Plaintext key from `[workspaces."<slug>"].api_key`; absent → keyring-backed. */
|
|
162
174
|
apiKey?: string;
|
|
175
|
+
/** This workspace uses the isolated `oauth:<slug>` keyring account. */
|
|
176
|
+
oauth?: boolean;
|
|
163
177
|
}
|
|
164
178
|
|
|
165
179
|
/** Parsed view of the user config: top-level settings + nested workspace creds. */
|
|
@@ -310,7 +324,11 @@ function readUserConfig(path: string): UserConfig {
|
|
|
310
324
|
if (val && typeof val === "object") {
|
|
311
325
|
// A table with no api_key is a keyring-backed workspace (whether or
|
|
312
326
|
// not it carries the `keyring = true` marker `auth login` writes).
|
|
313
|
-
|
|
327
|
+
const table = val as Record<string, unknown>;
|
|
328
|
+
workspaces[slug] = {
|
|
329
|
+
apiKey: asString(table.api_key),
|
|
330
|
+
oauth: table.oauth === true,
|
|
331
|
+
};
|
|
314
332
|
}
|
|
315
333
|
}
|
|
316
334
|
}
|
|
@@ -335,7 +353,21 @@ function readUserConfig(path: string): UserConfig {
|
|
|
335
353
|
function lookupCredential(
|
|
336
354
|
user: UserConfig,
|
|
337
355
|
slug: string,
|
|
338
|
-
):
|
|
356
|
+
):
|
|
357
|
+
| { apiKey: string; source: "user" | "keychain" }
|
|
358
|
+
| { oauthCredential: OAuthUserCredential; source: "keychain" }
|
|
359
|
+
| { error: CliError }
|
|
360
|
+
| undefined {
|
|
361
|
+
if (user.workspaces[slug]?.oauth) {
|
|
362
|
+
try {
|
|
363
|
+
const credential = readOAuthCredential(slug);
|
|
364
|
+
return credential ? { oauthCredential: credential, source: "keychain" } : undefined;
|
|
365
|
+
} catch (err) {
|
|
366
|
+
return {
|
|
367
|
+
error: err instanceof CliError ? err : new CliError("Could not read OAuth credentials."),
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
}
|
|
339
371
|
const plaintext = user.workspaces[slug]?.apiKey;
|
|
340
372
|
if (plaintext) return { apiKey: plaintext, source: "user" };
|
|
341
373
|
const backend = keyring();
|
|
@@ -385,6 +417,7 @@ export function findProjectConfig(cwd: string): string | undefined {
|
|
|
385
417
|
function fromEnv(env: NodeJS.ProcessEnv): RawSettings {
|
|
386
418
|
return {
|
|
387
419
|
apiKey: env.LINEAR_API_KEY || env.LINEAR_API_TOKEN,
|
|
420
|
+
accessToken: env.LINEAR_ACCESS_TOKEN,
|
|
388
421
|
team: env.LINEAR_TEAM || env.LINEAR_TEAM_ID,
|
|
389
422
|
workspace: env.LINEAR_WORKSPACE,
|
|
390
423
|
sort: env.LINEAR_ISSUE_SORT,
|
|
@@ -407,19 +440,38 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
|
|
|
407
440
|
const globalPath = existsSync(globalCandidate) ? globalCandidate : undefined;
|
|
408
441
|
const global = globalPath ? readTomlFile(globalPath) : { settings: {}, keys: {} };
|
|
409
442
|
|
|
410
|
-
// -----
|
|
443
|
+
// ----- Credential resolution (strict trust boundary) --------------------
|
|
411
444
|
// Resolution is TOTAL: selection problems are stashed in `apiKeyError` rather
|
|
412
445
|
// than thrown, so auth-repair commands can still build a Context. The error
|
|
413
446
|
// is surfaced lazily, when an API client is actually needed.
|
|
414
447
|
let apiKey: string | undefined;
|
|
415
448
|
let apiKeySource: ConfigSource = "none";
|
|
449
|
+
let accessToken: string | undefined;
|
|
450
|
+
let accessTokenSource: ConfigSource = "none";
|
|
451
|
+
let oauthCredential: OAuthUserCredential | undefined;
|
|
416
452
|
let credentialWorkspace: string | undefined;
|
|
417
453
|
let apiKeyError: CliError | undefined;
|
|
418
454
|
|
|
419
|
-
if (flags.apiKey) {
|
|
420
|
-
|
|
455
|
+
if (flags.apiKey && flags.accessToken) {
|
|
456
|
+
apiKeyError = new CliError(
|
|
457
|
+
"Pass only one of --api-key or --access-token; they may identify different Linear actors.",
|
|
458
|
+
"usage",
|
|
459
|
+
);
|
|
460
|
+
} else if (flags.accessToken) {
|
|
461
|
+
// 1. An explicit credential flag is absolute: it bypasses env and workspace selection.
|
|
462
|
+
accessToken = flags.accessToken;
|
|
463
|
+
accessTokenSource = "flag";
|
|
464
|
+
} else if (flags.apiKey) {
|
|
421
465
|
apiKey = flags.apiKey;
|
|
422
466
|
apiKeySource = "flag";
|
|
467
|
+
} else if (envSettings.apiKey && envSettings.accessToken) {
|
|
468
|
+
apiKeyError = new CliError(
|
|
469
|
+
"Both LINEAR_API_KEY/LINEAR_API_TOKEN and LINEAR_ACCESS_TOKEN are set. Unset one so the Linear actor is unambiguous.",
|
|
470
|
+
"usage",
|
|
471
|
+
);
|
|
472
|
+
} else if (envSettings.accessToken) {
|
|
473
|
+
accessToken = envSettings.accessToken;
|
|
474
|
+
accessTokenSource = "env";
|
|
423
475
|
} else if (envSettings.apiKey) {
|
|
424
476
|
// 1. LINEAR_API_KEY/LINEAR_API_TOKEN is absolute too.
|
|
425
477
|
apiKey = envSettings.apiKey;
|
|
@@ -448,8 +500,14 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
|
|
|
448
500
|
} else if ("error" in found) {
|
|
449
501
|
apiKeyError = found.error;
|
|
450
502
|
} else {
|
|
451
|
-
|
|
452
|
-
|
|
503
|
+
if ("oauthCredential" in found) {
|
|
504
|
+
oauthCredential = found.oauthCredential;
|
|
505
|
+
accessToken = found.oauthCredential.accessToken;
|
|
506
|
+
accessTokenSource = found.source;
|
|
507
|
+
} else {
|
|
508
|
+
apiKey = found.apiKey;
|
|
509
|
+
apiKeySource = found.source;
|
|
510
|
+
}
|
|
453
511
|
credentialWorkspace = slug;
|
|
454
512
|
}
|
|
455
513
|
};
|
|
@@ -504,6 +562,9 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
|
|
|
504
562
|
return {
|
|
505
563
|
apiKey,
|
|
506
564
|
apiKeySource,
|
|
565
|
+
accessToken,
|
|
566
|
+
accessTokenSource,
|
|
567
|
+
oauthCredential,
|
|
507
568
|
apiKeyError,
|
|
508
569
|
credentialWorkspace,
|
|
509
570
|
team: pick("team"),
|
|
@@ -761,6 +822,7 @@ function writeUserObject(path: string, obj: Record<string, unknown>): void {
|
|
|
761
822
|
interface WorkspaceTable {
|
|
762
823
|
api_key?: string;
|
|
763
824
|
keyring?: boolean;
|
|
825
|
+
oauth?: boolean;
|
|
764
826
|
}
|
|
765
827
|
|
|
766
828
|
function workspacesTable(obj: Record<string, unknown>): Record<string, WorkspaceTable> {
|
|
@@ -777,6 +839,166 @@ export interface WriteCredentialResult {
|
|
|
777
839
|
keyringLabel?: string;
|
|
778
840
|
}
|
|
779
841
|
|
|
842
|
+
export const OAUTH_KEYRING_ACCOUNT_PREFIX = "oauth:";
|
|
843
|
+
|
|
844
|
+
function oauthAccount(slug: string): string {
|
|
845
|
+
return `${OAUTH_KEYRING_ACCOUNT_PREFIX}${slug}`;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function parseOAuthCredential(slug: string, raw: string): OAuthUserCredential {
|
|
849
|
+
let value: unknown;
|
|
850
|
+
try {
|
|
851
|
+
value = JSON.parse(raw);
|
|
852
|
+
} catch {
|
|
853
|
+
throw new CliError(
|
|
854
|
+
`The stored OAuth credential for workspace '${sanitize(slug)}' is malformed. Run \`linear auth logout --workspace ${sanitize(slug)} --local-only\` and sign in again.`,
|
|
855
|
+
"auth",
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
const c = value as Partial<OAuthUserCredential> | null;
|
|
859
|
+
if (
|
|
860
|
+
!c ||
|
|
861
|
+
c.version !== 1 ||
|
|
862
|
+
c.kind !== "oauth-user" ||
|
|
863
|
+
c.actor !== "user" ||
|
|
864
|
+
typeof c.accessToken !== "string" ||
|
|
865
|
+
!c.accessToken ||
|
|
866
|
+
typeof c.refreshToken !== "string" ||
|
|
867
|
+
!c.refreshToken ||
|
|
868
|
+
typeof c.expiresAt !== "number" ||
|
|
869
|
+
!Number.isFinite(c.expiresAt) ||
|
|
870
|
+
!Array.isArray(c.scopes) ||
|
|
871
|
+
c.scopes.some((scope) => typeof scope !== "string" || !scope) ||
|
|
872
|
+
c.tokenType !== "Bearer" ||
|
|
873
|
+
typeof c.clientId !== "string" ||
|
|
874
|
+
!c.clientId ||
|
|
875
|
+
!c.workspace ||
|
|
876
|
+
typeof c.workspace.id !== "string" ||
|
|
877
|
+
typeof c.workspace.name !== "string" ||
|
|
878
|
+
c.workspace.urlKey !== slug ||
|
|
879
|
+
!c.user ||
|
|
880
|
+
typeof c.user.id !== "string" ||
|
|
881
|
+
typeof c.user.name !== "string" ||
|
|
882
|
+
typeof c.user.email !== "string"
|
|
883
|
+
) {
|
|
884
|
+
throw new CliError(
|
|
885
|
+
`The stored OAuth credential for workspace '${sanitize(slug)}' is invalid. Sign in again.`,
|
|
886
|
+
"auth",
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
return c as OAuthUserCredential;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/** Read one explicitly selected human OAuth session from its isolated keyring account. */
|
|
893
|
+
export function readOAuthCredential(slug: string): OAuthUserCredential | undefined {
|
|
894
|
+
const backend = keyring();
|
|
895
|
+
if (!backend) {
|
|
896
|
+
throw new CliError(
|
|
897
|
+
`A system keyring is required for OAuth credentials for workspace '${sanitize(slug)}'.`,
|
|
898
|
+
"runtime",
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
let raw: string | null;
|
|
902
|
+
try {
|
|
903
|
+
raw = backend.get(oauthAccount(slug));
|
|
904
|
+
} catch {
|
|
905
|
+
throw new CliError(
|
|
906
|
+
`Could not read the ${backend.label} OAuth entry for workspace '${sanitize(slug)}'.`,
|
|
907
|
+
"runtime",
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
return raw ? parseOAuthCredential(slug, raw) : undefined;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
export interface WriteOAuthCredentialResult {
|
|
914
|
+
path: string;
|
|
915
|
+
storage: "keychain";
|
|
916
|
+
keyringLabel: string;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/** Store the complete OAuth session in the keyring; the config gets only a non-secret marker. */
|
|
920
|
+
export function writeOAuthCredential(credential: OAuthUserCredential): WriteOAuthCredentialResult {
|
|
921
|
+
const slug = credential.workspace.urlKey;
|
|
922
|
+
const path = userConfigPath();
|
|
923
|
+
const backend = keyring();
|
|
924
|
+
if (!backend) {
|
|
925
|
+
throw new CliError(
|
|
926
|
+
"Browser OAuth requires a usable system keyring; use `linear auth login --key -` on this platform.",
|
|
927
|
+
"runtime",
|
|
928
|
+
);
|
|
929
|
+
}
|
|
930
|
+
return withUserConfigLock(path, () => {
|
|
931
|
+
const account = oauthAccount(slug);
|
|
932
|
+
let previous: string | null;
|
|
933
|
+
try {
|
|
934
|
+
previous = backend.get(account);
|
|
935
|
+
backend.set(account, JSON.stringify(credential));
|
|
936
|
+
} catch {
|
|
937
|
+
throw new CliError(
|
|
938
|
+
`Could not store OAuth credentials for workspace '${sanitize(slug)}' in the ${backend.label}.`,
|
|
939
|
+
"runtime",
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
try {
|
|
943
|
+
const obj = readUserObject(path);
|
|
944
|
+
const ws = workspacesTable(obj);
|
|
945
|
+
const table: WorkspaceTable = { ...(ws[slug] ?? {}) };
|
|
946
|
+
table.keyring = true;
|
|
947
|
+
table.oauth = true;
|
|
948
|
+
ws[slug] = table;
|
|
949
|
+
if (!asString(obj.default_workspace)) {
|
|
950
|
+
const reference = readReferenceCredentials(referenceCredentialsPath());
|
|
951
|
+
if (!reference.defaultWorkspace) obj.default_workspace = slug;
|
|
952
|
+
}
|
|
953
|
+
writeUserObject(path, obj);
|
|
954
|
+
} catch (err) {
|
|
955
|
+
try {
|
|
956
|
+
if (previous === null) backend.delete(account);
|
|
957
|
+
else backend.set(account, previous);
|
|
958
|
+
} catch {
|
|
959
|
+
// The primary error is the failed config commit; do not expose secrets from a rollback error.
|
|
960
|
+
}
|
|
961
|
+
throw err;
|
|
962
|
+
}
|
|
963
|
+
return { path, storage: "keychain", keyringLabel: backend.label };
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/** Compare-and-swap rotating refresh state. A concurrent winner is retained, never overwritten. */
|
|
968
|
+
export function rotateOAuthCredential(
|
|
969
|
+
slug: string,
|
|
970
|
+
previousRefreshToken: string,
|
|
971
|
+
next: OAuthUserCredential,
|
|
972
|
+
): OAuthUserCredential {
|
|
973
|
+
const path = userConfigPath();
|
|
974
|
+
return withUserConfigLock(path, () => {
|
|
975
|
+
const backend = keyring();
|
|
976
|
+
if (!backend) throw new CliError("A system keyring is required to refresh OAuth credentials.");
|
|
977
|
+
let raw: string | null;
|
|
978
|
+
try {
|
|
979
|
+
raw = backend.get(oauthAccount(slug));
|
|
980
|
+
} catch {
|
|
981
|
+
throw new CliError(
|
|
982
|
+
`Could not read OAuth credentials for workspace '${sanitize(slug)}' from the ${backend.label}.`,
|
|
983
|
+
"runtime",
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
if (!raw)
|
|
987
|
+
throw new CliError(`No stored OAuth credential for workspace '${sanitize(slug)}'.`, "auth");
|
|
988
|
+
const current = parseOAuthCredential(slug, raw);
|
|
989
|
+
if (current.refreshToken !== previousRefreshToken) return current;
|
|
990
|
+
try {
|
|
991
|
+
backend.set(oauthAccount(slug), JSON.stringify(next));
|
|
992
|
+
} catch {
|
|
993
|
+
throw new CliError(
|
|
994
|
+
`Could not rotate OAuth credentials for workspace '${sanitize(slug)}' in the ${backend.label}.`,
|
|
995
|
+
"runtime",
|
|
996
|
+
);
|
|
997
|
+
}
|
|
998
|
+
return next;
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
|
|
780
1002
|
/** A single, explicitly named credential found in the shared OS keyring. */
|
|
781
1003
|
export interface KeyringCredentialProbe {
|
|
782
1004
|
/** Kept in memory only; callers must never print it or pass it on argv. */
|
|
@@ -880,6 +1102,7 @@ export function writeCredential(
|
|
|
880
1102
|
const path = userConfigPath();
|
|
881
1103
|
return withUserConfigLock(path, () => {
|
|
882
1104
|
const backend = opts.plaintext ? null : keyring();
|
|
1105
|
+
const systemBackend = keyring();
|
|
883
1106
|
const obj = readUserObject(path);
|
|
884
1107
|
const ws = workspacesTable(obj);
|
|
885
1108
|
const table: WorkspaceTable = { ...(ws[slug] ?? {}) };
|
|
@@ -889,12 +1112,15 @@ export function writeCredential(
|
|
|
889
1112
|
backend.set(slug, apiKey);
|
|
890
1113
|
delete table.api_key;
|
|
891
1114
|
table.keyring = true;
|
|
1115
|
+
delete table.oauth;
|
|
892
1116
|
storage = "keychain";
|
|
893
1117
|
} else {
|
|
894
1118
|
delete table.keyring;
|
|
1119
|
+
delete table.oauth;
|
|
895
1120
|
table.api_key = apiKey;
|
|
896
1121
|
storage = "file";
|
|
897
1122
|
}
|
|
1123
|
+
systemBackend?.delete(oauthAccount(slug));
|
|
898
1124
|
ws[slug] = table;
|
|
899
1125
|
if (!asString(obj.default_workspace)) {
|
|
900
1126
|
// Leave the reference CLI's default in charge if it has one; writing ours
|
|
@@ -956,7 +1182,10 @@ export function removeCredential(slug: string): boolean {
|
|
|
956
1182
|
let removed = false;
|
|
957
1183
|
|
|
958
1184
|
const backend = keyring();
|
|
959
|
-
if (backend
|
|
1185
|
+
if (backend) {
|
|
1186
|
+
if (backend.delete(slug)) removed = true;
|
|
1187
|
+
if (backend.delete(oauthAccount(slug))) removed = true;
|
|
1188
|
+
}
|
|
960
1189
|
if (removeFromReferenceCredentials(slug)) removed = true;
|
|
961
1190
|
|
|
962
1191
|
if (!existsSync(path)) return removed;
|
|
@@ -981,6 +1210,78 @@ export function removeCredential(slug: string): boolean {
|
|
|
981
1210
|
});
|
|
982
1211
|
}
|
|
983
1212
|
|
|
1213
|
+
export interface RemoveOAuthCredentialResult {
|
|
1214
|
+
removed: boolean;
|
|
1215
|
+
fallbackCredentialType: "api-key" | null;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/** Remove only the human OAuth lifecycle, restoring a pre-existing API-key profile if present. */
|
|
1219
|
+
export function removeOAuthCredential(slug: string): RemoveOAuthCredentialResult {
|
|
1220
|
+
const path = userConfigPath();
|
|
1221
|
+
return withUserConfigLock(path, () => {
|
|
1222
|
+
const backend = keyring();
|
|
1223
|
+
let oauthRaw: string | null = null;
|
|
1224
|
+
let keyringApiKey = false;
|
|
1225
|
+
let removed = false;
|
|
1226
|
+
if (backend) {
|
|
1227
|
+
try {
|
|
1228
|
+
oauthRaw = backend.get(oauthAccount(slug));
|
|
1229
|
+
keyringApiKey = backend.get(slug) !== null;
|
|
1230
|
+
if (oauthRaw && backend.delete(oauthAccount(slug))) removed = true;
|
|
1231
|
+
} catch {
|
|
1232
|
+
throw new CliError(
|
|
1233
|
+
`Could not remove OAuth credentials for workspace '${sanitize(slug)}' from the ${backend.label}.`,
|
|
1234
|
+
"runtime",
|
|
1235
|
+
);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
if (!existsSync(path)) {
|
|
1240
|
+
return { removed, fallbackCredentialType: keyringApiKey ? "api-key" : null };
|
|
1241
|
+
}
|
|
1242
|
+
try {
|
|
1243
|
+
const obj = readUserObject(path);
|
|
1244
|
+
const ws = workspacesTable(obj);
|
|
1245
|
+
const table = ws[slug];
|
|
1246
|
+
const hadOAuthMarker = table?.oauth === true;
|
|
1247
|
+
if (hadOAuthMarker) removed = true;
|
|
1248
|
+
const reference = readReferenceCredentials(referenceCredentialsPath());
|
|
1249
|
+
const apiKeyFallback = Boolean(asString(table?.api_key)) || keyringApiKey;
|
|
1250
|
+
|
|
1251
|
+
if (apiKeyFallback) {
|
|
1252
|
+
const fallback: WorkspaceTable = { ...(table ?? {}) };
|
|
1253
|
+
delete fallback.oauth;
|
|
1254
|
+
if (asString(fallback.api_key)) delete fallback.keyring;
|
|
1255
|
+
else fallback.keyring = true;
|
|
1256
|
+
ws[slug] = fallback;
|
|
1257
|
+
} else if (table) {
|
|
1258
|
+
delete ws[slug];
|
|
1259
|
+
}
|
|
1260
|
+
if (Object.keys(ws).length === 0) delete obj.workspaces;
|
|
1261
|
+
|
|
1262
|
+
if (!apiKeyFallback && asString(obj.default_workspace) === slug) {
|
|
1263
|
+
const remaining = obj.workspaces ? Object.keys(obj.workspaces as object) : [];
|
|
1264
|
+
const referenceFallback = reference.workspaces.find((candidate) => candidate !== slug);
|
|
1265
|
+
if (remaining.length > 0) obj.default_workspace = remaining[0]!;
|
|
1266
|
+
else if (referenceFallback) obj.default_workspace = referenceFallback;
|
|
1267
|
+
else delete obj.default_workspace;
|
|
1268
|
+
}
|
|
1269
|
+
if (hadOAuthMarker || apiKeyFallback) writeUserObject(path, obj);
|
|
1270
|
+
return {
|
|
1271
|
+
removed,
|
|
1272
|
+
fallbackCredentialType: apiKeyFallback ? "api-key" : null,
|
|
1273
|
+
};
|
|
1274
|
+
} catch (err) {
|
|
1275
|
+
try {
|
|
1276
|
+
if (backend && oauthRaw) backend.set(oauthAccount(slug), oauthRaw);
|
|
1277
|
+
} catch {
|
|
1278
|
+
// Preserve the primary config error without exposing keyring or credential details.
|
|
1279
|
+
}
|
|
1280
|
+
throw err;
|
|
1281
|
+
}
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
|
|
984
1285
|
export interface MigrateResult {
|
|
985
1286
|
/** Slugs whose keys moved from the file into the keyring. */
|
|
986
1287
|
migrated: string[];
|
|
@@ -1042,6 +1343,7 @@ export interface CredentialEntry {
|
|
|
1042
1343
|
isDefault: boolean;
|
|
1043
1344
|
/** Where the secret is kept. */
|
|
1044
1345
|
storage: CredentialStorage;
|
|
1346
|
+
credentialType: "api-key" | "oauth-user";
|
|
1045
1347
|
}
|
|
1046
1348
|
|
|
1047
1349
|
/** List configured credentials: one entry per stored workspace. */
|
|
@@ -1052,6 +1354,7 @@ export function listCredentials(env: NodeJS.ProcessEnv = process.env): Credentia
|
|
|
1052
1354
|
slug,
|
|
1053
1355
|
isDefault: user.defaultWorkspace === slug,
|
|
1054
1356
|
storage: entry.apiKey ? "file" : "keychain",
|
|
1357
|
+
credentialType: entry.oauth ? "oauth-user" : "api-key",
|
|
1055
1358
|
}));
|
|
1056
1359
|
}
|
|
1057
1360
|
|
|
@@ -1088,7 +1391,7 @@ const KEY_ALIASES: Record<SettableKey, readonly string[]> = {
|
|
|
1088
1391
|
};
|
|
1089
1392
|
|
|
1090
1393
|
/** Keys that must never be written to a project file, and why. */
|
|
1091
|
-
const SECRET_KEYS = new Set(["api_key", "workspaces", "default_workspace", "keyring"]);
|
|
1394
|
+
const SECRET_KEYS = new Set(["api_key", "workspaces", "default_workspace", "keyring", "oauth"]);
|
|
1092
1395
|
|
|
1093
1396
|
/**
|
|
1094
1397
|
* Where `config init` writes and `config set` falls back to when no project
|
package/src/context.ts
CHANGED
|
@@ -28,6 +28,7 @@ export interface GlobalOptions {
|
|
|
28
28
|
/** `--no-input`. Present (true) only when passed. */
|
|
29
29
|
noInput?: boolean;
|
|
30
30
|
apiKey?: string;
|
|
31
|
+
accessToken?: string;
|
|
31
32
|
/** A key, or several — `issue list`/`mine`/`search` make `--team` repeatable. */
|
|
32
33
|
team?: string | string[];
|
|
33
34
|
workspace?: string;
|
|
@@ -60,6 +61,7 @@ export class Context {
|
|
|
60
61
|
// and the config sees the first key.
|
|
61
62
|
flags: {
|
|
62
63
|
apiKey: options.apiKey,
|
|
64
|
+
accessToken: options.accessToken,
|
|
63
65
|
team: firstTeam(options.team),
|
|
64
66
|
workspace: options.workspace,
|
|
65
67
|
},
|
package/src/index.ts
CHANGED
|
@@ -6,5 +6,24 @@ export { createProgram, VERSION } from "./cli.js";
|
|
|
6
6
|
export { Context } from "./context.js";
|
|
7
7
|
export { resolveConfig, type ResolvedConfig } from "./config.js";
|
|
8
8
|
export { createClient } from "./client.js";
|
|
9
|
+
export {
|
|
10
|
+
DEFAULT_OAUTH_CALLBACK_TIMEOUT_MS,
|
|
11
|
+
DEFAULT_OAUTH_REFRESH_SKEW_MS,
|
|
12
|
+
DEFAULT_OAUTH_SCOPES,
|
|
13
|
+
ClientCredentialsTokenProvider,
|
|
14
|
+
OAuthUserTokenProvider,
|
|
15
|
+
OAuthTokenError,
|
|
16
|
+
buildAuthorizationUrl,
|
|
17
|
+
createPkceRequest,
|
|
18
|
+
exchangeAuthorizationCode,
|
|
19
|
+
revokeOAuthToken,
|
|
20
|
+
startLoopbackCallback,
|
|
21
|
+
type ClientCredentialsTokenProviderOptions,
|
|
22
|
+
type GetAccessTokenOptions,
|
|
23
|
+
type OAuthAccessToken,
|
|
24
|
+
type OAuthUserCredential,
|
|
25
|
+
type OAuthUserTokenProviderOptions,
|
|
26
|
+
type PkceRequest,
|
|
27
|
+
} from "./oauth.js";
|
|
9
28
|
export { CliError, ExitCode, normalizeError } from "./lib/errors.js";
|
|
10
29
|
export { branchToIssueId, currentIssueId } from "./git.js";
|
package/src/lib/introspect.ts
CHANGED
package/src/lib/options.ts
CHANGED
|
@@ -263,6 +263,7 @@ function globalOptions(): Option[] {
|
|
|
263
263
|
// spellings cannot drift apart.
|
|
264
264
|
new NoFlagOption("--no-color", "alias of --no-ansi", "noAnsi").hideHelp(),
|
|
265
265
|
new Option("--api-key <key>", "Linear API key (overrides env/config)"),
|
|
266
|
+
new Option("--access-token <token>", "Linear OAuth access token (overrides env/config)"),
|
|
266
267
|
new Option("--workspace <slug>", "select workspace credential profile"),
|
|
267
268
|
new Option("-t, --team <key>", "default team key (e.g. TES)"),
|
|
268
269
|
new Option("-n, --limit <n>", "max results (positive integer; 0 = all)").argParser(
|
package/src/lib/output-shapes.ts
CHANGED
|
@@ -160,6 +160,8 @@ const SETTING_ORIGINS_SHAPE = shape<SettingOrigins>({
|
|
|
160
160
|
const CONFIG_SHOW_SHAPE: ObjectFields = {
|
|
161
161
|
apiKey: "string",
|
|
162
162
|
apiKeySource: "string",
|
|
163
|
+
accessToken: "string",
|
|
164
|
+
accessTokenSource: "string",
|
|
163
165
|
credentialWorkspace: "string|null",
|
|
164
166
|
team: "string|null",
|
|
165
167
|
workspace: "string|null",
|
|
@@ -262,22 +264,39 @@ export const OUTPUT_SHAPES: Record<string, OutputShape | null> = {
|
|
|
262
264
|
path: "string",
|
|
263
265
|
}),
|
|
264
266
|
"auth default": receipt({ success: "boolean", default_workspace: "string", path: "string" }),
|
|
265
|
-
"auth list": list({
|
|
267
|
+
"auth list": list({
|
|
268
|
+
slug: "string",
|
|
269
|
+
isDefault: "boolean",
|
|
270
|
+
credentialType: "string",
|
|
271
|
+
storage: "string",
|
|
272
|
+
}),
|
|
266
273
|
"auth login": receipt({
|
|
267
274
|
success: "boolean",
|
|
275
|
+
credentialType: "string",
|
|
268
276
|
workspace: "string",
|
|
269
277
|
user: { id: "string", name: "string", email: "string" },
|
|
270
278
|
storage: "string",
|
|
279
|
+
"scopes?": ["string"],
|
|
280
|
+
"expiresAt?": "string",
|
|
271
281
|
path: "string",
|
|
272
282
|
}),
|
|
273
|
-
"auth logout": receipt({
|
|
283
|
+
"auth logout": receipt({
|
|
284
|
+
success: "boolean",
|
|
285
|
+
workspace: "string",
|
|
286
|
+
removed: "boolean",
|
|
287
|
+
revocation: "string",
|
|
288
|
+
fallbackCredentialType: "string|null",
|
|
289
|
+
}),
|
|
274
290
|
"auth migrate": receipt({ success: "boolean", migrated: ["string"], path: "string" }),
|
|
275
291
|
"auth status": object({
|
|
276
292
|
authenticated: "boolean",
|
|
293
|
+
credentialType: "string|null",
|
|
277
294
|
source: "string",
|
|
278
295
|
workspace: "string|null",
|
|
279
296
|
key: "string",
|
|
280
297
|
keyring: "string|null",
|
|
298
|
+
scopes: { nullable: ["string"] },
|
|
299
|
+
expiresAt: "string|null",
|
|
281
300
|
}),
|
|
282
301
|
"auth token": object({ apiKey: "string", workspace: "string|null" }),
|
|
283
302
|
"auth whoami": WHOAMI,
|