@enrichlayer/el-linear 1.7.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 (62) hide show
  1. package/README.md +69 -1
  2. package/claude-skills/linear-operations/SKILL.md +55 -0
  3. package/dist/auth/oauth-callback.js +14 -7
  4. package/dist/auth/oauth-fs.d.ts +22 -0
  5. package/dist/auth/oauth-fs.js +77 -6
  6. package/dist/auth/oauth-headless.d.ts +13 -8
  7. package/dist/auth/oauth-headless.js +18 -11
  8. package/dist/auth/oauth-storage.d.ts +11 -3
  9. package/dist/auth/oauth-storage.js +15 -8
  10. package/dist/auth/token-resolver.d.ts +9 -0
  11. package/dist/auth/token-resolver.js +57 -37
  12. package/dist/commands/init/index.js +2 -0
  13. package/dist/commands/init/oauth.d.ts +6 -0
  14. package/dist/commands/init/oauth.js +8 -2
  15. package/dist/commands/init/token.d.ts +0 -8
  16. package/dist/commands/init/token.js +13 -1
  17. package/dist/commands/issues/branch.d.ts +17 -0
  18. package/dist/commands/issues/branch.js +40 -0
  19. package/dist/commands/issues/description.d.ts +89 -0
  20. package/dist/commands/issues/description.js +187 -0
  21. package/dist/commands/issues.js +34 -215
  22. package/dist/commands/labels.js +7 -5
  23. package/dist/commands/profile/migrate-legacy.js +10 -33
  24. package/dist/commands/profile.js +1 -10
  25. package/dist/commands/projects.d.ts +5 -1
  26. package/dist/commands/projects.js +135 -64
  27. package/dist/commands/read-shortcut.d.ts +6 -0
  28. package/dist/commands/read-shortcut.js +6 -1
  29. package/dist/commands/refs.js +2 -1
  30. package/dist/commands/templates.js +127 -1
  31. package/dist/commands/users.js +2 -1
  32. package/dist/config/config.js +31 -9
  33. package/dist/config/issue-validation.js +1 -1
  34. package/dist/config/paths.d.ts +11 -0
  35. package/dist/config/paths.js +44 -6
  36. package/dist/config/term-enforcer.js +1 -1
  37. package/dist/main.js +37 -2
  38. package/dist/queries/templates.d.ts +3 -0
  39. package/dist/queries/templates.js +43 -0
  40. package/dist/utils/auth.js +7 -9
  41. package/dist/utils/auto-link-references.js +15 -1
  42. package/dist/utils/disk-cache.js +2 -1
  43. package/dist/utils/formatters/summary.d.ts +62 -0
  44. package/dist/utils/formatters/summary.js +755 -0
  45. package/dist/utils/graphql-issues-service.d.ts +106 -3
  46. package/dist/utils/graphql-issues-service.js +51 -37
  47. package/dist/utils/issue-reference-extractor.d.ts +9 -1
  48. package/dist/utils/issue-reference-extractor.js +16 -9
  49. package/dist/utils/issue-reference-wrapper.js +1 -54
  50. package/dist/utils/linear-service.d.ts +7 -3
  51. package/dist/utils/linear-service.js +27 -5
  52. package/dist/utils/markdown-prosemirror.js +17 -1
  53. package/dist/utils/mention-resolver.js +17 -5
  54. package/dist/utils/output.d.ts +5 -1
  55. package/dist/utils/output.js +28 -1
  56. package/dist/utils/protected-ranges.d.ts +33 -0
  57. package/dist/utils/protected-ranges.js +73 -0
  58. package/dist/utils/table-formatter.d.ts +36 -0
  59. package/dist/utils/table-formatter.js +46 -24
  60. package/dist/utils/validators.d.ts +9 -1
  61. package/dist/utils/validators.js +10 -0
  62. package/package.json +1 -1
@@ -7,74 +7,129 @@ import { createLinearService } from "../utils/linear-service.js";
7
7
  import { logger } from "../utils/logger.js";
8
8
  import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
9
9
  import { getRootOpts } from "../utils/root-opts.js";
10
+ import { renderCsv, renderFixedWidthTable, renderMarkdownTable, } from "../utils/table-formatter.js";
10
11
  import { isUuid } from "../utils/uuid.js";
11
12
  import { splitList } from "../utils/validators.js";
