@enrichlayer/el-linear 1.2.0 → 1.4.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 +74 -2
- package/dist/commands/init/index.d.ts +7 -0
- package/dist/commands/init/index.js +5 -1
- package/dist/commands/init/shared.js +30 -5
- package/dist/commands/profile.d.ts +46 -0
- package/dist/commands/profile.js +185 -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 +17 -3
- package/dist/utils/issue-reference-wrapper.d.ts +19 -5
- package/dist/utils/issue-reference-wrapper.js +33 -6
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -17,6 +17,8 @@ first-class command.
|
|
|
17
17
|
|
|
18
18
|
## Install
|
|
19
19
|
|
|
20
|
+
Published on npm as [`@enrichlayer/el-linear`](https://www.npmjs.com/package/@enrichlayer/el-linear).
|
|
21
|
+
|
|
20
22
|
```bash
|
|
21
23
|
pnpm add -g @enrichlayer/el-linear
|
|
22
24
|
# or
|
|
@@ -76,11 +78,50 @@ The API token is resolved in this order:
|
|
|
76
78
|
|
|
77
79
|
1. `--api-token <token>` flag.
|
|
78
80
|
2. `LINEAR_API_TOKEN` environment variable.
|
|
79
|
-
3. `~/.config/el-linear/token` file (
|
|
80
|
-
4. `~/.
|
|
81
|
+
3. **Active profile's** `~/.config/el-linear/profiles/<name>/token` file (see *Profiles* below).
|
|
82
|
+
4. `~/.config/el-linear/token` file (legacy single-profile, recommended for human use when only one workspace is needed).
|
|
83
|
+
5. `~/.linear_api_token` file (legacy, still honored).
|
|
81
84
|
|
|
82
85
|
el-linear never logs the token.
|
|
83
86
|
|
|
87
|
+
## Profiles
|
|
88
|
+
|
|
89
|
+
Use **profiles** to switch between multiple Linear workspaces (e.g.
|
|
90
|
+
day-job and side-project) with separate tokens + configs.
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
# Create a profile + run the init wizard scoped to it.
|
|
94
|
+
# After this finishes, <name> becomes the active profile.
|
|
95
|
+
el-linear profile add forage
|
|
96
|
+
|
|
97
|
+
# Switch the default at any time:
|
|
98
|
+
el-linear profile use day-job
|
|
99
|
+
el-linear profile current # → day-job
|
|
100
|
+
el-linear profile list # all profiles + which is active
|
|
101
|
+
|
|
102
|
+
# One-off override for a single command:
|
|
103
|
+
el-linear --profile forage issues list
|
|
104
|
+
EL_LINEAR_PROFILE=forage el-linear teams list
|
|
105
|
+
|
|
106
|
+
# Remove a profile (token + config gone; confirms first):
|
|
107
|
+
el-linear profile remove old-profile
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Each profile lives at `~/.config/el-linear/profiles/<name>/` and owns:
|
|
111
|
+
|
|
112
|
+
- `token` — its Linear API token (mode 0600)
|
|
113
|
+
- `config.json` — its full el-linear config (defaultTeam, terms, etc.)
|
|
114
|
+
|
|
115
|
+
The active profile is selected by, in priority:
|
|
116
|
+
|
|
117
|
+
1. `--profile <name>` flag (per-invocation)
|
|
118
|
+
2. `EL_LINEAR_PROFILE` env var
|
|
119
|
+
3. `~/.config/el-linear/active-profile` (one-line marker, written by `profile use`)
|
|
120
|
+
4. Legacy single-file layout (`~/.config/el-linear/{token,config.json}`)
|
|
121
|
+
|
|
122
|
+
The legacy fallback means **existing single-profile users see no
|
|
123
|
+
behavior change** — profiles are purely opt-in.
|
|
124
|
+
|
|
84
125
|
## Configuration
|
|
85
126
|
|
|
86
127
|
el-linear reads `~/.config/el-linear/config.json` on startup. All keys are
|
|
@@ -174,12 +215,43 @@ el-linear <command> --help # detailed help for one command
|
|
|
174
215
|
| Releases | `releases {list, read, create, pipelines}` |
|
|
175
216
|
| Files | `embeds {upload, download}`, `attachments {list, create, delete}` |
|
|
176
217
|
| Search | `search <query>` (semantic, cross-resource) |
|
|
218
|
+
| Refs | `refs wrap` (rewrite issue identifiers in arbitrary text as links) |
|
|
177
219
|
| Escape hatch | `graphql [query]` (with `--introspect`) |
|
|
178
220
|
| Config | `config show`, `users list`, `teams list`, `templates list` |
|
|
179
221
|
|
|
180
222
|
All `list` subcommands support `-l, --limit <n>`. All commands accept the
|
|
181
223
|
top-level filters: `--raw`, `--jq <expr>`, `--fields <list>`.
|
|
182
224
|
|
|
225
|
+
## Wrapping Linear references in arbitrary text
|
|
226
|
+
|
|
227
|
+
`el-linear refs wrap` takes plain text on stdin (or via `--file`) and rewrites
|
|
228
|
+
every recognized Linear issue identifier (e.g. `DEV-123`, `LIN-1`) as a real
|
|
229
|
+
link. By default it validates each candidate against the workspace — strings
|
|
230
|
+
that match the `[A-Z]+-\d+` shape but aren't real issues (e.g. `ISO-1424`)
|
|
231
|
+
are left untouched.
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
# stdin → stdout, markdown output (default)
|
|
235
|
+
echo "see DEV-100 and ISO-1424" | el-linear refs wrap
|
|
236
|
+
# → see [DEV-100](https://linear.app/acme/issue/DEV-100/) and ISO-1424
|
|
237
|
+
|
|
238
|
+
# read from a file
|
|
239
|
+
el-linear refs wrap --file notes.md > notes.linked.md
|
|
240
|
+
|
|
241
|
+
# Slack mrkdwn output: <url|label>
|
|
242
|
+
el-linear refs wrap --target slack < release-notes.md
|
|
243
|
+
|
|
244
|
+
# offline regex-only fallback — wraps every match, no API calls,
|
|
245
|
+
# may produce broken links for IDs that don't exist in the workspace
|
|
246
|
+
el-linear refs wrap --no-validate < notes.md
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Wrapping is **idempotent** — running it again on already-wrapped output is a
|
|
250
|
+
no-op. Refs are also skipped inside fenced code blocks, inline backticks,
|
|
251
|
+
existing markdown or Slack links, angle-bracket autolinks, and bare URLs, so
|
|
252
|
+
it's safe to pipe documents that already contain a mix of formatted links
|
|
253
|
+
and bare identifiers.
|
|
254
|
+
|
|
183
255
|
## Use with Claude Code
|
|
184
256
|
|
|
185
257
|
el-linear ships a Claude Code skill at `claude-skills/linear-operations/SKILL.md`.
|
|
@@ -16,3 +16,10 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import type { Command } from "commander";
|
|
18
18
|
export declare function setupInitCommands(program: Command): void;
|
|
19
|
+
/**
|
|
20
|
+
* Full wizard: walk through all four steps in sequence. Each step writes its
|
|
21
|
+
* own slice of the config and is restartable on its own.
|
|
22
|
+
*/
|
|
23
|
+
export declare function runFullWizard(options?: {
|
|
24
|
+
force?: boolean;
|
|
25
|
+
}): Promise<void>;
|
|
@@ -129,7 +129,11 @@ export function setupInitCommands(program) {
|
|
|
129
129
|
* Full wizard: walk through all four steps in sequence. Each step writes its
|
|
130
130
|
* own slice of the config and is restartable on its own.
|
|
131
131
|
*/
|
|
132
|
-
async function runFullWizard(options) {
|
|
132
|
+
export async function runFullWizard(options = {}) {
|
|
133
|
+
const opts = { force: options.force ?? false };
|
|
134
|
+
await runFullWizardImpl(opts);
|
|
135
|
+
}
|
|
136
|
+
async function runFullWizardImpl(options) {
|
|
133
137
|
// biome-ignore lint/suspicious/noConsole: wizard
|
|
134
138
|
console.log("Welcome to el-linear. This wizard will set up your config.");
|
|
135
139
|
// biome-ignore lint/suspicious/noConsole: wizard
|
|
@@ -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,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `el-linear profile` — manage named profiles.
|
|
3
|
+
*
|
|
4
|
+
* A profile is a named directory under `~/.config/el-linear/profiles/`
|
|
5
|
+
* that holds its own `token` + `config.json`. Profiles let one user
|
|
6
|
+
* keep multiple Linear workspaces (e.g. day-job + side-project) on the
|
|
7
|
+
* same machine without juggling tokens.
|
|
8
|
+
*
|
|
9
|
+
* Subcommands:
|
|
10
|
+
*
|
|
11
|
+
* el-linear profile list — show all profiles + which is active
|
|
12
|
+
* el-linear profile current — print the active profile name (or `<default>`)
|
|
13
|
+
* el-linear profile use <name> — make <name> the default profile
|
|
14
|
+
* el-linear profile add <name> — create a profile + run init for it
|
|
15
|
+
* el-linear profile remove <name> — delete the profile dir (with confirmation)
|
|
16
|
+
*
|
|
17
|
+
* The `--profile <name>` flag (top-level, see main.ts) overrides the
|
|
18
|
+
* active profile for one invocation only.
|
|
19
|
+
*
|
|
20
|
+
* Backward-compat: when no profile is configured, every read still
|
|
21
|
+
* falls back to the legacy single-file paths (CONFIG_PATH / TOKEN_PATH).
|
|
22
|
+
*/
|
|
23
|
+
import type { Command } from "commander";
|
|
24
|
+
export declare function setupProfileCommands(program: Command): void;
|
|
25
|
+
export interface ProfileListEntry {
|
|
26
|
+
name: string;
|
|
27
|
+
active: boolean;
|
|
28
|
+
hasToken: boolean;
|
|
29
|
+
hasConfig: boolean;
|
|
30
|
+
configPath: string;
|
|
31
|
+
tokenPath: string;
|
|
32
|
+
}
|
|
33
|
+
export interface ProfileListReport {
|
|
34
|
+
activeName: string | null;
|
|
35
|
+
defaultPaths: {
|
|
36
|
+
configPath: string;
|
|
37
|
+
tokenPath: string;
|
|
38
|
+
};
|
|
39
|
+
hasLegacyToken: boolean;
|
|
40
|
+
hasLegacyConfig: boolean;
|
|
41
|
+
profiles: ProfileListEntry[];
|
|
42
|
+
}
|
|
43
|
+
export declare function runProfileList(): Promise<ProfileListReport>;
|
|
44
|
+
export declare function runProfileUse(name: string): Promise<void>;
|
|
45
|
+
export declare function runProfileAdd(name: string): Promise<void>;
|
|
46
|
+
export declare function runProfileRemove(name: string, force: boolean): Promise<void>;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `el-linear profile` — manage named profiles.
|
|
3
|
+
*
|
|
4
|
+
* A profile is a named directory under `~/.config/el-linear/profiles/`
|
|
5
|
+
* that holds its own `token` + `config.json`. Profiles let one user
|
|
6
|
+
* keep multiple Linear workspaces (e.g. day-job + side-project) on the
|
|
7
|
+
* same machine without juggling tokens.
|
|
8
|
+
*
|
|
9
|
+
* Subcommands:
|
|
10
|
+
*
|
|
11
|
+
* el-linear profile list — show all profiles + which is active
|
|
12
|
+
* el-linear profile current — print the active profile name (or `<default>`)
|
|
13
|
+
* el-linear profile use <name> — make <name> the default profile
|
|
14
|
+
* el-linear profile add <name> — create a profile + run init for it
|
|
15
|
+
* el-linear profile remove <name> — delete the profile dir (with confirmation)
|
|
16
|
+
*
|
|
17
|
+
* The `--profile <name>` flag (top-level, see main.ts) overrides the
|
|
18
|
+
* active profile for one invocation only.
|
|
19
|
+
*
|
|
20
|
+
* Backward-compat: when no profile is configured, every read still
|
|
21
|
+
* falls back to the legacy single-file paths (CONFIG_PATH / TOKEN_PATH).
|
|
22
|
+
*/
|
|
23
|
+
import { promises as fsp } from "node:fs";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import { confirm } from "@inquirer/prompts";
|
|
26
|
+
import { ACTIVE_PROFILE_FILE, CONFIG_DIR, CONFIG_PATH, PROFILES_DIR, profilePaths, resolveActiveProfile, setActiveProfileForSession, TOKEN_PATH, } from "../config/paths.js";
|
|
27
|
+
import { outputSuccess, outputWarning } from "../utils/output.js";
|
|
28
|
+
import { runFullWizard } from "./init/index.js";
|
|
29
|
+
export function setupProfileCommands(program) {
|
|
30
|
+
const profile = program
|
|
31
|
+
.command("profile")
|
|
32
|
+
.description("Manage el-linear profiles (named workspaces with separate tokens + configs).");
|
|
33
|
+
profile.action(() => profile.help());
|
|
34
|
+
profile
|
|
35
|
+
.command("list")
|
|
36
|
+
.description("List configured profiles + which one is active.")
|
|
37
|
+
.action(async () => {
|
|
38
|
+
const data = await runProfileList();
|
|
39
|
+
outputSuccess({ data });
|
|
40
|
+
});
|
|
41
|
+
profile
|
|
42
|
+
.command("current")
|
|
43
|
+
.description("Print the active profile name (or `<default>` for the legacy single-profile setup).")
|
|
44
|
+
.action(() => {
|
|
45
|
+
const active = resolveActiveProfile();
|
|
46
|
+
outputSuccess({
|
|
47
|
+
data: {
|
|
48
|
+
name: active.name ?? "<default>",
|
|
49
|
+
configPath: active.configPath,
|
|
50
|
+
tokenPath: active.tokenPath,
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
profile
|
|
55
|
+
.command("use <name>")
|
|
56
|
+
.description("Make <name> the active profile (writes ~/.config/el-linear/active-profile).")
|
|
57
|
+
.action(async (name) => {
|
|
58
|
+
await runProfileUse(name);
|
|
59
|
+
outputSuccess({ data: { name, activeProfileFile: ACTIVE_PROFILE_FILE } });
|
|
60
|
+
});
|
|
61
|
+
profile
|
|
62
|
+
.command("add <name>")
|
|
63
|
+
.description("Create a new profile named <name> + run the init wizard scoped to it. After this finishes, <name> becomes the active profile.")
|
|
64
|
+
.action(async (name) => {
|
|
65
|
+
await runProfileAdd(name);
|
|
66
|
+
});
|
|
67
|
+
profile
|
|
68
|
+
.command("remove <name>")
|
|
69
|
+
.alias("rm")
|
|
70
|
+
.description("Delete the profile directory + its token (with confirmation).")
|
|
71
|
+
.option("--force", "skip the confirmation prompt")
|
|
72
|
+
.action(async (name, opts) => {
|
|
73
|
+
await runProfileRemove(name, opts.force === true);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
export async function runProfileList() {
|
|
77
|
+
const active = resolveActiveProfile();
|
|
78
|
+
const profiles = [];
|
|
79
|
+
let entries = [];
|
|
80
|
+
try {
|
|
81
|
+
entries = (await fsp.readdir(PROFILES_DIR, { withFileTypes: true }))
|
|
82
|
+
.filter((d) => d.isDirectory())
|
|
83
|
+
.map((d) => d.name)
|
|
84
|
+
.sort();
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
if (err.code !== "ENOENT")
|
|
88
|
+
throw err;
|
|
89
|
+
}
|
|
90
|
+
for (const name of entries) {
|
|
91
|
+
const paths = profilePaths(name);
|
|
92
|
+
profiles.push({
|
|
93
|
+
name,
|
|
94
|
+
active: active.name === name,
|
|
95
|
+
hasToken: await pathExists(paths.tokenPath),
|
|
96
|
+
hasConfig: await pathExists(paths.configPath),
|
|
97
|
+
configPath: paths.configPath,
|
|
98
|
+
tokenPath: paths.tokenPath,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
activeName: active.name,
|
|
103
|
+
defaultPaths: { configPath: CONFIG_PATH, tokenPath: TOKEN_PATH },
|
|
104
|
+
hasLegacyToken: await pathExists(TOKEN_PATH),
|
|
105
|
+
hasLegacyConfig: await pathExists(CONFIG_PATH),
|
|
106
|
+
profiles,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
export async function runProfileUse(name) {
|
|
110
|
+
const trimmed = name.trim();
|
|
111
|
+
if (!trimmed)
|
|
112
|
+
throw new Error("Profile name must be non-empty.");
|
|
113
|
+
if (!isSafeName(trimmed)) {
|
|
114
|
+
throw new Error(`Profile name "${trimmed}" must contain only [a-z0-9_-]. Pick a different name.`);
|
|
115
|
+
}
|
|
116
|
+
await fsp.mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
117
|
+
await fsp.writeFile(ACTIVE_PROFILE_FILE, `${trimmed}\n`, { mode: 0o644 });
|
|
118
|
+
}
|
|
119
|
+
export async function runProfileAdd(name) {
|
|
120
|
+
const trimmed = name.trim();
|
|
121
|
+
if (!trimmed)
|
|
122
|
+
throw new Error("Profile name must be non-empty.");
|
|
123
|
+
if (!isSafeName(trimmed)) {
|
|
124
|
+
throw new Error(`Profile name "${trimmed}" must contain only [a-z0-9_-]. Pick a different name.`);
|
|
125
|
+
}
|
|
126
|
+
const dir = path.dirname(profilePaths(trimmed).configPath);
|
|
127
|
+
await fsp.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
128
|
+
// Activate the profile for the rest of THIS process so the wizard's
|
|
129
|
+
// readConfig/writeConfig/readToken/writeToken IO targets the new
|
|
130
|
+
// profile's directory (not the legacy single-file path).
|
|
131
|
+
setActiveProfileForSession(trimmed);
|
|
132
|
+
// Persist activation: write `active-profile` so subsequent invocations
|
|
133
|
+
// stay on the new profile until `profile use <other>` switches away.
|
|
134
|
+
await runProfileUse(trimmed);
|
|
135
|
+
outputWarning(`Created profile "${trimmed}" at ${dir}. Running init wizard scoped to this profile…`);
|
|
136
|
+
await runFullWizard();
|
|
137
|
+
}
|
|
138
|
+
export async function runProfileRemove(name, force) {
|
|
139
|
+
const trimmed = name.trim();
|
|
140
|
+
if (!trimmed || !isSafeName(trimmed)) {
|
|
141
|
+
throw new Error(`Invalid profile name "${name}".`);
|
|
142
|
+
}
|
|
143
|
+
const paths = profilePaths(trimmed);
|
|
144
|
+
const dir = path.dirname(paths.configPath);
|
|
145
|
+
if (!(await pathExists(dir))) {
|
|
146
|
+
throw new Error(`Profile "${trimmed}" not found at ${dir}.`);
|
|
147
|
+
}
|
|
148
|
+
if (!force) {
|
|
149
|
+
const confirmed = await confirm({
|
|
150
|
+
message: `Delete profile "${trimmed}" (${dir})? Token + config will be lost.`,
|
|
151
|
+
default: false,
|
|
152
|
+
});
|
|
153
|
+
if (!confirmed) {
|
|
154
|
+
outputWarning("Aborted.");
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
await fsp.rm(dir, { recursive: true, force: true });
|
|
159
|
+
// If the just-removed profile was the active one, clear the marker
|
|
160
|
+
// so subsequent invocations fall back to the default paths.
|
|
161
|
+
const active = resolveActiveProfile();
|
|
162
|
+
if (active.name === trimmed && (await pathExists(ACTIVE_PROFILE_FILE))) {
|
|
163
|
+
await fsp.rm(ACTIVE_PROFILE_FILE, { force: true });
|
|
164
|
+
}
|
|
165
|
+
outputSuccess({ data: { removed: trimmed, dir } });
|
|
166
|
+
}
|
|
167
|
+
// ---- Helpers ------------------------------------------------------------
|
|
168
|
+
async function pathExists(p) {
|
|
169
|
+
try {
|
|
170
|
+
await fsp.access(p);
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Profile names land in filesystem paths AND get written to a
|
|
179
|
+
* single-line marker. Restrict to a conservative charset so we can't
|
|
180
|
+
* accidentally pick up `..` traversal, command-line metacharacters, or
|
|
181
|
+
* Unicode lookalikes that would confuse `profile use`.
|
|
182
|
+
*/
|
|
183
|
+
function isSafeName(name) {
|
|
184
|
+
return /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(name);
|
|
185
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { type WrapTarget } from "../utils/issue-reference-wrapper.js";
|
|
3
|
+
interface WrapDeps {
|
|
4
|
+
resolveValidIdentifiers: (ids: readonly string[]) => Promise<Set<string>>;
|
|
5
|
+
resolveUrlKey: () => Promise<string>;
|
|
6
|
+
}
|
|
7
|
+
interface WrapInput {
|
|
8
|
+
text: string;
|
|
9
|
+
target: WrapTarget;
|
|
10
|
+
validate: boolean;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Pure-ish core of `refs wrap` — split out so tests can drive it directly with
|
|
14
|
+
* a stubbed `WrapDeps`, no commander parsing or stdin/fs IO.
|
|
15
|
+
*/
|
|
16
|
+
export declare function wrapRefsCore(input: WrapInput, deps: WrapDeps): Promise<string>;
|
|
17
|
+
export declare function setupRefsCommands(program: Command): void;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { createGraphQLService } from "../utils/graphql-service.js";
|
|
3
|
+
import { extractIssueReferences } from "../utils/issue-reference-extractor.js";
|
|
4
|
+
import { wrapIssueReferencesAsLinks, } from "../utils/issue-reference-wrapper.js";
|
|
5
|
+
import { createLinearService } from "../utils/linear-service.js";
|
|
6
|
+
import { handleAsyncCommand } from "../utils/output.js";
|
|
7
|
+
import { validateReferences } from "../utils/validate-references.js";
|
|
8
|
+
import { getWorkspaceUrlKey } from "../utils/workspace-url.js";
|
|
9
|
+
const VALID_TARGETS = new Set(["markdown", "slack"]);
|
|
10
|
+
function isWrapTarget(value) {
|
|
11
|
+
return VALID_TARGETS.has(value);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Read all input bytes from a Readable stream as a UTF-8 string. Used to slurp
|
|
15
|
+
* stdin when the user pipes content (`el-linear refs wrap < input.md`).
|
|
16
|
+
*/
|
|
17
|
+
async function readAllStdin() {
|
|
18
|
+
if (process.stdin.isTTY) {
|
|
19
|
+
throw new Error("No input provided. Pipe text to stdin or pass --file <path>.");
|
|
20
|
+
}
|
|
21
|
+
const chunks = [];
|
|
22
|
+
for await (const chunk of process.stdin) {
|
|
23
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
24
|
+
}
|
|
25
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Pure-ish core of `refs wrap` — split out so tests can drive it directly with
|
|
29
|
+
* a stubbed `WrapDeps`, no commander parsing or stdin/fs IO.
|
|
30
|
+
*/
|
|
31
|
+
export async function wrapRefsCore(input, deps) {
|
|
32
|
+
const refs = extractIssueReferences(input.text);
|
|
33
|
+
if (refs.length === 0) {
|
|
34
|
+
return input.text;
|
|
35
|
+
}
|
|
36
|
+
const candidateIds = refs.map((r) => r.identifier);
|
|
37
|
+
let validIds;
|
|
38
|
+
if (input.validate) {
|
|
39
|
+
validIds = await deps.resolveValidIdentifiers(candidateIds);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
// --no-validate: trust every regex match, skip the API.
|
|
43
|
+
validIds = new Set(candidateIds);
|
|
44
|
+
}
|
|
45
|
+
if (validIds.size === 0) {
|
|
46
|
+
return input.text;
|
|
47
|
+
}
|
|
48
|
+
const urlKey = await deps.resolveUrlKey();
|
|
49
|
+
return wrapIssueReferencesAsLinks(input.text, validIds, urlKey, input.target);
|
|
50
|
+
}
|
|
51
|
+
async function handleWrap(options, command) {
|
|
52
|
+
const target = options.target ?? "markdown";
|
|
53
|
+
if (!isWrapTarget(target)) {
|
|
54
|
+
throw new Error(`Invalid --target "${target}". Expected one of: ${[...VALID_TARGETS].join(", ")}`);
|
|
55
|
+
}
|
|
56
|
+
// `validate` is `true` by default and `false` when `--no-validate` is passed.
|
|
57
|
+
const validate = options.validate !== false;
|
|
58
|
+
const text = typeof options.file === "string" && options.file.length > 0
|
|
59
|
+
? readFileSync(options.file, "utf8")
|
|
60
|
+
: await readAllStdin();
|
|
61
|
+
const rootOpts = command.parent.parent.opts();
|
|
62
|
+
const deps = {
|
|
63
|
+
async resolveValidIdentifiers(ids) {
|
|
64
|
+
const linearService = createLinearService(rootOpts);
|
|
65
|
+
const map = await validateReferences(ids, linearService);
|
|
66
|
+
return new Set(map.keys());
|
|
67
|
+
},
|
|
68
|
+
async resolveUrlKey() {
|
|
69
|
+
const graphQLService = createGraphQLService(rootOpts);
|
|
70
|
+
return getWorkspaceUrlKey(graphQLService);
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
if (!validate) {
|
|
74
|
+
// stderr advisory only — keeps stdout a clean text stream for piping.
|
|
75
|
+
process.stderr.write("el-linear refs wrap: --no-validate set; emitting links for every regex match without checking the workspace.\n");
|
|
76
|
+
}
|
|
77
|
+
const wrapped = await wrapRefsCore({ text, target, validate }, deps);
|
|
78
|
+
process.stdout.write(wrapped);
|
|
79
|
+
}
|
|
80
|
+
export function setupRefsCommands(program) {
|
|
81
|
+
const refs = program
|
|
82
|
+
.command("refs")
|
|
83
|
+
.description("Operations on Linear issue references found in arbitrary text.");
|
|
84
|
+
refs.action(() => refs.help());
|
|
85
|
+
refs
|
|
86
|
+
.command("wrap")
|
|
87
|
+
.description("Wrap recognized Linear issue identifiers in input text as links. " +
|
|
88
|
+
"Reads from stdin (or --file) and writes to stdout. By default, " +
|
|
89
|
+
"each candidate identifier is validated against the workspace; " +
|
|
90
|
+
"unresolvable ones are left as plain text.")
|
|
91
|
+
.option("--file <path>", "read input from a file instead of stdin")
|
|
92
|
+
.option("--target <target>", "output format: markdown (default) or slack", "markdown")
|
|
93
|
+
.option("--no-validate", "skip workspace validation; wrap every regex match. Faster, but may produce broken links for IDs that don't exist.")
|
|
94
|
+
.action(handleAsyncCommand(handleWrap));
|
|
95
|
+
}
|
package/dist/config/config.d.ts
CHANGED
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.3.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,5 @@
|
|
|
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
3
|
export function getApiToken(options) {
|
|
4
4
|
if (options.apiToken) {
|
|
5
5
|
return options.apiToken;
|
|
@@ -7,7 +7,18 @@ export function getApiToken(options) {
|
|
|
7
7
|
if (process.env.LINEAR_API_TOKEN) {
|
|
8
8
|
return process.env.LINEAR_API_TOKEN;
|
|
9
9
|
}
|
|
10
|
-
|
|
10
|
+
// Profile-aware: read from <CONFIG_DIR>/profiles/<name>/token when a
|
|
11
|
+
// profile is active, falling back to the legacy single-file path
|
|
12
|
+
// (TOKEN_PATH) for the no-profile case.
|
|
13
|
+
const active = resolveActiveProfile();
|
|
14
|
+
if (fs.existsSync(active.tokenPath)) {
|
|
15
|
+
return fs.readFileSync(active.tokenPath, "utf8").trim();
|
|
16
|
+
}
|
|
17
|
+
// Even when a profile is selected, fall through to the legacy token
|
|
18
|
+
// path so an operator who only has the single-file layout doesn't
|
|
19
|
+
// suddenly fail. The active-profile name is informational only when
|
|
20
|
+
// no profile-scoped token exists yet.
|
|
21
|
+
if (active.tokenPath !== TOKEN_PATH && fs.existsSync(TOKEN_PATH)) {
|
|
11
22
|
return fs.readFileSync(TOKEN_PATH, "utf8").trim();
|
|
12
23
|
}
|
|
13
24
|
// Fallback to the linctl-era token (the CLI was briefly published as
|
|
@@ -19,5 +30,8 @@ export function getApiToken(options) {
|
|
|
19
30
|
if (fs.existsSync(LEGACY_TOKEN_PATH)) {
|
|
20
31
|
return fs.readFileSync(LEGACY_TOKEN_PATH, "utf8").trim();
|
|
21
32
|
}
|
|
22
|
-
|
|
33
|
+
const profileNote = active.name
|
|
34
|
+
? ` (active profile: \`${active.name}\` — expected token at ${active.tokenPath})`
|
|
35
|
+
: "";
|
|
36
|
+
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
37
|
}
|
|
@@ -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);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enrichlayer/el-linear",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.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",
|
|
@@ -52,11 +52,11 @@
|
|
|
52
52
|
"picocolors": "^1.1.1"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
|
-
"@biomejs/biome": "^2.4.
|
|
55
|
+
"@biomejs/biome": "^2.4.14",
|
|
56
56
|
"@types/node": "^25.0.0",
|
|
57
57
|
"graphql": "^16.0.0",
|
|
58
|
-
"tsx": "^4.
|
|
59
|
-
"typescript": "^
|
|
58
|
+
"tsx": "^4.21.0",
|
|
59
|
+
"typescript": "^6.0.3",
|
|
60
60
|
"vitest": "^4.0.18"
|
|
61
61
|
},
|
|
62
62
|
"scripts": {
|