@enrichlayer/el-linear 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,6 +26,7 @@ import { confirm } from "@inquirer/prompts";
26
26
  import { ACTIVE_PROFILE_FILE, CONFIG_DIR, CONFIG_PATH, PROFILES_DIR, profilePaths, resolveActiveProfile, setActiveProfileForSession, TOKEN_PATH, } from "../config/paths.js";
27
27
  import { outputSuccess, outputWarning } from "../utils/output.js";
28
28
  import { runFullWizard } from "./init/index.js";
29
+ import { registerMigrateLegacy } from "./profile/migrate-legacy.js";
29
30
  export function setupProfileCommands(program) {
30
31
  const profile = program
31
32
  .command("profile")
@@ -72,6 +73,11 @@ export function setupProfileCommands(program) {
72
73
  .action(async (name, opts) => {
73
74
  await runProfileRemove(name, opts.force === true);
74
75
  });
76
+ // `el-linear profile migrate-legacy` — registered alongside add/list/etc.
77
+ // Lives in its own module so the multi-step migration logic stays
78
+ // self-contained and unit-testable without dragging in the full
79
+ // profile-management surface.
80
+ registerMigrateLegacy(profile);
75
81
  }
76
82
  export async function runProfileList() {
77
83
  const active = resolveActiveProfile();
package/dist/main.js CHANGED
@@ -30,7 +30,7 @@ import { splitList } from "./utils/validators.js";
30
30
  program
31
31
  .name("el-linear")
32
32
  .description("A pragmatic CLI for Linear.app — deterministic resolution, structured validation, GraphQL escape hatch.")
33
- .version("1.3.0")
33
+ .version("1.5.0")
34
34
  .option("--api-token <token>", "Linear API token")
35
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.")
36
36
  .option("--json", "output as JSON (default, accepted for compatibility)")
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
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;
@@ -30,6 +31,13 @@ export function getApiToken(options) {
30
31
  if (fs.existsSync(LEGACY_TOKEN_PATH)) {
31
32
  return fs.readFileSync(LEGACY_TOKEN_PATH, "utf8").trim();
32
33
  }
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();
33
41
  const profileNote = active.name
34
42
  ? ` (active profile: \`${active.name}\` — expected token at ${active.tokenPath})`
35
43
  : "";
@@ -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
  }
@@ -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;
@@ -0,0 +1,90 @@
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 { detectLegacyDrift, } from "./legacy-config-detection.js";
25
+ let hintAlreadyEmitted = false;
26
+ /**
27
+ * Test seam — resets the once-per-process latch so each test starts fresh.
28
+ * Production code never calls this.
29
+ */
30
+ export function _resetMigrationHintForTests() {
31
+ hintAlreadyEmitted = false;
32
+ }
33
+ /**
34
+ * Emit the migration hint to stderr if (and only if) legacy drift is
35
+ * detected, the latch hasn't fired this process, and the suppress env var
36
+ * isn't set. Returns the detection state so callers can branch (or log).
37
+ *
38
+ * Optional `fsImpl` lets tests drive detection without touching disk.
39
+ * Optional `stderr` lets tests capture the output without spying on the
40
+ * global stream.
41
+ */
42
+ export function maybeEmitMigrationHint(fsImpl, stderr = process.stderr) {
43
+ const state = detectLegacyDrift(fsImpl);
44
+ // Read the env var at call time — the suppress flag may be flipped between
45
+ // commands in long-lived test processes.
46
+ if (process.env.EL_LINEAR_SKIP_MIGRATION_HINT === "1") {
47
+ return state;
48
+ }
49
+ if (hintAlreadyEmitted) {
50
+ return state;
51
+ }
52
+ const message = formatHint(state);
53
+ if (message === null) {
54
+ return state;
55
+ }
56
+ hintAlreadyEmitted = true;
57
+ stderr.write(`${message}\n`);
58
+ return state;
59
+ }
60
+ /**
61
+ * Render the hint string for a given state. Returns null when no hint
62
+ * should be emitted (no-drift). Exported for unit testing.
63
+ */
64
+ export function formatHint(state) {
65
+ if (state.kind === "no-drift")
66
+ return null;
67
+ if (state.kind === "broken-active-profile") {
68
+ return [
69
+ `el-linear: active profile "${state.pointedAt}" doesn't exist.`,
70
+ "Switch with:",
71
+ "",
72
+ " el-linear profile use <name>",
73
+ "",
74
+ "Or list available profiles:",
75
+ "",
76
+ " el-linear profile list",
77
+ "",
78
+ "Or suppress this hint with EL_LINEAR_SKIP_MIGRATION_HINT=1.",
79
+ ].join("\n");
80
+ }
81
+ // legacy-no-token
82
+ return [
83
+ `el-linear: legacy config detected at ${state.legacyConfigPath}`,
84
+ "but no token. Migrate with:",
85
+ "",
86
+ " el-linear profile migrate-legacy [--name <profile>]",
87
+ "",
88
+ "Or suppress this hint with EL_LINEAR_SKIP_MIGRATION_HINT=1.",
89
+ ].join("\n");
90
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enrichlayer/el-linear",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "A pragmatic CLI for Linear.app — deterministic team/label/member resolution, structured issue validation, configurable term enforcement, and a GraphQL escape hatch.",
5
5
  "main": "dist/main.js",
6
6
  "types": "dist/main.d.ts",