@enrichlayer/el-linear 1.10.0 → 1.15.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 (106) hide show
  1. package/README.md +126 -10
  2. package/claude-skills/linear-operations/SKILL.md +41 -1
  3. package/dist/auth/linear-credential.d.ts +27 -0
  4. package/dist/auth/linear-credential.js +1 -0
  5. package/dist/auth/oauth-app-config.d.ts +4 -3
  6. package/dist/auth/oauth-app-config.js +13 -2
  7. package/dist/auth/oauth-callback.d.ts +2 -3
  8. package/dist/auth/oauth-callback.js +2 -2
  9. package/dist/auth/oauth-client.d.ts +8 -2
  10. package/dist/auth/oauth-client.js +26 -0
  11. package/dist/auth/oauth-fs.d.ts +2 -1
  12. package/dist/auth/oauth-headless.d.ts +2 -1
  13. package/dist/auth/oauth-storage.d.ts +5 -1
  14. package/dist/auth/oauth-storage.js +1 -1
  15. package/dist/auth/oauth-token.d.ts +4 -3
  16. package/dist/auth/oauth-token.js +16 -4
  17. package/dist/auth/token-resolver.d.ts +14 -5
  18. package/dist/auth/token-resolver.js +6 -1
  19. package/dist/commands/batch.js +18 -21
  20. package/dist/commands/comments.js +5 -5
  21. package/dist/commands/config.js +178 -5
  22. package/dist/commands/init/aliases.js +1 -1
  23. package/dist/commands/init/defaults.d.ts +2 -1
  24. package/dist/commands/init/index.js +45 -35
  25. package/dist/commands/init/oauth.d.ts +4 -1
  26. package/dist/commands/init/oauth.js +22 -4
  27. package/dist/commands/init/shared.d.ts +24 -2
  28. package/dist/commands/init/shared.js +35 -4
  29. package/dist/commands/init/token.d.ts +3 -3
  30. package/dist/commands/init/token.js +5 -24
  31. package/dist/commands/init/workspace.d.ts +2 -1
  32. package/dist/commands/init/workspace.js +1 -1
  33. package/dist/commands/introspect.d.ts +27 -0
  34. package/dist/commands/introspect.js +178 -0
  35. package/dist/commands/issues/branch.js +9 -1
  36. package/dist/commands/issues/relations.d.ts +3 -14
  37. package/dist/commands/issues/relations.js +3 -3
  38. package/dist/commands/issues.js +222 -43
  39. package/dist/commands/labels.js +2 -1
  40. package/dist/commands/profile.js +1 -0
  41. package/dist/commands/projects.d.ts +2 -0
  42. package/dist/commands/projects.js +91 -7
  43. package/dist/commands/read-shortcut.d.ts +1 -1
  44. package/dist/commands/read-shortcut.js +28 -8
  45. package/dist/commands/refs.js +67 -8
  46. package/dist/commands/search.js +30 -5
  47. package/dist/commands/users.js +4 -2
  48. package/dist/config/config.d.ts +99 -1
  49. package/dist/config/config.js +264 -52
  50. package/dist/config/error-enrichment.d.ts +62 -0
  51. package/dist/config/error-enrichment.js +417 -0
  52. package/dist/config/issue-validation.d.ts +37 -0
  53. package/dist/config/issue-validation.js +63 -1
  54. package/dist/config/paths.d.ts +2 -8
  55. package/dist/config/paths.js +4 -2
  56. package/dist/config/resolver.d.ts +8 -1
  57. package/dist/config/resolver.js +9 -2
  58. package/dist/main.js +13 -1
  59. package/dist/queries/comments-types.d.ts +15 -9
  60. package/dist/queries/common.d.ts +2 -2
  61. package/dist/queries/common.js +8 -0
  62. package/dist/queries/documents-types.d.ts +4 -3
  63. package/dist/queries/introspect-types.d.ts +8 -7
  64. package/dist/queries/issues-types.d.ts +92 -27
  65. package/dist/queries/issues.d.ts +49 -10
  66. package/dist/queries/issues.js +125 -5
  67. package/dist/queries/labels-types.d.ts +7 -6
  68. package/dist/queries/project-milestones-types.d.ts +5 -4
  69. package/dist/queries/project-milestones.d.ts +1 -1
  70. package/dist/queries/projects-types.d.ts +8 -7
  71. package/dist/queries/releases-types.d.ts +5 -4
  72. package/dist/queries/search-types.d.ts +28 -12
  73. package/dist/queries/templates-types.d.ts +3 -2
  74. package/dist/types/linear.d.ts +13 -1
  75. package/dist/utils/auto-link-references.d.ts +3 -3
  76. package/dist/utils/auto-link-references.js +1 -10
  77. package/dist/utils/extract-field.d.ts +19 -0
  78. package/dist/utils/extract-field.js +99 -0
  79. package/dist/utils/file-service.d.ts +6 -13
  80. package/dist/utils/file-service.js +0 -2
  81. package/dist/utils/formatters/summary.js +6 -1
  82. package/dist/utils/graphql-issues-service.d.ts +101 -45
  83. package/dist/utils/graphql-issues-service.js +252 -39
  84. package/dist/utils/graphql-service.d.ts +10 -12
  85. package/dist/utils/graphql-service.js +0 -3
  86. package/dist/utils/issue-reference-extractor.d.ts +7 -0
  87. package/dist/utils/issue-reference-extractor.js +5 -3
  88. package/dist/utils/issues-service-bootstrap.d.ts +28 -0
  89. package/dist/utils/issues-service-bootstrap.js +27 -0
  90. package/dist/utils/linear-service.d.ts +21 -14
  91. package/dist/utils/linear-service.js +73 -11
  92. package/dist/utils/markdown-prosemirror.js +12 -12
  93. package/dist/utils/mention-resolver.js +1 -1
  94. package/dist/utils/output.d.ts +81 -3
  95. package/dist/utils/output.js +61 -6
  96. package/dist/utils/project-slug.d.ts +21 -0
  97. package/dist/utils/project-slug.js +45 -0
  98. package/dist/utils/protected-ranges.d.ts +14 -0
  99. package/dist/utils/protected-ranges.js +88 -2
  100. package/dist/utils/sanitize-for-log.d.ts +24 -0
  101. package/dist/utils/sanitize-for-log.js +38 -0
  102. package/dist/utils/table-formatter.js +24 -0
  103. package/dist/utils/validators.d.ts +7 -2
  104. package/dist/utils/validators.js +6 -0
  105. package/dist/utils/workspace-url.js +20 -4
  106. package/package.json +2 -2
