@enrichlayer/el-linear 1.39.0 → 1.40.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/claude-skills/linear-operations/SKILL.md +1 -1
- package/dist/commands/issues/description.d.ts +3 -0
- package/dist/commands/issues/description.js +5 -8
- package/dist/commands/projects.d.ts +24 -0
- package/dist/commands/projects.js +54 -5
- package/dist/utils/text-input-file.d.ts +23 -0
- package/dist/utils/text-input-file.js +32 -0
- package/package.json +1 -1
|
@@ -447,7 +447,7 @@ Run `el-linear usage` for the full command reference. Non-obvious rules:
|
|
|
447
447
|
- **Subcommand aliases** — `read`/`view`/`get`/`show`, `update`/`edit`/`set`.
|
|
448
448
|
- **`--jq` for GraphQL filtering** — never pipe through `jq` directly (zsh escaping breaks `!=`).
|
|
449
449
|
- **`--raw` flag** strips the `{ data, meta }` wrapper — emits just the array.
|
|
450
|
-
- **Body/description from a file** — `issues create`/`update` take `--description-file <path>`; `comments create`/`update` take `--body-file <path
|
|
450
|
+
- **Body/description/content from a file** — `issues create`/`update` take `--description-file <path>`; `comments create`/`update` take `--body-file <path>`; **`projects create`/`update` take `--content-file <path>`** (the project's full markdown **content**, not its short `description` — see the two-field gotcha under *Project Management Gotchas*). Prefer the file form for any body with backticks, fenced code, or markdown tables — it sidesteps shell-quoting traps (the same reason `el-git mr comment --body-file` exists). The inline and file flags are **mutually exclusive** (passing both errors); file-sourced bodies get the same auto-link / auto-mention treatment as inline text.
|
|
451
451
|
|
|
452
452
|
### Output format
|
|
453
453
|
|
|
@@ -23,6 +23,9 @@ import type { LinearService } from "../../utils/linear-service.js";
|
|
|
23
23
|
/**
|
|
24
24
|
* Read description from a file path or stdin ("-").
|
|
25
25
|
* Avoids shell escaping issues when descriptions contain special characters.
|
|
26
|
+
*
|
|
27
|
+
* Delegates to the shared reader so `projects --content-file` (DEV-6033), which
|
|
28
|
+
* is specified to behave identically, cannot drift from this one.
|
|
26
29
|
*/
|
|
27
30
|
export declare function readDescriptionFile(filePath: string): string;
|
|
28
31
|
/**
|
|
@@ -16,27 +16,24 @@
|
|
|
16
16
|
* Extracted from `commands/issues.ts` (ALL-938) so that file can
|
|
17
17
|
* focus on commander wiring + handlers.
|
|
18
18
|
*/
|
|
19
|
-
import fs from "node:fs";
|
|
20
19
|
import { loadConfig } from "../../config/config.js";
|
|
21
20
|
import { UPDATE_ISSUE_MUTATION } from "../../queries/issues.js";
|
|
22
21
|
import { autoLinkReferences, } from "../../utils/auto-link-references.js";
|
|
23
22
|
import { normalizeInlineTextInput } from "../../utils/inline-text-input.js";
|
|
24
23
|
import { extractIssueReferences } from "../../utils/issue-reference-extractor.js";
|
|
25
24
|
import { wrapIssueReferencesAsLinks } from "../../utils/issue-reference-wrapper.js";
|
|
25
|
+
import { readTextInputFile } from "../../utils/text-input-file.js";
|
|
26
26
|
import { validateReferences } from "../../utils/validate-references.js";
|
|
27
27
|
import { getWorkspaceUrlKey } from "../../utils/workspace-url.js";
|
|
28
28
|
/**
|
|
29
29
|
* Read description from a file path or stdin ("-").
|
|
30
30
|
* Avoids shell escaping issues when descriptions contain special characters.
|
|
31
|
+
*
|
|
32
|
+
* Delegates to the shared reader so `projects --content-file` (DEV-6033), which
|
|
33
|
+
* is specified to behave identically, cannot drift from this one.
|
|
31
34
|
*/
|
|
32
35
|
export function readDescriptionFile(filePath) {
|
|
33
|
-
|
|
34
|
-
return fs.readFileSync(0, "utf8").trim();
|
|
35
|
-
}
|
|
36
|
-
if (!fs.existsSync(filePath)) {
|
|
37
|
-
throw new Error(`Description file not found: ${filePath}`);
|
|
38
|
-
}
|
|
39
|
-
return fs.readFileSync(filePath, "utf8").trim();
|
|
36
|
+
return readTextInputFile(filePath, "Description");
|
|
40
37
|
}
|
|
41
38
|
/**
|
|
42
39
|
* Resolve the description from --description, --description-file, or
|
|
@@ -5,4 +5,28 @@ export declare function resolveProjectStateFilter(options: OptionValues): {
|
|
|
5
5
|
states?: string[];
|
|
6
6
|
excludeStates?: string[];
|
|
7
7
|
};
|
|
8
|
+
/**
|
|
9
|
+
* Resolve the project body from `--content <markdown>` or `--content-file <path>`
|
|
10
|
+
* (DEV-6033). The two are sources for the same field, so accepting both would
|
|
11
|
+
* silently drop one — we REJECT up front instead.
|
|
12
|
+
*
|
|
13
|
+
* That matches `comments --body-file` and `project-updates --body-file`, and it
|
|
14
|
+
* deliberately DIVERGES from `issues --description-file`, which documents a
|
|
15
|
+
* precedence (`--description-file` > `--description`) and therefore lets the file
|
|
16
|
+
* silently win when both are passed. Rejecting is the better contract — a caller
|
|
17
|
+
* who passes both has a bug, and telling them beats guessing — but do not describe
|
|
18
|
+
* this as "mirroring" issues: it is not, and a false claim about a sibling's
|
|
19
|
+
* mechanism outlives the person who wrote it.
|
|
20
|
+
*
|
|
21
|
+
* Returns `undefined` only when NEITHER flag was passed, which is what lets
|
|
22
|
+
* `projects update` keep distinguishing "leave content alone" from "clear it"
|
|
23
|
+
* (`--content ""` yields `""`, and `"" !== undefined`, so it still reaches the
|
|
24
|
+
* mutation — the same distinction `hasOption` drew before).
|
|
25
|
+
*
|
|
26
|
+
* Inline `--content` is passed through verbatim, exactly as it was before this
|
|
27
|
+
* flag existed. It is deliberately NOT run through `normalizeInlineTextInput`:
|
|
28
|
+
* that would newly rewrite `\n` inside an existing caller's `--content` string,
|
|
29
|
+
* a behavior change to a shipped flag that this issue does not ask for.
|
|
30
|
+
*/
|
|
31
|
+
export declare function resolveProjectContent(options: OptionValues): string | undefined;
|
|
8
32
|
export declare function setupProjectsCommands(program: Command): void;
|
|
@@ -8,6 +8,7 @@ import { logger } from "../utils/logger.js";
|
|
|
8
8
|
import { handleAsyncCommand, outputSuccess, outputWarning, } from "../utils/output.js";
|
|
9
9
|
import { effectiveOption, getRootOpts } from "../utils/root-opts.js";
|
|
10
10
|
import { renderCsv, renderFixedWidthTable, renderMarkdownTable, } from "../utils/table-formatter.js";
|
|
11
|
+
import { readTextInputFile } from "../utils/text-input-file.js";
|
|
11
12
|
import { isUuid } from "../utils/uuid.js";
|
|
12
13
|
import { parsePositiveInt, splitList } from "../utils/validators.js";
|
|
13
14
|
const VALID_PROJECT_STATES = new Set([
|
|
@@ -232,6 +233,43 @@ function formatTeamsOutput(projectUpdate) {
|
|
|
232
233
|
function hasOption(options, key) {
|
|
233
234
|
return options[key] !== undefined;
|
|
234
235
|
}
|
|
236
|
+
/**
|
|
237
|
+
* Resolve the project body from `--content <markdown>` or `--content-file <path>`
|
|
238
|
+
* (DEV-6033). The two are sources for the same field, so accepting both would
|
|
239
|
+
* silently drop one — we REJECT up front instead.
|
|
240
|
+
*
|
|
241
|
+
* That matches `comments --body-file` and `project-updates --body-file`, and it
|
|
242
|
+
* deliberately DIVERGES from `issues --description-file`, which documents a
|
|
243
|
+
* precedence (`--description-file` > `--description`) and therefore lets the file
|
|
244
|
+
* silently win when both are passed. Rejecting is the better contract — a caller
|
|
245
|
+
* who passes both has a bug, and telling them beats guessing — but do not describe
|
|
246
|
+
* this as "mirroring" issues: it is not, and a false claim about a sibling's
|
|
247
|
+
* mechanism outlives the person who wrote it.
|
|
248
|
+
*
|
|
249
|
+
* Returns `undefined` only when NEITHER flag was passed, which is what lets
|
|
250
|
+
* `projects update` keep distinguishing "leave content alone" from "clear it"
|
|
251
|
+
* (`--content ""` yields `""`, and `"" !== undefined`, so it still reaches the
|
|
252
|
+
* mutation — the same distinction `hasOption` drew before).
|
|
253
|
+
*
|
|
254
|
+
* Inline `--content` is passed through verbatim, exactly as it was before this
|
|
255
|
+
* flag existed. It is deliberately NOT run through `normalizeInlineTextInput`:
|
|
256
|
+
* that would newly rewrite `\n` inside an existing caller's `--content` string,
|
|
257
|
+
* a behavior change to a shipped flag that this issue does not ask for.
|
|
258
|
+
*/
|
|
259
|
+
export function resolveProjectContent(options) {
|
|
260
|
+
const hasInline = typeof options.content === "string";
|
|
261
|
+
const hasFile = typeof options.contentFile === "string";
|
|
262
|
+
if (hasInline && hasFile) {
|
|
263
|
+
throw new Error("--content and --content-file are mutually exclusive — pass one or the other");
|
|
264
|
+
}
|
|
265
|
+
if (hasFile) {
|
|
266
|
+
return readTextInputFile(options.contentFile, "Content");
|
|
267
|
+
}
|
|
268
|
+
if (hasInline) {
|
|
269
|
+
return options.content;
|
|
270
|
+
}
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
235
273
|
function flattenProjectUpdate(projectUpdate) {
|
|
236
274
|
if (!projectUpdate.project) {
|
|
237
275
|
throw new Error("Failed to update project");
|
|
@@ -345,6 +383,11 @@ async function handleRemoveTeam(projectNameOrId, teamInput, options, command) {
|
|
|
345
383
|
});
|
|
346
384
|
}
|
|
347
385
|
async function handleCreateProject(name, options, command) {
|
|
386
|
+
// Resolve the body BEFORE the create mutation. An unreadable --content-file
|
|
387
|
+
// (or --content alongside it) must fail while nothing has been created yet —
|
|
388
|
+
// resolving after the create would leave an orphan project behind and then
|
|
389
|
+
// throw, which is the worst of both outcomes.
|
|
390
|
+
const content = resolveProjectContent(options);
|
|
348
391
|
const rootOpts = getRootOpts(command);
|
|
349
392
|
const graphQLService = await createGraphQLService(rootOpts);
|
|
350
393
|
// Step 1: Check for duplicate projects (case-insensitive)
|
|
@@ -376,10 +419,10 @@ async function handleCreateProject(name, options, command) {
|
|
|
376
419
|
}
|
|
377
420
|
const project = createResult.projectCreate.project;
|
|
378
421
|
// Step 4: Set content if provided (separate mutation — Linear API quirk)
|
|
379
|
-
if (
|
|
422
|
+
if (content) {
|
|
380
423
|
await graphQLService.rawRequest(UPDATE_PROJECT_MUTATION, {
|
|
381
424
|
id: project.id,
|
|
382
|
-
input: { content
|
|
425
|
+
input: { content },
|
|
383
426
|
});
|
|
384
427
|
}
|
|
385
428
|
const teamList = project.teams.nodes.map((t) => t.key).join(", ");
|
|
@@ -465,11 +508,15 @@ async function handleUpdateProject(projectNameOrId, options, command) {
|
|
|
465
508
|
if (hasOption(options, "description")) {
|
|
466
509
|
input.description = options.description;
|
|
467
510
|
}
|
|
468
|
-
|
|
469
|
-
|
|
511
|
+
// `undefined` means neither --content nor --content-file was passed. An
|
|
512
|
+
// explicit `--content ""` resolves to "" and still lands here, so clearing a
|
|
513
|
+
// project's body keeps working exactly as it did under `hasOption`.
|
|
514
|
+
const content = resolveProjectContent(options);
|
|
515
|
+
if (content !== undefined) {
|
|
516
|
+
input.content = content;
|
|
470
517
|
}
|
|
471
518
|
if (Object.keys(input).length === 0) {
|
|
472
|
-
throw new Error("Nothing to update. Pass at least one of --name, --description, or --content.");
|
|
519
|
+
throw new Error("Nothing to update. Pass at least one of --name, --description, --content, or --content-file.");
|
|
473
520
|
}
|
|
474
521
|
const rootOpts = getRootOpts(command);
|
|
475
522
|
const graphQLService = await createGraphQLService(rootOpts);
|
|
@@ -494,6 +541,7 @@ export function setupProjectsCommands(program) {
|
|
|
494
541
|
.option("--team <teams>", "comma-separated team keys (e.g., FE,DEV)")
|
|
495
542
|
.option("-d, --description <text>", "short summary (max 255 chars, shown in lists)")
|
|
496
543
|
.option("--content <markdown>", "full markdown body (shown in project panel)")
|
|
544
|
+
.option("--content-file <path>", "read the markdown body from a file (or '-' for stdin); mutually exclusive with --content")
|
|
497
545
|
.option("--force", "create even if a project with the same name exists")
|
|
498
546
|
.action(handleAsyncCommand(handleCreateProject));
|
|
499
547
|
projects
|
|
@@ -514,6 +562,7 @@ export function setupProjectsCommands(program) {
|
|
|
514
562
|
.option("--name <name>", "project name")
|
|
515
563
|
.option("-d, --description <text>", "short summary (max 255 chars, shown in lists)")
|
|
516
564
|
.option("--content <markdown>", "full markdown body (shown in project panel)")
|
|
565
|
+
.option("--content-file <path>", "read the markdown body from a file (or '-' for stdin); mutually exclusive with --content")
|
|
517
566
|
.action(handleAsyncCommand(handleUpdateProject));
|
|
518
567
|
projects
|
|
519
568
|
.command("list")
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read the body for a `--*-file` flag: a path on disk, or `-` for stdin.
|
|
3
|
+
*
|
|
4
|
+
* These flags exist to keep large markdown bodies away from the shell. The
|
|
5
|
+
* workaround they replace — `--content "$(cat body.md)"` — hands the file's
|
|
6
|
+
* bytes to the shell first, so backticks, `$`, and nested quotes inside the
|
|
7
|
+
* markdown get interpolated before the CLI ever sees them. Reading the path
|
|
8
|
+
* ourselves means the bytes arrive exactly as authored.
|
|
9
|
+
*
|
|
10
|
+
* For the same reason the content is used verbatim: no escape-sequence
|
|
11
|
+
* normalization. That is `normalizeInlineTextInput`'s job for the *inline*
|
|
12
|
+
* flags, where a user typing `\n` at a shell prompt means a newline. In a file,
|
|
13
|
+
* a literal `\n` inside a fenced code block is content, and rewriting it would
|
|
14
|
+
* corrupt the document.
|
|
15
|
+
*
|
|
16
|
+
* `label` names the subject in the not-found error, so each flag reports itself
|
|
17
|
+
* ("Description file not found: …", "Content file not found: …").
|
|
18
|
+
*
|
|
19
|
+
* Single source of truth for `issues --description-file` and `projects
|
|
20
|
+
* --content-file` (DEV-6033) — the two are specified to behave identically, so
|
|
21
|
+
* they share one implementation rather than two that drift.
|
|
22
|
+
*/
|
|
23
|
+
export declare function readTextInputFile(filePath: string, label: string): string;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
/**
|
|
3
|
+
* Read the body for a `--*-file` flag: a path on disk, or `-` for stdin.
|
|
4
|
+
*
|
|
5
|
+
* These flags exist to keep large markdown bodies away from the shell. The
|
|
6
|
+
* workaround they replace — `--content "$(cat body.md)"` — hands the file's
|
|
7
|
+
* bytes to the shell first, so backticks, `$`, and nested quotes inside the
|
|
8
|
+
* markdown get interpolated before the CLI ever sees them. Reading the path
|
|
9
|
+
* ourselves means the bytes arrive exactly as authored.
|
|
10
|
+
*
|
|
11
|
+
* For the same reason the content is used verbatim: no escape-sequence
|
|
12
|
+
* normalization. That is `normalizeInlineTextInput`'s job for the *inline*
|
|
13
|
+
* flags, where a user typing `\n` at a shell prompt means a newline. In a file,
|
|
14
|
+
* a literal `\n` inside a fenced code block is content, and rewriting it would
|
|
15
|
+
* corrupt the document.
|
|
16
|
+
*
|
|
17
|
+
* `label` names the subject in the not-found error, so each flag reports itself
|
|
18
|
+
* ("Description file not found: …", "Content file not found: …").
|
|
19
|
+
*
|
|
20
|
+
* Single source of truth for `issues --description-file` and `projects
|
|
21
|
+
* --content-file` (DEV-6033) — the two are specified to behave identically, so
|
|
22
|
+
* they share one implementation rather than two that drift.
|
|
23
|
+
*/
|
|
24
|
+
export function readTextInputFile(filePath, label) {
|
|
25
|
+
if (filePath === "-") {
|
|
26
|
+
return fs.readFileSync(0, "utf8").trim();
|
|
27
|
+
}
|
|
28
|
+
if (!fs.existsSync(filePath)) {
|
|
29
|
+
throw new Error(`${label} file not found: ${filePath}`);
|
|
30
|
+
}
|
|
31
|
+
return fs.readFileSync(filePath, "utf8").trim();
|
|
32
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enrichlayer/el-linear",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.40.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",
|