@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
package/dist/config/config.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import { outputWarning } from "../utils/output.js";
|
|
3
|
-
import { CONFIG_PATH } from "./paths.js";
|
|
3
|
+
import { CONFIG_PATH, resolveActiveProfile } from "./paths.js";
|
|
4
4
|
const DEFAULT_CONFIG = {
|
|
5
5
|
defaultTeam: "",
|
|
6
6
|
defaultLabels: [],
|
|
@@ -23,13 +23,25 @@ const DEFAULT_CONFIG = {
|
|
|
23
23
|
terms: [],
|
|
24
24
|
};
|
|
25
25
|
let cachedConfig;
|
|
26
|
+
/** Test seam — resets the cache between test cases. */
|
|
27
|
+
export function _resetConfigCacheForTests() {
|
|
28
|
+
cachedConfig = undefined;
|
|
29
|
+
}
|
|
26
30
|
export function loadConfig() {
|
|
27
31
|
if (cachedConfig) {
|
|
28
32
|
return cachedConfig;
|
|
29
33
|
}
|
|
30
|
-
|
|
34
|
+
// Profile-aware: read from <CONFIG_DIR>/profiles/<name>/config.json
|
|
35
|
+
// when a profile is active, falling back to the legacy single-file
|
|
36
|
+
// path so existing setups keep working without migration.
|
|
37
|
+
const active = resolveActiveProfile();
|
|
38
|
+
const candidates = [active.configPath];
|
|
39
|
+
if (active.configPath !== CONFIG_PATH)
|
|
40
|
+
candidates.push(CONFIG_PATH);
|
|
41
|
+
const sourcePath = candidates.find((p) => fs.existsSync(p));
|
|
42
|
+
if (sourcePath) {
|
|
31
43
|
try {
|
|
32
|
-
const userConfig = JSON.parse(fs.readFileSync(
|
|
44
|
+
const userConfig = JSON.parse(fs.readFileSync(sourcePath, "utf8"));
|
|
33
45
|
// Migration: the legacy `brand: { name, reject }` config is auto-promoted to
|
|
34
46
|
// a single entry in `terms[]`. We warn (not throw) so existing users get a
|
|
35
47
|
// grace period to update their config.
|
|
@@ -52,12 +64,15 @@ export function loadConfig() {
|
|
|
52
64
|
cachedConfig = deepMerge(DEFAULT_CONFIG, userConfig);
|
|
53
65
|
}
|
|
54
66
|
catch {
|
|
55
|
-
outputWarning(`Failed to parse ${
|
|
67
|
+
outputWarning(`Failed to parse ${sourcePath}, using empty defaults`);
|
|
56
68
|
cachedConfig = DEFAULT_CONFIG;
|
|
57
69
|
}
|
|
58
70
|
}
|
|
59
71
|
else {
|
|
60
|
-
|
|
72
|
+
const profileNote = active.name
|
|
73
|
+
? ` (active profile: \`${active.name}\` — expected at ${active.configPath})`
|
|
74
|
+
: "";
|
|
75
|
+
outputWarning(`No config found at ${active.configPath}${profileNote}. Run \`el-linear init\` (or \`el-linear profile add ${active.name ?? "<name>"}\`) to create one.`);
|
|
61
76
|
cachedConfig = DEFAULT_CONFIG;
|
|
62
77
|
}
|
|
63
78
|
return cachedConfig;
|
package/dist/config/paths.d.ts
CHANGED
|
@@ -18,3 +18,30 @@ export declare const LEGACY_LINCTL_CONFIG_PATH: string;
|
|
|
18
18
|
export declare const LEGACY_LINCTL_TOKEN_PATH: string;
|
|
19
19
|
/** Even older fallback from before the `~/.config/...` move (one release). */
|
|
20
20
|
export declare const LEGACY_TOKEN_PATH: string;
|
|
21
|
+
export declare const PROFILES_DIR: string;
|
|
22
|
+
export declare const ACTIVE_PROFILE_FILE: string;
|
|
23
|
+
export interface ProfilePaths {
|
|
24
|
+
/** Profile name; null when using the legacy single-file layout. */
|
|
25
|
+
name: string | null;
|
|
26
|
+
configPath: string;
|
|
27
|
+
tokenPath: string;
|
|
28
|
+
}
|
|
29
|
+
export interface ProfileFsOps {
|
|
30
|
+
readFileSync: (p: string) => string;
|
|
31
|
+
existsSync: (p: string) => boolean;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Set the active profile for the duration of this process. Pass `null`
|
|
35
|
+
* to clear the override and fall back to env / on-disk markers.
|
|
36
|
+
*/
|
|
37
|
+
export declare function setActiveProfileForSession(name: string | null): void;
|
|
38
|
+
/** Read-only accessor for the per-session override (test seam). */
|
|
39
|
+
export declare function getSessionProfileOverride(): string | null;
|
|
40
|
+
/**
|
|
41
|
+
* Resolve the active profile name + on-disk paths. Returns the legacy
|
|
42
|
+
* single-file layout when no profile is selected, so existing setups
|
|
43
|
+
* keep working without migration.
|
|
44
|
+
*/
|
|
45
|
+
export declare function resolveActiveProfile(env?: NodeJS.ProcessEnv, fsImpl?: ProfileFsOps): ProfilePaths;
|
|
46
|
+
/** Build profile-relative paths for a named profile. Pure. */
|
|
47
|
+
export declare function profilePaths(name: string): ProfilePaths;
|
package/dist/config/paths.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* config loader, the auth module, and the init wizard all agree on where to
|
|
4
4
|
* read and write.
|
|
5
5
|
*/
|
|
6
|
+
import fs from "node:fs";
|
|
6
7
|
import os from "node:os";
|
|
7
8
|
import path from "node:path";
|
|
8
9
|
export const CONFIG_DIR = path.join(os.homedir(), ".config", "el-linear");
|
|
@@ -20,3 +21,79 @@ export const LEGACY_LINCTL_CONFIG_PATH = path.join(LEGACY_LINCTL_CONFIG_DIR, "co
|
|
|
20
21
|
export const LEGACY_LINCTL_TOKEN_PATH = path.join(LEGACY_LINCTL_CONFIG_DIR, "token");
|
|
21
22
|
/** Even older fallback from before the `~/.config/...` move (one release). */
|
|
22
23
|
export const LEGACY_TOKEN_PATH = path.join(os.homedir(), ".linear_api_token");
|
|
24
|
+
// ---- Profiles --------------------------------------------------------
|
|
25
|
+
//
|
|
26
|
+
// el-linear supports named profiles for switching between Linear
|
|
27
|
+
// workspaces (org A vs org B vs scratch token). Each profile owns its
|
|
28
|
+
// own config + token. The single-profile layout (CONFIG_PATH +
|
|
29
|
+
// TOKEN_PATH above) keeps working unchanged — existing users see no
|
|
30
|
+
// behavior change. A user opts in to multi-profile by either:
|
|
31
|
+
// - calling `el-linear profile add <name>`, OR
|
|
32
|
+
// - hand-creating <CONFIG_DIR>/profiles/<name>/{config.json,token}
|
|
33
|
+
//
|
|
34
|
+
// Resolution order (highest priority first):
|
|
35
|
+
// 1. Per-call --profile <name> flag (set via setActiveProfileForSession)
|
|
36
|
+
// 2. EL_LINEAR_PROFILE env var
|
|
37
|
+
// 3. <CONFIG_DIR>/active-profile (single-line text file)
|
|
38
|
+
// 4. Legacy single-file paths (CONFIG_PATH / TOKEN_PATH) — default,
|
|
39
|
+
// keeps backward compatibility.
|
|
40
|
+
export const PROFILES_DIR = path.join(CONFIG_DIR, "profiles");
|
|
41
|
+
export const ACTIVE_PROFILE_FILE = path.join(CONFIG_DIR, "active-profile");
|
|
42
|
+
const DEFAULT_FS_OPS = {
|
|
43
|
+
readFileSync: (p) => fs.readFileSync(p, "utf8"),
|
|
44
|
+
existsSync: (p) => fs.existsSync(p),
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Per-process override for the active profile name. Set by main.ts when
|
|
48
|
+
* `--profile <name>` is passed (highest priority).
|
|
49
|
+
*/
|
|
50
|
+
let sessionProfileOverride = null;
|
|
51
|
+
/**
|
|
52
|
+
* Set the active profile for the duration of this process. Pass `null`
|
|
53
|
+
* to clear the override and fall back to env / on-disk markers.
|
|
54
|
+
*/
|
|
55
|
+
export function setActiveProfileForSession(name) {
|
|
56
|
+
sessionProfileOverride = name && name.trim().length > 0 ? name.trim() : null;
|
|
57
|
+
}
|
|
58
|
+
/** Read-only accessor for the per-session override (test seam). */
|
|
59
|
+
export function getSessionProfileOverride() {
|
|
60
|
+
return sessionProfileOverride;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Resolve the active profile name + on-disk paths. Returns the legacy
|
|
64
|
+
* single-file layout when no profile is selected, so existing setups
|
|
65
|
+
* keep working without migration.
|
|
66
|
+
*/
|
|
67
|
+
export function resolveActiveProfile(env = process.env, fsImpl = DEFAULT_FS_OPS) {
|
|
68
|
+
const explicit = sessionProfileOverride ??
|
|
69
|
+
(env.EL_LINEAR_PROFILE?.trim() || null) ??
|
|
70
|
+
readActiveProfileMarker(fsImpl);
|
|
71
|
+
if (explicit) {
|
|
72
|
+
return profilePaths(explicit);
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
name: null,
|
|
76
|
+
configPath: CONFIG_PATH,
|
|
77
|
+
tokenPath: TOKEN_PATH,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** Build profile-relative paths for a named profile. Pure. */
|
|
81
|
+
export function profilePaths(name) {
|
|
82
|
+
const dir = path.join(PROFILES_DIR, name);
|
|
83
|
+
return {
|
|
84
|
+
name,
|
|
85
|
+
configPath: path.join(dir, "config.json"),
|
|
86
|
+
tokenPath: path.join(dir, "token"),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function readActiveProfileMarker(fsImpl) {
|
|
90
|
+
if (!fsImpl.existsSync(ACTIVE_PROFILE_FILE))
|
|
91
|
+
return null;
|
|
92
|
+
try {
|
|
93
|
+
const value = fsImpl.readFileSync(ACTIVE_PROFILE_FILE).toString().trim();
|
|
94
|
+
return value.length > 0 ? value : null;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -13,22 +13,26 @@ import { setupInitCommands } from "./commands/init/index.js";
|
|
|
13
13
|
import { setupIssueIdCommand } from "./commands/issue-id.js";
|
|
14
14
|
import { setupIssuesCommands } from "./commands/issues.js";
|
|
15
15
|
import { setupLabelsCommands } from "./commands/labels.js";
|
|
16
|
+
import { setupProfileCommands } from "./commands/profile.js";
|
|
16
17
|
import { setupProjectMilestonesCommands } from "./commands/project-milestones.js";
|
|
17
18
|
import { setupProjectsCommands } from "./commands/projects.js";
|
|
18
19
|
import { setupReadShortcut } from "./commands/read-shortcut.js";
|
|
20
|
+
import { setupRefsCommands } from "./commands/refs.js";
|
|
19
21
|
import { setupReleasesCommands } from "./commands/releases.js";
|
|
20
22
|
import { setupSearchCommands } from "./commands/search.js";
|
|
21
23
|
import { setupTeamsCommands } from "./commands/teams.js";
|
|
22
24
|
import { setupTemplatesCommands } from "./commands/templates.js";
|
|
23
25
|
import { setupUsersCommands } from "./commands/users.js";
|
|
26
|
+
import { setActiveProfileForSession } from "./config/paths.js";
|
|
24
27
|
import { setFieldsFilter, setJqFilter, setRawMode } from "./utils/output.js";
|
|
25
28
|
import { outputUsageInfo } from "./utils/usage.js";
|
|
26
29
|
import { splitList } from "./utils/validators.js";
|
|
27
30
|
program
|
|
28
31
|
.name("el-linear")
|
|
29
32
|
.description("A pragmatic CLI for Linear.app — deterministic resolution, structured validation, GraphQL escape hatch.")
|
|
30
|
-
.version("1.
|
|
33
|
+
.version("1.5.0")
|
|
31
34
|
.option("--api-token <token>", "Linear API token")
|
|
35
|
+
.option("--profile <name>", "named profile (under ~/.config/el-linear/profiles/<name>/) for this invocation. Overrides EL_LINEAR_PROFILE env + the on-disk active-profile marker.")
|
|
32
36
|
.option("--json", "output as JSON (default, accepted for compatibility)")
|
|
33
37
|
.option("--raw", "strip { data, meta } wrapper from list output — emit the array directly")
|
|
34
38
|
.option("--jq <filter>", "apply a jq filter to the JSON output")
|
|
@@ -44,6 +48,13 @@ program.hook("preAction", (_thisCommand, actionCommand) => {
|
|
|
44
48
|
if (rootOpts.fields) {
|
|
45
49
|
setFieldsFilter(splitList(rootOpts.fields));
|
|
46
50
|
}
|
|
51
|
+
// `--profile <name>` is highest-priority. preAction runs BEFORE the
|
|
52
|
+
// command body, which is BEFORE getApiToken / loadConfig fire — so
|
|
53
|
+
// setting the override here means the rest of the run picks up the
|
|
54
|
+
// right profile's token + config.
|
|
55
|
+
if (rootOpts.profile) {
|
|
56
|
+
setActiveProfileForSession(rootOpts.profile);
|
|
57
|
+
}
|
|
47
58
|
});
|
|
48
59
|
program.action(() => {
|
|
49
60
|
program.help();
|
|
@@ -68,6 +79,8 @@ setupGdocCommands(program);
|
|
|
68
79
|
setupGraphQLCommands(program);
|
|
69
80
|
setupConfigCommands(program);
|
|
70
81
|
setupInitCommands(program);
|
|
82
|
+
setupProfileCommands(program);
|
|
83
|
+
setupRefsCommands(program);
|
|
71
84
|
setupReadShortcut(program);
|
|
72
85
|
program
|
|
73
86
|
.command("usage")
|
package/dist/utils/auth.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import { LEGACY_LINCTL_TOKEN_PATH, LEGACY_TOKEN_PATH, TOKEN_PATH, } from "../config/paths.js";
|
|
2
|
+
import { LEGACY_LINCTL_TOKEN_PATH, LEGACY_TOKEN_PATH, resolveActiveProfile, TOKEN_PATH, } from "../config/paths.js";
|
|
3
|
+
import { maybeEmitMigrationHint } from "./migration-hint.js";
|
|
3
4
|
export function getApiToken(options) {
|
|
4
5
|
if (options.apiToken) {
|
|
5
6
|
return options.apiToken;
|
|
@@ -7,7 +8,18 @@ export function getApiToken(options) {
|
|
|
7
8
|
if (process.env.LINEAR_API_TOKEN) {
|
|
8
9
|
return process.env.LINEAR_API_TOKEN;
|
|
9
10
|
}
|
|
10
|
-
|
|
11
|
+
// Profile-aware: read from <CONFIG_DIR>/profiles/<name>/token when a
|
|
12
|
+
// profile is active, falling back to the legacy single-file path
|
|
13
|
+
// (TOKEN_PATH) for the no-profile case.
|
|
14
|
+
const active = resolveActiveProfile();
|
|
15
|
+
if (fs.existsSync(active.tokenPath)) {
|
|
16
|
+
return fs.readFileSync(active.tokenPath, "utf8").trim();
|
|
17
|
+
}
|
|
18
|
+
// Even when a profile is selected, fall through to the legacy token
|
|
19
|
+
// path so an operator who only has the single-file layout doesn't
|
|
20
|
+
// suddenly fail. The active-profile name is informational only when
|
|
21
|
+
// no profile-scoped token exists yet.
|
|
22
|
+
if (active.tokenPath !== TOKEN_PATH && fs.existsSync(TOKEN_PATH)) {
|
|
11
23
|
return fs.readFileSync(TOKEN_PATH, "utf8").trim();
|
|
12
24
|
}
|
|
13
25
|
// Fallback to the linctl-era token (the CLI was briefly published as
|
|
@@ -19,5 +31,15 @@ export function getApiToken(options) {
|
|
|
19
31
|
if (fs.existsSync(LEGACY_TOKEN_PATH)) {
|
|
20
32
|
return fs.readFileSync(LEGACY_TOKEN_PATH, "utf8").trim();
|
|
21
33
|
}
|
|
22
|
-
|
|
34
|
+
// Before falling through to the auth error, check for legacy-config
|
|
35
|
+
// drift (legacy `config.json` present but no token, or active-profile
|
|
36
|
+
// pointer broken). If detected, emit a one-shot stderr hint pointing
|
|
37
|
+
// the user at `el-linear profile migrate-legacy`. The hint is purely
|
|
38
|
+
// informational — we always still throw below, so scripted callers
|
|
39
|
+
// continue to see a non-zero exit and a parseable JSON error on stdout.
|
|
40
|
+
maybeEmitMigrationHint();
|
|
41
|
+
const profileNote = active.name
|
|
42
|
+
? ` (active profile: \`${active.name}\` — expected token at ${active.tokenPath})`
|
|
43
|
+
: "";
|
|
44
|
+
throw new Error(`No API token found${profileNote}. Use --api-token, LINEAR_API_TOKEN env var, ~/.config/el-linear/token, or ~/.linear_api_token file. To switch profiles, use \`el-linear profile use <name>\` or \`--profile <name>\`.`);
|
|
23
45
|
}
|
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
import type { GraphQLResponseData, GraphQLVariables } from "../types/linear.js";
|
|
2
2
|
import { type AuthOptions } from "./auth.js";
|
|
3
|
+
/**
|
|
4
|
+
* Constructor arg shapes for `GraphQLService`. Three variants:
|
|
5
|
+
* - `string` → personal API token (legacy; sent without `Bearer` prefix).
|
|
6
|
+
* - `{apiKey: string}` → personal API token (explicit).
|
|
7
|
+
* - `{oauthToken: string}` → OAuth access token (sent as
|
|
8
|
+
* `Authorization: Bearer <token>` via the SDK's accessToken option).
|
|
9
|
+
*
|
|
10
|
+
* The string variant exists because hundreds of call sites and tests pass
|
|
11
|
+
* a plain string. We continue to support it indefinitely.
|
|
12
|
+
*/
|
|
13
|
+
export type GraphQLServiceAuth = string | {
|
|
14
|
+
apiKey: string;
|
|
15
|
+
} | {
|
|
16
|
+
oauthToken: string;
|
|
17
|
+
};
|
|
3
18
|
export declare class GraphQLService {
|
|
4
19
|
private readonly graphQLClient;
|
|
5
|
-
constructor(
|
|
20
|
+
constructor(auth: GraphQLServiceAuth);
|
|
6
21
|
rawRequest<T = GraphQLResponseData>(query: string, variables?: GraphQLVariables): Promise<T>;
|
|
7
22
|
}
|
|
8
23
|
export declare function createGraphQLService(options: AuthOptions): GraphQLService;
|
|
@@ -1,14 +1,26 @@
|
|
|
1
1
|
import { LinearClient } from "@linear/sdk";
|
|
2
2
|
import { getApiToken } from "./auth.js";
|
|
3
|
+
function buildLinearClient(auth) {
|
|
4
|
+
const baseHeaders = { "public-file-urls-expire-in": "3600" };
|
|
5
|
+
if (typeof auth === "string") {
|
|
6
|
+
return new LinearClient({ apiKey: auth, headers: baseHeaders });
|
|
7
|
+
}
|
|
8
|
+
if ("oauthToken" in auth) {
|
|
9
|
+
// Linear's SDK natively supports OAuth via the `accessToken` option,
|
|
10
|
+
// which causes the underlying graphql-request client to send
|
|
11
|
+
// `Authorization: Bearer <token>` instead of the personal-token
|
|
12
|
+
// shape (`Authorization: <token>`).
|
|
13
|
+
return new LinearClient({
|
|
14
|
+
accessToken: auth.oauthToken,
|
|
15
|
+
headers: baseHeaders,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
return new LinearClient({ apiKey: auth.apiKey, headers: baseHeaders });
|
|
19
|
+
}
|
|
3
20
|
export class GraphQLService {
|
|
4
21
|
graphQLClient;
|
|
5
|
-
constructor(
|
|
6
|
-
const client =
|
|
7
|
-
apiKey: apiToken,
|
|
8
|
-
headers: {
|
|
9
|
-
"public-file-urls-expire-in": "3600",
|
|
10
|
-
},
|
|
11
|
-
});
|
|
22
|
+
constructor(auth) {
|
|
23
|
+
const client = buildLinearClient(auth);
|
|
12
24
|
// LinearClient stores a private graphql-request client — access via escape hatch
|
|
13
25
|
this.graphQLClient = client.client;
|
|
14
26
|
}
|
|
@@ -1,12 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output emitter target. `markdown` produces `[ID](url)`; `slack` produces
|
|
3
|
+
* Slack mrkdwn `<url|ID>`. New targets (e.g. `html`) can be added to the
|
|
4
|
+
* discriminated map without touching callers.
|
|
5
|
+
*/
|
|
6
|
+
export type WrapTarget = "markdown" | "slack";
|
|
1
7
|
/**
|
|
2
8
|
* Rewrite `text`, wrapping each occurrence of a known-valid Linear issue identifier
|
|
3
|
-
* as a
|
|
4
|
-
* inline code, existing markdown links,
|
|
9
|
+
* as a link in the chosen `target` syntax. Skips any occurrence inside protected
|
|
10
|
+
* ranges (code blocks, inline code, existing markdown links, Slack links,
|
|
11
|
+
* angle-bracket autolinks, bare URLs).
|
|
5
12
|
*
|
|
6
13
|
* Only IDs in `validIdentifiers` are wrapped — IDs that don't resolve in the
|
|
7
14
|
* workspace are left as plain text (handles false positives like ISO codes).
|
|
8
15
|
*
|
|
9
|
-
* If `text` already contains `[ID](url)` for a given ID,
|
|
10
|
-
* alone (it's inside a protected range), so this
|
|
16
|
+
* If `text` already contains a wrapped `[ID](url)` or `<url|ID>` for a given ID,
|
|
17
|
+
* that occurrence is left alone (it's inside a protected range), so this
|
|
18
|
+
* function is idempotent for the matching target. Running `markdown` then
|
|
19
|
+
* `slack` (or vice versa) will not re-wrap previously-wrapped IDs because the
|
|
20
|
+
* existing link's content stays inside a protected range.
|
|
21
|
+
*
|
|
22
|
+
* The 3-arg signature is preserved as a positional `target` defaulting to
|
|
23
|
+
* `"markdown"` for backward compatibility with existing callers
|
|
24
|
+
* (`issues create/update`, `comments create/update`).
|
|
11
25
|
*/
|
|
12
|
-
export declare function wrapIssueReferencesAsLinks(text: string, validIdentifiers: Set<string>, workspaceUrlKey: string): string;
|
|
26
|
+
export declare function wrapIssueReferencesAsLinks(text: string, validIdentifiers: Set<string>, workspaceUrlKey: string, target?: WrapTarget): string;
|
|
@@ -9,6 +9,9 @@ const FENCED_CODE_BLOCK_REGEX = /(?:^|\n)([ \t]*)(?:```|~~~)[^\n]*\n[\s\S]*?\n\1
|
|
|
9
9
|
const INLINE_CODE_REGEX = /`[^`\n]+?`/g;
|
|
10
10
|
// Existing markdown links: [text](url). We protect both the text and the url.
|
|
11
11
|
const MARKDOWN_LINK_REGEX = /\[([^\]]*)\]\(([^)]*)\)/g;
|
|
12
|
+
// Existing Slack mrkdwn links: <url|text>. We protect both halves so identifiers
|
|
13
|
+
// inside an already-wrapped Slack link aren't double-wrapped.
|
|
14
|
+
const SLACK_LINK_REGEX = /<https?:\/\/[^|>\s]+\|[^>]*>/g;
|
|
12
15
|
// Angle-bracket autolinks: <https://...>
|
|
13
16
|
const ANGLE_AUTOLINK_REGEX = /<[^>\s]+>/g;
|
|
14
17
|
// Bare URLs in prose. We protect these so identifiers inside paths
|
|
@@ -30,6 +33,11 @@ function findProtectedRanges(text) {
|
|
|
30
33
|
FENCED_CODE_BLOCK_REGEX,
|
|
31
34
|
INLINE_CODE_REGEX,
|
|
32
35
|
MARKDOWN_LINK_REGEX,
|
|
36
|
+
// Slack links must be protected before generic angle-bracket autolinks,
|
|
37
|
+
// otherwise the autolink regex would still match `<https://…|label>` because
|
|
38
|
+
// it doesn't include the pipe boundary. matchAll resets per regex so order
|
|
39
|
+
// in this list is irrelevant for correctness — both ranges still cover the span.
|
|
40
|
+
SLACK_LINK_REGEX,
|
|
33
41
|
ANGLE_AUTOLINK_REGEX,
|
|
34
42
|
BARE_URL_REGEX,
|
|
35
43
|
]) {
|
|
@@ -51,22 +59,41 @@ function isProtected(pos, ranges) {
|
|
|
51
59
|
function buildIssueUrl(identifier, workspaceUrlKey) {
|
|
52
60
|
return `https://linear.app/${workspaceUrlKey}/issue/${identifier}/`;
|
|
53
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Per-target emitters. Each takes the resolved url and human identifier and
|
|
64
|
+
* returns the wrapped link string. Add a new target by adding a key here.
|
|
65
|
+
*/
|
|
66
|
+
const EMITTERS = {
|
|
67
|
+
markdown: (id, url) => `[${id}](${url})`,
|
|
68
|
+
// Slack mrkdwn link syntax: <url|label>. Reference:
|
|
69
|
+
// https://api.slack.com/reference/surfaces/formatting#linking-urls
|
|
70
|
+
slack: (id, url) => `<${url}|${id}>`,
|
|
71
|
+
};
|
|
54
72
|
/**
|
|
55
73
|
* Rewrite `text`, wrapping each occurrence of a known-valid Linear issue identifier
|
|
56
|
-
* as a
|
|
57
|
-
* inline code, existing markdown links,
|
|
74
|
+
* as a link in the chosen `target` syntax. Skips any occurrence inside protected
|
|
75
|
+
* ranges (code blocks, inline code, existing markdown links, Slack links,
|
|
76
|
+
* angle-bracket autolinks, bare URLs).
|
|
58
77
|
*
|
|
59
78
|
* Only IDs in `validIdentifiers` are wrapped — IDs that don't resolve in the
|
|
60
79
|
* workspace are left as plain text (handles false positives like ISO codes).
|
|
61
80
|
*
|
|
62
|
-
* If `text` already contains `[ID](url)` for a given ID,
|
|
63
|
-
* alone (it's inside a protected range), so this
|
|
81
|
+
* If `text` already contains a wrapped `[ID](url)` or `<url|ID>` for a given ID,
|
|
82
|
+
* that occurrence is left alone (it's inside a protected range), so this
|
|
83
|
+
* function is idempotent for the matching target. Running `markdown` then
|
|
84
|
+
* `slack` (or vice versa) will not re-wrap previously-wrapped IDs because the
|
|
85
|
+
* existing link's content stays inside a protected range.
|
|
86
|
+
*
|
|
87
|
+
* The 3-arg signature is preserved as a positional `target` defaulting to
|
|
88
|
+
* `"markdown"` for backward compatibility with existing callers
|
|
89
|
+
* (`issues create/update`, `comments create/update`).
|
|
64
90
|
*/
|
|
65
|
-
export function wrapIssueReferencesAsLinks(text, validIdentifiers, workspaceUrlKey) {
|
|
91
|
+
export function wrapIssueReferencesAsLinks(text, validIdentifiers, workspaceUrlKey, target = "markdown") {
|
|
66
92
|
if (!text || validIdentifiers.size === 0) {
|
|
67
93
|
return text;
|
|
68
94
|
}
|
|
69
95
|
const ranges = findProtectedRanges(text);
|
|
96
|
+
const emit = EMITTERS[target];
|
|
70
97
|
// Walk through matches and build the result string in pieces.
|
|
71
98
|
// We can't use String.replace with a callback because we need positional protection checks.
|
|
72
99
|
let result = "";
|
|
@@ -83,7 +110,7 @@ export function wrapIssueReferencesAsLinks(text, validIdentifiers, workspaceUrlK
|
|
|
83
110
|
}
|
|
84
111
|
// Append everything up to this match unchanged
|
|
85
112
|
result += text.slice(cursor, start);
|
|
86
|
-
result +=
|
|
113
|
+
result += emit(id, buildIssueUrl(id, workspaceUrlKey));
|
|
87
114
|
cursor = end;
|
|
88
115
|
}
|
|
89
116
|
result += text.slice(cursor);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detects "legacy drift" — the on-disk state where a user upgraded el-linear
|
|
3
|
+
* to >=1.4.0 (named profiles) while their old `~/.config/el-linear/config.json`
|
|
4
|
+
* is still present but the legacy single-file `token` (and any per-profile
|
|
5
|
+
* token) is missing or unreadable. In that state every command fails with
|
|
6
|
+
* "No API token found" and the user has no clear migration path.
|
|
7
|
+
*
|
|
8
|
+
* This module is **pure detection** — it returns a discriminated state and
|
|
9
|
+
* nothing else. The hint emission lives in `migration-hint.ts` and is wired
|
|
10
|
+
* into the auth-failure path (`auth.ts`) so the user gets a single clear
|
|
11
|
+
* stderr line *before* the regular auth error fires.
|
|
12
|
+
*
|
|
13
|
+
* The state shape is intentionally a discriminated union so callers can match
|
|
14
|
+
* exhaustively without re-checking individual booleans:
|
|
15
|
+
*
|
|
16
|
+
* { kind: 'no-drift' }
|
|
17
|
+
* — healthy: legacy single-file layout *or* a working active profile.
|
|
18
|
+
*
|
|
19
|
+
* { kind: 'legacy-no-token' }
|
|
20
|
+
* — `config.json` exists but no token (legacy or per-profile) does. This
|
|
21
|
+
* is the post-upgrade case: 1.4.0 expects per-profile tokens; legacy
|
|
22
|
+
* config was never migrated.
|
|
23
|
+
*
|
|
24
|
+
* { kind: 'broken-active-profile' }
|
|
25
|
+
* — `active-profile` points at a name whose directory doesn't exist.
|
|
26
|
+
* Typically caused by an interrupted `profile remove` or a hand-edit.
|
|
27
|
+
*/
|
|
28
|
+
export type LegacyDriftState = {
|
|
29
|
+
kind: "no-drift";
|
|
30
|
+
} | {
|
|
31
|
+
kind: "legacy-no-token";
|
|
32
|
+
legacyConfigPath: string;
|
|
33
|
+
} | {
|
|
34
|
+
kind: "broken-active-profile";
|
|
35
|
+
pointedAt: string;
|
|
36
|
+
};
|
|
37
|
+
export interface DetectionFsOps {
|
|
38
|
+
existsSync: (p: string) => boolean;
|
|
39
|
+
readFileSync: (p: string) => string;
|
|
40
|
+
readdirSync: (p: string) => string[];
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Detect drift between the legacy single-file layout and the >=1.4 named-
|
|
44
|
+
* profiles layout. Pure — `fsImpl` is overridable so tests can drive every
|
|
45
|
+
* branch without touching the filesystem.
|
|
46
|
+
*/
|
|
47
|
+
export declare function detectLegacyDrift(fsImpl?: DetectionFsOps): LegacyDriftState;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detects "legacy drift" — the on-disk state where a user upgraded el-linear
|
|
3
|
+
* to >=1.4.0 (named profiles) while their old `~/.config/el-linear/config.json`
|
|
4
|
+
* is still present but the legacy single-file `token` (and any per-profile
|
|
5
|
+
* token) is missing or unreadable. In that state every command fails with
|
|
6
|
+
* "No API token found" and the user has no clear migration path.
|
|
7
|
+
*
|
|
8
|
+
* This module is **pure detection** — it returns a discriminated state and
|
|
9
|
+
* nothing else. The hint emission lives in `migration-hint.ts` and is wired
|
|
10
|
+
* into the auth-failure path (`auth.ts`) so the user gets a single clear
|
|
11
|
+
* stderr line *before* the regular auth error fires.
|
|
12
|
+
*
|
|
13
|
+
* The state shape is intentionally a discriminated union so callers can match
|
|
14
|
+
* exhaustively without re-checking individual booleans:
|
|
15
|
+
*
|
|
16
|
+
* { kind: 'no-drift' }
|
|
17
|
+
* — healthy: legacy single-file layout *or* a working active profile.
|
|
18
|
+
*
|
|
19
|
+
* { kind: 'legacy-no-token' }
|
|
20
|
+
* — `config.json` exists but no token (legacy or per-profile) does. This
|
|
21
|
+
* is the post-upgrade case: 1.4.0 expects per-profile tokens; legacy
|
|
22
|
+
* config was never migrated.
|
|
23
|
+
*
|
|
24
|
+
* { kind: 'broken-active-profile' }
|
|
25
|
+
* — `active-profile` points at a name whose directory doesn't exist.
|
|
26
|
+
* Typically caused by an interrupted `profile remove` or a hand-edit.
|
|
27
|
+
*/
|
|
28
|
+
import fs from "node:fs";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
import { ACTIVE_PROFILE_FILE, CONFIG_PATH, PROFILES_DIR, TOKEN_PATH, } from "../config/paths.js";
|
|
31
|
+
const DEFAULT_FS_OPS = {
|
|
32
|
+
existsSync: (p) => fs.existsSync(p),
|
|
33
|
+
readFileSync: (p) => fs.readFileSync(p, "utf8"),
|
|
34
|
+
readdirSync: (p) => fs.readdirSync(p),
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Detect drift between the legacy single-file layout and the >=1.4 named-
|
|
38
|
+
* profiles layout. Pure — `fsImpl` is overridable so tests can drive every
|
|
39
|
+
* branch without touching the filesystem.
|
|
40
|
+
*/
|
|
41
|
+
export function detectLegacyDrift(fsImpl = DEFAULT_FS_OPS) {
|
|
42
|
+
// Branch 1: broken active-profile pointer.
|
|
43
|
+
// The `active-profile` marker names a profile that doesn't exist on disk —
|
|
44
|
+
// classic post-`profile remove` orphan, or a hand-edit typo. We classify
|
|
45
|
+
// this *before* the legacy-no-token branch because the user explicitly
|
|
46
|
+
// asked for that profile; the right fix is `profile use <good-name>`, not
|
|
47
|
+
// migration.
|
|
48
|
+
if (fsImpl.existsSync(ACTIVE_PROFILE_FILE)) {
|
|
49
|
+
let pointedAt = "";
|
|
50
|
+
try {
|
|
51
|
+
pointedAt = fsImpl.readFileSync(ACTIVE_PROFILE_FILE).trim();
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
pointedAt = "";
|
|
55
|
+
}
|
|
56
|
+
if (pointedAt.length > 0) {
|
|
57
|
+
const profileDir = path.join(PROFILES_DIR, pointedAt);
|
|
58
|
+
if (!fsImpl.existsSync(profileDir)) {
|
|
59
|
+
return { kind: "broken-active-profile", pointedAt };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Branch 2: legacy drift.
|
|
64
|
+
// Legacy `config.json` exists; legacy `token` doesn't; no profiles configured.
|
|
65
|
+
// "No profiles configured" = profiles dir missing OR exists-but-empty.
|
|
66
|
+
if (!fsImpl.existsSync(CONFIG_PATH)) {
|
|
67
|
+
return { kind: "no-drift" };
|
|
68
|
+
}
|
|
69
|
+
if (fsImpl.existsSync(TOKEN_PATH)) {
|
|
70
|
+
return { kind: "no-drift" };
|
|
71
|
+
}
|
|
72
|
+
const profilesEmpty = isProfilesDirEmpty(fsImpl);
|
|
73
|
+
if (!profilesEmpty) {
|
|
74
|
+
return { kind: "no-drift" };
|
|
75
|
+
}
|
|
76
|
+
return { kind: "legacy-no-token", legacyConfigPath: CONFIG_PATH };
|
|
77
|
+
}
|
|
78
|
+
function isProfilesDirEmpty(fsImpl) {
|
|
79
|
+
if (!fsImpl.existsSync(PROFILES_DIR))
|
|
80
|
+
return true;
|
|
81
|
+
try {
|
|
82
|
+
const entries = fsImpl.readdirSync(PROFILES_DIR);
|
|
83
|
+
return entries.length === 0;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Unreadable dir — treat as empty for the purposes of drift detection
|
|
87
|
+
// so we don't suppress the hint on a permissions edge case.
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Once-per-process stderr hint for legacy-config drift.
|
|
3
|
+
*
|
|
4
|
+
* Wired into the auth-failure path in `auth.ts`. When the legacy single-file
|
|
5
|
+
* config exists but no token can be found anywhere (the >=1.4 post-upgrade
|
|
6
|
+
* scenario), we emit one stderr line pointing the user at
|
|
7
|
+
* `el-linear profile migrate-legacy` *before* the regular "No API token
|
|
8
|
+
* found" error fires.
|
|
9
|
+
*
|
|
10
|
+
* Constraints:
|
|
11
|
+
*
|
|
12
|
+
* - **stderr only** — stdout is reserved for the JSON error payload that
|
|
13
|
+
* machine callers parse, and the hint must not corrupt that stream.
|
|
14
|
+
* - **Once per process** — even if a script invokes el-linear in a tight
|
|
15
|
+
* loop within a single Node process (uncommon but possible), only the
|
|
16
|
+
* first failure prints. Anything more is noise.
|
|
17
|
+
* - **Suppressible** — `EL_LINEAR_SKIP_MIGRATION_HINT=1` silences the hint
|
|
18
|
+
* for users who've decided to stay on the legacy layout intentionally.
|
|
19
|
+
* The env var is read at *emission time*, not module load, so toggling
|
|
20
|
+
* it in tests works without re-importing.
|
|
21
|
+
* - **Non-blocking** — never throws, never delays the underlying auth
|
|
22
|
+
* error. The hint is purely informational.
|
|
23
|
+
*/
|
|
24
|
+
import { type DetectionFsOps, type LegacyDriftState } from "./legacy-config-detection.js";
|
|
25
|
+
/**
|
|
26
|
+
* Test seam — resets the once-per-process latch so each test starts fresh.
|
|
27
|
+
* Production code never calls this.
|
|
28
|
+
*/
|
|
29
|
+
export declare function _resetMigrationHintForTests(): void;
|
|
30
|
+
/**
|
|
31
|
+
* Emit the migration hint to stderr if (and only if) legacy drift is
|
|
32
|
+
* detected, the latch hasn't fired this process, and the suppress env var
|
|
33
|
+
* isn't set. Returns the detection state so callers can branch (or log).
|
|
34
|
+
*
|
|
35
|
+
* Optional `fsImpl` lets tests drive detection without touching disk.
|
|
36
|
+
* Optional `stderr` lets tests capture the output without spying on the
|
|
37
|
+
* global stream.
|
|
38
|
+
*/
|
|
39
|
+
export declare function maybeEmitMigrationHint(fsImpl?: DetectionFsOps, stderr?: {
|
|
40
|
+
write: (chunk: string) => void;
|
|
41
|
+
}): LegacyDriftState;
|
|
42
|
+
/**
|
|
43
|
+
* Render the hint string for a given state. Returns null when no hint
|
|
44
|
+
* should be emitted (no-drift). Exported for unit testing.
|
|
45
|
+
*/
|
|
46
|
+
export declare function formatHint(state: LegacyDriftState): string | null;
|