@enrichlayer/el-linear 1.6.0 → 1.7.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 CHANGED
@@ -74,13 +74,41 @@ every team ends up writing themselves:
74
74
 
75
75
  ## Authentication
76
76
 
77
- The API token is resolved in this order:
77
+ el-linear supports either OAuth or a personal Linear API token. OAuth is
78
+ configured with:
79
+
80
+ ```bash
81
+ el-linear init oauth
82
+ ```
83
+
84
+ By default, that command walks you through registering your own Linear OAuth
85
+ app. Teams can make the flow a single browser authorization step by writing a
86
+ local, untracked app-defaults file at `~/.config/el-linear/team-oauth.json`
87
+ or pointing `EL_LINEAR_OAUTH_CONFIG` at one:
88
+
89
+ ```json
90
+ {
91
+ "linearOAuth": {
92
+ "clientId": "your-linear-oauth-client-id",
93
+ "redirectPort": 8765,
94
+ "scopes": ["read", "write", "issues:create", "comments:create"],
95
+ "passwordManagerPath": "op://vault/item/client_id"
96
+ }
97
+ }
98
+ ```
99
+
100
+ `passwordManagerPath` is optional metadata for humans/scripts; el-linear does
101
+ not execute password-manager commands from it. Do not put a `client_secret` in
102
+ this shared file. The OAuth flow uses PKCE.
103
+
104
+ At runtime, credentials are resolved in this order:
78
105
 
79
106
  1. `--api-token <token>` flag.
80
107
  2. `LINEAR_API_TOKEN` environment variable.
81
- 3. **Active profile's** `~/.config/el-linear/profiles/<name>/token` file (see *Profiles* below).
82
- 4. `~/.config/el-linear/token` file (legacy single-profile, recommended for human use when only one workspace is needed).
83
- 5. `~/.linear_api_token` file (legacy, still honored).
108
+ 3. **Active profile's** OAuth state (`oauth.json`) from `el-linear init oauth`.
109
+ 4. **Active profile's** `~/.config/el-linear/profiles/<name>/token` file (see *Profiles* below).
110
+ 5. `~/.config/el-linear/token` file (legacy single-profile, recommended for human use when only one workspace is needed).
111
+ 6. `~/.linear_api_token` file (legacy, still honored).
84
112
 
85
113
  el-linear never logs the token.
