@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
@@ -22,23 +22,38 @@ const DEFAULT_CONFIG = {
22
22
  },
23
23
  terms: [],
24
24
  };
25
- let cachedConfig;
25
+ // Profile-keyed cache. Pre-fix this was a single `cachedConfig` —
26
+ // switching the active profile mid-process and calling `loadConfig`
27
+ // again would return the OLD profile's config until
28
+ // `_resetConfigCacheForTests` ran. Today the CLI sets the profile
29
+ // in `preAction` before any command body, so this is latent — but
30
+ // keying by profile makes it future-proof and makes test isolation
31
+ // less footgun-prone (profile A's setup pollutes profile B's read).
32
+ // ALL-935 deferred fix.
33
+ //
34
+ // Key: `null` for the legacy single-file layout (no active profile),
35
+ // otherwise the profile name. We never mix the two paths in the
36
+ // cache — the marker name selects exactly one path.
37
+ const cachedConfigByProfile = new Map();
26
38
  /** Test seam — resets the cache between test cases. */
27
39
  export function _resetConfigCacheForTests() {
28
- cachedConfig = undefined;
40
+ cachedConfigByProfile.clear();
29
41
  }
30
42
  export function loadConfig() {
31
- if (cachedConfig) {
32
- return cachedConfig;
33
- }
34
43
  // Profile-aware: read from <CONFIG_DIR>/profiles/<name>/config.json
35
44
  // when a profile is active, falling back to the legacy single-file
36
45
  // path so existing setups keep working without migration.
37
46
  const active = resolveActiveProfile();
47
+ const cacheKey = active.name;
48
+ const cached = cachedConfigByProfile.get(cacheKey);
49
+ if (cached) {
50
+ return cached;
51
+ }
38
52
  const candidates = [active.configPath];
39
53
  if (active.configPath !== CONFIG_PATH)
40
54
  candidates.push(CONFIG_PATH);
41
55
  const sourcePath = candidates.find((p) => fs.existsSync(p));
56
+ let resolved;
42
57
  if (sourcePath) {
43
58
  try {
44
59
  const userConfig = JSON.parse(fs.readFileSync(sourcePath, "utf8"));
@@ -61,11 +76,11 @@ export function loadConfig() {
61
76
  }
62
77
  delete userConfig.brand;
63
78
  }
64
- cachedConfig = deepMerge(DEFAULT_CONFIG, userConfig);
79
+ resolved = deepMerge(DEFAULT_CONFIG, userConfig);
65
80
  }
66
81
  catch {
67
82
  outputWarning(`Failed to parse ${sourcePath}, using empty defaults`);
68
- cachedConfig = DEFAULT_CONFIG;
83
+ resolved = DEFAULT_CONFIG;
69
84
  }
70
85
  }
71
86
  else {
@@ -73,13 +88,20 @@ export function loadConfig() {
73
88
  ? ` (active profile: \`${active.name}\` — expected at ${active.configPath})`
74
89
  : "";
75
90
  outputWarning(`No config found at ${active.configPath}${profileNote}. Run \`el-linear init\` (or \`el-linear profile add ${active.name ?? "<name>"}\`) to create one.`);
76
- cachedConfig = DEFAULT_CONFIG;
91
+ resolved = DEFAULT_CONFIG;
77
92
  }
78
- return cachedConfig;
93
+ cachedConfigByProfile.set(cacheKey, resolved);
94
+ return resolved;
79
95
  }
