1836 lines
61 KiB
JavaScript
1836 lines
61 KiB
JavaScript
(() => {
|
|
if (window.forgeflow) return;
|
|
|
|
const wait = (ms = 180) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
const clone = (value) => JSON.parse(JSON.stringify(value));
|
|
const iso = (offset = 0) => new Date(Date.now() + offset).toISOString();
|
|
const storage = {
|
|
get(key) {
|
|
try {
|
|
return localStorage.getItem(key);
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
set(key, value) {
|
|
try {
|
|
localStorage.setItem(key, value);
|
|
} catch {}
|
|
},
|
|
};
|
|
const repositoryListeners = new Set();
|
|
const operationListeners = new Set();
|
|
const updateListeners = new Set();
|
|
const emitRepositories = () =>
|
|
repositoryListeners.forEach((listener) =>
|
|
listener({ reason: "demo-change" }),
|
|
);
|
|
const emitOperations = (operations) =>
|
|
operationListeners.forEach((listener) =>
|
|
listener({ operations: clone(operations) }),
|
|
);
|
|
const randomSha = () =>
|
|
`${Math.random().toString(16).slice(2)}${Date.now().toString(16)}`
|
|
.padEnd(40, "a")
|
|
.slice(0, 40);
|
|
|
|
const makeStatus = ({
|
|
head,
|
|
branch = "main",
|
|
ahead = 0,
|
|
behind = 0,
|
|
upstream = `origin/${branch}`,
|
|
files = [],
|
|
}) => ({
|
|
branch: { oid: head, head: branch, upstream, ahead, behind },
|
|
files,
|
|
counts: {
|
|
changed: files.length,
|
|
staged: files.filter((item) => item.staged).length,
|
|
unstaged: files.filter((item) => item.unstaged).length,
|
|
conflicts: files.filter((item) => item.conflict).length,
|
|
untracked: files.filter((item) => item.untracked).length,
|
|
},
|
|
clean: files.length === 0,
|
|
root: "",
|
|
remoteUrl: "",
|
|
head,
|
|
shortHead: head.slice(0, 7),
|
|
fingerprint: `${head}:${branch}:${ahead}:${behind}:${files.map((item) => `${item.path}:${item.indexCode}${item.worktreeCode}`).join("|")}`,
|
|
});
|
|
|
|
const makeFile = (path, status = "modified", options = {}) => ({
|
|
path,
|
|
originalPath: options.originalPath || null,
|
|
indexCode: options.staged
|
|
? status === "added"
|
|
? "A"
|
|
: status === "deleted"
|
|
? "D"
|
|
: "M"
|
|
: ".",
|
|
worktreeCode: options.staged
|
|
? "."
|
|
: status === "untracked"
|
|
? "?"
|
|
: status === "deleted"
|
|
? "D"
|
|
: status === "conflict"
|
|
? "U"
|
|
: "M",
|
|
staged: Boolean(options.staged),
|
|
unstaged: !options.staged,
|
|
untracked: status === "untracked",
|
|
conflict: status === "conflict",
|
|
status,
|
|
});
|
|
|
|
const profile = (id, name, environment, options = {}) => ({
|
|
id,
|
|
name,
|
|
environment,
|
|
provider: "gitea-actions",
|
|
branch: options.branch || "main",
|
|
workflowFile: options.workflowFile || "deploy.yml",
|
|
rollbackWorkflowFile: options.rollbackWorkflowFile ?? "rollback.yml",
|
|
healthcheckUrl:
|
|
options.healthcheckUrl || `https://${environment}.internal/health`,
|
|
statusUrl:
|
|
options.statusUrl ||
|
|
`https://${environment}.internal/.well-known/forgeflow`,
|
|
confirmationRequired: options.confirmationRequired !== false,
|
|
inputs: {},
|
|
state: {
|
|
liveSha: options.liveSha || null,
|
|
previousSha: options.previousSha || null,
|
|
healthy: options.healthy ?? null,
|
|
healthConfigured: true,
|
|
statusConfigured: true,
|
|
healthStatus: options.healthy === false ? 503 : 200,
|
|
healthLatencyMs: 42,
|
|
checkedAt: options.checkedAt || iso(-120000),
|
|
},
|
|
});
|
|
|
|
const sshProfile = (id, name, environment, options = {}) => ({
|
|
id, name, environment, provider: "ssh-unraid", branch: options.branch || "main",
|
|
serverId: "demo-unraid", remoteFolder: options.remoteFolder || name,
|
|
deploymentMode: "server-git", composeFiles: ["compose.yml"],
|
|
composeProject: options.composeProject || String(options.remoteFolder || name).toLowerCase(),
|
|
composeServices: options.composeServices || [String(options.remoteFolder || name).toLowerCase()],
|
|
containerName: options.containerName || options.remoteFolder || name,
|
|
generatedCompose: false, adoptedFromServer: true, serverSourceOfTruth: true,
|
|
confirmationRequired: true,
|
|
serverGitAccess: { configured: options.accessConfigured !== false, keyFingerprint: "SHA256:demo", hostFingerprint: "SHA256:gitea", configuredAt: iso(-3600000) },
|
|
state: {
|
|
liveSha: options.liveSha || null, giteaSha: options.giteaSha || options.liveSha || null,
|
|
previousSha: options.previousSha || null, healthy: options.healthy ?? true,
|
|
containerRunning: true, runtimeVerification: "verified", matchesGitea: options.matchesGitea ?? true,
|
|
checkedAt: iso(-120000), dockerMan: { templateExists: true, webUi: true, icon: true },
|
|
},
|
|
});
|
|
|
|
const now = iso();
|
|
const defaultPreferences = {
|
|
autoRefresh: true,
|
|
repositoryPollSeconds: 4,
|
|
operationPollSeconds: 5,
|
|
fetchIntervalMinutes: 10,
|
|
preferredCloneProtocol: "https",
|
|
diagnosticsEnabled: true,
|
|
diagnosticLevel: "info",
|
|
logRetentionDays: 14,
|
|
maxLogFileMb: 8,
|
|
};
|
|
|
|
let state = {
|
|
schemaVersion: 8,
|
|
setupComplete: storage.get("forgeflow-demo-setup") !== "false",
|
|
appearance: storage.get("forgeflow-theme") || "dark",
|
|
gitea: {
|
|
baseUrl: "https://gitea.internal",
|
|
user: { login: "jens", full_name: "Jens" },
|
|
hasToken: true,
|
|
},
|
|
workspaceRoots: ["C:\\Development"],
|
|
repositoryMappings: {},
|
|
deploymentProfiles: {},
|
|
deploymentStates: {},
|
|
favorites: [
|
|
"jens/microsoft-cloud-operations-platform",
|
|
"jens/unraid-appops-gateway",
|
|
],
|
|
updates: {
|
|
owner: "Jens",
|
|
repo: "ForgeFlow",
|
|
branch: "main",
|
|
autoCheck: true,
|
|
lastCheckedAt: null,
|
|
},
|
|
servers: [
|
|
{
|
|
id: "server-unraid",
|
|
name: "Unraid",
|
|
host: "192.168.1.10",
|
|
port: 22,
|
|
username: "root",
|
|
authType: "privateKey",
|
|
basePath: "/mnt/user/appdata",
|
|
privateKeyPath: "C:\\Users\\Jens\\.ssh\\id_ed25519",
|
|
hostFingerprint: "SHA256:demo",
|
|
hasPassword: false,
|
|
hasPassphrase: false,
|
|
},
|
|
],
|
|
preferences: { ...defaultPreferences },
|
|
operations: [
|
|
{
|
|
id: "op-success",
|
|
type: "deployment",
|
|
action: "deploy",
|
|
status: "success",
|
|
repository: "jens/microsoft-cloud-operations-platform",
|
|
profileId: "profile-mcop-prod",
|
|
profileName: "Production",
|
|
environment: "production",
|
|
workflowFile: "deploy.yml",
|
|
branch: "main",
|
|
sha: "b82f91ab0173cd4346ca0f0f7dcc3e8182cc8fd0",
|
|
shortSha: "b82f91a",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
stages: [
|
|
{ id: "requested", label: "Requested", status: "complete" },
|
|
{ id: "verified", label: "Verified", status: "complete" },
|
|
{ id: "queued", label: "Workflow queued", status: "complete" },
|
|
{ id: "runner", label: "Runner execution", status: "complete" },
|
|
{ id: "healthcheck", label: "Healthcheck", status: "complete" },
|
|
{ id: "complete", label: "Complete", status: "complete" },
|
|
],
|
|
logs: [
|
|
"[info] Exact commit verified.",
|
|
"[job] deploy: success",
|
|
"[ok] Server reports b82f91a and healthcheck returned 200.",
|
|
],
|
|
run: {
|
|
id: 48,
|
|
runNumber: 48,
|
|
status: "completed",
|
|
conclusion: "success",
|
|
name: "ForgeFlow deployment",
|
|
},
|
|
runUrl:
|
|
"https://gitea.internal/jens/microsoft-cloud-operations-platform/actions/runs/48",
|
|
},
|
|
{
|
|
id: "op-failed",
|
|
type: "deployment",
|
|
action: "deploy",
|
|
status: "failed",
|
|
repository: "jens/portfolio",
|
|
profileId: "profile-portfolio",
|
|
profileName: "Production",
|
|
environment: "production",
|
|
workflowFile: "deploy.yml",
|
|
branch: "main",
|
|
sha: "a7f2e1c1bb6147fc8b6633d2b08500c93402a719",
|
|
shortSha: "a7f2e1c",
|
|
createdAt: iso(-86400000),
|
|
updatedAt: iso(-86300000),
|
|
failure: { stage: "healthcheck", message: "Healthcheck returned 502." },
|
|
stages: [
|
|
{ id: "requested", label: "Requested", status: "complete" },
|
|
{ id: "verified", label: "Verified", status: "complete" },
|
|
{ id: "queued", label: "Workflow queued", status: "complete" },
|
|
{ id: "runner", label: "Runner execution", status: "complete" },
|
|
{ id: "healthcheck", label: "Healthcheck", status: "failed" },
|
|
{ id: "complete", label: "Complete", status: "failed" },
|
|
],
|
|
logs: ["[job] deploy: success", "[error] Healthcheck returned 502."],
|
|
},
|
|
],
|
|
};
|
|
|
|
let repositories = [
|
|
{
|
|
id: 1,
|
|
name: "microsoft-cloud-operations-platform",
|
|
fullName: "jens/microsoft-cloud-operations-platform",
|
|
owner: { login: "jens" },
|
|
description: "Tenant-aware Microsoft cloud operations console.",
|
|
private: true,
|
|
defaultBranch: "main",
|
|
htmlUrl:
|
|
"https://gitea.internal/jens/microsoft-cloud-operations-platform",
|
|
cloneUrl:
|
|
"https://gitea.internal/jens/microsoft-cloud-operations-platform.git",
|
|
sshUrl: "git@gitea.internal:jens/microsoft-cloud-operations-platform.git",
|
|
updatedAt: now,
|
|
localPath: "C:\\Development\\Microsoft-Cloud-Operations-Platform",
|
|
localStatus: makeStatus({
|
|
head: "b82f91ab0173cd4346ca0f0f7dcc3e8182cc8fd0",
|
|
}),
|
|
linkState: "linked",
|
|
deploymentProfiles: [
|
|
profile("profile-mcop-prod", "Production", "production", {
|
|
liveSha: "72bd10eb0173cd4346ca0f0f7dcc3e8182cc8fd0",
|
|
previousSha: "6ac991ab0173cd4346ca0f0f7dcc3e8182cc8fd0",
|
|
healthy: true,
|
|
}),
|
|
profile("profile-mcop-stage", "Staging", "staging", {
|
|
liveSha: "b82f91ab0173cd4346ca0f0f7dcc3e8182cc8fd0",
|
|
previousSha: "72bd10eb0173cd4346ca0f0f7dcc3e8182cc8fd0",
|
|
healthy: true,
|
|
confirmationRequired: false,
|
|
}),
|
|
],
|
|
},
|
|
{
|
|
id: 2,
|
|
name: "vacancyradar",
|
|
fullName: "jens/vacancyradar",
|
|
owner: { login: "jens" },
|
|
description: "Local-first vacancy intelligence cockpit.",
|
|
private: true,
|
|
defaultBranch: "main",
|
|
htmlUrl: "https://gitea.internal/jens/vacancyradar",
|
|
cloneUrl: "https://gitea.internal/jens/vacancyradar.git",
|
|
sshUrl: "git@gitea.internal:jens/vacancyradar.git",
|
|
updatedAt: now,
|
|
localPath: "C:\\Development\\VacancyRadar",
|
|
localStatus: makeStatus({
|
|
head: "c9182d0d28318c8cf0af109edc054732426aadf1",
|
|
branch: "feature/deployment-api",
|
|
files: [
|
|
makeFile("src/api/deploy.ts", "added", { staged: true }),
|
|
makeFile("src/main.tsx"),
|
|
makeFile("src/components/Sidebar.tsx"),
|
|
],
|
|
}),
|
|
linkState: "linked",
|
|
deploymentProfiles: [
|
|
profile("profile-vr", "Production", "production", {
|
|
liveSha: "c117ab9d28318c8cf0af109edc054732426aadf1",
|
|
previousSha: "b1f57aad28318c8cf0af109edc054732426aadf1",
|
|
healthy: true,
|
|
}),
|
|
],
|
|
},
|
|
{
|
|
id: 3,
|
|
name: "unraid-appops-gateway",
|
|
fullName: "jens/unraid-appops-gateway",
|
|
owner: { login: "jens" },
|
|
description: "Safe operations gateway for Unraid and Portainer.",
|
|
private: true,
|
|
defaultBranch: "main",
|
|
htmlUrl: "https://gitea.internal/jens/unraid-appops-gateway",
|
|
cloneUrl: "https://gitea.internal/jens/unraid-appops-gateway.git",
|
|
sshUrl: "git@gitea.internal:jens/unraid-appops-gateway.git",
|
|
updatedAt: now,
|
|
localPath: "C:\\Development\\Unraid-AppOps-Gateway",
|
|
localStatus: makeStatus({
|
|
head: "f2d1e0a1bb6147fc8b6633d2b08500c93402a719",
|
|
ahead: 2,
|
|
}),
|
|
linkState: "linked",
|
|
deploymentProfiles: [
|
|
profile("profile-appops", "Production", "production", {
|
|
liveSha: "8ac731b1bb6147fc8b6633d2b08500c93402a719",
|
|
previousSha: "7bc198a1bb6147fc8b6633d2b08500c93402a719",
|
|
healthy: true,
|
|
}),
|
|
],
|
|
},
|
|
{
|
|
id: 4,
|
|
name: "support-bundle-collector",
|
|
fullName: "jens/support-bundle-collector",
|
|
owner: { login: "jens" },
|
|
description: "Privacy-aware Windows support bundle collector.",
|
|
private: true,
|
|
defaultBranch: "main",
|
|
htmlUrl: "https://gitea.internal/jens/support-bundle-collector",
|
|
cloneUrl: "https://gitea.internal/jens/support-bundle-collector.git",
|
|
sshUrl: "git@gitea.internal:jens/support-bundle-collector.git",
|
|
updatedAt: now,
|
|
localPath: null,
|
|
localStatus: null,
|
|
linkState: "remote-only",
|
|
deploymentProfiles: [],
|
|
},
|
|
{
|
|
id: 5,
|
|
name: "portfolio",
|
|
fullName: "jens/portfolio",
|
|
owner: { login: "jens" },
|
|
description: "Professional infrastructure and automation portfolio.",
|
|
private: false,
|
|
defaultBranch: "main",
|
|
htmlUrl: "https://gitea.internal/jens/portfolio",
|
|
cloneUrl: "https://gitea.internal/jens/portfolio.git",
|
|
sshUrl: "git@gitea.internal:jens/portfolio.git",
|
|
updatedAt: now,
|
|
localPath: "C:\\Development\\portfolio",
|
|
localStatus: makeStatus({
|
|
head: "a7f2e1c1bb6147fc8b6633d2b08500c93402a719",
|
|
behind: 1,
|
|
}),
|
|
linkState: "linked",
|
|
deploymentProfiles: [
|
|
sshProfile("profile-portfolio", "Production", "production", {
|
|
remoteFolder: "Portfolio",
|
|
containerName: "Portfolio",
|
|
liveSha: "4c20dd11bb6147fc8b6633d2b08500c93402a719",
|
|
giteaSha: "a7f2e1c1bb6147fc8b6633d2b08500c93402a719",
|
|
previousSha: "31adfe11bb6147fc8b6633d2b08500c93402a719",
|
|
healthy: false,
|
|
matchesGitea: false,
|
|
}),
|
|
],
|
|
},
|
|
];
|
|
|
|
const diffs = {
|
|
"src/api/deploy.ts": `diff --git a/src/api/deploy.ts b/src/api/deploy.ts\nnew file mode 100644\n--- /dev/null\n+++ b/src/api/deploy.ts\n@@ -0,0 +1,18 @@\n+export interface DeploymentRequest {\n+ environment: 'staging' | 'production';\n+ commitSha: string;\n+}\n+\n+export async function deploy(request: DeploymentRequest) {\n+ return api.post('/deployments', request);\n+}`,
|
|
"src/main.tsx": `diff --git a/src/main.tsx b/src/main.tsx\nindex 45ad1a2..939fc17 100644\n--- a/src/main.tsx\n+++ b/src/main.tsx\n@@ -24,8 +24,9 @@ import { Router } from './routes';\n-const API_ENDPOINT = 'http://localhost:3000';\n+const API_ENDPOINT = process.env.VITE_API_URL || '/api';\n+const DEPLOY_VERSION = '1.0.4-rc1';`,
|
|
"src/components/Sidebar.tsx": `diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx\nindex a7bbd82..bf21e90 100644\n--- a/src/components/Sidebar.tsx\n+++ b/src/components/Sidebar.tsx\n@@ -31,6 +31,7 @@ export function Sidebar() {\n+ <NavItem to="/deployments">Deployments</NavItem>`,
|
|
};
|
|
|
|
const findRepo = (localPath) =>
|
|
repositories.find((item) => item.localPath === localPath);
|
|
const findProfileRepo = (profileId) =>
|
|
repositories.find((item) =>
|
|
item.deploymentProfiles.some((entry) => entry.id === profileId),
|
|
);
|
|
const syncState = () => {
|
|
state.deploymentProfiles = {};
|
|
state.deploymentStates = {};
|
|
state.repositoryMappings = {};
|
|
for (const repository of repositories) {
|
|
if (repository.localPath)
|
|
state.repositoryMappings[repository.fullName.toLowerCase()] =
|
|
repository.localPath;
|
|
state.deploymentProfiles[repository.fullName.toLowerCase()] =
|
|
repository.deploymentProfiles.map(
|
|
({ state: profileState, ...entry }) => entry,
|
|
);
|
|
for (const entry of repository.deploymentProfiles)
|
|
if (entry.state) state.deploymentStates[entry.id] = clone(entry.state);
|
|
}
|
|
};
|
|
const recompute = (repository) => {
|
|
const status = repository.localStatus;
|
|
if (status) {
|
|
status.counts = {
|
|
changed: status.files.length,
|
|
staged: status.files.filter((item) => item.staged).length,
|
|
unstaged: status.files.filter((item) => item.unstaged).length,
|
|
conflicts: status.files.filter((item) => item.conflict).length,
|
|
untracked: status.files.filter((item) => item.untracked).length,
|
|
};
|
|
status.clean = status.files.length === 0;
|
|
status.shortHead = status.head.slice(0, 7);
|
|
status.branch.oid = status.head;
|
|
}
|
|
repository.favorite = state.favorites.includes(
|
|
repository.fullName.toLowerCase(),
|
|
);
|
|
repository.readyToDeploy = Boolean(
|
|
repository.localPath &&
|
|
status?.clean &&
|
|
status.branch.upstream &&
|
|
status.branch.ahead === 0 &&
|
|
status.branch.behind === 0 &&
|
|
repository.deploymentProfiles.some(
|
|
(entry) => entry.branch === status.branch.head,
|
|
),
|
|
);
|
|
repository.attention =
|
|
!repository.localPath ||
|
|
Boolean(
|
|
status?.counts.conflicts ||
|
|
status?.branch.behind ||
|
|
status?.branch.ahead ||
|
|
status?.counts.changed,
|
|
);
|
|
repository.attentionReason = !repository.localPath
|
|
? "No local folder linked"
|
|
: status?.counts.conflicts
|
|
? `${status.counts.conflicts} conflict(s)`
|
|
: status?.counts.changed
|
|
? `${status.counts.changed} local change(s)`
|
|
: status?.branch.behind
|
|
? `${status.branch.behind} commit(s) behind remote`
|
|
: status?.branch.ahead
|
|
? `${status.branch.ahead} unpushed commit(s)`
|
|
: null;
|
|
repository.preferredCloneUrl =
|
|
state.preferences.preferredCloneProtocol === "ssh"
|
|
? repository.sshUrl
|
|
: repository.cloneUrl;
|
|
};
|
|
const snapshot = () => {
|
|
repositories.forEach(recompute);
|
|
syncState();
|
|
return clone(repositories);
|
|
};
|
|
syncState();
|
|
|
|
const commitHistory = [
|
|
{
|
|
sha: "c9182d0d28318c8cf0af109edc054732426aadf1",
|
|
shortSha: "c9182d0",
|
|
author: "Jens",
|
|
date: now,
|
|
subject: "feat: add deployment provider contract",
|
|
},
|
|
{
|
|
sha: "1fa7399d28318c8cf0af109edc054732426aadf1",
|
|
shortSha: "1fa7399",
|
|
author: "Jens",
|
|
date: iso(-86400000),
|
|
subject: "refactor: consolidate repository state",
|
|
},
|
|
{
|
|
sha: "a251a11d28318c8cf0af109edc054732426aadf1",
|
|
shortSha: "a251a11",
|
|
author: "Jens",
|
|
date: iso(-172800000),
|
|
subject: "docs: define deployment safety gates",
|
|
},
|
|
];
|
|
const branchesByRepo = new Map();
|
|
const stashesByRepo = new Map();
|
|
|
|
function updateOperation(operation) {
|
|
state.operations = [
|
|
clone(operation),
|
|
...state.operations.filter((item) => item.id !== operation.id),
|
|
].slice(0, 250);
|
|
emitOperations([operation]);
|
|
return clone(operation);
|
|
}
|
|
|
|
function advanceOperation(operation) {
|
|
if (
|
|
!operation ||
|
|
["success", "failed", "cancelled", "rolled-back"].includes(
|
|
operation.status,
|
|
)
|
|
)
|
|
return operation;
|
|
operation.demoPolls = (operation.demoPolls || 0) + 1;
|
|
if (operation.demoPolls === 1) {
|
|
operation.status = "running";
|
|
operation.run = {
|
|
id: 81,
|
|
runNumber: 81,
|
|
status: "running",
|
|
conclusion: null,
|
|
name:
|
|
operation.action === "rollback"
|
|
? "ForgeFlow rollback"
|
|
: "ForgeFlow deployment",
|
|
};
|
|
operation.runUrl = `https://gitea.internal/${operation.repository}/actions/runs/81`;
|
|
operation.stages.find((item) => item.id === "queued").status = "complete";
|
|
operation.stages.find((item) => item.id === "runner").status = "active";
|
|
operation.jobs = [
|
|
{
|
|
id: 201,
|
|
name: operation.action === "rollback" ? "rollback" : "deploy",
|
|
status: "running",
|
|
conclusion: null,
|
|
},
|
|
];
|
|
operation.logs.push(`[job] ${operation.jobs[0].name}: running`);
|
|
} else if (operation.demoPolls >= 2) {
|
|
operation.status =
|
|
operation.action === "rollback" ? "rolled-back" : "success";
|
|
operation.stages.forEach((item) => {
|
|
item.status = "complete";
|
|
});
|
|
operation.jobs = [
|
|
{
|
|
id: 201,
|
|
name: operation.action === "rollback" ? "rollback" : "deploy",
|
|
status: "completed",
|
|
conclusion: "success",
|
|
},
|
|
];
|
|
operation.logs.push(
|
|
"[ok] Runner completed successfully.",
|
|
`[ok] Server status endpoint confirms ${operation.shortSha}.`,
|
|
);
|
|
const repository = repositories.find(
|
|
(item) => item.fullName === operation.repository,
|
|
);
|
|
const targetProfile = repository?.deploymentProfiles.find(
|
|
(item) => item.id === operation.profileId,
|
|
);
|
|
if (targetProfile) {
|
|
const oldLive = targetProfile.state.liveSha;
|
|
targetProfile.state.previousSha = oldLive;
|
|
targetProfile.state.liveSha = operation.sha;
|
|
targetProfile.state.healthy = true;
|
|
targetProfile.state.checkedAt = iso();
|
|
}
|
|
}
|
|
operation.updatedAt = iso();
|
|
return operation;
|
|
}
|
|
|
|
window.forgeflow = Object.freeze({
|
|
async bootstrap() {
|
|
await wait(80);
|
|
snapshot();
|
|
return {
|
|
appVersion: "0.10.0-demo",
|
|
platform: "win32",
|
|
state: clone(state),
|
|
git: { available: true, version: "git version 2.47.3" },
|
|
diagnostics: {
|
|
enabled: true,
|
|
level: state.preferences.diagnosticLevel,
|
|
retentionDays: state.preferences.logRetentionDays,
|
|
maxFileMb: state.preferences.maxLogFileMb,
|
|
directory: "<HOME>/AppData/Roaming/ForgeFlow/diagnostics",
|
|
fileCount: 2,
|
|
totalBytes: 18432,
|
|
totalSize: "18.0 KB",
|
|
latestAt: iso(-2000),
|
|
lastWriteError: null,
|
|
},
|
|
};
|
|
},
|
|
async selectDirectory() {
|
|
await wait();
|
|
return "C:\\Development";
|
|
},
|
|
async selectKeyFile() {
|
|
await wait();
|
|
return "C:\\Users\\Jens\\.ssh\\id_ed25519";
|
|
},
|
|
async setupPreflight({ baseUrl, token, roots = [] }) {
|
|
await wait(240);
|
|
const checks = [
|
|
{
|
|
id: "git.available",
|
|
label: "Git command line",
|
|
status: "pass",
|
|
detail: "git version 2.47.3",
|
|
required: true,
|
|
},
|
|
{
|
|
id: "git.identity",
|
|
label: "Git author identity",
|
|
status: "pass",
|
|
detail: "Jens <jens@example.invalid>",
|
|
required: false,
|
|
},
|
|
{
|
|
id: "storage.userdata",
|
|
label: "Application data storage",
|
|
status: "pass",
|
|
detail: "ForgeFlow can write its local configuration.",
|
|
required: true,
|
|
},
|
|
{
|
|
id: "storage.diagnostics",
|
|
label: "Diagnostic log storage",
|
|
status: "pass",
|
|
detail: "The diagnostic directory is writable.",
|
|
required: true,
|
|
},
|
|
{
|
|
id: "storage.credentials",
|
|
label: "Protected credential storage",
|
|
status: "pass",
|
|
detail: "The operating system can encrypt the Gitea token at rest.",
|
|
required: false,
|
|
},
|
|
{
|
|
id: "workspace.roots",
|
|
label: "Development folders",
|
|
status: roots.length ? "pass" : "warning",
|
|
detail: roots.length
|
|
? `${roots.length} folder(s) selected.`
|
|
: "No development folder selected yet.",
|
|
required: false,
|
|
},
|
|
{
|
|
id: "gitea.connection",
|
|
label: "Gitea connection",
|
|
status: baseUrl && token ? "pass" : "warning",
|
|
detail:
|
|
baseUrl && token
|
|
? "Connection parameters are ready for validation."
|
|
: "Enter the Gitea URL and token.",
|
|
required: false,
|
|
},
|
|
];
|
|
return {
|
|
kind: "system",
|
|
startedAt: iso(-100),
|
|
completedAt: iso(),
|
|
checks,
|
|
summary: {
|
|
counts: {
|
|
pass: checks.filter((i) => i.status === "pass").length,
|
|
warning: checks.filter((i) => i.status === "warning").length,
|
|
fail: 0,
|
|
skipped: 0,
|
|
},
|
|
blocking: [],
|
|
ready: true,
|
|
},
|
|
};
|
|
},
|
|
async validateGitea({ baseUrl, token }) {
|
|
await wait(320);
|
|
if (!baseUrl || !token)
|
|
throw new Error("Enter an instance URL and access token.");
|
|
return {
|
|
baseUrl: baseUrl.replace(/\/$/, ""),
|
|
user: { login: "jens", full_name: "Jens" },
|
|
repositoryCount: repositories.length,
|
|
version: "1.26.0",
|
|
};
|
|
},
|
|
async completeSetup(payload) {
|
|
await wait(300);
|
|
state.setupComplete = true;
|
|
state.gitea = {
|
|
baseUrl: payload.baseUrl,
|
|
user: payload.user,
|
|
hasToken: true,
|
|
};
|
|
state.workspaceRoots = payload.workspaceRoots;
|
|
storage.set("forgeflow-demo-setup", "true");
|
|
return { state: clone(state), tokenState: { persistent: true } };
|
|
},
|
|
async updateGitea(payload) {
|
|
const validation = await this.validateGitea({
|
|
...payload,
|
|
token: payload.token || "preserved-demo-token",
|
|
});
|
|
state.gitea = {
|
|
baseUrl: validation.baseUrl,
|
|
user: validation.user,
|
|
hasToken: true,
|
|
};
|
|
return {
|
|
validation,
|
|
tokenState: { persistent: true, preserved: !payload.token },
|
|
state: clone(state),
|
|
};
|
|
},
|
|
async setWorkspaceRoots(roots) {
|
|
state.workspaceRoots = [...new Set(roots)];
|
|
return clone(state);
|
|
},
|
|
async setAppearance(appearance) {
|
|
state.appearance = appearance;
|
|
storage.set("forgeflow-theme", appearance);
|
|
return clone(state);
|
|
},
|
|
async setPreferences(preferences) {
|
|
state.preferences = { ...state.preferences, ...preferences };
|
|
snapshot();
|
|
return clone(state);
|
|
},
|
|
async setUpdatePreferences(updates) {
|
|
state.updates = { ...state.updates, ...updates };
|
|
return clone(state);
|
|
},
|
|
async checkForUpdates() {
|
|
await wait(300);
|
|
return {
|
|
checkedAt: iso(),
|
|
owner: state.updates.owner,
|
|
repo: state.updates.repo,
|
|
branch: state.updates.branch,
|
|
currentVersion: "0.5.4",
|
|
remoteVersion: "0.6.0",
|
|
remoteSha: "a".repeat(40),
|
|
shortSha: "aaaaaaa",
|
|
available: true,
|
|
mode: "source",
|
|
};
|
|
},
|
|
async downloadUpdate() {
|
|
await wait(500);
|
|
return {
|
|
...(await this.checkForUpdates()),
|
|
downloaded: true,
|
|
archivePath: "C:\\Temp\\ForgeFlow-0.4.1.zip",
|
|
sha256: "b".repeat(64),
|
|
};
|
|
},
|
|
async applyUpdate() {
|
|
await wait(200);
|
|
return { launched: true, confirmed: true, version: "0.6.0" };
|
|
},
|
|
async saveServer(server) {
|
|
const saved = {
|
|
...server,
|
|
id: server.id || `server-${Date.now()}`,
|
|
hasPassword: server.authType === "password",
|
|
hasPassphrase: false,
|
|
};
|
|
state.servers = [
|
|
saved,
|
|
...state.servers.filter((item) => item.id !== saved.id),
|
|
];
|
|
return { server: clone(saved), state: clone(state) };
|
|
},
|
|
async deleteServer(serverId) {
|
|
state.servers = state.servers.filter((item) => item.id !== serverId);
|
|
return clone(state);
|
|
},
|
|
async testServer(serverId) {
|
|
const server = state.servers.find((item) => item.id === serverId);
|
|
server.hostFingerprint = server.hostFingerprint || "SHA256:demo";
|
|
return {
|
|
connected: true,
|
|
fingerprint: server.hostFingerprint,
|
|
server: clone(server),
|
|
output: "Linux\n/usr/bin/git\nDocker Compose version v2",
|
|
state: clone(state),
|
|
};
|
|
},
|
|
async inspectServerProject() {
|
|
return {
|
|
exists: true,
|
|
rootGit: true,
|
|
head: "d42d4a7".padEnd(40, "0"),
|
|
branch: "main",
|
|
trackedChanges: [],
|
|
composeFiles: ["docker-compose.yml"],
|
|
nestedGit: ["source"],
|
|
dockerfile: true,
|
|
};
|
|
},
|
|
async refreshRepositories() {
|
|
await wait(260);
|
|
return snapshot();
|
|
},
|
|
async discoverRepositories() {
|
|
await wait(360);
|
|
return snapshot()
|
|
.filter((repo) => repo.localPath)
|
|
.map((repo) => ({
|
|
localPath: repo.localPath,
|
|
remoteUrl: repo.cloneUrl,
|
|
status: repo.localStatus,
|
|
}));
|
|
},
|
|
async favoriteRepository(fullName, favorite) {
|
|
const key = fullName.toLowerCase();
|
|
state.favorites = favorite
|
|
? [...new Set([...state.favorites, key])]
|
|
: state.favorites.filter((item) => item !== key);
|
|
snapshot();
|
|
return clone(state);
|
|
},
|
|
async linkRepository(fullName, localPath) {
|
|
const repo = repositories.find((item) => item.fullName === fullName);
|
|
repo.localPath = localPath;
|
|
repo.linkState = "linked";
|
|
repo.localStatus = makeStatus({ head: randomSha() });
|
|
emitRepositories();
|
|
return snapshot();
|
|
},
|
|
async unlinkRepository(fullName) {
|
|
const repo = repositories.find((item) => item.fullName === fullName);
|
|
repo.localPath = null;
|
|
repo.localStatus = null;
|
|
repo.linkState = "remote-only";
|
|
emitRepositories();
|
|
return snapshot();
|
|
},
|
|
async repositoryStatus(localPath) {
|
|
return clone(findRepo(localPath)?.localStatus);
|
|
},
|
|
async repositoryDiff(localPath, filePath) {
|
|
await wait(80);
|
|
return (
|
|
diffs[filePath] ||
|
|
`diff --git a/${filePath} b/${filePath}\n--- a/${filePath}\n+++ b/${filePath}\n@@ -1 +1 @@\n-old\n+new`
|
|
);
|
|
},
|
|
async repositoryDiffHunks(localPath, filePath) {
|
|
const diff = await this.repositoryDiff(localPath, filePath);
|
|
return {
|
|
filePath,
|
|
partialSupported: true,
|
|
hunks: [
|
|
{
|
|
index: 0,
|
|
heading: "@@ -1 +1 @@",
|
|
additions: 1,
|
|
deletions: 1,
|
|
lines: diff.split("\n").slice(-4),
|
|
},
|
|
],
|
|
};
|
|
},
|
|
async stageHunks(localPath, filePath) {
|
|
return this.stageFiles(localPath, [filePath]);
|
|
},
|
|
async conflictState(localPath) {
|
|
const repo = findRepo(localPath);
|
|
const files = repo.localStatus.files
|
|
.filter((item) => item.conflict)
|
|
.map((item) => item.path);
|
|
return {
|
|
operation: files.length ? "merge" : null,
|
|
files,
|
|
canContinue: false,
|
|
status: clone(repo.localStatus),
|
|
};
|
|
},
|
|
async resolveConflict(localPath, filePath) {
|
|
const repo = findRepo(localPath);
|
|
const file = repo.localStatus.files.find(
|
|
(item) => item.path === filePath,
|
|
);
|
|
if (file) {
|
|
file.conflict = false;
|
|
file.staged = true;
|
|
file.unstaged = false;
|
|
}
|
|
recompute(repo);
|
|
return this.conflictState(localPath);
|
|
},
|
|
async continueGitOperation(localPath) {
|
|
return this.conflictState(localPath);
|
|
},
|
|
async abortGitOperation(localPath) {
|
|
return this.conflictState(localPath);
|
|
},
|
|
async stageFiles(localPath, files) {
|
|
const repo = findRepo(localPath);
|
|
repo.localStatus.files.forEach((item) => {
|
|
if (!files?.length || files.includes(item.path)) {
|
|
item.staged = true;
|
|
item.unstaged = false;
|
|
item.indexCode = item.untracked ? "A" : "M";
|
|
item.worktreeCode = ".";
|
|
}
|
|
});
|
|
recompute(repo);
|
|
emitRepositories();
|
|
return clone(repo.localStatus);
|
|
},
|
|
async unstageFiles(localPath, files) {
|
|
const repo = findRepo(localPath);
|
|
repo.localStatus.files.forEach((item) => {
|
|
if (!files?.length || files.includes(item.path)) {
|
|
item.staged = false;
|
|
item.unstaged = true;
|
|
item.indexCode = ".";
|
|
item.worktreeCode = item.untracked ? "?" : "M";
|
|
}
|
|
});
|
|
recompute(repo);
|
|
emitRepositories();
|
|
return clone(repo.localStatus);
|
|
},
|
|
async commit(localPath, message, files) {
|
|
await wait(520);
|
|
if (!message?.trim()) throw new Error("Enter a commit message.");
|
|
const repo = findRepo(localPath);
|
|
repo.localStatus.files = repo.localStatus.files.filter(
|
|
(item) => !files?.includes(item.path),
|
|
);
|
|
repo.localStatus.head = randomSha();
|
|
repo.localStatus.branch.ahead += 1;
|
|
recompute(repo);
|
|
emitRepositories();
|
|
return {
|
|
commitOutput: `[${repo.localStatus.branch.head} ${repo.localStatus.shortHead}] ${message}`,
|
|
commitSha: repo.localStatus.head,
|
|
status: clone(repo.localStatus),
|
|
};
|
|
},
|
|
async commitAndPush(localPath, message, files) {
|
|
const result = await this.commit(localPath, message, files);
|
|
const repo = findRepo(localPath);
|
|
await wait(240);
|
|
repo.localStatus.branch.ahead = 0;
|
|
recompute(repo);
|
|
emitRepositories();
|
|
return {
|
|
...result,
|
|
pushOutput: "Push completed.",
|
|
status: clone(repo.localStatus),
|
|
};
|
|
},
|
|
async commitStaged(localPath, message) {
|
|
const repo = findRepo(localPath);
|
|
return this.commit(
|
|
localPath,
|
|
message,
|
|
repo.localStatus.files
|
|
.filter((item) => item.staged)
|
|
.map((item) => item.path),
|
|
);
|
|
},
|
|
async commitStagedAndPush(localPath, message) {
|
|
const repo = findRepo(localPath);
|
|
return this.commitAndPush(
|
|
localPath,
|
|
message,
|
|
repo.localStatus.files
|
|
.filter((item) => item.staged)
|
|
.map((item) => item.path),
|
|
);
|
|
},
|
|
async push(localPath) {
|
|
await wait(360);
|
|
const repo = findRepo(localPath);
|
|
repo.localStatus.branch.ahead = 0;
|
|
recompute(repo);
|
|
emitRepositories();
|
|
return { output: "Push completed.", status: clone(repo.localStatus) };
|
|
},
|
|
async fetch() {
|
|
await wait(260);
|
|
return { output: "Fetch completed." };
|
|
},
|
|
async pull(localPath) {
|
|
await wait(380);
|
|
const repo = findRepo(localPath);
|
|
repo.localStatus.branch.behind = 0;
|
|
recompute(repo);
|
|
emitRepositories();
|
|
return { output: "Fast-forwarded.", status: clone(repo.localStatus) };
|
|
},
|
|
async history() {
|
|
await wait(100);
|
|
return clone(commitHistory);
|
|
},
|
|
async branchProtection(fullName, branch) {
|
|
return {
|
|
branch,
|
|
protected: branch === "main",
|
|
requiredApprovals: branch === "main" ? 1 : 0,
|
|
requireSignedCommits: false,
|
|
};
|
|
},
|
|
async pullRequests() {
|
|
return [
|
|
{
|
|
number: 42,
|
|
title: "Harden deployment preflight",
|
|
html_url: "https://gitea.internal/jens/vacancyradar/pulls/42",
|
|
created_at: iso(-7_200_000),
|
|
updated_at: iso(-900_000),
|
|
head: { ref: "feature/deployment-api" },
|
|
base: { ref: "main" },
|
|
},
|
|
];
|
|
},
|
|
async createPullRequest(fullName, title, body, base) {
|
|
return {
|
|
number: 42,
|
|
title,
|
|
body,
|
|
base,
|
|
html_url: `https://gitea.internal/${fullName}/pulls/42`,
|
|
};
|
|
},
|
|
async branches(localPath) {
|
|
const repo = findRepo(localPath);
|
|
if (!branchesByRepo.has(localPath))
|
|
branchesByRepo.set(localPath, [
|
|
{
|
|
name: repo.localStatus.branch.head,
|
|
current: true,
|
|
sha: repo.localStatus.head,
|
|
shortSha: repo.localStatus.shortHead,
|
|
upstream: repo.localStatus.branch.upstream,
|
|
},
|
|
{
|
|
name: "main",
|
|
current: repo.localStatus.branch.head === "main",
|
|
sha: repo.localStatus.head,
|
|
shortSha: repo.localStatus.shortHead,
|
|
upstream: "origin/main",
|
|
},
|
|
]);
|
|
return clone(branchesByRepo.get(localPath));
|
|
},
|
|
async checkoutBranch(localPath, branch) {
|
|
const repo = findRepo(localPath);
|
|
if (!repo.localStatus.clean)
|
|
throw new Error(
|
|
"Commit or stash local changes before switching branches.",
|
|
);
|
|
const list = await this.branches(localPath);
|
|
list.forEach((item) => {
|
|
item.current = item.name === branch;
|
|
});
|
|
branchesByRepo.set(localPath, list);
|
|
repo.localStatus.branch.head = branch;
|
|
repo.localStatus.branch.upstream = `origin/${branch}`;
|
|
recompute(repo);
|
|
emitRepositories();
|
|
return { status: clone(repo.localStatus), branches: clone(list) };
|
|
},
|
|
async createBranch(localPath, branch) {
|
|
const repo = findRepo(localPath);
|
|
const list = await this.branches(localPath);
|
|
list.forEach((item) => {
|
|
item.current = false;
|
|
});
|
|
list.unshift({
|
|
name: branch,
|
|
current: true,
|
|
sha: repo.localStatus.head,
|
|
shortSha: repo.localStatus.shortHead,
|
|
upstream: null,
|
|
});
|
|
branchesByRepo.set(localPath, list);
|
|
repo.localStatus.branch.head = branch;
|
|
repo.localStatus.branch.upstream = null;
|
|
recompute(repo);
|
|
emitRepositories();
|
|
return { status: clone(repo.localStatus), branches: clone(list) };
|
|
},
|
|
async stash(localPath, message) {
|
|
const repo = findRepo(localPath);
|
|
const list = stashesByRepo.get(localPath) || [];
|
|
list.unshift({
|
|
ref: `stash@{${list.length}}`,
|
|
subject: message || "ForgeFlow stash",
|
|
date: iso(),
|
|
});
|
|
stashesByRepo.set(localPath, list);
|
|
repo.localStatus.files = [];
|
|
recompute(repo);
|
|
emitRepositories();
|
|
return {
|
|
output: "Saved working directory and index state.",
|
|
status: clone(repo.localStatus),
|
|
stashes: clone(list),
|
|
};
|
|
},
|
|
async stashList(localPath) {
|
|
return clone(stashesByRepo.get(localPath) || []);
|
|
},
|
|
async popStash(localPath, ref) {
|
|
const repo = findRepo(localPath);
|
|
const list = stashesByRepo.get(localPath) || [];
|
|
const index = list.findIndex((item) => item.ref === ref);
|
|
if (index < 0) throw new Error("Stash not found.");
|
|
list.splice(index, 1);
|
|
stashesByRepo.set(localPath, list);
|
|
repo.localStatus.files = [makeFile("src/restored-from-stash.ts")];
|
|
recompute(repo);
|
|
emitRepositories();
|
|
return {
|
|
output: "Stash applied.",
|
|
status: clone(repo.localStatus),
|
|
stashes: clone(list),
|
|
};
|
|
},
|
|
async indexLockInfo() {
|
|
return { exists: false, ageMs: 0 };
|
|
},
|
|
async repairIndexLock() {
|
|
return { removed: true };
|
|
},
|
|
async setOrigin(localPath, remoteUrl) {
|
|
const repo = findRepo(localPath);
|
|
repo.localStatus.remoteUrl = remoteUrl;
|
|
repo.sshUrl = remoteUrl;
|
|
emitRepositories();
|
|
return clone(repo.localStatus);
|
|
},
|
|
async normalizeOrigins() {
|
|
const changes = [];
|
|
repositories
|
|
.filter((repo) => repo.localPath && repo.sshUrl)
|
|
.forEach((repo) => {
|
|
if (repo.localStatus.remoteUrl !== repo.sshUrl) {
|
|
changes.push({
|
|
fullName: repo.fullName,
|
|
previous: repo.localStatus.remoteUrl,
|
|
next: repo.sshUrl,
|
|
});
|
|
repo.localStatus.remoteUrl = repo.sshUrl;
|
|
}
|
|
});
|
|
emitRepositories();
|
|
return { changes, repositories: snapshot() };
|
|
},
|
|
async cloneRepository(fullName, mode = "default") {
|
|
await wait(620);
|
|
const repository = repositories.find(
|
|
(item) => item.fullName === fullName,
|
|
);
|
|
if (!repository) throw new Error("Repository not found.");
|
|
if (repository.localPath)
|
|
throw new Error("This repository already has a linked local folder.");
|
|
const root =
|
|
mode === "custom" ? "D:\\OtherProjects" : state.workspaceRoots[0];
|
|
if (!root) return { cancelled: true };
|
|
const target = `${root.replace(/[\\/]+$/, "")}\\${repository.name}`;
|
|
const head = randomSha();
|
|
repository.localPath = target;
|
|
repository.localStatus = makeStatus({
|
|
head,
|
|
branch: repository.defaultBranch || "main",
|
|
});
|
|
repository.localStatus.root = target;
|
|
repository.localStatus.remoteUrl =
|
|
repository.preferredCloneUrl || repository.cloneUrl;
|
|
repository.linkState = "linked";
|
|
recompute(repository);
|
|
const current = snapshot();
|
|
emitRepositories();
|
|
return {
|
|
target,
|
|
status: clone(repository.localStatus),
|
|
reused: false,
|
|
repositories: current,
|
|
state: clone(state),
|
|
};
|
|
},
|
|
async openPath() {
|
|
return true;
|
|
},
|
|
async openEditor() {
|
|
return { launched: true, executable: "code" };
|
|
},
|
|
async openTerminal() {
|
|
return { launched: true, executable: "wt.exe" };
|
|
},
|
|
async openExternal() {
|
|
return true;
|
|
},
|
|
async saveDeploymentProfile(fullName, input) {
|
|
const repo = repositories.find((item) => item.fullName === fullName);
|
|
const existing = repo.deploymentProfiles.find(
|
|
(item) => item.id === input.id,
|
|
);
|
|
const saved = {
|
|
...(existing ||
|
|
profile(
|
|
input.id || `profile-${Date.now()}`,
|
|
input.name || input.environment,
|
|
input.environment || "production",
|
|
)),
|
|
...input,
|
|
id: input.id || `profile-${Date.now()}`,
|
|
provider: input.provider || existing?.provider || "gitea-actions",
|
|
inputs: existing?.inputs || {},
|
|
state: existing?.state || {
|
|
liveSha: null,
|
|
previousSha: null,
|
|
healthy: null,
|
|
healthConfigured: Boolean(input.healthcheckUrl),
|
|
statusConfigured: Boolean(input.statusUrl),
|
|
checkedAt: null,
|
|
},
|
|
};
|
|
repo.deploymentProfiles = [
|
|
...repo.deploymentProfiles.filter((item) => item.id !== saved.id),
|
|
saved,
|
|
];
|
|
snapshot();
|
|
return { profile: clone(saved), state: clone(state) };
|
|
},
|
|
async deleteDeploymentProfile(fullName, profileId) {
|
|
const repo = repositories.find((item) => item.fullName === fullName);
|
|
repo.deploymentProfiles = repo.deploymentProfiles.filter(
|
|
(item) => item.id !== profileId,
|
|
);
|
|
snapshot();
|
|
return { profiles: clone(repo.deploymentProfiles), state: clone(state) };
|
|
},
|
|
async deploymentPreflight(repository, profileId) {
|
|
await wait(280);
|
|
const profile = repository.deploymentProfiles.find(
|
|
(item) => item.id === profileId,
|
|
);
|
|
const status = repository.localStatus;
|
|
const checks = [
|
|
{
|
|
id: "repository.linked",
|
|
label: "Local repository link",
|
|
status: repository.localPath ? "pass" : "fail",
|
|
detail: repository.localPath || "No local folder linked.",
|
|
required: true,
|
|
},
|
|
{
|
|
id: "git.branch",
|
|
label: "Allowed branch",
|
|
status: status?.branch.head === profile?.branch ? "pass" : "fail",
|
|
detail: `Current: ${status?.branch.head || "unknown"}; required: ${profile?.branch || "unknown"}.`,
|
|
required: true,
|
|
},
|
|
{
|
|
id: "git.clean",
|
|
label: "Clean working tree",
|
|
status: status?.clean ? "pass" : "fail",
|
|
detail: status?.clean
|
|
? "No uncommitted changes."
|
|
: `${status?.counts.changed || 0} changed file(s).`,
|
|
required: true,
|
|
},
|
|
{
|
|
id: "git.sync",
|
|
label: "Local and Gitea synchronized",
|
|
status:
|
|
!status?.branch.ahead && !status?.branch.behind ? "pass" : "fail",
|
|
detail: `${status?.branch.ahead || 0} ahead, ${status?.branch.behind || 0} behind.`,
|
|
required: true,
|
|
},
|
|
{
|
|
id: "workflow.deploy.remote",
|
|
label: "Deploy workflow on Gitea branch",
|
|
status: "pass",
|
|
detail: `${profile?.workflowFile || "deploy.yml"} exists on ${profile?.branch || "main"}.`,
|
|
required: true,
|
|
},
|
|
{
|
|
id: "gitea.actions",
|
|
label: "Gitea Actions API",
|
|
status: "pass",
|
|
detail: "The Actions runs endpoint is accessible.",
|
|
required: true,
|
|
},
|
|
{
|
|
id: "server.status",
|
|
label: "Server version endpoint",
|
|
status: profile?.statusUrl ? "pass" : "warning",
|
|
detail: profile?.statusUrl
|
|
? `Endpoint reachable; live ${profile.state?.liveSha?.slice(0, 7) || "unknown"}.`
|
|
: "No status URL configured.",
|
|
required: false,
|
|
},
|
|
{
|
|
id: "server.health",
|
|
label: "Application healthcheck",
|
|
status: profile?.healthcheckUrl ? "pass" : "warning",
|
|
detail: profile?.healthcheckUrl
|
|
? "HTTP 200 in 42 ms."
|
|
: "No healthcheck URL configured.",
|
|
required: false,
|
|
},
|
|
];
|
|
const blocking = checks
|
|
.filter((i) => i.required && i.status === "fail")
|
|
.map((i) => i.id);
|
|
return {
|
|
kind: "deployment",
|
|
repository: repository.fullName,
|
|
profileId,
|
|
startedAt: iso(-100),
|
|
completedAt: iso(),
|
|
checks,
|
|
summary: {
|
|
counts: {
|
|
pass: checks.filter((i) => i.status === "pass").length,
|
|
warning: checks.filter((i) => i.status === "warning").length,
|
|
fail: checks.filter((i) => i.status === "fail").length,
|
|
skipped: 0,
|
|
},
|
|
blocking,
|
|
ready: blocking.length === 0,
|
|
},
|
|
head: status?.head || null,
|
|
};
|
|
},
|
|
async deploy(repository, profileId, sha) {
|
|
await wait(320);
|
|
const selected = repository.deploymentProfiles.find(
|
|
(item) => item.id === profileId,
|
|
);
|
|
const operation = {
|
|
id: `deploy-${Date.now()}`,
|
|
type: "deployment",
|
|
action: "deploy",
|
|
status: "queued",
|
|
repository: repository.fullName,
|
|
profileId,
|
|
profileName: selected.name,
|
|
environment: selected.environment,
|
|
workflowFile: selected.workflowFile,
|
|
branch: selected.branch,
|
|
sha,
|
|
shortSha: sha.slice(0, 7),
|
|
dispatchedAt: iso(),
|
|
createdAt: iso(),
|
|
updatedAt: iso(),
|
|
demoPolls: 0,
|
|
stages: [
|
|
{ id: "requested", label: "Requested", status: "complete" },
|
|
{ id: "verified", label: "Verified", status: "complete" },
|
|
{ id: "queued", label: "Workflow queued", status: "active" },
|
|
{ id: "runner", label: "Runner execution", status: "pending" },
|
|
{ id: "healthcheck", label: "Healthcheck", status: "pending" },
|
|
{ id: "complete", label: "Complete", status: "pending" },
|
|
],
|
|
logs: [
|
|
`[info] Verified clean ${selected.branch} at ${sha}`,
|
|
`[ok] Gitea accepted ${selected.workflowFile}.`,
|
|
],
|
|
};
|
|
return updateOperation(operation);
|
|
},
|
|
async rollback(repository, profileId, targetSha) {
|
|
await wait(320);
|
|
const selected = repository.deploymentProfiles.find(
|
|
(item) => item.id === profileId,
|
|
);
|
|
const operation = {
|
|
id: `rollback-${Date.now()}`,
|
|
type: "deployment",
|
|
action: "rollback",
|
|
status: "queued",
|
|
repository: repository.fullName,
|
|
profileId,
|
|
profileName: selected.name,
|
|
environment: selected.environment,
|
|
workflowFile: selected.rollbackWorkflowFile,
|
|
branch: selected.branch,
|
|
sha: targetSha,
|
|
shortSha: targetSha.slice(0, 7),
|
|
dispatchedAt: iso(),
|
|
createdAt: iso(),
|
|
updatedAt: iso(),
|
|
demoPolls: 0,
|
|
stages: [
|
|
{ id: "requested", label: "Requested", status: "complete" },
|
|
{ id: "verified", label: "Verified", status: "complete" },
|
|
{ id: "queued", label: "Workflow queued", status: "active" },
|
|
{ id: "runner", label: "Runner execution", status: "pending" },
|
|
{ id: "healthcheck", label: "Healthcheck", status: "pending" },
|
|
{ id: "complete", label: "Complete", status: "pending" },
|
|
],
|
|
logs: [
|
|
`[warning] Rollback target verified: ${targetSha}`,
|
|
`[ok] Gitea accepted ${selected.rollbackWorkflowFile}.`,
|
|
],
|
|
};
|
|
return updateOperation(operation);
|
|
},
|
|
async healthcheck() {
|
|
await wait(160);
|
|
return { configured: true, healthy: true, status: 200, latencyMs: 42 };
|
|
},
|
|
async refreshProfileState(fullName, profileId) {
|
|
await wait(240);
|
|
const repo =
|
|
repositories.find((item) => item.fullName === fullName) ||
|
|
findProfileRepo(profileId);
|
|
const target = repo?.deploymentProfiles.find(
|
|
(item) => item.id === profileId,
|
|
);
|
|
if (!target) throw new Error("Deployment profile not found.");
|
|
target.state = {
|
|
...target.state,
|
|
checkedAt: iso(),
|
|
healthy: target.state.healthy !== false,
|
|
healthConfigured: Boolean(target.healthcheckUrl),
|
|
statusConfigured: Boolean(target.statusUrl),
|
|
};
|
|
syncState();
|
|
return clone(target.state);
|
|
},
|
|
async discoverServerDeployments() {
|
|
await wait(80);
|
|
return [
|
|
{
|
|
serverId: "server-unraid",
|
|
serverName: "Unraid",
|
|
detected: 2,
|
|
adopted: 0,
|
|
verified: 1,
|
|
linked: 1,
|
|
unmatched: 0,
|
|
needsReview: 1,
|
|
running: 2,
|
|
stopped: 0,
|
|
capabilities: {
|
|
docker: true,
|
|
dockerReady: true,
|
|
compose: true,
|
|
git: false,
|
|
tar: true,
|
|
checksum: true,
|
|
},
|
|
warnings: [],
|
|
workloads: [
|
|
{
|
|
workloadId: "workload-demo-linked",
|
|
displayName: "Portfolio",
|
|
status: "linked",
|
|
runtime: { running: true, health: "healthy" },
|
|
compose: {
|
|
project: "portfolio",
|
|
workingDir: "/mnt/user/appdata/portfolio",
|
|
configFiles: ["/mnt/user/appdata/portfolio/docker-compose.yml"],
|
|
services: ["web"],
|
|
},
|
|
containers: [{ name: "Portfolio", running: true }],
|
|
candidates: [],
|
|
link: {
|
|
profileId: "profile-portfolio",
|
|
repositoryFullName: "jens/portfolio",
|
|
source: "manual",
|
|
},
|
|
},
|
|
{
|
|
workloadId: "workload-demo-review",
|
|
displayName: "OmniRoute",
|
|
status: "suggested",
|
|
runtime: { running: true, health: "unverified" },
|
|
compose: {
|
|
project: "omniroute",
|
|
workingDir: "/mnt/user/appdata/OmniRoute",
|
|
configFiles: ["/mnt/user/appdata/OmniRoute/docker-compose.yml"],
|
|
services: ["omniroute"],
|
|
},
|
|
containers: [{ name: "omniroute", running: true }],
|
|
remoteFolderCandidate: "OmniRoute",
|
|
candidates: repositories.slice(0, 1).map((repository) => ({
|
|
repositoryFullName: repository.fullName,
|
|
repositoryName: repository.name,
|
|
score: 55,
|
|
exact: false,
|
|
reasons: ["container and repository names are similar"],
|
|
})),
|
|
},
|
|
],
|
|
},
|
|
];
|
|
},
|
|
async planServerReconciliation(serverId) {
|
|
await wait(90);
|
|
const id = "a".repeat(64);
|
|
return {
|
|
inventory: (await this.discoverServerDeployments()).find((item) => item.serverId === serverId),
|
|
plan: {
|
|
id,
|
|
serverId,
|
|
summary: { additions: 0, updates: 1, stale: 0, conflicts: 1 },
|
|
additions: [],
|
|
updates: [{ workloadId: "workload-demo-linked", profileId: "profile-portfolio", repositoryFullName: "jens/portfolio", impact: "Refresh detected Compose identity and observed deployment state" }],
|
|
stale: [],
|
|
conflicts: [{ workloadId: "workload-demo-review", displayName: "OmniRoute", status: "suggested", candidates: [{ repositoryFullName: repositories[0].fullName, score: 55, exact: false }] }],
|
|
},
|
|
};
|
|
},
|
|
async applyServerReconciliation(serverId, planId) {
|
|
await wait(120);
|
|
if (serverId !== "server-unraid" || planId !== "a".repeat(64)) throw new Error("The reconciliation plan is stale.");
|
|
return { adopted: 0, refreshed: 1, retired: 0, state: clone(state) };
|
|
},
|
|
async linkServerWorkload(repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "") {
|
|
await wait(120);
|
|
const repo = repositories.find((item) => item.fullName === repository.fullName);
|
|
if (!repo) throw new Error("Repository not found.");
|
|
const id = `profile-${workloadId}`;
|
|
const saved = {
|
|
id,
|
|
name: `Unraid · ${remoteFolder || repo.name}`,
|
|
environment: "production",
|
|
provider: "ssh-unraid",
|
|
branch: repo.defaultBranch || "main",
|
|
serverId,
|
|
remoteFolder: remoteFolder || repo.name,
|
|
deploymentMode,
|
|
composeFile: "docker-compose.yml",
|
|
composeFiles: ["docker-compose.yml"],
|
|
composeProject: String(remoteFolder || repo.name).toLowerCase(),
|
|
composeService: String(remoteFolder || repo.name).toLowerCase(),
|
|
composeServices: [String(remoteFolder || repo.name).toLowerCase()],
|
|
containerName: remoteFolder || repo.name,
|
|
preservePaths: [".env", "appdata", "data", "logs", "config"],
|
|
generatedCompose: false,
|
|
adoptedFromServer: true,
|
|
serverSourceOfTruth: true,
|
|
manageDockerMan: false,
|
|
forceRecreate: false,
|
|
removeOrphans: false,
|
|
workloadIdentity: { workloadId, linkSource: "manual", linkedAt: iso() },
|
|
confirmationRequired: true,
|
|
state: {
|
|
liveSha: null,
|
|
healthy: null,
|
|
containerRunning: true,
|
|
runtimeVerification: "running-unverified",
|
|
checkedAt: iso(),
|
|
},
|
|
};
|
|
repo.deploymentProfiles = [
|
|
...repo.deploymentProfiles.filter((item) => item.id !== id),
|
|
saved,
|
|
];
|
|
syncState();
|
|
return { profile: clone(saved), state: clone(state) };
|
|
},
|
|
async configureServerGitAccess(repository, profileId) {
|
|
const repo = repositories.find((item) => item.fullName === repository.fullName);
|
|
const target = repo?.deploymentProfiles.find((item) => item.id === profileId);
|
|
if (!target) throw new Error("Deployment profile not found.");
|
|
target.deploymentMode = "server-git";
|
|
target.serverGitAccess = { configured: true, keyFingerprint: "SHA256:demo", hostFingerprint: "SHA256:gitea", configuredAt: iso() };
|
|
syncState();
|
|
return { profile: clone(target), created: true, remoteSha: target.state?.giteaSha || repo.localStatus?.head };
|
|
},
|
|
async verifyServerGitProfile(repository, profileId) {
|
|
const repo = repositories.find((item) => item.fullName === repository.fullName);
|
|
const target = repo?.deploymentProfiles.find((item) => item.id === profileId);
|
|
if (!target) throw new Error("Deployment profile not found.");
|
|
const branchSha = target.state?.giteaSha || repo.localStatus?.head || null;
|
|
const liveSha = target.state?.liveSha || null;
|
|
return {
|
|
readiness: branchSha && liveSha === branchSha ? "Ready" : "Commit mismatch",
|
|
ready: true,
|
|
checkedAt: iso(),
|
|
repository: repo.fullName,
|
|
profileId,
|
|
branchSha,
|
|
liveSha,
|
|
checks: [
|
|
{ id: "remote-branch", label: "Gitea branch", status: "pass", detail: "Exact branch resolved." },
|
|
{ id: "deploy-key-scope", label: "Repository deploy key", status: "pass", detail: "Repository-scoped and read-only." },
|
|
{ id: "server-git-access", label: "Unraid to Gitea", status: "pass", detail: "Pinned SSH access verified." },
|
|
],
|
|
};
|
|
},
|
|
async refreshOperations(operationId = null) {
|
|
await wait(300);
|
|
if (operationId) {
|
|
const operation = state.operations.find(
|
|
(item) => item.id === operationId,
|
|
);
|
|
if (!operation) throw new Error("Operation not found.");
|
|
return updateOperation(advanceOperation(operation));
|
|
}
|
|
const active = state.operations
|
|
.filter(
|
|
(item) =>
|
|
!["success", "failed", "cancelled", "rolled-back"].includes(
|
|
item.status,
|
|
),
|
|
)
|
|
.map(advanceOperation);
|
|
if (active.length) emitOperations(active);
|
|
state.operations = state.operations.map(
|
|
(item) => active.find((entry) => entry.id === item.id) || item,
|
|
);
|
|
return clone(active);
|
|
},
|
|
async getOperation(operationId) {
|
|
return clone(
|
|
state.operations.find((item) => item.id === operationId) || null,
|
|
);
|
|
},
|
|
async gitValidatorScan(fullName) {
|
|
await wait(260);
|
|
return {
|
|
repository: fullName,
|
|
checkedAt: iso(),
|
|
score: 78,
|
|
grade: "Good",
|
|
summary: { passed: 7, warnings: 3, errors: 0, repairable: 2 },
|
|
checks: [
|
|
{
|
|
id: "origin",
|
|
category: "Repository identity",
|
|
title: "Origin matches Gitea",
|
|
status: "pass",
|
|
detail: "The local origin resolves to this Gitea repository.",
|
|
weight: 15,
|
|
},
|
|
{
|
|
id: "default-branch-protection",
|
|
category: "Gitea governance",
|
|
title: "Default branch protection",
|
|
status: "warning",
|
|
detail: "main accepts unprotected direct changes.",
|
|
weight: 18,
|
|
fixAction: "protect-default-branch",
|
|
safe: false,
|
|
confirmation:
|
|
"Protect main on Gitea and block direct and force pushes?",
|
|
},
|
|
{
|
|
id: "force-push",
|
|
category: "Gitea governance",
|
|
title: "Force-push protection",
|
|
status: "pass",
|
|
detail: "Force pushes are blocked.",
|
|
weight: 8,
|
|
},
|
|
{
|
|
id: "upstream",
|
|
category: "Branch hygiene",
|
|
title: "Current branch has an upstream",
|
|
status: "pass",
|
|
detail: "main tracks origin/main.",
|
|
weight: 8,
|
|
},
|
|
{
|
|
id: "working-tree",
|
|
category: "Branch hygiene",
|
|
title: "Working tree is intentional",
|
|
status: "warning",
|
|
detail: "3 changed files require review, commit or stash.",
|
|
weight: 5,
|
|
},
|
|
{
|
|
id: "identity",
|
|
category: "Commit integrity",
|
|
title: "Repository author identity",
|
|
status: "pass",
|
|
detail: "Jens <jens@example.test>",
|
|
weight: 7,
|
|
},
|
|
{
|
|
id: "local-safety",
|
|
category: "Local configuration",
|
|
title: "Safe synchronization defaults",
|
|
status: "warning",
|
|
detail: "Recommended repository-local safeguards are incomplete.",
|
|
weight: 10,
|
|
fixAction: "configure-local-safety",
|
|
safe: true,
|
|
},
|
|
{
|
|
id: "readme",
|
|
category: "Repository documentation",
|
|
title: "README is versioned",
|
|
status: "pass",
|
|
detail: "Repository documentation is tracked.",
|
|
weight: 7,
|
|
},
|
|
{
|
|
id: "gitignore",
|
|
category: "Repository hygiene",
|
|
title: ".gitignore is versioned",
|
|
status: "pass",
|
|
detail: "Generated files are excluded centrally.",
|
|
weight: 8,
|
|
},
|
|
{
|
|
id: "tracked-secrets",
|
|
category: "Security",
|
|
title: "No secret-shaped files are tracked",
|
|
status: "pass",
|
|
detail:
|
|
"No tracked environment, key or credential filenames detected.",
|
|
weight: 22,
|
|
},
|
|
{
|
|
id: "large-files",
|
|
category: "Repository performance",
|
|
title: "No oversized tracked files",
|
|
status: "pass",
|
|
detail: "No tracked files above 10 MB were found.",
|
|
weight: 7,
|
|
},
|
|
],
|
|
};
|
|
},
|
|
async gitValidatorRepair() {
|
|
await wait(180);
|
|
return { repaired: true };
|
|
},
|
|
async diagnosticsStatus() {
|
|
return {
|
|
enabled: state.preferences.diagnosticsEnabled !== false,
|
|
level: state.preferences.diagnosticLevel,
|
|
retentionDays: state.preferences.logRetentionDays,
|
|
maxFileMb: state.preferences.maxLogFileMb,
|
|
directory: "<HOME>/AppData/Roaming/ForgeFlow/diagnostics",
|
|
fileCount: 2,
|
|
totalBytes: 18432,
|
|
totalSize: "18.0 KB",
|
|
latestAt: iso(-2000),
|
|
lastWriteError: null,
|
|
};
|
|
},
|
|
async exportConfigurationBackup() {
|
|
return {
|
|
filePath: "C:\\Downloads\\ForgeFlow-Configuration-demo.ffbackup",
|
|
};
|
|
},
|
|
async importConfigurationBackup() {
|
|
return { state: clone(state), exportedAt: iso(-86400000) };
|
|
},
|
|
async listAuditEvents() {
|
|
return [
|
|
{
|
|
id: "audit-1",
|
|
timestamp: iso(-60000),
|
|
event: "deployment.completed",
|
|
details: { repository: "Jens/ForgeFlow", result: "success" },
|
|
},
|
|
];
|
|
},
|
|
async exportAuditLog() {
|
|
return { filePath: "C:\\Downloads\\ForgeFlow-Audit-demo.json", count: 1 };
|
|
},
|
|
async clearDiagnostics() {
|
|
return {
|
|
enabled: true,
|
|
level: state.preferences.diagnosticLevel,
|
|
retentionDays: state.preferences.logRetentionDays,
|
|
maxFileMb: state.preferences.maxLogFileMb,
|
|
directory: "<HOME>/AppData/Roaming/ForgeFlow/diagnostics",
|
|
fileCount: 1,
|
|
totalBytes: 256,
|
|
totalSize: "256 B",
|
|
latestAt: iso(),
|
|
lastWriteError: null,
|
|
};
|
|
},
|
|
async openDiagnosticsFolder() {
|
|
return true;
|
|
},
|
|
async exportDiagnostics(privacyMode = "standard") {
|
|
await wait(500);
|
|
return {
|
|
path: `C:\Users\Jens\Downloads\ForgeFlow-Diagnostics-demo.zip`,
|
|
bytes: 38221,
|
|
size: "37.3 KB",
|
|
sha256: "b".repeat(64),
|
|
privacyMode,
|
|
generatedAt: iso(),
|
|
};
|
|
},
|
|
async showDiagnosticBundle() {
|
|
return true;
|
|
},
|
|
async reportRendererEvent() {
|
|
return true;
|
|
},
|
|
onRepositoriesChanged(listener) {
|
|
repositoryListeners.add(listener);
|
|
return () => repositoryListeners.delete(listener);
|
|
},
|
|
onOperationsChanged(listener) {
|
|
operationListeners.add(listener);
|
|
return () => operationListeners.delete(listener);
|
|
},
|
|
onUpdatesChanged(listener) {
|
|
updateListeners.add(listener);
|
|
return () => updateListeners.delete(listener);
|
|
},
|
|
async reset() {
|
|
state.setupComplete = false;
|
|
storage.set("forgeflow-demo-setup", "false");
|
|
return clone(state);
|
|
},
|
|
});
|
|
})();
|