linear-sdk-cli 0.4.0 → 0.5.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.
@@ -12,7 +12,7 @@ import {
12
12
  writeCredential,
13
13
  probeKeyringCredential,
14
14
  adoptKeyringCredential,
15
- removeCredential,
15
+ removeCredentialWithMetadata,
16
16
  removeOAuthCredential,
17
17
  setDefaultWorkspace,
18
18
  listCredentials,
@@ -24,15 +24,17 @@ import {
24
24
  defaultProjectConfigPath,
25
25
  assertSettableKey,
26
26
  setConfigKey,
27
+ setWorkspaceTeam,
27
28
  initProjectConfig,
28
29
  SETTABLE_KEYS,
29
30
  type SettableKey,
30
31
  } from "../config.js";
31
32
  import { keyring } from "../lib/keyring.js";
32
33
  import { readStdinSync } from "../lib/body.js";
33
- import { authError, normalizeError, usageError } from "../lib/errors.js";
34
+ import { CliError, authError, normalizeError, usageError } from "../lib/errors.js";
34
35
  import { confirmDestructive, promptSelect } from "../lib/prompt.js";
35
36
  import { listTeams } from "../services/team.js";
37
+ import { resolveTeam } from "../lib/resolve.js";
36
38
  import { ISSUE_SORTS } from "../services/issue.js";
37
39
  import { firstTeam, type Context } from "../context.js";
38
40
  import { openUrl } from "../lib/open.js";
@@ -105,6 +107,31 @@ export function setAuthValidationClientFactoryForTests(
105
107
  authValidationClientFactory = factory ?? defaultAuthValidationClientFactory;
106
108
  }
107
109
 
110
+ type WorkspaceTeamValidator = (ctx: Context, workspace: string, team: string) => Promise<string>;
111
+
112
+ /** Validate that an effective credential and team both belong to the requested workspace. */
113
+ export const validateWorkspaceTeam: WorkspaceTeamValidator = async (ctx, workspace, team) => {
114
+ const [organization, resolved] = await Promise.all([
115
+ withRetry(() => ctx.client.organization),
116
+ resolveTeam(ctx.client, team, undefined),
117
+ ]);
118
+ if (organization.urlKey !== workspace) {
119
+ throw usageError(
120
+ `--workspace '${workspace}' does not match the credential's workspace '${organization.urlKey}'.`,
121
+ );
122
+ }
123
+ return resolved.key;
124
+ };
125
+
126
+ let workspaceTeamValidator = validateWorkspaceTeam;
127
+
128
+ /** Test seam for profile-team validation; production always checks Linear. */
129
+ export function setWorkspaceTeamValidatorForTests(
130
+ validator: WorkspaceTeamValidator | undefined,
131
+ ): void {
132
+ workspaceTeamValidator = validator ?? validateWorkspaceTeam;
133
+ }
134
+
108
135
  /** Merge the local and global login spellings without silently choosing one. */
109
136
  export function selectLoginKey(
110
137
  localKey: string | undefined,
@@ -150,6 +177,21 @@ export async function validateAuthCredential(
150
177
  };
151
178
  }
152
179
 
180
+ /** Bind a completed login using the same project location rules as `config set`. */
181
+ function saveLoginProject(ctx: Context, workspace: string, project: boolean): string | null {
182
+ if (!project) return null;
183
+ const path = ctx.config.projectConfigPath ?? defaultProjectConfigPath();
184
+ try {
185
+ setConfigKey(path, "workspace", workspace);
186
+ } catch {
187
+ throw new CliError(
188
+ `Credentials saved for workspace '${workspace}', but its project association could not be saved to ${path}. Fix the project file and run \`linear config set workspace ${workspace}\`; login does not need to be repeated.`,
189
+ "runtime",
190
+ );
191
+ }
192
+ return path;
193
+ }
194
+
153
195
  function explicitWorkspaceSlug(input: string): string {
154
196
  const slug = input.trim();
155
197
  if (!/^[a-z0-9](?:[a-z0-9-]{0,98}[a-z0-9])?$/.test(slug)) {
@@ -175,7 +217,8 @@ export function registerMeta(program: Command): void {
175
217
  // login -------------------------------------------------------------------
176
218
  auth
177
219
  .command("login")
178
- .description("Authenticate in the browser with OAuth, or store a personal API key")
220
+ .description("Authenticate and select the workspace for this project")
221
+ .option("--no-project", "save credentials without changing the project workspace")
179
222
  .option("--key <key>", "use a personal API key ('-' reads it from stdin)")
180
223
  .option("--plaintext", "Store the key in the config file (0600) instead of the system keyring")
181
224
  .option("--no-browser", "print the authorization URL instead of opening it")
@@ -214,6 +257,7 @@ export function registerMeta(program: Command): void {
214
257
  const { path, storage, keyringLabel } = writeCredential(org.urlKey, key, {
215
258
  plaintext: opts.plaintext === true,
216
259
  });
260
+ const projectConfigPath = saveLoginProject(ctx, org.urlKey, opts.project !== false);
217
261
  const where =
218
262
  storage === "keychain"
219
263
  ? `Credential saved to the ${keyringLabel}; ${path} records the workspace.`
@@ -226,10 +270,11 @@ export function registerMeta(program: Command): void {
226
270
  user: me,
227
271
  storage,
228
272
  path,
273
+ projectConfigPath,
229
274
  },
230
275
  () =>
231
276
  ctx.output.success(
232
- `Authenticated as ${me.name} <${me.email}> for workspace '${org.urlKey}'. ${where}`,
277
+ `Authenticated as ${me.name} <${me.email}> for workspace '${org.urlKey}'. ${where}${projectConfigPath ? ` Project workspace saved to ${projectConfigPath}.` : ""}`,
233
278
  ),
234
279
  );
235
280
  return;
@@ -300,6 +345,11 @@ export function registerMeta(program: Command): void {
300
345
  user: identity.user,
301
346
  };
302
347
  const saved = writeOAuthCredential(credential);
348
+ const projectConfigPath = saveLoginProject(
349
+ ctx,
350
+ credential.workspace.urlKey,
351
+ opts.project !== false,
352
+ );
303
353
  ctx.output.emit(
304
354
  {
305
355
  success: true,
@@ -310,10 +360,11 @@ export function registerMeta(program: Command): void {
310
360
  scopes: credential.scopes,
311
361
  expiresAt: new Date(credential.expiresAt).toISOString(),
312
362
  path: saved.path,
363
+ projectConfigPath,
313
364
  },
314
365
  () =>
315
366
  ctx.output.success(
316
- `Authenticated as ${credential.user.name} <${credential.user.email}> for workspace '${credential.workspace.urlKey}'. OAuth credentials saved to the ${saved.keyringLabel}.`,
367
+ `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}.` : ""}`,
317
368
  ),
318
369
  );
319
370
  } finally {
@@ -428,6 +479,7 @@ export function registerMeta(program: Command): void {
428
479
  .action(
429
480
  action(async (ctx) => {
430
481
  const c = ctx.config;
482
+ if (c.workspaceChoices) throw c.apiKeyError;
431
483
  const credential = c.accessToken ?? c.apiKey;
432
484
  const credentialType = c.oauthCredential
433
485
  ? "oauth-user"
@@ -529,7 +581,7 @@ export function registerMeta(program: Command): void {
529
581
  const local =
530
582
  entry?.credentialType === "oauth-user"
531
583
  ? removeOAuthCredential(slug)
532
- : { removed: removeCredential(slug), fallbackCredentialType: null };
584
+ : { ...removeCredentialWithMetadata(slug), fallbackCredentialType: null };
533
585
  ctx.output.emit(
534
586
  {
535
587
  success: true,
@@ -537,11 +589,12 @@ export function registerMeta(program: Command): void {
537
589
  removed: local.removed,
538
590
  revocation,
539
591
  fallbackCredentialType: local.fallbackCredentialType,
592
+ teamMetadataRemoved: local.teamMetadataRemoved,
540
593
  },
541
594
  () =>
542
595
  local.removed
543
596
  ? ctx.output.success(
544
- `Removed workspace '${slug}'.${revocation === "revoked" ? " OAuth access was revoked." : revocation === "already-revoked" ? " OAuth access was already revoked." : ""}${local.fallbackCredentialType ? " The existing personal API-key profile is active again." : ""}`,
597
+ `Removed workspace '${slug}'.${revocation === "revoked" ? " OAuth access was revoked." : revocation === "already-revoked" ? " OAuth access was already revoked." : ""}${local.fallbackCredentialType ? " The existing personal API-key profile is active again." : ""}${local.teamMetadataRemoved ? " Its default team metadata was removed." : ""}`,
545
598
  )
546
599
  : ctx.output.info(`No credential for workspace '${slug}' to remove.`),
547
600
  );
@@ -589,6 +642,7 @@ export function registerMeta(program: Command): void {
589
642
  accessToken: redactKey(c.accessToken),
590
643
  accessTokenSource: c.accessTokenSource,
591
644
  credentialWorkspace: c.credentialWorkspace ?? null,
645
+ workspaceProfile: c.workspaceProfile ?? null,
592
646
  team: c.team ?? null,
593
647
  workspace: c.workspace ?? null,
594
648
  sort: c.sort,
@@ -601,6 +655,7 @@ export function registerMeta(program: Command): void {
601
655
  ["API key", `${redactKey(c.apiKey)} (${c.apiKeySource})`],
602
656
  ["OAuth access token", `${redactKey(c.accessToken)} (${c.accessTokenSource})`],
603
657
  ["Credential workspace", c.credentialWorkspace ?? "(none)"],
658
+ ["Workspace profile", c.workspaceProfile ?? "(none)"],
604
659
  ["Team", show("team", c.team)],
605
660
  ["Workspace", show("workspace", c.workspace)],
606
661
  ["Sort", show("sort", c.sort)],
@@ -630,6 +685,7 @@ export function registerMeta(program: Command): void {
630
685
  if (!ctx.isTTY) {
631
686
  throw usageError("Pass --team <key>; there is no terminal to choose one from.");
632
687
  }
688
+ await ctx.selectWorkspace();
633
689
  const teams = await listTeams(ctx.client, Infinity);
634
690
  if (teams.length === 0) throw usageError("This workspace has no teams to choose from.");
635
691
  team = await promptSelect(
@@ -656,8 +712,8 @@ export function registerMeta(program: Command): void {
656
712
  // set ---------------------------------------------------------------------
657
713
  config
658
714
  .command("set <key> <value>")
659
- .description(`Set one project setting (${SETTABLE_KEYS.join(", ")}) in the project config`)
660
- .option("--user", "write the user config (~/.config/linear/config.toml) instead")
715
+ .description(`Set one project or user setting (${SETTABLE_KEYS.join(", ")})`)
716
+ .option("--user", "write the user config (team + --workspace targets that workspace profile)")
661
717
  .option("--path <file>", "write this file instead of the project config in effect")
662
718
  .action(
663
719
  action(async (ctx: Context, opts, key: string, value: string) => {
@@ -676,10 +732,26 @@ export function registerMeta(program: Command): void {
676
732
  // here — or a fresh .linear.toml at the git root if there is none.
677
733
  path = ctx.config.projectConfigPath ?? defaultProjectConfigPath();
678
734
  }
679
- const spelling = setConfigKey(path, key, clean, { mode });
680
- ctx.output.emit({ success: true, path, key: spelling, value: clean }, () =>
681
- ctx.output.success(`Set ${spelling} = ${JSON.stringify(clean)} in ${path}`),
682
- );
735
+ const profileWorkspace = opts.user && key === "team" ? ctx.options.workspace : undefined;
736
+ if (profileWorkspace) {
737
+ let team = clean;
738
+ if (ctx.config.apiKey || ctx.config.accessToken) {
739
+ team = await workspaceTeamValidator(ctx, profileWorkspace, clean);
740
+ }
741
+ path = setWorkspaceTeam(profileWorkspace, team);
742
+ ctx.output.emit(
743
+ { success: true, path, key: "team", value: team, workspace: profileWorkspace },
744
+ () =>
745
+ ctx.output.success(
746
+ `Set team = ${JSON.stringify(team)} for workspace '${profileWorkspace}' in ${path}`,
747
+ ),
748
+ );
749
+ } else {
750
+ const spelling = setConfigKey(path, key, clean, { mode });
751
+ ctx.output.emit({ success: true, path, key: spelling, value: clean }, () =>
752
+ ctx.output.success(`Set ${spelling} = ${JSON.stringify(clean)} in ${path}`),
753
+ );
754
+ }
683
755
  }),
684
756
  );
685
757
  }
@@ -9,9 +9,11 @@ import type { Context } from "../context.js";
9
9
  import * as svc from "../services/notification.js";
10
10
  import type { Column } from "../output/table.js";
11
11
  import { ExitCode } from "../lib/errors.js";
12
+ import { addIncludeArchivedOption } from "../lib/options.js";
13
+ import { lifecycleSuffix } from "../output/lifecycle.js";
12
14
 
13
15
  const ROW_COLUMNS: Column<svc.NotificationRow>[] = [
14
- { key: "type", header: "Type", value: (r) => r.type, max: 24 },
16
+ { key: "type", header: "Type", value: (r) => `${r.type}${lifecycleSuffix(r)}`, max: 36 },
15
17
  { key: "subject", header: "Subject", value: (r) => r.subject ?? "—", max: 50 },
16
18
  { key: "read", header: "Read", value: (r) => (r.read ? "✓" : "•") },
17
19
  { key: "createdAt", header: "Created", value: (r) => r.createdAt.slice(0, 10) },
@@ -24,17 +26,17 @@ export function registerNotification(program: Command): void {
24
26
  .description("Work with your notifications");
25
27
 
26
28
  // list --------------------------------------------------------------------
27
- notification
29
+ const list = notification
28
30
  .command("list")
29
31
  .alias("ls")
30
32
  .description("List your notifications")
31
- .option("--include-archived", "include archived notifications")
32
33
  .action(
33
34
  action(async (ctx: Context, opts) => {
34
35
  const rows = await svc.listNotifications(ctx.client, ctx.limit, !!opts.includeArchived);
35
36
  ctx.output.list(rows, ROW_COLUMNS, rows);
36
37
  }),
37
38
  );
39
+ addIncludeArchivedOption(list);
38
40
 
39
41
  // read --------------------------------------------------------------------
40
42
  notification
@@ -46,11 +46,12 @@ async function resolveOpenTarget(
46
46
  ctx: Context,
47
47
  target: string | undefined,
48
48
  ): Promise<{ target: OpenTarget; url: string; label: string }> {
49
+ if (target && URL.test(target)) return { target: "url", url: target, label: target };
50
+ await ctx.selectWorkspace();
49
51
  if (!target) {
50
52
  const org = await getOrganizationDetail(ctx.client);
51
53
  return { target: "workspace", url: `https://linear.app/${org.urlKey}`, label: org.name };
52
54
  }
53
- if (URL.test(target)) return { target: "url", url: target, label: target };
54
55
  if (target.startsWith("team:")) {
55
56
  const team = await resolveTeam(ctx.client, target.slice("team:".length), ctx.defaultTeam);
56
57
  const org = await getOrganizationDetail(ctx.client);
@@ -4,7 +4,13 @@
4
4
 
5
5
  import { Command, Option } from "commander";
6
6
  import { action } from "../lib/action.js";
7
- import { parseList, parseIntOption, addAliasOption, readAlias } from "../lib/options.js";
7
+ import {
8
+ parseList,
9
+ parseIntOption,
10
+ addAliasOption,
11
+ readAlias,
12
+ addIncludeArchivedOption,
13
+ } from "../lib/options.js";
8
14
  import { usageError } from "../lib/errors.js";
9
15
  import { resolveBody } from "../lib/body.js";
10
16
  import { confirmDestructive, promptInput } from "../lib/prompt.js";
@@ -12,10 +18,16 @@ import type { Context } from "../context.js";
12
18
  import * as svc from "../services/project.js";
13
19
  import { formatMilestoneProgress } from "./milestone.js";
14
20
  import type { Column } from "../output/table.js";
21
+ import { lifecycleSuffix } from "../output/lifecycle.js";
15
22
 
16
23
  const ROW_COLUMNS: Column<svc.ProjectRow>[] = [
17
24
  { key: "name", header: "Name", value: (r) => r.name, max: 36 },
18
- { key: "state", header: "State", value: (r) => r.status?.name ?? r.state ?? "—", max: 14 },
25
+ {
26
+ key: "state",
27
+ header: "State",
28
+ value: (r) => `${r.status?.name ?? r.state ?? "—"}${lifecycleSuffix(r)}`,
29
+ max: 26,
30
+ },
19
31
  { key: "progress", header: "Progress", value: (r) => formatProgress(r.progress) },
20
32
  { key: "lead", header: "Lead", value: (r) => r.lead?.displayName ?? "—", max: 16 },
21
33
  { key: "target", header: "Target", value: (r) => r.targetDate ?? "—" },
@@ -72,6 +84,7 @@ export function registerProject(program: Command): void {
72
84
  team: opts.allTeams ? undefined : (opts.team ?? ctx.defaultTeam),
73
85
  allTeams: !!opts.allTeams,
74
86
  state: readAlias(opts, "--state", "--status"),
87
+ includeArchived: !!opts.includeArchived,
75
88
  },
76
89
  ctx.limit,
77
90
  ctx.defaultTeam,
@@ -79,6 +92,7 @@ export function registerProject(program: Command): void {
79
92
  ctx.output.list(rows, ROW_COLUMNS, rows);
80
93
  }),
81
94
  );
95
+ addIncludeArchivedOption(list);
82
96
  // `--status` is the reference CLI's spelling for the same thing; it shipped
83
97
  // earlier as a visible duplicate and now goes through the shared alias
84
98
  // mechanism, so `--help` shows one canonical spelling here like everywhere else.
@@ -94,7 +108,11 @@ export function registerProject(program: Command): void {
94
108
  const detail = await svc.getProjectDetail(ctx.client, idArg);
95
109
  ctx.output.detail(detail, [
96
110
  ["Project", detail.name],
97
- ["Archived", detail.archivedAt ? `YES (${detail.archivedAt})` : null],
111
+ [
112
+ "Trashed",
113
+ detail.trashed ? `YES (deleted ${detail.archivedAt ?? "at an unknown time"})` : null,
114
+ ],
115
+ ["Archived", !detail.trashed && detail.archivedAt ? `YES (${detail.archivedAt})` : null],
98
116
  ["State", detail.status?.name ?? detail.state],
99
117
  ["Health", detail.health],
100
118
  ["Progress", detail.progress !== null ? formatProgress(detail.progress) : null],