|
|
|
@@ -3,10 +3,25 @@
|
|
|
|
|
|
|
|
|
|
import { execFileSync } from "node:child_process";
|
|
|
|
|
import { createHash } from "node:crypto";
|
|
|
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
|
|
|
import { isAbsolute, join, normalize, relative, resolve } from "node:path";
|
|
|
|
|
import { rootPath } from "../config";
|
|
|
|
|
import { parseEnvFile } from "../platform-infra-ops-library";
|
|
|
|
|
import { readIssueCommentBody } from "./body-input";
|
|
|
|
|
import { PREVIEW_CHARS } from "./types";
|
|
|
|
|
import { PREVIEW_CHARS, UNIDESK_CLI_CONFIG_PATH } from "./types";
|
|
|
|
|
import type { GitHubOptions, GitHubTokenProbe } from "./types";
|
|
|
|
|
|
|
|
|
|
const GITHUB_TOKEN_SOURCE_CONFIG_REF = `${UNIDESK_CLI_CONFIG_PATH}#github.auth.tokenSource`;
|
|
|
|
|
|
|
|
|
|
interface GitHubYamlTokenSourceConfig {
|
|
|
|
|
enabled: boolean;
|
|
|
|
|
priority: "before-env" | "after-env";
|
|
|
|
|
root: string;
|
|
|
|
|
sourceRef: string;
|
|
|
|
|
sourceKey: string;
|
|
|
|
|
format: "env" | "raw-token";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function readIssueLifecycleCommentBody(options: GitHubOptions, command: string): { body: string; bodySource: Record<string, unknown> } | null {
|
|
|
|
|
if (options.comment === undefined && options.commentFile === undefined) return null;
|
|
|
|
|
if (options.comment !== undefined) {
|
|
|
|
@@ -34,6 +49,50 @@ export function tokenFromEnvironment(): GitHubTokenProbe {
|
|
|
|
|
return { present: false, source: null, ghFallbackAttempted: false };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function tokenFromYamlSource(): { token: string | null; probe: GitHubTokenProbe } {
|
|
|
|
|
const config = readGitHubYamlTokenSourceConfig();
|
|
|
|
|
if (config === null || !config.enabled) {
|
|
|
|
|
return {
|
|
|
|
|
token: null,
|
|
|
|
|
probe: {
|
|
|
|
|
present: false,
|
|
|
|
|
source: null,
|
|
|
|
|
ghFallbackAttempted: false,
|
|
|
|
|
yamlSourceAttempted: true,
|
|
|
|
|
yamlSourceEnabled: config?.enabled ?? false,
|
|
|
|
|
yamlSourcePriority: config?.priority,
|
|
|
|
|
configRef: GITHUB_TOKEN_SOURCE_CONFIG_REF,
|
|
|
|
|
valuesPrinted: false,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
const sourceRoot = resolveYamlSourceRoot(config.root);
|
|
|
|
|
const sourcePath = resolveYamlSourcePath(sourceRoot, config.sourceRef);
|
|
|
|
|
const sourceExists = existsSync(sourcePath);
|
|
|
|
|
const values = sourceExists ? parseYamlTokenSource(readFileSync(sourcePath, "utf8"), config) : {};
|
|
|
|
|
const token = values[config.sourceKey] ?? null;
|
|
|
|
|
const present = token !== null && token.length > 0;
|
|
|
|
|
return {
|
|
|
|
|
token: present ? token : null,
|
|
|
|
|
probe: {
|
|
|
|
|
present,
|
|
|
|
|
source: present ? "yaml-token-source" : null,
|
|
|
|
|
ghFallbackAttempted: false,
|
|
|
|
|
yamlSourceAttempted: true,
|
|
|
|
|
yamlSourceEnabled: true,
|
|
|
|
|
yamlSourcePriority: config.priority,
|
|
|
|
|
configRef: GITHUB_TOKEN_SOURCE_CONFIG_REF,
|
|
|
|
|
sourceRef: config.sourceRef,
|
|
|
|
|
sourceKey: config.sourceKey,
|
|
|
|
|
sourcePath: redactRepoPath(sourcePath),
|
|
|
|
|
sourceExists,
|
|
|
|
|
sourceKeyPresent: present,
|
|
|
|
|
tokenFingerprint: present ? fingerprintToken(token) : null,
|
|
|
|
|
valuesPrinted: false,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function ghBinaryPath(): string | null {
|
|
|
|
|
try {
|
|
|
|
|
const output = execFileSync("sh", ["-lc", "command -v gh"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
|
|
@@ -53,17 +112,103 @@ export function ghAuthToken(): string | null {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function resolveToken(allowGhFallback: boolean): { token: string | null; probe: GitHubTokenProbe } {
|
|
|
|
|
const yamlToken = tokenFromYamlSource();
|
|
|
|
|
if (yamlToken.probe.yamlSourcePriority === "before-env" && yamlToken.probe.present) return yamlToken;
|
|
|
|
|
const envProbe = tokenFromEnvironment();
|
|
|
|
|
if (envProbe.present) {
|
|
|
|
|
const token = envProbe.source === "GH_TOKEN" ? process.env.GH_TOKEN ?? null : process.env.GITHUB_TOKEN ?? null;
|
|
|
|
|
return { token, probe: envProbe };
|
|
|
|
|
}
|
|
|
|
|
if (!allowGhFallback) return { token: null, probe: envProbe };
|
|
|
|
|
if (yamlToken.probe.present) return yamlToken;
|
|
|
|
|
if (!allowGhFallback) return { token: null, probe: yamlToken.probe };
|
|
|
|
|
const ghPath = ghBinaryPath();
|
|
|
|
|
if (ghPath === null) return { token: null, probe: { present: false, source: null, ghFallbackAttempted: true, ghBinaryFound: false, ghAuthTokenAvailable: null } };
|
|
|
|
|
if (ghPath === null) return { token: null, probe: { ...yamlToken.probe, ghFallbackAttempted: true, ghBinaryFound: false, ghAuthTokenAvailable: null } };
|
|
|
|
|
const token = ghAuthToken();
|
|
|
|
|
if (token !== null) return { token, probe: { present: true, source: "gh-auth-token", ghFallbackAttempted: true, ghBinaryFound: true, ghAuthTokenAvailable: true } };
|
|
|
|
|
return { token: null, probe: { present: false, source: null, ghFallbackAttempted: true, ghBinaryFound: true, ghAuthTokenAvailable: false } };
|
|
|
|
|
return { token: null, probe: { ...yamlToken.probe, ghFallbackAttempted: true, ghBinaryFound: true, ghAuthTokenAvailable: false } };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readGitHubYamlTokenSourceConfig(): GitHubYamlTokenSourceConfig | null {
|
|
|
|
|
const configPath = rootPath(UNIDESK_CLI_CONFIG_PATH);
|
|
|
|
|
if (!existsSync(configPath)) return null;
|
|
|
|
|
const parsed = asRecord(Bun.YAML.parse(readFileSync(configPath, "utf8")) as unknown, UNIDESK_CLI_CONFIG_PATH);
|
|
|
|
|
const github = optionalRecord(parsed.github, `${UNIDESK_CLI_CONFIG_PATH}.github`);
|
|
|
|
|
const auth = optionalRecord(github?.auth, `${UNIDESK_CLI_CONFIG_PATH}.github.auth`);
|
|
|
|
|
const tokenSource = optionalRecord(auth?.tokenSource, `${GITHUB_TOKEN_SOURCE_CONFIG_REF}`);
|
|
|
|
|
if (tokenSource === null) return null;
|
|
|
|
|
return {
|
|
|
|
|
enabled: booleanField(tokenSource, "enabled", GITHUB_TOKEN_SOURCE_CONFIG_REF),
|
|
|
|
|
priority: tokenSourcePriorityField(tokenSource, "priority", GITHUB_TOKEN_SOURCE_CONFIG_REF),
|
|
|
|
|
root: stringField(tokenSource, "root", GITHUB_TOKEN_SOURCE_CONFIG_REF),
|
|
|
|
|
sourceRef: stringField(tokenSource, "sourceRef", GITHUB_TOKEN_SOURCE_CONFIG_REF),
|
|
|
|
|
sourceKey: envKeyField(tokenSource, "sourceKey", GITHUB_TOKEN_SOURCE_CONFIG_REF),
|
|
|
|
|
format: tokenSourceFormatField(tokenSource, "format", GITHUB_TOKEN_SOURCE_CONFIG_REF),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseYamlTokenSource(text: string, config: GitHubYamlTokenSourceConfig): Record<string, string> {
|
|
|
|
|
if (config.format === "raw-token") return { [config.sourceKey]: text.trim() };
|
|
|
|
|
return parseEnvFile(text);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolveYamlSourceRoot(root: string): string {
|
|
|
|
|
return isAbsolute(root) ? resolve(root) : rootPath(root);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolveYamlSourcePath(root: string, sourceRef: string): string {
|
|
|
|
|
if (isAbsolute(sourceRef) || sourceRef.includes("..")) throw new Error(`${GITHUB_TOKEN_SOURCE_CONFIG_REF}.sourceRef must be a relative path without ..`);
|
|
|
|
|
const resolved = normalize(join(root, sourceRef));
|
|
|
|
|
if (resolved !== root && !resolved.startsWith(`${root}/`)) throw new Error(`${GITHUB_TOKEN_SOURCE_CONFIG_REF}.sourceRef escapes configured root`);
|
|
|
|
|
return resolved;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function redactRepoPath(value: string): string {
|
|
|
|
|
const root = rootPath();
|
|
|
|
|
return value === root ? "." : value.startsWith(`${root}/`) ? relative(root, value) : value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function fingerprintToken(value: string): string {
|
|
|
|
|
return `sha256:${createHash("sha256").update(value, "utf8").digest("hex")}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function asRecord(value: unknown, path: string): Record<string, unknown> {
|
|
|
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be a YAML object`);
|
|
|
|
|
return value as Record<string, unknown>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function optionalRecord(value: unknown, path: string): Record<string, unknown> | null {
|
|
|
|
|
if (value === undefined || value === null) return null;
|
|
|
|
|
return asRecord(value, path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stringField(obj: Record<string, unknown>, key: string, path: string): string {
|
|
|
|
|
const value = obj[key];
|
|
|
|
|
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path}.${key} must be a non-empty string`);
|
|
|
|
|
return value.trim();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function envKeyField(obj: Record<string, unknown>, key: string, path: string): string {
|
|
|
|
|
const value = stringField(obj, key, path);
|
|
|
|
|
if (!/^[A-Z_][A-Z0-9_]*$/u.test(value)) throw new Error(`${path}.${key} must be an env key`);
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function tokenSourcePriorityField(obj: Record<string, unknown>, key: string, path: string): "before-env" | "after-env" {
|
|
|
|
|
const value = stringField(obj, key, path);
|
|
|
|
|
if (value !== "before-env" && value !== "after-env") throw new Error(`${path}.${key} must be before-env or after-env`);
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function tokenSourceFormatField(obj: Record<string, unknown>, key: string, path: string): "env" | "raw-token" {
|
|
|
|
|
const value = obj[key] === undefined ? "env" : stringField(obj, key, path);
|
|
|
|
|
if (value !== "env" && value !== "raw-token") throw new Error(`${path}.${key} must be env or raw-token`);
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function booleanField(obj: Record<string, unknown>, key: string, path: string): boolean {
|
|
|
|
|
const value = obj[key];
|
|
|
|
|
if (typeof value !== "boolean") throw new Error(`${path}.${key} must be a boolean`);
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function repoParts(repo: string): { owner: string; name: string } {
|
|
|
|
|