@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.
Files changed (69) hide show
  1. package/README.md +101 -5
  2. package/claude-skills/linear-operations/SKILL.md +55 -0
  3. package/dist/auth/oauth-app-config.d.ts +21 -0
  4. package/dist/auth/oauth-app-config.js +82 -0
  5. package/dist/auth/oauth-callback.js +14 -7
  6. package/dist/auth/oauth-fs.d.ts +22 -0
  7. package/dist/auth/oauth-fs.js +77 -6
  8. package/dist/auth/oauth-headless.d.ts +13 -8
  9. package/dist/auth/oauth-headless.js +18 -11
  10. package/dist/auth/oauth-storage.d.ts +11 -3
  11. package/dist/auth/oauth-storage.js +15 -8
  12. package/dist/auth/token-resolver.d.ts +9 -0
  13. package/dist/auth/token-resolver.js +57 -37
  14. package/dist/commands/init/defaults.d.ts +18 -1
  15. package/dist/commands/init/defaults.js +83 -2
  16. package/dist/commands/init/index.js +9 -1
  17. package/dist/commands/init/oauth.d.ts +10 -4
  18. package/dist/commands/init/oauth.js +32 -8
  19. package/dist/commands/init/token.d.ts +0 -8
  20. package/dist/commands/init/token.js +13 -1
  21. package/dist/commands/issues/branch.d.ts +17 -0
  22. package/dist/commands/issues/branch.js +40 -0
  23. package/dist/commands/issues/description.d.ts +89 -0
  24. package/dist/commands/issues/description.js +187 -0
  25. package/dist/commands/issues.js +76 -222
  26. package/dist/commands/labels.js +17 -2
  27. package/dist/commands/profile/migrate-legacy.js +10 -33
  28. package/dist/commands/profile.js +1 -10
  29. package/dist/commands/projects.d.ts +5 -1
  30. package/dist/commands/projects.js +144 -64
  31. package/dist/commands/read-shortcut.d.ts +6 -0
  32. package/dist/commands/read-shortcut.js +6 -1
  33. package/dist/commands/refs.js +2 -1
  34. package/dist/commands/teams.js +12 -2
  35. package/dist/commands/templates.js +127 -1
  36. package/dist/commands/users.js +2 -1
  37. package/dist/config/config.d.ts +19 -0
  38. package/dist/config/config.js +31 -9
  39. package/dist/config/issue-validation.js +1 -1
  40. package/dist/config/paths.d.ts +12 -0
  41. package/dist/config/paths.js +45 -6
  42. package/dist/config/term-enforcer.js +1 -1
  43. package/dist/main.js +39 -3
  44. package/dist/queries/templates.d.ts +3 -0
  45. package/dist/queries/templates.js +43 -0
  46. package/dist/utils/auth.js +7 -9
  47. package/dist/utils/auto-link-references.js +15 -1
  48. package/dist/utils/disk-cache.d.ts +51 -0
  49. package/dist/utils/disk-cache.js +179 -0
  50. package/dist/utils/formatters/summary.d.ts +62 -0
  51. package/dist/utils/formatters/summary.js +755 -0
  52. package/dist/utils/graphql-issues-service.d.ts +106 -3
  53. package/dist/utils/graphql-issues-service.js +51 -37
  54. package/dist/utils/issue-reference-extractor.d.ts +9 -1
  55. package/dist/utils/issue-reference-extractor.js +16 -9
  56. package/dist/utils/issue-reference-wrapper.js +1 -54
  57. package/dist/utils/linear-service.d.ts +7 -3
  58. package/dist/utils/linear-service.js +27 -5
  59. package/dist/utils/markdown-prosemirror.js +17 -1
  60. package/dist/utils/mention-resolver.js +17 -5
  61. package/dist/utils/output.d.ts +5 -1
  62. package/dist/utils/output.js +28 -1
  63. package/dist/utils/protected-ranges.d.ts +33 -0
  64. package/dist/utils/protected-ranges.js +73 -0
  65. package/dist/utils/table-formatter.d.ts +36 -0
  66. package/dist/utils/table-formatter.js +46 -24
  67. package/dist/utils/validators.d.ts +9 -1
  68. package/dist/utils/validators.js +10 -0
  69. 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 { isAccessTokenFresh, readOAuthState, writeOAuthState, } from "./oauth-storage.js";
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
- 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.`);
68
+ // Fast path: token is fresh; no lock, no refresh.
69
+ if (now() + 60_000 < state.expiresAt) {
70
+ return state;
79
71
  }
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;
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, status defaults, term enforcement.
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, status defaults, term enforcement.
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 rules")
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. Present a "what is this?" intro pointing the user at Linear's OAuth
6
- * app registration page. (Until we ship a shared OAuth client_id,
7
- * every user registers their own app.)
8
- * 2. Prompt for `client_id`, optional `client_secret`, port, scopes.
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. Present a "what is this?" intro pointing the user at Linear's OAuth
6
- * app registration page. (Until we ship a shared OAuth client_id,
7
- * every user registers their own app.)
8
- * 2. Prompt for `client_id`, optional `client_secret`, port, scopes.
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 promptRegistration({
217
- port: options.port ?? extractPortFromRedirect(existing) ?? DEFAULT_PORT,
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({ expectedState: state });
273
+ callback = await promptForPastedCode({
274
+ expectedState: state,
275
+ unsafeBareCode: options.unsafeBareCode,
276
+ });
256
277
  }
257
278
  }
258
279
  else {
259
- callback = await promptForPastedCode({ expectedState: state });
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.replace(/lin_api_[A-Za-z0-9_-]{16,}/g, "lin_api_***REDACTED***");
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 {};