@enrichlayer/el-linear 1.4.0 → 1.6.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/README.md +54 -0
- package/dist/auth/oauth-callback.d.ts +40 -0
- package/dist/auth/oauth-callback.js +142 -0
- package/dist/auth/oauth-client.d.ts +55 -0
- package/dist/auth/oauth-client.js +134 -0
- package/dist/auth/oauth-fs.d.ts +1 -0
- package/dist/auth/oauth-fs.js +29 -0
- package/dist/auth/oauth-headless.d.ts +38 -0
- package/dist/auth/oauth-headless.js +50 -0
- package/dist/auth/oauth-storage.d.ts +51 -0
- package/dist/auth/oauth-storage.js +87 -0
- package/dist/auth/oauth-token.d.ts +70 -0
- package/dist/auth/oauth-token.js +141 -0
- package/dist/auth/token-resolver.d.ts +48 -0
- package/dist/auth/token-resolver.js +95 -0
- package/dist/commands/attachments.js +11 -12
- package/dist/commands/batch.js +15 -14
- package/dist/commands/comments.js +22 -10
- package/dist/commands/cycles.js +5 -4
- package/dist/commands/documents.js +18 -15
- package/dist/commands/embeds.js +4 -6
- package/dist/commands/graphql.js +5 -4
- package/dist/commands/init/aliases.js +0 -14
- package/dist/commands/init/defaults.js +0 -6
- package/dist/commands/init/index.js +22 -12
- package/dist/commands/init/oauth.d.ts +85 -0
- package/dist/commands/init/oauth.js +308 -0
- package/dist/commands/init/shared.js +0 -1
- package/dist/commands/init/token.js +0 -7
- package/dist/commands/init/workspace.js +0 -2
- package/dist/commands/issue-id.js +3 -1
- package/dist/commands/issues.js +87 -46
- package/dist/commands/labels.js +9 -8
- package/dist/commands/profile/migrate-legacy.d.ts +96 -0
- package/dist/commands/profile/migrate-legacy.js +271 -0
- package/dist/commands/profile.js +6 -0
- package/dist/commands/project-milestones.js +13 -12
- package/dist/commands/projects.js +18 -14
- package/dist/commands/read-shortcut.js +8 -7
- package/dist/commands/refs.js +4 -3
- package/dist/commands/releases.js +9 -8
- package/dist/commands/search.js +3 -2
- package/dist/commands/teams.js +3 -2
- package/dist/commands/templates.js +5 -4
- package/dist/commands/users.js +3 -2
- package/dist/config/config.d.ts +18 -0
- package/dist/config/issue-validation.js +1 -1
- package/dist/config/resolver.js +1 -1
- package/dist/main.js +1 -1
- package/dist/utils/auth.js +8 -0
- package/dist/utils/download-uploads.d.ts +2 -1
- package/dist/utils/download-uploads.js +2 -4
- package/dist/utils/file-service.d.ts +26 -2
- package/dist/utils/file-service.js +27 -5
- package/dist/utils/footer.d.ts +19 -0
- package/dist/utils/footer.js +27 -0
- package/dist/utils/gdoc-parser.js +1 -1
- package/dist/utils/graphql-attachments-service.d.ts +1 -1
- package/dist/utils/graphql-attachments-service.js +2 -2
- package/dist/utils/graphql-documents-service.d.ts +1 -1
- package/dist/utils/graphql-documents-service.js +2 -2
- package/dist/utils/graphql-service.d.ts +18 -3
- package/dist/utils/graphql-service.js +26 -11
- package/dist/utils/legacy-config-detection.d.ts +47 -0
- package/dist/utils/legacy-config-detection.js +90 -0
- package/dist/utils/linear-service.d.ts +18 -3
- package/dist/utils/linear-service.js +21 -6
- package/dist/utils/markdown-prosemirror.js +16 -9
- package/dist/utils/migration-hint.d.ts +46 -0
- package/dist/utils/migration-hint.js +90 -0
- package/dist/utils/root-opts.d.ts +11 -0
- package/dist/utils/root-opts.js +13 -0
- package/dist/utils/validators.d.ts +7 -0
- package/dist/utils/validators.js +14 -14
- package/package.json +1 -1
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-disk storage for OAuth tokens.
|
|
3
|
+
*
|
|
4
|
+
* Stored at `<active-profile-dir>/oauth.json` with mode 0600 — same security
|
|
5
|
+
* posture as the personal-token file. Schema is versioned (`v: 1`) so future
|
|
6
|
+
* changes can be migrated without silently corrupting older state.
|
|
7
|
+
*
|
|
8
|
+
* The personal-token file (`<profile-dir>/token`) and `oauth.json` are
|
|
9
|
+
* mutually-exclusive *by convention*, not by enforcement: if both exist the
|
|
10
|
+
* resolver prefers OAuth. We don't delete the personal token automatically
|
|
11
|
+
* during `init oauth` — operators sometimes keep both for fallback.
|
|
12
|
+
*/
|
|
13
|
+
import fs from "node:fs/promises";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { CONFIG_DIR, resolveActiveProfile } from "../config/paths.js";
|
|
16
|
+
import { atomicWrite } from "./oauth-fs.js";
|
|
17
|
+
export const OAUTH_STATE_VERSION = 1;
|
|
18
|
+
export const OAUTH_STATE_FILENAME = "oauth.json";
|
|
19
|
+
/**
|
|
20
|
+
* Resolve the path to the active profile's `oauth.json`. Mirrors
|
|
21
|
+
* `activePaths()` in `commands/init/shared.ts` so OAuth state lands in the
|
|
22
|
+
* same directory as the profile's `config.json` and `token`.
|
|
23
|
+
*/
|
|
24
|
+
export function oauthStatePath() {
|
|
25
|
+
const active = resolveActiveProfile();
|
|
26
|
+
return path.join(path.dirname(active.configPath), OAUTH_STATE_FILENAME);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Read the active profile's OAuth state, or `null` if none has been written.
|
|
30
|
+
* Returns `null` (not throw) on JSON parse errors so callers can fall back
|
|
31
|
+
* to personal-token auth without spamming users with repair instructions —
|
|
32
|
+
* the `init oauth` command is responsible for repair.
|
|
33
|
+
*/
|
|
34
|
+
export async function readOAuthState() {
|
|
35
|
+
try {
|
|
36
|
+
const raw = await fs.readFile(oauthStatePath(), "utf8");
|
|
37
|
+
const parsed = JSON.parse(raw);
|
|
38
|
+
if (parsed?.v !== OAUTH_STATE_VERSION)
|
|
39
|
+
return null;
|
|
40
|
+
if (typeof parsed.accessToken !== "string" || parsed.accessToken === "") {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
return parsed;
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
if (err.code === "ENOENT")
|
|
47
|
+
return null;
|
|
48
|
+
// Corrupt JSON or unreadable file — treat as "no state" so the
|
|
49
|
+
// resolver can fall through to personal-token auth.
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Write the active profile's OAuth state atomically with mode 0600.
|
|
55
|
+
*
|
|
56
|
+
* IMPORTANT: uses the same write-tmp + rename pattern as `writeToken` so a
|
|
57
|
+
* pre-existing 0644 file gets its mode reset. Tokens leaking via group/other
|
|
58
|
+
* read is the failure mode we want to make impossible.
|
|
59
|
+
*/
|
|
60
|
+
export async function writeOAuthState(state) {
|
|
61
|
+
const target = oauthStatePath();
|
|
62
|
+
// Ensure both the legacy CONFIG_DIR (where active-profile + profiles/
|
|
63
|
+
// live) and the active profile's directory exist before writing.
|
|
64
|
+
await fs.mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
65
|
+
await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
66
|
+
await atomicWrite(target, `${JSON.stringify(state, null, 2)}\n`, 0o600);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Delete the active profile's OAuth state. No-op if the file is already gone.
|
|
70
|
+
*/
|
|
71
|
+
export async function clearOAuthState() {
|
|
72
|
+
try {
|
|
73
|
+
await fs.unlink(oauthStatePath());
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
if (err.code !== "ENOENT")
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Return `true` when the access token is still valid for at least
|
|
82
|
+
* `skewMs` milliseconds. Default 60s skew protects against clock drift +
|
|
83
|
+
* network latency.
|
|
84
|
+
*/
|
|
85
|
+
export function isAccessTokenFresh(state, skewMs = 60_000) {
|
|
86
|
+
return Date.now() + skewMs < state.expiresAt;
|
|
87
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linear OAuth token endpoint calls: code exchange, refresh, and revoke.
|
|
3
|
+
*
|
|
4
|
+
* Linear's token endpoint accepts both `application/x-www-form-urlencoded`
|
|
5
|
+
* and JSON; we use form-encoded because that's what the docs show and what
|
|
6
|
+
* spec-compliant servers all support.
|
|
7
|
+
*
|
|
8
|
+
* We keep the dependency surface tiny — just `globalThis.fetch` (Node 22+
|
|
9
|
+
* has it native) plus our own tiny error envelope. The only thing the
|
|
10
|
+
* caller injects is the URL fetcher, so tests can mock without touching the
|
|
11
|
+
* network.
|
|
12
|
+
*/
|
|
13
|
+
import { type OAuthScope } from "./oauth-client.js";
|
|
14
|
+
/**
|
|
15
|
+
* Minimal subset of `globalThis.fetch` we use. Typing as the structural
|
|
16
|
+
* shape (instead of `typeof fetch`) avoids dragging in DOM lib types.
|
|
17
|
+
*/
|
|
18
|
+
export type FetchLike = (url: string, init: {
|
|
19
|
+
method: string;
|
|
20
|
+
headers: Record<string, string>;
|
|
21
|
+
body: string;
|
|
22
|
+
}) => Promise<{
|
|
23
|
+
ok: boolean;
|
|
24
|
+
status: number;
|
|
25
|
+
statusText: string;
|
|
26
|
+
text(): Promise<string>;
|
|
27
|
+
}>;
|
|
28
|
+
export interface ExchangeCodeInput {
|
|
29
|
+
clientId: string;
|
|
30
|
+
clientSecret?: string;
|
|
31
|
+
code: string;
|
|
32
|
+
redirectUri: string;
|
|
33
|
+
codeVerifier: string;
|
|
34
|
+
}
|
|
35
|
+
export interface RefreshTokensInput {
|
|
36
|
+
clientId: string;
|
|
37
|
+
clientSecret?: string;
|
|
38
|
+
refreshToken: string;
|
|
39
|
+
}
|
|
40
|
+
export interface RevokeTokenInput {
|
|
41
|
+
accessToken: string;
|
|
42
|
+
}
|
|
43
|
+
export interface ExchangeResult {
|
|
44
|
+
accessToken: string;
|
|
45
|
+
refreshToken?: string;
|
|
46
|
+
tokenType: string;
|
|
47
|
+
scopes: OAuthScope[];
|
|
48
|
+
/** Unix epoch milliseconds; computed from `expires_in`. */
|
|
49
|
+
expiresAt: number;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Exchange an authorization code for tokens. PKCE-aware: send the verifier;
|
|
53
|
+
* `client_secret` is optional (some Linear apps configured as native/public
|
|
54
|
+
* don't have one).
|
|
55
|
+
*/
|
|
56
|
+
export declare function exchangeCodeForTokens(input: ExchangeCodeInput, fetchImpl?: FetchLike, now?: () => number): Promise<ExchangeResult>;
|
|
57
|
+
/**
|
|
58
|
+
* Use a refresh token to get a new access token. Linear may rotate the
|
|
59
|
+
* refresh token, so we plumb both fields through.
|
|
60
|
+
*/
|
|
61
|
+
export declare function refreshTokens(input: RefreshTokensInput, fetchImpl?: FetchLike, now?: () => number): Promise<ExchangeResult>;
|
|
62
|
+
/**
|
|
63
|
+
* Revoke an access token. Best-effort — we don't throw on transport
|
|
64
|
+
* errors so callers can still clear local state.
|
|
65
|
+
*/
|
|
66
|
+
export declare function revokeToken(input: RevokeTokenInput, fetchImpl?: FetchLike): Promise<{
|
|
67
|
+
ok: boolean;
|
|
68
|
+
status: number;
|
|
69
|
+
message?: string;
|
|
70
|
+
}>;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linear OAuth token endpoint calls: code exchange, refresh, and revoke.
|
|
3
|
+
*
|
|
4
|
+
* Linear's token endpoint accepts both `application/x-www-form-urlencoded`
|
|
5
|
+
* and JSON; we use form-encoded because that's what the docs show and what
|
|
6
|
+
* spec-compliant servers all support.
|
|
7
|
+
*
|
|
8
|
+
* We keep the dependency surface tiny — just `globalThis.fetch` (Node 22+
|
|
9
|
+
* has it native) plus our own tiny error envelope. The only thing the
|
|
10
|
+
* caller injects is the URL fetcher, so tests can mock without touching the
|
|
11
|
+
* network.
|
|
12
|
+
*/
|
|
13
|
+
import { LINEAR_REVOKE_URL, LINEAR_TOKEN_URL, } from "./oauth-client.js";
|
|
14
|
+
const defaultFetch = async (url, init) => {
|
|
15
|
+
const res = await globalThis.fetch(url, init);
|
|
16
|
+
return {
|
|
17
|
+
ok: res.ok,
|
|
18
|
+
status: res.status,
|
|
19
|
+
statusText: res.statusText,
|
|
20
|
+
text: () => res.text(),
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
/** Common form-encoded POST helper. Throws an error with sanitized body on non-2xx. */
|
|
24
|
+
async function postForm(url, params, fetchImpl) {
|
|
25
|
+
const body = new URLSearchParams(params).toString();
|
|
26
|
+
let res;
|
|
27
|
+
try {
|
|
28
|
+
res = await fetchImpl(url, {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: {
|
|
31
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
32
|
+
accept: "application/json",
|
|
33
|
+
},
|
|
34
|
+
body,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
39
|
+
throw new Error(`Network error talking to ${url}: ${message}`);
|
|
40
|
+
}
|
|
41
|
+
const text = await res.text();
|
|
42
|
+
if (!res.ok) {
|
|
43
|
+
// Don't dump the request body — it contains client_secret /
|
|
44
|
+
// refresh_token / authorization code, all of which are secrets.
|
|
45
|
+
throw new Error(`OAuth endpoint ${url} responded ${res.status} ${res.statusText}: ${text || "(empty body)"}`);
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(text);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
throw new Error(`OAuth endpoint ${url} returned non-JSON response: ${text.slice(0, 200)}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function parseScopes(raw) {
|
|
55
|
+
if (!raw)
|
|
56
|
+
return [];
|
|
57
|
+
// Linear docs show scopes are returned comma-separated; some OAuth
|
|
58
|
+
// servers return space-separated. Accept either.
|
|
59
|
+
return raw
|
|
60
|
+
.split(/[,\s]+/)
|
|
61
|
+
.map((s) => s.trim())
|
|
62
|
+
.filter(Boolean);
|
|
63
|
+
}
|
|
64
|
+
function tokenResponseToResult(response, now) {
|
|
65
|
+
const expiresInMs = typeof response.expires_in === "number" && response.expires_in > 0
|
|
66
|
+
? response.expires_in * 1000
|
|
67
|
+
: // Linear docs say tokens last 24h. If the field is missing, fall
|
|
68
|
+
// back to 23h to leave a safety margin before forcing a refresh.
|
|
69
|
+
23 * 60 * 60 * 1000;
|
|
70
|
+
return {
|
|
71
|
+
accessToken: response.access_token,
|
|
72
|
+
refreshToken: response.refresh_token,
|
|
73
|
+
tokenType: response.token_type ?? "Bearer",
|
|
74
|
+
scopes: parseScopes(response.scope),
|
|
75
|
+
expiresAt: now + expiresInMs,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Exchange an authorization code for tokens. PKCE-aware: send the verifier;
|
|
80
|
+
* `client_secret` is optional (some Linear apps configured as native/public
|
|
81
|
+
* don't have one).
|
|
82
|
+
*/
|
|
83
|
+
export async function exchangeCodeForTokens(input, fetchImpl = defaultFetch, now = Date.now) {
|
|
84
|
+
const params = {
|
|
85
|
+
grant_type: "authorization_code",
|
|
86
|
+
code: input.code,
|
|
87
|
+
redirect_uri: input.redirectUri,
|
|
88
|
+
client_id: input.clientId,
|
|
89
|
+
code_verifier: input.codeVerifier,
|
|
90
|
+
};
|
|
91
|
+
if (input.clientSecret)
|
|
92
|
+
params.client_secret = input.clientSecret;
|
|
93
|
+
const res = await postForm(LINEAR_TOKEN_URL, params, fetchImpl);
|
|
94
|
+
if (typeof res.access_token !== "string" || res.access_token === "") {
|
|
95
|
+
throw new Error("OAuth response missing `access_token`.");
|
|
96
|
+
}
|
|
97
|
+
return tokenResponseToResult(res, now());
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Use a refresh token to get a new access token. Linear may rotate the
|
|
101
|
+
* refresh token, so we plumb both fields through.
|
|
102
|
+
*/
|
|
103
|
+
export async function refreshTokens(input, fetchImpl = defaultFetch, now = Date.now) {
|
|
104
|
+
const params = {
|
|
105
|
+
grant_type: "refresh_token",
|
|
106
|
+
refresh_token: input.refreshToken,
|
|
107
|
+
client_id: input.clientId,
|
|
108
|
+
};
|
|
109
|
+
if (input.clientSecret)
|
|
110
|
+
params.client_secret = input.clientSecret;
|
|
111
|
+
const res = await postForm(LINEAR_TOKEN_URL, params, fetchImpl);
|
|
112
|
+
if (typeof res.access_token !== "string" || res.access_token === "") {
|
|
113
|
+
throw new Error("OAuth refresh response missing `access_token`.");
|
|
114
|
+
}
|
|
115
|
+
return tokenResponseToResult(res, now());
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Revoke an access token. Best-effort — we don't throw on transport
|
|
119
|
+
* errors so callers can still clear local state.
|
|
120
|
+
*/
|
|
121
|
+
export async function revokeToken(input, fetchImpl = defaultFetch) {
|
|
122
|
+
try {
|
|
123
|
+
const res = await fetchImpl(LINEAR_REVOKE_URL, {
|
|
124
|
+
method: "POST",
|
|
125
|
+
headers: {
|
|
126
|
+
authorization: `Bearer ${input.accessToken}`,
|
|
127
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
128
|
+
},
|
|
129
|
+
body: "",
|
|
130
|
+
});
|
|
131
|
+
return {
|
|
132
|
+
ok: res.ok,
|
|
133
|
+
status: res.status,
|
|
134
|
+
message: res.ok ? undefined : await res.text(),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
139
|
+
return { ok: false, status: 0, message };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified resolver: returns the right credential for a CLI invocation,
|
|
3
|
+
* preferring OAuth state when present and auto-refreshing expired tokens.
|
|
4
|
+
*
|
|
5
|
+
* Resolution order (highest priority first):
|
|
6
|
+
* 1. `--api-token <token>` flag (always personal-style — sent without
|
|
7
|
+
* `Bearer `).
|
|
8
|
+
* 2. `LINEAR_API_TOKEN` env var (personal-style).
|
|
9
|
+
* 3. Profile OAuth state (`<profile-dir>/oauth.json`) — auto-refreshes
|
|
10
|
+
* when the access token has < 60s of validity left.
|
|
11
|
+
* 4. Profile personal token (`<profile-dir>/token`) and legacy fallbacks.
|
|
12
|
+
*
|
|
13
|
+
* The kind discriminant (`personal` vs `oauth`) tells `GraphQLService` which
|
|
14
|
+
* `Authorization` header shape to use:
|
|
15
|
+
* - personal: `Authorization: <token>` (no Bearer prefix)
|
|
16
|
+
* - oauth: `Authorization: Bearer <token>`
|
|
17
|
+
*/
|
|
18
|
+
import { type OAuthState } from "./oauth-storage.js";
|
|
19
|
+
import { type FetchLike } from "./oauth-token.js";
|
|
20
|
+
export interface ActiveAuth {
|
|
21
|
+
kind: "personal" | "oauth";
|
|
22
|
+
token: string;
|
|
23
|
+
/** Original OAuth state, when `kind === "oauth"`. */
|
|
24
|
+
oauth?: OAuthState;
|
|
25
|
+
}
|
|
26
|
+
export interface GetActiveAuthOptions {
|
|
27
|
+
apiToken?: string;
|
|
28
|
+
/** Test seam: override the network fetcher used by refresh. */
|
|
29
|
+
fetchImpl?: FetchLike;
|
|
30
|
+
/** Test seam: override the wall-clock for refresh expiry checks. */
|
|
31
|
+
now?: () => number;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Resolve the credential for this invocation.
|
|
35
|
+
*
|
|
36
|
+
* Synchronous personal-token paths still work via `getApiToken` for
|
|
37
|
+
* backwards compatibility — code that hasn't been migrated to OAuth-aware
|
|
38
|
+
* call sites keeps using `getApiToken` directly.
|
|
39
|
+
*/
|
|
40
|
+
export declare function getActiveAuth(options?: GetActiveAuthOptions): Promise<ActiveAuth>;
|
|
41
|
+
/**
|
|
42
|
+
* If the token is fresh, return state unchanged. Otherwise call
|
|
43
|
+
* `refreshTokens`, write the new state to disk, and return it.
|
|
44
|
+
*
|
|
45
|
+
* On refresh failure, throws an actionable error pointing at
|
|
46
|
+
* `el-linear init oauth`.
|
|
47
|
+
*/
|
|
48
|
+
export declare function ensureFreshAccessToken(state: OAuthState, options?: GetActiveAuthOptions): Promise<OAuthState>;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified resolver: returns the right credential for a CLI invocation,
|
|
3
|
+
* preferring OAuth state when present and auto-refreshing expired tokens.
|
|
4
|
+
*
|
|
5
|
+
* Resolution order (highest priority first):
|
|
6
|
+
* 1. `--api-token <token>` flag (always personal-style — sent without
|
|
7
|
+
* `Bearer `).
|
|
8
|
+
* 2. `LINEAR_API_TOKEN` env var (personal-style).
|
|
9
|
+
* 3. Profile OAuth state (`<profile-dir>/oauth.json`) — auto-refreshes
|
|
10
|
+
* when the access token has < 60s of validity left.
|
|
11
|
+
* 4. Profile personal token (`<profile-dir>/token`) and legacy fallbacks.
|
|
12
|
+
*
|
|
13
|
+
* The kind discriminant (`personal` vs `oauth`) tells `GraphQLService` which
|
|
14
|
+
* `Authorization` header shape to use:
|
|
15
|
+
* - personal: `Authorization: <token>` (no Bearer prefix)
|
|
16
|
+
* - oauth: `Authorization: Bearer <token>`
|
|
17
|
+
*/
|
|
18
|
+
import { getApiToken } from "../utils/auth.js";
|
|
19
|
+
import { isAccessTokenFresh, readOAuthState, writeOAuthState, } from "./oauth-storage.js";
|
|
20
|
+
import { refreshTokens, } from "./oauth-token.js";
|
|
21
|
+
/**
|
|
22
|
+
* Resolve the credential for this invocation.
|
|
23
|
+
*
|
|
24
|
+
* Synchronous personal-token paths still work via `getApiToken` for
|
|
25
|
+
* backwards compatibility — code that hasn't been migrated to OAuth-aware
|
|
26
|
+
* call sites keeps using `getApiToken` directly.
|
|
27
|
+
*/
|
|
28
|
+
export async function getActiveAuth(options = {}) {
|
|
29
|
+
// 1) Explicit override: `--api-token` always wins. Personal-style.
|
|
30
|
+
if (options.apiToken) {
|
|
31
|
+
return { kind: "personal", token: options.apiToken };
|
|
32
|
+
}
|
|
33
|
+
// 2) Env var: same precedence as the legacy resolver. Personal-style.
|
|
34
|
+
if (process.env.LINEAR_API_TOKEN) {
|
|
35
|
+
return { kind: "personal", token: process.env.LINEAR_API_TOKEN };
|
|
36
|
+
}
|
|
37
|
+
// 3) OAuth state for the active profile, auto-refreshed if needed.
|
|
38
|
+
const oauth = await readOAuthState();
|
|
39
|
+
if (oauth) {
|
|
40
|
+
const fresh = await ensureFreshAccessToken(oauth, options);
|
|
41
|
+
return { kind: "oauth", token: fresh.accessToken, oauth: fresh };
|
|
42
|
+
}
|
|
43
|
+
// 4) Fall back to the existing personal-token resolver (handles
|
|
44
|
+
// profile-aware token files, legacy paths, etc.). If no token can
|
|
45
|
+
// be found, this throws — and the message already mentions the
|
|
46
|
+
// profile, so we don't have to re-wrap.
|
|
47
|
+
return { kind: "personal", token: getApiToken({}) };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* If the token is fresh, return state unchanged. Otherwise call
|
|
51
|
+
* `refreshTokens`, write the new state to disk, and return it.
|
|
52
|
+
*
|
|
53
|
+
* On refresh failure, throws an actionable error pointing at
|
|
54
|
+
* `el-linear init oauth`.
|
|
55
|
+
*/
|
|
56
|
+
export async function ensureFreshAccessToken(state, options = {}) {
|
|
57
|
+
const now = options.now ?? Date.now;
|
|
58
|
+
if (isAccessTokenFresh(state, /* skewMs */ 60_000)) {
|
|
59
|
+
// `isAccessTokenFresh` reads `Date.now()` internally; for the
|
|
60
|
+
// purpose of the test seam we re-check against the injected clock.
|
|
61
|
+
if (now() + 60_000 < state.expiresAt) {
|
|
62
|
+
return state;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (!state.refreshToken) {
|
|
66
|
+
throw new Error("OAuth access token expired and no refresh token is stored. Re-run `el-linear init oauth`.");
|
|
67
|
+
}
|
|
68
|
+
let refreshed;
|
|
69
|
+
try {
|
|
70
|
+
refreshed = await refreshTokens({
|
|
71
|
+
clientId: state.clientId,
|
|
72
|
+
clientSecret: state.clientSecret,
|
|
73
|
+
refreshToken: state.refreshToken,
|
|
74
|
+
}, options.fetchImpl, now);
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
78
|
+
throw new Error(`OAuth refresh failed: ${message}. Re-run \`el-linear init oauth\` to re-authorize.`);
|
|
79
|
+
}
|
|
80
|
+
const next = {
|
|
81
|
+
...state,
|
|
82
|
+
accessToken: refreshed.accessToken,
|
|
83
|
+
// Preserve the previous refresh token if the server didn't rotate
|
|
84
|
+
// (some OAuth servers only return a new refresh_token periodically).
|
|
85
|
+
refreshToken: refreshed.refreshToken ?? state.refreshToken,
|
|
86
|
+
tokenType: refreshed.tokenType,
|
|
87
|
+
// Use the freshly-returned scopes only if non-empty; otherwise keep
|
|
88
|
+
// what we had, since some token endpoints omit `scope` on refresh.
|
|
89
|
+
scopes: refreshed.scopes.length > 0 ? refreshed.scopes : state.scopes,
|
|
90
|
+
expiresAt: refreshed.expiresAt,
|
|
91
|
+
obtainedAt: now(),
|
|
92
|
+
};
|
|
93
|
+
await writeOAuthState(next);
|
|
94
|
+
return next;
|
|
95
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { FileService } from "../utils/file-service.js";
|
|
1
|
+
import { createFileService } from "../utils/file-service.js";
|
|
3
2
|
import { createGraphQLAttachmentsService } from "../utils/graphql-attachments-service.js";
|
|
4
3
|
import { createLinearService } from "../utils/linear-service.js";
|
|
5
4
|
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
5
|
+
import { getRootOpts } from "../utils/root-opts.js";
|
|
6
6
|
export function setupAttachmentsCommands(program) {
|
|
7
7
|
const attachments = program
|
|
8
8
|
.command("attachments")
|
|
@@ -14,16 +14,15 @@ export function setupAttachmentsCommands(program) {
|
|
|
14
14
|
.requiredOption("--file <path>", "path to file to upload")
|
|
15
15
|
.option("--title <title>", "attachment title (defaults to filename)")
|
|
16
16
|
.action(handleAsyncCommand(async (issueId, options, command) => {
|
|
17
|
-
const rootOpts = command
|
|
18
|
-
const
|
|
19
|
-
const linearService = createLinearService(rootOpts);
|
|
17
|
+
const rootOpts = getRootOpts(command);
|
|
18
|
+
const linearService = await createLinearService(rootOpts);
|
|
20
19
|
const resolvedIssueId = await linearService.resolveIssueId(issueId);
|
|
21
|
-
const fileService =
|
|
20
|
+
const fileService = await createFileService(rootOpts);
|
|
22
21
|
const uploadResult = await fileService.uploadFile(options.file);
|
|
23
22
|
if (!uploadResult.success) {
|
|
24
23
|
throw new Error(uploadResult.error);
|
|
25
24
|
}
|
|
26
|
-
const attachmentsService = createGraphQLAttachmentsService(rootOpts);
|
|
25
|
+
const attachmentsService = await createGraphQLAttachmentsService(rootOpts);
|
|
27
26
|
const attachment = await attachmentsService.createAttachment({
|
|
28
27
|
issueId: resolvedIssueId,
|
|
29
28
|
url: uploadResult.assetUrl,
|
|
@@ -36,10 +35,10 @@ export function setupAttachmentsCommands(program) {
|
|
|
36
35
|
.description("List attachments on an issue.")
|
|
37
36
|
.option("-l, --limit <number>", "maximum number of attachments", "50")
|
|
38
37
|
.action(handleAsyncCommand(async (issueId, options, command) => {
|
|
39
|
-
const rootOpts = command
|
|
40
|
-
const linearService = createLinearService(rootOpts);
|
|
38
|
+
const rootOpts = getRootOpts(command);
|
|
39
|
+
const linearService = await createLinearService(rootOpts);
|
|
41
40
|
const resolvedIssueId = await linearService.resolveIssueId(issueId);
|
|
42
|
-
const attachmentsService = createGraphQLAttachmentsService(rootOpts);
|
|
41
|
+
const attachmentsService = await createGraphQLAttachmentsService(rootOpts);
|
|
43
42
|
const allAttachments = await attachmentsService.listAttachments(resolvedIssueId);
|
|
44
43
|
const limit = Number.parseInt(options.limit, 10);
|
|
45
44
|
const data = allAttachments.slice(0, limit);
|
|
@@ -49,8 +48,8 @@ export function setupAttachmentsCommands(program) {
|
|
|
49
48
|
.command("delete <attachmentId>")
|
|
50
49
|
.description("Delete an attachment.")
|
|
51
50
|
.action(handleAsyncCommand(async (attachmentId, _options, command) => {
|
|
52
|
-
const rootOpts = command
|
|
53
|
-
const attachmentsService = createGraphQLAttachmentsService(rootOpts);
|
|
51
|
+
const rootOpts = getRootOpts(command);
|
|
52
|
+
const attachmentsService = await createGraphQLAttachmentsService(rootOpts);
|
|
54
53
|
await attachmentsService.deleteAttachment(attachmentId);
|
|
55
54
|
outputSuccess({ success: true, message: "Attachment deleted" });
|
|
56
55
|
}));
|
package/dist/commands/batch.js
CHANGED
|
@@ -4,6 +4,7 @@ import { createGraphQLService } from "../utils/graphql-service.js";
|
|
|
4
4
|
import { createLinearService } from "../utils/linear-service.js";
|
|
5
5
|
import { logger } from "../utils/logger.js";
|
|
6
6
|
import { handleAsyncCommand, outputSuccess, outputWarning, } from "../utils/output.js";
|
|
7
|
+
import { getRootOpts } from "../utils/root-opts.js";
|
|
7
8
|
import { splitList } from "../utils/validators.js";
|
|
8
9
|
/**
|
|
9
10
|
* Parse the --filter string into structured search arguments.
|
|
@@ -29,8 +30,8 @@ function parseFilterString(filter) {
|
|
|
29
30
|
* Returns LinearIssue[] so we can show a preview.
|
|
30
31
|
*/
|
|
31
32
|
async function resolveTargetIssues(options, rootOpts) {
|
|
32
|
-
const graphQLService = createGraphQLService(rootOpts);
|
|
33
|
-
const linearService = createLinearService(rootOpts);
|
|
33
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
34
|
+
const linearService = await createLinearService(rootOpts);
|
|
34
35
|
const issuesService = new GraphQLIssuesService(graphQLService, linearService);
|
|
35
36
|
if (options.issues) {
|
|
36
37
|
const ids = splitList(options.issues);
|
|
@@ -95,7 +96,7 @@ async function executeBatch(issues, operation) {
|
|
|
95
96
|
return { results };
|
|
96
97
|
}
|
|
97
98
|
async function handleBatchAssign(options, command) {
|
|
98
|
-
const rootOpts = command
|
|
99
|
+
const rootOpts = getRootOpts(command);
|
|
99
100
|
if (!options.assignee) {
|
|
100
101
|
throw new Error("--assignee is required.");
|
|
101
102
|
}
|
|
@@ -116,8 +117,8 @@ async function handleBatchAssign(options, command) {
|
|
|
116
117
|
});
|
|
117
118
|
return;
|
|
118
119
|
}
|
|
119
|
-
const graphQLService = createGraphQLService(rootOpts);
|
|
120
|
-
const linearService = createLinearService(rootOpts);
|
|
120
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
121
|
+
const linearService = await createLinearService(rootOpts);
|
|
121
122
|
const issuesService = new GraphQLIssuesService(graphQLService, linearService);
|
|
122
123
|
const { results } = await executeBatch(issues, (issue) => issuesService.updateIssue({ id: issue.identifier, assigneeId }, "adding"));
|
|
123
124
|
outputSuccess({
|
|
@@ -132,7 +133,7 @@ async function handleBatchAssign(options, command) {
|
|
|
132
133
|
});
|
|
133
134
|
}
|
|
134
135
|
async function handleBatchLabel(options, command) {
|
|
135
|
-
const rootOpts = command
|
|
136
|
+
const rootOpts = getRootOpts(command);
|
|
136
137
|
if (!(options.add || options.remove)) {
|
|
137
138
|
throw new Error("Specify --add and/or --remove for labels.");
|
|
138
139
|
}
|
|
@@ -163,8 +164,8 @@ async function handleBatchLabel(options, command) {
|
|
|
163
164
|
});
|
|
164
165
|
return;
|
|
165
166
|
}
|
|
166
|
-
const graphQLService = createGraphQLService(rootOpts);
|
|
167
|
-
const linearService = createLinearService(rootOpts);
|
|
167
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
168
|
+
const linearService = await createLinearService(rootOpts);
|
|
168
169
|
const issuesService = new GraphQLIssuesService(graphQLService, linearService);
|
|
169
170
|
const removeLower = removeLabels.map((l) => l.toLowerCase());
|
|
170
171
|
const { results } = await executeBatch(issues, (issue) => {
|
|
@@ -197,7 +198,7 @@ async function handleBatchLabel(options, command) {
|
|
|
197
198
|
});
|
|
198
199
|
}
|
|
199
200
|
async function handleBatchMove(options, command) {
|
|
200
|
-
const rootOpts = command
|
|
201
|
+
const rootOpts = getRootOpts(command);
|
|
201
202
|
if (!options.project) {
|
|
202
203
|
throw new Error("--project is required.");
|
|
203
204
|
}
|
|
@@ -217,8 +218,8 @@ async function handleBatchMove(options, command) {
|
|
|
217
218
|
});
|
|
218
219
|
return;
|
|
219
220
|
}
|
|
220
|
-
const graphQLService = createGraphQLService(rootOpts);
|
|
221
|
-
const linearService = createLinearService(rootOpts);
|
|
221
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
222
|
+
const linearService = await createLinearService(rootOpts);
|
|
222
223
|
const issuesService = new GraphQLIssuesService(graphQLService, linearService);
|
|
223
224
|
const { results } = await executeBatch(issues, (issue) => issuesService.updateIssue({ id: issue.identifier, projectId: options.project }, "adding"));
|
|
224
225
|
outputSuccess({
|
|
@@ -233,7 +234,7 @@ async function handleBatchMove(options, command) {
|
|
|
233
234
|
});
|
|
234
235
|
}
|
|
235
236
|
async function handleBatchStatus(options, command) {
|
|
236
|
-
const rootOpts = command
|
|
237
|
+
const rootOpts = getRootOpts(command);
|
|
237
238
|
if (!options.status) {
|
|
238
239
|
throw new Error("--status is required.");
|
|
239
240
|
}
|
|
@@ -253,8 +254,8 @@ async function handleBatchStatus(options, command) {
|
|
|
253
254
|
});
|
|
254
255
|
return;
|
|
255
256
|
}
|
|
256
|
-
const graphQLService = createGraphQLService(rootOpts);
|
|
257
|
-
const linearService = createLinearService(rootOpts);
|
|
257
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
258
|
+
const linearService = await createLinearService(rootOpts);
|
|
258
259
|
const issuesService = new GraphQLIssuesService(graphQLService, linearService);
|
|
259
260
|
const { results } = await executeBatch(issues, (issue) => issuesService.updateIssue({ id: issue.identifier, statusId: options.status }, "adding"));
|
|
260
261
|
outputSuccess({
|
|
@@ -2,12 +2,14 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import { resolveUserDisplayName } from "../config/resolver.js";
|
|
3
3
|
import { CREATE_COMMENT_MUTATION, LIST_COMMENTS_QUERY, UPDATE_COMMENT_MUTATION, } from "../queries/comments.js";
|
|
4
4
|
import { autoLinkReferences, } from "../utils/auto-link-references.js";
|
|
5
|
+
import { applyFooter } from "../utils/footer.js";
|
|
5
6
|
import { createGraphQLService, } from "../utils/graphql-service.js";
|
|
6
7
|
import { extractIssueReferences } from "../utils/issue-reference-extractor.js";
|
|
7
8
|
import { wrapIssueReferencesAsLinks } from "../utils/issue-reference-wrapper.js";
|
|
8
9
|
import { createLinearService, } from "../utils/linear-service.js";
|
|
9
10
|
import { resolveMentions } from "../utils/mention-resolver.js";
|
|
10
11
|
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
12
|
+
import { getRootOpts } from "../utils/root-opts.js";
|
|
11
13
|
import { validateReferences } from "../utils/validate-references.js";
|
|
12
14
|
import { getWorkspaceUrlKey } from "../utils/workspace-url.js";
|
|
13
15
|
// Match Linear's bodyData validation error in multiple phrasings so a wording
|
|
@@ -98,10 +100,18 @@ async function autoLinkCommentReferences(args) {
|
|
|
98
100
|
return result;
|
|
99
101
|
}
|
|
100
102
|
async function handleCreateComment(issueId, options, command) {
|
|
101
|
-
const rootOpts = command
|
|
102
|
-
const graphQLService = createGraphQLService(rootOpts);
|
|
103
|
-
const linearService = createLinearService(rootOpts);
|
|
104
|
-
|
|
103
|
+
const rootOpts = getRootOpts(command);
|
|
104
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
105
|
+
const linearService = await createLinearService(rootOpts);
|
|
106
|
+
// Apply messageFooter (config or --footer flag) before any further
|
|
107
|
+
// processing, so auto-link / mention-resolution see it as part of the body.
|
|
108
|
+
// Commander parses --no-footer as `options.footer === false`.
|
|
109
|
+
const noFooter = options.footer === false;
|
|
110
|
+
const explicitFooter = typeof options.footer === "string" ? options.footer : undefined;
|
|
111
|
+
const rawBody = applyFooter(readBody(options), {
|
|
112
|
+
footer: explicitFooter,
|
|
113
|
+
noFooter,
|
|
114
|
+
}) ?? "";
|
|
105
115
|
const resolvedIssueId = await linearService.resolveIssueId(issueId);
|
|
106
116
|
// Wrap valid refs as markdown links before mention resolution. Idempotent against
|
|
107
117
|
// already-wrapped refs.
|
|
@@ -171,9 +181,9 @@ async function handleCreateComment(issueId, options, command) {
|
|
|
171
181
|
outputSuccess(autoLinked ? { ...output, autoLinked } : output);
|
|
172
182
|
}
|
|
173
183
|
async function handleUpdateComment(commentId, options, command) {
|
|
174
|
-
const rootOpts = command
|
|
175
|
-
const graphQLService = createGraphQLService(rootOpts);
|
|
176
|
-
const linearService = createLinearService(rootOpts);
|
|
184
|
+
const rootOpts = getRootOpts(command);
|
|
185
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
186
|
+
const linearService = await createLinearService(rootOpts);
|
|
177
187
|
const rawBody = readBody(options);
|
|
178
188
|
const { body, preResolved } = await prepareCommentBodyWithLinks(rawBody, options, linearService, graphQLService);
|
|
179
189
|
const autoMention = options.autoMention !== false;
|
|
@@ -218,9 +228,9 @@ async function handleUpdateComment(commentId, options, command) {
|
|
|
218
228
|
outputSuccess(autoLinked ? { ...output, autoLinked } : output);
|
|
219
229
|
}
|
|
220
230
|
async function handleListComments(issueId, options, command) {
|
|
221
|
-
const rootOpts = command
|
|
222
|
-
const graphQLService = createGraphQLService(rootOpts);
|
|
223
|
-
const linearService = createLinearService(rootOpts);
|
|
231
|
+
const rootOpts = getRootOpts(command);
|
|
232
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
233
|
+
const linearService = await createLinearService(rootOpts);
|
|
224
234
|
const resolvedId = await linearService.resolveIssueId(issueId);
|
|
225
235
|
const result = await graphQLService.rawRequest(LIST_COMMENTS_QUERY, {
|
|
226
236
|
issueId: resolvedId,
|
|
@@ -254,6 +264,8 @@ export function setupCommentsCommands(program) {
|
|
|
254
264
|
.option("--file <path>", "read comment body from file")
|
|
255
265
|
.option("--no-auto-mention", "do not auto-convert bare team-member names to @mentions")
|
|
256
266
|
.option("--no-auto-link", "skip wrapping issue refs as markdown links and creating sidebar relations")
|
|
267
|
+
.option("--footer <text>", "text appended to the comment body (overrides config.messageFooter)")
|
|
268
|
+
.option("--no-footer", "skip the configured messageFooter for this comment")
|
|
257
269
|
.action(handleAsyncCommand(handleCreateComment));
|
|
258
270
|
comments
|
|
259
271
|
.command("update <commentId>")
|