@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.
Files changed (151) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +219 -0
  3. package/claude-skills/linear-operations/SKILL.md +315 -0
  4. package/claude-skills/linear-operations/evals/evals.json +46 -0
  5. package/dist/commands/attachments.d.ts +2 -0
  6. package/dist/commands/attachments.js +57 -0
  7. package/dist/commands/batch.d.ts +2 -0
  8. package/dist/commands/batch.js +309 -0
  9. package/dist/commands/comments.d.ts +2 -0
  10. package/dist/commands/comments.js +272 -0
  11. package/dist/commands/config.d.ts +2 -0
  12. package/dist/commands/config.js +15 -0
  13. package/dist/commands/cycles.d.ts +2 -0
  14. package/dist/commands/cycles.js +63 -0
  15. package/dist/commands/documents.d.ts +2 -0
  16. package/dist/commands/documents.js +175 -0
  17. package/dist/commands/embeds.d.ts +2 -0
  18. package/dist/commands/embeds.js +65 -0
  19. package/dist/commands/gdoc.d.ts +2 -0
  20. package/dist/commands/gdoc.js +37 -0
  21. package/dist/commands/graphql.d.ts +2 -0
  22. package/dist/commands/graphql.js +70 -0
  23. package/dist/commands/init/aliases.d.ts +109 -0
  24. package/dist/commands/init/aliases.js +569 -0
  25. package/dist/commands/init/defaults.d.ts +25 -0
  26. package/dist/commands/init/defaults.js +112 -0
  27. package/dist/commands/init/index.d.ts +18 -0
  28. package/dist/commands/init/index.js +182 -0
  29. package/dist/commands/init/shared.d.ts +88 -0
  30. package/dist/commands/init/shared.js +164 -0
  31. package/dist/commands/init/token.d.ts +50 -0
  32. package/dist/commands/init/token.js +141 -0
  33. package/dist/commands/init/workspace.d.ts +20 -0
  34. package/dist/commands/init/workspace.js +80 -0
  35. package/dist/commands/issue-id.d.ts +28 -0
  36. package/dist/commands/issue-id.js +81 -0
  37. package/dist/commands/issues.d.ts +2 -0
  38. package/dist/commands/issues.js +1145 -0
  39. package/dist/commands/labels.d.ts +2 -0
  40. package/dist/commands/labels.js +100 -0
  41. package/dist/commands/project-milestones.d.ts +2 -0
  42. package/dist/commands/project-milestones.js +143 -0
  43. package/dist/commands/projects.d.ts +2 -0
  44. package/dist/commands/projects.js +336 -0
  45. package/dist/commands/read-shortcut.d.ts +6 -0
  46. package/dist/commands/read-shortcut.js +69 -0
  47. package/dist/commands/releases.d.ts +2 -0
  48. package/dist/commands/releases.js +142 -0
  49. package/dist/commands/search.d.ts +2 -0
  50. package/dist/commands/search.js +171 -0
  51. package/dist/commands/teams.d.ts +2 -0
  52. package/dist/commands/teams.js +19 -0
  53. package/dist/commands/templates.d.ts +2 -0
  54. package/dist/commands/templates.js +58 -0
  55. package/dist/commands/users.d.ts +2 -0
  56. package/dist/commands/users.js +17 -0
  57. package/dist/config/config.d.ts +43 -0
  58. package/dist/config/config.js +81 -0
  59. package/dist/config/issue-validation.d.ts +39 -0
  60. package/dist/config/issue-validation.js +264 -0
  61. package/dist/config/paths.d.ts +20 -0
  62. package/dist/config/paths.js +22 -0
  63. package/dist/config/resolver.d.ts +25 -0
  64. package/dist/config/resolver.js +183 -0
  65. package/dist/config/status-defaults.d.ts +13 -0
  66. package/dist/config/status-defaults.js +20 -0
  67. package/dist/config/term-enforcer.d.ts +31 -0
  68. package/dist/config/term-enforcer.js +69 -0
  69. package/dist/main.d.ts +2 -0
  70. package/dist/main.js +76 -0
  71. package/dist/queries/attachments.d.ts +3 -0
  72. package/dist/queries/attachments.js +35 -0
  73. package/dist/queries/comments.d.ts +3 -0
  74. package/dist/queries/comments.js +64 -0
  75. package/dist/queries/common.d.ts +2 -0
  76. package/dist/queries/common.js +109 -0
  77. package/dist/queries/cycles.d.ts +2 -0
  78. package/dist/queries/cycles.js +40 -0
  79. package/dist/queries/documents.d.ts +5 -0
  80. package/dist/queries/documents.js +67 -0
  81. package/dist/queries/introspect.d.ts +2 -0
  82. package/dist/queries/introspect.js +24 -0
  83. package/dist/queries/issues.d.ts +23 -0
  84. package/dist/queries/issues.js +380 -0
  85. package/dist/queries/labels.d.ts +4 -0
  86. package/dist/queries/labels.js +53 -0
  87. package/dist/queries/project-milestones.d.ts +6 -0
  88. package/dist/queries/project-milestones.js +125 -0
  89. package/dist/queries/projects.d.ts +7 -0
  90. package/dist/queries/projects.js +104 -0
  91. package/dist/queries/releases.d.ts +4 -0
  92. package/dist/queries/releases.js +85 -0
  93. package/dist/queries/search.d.ts +1 -0
  94. package/dist/queries/search.js +35 -0
  95. package/dist/queries/templates.d.ts +2 -0
  96. package/dist/queries/templates.js +30 -0
  97. package/dist/types/linear.d.ts +217 -0
  98. package/dist/types/linear.js +6 -0
  99. package/dist/utils/auth.d.ts +4 -0
  100. package/dist/utils/auth.js +23 -0
  101. package/dist/utils/auto-link-references.d.ts +47 -0
  102. package/dist/utils/auto-link-references.js +188 -0
  103. package/dist/utils/date-format.d.ts +4 -0
  104. package/dist/utils/date-format.js +8 -0
  105. package/dist/utils/download-uploads.d.ts +7 -0
  106. package/dist/utils/download-uploads.js +88 -0
  107. package/dist/utils/embed-parser.d.ts +8 -0
  108. package/dist/utils/embed-parser.js +54 -0
  109. package/dist/utils/error-messages.d.ts +4 -0
  110. package/dist/utils/error-messages.js +17 -0
  111. package/dist/utils/file-service.d.ts +13 -0
  112. package/dist/utils/file-service.js +239 -0
  113. package/dist/utils/gdoc-parser.d.ts +45 -0
  114. package/dist/utils/gdoc-parser.js +107 -0
  115. package/dist/utils/graphql-attachments-service.d.ts +12 -0
  116. package/dist/utils/graphql-attachments-service.js +46 -0
  117. package/dist/utils/graphql-documents-service.d.ts +18 -0
  118. package/dist/utils/graphql-documents-service.js +97 -0
  119. package/dist/utils/graphql-issues-service.d.ts +49 -0
  120. package/dist/utils/graphql-issues-service.js +925 -0
  121. package/dist/utils/graphql-service.d.ts +8 -0
  122. package/dist/utils/graphql-service.js +35 -0
  123. package/dist/utils/identifier-parser.d.ts +7 -0
  124. package/dist/utils/identifier-parser.js +20 -0
  125. package/dist/utils/issue-reference-extractor.d.ts +18 -0
  126. package/dist/utils/issue-reference-extractor.js +95 -0
  127. package/dist/utils/issue-reference-wrapper.d.ts +12 -0
  128. package/dist/utils/issue-reference-wrapper.js +91 -0
  129. package/dist/utils/linear-service.d.ts +26 -0
  130. package/dist/utils/linear-service.js +442 -0
  131. package/dist/utils/logger.d.ts +4 -0
  132. package/dist/utils/logger.js +8 -0
  133. package/dist/utils/markdown-prosemirror.d.ts +24 -0
  134. package/dist/utils/markdown-prosemirror.js +325 -0
  135. package/dist/utils/mention-resolver.d.ts +31 -0
  136. package/dist/utils/mention-resolver.js +234 -0
  137. package/dist/utils/output.d.ts +7 -0
  138. package/dist/utils/output.js +125 -0
  139. package/dist/utils/table-formatter.d.ts +4 -0
  140. package/dist/utils/table-formatter.js +249 -0
  141. package/dist/utils/usage.d.ts +2 -0
  142. package/dist/utils/usage.js +24 -0
  143. package/dist/utils/uuid.d.ts +2 -0
  144. package/dist/utils/uuid.js +8 -0
  145. package/dist/utils/validate-references.d.ts +10 -0
  146. package/dist/utils/validate-references.js +33 -0
  147. package/dist/utils/validators.d.ts +7 -0
  148. package/dist/utils/validators.js +71 -0
  149. package/dist/utils/workspace-url.d.ts +4 -0
  150. package/dist/utils/workspace-url.js +44 -0
  151. 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 {};