@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
@@ -0,0 +1,272 @@
1
+ /**
2
+ * `el-linear profile migrate-legacy` — one-shot migration from the legacy
3
+ * single-file config layout (`~/.config/el-linear/{token,config.json}`) to
4
+ * the named-profiles layout introduced in 1.4 (`~/.config/el-linear/
5
+ * profiles/<name>/{token,config.json}`).
6
+ *
7
+ * Why this command exists:
8
+ *
9
+ * When a user upgraded el-linear to >=1.4, their existing single-file
10
+ * `config.json` was preserved verbatim, but the legacy `token` slot was
11
+ * sometimes cleared (depending on how the upgrade was performed) and
12
+ * 1.4 expects per-profile tokens. Result: every command failed with
13
+ * "Authentication required" while the rich legacy config (member
14
+ * aliases, brand rules, default labels) sat right there on disk with
15
+ * no documented migration path.
16
+ *
17
+ * Design constraints:
18
+ *
19
+ * - **Each step is independently idempotent.** Re-running the command
20
+ * after a successful migration is a no-op — config + token files match,
21
+ * active-profile already points at the right name. Re-running after a
22
+ * partial failure picks up where it left off without `--force`.
23
+ *
24
+ * - **Validate before writing.** A token that doesn't pass `viewer { ... }`
25
+ * never lands on disk. The validate-then-write order means an interrupted
26
+ * migration can't leave a dud token in a freshly-created profile dir.
27
+ *
28
+ * - **Legacy preservation.** We never delete the legacy `config.json` or
29
+ * `token` — the user gets a rollback path. A one-line stdout hint says
30
+ * so explicitly.
31
+ *
32
+ * - **`--force` is opt-in destruction.** When the destination profile
33
+ * already has a config.json or token that *differs* from the source,
34
+ * the command refuses by default with a clear diff hint. `--force`
35
+ * overwrites; `--yes` skips the interactive confirm. Both are
36
+ * required-together for unattended (CI / scripted) overwrites.
37
+ */
38
+ import { promises as fsp } from "node:fs";
39
+ import path from "node:path";
40
+ import { confirm, input, password } from "@inquirer/prompts";
41
+ import { ACTIVE_PROFILE_FILE, CONFIG_DIR, CONFIG_PATH, PROFILES_DIR, profilePaths, TOKEN_PATH, } from "../../config/paths.js";
42
+ import { sanitizeForLog, validateToken } from "../init/token.js";
43
+ const DEFAULT_PROMPTS = { input, password, confirm };
44
+ /** Register `el-linear profile migrate-legacy` under the parent `profile` command. */
45
+ export function registerMigrateLegacy(profile) {
46
+ profile
47
+ .command("migrate-legacy")
48
+ .description("Copy the legacy ~/.config/el-linear/{config.json,token} into a named profile so >=1.4 commands work again.")
49
+ .option("--name <name>", "Target profile name. Defaults to `default`.", "default")
50
+ .option("--token-from <path>", "Read the API token from this file instead of prompting.")
51
+ .option("--force", "Overwrite an existing per-profile config.json or token even when contents differ.")
52
+ .option("--yes", "Skip interactive confirmations (still respects --force semantics).")
53
+ .action(async (opts) => {
54
+ await runMigrateLegacy({
55
+ name: opts.name,
56
+ tokenFrom: opts.tokenFrom,
57
+ force: opts.force === true,
58
+ yes: opts.yes === true,
59
+ });
60
+ });
61
+ }
62
+ /**
63
+ * Top-level orchestrator. Each helper below is independently idempotent;
64
+ * this function just sequences them and prints the final ✓ banner.
65
+ *
66
+ * Exit semantics:
67
+ *
68
+ * - Missing legacy config → `process.exit(1)` (user error, nothing to do).
69
+ * - Refused overwrite (no --force) → throws — handled by handleAsyncCommand
70
+ * elsewhere in the CLI and surfaces as a structured JSON error on stdout.
71
+ * - Token validation failure → throws (no on-disk change has happened yet).
72
+ */
73
+ export async function runMigrateLegacy(options, deps = {}) {
74
+ const stdout = deps.stdout ?? process.stdout;
75
+ const prompts = deps.prompts ?? DEFAULT_PROMPTS;
76
+ const validate = deps.validateToken ?? validateToken;
77
+ // 1. Pre-flight: legacy config must exist. There's nothing to migrate
78
+ // on a freshly-onboarded machine and we don't want to silently
79
+ // create an empty profile.
80
+ if (!(await pathExists(CONFIG_PATH))) {
81
+ stdout.write(`Nothing to migrate — no legacy config found at ${CONFIG_PATH}.\n`);
82
+ stdout.write("If this is a fresh install, run `el-linear init` instead.\n");
83
+ process.exit(1);
84
+ }
85
+ const initialName = (options.name ?? "default").trim() || "default";
86
+ let name = initialName;
87
+ // Allow an interactive override of the target name unless --yes was passed
88
+ // (scripted) or --name was explicitly set to something other than the
89
+ // default. Keeps backward compat with the CLI flag while not surprising
90
+ // CI runs.
91
+ if (!options.skipPrompt && !options.yes && options.name === undefined) {
92
+ const answer = await prompts.input({
93
+ message: "Target profile name:",
94
+ default: name,
95
+ });
96
+ const trimmed = answer.trim();
97
+ if (trimmed.length > 0)
98
+ name = trimmed;
99
+ }
100
+ if (!isSafeName(name)) {
101
+ throw new Error(`Invalid profile name "${name}". Allowed: [a-z0-9_.-], up to 64 chars.`);
102
+ }
103
+ // 2. Token source priority: --token-from > EL_LINEAR_TOKEN > prompt.
104
+ // Validate before writing anything to disk.
105
+ const token = await resolveAndValidateToken(options, prompts, validate);
106
+ // 3. Profile dir.
107
+ const paths = profilePaths(name);
108
+ const profileDir = path.dirname(paths.configPath);
109
+ await fsp.mkdir(profileDir, { recursive: true, mode: 0o700 });
110
+ // 4. Config copy with idempotent + force semantics.
111
+ await copyConfigIntoProfile(paths.configPath, options, prompts);
112
+ // 5. Token write with idempotent + force semantics.
113
+ await writeProfileToken(paths.tokenPath, token, options, prompts);
114
+ // 6. active-profile marker.
115
+ await ensureActiveProfile(name);
116
+ // 7. Legacy preservation hint. We do NOT delete the legacy paths —
117
+ // the user gets a rollback if anything went sideways.
118
+ stdout.write(`legacy ${CONFIG_PATH} kept for rollback; safe to remove later if no longer needed\n`);
119
+ // 8. Final verify against the freshly-written profile token. This is
120
+ // a defensive double-check: the token was already validated above,
121
+ // but verifying *after* the write catches any FS-level surprise
122
+ // (e.g. wrong token landed in the wrong dir on a multi-profile box).
123
+ const onDiskToken = (await fsp.readFile(paths.tokenPath, "utf8")).trim();
124
+ const viewer = await validate(onDiskToken);
125
+ stdout.write(`✓ Migrated. Active profile: ${name}. Workspace: ${viewer.organization.urlKey}\n`);
126
+ }
127
+ // ---- Helpers --------------------------------------------------------
128
+ async function resolveAndValidateToken(options, prompts, validate) {
129
+ if (options.tokenFrom) {
130
+ const raw = await fsp.readFile(options.tokenFrom, "utf8");
131
+ const token = raw.trim();
132
+ if (!token) {
133
+ throw new Error(`Token file ${options.tokenFrom} is empty.`);
134
+ }
135
+ await validate(token);
136
+ return token;
137
+ }
138
+ const envToken = process.env.EL_LINEAR_TOKEN?.trim();
139
+ if (envToken) {
140
+ await validate(envToken);
141
+ return envToken;
142
+ }
143
+ // Interactive: up to three attempts, hidden input. We re-prompt on
144
+ // validation failure rather than aborting so the user can paste a
145
+ // fresh token without re-running the whole command.
146
+ for (let attempt = 0; attempt < 3; attempt++) {
147
+ const candidate = (await prompts.password({
148
+ message: attempt === 0
149
+ ? "Linear API token (input hidden):"
150
+ : "Try again (input hidden):",
151
+ mask: "*",
152
+ validate: (s) => s.trim().length > 0 || "Token cannot be empty",
153
+ })).trim();
154
+ try {
155
+ await validate(candidate);
156
+ return candidate;
157
+ }
158
+ catch (err) {
159
+ const raw = err instanceof Error ? err.message : String(err);
160
+ // biome-ignore lint/suspicious/noConsole: interactive prompt feedback
161
+ console.log(` ✗ ${sanitizeForLog(raw)}`);
162
+ }
163
+ }
164
+ throw new Error("Could not validate a Linear API token after 3 attempts. Aborting migration.");
165
+ }
166
+ async function copyConfigIntoProfile(destConfigPath, options, prompts) {
167
+ const sourceContent = await fsp.readFile(CONFIG_PATH, "utf8");
168
+ if (!(await pathExists(destConfigPath))) {
169
+ await fsp.writeFile(destConfigPath, sourceContent, {
170
+ mode: 0o644,
171
+ encoding: "utf8",
172
+ });
173
+ return;
174
+ }
175
+ const destContent = await fsp.readFile(destConfigPath, "utf8");
176
+ if (destContent === sourceContent) {
177
+ // Idempotent re-run — nothing to do.
178
+ return;
179
+ }
180
+ if (!options.force) {
181
+ throw new Error([
182
+ `Refusing to overwrite ${destConfigPath} — its contents differ from ${CONFIG_PATH}.`,
183
+ "Re-run with --force to overwrite. The legacy file is kept either way; only the per-profile copy changes.",
184
+ `Diff hint: \`diff ${CONFIG_PATH} ${destConfigPath}\``,
185
+ ].join("\n"));
186
+ }
187
+ if (!options.yes) {
188
+ const ok = await prompts.confirm({
189
+ message: `Overwrite ${destConfigPath} with the legacy config?`,
190
+ default: false,
191
+ });
192
+ if (!ok) {
193
+ throw new Error("Aborted by user.");
194
+ }
195
+ }
196
+ await fsp.writeFile(destConfigPath, sourceContent, {
197
+ mode: 0o644,
198
+ encoding: "utf8",
199
+ });
200
+ }
201
+ async function writeProfileToken(destTokenPath, token, options, prompts) {
202
+ const newline = `${token}\n`;
203
+ if (!(await pathExists(destTokenPath))) {
204
+ await fsp.writeFile(destTokenPath, newline, {
205
+ mode: 0o600,
206
+ encoding: "utf8",
207
+ });
208
+ // fs.writeFile mode is only honored on file creation; chmod defensively
209
+ // in case a parent process pre-created the file with permissive perms.
210
+ await fsp.chmod(destTokenPath, 0o600);
211
+ return;
212
+ }
213
+ const existing = (await fsp.readFile(destTokenPath, "utf8")).trim();
214
+ if (existing === token) {
215
+ // Idempotent re-run — already correct; force perms regardless.
216
+ await fsp.chmod(destTokenPath, 0o600);
217
+ return;
218
+ }
219
+ if (!options.force) {
220
+ throw new Error([
221
+ `Refusing to overwrite ${destTokenPath} — it contains a different token.`,
222
+ "Re-run with --force to overwrite the per-profile token.",
223
+ ].join("\n"));
224
+ }
225
+ if (!options.yes) {
226
+ const ok = await prompts.confirm({
227
+ message: `Overwrite the existing token at ${destTokenPath}?`,
228
+ default: false,
229
+ });
230
+ if (!ok) {
231
+ throw new Error("Aborted by user.");
232
+ }
233
+ }
234
+ await fsp.writeFile(destTokenPath, newline, {
235
+ mode: 0o600,
236
+ encoding: "utf8",
237
+ });
238
+ await fsp.chmod(destTokenPath, 0o600);
239
+ }
240
+ async function ensureActiveProfile(name) {
241
+ await fsp.mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
242
+ let current = null;
243
+ if (await pathExists(ACTIVE_PROFILE_FILE)) {
244
+ current = (await fsp.readFile(ACTIVE_PROFILE_FILE, "utf8")).trim();
245
+ }
246
+ if (current === name)
247
+ return;
248
+ await fsp.writeFile(ACTIVE_PROFILE_FILE, `${name}\n`, {
249
+ mode: 0o644,
250
+ encoding: "utf8",
251
+ });
252
+ }
253
+ async function pathExists(p) {
254
+ try {
255
+ await fsp.access(p);
256
+ return true;
257
+ }
258
+ catch {
259
+ return false;
260
+ }
261
+ }
262
+ /**
263
+ * Profile names land in filesystem paths and the active-profile marker.
264
+ * Same conservative charset as `profile add` to avoid `..` traversal,
265
+ * shell metacharacters, and Unicode lookalikes.
266
+ */
267
+ function isSafeName(name) {
268
+ return /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(name);
269
+ }
270
+ // Re-export internals so the integration test (and follow-up commands)
271
+ // can compose them without re-implementing the idempotency rules.
272
+ export { CONFIG_PATH, PROFILES_DIR, TOKEN_PATH };
@@ -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,191 @@
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
+ import { registerMigrateLegacy } from "./profile/migrate-legacy.js";
30
+ export function setupProfileCommands(program) {
31
+ const profile = program
32
+ .command("profile")
33
+ .description("Manage el-linear profiles (named workspaces with separate tokens + configs).");
34
+ profile.action(() => profile.help());
35
+ profile
36
+ .command("list")
37
+ .description("List configured profiles + which one is active.")
38
+ .action(async () => {
39
+ const data = await runProfileList();
40
+ outputSuccess({ data });
41
+ });
42
+ profile
43
+ .command("current")
44
+ .description("Print the active profile name (or `<default>` for the legacy single-profile setup).")
45
+ .action(() => {
46
+ const active = resolveActiveProfile();
47
+ outputSuccess({
48
+ data: {
49
+ name: active.name ?? "<default>",
50
+ configPath: active.configPath,
51
+ tokenPath: active.tokenPath,
52
+ },
53
+ });
54
+ });
55
+ profile
56
+ .command("use <name>")
57
+ .description("Make <name> the active profile (writes ~/.config/el-linear/active-profile).")
58
+ .action(async (name) => {
59
+ await runProfileUse(name);
60
+ outputSuccess({ data: { name, activeProfileFile: ACTIVE_PROFILE_FILE } });
61
+ });
62
+ profile
63
+ .command("add <name>")
64
+ .description("Create a new profile named <name> + run the init wizard scoped to it. After this finishes, <name> becomes the active profile.")
65
+ .action(async (name) => {
66
+ await runProfileAdd(name);
67
+ });
68
+ profile
69
+ .command("remove <name>")
70
+ .alias("rm")
71
+ .description("Delete the profile directory + its token (with confirmation).")
72
+ .option("--force", "skip the confirmation prompt")
73
+ .action(async (name, opts) => {
74
+ await runProfileRemove(name, opts.force === true);
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);
81
+ }
82
+ export async function runProfileList() {
83
+ const active = resolveActiveProfile();
84
+ const profiles = [];
85
+ let entries = [];
86
+ try {
87
+ entries = (await fsp.readdir(PROFILES_DIR, { withFileTypes: true }))
88
+ .filter((d) => d.isDirectory())
89
+ .map((d) => d.name)
90
+ .sort();
91
+ }
92
+ catch (err) {
93
+ if (err.code !== "ENOENT")
94
+ throw err;
95
+ }
96
+ for (const name of entries) {
97
+ const paths = profilePaths(name);
98
+ profiles.push({
99
+ name,
100
+ active: active.name === name,
101
+ hasToken: await pathExists(paths.tokenPath),
102
+ hasConfig: await pathExists(paths.configPath),
103
+ configPath: paths.configPath,
104
+ tokenPath: paths.tokenPath,
105
+ });
106
+ }
107
+ return {
108
+ activeName: active.name,
109
+ defaultPaths: { configPath: CONFIG_PATH, tokenPath: TOKEN_PATH },
110
+ hasLegacyToken: await pathExists(TOKEN_PATH),
111
+ hasLegacyConfig: await pathExists(CONFIG_PATH),
112
+ profiles,
113
+ };
114
+ }
115
+ export async function runProfileUse(name) {
116
+ const trimmed = name.trim();
117
+ if (!trimmed)
118
+ throw new Error("Profile name must be non-empty.");
119
+ if (!isSafeName(trimmed)) {
120
+ throw new Error(`Profile name "${trimmed}" must contain only [a-z0-9_-]. Pick a different name.`);
121
+ }
122
+ await fsp.mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
123
+ await fsp.writeFile(ACTIVE_PROFILE_FILE, `${trimmed}\n`, { mode: 0o644 });
124
+ }
125
+ export async function runProfileAdd(name) {
126
+ const trimmed = name.trim();
127
+ if (!trimmed)
128
+ throw new Error("Profile name must be non-empty.");
129
+ if (!isSafeName(trimmed)) {
130
+ throw new Error(`Profile name "${trimmed}" must contain only [a-z0-9_-]. Pick a different name.`);
131
+ }
132
+ const dir = path.dirname(profilePaths(trimmed).configPath);
133
+ await fsp.mkdir(dir, { recursive: true, mode: 0o700 });
134
+ // Activate the profile for the rest of THIS process so the wizard's
135
+ // readConfig/writeConfig/readToken/writeToken IO targets the new
136
+ // profile's directory (not the legacy single-file path).
137
+ setActiveProfileForSession(trimmed);
138
+ // Persist activation: write `active-profile` so subsequent invocations
139
+ // stay on the new profile until `profile use <other>` switches away.
140
+ await runProfileUse(trimmed);
141
+ outputWarning(`Created profile "${trimmed}" at ${dir}. Running init wizard scoped to this profile…`);
142
+ await runFullWizard();
143
+ }
144
+ export async function runProfileRemove(name, force) {
145
+ const trimmed = name.trim();
146
+ if (!trimmed || !isSafeName(trimmed)) {
147
+ throw new Error(`Invalid profile name "${name}".`);
148
+ }
149
+ const paths = profilePaths(trimmed);
150
+ const dir = path.dirname(paths.configPath);
151
+ if (!(await pathExists(dir))) {
152
+ throw new Error(`Profile "${trimmed}" not found at ${dir}.`);
153
+ }
154
+ if (!force) {
155
+ const confirmed = await confirm({
156
+ message: `Delete profile "${trimmed}" (${dir})? Token + config will be lost.`,
157
+ default: false,
158
+ });
159
+ if (!confirmed) {
160
+ outputWarning("Aborted.");
161
+ return;
162
+ }
163
+ }
164
+ await fsp.rm(dir, { recursive: true, force: true });
165
+ // If the just-removed profile was the active one, clear the marker
166
+ // so subsequent invocations fall back to the default paths.
167
+ const active = resolveActiveProfile();
168
+ if (active.name === trimmed && (await pathExists(ACTIVE_PROFILE_FILE))) {
169
+ await fsp.rm(ACTIVE_PROFILE_FILE, { force: true });
170
+ }
171
+ outputSuccess({ data: { removed: trimmed, dir } });
172
+ }
173
+ // ---- Helpers ------------------------------------------------------------
174
+ async function pathExists(p) {
175
+ try {
176
+ await fsp.access(p);
177
+ return true;
178
+ }
179
+ catch {
180
+ return false;
181
+ }
182
+ }
183
+ /**
184
+ * Profile names land in filesystem paths AND get written to a
185
+ * single-line marker. Restrict to a conservative charset so we can't
186
+ * accidentally pick up `..` traversal, command-line metacharacters, or
187
+ * Unicode lookalikes that would confuse `profile use`.
188
+ */
189
+ function isSafeName(name) {
190
+ return /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(name);
191
+ }
@@ -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
+ }
@@ -40,4 +40,6 @@ export interface ElLinearConfig {
40
40
  */
41
41
  workspaceUrlKey?: string;
42
42
  }
43
+ /** Test seam — resets the cache between test cases. */
44
+ export declare function _resetConfigCacheForTests(): void;
43
45
  export declare function loadConfig(): ElLinearConfig;