feat: add migration preview report
This commit is contained in:
10
README.md
10
README.md
@@ -12,16 +12,18 @@ Projektbegriffe werden zeilenweise als `BEGRIFF: KundenSuite` oder `PERSON: Max
|
|||||||
|
|
||||||
### Einmalige Übernahme aus KI Datenschutz
|
### Einmalige Übernahme aus KI Datenschutz
|
||||||
|
|
||||||
Velum enthält **keinen** Laufzeit-Zugriff auf die frühere Extension. Das eigenständige Skript `tools/migrate-ki-datenschutz.mjs` wandelt einen bereits anonymisierten KI-Datenschutz-Projektordner samt seiner eingefrorenen Variante-Tabelle in einen Velum-Projektstand um. Es verändert weder den alten Ordner noch die alte Tabelle. Der neue Stand erscheint anschließend unter `Projektdateien rückübersetzen`; Begriffe nur für dieses Projekt werden als Velum-Projektregeln übernommen. Alle Inhalte bleiben während der Migration lokal und werden nicht an eine API gesendet. Das Skript protokolliert nur Zahlen, keine Werte oder Dateinamen.
|
Velum enthält **keinen** Laufzeit-Zugriff auf die frühere Extension. Das eigenständige Skript `tools/migrate-ki-datenschutz.mjs` wandelt einen bereits anonymisierten KI-Datenschutz-Projektordner samt seiner eingefrorenen Variante-Tabelle in einen Velum-Projektstand um. Es verändert weder den alten Ordner noch die alte Tabelle. Der neue Stand erscheint anschließend unter `Projektdateien rückübersetzen`; Begriffe nur für dieses Projekt werden als Velum-Projektregeln übernommen. Alle Inhalte bleiben während der Migration lokal und werden nicht an eine API gesendet. Im Terminal protokolliert das Skript nur Zahlen, keine Werte oder Dateinamen.
|
||||||
|
|
||||||
1. In Raycast `Migrationsziel anzeigen` öffnen und den Velum-Datenordner kopieren.
|
1. In Raycast `Migrationsziel anzeigen` öffnen und den Velum-Datenordner kopieren.
|
||||||
2. Den alten Projektordner und die zugehörige Datei `projects/<Projekt-ID>.json` aus dem Datenordner von KI Datenschutz angeben. Zuerst ohne `--apply` prüfen:
|
2. Den alten Projektordner und die zugehörige Datei `projects/<Projekt-ID>.json` aus dem Datenordner von KI Datenschutz angeben. Mit `--preview-file` eine prüfbare Vorschau erstellen:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node tools/migrate-ki-datenschutz.mjs --project "/Pfad/zum/Projekt" --manifest "/Pfad/zu/projects/abcdef0123456789.json" --target-support "/kopierter/Velum-Datenordner"
|
node tools/migrate-ki-datenschutz.mjs --project "/Pfad/zum/Projekt" --manifest "/Pfad/zu/projects/abcdef0123456789.json" --target-support "/kopierter/Velum-Datenordner" --preview-file "$HOME/Downloads/Migrationsvorschau.md"
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Denselben Befehl mit `--apply` ausführen. Danach den neuen Stand in Velum öffnen und den Klartext-Export mit einem Testdokument prüfen.
|
Die Datei zeigt Quell- und Zielnamen, alte und neue Platzhalter samt Häufigkeit pro Datei sowie übersprungene Pfade mit Grund. Sie enthält keine Klartextwerte aus der Zuordnungstabelle. Da Dateinamen trotzdem vertraulich sein können, wird sie nur lokal mit eingeschränkten Dateirechten erstellt und eine vorhandene Datei nie überschrieben. Der Vorschaupfad muss außerhalb des Projekts liegen. Ohne `--preview-file` bleibt es bei der Zahlenausgabe im Terminal.
|
||||||
|
|
||||||
|
3. Nach Prüfung denselben Befehl **ohne** `--preview-file`, dafür mit `--apply` ausführen. Danach den neuen Stand in Velum öffnen und den Klartext-Export mit einem Testdokument prüfen.
|
||||||
|
|
||||||
Das Skript übernimmt Text- und Codedateien. Binäre Dateien und bisher nicht anonymisierte Office/PDF-Dateien werden gezählt und ausgelassen; sie müssen im neuen Projektdateien-Befehl geprüft werden. Platzhalter, die in technischen Bezeichnern kleben, behalten ein eigenes Rückübersetzungsverhalten. Wenn die alte Variante-Tabelle mehrere Klartext-Schreibweisen für denselben Platzhalter enthält, ist die exakte frühere Schreibweise nicht mehr rekonstruierbar; das Skript verwendet dieselbe kontextabhängige Kanonisierung wie der alte Klartext-Export. Die alte Extension kann nach erfolgreicher Prüfung entfernt werden.
|
Das Skript übernimmt Text- und Codedateien. Binäre Dateien und bisher nicht anonymisierte Office/PDF-Dateien werden gezählt und ausgelassen; sie müssen im neuen Projektdateien-Befehl geprüft werden. Platzhalter, die in technischen Bezeichnern kleben, behalten ein eigenes Rückübersetzungsverhalten. Wenn die alte Variante-Tabelle mehrere Klartext-Schreibweisen für denselben Platzhalter enthält, ist die exakte frühere Schreibweise nicht mehr rekonstruierbar; das Skript verwendet dieselbe kontextabhängige Kanonisierung wie der alte Klartext-Export. Die alte Extension kann nach erfolgreicher Prüfung entfernt werden.
|
||||||
|
|
||||||
|
|||||||
@@ -16,11 +16,14 @@ it("migriert einen künstlichen Altbestand verlustarm ohne den Quellordner zu ä
|
|||||||
const project = join(root, "Projekt");
|
const project = join(root, "Projekt");
|
||||||
const targetSupport = join(root, "VelumSupport");
|
const targetSupport = join(root, "VelumSupport");
|
||||||
const oldSupport = join(root, "OldSupport");
|
const oldSupport = join(root, "OldSupport");
|
||||||
|
const previewFile = join(root, "Migrationsvorschau.md");
|
||||||
await fs.mkdir(project);
|
await fs.mkdir(project);
|
||||||
await fs.mkdir(join(oldSupport, "projects"), { recursive: true });
|
await fs.mkdir(join(oldSupport, "projects"), { recursive: true });
|
||||||
const legacyName = "KUNDE ALPHA.txt";
|
const legacyName = "KUNDE ALPHA.txt";
|
||||||
const legacyContent = "kundeAlphaService und KUNDE ALPHA";
|
const legacyContent = "kundeAlphaService und KUNDE ALPHA";
|
||||||
await fs.writeFile(join(project, legacyName), legacyContent);
|
await fs.writeFile(join(project, legacyName), legacyContent);
|
||||||
|
await fs.writeFile(join(project, "Handbuch.docx"), "office");
|
||||||
|
await fs.writeFile(join(project, "leer.txt"), "Ohne Platzhalter");
|
||||||
const projectId = createHash("sha1").update(project).digest("hex").slice(0, 16);
|
const projectId = createHash("sha1").update(project).digest("hex").slice(0, 16);
|
||||||
const manifestFile = join(oldSupport, "projects", `${projectId}.json`);
|
const manifestFile = join(oldSupport, "projects", `${projectId}.json`);
|
||||||
await fs.writeFile(manifestFile, JSON.stringify({
|
await fs.writeFile(manifestFile, JSON.stringify({
|
||||||
@@ -30,11 +33,24 @@ it("migriert einen künstlichen Altbestand verlustarm ohne den Quellordner zu ä
|
|||||||
{ termId: "t1", termVariant: "muellerGmbh", placeholderVariant: "kundeAlpha", style: "camel", boundary: "identifier" },
|
{ termId: "t1", termVariant: "muellerGmbh", placeholderVariant: "kundeAlpha", style: "camel", boundary: "identifier" },
|
||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
const preview = await migrate({ project, manifestFile, targetSupport });
|
const preview = await migrate({ project, manifestFile, targetSupport, previewFile });
|
||||||
expect(preview.applied).toBe(false);
|
expect(preview.applied).toBe(false);
|
||||||
expect(preview.summary.files).toBe(1);
|
expect(preview.summary.files).toBe(2);
|
||||||
|
expect(preview.summary.skipped).toBe(1);
|
||||||
|
const report = await fs.readFile(previewFile, "utf8");
|
||||||
|
expect(report).toContain('"KUNDE ALPHA.txt"');
|
||||||
|
expect(report).toContain('"KUNDE_100001.txt"');
|
||||||
|
expect(report).toContain('"kundeAlpha" → "KUNDE_100002" (1×)');
|
||||||
|
expect(report).toContain('"Handbuch.docx": Office- oder PDF-Datei');
|
||||||
|
expect(report).toContain("Keine Änderung.");
|
||||||
|
expect(report).not.toContain("Müller GmbH");
|
||||||
|
expect((await fs.stat(previewFile)).mode & 0o777).toBe(0o600);
|
||||||
expect(await fs.readFile(join(project, legacyName), "utf8")).toBe(legacyContent);
|
expect(await fs.readFile(join(project, legacyName), "utf8")).toBe(legacyContent);
|
||||||
expect(await fs.readdir(root)).not.toContain("VelumSupport");
|
expect(await fs.readdir(root)).not.toContain("VelumSupport");
|
||||||
|
await expect(migrate({ project, manifestFile, targetSupport, previewFile })).rejects.toMatchObject({ code: "EEXIST" });
|
||||||
|
await expect(migrate({ project, manifestFile, targetSupport, previewFile, apply: true })).rejects.toThrow();
|
||||||
|
await expect(migrate({ project, manifestFile, targetSupport,
|
||||||
|
previewFile: join(project, "vorschau.md") })).rejects.toThrow();
|
||||||
|
|
||||||
const result = await migrate({ project, manifestFile, targetSupport, apply: true });
|
const result = await migrate({ project, manifestFile, targetSupport, apply: true });
|
||||||
expect(result.applied).toBe(true);
|
expect(result.applied).toBe(true);
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function createConverter(manifest, globalTerms = []) {
|
|||||||
const counters = new Map();
|
const counters = new Map();
|
||||||
let replacements = 0;
|
let replacements = 0;
|
||||||
|
|
||||||
function convert(input) {
|
function convert(input, onReplacement) {
|
||||||
if (!pattern) return input;
|
if (!pattern) return input;
|
||||||
pattern.lastIndex = 0;
|
pattern.lastIndex = 0;
|
||||||
return input.replace(pattern, (matched, offset, whole) => {
|
return input.replace(pattern, (matched, offset, whole) => {
|
||||||
@@ -85,6 +85,7 @@ export function createConverter(manifest, globalTerms = []) {
|
|||||||
allocated.set(key, placeholder);
|
allocated.set(key, placeholder);
|
||||||
}
|
}
|
||||||
replacements += 1;
|
replacements += 1;
|
||||||
|
onReplacement?.(matched, placeholder);
|
||||||
return placeholder;
|
return placeholder;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -101,19 +102,20 @@ function safeRelative(path) {
|
|||||||
|
|
||||||
async function filesUnder(root) {
|
async function filesUnder(root) {
|
||||||
const files = [];
|
const files = [];
|
||||||
let skipped = 0;
|
const skippedFiles = [];
|
||||||
|
const skip = (path, reason) => skippedFiles.push({ path: relative(root, path).split(sep).join("/"), reason });
|
||||||
async function walk(dir) {
|
async function walk(dir) {
|
||||||
for (const entry of (await fs.readdir(dir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
|
for (const entry of (await fs.readdir(dir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||||
const path = join(dir, entry.name);
|
const path = join(dir, entry.name);
|
||||||
if (entry.isSymbolicLink()) { skipped += 1; continue; }
|
if (entry.isSymbolicLink()) { skip(path, "Symbolischer Link"); continue; }
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
if (excluded.has(entry.name)) { skipped += 1; continue; }
|
if (excluded.has(entry.name)) { skip(path, "Ausgeschlossener Ordner"); continue; }
|
||||||
await walk(path);
|
await walk(path);
|
||||||
} else if (entry.isFile()) {
|
} else if (entry.isFile()) {
|
||||||
if (entry.name === ".aishield.json") continue;
|
if (entry.name === ".aishield.json") { skip(path, "Alte Projektmetadaten"); continue; }
|
||||||
if (entry.name === ".env" || entry.name.startsWith(".env.") ||
|
if (entry.name === ".env" || entry.name.startsWith(".env.") ||
|
||||||
[".pem", ".key", ".p12", ".pfx"].includes(extname(entry.name).toLowerCase())) {
|
[".pem", ".key", ".p12", ".pfx"].includes(extname(entry.name).toLowerCase())) {
|
||||||
skipped += 1;
|
skip(path, "Vertrauliche Konfigurations- oder Schlüsseldatei");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
files.push({ abs: path, rel: relative(root, path).split(sep).join("/") });
|
files.push({ abs: path, rel: relative(root, path).split(sep).join("/") });
|
||||||
@@ -121,15 +123,55 @@ async function filesUnder(root) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
await walk(root);
|
await walk(root);
|
||||||
return { files, get skipped() { return skipped; } };
|
return { files, skippedFiles };
|
||||||
}
|
}
|
||||||
|
|
||||||
function convertedPath(rel, convert) {
|
function convertedPath(rel, convert) {
|
||||||
return safeRelative(rel.split("/").map(convert).join("/"));
|
return safeRelative(rel.split("/").map(convert).join("/"));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function migrate({ project, manifestFile, targetSupport, apply = false }) {
|
function replacementList(counts) {
|
||||||
|
return [...counts.values()].sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to));
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportText(summary, converted, skippedFiles) {
|
||||||
|
const quoted = (value) => JSON.stringify(value);
|
||||||
|
const lines = [
|
||||||
|
"# Migrationsvorschau für Velum", "",
|
||||||
|
"Diese Vorschau wurde ohne Übernahme erstellt. Sie enthält alte und neue Platzhalter, aber keine Klartextwerte aus der Zuordnungstabelle.", "",
|
||||||
|
`Dateien: ${summary.files} · Ausgelassen: ${summary.skipped} · Ersetzungen: ${summary.replacements} · Neue Platzhalter: ${summary.placeholders}`, "",
|
||||||
|
"## Dateien", "",
|
||||||
|
];
|
||||||
|
for (const file of converted) {
|
||||||
|
lines.push(`- Quelle: ${quoted(file.source)}`);
|
||||||
|
if (file.source !== file.output) lines.push(` Zielname: ${quoted(file.output)}`);
|
||||||
|
const pathReplacements = replacementList(file.pathReplacements);
|
||||||
|
const contentReplacements = replacementList(file.contentReplacements);
|
||||||
|
if (!pathReplacements.length && !contentReplacements.length) lines.push(" Keine Änderung.");
|
||||||
|
for (const [label, entries] of [["Name", pathReplacements], ["Inhalt", contentReplacements]]) {
|
||||||
|
for (const entry of entries) lines.push(` ${label}: ${quoted(entry.from)} → ${quoted(entry.to)} (${entry.count}×)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!converted.length) lines.push("Keine übernehmbaren Dateien.");
|
||||||
|
lines.push("", "## Ausgelassen", "");
|
||||||
|
for (const item of skippedFiles) lines.push(`- ${quoted(item.path)}: ${item.reason}`);
|
||||||
|
if (!skippedFiles.length) lines.push("Keine.");
|
||||||
|
return lines.join("\n") + "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function migrate({ project, manifestFile, targetSupport, apply = false, previewFile }) {
|
||||||
const root = resolve(project);
|
const root = resolve(project);
|
||||||
|
if (apply && previewFile) throw new Error("Vorschau-Datei und --apply bitte in getrennten Aufrufen verwenden.");
|
||||||
|
if (previewFile && (resolve(previewFile) === root || resolve(previewFile).startsWith(root + sep))) {
|
||||||
|
throw new Error("Die Vorschau-Datei muss außerhalb des Projektordners liegen.");
|
||||||
|
}
|
||||||
|
if (previewFile) {
|
||||||
|
const realRoot = await fs.realpath(root);
|
||||||
|
const realPreviewParent = await fs.realpath(dirname(resolve(previewFile)));
|
||||||
|
if (realPreviewParent === realRoot || realPreviewParent.startsWith(realRoot + sep)) {
|
||||||
|
throw new Error("Die Vorschau-Datei muss außerhalb des Projektordners liegen.");
|
||||||
|
}
|
||||||
|
}
|
||||||
if (resolve(targetSupport) === root || resolve(targetSupport).startsWith(root + sep)) {
|
if (resolve(targetSupport) === root || resolve(targetSupport).startsWith(root + sep)) {
|
||||||
throw new Error("Der Velum-Datenordner muss außerhalb des Projektordners liegen.");
|
throw new Error("Der Velum-Datenordner muss außerhalb des Projektordners liegen.");
|
||||||
}
|
}
|
||||||
@@ -146,21 +188,31 @@ export async function migrate({ project, manifestFile, targetSupport, apply = fa
|
|||||||
const scan = await filesUnder(root);
|
const scan = await filesUnder(root);
|
||||||
const converted = [];
|
const converted = [];
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
let skipped = scan.skipped;
|
const skippedFiles = [...scan.skippedFiles];
|
||||||
|
const skip = (file, reason) => skippedFiles.push({ path: file.rel, reason });
|
||||||
|
const track = (counts) => (from, to) => {
|
||||||
|
const key = `${from}\0${to}`;
|
||||||
|
const entry = counts.get(key) ?? { from, to, count: 0 };
|
||||||
|
entry.count += 1;
|
||||||
|
counts.set(key, entry);
|
||||||
|
};
|
||||||
for (const file of scan.files) {
|
for (const file of scan.files) {
|
||||||
if ([".docx", ".xlsx", ".pptx", ".pdf"].includes(extname(file.rel).toLowerCase())) {
|
if ([".docx", ".xlsx", ".pptx", ".pdf"].includes(extname(file.rel).toLowerCase())) {
|
||||||
skipped += 1;
|
skip(file, "Office- oder PDF-Datei");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const bytes = await fs.readFile(file.abs);
|
const bytes = await fs.readFile(file.abs);
|
||||||
if (bytes.includes(0)) { skipped += 1; continue; }
|
if (bytes.includes(0)) { skip(file, "Binärdatei"); continue; }
|
||||||
let text;
|
let text;
|
||||||
try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); }
|
try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); }
|
||||||
catch { skipped += 1; continue; }
|
catch { skip(file, "Kein gültiger UTF-8-Text"); continue; }
|
||||||
const output = convertedPath(file.rel, converter.convert);
|
const pathReplacements = new Map();
|
||||||
|
const contentReplacements = new Map();
|
||||||
|
const output = convertedPath(file.rel, (part) => converter.convert(part, track(pathReplacements)));
|
||||||
if (seen.has(output)) throw new Error("Zwei Dateien hätten denselben Zielnamen.");
|
if (seen.has(output)) throw new Error("Zwei Dateien hätten denselben Zielnamen.");
|
||||||
seen.add(output);
|
seen.add(output);
|
||||||
converted.push({ source: file.rel, output, content: converter.convert(text), kind: "text" });
|
converted.push({ source: file.rel, output, content: converter.convert(text, track(contentReplacements)),
|
||||||
|
kind: "text", pathReplacements, contentReplacements });
|
||||||
}
|
}
|
||||||
const id = `${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`;
|
const id = `${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`;
|
||||||
const projectId = createHash("sha1").update(root.replace(/\/+$/, "")).digest("hex").slice(0, 16);
|
const projectId = createHash("sha1").update(root.replace(/\/+$/, "")).digest("hex").slice(0, 16);
|
||||||
@@ -171,9 +223,15 @@ export async function migrate({ project, manifestFile, targetSupport, apply = fa
|
|||||||
mapping: converter.mapping, matchModes: converter.matchModes,
|
mapping: converter.mapping, matchModes: converter.matchModes,
|
||||||
files: converted.map(({ source, output, kind }) => ({ source, output, kind })),
|
files: converted.map(({ source, output, kind }) => ({ source, output, kind })),
|
||||||
};
|
};
|
||||||
const summary = { files: converted.length, skipped, replacements: converter.replacements,
|
const summary = { files: converted.length, skipped: skippedFiles.length, replacements: converter.replacements,
|
||||||
placeholders: Object.keys(converter.mapping).length };
|
placeholders: Object.keys(converter.mapping).length };
|
||||||
if (!apply) return { summary, applied: false };
|
if (!apply) {
|
||||||
|
if (previewFile) {
|
||||||
|
await fs.writeFile(resolve(previewFile), reportText(summary, converted, skippedFiles),
|
||||||
|
{ encoding: "utf8", flag: "wx", mode: 0o600 });
|
||||||
|
}
|
||||||
|
return { summary, applied: false, previewFile: previewFile && resolve(previewFile) };
|
||||||
|
}
|
||||||
const stage = join(projectData, `.${id}.staging`);
|
const stage = join(projectData, `.${id}.staging`);
|
||||||
await fs.mkdir(join(stage, "files"), { recursive: true, mode: 0o700 });
|
await fs.mkdir(join(stage, "files"), { recursive: true, mode: 0o700 });
|
||||||
let committed = false;
|
let committed = false;
|
||||||
@@ -213,13 +271,16 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
|
|||||||
const project = option(args, "--project");
|
const project = option(args, "--project");
|
||||||
const manifestFile = option(args, "--manifest");
|
const manifestFile = option(args, "--manifest");
|
||||||
const targetSupport = option(args, "--target-support");
|
const targetSupport = option(args, "--target-support");
|
||||||
if (!project || !manifestFile || !targetSupport) {
|
const previewFile = option(args, "--preview-file");
|
||||||
process.stderr.write("Aufruf: node tools/migrate-ki-datenschutz.mjs --project <Ordner> --manifest <alte Tabelle.json> --target-support <Velum-Datenordner> [--apply]\n");
|
if (!project || !manifestFile || !targetSupport ||
|
||||||
|
(args.includes("--preview-file") && (!previewFile || previewFile.startsWith("--")))) {
|
||||||
|
process.stderr.write("Aufruf: node tools/migrate-ki-datenschutz.mjs --project <Ordner> --manifest <alte Tabelle.json> --target-support <Velum-Datenordner> [--preview-file <Bericht.md> | --apply]\n");
|
||||||
process.exitCode = 2;
|
process.exitCode = 2;
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const result = await migrate({ project, manifestFile, targetSupport, apply: args.includes("--apply") });
|
const result = await migrate({ project, manifestFile, targetSupport, previewFile, apply: args.includes("--apply") });
|
||||||
process.stdout.write(`${result.applied ? "Import erstellt" : "Vorschau"}: ${result.summary.files} Dateien, ${result.summary.skipped} übersprungen, ${result.summary.replacements} Ersetzungen, ${result.summary.placeholders} neue Platzhalter.\n`);
|
process.stdout.write(`${result.applied ? "Import erstellt" : "Vorschau"}: ${result.summary.files} Dateien, ${result.summary.skipped} übersprungen, ${result.summary.replacements} Ersetzungen, ${result.summary.placeholders} neue Platzhalter.\n`);
|
||||||
|
if (result.previewFile) process.stdout.write(`Bericht: ${result.previewFile}\n`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
process.stderr.write(`Migration fehlgeschlagen: ${error.message}\n`);
|
process.stderr.write(`Migration fehlgeschlagen: ${error.message}\n`);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
|
|||||||
Reference in New Issue
Block a user