team-toon-tack 3.5.0 ā 3.6.2
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 +43 -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,42 @@
|
|
|
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 project-root .env so dotenv-aware tools (and future
|
|
27
|
+
// ttt runs) can reuse it without relying on the user's shell profile.
|
|
28
|
+
console.log("\nš Local .env file:");
|
|
29
|
+
console.log(` Path: ${paths.envPath}${fromSystemEnv ? " (you already have this key in your shell ā this just pins it per-project)" : ""}`);
|
|
30
|
+
let saveToEnvFile = false;
|
|
31
|
+
if (options.interactive) {
|
|
32
|
+
saveToEnvFile = await confirm({
|
|
33
|
+
message: `Write ${envName} to ${paths.envPath}?`,
|
|
34
|
+
default: true,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
if (saveToEnvFile) {
|
|
38
|
+
await writeDotEnv(paths.envPath, { [envName]: apiKey });
|
|
39
|
+
console.log(` ā Wrote ${envName} to ${paths.envPath}`);
|
|
40
|
+
}
|
|
20
41
|
// Create Linear client
|
|
21
42
|
const client = getLinearClient();
|
|
22
43
|
console.log("\nš” Fetching data from Linear...");
|
|
@@ -120,7 +141,7 @@ export async function initLinear(options, paths) {
|
|
|
120
141
|
const currentUserValue = currentUserKeys.length === 1 ? currentUserKeys[0] : currentUserKeys;
|
|
121
142
|
const devTeamKey = findTeamKey(config.teams, devTeam.id);
|
|
122
143
|
const localConfig = buildLocalConfig(currentUserValue, devTeamKey, devTestingStatus, qaPmTeams, completionMode, defaultLabel, undefined, // excludeLabels
|
|
123
|
-
statusSource);
|
|
144
|
+
statusSource, envName);
|
|
124
145
|
// Write config files
|
|
125
146
|
console.log("\nš Writing configuration files...");
|
|
126
147
|
await fs.mkdir(paths.baseDir, { recursive: true });
|
|
@@ -131,7 +152,9 @@ export async function initLinear(options, paths) {
|
|
|
131
152
|
if (configExists && !options.force) {
|
|
132
153
|
try {
|
|
133
154
|
const existingContent = await fs.readFile(paths.configPath, "utf-8");
|
|
134
|
-
const existingConfig = decode(existingContent, {
|
|
155
|
+
const existingConfig = decode(existingContent, {
|
|
156
|
+
strict: false,
|
|
157
|
+
});
|
|
135
158
|
if (existingConfig.cycle_history) {
|
|
136
159
|
config.cycle_history = existingConfig.cycle_history;
|
|
137
160
|
}
|
|
@@ -152,7 +175,9 @@ export async function initLinear(options, paths) {
|
|
|
152
175
|
if (localExists && !options.force) {
|
|
153
176
|
try {
|
|
154
177
|
const existingContent = await fs.readFile(paths.localPath, "utf-8");
|
|
155
|
-
const existingLocal = decode(existingContent, {
|
|
178
|
+
const existingLocal = decode(existingContent, {
|
|
179
|
+
strict: false,
|
|
180
|
+
});
|
|
156
181
|
if (!options.interactive) {
|
|
157
182
|
if (existingLocal.current_user)
|
|
158
183
|
localConfig.current_user = existingLocal.current_user;
|
|
@@ -170,6 +195,8 @@ export async function initLinear(options, paths) {
|
|
|
170
195
|
localConfig.exclude_labels = existingLocal.exclude_labels;
|
|
171
196
|
if (existingLocal.status_source)
|
|
172
197
|
localConfig.status_source = existingLocal.status_source;
|
|
198
|
+
if (existingLocal.linear_api_key_env)
|
|
199
|
+
localConfig.linear_api_key_env = existingLocal.linear_api_key_env;
|
|
173
200
|
}
|
|
174
201
|
}
|
|
175
202
|
catch {
|
|
@@ -183,6 +210,12 @@ export async function initLinear(options, paths) {
|
|
|
183
210
|
// Summary
|
|
184
211
|
console.log("\nā
Initialization complete!\n");
|
|
185
212
|
console.log("Configuration summary:");
|
|
213
|
+
if (resolved.organizationName) {
|
|
214
|
+
console.log(` Workspace: ${resolved.organizationName} (via ${envName})`);
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
console.log(` API key env: ${envName}`);
|
|
218
|
+
}
|
|
186
219
|
console.log(` Dev Team: ${devTeam.name}`);
|
|
187
220
|
if (selectedUsers.length === 0) {
|
|
188
221
|
console.log(" Users: (all team members)");
|
|
@@ -217,11 +250,11 @@ export async function initLinear(options, paths) {
|
|
|
217
250
|
console.log(` Blocked: ${statusTransitions.blocked}`);
|
|
218
251
|
}
|
|
219
252
|
console.log("\nNext steps:");
|
|
220
|
-
|
|
221
|
-
|
|
253
|
+
const needsShellExport = !saveToEnvFile && !fromSystemEnv;
|
|
254
|
+
if (needsShellExport) {
|
|
222
255
|
const maskedKey = `${apiKey.slice(0, 12)}...${"*".repeat(8)}`;
|
|
223
|
-
console.log(
|
|
224
|
-
console.log(` export
|
|
256
|
+
console.log(` 1. Set ${envName} in your shell profile:`);
|
|
257
|
+
console.log(` export ${envName}="${maskedKey}"`);
|
|
225
258
|
console.log(" 2. Run sync: ttt sync");
|
|
226
259
|
console.log(" 3. Start working: ttt work-on");
|
|
227
260
|
}
|
|
@@ -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)
|