@enrichlayer/el-linear 1.2.0 → 1.5.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 (41) hide show
  1. package/README.md +128 -2
  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/init/index.d.ts +7 -0
  17. package/dist/commands/init/index.js +27 -1
  18. package/dist/commands/init/oauth.d.ts +85 -0
  19. package/dist/commands/init/oauth.js +308 -0
  20. package/dist/commands/init/shared.js +30 -5
  21. package/dist/commands/profile/migrate-legacy.d.ts +96 -0
  22. package/dist/commands/profile/migrate-legacy.js +272 -0
  23. package/dist/commands/profile.d.ts +46 -0
  24. package/dist/commands/profile.js +191 -0
  25. package/dist/commands/refs.d.ts +18 -0
  26. package/dist/commands/refs.js +95 -0
  27. package/dist/config/config.d.ts +2 -0
  28. package/dist/config/config.js +20 -5
  29. package/dist/config/paths.d.ts +27 -0
  30. package/dist/config/paths.js +77 -0
  31. package/dist/main.js +14 -1
  32. package/dist/utils/auth.js +25 -3
  33. package/dist/utils/graphql-service.d.ts +16 -1
  34. package/dist/utils/graphql-service.js +19 -7
  35. package/dist/utils/issue-reference-wrapper.d.ts +19 -5
  36. package/dist/utils/issue-reference-wrapper.js +33 -6
  37. package/dist/utils/legacy-config-detection.d.ts +47 -0
  38. package/dist/utils/legacy-config-detection.js +90 -0
  39. package/dist/utils/migration-hint.d.ts +46 -0
  40. package/dist/utils/migration-hint.js +90 -0
  41. package/package.json +4 -4
@@ -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
+ }
@@ -7,9 +7,27 @@
7
7
  */
8
8
  import { randomBytes } from "node:crypto";
9
9
  import fs from "node:fs/promises";
10
- import { ALIASES_PROGRESS_PATH, CONFIG_DIR, CONFIG_PATH, TOKEN_PATH, } from "../../config/paths.js";
10
+ import path from "node:path";
11
+ import { ALIASES_PROGRESS_PATH, CONFIG_DIR, CONFIG_PATH, resolveActiveProfile, TOKEN_PATH, } from "../../config/paths.js";
11
12
  // Re-export for tests and call sites that already pulled the paths from here.
12
13
  export { ALIASES_PROGRESS_PATH, CONFIG_PATH, TOKEN_PATH };
14
+ /**
15
+ * Profile-aware paths for the active wizard run. The wizard always
16
+ * writes to (and reads from) the active profile — switched via
17
+ * `EL_LINEAR_PROFILE`, `--profile`, or the on-disk `active-profile`
18
+ * marker. When no profile is selected, paths fall through to the
19
+ * legacy single-file layout (CONFIG_PATH / TOKEN_PATH).
20
+ */
21
+ function activePaths() {
22
+ const active = resolveActiveProfile();
23
+ return {
24
+ // Profile dir is always the directory of configPath (whether
25
+ // that's the legacy CONFIG_DIR or a per-profile subdirectory).
26
+ configDir: path.dirname(active.configPath),
27
+ configPath: active.configPath,
28
+ tokenPath: active.tokenPath,
29
+ };
30
+ }
13
31
  /**
14
32
  * Atomic file write: write to a sibling tmp file then rename. Survives SIGINT,
15
33
  * OOM, and laptop suspend mid-write — the original file is either untouched
@@ -38,11 +56,18 @@ async function atomicWrite(targetPath, data, mode = 0o644) {
38
56
  }
39
57
  }
40
58
  export async function ensureConfigDir() {
59
+ // Always make sure the legacy CONFIG_DIR exists (it's where the
60
+ // `active-profile` marker + `profiles/` tree live), then make the
61
+ // active profile's directory if it differs.
41
62
  await fs.mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
63
+ const dir = activePaths().configDir;
64
+ if (dir !== CONFIG_DIR) {
65
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
66
+ }
42
67
  }
43
68
  export async function readConfig() {
44
69
  try {
45
- const raw = await fs.readFile(CONFIG_PATH, "utf8");
70
+ const raw = await fs.readFile(activePaths().configPath, "utf8");
46
71
  return JSON.parse(raw);
47
72
  }
48
73
  catch (err) {
@@ -56,11 +81,11 @@ export async function writeConfig(config) {
56
81
  await ensureConfigDir();
57
82
  // Stable key order so byte-identical config produces byte-identical output.
58
83
  const sorted = sortKeys(config);
59
- await atomicWrite(CONFIG_PATH, `${JSON.stringify(sorted, null, 2)}\n`, 0o644);
84
+ await atomicWrite(activePaths().configPath, `${JSON.stringify(sorted, null, 2)}\n`, 0o644);
60
85
  }
61
86
  export async function readToken() {
62
87
  try {
63
- const raw = await fs.readFile(TOKEN_PATH, "utf8");
88
+ const raw = await fs.readFile(activePaths().tokenPath, "utf8");
64
89
  return raw.trim() || null;
65
90
  }
66
91
  catch (err) {
@@ -82,7 +107,7 @@ export async function readToken() {
82
107
  */
83
108
  export async function writeToken(token) {
84
109
  await ensureConfigDir();
85
- await atomicWrite(TOKEN_PATH, `${token.trim()}\n`, 0o600);
110
+ await atomicWrite(activePaths().tokenPath, `${token.trim()}\n`, 0o600);
86
111
  }
87
112
  /**
88
113
  * Build a new object containing only the keys whose values are not `undefined`.
@@ -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 };