@enrichlayer/el-linear 1.2.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/LICENSE +21 -0
- package/README.md +219 -0
- package/claude-skills/linear-operations/SKILL.md +315 -0
- package/claude-skills/linear-operations/evals/evals.json +46 -0
- package/dist/commands/attachments.d.ts +2 -0
- package/dist/commands/attachments.js +57 -0
- package/dist/commands/batch.d.ts +2 -0
- package/dist/commands/batch.js +309 -0
- package/dist/commands/comments.d.ts +2 -0
- package/dist/commands/comments.js +272 -0
- package/dist/commands/config.d.ts +2 -0
- package/dist/commands/config.js +15 -0
- package/dist/commands/cycles.d.ts +2 -0
- package/dist/commands/cycles.js +63 -0
- package/dist/commands/documents.d.ts +2 -0
- package/dist/commands/documents.js +175 -0
- package/dist/commands/embeds.d.ts +2 -0
- package/dist/commands/embeds.js +65 -0
- package/dist/commands/gdoc.d.ts +2 -0
- package/dist/commands/gdoc.js +37 -0
- package/dist/commands/graphql.d.ts +2 -0
- package/dist/commands/graphql.js +70 -0
- package/dist/commands/init/aliases.d.ts +109 -0
- package/dist/commands/init/aliases.js +569 -0
- package/dist/commands/init/defaults.d.ts +25 -0
- package/dist/commands/init/defaults.js +112 -0
- package/dist/commands/init/index.d.ts +18 -0
- package/dist/commands/init/index.js +182 -0
- package/dist/commands/init/shared.d.ts +88 -0
- package/dist/commands/init/shared.js +164 -0
- package/dist/commands/init/token.d.ts +50 -0
- package/dist/commands/init/token.js +141 -0
- package/dist/commands/init/workspace.d.ts +20 -0
- package/dist/commands/init/workspace.js +80 -0
- package/dist/commands/issue-id.d.ts +28 -0
- package/dist/commands/issue-id.js +81 -0
- package/dist/commands/issues.d.ts +2 -0
- package/dist/commands/issues.js +1145 -0
- package/dist/commands/labels.d.ts +2 -0
- package/dist/commands/labels.js +100 -0
- package/dist/commands/project-milestones.d.ts +2 -0
- package/dist/commands/project-milestones.js +143 -0
- package/dist/commands/projects.d.ts +2 -0
- package/dist/commands/projects.js +336 -0
- package/dist/commands/read-shortcut.d.ts +6 -0
- package/dist/commands/read-shortcut.js +69 -0
- package/dist/commands/releases.d.ts +2 -0
- package/dist/commands/releases.js +142 -0
- package/dist/commands/search.d.ts +2 -0
- package/dist/commands/search.js +171 -0
- package/dist/commands/teams.d.ts +2 -0
- package/dist/commands/teams.js +19 -0
- package/dist/commands/templates.d.ts +2 -0
- package/dist/commands/templates.js +58 -0
- package/dist/commands/users.d.ts +2 -0
- package/dist/commands/users.js +17 -0
- package/dist/config/config.d.ts +43 -0
- package/dist/config/config.js +81 -0
- package/dist/config/issue-validation.d.ts +39 -0
- package/dist/config/issue-validation.js +264 -0
- package/dist/config/paths.d.ts +20 -0
- package/dist/config/paths.js +22 -0
- package/dist/config/resolver.d.ts +25 -0
- package/dist/config/resolver.js +183 -0
- package/dist/config/status-defaults.d.ts +13 -0
- package/dist/config/status-defaults.js +20 -0
- package/dist/config/term-enforcer.d.ts +31 -0
- package/dist/config/term-enforcer.js +69 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +76 -0
- package/dist/queries/attachments.d.ts +3 -0
- package/dist/queries/attachments.js +35 -0
- package/dist/queries/comments.d.ts +3 -0
- package/dist/queries/comments.js +64 -0
- package/dist/queries/common.d.ts +2 -0
- package/dist/queries/common.js +109 -0
- package/dist/queries/cycles.d.ts +2 -0
- package/dist/queries/cycles.js +40 -0
- package/dist/queries/documents.d.ts +5 -0
- package/dist/queries/documents.js +67 -0
- package/dist/queries/introspect.d.ts +2 -0
- package/dist/queries/introspect.js +24 -0
- package/dist/queries/issues.d.ts +23 -0
- package/dist/queries/issues.js +380 -0
- package/dist/queries/labels.d.ts +4 -0
- package/dist/queries/labels.js +53 -0
- package/dist/queries/project-milestones.d.ts +6 -0
- package/dist/queries/project-milestones.js +125 -0
- package/dist/queries/projects.d.ts +7 -0
- package/dist/queries/projects.js +104 -0
- package/dist/queries/releases.d.ts +4 -0
- package/dist/queries/releases.js +85 -0
- package/dist/queries/search.d.ts +1 -0
- package/dist/queries/search.js +35 -0
- package/dist/queries/templates.d.ts +2 -0
- package/dist/queries/templates.js +30 -0
- package/dist/types/linear.d.ts +217 -0
- package/dist/types/linear.js +6 -0
- package/dist/utils/auth.d.ts +4 -0
- package/dist/utils/auth.js +23 -0
- package/dist/utils/auto-link-references.d.ts +47 -0
- package/dist/utils/auto-link-references.js +188 -0
- package/dist/utils/date-format.d.ts +4 -0
- package/dist/utils/date-format.js +8 -0
- package/dist/utils/download-uploads.d.ts +7 -0
- package/dist/utils/download-uploads.js +88 -0
- package/dist/utils/embed-parser.d.ts +8 -0
- package/dist/utils/embed-parser.js +54 -0
- package/dist/utils/error-messages.d.ts +4 -0
- package/dist/utils/error-messages.js +17 -0
- package/dist/utils/file-service.d.ts +13 -0
- package/dist/utils/file-service.js +239 -0
- package/dist/utils/gdoc-parser.d.ts +45 -0
- package/dist/utils/gdoc-parser.js +107 -0
- package/dist/utils/graphql-attachments-service.d.ts +12 -0
- package/dist/utils/graphql-attachments-service.js +46 -0
- package/dist/utils/graphql-documents-service.d.ts +18 -0
- package/dist/utils/graphql-documents-service.js +97 -0
- package/dist/utils/graphql-issues-service.d.ts +49 -0
- package/dist/utils/graphql-issues-service.js +925 -0
- package/dist/utils/graphql-service.d.ts +8 -0
- package/dist/utils/graphql-service.js +35 -0
- package/dist/utils/identifier-parser.d.ts +7 -0
- package/dist/utils/identifier-parser.js +20 -0
- package/dist/utils/issue-reference-extractor.d.ts +18 -0
- package/dist/utils/issue-reference-extractor.js +95 -0
- package/dist/utils/issue-reference-wrapper.d.ts +12 -0
- package/dist/utils/issue-reference-wrapper.js +91 -0
- package/dist/utils/linear-service.d.ts +26 -0
- package/dist/utils/linear-service.js +442 -0
- package/dist/utils/logger.d.ts +4 -0
- package/dist/utils/logger.js +8 -0
- package/dist/utils/markdown-prosemirror.d.ts +24 -0
- package/dist/utils/markdown-prosemirror.js +325 -0
- package/dist/utils/mention-resolver.d.ts +31 -0
- package/dist/utils/mention-resolver.js +234 -0
- package/dist/utils/output.d.ts +7 -0
- package/dist/utils/output.js +125 -0
- package/dist/utils/table-formatter.d.ts +4 -0
- package/dist/utils/table-formatter.js +249 -0
- package/dist/utils/usage.d.ts +2 -0
- package/dist/utils/usage.js +24 -0
- package/dist/utils/uuid.d.ts +2 -0
- package/dist/utils/uuid.js +8 -0
- package/dist/utils/validate-references.d.ts +10 -0
- package/dist/utils/validate-references.js +33 -0
- package/dist/utils/validators.d.ts +7 -0
- package/dist/utils/validators.js +71 -0
- package/dist/utils/workspace-url.d.ts +4 -0
- package/dist/utils/workspace-url.js +44 -0
- package/package.json +71 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step 4 of the wizard: default labels, status defaults, term enforcement.
|
|
3
|
+
*
|
|
4
|
+
* All optional. Each subsection asks "change?" with default=N so re-running
|
|
5
|
+
* with no input is a no-op.
|
|
6
|
+
*/
|
|
7
|
+
import { confirm, input } from "@inquirer/prompts";
|
|
8
|
+
import { parseCsvList } from "./shared.js";
|
|
9
|
+
const STATUS_FALLBACK = { noProject: "Triage", withAssigneeAndProject: "Todo" };
|
|
10
|
+
export async function runDefaultsStep(existing) {
|
|
11
|
+
// Idempotency rule: when the user skips a sub-section, return the existing
|
|
12
|
+
// value byte-for-byte. We deliberately do NOT spread or backfill optional
|
|
13
|
+
// fields (a partial { noProject: "Backlog" } stays partial) so re-running
|
|
14
|
+
// with no input produces a byte-identical config.
|
|
15
|
+
const result = {
|
|
16
|
+
defaultLabels: existing.defaultLabels,
|
|
17
|
+
statusDefaults: existing.statusDefaults,
|
|
18
|
+
terms: existing.terms,
|
|
19
|
+
};
|
|
20
|
+
// ── Default labels ────────────────────────────────────────────────
|
|
21
|
+
const currentLabels = existing.defaultLabels ?? [];
|
|
22
|
+
// biome-ignore lint/suspicious/noConsole: wizard
|
|
23
|
+
console.log(` Current default labels: ${currentLabels.length > 0 ? currentLabels.join(", ") : "(none)"}`);
|
|
24
|
+
const editLabels = await confirm({
|
|
25
|
+
message: "Change default labels for new issues?",
|
|
26
|
+
default: false,
|
|
27
|
+
});
|
|
28
|
+
if (editLabels) {
|
|
29
|
+
const raw = await input({
|
|
30
|
+
message: "Default labels (comma-separated, blank for none):",
|
|
31
|
+
default: currentLabels.join(", "),
|
|
32
|
+
});
|
|
33
|
+
const parsed = parseCsvList(raw);
|
|
34
|
+
result.defaultLabels = parsed.length > 0 ? parsed : undefined;
|
|
35
|
+
}
|
|
36
|
+
// ── Status defaults ────────────────────────────────────────────────
|
|
37
|
+
const cur = existing.statusDefaults;
|
|
38
|
+
// biome-ignore lint/suspicious/noConsole: wizard
|
|
39
|
+
console.log(` Current status defaults: noProject=${cur?.noProject ?? STATUS_FALLBACK.noProject}, ` +
|
|
40
|
+
`withAssigneeAndProject=${cur?.withAssigneeAndProject ?? STATUS_FALLBACK.withAssigneeAndProject}`);
|
|
41
|
+
const editStatus = await confirm({
|
|
42
|
+
message: "Change status defaults?",
|
|
43
|
+
default: false,
|
|
44
|
+
});
|
|
45
|
+
if (editStatus) {
|
|
46
|
+
const noProject = await input({
|
|
47
|
+
message: "Status when no project assigned:",
|
|
48
|
+
default: cur?.noProject ?? STATUS_FALLBACK.noProject,
|
|
49
|
+
});
|
|
50
|
+
const withAP = await input({
|
|
51
|
+
message: "Status when assignee + project both set:",
|
|
52
|
+
default: cur?.withAssigneeAndProject ?? STATUS_FALLBACK.withAssigneeAndProject,
|
|
53
|
+
});
|
|
54
|
+
result.statusDefaults = {
|
|
55
|
+
noProject: noProject.trim(),
|
|
56
|
+
withAssigneeAndProject: withAP.trim(),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
// ── Term enforcement ───────────────────────────────────────────────
|
|
60
|
+
const currentTerms = existing.terms ?? [];
|
|
61
|
+
// biome-ignore lint/suspicious/noConsole: wizard
|
|
62
|
+
console.log(` Current term-enforcement rules: ${currentTerms.length}`);
|
|
63
|
+
for (const t of currentTerms) {
|
|
64
|
+
// biome-ignore lint/suspicious/noConsole: wizard
|
|
65
|
+
console.log(` "${t.canonical}" rejects: ${t.reject.join(", ")}`);
|
|
66
|
+
}
|
|
67
|
+
const editTerms = await confirm({
|
|
68
|
+
message: currentTerms.length === 0
|
|
69
|
+
? "Set up term-enforcement rules?"
|
|
70
|
+
: "Change term-enforcement rules?",
|
|
71
|
+
default: false,
|
|
72
|
+
});
|
|
73
|
+
if (editTerms) {
|
|
74
|
+
result.terms = await collectTermsInteractively(currentTerms);
|
|
75
|
+
}
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
async function collectTermsInteractively(initial) {
|
|
79
|
+
const terms = [...initial];
|
|
80
|
+
// biome-ignore lint/suspicious/noConsole: wizard
|
|
81
|
+
console.log(" Term enforcement catches misspellings of brand or product names in issue titles " +
|
|
82
|
+
"and descriptions. Define the canonical form and a list of rejected variants.");
|
|
83
|
+
if (terms.length > 0) {
|
|
84
|
+
const replace = await confirm({
|
|
85
|
+
message: `Replace the existing ${terms.length} rule(s) instead of appending?`,
|
|
86
|
+
default: false,
|
|
87
|
+
});
|
|
88
|
+
if (replace)
|
|
89
|
+
terms.length = 0;
|
|
90
|
+
}
|
|
91
|
+
for (;;) {
|
|
92
|
+
const canonical = (await input({
|
|
93
|
+
message: terms.length === 0
|
|
94
|
+
? "Canonical form (e.g. 'Enrich Layer'):"
|
|
95
|
+
: "Add another? Canonical form (blank to finish):",
|
|
96
|
+
default: "",
|
|
97
|
+
})).trim();
|
|
98
|
+
if (!canonical)
|
|
99
|
+
break;
|
|
100
|
+
const rejectRaw = await input({
|
|
101
|
+
message: `Rejected variants of "${canonical}" (comma-separated, e.g. EnrichLayer, enrichlayer):`,
|
|
102
|
+
});
|
|
103
|
+
const reject = parseCsvList(rejectRaw);
|
|
104
|
+
if (reject.length === 0) {
|
|
105
|
+
// biome-ignore lint/suspicious/noConsole: wizard
|
|
106
|
+
console.log(" No variants given — skipping this rule.");
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
terms.push({ canonical, reject });
|
|
110
|
+
}
|
|
111
|
+
return terms;
|
|
112
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Setup wizard for first-time el-linear users.
|
|
3
|
+
*
|
|
4
|
+
* Top-level command:
|
|
5
|
+
* el-linear init — full wizard (token → workspace → aliases → defaults)
|
|
6
|
+
*
|
|
7
|
+
* Sub-commands (each idempotent, runnable in isolation):
|
|
8
|
+
* el-linear init token — set/replace the API token
|
|
9
|
+
* el-linear init workspace — pick a default team
|
|
10
|
+
* el-linear init aliases — walk users for aliases / handles (resumable)
|
|
11
|
+
* el-linear init aliases --import users.csv
|
|
12
|
+
* el-linear init defaults — default labels, status, term enforcement
|
|
13
|
+
*
|
|
14
|
+
* Skip is the default at every prompt. Only `init token` is required for a
|
|
15
|
+
* first-time setup; everything else can be skipped and revisited later.
|
|
16
|
+
*/
|
|
17
|
+
import type { Command } from "commander";
|
|
18
|
+
export declare function setupInitCommands(program: Command): void;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Setup wizard for first-time el-linear users.
|
|
3
|
+
*
|
|
4
|
+
* Top-level command:
|
|
5
|
+
* el-linear init — full wizard (token → workspace → aliases → defaults)
|
|
6
|
+
*
|
|
7
|
+
* Sub-commands (each idempotent, runnable in isolation):
|
|
8
|
+
* el-linear init token — set/replace the API token
|
|
9
|
+
* el-linear init workspace — pick a default team
|
|
10
|
+
* el-linear init aliases — walk users for aliases / handles (resumable)
|
|
11
|
+
* el-linear init aliases --import users.csv
|
|
12
|
+
* el-linear init defaults — default labels, status, term enforcement
|
|
13
|
+
*
|
|
14
|
+
* Skip is the default at every prompt. Only `init token` is required for a
|
|
15
|
+
* first-time setup; everything else can be skipped and revisited later.
|
|
16
|
+
*/
|
|
17
|
+
import { mergeAliasesIntoConfig, runAliasesImport, runAliasesStep, } from "./aliases.js";
|
|
18
|
+
import { runDefaultsStep } from "./defaults.js";
|
|
19
|
+
import { assignDefined, printStep, readConfig, writeConfig, } from "./shared.js";
|
|
20
|
+
import { runTokenStep } from "./token.js";
|
|
21
|
+
import { runWorkspaceStep } from "./workspace.js";
|
|
22
|
+
/**
|
|
23
|
+
* Wrap a wizard handler so that pressing Ctrl+C at any inquirer prompt exits
|
|
24
|
+
* cleanly with code 130 (the standard SIGINT exit code) instead of dumping a
|
|
25
|
+
* stack trace. inquirer throws an `ExitPromptError` whose `name === "ExitPromptError"`;
|
|
26
|
+
* we catch by name to avoid a hard import dependency on the internal class.
|
|
27
|
+
*/
|
|
28
|
+
function withCleanExit(fn) {
|
|
29
|
+
return async (...args) => {
|
|
30
|
+
try {
|
|
31
|
+
await fn(...args);
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
if (err instanceof Error && err.name === "ExitPromptError") {
|
|
35
|
+
// biome-ignore lint/suspicious/noConsole: wizard
|
|
36
|
+
console.log("\n Cancelled.");
|
|
37
|
+
process.exit(130);
|
|
38
|
+
}
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export function setupInitCommands(program) {
|
|
44
|
+
const init = program
|
|
45
|
+
.command("init")
|
|
46
|
+
.description("Interactive setup wizard for first-time el-linear users")
|
|
47
|
+
.option("--force", "ignore existing config when prompting")
|
|
48
|
+
.action(withCleanExit(async (options) => {
|
|
49
|
+
await runFullWizard({ force: options.force ?? false });
|
|
50
|
+
}));
|
|
51
|
+
init
|
|
52
|
+
.command("token")
|
|
53
|
+
.description("Set or replace the Linear API token")
|
|
54
|
+
.option("--force", "always replace existing token")
|
|
55
|
+
.action(withCleanExit(async (options) => {
|
|
56
|
+
printStep("token", "Linear API token");
|
|
57
|
+
await runTokenStep({ force: options.force ?? false });
|
|
58
|
+
}));
|
|
59
|
+
init
|
|
60
|
+
.command("workspace")
|
|
61
|
+
.description("Set the default team and refresh team UUID cache")
|
|
62
|
+
.action(withCleanExit(async () => {
|
|
63
|
+
const tokenResult = await runTokenStep();
|
|
64
|
+
const existing = await readConfig();
|
|
65
|
+
printStep("workspace", "Workspace defaults");
|
|
66
|
+
const ws = await runWorkspaceStep(tokenResult.token, tokenResult.viewer.organization.urlKey, existing);
|
|
67
|
+
const merged = assignDefined(existing, {
|
|
68
|
+
defaultTeam: ws.defaultTeam,
|
|
69
|
+
teams: { ...(existing.teams ?? {}), ...ws.teams },
|
|
70
|
+
// Don't clobber a manual urlKey override — same idempotency
|
|
71
|
+
// rule as the full wizard.
|
|
72
|
+
workspaceUrlKey: existing.workspaceUrlKey ?? ws.workspaceUrlKey,
|
|
73
|
+
});
|
|
74
|
+
await writeConfig(merged);
|
|
75
|
+
// biome-ignore lint/suspicious/noConsole: wizard
|
|
76
|
+
console.log(" ✓ Workspace defaults saved.");
|
|
77
|
+
}));
|
|
78
|
+
init
|
|
79
|
+
.command("aliases")
|
|
80
|
+
.description("Walk Linear users to add aliases / GitHub / GitLab handles (resumable)")
|
|
81
|
+
.option("--import <csv>", "Batch import from a CSV with columns: email,aliases,github,gitlab")
|
|
82
|
+
.option("--force", "skip the 'walk now?' prompt")
|
|
83
|
+
.action(withCleanExit(async (options) => {
|
|
84
|
+
const tokenResult = await runTokenStep();
|
|
85
|
+
const existing = await readConfig();
|
|
86
|
+
printStep("aliases", "Member aliases");
|
|
87
|
+
let updates;
|
|
88
|
+
if (options.import) {
|
|
89
|
+
const result = await runAliasesImport(tokenResult.token, options.import);
|
|
90
|
+
updates = result.updates;
|
|
91
|
+
if (result.skipped.length > 0) {
|
|
92
|
+
// biome-ignore lint/suspicious/noConsole: wizard
|
|
93
|
+
console.log(` Skipped ${result.skipped.length} unmatched email(s): ${result.skipped.join(", ")}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
updates = await runAliasesStep(tokenResult.token, existing, {
|
|
98
|
+
force: options.force ?? false,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
if (updates.size === 0) {
|
|
102
|
+
// biome-ignore lint/suspicious/noConsole: wizard
|
|
103
|
+
console.log(" No alias changes — config unchanged.");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const merged = mergeAliasesIntoConfig(existing, updates);
|
|
107
|
+
await writeConfig(merged);
|
|
108
|
+
// biome-ignore lint/suspicious/noConsole: wizard
|
|
109
|
+
console.log(` ✓ Updated aliases for ${updates.size} user(s).`);
|
|
110
|
+
}));
|
|
111
|
+
init
|
|
112
|
+
.command("defaults")
|
|
113
|
+
.description("Default labels, status defaults, term enforcement rules")
|
|
114
|
+
.action(withCleanExit(async () => {
|
|
115
|
+
const existing = await readConfig();
|
|
116
|
+
printStep("defaults", "Defaults");
|
|
117
|
+
const result = await runDefaultsStep(existing);
|
|
118
|
+
const merged = assignDefined(existing, {
|
|
119
|
+
defaultLabels: result.defaultLabels,
|
|
120
|
+
statusDefaults: result.statusDefaults,
|
|
121
|
+
terms: result.terms,
|
|
122
|
+
});
|
|
123
|
+
await writeConfig(merged);
|
|
124
|
+
// biome-ignore lint/suspicious/noConsole: wizard
|
|
125
|
+
console.log(" ✓ Defaults saved.");
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Full wizard: walk through all four steps in sequence. Each step writes its
|
|
130
|
+
* own slice of the config and is restartable on its own.
|
|
131
|
+
*/
|
|
132
|
+
async function runFullWizard(options) {
|
|
133
|
+
// biome-ignore lint/suspicious/noConsole: wizard
|
|
134
|
+
console.log("Welcome to el-linear. This wizard will set up your config.");
|
|
135
|
+
// biome-ignore lint/suspicious/noConsole: wizard
|
|
136
|
+
console.log("Skip is the default at every prompt; only the API token is required.\n");
|
|
137
|
+
const existing = await readConfig();
|
|
138
|
+
// Step 1: token (required)
|
|
139
|
+
printStep("1/4", "Linear API token");
|
|
140
|
+
const tokenResult = await runTokenStep({ force: options.force });
|
|
141
|
+
// Step 2: workspace
|
|
142
|
+
printStep("2/4", "Workspace defaults");
|
|
143
|
+
const ws = await runWorkspaceStep(tokenResult.token, tokenResult.viewer.organization.urlKey, existing);
|
|
144
|
+
// Step 3: aliases
|
|
145
|
+
printStep("3/4", "Member aliases");
|
|
146
|
+
const aliasUpdates = await runAliasesStep(tokenResult.token, existing);
|
|
147
|
+
// Step 4: defaults
|
|
148
|
+
printStep("4/4", "Defaults");
|
|
149
|
+
const defaults = await runDefaultsStep(existing);
|
|
150
|
+
// Merge everything and write atomically at the end.
|
|
151
|
+
// Idempotency rule: only write a key when the user explicitly changed it.
|
|
152
|
+
// `existing.X ?? new.X` preserves any manual override the user may have
|
|
153
|
+
// in config.json (self-hosted Linear urlKey, custom default team, etc.).
|
|
154
|
+
// `assignDefined` skips undefined values so the resulting object's own-
|
|
155
|
+
// property set matches what JSON.stringify would actually serialize.
|
|
156
|
+
let merged = assignDefined(existing, {
|
|
157
|
+
// defaults step: result is `existing.X` itself when the user skipped
|
|
158
|
+
// the edit branch, so direct assignment is safe.
|
|
159
|
+
defaultLabels: defaults.defaultLabels,
|
|
160
|
+
statusDefaults: defaults.statusDefaults,
|
|
161
|
+
terms: defaults.terms,
|
|
162
|
+
// workspace step: `ws.defaultTeam` may be the existing value (user
|
|
163
|
+
// skipped) or a new pick.
|
|
164
|
+
defaultTeam: ws.defaultTeam,
|
|
165
|
+
// Always merge the team UUID cache (additive, not destructive).
|
|
166
|
+
teams: { ...(existing.teams ?? {}), ...ws.teams },
|
|
167
|
+
// workspaceUrlKey: never clobber an existing manual override.
|
|
168
|
+
workspaceUrlKey: existing.workspaceUrlKey ?? ws.workspaceUrlKey,
|
|
169
|
+
});
|
|
170
|
+
if (aliasUpdates.size > 0) {
|
|
171
|
+
merged = mergeAliasesIntoConfig(merged, aliasUpdates);
|
|
172
|
+
}
|
|
173
|
+
await writeConfig(merged);
|
|
174
|
+
// biome-ignore lint/suspicious/noConsole: wizard
|
|
175
|
+
console.log("\n✓ Setup complete.");
|
|
176
|
+
// biome-ignore lint/suspicious/noConsole: wizard
|
|
177
|
+
console.log(" Token: ~/.config/el-linear/token (mode 0600)");
|
|
178
|
+
// biome-ignore lint/suspicious/noConsole: wizard
|
|
179
|
+
console.log(" Config: ~/.config/el-linear/config.json");
|
|
180
|
+
// biome-ignore lint/suspicious/noConsole: wizard
|
|
181
|
+
console.log("\nTry: el-linear teams list");
|
|
182
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for the `el-linear init` wizard.
|
|
3
|
+
*
|
|
4
|
+
* Every step reads the on-disk config first, shows current state, and defaults
|
|
5
|
+
* to "keep as-is" so re-running the wizard with no input produces a
|
|
6
|
+
* byte-identical config.
|
|
7
|
+
*/
|
|
8
|
+
import type { ElLinearConfig } from "../../config/config.js";
|
|
9
|
+
import { ALIASES_PROGRESS_PATH, CONFIG_PATH, TOKEN_PATH } from "../../config/paths.js";
|
|
10
|
+
export { ALIASES_PROGRESS_PATH, CONFIG_PATH, TOKEN_PATH };
|
|
11
|
+
/** Recursive Partial — sub-objects are also Partial. Arrays/primitives unchanged. */
|
|
12
|
+
type DeepPartial<T> = T extends Array<infer _U> ? T : T extends object ? {
|
|
13
|
+
[K in keyof T]?: DeepPartial<T[K]>;
|
|
14
|
+
} : T;
|
|
15
|
+
/**
|
|
16
|
+
* Shape the wizard reads and writes. `DeepPartial<ElLinearConfig>` because a
|
|
17
|
+
* wizard run may only set a subset of keys at any nesting level (a partial
|
|
18
|
+
* `statusDefaults: { noProject: "Backlog" }` with no `withAssigneeAndProject`
|
|
19
|
+
* is valid on disk; the runtime loader applies fallbacks at read time).
|
|
20
|
+
*
|
|
21
|
+
* The runtime config loader (`src/config/config.ts`) is the canonical source
|
|
22
|
+
* of truth for the on-disk shape; we narrow to a partial view here so the
|
|
23
|
+
* wizard can compose updates without owning the whole tree.
|
|
24
|
+
*
|
|
25
|
+
* The on-disk `config.json` may also contain unknown keys (custom extensions
|
|
26
|
+
* the wizard doesn't recognise); these are preserved verbatim through the
|
|
27
|
+
* `JSON.parse → sortKeys → JSON.stringify` round-trip in `readConfig` /
|
|
28
|
+
* `writeConfig` even though the type doesn't surface them.
|
|
29
|
+
*/
|
|
30
|
+
export type WizardConfig = DeepPartial<ElLinearConfig>;
|
|
31
|
+
export declare function ensureConfigDir(): Promise<void>;
|
|
32
|
+
export declare function readConfig(): Promise<WizardConfig>;
|
|
33
|
+
export declare function writeConfig(config: WizardConfig): Promise<void>;
|
|
34
|
+
export declare function readToken(): Promise<string | null>;
|
|
35
|
+
/**
|
|
36
|
+
* Write the token to disk with mode 0600.
|
|
37
|
+
*
|
|
38
|
+
* IMPORTANT: We use atomicWrite (write-tmp + rename), which guarantees the
|
|
39
|
+
* destination file's mode comes from the freshly-created tmp file — not from
|
|
40
|
+
* any pre-existing token file. This closes a real security hole: `fs.writeFile`
|
|
41
|
+
* with `{mode}` only honors the mode when *creating* a new file, so a legacy
|
|
42
|
+
* token left at 0644 (umask, scp from another machine, migrated from
|
|
43
|
+
* `~/.linear_api_token`) would have stayed world-readable forever otherwise.
|
|
44
|
+
*/
|
|
45
|
+
export declare function writeToken(token: string): Promise<void>;
|
|
46
|
+
/**
|
|
47
|
+
* Build a new object containing only the keys whose values are not `undefined`.
|
|
48
|
+
*
|
|
49
|
+
* Use this anywhere you'd otherwise spread `{ ...existing, key: maybeUndefined }`
|
|
50
|
+
* — `JSON.stringify` skips `undefined` values, so the resulting JSON is fine,
|
|
51
|
+
* but the in-memory merged object has an own `key` property that subsequent
|
|
52
|
+
* reads diverge on. That asymmetry is what made `defaultTeam: undefined` round-
|
|
53
|
+
* trip inconsistently across runs.
|
|
54
|
+
*/
|
|
55
|
+
export declare function assignDefined<T extends Record<string, unknown>>(target: T, updates: {
|
|
56
|
+
[K in keyof T]?: T[K] | undefined;
|
|
57
|
+
}): T;
|
|
58
|
+
/**
|
|
59
|
+
* Progress checkpoint for the resumable user-walk.
|
|
60
|
+
*
|
|
61
|
+
* Stores the UUID of the last user the operator completed (rather than the
|
|
62
|
+
* positional index). On resume we look up that UUID's index in the freshly-
|
|
63
|
+
* fetched user list — so adding or removing users in the workspace between
|
|
64
|
+
* runs no longer silently misaligns the resume point onto the wrong person.
|
|
65
|
+
*
|
|
66
|
+
* `totalUsers` is kept around as a soft sanity check; if both UUID matching
|
|
67
|
+
* and total-count match fail, we fall back to starting over.
|
|
68
|
+
*/
|
|
69
|
+
export interface AliasesProgress {
|
|
70
|
+
/** UUID of the last user the operator finished. */
|
|
71
|
+
lastCompletedUserId: string;
|
|
72
|
+
/** Total user count when the run started — used as a soft drift signal. */
|
|
73
|
+
totalUsers: number;
|
|
74
|
+
/** When the progress was saved. */
|
|
75
|
+
savedAt: string;
|
|
76
|
+
}
|
|
77
|
+
export declare function readAliasesProgress(): Promise<AliasesProgress | null>;
|
|
78
|
+
export declare function writeAliasesProgress(p: AliasesProgress): Promise<void>;
|
|
79
|
+
export declare function clearAliasesProgress(): Promise<void>;
|
|
80
|
+
/**
|
|
81
|
+
* Print a step header in the wizard. Step strings like "1/4" or "2/4 (skipped)".
|
|
82
|
+
*/
|
|
83
|
+
export declare function printStep(label: string, title: string): void;
|
|
84
|
+
/**
|
|
85
|
+
* Parse a comma-separated user input ("alice, ali, alex") into trimmed,
|
|
86
|
+
* non-empty values. Used by the alias and label prompts.
|
|
87
|
+
*/
|
|
88
|
+
export declare function parseCsvList(value: string): string[];
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for the `el-linear init` wizard.
|
|
3
|
+
*
|
|
4
|
+
* Every step reads the on-disk config first, shows current state, and defaults
|
|
5
|
+
* to "keep as-is" so re-running the wizard with no input produces a
|
|
6
|
+
* byte-identical config.
|
|
7
|
+
*/
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
|
+
import fs from "node:fs/promises";
|
|
10
|
+
import { ALIASES_PROGRESS_PATH, CONFIG_DIR, CONFIG_PATH, TOKEN_PATH, } from "../../config/paths.js";
|
|
11
|
+
// Re-export for tests and call sites that already pulled the paths from here.
|
|
12
|
+
export { ALIASES_PROGRESS_PATH, CONFIG_PATH, TOKEN_PATH };
|
|
13
|
+
/**
|
|
14
|
+
* Atomic file write: write to a sibling tmp file then rename. Survives SIGINT,
|
|
15
|
+
* OOM, and laptop suspend mid-write — the original file is either untouched
|
|
16
|
+
* (rename never happened) or fully replaced (rename succeeded). On POSIX
|
|
17
|
+
* same-filesystem, rename is atomic.
|
|
18
|
+
*
|
|
19
|
+
* Tmp suffix uses crypto random bytes so concurrent writers don't collide.
|
|
20
|
+
*
|
|
21
|
+
* @param targetPath destination path
|
|
22
|
+
* @param data bytes/string to write
|
|
23
|
+
* @param mode unix mode for the new file (default 0o644)
|
|
24
|
+
*/
|
|
25
|
+
async function atomicWrite(targetPath, data, mode = 0o644) {
|
|
26
|
+
const tmpPath = `${targetPath}.tmp-${randomBytes(8).toString("hex")}`;
|
|
27
|
+
try {
|
|
28
|
+
await fs.writeFile(tmpPath, data, { encoding: "utf8", mode });
|
|
29
|
+
// Defensively chmod — fs.writeFile only honors `mode` when the file is
|
|
30
|
+
// newly created. Tmp is always new, but be explicit.
|
|
31
|
+
await fs.chmod(tmpPath, mode);
|
|
32
|
+
await fs.rename(tmpPath, targetPath);
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
// Best-effort cleanup of orphaned tmp.
|
|
36
|
+
await fs.unlink(tmpPath).catch(() => { });
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export async function ensureConfigDir() {
|
|
41
|
+
await fs.mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
42
|
+
}
|
|
43
|
+
export async function readConfig() {
|
|
44
|
+
try {
|
|
45
|
+
const raw = await fs.readFile(CONFIG_PATH, "utf8");
|
|
46
|
+
return JSON.parse(raw);
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
if (err.code === "ENOENT") {
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export async function writeConfig(config) {
|
|
56
|
+
await ensureConfigDir();
|
|
57
|
+
// Stable key order so byte-identical config produces byte-identical output.
|
|
58
|
+
const sorted = sortKeys(config);
|
|
59
|
+
await atomicWrite(CONFIG_PATH, `${JSON.stringify(sorted, null, 2)}\n`, 0o644);
|
|
60
|
+
}
|
|
61
|
+
export async function readToken() {
|
|
62
|
+
try {
|
|
63
|
+
const raw = await fs.readFile(TOKEN_PATH, "utf8");
|
|
64
|
+
return raw.trim() || null;
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
if (err.code === "ENOENT") {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Write the token to disk with mode 0600.
|
|
75
|
+
*
|
|
76
|
+
* IMPORTANT: We use atomicWrite (write-tmp + rename), which guarantees the
|
|
77
|
+
* destination file's mode comes from the freshly-created tmp file — not from
|
|
78
|
+
* any pre-existing token file. This closes a real security hole: `fs.writeFile`
|
|
79
|
+
* with `{mode}` only honors the mode when *creating* a new file, so a legacy
|
|
80
|
+
* token left at 0644 (umask, scp from another machine, migrated from
|
|
81
|
+
* `~/.linear_api_token`) would have stayed world-readable forever otherwise.
|
|
82
|
+
*/
|
|
83
|
+
export async function writeToken(token) {
|
|
84
|
+
await ensureConfigDir();
|
|
85
|
+
await atomicWrite(TOKEN_PATH, `${token.trim()}\n`, 0o600);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Build a new object containing only the keys whose values are not `undefined`.
|
|
89
|
+
*
|
|
90
|
+
* Use this anywhere you'd otherwise spread `{ ...existing, key: maybeUndefined }`
|
|
91
|
+
* — `JSON.stringify` skips `undefined` values, so the resulting JSON is fine,
|
|
92
|
+
* but the in-memory merged object has an own `key` property that subsequent
|
|
93
|
+
* reads diverge on. That asymmetry is what made `defaultTeam: undefined` round-
|
|
94
|
+
* trip inconsistently across runs.
|
|
95
|
+
*/
|
|
96
|
+
export function assignDefined(target, updates) {
|
|
97
|
+
const next = { ...target };
|
|
98
|
+
for (const key of Object.keys(updates)) {
|
|
99
|
+
const v = updates[key];
|
|
100
|
+
if (v !== undefined)
|
|
101
|
+
next[key] = v;
|
|
102
|
+
}
|
|
103
|
+
return next;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Recursively sort object keys for stable JSON output. Arrays are left in
|
|
107
|
+
* insertion order; primitive values are returned as-is.
|
|
108
|
+
*/
|
|
109
|
+
function sortKeys(value) {
|
|
110
|
+
if (Array.isArray(value)) {
|
|
111
|
+
return value.map(sortKeys);
|
|
112
|
+
}
|
|
113
|
+
if (value && typeof value === "object" && value.constructor === Object) {
|
|
114
|
+
const out = {};
|
|
115
|
+
for (const k of Object.keys(value).sort()) {
|
|
116
|
+
out[k] = sortKeys(value[k]);
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
return value;
|
|
121
|
+
}
|
|
122
|
+
export async function readAliasesProgress() {
|
|
123
|
+
try {
|
|
124
|
+
const raw = await fs.readFile(ALIASES_PROGRESS_PATH, "utf8");
|
|
125
|
+
return JSON.parse(raw);
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
if (err.code === "ENOENT") {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
throw err;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
export async function writeAliasesProgress(p) {
|
|
135
|
+
await ensureConfigDir();
|
|
136
|
+
await atomicWrite(ALIASES_PROGRESS_PATH, JSON.stringify(p, null, 2), 0o644);
|
|
137
|
+
}
|
|
138
|
+
export async function clearAliasesProgress() {
|
|
139
|
+
try {
|
|
140
|
+
await fs.unlink(ALIASES_PROGRESS_PATH);
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
if (err.code !== "ENOENT") {
|
|
144
|
+
throw err;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Print a step header in the wizard. Step strings like "1/4" or "2/4 (skipped)".
|
|
150
|
+
*/
|
|
151
|
+
export function printStep(label, title) {
|
|
152
|
+
// biome-ignore lint/suspicious/noConsole: wizard output is meant for stdout
|
|
153
|
+
console.log(`\n[${label}] ${title}`);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Parse a comma-separated user input ("alice, ali, alex") into trimmed,
|
|
157
|
+
* non-empty values. Used by the alias and label prompts.
|
|
158
|
+
*/
|
|
159
|
+
export function parseCsvList(value) {
|
|
160
|
+
return value
|
|
161
|
+
.split(",")
|
|
162
|
+
.map((s) => s.trim())
|
|
163
|
+
.filter(Boolean);
|
|
164
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step 1 of the wizard: Linear API token.
|
|
3
|
+
*
|
|
4
|
+
* The only required step. Validates the token by calling `viewer { ... }`
|
|
5
|
+
* before saving. Token is stored at ~/.config/el-linear/token (mode 0600),
|
|
6
|
+
* never embedded in config.json.
|
|
7
|
+
*/
|
|
8
|
+
interface ViewerResponse {
|
|
9
|
+
viewer: {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
email: string;
|
|
13
|
+
displayName: string;
|
|
14
|
+
organization: {
|
|
15
|
+
urlKey: string;
|
|
16
|
+
name: string;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export interface TokenStepResult {
|
|
21
|
+
token: string;
|
|
22
|
+
viewer: ViewerResponse["viewer"];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Strip anything that looks like a Linear API token from a string. Defense in
|
|
26
|
+
* depth: today the @linear/sdk error message embeds {query, variables} but not
|
|
27
|
+
* the Authorization header. A future SDK upgrade that includes headers (which
|
|
28
|
+
* upstream graphql-request has done historically) would otherwise silently
|
|
29
|
+
* write `Bearer lin_api_…` into stdout / shell history / CI logs. The regex
|
|
30
|
+
* also catches token shapes that may show up in custom error wrappers.
|
|
31
|
+
*/
|
|
32
|
+
export declare function sanitizeForLog(text: string): string;
|
|
33
|
+
/**
|
|
34
|
+
* Validate a Linear API token by fetching the viewer. Throws with a
|
|
35
|
+
* sanitized user-readable message on auth failure — the error string is
|
|
36
|
+
* always run through sanitizeForLog so a leaked token in an upstream error
|
|
37
|
+
* is redacted before it hits stdout.
|
|
38
|
+
*/
|
|
39
|
+
export declare function validateToken(token: string): Promise<ViewerResponse["viewer"]>;
|
|
40
|
+
/**
|
|
41
|
+
* Run the interactive token step. Returns the validated token + viewer info.
|
|
42
|
+
*
|
|
43
|
+
* On re-run with an existing valid token, the prompt defaults to "keep" — so
|
|
44
|
+
* pressing enter is a no-op.
|
|
45
|
+
*/
|
|
46
|
+
export declare function runTokenStep(options?: {
|
|
47
|
+
/** Skip the "replace existing?" prompt; always replace if existing is present. */
|
|
48
|
+
force?: boolean;
|
|
49
|
+
}): Promise<TokenStepResult>;
|
|
50
|
+
export {};
|