@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
@@ -3,11 +3,12 @@ import { CREATE_LABEL_MUTATION, FIND_PARENT_LABEL_QUERY, RESTORE_LABEL_MUTATION,
3
3
  import { createGraphQLService } from "../utils/graphql-service.js";
4
4
  import { createLinearService } from "../utils/linear-service.js";
5
5
  import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
6
+ import { getRootOpts } from "../utils/root-opts.js";
6
7
  import { validateHexColor } from "../utils/validators.js";
7
8
  async function handleCreateLabel(name, options, command) {
8
- const rootOpts = command.parent.parent.opts();
9
+ const rootOpts = getRootOpts(command);
9
10
  const teamId = resolveTeam(options.team);
10
- const graphQLService = createGraphQLService(rootOpts);
11
+ const graphQLService = await createGraphQLService(rootOpts);
11
12
  const input = { name, teamId };
12
13
  if (options.color) {
13
14
  input.color = validateHexColor(options.color);
@@ -37,8 +38,8 @@ async function handleCreateLabel(name, options, command) {
37
38
  outputSuccess(createResult.issueLabel);
38
39
  }
39
40
  async function handleRetireLabel(labelId, _options, command) {
40
- const rootOpts = command.parent.parent.opts();
41
- const graphQLService = createGraphQLService(rootOpts);
41
+ const rootOpts = getRootOpts(command);
42
+ const graphQLService = await createGraphQLService(rootOpts);
42
43
  const result = await graphQLService.rawRequest(RETIRE_LABEL_MUTATION, {
43
44
  id: labelId,
44
45
  });
@@ -49,8 +50,8 @@ async function handleRetireLabel(labelId, _options, command) {
49
50
  outputSuccess(retireResult.issueLabel);
50
51
  }
51
52
  async function handleRestoreLabel(labelId, _options, command) {
52
- const rootOpts = command.parent.parent.opts();
53
- const graphQLService = createGraphQLService(rootOpts);
53
+ const rootOpts = getRootOpts(command);
54
+ const graphQLService = await createGraphQLService(rootOpts);
54
55
  const result = await graphQLService.rawRequest(RESTORE_LABEL_MUTATION, {
55
56
  id: labelId,
56
57
  });
@@ -72,9 +73,9 @@ export function setupLabelsCommands(program) {
72
73
  .option("--team <team>", "filter by team key, name, or ID")
73
74
  .option("-l, --limit <number>", "limit results", "100")
74
75
  .action(handleAsyncCommand(async (options, command) => {
75
- const rootOpts = command.parent.parent.opts();
76
+ const rootOpts = getRootOpts(command);
76
77
  const teamFilter = options.team ? resolveTeam(options.team) : undefined;
77
- const service = createLinearService(rootOpts);
78
+ const service = await createLinearService(rootOpts);
78
79
  const result = await service.getLabels(teamFilter, Number.parseInt(options.limit, 10));
79
80
  outputSuccess({
80
81
  data: result.labels,
@@ -0,0 +1,96 @@
1
+ /**
2
+ * `el-linear profile migrate-legacy` — one-shot migration from the legacy
3
+ * single-file config layout (`~/.config/el-linear/{token,config.json}`) to
4
+ * the named-profiles layout introduced in 1.4 (`~/.config/el-linear/
5
+ * profiles/<name>/{token,config.json}`).
6
+ *
7
+ * Why this command exists:
8
+ *
9
+ * When a user upgraded el-linear to >=1.4, their existing single-file
10
+ * `config.json` was preserved verbatim, but the legacy `token` slot was
11
+ * sometimes cleared (depending on how the upgrade was performed) and
12
+ * 1.4 expects per-profile tokens. Result: every command failed with
13
+ * "Authentication required" while the rich legacy config (member
14
+ * aliases, brand rules, default labels) sat right there on disk with
15
+ * no documented migration path.
16
+ *
17
+ * Design constraints:
18
+ *
19
+ * - **Each step is independently idempotent.** Re-running the command
20
+ * after a successful migration is a no-op — config + token files match,
21
+ * active-profile already points at the right name. Re-running after a
22
+ * partial failure picks up where it left off without `--force`.
23
+ *
24
+ * - **Validate before writing.** A token that doesn't pass `viewer { ... }`
25
+ * never lands on disk. The validate-then-write order means an interrupted
26
+ * migration can't leave a dud token in a freshly-created profile dir.
27
+ *
28
+ * - **Legacy preservation.** We never delete the legacy `config.json` or
29
+ * `token` — the user gets a rollback path. A one-line stdout hint says
30
+ * so explicitly.
31
+ *
32
+ * - **`--force` is opt-in destruction.** When the destination profile
33
+ * already has a config.json or token that *differs* from the source,
34
+ * the command refuses by default with a clear diff hint. `--force`
35
+ * overwrites; `--yes` skips the interactive confirm. Both are
36
+ * required-together for unattended (CI / scripted) overwrites.
37
+ */
38
+ import { confirm, input, password } from "@inquirer/prompts";
39
+ import type { Command } from "commander";
40
+ import { CONFIG_PATH, PROFILES_DIR, TOKEN_PATH } from "../../config/paths.js";
41
+ export interface MigrateLegacyOptions {
42
+ /** Target profile name. Defaults to "default" when omitted. */
43
+ name?: string;
44
+ /** Path to a file containing the API token (whitespace trimmed). */
45
+ tokenFrom?: string;
46
+ /** Overwrite existing profile config.json/token even if they differ. */
47
+ force?: boolean;
48
+ /** Skip interactive confirmation when --force is needed. */
49
+ yes?: boolean;
50
+ /** Skip the interactive name prompt — use whatever `name` resolved to. */
51
+ skipPrompt?: boolean;
52
+ }
53
+ export interface MigrateLegacyDeps {
54
+ /**
55
+ * Hook around the `viewer` validation call so tests can short-circuit
56
+ * the GraphQL roundtrip. Production wiring uses the real
57
+ * `validateToken` from `init/token.ts`.
58
+ */
59
+ validateToken?: (token: string) => Promise<{
60
+ id: string;
61
+ organization: {
62
+ urlKey: string;
63
+ name: string;
64
+ };
65
+ displayName: string;
66
+ email: string;
67
+ }>;
68
+ /** stdout writer (for the success line + "kept for rollback" hint). */
69
+ stdout?: {
70
+ write: (chunk: string) => void;
71
+ };
72
+ /**
73
+ * Inquirer-based prompts. Tests inject deterministic responses so we
74
+ * don't need a TTY.
75
+ */
76
+ prompts?: {
77
+ input: typeof input;
78
+ password: typeof password;
79
+ confirm: typeof confirm;
80
+ };
81
+ }
82
+ /** Register `el-linear profile migrate-legacy` under the parent `profile` command. */
83
+ export declare function registerMigrateLegacy(profile: Command): void;
84
+ /**
85
+ * Top-level orchestrator. Each helper below is independently idempotent;
86
+ * this function just sequences them and prints the final ✓ banner.
87
+ *
88
+ * Exit semantics:
89
+ *
90
+ * - Missing legacy config → `process.exit(1)` (user error, nothing to do).
91
+ * - Refused overwrite (no --force) → throws — handled by handleAsyncCommand
92
+ * elsewhere in the CLI and surfaces as a structured JSON error on stdout.
93
+ * - Token validation failure → throws (no on-disk change has happened yet).
94
+ */
95
+ export declare function runMigrateLegacy(options: MigrateLegacyOptions, deps?: MigrateLegacyDeps): Promise<void>;
96
+ export { CONFIG_PATH, PROFILES_DIR, TOKEN_PATH };
@@ -0,0 +1,271 @@
1
+ /**
2
+ * `el-linear profile migrate-legacy` — one-shot migration from the legacy
3
+ * single-file config layout (`~/.config/el-linear/{token,config.json}`) to
4
+ * the named-profiles layout introduced in 1.4 (`~/.config/el-linear/
5
+ * profiles/<name>/{token,config.json}`).
6
+ *
7
+ * Why this command exists:
8
+ *
9
+ * When a user upgraded el-linear to >=1.4, their existing single-file
10
+ * `config.json` was preserved verbatim, but the legacy `token` slot was
11
+ * sometimes cleared (depending on how the upgrade was performed) and
12
+ * 1.4 expects per-profile tokens. Result: every command failed with
13
+ * "Authentication required" while the rich legacy config (member
14
+ * aliases, brand rules, default labels) sat right there on disk with
15
+ * no documented migration path.
16
+ *
17
+ * Design constraints:
18
+ *
19
+ * - **Each step is independently idempotent.** Re-running the command
20
+ * after a successful migration is a no-op — config + token files match,
21
+ * active-profile already points at the right name. Re-running after a
22
+ * partial failure picks up where it left off without `--force`.
23
+ *
24
+ * - **Validate before writing.** A token that doesn't pass `viewer { ... }`
25
+ * never lands on disk. The validate-then-write order means an interrupted
26
+ * migration can't leave a dud token in a freshly-created profile dir.
27
+ *
28
+ * - **Legacy preservation.** We never delete the legacy `config.json` or
29
+ * `token` — the user gets a rollback path. A one-line stdout hint says
30
+ * so explicitly.
31
+ *
32
+ * - **`--force` is opt-in destruction.** When the destination profile
33
+ * already has a config.json or token that *differs* from the source,
34
+ * the command refuses by default with a clear diff hint. `--force`
35
+ * overwrites; `--yes` skips the interactive confirm. Both are
36
+ * required-together for unattended (CI / scripted) overwrites.
37
+ */
38
+ import { promises as fsp } from "node:fs";
39
+ import path from "node:path";
40
+ import { confirm, input, password } from "@inquirer/prompts";
41
+ import { ACTIVE_PROFILE_FILE, CONFIG_DIR, CONFIG_PATH, PROFILES_DIR, profilePaths, TOKEN_PATH, } from "../../config/paths.js";
42
+ import { sanitizeForLog, validateToken } from "../init/token.js";
43
+ const DEFAULT_PROMPTS = { input, password, confirm };
44
+ /** Register `el-linear profile migrate-legacy` under the parent `profile` command. */
45
+ export function registerMigrateLegacy(profile) {
46
+ profile
47
+ .command("migrate-legacy")
48
+ .description("Copy the legacy ~/.config/el-linear/{config.json,token} into a named profile so >=1.4 commands work again.")
49
+ .option("--name <name>", "Target profile name. Defaults to `default`.", "default")
50
+ .option("--token-from <path>", "Read the API token from this file instead of prompting.")
51
+ .option("--force", "Overwrite an existing per-profile config.json or token even when contents differ.")
52
+ .option("--yes", "Skip interactive confirmations (still respects --force semantics).")
53
+ .action(async (opts) => {
54
+ await runMigrateLegacy({
55
+ name: opts.name,
56
+ tokenFrom: opts.tokenFrom,
57
+ force: opts.force === true,
58
+ yes: opts.yes === true,
59
+ });
60
+ });
61
+ }
62
+ /**
63
+ * Top-level orchestrator. Each helper below is independently idempotent;
64
+ * this function just sequences them and prints the final ✓ banner.
65
+ *
66
+ * Exit semantics:
67
+ *
68
+ * - Missing legacy config → `process.exit(1)` (user error, nothing to do).
69
+ * - Refused overwrite (no --force) → throws — handled by handleAsyncCommand
70
+ * elsewhere in the CLI and surfaces as a structured JSON error on stdout.
71
+ * - Token validation failure → throws (no on-disk change has happened yet).
72
+ */
73
+ export async function runMigrateLegacy(options, deps = {}) {
74
+ const stdout = deps.stdout ?? process.stdout;
75
+ const prompts = deps.prompts ?? DEFAULT_PROMPTS;
76
+ const validate = deps.validateToken ?? validateToken;
77
+ // 1. Pre-flight: legacy config must exist. There's nothing to migrate
78
+ // on a freshly-onboarded machine and we don't want to silently
79
+ // create an empty profile.
80
+ if (!(await pathExists(CONFIG_PATH))) {
81
+ stdout.write(`Nothing to migrate — no legacy config found at ${CONFIG_PATH}.\n`);
82
+ stdout.write("If this is a fresh install, run `el-linear init` instead.\n");
83
+ process.exit(1);
84
+ }
85
+ const initialName = (options.name ?? "default").trim() || "default";
86
+ let name = initialName;
87
+ // Allow an interactive override of the target name unless --yes was passed
88
+ // (scripted) or --name was explicitly set to something other than the
89
+ // default. Keeps backward compat with the CLI flag while not surprising
90
+ // CI runs.
91
+ if (!options.skipPrompt && !options.yes && options.name === undefined) {
92
+ const answer = await prompts.input({
93
+ message: "Target profile name:",
94
+ default: name,
95
+ });
96
+ const trimmed = answer.trim();
97
+ if (trimmed.length > 0)
98
+ name = trimmed;
99
+ }
100
+ if (!isSafeName(name)) {
101
+ throw new Error(`Invalid profile name "${name}". Allowed: [a-z0-9_.-], up to 64 chars.`);
102
+ }
103
+ // 2. Token source priority: --token-from > EL_LINEAR_TOKEN > prompt.
104
+ // Validate before writing anything to disk.
105
+ const token = await resolveAndValidateToken(options, prompts, validate);
106
+ // 3. Profile dir.
107
+ const paths = profilePaths(name);
108
+ const profileDir = path.dirname(paths.configPath);
109
+ await fsp.mkdir(profileDir, { recursive: true, mode: 0o700 });
110
+ // 4. Config copy with idempotent + force semantics.
111
+ await copyConfigIntoProfile(paths.configPath, options, prompts);
112
+ // 5. Token write with idempotent + force semantics.
113
+ await writeProfileToken(paths.tokenPath, token, options, prompts);
114
+ // 6. active-profile marker.
115
+ await ensureActiveProfile(name);
116
+ // 7. Legacy preservation hint. We do NOT delete the legacy paths —
117
+ // the user gets a rollback if anything went sideways.
118
+ stdout.write(`legacy ${CONFIG_PATH} kept for rollback; safe to remove later if no longer needed\n`);
119
+ // 8. Final verify against the freshly-written profile token. This is
120
+ // a defensive double-check: the token was already validated above,
121
+ // but verifying *after* the write catches any FS-level surprise
122
+ // (e.g. wrong token landed in the wrong dir on a multi-profile box).
123
+ const onDiskToken = (await fsp.readFile(paths.tokenPath, "utf8")).trim();
124
+ const viewer = await validate(onDiskToken);
125
+ stdout.write(`✓ Migrated. Active profile: ${name}. Workspace: ${viewer.organization.urlKey}\n`);
126
+ }
127
+ // ---- Helpers --------------------------------------------------------
128
+ async function resolveAndValidateToken(options, prompts, validate) {
129
+ if (options.tokenFrom) {
130
+ const raw = await fsp.readFile(options.tokenFrom, "utf8");
131
+ const token = raw.trim();
132
+ if (!token) {
133
+ throw new Error(`Token file ${options.tokenFrom} is empty.`);
134
+ }
135
+ await validate(token);
136
+ return token;
137
+ }
138
+ const envToken = process.env.EL_LINEAR_TOKEN?.trim();
139
+ if (envToken) {
140
+ await validate(envToken);
141
+ return envToken;
142
+ }
143
+ // Interactive: up to three attempts, hidden input. We re-prompt on
144
+ // validation failure rather than aborting so the user can paste a
145
+ // fresh token without re-running the whole command.
146
+ for (let attempt = 0; attempt < 3; attempt++) {
147
+ const candidate = (await prompts.password({
148
+ message: attempt === 0
149
+ ? "Linear API token (input hidden):"
150
+ : "Try again (input hidden):",
151
+ mask: "*",
152
+ validate: (s) => s.trim().length > 0 || "Token cannot be empty",
153
+ })).trim();
154
+ try {
155
+ await validate(candidate);
156
+ return candidate;
157
+ }
158
+ catch (err) {
159
+ const raw = err instanceof Error ? err.message : String(err);
160
+ console.log(` ✗ ${sanitizeForLog(raw)}`);
161
+ }
162
+ }
163
+ throw new Error("Could not validate a Linear API token after 3 attempts. Aborting migration.");
164
+ }
165
+ async function copyConfigIntoProfile(destConfigPath, options, prompts) {
166
+ const sourceContent = await fsp.readFile(CONFIG_PATH, "utf8");
167
+ if (!(await pathExists(destConfigPath))) {
168
+ await fsp.writeFile(destConfigPath, sourceContent, {
169
+ mode: 0o644,
170
+ encoding: "utf8",
171
+ });
172
+ return;
173
+ }
174
+ const destContent = await fsp.readFile(destConfigPath, "utf8");
175
+ if (destContent === sourceContent) {
176
+ // Idempotent re-run — nothing to do.
177
+ return;
178
+ }
179
+ if (!options.force) {
180
+ throw new Error([
181
+ `Refusing to overwrite ${destConfigPath} — its contents differ from ${CONFIG_PATH}.`,
182
+ "Re-run with --force to overwrite. The legacy file is kept either way; only the per-profile copy changes.",
183
+ `Diff hint: \`diff ${CONFIG_PATH} ${destConfigPath}\``,
184
+ ].join("\n"));
185
+ }
186
+ if (!options.yes) {
187
+ const ok = await prompts.confirm({
188
+ message: `Overwrite ${destConfigPath} with the legacy config?`,
189
+ default: false,
190
+ });
191
+ if (!ok) {
192
+ throw new Error("Aborted by user.");
193
+ }
194
+ }
195
+ await fsp.writeFile(destConfigPath, sourceContent, {
196
+ mode: 0o644,
197
+ encoding: "utf8",
198
+ });
199
+ }
200
+ async function writeProfileToken(destTokenPath, token, options, prompts) {
201
+ const newline = `${token}\n`;
202
+ if (!(await pathExists(destTokenPath))) {
203
+ await fsp.writeFile(destTokenPath, newline, {
204
+ mode: 0o600,
205
+ encoding: "utf8",
206
+ });
207
+ // fs.writeFile mode is only honored on file creation; chmod defensively
208
+ // in case a parent process pre-created the file with permissive perms.
209
+ await fsp.chmod(destTokenPath, 0o600);
210
+ return;
211
+ }
212
+ const existing = (await fsp.readFile(destTokenPath, "utf8")).trim();
213
+ if (existing === token) {
214
+ // Idempotent re-run — already correct; force perms regardless.
215
+ await fsp.chmod(destTokenPath, 0o600);
216
+ return;
217
+ }
218
+ if (!options.force) {
219
+ throw new Error([
220
+ `Refusing to overwrite ${destTokenPath} — it contains a different token.`,
221
+ "Re-run with --force to overwrite the per-profile token.",
222
+ ].join("\n"));
223
+ }
224
+ if (!options.yes) {
225
+ const ok = await prompts.confirm({
226
+ message: `Overwrite the existing token at ${destTokenPath}?`,
227
+ default: false,
228
+ });
229
+ if (!ok) {
230
+ throw new Error("Aborted by user.");
231
+ }
232
+ }
233
+ await fsp.writeFile(destTokenPath, newline, {
234
+ mode: 0o600,
235
+ encoding: "utf8",
236
+ });
237
+ await fsp.chmod(destTokenPath, 0o600);
238
+ }
239
+ async function ensureActiveProfile(name) {
240
+ await fsp.mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
241
+ let current = null;
242
+ if (await pathExists(ACTIVE_PROFILE_FILE)) {
243
+ current = (await fsp.readFile(ACTIVE_PROFILE_FILE, "utf8")).trim();
244
+ }
245
+ if (current === name)
246
+ return;
247
+ await fsp.writeFile(ACTIVE_PROFILE_FILE, `${name}\n`, {
248
+ mode: 0o644,
249
+ encoding: "utf8",
250
+ });
251
+ }
252
+ async function pathExists(p) {
253
+ try {
254
+ await fsp.access(p);
255
+ return true;
256
+ }
257
+ catch {
258
+ return false;
259
+ }
260
+ }
261
+ /**
262
+ * Profile names land in filesystem paths and the active-profile marker.
263
+ * Same conservative charset as `profile add` to avoid `..` traversal,
264
+ * shell metacharacters, and Unicode lookalikes.
265
+ */
266
+ function isSafeName(name) {
267
+ return /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(name);
268
+ }
269
+ // Re-export internals so the integration test (and follow-up commands)
270
+ // can compose them without re-implementing the idempotency rules.
271
+ export { CONFIG_PATH, PROFILES_DIR, TOKEN_PATH };
@@ -26,6 +26,7 @@ import { confirm } from "@inquirer/prompts";
26
26
  import { ACTIVE_PROFILE_FILE, CONFIG_DIR, CONFIG_PATH, PROFILES_DIR, profilePaths, resolveActiveProfile, setActiveProfileForSession, TOKEN_PATH, } from "../config/paths.js";
27
27
  import { outputSuccess, outputWarning } from "../utils/output.js";
28
28
  import { runFullWizard } from "./init/index.js";
29
+ import { registerMigrateLegacy } from "./profile/migrate-legacy.js";
29
30
  export function setupProfileCommands(program) {
30
31
  const profile = program
31
32
  .command("profile")
@@ -72,6 +73,11 @@ export function setupProfileCommands(program) {
72
73
  .action(async (name, opts) => {
73
74
  await runProfileRemove(name, opts.force === true);
74
75
  });
76
+ // `el-linear profile migrate-legacy` — registered alongside add/list/etc.
77
+ // Lives in its own module so the multi-step migration logic stays
78
+ // self-contained and unit-testable without dragging in the full
79
+ // profile-management surface.
80
+ registerMigrateLegacy(profile);
75
81
  }
76
82
  export async function runProfileList() {
77
83
  const active = resolveActiveProfile();
@@ -3,6 +3,7 @@ import { multipleMatchesError, notFoundError, } from "../utils/error-messages.js
3
3
  import { createGraphQLService, } from "../utils/graphql-service.js";
4
4
  import { createLinearService, } from "../utils/linear-service.js";
5
5
  import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
6
+ import { getRootOpts } from "../utils/root-opts.js";
6
7
  import { isUuid } from "../utils/uuid.js";
7
8
  import { validateIsoDate } from "../utils/validators.js";
8
9
  async function resolveMilestoneId(milestoneNameOrId, graphQLService, linearService, projectNameOrId) {
@@ -37,9 +38,9 @@ async function resolveMilestoneId(milestoneNameOrId, graphQLService, linearServi
37
38
  return nodes[0].id;
38
39
  }
39
40
  async function handleListMilestones(options, command) {
40
- const rootOpts = command.parent.parent.opts();
41
- const graphQLService = createGraphQLService(rootOpts);
42
- const linearService = createLinearService(rootOpts);
41
+ const rootOpts = getRootOpts(command);
42
+ const graphQLService = await createGraphQLService(rootOpts);
43
+ const linearService = await createLinearService(rootOpts);
43
44
  const projectId = await linearService.resolveProjectId(options.project);
44
45
  const result = await graphQLService.rawRequest(LIST_PROJECT_MILESTONES_QUERY, {
45
46
  projectId,
@@ -51,9 +52,9 @@ async function handleListMilestones(options, command) {
51
52
  outputSuccess({ data: nodes, meta: { count: nodes.length } });
52
53
  }
53
54
  async function handleReadMilestone(milestoneIdOrName, options, command) {
54
- const rootOpts = command.parent.parent.opts();
55
- const graphQLService = createGraphQLService(rootOpts);
56
- const linearService = createLinearService(rootOpts);
55
+ const rootOpts = getRootOpts(command);
56
+ const graphQLService = await createGraphQLService(rootOpts);
57
+ const linearService = await createLinearService(rootOpts);
57
58
  const milestoneId = await resolveMilestoneId(milestoneIdOrName, graphQLService, linearService, options.project);
58
59
  const result = await graphQLService.rawRequest(GET_PROJECT_MILESTONE_BY_ID_QUERY, {
59
60
  id: milestoneId,
@@ -62,9 +63,9 @@ async function handleReadMilestone(milestoneIdOrName, options, command) {
62
63
  outputSuccess(result.projectMilestone);
63
64
  }
64
65
  async function handleCreateMilestone(name, options, command) {
65
- const rootOpts = command.parent.parent.opts();
66
- const graphQLService = createGraphQLService(rootOpts);
67
- const linearService = createLinearService(rootOpts);
66
+ const rootOpts = getRootOpts(command);
67
+ const graphQLService = await createGraphQLService(rootOpts);
68
+ const linearService = await createLinearService(rootOpts);
68
69
  const projectId = await linearService.resolveProjectId(options.project);
69
70
  if (options.targetDate) {
70
71
  validateIsoDate(options.targetDate);
@@ -82,9 +83,9 @@ async function handleCreateMilestone(name, options, command) {
82
83
  outputSuccess(createData.projectMilestone);
83
84
  }
84
85
  async function handleUpdateMilestone(milestoneIdOrName, options, command) {
85
- const rootOpts = command.parent.parent.opts();
86
- const graphQLService = createGraphQLService(rootOpts);
87
- const linearService = createLinearService(rootOpts);
86
+ const rootOpts = getRootOpts(command);
87
+ const graphQLService = await createGraphQLService(rootOpts);
88
+ const linearService = await createLinearService(rootOpts);
88
89
  const milestoneId = await resolveMilestoneId(milestoneIdOrName, graphQLService, linearService, options.project);
89
90
  const updateVars = { id: milestoneId };
90
91
  if (options.name !== undefined) {
@@ -4,6 +4,7 @@ import { createGraphQLService } from "../utils/graphql-service.js";
4
4
  import { createLinearService } from "../utils/linear-service.js";
5
5
  import { logger } from "../utils/logger.js";
6
6
  import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
7
+ import { getRootOpts } from "../utils/root-opts.js";
7
8
  import { isUuid } from "../utils/uuid.js";
8
9
  import { splitList } from "../utils/validators.js";
9
10
  function formatProjectsOutput(projects, format, fieldNames) {
@@ -126,9 +127,10 @@ function formatTeamsOutput(projectUpdate) {
126
127
  };
127
128
  }
128
129
  async function handleAddTeam(projectNameOrId, teamInput, _options, command) {
129
- const rootOpts = command.parent.parent.opts();
130
- const graphQLService = createGraphQLService(rootOpts);
131
- const finalTeamId = await createLinearService(rootOpts).resolveTeamId(resolveTeam(teamInput));
130
+ const rootOpts = getRootOpts(command);
131
+ const graphQLService = await createGraphQLService(rootOpts);
132
+ const linearService = await createLinearService(rootOpts);
133
+ const finalTeamId = await linearService.resolveTeamId(resolveTeam(teamInput));
132
134
  const { projectId, currentTeams } = await resolveProjectWithTeams(graphQLService, projectNameOrId);
133
135
  const currentTeamIds = currentTeams.map((t) => t.id);
134
136
  if (currentTeamIds.includes(finalTeamId)) {
@@ -149,9 +151,10 @@ async function handleAddTeam(projectNameOrId, teamInput, _options, command) {
149
151
  outputSuccess(formatTeamsOutput(projectUpdate));
150
152
  }
151
153
  async function handleAddTeams(projectNameOrId, teamInputs, _options, command) {
152
- const rootOpts = command.parent.parent.opts();
153
- const graphQLService = createGraphQLService(rootOpts);
154
- const resolvedTeamIds = await Promise.all(teamInputs.map((t) => createLinearService(rootOpts).resolveTeamId(resolveTeam(t))));
154
+ const rootOpts = getRootOpts(command);
155
+ const graphQLService = await createGraphQLService(rootOpts);
156
+ const linearService = await createLinearService(rootOpts);
157
+ const resolvedTeamIds = await Promise.all(teamInputs.map((t) => linearService.resolveTeamId(resolveTeam(t))));
155
158
  const { projectId, currentTeams } = await resolveProjectWithTeams(graphQLService, projectNameOrId);
156
159
  const currentTeamIds = currentTeams.map((t) => t.id);
157
160
  const newTeamIds = resolvedTeamIds.filter((id) => !currentTeamIds.includes(id));
@@ -181,9 +184,10 @@ async function handleAddTeams(projectNameOrId, teamInputs, _options, command) {
181
184
  });
182
185
  }
183
186
  async function handleRemoveTeam(projectNameOrId, teamInput, options, command) {
184
- const rootOpts = command.parent.parent.opts();
185
- const graphQLService = createGraphQLService(rootOpts);
186
- const finalTeamId = await createLinearService(rootOpts).resolveTeamId(resolveTeam(teamInput));
187
+ const rootOpts = getRootOpts(command);
188
+ const graphQLService = await createGraphQLService(rootOpts);
189
+ const linearService = await createLinearService(rootOpts);
190
+ const finalTeamId = await linearService.resolveTeamId(resolveTeam(teamInput));
187
191
  const { projectId, projectName, currentTeams } = await resolveProjectWithTeams(graphQLService, projectNameOrId);
188
192
  const currentTeamIds = currentTeams.map((t) => t.id);
189
193
  if (!currentTeamIds.includes(finalTeamId)) {
@@ -220,8 +224,8 @@ async function handleRemoveTeam(projectNameOrId, teamInput, options, command) {
220
224
  });
221
225
  }
222
226
  async function handleCreateProject(name, options, command) {
223
- const rootOpts = command.parent.parent.opts();
224
- const graphQLService = createGraphQLService(rootOpts);
227
+ const rootOpts = getRootOpts(command);
228
+ const graphQLService = await createGraphQLService(rootOpts);
225
229
  // Step 1: Check for duplicate projects (case-insensitive)
226
230
  const searchResult = await graphQLService.rawRequest(SEARCH_PROJECTS_BY_NAME_QUERY, { name });
227
231
  const existing = (searchResult.projects
@@ -236,7 +240,7 @@ async function handleCreateProject(name, options, command) {
236
240
  `Use --force to create anyway, or use the existing project.`);
237
241
  }
238
242
  // Step 2: Resolve team IDs
239
- const service = createLinearService(rootOpts);
243
+ const service = await createLinearService(rootOpts);
240
244
  const teamKeys = options.team ? splitList(options.team) : [];
241
245
  const teamIds = [];
242
246
  for (const key of teamKeys) {
@@ -300,8 +304,8 @@ export function setupProjectsCommands(program) {
300
304
  .option("--format <format>", "output format (json, table, md, csv)", "json")
301
305
  .option("--fields <fields>", "columns for table/csv (comma-separated: name,state,progress,teams,lead,targetDate)")
302
306
  .action(handleAsyncCommand(async (options, command) => {
303
- const rootOpts = command.parent.parent.opts();
304
- const service = createLinearService(rootOpts);
307
+ const rootOpts = getRootOpts(command);
308
+ const service = await createLinearService(rootOpts);
305
309
  const result = await service.getProjects(Number.parseInt(options.limit, 10));
306
310
  const format = options.format;
307
311
  if (format === "table" ||
@@ -1,9 +1,10 @@
1
- import { getApiToken } from "../utils/auth.js";
2
1
  import { downloadLinearUploads } from "../utils/download-uploads.js";
2
+ import { createFileService } from "../utils/file-service.js";
3
3
  import { GraphQLIssuesService } from "../utils/graphql-issues-service.js";
4
4
  import { createGraphQLService } from "../utils/graphql-service.js";
5
5
  import { createLinearService } from "../utils/linear-service.js";
6
6
  import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
7
+ import { getRootOpts } from "../utils/root-opts.js";
7
8
  /**
8
9
  * Issue ID pattern: 1-5 uppercase letters, dash, 1+ digits (e.g. ADM-652, DEV-12).
9
10
  */
@@ -49,20 +50,20 @@ export function setupReadShortcut(program) {
49
50
  };
50
51
  }
51
52
  async function readIssues(issueIds, _options, command) {
52
- const rootOpts = command.parent.opts();
53
- const graphQLService = createGraphQLService(rootOpts);
54
- const linearService = createLinearService(rootOpts);
53
+ const rootOpts = getRootOpts(command);
54
+ const graphQLService = await createGraphQLService(rootOpts);
55
+ const linearService = await createLinearService(rootOpts);
55
56
  const issuesService = new GraphQLIssuesService(graphQLService, linearService);
56
- const apiToken = getApiToken(rootOpts);
57
+ const fileService = await createFileService(rootOpts);
57
58
  if (issueIds.length === 1) {
58
59
  const issue = await issuesService.getIssueById(issueIds[0]);
59
- const resolved = await downloadLinearUploads(issue, apiToken);
60
+ const resolved = await downloadLinearUploads(issue, fileService);
60
61
  outputSuccess(resolved);
61
62
  }
62
63
  else {
63
64
  const results = await Promise.all(issueIds.map(async (id) => {
64
65
  const issue = await issuesService.getIssueById(id);
65
- return downloadLinearUploads(issue, apiToken);
66
+ return downloadLinearUploads(issue, fileService);
66
67
  }));
67
68
  outputSuccess(results);
68
69
  }
@@ -4,6 +4,7 @@ import { extractIssueReferences } from "../utils/issue-reference-extractor.js";
4
4
  import { wrapIssueReferencesAsLinks, } from "../utils/issue-reference-wrapper.js";
5
5
  import { createLinearService } from "../utils/linear-service.js";
6
6
  import { handleAsyncCommand } from "../utils/output.js";
7
+ import { getRootOpts } from "../utils/root-opts.js";
7
8
  import { validateReferences } from "../utils/validate-references.js";
8
9
  import { getWorkspaceUrlKey } from "../utils/workspace-url.js";
9
10
  const VALID_TARGETS = new Set(["markdown", "slack"]);
@@ -58,15 +59,15 @@ async function handleWrap(options, command) {
58
59
  const text = typeof options.file === "string" && options.file.length > 0
59
60
  ? readFileSync(options.file, "utf8")
60
61
  : await readAllStdin();
61
- const rootOpts = command.parent.parent.opts();
62
+ const rootOpts = getRootOpts(command);
62
63
  const deps = {
63
64
  async resolveValidIdentifiers(ids) {
64
- const linearService = createLinearService(rootOpts);
65
+ const linearService = await createLinearService(rootOpts);
65
66
  const map = await validateReferences(ids, linearService);
66
67
  return new Set(map.keys());
67
68
  },
68
69
  async resolveUrlKey() {
69
- const graphQLService = createGraphQLService(rootOpts);
70
+ const graphQLService = await createGraphQLService(rootOpts);
70
71
  return getWorkspaceUrlKey(graphQLService);
71
72
  },
72
73
  };