import { exec, execFile } from 'child_process';
|
import { promisify } from 'util';
|
import * as fs from 'fs';
|
import * as path from 'path';
|
import {
|
abgleichMisserfolg,
|
alsIsoDatum,
|
dateiVorhanden,
|
ersetzeDateiSicher,
|
inhaltBeanstanden,
|
type Abgleichauftrag,
|
type Bezugsquelle,
|
type GeheimnisBedarf,
|
type QuellenAbgleich,
|
} from './basis';
|
|
const execAsync = promisify(exec);
|
const execFileAsync = promisify(execFile);
|
|
/** Ein Bau darf lange dauern (z. B. PyInstaller mit vielen Abhängigkeiten). */
|
const BAU_ZEITGRENZE_MS = 30 * 60 * 1000;
|
const GIT_ZEITGRENZE_MS = 10 * 60 * 1000;
|
|
/** Name der Bauanleitung, die ein Repository selbst mitbringt. */
|
export const BAUANLEITUNG_DATEI = 'software-bibliothek.json';
|
|
interface Bauanleitung {
|
bauBefehl: string;
|
ergebnis: string;
|
geheimnisse: GeheimnisBedarf[];
|
}
|
|
/**
|
* Aufbau der Anleitung, wie sie im Repository steht. Die Feldnamen sind englisch,
|
* weil sie ein Vertrag nach außen sind — jedes anzubindende Tool schreibt sie.
|
* Beschrieben im Skill „build-secrets" des Repos dh-claude-rules.
|
*/
|
interface AnleitungImRepo {
|
buildCommand?: string;
|
artifact?: string;
|
secrets?: Array<{ name?: string; description?: string }>;
|
}
|
|
/**
|
* Interne Tools aus einem Git-Repository: Arbeitskopie holen bzw. aktualisieren,
|
* bauen und das erzeugte Programm bereitstellen.
|
*
|
* Angabe der Quelle: nur die Repo-URL, optional mit `#zweig` (z. B. `ssh://…/tool.git#main`).
|
* Wie gebaut wird, beschreibt das Repository selbst in `software-bibliothek.json`:
|
*
|
* { "buildCommand": "powershell -ExecutionPolicy Bypass -File build_exe.ps1",
|
* "artifact": "dist/MeinTool.exe" }
|
*
|
* So bleibt die Bauanleitung dort, wo sie hingehört — beim Tool. In der Bibliothek
|
* lassen sich beide Angaben bei Bedarf überschreiben (für fremde Repos, die man
|
* nicht ändern kann).
|
*
|
* Die Arbeitskopie legt die Anwendung selbst an (`<Datenordner>\quellen\<id>`) — es
|
* wird nichts vorausgesetzt, was auf dem jeweiligen Rechner schon vorhanden sein müsste.
|
*
|
* Hinweis zur Sicherheit: Der Bau-Befehl wird auf dem Server mit den Rechten des
|
* Dienstbenutzers ausgeführt. Pflegen kann ihn nur, wer lokal am Server arbeitet
|
* bzw. wer ins Repository schreiben darf.
|
*/
|
export class GitBuildQuelle implements Bezugsquelle {
|
async gleicheAb(auftrag: Abgleichauftrag): Promise<QuellenAbgleich> {
|
const [repoUrl, gewuenschterZweig] = auftrag.wert.split('#');
|
let commit = '';
|
try {
|
commit = await arbeitskopieAktualisieren(repoUrl.trim(), gewuenschterZweig?.trim() ?? '', auftrag.arbeitsordner, auftrag.gitSshBefehl);
|
} catch (fehler) {
|
return abgleichMisserfolg(auftrag.aktuelleVersion, gitFehlertext(fehler));
|
}
|
|
const veroeffentlichtAm = await commitZeitpunkt(auftrag.arbeitsordner);
|
const anleitung = leseBauanleitung(auftrag);
|
if (!anleitung.bauBefehl || !anleitung.ergebnis) {
|
return abgleichMisserfolg(
|
auftrag.aktuelleVersion,
|
`Im Repository fehlt die Datei ${BAUANLEITUNG_DATEI} mit „buildCommand" und „artifact" — alternativ beides in der Bibliothek hinterlegen.`
|
);
|
}
|
|
// Nur bauen, wenn sich seit dem letzten Mal etwas geändert hat.
|
const bekannterDateiname = ergebnisDateiname(anleitung.ergebnis);
|
if (commit === auftrag.aktuelleVersion && dateiVorhanden(auftrag.zielordner, bekannterDateiname)) {
|
return { erfolg: true, geaendert: false, version: commit, dateiname: bekannterDateiname, meldung: 'Bereits aktuell (unveränderter Stand).', veroeffentlichtAm, benoetigteGeheimnisse: anleitung.geheimnisse };
|
}
|
|
const fehlende = anleitung.geheimnisse.filter((bedarf) => !auftrag.geheimnisse[bedarf.name]).map((bedarf) => bedarf.name);
|
try {
|
// Geheimnisse gehen als Umgebungsvariablen in den Bau — das Tool entscheidet selbst,
|
// was es daraus macht. Sie landen dadurch nirgends auf der Platte.
|
await execAsync(anleitung.bauBefehl, {
|
cwd: auftrag.arbeitsordner,
|
timeout: BAU_ZEITGRENZE_MS,
|
windowsHide: true,
|
maxBuffer: 32 * 1024 * 1024,
|
env: { ...process.env, ...auftrag.geheimnisse },
|
});
|
} catch (fehler) {
|
const hinweis = fehlende.length > 0 ? ` Nicht hinterlegte Schlüssel: ${fehlende.join(', ')}.` : '';
|
return abgleichMisserfolg(auftrag.aktuelleVersion, `Bau fehlgeschlagen: ${kurzeFehlermeldung(fehler)}${hinweis}`);
|
}
|
|
const ergebnisPfad = findeErgebnis(auftrag.arbeitsordner, anleitung.ergebnis);
|
if (!ergebnisPfad) {
|
return abgleichMisserfolg(auftrag.aktuelleVersion, `Der Bau lief durch, aber unter „${anleitung.ergebnis}" liegt keine Datei.`);
|
}
|
|
const dateiname = path.basename(ergebnisPfad);
|
const inhalt = fs.readFileSync(ergebnisPfad);
|
const beanstandung = inhaltBeanstanden(inhalt, dateiname, null, auftrag.vorherigeGroesse);
|
if (beanstandung) {
|
return abgleichMisserfolg(auftrag.aktuelleVersion, beanstandung);
|
}
|
|
ersetzeDateiSicher(auftrag.zielordner, dateiname, inhalt);
|
return {
|
erfolg: true,
|
geaendert: true,
|
version: commit,
|
dateiname,
|
meldung: `Aus Git gebaut (Stand ${commit.slice(0, 8)}).`,
|
veroeffentlichtAm,
|
benoetigteGeheimnisse: anleitung.geheimnisse,
|
};
|
}
|
}
|
|
/**
|
* Bauanleitung ermitteln: bevorzugt aus dem Repository selbst, ersatzweise aus den
|
* in der Bibliothek hinterlegten Angaben (die dort gepflegten haben Vorrang, damit
|
* sich fremde Repos übersteuern lassen).
|
*/
|
function leseBauanleitung(auftrag: Abgleichauftrag): Bauanleitung {
|
let ausRepo: AnleitungImRepo = {};
|
const anleitungsPfad = path.join(auftrag.arbeitsordner, BAUANLEITUNG_DATEI);
|
if (fs.existsSync(anleitungsPfad)) {
|
try {
|
ausRepo = JSON.parse(fs.readFileSync(anleitungsPfad, 'utf8')) as AnleitungImRepo;
|
} catch {
|
// Unlesbare Anleitung wie „nicht vorhanden" behandeln — die Angaben aus der
|
// Bibliothek können den Bau trotzdem ermöglichen.
|
}
|
}
|
const angeforderte = Array.isArray(ausRepo.secrets) ? ausRepo.secrets : [];
|
return {
|
bauBefehl: auftrag.bauBefehl.trim() || (ausRepo.buildCommand ?? '').trim(),
|
ergebnis: auftrag.bauErgebnis.trim() || (ausRepo.artifact ?? '').trim(),
|
geheimnisse: angeforderte
|
.filter((bedarf): bedarf is { name: string; description?: string } => typeof bedarf?.name === 'string' && !!bedarf.name.trim())
|
.map((bedarf) => ({ name: bedarf.name.trim(), beschreibung: String(bedarf.description ?? '') })),
|
};
|
}
|
|
/** Klont die Arbeitskopie oder bringt sie auf den neuesten Stand. Liefert den Commit. */
|
async function arbeitskopieAktualisieren(repoUrl: string, gewuenschterZweig: string, arbeitsordner: string, sshBefehl: string): Promise<string> {
|
// Die Kennung kommt aus den Einstellungen, nicht aus der URL — so bleibt der
|
// Katalogeintrag frei von Konto-Angaben und ein Kontowechsel ist eine Änderung.
|
const umgebung = sshBefehl ? { ...process.env, GIT_SSH_COMMAND: sshBefehl } : process.env;
|
const git = (argumente: string[], cwd?: string) =>
|
execFileAsync('git', argumente, { cwd, timeout: GIT_ZEITGRENZE_MS, windowsHide: true, maxBuffer: 16 * 1024 * 1024, env: umgebung });
|
|
if (!fs.existsSync(path.join(arbeitsordner, '.git'))) {
|
fs.rmSync(arbeitsordner, { recursive: true, force: true });
|
fs.mkdirSync(path.dirname(arbeitsordner), { recursive: true });
|
await git(['clone', repoUrl, arbeitsordner]);
|
} else {
|
await git(['remote', 'set-url', 'origin', repoUrl], arbeitsordner);
|
await git(['fetch', '--prune', 'origin'], arbeitsordner);
|
}
|
|
const zweig = gewuenschterZweig || (await ermittleStandardZweig(arbeitsordner));
|
// Harter Abgleich auf den Fernstand — lokale Reste aus einem früheren Bau stören sonst.
|
await git(['checkout', '-B', zweig, `origin/${zweig}`], arbeitsordner);
|
await git(['reset', '--hard', `origin/${zweig}`], arbeitsordner);
|
await git(['clean', '-ffdx', '-e', 'node_modules', '-e', '.venv'], arbeitsordner);
|
|
const { stdout } = await git(['rev-parse', 'HEAD'], arbeitsordner);
|
return stdout.trim();
|
}
|
|
/** Zeitpunkt des ausgecheckten Commits — das ist der „Stand" des internen Tools. */
|
async function commitZeitpunkt(arbeitsordner: string): Promise<string> {
|
try {
|
const { stdout } = await execFileAsync('git', ['log', '-1', '--format=%cI'], {
|
cwd: arbeitsordner,
|
timeout: 30000,
|
windowsHide: true,
|
});
|
return alsIsoDatum(stdout.trim());
|
} catch {
|
return '';
|
}
|
}
|
|
async function ermittleStandardZweig(arbeitsordner: string): Promise<string> {
|
try {
|
const { stdout } = await execFileAsync('git', ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], {
|
cwd: arbeitsordner,
|
timeout: 30000,
|
windowsHide: true,
|
});
|
return stdout.trim().replace(/^origin\//, '') || 'main';
|
} catch {
|
return 'main'; // Repos ohne gesetzten HEAD-Zeiger
|
}
|
}
|
|
/** Löst den Ergebnis-Pfad auf; im Dateinamen ist ein `*` erlaubt. */
|
function findeErgebnis(arbeitsordner: string, muster: string): string | undefined {
|
const vollstaendig = path.resolve(arbeitsordner, muster);
|
if (!muster.includes('*')) {
|
return fs.existsSync(vollstaendig) ? vollstaendig : undefined;
|
}
|
const ordner = path.dirname(vollstaendig);
|
if (!fs.existsSync(ordner)) {
|
return undefined;
|
}
|
const regel = new RegExp(`^${path.basename(vollstaendig).replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`, 'i');
|
const treffer = fs.readdirSync(ordner).filter((name) => regel.test(name));
|
return treffer.length > 0 ? path.join(ordner, treffer[0]) : undefined;
|
}
|
|
function ergebnisDateiname(muster: string): string {
|
const name = path.basename(muster);
|
return name.includes('*') ? '' : name;
|
}
|
|
function gitFehlertext(fehler: unknown): string {
|
const text = kurzeFehlermeldung(fehler);
|
if (/authenticat|permission denied|could not read|host key|publickey/i.test(text)) {
|
return `Git-Anmeldung fehlgeschlagen. Der Dienstbenutzer braucht Zugriff auf das Repository (SSH-Schlüssel bzw. einmalige Anmeldung im Windows-Anmeldespeicher). Ursprüngliche Meldung: ${text}`;
|
}
|
return `Git fehlgeschlagen: ${text}`;
|
}
|
|
/** Kommandozeilen-Fehler enthalten oft seitenlange Ausgaben — auf das Wesentliche kürzen. */
|
function kurzeFehlermeldung(fehler: unknown): string {
|
const roh = fehler instanceof Error ? ((fehler as Error & { stderr?: string }).stderr || fehler.message) : String(fehler);
|
const zeilen = roh.split('\n').map((zeile) => zeile.trim()).filter(Boolean);
|
return zeilen.slice(-4).join(' | ').slice(0, 500);
|
}
|