@enrichlayer/el-linear 1.6.0 → 1.9.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 +101 -5
- package/claude-skills/linear-operations/SKILL.md +55 -0
- package/dist/auth/oauth-app-config.d.ts +21 -0
- package/dist/auth/oauth-app-config.js +82 -0
- package/dist/auth/oauth-callback.js +14 -7
- package/dist/auth/oauth-fs.d.ts +22 -0
- package/dist/auth/oauth-fs.js +77 -6
- package/dist/auth/oauth-headless.d.ts +13 -8
- package/dist/auth/oauth-headless.js +18 -11
- package/dist/auth/oauth-storage.d.ts +11 -3
- package/dist/auth/oauth-storage.js +15 -8
- package/dist/auth/token-resolver.d.ts +9 -0
- package/dist/auth/token-resolver.js +57 -37
- package/dist/commands/init/defaults.d.ts +18 -1
- package/dist/commands/init/defaults.js +83 -2
- package/dist/commands/init/index.js +9 -1
- package/dist/commands/init/oauth.d.ts +10 -4
- package/dist/commands/init/oauth.js +32 -8
- package/dist/commands/init/token.d.ts +0 -8
- package/dist/commands/init/token.js +13 -1
- package/dist/commands/issues/branch.d.ts +17 -0
- package/dist/commands/issues/branch.js +40 -0
- package/dist/commands/issues/description.d.ts +89 -0
- package/dist/commands/issues/description.js +187 -0
- package/dist/commands/issues.js +76 -222
- package/dist/commands/labels.js +17 -2
- package/dist/commands/profile/migrate-legacy.js +10 -33
- package/dist/commands/profile.js +1 -10
- package/dist/commands/projects.d.ts +5 -1
- package/dist/commands/projects.js +144 -64
- package/dist/commands/read-shortcut.d.ts +6 -0
- package/dist/commands/read-shortcut.js +6 -1
- package/dist/commands/refs.js +2 -1
- package/dist/commands/teams.js +12 -2
- package/dist/commands/templates.js +127 -1
- package/dist/commands/users.js +2 -1
- package/dist/config/config.d.ts +19 -0
- package/dist/config/config.js +31 -9
- package/dist/config/issue-validation.js +1 -1
- package/dist/config/paths.d.ts +12 -0
- package/dist/config/paths.js +45 -6
- package/dist/config/term-enforcer.js +1 -1
- package/dist/main.js +39 -3
- package/dist/queries/templates.d.ts +3 -0
- package/dist/queries/templates.js +43 -0
- package/dist/utils/auth.js +7 -9
- package/dist/utils/auto-link-references.js +15 -1
- package/dist/utils/disk-cache.d.ts +51 -0
- package/dist/utils/disk-cache.js +179 -0
- package/dist/utils/formatters/summary.d.ts +62 -0
- package/dist/utils/formatters/summary.js +755 -0
- package/dist/utils/graphql-issues-service.d.ts +106 -3
- package/dist/utils/graphql-issues-service.js +51 -37
- package/dist/utils/issue-reference-extractor.d.ts +9 -1
- package/dist/utils/issue-reference-extractor.js +16 -9
- package/dist/utils/issue-reference-wrapper.js +1 -54
- package/dist/utils/linear-service.d.ts +7 -3
- package/dist/utils/linear-service.js +27 -5
- package/dist/utils/markdown-prosemirror.js +17 -1
- package/dist/utils/mention-resolver.js +17 -5
- package/dist/utils/output.d.ts +5 -1
- package/dist/utils/output.js +28 -1
- package/dist/utils/protected-ranges.d.ts +33 -0
- package/dist/utils/protected-ranges.js +73 -0
- package/dist/utils/table-formatter.d.ts +36 -0
- package/dist/utils/table-formatter.js +46 -24
- package/dist/utils/validators.d.ts +9 -1
- package/dist/utils/validators.js +10 -0
- package/package.json +1 -1
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
* - oauth: `Authorization: Bearer <token>`
|
|
17
17
|
*/
|
|
18
18
|
import { getApiToken } from "../utils/auth.js";
|
|
19
|
-
import {
|
|
19
|
+
import { withFileLock } from "./oauth-fs.js";
|
|
20
|
+
import { oauthStatePath, readOAuthState, writeOAuthState, } from "./oauth-storage.js";
|
|
20
21
|
import { refreshTokens, } from "./oauth-token.js";
|
|
21
22
|
/**
|
|
22
23
|
* Resolve the credential for this invocation.
|
|
@@ -52,44 +53,63 @@ export async function getActiveAuth(options = {}) {
|
|
|
52
53
|
*
|
|
53
54
|
* On refresh failure, throws an actionable error pointing at
|
|
54
55
|
* `el-linear init oauth`.
|
|
56
|
+
*
|
|
57
|
+
* **Concurrency.** When a refresh is needed, this acquires an exclusive
|
|
58
|
+
* file lock on the oauth.json sidecar before reading-refreshing-writing.
|
|
59
|
+
* Two parallel CLI invocations would otherwise both call `refreshTokens`
|
|
60
|
+
* with the same refresh token; Linear's server invalidates the loser's
|
|
61
|
+
* stored token and the next refresh permanently fails. The lock
|
|
62
|
+
* serialises them — the second process re-reads the freshly-written
|
|
63
|
+
* state inside the lock and uses the winner's tokens instead of issuing
|
|
64
|
+
* a second refresh.
|
|
55
65
|
*/
|
|
56
66
|
export async function ensureFreshAccessToken(state, options = {}) {
|
|
57
67
|
const now = options.now ?? Date.now;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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.`);
|
|
68
|
+
// Fast path: token is fresh; no lock, no refresh.
|
|
69
|
+
if (now() + 60_000 < state.expiresAt) {
|
|
70
|
+
return state;
|
|
79
71
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
72
|
+
// Snapshot the target path ONCE so a profile switch between the
|
|
73
|
+
// lock acquisition and the write inside the closure can't cause
|
|
74
|
+
// cross-profile contamination. The lock + read + write all bind
|
|
75
|
+
// to this path. ALL-935 deferred fix.
|
|
76
|
+
const targetPath = oauthStatePath();
|
|
77
|
+
return withFileLock(targetPath, async () => {
|
|
78
|
+
// Re-read inside the lock — another process may have refreshed
|
|
79
|
+
// while we were waiting. If so, use their result.
|
|
80
|
+
const current = (await readOAuthState(targetPath)) ?? state;
|
|
81
|
+
if (now() + 60_000 < current.expiresAt) {
|
|
82
|
+
return current;
|
|
83
|
+
}
|
|
84
|
+
if (!current.refreshToken) {
|
|
85
|
+
throw new Error("OAuth access token expired and no refresh token is stored. Re-run `el-linear init oauth`.");
|
|
86
|
+
}
|
|
87
|
+
let refreshed;
|
|
88
|
+
try {
|
|
89
|
+
refreshed = await refreshTokens({
|
|
90
|
+
clientId: current.clientId,
|
|
91
|
+
clientSecret: current.clientSecret,
|
|
92
|
+
refreshToken: current.refreshToken,
|
|
93
|
+
}, options.fetchImpl, now);
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
97
|
+
throw new Error(`OAuth refresh failed: ${message}. Re-run \`el-linear init oauth\` to re-authorize.`);
|
|
98
|
+
}
|
|
99
|
+
const next = {
|
|
100
|
+
...current,
|
|
101
|
+
accessToken: refreshed.accessToken,
|
|
102
|
+
// Preserve the previous refresh token if the server didn't rotate
|
|
103
|
+
// (some OAuth servers only return a new refresh_token periodically).
|
|
104
|
+
refreshToken: refreshed.refreshToken ?? current.refreshToken,
|
|
105
|
+
tokenType: refreshed.tokenType,
|
|
106
|
+
// Use the freshly-returned scopes only if non-empty; otherwise
|
|
107
|
+
// keep what we had — some token endpoints omit `scope` on refresh.
|
|
108
|
+
scopes: refreshed.scopes.length > 0 ? refreshed.scopes : current.scopes,
|
|
109
|
+
expiresAt: refreshed.expiresAt,
|
|
110
|
+
obtainedAt: now(),
|
|
111
|
+
};
|
|
112
|
+
await writeOAuthState(next, targetPath);
|
|
113
|
+
return next;
|
|
114
|
+
});
|
|
95
115
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Step 4 of the wizard: default labels,
|
|
2
|
+
* Step 4 of the wizard: default labels, default assignee, default priority,
|
|
3
|
+
* status defaults, term enforcement, cache TTL.
|
|
3
4
|
*
|
|
4
5
|
* All optional. Each subsection asks "change?" with default=N so re-running
|
|
5
6
|
* with no input is a no-op.
|
|
@@ -7,6 +8,19 @@
|
|
|
7
8
|
import { type WizardConfig } from "./shared.js";
|
|
8
9
|
export interface DefaultsStepResult {
|
|
9
10
|
defaultLabels: string[] | undefined;
|
|
11
|
+
/**
|
|
12
|
+
* Default assignee identifier (alias / display name / email / UUID — same
|
|
13
|
+
* shapes resolveAssignee accepts) for `issues create`. The wizard does NOT
|
|
14
|
+
* validate this against Linear (no API call) — the runtime resolver handles
|
|
15
|
+
* validation at issue-create time, keeping the wizard offline.
|
|
16
|
+
*/
|
|
17
|
+
defaultAssignee: string | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* Default priority keyword: `none|urgent|high|medium|normal|low`. Stored
|
|
20
|
+
* as-is; the runtime path runs it through validatePriority() to get the
|
|
21
|
+
* Linear priority number.
|
|
22
|
+
*/
|
|
23
|
+
defaultPriority: string | undefined;
|
|
10
24
|
/**
|
|
11
25
|
* Status defaults. Both fields are optional so the wizard preserves
|
|
12
26
|
* partial existing configs (`{ noProject: "Backlog" }` only) byte-for-byte
|
|
@@ -21,5 +35,8 @@ export interface DefaultsStepResult {
|
|
|
21
35
|
canonical: string;
|
|
22
36
|
reject: string[];
|
|
23
37
|
}> | undefined;
|
|
38
|
+
/** TTL (seconds) for `teams list` / `labels list` / `projects list` disk
|
|
39
|
+
* cache. `0` disables. */
|
|
40
|
+
cacheTTLSeconds: number | undefined;
|
|
24
41
|
}
|
|
25
42
|
export declare function runDefaultsStep(existing: WizardConfig): Promise<DefaultsStepResult>;
|
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Step 4 of the wizard: default labels,
|
|
2
|
+
* Step 4 of the wizard: default labels, default assignee, default priority,
|
|
3
|
+
* status defaults, term enforcement, cache TTL.
|
|
3
4
|
*
|
|
4
5
|
* All optional. Each subsection asks "change?" with default=N so re-running
|
|
5
6
|
* with no input is a no-op.
|
|
6
7
|
*/
|
|
7
|
-
import { confirm, input } from "@inquirer/prompts";
|
|
8
|
+
import { confirm, input, select } from "@inquirer/prompts";
|
|
8
9
|
import { parseCsvList } from "./shared.js";
|
|
9
10
|
const STATUS_FALLBACK = { noProject: "Triage", withAssigneeAndProject: "Todo" };
|
|
11
|
+
const CACHE_TTL_FALLBACK = 3600;
|
|
12
|
+
const PRIORITY_CHOICES = [
|
|
13
|
+
{ name: "none", value: "none" },
|
|
14
|
+
{ name: "urgent", value: "urgent" },
|
|
15
|
+
{ name: "high", value: "high" },
|
|
16
|
+
{ name: "medium", value: "medium" },
|
|
17
|
+
{ name: "normal", value: "normal" },
|
|
18
|
+
{ name: "low", value: "low" },
|
|
19
|
+
];
|
|
10
20
|
export async function runDefaultsStep(existing) {
|
|
11
21
|
// Idempotency rule: when the user skips a sub-section, return the existing
|
|
12
22
|
// value byte-for-byte. We deliberately do NOT spread or backfill optional
|
|
@@ -14,8 +24,11 @@ export async function runDefaultsStep(existing) {
|
|
|
14
24
|
// with no input produces a byte-identical config.
|
|
15
25
|
const result = {
|
|
16
26
|
defaultLabels: existing.defaultLabels,
|
|
27
|
+
defaultAssignee: existing.defaultAssignee,
|
|
28
|
+
defaultPriority: existing.defaultPriority,
|
|
17
29
|
statusDefaults: existing.statusDefaults,
|
|
18
30
|
terms: existing.terms,
|
|
31
|
+
cacheTTLSeconds: existing.cacheTTLSeconds,
|
|
19
32
|
};
|
|
20
33
|
// ── Default labels ────────────────────────────────────────────────
|
|
21
34
|
const currentLabels = existing.defaultLabels ?? [];
|
|
@@ -32,6 +45,45 @@ export async function runDefaultsStep(existing) {
|
|
|
32
45
|
const parsed = parseCsvList(raw);
|
|
33
46
|
result.defaultLabels = parsed.length > 0 ? parsed : undefined;
|
|
34
47
|
}
|
|
48
|
+
// ── Default assignee ──────────────────────────────────────────────
|
|
49
|
+
console.log(` Current default assignee: ${existing.defaultAssignee ?? "(none)"}`);
|
|
50
|
+
const editAssignee = await confirm({
|
|
51
|
+
message: "Change default assignee for new issues?",
|
|
52
|
+
default: false,
|
|
53
|
+
});
|
|
54
|
+
if (editAssignee) {
|
|
55
|
+
// We deliberately don't validate against Linear here — the wizard runs
|
|
56
|
+
// offline-friendly. The runtime resolver (resolveAssignee) catches
|
|
57
|
+
// typos at issue-create time. "none" is the explicit-clear sentinel
|
|
58
|
+
// so users have an unambiguous way to wipe the field.
|
|
59
|
+
const raw = (await input({
|
|
60
|
+
message: "Default assignee (alias, name, email, or 'none' to clear):",
|
|
61
|
+
default: existing.defaultAssignee ?? "",
|
|
62
|
+
})).trim();
|
|
63
|
+
if (!raw || raw.toLowerCase() === "none") {
|
|
64
|
+
result.defaultAssignee = undefined;
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
result.defaultAssignee = raw;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// ── Default priority ──────────────────────────────────────────────
|
|
71
|
+
console.log(` Current default priority: ${existing.defaultPriority ?? "(none)"}`);
|
|
72
|
+
const editPriority = await confirm({
|
|
73
|
+
message: "Change default priority for new issues?",
|
|
74
|
+
default: false,
|
|
75
|
+
});
|
|
76
|
+
if (editPriority) {
|
|
77
|
+
// `select` always picks one option — so picking "none" stores the
|
|
78
|
+
// keyword string "none" (Linear's "No priority"), distinct from
|
|
79
|
+
// `undefined` which means "no default at all".
|
|
80
|
+
const choice = await select({
|
|
81
|
+
message: "Default priority:",
|
|
82
|
+
choices: PRIORITY_CHOICES,
|
|
83
|
+
default: existing.defaultPriority ?? "none",
|
|
84
|
+
});
|
|
85
|
+
result.defaultPriority = choice;
|
|
86
|
+
}
|
|
35
87
|
// ── Status defaults ────────────────────────────────────────────────
|
|
36
88
|
const cur = existing.statusDefaults;
|
|
37
89
|
console.log(` Current status defaults: noProject=${cur?.noProject ?? STATUS_FALLBACK.noProject}, ` +
|
|
@@ -54,6 +106,35 @@ export async function runDefaultsStep(existing) {
|
|
|
54
106
|
withAssigneeAndProject: withAP.trim(),
|
|
55
107
|
};
|
|
56
108
|
}
|
|
109
|
+
// ── Cache TTL ─────────────────────────────────────────────────────
|
|
110
|
+
const currentTTL = existing.cacheTTLSeconds ?? CACHE_TTL_FALLBACK;
|
|
111
|
+
console.log(` Current cache TTL: ${currentTTL}s`);
|
|
112
|
+
const editTTL = await confirm({
|
|
113
|
+
message: "Change cache TTL?",
|
|
114
|
+
default: false,
|
|
115
|
+
});
|
|
116
|
+
if (editTTL) {
|
|
117
|
+
const raw = await input({
|
|
118
|
+
message: "Cache TTL in seconds (0 to disable):",
|
|
119
|
+
default: String(currentTTL),
|
|
120
|
+
// `validate` runs per submit; we reject anything that isn't a
|
|
121
|
+
// non-negative integer literal. Number("") is `0` (truthy by
|
|
122
|
+
// the integer test) so we explicitly require non-empty input —
|
|
123
|
+
// otherwise an empty submission would silently store 0.
|
|
124
|
+
validate: (value) => {
|
|
125
|
+
const trimmed = value.trim();
|
|
126
|
+
if (trimmed === "") {
|
|
127
|
+
return "Enter a non-negative integer (e.g. 3600 for 1 hour, 0 to disable).";
|
|
128
|
+
}
|
|
129
|
+
const n = Number(trimmed);
|
|
130
|
+
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
|
|
131
|
+
return "Enter a non-negative integer (e.g. 3600 for 1 hour, 0 to disable).";
|
|
132
|
+
}
|
|
133
|
+
return true;
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
result.cacheTTLSeconds = Number(raw);
|
|
137
|
+
}
|
|
57
138
|
// ── Term enforcement ───────────────────────────────────────────────
|
|
58
139
|
const currentTerms = existing.terms ?? [];
|
|
59
140
|
console.log(` Current term-enforcement rules: ${currentTerms.length}`);
|
|
@@ -63,6 +63,7 @@ export function setupInitCommands(program) {
|
|
|
63
63
|
.option("--revoke", "revoke and remove the stored OAuth tokens")
|
|
64
64
|
.option("--no-browser", "skip the browser-open + localhost listener; paste the code manually")
|
|
65
65
|
.option("--port <port>", "localhost callback port (default 8765)", (value) => Number.parseInt(value, 10))
|
|
66
|
+
.option("--unsafe-bare-code", "allow pasting a bare authorization code in the headless flow (skips the OAuth `state` CSRF check; opt-in only)")
|
|
66
67
|
.action(withCleanExit(async (options) => {
|
|
67
68
|
printStep("oauth", "Linear OAuth (PKCE)");
|
|
68
69
|
if (options.revoke) {
|
|
@@ -75,6 +76,7 @@ export function setupInitCommands(program) {
|
|
|
75
76
|
// commander's `--no-browser` produces `browser: false`.
|
|
76
77
|
noBrowser: options.browser === false,
|
|
77
78
|
port: options.port,
|
|
79
|
+
unsafeBareCode: options.unsafeBareCode === true,
|
|
78
80
|
});
|
|
79
81
|
}));
|
|
80
82
|
init
|
|
@@ -127,15 +129,18 @@ export function setupInitCommands(program) {
|
|
|
127
129
|
}));
|
|
128
130
|
init
|
|
129
131
|
.command("defaults")
|
|
130
|
-
.description("Default labels, status defaults, term enforcement
|
|
132
|
+
.description("Default labels, default assignee, default priority, status defaults, cache TTL, term enforcement")
|
|
131
133
|
.action(withCleanExit(async () => {
|
|
132
134
|
const existing = await readConfig();
|
|
133
135
|
printStep("defaults", "Defaults");
|
|
134
136
|
const result = await runDefaultsStep(existing);
|
|
135
137
|
const merged = assignDefined(existing, {
|
|
136
138
|
defaultLabels: result.defaultLabels,
|
|
139
|
+
defaultAssignee: result.defaultAssignee,
|
|
140
|
+
defaultPriority: result.defaultPriority,
|
|
137
141
|
statusDefaults: result.statusDefaults,
|
|
138
142
|
terms: result.terms,
|
|
143
|
+
cacheTTLSeconds: result.cacheTTLSeconds,
|
|
139
144
|
});
|
|
140
145
|
await writeConfig(merged);
|
|
141
146
|
console.log(" ✓ Defaults saved.");
|
|
@@ -175,8 +180,11 @@ async function runFullWizardImpl(options) {
|
|
|
175
180
|
// defaults step: result is `existing.X` itself when the user skipped
|
|
176
181
|
// the edit branch, so direct assignment is safe.
|
|
177
182
|
defaultLabels: defaults.defaultLabels,
|
|
183
|
+
defaultAssignee: defaults.defaultAssignee,
|
|
184
|
+
defaultPriority: defaults.defaultPriority,
|
|
178
185
|
statusDefaults: defaults.statusDefaults,
|
|
179
186
|
terms: defaults.terms,
|
|
187
|
+
cacheTTLSeconds: defaults.cacheTTLSeconds,
|
|
180
188
|
// workspace step: `ws.defaultTeam` may be the existing value (user
|
|
181
189
|
// skipped) or a new pick.
|
|
182
190
|
defaultTeam: ws.defaultTeam,
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
* Wizard step for OAuth 2.0 (PKCE flow) authorization.
|
|
3
3
|
*
|
|
4
4
|
* Flow:
|
|
5
|
-
* 1.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* 1. Read optional local/team OAuth app defaults, if present.
|
|
6
|
+
* 2. Otherwise present a "what is this?" intro pointing the user at
|
|
7
|
+
* Linear's OAuth app registration page, then prompt for `client_id`,
|
|
8
|
+
* optional `client_secret`, port, scopes.
|
|
9
9
|
* 3. Generate PKCE verifier + state, build the authorize URL.
|
|
10
10
|
* 4. Try to open the system browser; fall back to printing the URL.
|
|
11
11
|
* 5. Spin a localhost listener (or fall back to pasted-code prompt) to
|
|
@@ -39,6 +39,12 @@ export interface OAuthStepOptions {
|
|
|
39
39
|
noBrowser?: boolean;
|
|
40
40
|
/** Override the localhost port. Default 8765. */
|
|
41
41
|
port?: number;
|
|
42
|
+
/**
|
|
43
|
+
* Allow pasting a bare authorization code (no surrounding URL) in
|
|
44
|
+
* the headless flow. Bypasses the OAuth `state` CSRF check, so
|
|
45
|
+
* opt-in only — see `oauth-headless.ts` for the rationale.
|
|
46
|
+
*/
|
|
47
|
+
unsafeBareCode?: boolean;
|
|
42
48
|
/** Test seam for the OAuth token endpoint. */
|
|
43
49
|
fetchImpl?: FetchLike;
|
|
44
50
|
/**
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
* Wizard step for OAuth 2.0 (PKCE flow) authorization.
|
|
3
3
|
*
|
|
4
4
|
* Flow:
|
|
5
|
-
* 1.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* 1. Read optional local/team OAuth app defaults, if present.
|
|
6
|
+
* 2. Otherwise present a "what is this?" intro pointing the user at
|
|
7
|
+
* Linear's OAuth app registration page, then prompt for `client_id`,
|
|
8
|
+
* optional `client_secret`, port, scopes.
|
|
9
9
|
* 3. Generate PKCE verifier + state, build the authorize URL.
|
|
10
10
|
* 4. Try to open the system browser; fall back to printing the URL.
|
|
11
11
|
* 5. Spin a localhost listener (or fall back to pasted-code prompt) to
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { spawn } from "node:child_process";
|
|
21
21
|
import { checkbox, input, password, select } from "@inquirer/prompts";
|
|
22
|
+
import { readTeamOAuthConfig } from "../../auth/oauth-app-config.js";
|
|
22
23
|
import { DEFAULT_CALLBACK_PATH, runLocalhostCallback, } from "../../auth/oauth-callback.js";
|
|
23
24
|
import { ALL_SCOPES, buildAuthorizeUrl, DEFAULT_SCOPES, generatePkce, generateState, SCOPE_DESCRIPTIONS, validateScopes, } from "../../auth/oauth-client.js";
|
|
24
25
|
import { promptForPastedCode } from "../../auth/oauth-headless.js";
|
|
@@ -165,6 +166,22 @@ async function promptRegistration(defaults) {
|
|
|
165
166
|
scopes: validateScopes(scopes),
|
|
166
167
|
};
|
|
167
168
|
}
|
|
169
|
+
async function resolveRegistration(defaults) {
|
|
170
|
+
const teamConfig = await readTeamOAuthConfig();
|
|
171
|
+
if (!teamConfig) {
|
|
172
|
+
return promptRegistration({ port: defaults.manualPort });
|
|
173
|
+
}
|
|
174
|
+
const port = defaults.requestedPort ?? teamConfig.redirectPort;
|
|
175
|
+
logLine("");
|
|
176
|
+
logLine(TS(`Using Linear OAuth app defaults from ${teamConfig.sourcePath}.`));
|
|
177
|
+
logLine(TS(`Callback URL: http://localhost:${port}${DEFAULT_CALLBACK_PATH}`));
|
|
178
|
+
logLine("");
|
|
179
|
+
return {
|
|
180
|
+
clientId: teamConfig.clientId,
|
|
181
|
+
port,
|
|
182
|
+
scopes: teamConfig.scopes,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
168
185
|
/**
|
|
169
186
|
* Default viewer-validation routine. Calls `viewer { ... }` with the new
|
|
170
187
|
* bearer token to confirm Linear accepted it. Reused for both the wizard
|
|
@@ -213,8 +230,9 @@ export async function runOAuthStep(options = {}) {
|
|
|
213
230
|
}
|
|
214
231
|
// Both `reauth` and `revoked` fall through to the re-auth flow.
|
|
215
232
|
}
|
|
216
|
-
const reg = await
|
|
217
|
-
|
|
233
|
+
const reg = await resolveRegistration({
|
|
234
|
+
manualPort: options.port ?? extractPortFromRedirect(existing) ?? DEFAULT_PORT,
|
|
235
|
+
requestedPort: options.port,
|
|
218
236
|
});
|
|
219
237
|
const redirectUri = `http://localhost:${reg.port}${DEFAULT_CALLBACK_PATH}`;
|
|
220
238
|
const pkce = generatePkce();
|
|
@@ -252,11 +270,17 @@ export async function runOAuthStep(options = {}) {
|
|
|
252
270
|
catch (err) {
|
|
253
271
|
const raw = err instanceof Error ? err.message : String(err);
|
|
254
272
|
logLine(TS(`Localhost listener failed (${sanitizeForLog(raw)}). Falling back to manual paste.`));
|
|
255
|
-
callback = await promptForPastedCode({
|
|
273
|
+
callback = await promptForPastedCode({
|
|
274
|
+
expectedState: state,
|
|
275
|
+
unsafeBareCode: options.unsafeBareCode,
|
|
276
|
+
});
|
|
256
277
|
}
|
|
257
278
|
}
|
|
258
279
|
else {
|
|
259
|
-
callback = await promptForPastedCode({
|
|
280
|
+
callback = await promptForPastedCode({
|
|
281
|
+
expectedState: state,
|
|
282
|
+
unsafeBareCode: options.unsafeBareCode,
|
|
283
|
+
});
|
|
260
284
|
}
|
|
261
285
|
logLine(TS("Exchanging authorization code for tokens…"));
|
|
262
286
|
const exchanged = await exchangeCodeForTokens({
|
|
@@ -21,14 +21,6 @@ export interface TokenStepResult {
|
|
|
21
21
|
token: string;
|
|
22
22
|
viewer: ViewerResponse["viewer"];
|
|
23
23
|
}
|
|
24
|
-
/**
|
|
25
|
-
* Strip anything that looks like a Linear API token from a string. Defense in
|
|
26
|
-
* depth: today the @linear/sdk error message embeds {query, variables} but not
|
|
27
|
-
* the Authorization header. A future SDK upgrade that includes headers (which
|
|
28
|
-
* upstream graphql-request has done historically) would otherwise silently
|
|
29
|
-
* write `Bearer lin_api_…` into stdout / shell history / CI logs. The regex
|
|
30
|
-
* also catches token shapes that may show up in custom error wrappers.
|
|
31
|
-
*/
|
|
32
24
|
export declare function sanitizeForLog(text: string): string;
|
|
33
25
|
/**
|
|
34
26
|
* Validate a Linear API token by fetching the viewer. Throws with a
|
|
@@ -31,8 +31,20 @@ const VIEWER_QUERY = /* GraphQL */ `
|
|
|
31
31
|
* write `Bearer lin_api_…` into stdout / shell history / CI logs. The regex
|
|
32
32
|
* also catches token shapes that may show up in custom error wrappers.
|
|
33
33
|
*/
|
|
34
|
+
// Personal-API tokens (`lin_api_…`) and OAuth access/refresh tokens
|
|
35
|
+
// (`lin_oauth_…`). Pre-fix the regex only matched personal tokens.
|
|
36
|
+
const TOKEN_PREFIX_RE = /lin_(api|oauth)_[A-Za-z0-9_-]{16,}/g;
|
|
37
|
+
// High-entropy bearer payload fallback: catches generic Bearer-style
|
|
38
|
+
// strings adjacent to Authorization / Bearer keywords. Useful for
|
|
39
|
+
// future SDK error wrappers that might leak headers without the
|
|
40
|
+
// `lin_` prefix.
|
|
41
|
+
const BEARER_PAYLOAD_RE = /(\b(?:Authorization|Bearer)\b[:\s]*)([A-Za-z0-9_\-/+=]{40,})/gi;
|
|
34
42
|
export function sanitizeForLog(text) {
|
|
35
|
-
return text
|
|
43
|
+
return text
|
|
44
|
+
.replace(TOKEN_PREFIX_RE, (m) => m.startsWith("lin_oauth_")
|
|
45
|
+
? "lin_oauth_***REDACTED***"
|
|
46
|
+
: "lin_api_***REDACTED***")
|
|
47
|
+
.replace(BEARER_PAYLOAD_RE, "$1***REDACTED***");
|
|
36
48
|
}
|
|
37
49
|
/**
|
|
38
50
|
* Strict shape check on the viewer response. Treats whitespace-only fields as
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git branch helpers used by `issues create --branch` and the
|
|
3
|
+
* `issues retrolink` flow.
|
|
4
|
+
*
|
|
5
|
+
* Extracted from `commands/issues.ts` (ALL-938) to keep that file
|
|
6
|
+
* focused on commander wiring + handlers.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Transform Linear's branchName (e.g. "dev-3549-slug") into our convention:
|
|
10
|
+
* "feature/DEV-3549-slug" — uppercase team key with a configurable prefix.
|
|
11
|
+
*/
|
|
12
|
+
export declare function toBranchName(linearBranchName: string, prefix?: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* Check out a new git branch. Warns and skips if not in a git repo.
|
|
15
|
+
* Throws if the branch already exists.
|
|
16
|
+
*/
|
|
17
|
+
export declare function gitCheckoutBranch(branchName: string): void;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git branch helpers used by `issues create --branch` and the
|
|
3
|
+
* `issues retrolink` flow.
|
|
4
|
+
*
|
|
5
|
+
* Extracted from `commands/issues.ts` (ALL-938) to keep that file
|
|
6
|
+
* focused on commander wiring + handlers.
|
|
7
|
+
*/
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import { outputWarning } from "../../utils/output.js";
|
|
10
|
+
const LINEAR_BRANCH_REGEX = /^([a-zA-Z]+)-(\d+)-(.+)$/;
|
|
11
|
+
/**
|
|
12
|
+
* Transform Linear's branchName (e.g. "dev-3549-slug") into our convention:
|
|
13
|
+
* "feature/DEV-3549-slug" — uppercase team key with a configurable prefix.
|
|
14
|
+
*/
|
|
15
|
+
export function toBranchName(linearBranchName, prefix = "feature/") {
|
|
16
|
+
// Linear branch names look like "dev-123-some-slug"
|
|
17
|
+
// We need to uppercase the team key: "DEV-123-some-slug"
|
|
18
|
+
const match = linearBranchName.match(LINEAR_BRANCH_REGEX);
|
|
19
|
+
if (!match) {
|
|
20
|
+
return `${prefix}${linearBranchName}`;
|
|
21
|
+
}
|
|
22
|
+
const [, teamKey, number, slug] = match;
|
|
23
|
+
return `${prefix}${teamKey.toUpperCase()}-${number}-${slug}`;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Check out a new git branch. Warns and skips if not in a git repo.
|
|
27
|
+
* Throws if the branch already exists.
|
|
28
|
+
*/
|
|
29
|
+
export function gitCheckoutBranch(branchName) {
|
|
30
|
+
try {
|
|
31
|
+
execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
32
|
+
stdio: "pipe",
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
outputWarning("Not inside a git repository — skipping branch checkout.");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
execFileSync("git", ["checkout", "-b", branchName], { stdio: "pipe" });
|
|
40
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Description-handling helpers for issues create/update and the
|
|
3
|
+
* `issues link-references --rewrite-description` flow.
|
|
4
|
+
*
|
|
5
|
+
* Three concerns live here:
|
|
6
|
+
*
|
|
7
|
+
* 1. Resolving the description value from `--description`,
|
|
8
|
+
* `--description-file`, or `--template`.
|
|
9
|
+
* 2. The shared wrap-and-resolve pipeline (`wrapAndResolveRefs`)
|
|
10
|
+
* used by the create/update path and the rewrite-description
|
|
11
|
+
* path. Both wrap valid identifiers as markdown links and
|
|
12
|
+
* return the resolved id→uuid map for downstream auto-link.
|
|
13
|
+
* 3. The post-create/update `maybeAutoLink` hook that creates
|
|
14
|
+
* sidebar relations for refs in the description.
|
|
15
|
+
*
|
|
16
|
+
* Extracted from `commands/issues.ts` (ALL-938) so that file can
|
|
17
|
+
* focus on commander wiring + handlers.
|
|
18
|
+
*/
|
|
19
|
+
import type { OptionValues } from "commander";
|
|
20
|
+
import { type AutoLinkResult } from "../../utils/auto-link-references.js";
|
|
21
|
+
import type { GraphQLService } from "../../utils/graphql-service.js";
|
|
22
|
+
import type { LinearService } from "../../utils/linear-service.js";
|
|
23
|
+
/**
|
|
24
|
+
* Read description from a file path or stdin ("-").
|
|
25
|
+
* Avoids shell escaping issues when descriptions contain special characters.
|
|
26
|
+
*/
|
|
27
|
+
export declare function readDescriptionFile(filePath: string): string;
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the description from --description, --description-file, or
|
|
30
|
+
* --template (looked up in `config.descriptionTemplates`).
|
|
31
|
+
*
|
|
32
|
+
* Precedence: --description-file > --description > --template
|
|
33
|
+
*
|
|
34
|
+
* Passing --template alongside --description / --description-file is
|
|
35
|
+
* a usage error — the explicit body and a template both producing
|
|
36
|
+
* content would silently drop one. We throw so the user picks one.
|
|
37
|
+
*/
|
|
38
|
+
export declare function resolveDescription(options: OptionValues): string | undefined;
|
|
39
|
+
export interface PreparedDescription {
|
|
40
|
+
/** The (possibly rewritten) description text to send to Linear */
|
|
41
|
+
description: string | undefined;
|
|
42
|
+
/** Map<identifier, uuid> of refs that resolved — passed to autoLink to avoid re-resolution */
|
|
43
|
+
preResolved: Map<string, string>;
|
|
44
|
+
/** True when the original description was rewritten (i.e. at least one link was wrapped) */
|
|
45
|
+
rewritten: boolean;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Pre-process a description before sending to Linear:
|
|
49
|
+
* 1. Extract all issue references.
|
|
50
|
+
* 2. Validate them (drop ones that don't resolve in the workspace —
|
|
51
|
+
* handles "ISO-1424"-style false positives).
|
|
52
|
+
* 3. Wrap valid identifiers as markdown links — skipping any
|
|
53
|
+
* already inside a link, code block, or backtick span.
|
|
54
|
+
*
|
|
55
|
+
* No-ops (returns the original description with an empty map) when:
|
|
56
|
+
* - description is empty/undefined
|
|
57
|
+
* - the user passed `--no-auto-link`
|
|
58
|
+
*/
|
|
59
|
+
export declare function prepareAutoLinkedDescription(description: string | undefined, options: OptionValues, selfIdentifier: string | undefined, linearService: LinearService, graphQLService: GraphQLService): Promise<PreparedDescription>;
|
|
60
|
+
export interface PreparedRewrite {
|
|
61
|
+
/** Resolved id→uuid map (passed to autoLink to skip duplicate resolution) */
|
|
62
|
+
preResolved: Map<string, string> | undefined;
|
|
63
|
+
/** New description text — undefined when wrapping wouldn't change anything */
|
|
64
|
+
wrapped: string | undefined;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Prepare a description rewrite for `link-references --rewrite-description`.
|
|
68
|
+
* Same wrap-and-resolve core; different return shape so the caller can
|
|
69
|
+
* skip the rewrite mutation when nothing would change.
|
|
70
|
+
*/
|
|
71
|
+
export declare function prepareDescriptionRewrite(description: string, selfIdentifier: string, linearService: LinearService, graphQLService: GraphQLService): Promise<PreparedRewrite>;
|
|
72
|
+
export declare function pushDescriptionUpdate(issueUuid: string, description: string, graphQLService: GraphQLService): Promise<void>;
|
|
73
|
+
interface MaybeAutoLinkInput {
|
|
74
|
+
description: string | null | undefined;
|
|
75
|
+
graphQLService: GraphQLService;
|
|
76
|
+
identifier: string;
|
|
77
|
+
issueId: string;
|
|
78
|
+
linearService: LinearService;
|
|
79
|
+
options: OptionValues;
|
|
80
|
+
preResolved?: Map<string, string>;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Run auto-linking for issue references found in a description, unless
|
|
84
|
+
* the user opted out with `--no-auto-link` or no description was
|
|
85
|
+
* provided. Returns undefined when nothing was linked / skipped /
|
|
86
|
+
* failed (so callers can omit the field from JSON output).
|
|
87
|
+
*/
|
|
88
|
+
export declare function maybeAutoLink(input: MaybeAutoLinkInput): Promise<AutoLinkResult | undefined>;
|
|
89
|
+
export {};
|