@enrichlayer/el-linear 1.9.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 (135) hide show
  1. package/README.md +139 -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/attachments.js +2 -1
  20. package/dist/commands/batch.js +18 -21
  21. package/dist/commands/comments.js +22 -33
  22. package/dist/commands/config.js +178 -5
  23. package/dist/commands/cycles.js +2 -1
  24. package/dist/commands/documents.js +2 -1
  25. package/dist/commands/graphql.js +4 -6
  26. package/dist/commands/init/aliases.js +1 -1
  27. package/dist/commands/init/defaults.d.ts +2 -1
  28. package/dist/commands/init/index.js +45 -35
  29. package/dist/commands/init/oauth.d.ts +4 -1
  30. package/dist/commands/init/oauth.js +22 -4
  31. package/dist/commands/init/shared.d.ts +24 -2
  32. package/dist/commands/init/shared.js +35 -4
  33. package/dist/commands/init/token.d.ts +3 -3
  34. package/dist/commands/init/token.js +5 -24
  35. package/dist/commands/init/workspace.d.ts +2 -1
  36. package/dist/commands/init/workspace.js +1 -1
  37. package/dist/commands/introspect.d.ts +27 -0
  38. package/dist/commands/introspect.js +178 -0
  39. package/dist/commands/issue-id.js +1 -3
  40. package/dist/commands/issues/branch.js +9 -1
  41. package/dist/commands/issues/description.js +2 -6
  42. package/dist/commands/issues/link-references.d.ts +21 -0
  43. package/dist/commands/issues/link-references.js +171 -0
  44. package/dist/commands/issues/relations.d.ts +44 -0
  45. package/dist/commands/issues/relations.js +132 -0
  46. package/dist/commands/issues.js +269 -309
  47. package/dist/commands/labels.js +15 -24
  48. package/dist/commands/profile.js +1 -0
  49. package/dist/commands/project-milestones.js +13 -20
  50. package/dist/commands/projects.d.ts +2 -0
  51. package/dist/commands/projects.js +157 -44
  52. package/dist/commands/read-shortcut.d.ts +1 -1
  53. package/dist/commands/read-shortcut.js +28 -8
  54. package/dist/commands/refs.js +75 -8
  55. package/dist/commands/releases.js +26 -30
  56. package/dist/commands/search.js +49 -33
  57. package/dist/commands/teams.js +2 -1
  58. package/dist/commands/templates.js +9 -14
  59. package/dist/commands/users.js +5 -2
  60. package/dist/config/config.d.ts +99 -1
  61. package/dist/config/config.js +264 -52
  62. package/dist/config/error-enrichment.d.ts +62 -0
  63. package/dist/config/error-enrichment.js +417 -0
  64. package/dist/config/issue-validation.d.ts +37 -0
  65. package/dist/config/issue-validation.js +63 -1
  66. package/dist/config/paths.d.ts +2 -8
  67. package/dist/config/paths.js +4 -2
  68. package/dist/config/resolver.d.ts +8 -1
  69. package/dist/config/resolver.js +11 -5
  70. package/dist/main.js +13 -1
  71. package/dist/queries/attachments-types.d.ts +30 -0
  72. package/dist/queries/attachments-types.js +5 -0
  73. package/dist/queries/comments-types.d.ts +55 -0
  74. package/dist/queries/comments-types.js +5 -0
  75. package/dist/queries/common.d.ts +2 -2
  76. package/dist/queries/common.js +8 -0
  77. package/dist/queries/documents-types.d.ts +62 -0
  78. package/dist/queries/documents-types.js +9 -0
  79. package/dist/queries/introspect-types.d.ts +58 -0
  80. package/dist/queries/introspect-types.js +10 -0
  81. package/dist/queries/issues-types.d.ts +481 -0
  82. package/dist/queries/issues-types.js +23 -0
  83. package/dist/queries/issues.d.ts +51 -10
  84. package/dist/queries/issues.js +147 -5
  85. package/dist/queries/labels-types.d.ts +65 -0
  86. package/dist/queries/labels-types.js +5 -0
  87. package/dist/queries/project-milestones-types.d.ts +92 -0
  88. package/dist/queries/project-milestones-types.js +10 -0
  89. package/dist/queries/project-milestones.d.ts +1 -1
  90. package/dist/queries/projects-types.d.ts +76 -0
  91. package/dist/queries/projects-types.js +5 -0
  92. package/dist/queries/projects.d.ts +2 -0
  93. package/dist/queries/projects.js +22 -0
  94. package/dist/queries/releases-types.d.ts +85 -0
  95. package/dist/queries/releases-types.js +5 -0
  96. package/dist/queries/search-types.d.ts +102 -0
  97. package/dist/queries/search-types.js +6 -0
  98. package/dist/queries/templates-types.d.ts +62 -0
  99. package/dist/queries/templates-types.js +9 -0
  100. package/dist/types/linear.d.ts +21 -3
  101. package/dist/utils/auto-link-references.d.ts +3 -3
  102. package/dist/utils/auto-link-references.js +30 -34
  103. package/dist/utils/extract-field.d.ts +19 -0
  104. package/dist/utils/extract-field.js +99 -0
  105. package/dist/utils/file-service.d.ts +6 -13
  106. package/dist/utils/file-service.js +0 -2
  107. package/dist/utils/formatters/summary.js +6 -1
  108. package/dist/utils/graphql-attachments-service.js +6 -9
  109. package/dist/utils/graphql-documents-service.js +19 -25
  110. package/dist/utils/graphql-issues-service.d.ts +112 -46
  111. package/dist/utils/graphql-issues-service.js +398 -206
  112. package/dist/utils/graphql-service.d.ts +10 -12
  113. package/dist/utils/graphql-service.js +0 -3
  114. package/dist/utils/issue-reference-extractor.d.ts +7 -0
  115. package/dist/utils/issue-reference-extractor.js +5 -3
  116. package/dist/utils/issues-service-bootstrap.d.ts +28 -0
  117. package/dist/utils/issues-service-bootstrap.js +27 -0
  118. package/dist/utils/linear-service.d.ts +21 -14
  119. package/dist/utils/linear-service.js +73 -11
  120. package/dist/utils/markdown-prosemirror.js +12 -12
  121. package/dist/utils/mention-resolver.js +1 -1
  122. package/dist/utils/output.d.ts +82 -2
  123. package/dist/utils/output.js +76 -11
  124. package/dist/utils/project-slug.d.ts +21 -0
  125. package/dist/utils/project-slug.js +45 -0
  126. package/dist/utils/protected-ranges.d.ts +14 -0
  127. package/dist/utils/protected-ranges.js +88 -2
  128. package/dist/utils/sanitize-for-log.d.ts +24 -0
  129. package/dist/utils/sanitize-for-log.js +38 -0
  130. package/dist/utils/table-formatter.js +24 -0
  131. package/dist/utils/validators.d.ts +7 -2
  132. package/dist/utils/validators.js +6 -0
  133. package/dist/utils/workspace-url.d.ts +5 -1
  134. package/dist/utils/workspace-url.js +53 -7
  135. package/package.json +2 -2