@@ -1,14 +1,12 @@
1
- import { resolveAssignee, resolveLabels, resolveTeam, } from "../config/resolver.js";
2
- import { GraphQLIssuesService } from "../utils/graphql-issues-service.js";
3
- import { createGraphQLService } from "../utils/graphql-service.js";
4
- import { createLinearService } from "../utils/linear-service.js";
1
+ import { resolveAssignee, resolveLabels, resolveMember, resolveTeam, } from "../config/resolver.js";
2
+ import { createIssuesService } from "../utils/issues-service-bootstrap.js";
5
3
  import { logger } from "../utils/logger.js";
6
4
  import { handleAsyncCommand, outputSuccess, outputWarning, } from "../utils/output.js";
7
5
  import { getRootOpts } from "../utils/root-opts.js";
8
6
  import { splitList } from "../utils/validators.js";
9
7
  /**
10
8
  * Parse the --filter string into structured search arguments.
11
- * Format: "status:Backlog team:DEV label:Bug assignee:Alice project:Sprint12"
9
+ * Format: "status:Backlog team:DEV label:Bug assignee:Alice delegate:Claude project:Sprint12"
12
10
  */
13
11
  function parseFilterString(filter) {
14
12
  const result = {};
@@ -30,21 +28,28 @@ function parseFilterString(filter) {
30
28
  * Returns LinearIssue[] so we can show a preview.
31
29
  */
32
30
  async function resolveTargetIssues(options, rootOpts) {
33
- const graphQLService = await createGraphQLService(rootOpts);
34
- const linearService = await createLinearService(rootOpts);
35
- const issuesService = new GraphQLIssuesService(graphQLService, linearService);
31
+ const { issuesService } = await createIssuesService(rootOpts);
36
32
  if (options.issues) {
37
33
  const ids = splitList(options.issues);
38
34
  return Promise.all(ids.map((id) => issuesService.getIssueById(id)));
39
35
  }
40
36
  if (options.filter) {
41
37
  const filters = parseFilterString(options.filter);
38
+ // Typed as SearchIssueArgs (not Record<string, unknown>) so a future
39
+ // rename of any field — like the DEV-4068 T4 `projectId` → `project`
40
+ // migration — surfaces here at compile time instead of silently
41
+ // dropping the filter.
42
42
  const searchArgs = {
43
43
  teamId: filters.team ? resolveTeam(filters.team) : undefined,
44
44
  assigneeId: filters.assignee
45
45
  ? await resolveAssignee(filters.assignee, rootOpts)
46
46
  : undefined,
47
- projectId: filters.project || undefined,
47
+ delegateId: filters.delegate
48
+ ? resolveMember(filters.delegate)
49
+ : undefined,
50
+ project: filters.project
51
+ ? { kind: "id", id: filters.project }
52
+ : undefined,
48
53
  labelNames: filters.label ? splitList(filters.label) : undefined,
49
54
  status: filters.status ? splitList(filters.status) : undefined,
50
55
  limit: 50,
@@ -117,9 +122,7 @@ async function handleBatchAssign(options, command) {
117
122
  });
118
123
  return;
119
124
  }
120
- const graphQLService = await createGraphQLService(rootOpts);
121
- const linearService = await createLinearService(rootOpts);
122
- const issuesService = new GraphQLIssuesService(graphQLService, linearService);
125
+ const { issuesService } = await createIssuesService(rootOpts);
123
126
  const { results } = await executeBatch(issues, (issue) => issuesService.updateIssue({ id: issue.identifier, assigneeId }, "adding"));
124
127
  outputSuccess({
125
128
  action: "assign",
@@ -164,9 +167,7 @@ async function handleBatchLabel(options, command) {
164
167
  });
165
168
  return;
166
169
  }
167
- const graphQLService = await createGraphQLService(rootOpts);
168
- const linearService = await createLinearService(rootOpts);
169
- const issuesService = new GraphQLIssuesService(graphQLService, linearService);
170
+ const { issuesService } = await createIssuesService(rootOpts);
170
171
  const removeLower = removeLabels.map((l) => l.toLowerCase());
171
172
  const { results } = await executeBatch(issues, (issue) => {
172
173
  // Filter out labels to remove (by name, case-insensitive)
@@ -218,9 +219,7 @@ async function handleBatchMove(options, command) {
218
219
  });
219
220
  return;
220
221
  }
221
- const graphQLService = await createGraphQLService(rootOpts);
222
- const linearService = await createLinearService(rootOpts);
223
- const issuesService = new GraphQLIssuesService(graphQLService, linearService);
222
+ const { issuesService } = await createIssuesService(rootOpts);
224
223
  const { results } = await executeBatch(issues, (issue) => issuesService.updateIssue({ id: issue.identifier, projectId: options.project }, "adding"));
225
224
  outputSuccess({
226
225
  action: "move",
@@ -254,9 +253,7 @@ async function handleBatchStatus(options, command) {
254
253
  });
255
254
  return;
256
255
  }
257
- const graphQLService = await createGraphQLService(rootOpts);
258
- const linearService = await createLinearService(rootOpts);
259
- const issuesService = new GraphQLIssuesService(graphQLService, linearService);
256
+ const { issuesService } = await createIssuesService(rootOpts);
260
257
  const { results } = await executeBatch(issues, (issue) => issuesService.updateIssue({ id: issue.identifier, statusId: options.status }, "adding"));
261
258
  outputSuccess({
262
259
  action: "status",
@@ -18,13 +18,13 @@ import { getWorkspaceUrlKey } from "../utils/workspace-url.js";
18
18
  const BODY_DATA_ERROR_RE = /prosemirror|bodydata|invalid.*body/i;
19
19
  const ISSUE_IDENTIFIER_REGEX = /^[A-Z][A-Z0-9]*-\d+$/;
20
20
  function readBody(options) {
21
- if (options.file) {
22
- return readFileSync(options.file, "utf-8");
21
+ if (options.bodyFile) {
22
+ return readFileSync(options.bodyFile, "utf-8");
23
23
  }
24
24
  if (options.body) {
25
25
  return options.body;
26
26
  }
27
- throw new Error("Either --body or --file is required");
27
+ throw new Error("Either --body or --body-file is required");
28
28
  }
29
29
  async function fetchSelfUserId(graphQLService) {
30
30
  try {
@@ -250,7 +250,7 @@ export function setupCommentsCommands(program) {
250
250
  .description("Create new comment on issue.")
251
251
  .addHelpText("after", "\nBoth UUID and identifiers like ABC-123 are supported.\nBare references to known team members (e.g. 'Dima') are auto-converted to @mentions — pass --no-auto-mention to disable.\nIssue identifiers (e.g. DEV-123) are wrapped as markdown links and added as 'related' relations on the parent issue — pass --no-auto-link to disable.")
252
252
  .option("--body <body>", "comment body (inline)")
253
- .option("--file <path>", "read comment body from file")
253
+ .option("--body-file <path>", "read comment body from file")
254
254
  .option("--no-auto-mention", "do not auto-convert bare team-member names to @mentions")
255
255
  .option("--no-auto-link", "skip wrapping issue refs as markdown links and creating sidebar relations")
256
256
  .option("--footer <text>", "text appended to the comment body (overrides config.messageFooter)")
@@ -260,7 +260,7 @@ export function setupCommentsCommands(program) {
260
260
  .command("update <commentId>")
261
261
  .description("Update an existing comment.")
262
262
  .option("--body <body>", "new comment body (inline)")
263
- .option("--file <path>", "read new comment body from file")
263
+ .option("--body-file <path>", "read new comment body from file")
264
264
  .option("--no-auto-mention", "do not auto-convert bare team-member names to @mentions")
265
265
  .option("--no-auto-link", "skip wrapping issue refs as markdown links and creating sidebar relations")
266
266
  .action(handleAsyncCommand(handleUpdateComment));
@@ -1,5 +1,47 @@
1
- import { loadConfig } from "../config/config.js";
2
- import { outputSuccess } from "../utils/output.js";
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { getActiveTeamConfigInfo, getActiveTeamConfigPath, loadConfig, loadLocalConfig, } from "../config/config.js";
5
+ import { resolveActiveProfile } from "../config/paths.js";
6
+ import { handleAsyncCommand, outputSuccess, outputWarning, } from "../utils/output.js";
7
+ import { updateConfig } from "./init/shared.js";
8
+ /**
9
+ * Expand a leading `~` to the user's home directory. Shells expand `~` before
10
+ * the command sees it, but a literal `~/...` pasted from documentation reaches
11
+ * Node verbatim. We handle it so `el-linear config team set-path ~/git/...`
12
+ * works regardless of whether the shell did the expansion first.
13
+ */
14
+ function expandHome(p) {
15
+ if (p === "~")
16
+ return os.homedir();
17
+ if (p.startsWith("~/"))
18
+ return path.join(os.homedir(), p.slice(2));
19
+ return p;
20
+ }
21
+ /**
22
+ * Render the team-config source token returned by `getActiveTeamConfigInfo()`
23
+ * as a human-readable string for `config team show`. Kept next to the command
24
+ * (not in `config/config.ts`) so the wire format is owned by the renderer,
25
+ * not the resolver. DEV-4258.
26
+ */
27
+ function sourceLabel(source) {
28
+ switch (source) {
29
+ case "env":
30
+ return "EL_LINEAR_TEAM_CONFIG env var";
31
+ case "personal":
32
+ return "teamConfigPath in personal config";
33
+ case "marker":
34
+ return "auto-discovered via ~/.config/el-tools-root";
35
+ case null:
36
+ return null;
37
+ }
38
+ }
39
+ const LOCAL_SETTABLE_KEYS = [
40
+ "assigneeEmail",
41
+ "defaultAssignee",
42
+ "defaultPriority",
43
+ "cacheTTLSeconds",
44
+ ];
3
45
  export function setupConfigCommands(program) {
4
46
  const config = program
5
47
  .command("config")
@@ -7,9 +49,140 @@ export function setupConfigCommands(program) {
7
49
  config.action(() => config.help());
8
50
  config
9
51
  .command("show")
10
- .description("Show resolved configuration")
11
- .addHelpText("after", "\nDumps the merged config from ~/.config/el-linear/config.json.\n\nExamples:\n el-linear config show")
52
+ .description("Show resolved configuration (team file → personal config → local overrides)")
53
+ .addHelpText("after", "\nDumps the merged config from the active team file + config.json + local.json.\n\nExamples:\n el-linear config show")
54
+ .action(() => {
55
+ const data = loadConfig();
56
+ const teamConfig = getActiveTeamConfigPath();
57
+ const local = loadLocalConfig();
58
+ const out = { data };
59
+ if (teamConfig)
60
+ out.teamConfig = teamConfig;
61
+ if (Object.keys(local).length > 0)
62
+ out.local = local;
63
+ outputSuccess(out);
64
+ });
65
+ const local = config
66
+ .command("local")
67
+ .description("Manage user-local config overrides (local.json)");
68
+ local.action(() => local.help());
69
+ local
70
+ .command("show")
71
+ .description("Show the raw contents of local.json")
72
+ .addHelpText("after", `\nShows ~/.config/el-linear/local.json (or the per-profile equivalent).\n\nExamples:\n el-linear config local show`)
73
+ .action(() => {
74
+ outputSuccess({ data: loadLocalConfig() });
75
+ });
76
+ const team = config
77
+ .command("team")
78
+ .description("Manage the shared team config layer (teamConfigPath in personal config)");
79
+ team.action(() => team.help());
80
+ team
81
+ .command("show")
82
+ .description("Show the active team config layer: path, source (env var, personal config, or auto-discovered marker), file status, top-level keys it contributes")
83
+ .addHelpText("after", "\nResolves the team config in the same order loadConfig() does:\n EL_LINEAR_TEAM_CONFIG (env) > teamConfigPath (personal config) > ~/.config/el-tools-root marker\n\nExamples:\n el-linear config team show")
12
84
  .action(() => {
13
- outputSuccess({ data: loadConfig() });
85
+ const info = getActiveTeamConfigInfo();
86
+ const teamPath = info.path;
87
+ const source = sourceLabel(info.source);
88
+ const out = {
89
+ teamConfigPath: teamPath ?? null,
90
+ source,
91
+ };
92
+ if (teamPath) {
93
+ try {
94
+ const raw = fs.readFileSync(teamPath, "utf8");
95
+ const parsed = JSON.parse(raw);
96
+ out.exists = true;
97
+ out.valid = true;
98
+ out.providedKeys = Object.keys(parsed).sort();
99
+ }
100
+ catch (err) {
101
+ out.exists = fs.existsSync(teamPath);
102
+ out.valid = false;
103
+ out.error = err.message;
104
+ }
105
+ }
106
+ outputSuccess({ data: out });
14
107
  });
108
+ team
109
+ .command("set-path <path>")
110
+ .description("Write teamConfigPath into personal config.json. Path is resolved to an absolute path. The target file must exist and be valid JSON — a pointer to a missing or broken file is footgun-shaped and refused.")
111
+ .addHelpText("after", "\nUse this when adopting a shared team config (e.g. one checked into a tools repo):\n el-linear config team set-path ~/git/enrichlayer/tools/config/el-linear.shared.json\n\nRelative paths resolve against the current working directory; absolute paths are stored as-is. EL_LINEAR_TEAM_CONFIG in the environment still overrides the personal field when both are set — a warning surfaces in that case.")
112
+ .action(handleAsyncCommand(async (rawPath) => {
113
+ const resolved = path.resolve(expandHome(rawPath));
114
+ if (!fs.existsSync(resolved)) {
115
+ throw new Error(`Team config file not found at ${resolved}. Create the file (or fix the path) before pointing personal config at it.`);
116
+ }
117
+ try {
118
+ JSON.parse(fs.readFileSync(resolved, "utf8"));
119
+ }
120
+ catch (err) {
121
+ throw new Error(`Team config at ${resolved} is not valid JSON: ${err.message}`);
122
+ }
123
+ await updateConfig((current) => ({
124
+ ...current,
125
+ teamConfigPath: resolved,
126
+ }));
127
+ const envOverride = process.env.EL_LINEAR_TEAM_CONFIG?.trim() || undefined;
128
+ if (envOverride !== undefined && envOverride !== resolved) {
129
+ outputWarning(`EL_LINEAR_TEAM_CONFIG=${envOverride} is set in this shell and overrides the personal config field. Unset it (or align it) for the persistent setting to take effect.`);
130
+ }
131
+ outputSuccess({
132
+ data: {
133
+ teamConfigPath: resolved,
134
+ written: resolveActiveProfile().configPath,
135
+ },
136
+ });
137
+ }));
138
+ team
139
+ .command("clear")
140
+ .description("Remove teamConfigPath from personal config.json. EL_LINEAR_TEAM_CONFIG (env) is unaffected and continues to override if set.")
141
+ .action(handleAsyncCommand(async () => {
142
+ let removed;
143
+ await updateConfig((current) => {
144
+ removed = current.teamConfigPath;
145
+ const { teamConfigPath: _drop, ...rest } = current;
146
+ return rest;
147
+ });
148
+ const envOverride = process.env.EL_LINEAR_TEAM_CONFIG?.trim() || undefined;
149
+ if (envOverride !== undefined) {
150
+ outputWarning(`EL_LINEAR_TEAM_CONFIG=${envOverride} is still set in this shell; it overrides the personal field and remains active until unset.`);
151
+ }
152
+ outputSuccess({
153
+ data: {
154
+ cleared: true,
155
+ previousTeamConfigPath: removed ?? null,
156
+ written: resolveActiveProfile().configPath,
157
+ },
158
+ });
159
+ }));
160
+ local
161
+ .command("set <key> <value>")
162
+ .description(`Set a key in local.json. Keys: ${LOCAL_SETTABLE_KEYS.join(", ")}`)
163
+ .addHelpText("after", `\nAllowed keys:\n assigneeEmail — your Linear account email (used as default --assignee)\n defaultAssignee — alias / display name / email / UUID\n defaultPriority — none|urgent|high|medium|normal|low\n cacheTTLSeconds — integer seconds (0 = disable cache)\n\nExamples:\n el-linear config local set assigneeEmail you@example.com\n el-linear config local set defaultPriority medium\n el-linear config local set cacheTTLSeconds 0`)
164
+ .action(handleAsyncCommand(async (key, value) => {
165
+ if (!LOCAL_SETTABLE_KEYS.includes(key)) {
166
+ throw new Error(`Unknown local config key "${key}". Allowed: ${LOCAL_SETTABLE_KEYS.join(", ")}`);
167
+ }
168
+ const active = resolveActiveProfile();
169
+ const localPath = active.localConfigPath;
170
+ let existing = {};
171
+ if (fs.existsSync(localPath)) {
172
+ try {
173
+ existing = JSON.parse(fs.readFileSync(localPath, "utf8"));
174
+ }
175
+ catch {
176
+ // overwrite a corrupt file
177
+ }
178
+ }
179
+ const typedValue = key === "cacheTTLSeconds" ? Number(value) : value;
180
+ if (key === "cacheTTLSeconds" && Number.isNaN(typedValue)) {
181
+ throw new Error(`cacheTTLSeconds must be an integer, got "${value}"`);
182
+ }
183
+ const updated = { ...existing, [key]: typedValue };
184
+ fs.mkdirSync(path.dirname(localPath), { recursive: true });
185
+ fs.writeFileSync(localPath, `${JSON.stringify(updated, null, 2)}\n`, "utf8");
186
+ outputSuccess({ data: updated });
187
+ }));
15
188
  }
@@ -22,7 +22,7 @@ const USERS_QUERY = /* GraphQL */ `
22
22
  }
23
23
  `;
24
24
  export async function fetchAllUsers(token) {
25
- const service = new GraphQLService(token);
25
+ const service = new GraphQLService({ apiKey: token });
26
26
  const out = [];
27
27
  let after;
28
28
  for (;;) {
@@ -6,7 +6,7 @@
6
6
  * with no input is a no-op.
7
7
  */
8
8
  import { type WizardConfig } from "./shared.js";
9
- export interface DefaultsStepResult {
9
+ interface DefaultsStepResult {
10
10
  defaultLabels: string[] | undefined;
11
11
  /**
12
12
  * Default assignee identifier (alias / display name / email / UUID — same
@@ -40,3 +40,4 @@ export interface DefaultsStepResult {
40
40
  cacheTTLSeconds: number | undefined;
41
41
  }
42
42
  export declare function runDefaultsStep(existing: WizardConfig): Promise<DefaultsStepResult>;
43
+ export {};
@@ -14,10 +14,11 @@
14
14
  * Skip is the default at every prompt. Only `init token` is required for a
15
15
  * first-time setup; everything else can be skipped and revisited later.
16
16
  */
17
+ import { validateOAuthActor, } from "../../auth/oauth-client.js";
17
18
  import { mergeAliasesIntoConfig, runAliasesImport, runAliasesStep, } from "./aliases.js";
18
19
  import { runDefaultsStep } from "./defaults.js";
19
20
  import { runOAuthRevoke, runOAuthStep } from "./oauth.js";
20
- import { assignDefined, printStep, readConfig, writeConfig, } from "./shared.js";
21
+ import { assignDefined, printStep, readConfig, updateConfig, } from "./shared.js";
21
22
  import { runTokenStep } from "./token.js";
22
23
  import { runWorkspaceStep } from "./workspace.js";
23
24
  /**
@@ -60,6 +61,7 @@ export function setupInitCommands(program) {
60
61
  .command("oauth")
61
62
  .description("Authorize via OAuth 2.0 (PKCE) — alternative to a personal API token")
62
63
  .option("--force", "ignore existing tokens; re-authorize unconditionally")
64
+ .option("--actor <actor>", "OAuth actor: user (default) or app for agents/service accounts", validateOAuthActor)
63
65
  .option("--revoke", "revoke and remove the stored OAuth tokens")
64
66
  .option("--no-browser", "skip the browser-open + localhost listener; paste the code manually")
65
67
  .option("--port <port>", "localhost callback port (default 8765)", (value) => Number.parseInt(value, 10))
@@ -72,6 +74,7 @@ export function setupInitCommands(program) {
72
74
  return;
73
75
  }
74
76
  await runOAuthStep({
77
+ actor: options.actor,
75
78
  force: options.force ?? false,
76
79
  // commander's `--no-browser` produces `browser: false`.
77
80
  noBrowser: options.browser === false,
@@ -87,14 +90,15 @@ export function setupInitCommands(program) {
87
90
  const existing = await readConfig();
88
91
  printStep("workspace", "Workspace defaults");
89
92
  const ws = await runWorkspaceStep(tokenResult.token, tokenResult.viewer.organization.urlKey, existing);
90
- const merged = assignDefined(existing, {
93
+ // Re-read inside the lock so a concurrent `init <step>` that
94
+ // finished while we were prompting isn't clobbered (DEV-4066).
95
+ await updateConfig((current) => assignDefined(current, {
91
96
  defaultTeam: ws.defaultTeam,
92
- teams: { ...(existing.teams ?? {}), ...ws.teams },
93
- // Don't clobber a manual urlKey override — same idempotency
94
- // rule as the full wizard.
95
- workspaceUrlKey: existing.workspaceUrlKey ?? ws.workspaceUrlKey,
96
- });
97
- await writeConfig(merged);
97
+ teams: { ...(current.teams ?? {}), ...ws.teams },
98
+ // Don't clobber a manual urlKey override — same
99
+ // idempotency rule as the full wizard.
100
+ workspaceUrlKey: current.workspaceUrlKey ?? ws.workspaceUrlKey,
101
+ }));
98
102
  console.log(" ✓ Workspace defaults saved.");
99
103
  }));
100
104
  init
@@ -123,8 +127,9 @@ export function setupInitCommands(program) {
123
127
  console.log(" No alias changes — config unchanged.");
124
128
  return;
125
129
  }
126
- const merged = mergeAliasesIntoConfig(existing, updates);
127
- await writeConfig(merged);
130
+ // Re-merge under the lock against the latest on-disk state so
131
+ // a concurrent step's write isn't lost (DEV-4066).
132
+ await updateConfig((current) => mergeAliasesIntoConfig(current, updates));
128
133
  console.log(` ✓ Updated aliases for ${updates.size} user(s).`);
129
134
  }));
130
135
  init
@@ -134,15 +139,16 @@ export function setupInitCommands(program) {
134
139
  const existing = await readConfig();
135
140
  printStep("defaults", "Defaults");
136
141
  const result = await runDefaultsStep(existing);
137
- const merged = assignDefined(existing, {
142
+ // Re-read inside the lock so a concurrent `init <step>` that
143
+ // finished while we were prompting isn't clobbered (DEV-4066).
144
+ await updateConfig((current) => assignDefined(current, {
138
145
  defaultLabels: result.defaultLabels,
139
146
  defaultAssignee: result.defaultAssignee,
140
147
  defaultPriority: result.defaultPriority,
141
148
  statusDefaults: result.statusDefaults,
142
149
  terms: result.terms,
143
150
  cacheTTLSeconds: result.cacheTTLSeconds,
144
- });
145
- await writeConfig(merged);
151
+ }));
146
152
  console.log(" ✓ Defaults saved.");
147
153
  }));
148
154
  }
@@ -170,33 +176,37 @@ async function runFullWizardImpl(options) {
170
176
  // Step 4: defaults
171
177
  printStep("4/4", "Defaults");
172
178
  const defaults = await runDefaultsStep(existing);
173
- // Merge everything and write atomically at the end.
179
+ // Merge everything and write atomically at the end, under the config
180
+ // lock so a parallel `init <step>` invocation can't clobber us
181
+ // (DEV-4066). Re-read inside the lock to merge on the latest state.
174
182
  // Idempotency rule: only write a key when the user explicitly changed it.
175
- // `existing.X ?? new.X` preserves any manual override the user may have
183
+ // `current.X ?? new.X` preserves any manual override the user may have
176
184
  // in config.json (self-hosted Linear urlKey, custom default team, etc.).
177
185
  // `assignDefined` skips undefined values so the resulting object's own-
178
186
  // property set matches what JSON.stringify would actually serialize.
179
- let merged = assignDefined(existing, {
180
- // defaults step: result is `existing.X` itself when the user skipped
181
- // the edit branch, so direct assignment is safe.
182
- defaultLabels: defaults.defaultLabels,
183
- defaultAssignee: defaults.defaultAssignee,
184
- defaultPriority: defaults.defaultPriority,
185
- statusDefaults: defaults.statusDefaults,
186
- terms: defaults.terms,
187
- cacheTTLSeconds: defaults.cacheTTLSeconds,
188
- // workspace step: `ws.defaultTeam` may be the existing value (user
189
- // skipped) or a new pick.
190
- defaultTeam: ws.defaultTeam,
191
- // Always merge the team UUID cache (additive, not destructive).
192
- teams: { ...(existing.teams ?? {}), ...ws.teams },
193
- // workspaceUrlKey: never clobber an existing manual override.
194
- workspaceUrlKey: existing.workspaceUrlKey ?? ws.workspaceUrlKey,
187
+ await updateConfig((current) => {
188
+ let merged = assignDefined(current, {
189
+ // defaults step: result is `existing.X` itself when the user
190
+ // skipped the edit branch, so direct assignment is safe.
191
+ defaultLabels: defaults.defaultLabels,
192
+ defaultAssignee: defaults.defaultAssignee,
193
+ defaultPriority: defaults.defaultPriority,
194
+ statusDefaults: defaults.statusDefaults,
195
+ terms: defaults.terms,
196
+ cacheTTLSeconds: defaults.cacheTTLSeconds,
197
+ // workspace step: `ws.defaultTeam` may be the existing value (user
198
+ // skipped) or a new pick.
199
+ defaultTeam: ws.defaultTeam,
200
+ // Always merge the team UUID cache (additive, not destructive).
201
+ teams: { ...(current.teams ?? {}), ...ws.teams },
202
+ // workspaceUrlKey: never clobber an existing manual override.
203
+ workspaceUrlKey: current.workspaceUrlKey ?? ws.workspaceUrlKey,
204
+ });
205
+ if (aliasUpdates.size > 0) {
206
+ merged = mergeAliasesIntoConfig(merged, aliasUpdates);
207
+ }
208
+ return merged;
195
209
  });
196
- if (aliasUpdates.size > 0) {
197
- merged = mergeAliasesIntoConfig(merged, aliasUpdates);
198
- }
199
- await writeConfig(merged);
200
210
  console.log("\n✓ Setup complete.");
201
211
  console.log(" Token: ~/.config/el-linear/token (mode 0600)");
202
212
  console.log(" Config: ~/.config/el-linear/config.json");
@@ -18,6 +18,7 @@
18
18
  * revoke before doing anything else.
19
19
  */
20
20
  import { runLocalhostCallback } from "../../auth/oauth-callback.js";
21
+ import { type OAuthActor } from "../../auth/oauth-client.js";
21
22
  import { type OAuthState } from "../../auth/oauth-storage.js";
22
23
  import { type FetchLike } from "../../auth/oauth-token.js";
23
24
  interface ViewerResponse {
@@ -33,6 +34,8 @@ interface ViewerResponse {
33
34
  };
34
35
  }
35
36
  export interface OAuthStepOptions {
37
+ /** OAuth actor mode: `user` (default) or `app` for agents/service accounts. */
38
+ actor?: OAuthActor;
36
39
  /** Force re-authorization even if existing state is valid. */
37
40
  force?: boolean;
38
41
  /** Skip the localhost listener; use the headless code-paste prompt. */
@@ -56,7 +59,7 @@ export interface OAuthStepOptions {
56
59
  /** Test seam for `viewer` validation against the new bearer token. */
57
60
  validateViewer?: (oauthToken: string) => Promise<ViewerResponse["viewer"]>;
58
61
  }
59
- export interface OAuthStepResult {
62
+ interface OAuthStepResult {
60
63
  state: OAuthState;
61
64
  viewer: ViewerResponse["viewer"];
62
65
  }
@@ -21,7 +21,7 @@ import { spawn } from "node:child_process";
21
21
  import { checkbox, input, password, select } from "@inquirer/prompts";
22
22
  import { readTeamOAuthConfig } from "../../auth/oauth-app-config.js";
23
23
  import { DEFAULT_CALLBACK_PATH, runLocalhostCallback, } from "../../auth/oauth-callback.js";
24
- import { ALL_SCOPES, buildAuthorizeUrl, DEFAULT_SCOPES, generatePkce, generateState, SCOPE_DESCRIPTIONS, validateScopes, } from "../../auth/oauth-client.js";
24
+ import { ALL_SCOPES, buildAuthorizeUrl, DEFAULT_SCOPES, generatePkce, generateState, SCOPE_DESCRIPTIONS, validateActorScopes, validateScopes, } from "../../auth/oauth-client.js";
25
25
  import { promptForPastedCode } from "../../auth/oauth-headless.js";
26
26
  import { clearOAuthState, OAUTH_STATE_VERSION, readOAuthState, writeOAuthState, } from "../../auth/oauth-storage.js";
27
27
  import { exchangeCodeForTokens, revokeToken, } from "../../auth/oauth-token.js";
@@ -128,7 +128,11 @@ async function promptRegistration(defaults) {
128
128
  logLine("");
129
129
  logLine(TS(`Register a Linear OAuth app: ${REGISTRATION_URL}`));
130
130
  logLine(TS(`Set the redirect URL to: http://localhost:${defaults.port ?? DEFAULT_PORT}${DEFAULT_CALLBACK_PATH}`));
131
- logLine(TS("Then paste the client_id (and client_secret, if your app is configured as confidential)."));
131
+ logLine(TS(`Then paste the client_id (and client_secret, if your app is configured as confidential).`));
132
+ logLine(TS(`Actor: ${defaults.actor}.`));
133
+ if (defaults.actor === "user") {
134
+ logLine(TS("Use --actor app for agent/service-account app user tokens."));
135
+ }
132
136
  logLine("");
133
137
  const port = Number.parseInt(await input({
134
138
  message: "Localhost callback port:",
@@ -159,24 +163,34 @@ async function promptRegistration(defaults) {
159
163
  })),
160
164
  validate: (selections) => selections.length > 0 || "Pick at least one scope",
161
165
  }));
166
+ const validatedScopes = validateScopes(scopes);
167
+ validateActorScopes(defaults.actor, validatedScopes);
162
168
  return {
169
+ actor: defaults.actor,
163
170
  clientId,
164
171
  clientSecret: clientSecret || undefined,
165
172
  port,
166
- scopes: validateScopes(scopes),
173
+ scopes: validatedScopes,
167
174
  };
168
175
  }
169
176
  async function resolveRegistration(defaults) {
170
177
  const teamConfig = await readTeamOAuthConfig();
171
178
  if (!teamConfig) {
172
- return promptRegistration({ port: defaults.manualPort });
179
+ return promptRegistration({
180
+ actor: defaults.actor ?? "user",
181
+ port: defaults.manualPort,
182
+ });
173
183
  }
184
+ const actor = defaults.actor ?? teamConfig.actor;
174
185
  const port = defaults.requestedPort ?? teamConfig.redirectPort;
175
186
  logLine("");
176
187
  logLine(TS(`Using Linear OAuth app defaults from ${teamConfig.sourcePath}.`));
188
+ logLine(TS(`Actor: ${actor}.`));
177
189
  logLine(TS(`Callback URL: http://localhost:${port}${DEFAULT_CALLBACK_PATH}`));
178
190
  logLine("");
191
+ validateActorScopes(actor, teamConfig.scopes);
179
192
  return {
193
+ actor,
180
194
  clientId: teamConfig.clientId,
181
195
  port,
182
196
  scopes: teamConfig.scopes,
@@ -231,6 +245,7 @@ export async function runOAuthStep(options = {}) {
231
245
  // Both `reauth` and `revoked` fall through to the re-auth flow.
232
246
  }
233
247
  const reg = await resolveRegistration({
248
+ actor: options.actor,
234
249
  manualPort: options.port ?? extractPortFromRedirect(existing) ?? DEFAULT_PORT,
235
250
  requestedPort: options.port,
236
251
  });
@@ -243,6 +258,7 @@ export async function runOAuthStep(options = {}) {
243
258
  scopes: reg.scopes,
244
259
  state,
245
260
  codeChallenge: pkce.challenge,
261
+ actor: reg.actor,
246
262
  });
247
263
  logLine("");
248
264
  logLine(TS("Opening your browser to authorize…"));
@@ -292,6 +308,7 @@ export async function runOAuthStep(options = {}) {
292
308
  }, options.fetchImpl);
293
309
  const newState = {
294
310
  v: OAUTH_STATE_VERSION,
311
+ actor: reg.actor,
295
312
  clientId: reg.clientId,
296
313
  clientSecret: reg.clientSecret,
297
314
  registeredRedirectUri: redirectUri,
@@ -304,6 +321,7 @@ export async function runOAuthStep(options = {}) {
304
321
  };
305
322
  logLine(TS("Validating against viewer…"));
306
323
  const viewer = await validateViewer(newState.accessToken);
324
+ newState.viewerId = viewer.id;
307
325
  await writeOAuthState(newState);
308
326
  logLine(TS(`✓ Authorized as ${viewer.displayName} <${viewer.email}> (${viewer.organization.name}).`));
309
327
  return { state: newState, viewer };
@@ -29,8 +29,30 @@ type DeepPartial<T> = T extends Array<infer _U> ? T : T extends object ? {
29
29
  */
30
30
  export type WizardConfig = DeepPartial<ElLinearConfig>;
31
31
  export declare function ensureConfigDir(): Promise<void>;
32
- export declare function readConfig(): Promise<WizardConfig>;
33
- export declare function writeConfig(config: WizardConfig): Promise<void>;
32
+ export declare function readConfig(configPath?: string): Promise<WizardConfig>;
33
+ export declare function writeConfig(config: WizardConfig, configPath?: string): Promise<void>;
34
+ /**
35
+ * Run a read-modify-write update on the active profile's config.json under
36
+ * an exclusive file lock. The mutator receives the latest on-disk config
37
+ * (re-read inside the lock), and its return value is written back atomically.
38
+ *
39
+ * Use this anywhere two parallel wizard invocations could race a
40
+ * read → mutate → write sequence. Each `el-linear init <step>` re-reads the
41
+ * config and merges its slice; without serialization, the slower writer's
42
+ * mutation would clobber the faster writer's already-persisted changes.
43
+ *
44
+ * The interactive prompt phase MUST run outside the lock — prompts can sit
45
+ * waiting for user input longer than the lock's stale window. Seed prompts
46
+ * with a cheap pre-read, do the prompts, then call `updateConfig` with a
47
+ * mutator that re-reads and merges your slice on top of the latest state.
48
+ *
49
+ * The active-profile path is snapshotted ONCE at entry and threaded through
50
+ * `readConfig` + `writeConfig`. A theoretical mid-update profile switch
51
+ * (`--profile` is bound by the commander preAction before any subcommand
52
+ * runs, so this can't happen via the CLI today) can't cause a lock-A /
53
+ * read-or-write-B mismatch.
54
+ */
55
+ export declare function updateConfig(mutator: (current: WizardConfig) => WizardConfig | Promise<WizardConfig>): Promise<void>;
34
56
  export declare function readToken(): Promise<string | null>;
35
57
  /**
36
58
  * Write the token to disk with mode 0600.