linear-sdk-cli 0.8.3 → 0.9.1

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.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Top-level meta commands: whoami, auth (login/list/default/token/status/
3
- * logout/migrate), config.
3
+ * logout/rename/migrate), config.
4
4
  */
5
5
 
6
6
  import { Command } from "commander";
@@ -18,8 +18,9 @@ import {
18
18
  setDefaultWorkspace,
19
19
  listCredentials,
20
20
  readOAuthCredential,
21
- writeOAuthCredential,
22
21
  migrateCredentials,
22
+ renameApiKeyProfile,
23
+ profileRenameProjectUpdates,
23
24
  redactKey,
24
25
  resolveConfig,
25
26
  defaultProjectConfigPath,
@@ -33,12 +34,11 @@ import {
33
34
  import { keyring } from "../lib/keyring.js";
34
35
  import { readStdinSync } from "../lib/body.js";
35
36
  import { CliError, authError, normalizeError, usageError } from "../lib/errors.js";
36
- import { confirmDestructive, promptSelect } from "../lib/prompt.js";
37
+ import { confirmDestructive, promptSecret, promptSelect } from "../lib/prompt.js";
37
38
  import { listTeams } from "../services/team.js";
38
39
  import { resolveTeam } from "../lib/resolve.js";
39
40
  import { ISSUE_SORTS } from "../services/issue.js";
40
41
  import { firstTeam, type Context } from "../context.js";
41
- import { openUrl } from "../lib/open.js";
42
42
  import {
43
43
  authorizationCapabilities,
44
44
  credentialType as resolvedCredentialType,
@@ -46,19 +46,9 @@ import {
46
46
  import {
47
47
  ClientCredentialsTokenProvider,
48
48
  clientCredentialsScopes,
49
- DEFAULT_OAUTH_SCOPES,
50
- buildAuthorizationUrl,
51
- createPkceRequest,
52
- exchangeAuthorizationCode,
53
49
  revokeOAuthToken,
54
- startLoopbackCallback,
55
- type OAuthUserCredential,
56
50
  } from "../oauth.js";
57
51
 
58
- export const DEFAULT_OAUTH_REDIRECT_URI = "http://127.0.0.1:43821/oauth/callback";
59
- /** Public PKCE client identity. This is not a credential; no client secret is shipped or used. */
60
- export const DEFAULT_OAUTH_CLIENT_ID = "eda60862e4bb8b8af82cfb3193b65c2f";
61
-
62
52
  /**
63
53
  * The `whoami` handler, shared by the top-level `linear whoami` and the
64
54
  * reference CLI's spelling `linear auth whoami` so the two cannot drift.
@@ -111,6 +101,12 @@ const defaultAuthValidationClientFactory: AuthValidationClientFactory = (credent
111
101
  kind === "oauth-access-token" ? { accessToken: credential } : { apiKey: credential },
112
102
  ) as unknown as AuthValidationClient;
113
103
  let authValidationClientFactory = defaultAuthValidationClientFactory;
104
+ let authSecretPrompt = promptSecret;
105
+
106
+ /** Test seam that does not depend on Bun's module-namespace spying behavior. */
107
+ export function setAuthSecretPromptForTests(prompt: typeof promptSecret | undefined): void {
108
+ authSecretPrompt = prompt ?? promptSecret;
109
+ }
114
110
 
115
111
  /** Test seam for command-level auth tests; production always uses LinearClient. */
116
112
  export function setAuthValidationClientFactoryForTests(
@@ -229,17 +225,14 @@ export function registerMeta(program: Command): void {
229
225
  // login -------------------------------------------------------------------
230
226
  auth
231
227
  .command("login")
232
- .description("Authenticate and select the workspace for this project")
228
+ .description("Sign in with a personal API key (default) or app credentials")
233
229
  .option("--no-project", "save credentials without changing the project workspace")
234
230
  .option("--key <key>", "use a personal API key ('-' reads it from stdin)")
235
231
  .option(
236
232
  "--plaintext",
237
233
  "Store API-key or app credentials in the private user config file (0600)",
238
234
  )
239
- .option("--no-browser", "print the authorization URL instead of opening it")
240
- .option("--read-only", "request read-only OAuth access")
241
- .option("--admin", "explicitly add the OAuth admin scope")
242
- .option("--timeout <seconds>", "seconds to wait for the loopback callback", "120")
235
+ .option("--read-only", "request read-only access with --app")
243
236
  .option("--app", "sign in as an app using client credentials")
244
237
  .option("--client-credentials", "alias for --app")
245
238
  .option("--scope <scopes>", "comma-separated app scopes (default: read,write)")
@@ -247,30 +240,18 @@ export function registerMeta(program: Command): void {
247
240
  "--client-secret <secret>",
248
241
  "app client secret ('-' reads stdin; defaults to LINEAR_CLIENT_SECRET)",
249
242
  )
250
- .option(
251
- "--client-id <id>",
252
- "OAuth client ID (app: LINEAR_CLIENT_ID; browser: packaged CLI app)",
253
- )
254
- .option("--redirect-uri <uri>", "registered HTTP loopback callback URI")
243
+ .option("--client-id <id>", "app client ID (defaults to LINEAR_CLIENT_ID)")
255
244
  .action(
256
245
  action(async (ctx: Context, opts) => {
257
246
  if (ctx.options.accessToken !== undefined) {
258
247
  throw usageError(
259
- "`auth login` cannot persist an injected access token. Remove --access-token and use browser OAuth, or use --key - for a personal API key.",
248
+ "`auth login` cannot persist an injected access token. Use LINEAR_ACCESS_TOKEN or --access-token with a data command, or use `auth login --key -` for a personal API key.",
260
249
  );
261
250
  }
262
251
  if (opts.app || opts.clientCredentials) {
263
- if (
264
- opts.key !== undefined ||
265
- ctx.options.apiKey !== undefined ||
266
- opts.redirectUri ||
267
- opts.browser === false
268
- ) {
269
- throw usageError(
270
- "--app cannot be combined with personal API-key or browser callback options.",
271
- );
252
+ if (opts.key !== undefined || ctx.options.apiKey !== undefined) {
253
+ throw usageError("--app cannot be combined with --key or --api-key.");
272
254
  }
273
- if (opts.admin) throw usageError("App actors cannot request the admin scope.");
274
255
  if (!opts.plaintext && !keyring())
275
256
  throw new CliError(
276
257
  "App authentication requires a system keyring; use --plaintext for explicit private-file storage.",
@@ -335,144 +316,64 @@ export function registerMeta(program: Command): void {
335
316
  );
336
317
  return;
337
318
  }
338
- if (opts.clientSecret !== undefined || opts.scope !== undefined)
339
- throw usageError("--client-secret and --scope require --app or --client-credentials.");
319
+ if (
320
+ opts.clientId !== undefined ||
321
+ opts.clientSecret !== undefined ||
322
+ opts.scope !== undefined ||
323
+ opts.readOnly
324
+ )
325
+ throw usageError(
326
+ "--client-id, --client-secret, --scope, and --read-only require --app or --client-credentials. Set personal API-key permissions in Linear's Security & Access settings.",
327
+ );
340
328
  let key: string | undefined = selectLoginKey(opts.key, ctx.options.apiKey);
341
- if (key !== undefined) {
342
- if (
343
- opts.readOnly ||
344
- opts.admin ||
345
- opts.browser === false ||
346
- opts.redirectUri ||
347
- opts.clientId
348
- ) {
349
- throw usageError("OAuth-only options cannot be combined with --key or --api-key.");
350
- }
329
+ if (key === undefined && !ctx.isTTY)
330
+ throw usageError(
331
+ "Personal API-key login requires interactive input. Pipe a key to `linear auth login --key -`, or use --app for app credentials. To authenticate commands without saving credentials, set LINEAR_API_KEY or LINEAR_ACCESS_TOKEN.",
332
+ );
333
+ if (!opts.plaintext && !keyring())
334
+ throw new CliError(
335
+ "API-key login requires a system keyring; use --plaintext for explicit private-file storage.",
336
+ "runtime",
337
+ );
338
+ if (key === undefined) {
339
+ ctx.output.info(
340
+ "Create a personal API key at https://linear.app/settings/account/security. Choose its permissions and team access in Linear; workspace policy may restrict key creation.",
341
+ );
342
+ key = await authSecretPrompt(ctx, "Linear API key:", { required: true });
343
+ } else {
351
344
  if (key === "-") key = readStdinSync();
352
345
  else
353
346
  ctx.output.warn(
354
347
  "An API key on the command line is visible to other processes and shell history; prefer --key - (stdin).",
355
348
  );
356
- key = key.trim();
357
- if (!key) throw usageError("No API key provided.");
358
- const identity = await validateAuthCredential(key, ctx.options.workspace);
359
- const { user: me, organization: org } = identity;
360
- const { path, storage, keyringLabel } = writeCredential(org.urlKey, key, {
361
- plaintext: opts.plaintext === true,
362
- });
363
- const projectConfigPath = saveLoginProject(ctx, org.urlKey, opts.project !== false);
364
- const where =
365
- storage === "keychain"
366
- ? `Credential saved to the ${keyringLabel}; ${path} records the workspace.`
367
- : `Credential saved to ${path} (plaintext, 0600).`;
368
- ctx.output.emit(
369
- {
370
- success: true,
371
- credentialType: "api-key",
372
- workspace: org.urlKey,
373
- user: me,
374
- storage,
375
- path,
376
- projectConfigPath,
377
- },
378
- () =>
379
- ctx.output.success(
380
- `Authenticated as ${me.name} <${me.email}> for workspace '${org.urlKey}'. ${where}${projectConfigPath ? ` Project workspace saved to ${projectConfigPath}.` : ""}`,
381
- ),
382
- );
383
- return;
384
349
  }
385
-
386
- if (opts.plaintext) {
387
- throw usageError(
388
- "Browser OAuth is keyring-only; --plaintext requires --key, --api-key, or --client-credentials.",
389
- );
390
- }
391
- if (opts.readOnly && opts.admin) {
392
- throw usageError("--read-only cannot be combined with --admin.");
393
- }
394
- const timeoutSeconds = Number(opts.timeout);
395
- if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) {
396
- throw usageError("--timeout must be a positive number of seconds.");
397
- }
398
- const clientId = String(
399
- opts.clientId ?? process.env.LINEAR_OAUTH_CLIENT_ID ?? DEFAULT_OAUTH_CLIENT_ID,
400
- ).trim();
401
- const redirectUri = String(
402
- opts.redirectUri ?? process.env.LINEAR_OAUTH_REDIRECT_URI ?? DEFAULT_OAUTH_REDIRECT_URI,
403
- );
404
- const scopes = opts.readOnly
405
- ? ["read"]
406
- : opts.admin
407
- ? [...DEFAULT_OAUTH_SCOPES, "admin"]
408
- : [...DEFAULT_OAUTH_SCOPES];
409
- const pkce = createPkceRequest();
410
- const callback = await startLoopbackCallback({
411
- redirectUri,
412
- state: pkce.state,
413
- timeoutMs: timeoutSeconds * 1000,
350
+ key = key.trim();
351
+ if (!key) throw usageError("No API key provided.");
352
+ const identity = await validateAuthCredential(key, ctx.options.workspace);
353
+ const { user: me, organization: org } = identity;
354
+ const { path, storage, keyringLabel } = writeCredential(org.urlKey, key, {
355
+ plaintext: opts.plaintext === true,
414
356
  });
415
- try {
416
- const authorizationUrl = buildAuthorizationUrl({
417
- clientId,
418
- redirectUri: callback.redirectUri,
419
- scopes,
420
- challenge: pkce.challenge,
421
- state: pkce.state,
422
- });
423
- if (opts.browser === false) {
424
- ctx.output.info(`Open this URL to authenticate with Linear:\n${authorizationUrl}`);
425
- } else {
426
- ctx.output.info("Opening Linear in your browser…");
427
- await openUrl(authorizationUrl);
428
- }
429
- const code = await callback.wait;
430
- const token = await exchangeAuthorizationCode({
431
- code,
432
- verifier: pkce.verifier,
433
- clientId,
434
- redirectUri: callback.redirectUri,
435
- });
436
- const identity = await validateAuthCredential(
437
- token.accessToken,
438
- ctx.options.workspace,
439
- "oauth-access-token",
440
- );
441
- const credential: OAuthUserCredential = {
442
- version: 1,
443
- kind: "oauth-user",
444
- actor: "user",
445
- ...token,
446
- clientId,
447
- workspace: identity.organization,
448
- user: identity.user,
449
- };
450
- const saved = writeOAuthCredential(credential);
451
- const projectConfigPath = saveLoginProject(
452
- ctx,
453
- credential.workspace.urlKey,
454
- opts.project !== false,
455
- );
456
- ctx.output.emit(
457
- {
458
- success: true,
459
- credentialType: "oauth-user",
460
- workspace: credential.workspace.urlKey,
461
- user: credential.user,
462
- storage: saved.storage,
463
- scopes: credential.scopes,
464
- expiresAt: new Date(credential.expiresAt).toISOString(),
465
- path: saved.path,
466
- projectConfigPath,
467
- },
468
- () =>
469
- ctx.output.success(
470
- `Authenticated as ${credential.user.name} <${credential.user.email}> for workspace '${credential.workspace.urlKey}'. OAuth credentials saved to the ${saved.keyringLabel}.${projectConfigPath ? ` Project workspace saved to ${projectConfigPath}.` : ""}`,
471
- ),
472
- );
473
- } finally {
474
- await callback.close();
475
- }
357
+ const projectConfigPath = saveLoginProject(ctx, org.urlKey, opts.project !== false);
358
+ const where =
359
+ storage === "keychain"
360
+ ? `Credential saved to the ${keyringLabel}; ${path} records the workspace.`
361
+ : `Credential saved to ${path} (plaintext, 0600).`;
362
+ ctx.output.emit(
363
+ {
364
+ success: true,
365
+ credentialType: "api-key",
366
+ workspace: org.urlKey,
367
+ user: me,
368
+ storage,
369
+ path,
370
+ projectConfigPath,
371
+ },
372
+ () =>
373
+ ctx.output.success(
374
+ `Authenticated as ${me.name} <${me.email}> for workspace '${org.urlKey}'. ${where}${projectConfigPath ? ` Project workspace saved to ${projectConfigPath}.` : ""}`,
375
+ ),
376
+ );
476
377
  }),
477
378
  );
478
379
 
@@ -631,6 +532,80 @@ export function registerMeta(program: Command): void {
631
532
  }),
632
533
  );
633
534
 
535
+ // rename ------------------------------------------------------------------
536
+ auth
537
+ .command("rename <old-slug> <new-slug>")
538
+ .description("Rename a stored API-key profile after its Linear workspace URL slug changes")
539
+ .option(
540
+ "--project-config <paths...>",
541
+ "also inspect these project configs for old workspace selectors (read-only)",
542
+ )
543
+ .addHelpText(
544
+ "after",
545
+ `
546
+ Validates the exact stored API key against the current Linear workspace slug.
547
+ Preserves the key, profile team, user workspace/default, and shared keyring list.
548
+ Refuses configured or unlisted keyring destinations; never overwrites or revokes.
549
+ Keyring moves require atomic create-only support (macOS Keychain). Linux Secret
550
+ Service entries are refused; file-only API-key profiles are supported.
551
+ An interrupted rename can be resumed by repeating the same command.
552
+ OAuth user/app profiles are not supported; their credentials and grants stay unchanged.
553
+ Project files are never edited. Reports the discovered project config and any
554
+ --project-config paths needing updates; other repositories must be checked separately.
555
+ Example: linear auth rename old-workspace new-workspace --json`,
556
+ )
557
+ .action(
558
+ action(async (ctx: Context, opts, oldSlug: string, newSlug: string) => {
559
+ const from = explicitWorkspaceSlug(oldSlug);
560
+ const to = explicitWorkspaceSlug(newSlug);
561
+ if (ctx.options.workspace && ctx.options.workspace !== from)
562
+ throw usageError("--workspace must match <old-slug> when renaming a profile.");
563
+ if (ctx.options.apiKey || ctx.options.accessToken)
564
+ throw usageError(
565
+ "`auth rename` uses only the named stored profile; remove credential flags.",
566
+ );
567
+ const checkedProjectConfigs = [
568
+ ...new Set([
569
+ ...(ctx.config.projectConfigPath ? [ctx.config.projectConfigPath] : []),
570
+ ...(opts.projectConfig ?? []).map((path: string) => resolve(path)),
571
+ ]),
572
+ ] as string[];
573
+ const projectConfigUpdates = profileRenameProjectUpdates(from, to, checkedProjectConfigs);
574
+ const globalConfigUpdates = profileRenameProjectUpdates(
575
+ from,
576
+ to,
577
+ ctx.config.globalConfigPath ? [ctx.config.globalConfigPath] : [],
578
+ );
579
+ let organization: AuthIdentity["organization"] | undefined;
580
+ const saved = await renameApiKeyProfile(from, to, async (key) => {
581
+ organization = (await validateAuthCredential(key, to)).organization;
582
+ });
583
+ const otherProjects = `Update workspace = "${from}" to "${to}" in other project configs and LINEAR_WORKSPACE overrides. Only the listed project paths were checked.`;
584
+ ctx.output.emit(
585
+ {
586
+ success: true,
587
+ from,
588
+ workspace: to,
589
+ credentialType: "api-key",
590
+ organization: organization!,
591
+ ...saved,
592
+ checkedProjectConfigs,
593
+ projectConfigUpdates,
594
+ globalConfigUpdates,
595
+ otherProjects,
596
+ },
597
+ () => {
598
+ ctx.output.success(
599
+ `Renamed API-key profile '${from}' to '${to}'. Credential preserved; no grant revoked.`,
600
+ );
601
+ for (const update of [...projectConfigUpdates, ...globalConfigUpdates])
602
+ ctx.output.info(`Update ${update.path}: workspace = "${to}" (currently "${from}").`);
603
+ ctx.output.info(otherProjects);
604
+ },
605
+ );
606
+ }),
607
+ );
608
+
634
609
  // migrate -----------------------------------------------------------------
635
610
  auth
636
611
  .command("migrate")
@@ -753,6 +728,7 @@ export function registerMeta(program: Command): void {
753
728
  workspaceProfile: c.workspaceProfile ?? null,
754
729
  team: c.team ?? null,
755
730
  workspace: c.workspace ?? null,
731
+ project: c.project ?? null,
756
732
  sort: c.sort,
757
733
  origins: c.origins,
758
734
  userConfigPath: c.userConfigPath,
@@ -766,6 +742,7 @@ export function registerMeta(program: Command): void {
766
742
  ["Workspace profile", c.workspaceProfile ?? "(none)"],
767
743
  ["Team", show("team", c.team)],
768
744
  ["Workspace", show("workspace", c.workspace)],
745
+ ["Default issue project", show("project", c.project)],
769
746
  ["Sort", show("sort", c.sort)],
770
747
  ["User config", c.userConfigPath],
771
748
  ["Project config", c.projectConfigPath],
@@ -780,6 +757,7 @@ export function registerMeta(program: Command): void {
780
757
  .command("init")
781
758
  .description("Write a project .linear.toml (at the git root, or here outside a repository)")
782
759
  .option("--team <key>", "default team key (otherwise chosen from a list)")
760
+ .option("--project <name|id>", "default Linear project for new issues")
783
761
  .option("--sort <order>", `default issue-list sort (${ISSUE_SORTS.join(" | ")})`)
784
762
  .option("--path <file>", "write this file instead of <git root>/.linear.toml")
785
763
  .option("--force", "overwrite an existing file")
@@ -808,10 +786,11 @@ export function registerMeta(program: Command): void {
808
786
  team: validateSetting("team", team),
809
787
  };
810
788
  if (opts.sort !== undefined) settings.sort = validateSetting("sort", opts.sort);
789
+ if (opts.project !== undefined) settings.project = validateSetting("project", opts.project);
811
790
  initProjectConfig(path, settings, { force: opts.force === true });
812
791
  ctx.output.emit({ success: true, path, ...settings }, () =>
813
792
  ctx.output.success(
814
- `Wrote ${path} (team ${settings.team}${settings.sort ? `, sort ${settings.sort}` : ""}).`,
793
+ `Wrote ${path} (team ${settings.team}${settings.project ? `, project ${settings.project}` : ""}${settings.sort ? `, sort ${settings.sort}` : ""}).`,
815
794
  ),
816
795
  );
817
796
  }),
@@ -0,0 +1,90 @@
1
+ import { Command } from "commander";
2
+ import { action } from "../lib/action.js";
3
+ import { noteOptions, noteInput } from "../lib/release-input.js";
4
+ import { confirmDestructive } from "../lib/prompt.js";
5
+ import * as svc from "../services/release.js";
6
+
7
+ export function registerReleaseNote(program: Command): void {
8
+ const group = program
9
+ .command("release-note")
10
+ .description("Manage Markdown notes covering one or more releases");
11
+ group
12
+ .command("list")
13
+ .alias("ls")
14
+ .description("List release notes with optional pipeline/release filters")
15
+ .option("--pipeline <id|slug>", "filter by pipeline UUID or slug")
16
+ .option("--release <id>", "filter by covered release UUID or slug")
17
+ .option("--include-archived", "include archived notes")
18
+ .action(
19
+ action(async (ctx, opts) => {
20
+ const rows = await svc.listNotes(ctx.client, ctx.limit, opts);
21
+ ctx.output.list(
22
+ rows,
23
+ [
24
+ { key: "id", header: "ID", value: (r) => r.id },
25
+ { key: "title", header: "Title", value: (r) => r.title ?? "—" },
26
+ { key: "pipeline", header: "Pipeline", value: (r) => r.pipeline.name },
27
+ { key: "releaseCount", header: "Releases", value: (r) => r.releaseCount },
28
+ ],
29
+ rows,
30
+ );
31
+ }),
32
+ );
33
+ group
34
+ .command("view <id>")
35
+ .description("Show a release note and its Markdown body by UUID or slug")
36
+ .action(
37
+ action(async (ctx, _opts, id: string) => {
38
+ const n = await svc.getNote(ctx.client, id);
39
+ ctx.output.detail(n, [
40
+ ["ID", n.id],
41
+ ["Title", n.title],
42
+ ["Pipeline", n.pipeline.name],
43
+ ["Releases", n.releaseCount],
44
+ ["First release", n.firstRelease?.name],
45
+ ["Last release", n.lastRelease?.name],
46
+ ["Generation status", n.generationStatus],
47
+ ["Archived", n.archivedAt],
48
+ ["URL", n.url],
49
+ ["Content", n.content],
50
+ ]);
51
+ }),
52
+ );
53
+ noteOptions(
54
+ group
55
+ .command("create")
56
+ .description("Create a note for explicit releases or an inclusive createdAt range"),
57
+ )
58
+ .requiredOption("--pipeline <id|slug>", "pipeline UUID or slug")
59
+ .action(
60
+ action(async (ctx, opts) => {
61
+ const input = noteInput(opts);
62
+ svc.validateNoteScope(input, true);
63
+ const n = await svc.createNote(ctx.client, opts.pipeline, input);
64
+ ctx.output.emit(n, () => ctx.output.success(`Created release note ${n.id}`));
65
+ }),
66
+ );
67
+ noteOptions(
68
+ group
69
+ .command("update <id>")
70
+ .description("Update a note's title, Markdown, or covered releases"),
71
+ ).action(
72
+ action(async (ctx, opts, id: string) => {
73
+ const input = noteInput(opts);
74
+ svc.validateNoteScope(input, false);
75
+ const n = await svc.updateNote(ctx.client, id, input);
76
+ ctx.output.emit(n, () => ctx.output.success(`Updated release note ${n.id}`));
77
+ }),
78
+ );
79
+ group
80
+ .command("delete <id>")
81
+ .description("Delete a release note by UUID or slug")
82
+ .action(
83
+ action(async (ctx, _opts, id: string) => {
84
+ const n = await svc.getNote(ctx.client, id);
85
+ if (!(await confirmDestructive(ctx, `Delete release note ${n.title ?? n.id}?`))) return;
86
+ const receipt = await svc.deleteNote(ctx.client, n.id);
87
+ ctx.output.emit(receipt, () => ctx.output.success(`Deleted release note ${receipt.id}`));
88
+ }),
89
+ );
90
+ }
@@ -0,0 +1,122 @@
1
+ import { Command, Option } from "commander";
2
+ import { action } from "../lib/action.js";
3
+ import { booleanValue, repeat, PIPELINE_TYPES } from "../lib/release-input.js";
4
+ import * as svc from "../services/release.js";
5
+
6
+ function options(command: Command, creating = false): Command {
7
+ const result = command
8
+ .option("--name <name>", "pipeline name")
9
+ .addOption(new Option("--type <type>", "pipeline type").choices(PIPELINE_TYPES))
10
+ .option(
11
+ "--production <boolean>",
12
+ "whether pipeline targets production (true|false)",
13
+ booleanValue,
14
+ )
15
+ .option(
16
+ "--auto-release-notes <boolean>",
17
+ "automatically generate notes on completion (true|false)",
18
+ booleanValue,
19
+ )
20
+ .option(
21
+ "--rollover-issues <boolean>",
22
+ "move open issues to the next scheduled release on completion (true|false)",
23
+ booleanValue,
24
+ )
25
+ .addOption(
26
+ new Option("--include-path <glob>", "replace path patterns with these globs (repeatable)")
27
+ .argParser(repeat)
28
+ .conflicts("clearPaths"),
29
+ )
30
+ .addOption(new Option("--clear-paths", "clear all path filters").conflicts("includePath"))
31
+ .addOption(
32
+ new Option("--team-id <uuid>", "replace associated teams with these UUIDs (repeatable)")
33
+ .argParser(repeat)
34
+ .conflicts("clearTeams"),
35
+ )
36
+ .addOption(new Option("--clear-teams", "clear team associations").conflicts("teamId"));
37
+ if (creating)
38
+ for (const flag of ["--name", "--type"])
39
+ result.options.find((o) => o.long === flag)!.makeOptionMandatory();
40
+ return result;
41
+ }
42
+ function input(opts: Record<string, any>): svc.PipelineInput {
43
+ return {
44
+ name: opts.name,
45
+ type: opts.type,
46
+ isProduction: opts.production,
47
+ autoGenerateReleaseNotesOnCompletion: opts.autoReleaseNotes,
48
+ rolloverIssuesOnCompletion: opts.rolloverIssues,
49
+ includePathPatterns: opts.clearPaths ? [] : opts.includePath,
50
+ teamIds: opts.clearTeams ? [] : opts.teamId,
51
+ };
52
+ }
53
+ export function registerReleasePipeline(program: Command): void {
54
+ const group = program
55
+ .command("release-pipeline")
56
+ .description("Manage release pipelines (Business/Enterprise)")
57
+ .addHelpText(
58
+ "after",
59
+ "\nUse normal Linear OAuth/API credentials. Pipeline access keys belong to\nlinear-release and cannot replace these credentials. Creating a pipeline\ncreates default stages; entitlement and workspace permissions are enforced by Linear.",
60
+ );
61
+ group
62
+ .command("list")
63
+ .alias("ls")
64
+ .description("List workspace release pipelines")
65
+ .option("--include-archived", "include archived pipelines")
66
+ .action(
67
+ action(async (ctx, opts) => {
68
+ const rows = await svc.listPipelines(ctx.client, ctx.limit, opts.includeArchived);
69
+ ctx.output.list(
70
+ rows,
71
+ [
72
+ { key: "id", header: "ID", value: (r) => r.id },
73
+ { key: "name", header: "Name", value: (r) => r.name },
74
+ { key: "type", header: "Type", value: (r) => r.type },
75
+ { key: "slugId", header: "Slug", value: (r) => r.slugId },
76
+ ],
77
+ rows,
78
+ );
79
+ }),
80
+ );
81
+ group
82
+ .command("view <id>")
83
+ .description("Show pipeline settings by UUID or slug")
84
+ .action(
85
+ action(async (ctx, _opts, id: string) => {
86
+ const p = await svc.getPipeline(ctx.client, id);
87
+ ctx.output.detail(p, [
88
+ ["ID", p.id],
89
+ ["Name", p.name],
90
+ ["Slug", p.slugId],
91
+ ["Type", p.type],
92
+ ["Production", p.isProduction],
93
+ ["Auto release notes", p.autoGenerateReleaseNotesOnCompletion],
94
+ ["Rollover issues", p.rolloverIssuesOnCompletion],
95
+ ["Path patterns", p.includePathPatterns.join(", ")],
96
+ ["Archived", p.archivedAt],
97
+ ["URL", p.url],
98
+ ]);
99
+ }),
100
+ );
101
+ options(
102
+ group.command("create").description("Create a pipeline with default stages"),
103
+ true,
104
+ ).action(
105
+ action(async (ctx, opts) => {
106
+ const p = await svc.createPipeline(ctx.client, {
107
+ ...input(opts),
108
+ name: opts.name,
109
+ type: opts.type,
110
+ });
111
+ ctx.output.emit(p, () => ctx.output.success(`Created release pipeline ${p.name} (${p.id})`));
112
+ }),
113
+ );
114
+ options(
115
+ group.command("update <id>").description("Update pipeline settings by UUID or slug"),
116
+ ).action(
117
+ action(async (ctx, opts, id: string) => {
118
+ const p = await svc.updatePipeline(ctx.client, id, input(opts));
119
+ ctx.output.emit(p, () => ctx.output.success(`Updated release pipeline ${p.name} (${p.id})`));
120
+ }),
121
+ );
122
+ }