@enrichlayer/el-linear 1.4.0 → 1.6.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 (75) hide show
  1. package/README.md +54 -0
  2. package/dist/auth/oauth-callback.d.ts +40 -0
  3. package/dist/auth/oauth-callback.js +142 -0
  4. package/dist/auth/oauth-client.d.ts +55 -0
  5. package/dist/auth/oauth-client.js +134 -0
  6. package/dist/auth/oauth-fs.d.ts +1 -0
  7. package/dist/auth/oauth-fs.js +29 -0
  8. package/dist/auth/oauth-headless.d.ts +38 -0
  9. package/dist/auth/oauth-headless.js +50 -0
  10. package/dist/auth/oauth-storage.d.ts +51 -0
  11. package/dist/auth/oauth-storage.js +87 -0
  12. package/dist/auth/oauth-token.d.ts +70 -0
  13. package/dist/auth/oauth-token.js +141 -0
  14. package/dist/auth/token-resolver.d.ts +48 -0
  15. package/dist/auth/token-resolver.js +95 -0
  16. package/dist/commands/attachments.js +11 -12
  17. package/dist/commands/batch.js +15 -14
  18. package/dist/commands/comments.js +22 -10
  19. package/dist/commands/cycles.js +5 -4
  20. package/dist/commands/documents.js +18 -15
  21. package/dist/commands/embeds.js +4 -6
  22. package/dist/commands/graphql.js +5 -4
  23. package/dist/commands/init/aliases.js +0 -14
  24. package/dist/commands/init/defaults.js +0 -6
  25. package/dist/commands/init/index.js +22 -12
  26. package/dist/commands/init/oauth.d.ts +85 -0
  27. package/dist/commands/init/oauth.js +308 -0
  28. package/dist/commands/init/shared.js +0 -1
  29. package/dist/commands/init/token.js +0 -7
  30. package/dist/commands/init/workspace.js +0 -2
  31. package/dist/commands/issue-id.js +3 -1
  32. package/dist/commands/issues.js +87 -46
  33. package/dist/commands/labels.js +9 -8
  34. package/dist/commands/profile/migrate-legacy.d.ts +96 -0
  35. package/dist/commands/profile/migrate-legacy.js +271 -0
  36. package/dist/commands/profile.js +6 -0
  37. package/dist/commands/project-milestones.js +13 -12
  38. package/dist/commands/projects.js +18 -14
  39. package/dist/commands/read-shortcut.js +8 -7
  40. package/dist/commands/refs.js +4 -3
  41. package/dist/commands/releases.js +9 -8
  42. package/dist/commands/search.js +3 -2
  43. package/dist/commands/teams.js +3 -2
  44. package/dist/commands/templates.js +5 -4
  45. package/dist/commands/users.js +3 -2
  46. package/dist/config/config.d.ts +18 -0
  47. package/dist/config/issue-validation.js +1 -1
  48. package/dist/config/resolver.js +1 -1
  49. package/dist/main.js +1 -1
  50. package/dist/utils/auth.js +8 -0
  51. package/dist/utils/download-uploads.d.ts +2 -1
  52. package/dist/utils/download-uploads.js +2 -4
  53. package/dist/utils/file-service.d.ts +26 -2
  54. package/dist/utils/file-service.js +27 -5
  55. package/dist/utils/footer.d.ts +19 -0
  56. package/dist/utils/footer.js +27 -0
  57. package/dist/utils/gdoc-parser.js +1 -1
  58. package/dist/utils/graphql-attachments-service.d.ts +1 -1
  59. package/dist/utils/graphql-attachments-service.js +2 -2
  60. package/dist/utils/graphql-documents-service.d.ts +1 -1
  61. package/dist/utils/graphql-documents-service.js +2 -2
  62. package/dist/utils/graphql-service.d.ts +18 -3
  63. package/dist/utils/graphql-service.js +26 -11
  64. package/dist/utils/legacy-config-detection.d.ts +47 -0
  65. package/dist/utils/legacy-config-detection.js +90 -0
  66. package/dist/utils/linear-service.d.ts +18 -3
  67. package/dist/utils/linear-service.js +21 -6
  68. package/dist/utils/markdown-prosemirror.js +16 -9
  69. package/dist/utils/migration-hint.d.ts +46 -0
  70. package/dist/utils/migration-hint.js +90 -0
  71. package/dist/utils/root-opts.d.ts +11 -0
  72. package/dist/utils/root-opts.js +13 -0
  73. package/dist/utils/validators.d.ts +7 -0
  74. package/dist/utils/validators.js +14 -14
  75. package/package.json +1 -1
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Wizard step for OAuth 2.0 (PKCE flow) authorization.
3
+ *
4
+ * Flow:
5
+ * 1. Present a "what is this?" intro pointing the user at Linear's OAuth
6
+ * app registration page. (Until we ship a shared OAuth client_id,
7
+ * every user registers their own app.)
8
+ * 2. Prompt for `client_id`, optional `client_secret`, port, scopes.
9
+ * 3. Generate PKCE verifier + state, build the authorize URL.
10
+ * 4. Try to open the system browser; fall back to printing the URL.
11
+ * 5. Spin a localhost listener (or fall back to pasted-code prompt) to
12
+ * receive the redirect.
13
+ * 6. Exchange code for tokens.
14
+ * 7. Validate by calling `viewer { ... }` with the new bearer token.
15
+ * 8. Persist `oauth.json` to the active profile.
16
+ *
17
+ * Idempotent: if a fresh `oauth.json` already exists, offer keep / re-auth /
18
+ * revoke before doing anything else.
19
+ */
20
+ import { spawn } from "node:child_process";
21
+ import { checkbox, input, password, select } from "@inquirer/prompts";
22
+ import { DEFAULT_CALLBACK_PATH, runLocalhostCallback, } from "../../auth/oauth-callback.js";
23
+ import { ALL_SCOPES, buildAuthorizeUrl, DEFAULT_SCOPES, generatePkce, generateState, SCOPE_DESCRIPTIONS, validateScopes, } from "../../auth/oauth-client.js";
24
+ import { promptForPastedCode } from "../../auth/oauth-headless.js";
25
+ import { clearOAuthState, OAUTH_STATE_VERSION, readOAuthState, writeOAuthState, } from "../../auth/oauth-storage.js";
26
+ import { exchangeCodeForTokens, revokeToken, } from "../../auth/oauth-token.js";
27
+ import { GraphQLService } from "../../utils/graphql-service.js";
28
+ import { sanitizeForLog } from "./token.js";
29
+ const DEFAULT_PORT = 8765;
30
+ const REGISTRATION_URL = "https://linear.app/settings/api/applications/new";
31
+ const VIEWER_QUERY = /* GraphQL */ `
32
+ query {
33
+ viewer {
34
+ id
35
+ name
36
+ email
37
+ displayName
38
+ organization {
39
+ urlKey
40
+ name
41
+ }
42
+ }
43
+ }
44
+ `;
45
+ const TS = (msg) => ` ${msg}`;
46
+ function logLine(msg) {
47
+ console.log(msg);
48
+ }
49
+ /**
50
+ * Open `url` in the system browser. Forks per-platform:
51
+ * - darwin → `open <url>`
52
+ * - win32 → `start "" "<url>"` (cmd builtin)
53
+ * - other → `xdg-open <url>` (Linux / BSD with desktop env)
54
+ *
55
+ * Returns once the spawn call succeeds — does NOT wait for the browser to
56
+ * actually load. Throws on spawn failure (e.g. xdg-open not installed in
57
+ * a barebones container).
58
+ */
59
+ export async function openSystemBrowser(url) {
60
+ return new Promise((resolve, reject) => {
61
+ let cmd;
62
+ let args;
63
+ if (process.platform === "darwin") {
64
+ cmd = "open";
65
+ args = [url];
66
+ }
67
+ else if (process.platform === "win32") {
68
+ cmd = "cmd";
69
+ // `start "" "<url>"` — empty title means "use the URL".
70
+ args = ["/c", "start", "", url];
71
+ }
72
+ else {
73
+ cmd = "xdg-open";
74
+ args = [url];
75
+ }
76
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
77
+ child.once("error", reject);
78
+ // Don't keep the parent alive on the child — we don't track its exit.
79
+ child.unref();
80
+ // Linux's xdg-open immediately exits with the result code. Wait
81
+ // until the next tick so a synchronous `error` event has a chance
82
+ // to fire before we resolve.
83
+ setImmediate(() => resolve());
84
+ });
85
+ }
86
+ /**
87
+ * Prompt for what to do with existing OAuth state. Returns a discriminated
88
+ * tagged union so the caller can branch cleanly.
89
+ */
90
+ async function handleExistingState(existing, options) {
91
+ if (options.force)
92
+ return { kind: "reauth" };
93
+ logLine(TS(`Existing OAuth tokens found for client ${existing.clientId} (${existing.scopes.join(",")}).`));
94
+ const choice = await select({
95
+ message: "What would you like to do?",
96
+ choices: [
97
+ { name: "Keep existing tokens (no changes)", value: "keep" },
98
+ { name: "Re-authorize (replace tokens)", value: "reauth" },
99
+ { name: "Revoke and remove", value: "revoke" },
100
+ ],
101
+ default: "keep",
102
+ });
103
+ if (choice === "keep")
104
+ return { kind: "keep", state: existing };
105
+ if (choice === "revoke") {
106
+ const result = await revokeToken({ accessToken: existing.accessToken }, options.fetchImpl);
107
+ await clearOAuthState();
108
+ logLine(TS(result.ok
109
+ ? "✓ Token revoked and oauth.json removed."
110
+ : `Revoke endpoint returned ${result.status} (${sanitizeForLog(result.message ?? "")}). Local oauth.json removed anyway.`));
111
+ return { kind: "revoked" };
112
+ }
113
+ return { kind: "reauth" };
114
+ }
115
+ /** Extract the port from a stored `http://localhost:NNN/...` redirect URI, if any. */
116
+ function extractPortFromRedirect(state) {
117
+ if (!state)
118
+ return null;
119
+ const match = state.registeredRedirectUri.match(/:(\d+)\//);
120
+ if (!match)
121
+ return null;
122
+ const n = Number.parseInt(match[1], 10);
123
+ return Number.isInteger(n) ? n : null;
124
+ }
125
+ /** Walk the user through their OAuth-app registration values. */
126
+ async function promptRegistration(defaults) {
127
+ logLine("");
128
+ logLine(TS(`Register a Linear OAuth app: ${REGISTRATION_URL}`));
129
+ logLine(TS(`Set the redirect URL to: http://localhost:${defaults.port ?? DEFAULT_PORT}${DEFAULT_CALLBACK_PATH}`));
130
+ logLine(TS("Then paste the client_id (and client_secret, if your app is configured as confidential)."));
131
+ logLine("");
132
+ const port = Number.parseInt(await input({
133
+ message: "Localhost callback port:",
134
+ default: String(defaults.port ?? DEFAULT_PORT),
135
+ validate: (value) => {
136
+ const n = Number.parseInt(value, 10);
137
+ if (!Number.isInteger(n) || n < 1024 || n > 65535) {
138
+ return "Port must be an integer between 1024 and 65535";
139
+ }
140
+ return true;
141
+ },
142
+ }), 10);
143
+ const clientId = (await input({
144
+ message: "Linear OAuth client_id:",
145
+ validate: (v) => v.trim().length > 0 || "client_id cannot be empty",
146
+ })).trim();
147
+ const clientSecret = (await password({
148
+ message: "Linear OAuth client_secret (optional, hidden — press enter to skip):",
149
+ mask: "*",
150
+ validate: () => true,
151
+ })).trim();
152
+ const scopes = (await checkbox({
153
+ message: "Scopes (space to toggle, enter to confirm):",
154
+ choices: ALL_SCOPES.map((s) => ({
155
+ name: `${s} — ${SCOPE_DESCRIPTIONS[s]}`,
156
+ value: s,
157
+ checked: DEFAULT_SCOPES.includes(s),
158
+ })),
159
+ validate: (selections) => selections.length > 0 || "Pick at least one scope",
160
+ }));
161
+ return {
162
+ clientId,
163
+ clientSecret: clientSecret || undefined,
164
+ port,
165
+ scopes: validateScopes(scopes),
166
+ };
167
+ }
168
+ /**
169
+ * Default viewer-validation routine. Calls `viewer { ... }` with the new
170
+ * bearer token to confirm Linear accepted it. Reused for both the wizard
171
+ * step's success path and the test seam.
172
+ */
173
+ async function defaultValidateViewer(oauthToken) {
174
+ const service = new GraphQLService({ oauthToken });
175
+ let data;
176
+ try {
177
+ data = await service.rawRequest(VIEWER_QUERY);
178
+ }
179
+ catch (err) {
180
+ const raw = err instanceof Error ? err.message : String(err);
181
+ throw new Error(`Could not validate the OAuth access token via viewer: ${sanitizeForLog(raw)}`);
182
+ }
183
+ const viewer = data?.viewer;
184
+ if (!viewer || typeof viewer !== "object" || typeof viewer.id !== "string") {
185
+ throw new Error("OAuth token validated but the response was missing a viewer with id. Try a different scope set.");
186
+ }
187
+ return viewer;
188
+ }
189
+ /**
190
+ * Run the OAuth step. Returns the final stored state + viewer info.
191
+ *
192
+ * If the user opts to keep their existing tokens, returns the existing
193
+ * state unchanged (no network calls).
194
+ */
195
+ export async function runOAuthStep(options = {}) {
196
+ const validateViewer = options.validateViewer ?? defaultValidateViewer;
197
+ const existing = await readOAuthState();
198
+ if (existing && !options.force) {
199
+ const handled = await handleExistingState(existing, options);
200
+ if (handled.kind === "keep") {
201
+ // Validate the existing token actually works; if it's already
202
+ // expired and unrefreshable, the next `el-linear` invocation
203
+ // would fall over — fail loudly here.
204
+ try {
205
+ const viewer = await validateViewer(handled.state.accessToken);
206
+ logLine(TS(`✓ Existing OAuth tokens verified — authenticated as ${viewer.displayName} <${viewer.email}>.`));
207
+ return { state: handled.state, viewer };
208
+ }
209
+ catch (err) {
210
+ const raw = err instanceof Error ? err.message : String(err);
211
+ logLine(TS(`Existing token failed validation (${sanitizeForLog(raw)}). Continuing with re-authorization…`));
212
+ }
213
+ }
214
+ // Both `reauth` and `revoked` fall through to the re-auth flow.
215
+ }
216
+ const reg = await promptRegistration({
217
+ port: options.port ?? extractPortFromRedirect(existing) ?? DEFAULT_PORT,
218
+ });
219
+ const redirectUri = `http://localhost:${reg.port}${DEFAULT_CALLBACK_PATH}`;
220
+ const pkce = generatePkce();
221
+ const state = generateState();
222
+ const authorizeUrl = buildAuthorizeUrl({
223
+ clientId: reg.clientId,
224
+ redirectUri,
225
+ scopes: reg.scopes,
226
+ state,
227
+ codeChallenge: pkce.challenge,
228
+ });
229
+ logLine("");
230
+ logLine(TS("Opening your browser to authorize…"));
231
+ logLine(TS(`If it doesn't open, visit: ${authorizeUrl}`));
232
+ const useBrowser = !options.noBrowser;
233
+ let browserOpened = false;
234
+ if (useBrowser) {
235
+ try {
236
+ await (options.openBrowser ?? openSystemBrowser)(authorizeUrl);
237
+ browserOpened = true;
238
+ }
239
+ catch (err) {
240
+ const raw = err instanceof Error ? err.message : String(err);
241
+ logLine(TS(`Could not open a browser automatically (${raw}). Falling back to manual.`));
242
+ }
243
+ }
244
+ let callback;
245
+ if (browserOpened) {
246
+ try {
247
+ callback = await (options.runLocalhostCallbackImpl ?? runLocalhostCallback)({
248
+ port: reg.port,
249
+ expectedState: state,
250
+ });
251
+ }
252
+ catch (err) {
253
+ const raw = err instanceof Error ? err.message : String(err);
254
+ logLine(TS(`Localhost listener failed (${sanitizeForLog(raw)}). Falling back to manual paste.`));
255
+ callback = await promptForPastedCode({ expectedState: state });
256
+ }
257
+ }
258
+ else {
259
+ callback = await promptForPastedCode({ expectedState: state });
260
+ }
261
+ logLine(TS("Exchanging authorization code for tokens…"));
262
+ const exchanged = await exchangeCodeForTokens({
263
+ clientId: reg.clientId,
264
+ clientSecret: reg.clientSecret,
265
+ code: callback.code,
266
+ redirectUri,
267
+ codeVerifier: pkce.verifier,
268
+ }, options.fetchImpl);
269
+ const newState = {
270
+ v: OAUTH_STATE_VERSION,
271
+ clientId: reg.clientId,
272
+ clientSecret: reg.clientSecret,
273
+ registeredRedirectUri: redirectUri,
274
+ accessToken: exchanged.accessToken,
275
+ refreshToken: exchanged.refreshToken,
276
+ tokenType: exchanged.tokenType,
277
+ scopes: exchanged.scopes.length > 0 ? exchanged.scopes : reg.scopes,
278
+ expiresAt: exchanged.expiresAt,
279
+ obtainedAt: Date.now(),
280
+ };
281
+ logLine(TS("Validating against viewer…"));
282
+ const viewer = await validateViewer(newState.accessToken);
283
+ await writeOAuthState(newState);
284
+ logLine(TS(`✓ Authorized as ${viewer.displayName} <${viewer.email}> (${viewer.organization.name}).`));
285
+ return { state: newState, viewer };
286
+ }
287
+ /**
288
+ * `init oauth --revoke`: revoke the active profile's tokens and remove
289
+ * `oauth.json`. Best-effort on the network call.
290
+ */
291
+ export async function runOAuthRevoke(options = {}) {
292
+ const existing = await readOAuthState();
293
+ if (!existing) {
294
+ return { revoked: false, message: "No OAuth state to revoke." };
295
+ }
296
+ const result = await revokeToken({ accessToken: existing.accessToken }, options.fetchImpl);
297
+ await clearOAuthState();
298
+ if (result.ok) {
299
+ return {
300
+ revoked: true,
301
+ message: "✓ Token revoked and oauth.json removed.",
302
+ };
303
+ }
304
+ return {
305
+ revoked: false,
306
+ message: `Revoke endpoint returned ${result.status} (${sanitizeForLog(result.message ?? "")}). Local oauth.json removed anyway.`,
307
+ };
308
+ }
@@ -174,7 +174,6 @@ export async function clearAliasesProgress() {
174
174
  * Print a step header in the wizard. Step strings like "1/4" or "2/4 (skipped)".
175
175
  */
176
176
  export function printStep(label, title) {
177
- // biome-ignore lint/suspicious/noConsole: wizard output is meant for stdout
178
177
  console.log(`\n[${label}] ${title}`);
179
178
  }
180
179
  /**
@@ -91,7 +91,6 @@ export async function runTokenStep(options = {}) {
91
91
  if (existing && !options.force) {
92
92
  try {
93
93
  const viewer = await validateToken(existing);
94
- // biome-ignore lint/suspicious/noConsole: wizard
95
94
  console.log(` Existing token works — authenticated as ${viewer.displayName} <${viewer.email}>.`);
96
95
  const replace = await confirm({
97
96
  message: "Replace this token?",
@@ -104,13 +103,10 @@ export async function runTokenStep(options = {}) {
104
103
  catch (err) {
105
104
  const raw = err instanceof Error ? err.message : String(err);
106
105
  const message = sanitizeForLog(raw);
107
- // biome-ignore lint/suspicious/noConsole: wizard
108
106
  console.log(` Existing token failed validation (${message}). You'll need to provide a new one.`);
109
107
  }
110
108
  }
111
- // biome-ignore lint/suspicious/noConsole: wizard
112
109
  console.log(` Generate a personal API token: ${TOKEN_GENERATION_URL}`);
113
- // biome-ignore lint/suspicious/noConsole: wizard
114
110
  console.log(" The token stays on your machine. el-linear only sends it to Linear's API. " +
115
111
  "It's stored at ~/.config/el-linear/token (mode 0600).");
116
112
  // Up to three attempts before giving up.
@@ -123,17 +119,14 @@ export async function runTokenStep(options = {}) {
123
119
  validate: (input) => input.trim().length > 0 || "Token cannot be empty",
124
120
  })).trim();
125
121
  try {
126
- // biome-ignore lint/suspicious/noConsole: wizard
127
122
  console.log(" Validating…");
128
123
  const viewer = await validateToken(token);
129
124
  await writeToken(token);
130
- // biome-ignore lint/suspicious/noConsole: wizard
131
125
  console.log(` ✓ Authenticated as ${viewer.displayName} <${viewer.email}> (${viewer.organization.name}).`);
132
126
  return { token, viewer };
133
127
  }
134
128
  catch (err) {
135
129
  const raw = err instanceof Error ? err.message : String(err);
136
- // biome-ignore lint/suspicious/noConsole: wizard
137
130
  console.log(` ✗ ${sanitizeForLog(raw)}`);
138
131
  }
139
132
  }
@@ -33,7 +33,6 @@ export async function runWorkspaceStep(token, workspaceUrlKey, existing) {
33
33
  for (const t of teams) {
34
34
  teamMap[t.key] = t.id;
35
35
  }
36
- // biome-ignore lint/suspicious/noConsole: wizard
37
36
  console.log(` Workspace: ${workspaceUrlKey} (${teams.length} teams)`);
38
37
  const currentDefault = existing.defaultTeam || "";
39
38
  // `change` reflects what the user agreed to: `true` means "open the picker
@@ -53,7 +52,6 @@ export async function runWorkspaceStep(token, workspaceUrlKey, existing) {
53
52
  };
54
53
  }
55
54
  if (teams.length === 0) {
56
- // biome-ignore lint/suspicious/noConsole: wizard
57
55
  console.log(" No teams visible to this token. Skipping default-team picker.");
58
56
  return {
59
57
  workspaceUrlKey,
@@ -72,7 +72,9 @@ export function setupIssueIdCommand(program) {
72
72
  return;
73
73
  }
74
74
  const rootOpts = command.parent?.opts() ?? {};
75
- const service = createGraphQLService({ apiToken: rootOpts.apiToken });
75
+ const service = await createGraphQLService({
76
+ apiToken: rootOpts.apiToken,
77
+ });
76
78
  const result = (await service.rawRequest(ISSUE_QUERY, {
77
79
  id: parsed.issueId,
78
80
  }));
@@ -7,10 +7,10 @@ import { resolveDefaultStatus } from "../config/status-defaults.js";
7
7
  import { enforceTerms } from "../config/term-enforcer.js";
8
8
  import { LIST_COMMENTS_QUERY } from "../queries/comments.js";
9
9
  import { GET_ISSUE_RELATIONS_QUERY, GET_ISSUE_STATE_HISTORY_QUERY, ISSUE_RELATION_CREATE_MUTATION, SCAN_ISSUES_QUERY, UPDATE_ISSUE_MUTATION, } from "../queries/issues.js";
10
- import { getApiToken } from "../utils/auth.js";
11
10
  import { autoLinkReferences, } from "../utils/auto-link-references.js";
12
11
  import { downloadLinearUploads } from "../utils/download-uploads.js";
13
- import { FileService } from "../utils/file-service.js";
12
+ import { createFileService } from "../utils/file-service.js";
13
+ import { applyFooter } from "../utils/footer.js";
14
14
  import { createGraphQLAttachmentsService } from "../utils/graphql-attachments-service.js";
15
15
  import { GraphQLIssuesService } from "../utils/graphql-issues-service.js";
16
16
  import { createGraphQLService, } from "../utils/graphql-service.js";
@@ -19,6 +19,7 @@ import { wrapIssueReferencesAsLinks } from "../utils/issue-reference-wrapper.js"
19
19
  import { createLinearService, } from "../utils/linear-service.js";
20
20
  import { logger } from "../utils/logger.js";
21
21
  import { handleAsyncCommand, outputSuccess, outputWarning, } from "../utils/output.js";
22
+ import { getRootOpts } from "../utils/root-opts.js";
22
23
  import { formatCsv, formatMarkdown, formatTable, } from "../utils/table-formatter.js";
23
24
  import { validateReferences } from "../utils/validate-references.js";
24
25
  import { parsePriorityFilter, splitList, validatePriority, } from "../utils/validators.js";
@@ -78,14 +79,43 @@ function readDescriptionFile(filePath) {
78
79
  return fs.readFileSync(filePath, "utf8").trim();
79
80
  }
80
81
  /**
81
- * Resolve the description from --description, --description-file, or both.
82
- * --description-file takes precedence when both are provided.
82
+ * Resolve the description from --description, --description-file, or
83
+ * --template (looked up in `config.descriptionTemplates`).
84
+ *
85
+ * Precedence:
86
+ * --description-file > --description > --template
87
+ *
88
+ * Passing --template alongside --description or --description-file is a
89
+ * usage error — the explicit body and a template both producing content
90
+ * would be silently dropping one. We throw so the user picks one.
83
91
  */
84
92
  function resolveDescription(options) {
85
- if (options.descriptionFile) {
93
+ const hasInline = typeof options.description === "string" && options.description.length > 0;
94
+ const hasFile = Boolean(options.descriptionFile);
95
+ const hasTemplate = typeof options.template === "string" && options.template;
96
+ if (hasTemplate && (hasInline || hasFile)) {
97
+ throw new Error("--template is mutually exclusive with --description / --description-file. " +
98
+ "Pick one.");
99
+ }
100
+ if (hasFile) {
86
101
  return readDescriptionFile(options.descriptionFile);
87
102
  }
88
- return options.description;
103
+ if (hasInline) {
104
+ return options.description;
105
+ }
106
+ if (hasTemplate) {
107
+ const templates = loadConfig().descriptionTemplates ?? {};
108
+ const body = templates[options.template];
109
+ if (!body) {
110
+ const available = Object.keys(templates).sort();
111
+ const hint = available.length
112
+ ? `Available templates: ${available.join(", ")}`
113
+ : "No templates configured. Add one under `descriptionTemplates` in your config.";
114
+ throw new Error(`Template "${options.template}" not found. ${hint}`);
115
+ }
116
+ return body;
117
+ }
118
+ return undefined;
89
119
  }
90
120
  function isImageFile(filename) {
91
121
  const ext = filename.lastIndexOf(".");
@@ -296,9 +326,9 @@ async function handleListIssues(options, command) {
296
326
  if (options.label && !options.labels) {
297
327
  options.labels = options.label;
298
328
  }
299
- const rootOpts = command.parent.parent.opts();
300
- const graphQLService = createGraphQLService(rootOpts);
301
- const linearService = createLinearService(rootOpts);
329
+ const rootOpts = getRootOpts(command);
330
+ const graphQLService = await createGraphQLService(rootOpts);
331
+ const linearService = await createLinearService(rootOpts);
302
332
  const issuesService = new GraphQLIssuesService(graphQLService, linearService);
303
333
  const hasFilters = options.team ||
304
334
  options.labels ||
@@ -337,9 +367,9 @@ async function handleSearchIssues(query, options, command) {
337
367
  if (options.label && !options.labels) {
338
368
  options.labels = options.label;
339
369
  }
340
- const rootOpts = command.parent.parent.opts();
341
- const graphQLService = createGraphQLService(rootOpts);
342
- const linearService = createLinearService(rootOpts);
370
+ const rootOpts = getRootOpts(command);
371
+ const graphQLService = await createGraphQLService(rootOpts);
372
+ const linearService = await createLinearService(rootOpts);
343
373
  const issuesService = new GraphQLIssuesService(graphQLService, linearService);
344
374
  const searchArgs = {
345
375
  query,
@@ -417,8 +447,7 @@ async function uploadAttachmentsIfNeeded(options, rootOpts) {
417
447
  const paths = Array.isArray(options.attachment)
418
448
  ? options.attachment
419
449
  : [options.attachment];
420
- const apiToken = getApiToken(rootOpts);
421
- const fileService = new FileService(apiToken);
450
+ const fileService = await createFileService(rootOpts);
422
451
  const results = [];
423
452
  for (const filePath of paths) {
424
453
  const result = await fileService.uploadFile(filePath);
@@ -442,12 +471,21 @@ function buildDescriptionWithAttachments(baseDescription, uploadResults) {
442
471
  return description;
443
472
  }
444
473
  async function handleCreateIssue(title, options, command) {
445
- const rootOpts = command.parent.parent.opts();
474
+ const rootOpts = getRootOpts(command);
446
475
  const { teamInput, teamId, assigneeId, labelIds, status, subscriberIds } = await resolveCreateInputs(title, options, rootOpts);
447
476
  const uploadResults = await uploadAttachmentsIfNeeded(options, rootOpts);
448
- const description = buildDescriptionWithAttachments(resolveDescription(options) || "", uploadResults);
449
- const graphQLService = createGraphQLService(rootOpts);
450
- const linearService = createLinearService(rootOpts);
477
+ const descriptionWithAttachments = buildDescriptionWithAttachments(resolveDescription(options) || "", uploadResults);
478
+ // Append messageFooter (config or --footer flag) so auto-link picks up any
479
+ // issue refs in the footer too. --no-footer skips both flag and config.
480
+ // Commander parses --no-footer as `options.footer === false`.
481
+ const noFooter = options.footer === false;
482
+ const explicitFooter = typeof options.footer === "string" ? options.footer : undefined;
483
+ const description = applyFooter(descriptionWithAttachments, {
484
+ footer: explicitFooter,
485
+ noFooter,
486
+ }) ?? "";
487
+ const graphQLService = await createGraphQLService(rootOpts);
488
+ const linearService = await createLinearService(rootOpts);
451
489
  const issuesService = new GraphQLIssuesService(graphQLService, linearService);
452
490
  // Wrap valid issue identifiers as markdown links before creating, so the description
453
491
  // saved on Linear has clickable refs from the start. Self-reference can't apply here
@@ -486,7 +524,7 @@ async function handleCreateIssue(title, options, command) {
486
524
  // so only create separate attachment records for non-image files.
487
525
  const attachments = [];
488
526
  if (uploadResults.length > 0) {
489
- const attachmentsService = createGraphQLAttachmentsService(rootOpts);
527
+ const attachmentsService = await createGraphQLAttachmentsService(rootOpts);
490
528
  for (const uploadResult of uploadResults) {
491
529
  if (!isImageFile(uploadResult.filename)) {
492
530
  const attachment = await attachmentsService.createAttachment({
@@ -517,9 +555,9 @@ async function handleCreateIssue(title, options, command) {
517
555
  outputSuccess(output);
518
556
  }
519
557
  async function handleHistoryIssue(issueId, _options, command) {
520
- const rootOpts = command.parent.parent.opts();
521
- const graphQLService = createGraphQLService(rootOpts);
522
- const linearService = createLinearService(rootOpts);
558
+ const rootOpts = getRootOpts(command);
559
+ const graphQLService = await createGraphQLService(rootOpts);
560
+ const linearService = await createLinearService(rootOpts);
523
561
  const resolvedId = await linearService.resolveIssueId(issueId);
524
562
  const result = await graphQLService.rawRequest(GET_ISSUE_STATE_HISTORY_QUERY, {
525
563
  id: resolvedId,
@@ -547,9 +585,9 @@ async function handleHistoryIssue(issueId, _options, command) {
547
585
  });
548
586
  }
549
587
  async function handleRelateIssue(issueId, options, command) {
550
- const rootOpts = command.parent.parent.opts();
551
- const graphQLService = createGraphQLService(rootOpts);
552
- const linearService = createLinearService(rootOpts);
588
+ const rootOpts = getRootOpts(command);
589
+ const graphQLService = await createGraphQLService(rootOpts);
590
+ const linearService = await createLinearService(rootOpts);
553
591
  const sourceId = await linearService.resolveIssueId(issueId);
554
592
  const relations = await createRelations(sourceId, options, graphQLService, linearService);
555
593
  if (relations.length === 0) {
@@ -620,9 +658,9 @@ function buildRelationEntries(nodes, peerKey, direction, normalizeType) {
620
658
  return entries;
621
659
  }
622
660
  async function handleRelatedIssues(issueId, _options, command) {
623
- const rootOpts = command.parent.parent.opts();
624
- const graphQLService = createGraphQLService(rootOpts);
625
- const linearService = createLinearService(rootOpts);
661
+ const rootOpts = getRootOpts(command);
662
+ const graphQLService = await createGraphQLService(rootOpts);
663
+ const linearService = await createLinearService(rootOpts);
626
664
  const resolvedId = await linearService.resolveIssueId(issueId);
627
665
  const result = await graphQLService.rawRequest(GET_ISSUE_RELATIONS_QUERY, {
628
666
  id: resolvedId,
@@ -813,10 +851,10 @@ async function handleLinkReferencesBatch(teamInput, options, graphQLService, lin
813
851
  },
814
852
  });
815
853
  }
816
- function handleLinkReferencesIssue(issueIdOrTeamFlag, options, command) {
817
- const rootOpts = command.parent.parent.opts();
818
- const graphQLService = createGraphQLService(rootOpts);
819
- const linearService = createLinearService(rootOpts);
854
+ async function handleLinkReferencesIssue(issueIdOrTeamFlag, options, command) {
855
+ const rootOpts = getRootOpts(command);
856
+ const graphQLService = await createGraphQLService(rootOpts);
857
+ const linearService = await createLinearService(rootOpts);
820
858
  if (options.team) {
821
859
  if (issueIdOrTeamFlag) {
822
860
  throw new Error("Provide either an issueId positional argument OR --team, not both. With --team, omit the issueId.");
@@ -832,20 +870,20 @@ function handleLinkReferencesIssue(issueIdOrTeamFlag, options, command) {
832
870
  return handleLinkReferencesSingle(issueIdOrTeamFlag, options, graphQLService, linearService);
833
871
  }
834
872
  async function handleReadIssue(issueIds, _options, command) {
835
- const rootOpts = command.parent.parent.opts();
836
- const graphQLService = createGraphQLService(rootOpts);
837
- const linearService = createLinearService(rootOpts);
873
+ const rootOpts = getRootOpts(command);
874
+ const graphQLService = await createGraphQLService(rootOpts);
875
+ const linearService = await createLinearService(rootOpts);
838
876
  const issuesService = new GraphQLIssuesService(graphQLService, linearService);
839
- const apiToken = getApiToken(rootOpts);
877
+ const fileService = await createFileService(rootOpts);
840
878
  if (issueIds.length === 1) {
841
879
  const issue = await issuesService.getIssueById(issueIds[0]);
842
- const resolved = await downloadLinearUploads(issue, apiToken);
880
+ const resolved = await downloadLinearUploads(issue, fileService);
843
881
  outputSuccess(resolved);
844
882
  }
845
883
  else {
846
884
  const results = await Promise.all(issueIds.map(async (id) => {
847
885
  const issue = await issuesService.getIssueById(id);
848
- return downloadLinearUploads(issue, apiToken);
886
+ return downloadLinearUploads(issue, fileService);
849
887
  }));
850
888
  outputSuccess(results);
851
889
  }
@@ -863,9 +901,9 @@ async function handleUpdateIssue(issueId, options, command) {
863
901
  options.description = readDescriptionFile(options.descriptionFile);
864
902
  }
865
903
  validateUpdateOptions(options);
866
- const rootOpts = command.parent.parent.opts();
867
- const graphQLService = createGraphQLService(rootOpts);
868
- const linearService = createLinearService(rootOpts);
904
+ const rootOpts = getRootOpts(command);
905
+ const graphQLService = await createGraphQLService(rootOpts);
906
+ const linearService = await createLinearService(rootOpts);
869
907
  if (options.appendDescription) {
870
908
  const resolved = await linearService.resolveIssueId(issueId);
871
909
  const current = await graphQLService.rawRequest("query($id: String!) { issue(id: $id) { description } }", { id: resolved });
@@ -952,9 +990,9 @@ async function handleRetrolink(options, command) {
952
990
  const description = commits.length > 0
953
991
  ? commits.map((msg) => `- ${msg}`).join("\n")
954
992
  : `Retrolinked from branch: ${currentBranch}`;
955
- const rootOpts = command.parent.parent.opts();
956
- const graphQLService = createGraphQLService(rootOpts);
957
- const linearService = createLinearService(rootOpts);
993
+ const rootOpts = getRootOpts(command);
994
+ const graphQLService = await createGraphQLService(rootOpts);
995
+ const linearService = await createLinearService(rootOpts);
958
996
  const issuesService = new GraphQLIssuesService(graphQLService, linearService);
959
997
  let result;
960
998
  if (options.issue) {
@@ -1035,8 +1073,9 @@ export function setupIssuesCommands(program) {
1035
1073
  .option("-t, --title <title>", "issue title (alternative to positional argument)")
1036
1074
  .option("-d, --description <desc>", "issue description")
1037
1075
  .option("--description-file <path>", "read description from file (use - for stdin)")
1076
+ .option("--template <name>", "use a named description template from config.descriptionTemplates")
1038
1077
  .option("-a, --assignee <assignee>", "assign to user (name, alias, or UUID)")
1039
- .option("-p, --priority <priority>", "priority: name (urgent/high/medium/low) or number (1-4)")
1078
+ .option("-p, --priority <priority>", "priority: name (none/urgent/high/medium/normal/low) or number (0-4)")
1040
1079
  .option("--project <project>", "add to project (name or ID)")
1041
1080
  .option("--team <team>", "team key or name (default: from config)")
1042
1081
  .option("--labels <labels>", "labels (comma-separated names, auto-resolved per team)")
@@ -1056,6 +1095,8 @@ export function setupIssuesCommands(program) {
1056
1095
  .option("--checkout", "create and checkout a git branch named after the issue")
1057
1096
  .option("--skip-validation", "skip all validation (labels, description, assignee, project)")
1058
1097
  .option("--no-auto-link", "skip auto-linking issue references found in the description")
1098
+ .option("--footer <text>", "text appended to the description (overrides config.messageFooter)")
1099
+ .option("--no-footer", "skip the configured messageFooter for this issue")
1059
1100
  .action(handleAsyncCommand((titleArg, options, command) => {
1060
1101
  // Normalize --label alias to --labels
1061
1102
  if (options.label && !options.labels) {
@@ -1087,7 +1128,7 @@ export function setupIssuesCommands(program) {
1087
1128
  .option("--append-description <text>", "append text to the existing description")
1088
1129
  .option("-s, --status <status>", "new status name or ID")
1089
1130
  .option("--state <status>", "alias for --status (new status name or ID)")
1090
- .option("-p, --priority <priority>", "new priority: name (urgent/high/medium/low) or number (1-4)")
1131
+ .option("-p, --priority <priority>", "new priority: name (none/urgent/high/medium/normal/low) or number (0-4)")
1091
1132
  .option("--assignee <assignee>", "new assignee (name, alias, or UUID)")
1092
1133
  .option("--project <project>", "new project (name or ID)")
1093
1134
  .option("--labels <labels>", "labels (comma-separated names or IDs)")