@@ -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.
@@ -8,6 +8,7 @@
8
8
  import { randomBytes } from "node:crypto";
9
9
  import fs from "node:fs/promises";
10
10
  import path from "node:path";
11
+ import { withFileLock } from "../../auth/oauth-fs.js";
11
12
  import { ALIASES_PROGRESS_PATH, CONFIG_DIR, CONFIG_PATH, resolveActiveProfile, TOKEN_PATH, } from "../../config/paths.js";
12
13
  // Re-export for tests and call sites that already pulled the paths from here.
13
14
  export { ALIASES_PROGRESS_PATH, CONFIG_PATH, TOKEN_PATH };
@@ -65,9 +66,9 @@ export async function ensureConfigDir() {
65
66
  await fs.mkdir(dir, { recursive: true, mode: 0o700 });
66
67
  }
67
68
  }
68
- export async function readConfig() {
69
+ export async function readConfig(configPath = activePaths().configPath) {
69
70
  try {
70
- const raw = await fs.readFile(activePaths().configPath, "utf8");
71
+ const raw = await fs.readFile(configPath, "utf8");
71
72
  return JSON.parse(raw);
72
73
  }
73
74
  catch (err) {
@@ -77,11 +78,41 @@ export async function readConfig() {
77
78
  throw err;
78
79
  }
79
80
  }
80
- export async function writeConfig(config) {
81
+ export async function writeConfig(config, configPath = activePaths().configPath) {
81
82
  await ensureConfigDir();
82
83
  // Stable key order so byte-identical config produces byte-identical output.
83
84
  const sorted = sortKeys(config);
84
- await atomicWrite(activePaths().configPath, `${JSON.stringify(sorted, null, 2)}\n`, 0o644);
85
+ await atomicWrite(configPath, `${JSON.stringify(sorted, null, 2)}\n`, 0o644);
86
+ }
87
+ /**
88
+ * Run a read-modify-write update on the active profile's config.json under
89
+ * an exclusive file lock. The mutator receives the latest on-disk config
90
+ * (re-read inside the lock), and its return value is written back atomically.
91
+ *
92
+ * Use this anywhere two parallel wizard invocations could race a
93
+ * read → mutate → write sequence. Each `el-linear init <step>` re-reads the
94
+ * config and merges its slice; without serialization, the slower writer's
95
+ * mutation would clobber the faster writer's already-persisted changes.
96
+ *
97
+ * The interactive prompt phase MUST run outside the lock — prompts can sit
98
+ * waiting for user input longer than the lock's stale window. Seed prompts
99
+ * with a cheap pre-read, do the prompts, then call `updateConfig` with a
100
+ * mutator that re-reads and merges your slice on top of the latest state.
101
+ *
102
+ * The active-profile path is snapshotted ONCE at entry and threaded through
103
+ * `readConfig` + `writeConfig`. A theoretical mid-update profile switch
104
+ * (`--profile` is bound by the commander preAction before any subcommand
105
+ * runs, so this can't happen via the CLI today) can't cause a lock-A /
106
+ * read-or-write-B mismatch.
107
+ */
108
+ export async function updateConfig(mutator) {
109
+ await ensureConfigDir();
110
+ const configPath = activePaths().configPath;
111
+ await withFileLock(configPath, async () => {
112
+ const current = await readConfig(configPath);
113
+ const next = await mutator(current);
114
+ await writeConfig(next, configPath);
115
+ });
85
116
  }
86
117
  export async function readToken() {
87
118
  try {
@@ -5,6 +5,8 @@
5
5
  * before saving. Token is stored at ~/.config/el-linear/token (mode 0600),
6
6
  * never embedded in config.json.
7
7
  */
8
+ import { sanitizeForLog } from "../../utils/sanitize-for-log.js";
9
+ export { sanitizeForLog };
8
10
  interface ViewerResponse {
9
11
  viewer: {
10
12
  id: string;
@@ -17,11 +19,10 @@ interface ViewerResponse {
17
19
  };
18
20
  };
19
21
  }
20
- export interface TokenStepResult {
22
+ interface TokenStepResult {
21
23
  token: string;
22
24
  viewer: ViewerResponse["viewer"];
23
25
  }
24
- export declare function sanitizeForLog(text: string): string;
25
26
  /**
26
27
  * Validate a Linear API token by fetching the viewer. Throws with a
27
28
  * sanitized user-readable message on auth failure — the error string is
@@ -39,4 +40,3 @@ export declare function runTokenStep(options?: {
39
40
  /** Skip the "replace existing?" prompt; always replace if existing is present. */
40
41
  force?: boolean;
41
42
  }): Promise<TokenStepResult>;
42
- export {};
@@ -7,7 +7,11 @@
7
7
  */
8
8
  import { confirm, password } from "@inquirer/prompts";
9
9
  import { GraphQLService } from "../../utils/graphql-service.js";
10
+ import { sanitizeForLog } from "../../utils/sanitize-for-log.js";
10
11
  import { readToken, writeToken } from "./shared.js";
12
+ // Re-export for legacy import paths under `init/`. New code should import
13
+ // from `utils/sanitize-for-log.js` directly.
14
+ export { sanitizeForLog };
11
15
  const TOKEN_GENERATION_URL = "https://linear.app/settings/account/security";
12
16
  const VIEWER_QUERY = /* GraphQL */ `
13
17
  query {
@@ -23,29 +27,6 @@ const VIEWER_QUERY = /* GraphQL */ `
23
27
  }
24
28
  }
25
29
  `;
26
- /**
27
- * Strip anything that looks like a Linear API token from a string. Defense in
28
- * depth: today the @linear/sdk error message embeds {query, variables} but not
29
- * the Authorization header. A future SDK upgrade that includes headers (which
30
- * upstream graphql-request has done historically) would otherwise silently
31
- * write `Bearer lin_api_…` into stdout / shell history / CI logs. The regex
32
- * also catches token shapes that may show up in custom error wrappers.
33
- */
34
- // Personal-API tokens (`lin_api_…`) and OAuth access/refresh tokens
35
- // (`lin_oauth_…`). Pre-fix the regex only matched personal tokens.
36
- const TOKEN_PREFIX_RE = /lin_(api|oauth)_[A-Za-z0-9_-]{16,}/g;
37
- // High-entropy bearer payload fallback: catches generic Bearer-style
38
- // strings adjacent to Authorization / Bearer keywords. Useful for
39
- // future SDK error wrappers that might leak headers without the
40
- // `lin_` prefix.
41
- const BEARER_PAYLOAD_RE = /(\b(?:Authorization|Bearer)\b[:\s]*)([A-Za-z0-9_\-/+=]{40,})/gi;
42
- export function sanitizeForLog(text) {
43
- return text
44
- .replace(TOKEN_PREFIX_RE, (m) => m.startsWith("lin_oauth_")
45
- ? "lin_oauth_***REDACTED***"
46
- : "lin_api_***REDACTED***")
47
- .replace(BEARER_PAYLOAD_RE, "$1***REDACTED***");
48
- }
49
30
  /**
50
31
  * Strict shape check on the viewer response. Treats whitespace-only fields as
51
32
  * "validated to nothing" — easy to forge with a malformed but truthy stub
@@ -73,7 +54,7 @@ function viewerIsValid(viewer) {
73
54
  * is redacted before it hits stdout.
74
55
  */
75
56
  export async function validateToken(token) {
76
- const service = new GraphQLService(token);
57
+ const service = new GraphQLService({ apiKey: token });
77
58
  let data;
78
59
  try {
79
60
  data = await service.rawRequest(VIEWER_QUERY);
@@ -5,7 +5,7 @@
5
5
  * Lets the user pick a default team, optional. Skip-by-default.
6
6
  */
7
7
  import type { WizardConfig } from "./shared.js";
8
- export interface WorkspaceStepResult {
8
+ interface WorkspaceStepResult {
9
9
  workspaceUrlKey: string;
10
10
  defaultTeam: string | undefined;
11
11
  teams: Record<string, string>;
@@ -18,3 +18,4 @@ export interface WorkspaceStepResult {
18
18
  * the existing config exactly.
19
19
  */
20
20
  export declare function runWorkspaceStep(token: string, workspaceUrlKey: string, existing: WizardConfig): Promise<WorkspaceStepResult>;
21
+ export {};
@@ -25,7 +25,7 @@ const TEAMS_QUERY = /* GraphQL */ `
25
25
  * the existing config exactly.
26
26
  */
27
27
  export async function runWorkspaceStep(token, workspaceUrlKey, existing) {
28
- const service = new GraphQLService(token);
28
+ const service = new GraphQLService({ apiKey: token });
29
29
  // Fetch teams once so we can populate both the picker and the cached id map.
30
30
  const data = await service.rawRequest(TEAMS_QUERY);
31
31
  const teams = data?.teams?.nodes ?? [];
@@ -0,0 +1,27 @@
1
+ /**
2
+ * `el-linear introspect` and `el-linear validate-flag` — expose the
3
+ * commander command tree so external linters (e.g. CI steps that scan
4
+ * SKILL.md files for `el-linear <cmd> --<flag>` references) can verify
5
+ * that hardcoded flag names actually exist on the current binary.
6
+ *
7
+ * Without this, a flag rename inside `el-linear` (say `--parent-ticket`
8
+ * → `--parent`) silently breaks every skill that prose-references the
9
+ * old name. Skills then fail at runtime, far from the rename commit.
10
+ *
11
+ * `introspect` dumps the full tree — name, description, version,
12
+ * options, aliases, and recursively nested subcommands.
13
+ *
14
+ * `validate-flag` is a thin ergonomic wrapper for the CI-lint use case:
15
+ *
16
+ * $ el-linear validate-flag issues create --parent-ticket
17
+ * { "ok": true, "command": ["issues", "create"], "flag": "--parent-ticket" }
18
+ * $ el-linear validate-flag issues create --does-not-exist
19
+ * { "ok": false, ... "error": "..." } # exit code 1
20
+ *
21
+ * Both commands ignore root-level flags (`--api-token`, `--json`, etc.)
22
+ * when walking subcommand options; those are dumped at the root, not
23
+ * under every command. `validate-flag` walks ancestors so a global flag
24
+ * (e.g. `--format`) validates on any subcommand.
25
+ */
26
+ import type { Command } from "commander";
27
+ export declare function setupIntrospectCommand(program: Command): void;