86
114
 
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Optional local OAuth app defaults.
3
+ *
4
+ * This deliberately lives outside the packaged/default config. Teams can
5
+ * materialize it from a password manager, while the OSS CLI keeps requiring
6
+ * users to bring their own OAuth app when no local file exists.
7
+ */
8
+ import { type OAuthScope } from "./oauth-client.js";
9
+ export declare const TEAM_OAUTH_CONFIG_ENV = "EL_LINEAR_OAUTH_CONFIG";
10
+ export interface TeamOAuthConfig {
11
+ clientId: string;
12
+ redirectPort: number;
13
+ scopes: OAuthScope[];
14
+ /**
15
+ * Optional human-facing pointer such as `op://vault/item/client_id`.
16
+ * The CLI does not execute password-manager commands from this value.
17
+ */
18
+ passwordManagerPath?: string;
19
+ sourcePath: string;
20
+ }
21
+ export declare function readTeamOAuthConfig(env?: NodeJS.ProcessEnv): Promise<TeamOAuthConfig | null>;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Optional local OAuth app defaults.
3
+ *
4
+ * This deliberately lives outside the packaged/default config. Teams can
5
+ * materialize it from a password manager, while the OSS CLI keeps requiring
6
+ * users to bring their own OAuth app when no local file exists.
7
+ */
8
+ import fs from "node:fs/promises";
9
+ import { TEAM_OAUTH_CONFIG_PATH } from "../config/paths.js";
10
+ import { DEFAULT_SCOPES, validateScopes, } from "./oauth-client.js";
11
+ export const TEAM_OAUTH_CONFIG_ENV = "EL_LINEAR_OAUTH_CONFIG";
12
+ export async function readTeamOAuthConfig(env = process.env) {
13
+ const envPath = env[TEAM_OAUTH_CONFIG_ENV]?.trim();
14
+ const sourcePath = envPath || TEAM_OAUTH_CONFIG_PATH;
15
+ let raw;
16
+ try {
17
+ raw = await fs.readFile(sourcePath, "utf8");
18
+ }
19
+ catch (err) {
20
+ if (err.code === "ENOENT" && !envPath) {
21
+ return null;
22
+ }
23
+ if (err.code === "ENOENT") {
24
+ throw new Error(`${TEAM_OAUTH_CONFIG_ENV} points to ${sourcePath}, but that file does not exist.`);
25
+ }
26
+ throw err;
27
+ }
28
+ let parsed;
29
+ try {
30
+ parsed = JSON.parse(raw);
31
+ }
32
+ catch (err) {
33
+ const message = err instanceof Error ? err.message : String(err);
34
+ throw new Error(`Failed to parse ${sourcePath}: ${message}`);
35
+ }
36
+ const linearOAuth = parsed.linearOAuth;
37
+ if (!linearOAuth || typeof linearOAuth !== "object") {
38
+ throw new Error(`${sourcePath} must contain a linearOAuth object with a clientId.`);
39
+ }
40
+ const clientId = typeof linearOAuth.clientId === "string" ? linearOAuth.clientId.trim() : "";
41
+ if (!clientId) {
42
+ throw new Error(`${sourcePath} linearOAuth.clientId must be a string.`);
43
+ }
44
+ const redirectPort = parseRedirectPort(linearOAuth.redirectPort, sourcePath);
45
+ const scopes = parseScopes(linearOAuth.scopes, sourcePath);
46
+ const passwordManagerPath = parsePasswordManagerPath(linearOAuth.passwordManagerPath, sourcePath);
47
+ return {
48
+ clientId,
49
+ redirectPort,
50
+ scopes,
51
+ passwordManagerPath,
52
+ sourcePath,
53
+ };
54
+ }
55
+ function parseRedirectPort(value, sourcePath) {
56
+ if (value === undefined)
57
+ return 8765;
58
+ if (typeof value !== "number" ||
59
+ !Number.isInteger(value) ||
60
+ value < 1024 ||
61
+ value > 65535) {
62
+ throw new Error(`${sourcePath} linearOAuth.redirectPort must be an integer between 1024 and 65535.`);
63
+ }
64
+ return value;
65
+ }
66
+ function parseScopes(value, sourcePath) {
67
+ if (value === undefined)
68
+ return [...DEFAULT_SCOPES];
69
+ if (!Array.isArray(value) || !value.every((s) => typeof s === "string")) {
70
+ throw new Error(`${sourcePath} linearOAuth.scopes must be a string array.`);
71
+ }
72
+ return validateScopes(value);
73
+ }
74
+ function parsePasswordManagerPath(value, sourcePath) {
75
+ if (value === undefined)
76
+ return undefined;
77
+ if (typeof value !== "string") {
78
+ throw new Error(`${sourcePath} linearOAuth.passwordManagerPath must be a string when set.`);
79
+ }
80
+ const trimmed = value.trim();
81
+ return trimmed || undefined;
82
+ }
@@ -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}`);
@@ -127,15 +127,18 @@ export function setupInitCommands(program) {
127
127
  }));
128
128
  init
129
129
  .command("defaults")
130
- .description("Default labels, status defaults, term enforcement rules")
130
+ .description("Default labels, default assignee, default priority, status defaults, cache TTL, term enforcement")
131
131
  .action(withCleanExit(async () => {
132
132
  const existing = await readConfig();
133
133
  printStep("defaults", "Defaults");
134
134
  const result = await runDefaultsStep(existing);
135
135
  const merged = assignDefined(existing, {
136
136
  defaultLabels: result.defaultLabels,
137
+ defaultAssignee: result.defaultAssignee,
138
+ defaultPriority: result.defaultPriority,
137
139
  statusDefaults: result.statusDefaults,
138
140
  terms: result.terms,
141
+ cacheTTLSeconds: result.cacheTTLSeconds,
139
142
  });
140
143
  await writeConfig(merged);
141
144
  console.log(" ✓ Defaults saved.");
@@ -175,8 +178,11 @@ async function runFullWizardImpl(options) {
175
178
  // defaults step: result is `existing.X` itself when the user skipped
176
179
  // the edit branch, so direct assignment is safe.
177
180
  defaultLabels: defaults.defaultLabels,
181
+ defaultAssignee: defaults.defaultAssignee,
182
+ defaultPriority: defaults.defaultPriority,
178
183
  statusDefaults: defaults.statusDefaults,
179
184
  terms: defaults.terms,
185
+ cacheTTLSeconds: defaults.cacheTTLSeconds,
180
186
  // workspace step: `ws.defaultTeam` may be the existing value (user
181
187
  // skipped) or a new pick.
182
188
  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
@@ -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();
@@ -263,12 +263,20 @@ function buildUpdateArgs(issueId, options, assigneeId) {
263
263
  else if (options.labels) {
264
264
  labelIds = splitList(options.labels);
265
265
  }
266
+ // On update, fall back to config.defaultPriority only when --priority
267
+ // wasn't passed. This keeps every update setting a priority for users who
268
+ // want a workspace-wide baseline (e.g. all unassigned triage tickets bump
269
+ // to "medium"). To leave priority untouched, omit defaultPriority from
270
+ // config — the field is opt-in.
271
+ const priorityInput = typeof options.priority === "string"
272
+ ? options.priority
273
+ : loadConfig().defaultPriority;
266
274
  return {
267
275
  id: issueId,
268
276
  title: options.title,
269
277
  description: options.description,
270
278
  statusId: options.status,
271
- priority: options.priority ? validatePriority(options.priority) : undefined,
279
+ priority: priorityInput ? validatePriority(priorityInput) : undefined,
272
280
  assigneeId,
273
281
  projectId: options.project,
274
282
  labelIds,
@@ -392,6 +400,21 @@ async function handleSearchIssues(query, options, command) {
392
400
  async function resolveCreateInputs(title, options, rootOpts) {
393
401
  const config = loadConfig();
394
402
  enforceTerms([title, options.description], { strict: options.strict });
403
+ // Effective assignee: explicit --assignee wins; --no-assignee (commander
404
+ // parses as `assignee === false`) skips both flag and config; otherwise
405
+ // fall back to config.defaultAssignee. Computed BEFORE validation so the
406
+ // "assignee required" rule sees the resolved value, not undefined.
407
+ const noAssignee = options.assignee === false;
408
+ const explicitAssignee = typeof options.assignee === "string" ? options.assignee : undefined;
409
+ const effectiveAssignee = noAssignee
410
+ ? undefined
411
+ : (explicitAssignee ?? config.defaultAssignee);
412
+ // Effective priority: explicit --priority wins; otherwise fall back to
413
+ // config.defaultPriority. Both go through validatePriority so a bad config
414
+ // value fails fast with a useful error.
415
+ const effectivePriorityInput = typeof options.priority === "string"
416
+ ? options.priority
417
+ : config.defaultPriority;
395
418
  // --- Validation (labels, description, assignee, project, title) ---
396
419
  // Controlled by config.validation.enabled (default: true).
397
420
  // Bypassed by --skip-validation flag.
@@ -402,7 +425,7 @@ async function resolveCreateInputs(title, options, rootOpts) {
402
425
  labels: rawLabels,
403
426
  description: description || undefined,
404
427
  title,
405
- assignee: options.assignee,
428
+ assignee: effectiveAssignee,
406
429
  project: options.project,
407
430
  });
408
431
  // Apply normalized labels back so resolution uses the canonical names
@@ -411,13 +434,13 @@ async function resolveCreateInputs(title, options, rootOpts) {
411
434
  }
412
435
  enforceValidation(validationResult);
413
436
  }
414
- if (!options.priority) {
437
+ if (!effectivePriorityInput) {
415
438
  outputWarning("Creating issue without --priority. Consider specifying it for better triage.", "missing_fields");
416
439
  }
417
440
  const teamInput = options.team || config.defaultTeam;
418
441
  const teamId = resolveTeam(teamInput);
419
- const assigneeId = options.assignee
420
- ? await resolveAssignee(options.assignee, rootOpts)
442
+ const assigneeId = effectiveAssignee
443
+ ? await resolveAssignee(effectiveAssignee, rootOpts)
421
444
  : undefined;
422
445
  let labelIds = [];
423
446
  if (options.labels) {
@@ -438,7 +461,18 @@ async function resolveCreateInputs(title, options, rootOpts) {
438
461
  if (options.subscriber) {
439
462
  subscriberIds = splitList(options.subscriber).map((s) => resolveMember(s));
440
463
  }
441
- return { teamInput, teamId, assigneeId, labelIds, status, subscriberIds };
464
+ const priority = effectivePriorityInput
465
+ ? validatePriority(effectivePriorityInput)
466
+ : undefined;
467
+ return {
468
+ teamInput,
469
+ teamId,
470
+ assigneeId,
471
+ labelIds,
472
+ status,
473
+ subscriberIds,
474
+ priority,
475
+ };
442
476
  }
443
477
  async function uploadAttachmentsIfNeeded(options, rootOpts) {
444
478
  if (!options.attachment) {
@@ -472,7 +506,7 @@ function buildDescriptionWithAttachments(baseDescription, uploadResults) {
472
506
  }
473
507
  async function handleCreateIssue(title, options, command) {
474
508
  const rootOpts = getRootOpts(command);
475
- const { teamInput, teamId, assigneeId, labelIds, status, subscriberIds } = await resolveCreateInputs(title, options, rootOpts);
509
+ const { teamInput, teamId, assigneeId, labelIds, status, subscriberIds, priority, } = await resolveCreateInputs(title, options, rootOpts);
476
510
  const uploadResults = await uploadAttachmentsIfNeeded(options, rootOpts);
477
511
  const descriptionWithAttachments = buildDescriptionWithAttachments(resolveDescription(options) || "", uploadResults);
478
512
  // Append messageFooter (config or --footer flag) so auto-link picks up any
@@ -497,7 +531,7 @@ async function handleCreateIssue(title, options, command) {
497
531
  teamInput,
498
532
  description: prepared.description,
499
533
  assigneeId,
500
- priority: options.priority ? validatePriority(options.priority) : undefined,
534
+ priority,
501
535
  projectId: options.project,
502
536
  statusId: status,
503
537
  labelIds: labelIds.length > 0 ? labelIds : undefined,
@@ -1075,6 +1109,7 @@ export function setupIssuesCommands(program) {
1075
1109
  .option("--description-file <path>", "read description from file (use - for stdin)")
1076
1110
  .option("--template <name>", "use a named description template from config.descriptionTemplates")
1077
1111
  .option("-a, --assignee <assignee>", "assign to user (name, alias, or UUID)")
1112
+ .option("--no-assignee", "create unassigned even when config.defaultAssignee is set")
1078
1113
  .option("-p, --priority <priority>", "priority: name (none/urgent/high/medium/normal/low) or number (0-4)")
1079
1114
  .option("--project <project>", "add to project (name or ID)")
1080
1115
  .option("--team <team>", "team key or name (default: from config)")
@@ -1,5 +1,7 @@
1
+ import { loadConfig } from "../config/config.js";
1
2
  import { resolveTeam } from "../config/resolver.js";
2
3
  import { CREATE_LABEL_MUTATION, FIND_PARENT_LABEL_QUERY, RESTORE_LABEL_MUTATION, RETIRE_LABEL_MUTATION, } from "../queries/labels.js";
4
+ import { cached, resolveCacheTTL } from "../utils/disk-cache.js";
3
5
  import { createGraphQLService } from "../utils/graphql-service.js";
4
6
  import { createLinearService } from "../utils/linear-service.js";
5
7
  import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
@@ -75,8 +77,19 @@ export function setupLabelsCommands(program) {
75
77
  .action(handleAsyncCommand(async (options, command) => {
76
78
  const rootOpts = getRootOpts(command);
77
79
  const teamFilter = options.team ? resolveTeam(options.team) : undefined;
78
- const service = await createLinearService(rootOpts);
79
- const result = await service.getLabels(teamFilter, Number.parseInt(options.limit, 10));
80
+ const limit = Number.parseInt(options.limit, 10);
81
+ const ttl = resolveCacheTTL({
82
+ configTTL: loadConfig().cacheTTLSeconds,
83
+ noCacheFlag: rootOpts.cache === false,
84
+ });
85
+ // Cache key includes the team filter so list-with-team and list-
86
+ // without-team don't collide. Same `limit` participates because
87
+ // a smaller list isn't a valid cached answer for a larger ask.
88
+ const cacheKey = `labels-list-team:${teamFilter ?? "_all"}-limit:${limit}`;
89
+ const result = await cached(cacheKey, ttl, async () => {
90
+ const service = await createLinearService(rootOpts);
91
+ return service.getLabels(teamFilter, limit);
92
+ });
80
93
  outputSuccess({
81
94
  data: result.labels,
82
95
  meta: { count: result.labels.length },
@@ -1,5 +1,7 @@
1
+ import { loadConfig } from "../config/config.js";
1
2
  import { resolveTeam } from "../config/resolver.js";
2
3
  import { CREATE_PROJECT_MUTATION, GET_PROJECT_QUERY, GET_PROJECT_TEAM_ISSUES_QUERY, PROJECT_BY_ID_QUERY, SEARCH_PROJECTS_BY_NAME_QUERY, UPDATE_PROJECT_MUTATION, } from "../queries/projects.js";
4
+ import { cached, resolveCacheTTL } from "../utils/disk-cache.js";
3
5
  import { createGraphQLService } from "../utils/graphql-service.js";
4
6
  import { createLinearService } from "../utils/linear-service.js";
5
7
  import { logger } from "../utils/logger.js";
@@ -305,8 +307,15 @@ export function setupProjectsCommands(program) {
305
307
  .option("--fields <fields>", "columns for table/csv (comma-separated: name,state,progress,teams,lead,targetDate)")
306
308
  .action(handleAsyncCommand(async (options, command) => {
307
309
  const rootOpts = getRootOpts(command);
308
- const service = await createLinearService(rootOpts);
309
- const result = await service.getProjects(Number.parseInt(options.limit, 10));
310
+ const limit = Number.parseInt(options.limit, 10);
311
+ const ttl = resolveCacheTTL({
312
+ configTTL: loadConfig().cacheTTLSeconds,
313
+ noCacheFlag: rootOpts.cache === false,
314
+ });
315
+ const result = await cached(`projects-list-limit:${limit}`, ttl, async () => {
316
+ const service = await createLinearService(rootOpts);
317
+ return service.getProjects(limit);
318
+ });
310
319
  const format = options.format;
311
320
  if (format === "table" ||
312
321
  format === "md" ||
@@ -1,3 +1,5 @@
1
+ import { loadConfig } from "../config/config.js";
2
+ import { cached, resolveCacheTTL } from "../utils/disk-cache.js";
1
3
  import { createLinearService } from "../utils/linear-service.js";
2
4
  import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
3
5
  import { getRootOpts } from "../utils/root-opts.js";
@@ -13,8 +15,16 @@ export function setupTeamsCommands(program) {
13
15
  .option("-l, --limit <number>", "limit results", "100")
14
16
  .action(handleAsyncCommand(async (options, command) => {
15
17
  const rootOpts = getRootOpts(command);
16
- const service = await createLinearService(rootOpts);
17
- const result = await service.getTeams(Number.parseInt(options.limit, 10));
18
+ const limit = Number.parseInt(options.limit, 10);
19
+ const ttl = resolveCacheTTL({
20
+ configTTL: loadConfig().cacheTTLSeconds,
21
+ // commander's `--no-cache` produces `cache: false` on the root opts.
22
+ noCacheFlag: rootOpts.cache === false,
23
+ });
24
+ const result = await cached(`teams-list-limit:${limit}`, ttl, async () => {
25
+ const service = await createLinearService(rootOpts);
26
+ return service.getTeams(limit);
27
+ });
18
28
  outputSuccess({ data: result, meta: { count: result.length } });
19
29
  }));
20
30
  }
@@ -57,6 +57,25 @@ export interface ElLinearConfig {
57
57
  * }
58
58
  */
59
59
  descriptionTemplates?: Record<string, string>;
60
+ /**
61
+ * Default assignee identifier (alias / display name / email / UUID — same
62
+ * shapes resolveAssignee accepts) for `issues create`. Applied when
63
+ * `--assignee` is not passed. Pass `--no-assignee` to override at one site.
64
+ */
65
+ defaultAssignee?: string;
66
+ /**
67
+ * Default priority for `issues create` / `issues update`. Accepts the same
68
+ * keywords as the --priority flag: `none|urgent|high|medium|normal|low`
69
+ * or `0`–`4`. Applied when `--priority` is not passed.
70
+ */
71
+ defaultPriority?: string;
72
+ /**
73
+ * TTL (seconds) for the on-disk cache used by `teams list`, `labels list`,
74
+ * and `projects list`. Defaults to 3600 (1 hour) when omitted. A value of
75
+ * `0` disables the cache entirely. Override per-invocation with
76
+ * `--no-cache`.
77
+ */
78
+ cacheTTLSeconds?: number;
60
79
  }
61
80
  /** Test seam — resets the cache between test cases. */
62
81
  export declare function _resetConfigCacheForTests(): void;
@@ -6,6 +6,7 @@
6
6
  export declare const CONFIG_DIR: string;
7
7
  export declare const CONFIG_PATH: string;
8
8
  export declare const TOKEN_PATH: string;
9
+ export declare const TEAM_OAUTH_CONFIG_PATH: string;
9
10
  export declare const ALIASES_PROGRESS_PATH: string;
10
11
  /**
11
12
  * Legacy fallback paths kept for backward compatibility. The CLI was briefly
@@ -9,6 +9,7 @@ import path from "node:path";
9
9
  export const CONFIG_DIR = path.join(os.homedir(), ".config", "el-linear");
10
10
  export const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
11
11
  export const TOKEN_PATH = path.join(CONFIG_DIR, "token");
12
+ export const TEAM_OAUTH_CONFIG_PATH = path.join(CONFIG_DIR, "team-oauth.json");
12
13
  export const ALIASES_PROGRESS_PATH = path.join(CONFIG_DIR, ".init-aliases-progress");
13
14
  /**
14
15
  * Legacy fallback paths kept for backward compatibility. The CLI was briefly
package/dist/main.js CHANGED
@@ -30,13 +30,14 @@ import { splitList } from "./utils/validators.js";
30
30
  program
31
31
  .name("el-linear")
32
32
  .description("A pragmatic CLI for Linear.app — deterministic resolution, structured validation, GraphQL escape hatch.")
33
- .version("1.6.0")
33
+ .version("1.7.0")
34
34
  .option("--api-token <token>", "Linear API token")
35
35
  .option("--profile <name>", "named profile (under ~/.config/el-linear/profiles/<name>/) for this invocation. Overrides EL_LINEAR_PROFILE env + the on-disk active-profile marker.")
36
36
  .option("--json", "output as JSON (default, accepted for compatibility)")
37
37
  .option("--raw", "strip { data, meta } wrapper from list output — emit the array directly")
38
38
  .option("--jq <filter>", "apply a jq filter to the JSON output")
39
- .option("--fields <fields>", "filter output to specific fields (comma-separated)");
39
+ .option("--fields <fields>", "filter output to specific fields (comma-separated)")
40
+ .option("--no-cache", "bypass the on-disk cache for `teams list` / `labels list` / `projects list`");
40
41
  program.hook("preAction", (_thisCommand, actionCommand) => {
41
42
  const rootOpts = actionCommand.optsWithGlobals();
42
43
  if (rootOpts.raw) {
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Profile-aware disk cache with TTL. Stores JSON envelopes at
3
+ * `<profile-dir>/cache/<key>.json`:
4
+ *
5
+ * { v: 1, key, fetchedAt, expiresAt, data }
6
+ *
7
+ * `cached(key, ttlSeconds, fetcher)`:
8
+ * - returns cached `data` when expiresAt > now
9
+ * - else awaits fetcher, writes envelope, returns fresh data
10
+ * - on read error (corrupt JSON, missing dir), silently refetches
11
+ *
12
+ * Bypass:
13
+ * - `bypass: true` option always refetches and rewrites (used by --no-cache)
14
+ * - any error during write is logged via stderr but doesn't fail the call
15
+ *
16
+ * Eviction:
17
+ * - no automatic eviction; old keys stay until manually cleared
18
+ * - `clearCache(prefix?)` for tests + a future `el-linear cache clear` command
19
+ *
20
+ * Path:
21
+ * - profile-aware via resolveActiveProfile() — caches don't bleed between
22
+ * profiles
23
+ */
24
+ export interface CacheOptions {
25
+ /** When true, skip the read step and always refetch + rewrite. */
26
+ bypass?: boolean;
27
+ }
28
+ /**
29
+ * Read-through cache wrapper.
30
+ *
31
+ * `key` — stable string identifier including any filter params
32
+ * (e.g. `teams-list`, `labels-list-team:ENG`)
33
+ * `ttlSeconds` — lifetime; `0` disables the cache entirely (always
34
+ * refetch, never write).
35
+ * `fetcher` — async function returning the fresh data on a miss.
36
+ * `options.bypass` — force a refetch even when an unexpired envelope
37
+ * exists. Still rewrites on success.
38
+ */
39
+ export declare function cached<T>(key: string, ttlSeconds: number, fetcher: () => Promise<T>, options?: CacheOptions): Promise<T>;
40
+ /**
41
+ * Clear cached entries. With no `prefix`, removes the entire cache
42
+ * directory. With a `prefix`, only deletes envelopes whose sanitized key
43
+ * starts with it. Errors (missing dir, permission) are swallowed so this is
44
+ * safe to call from tests.
45
+ */
46
+ export declare function clearCache(prefix?: string): Promise<void>;
47
+ /** Resolved cache TTL for command call sites: respects --no-cache + config. */
48
+ export declare function resolveCacheTTL(args: {
49
+ configTTL: number | undefined;
50
+ noCacheFlag: boolean | undefined;
51
+ }): number;
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Profile-aware disk cache with TTL. Stores JSON envelopes at
3
+ * `<profile-dir>/cache/<key>.json`:
4
+ *
5
+ * { v: 1, key, fetchedAt, expiresAt, data }
6
+ *
7
+ * `cached(key, ttlSeconds, fetcher)`:
8
+ * - returns cached `data` when expiresAt > now
9
+ * - else awaits fetcher, writes envelope, returns fresh data
10
+ * - on read error (corrupt JSON, missing dir), silently refetches
11
+ *
12
+ * Bypass:
13
+ * - `bypass: true` option always refetches and rewrites (used by --no-cache)
14
+ * - any error during write is logged via stderr but doesn't fail the call
15
+ *
16
+ * Eviction:
17
+ * - no automatic eviction; old keys stay until manually cleared
18
+ * - `clearCache(prefix?)` for tests + a future `el-linear cache clear` command
19
+ *
20
+ * Path:
21
+ * - profile-aware via resolveActiveProfile() — caches don't bleed between
22
+ * profiles
23
+ */
24
+ import { randomBytes } from "node:crypto";
25
+ import fs from "node:fs/promises";
26
+ import path from "node:path";
27
+ import { resolveActiveProfile } from "../config/paths.js";
28
+ const CACHE_VERSION = 1;
29
+ const CACHE_FILE_MODE = 0o644;
30
+ const CACHE_DIR_MODE = 0o700;
31
+ /**
32
+ * Resolve `<profile-dir>/cache/`. Profile-aware so caches don't bleed
33
+ * across profiles. The directory of the active profile's `configPath` is
34
+ * the canonical "profile dir" — this matches what `commands/init/shared.ts`
35
+ * uses.
36
+ */
37
+ function cacheDir() {
38
+ const active = resolveActiveProfile();
39
+ return path.join(path.dirname(active.configPath), "cache");
40
+ }
41
+ function cachePath(key) {
42
+ return path.join(cacheDir(), `${sanitizeKey(key)}.json`);
43
+ }
44
+ /**
45
+ * Cache keys may include filter values like `team:ENG` or `status:active`,
46
+ * which are POSIX-safe but we still strip path separators defensively so a
47
+ * malicious or buggy caller can't write outside the cache directory.
48
+ */
49
+ function sanitizeKey(key) {
50
+ return key.replace(/[/\\\0]/g, "_");
51
+ }
52
+ /**
53
+ * Atomic write: write to a sibling tmp file then rename. Mirrors the helper
54
+ * in `commands/init/shared.ts` and `auth/oauth-fs.ts`. Duplicated (12 lines)
55
+ * to keep the dependency graph clean — the wizard depends on cache callers
56
+ * indirectly, so importing wizard internals here would be a cycle hazard.
57
+ */
58
+ async function atomicWrite(targetPath, data) {
59
+ const tmpPath = `${targetPath}.tmp-${randomBytes(8).toString("hex")}`;
60
+ try {
61
+ await fs.writeFile(tmpPath, data, {
62
+ encoding: "utf8",
63
+ mode: CACHE_FILE_MODE,
64
+ });
65
+ await fs.chmod(tmpPath, CACHE_FILE_MODE);
66
+ await fs.rename(tmpPath, targetPath);
67
+ }
68
+ catch (err) {
69
+ await fs.unlink(tmpPath).catch(() => { });
70
+ throw err;
71
+ }
72
+ }
73
+ async function readEnvelope(key) {
74
+ let raw;
75
+ try {
76
+ raw = await fs.readFile(cachePath(key), "utf8");
77
+ }
78
+ catch {
79
+ // Missing dir, missing file, permission errors → treat as cache miss.
80
+ return null;
81
+ }
82
+ try {
83
+ const parsed = JSON.parse(raw);
84
+ // Reject envelopes from a future cache version we don't understand.
85
+ if (!parsed ||
86
+ typeof parsed !== "object" ||
87
+ parsed.v !== CACHE_VERSION ||
88
+ typeof parsed.expiresAt !== "number") {
89
+ return null;
90
+ }
91
+ return parsed;
92
+ }
93
+ catch {
94
+ // Corrupt JSON → treat as cache miss.
95
+ return null;
96
+ }
97
+ }
98
+ async function writeEnvelope(key, envelope) {
99
+ const dir = cacheDir();
100
+ await fs.mkdir(dir, { recursive: true, mode: CACHE_DIR_MODE });
101
+ await atomicWrite(cachePath(key), JSON.stringify(envelope));
102
+ }
103
+ /**
104
+ * Read-through cache wrapper.
105
+ *
106
+ * `key` — stable string identifier including any filter params
107
+ * (e.g. `teams-list`, `labels-list-team:ENG`)
108
+ * `ttlSeconds` — lifetime; `0` disables the cache entirely (always
109
+ * refetch, never write).
110
+ * `fetcher` — async function returning the fresh data on a miss.
111
+ * `options.bypass` — force a refetch even when an unexpired envelope
112
+ * exists. Still rewrites on success.
113
+ */
114
+ export async function cached(key, ttlSeconds, fetcher, options) {
115
+ // TTL = 0 disables caching: never read, never write.
116
+ if (ttlSeconds <= 0) {
117
+ return fetcher();
118
+ }
119
+ const now = Date.now();
120
+ if (!options?.bypass) {
121
+ const envelope = await readEnvelope(key);
122
+ if (envelope && envelope.expiresAt > now) {
123
+ return envelope.data;
124
+ }
125
+ }
126
+ const data = await fetcher();
127
+ const envelope = {
128
+ v: CACHE_VERSION,
129
+ key,
130
+ fetchedAt: now,
131
+ expiresAt: now + ttlSeconds * 1000,
132
+ data,
133
+ };
134
+ try {
135
+ await writeEnvelope(key, envelope);
136
+ }
137
+ catch (err) {
138
+ // Cache writes are best-effort — log to stderr and return the data
139
+ // anyway so a flaky disk doesn't break the user's command.
140
+ const msg = err instanceof Error ? err.message : String(err);
141
+ process.stderr.write(`[disk-cache] write failed for "${key}": ${msg}\n`);
142
+ }
143
+ return data;
144
+ }
145
+ /**
146
+ * Clear cached entries. With no `prefix`, removes the entire cache
147
+ * directory. With a `prefix`, only deletes envelopes whose sanitized key
148
+ * starts with it. Errors (missing dir, permission) are swallowed so this is
149
+ * safe to call from tests.
150
+ */
151
+ export async function clearCache(prefix) {
152
+ const dir = cacheDir();
153
+ if (!prefix) {
154
+ await fs.rm(dir, { recursive: true, force: true });
155
+ return;
156
+ }
157
+ const sanitized = sanitizeKey(prefix);
158
+ let entries;
159
+ try {
160
+ entries = await fs.readdir(dir);
161
+ }
162
+ catch {
163
+ return;
164
+ }
165
+ await Promise.all(entries
166
+ .filter((name) => name.endsWith(".json") && name.startsWith(sanitized))
167
+ .map((name) => fs.unlink(path.join(dir, name)).catch(() => { })));
168
+ }
169
+ /** Resolved cache TTL for command call sites: respects --no-cache + config. */
170
+ export function resolveCacheTTL(args) {
171
+ if (args.noCacheFlag) {
172
+ return 0;
173
+ }
174
+ if (args.configTTL === undefined) {
175
+ return 3600;
176
+ }
177
+ return args.configTTL;
178
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enrichlayer/el-linear",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "A pragmatic CLI for Linear.app — deterministic team/label/member resolution, structured issue validation, configurable term enforcement, and a GraphQL escape hatch.",
5
5
  "main": "dist/main.js",
6
6
  "types": "dist/main.d.ts",