80
96
  function deepMerge(target, source) {
81
97
  const result = { ...target };
82
98
  for (const key of Object.keys(source)) {
99
+ // Reject prototype-pollution keys regardless of value shape: a
100
+ // hand-edited config.json with `__proto__` would otherwise mutate
101
+ // Object.prototype for the whole process.
102
+ if (key === "__proto__" || key === "constructor" || key === "prototype") {
103
+ continue;
104
+ }
83
105
  if (source[key] &&
84
106
  typeof source[key] === "object" &&
85
107
  !Array.isArray(source[key]) &&
@@ -252,7 +252,7 @@ function checkTitleVerbAlignment(title, typeLabel, result) {
252
252
  */
253
253
  export function enforceValidation(result) {
254
254
  for (const warning of result.warnings) {
255
- outputWarning(warning, "validation");
255
+ outputWarning(warning);
256
256
  }
257
257
  if (result.errors.length > 0) {
258
258
  const errorMsg = "Issue creation blocked by validation:\n\n" +
@@ -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
@@ -30,9 +31,20 @@ export interface ProfileFsOps {
30
31
  readFileSync: (p: string) => string;
31
32
  existsSync: (p: string) => boolean;
32
33
  }
34
+ /**
35
+ * Conservative profile-name charset. Rejects path traversal (`..`, `/`),
36
+ * shell metacharacters, and Unicode lookalikes that would confuse `profile
37
+ * use` or let `--profile`/EL_LINEAR_PROFILE/active-profile escape out of
38
+ * <CONFIG_DIR>/profiles/.
39
+ */
40
+ export declare function isSafeProfileName(name: string): boolean;
33
41
  /**
34
42
  * Set the active profile for the duration of this process. Pass `null`
35
43
  * to clear the override and fall back to env / on-disk markers.
44
+ *
45
+ * @throws when `name` is not a safe profile name. Validation lives here
46
+ * so every entry point (CLI flag, env var, marker file) goes through one
47
+ * gate — see `resolveActiveProfile`.
36
48
  */
37
49
  export declare function setActiveProfileForSession(name: string | null): void;
38
50
  /** Read-only accessor for the per-session override (test seam). */
@@ -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
@@ -43,6 +44,15 @@ const DEFAULT_FS_OPS = {
43
44
  readFileSync: (p) => fs.readFileSync(p, "utf8"),
44
45
  existsSync: (p) => fs.existsSync(p),
45
46
  };
47
+ /**
48
+ * Conservative profile-name charset. Rejects path traversal (`..`, `/`),
49
+ * shell metacharacters, and Unicode lookalikes that would confuse `profile
50
+ * use` or let `--profile`/EL_LINEAR_PROFILE/active-profile escape out of
51
+ * <CONFIG_DIR>/profiles/.
52
+ */
53
+ export function isSafeProfileName(name) {
54
+ return /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(name);
55
+ }
46
56
  /**
47
57
  * Per-process override for the active profile name. Set by main.ts when
48
58
  * `--profile <name>` is passed (highest priority).
@@ -51,9 +61,25 @@ let sessionProfileOverride = null;
51
61
  /**
52
62
  * Set the active profile for the duration of this process. Pass `null`
53
63
  * to clear the override and fall back to env / on-disk markers.
64
+ *
65
+ * @throws when `name` is not a safe profile name. Validation lives here
66
+ * so every entry point (CLI flag, env var, marker file) goes through one
67
+ * gate — see `resolveActiveProfile`.
54
68
  */
55
69
  export function setActiveProfileForSession(name) {
56
- sessionProfileOverride = name && name.trim().length > 0 ? name.trim() : null;
70
+ if (name === null) {
71
+ sessionProfileOverride = null;
72
+ return;
73
+ }
74
+ const trimmed = name.trim();
75
+ if (trimmed.length === 0) {
76
+ sessionProfileOverride = null;
77
+ return;
78
+ }
79
+ if (!isSafeProfileName(trimmed)) {
80
+ throw new Error(`Invalid profile name "${trimmed}". Allowed: [A-Za-z0-9][A-Za-z0-9_.-]{0,63}.`);
81
+ }
82
+ sessionProfileOverride = trimmed;
57
83
  }
58
84
  /** Read-only accessor for the per-session override (test seam). */
59
85
  export function getSessionProfileOverride() {
@@ -65,11 +91,24 @@ export function getSessionProfileOverride() {
65
91
  * keep working without migration.
66
92
  */
67
93
  export function resolveActiveProfile(env = process.env, fsImpl = DEFAULT_FS_OPS) {
68
- const explicit = sessionProfileOverride ??
69
- (env.EL_LINEAR_PROFILE?.trim() || null) ??
70
- readActiveProfileMarker(fsImpl);
71
- if (explicit) {
72
- return profilePaths(explicit);
94
+ // session override is already validated at write time (see
95
+ // setActiveProfileForSession). Env var is explicit user input and
96
+ // must fail loudly. Marker file is our own on-disk state; treat
97
+ // invalid contents as "no marker" so a stale/corrupt file doesn't
98
+ // brick every command.
99
+ if (sessionProfileOverride) {
100
+ return profilePaths(sessionProfileOverride);
101
+ }
102
+ const envName = env.EL_LINEAR_PROFILE?.trim();
103
+ if (envName) {
104
+ if (!isSafeProfileName(envName)) {
105
+ throw new Error(`Invalid EL_LINEAR_PROFILE value "${envName}". Allowed: [A-Za-z0-9][A-Za-z0-9_.-]{0,63}.`);
106
+ }
107
+ return profilePaths(envName);
108
+ }
109
+ const marker = readActiveProfileMarker(fsImpl);
110
+ if (marker && isSafeProfileName(marker)) {
111
+ return profilePaths(marker);
73
112
  }
74
113
  return {
75
114
  name: null,
@@ -62,7 +62,7 @@ export function enforceTerms(texts, options = {}) {
62
62
  if (options.strict) {
63
63
  throw new Error(`Term enforcement failed:\n${warnings.map((w) => ` - ${w}`).join("\n")}`);
64
64
  }
65
- outputWarning(warnings, "term_enforcement");
65
+ outputWarning(warnings);
66
66
  }
67
67
  function escapeRegExp(string) {
68
68
  return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
package/dist/main.js CHANGED
@@ -24,19 +24,21 @@ import { setupTeamsCommands } from "./commands/teams.js";
24
24
  import { setupTemplatesCommands } from "./commands/templates.js";
25
25
  import { setupUsersCommands } from "./commands/users.js";
26
26
  import { setActiveProfileForSession } from "./config/paths.js";
27
- import { setFieldsFilter, setJqFilter, setRawMode } from "./utils/output.js";
27
+ import { setFieldsFilter, setJqFilter, setOutputFormat, setRawMode, } from "./utils/output.js";
28
28
  import { outputUsageInfo } from "./utils/usage.js";
29
29
  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.8.1")
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
+ .option("--format <kind>", "output format: json (default, structured envelope) or summary (human-readable)", "json")
37
38
  .option("--raw", "strip { data, meta } wrapper from list output — emit the array directly")
38
39
  .option("--jq <filter>", "apply a jq filter to the JSON output")
39
- .option("--fields <fields>", "filter output to specific fields (comma-separated)");
40
+ .option("--fields <fields>", "filter output to specific fields (comma-separated)")
41
+ .option("--no-cache", "bypass the on-disk cache for `teams list` / `labels list` / `projects list`");
40
42
  program.hook("preAction", (_thisCommand, actionCommand) => {
41
43
  const rootOpts = actionCommand.optsWithGlobals();
42
44
  if (rootOpts.raw) {
@@ -48,6 +50,40 @@ program.hook("preAction", (_thisCommand, actionCommand) => {
48
50
  if (rootOpts.fields) {
49
51
  setFieldsFilter(splitList(rootOpts.fields));
50
52
  }
53
+ // Subcommand --format wins over the global flag (commander resolves
54
+ // options closest to the action), so `optsWithGlobals` returns the
55
+ // subcommand value when one is set. We accept any of the per-command
56
+ // formats (table/md/csv) silently — those take a different code path
57
+ // inside the handler and never reach outputSuccess. The global
58
+ // behavior only triggers for `summary`.
59
+ const fmt = typeof rootOpts.format === "string"
60
+ ? rootOpts.format.toLowerCase()
61
+ : "json";
62
+ if (fmt === "summary") {
63
+ setOutputFormat("summary");
64
+ }
65
+ else if (fmt === "json") {
66
+ setOutputFormat("json");
67
+ }
68
+ else if (fmt === "table" ||
69
+ fmt === "md" ||
70
+ fmt === "markdown" ||
71
+ fmt === "csv") {
72
+ // Per-subcommand format: no global state change. The subcommand
73
+ // owns its own output path.
74
+ setOutputFormat("json");
75
+ }
76
+ else {
77
+ // Match outputSuccess's stable JSON-on-stdout shape so machine
78
+ // callers always get a parseable error envelope on stdout. We
79
+ // can't route through handleAsyncCommand here — preAction runs
80
+ // before the action handler.
81
+ const msg = `Unknown --format: "${rootOpts.format}". Use one of: json, summary` +
82
+ " (per-command formats table/md/csv are also accepted on issues" +
83
+ " list/search and projects list).";
84
+ process.stdout.write(`${JSON.stringify({ error: msg }, null, 2)}\n`);
85
+ process.exit(1);
86
+ }
51
87
  // `--profile <name>` is highest-priority. preAction runs BEFORE the
52
88
  // command body, which is BEFORE getApiToken / loadConfig fire — so
53
89
  // setting the override here means the rest of the run picks up the
@@ -1,2 +1,5 @@
1
1
  export declare const TEMPLATES_LIST_QUERY = "\n query {\n templates {\n id\n name\n type\n description\n templateData\n createdAt\n updatedAt\n team { id key name }\n creator { id name }\n }\n }\n";
2
2
  export declare const TEMPLATE_BY_ID_QUERY = "\n query ($id: String!) {\n template(id: $id) {\n id\n name\n type\n description\n templateData\n createdAt\n updatedAt\n team { id key name }\n creator { id name }\n }\n }\n";
3
+ export declare const TEMPLATE_CREATE_MUTATION = "\n mutation ($input: TemplateCreateInput!) {\n templateCreate(input: $input) {\n success\n lastSyncId\n template {\n id\n name\n type\n description\n templateData\n team { id key name }\n createdAt\n updatedAt\n }\n }\n }\n";
4
+ export declare const TEMPLATE_UPDATE_MUTATION = "\n mutation ($id: String!, $input: TemplateUpdateInput!) {\n templateUpdate(id: $id, input: $input) {\n success\n lastSyncId\n template {\n id\n name\n type\n description\n templateData\n team { id key name }\n updatedAt\n }\n }\n }\n";
5
+ export declare const TEMPLATE_DELETE_MUTATION = "\n mutation ($id: String!) {\n templateDelete(id: $id) {\n success\n lastSyncId\n }\n }\n";
@@ -28,3 +28,46 @@ export const TEMPLATE_BY_ID_QUERY = `
28
28
  }
29
29
  }
30
30
  `;
31
+ export const TEMPLATE_CREATE_MUTATION = `
32
+ mutation ($input: TemplateCreateInput!) {
33
+ templateCreate(input: $input) {
34
+ success
35
+ lastSyncId
36
+ template {
37
+ id
38
+ name
39
+ type
40
+ description
41
+ templateData
42
+ team { id key name }
43
+ createdAt
44
+ updatedAt
45
+ }
46
+ }
47
+ }
48
+ `;
49
+ export const TEMPLATE_UPDATE_MUTATION = `
50
+ mutation ($id: String!, $input: TemplateUpdateInput!) {
51
+ templateUpdate(id: $id, input: $input) {
52
+ success
53
+ lastSyncId
54
+ template {
55
+ id
56
+ name
57
+ type
58
+ description
59
+ templateData
60
+ team { id key name }
61
+ updatedAt
62
+ }
63
+ }
64
+ }
65
+ `;
66
+ export const TEMPLATE_DELETE_MUTATION = `
67
+ mutation ($id: String!) {
68
+ templateDelete(id: $id) {
69
+ success
70
+ lastSyncId
71
+ }
72
+ }
73
+ `;
@@ -1,5 +1,5 @@
1
1
  import fs from "node:fs";
2
- import { LEGACY_LINCTL_TOKEN_PATH, LEGACY_TOKEN_PATH, resolveActiveProfile, TOKEN_PATH, } from "../config/paths.js";
2
+ import { LEGACY_LINCTL_TOKEN_PATH, LEGACY_TOKEN_PATH, resolveActiveProfile, } from "../config/paths.js";
3
3
  import { maybeEmitMigrationHint } from "./migration-hint.js";
4
4
  export function getApiToken(options) {
5
5
  if (options.apiToken) {
@@ -9,18 +9,16 @@ export function getApiToken(options) {
9
9
  return process.env.LINEAR_API_TOKEN;
10
10
  }
11
11
  // Profile-aware: read from <CONFIG_DIR>/profiles/<name>/token when a
12
- // profile is active, falling back to the legacy single-file path
13
- // (TOKEN_PATH) for the no-profile case.
12
+ // profile is active. When the user has explicitly selected a profile
13
+ // (--profile / EL_LINEAR_PROFILE / active-profile marker), do NOT
14
+ // fall back to the legacy single-file token — that path silently
15
+ // posts writes to the wrong workspace when a profile token is missing.
14
16
  const active = resolveActiveProfile();
15
17
  if (fs.existsSync(active.tokenPath)) {
16
18
  return fs.readFileSync(active.tokenPath, "utf8").trim();
17
19
  }
18
- // Even when a profile is selected, fall through to the legacy token
19
- // path so an operator who only has the single-file layout doesn't
20
- // suddenly fail. The active-profile name is informational only when
21
- // no profile-scoped token exists yet.
22
- if (active.tokenPath !== TOKEN_PATH && fs.existsSync(TOKEN_PATH)) {
23
- return fs.readFileSync(TOKEN_PATH, "utf8").trim();
20
+ if (active.name !== null) {
21
+ throw new Error(`No token for active profile \`${active.name}\` (expected at ${active.tokenPath}). Run \`el-linear init token --profile ${active.name}\` (or unset --profile / EL_LINEAR_PROFILE / \`el-linear profile use <name>\`) before retrying. Refusing to fall back to the legacy single-file token to avoid posting writes to the wrong workspace.`);
24
22
  }
25
23
  // Fallback to the linctl-era token (the CLI was briefly published as
26
24
  // `@enrichlayer/linctl` then reverted). Kept for one release.
@@ -155,7 +155,21 @@ export async function autoLinkReferences(input) {
155
155
  const failed = [];
156
156
  const descriptionRefs = extractIssueReferences(description ?? "", identifier);
157
157
  const commentRefs = (comments ?? []).flatMap((body) => extractIssueReferences(body, identifier));
158
- const candidates = mergeCandidates(descriptionRefs, commentRefs);
158
+ const merged = mergeCandidates(descriptionRefs, commentRefs);
159
+ // Cap the number of candidates we'll resolve per call. A malicious
160
+ // (or merely careless) issue body containing hundreds of fake
161
+ // identifiers — `AAA-1, AAA-2, …, AAA-999` — would otherwise trigger
162
+ // 999 GraphQL roundtrips before deciding none resolve. Realistic
163
+ // human-authored bodies stay well under this cap.
164
+ const MAX_CANDIDATES_PER_CALL = 50;
165
+ const candidates = merged.slice(0, MAX_CANDIDATES_PER_CALL);
166
+ if (merged.length > MAX_CANDIDATES_PER_CALL) {
167
+ const overflow = merged.length - MAX_CANDIDATES_PER_CALL;
168
+ failed.push({
169
+ identifier: `+${overflow} more`,
170
+ reason: `Too many candidate references (${merged.length}); only the first ${MAX_CANDIDATES_PER_CALL} are processed.`,
171
+ });
172
+ }
159
173
  if (candidates.length === 0) {
160
174
  return { linked, skipped, failed };
161
175
  }
@@ -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,179 @@
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
+ import { logger } from "./logger.js";
29
+ const CACHE_VERSION = 1;
30
+ const CACHE_FILE_MODE = 0o644;
31
+ const CACHE_DIR_MODE = 0o700;
32
+ /**
33
+ * Resolve `<profile-dir>/cache/`. Profile-aware so caches don't bleed
34
+ * across profiles. The directory of the active profile's `configPath` is
35
+ * the canonical "profile dir" — this matches what `commands/init/shared.ts`
36
+ * uses.
37
+ */
38
+ function cacheDir() {
39
+ const active = resolveActiveProfile();
40
+ return path.join(path.dirname(active.configPath), "cache");
41
+ }
42
+ function cachePath(key) {
43
+ return path.join(cacheDir(), `${sanitizeKey(key)}.json`);
44
+ }
45
+ /**
46
+ * Cache keys may include filter values like `team:ENG` or `status:active`,
47
+ * which are POSIX-safe but we still strip path separators defensively so a
48
+ * malicious or buggy caller can't write outside the cache directory.
49
+ */
50
+ function sanitizeKey(key) {
51
+ return key.replace(/[/\\\0]/g, "_");
52
+ }
53
+ /**
54
+ * Atomic write: write to a sibling tmp file then rename. Mirrors the helper
55
+ * in `commands/init/shared.ts` and `auth/oauth-fs.ts`. Duplicated (12 lines)
56
+ * to keep the dependency graph clean — the wizard depends on cache callers
57
+ * indirectly, so importing wizard internals here would be a cycle hazard.
58
+ */
59
+ async function atomicWrite(targetPath, data) {
60
+ const tmpPath = `${targetPath}.tmp-${randomBytes(8).toString("hex")}`;
61
+ try {
62
+ await fs.writeFile(tmpPath, data, {
63
+ encoding: "utf8",
64
+ mode: CACHE_FILE_MODE,
65
+ });
66
+ await fs.chmod(tmpPath, CACHE_FILE_MODE);
67
+ await fs.rename(tmpPath, targetPath);
68
+ }
69
+ catch (err) {
70
+ await fs.unlink(tmpPath).catch(() => { });
71
+ throw err;
72
+ }
73
+ }
74
+ async function readEnvelope(key) {
75
+ let raw;
76
+ try {
77
+ raw = await fs.readFile(cachePath(key), "utf8");
78
+ }
79
+ catch {
80
+ // Missing dir, missing file, permission errors → treat as cache miss.
81
+ return null;
82
+ }
83
+ try {
84
+ const parsed = JSON.parse(raw);
85
+ // Reject envelopes from a future cache version we don't understand.
86
+ if (!parsed ||
87
+ typeof parsed !== "object" ||
88
+ parsed.v !== CACHE_VERSION ||
89
+ typeof parsed.expiresAt !== "number") {
90
+ return null;
91
+ }
92
+ return parsed;
93
+ }
94
+ catch {
95
+ // Corrupt JSON → treat as cache miss.
96
+ return null;
97
+ }
98
+ }
99
+ async function writeEnvelope(key, envelope) {
100
+ const dir = cacheDir();
101
+ await fs.mkdir(dir, { recursive: true, mode: CACHE_DIR_MODE });
102
+ await atomicWrite(cachePath(key), JSON.stringify(envelope));
103
+ }
104
+ /**
105
+ * Read-through cache wrapper.
106
+ *
107
+ * `key` — stable string identifier including any filter params
108
+ * (e.g. `teams-list`, `labels-list-team:ENG`)
109
+ * `ttlSeconds` — lifetime; `0` disables the cache entirely (always
110
+ * refetch, never write).
111
+ * `fetcher` — async function returning the fresh data on a miss.
112
+ * `options.bypass` — force a refetch even when an unexpired envelope
113
+ * exists. Still rewrites on success.
114
+ */
115
+ export async function cached(key, ttlSeconds, fetcher, options) {
116
+ // TTL = 0 disables caching: never read, never write.
117
+ if (ttlSeconds <= 0) {
118
+ return fetcher();
119
+ }
120
+ const now = Date.now();
121
+ if (!options?.bypass) {
122
+ const envelope = await readEnvelope(key);
123
+ if (envelope && envelope.expiresAt > now) {
124
+ return envelope.data;
125
+ }
126
+ }
127
+ const data = await fetcher();
128
+ const envelope = {
129
+ v: CACHE_VERSION,
130
+ key,
131
+ fetchedAt: now,
132
+ expiresAt: now + ttlSeconds * 1000,
133
+ data,
134
+ };
135
+ try {
136
+ await writeEnvelope(key, envelope);
137
+ }
138
+ catch (err) {
139
+ // Cache writes are best-effort — log to stderr and return the data
140
+ // anyway so a flaky disk doesn't break the user's command.
141
+ const msg = err instanceof Error ? err.message : String(err);
142
+ logger.error(`[disk-cache] write failed for "${key}": ${msg}`);
143
+ }
144
+ return data;
145
+ }
146
+ /**
147
+ * Clear cached entries. With no `prefix`, removes the entire cache
148
+ * directory. With a `prefix`, only deletes envelopes whose sanitized key
149
+ * starts with it. Errors (missing dir, permission) are swallowed so this is
150
+ * safe to call from tests.
151
+ */
152
+ export async function clearCache(prefix) {
153
+ const dir = cacheDir();
154
+ if (!prefix) {
155
+ await fs.rm(dir, { recursive: true, force: true });
156
+ return;
157
+ }
158
+ const sanitized = sanitizeKey(prefix);
159
+ let entries;
160
+ try {
161
+ entries = await fs.readdir(dir);
162
+ }
163
+ catch {
164
+ return;
165
+ }
166
+ await Promise.all(entries
167
+ .filter((name) => name.endsWith(".json") && name.startsWith(sanitized))
168
+ .map((name) => fs.unlink(path.join(dir, name)).catch(() => { })));
169
+ }
170
+ /** Resolved cache TTL for command call sites: respects --no-cache + config. */
171
+ export function resolveCacheTTL(args) {
172
+ if (args.noCacheFlag) {
173
+ return 0;
174
+ }
175
+ if (args.configTTL === undefined) {
176
+ return 3600;
177
+ }
178
+ return args.configTTL;
179
+ }