@enrichlayer/el-linear 1.4.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.
@@ -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
+ }
@@ -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,272 @@
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
+ // biome-ignore lint/suspicious/noConsole: interactive prompt feedback
161
+ console.log(` ✗ ${sanitizeForLog(raw)}`);
162
+ }
163
+ }
164
+ throw new Error("Could not validate a Linear API token after 3 attempts. Aborting migration.");
165
+ }
166
+ async function copyConfigIntoProfile(destConfigPath, options, prompts) {
167
+ const sourceContent = await fsp.readFile(CONFIG_PATH, "utf8");
168
+ if (!(await pathExists(destConfigPath))) {
169
+ await fsp.writeFile(destConfigPath, sourceContent, {
170
+ mode: 0o644,
171
+ encoding: "utf8",
172
+ });
173
+ return;
174
+ }
175
+ const destContent = await fsp.readFile(destConfigPath, "utf8");
176
+ if (destContent === sourceContent) {
177
+ // Idempotent re-run — nothing to do.
178
+ return;
179
+ }
180
+ if (!options.force) {
181
+ throw new Error([
182
+ `Refusing to overwrite ${destConfigPath} — its contents differ from ${CONFIG_PATH}.`,
183
+ "Re-run with --force to overwrite. The legacy file is kept either way; only the per-profile copy changes.",
184
+ `Diff hint: \`diff ${CONFIG_PATH} ${destConfigPath}\``,
185
+ ].join("\n"));
186
+ }
187
+ if (!options.yes) {
188
+ const ok = await prompts.confirm({
189
+ message: `Overwrite ${destConfigPath} with the legacy config?`,
190
+ default: false,
191
+ });
192
+ if (!ok) {
193
+ throw new Error("Aborted by user.");
194
+ }
195
+ }
196
+ await fsp.writeFile(destConfigPath, sourceContent, {
197
+ mode: 0o644,
198
+ encoding: "utf8",
199
+ });
200
+ }
201
+ async function writeProfileToken(destTokenPath, token, options, prompts) {
202
+ const newline = `${token}\n`;
203
+ if (!(await pathExists(destTokenPath))) {
204
+ await fsp.writeFile(destTokenPath, newline, {
205
+ mode: 0o600,
206
+ encoding: "utf8",
207
+ });
208
+ // fs.writeFile mode is only honored on file creation; chmod defensively
209
+ // in case a parent process pre-created the file with permissive perms.
210
+ await fsp.chmod(destTokenPath, 0o600);
211
+ return;
212
+ }
213
+ const existing = (await fsp.readFile(destTokenPath, "utf8")).trim();
214
+ if (existing === token) {
215
+ // Idempotent re-run — already correct; force perms regardless.
216
+ await fsp.chmod(destTokenPath, 0o600);
217
+ return;
218
+ }
219
+ if (!options.force) {
220
+ throw new Error([
221
+ `Refusing to overwrite ${destTokenPath} — it contains a different token.`,
222
+ "Re-run with --force to overwrite the per-profile token.",
223
+ ].join("\n"));
224
+ }
225
+ if (!options.yes) {
226
+ const ok = await prompts.confirm({
227
+ message: `Overwrite the existing token at ${destTokenPath}?`,
228
+ default: false,
229
+ });
230
+ if (!ok) {
231
+ throw new Error("Aborted by user.");
232
+ }
233
+ }
234
+ await fsp.writeFile(destTokenPath, newline, {
235
+ mode: 0o600,
236
+ encoding: "utf8",
237
+ });
238
+ await fsp.chmod(destTokenPath, 0o600);
239
+ }
240
+ async function ensureActiveProfile(name) {
241
+ await fsp.mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
242
+ let current = null;
243
+ if (await pathExists(ACTIVE_PROFILE_FILE)) {
244
+ current = (await fsp.readFile(ACTIVE_PROFILE_FILE, "utf8")).trim();
245
+ }
246
+ if (current === name)
247
+ return;
248
+ await fsp.writeFile(ACTIVE_PROFILE_FILE, `${name}\n`, {
249
+ mode: 0o644,
250
+ encoding: "utf8",
251
+ });
252
+ }
253
+ async function pathExists(p) {
254
+ try {
255
+ await fsp.access(p);
256
+ return true;
257
+ }
258
+ catch {
259
+ return false;
260
+ }
261
+ }
262
+ /**
263
+ * Profile names land in filesystem paths and the active-profile marker.
264
+ * Same conservative charset as `profile add` to avoid `..` traversal,
265
+ * shell metacharacters, and Unicode lookalikes.
266
+ */
267
+ function isSafeName(name) {
268
+ return /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(name);
269
+ }
270
+ // Re-export internals so the integration test (and follow-up commands)
271
+ // can compose them without re-implementing the idempotency rules.
272
+ export { CONFIG_PATH, PROFILES_DIR, TOKEN_PATH };