team-toon-tack 3.5.0 ā 3.6.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/dist/bin/cli.js +5 -0
- package/dist/scripts/init.js +1 -0
- package/dist/scripts/lib/config-builder.d.ts +1 -1
- package/dist/scripts/lib/config-builder.js +4 -1
- package/dist/scripts/lib/env.d.ts +16 -0
- package/dist/scripts/lib/env.js +86 -0
- package/dist/scripts/lib/init/file-ops.js +22 -16
- package/dist/scripts/lib/init/linear-init.js +44 -10
- package/dist/scripts/lib/init/linear-prompts.d.ts +12 -1
- package/dist/scripts/lib/init/linear-prompts.js +125 -9
- package/dist/scripts/lib/init/types.d.ts +1 -0
- package/dist/scripts/utils.d.ts +2 -0
- package/dist/scripts/utils.js +2 -0
- package/package.json +1 -1
package/dist/bin/cli.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
|
+
import { loadDotEnv, resolveLinearApiKey } from "../scripts/lib/env.js";
|
|
5
6
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
7
|
// When running from dist/bin/cli.js, we need to go up two levels to find package.json
|
|
7
8
|
const pkg = JSON.parse(readFileSync(join(__dirname, "..", "..", "package.json"), "utf-8"));
|
|
@@ -107,6 +108,10 @@ async function main() {
|
|
|
107
108
|
const { dir, commandArgs } = parseGlobalArgs(restArgs);
|
|
108
109
|
// Set TOON_DIR for scripts to use
|
|
109
110
|
process.env.TOON_DIR = dir;
|
|
111
|
+
// Load .ttt/.env (if present) and resolve configured Linear API key env
|
|
112
|
+
// var into LINEAR_API_KEY so downstream code is workspace-aware.
|
|
113
|
+
await loadDotEnv(join(dir, ".env"));
|
|
114
|
+
await resolveLinearApiKey(join(dir, "local.toon"));
|
|
110
115
|
if (!COMMANDS.includes(command)) {
|
|
111
116
|
console.error(`Unknown command: ${command}`);
|
|
112
117
|
console.error(`Run 'ttt help' for usage.`);
|
package/dist/scripts/init.js
CHANGED
|
@@ -39,4 +39,4 @@ export declare function buildConfig(teams: LinearTeam[], users: LinearUser[], la
|
|
|
39
39
|
export declare function findUserKey(usersConfig: Record<string, UserConfig>, userId: string): string;
|
|
40
40
|
export declare function findUserKeys(usersConfig: Record<string, UserConfig>, userIds: string[]): string[];
|
|
41
41
|
export declare function findTeamKey(teamsConfig: Record<string, TeamConfig>, teamId: string): string;
|
|
42
|
-
export declare function buildLocalConfig(currentUserKey: string | string[], devTeamKey: string, devTestingStatus?: string, qaPmTeams?: QaPmTeamConfig[], completionMode?: CompletionMode, defaultLabels?: string[], excludeLabels?: string[], statusSource?: "remote" | "local"): LocalConfig;
|
|
42
|
+
export declare function buildLocalConfig(currentUserKey: string | string[], devTeamKey: string, devTestingStatus?: string, qaPmTeams?: QaPmTeamConfig[], completionMode?: CompletionMode, defaultLabels?: string[], excludeLabels?: string[], statusSource?: "remote" | "local", linearApiKeyEnv?: string): LocalConfig;
|
|
@@ -113,7 +113,7 @@ export function findTeamKey(teamsConfig, teamId) {
|
|
|
113
113
|
return (Object.entries(teamsConfig).find(([_, t]) => t.id === teamId)?.[0] ||
|
|
114
114
|
Object.keys(teamsConfig)[0]);
|
|
115
115
|
}
|
|
116
|
-
export function buildLocalConfig(currentUserKey, devTeamKey, devTestingStatus, qaPmTeams, completionMode, defaultLabels, excludeLabels, statusSource) {
|
|
116
|
+
export function buildLocalConfig(currentUserKey, devTeamKey, devTestingStatus, qaPmTeams, completionMode, defaultLabels, excludeLabels, statusSource, linearApiKeyEnv) {
|
|
117
117
|
return {
|
|
118
118
|
current_user: currentUserKey,
|
|
119
119
|
team: devTeamKey,
|
|
@@ -123,5 +123,8 @@ export function buildLocalConfig(currentUserKey, devTeamKey, devTestingStatus, q
|
|
|
123
123
|
labels: defaultLabels && defaultLabels.length > 0 ? defaultLabels : undefined,
|
|
124
124
|
exclude_labels: excludeLabels && excludeLabels.length > 0 ? excludeLabels : undefined,
|
|
125
125
|
status_source: statusSource,
|
|
126
|
+
linear_api_key_env: linearApiKeyEnv && linearApiKeyEnv !== "LINEAR_API_KEY"
|
|
127
|
+
? linearApiKeyEnv
|
|
128
|
+
: undefined,
|
|
126
129
|
};
|
|
127
130
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal .env file loader and writer.
|
|
3
|
+
* No external dependency. Values already in process.env take precedence
|
|
4
|
+
* over values loaded from the file.
|
|
5
|
+
*/
|
|
6
|
+
/** Populate process.env from envPath. Existing process.env values win. */
|
|
7
|
+
export declare function loadDotEnv(envPath: string): Promise<void>;
|
|
8
|
+
/**
|
|
9
|
+
* Read linear_api_key_env from local.toon and mirror the chosen env var
|
|
10
|
+
* into LINEAR_API_KEY. No-op if local.toon is missing or the field is
|
|
11
|
+
* absent/default. Uses substring parsing to avoid importing the Linear SDK
|
|
12
|
+
* at CLI startup.
|
|
13
|
+
*/
|
|
14
|
+
export declare function resolveLinearApiKey(localPath: string): Promise<void>;
|
|
15
|
+
/** Merge vars into envPath, preserving existing entries. Creates with 0600. */
|
|
16
|
+
export declare function writeDotEnv(envPath: string, vars: Record<string, string>): Promise<void>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal .env file loader and writer.
|
|
3
|
+
* No external dependency. Values already in process.env take precedence
|
|
4
|
+
* over values loaded from the file.
|
|
5
|
+
*/
|
|
6
|
+
import fs from "node:fs/promises";
|
|
7
|
+
const ENV_LINE = /^([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/i;
|
|
8
|
+
function parseEnv(content) {
|
|
9
|
+
const result = {};
|
|
10
|
+
for (const rawLine of content.split("\n")) {
|
|
11
|
+
const line = rawLine.trim();
|
|
12
|
+
if (!line || line.startsWith("#"))
|
|
13
|
+
continue;
|
|
14
|
+
const match = line.match(ENV_LINE);
|
|
15
|
+
if (!match)
|
|
16
|
+
continue;
|
|
17
|
+
let value = match[2];
|
|
18
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
19
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
20
|
+
value = value.slice(1, -1);
|
|
21
|
+
}
|
|
22
|
+
result[match[1]] = value;
|
|
23
|
+
}
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
function quoteIfNeeded(value) {
|
|
27
|
+
if (/[\s"'#]/.test(value)) {
|
|
28
|
+
return `"${value.replace(/"/g, '\\"')}"`;
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
/** Populate process.env from envPath. Existing process.env values win. */
|
|
33
|
+
export async function loadDotEnv(envPath) {
|
|
34
|
+
let content;
|
|
35
|
+
try {
|
|
36
|
+
content = await fs.readFile(envPath, "utf-8");
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const parsed = parseEnv(content);
|
|
42
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
43
|
+
if (process.env[key] === undefined) {
|
|
44
|
+
process.env[key] = value;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Read linear_api_key_env from local.toon and mirror the chosen env var
|
|
50
|
+
* into LINEAR_API_KEY. No-op if local.toon is missing or the field is
|
|
51
|
+
* absent/default. Uses substring parsing to avoid importing the Linear SDK
|
|
52
|
+
* at CLI startup.
|
|
53
|
+
*/
|
|
54
|
+
export async function resolveLinearApiKey(localPath) {
|
|
55
|
+
let content;
|
|
56
|
+
try {
|
|
57
|
+
content = await fs.readFile(localPath, "utf-8");
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const match = content.match(/^linear_api_key_env\s*:\s*["']?([A-Z_][A-Z0-9_]*)["']?\s*$/im);
|
|
63
|
+
const envName = match?.[1];
|
|
64
|
+
if (!envName || envName === "LINEAR_API_KEY")
|
|
65
|
+
return;
|
|
66
|
+
const value = process.env[envName];
|
|
67
|
+
if (value) {
|
|
68
|
+
process.env.LINEAR_API_KEY = value;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Merge vars into envPath, preserving existing entries. Creates with 0600. */
|
|
72
|
+
export async function writeDotEnv(envPath, vars) {
|
|
73
|
+
let existing = {};
|
|
74
|
+
try {
|
|
75
|
+
const content = await fs.readFile(envPath, "utf-8");
|
|
76
|
+
existing = parseEnv(content);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// no existing file
|
|
80
|
+
}
|
|
81
|
+
const merged = { ...existing, ...vars };
|
|
82
|
+
const body = `${Object.entries(merged)
|
|
83
|
+
.map(([k, v]) => `${k}=${quoteIfNeeded(v)}`)
|
|
84
|
+
.join("\n")}\n`;
|
|
85
|
+
await fs.writeFile(envPath, body, { mode: 0o600 });
|
|
86
|
+
}
|
|
@@ -5,7 +5,8 @@ import fs from "node:fs/promises";
|
|
|
5
5
|
import { confirm } from "@inquirer/prompts";
|
|
6
6
|
export async function updateGitignore(tttDir, interactive) {
|
|
7
7
|
const gitignorePath = ".gitignore";
|
|
8
|
-
const
|
|
8
|
+
const tttEntry = `${tttDir}/`;
|
|
9
|
+
const envEntry = ".env";
|
|
9
10
|
try {
|
|
10
11
|
let content = "";
|
|
11
12
|
let exists = false;
|
|
@@ -16,32 +17,37 @@ export async function updateGitignore(tttDir, interactive) {
|
|
|
16
17
|
catch {
|
|
17
18
|
// .gitignore doesn't exist
|
|
18
19
|
}
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
line
|
|
23
|
-
line
|
|
24
|
-
|
|
25
|
-
|
|
20
|
+
const lines = content.split("\n").map((l) => l.trim());
|
|
21
|
+
const hasTtt = lines.some((line) => line === tttEntry ||
|
|
22
|
+
line === tttDir ||
|
|
23
|
+
line === `/${tttEntry}` ||
|
|
24
|
+
line === `/${tttDir}`);
|
|
25
|
+
const hasEnv = lines.some((line) => line === envEntry || line === "/.env");
|
|
26
|
+
const toAdd = [];
|
|
27
|
+
if (!hasTtt)
|
|
28
|
+
toAdd.push(tttEntry);
|
|
29
|
+
if (!hasEnv)
|
|
30
|
+
toAdd.push(envEntry);
|
|
31
|
+
if (toAdd.length === 0)
|
|
26
32
|
return;
|
|
27
|
-
}
|
|
28
|
-
// Ask user in interactive mode
|
|
29
33
|
if (interactive) {
|
|
30
34
|
const addToGitignore = await confirm({
|
|
31
|
-
message: `Add ${
|
|
35
|
+
message: `Add ${toAdd.join(" and ")} to .gitignore?`,
|
|
32
36
|
default: true,
|
|
33
37
|
});
|
|
34
38
|
if (!addToGitignore)
|
|
35
39
|
return;
|
|
36
40
|
}
|
|
37
|
-
|
|
41
|
+
const suffix = `${toAdd.join("\n")}\n`;
|
|
38
42
|
const newContent = exists
|
|
39
43
|
? content.endsWith("\n")
|
|
40
|
-
? `${content}${
|
|
41
|
-
: `${content}\n${
|
|
42
|
-
:
|
|
44
|
+
? `${content}${suffix}`
|
|
45
|
+
: `${content}\n${suffix}`
|
|
46
|
+
: suffix;
|
|
43
47
|
await fs.writeFile(gitignorePath, newContent, "utf-8");
|
|
44
|
-
|
|
48
|
+
for (const entry of toAdd) {
|
|
49
|
+
console.log(` ā Added ${entry} to .gitignore`);
|
|
50
|
+
}
|
|
45
51
|
}
|
|
46
52
|
catch (_error) {
|
|
47
53
|
// Silently ignore gitignore errors
|
|
@@ -2,21 +2,43 @@
|
|
|
2
2
|
* Linear initialization flow
|
|
3
3
|
*/
|
|
4
4
|
import fs from "node:fs/promises";
|
|
5
|
+
import { confirm } from "@inquirer/prompts";
|
|
5
6
|
import { decode, encode } from "@toon-format/toon";
|
|
6
7
|
import { fileExists, getLinearClient, } from "../../utils.js";
|
|
7
8
|
import { buildConfig, buildLocalConfig, buildTeamsConfig, findTeamKey, findUserKey, findUserKeys, } from "../config-builder.js";
|
|
9
|
+
import { writeDotEnv } from "../env.js";
|
|
8
10
|
import { formatTodoStatus } from "../status-helpers.js";
|
|
9
11
|
import { showPluginInstallInstructions, updateGitignore } from "./file-ops.js";
|
|
10
12
|
import { promptForApiKey, selectCompletionMode, selectDevTeam, selectDevTestingStatus, selectQaPmTeams, selectStatusMappings, } from "./linear-prompts.js";
|
|
11
13
|
import { selectLabelFilter, selectStatusSource, selectUsers, } from "./prompts.js";
|
|
12
14
|
export async function initLinear(options, paths) {
|
|
13
|
-
// Get API key
|
|
14
|
-
const
|
|
15
|
-
if (!
|
|
15
|
+
// Get API key (workspace-aware)
|
|
16
|
+
const resolved = await promptForApiKey(options);
|
|
17
|
+
if (!resolved) {
|
|
16
18
|
console.error("Error: LINEAR_API_KEY is required.");
|
|
17
19
|
console.error("Get your API key from: https://linear.app/settings/api");
|
|
18
20
|
process.exit(1);
|
|
19
21
|
}
|
|
22
|
+
const { apiKey, envName, fromSystemEnv } = resolved;
|
|
23
|
+
// Mirror into LINEAR_API_KEY so getLinearClient() picks it up regardless
|
|
24
|
+
// of which env var name the user selected.
|
|
25
|
+
process.env.LINEAR_API_KEY = apiKey;
|
|
26
|
+
// Offer to persist to .ttt/.env. Default yes for freshly entered keys,
|
|
27
|
+
// no for keys already in the system env (user already has them set).
|
|
28
|
+
let saveToEnvFile = false;
|
|
29
|
+
if (options.interactive) {
|
|
30
|
+
saveToEnvFile = await confirm({
|
|
31
|
+
message: fromSystemEnv
|
|
32
|
+
? `Save ${envName} to ${paths.envPath} for this project?`
|
|
33
|
+
: `Save ${envName} to ${paths.envPath}?`,
|
|
34
|
+
default: !fromSystemEnv,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
if (saveToEnvFile) {
|
|
38
|
+
await fs.mkdir(paths.baseDir, { recursive: true });
|
|
39
|
+
await writeDotEnv(paths.envPath, { [envName]: apiKey });
|
|
40
|
+
console.log(` ā Wrote ${envName} to ${paths.envPath}`);
|
|
41
|
+
}
|
|
20
42
|
// Create Linear client
|
|
21
43
|
const client = getLinearClient();
|
|
22
44
|
console.log("\nš” Fetching data from Linear...");
|
|
@@ -120,7 +142,7 @@ export async function initLinear(options, paths) {
|
|
|
120
142
|
const currentUserValue = currentUserKeys.length === 1 ? currentUserKeys[0] : currentUserKeys;
|
|
121
143
|
const devTeamKey = findTeamKey(config.teams, devTeam.id);
|
|
122
144
|
const localConfig = buildLocalConfig(currentUserValue, devTeamKey, devTestingStatus, qaPmTeams, completionMode, defaultLabel, undefined, // excludeLabels
|
|
123
|
-
statusSource);
|
|
145
|
+
statusSource, envName);
|
|
124
146
|
// Write config files
|
|
125
147
|
console.log("\nš Writing configuration files...");
|
|
126
148
|
await fs.mkdir(paths.baseDir, { recursive: true });
|
|
@@ -131,7 +153,9 @@ export async function initLinear(options, paths) {
|
|
|
131
153
|
if (configExists && !options.force) {
|
|
132
154
|
try {
|
|
133
155
|
const existingContent = await fs.readFile(paths.configPath, "utf-8");
|
|
134
|
-
const existingConfig = decode(existingContent, {
|
|
156
|
+
const existingConfig = decode(existingContent, {
|
|
157
|
+
strict: false,
|
|
158
|
+
});
|
|
135
159
|
if (existingConfig.cycle_history) {
|
|
136
160
|
config.cycle_history = existingConfig.cycle_history;
|
|
137
161
|
}
|
|
@@ -152,7 +176,9 @@ export async function initLinear(options, paths) {
|
|
|
152
176
|
if (localExists && !options.force) {
|
|
153
177
|
try {
|
|
154
178
|
const existingContent = await fs.readFile(paths.localPath, "utf-8");
|
|
155
|
-
const existingLocal = decode(existingContent, {
|
|
179
|
+
const existingLocal = decode(existingContent, {
|
|
180
|
+
strict: false,
|
|
181
|
+
});
|
|
156
182
|
if (!options.interactive) {
|
|
157
183
|
if (existingLocal.current_user)
|
|
158
184
|
localConfig.current_user = existingLocal.current_user;
|
|
@@ -170,6 +196,8 @@ export async function initLinear(options, paths) {
|
|
|
170
196
|
localConfig.exclude_labels = existingLocal.exclude_labels;
|
|
171
197
|
if (existingLocal.status_source)
|
|
172
198
|
localConfig.status_source = existingLocal.status_source;
|
|
199
|
+
if (existingLocal.linear_api_key_env)
|
|
200
|
+
localConfig.linear_api_key_env = existingLocal.linear_api_key_env;
|
|
173
201
|
}
|
|
174
202
|
}
|
|
175
203
|
catch {
|
|
@@ -183,6 +211,12 @@ export async function initLinear(options, paths) {
|
|
|
183
211
|
// Summary
|
|
184
212
|
console.log("\nā
Initialization complete!\n");
|
|
185
213
|
console.log("Configuration summary:");
|
|
214
|
+
if (resolved.organizationName) {
|
|
215
|
+
console.log(` Workspace: ${resolved.organizationName} (via ${envName})`);
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
console.log(` API key env: ${envName}`);
|
|
219
|
+
}
|
|
186
220
|
console.log(` Dev Team: ${devTeam.name}`);
|
|
187
221
|
if (selectedUsers.length === 0) {
|
|
188
222
|
console.log(" Users: (all team members)");
|
|
@@ -217,11 +251,11 @@ export async function initLinear(options, paths) {
|
|
|
217
251
|
console.log(` Blocked: ${statusTransitions.blocked}`);
|
|
218
252
|
}
|
|
219
253
|
console.log("\nNext steps:");
|
|
220
|
-
|
|
221
|
-
|
|
254
|
+
const needsShellExport = !saveToEnvFile && !fromSystemEnv;
|
|
255
|
+
if (needsShellExport) {
|
|
222
256
|
const maskedKey = `${apiKey.slice(0, 12)}...${"*".repeat(8)}`;
|
|
223
|
-
console.log(
|
|
224
|
-
console.log(` export
|
|
257
|
+
console.log(` 1. Set ${envName} in your shell profile:`);
|
|
258
|
+
console.log(` export ${envName}="${maskedKey}"`);
|
|
225
259
|
console.log(" 2. Run sync: ttt sync");
|
|
226
260
|
console.log(" 3. Start working: ttt work-on");
|
|
227
261
|
}
|
|
@@ -4,7 +4,18 @@
|
|
|
4
4
|
import type { CompletionMode, QaPmTeamConfig, StatusTransitions } from "../../utils.js";
|
|
5
5
|
import { type LinearState, type LinearTeam } from "../config-builder.js";
|
|
6
6
|
import type { InitOptions } from "./types.js";
|
|
7
|
-
export
|
|
7
|
+
export interface ResolvedApiKey {
|
|
8
|
+
apiKey: string;
|
|
9
|
+
envName: string;
|
|
10
|
+
fromSystemEnv: boolean;
|
|
11
|
+
organizationName?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Detect available Linear API keys from process.env, probe each for its
|
|
15
|
+
* workspace, and let the user pick. Supports adding a new key inline.
|
|
16
|
+
* Returns the chosen key plus the env var name that holds it.
|
|
17
|
+
*/
|
|
18
|
+
export declare function promptForApiKey(options: InitOptions): Promise<ResolvedApiKey | undefined>;
|
|
8
19
|
export declare function selectDevTeam(teams: LinearTeam[], options: InitOptions): Promise<LinearTeam>;
|
|
9
20
|
export declare function selectDevTestingStatus(devStates: LinearState[], options: InitOptions): Promise<string | undefined>;
|
|
10
21
|
export declare function selectQaPmTeams(teams: LinearTeam[], devTeam: LinearTeam, teamStatesMap: Map<string, LinearState[]>, teamsConfig: Record<string, {
|
|
@@ -1,20 +1,136 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Linear-specific prompt functions for init
|
|
3
3
|
*/
|
|
4
|
-
import { checkbox, password, select } from "@inquirer/prompts";
|
|
4
|
+
import { checkbox, input, password, select } from "@inquirer/prompts";
|
|
5
|
+
import { LinearClient } from "@linear/sdk";
|
|
5
6
|
import { getDefaultStatusTransitions, } from "../config-builder.js";
|
|
6
7
|
import { getFirstTodoStatus } from "../status-helpers.js";
|
|
8
|
+
function collectEnvCandidates() {
|
|
9
|
+
const candidates = [];
|
|
10
|
+
for (const [name, value] of Object.entries(process.env)) {
|
|
11
|
+
if (!value)
|
|
12
|
+
continue;
|
|
13
|
+
if (name === "LINEAR_API_KEY" || name.startsWith("LINEAR_API_KEY_")) {
|
|
14
|
+
if (value.startsWith("lin_api_")) {
|
|
15
|
+
candidates.push({ envName: name, apiKey: value });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return candidates;
|
|
20
|
+
}
|
|
21
|
+
async function probeWorkspace(candidate) {
|
|
22
|
+
try {
|
|
23
|
+
const client = new LinearClient({ apiKey: candidate.apiKey });
|
|
24
|
+
const org = await client.organization;
|
|
25
|
+
candidate.orgName = org.name;
|
|
26
|
+
candidate.orgUrlKey = org.urlKey;
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
candidate.error = err instanceof Error ? err.message : String(err);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function validateApiKey(v) {
|
|
33
|
+
return v.startsWith("lin_api_")
|
|
34
|
+
? true
|
|
35
|
+
: 'API key should start with "lin_api_"';
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Detect available Linear API keys from process.env, probe each for its
|
|
39
|
+
* workspace, and let the user pick. Supports adding a new key inline.
|
|
40
|
+
* Returns the chosen key plus the env var name that holds it.
|
|
41
|
+
*/
|
|
7
42
|
export async function promptForApiKey(options) {
|
|
8
|
-
|
|
9
|
-
if (!
|
|
10
|
-
apiKey =
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
43
|
+
// Non-interactive: use --api-key or LINEAR_API_KEY, no selection possible
|
|
44
|
+
if (!options.interactive) {
|
|
45
|
+
const apiKey = options.apiKey || process.env.LINEAR_API_KEY;
|
|
46
|
+
if (!apiKey)
|
|
47
|
+
return undefined;
|
|
48
|
+
return {
|
|
49
|
+
apiKey,
|
|
50
|
+
envName: "LINEAR_API_KEY",
|
|
51
|
+
fromSystemEnv: !options.apiKey && !!process.env.LINEAR_API_KEY,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
// --api-key flag short-circuits the workspace picker
|
|
55
|
+
if (options.apiKey) {
|
|
56
|
+
return {
|
|
57
|
+
apiKey: options.apiKey,
|
|
58
|
+
envName: "LINEAR_API_KEY",
|
|
59
|
+
fromSystemEnv: false,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const envCandidates = collectEnvCandidates();
|
|
63
|
+
if (envCandidates.length > 0) {
|
|
64
|
+
console.log(`\nš Found ${envCandidates.length} Linear API key${envCandidates.length > 1 ? "s" : ""} in environment. Probing workspaces...`);
|
|
65
|
+
await Promise.all(envCandidates.map(probeWorkspace));
|
|
66
|
+
for (const c of envCandidates) {
|
|
67
|
+
if (c.error) {
|
|
68
|
+
console.log(` ā ${c.envName} ā error: ${c.error}`);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
console.log(` ā ${c.envName} ā ${c.orgName ?? "?"}${c.orgUrlKey ? ` (${c.orgUrlKey})` : ""}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const valid = envCandidates.filter((c) => !c.error);
|
|
75
|
+
const choices = [
|
|
76
|
+
...valid.map((c) => ({
|
|
77
|
+
name: `${c.orgName ?? "(unknown)"} [${c.envName}]`,
|
|
78
|
+
value: c.envName,
|
|
79
|
+
description: c.orgUrlKey ? `${c.orgUrlKey}.linear.app` : undefined,
|
|
80
|
+
})),
|
|
81
|
+
{ name: "+ Enter a different API key", value: "__new__" },
|
|
82
|
+
];
|
|
83
|
+
const chosen = await select({
|
|
84
|
+
message: "Select workspace:",
|
|
85
|
+
choices,
|
|
15
86
|
});
|
|
87
|
+
if (chosen !== "__new__") {
|
|
88
|
+
const picked = valid.find((c) => c.envName === chosen);
|
|
89
|
+
if (picked) {
|
|
90
|
+
return {
|
|
91
|
+
apiKey: picked.apiKey,
|
|
92
|
+
envName: picked.envName,
|
|
93
|
+
fromSystemEnv: true,
|
|
94
|
+
organizationName: picked.orgName,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
16
98
|
}
|
|
17
|
-
|
|
99
|
+
// No env keys, or user chose to enter a new one
|
|
100
|
+
const apiKey = await password({
|
|
101
|
+
message: "Enter your Linear API key:",
|
|
102
|
+
validate: validateApiKey,
|
|
103
|
+
});
|
|
104
|
+
const newCandidate = {
|
|
105
|
+
envName: "LINEAR_API_KEY",
|
|
106
|
+
apiKey,
|
|
107
|
+
};
|
|
108
|
+
await probeWorkspace(newCandidate);
|
|
109
|
+
if (newCandidate.error) {
|
|
110
|
+
console.error(` ā Failed to fetch workspace: ${newCandidate.error}`);
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
console.log(` ā Workspace: ${newCandidate.orgName}${newCandidate.orgUrlKey ? ` (${newCandidate.orgUrlKey})` : ""}`);
|
|
114
|
+
// Ask for env var name (default to a slugged workspace name if LINEAR_API_KEY is taken)
|
|
115
|
+
const defaultName = process.env.LINEAR_API_KEY
|
|
116
|
+
? `LINEAR_API_KEY_${(newCandidate.orgUrlKey ??
|
|
117
|
+
newCandidate.orgName ??
|
|
118
|
+
"ALT")
|
|
119
|
+
.toUpperCase()
|
|
120
|
+
.replace(/[^A-Z0-9]/g, "_")}`
|
|
121
|
+
: "LINEAR_API_KEY";
|
|
122
|
+
const envName = await input({
|
|
123
|
+
message: "Env variable name to store this key under:",
|
|
124
|
+
default: defaultName,
|
|
125
|
+
validate: (v) => /^[A-Z_][A-Z0-9_]*$/.test(v) ||
|
|
126
|
+
"Env var names must be uppercase letters, digits, underscore",
|
|
127
|
+
});
|
|
128
|
+
return {
|
|
129
|
+
apiKey,
|
|
130
|
+
envName,
|
|
131
|
+
fromSystemEnv: false,
|
|
132
|
+
organizationName: newCandidate.orgName,
|
|
133
|
+
};
|
|
18
134
|
}
|
|
19
135
|
export async function selectDevTeam(teams, options) {
|
|
20
136
|
let devTeam = teams[0];
|
package/dist/scripts/utils.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export declare function getPaths(): {
|
|
|
6
6
|
cyclePath: string;
|
|
7
7
|
localPath: string;
|
|
8
8
|
outputPath: string;
|
|
9
|
+
envPath: string;
|
|
9
10
|
};
|
|
10
11
|
export interface TeamConfig {
|
|
11
12
|
id: string;
|
|
@@ -126,6 +127,7 @@ export interface LocalConfig {
|
|
|
126
127
|
exclude_labels?: string[];
|
|
127
128
|
labels?: string[];
|
|
128
129
|
status_source?: "remote" | "local";
|
|
130
|
+
linear_api_key_env?: string;
|
|
129
131
|
teams?: string[];
|
|
130
132
|
qa_pm_team?: string;
|
|
131
133
|
}
|
package/dist/scripts/utils.js
CHANGED
|
@@ -22,6 +22,7 @@ const CONFIG_PATH = path.join(BASE_DIR, "config.toon");
|
|
|
22
22
|
const CYCLE_PATH = path.join(BASE_DIR, "cycle.toon");
|
|
23
23
|
const LOCAL_PATH = path.join(BASE_DIR, "local.toon");
|
|
24
24
|
const OUTPUT_PATH = path.join(BASE_DIR, "output");
|
|
25
|
+
const ENV_PATH = path.join(BASE_DIR, ".env");
|
|
25
26
|
export function getPaths() {
|
|
26
27
|
return {
|
|
27
28
|
baseDir: BASE_DIR,
|
|
@@ -29,6 +30,7 @@ export function getPaths() {
|
|
|
29
30
|
cyclePath: CYCLE_PATH,
|
|
30
31
|
localPath: LOCAL_PATH,
|
|
31
32
|
outputPath: OUTPUT_PATH,
|
|
33
|
+
envPath: ENV_PATH,
|
|
32
34
|
};
|
|
33
35
|
}
|
|
34
36
|
// Linear priority value to name mapping (fixed by Linear API)
|