@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.
- package/README.md +128 -2
- package/dist/auth/oauth-callback.d.ts +40 -0
- package/dist/auth/oauth-callback.js +142 -0
- package/dist/auth/oauth-client.d.ts +55 -0
- package/dist/auth/oauth-client.js +134 -0
- package/dist/auth/oauth-fs.d.ts +1 -0
- package/dist/auth/oauth-fs.js +29 -0
- package/dist/auth/oauth-headless.d.ts +38 -0
- package/dist/auth/oauth-headless.js +50 -0
- package/dist/auth/oauth-storage.d.ts +51 -0
- package/dist/auth/oauth-storage.js +87 -0
- package/dist/auth/oauth-token.d.ts +70 -0
- package/dist/auth/oauth-token.js +141 -0
- package/dist/auth/token-resolver.d.ts +48 -0
- package/dist/auth/token-resolver.js +95 -0
- package/dist/commands/init/index.d.ts +7 -0
- package/dist/commands/init/index.js +27 -1
- package/dist/commands/init/oauth.d.ts +85 -0
- package/dist/commands/init/oauth.js +308 -0
- package/dist/commands/init/shared.js +30 -5
- package/dist/commands/profile/migrate-legacy.d.ts +96 -0
- package/dist/commands/profile/migrate-legacy.js +272 -0
- package/dist/commands/profile.d.ts +46 -0
- package/dist/commands/profile.js +191 -0
- package/dist/commands/refs.d.ts +18 -0
- package/dist/commands/refs.js +95 -0
- package/dist/config/config.d.ts +2 -0
- package/dist/config/config.js +20 -5
- package/dist/config/paths.d.ts +27 -0
- package/dist/config/paths.js +77 -0
- package/dist/main.js +14 -1
- package/dist/utils/auth.js +25 -3
- package/dist/utils/graphql-service.d.ts +16 -1
- package/dist/utils/graphql-service.js +19 -7
- package/dist/utils/issue-reference-wrapper.d.ts +19 -5
- package/dist/utils/issue-reference-wrapper.js +33 -6
- package/dist/utils/legacy-config-detection.d.ts +47 -0
- package/dist/utils/legacy-config-detection.js +90 -0
- package/dist/utils/migration-hint.d.ts +46 -0
- package/dist/utils/migration-hint.js +90 -0
- 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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 };
|