@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
@@ -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
- if (fs.existsSync(CONFIG_PATH)) {
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(CONFIG_PATH, "utf8"));
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 ${CONFIG_PATH}, using empty defaults`);
67
+ outputWarning(`Failed to parse ${sourcePath}, using empty defaults`);
56
68
  cachedConfig = DEFAULT_CONFIG;
57
69
  }
58
70
  }
59
71
  else {
60
- outputWarning(`No config found at ${CONFIG_PATH}. Run with --init to create one.`);
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;
@@ -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;
@@ -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.2.0")
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")
@@ -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
- if (fs.existsSync(TOKEN_PATH)) {
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
- throw new Error("No API token found. Use --api-token, LINEAR_API_TOKEN env var, ~/.config/el-linear/token, or ~/.linear_api_token file");
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(apiToken: string);
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(apiToken) {
6
- const client = new LinearClient({
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 markdown link. Skips any occurrence inside protected ranges (code blocks,
4
- * inline code, existing markdown links, angle-bracket autolinks).
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, that occurrence is left
10
- * alone (it's inside a protected range), so this function is idempotent.
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 markdown link. Skips any occurrence inside protected ranges (code blocks,
57
- * inline code, existing markdown links, angle-bracket autolinks).
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, that occurrence is left
63
- * alone (it's inside a protected range), so this function is idempotent.
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 += `[${id}](${buildIssueUrl(id, workspaceUrlKey)})`;
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;