12
- function formatProjectsOutput(projects, format, fieldNames) {
13
- const fields = fieldNames ?? [
14
- "name",
15
- "state",
16
- "progress",
17
- "teams",
18
- "lead",
19
- "targetDate",
20
- ];
21
- const rows = projects.map((p) => {
22
- const row = {};
23
- for (const f of fields) {
24
- switch (f) {
25
- case "name":
26
- row[f] = p.name;
27
- break;
28
- case "state":
29
- row[f] = p.state ?? "";
30
- break;
31
- case "progress":
32
- row[f] =
33
- p.progress !== undefined ? `${Math.round(p.progress * 100)}%` : "";
34
- break;
35
- case "teams":
36
- row[f] = p.teams?.map((t) => t.key).join(", ") ?? "";
37
- break;
38
- case "lead":
39
- row[f] = p.lead?.name ?? "";
40
- break;
41
- case "targetDate":
42
- row[f] = p.targetDate ?? "";
43
- break;
44
- case "id":
45
- row[f] = p.id;
46
- break;
47
- default:
48
- row[f] = String(p[f] ?? "");
49
- }
13
+ const VALID_PROJECT_STATES = new Set([
14
+ "backlog",
15
+ "planned",
16
+ "started",
17
+ "paused",
18
+ "completed",
19
+ "canceled",
20
+ ]);
21
+ function parseStateList(value, flagName) {
22
+ const states = splitList(value).map((s) => s.toLowerCase());
23
+ for (const s of states) {
24
+ if (!VALID_PROJECT_STATES.has(s)) {
25
+ throw new Error(`Invalid state "${s}" for ${flagName}. Valid: ${[...VALID_PROJECT_STATES].join(", ")}.`);
50
26
  }
51
- return row;
52
- });
27
+ }
28
+ return states;
29
+ }
30
+ export function resolveProjectStateFilter(options) {
31
+ const flags = [options.state, options.excludeState, options.active].filter(Boolean).length;
32
+ if (flags > 1) {
33
+ throw new Error("--state, --exclude-state, and --active are mutually exclusive.");
34
+ }
35
+ if (options.active) {
36
+ return { excludeStates: ["completed", "canceled"] };
37
+ }
38
+ if (options.state) {
39
+ return { states: parseStateList(options.state, "--state") };
40
+ }
41
+ if (options.excludeState) {
42
+ return {
43
+ excludeStates: parseStateList(options.excludeState, "--exclude-state"),
44
+ };
45
+ }
46
+ return {};
47
+ }
48
+ // ── Project column definitions ────────────────────────────────
49
+ //
50
+ // Pre-fix this file inlined an 80-line `formatProjectsOutput`
51
+ // function that hand-rolled column width math, table padding, CSV
52
+ // quoting, and markdown pipe syntax — all duplicating
53
+ // `utils/table-formatter.ts`. Now we declare `ColumnDef<LinearProject>`
54
+ // arrays and let the shared renderers do the work. ALL-938 cleanup.
55
+ const PROJECT_COLUMNS = {
56
+ name: { key: "name", header: "Name", width: 32, extract: (p) => p.name },
57
+ state: {
58
+ key: "state",
59
+ header: "State",
60
+ width: 10,
61
+ extract: (p) => p.state ?? "",
62
+ },
63
+ progress: {
64
+ key: "progress",
65
+ header: "Progress",
66
+ width: 10,
67
+ extract: (p) => p.progress !== undefined ? `${Math.round(p.progress * 100)}%` : "",
68
+ },
69
+ teams: {
70
+ key: "teams",
71
+ header: "Teams",
72
+ width: 18,
73
+ extract: (p) => p.teams?.map((t) => t.key).join(", ") ?? "",
74
+ },
75
+ lead: {
76
+ key: "lead",
77
+ header: "Lead",
78
+ width: 18,
79
+ extract: (p) => p.lead?.name ?? "",
80
+ },
81
+ targetDate: {
82
+ key: "targetDate",
83
+ header: "Target",
84
+ width: 12,
85
+ extract: (p) => p.targetDate ?? "",
86
+ },
87
+ id: { key: "id", header: "ID", width: 38, extract: (p) => p.id },
88
+ };
89
+ const PROJECT_MD_COLUMNS = {
90
+ name: { header: "Name", extract: (p) => p.name },
91
+ state: { header: "State", extract: (p) => p.state ?? "" },
92
+ progress: {
93
+ header: "Progress",
94
+ extract: (p) => p.progress !== undefined ? `${Math.round(p.progress * 100)}%` : "",
95
+ },
96
+ teams: {
97
+ header: "Teams",
98
+ extract: (p) => p.teams?.map((t) => t.key).join(", ") ?? "",
99
+ },
100
+ lead: { header: "Lead", extract: (p) => p.lead?.name ?? "" },
101
+ targetDate: { header: "Target", extract: (p) => p.targetDate ?? "" },
102
+ id: { header: "ID", extract: (p) => p.id },
103
+ };
104
+ const DEFAULT_PROJECT_COLUMNS = [
105
+ "name",
106
+ "state",
107
+ "progress",
108
+ "teams",
109
+ "lead",
110
+ "targetDate",
111
+ ];
112
+ function formatProjectsOutput(projects, format, fieldNames) {
113
+ const keys = fieldNames ?? DEFAULT_PROJECT_COLUMNS;
53
114
  if (format === "csv") {
54
- logger.info(fields.join(","));
55
- for (const row of rows) {
56
- logger.info(fields.map((f) => `"${(row[f] ?? "").replace(/"/g, '""')}"`).join(","));
57
- }
115
+ const columns = keys
116
+ .map((k) => PROJECT_COLUMNS[k])
117
+ .filter((c) => c !== undefined);
118
+ logger.info(renderCsv(projects, columns));
58
119
  return;
59
120
  }
60
- // table and md formats
61
- const widths = fields.map((f) => Math.max(f.length, ...rows.map((r) => (r[f] ?? "").length)));
62
- const separator = format === "md"
63
- ? `| ${widths.map((w) => "-".repeat(w)).join(" | ")} |`
64
- : widths.map((w) => "-".repeat(w + 2)).join("+");
65
- const header = format === "md"
66
- ? `| ${fields.map((f, i) => f.padEnd(widths[i])).join(" | ")} |`
67
- : fields.map((f, i) => ` ${f.padEnd(widths[i])} `).join("|");
68
- logger.info(header);
69
- logger.info(separator);
70
- for (const row of rows) {
71
- const line = format === "md"
72
- ? `| ${fields.map((f, i) => (row[f] ?? "").padEnd(widths[i])).join(" | ")} |`
73
- : fields
74
- .map((f, i) => ` ${(row[f] ?? "").padEnd(widths[i])} `)
75
- .join("|");
76
- logger.info(line);
121
+ if (format === "md") {
122
+ const columns = keys
123
+ .map((k) => PROJECT_MD_COLUMNS[k])
124
+ .filter((c) => c !== undefined);
125
+ logger.info(renderMarkdownTable(projects, columns));
126
+ return;
77
127
  }
128
+ // Default: fixed-width text table.
129
+ const columns = keys
130
+ .map((k) => PROJECT_COLUMNS[k])
131
+ .filter((c) => c !== undefined);
132
+ logger.info(renderFixedWidthTable(projects, columns));
78
133
  }
79
134
  async function resolveProjectWithTeams(graphQLService, projectNameOrId) {
80
135
  if (isUuid(projectNameOrId)) {
@@ -303,18 +358,34 @@ export function setupProjectsCommands(program) {
303
358
  .command("list")
304
359
  .description("List projects")
305
360
  .option("-l, --limit <number>", "limit results", "100")
306
- .option("--format <format>", "output format (json, table, md, csv)", "json")
361
+ .option("--format <format>", "output format (json, summary, table, md, csv)", "json")
307
362
  .option("--fields <fields>", "columns for table/csv (comma-separated: name,state,progress,teams,lead,targetDate)")
363
+ .option("--name <substring>", "filter by case-insensitive substring on project name")
364
+ .option("--state <names>", "include only projects in these states (comma-separated: backlog, planned, started, paused, completed, canceled)")
365
+ .option("--exclude-state <names>", "exclude projects in these states (comma-separated). Mutually exclusive with --state.")
366
+ .option("--active", "shorthand for --exclude-state completed,canceled (mutually exclusive with --state / --exclude-state)")
308
367
  .action(handleAsyncCommand(async (options, command) => {
309
368
  const rootOpts = getRootOpts(command);
310
369
  const limit = Number.parseInt(options.limit, 10);
370
+ const nameFilter = options.name;
371
+ const stateFilter = resolveProjectStateFilter(options);
311
372
  const ttl = resolveCacheTTL({
312
373
  configTTL: loadConfig().cacheTTLSeconds,
313
374
  noCacheFlag: rootOpts.cache === false,
314
375
  });
315
- const result = await cached(`projects-list-limit:${limit}`, ttl, async () => {
376
+ // Cache key includes filter inputs so different filter combinations
377
+ // don't collide.
378
+ const cacheKey = `projects-list-limit:${limit}` +
379
+ `-name:${nameFilter ?? "_all"}` +
380
+ `-states:${stateFilter.states?.join(",") ?? "_any"}` +
381
+ `-excl:${stateFilter.excludeStates?.join(",") ?? "_none"}`;
382
+ const result = await cached(cacheKey, ttl, async () => {
316
383
  const service = await createLinearService(rootOpts);
317
- return service.getProjects(limit);
384
+ return service.getProjects(limit, {
385
+ nameFilter,
386
+ states: stateFilter.states,
387
+ excludeStates: stateFilter.excludeStates,
388
+ });
318
389
  });
319
390
  const format = options.format;
320
391
  if (format === "table" ||
@@ -4,3 +4,9 @@ import type { Command } from "commander";
4
4
  * issue identifiers so `el-linear ADM-652` works without the `issues read` prefix.
5
5
  */
6
6
  export declare function setupReadShortcut(program: Command): void;
7
+ /**
8
+ * Shared `issues read` body — exported so the top-level shortcut and
9
+ * `issues read` (in `commands/issues.ts`) both call one implementation
10
+ * instead of carrying byte-equivalent duplicates. ALL-938 cleanup.
11
+ */
12
+ export declare function readIssues(issueIds: string[], _options: Record<string, unknown>, command: Command): Promise<void>;
@@ -49,7 +49,12 @@ export function setupReadShortcut(program) {
49
49
  return originalParse(argv, parseOptions);
50
50
  };
51
51
  }
52
- async function readIssues(issueIds, _options, command) {
52
+ /**
53
+ * Shared `issues read` body — exported so the top-level shortcut and
54
+ * `issues read` (in `commands/issues.ts`) both call one implementation
55
+ * instead of carrying byte-equivalent duplicates. ALL-938 cleanup.
56
+ */
57
+ export async function readIssues(issueIds, _options, command) {
53
58
  const rootOpts = getRootOpts(command);
54
59
  const graphQLService = await createGraphQLService(rootOpts);
55
60
  const linearService = await createLinearService(rootOpts);
@@ -3,6 +3,7 @@ import { createGraphQLService } from "../utils/graphql-service.js";
3
3
  import { extractIssueReferences } from "../utils/issue-reference-extractor.js";
4
4
  import { wrapIssueReferencesAsLinks, } from "../utils/issue-reference-wrapper.js";
5
5
  import { createLinearService } from "../utils/linear-service.js";
6
+ import { logger } from "../utils/logger.js";
6
7
  import { handleAsyncCommand } from "../utils/output.js";
7
8
  import { getRootOpts } from "../utils/root-opts.js";
8
9
  import { validateReferences } from "../utils/validate-references.js";
@@ -73,7 +74,7 @@ async function handleWrap(options, command) {
73
74
  };
74
75
  if (!validate) {
75
76
  // stderr advisory only — keeps stdout a clean text stream for piping.
76
- process.stderr.write("el-linear refs wrap: --no-validate set; emitting links for every regex match without checking the workspace.\n");
77
+ logger.error("el-linear refs wrap: --no-validate set; emitting links for every regex match without checking the workspace.");
77
78
  }
78
79
  const wrapped = await wrapRefsCore({ text, target, validate }, deps);
79
80
  process.stdout.write(wrapped);
@@ -1,4 +1,5 @@
1
- import { TEMPLATE_BY_ID_QUERY, TEMPLATES_LIST_QUERY, } from "../queries/templates.js";
1
+ import { readFileSync } from "node:fs";
2
+ import { TEMPLATE_BY_ID_QUERY, TEMPLATE_CREATE_MUTATION, TEMPLATE_DELETE_MUTATION, TEMPLATE_UPDATE_MUTATION, TEMPLATES_LIST_QUERY, } from "../queries/templates.js";
2
3
  import { createGraphQLService } from "../utils/graphql-service.js";
3
4
  import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
4
5
  import { getRootOpts } from "../utils/root-opts.js";
@@ -56,4 +57,129 @@ export function setupTemplatesCommands(program) {
56
57
  }
57
58
  outputSuccess(template);
58
59
  }));
60
+ templates
61
+ .command("create")
62
+ .description("Create a new issue / document / project template")
63
+ .requiredOption("--type <type>", "template kind (issue, document, project, recurringIssue)")
64
+ .requiredOption("--name <name>", "template display name")
65
+ .requiredOption("--team-id <id>", "team id (UUID) the template belongs to. See `el-linear teams list`")
66
+ .option("--description <text>", "description shown in the template picker")
67
+ .option("--data <json>", "templateData as a JSON string (typically { title, description, labelIds, priority })")
68
+ .option("--data-file <path>", "templateData read from a JSON file (relative path). Mutually exclusive with --data")
69
+ .option("--allow-absolute", "allow --data-file to read absolute or `..`-traversing paths (file contents are sent to Linear's API)")
70
+ .option("--icon <name>", "Lucide icon name shown in the picker")
71
+ .option("--color <hex>", "color (hex) for the template badge")
72
+ .action(handleAsyncCommand(async (options, command) => {
73
+ const rootOpts = getRootOpts(command);
74
+ const graphQLService = await createGraphQLService(rootOpts);
75
+ const templateData = parseTemplateData(options);
76
+ const input = {
77
+ type: options.type,
78
+ name: options.name,
79
+ teamId: options.teamId,
80
+ };
81
+ if (options.description)
82
+ input.description = options.description;
83
+ if (templateData !== undefined)
84
+ input.templateData = templateData;
85
+ if (options.icon)
86
+ input.icon = options.icon;
87
+ if (options.color)
88
+ input.color = options.color;
89
+ const result = await graphQLService.rawRequest(TEMPLATE_CREATE_MUTATION, { input });
90
+ const payload = result.templateCreate;
91
+ if (!payload?.success) {
92
+ throw new Error("templateCreate returned success=false");
93
+ }
94
+ outputSuccess(payload.template);
95
+ }));
96
+ templates
97
+ .command("update <templateId>")
98
+ .description("Update a template's name, description, templateData, icon, or color")
99
+ .option("--name <name>", "new display name")
100
+ .option("--description <text>", "new description")
101
+ .option("--data <json>", "new templateData as a JSON string")
102
+ .option("--data-file <path>", "new templateData read from a JSON file (relative path)")
103
+ .option("--allow-absolute", "allow --data-file to read absolute or `..`-traversing paths")
104
+ .option("--icon <name>", "Lucide icon name")
105
+ .option("--color <hex>", "color (hex)")
106
+ .option("--team-id <id>", "move the template to a different team")
107
+ .action(handleAsyncCommand(async (templateId, options, command) => {
108
+ const rootOpts = getRootOpts(command);
109
+ const graphQLService = await createGraphQLService(rootOpts);
110
+ const input = {};
111
+ if (options.name)
112
+ input.name = options.name;
113
+ if (options.description !== undefined) {
114
+ input.description = options.description;
115
+ }
116
+ const templateData = parseTemplateData(options);
117
+ if (templateData !== undefined)
118
+ input.templateData = templateData;
119
+ if (options.icon)
120
+ input.icon = options.icon;
121
+ if (options.color)
122
+ input.color = options.color;
123
+ if (options.teamId)
124
+ input.teamId = options.teamId;
125
+ if (Object.keys(input).length === 0) {
126
+ throw new Error("templates update: at least one of --name, --description, --data, --data-file, --icon, --color, --team-id is required");
127
+ }
128
+ const result = await graphQLService.rawRequest(TEMPLATE_UPDATE_MUTATION, { id: templateId, input });
129
+ const payload = result.templateUpdate;
130
+ if (!payload?.success) {
131
+ throw new Error("templateUpdate returned success=false");
132
+ }
133
+ outputSuccess(payload.template);
134
+ }));
135
+ templates
136
+ .command("delete <templateId>")
137
+ .description("Delete a template (cannot be undone)")
138
+ .action(handleAsyncCommand(async (templateId, _options, command) => {
139
+ const rootOpts = getRootOpts(command);
140
+ const graphQLService = await createGraphQLService(rootOpts);
141
+ const result = await graphQLService.rawRequest(TEMPLATE_DELETE_MUTATION, { id: templateId });
142
+ const payload = result.templateDelete;
143
+ if (!payload?.success) {
144
+ throw new Error("templateDelete returned success=false");
145
+ }
146
+ outputSuccess({ id: templateId, deleted: true });
147
+ }));
148
+ }
149
+ /**
150
+ * Resolve `--data <json>` or `--data-file <path>` into the parsed
151
+ * templateData object. Returns undefined when neither flag was set
152
+ * so the caller can omit the field from the input object.
153
+ */
154
+ function parseTemplateData(options) {
155
+ if (options.data && options.dataFile) {
156
+ throw new Error("Pass either --data or --data-file, not both");
157
+ }
158
+ if (options.data) {
159
+ try {
160
+ return JSON.parse(options.data);
161
+ }
162
+ catch (err) {
163
+ throw new Error(`--data is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
164
+ }
165
+ }
166
+ if (options.dataFile) {
167
+ const dataFile = String(options.dataFile);
168
+ // Reject absolute paths and `..` traversal unless explicitly
169
+ // allowed. CI invocations or scripted callers can pass an
170
+ // attacker-controlled path otherwise (e.g. `~/.aws/credentials.json`,
171
+ // `/etc/passwd`) and have its contents flow into Linear's API
172
+ // via the `templateData` field.
173
+ if (!options.allowAbsolute && /^(\/|\.\.\/|\.\.\\)/.test(dataFile)) {
174
+ throw new Error(`--data-file ${dataFile} resolves outside the current directory. Pass --allow-absolute to opt in (sends file contents to Linear's API).`);
175
+ }
176
+ const raw = readFileSync(dataFile, "utf8");
177
+ try {
178
+ return JSON.parse(raw);
179
+ }
180
+ catch (err) {
181
+ throw new Error(`--data-file ${dataFile} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
182
+ }
183
+ }
184
+ return undefined;
59
185
  }
@@ -8,11 +8,12 @@ export function setupUsersCommands(program) {
8
8
  .command("list")
9
9
  .description("List all users")
10
10
  .option("--active", "Only show active users")
11
+ .option("--name <substring>", "filter by case-insensitive substring on user name")
11
12
  .option("-l, --limit <number>", "limit results", "100")
12
13
  .action(handleAsyncCommand(async (options, command) => {
13
14
  const rootOpts = getRootOpts(command);
14
15
  const service = await createLinearService(rootOpts);
15
- const result = await service.getUsers(options.active, Number.parseInt(options.limit, 10));
16
+ const result = await service.getUsers(options.active, Number.parseInt(options.limit, 10), options.name);
16
17
  outputSuccess({ data: result, meta: { count: result.length } });
17
18
  }));
18
19
  }
@@ -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" +
@@ -31,9 +31,20 @@ export interface ProfileFsOps {
31
31
  readFileSync: (p: string) => string;
32
32
  existsSync: (p: string) => boolean;
33
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;
34
41
  /**
35
42
  * Set the active profile for the duration of this process. Pass `null`
36
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`.
37
48
  */
38
49
  export declare function setActiveProfileForSession(name: string | null): void;
39
50
  /** Read-only accessor for the per-session override (test seam). */
@@ -44,6 +44,15 @@ const DEFAULT_FS_OPS = {
44
44
  readFileSync: (p) => fs.readFileSync(p, "utf8"),
45
45
  existsSync: (p) => fs.existsSync(p),
46
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
+ }
47
56
  /**
48
57
  * Per-process override for the active profile name. Set by main.ts when
49
58
  * `--profile <name>` is passed (highest priority).
@@ -52,9 +61,25 @@ let sessionProfileOverride = null;
52
61
  /**
53
62
  * Set the active profile for the duration of this process. Pass `null`
54
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`.
55
68
  */
56
69
  export function setActiveProfileForSession(name) {
57
- 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;
58
83
  }
59
84
  /** Read-only accessor for the per-session override (test seam). */
60
85
  export function getSessionProfileOverride() {
@@ -66,11 +91,24 @@ export function getSessionProfileOverride() {
66
91
  * keep working without migration.
67
92
  */
68
93
  export function resolveActiveProfile(env = process.env, fsImpl = DEFAULT_FS_OPS) {
69
- const explicit = sessionProfileOverride ??
70
- (env.EL_LINEAR_PROFILE?.trim() || null) ??
71
- readActiveProfileMarker(fsImpl);
72
- if (explicit) {
73
- 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);
74
112
  }
75
113
  return {
76
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, "\\$&");