diff --git a/build-helper/README.md b/build-helper/README.md index b3325cf..66e2b38 100644 --- a/build-helper/README.md +++ b/build-helper/README.md @@ -20,20 +20,40 @@ written into your repos. ## Features -- **Dashboard** — every Flutter app under your projects root: detected - environments, version, signing/health, and last-build status at a glance. -- **Build** — per-environment version name + build number, pick APK/AAB/IPA, - optional `clean` / `pub get` / `analyze` / `test` gates, and a live streaming - build log. Environment/flavor switching and version writing happen as part of - the build (Android + the iOS Xcode/plist/export-compliance patches). -- **Distribute** — tick OneDrive / Firebase / Store per build; rollback a Play - release or retry a single upload without rebuilding; LAN QR install pages for - testers (`/install`) with an iOS ad-hoc manifest. +- **Dashboard** — a sortable, drag-to-reorder **project-health table** (favorite + ★, density toggle, per-row security grade from the Mobile Security tool), + **range / project / env filters** feeding KPIs and hand-rolled **SVG charts** + (builds over time, duration, upload health, env split), and a **⌘K** quick-jump + palette. A guided **tour** (`?` in the nav) explains each screen. +- **Build** — a dev→demo→qa→prod **pipeline**, per-env version (with **M/m/p** + bump) + build number (auto next-free), a full **outputs matrix** per env + (Build only / OneDrive / TestFlight / Firebase / Play + track selector + + validate-IPA + upload-symbols), **release notes** filled from a template, + grouped commits, or tracker tasks, a **Jira task picker** (search + create), + a **branch selector**, and an **Advanced** menu (clean/pub-get/analyze/test/ + commit+tag/push/warn-only). The live log **survives navigation**, and blocked + builds surface inline **guard prompts** (dirty / duplicate / low-version / + prod-confirm → "Build anyway" / "Use X"). +- **Build detail & history** — per-build detail with artifact download / copy + path / **re-upload to any target**, **TestFlight re-manage** (export + compliance + "What to Test"), and **Play rollback**. History supports + multi-select **bulk re-upload**. +- **Share** — a branded **build card** (canvas PNG + QR), copy image, copy team + / client text, **email client groups**, and **create a Jira release**. +- **App Setup** (per app) — release-readiness checklist, env-file/const override, + **custom environments**, Firebase App ID, per-app Apple/Play, build defaults + (flavor / args / pre- & post-build commands), **scheduled builds**, per-app + trackers, and client email groups. +- **Distribute** — OneDrive (incl. a **shared team-folder** mode) / Firebase / + Play / TestFlight; LAN QR install pages for testers (`/install`) with an iOS + ad-hoc manifest. - **Signing** — generate an upload keystore (or link an existing one), write `android/key.properties`, and wire `build.gradle` automatically. -- **Setup / Doctor** — one-click toolchain checks (Flutter, Xcode, CocoaPods, - rclone, firebase-tools…) with safe installers, plus OneDrive / Play / Apple / - Firebase connector setup. +- **Setup / Doctor** — one-click toolchain checks with safe installers and a live + **`flutter doctor`** run, plus OneDrive / Play / Apple / Firebase setup. +- **Scheduler & self-update** — a 1-minute tick runs unattended per-app + **scheduled builds**; the nav **⬆** button shows the version, tool changelog, + and updates via `git pull`. - **Reports** — filter build history by range/env and export CSV or a printable (PDF) report. @@ -43,14 +63,17 @@ configured in the shared **⚙ Settings** gear, powered by `platform-kit`. ## Layout ``` -server.js wiring only — createKitServer + team + connectors + route groups +server.js wiring only — createKitServer + team + connectors + route groups + scheduler tool.json id / name / port (4095) -lib/routes/ apps · build · signing · setup · dist · pages (barrel: index.js) +lib/routes/ apps · build · testflight · signing · setup · dist · pages · tool (barrel: index.js) lib/project/ detect · env-switch · version · signing · git · scan · artifacts lib/build/ the build pipeline (orchestrate · guards · artifacts · uploads · record) lib/stores/ ASC · Play · TestFlight · Firebase upload/versioning lib/dashboard/ dashboard data, reports, distribute, doctor, share, per-app config -public/js/ ordered UI modules: state → api → components → views → project → setup → app +lib/ schedule · selfupdate · qr · onedrive · trackers · messaging · setup · … +public/js/ state → api → components → charts → health-table → views → dashboard + → build-form → notes → build-run → history → share → share-actions + → detail → app-setup(+more) → project → setup → cmdk → app ``` Signing config can also come from a local `signing.json` (see diff --git a/build-helper/lib/dashboard/data.js b/build-helper/lib/dashboard/data.js index 6ac70db..81c7d11 100644 --- a/build-helper/lib/dashboard/data.js +++ b/build-helper/lib/dashboard/data.js @@ -4,7 +4,7 @@ const fs = require('fs'); const path = require('path'); const { ARTIFACTS_DIR, BUILDS_JSON, readJson, readConfig, projectsRoot, projectSources, running } = require('../state'); -const { scanAll, appHealth } = require('../project'); +const { scanAll, appHealth, findAppIcon } = require('../project'); // Total + per-project artifact bytes under artifacts/. function storageData() { @@ -34,7 +34,7 @@ function dashboardData() { const pb = all.filter((b) => b.path === p.path); const last = pb[0] || null; const h = appHealth(p.path, { needsConfig: p.needsConfig, firebase: p.firebase }); - return { ...p, buildCount: pb.length, + return { ...p, buildCount: pb.length, hasIcon: !!findAppIcon(p.path), favorite: !!(apps[p.path] && apps[p.path].favorite), order: apps[p.path] && apps[p.path].order != null ? apps[p.path].order : undefined, healthOk: h.envDetected && h.signing && h.packageId, diff --git a/build-helper/lib/dashboard/doctor.js b/build-helper/lib/dashboard/doctor.js index 10ff581..60a12db 100644 --- a/build-helper/lib/dashboard/doctor.js +++ b/build-helper/lib/dashboard/doctor.js @@ -1,6 +1,6 @@ 'use strict'; // Doctor: environment/preflight checks — is each tool installed, its version, and how to install it. -const { execFileSync } = require('child_process'); +const { execFileSync, spawn } = require('child_process'); const { ROOT, FLUTTER, RCLONE } = require('../state'); const { setupStatus } = require('../setup'); @@ -47,4 +47,16 @@ function doctorData() { } const doctorInstallCmd = (tool) => { const t = TOOL_INSTALL[tool]; return t && t.canInstall ? t.cmd : null; }; -module.exports = { TOOL_INSTALL, doctorData, doctorInstallCmd }; +// Live `flutter doctor -v` — streams each line to onLine, resolves on exit. Never rejects. +function doctorFull(onLine) { + return new Promise((resolve) => { + let buf = ''; + const pump = (c) => { buf += c.toString(); let i; while ((i = buf.indexOf('\n')) >= 0) { onLine(buf.slice(0, i)); buf = buf.slice(i + 1); } }; + let p; try { p = spawn(FLUTTER, ['doctor', '-v'], { env: process.env }); } catch (e) { onLine(`flutter doctor unavailable: ${e.message}`); return resolve(); } + p.stdout.on('data', pump); p.stderr.on('data', pump); + p.on('error', (e) => { onLine(`flutter doctor unavailable: ${e.message}`); resolve(); }); + p.on('close', () => { if (buf) onLine(buf); resolve(); }); + }); +} + +module.exports = { TOOL_INSTALL, doctorData, doctorInstallCmd, doctorFull }; diff --git a/build-helper/lib/onedrive.js b/build-helper/lib/onedrive.js index c51b14c..fa475e5 100644 --- a/build-helper/lib/onedrive.js +++ b/build-helper/lib/onedrive.js @@ -39,4 +39,27 @@ async function handleSetupRclone(req, res) { res.end(); } -module.exports = { onedriveConnected, ensureRclone, handleSetupRclone }; +// Shared/team-folder mode: connect your OWN OneDrive account, then route uploads into a team folder +// the owner shared with you. Add the shared folder to "My files" (OneDrive → shared → "Add shortcut +// to My files") so it appears under your drive at a plain path — then enter that path as the base. +// ponytail: path-based (needs the shortcut). Full share-link → drive_id/root_folder_id resolution via +// Microsoft Graph (the old team mode) can replace this if link-only sharing must work. +async function handleSetupRcloneShared(req, res, body) { + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }); + const send = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + const log = (line) => send('log', { line }); + const cfg = readConfig(); + try { + const base = String((body || {}).base || '').trim(); + if (!base) throw new Error('Enter the shared/team folder path (as it appears under your OneDrive).'); + await ensureRclone(log); + send('step', { text: 'Connecting your OneDrive — a browser window will open for login…' }); + await run(RCLONE, rc(['config', 'create', cfg.onedrive.remote, 'onedrive']), process.cwd(), log); + cfg.onedrive.base = base; writeConfig(cfg); + log(`OneDrive connected ✓ — uploads will go to "${base}".`); + send('done', { ok: true, base }); + } catch (e) { send('error', { message: e.message }); } + res.end(); +} + +module.exports = { onedriveConnected, ensureRclone, handleSetupRclone, handleSetupRcloneShared }; diff --git a/build-helper/lib/qr.js b/build-helper/lib/qr.js new file mode 100644 index 0000000..ffb8ccd --- /dev/null +++ b/build-helper/lib/qr.js @@ -0,0 +1,19 @@ +'use strict'; +// Same-origin QR proxy. The share-card can't drawImage a cross-origin QR without tainting +// the canvas (blocks toDataURL), so we pipe the PNG through our own origin. Binary-safe: kit.fetchUrl +// returns decoded text, so we stream raw bytes here instead. +// ponytail: proxies api.qrserver.com — swap for a local zero-dep QR encoder if offline use matters. +const https = require('https'); + +function proxyQr(res, text, size) { + if (!text) { res.writeHead(400); return res.end('missing text'); } + const s = Math.min(Math.max(parseInt(size, 10) || 220, 60), 600); + const url = `https://api.qrserver.com/v1/create-qr-code/?size=${s}x${s}&margin=0&data=${encodeURIComponent(text)}`; + https.get(url, (r) => { + if (r.statusCode !== 200) { r.resume(); res.writeHead(502); return res.end(); } + res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=86400' }); + r.pipe(res); + }).on('error', () => { res.writeHead(502); res.end(); }); +} + +module.exports = { proxyQr }; diff --git a/build-helper/lib/routes/apps.js b/build-helper/lib/routes/apps.js index fa56272..ae505cc 100644 --- a/build-helper/lib/routes/apps.js +++ b/build-helper/lib/routes/apps.js @@ -3,7 +3,10 @@ // health, git metadata, changelog, and the app icon image. const fs = require('fs'); const { spawn } = require('child_process'); +const { fetchUrl } = require('../../../platform-kit'); const { BUILDS_JSON, readJson, readConfig, projectsRoot } = require('../state'); + +const SECURITY_URL = process.env.SECURITY_URL || 'http://localhost:4110'; const { dashboardData, handleAppSave, handleFavorite, handleReorder } = require('../dashboard'); const { detectApp, scanProjects, gitBranches, gitLastCommit, gitChangelog, groupConventional, nextBuildNumber, appHealth, findAppIcon } = require('../project'); const { setupStatus } = require('../setup'); @@ -48,6 +51,15 @@ function register(app) { app.r('POST', '/api/app/favorite', ({ res, body }) => handleFavorite(res, body), { body: true }); app.r('POST', '/api/app/order', ({ res, body }) => handleReorder(res, body), { body: true }); app.r('GET', '/api/app/health', ({ res, q }) => { const p = q.get('path'); if (!p || !fs.existsSync(p)) return J(res, 404, { error: 'not found' }); return J(res, 200, appHealth(p)); }); + // Per-project security grades from the Mobile Security tool (4110). Best-effort — tolerates it being down. + app.r('GET', '/api/security/grades', async ({ res }) => { + const grades = {}; + try { + const r = await fetchUrl(`${SECURITY_URL}/api/security/all`, { timeout: 2500 }); + for (const a of (JSON.parse(r.body || '{}').apps || [])) if (a.path && a.grade) grades[a.path] = { letter: a.grade.letter, score: a.grade.score }; + } catch {} + return J(res, 200, { grades }); + }); app.r('GET', '/api/gitmeta', ({ res, q }) => J(res, 200, gitLastCommit(q.get('path'), q.get('branch') || '') || {})); app.r('GET', '/api/changelog', ({ res, q }) => { const text = gitChangelog(q.get('path'), q.get('env')); return J(res, 200, { text: q.get('grouped') ? groupConventional(text) : text }); }); app.r('GET', '/api/icon', ({ res, q }) => { diff --git a/build-helper/lib/routes/dist.js b/build-helper/lib/routes/dist.js index f65b7c1..ec6df72 100644 --- a/build-helper/lib/routes/dist.js +++ b/build-helper/lib/routes/dist.js @@ -2,9 +2,12 @@ // Distribution + reporting routes: rollback (re-promote a stored Play versionCode) and retry-upload // (re-run one upload target without rebuilding), share-card upload + OneDrive folder prep, artifact // cleanup, CSV / printable build reports, and Jira release / issue creation. +const fs = require('fs'); +const path = require('path'); +const { ARTIFACTS_DIR } = require('../state'); const { handleRollback, handleRetryUpload, handleShareImage, handleMkdirs, handleTeamCard, cleanupArtifacts, reportRows, reportCsv, reportHtml } = require('../dashboard'); -const { handleCreateRelease, handleCreateIssue } = require('../trackers'); +const { handleCreateRelease, handleCreateIssue, handleTasks, jiraSearchFor, handleJiraMeta } = require('../trackers'); function register(app) { const J = app.sendJson; @@ -14,8 +17,26 @@ function register(app) { app.r('POST', '/api/share/mkdirs', ({ req, res }) => handleMkdirs(req, res)); app.r('POST', '/api/share/card', ({ res, body }) => handleTeamCard(res, body), { body: true }); app.r('POST', '/api/cleanup', ({ res, body }) => J(res, 200, cleanupArtifacts(body)), { body: true }); + // On-disk artifacts for one project (build page browser) + delete-one. + app.r('GET', '/api/artifacts', ({ res, q }) => { + const dir = path.join(ARTIFACTS_DIR, path.basename(q.get('project') || '')); + let files = []; try { files = fs.readdirSync(dir).filter((n) => !n.startsWith('_')).map((n) => { const p = path.join(dir, n); return { name: n, path: p, size: fs.statSync(p).size }; }); } catch {} + return J(res, 200, { files }); + }); + app.r('POST', '/api/artifact/delete', ({ res, body }) => { + const p = path.resolve(String((body || {}).path || '')); + if (!p.startsWith(ARTIFACTS_DIR + path.sep)) return J(res, 400, { error: 'outside artifacts dir' }); + try { fs.unlinkSync(p); } catch (e) { return J(res, 400, { error: e.message }); } + return J(res, 200, { ok: true }); + }, { body: true }); app.r('POST', '/api/tracker/release', ({ req, res, body }) => handleCreateRelease(req, res, body), { body: true }); app.r('POST', '/api/tracker/issue', ({ res, body }) => handleCreateIssue(res, body), { body: true }); + // Build-page Jira picker: commit-scraped tasks (?path=&env=) and issue search to link (?path=&q=). + app.r('GET', '/api/tracker/tasks', ({ res, q }) => handleTasks(res, q.get('path'), q.get('env'))); + app.r('GET', '/api/jira/search', ({ res, q }) => jiraSearchFor(res, q.get('path'), q.get('q'))); + app.r('GET', '/api/jira/meta', ({ res, q }) => handleJiraMeta(res, q.get('path'))); + app.r('POST', '/api/jira/create-release', ({ req, res, body }) => handleCreateRelease(req, res, body), { body: true }); + app.r('POST', '/api/jira/create-issue', ({ res, body }) => handleCreateIssue(res, body), { body: true }); // Reports filter by ?range=&project=&env= and download inline. app.r('GET', '/api/report.csv', ({ res, q }) => { res.writeHead(200, { 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="build-report.csv"' }); diff --git a/build-helper/lib/routes/index.js b/build-helper/lib/routes/index.js index 373584f..c81f951 100644 --- a/build-helper/lib/routes/index.js +++ b/build-helper/lib/routes/index.js @@ -4,8 +4,10 @@ module.exports = function registerRoutes(app) { require('./apps').register(app); // dashboard, projects, per-app config, git, icon require('./build').register(app); // build lifecycle (start/stop/status/log/history) + require('./testflight').register(app); // TestFlight re-manage (compliance + notes) + status require('./signing').register(app); // Android release signing require('./setup').register(app); // global Setup (Play/Apple/Firebase/OneDrive) + Doctor require('./dist').register(app); // rollback, retry-upload, share, reports, cleanup, trackers require('./pages').register(app); // tester install pages, artifact download, activity feed + require('./tool').register(app); // version, tool changelog, self-update (git pull) }; diff --git a/build-helper/lib/routes/pages.js b/build-helper/lib/routes/pages.js index e93d312..49be442 100644 --- a/build-helper/lib/routes/pages.js +++ b/build-helper/lib/routes/pages.js @@ -4,8 +4,9 @@ const fs = require('fs'); const path = require('path'); const { installPage, manifestPlist } = require('../pages'); +const { proxyQr } = require('../qr'); const { readGroups, handleGroupsSave, handleEmailTest, handleEmailSend } = require('../messaging'); -const { feedClients, feedHistory, ARTIFACTS_DIR } = require('../state'); +const { feedClients, feedHistory, ARTIFACTS_DIR, LOGS_DIR } = require('../state'); function register(app) { const J = app.sendJson; @@ -16,7 +17,11 @@ function register(app) { feedClients.add(res); req.on('close', () => feedClients.delete(res)); }); - app.r('GET', '/api/groups', ({ res, q }) => { const g = readGroups(); const repo = q.get('repo') || ''; return J(res, 200, { groups: [...(g['*'] || []), ...(repo ? g[repo] || [] : [])] }); }); + // Same-origin QR PNG proxy — the share-card canvas needs an untainted image source. + app.r('GET', '/api/qr', ({ res, q }) => proxyQr(res, q.get('text') || q.get('data'), q.get('size'))); + app.r('GET', '/api/groups', ({ res, q }) => { const g = readGroups(); const repo = q.get('repo') || ''; return J(res, 200, { groups: [...(g['*'] || []), ...(repo ? g[repo] || [] : [])], own: repo ? g[repo] || [] : [], global: g['*'] || [] }); }); + // Raw build log by its LOGS_DIR-relative path (from a build record's logFile). + app.r('GET', '/api/log', ({ res, q }) => { const p = path.normalize(path.join(LOGS_DIR, q.get('file') || '')); if (!p.startsWith(LOGS_DIR + path.sep) || !fs.existsSync(p)) { res.writeHead(404); return res.end('not found'); } res.writeHead(200, { 'Content-Type': 'text/plain' }); fs.createReadStream(p).pipe(res); }); app.r('POST', '/api/groups/save', ({ res, body }) => handleGroupsSave(res, body), { body: true }); app.r('GET', '/api/email/test', ({ res }) => handleEmailTest(res)); app.r('POST', '/api/email/send', ({ req, res, body }) => handleEmailSend(req, res, body), { body: true }); diff --git a/build-helper/lib/routes/setup.js b/build-helper/lib/routes/setup.js index 0bbc961..9456a2e 100644 --- a/build-helper/lib/routes/setup.js +++ b/build-helper/lib/routes/setup.js @@ -8,8 +8,8 @@ const fs = require('fs'); const path = require('path'); const { setupStatus, handleSetupPlay, handleSetupApple, handleSetupFirebaseApp, handleSetupFirebaseLogin, handleSetupNotify, handleSetupEmail } = require('../setup'); const { setSources, validateSource } = require('../state'); -const { handleSetupRclone } = require('../onedrive'); -const { doctorData, doctorInstallCmd } = require('../dashboard'); +const { handleSetupRclone, handleSetupRcloneShared } = require('../onedrive'); +const { doctorData, doctorInstallCmd, doctorFull } = require('../dashboard'); const { run } = require('../shell'); function register(app) { @@ -33,9 +33,17 @@ function register(app) { app.r('POST', '/api/setup/email', ({ res, body }) => handleSetupEmail(res, body), { body: true }); // SSE OAuth/login flows — streamSSE POSTs an (ignored) empty body; the handler streams progress. app.r('POST', '/api/setup/onedrive', ({ req, res }) => handleSetupRclone(req, res)); + app.r('POST', '/api/setup/onedrive-shared', ({ req, res, body }) => handleSetupRcloneShared(req, res, body), { body: true }); app.r('POST', '/api/setup/firebase-login', ({ req, res }) => handleSetupFirebaseLogin(req, res)); app.r('GET', '/api/doctor', ({ res }) => J(res, 200, doctorData())); app.r('POST', '/api/doctor/install', ({ res, body }) => installTool(res, body), { body: true }); + // Live `flutter doctor -v` over SSE — the full toolchain report, on demand. + app.r('POST', '/api/doctor/full', ({ res }) => { + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }); + const send = (e, d) => res.write(`event: ${e}\ndata: ${JSON.stringify(d)}\n\n`); + send('step', { text: 'flutter doctor -v' }); + doctorFull((line) => send('log', { line })).then(() => { send('done', { ok: true }); res.end(); }); + }); } // Install one missing tool via its known-safe (non-sudo, non-interactive) command, streaming over SSE. diff --git a/build-helper/lib/routes/testflight.js b/build-helper/lib/routes/testflight.js new file mode 100644 index 0000000..7f289d1 --- /dev/null +++ b/build-helper/lib/routes/testflight.js @@ -0,0 +1,37 @@ +'use strict'; +// TestFlight post-upload management: (re)apply export compliance + "What to Test" notes to an +// already-uploaded build, and look up its processing state + deep link. Thin wrappers over +// lib/stores/asc — the build pipeline runs the same manage pass automatically; these let a user +// re-run it from a build's detail view. +const { readConfig } = require('../state'); +const { detectApp } = require('../project'); +const { appleAuth } = require('../signing-config'); +const { ascManageBuild, ascBuildStatus } = require('../stores'); + +// Resolve the Apple ASC account for a project: per-app override wins over the global signing.json one. +function appleFor(projectPath) { + const perApp = (readConfig().appleAccounts || {})[projectPath]; + return perApp && perApp.keyId && perApp.issuerId ? perApp : appleAuth(); +} + +function register(app) { + const J = app.sendJson; + app.r('POST', '/api/testflight/manage', ({ res, body }) => { + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }); + const send = (e, d) => res.write(`event: ${e}\ndata: ${JSON.stringify(d)}\n\n`); + const b = body || {}; + const a = b.path ? detectApp(b.path) : null; + const apple = b.path ? appleFor(b.path) : null; + if (!a || !apple) { send('error', { message: 'Apple App Store Connect not configured for this app.' }); return res.end(); } + ascManageBuild(a, apple, String(b.buildNumber || ''), b.notes || '', (line) => send('log', { line })) + .then((info) => { send('done', { ok: true, info }); res.end(); }) + .catch((e) => { send('error', { message: e.message }); res.end(); }); + }, { body: true }); + app.r('GET', '/api/testflight/latest', ({ res, q }) => { + const p = q.get('path'); const a = p ? detectApp(p) : null; const apple = p ? appleFor(p) : null; + if (!a || !apple) return J(res, 200, { url: 'https://appstoreconnect.apple.com/apps' }); + return ascBuildStatus(a, apple, q.get('buildNumber') || '').then((r) => J(res, 200, r || {})); + }); +} + +module.exports = { register }; diff --git a/build-helper/lib/routes/tool.js b/build-helper/lib/routes/tool.js new file mode 100644 index 0000000..dcf7116 --- /dev/null +++ b/build-helper/lib/routes/tool.js @@ -0,0 +1,13 @@ +'use strict'; +// Tool self-management routes: the version stamp, this tool's recent changelog, and self-update +// (git pull). Controllers only — logic lives in lib/selfupdate. +const { toolVersion, toolChangelog, selfUpdate } = require('../selfupdate'); + +function register(app) { + const J = app.sendJson; + app.r('GET', '/api/version', ({ res }) => J(res, 200, toolVersion())); + app.r('GET', '/api/changelog/tool', ({ res }) => J(res, 200, { commits: toolChangelog() })); + app.r('POST', '/api/self-update', ({ req, res }) => selfUpdate(req, res)); +} + +module.exports = { register }; diff --git a/build-helper/lib/schedule.js b/build-helper/lib/schedule.js new file mode 100644 index 0000000..d92d7b5 --- /dev/null +++ b/build-helper/lib/schedule.js @@ -0,0 +1,47 @@ +'use strict'; +// Unattended scheduled builds: a 1-minute tick fires any app schedule whose day + time match now. +// Schedules are per-app (cfg.apps[path].schedules = [{env, time:'HH:MM', days:[0-6], artifacts:[]}]), +// edited in App Setup → Schedule. Scheduled builds are build-only (no distribution) and bypass the +// interactive guards — nobody's watching to answer a "Build anyway?" prompt. +// ponytail: build-only + guards bypassed. Add per-schedule destinations if unattended uploads matter. +const { readConfig, writeConfig, running } = require('./state'); +const { detectApp } = require('./project'); +const { handleBuild } = require('./build'); + +function runScheduled(projectPath, sched) { + const app = detectApp(projectPath); + const buildName = (app.version || '1.0.0').split('+')[0] || '1.0.0'; + const dest = sched.dest || { onedrive: [], firebase: [], play: [], testflight: [] }; + const body = { + path: projectPath, artifacts: (sched.artifacts && sched.artifacts.length) ? sched.artifacts : ['apk'], + envs: [{ env: sched.env, buildName, buildNumber: '1', autoNum: true, dest }], + allowDirty: true, allowDuplicate: true, confirmProd: true, allowLowVersion: true, + }; + const res = { writeHead() {}, write() { return true; }, end() {}, on() {} }; + console.log(`[schedule] ${app.name} ${sched.env} @ ${sched.time} → building (${body.artifacts.join(',')})`); + handleBuild({ on() {} }, res, body).catch((e) => console.error('[schedule] build error:', e.message)); +} + +// Fire the first enabled schedule whose day + time match `now`, at most once per day (lastRunYmd is +// persisted). Skips while a build is running (next tick retries). +function checkSchedules(now) { + now = now || new Date(); + if (running.busy) return; + const day = now.getDay(), isWeekday = day >= 1 && day <= 5; + const hhmm = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`; + const ymd = now.toISOString().slice(0, 10); + const cfg = readConfig(), apps = cfg.apps || {}; + for (const [p, ax] of Object.entries(apps)) { + for (const s of (ax.schedules || [])) { + if (!s.enabled || !s.env || s.time !== hhmm) continue; + if (s.days === 'weekdays' && !isWeekday) continue; + if (s.lastRunYmd === ymd) continue; + s.lastRunYmd = ymd; writeConfig(cfg); + runScheduled(p, s); return; // one at a time + } + } +} + +function startScheduler() { setInterval(() => { try { checkSchedules(); } catch (e) { console.error('[schedule]', e.message); } }, 60000); } + +module.exports = { checkSchedules, runScheduled, startScheduler }; diff --git a/build-helper/lib/selfupdate.js b/build-helper/lib/selfupdate.js new file mode 100644 index 0000000..16f4a08 --- /dev/null +++ b/build-helper/lib/selfupdate.js @@ -0,0 +1,35 @@ +'use strict'; +// Self-update: pull the latest code for this tool's repo and report progress. The relaunch model +// differs from the launchd-KeepAlive setup — here the hub manages processes — so we pull + report +// "restart to apply" rather than force-exiting a process nothing may respawn. +// ponytail: no auto-exit. Under a KeepAlive service you could process.exit(0) after a successful pull. +const { execFileSync } = require('child_process'); +const { ROOT } = require('./state'); +const { run } = require('./shell'); + +const _git = (args) => { try { return execFileSync('git', ['-C', ROOT, ...args], { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); } catch { return ''; } }; + +function toolVersion() { + let behind = 0; try { behind = parseInt(_git(['rev-list', '--count', 'HEAD..@{u}']) || '0', 10) || 0; } catch {} + return { version: _git(['rev-parse', '--short', 'HEAD']) || 'dev', branch: _git(['rev-parse', '--abbrev-ref', 'HEAD']) || '', behind }; +} + +function toolChangelog() { + const out = _git(['log', '-20', '--pretty=%h%x09%s%x09%cs']); + return out ? out.split('\n').filter(Boolean).map((l) => { const [sha, subject, date] = l.split('\t'); return { sha, subject, date }; }) : []; +} + +async function selfUpdate(req, res) { + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }); + const send = (e, d) => res.write(`event: ${e}\ndata: ${JSON.stringify(d)}\n\n`); + const log = (line) => send('log', { line }); + try { + send('step', { text: 'git pull --ff-only' }); + await run('git', ['-C', ROOT, 'pull', '--ff-only'], ROOT, log); + log('✓ updated — restart Build Helper (or the hub) to apply the new code.'); + send('done', { ok: true, restart: true }); + } catch (e) { send('error', { message: e.message }); } + res.end(); +} + +module.exports = { toolVersion, toolChangelog, selfUpdate }; diff --git a/build-helper/lib/trackers.js b/build-helper/lib/trackers.js index dae063e..1e9771a 100644 --- a/build-helper/lib/trackers.js +++ b/build-helper/lib/trackers.js @@ -7,7 +7,7 @@ const { readConfig, sendJson } = require('./state'); const { basicAuth, apiGet } = require('./net'); const { gitChangelog } = require('./project'); const C = require('../../platform-kit').connectors; -const { TRACKERS, trackerById, extractKeys, formatTasks, jiraBase, adf, jiraMeta, jiraAttach, createIssue, getConnector, readStore, saveConnector } = C; +const { TRACKERS, trackerById, extractKeys, formatTasks, jiraBase, adf, jiraMeta, jiraAttach, createIssue, jiraSearch, getConnector, readStore, saveConnector } = C; // Per-app override wins over the shared store — same idiom as apple/play accounts. function resolveTracker(id, projectPath) { @@ -35,6 +35,11 @@ async function collectTasks(projectPath, env) { } catch { return { count: 0, items: [], text: '' }; } } +// Build-page pickers: commit-scraped tasks for a project/env, and a Jira issue search to link. +async function handleTasks(res, path, env) { return sendJson(res, 200, await collectTasks(path, env)); } +async function jiraSearchFor(res, path, q) { return sendJson(res, 200, await jiraSearch(resolveTracker('jira', path), q)); } +async function handleJiraMeta(res, path) { return sendJson(res, 200, await jiraMeta(resolveTracker('jira', path))); } + // ---------- Jira write path (create a "Mobile Release" work item) — build-helper specific ---------- async function handleCreateRelease(req, res, body) { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }); @@ -82,5 +87,5 @@ async function handleCreateIssue(res, body) { module.exports = { jiraBase, TRACKERS, trackerById, resolveTracker, configuredTrackers, collectTasks, - jiraMeta, handleCreateRelease, handleCreateIssue, readStore, saveConnector, + jiraMeta, handleCreateRelease, handleCreateIssue, handleTasks, jiraSearchFor, handleJiraMeta, readStore, saveConnector, }; diff --git a/build-helper/public/index.html b/build-helper/public/index.html index 30c837e..471c4b9 100644 --- a/build-helper/public/index.html +++ b/build-helper/public/index.html @@ -18,6 +18,22 @@ .bh-card:hover{border-color:var(--edge2)} .bh-art{cursor:pointer;user-select:none;transition:border-color .15s,background .15s} .bh-art.on{border-color:var(--brand);background:color-mix(in srgb,var(--brand) 10%,var(--panel))} + /* Release pipeline stations/rail (source design) — kit.css lacks the sel/cur states */ + .pipeline{display:flex;align-items:center;gap:0} + .station{display:inline-flex;align-items:center;gap:.5rem;flex:none;cursor:pointer;background:none;border:0;padding:.2rem .1rem;-webkit-appearance:none} + .station .node{width:.62rem;height:.62rem;border-radius:999px;background:color-mix(in srgb,var(--stn) 14%,transparent);box-shadow:inset 0 0 0 1.5px color-mix(in srgb,var(--stn) 40%,transparent);transition:transform .18s ease,box-shadow .22s ease,background .22s ease} + .station:hover .node{transform:scale(1.28);box-shadow:inset 0 0 0 1.5px var(--stn)} + .station.sel .node{background:var(--stn);box-shadow:0 0 0 4px color-mix(in srgb,var(--stn) 24%,transparent),0 0 14px color-mix(in srgb,var(--stn) 60%,transparent)} + .station.cur .node{box-shadow:0 0 0 2px color-mix(in srgb,var(--stn) 55%,transparent);animation:stnPulse 2.4s ease-in-out infinite} + .station.cur.sel .node{animation:none} + @keyframes stnPulse{0%,100%{box-shadow:0 0 0 2px color-mix(in srgb,var(--stn) 35%,transparent)}50%{box-shadow:0 0 0 4px color-mix(in srgb,var(--stn) 18%,transparent),0 0 10px color-mix(in srgb,var(--stn) 40%,transparent)}} + .station .lbl{font-size:.66rem;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:var(--ink2);transition:color .2s} + .station.sel .lbl,.station.cur .lbl{color:#fff} + .rail{height:2px;width:1.4rem;background:linear-gradient(90deg,var(--edge2),var(--edge));border-radius:2px;flex:none;transition:background .25s} + .rail.lit{background:linear-gradient(90deg,var(--a),var(--b))} + table.health th{cursor:pointer;user-select:none} + table.health tbody tr:hover{background:var(--hover,#171B22)} + #tt{position:fixed;z-index:60;display:none;pointer-events:none;background:#0C0E12;border:1px solid var(--edge2);border-radius:8px;padding:6px 9px;font-size:11px;color:var(--ink);box-shadow:0 12px 28px rgba(0,0,0,.6);max-width:240px} @@ -38,10 +54,25 @@ + + + + + + + + + + + + + + + diff --git a/build-helper/public/js/api.js b/build-helper/public/js/api.js index 0dd5343..74b2364 100644 --- a/build-helper/public/js/api.js +++ b/build-helper/public/js/api.js @@ -11,10 +11,19 @@ window.API = { project: (path) => _get('/api/project?path=' + encodeURIComponent(path)), builds: (path) => _get('/api/builds' + (path ? '?path=' + encodeURIComponent(path) : '')), status: () => _get('/api/build/status'), + buildLog: (since) => _get('/api/build/log?since=' + (since || 0)), + buildDetail: (time) => _get('/api/build?time=' + encodeURIComponent(time)), setup: () => _get('/api/setup'), doctor: () => _get('/api/doctor'), signing: (path) => _get('/api/signing/status?path=' + encodeURIComponent(path)), changelog: (path, env) => _get('/api/changelog?' + _q({ path, env, grouped: 1 })), + gitmeta: (path, branch) => _get('/api/gitmeta?' + _q({ path, branch })), + securityGrades: () => _get('/api/security/grades').then((r) => r.grades || {}).catch(() => ({})), + tasks: (path, env) => _get('/api/tracker/tasks?' + _q({ path, env })).catch(() => ({ items: [] })), + jiraSearch: (path, q) => _get('/api/jira/search?' + _q({ path, q })).catch(() => ({ issues: [] })), + groups: (repo) => _get('/api/groups?' + _q({ repo })).then((r) => r.groups || []).catch(() => []), + appGroups: (repo) => _get('/api/groups?' + _q({ repo })).catch(() => ({ own: [], global: [] })), + qrUrl: (text, size) => '/api/qr?' + _q({ text, size: size || 220 }), saveSources: (sources) => postJson('/api/setup/sources', sources), // { roots, pinned, recursive } suggestRoots: () => _get('/api/setup/suggest-roots').then((r) => r.roots || []).catch(() => []), diff --git a/build-helper/public/js/app-setup-more.js b/build-helper/public/js/app-setup-more.js new file mode 100644 index 0000000..7522436 --- /dev/null +++ b/build-helper/public/js/app-setup-more.js @@ -0,0 +1,56 @@ +// Build Helper frontend — App Setup wiring + save (source design). Collects the whole modal into a +// single /api/app/save (override/firebase/apple/play/settings+schedules/trackers), saves client +// groups via /api/groups/save, and streams keystore generate/link. Reloads modal + build page. +'use strict'; + +const _slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); + +function wireAppSetup(d, a, pth) { + const wireEnvDel = () => document.querySelectorAll('.el-del').forEach((b) => b.onclick = () => b.closest('.envrow').remove()); + wireEnvDel(); + el('#envadd').onclick = () => { el('#envrows').insertAdjacentHTML('beforeend', asEnvRow({ color: 'sky' })); wireEnvDel(); }; + const wireCmdDel = () => document.querySelectorAll('.cmd-del').forEach((b) => b.onclick = () => b.closest('.flex').remove()); + wireCmdDel(); + if (el('#preadd')) el('#preadd').onclick = () => { el('#prebox').insertAdjacentHTML('beforeend', asCmdRow('pre-cmd', '')); wireCmdDel(); }; + if (el('#postadd')) el('#postadd').onclick = () => { el('#postbox').insertAdjacentHTML('beforeend', asCmdRow('post-cmd', '')); wireCmdDel(); }; + const wireSchedDel = () => document.querySelectorAll('.sc-del').forEach((b) => b.onclick = () => b.closest('.schedrow').remove()); + wireSchedDel(); + if (el('#schedadd')) el('#schedadd').onclick = () => { const box = el('#schedbox'); const n = box.querySelector('.nosched'); if (n) n.remove(); box.insertAdjacentHTML('beforeend', asSchedRow(a, {})); wireSchedDel(); }; + const collectEnvs = () => { const out = [], used = new Set(); document.querySelectorAll('.envrow').forEach((row) => { const label = row.querySelector('.el-label').value.trim(), mode = row.querySelector('.el-mode').value.trim(); if (!label || !mode) return; let key = row.dataset.key || _slug(label) || 'env'; while (used.has(key)) key += '_2'; used.add(key); out.push({ key, label, mode, color: row.querySelector('.el-color').value, prod: row.querySelector('.el-prod').checked }); }); return out; }; + let appleClear = false, playClear = false; + if (el('#ap-aclear')) el('#ap-aclear').onclick = () => { appleClear = true; el('#ap-akey').value = ''; el('#ap-aiss').value = ''; el('#ap-ap8').value = ''; toast('Apple account will be removed on Save', 'info'); }; + if (el('#pl-aclear')) el('#pl-aclear').onclick = () => { playClear = true; el('#pl-ajson').value = ''; toast('Play account will be removed on Save', 'info'); }; + // Client groups (team-shared store). + const grpRepo = a.repo || a.name; + const grpRow = (g = {}) => `
`; + const wireGrpDel = () => document.querySelectorAll('.g-del').forEach((b) => b.onclick = () => b.closest('.grouprow').remove()); + const fillBox = (sel, rows) => { const box = el(sel); if (!box) return; box.innerHTML = (rows && rows.length) ? rows.map(grpRow).join('') : '
None yet — add one.
'; }; + fetch('/api/groups?repo=' + encodeURIComponent(grpRepo)).then((r) => r.json()).then((r) => { fillBox('#gglobalbox', r.global); fillBox('#groupsbox', r.own); wireGrpDel(); }).catch(() => { fillBox('#gglobalbox', []); fillBox('#groupsbox', []); }); + const addRow = (sel) => { const box = el(sel); const ng = box.querySelector('.nogroups'); if (ng) ng.remove(); box.insertAdjacentHTML('beforeend', grpRow()); wireGrpDel(); }; + if (el('#gaddgrp')) el('#gaddgrp').onclick = () => addRow('#groupsbox'); + if (el('#gaddglobal')) el('#gaddglobal').onclick = () => addRow('#gglobalbox'); + const collectGroups = (sel) => [...el(sel).querySelectorAll('.grouprow')].map((row) => ({ name: row.querySelector('.g-name').value.trim(), emails: row.querySelector('.g-emails').value.split(/[\s,;]+/).map((e) => e.trim()).filter(Boolean) })).filter((g) => g.name || g.emails.length); + if (el('#gsave')) el('#gsave').onclick = async () => { const g1 = await postJson('/api/groups/save', { repo: '*', groups: collectGroups('#gglobalbox') }); const g2 = await postJson('/api/groups/save', { repo: grpRepo, groups: collectGroups('#groupsbox') }); (g1.ok && g2.ok) ? toast('Client groups saved (shared with team)', 'ok') : toast(g1.error || g2.error || 'Save failed', 'err'); }; + const trackerDirty = new Set(), trackerClear = new Set(); + document.querySelectorAll('[id^="astr-"]').forEach((inp) => inp.addEventListener('input', () => { const id = inp.id.split('-')[1]; trackerDirty.add(id); trackerClear.delete(id); })); + document.querySelectorAll('.astr-clear').forEach((b) => b.onclick = () => { const id = b.dataset.id; trackerClear.add(id); trackerDirty.delete(id); ((d.trackerDefs || []).find((t) => t.id === id)?.fields || []).forEach(([n]) => { const e = el(`#astr-${id}-${n}`); if (e) e.value = ''; }); toast('Will use global on Save', 'info'); }); + const reloadAppSetup = async () => { const sc = el('#modalbody')?.scrollTop || 0; try { CUR = await API.project(pth); if (CUR && CUR.app && BH.view === 'project') renderProject(); } catch {} await showAppSetup(pth); const mb = el('#modalbody'); if (mb) mb.scrollTop = sc; }; + el('#assave').onclick = async () => { + const override = { envs: collectEnvs() }; const f = el('#ovfile').value.trim(), cn = el('#ovconst').value.trim(); + if (f) override.envFile = f; if (cn) override.constName = cn; + const body = { path: pth, override, firebaseApp: { appId: el('#asfbid').value.trim(), groups: el('#asfbgroups').value.trim() }, + apple: appleClear ? { clear: true } : { keyId: el('#ap-akey').value.trim(), issuerId: el('#ap-aiss').value.trim(), p8: el('#ap-ap8').value.trim() }, + play: playClear ? { clear: true } : { serviceAccountJson: el('#pl-ajson').value.trim(), defaultTrack: el('#pl-atrack').value }, + settings: { defaultTrack: el('#astrack').value, defaultArtifacts: [...document.querySelectorAll('.asart:checked')].map((x) => x.value), preBuild: [...document.querySelectorAll('.pre-cmd')].map((i) => i.value.trim()).filter(Boolean), postBuild: [...document.querySelectorAll('.post-cmd')].map((i) => i.value.trim()).filter(Boolean), buildArgs: el('#asbuildargs')?.value.trim() || '', flavor: el('#asflavor')?.value.trim() || '', + schedules: [...document.querySelectorAll('.schedrow')].map((row) => { const arts = [...row.querySelectorAll('.sc-art:checked')].map((x) => x.value); const dest = { onedrive: [], firebase: [], play: [], testflight: [] }; [...row.querySelectorAll('.sc-dst:checked')].forEach((x) => dest[x.value] = (arts.length ? arts : ['apk']).slice()); return { id: row.dataset.id || ('s' + Math.round(performance.now()).toString(36)), enabled: row.querySelector('.sc-en').checked, env: row.querySelector('.sc-env').value, time: row.querySelector('.sc-time').value, days: row.querySelector('.sc-days').value, artifacts: arts.length ? arts : ['apk'], dest, autoNum: true, lastRunYmd: row.dataset.last || '' }; }).filter((s) => s.env) } }; + const trackers = {}; + (d.trackerDefs || []).forEach((t) => { if (trackerClear.has(t.id)) trackers[t.id] = { clear: true }; else if (trackerDirty.has(t.id)) { const o = {}; t.fields.forEach(([n]) => o[n] = (el(`#astr-${t.id}-${n}`)?.value || '').trim()); trackers[t.id] = o; } }); + if (Object.keys(trackers).length) body.trackers = trackers; + const r = await postJson('/api/app/save', body); + if (!r.ok) { toast(r.error || 'Save failed', 'err'); return; } + toast('App setup saved', 'ok'); await reloadAppSetup(); + }; + const runSign = (url, extra) => { const box = el('#sglog'); box.classList.remove('hidden'); streamSSEInto(url, { path: pth, ...extra }, box, () => reloadAppSetup()); }; + el('#sggen').onclick = () => { const pw = el('#sgpw').value; if (pw.length < 6) { toast('Password ≥6 chars', 'warn'); return; } runSign('/api/signing/generate', { password: pw, alias: el('#sgalias').value.trim() || 'upload' }); }; + el('#sglinkbtn').onclick = () => { const sf = el('#sglink').value.trim(); if (!sf) { toast('Enter a .jks path', 'warn'); return; } const pw = prompt('Keystore password?') || ''; const al = prompt('Key alias?', 'upload') || 'upload'; runSign('/api/signing/link', { storeFile: sf, storePassword: pw, keyPassword: pw, keyAlias: al }); }; +} diff --git a/build-helper/public/js/app-setup.js b/build-helper/public/js/app-setup.js new file mode 100644 index 0000000..d2da439 --- /dev/null +++ b/build-helper/public/js/app-setup.js @@ -0,0 +1,69 @@ +// Build Helper frontend — per-app Setup, ONE tall scrolling modal (source design). Release +// readiness, env override, environments editor, Firebase/Apple/Play cards, build defaults, Android +// signing, build commands, scheduled builds, per-app trackers, client groups, sticky Save. +// Row builders + body markup here; wiring + save in app-setup-more.js. +'use strict'; + +const ENV_PALETTE = ['sky', 'violet', 'amber', 'emerald', 'rose', 'cyan', 'fuchsia', 'slate']; +const asChk = (ok, label, hint) => `
${ok ? ICON.check : ICON.x}${label}${ok ? '' : `${hint || ''}`}
`; +const asEnvRow = (e = {}) => `
${ICON.chevron}
`; +const asCmdRow = (cls, val) => `
`; +const asSchedRow = (a, s = {}) => `
build:${['apk', 'aab', 'ipa'].map((x) => ``).join('')}
upload to:${['onedrive', 'firebase', 'play', 'testflight'].map((dd) => ``).join('')}
`; + +window.showAppSetup = window.openAppSetup = async function (pth) { + const d = await API.project(pth).catch(() => null); if (!d || !d.app) return toast('Could not load app', 'err'); + const a = d.app, h = d.health || {}, set = d.settings || {}, fb = d.firebaseApp || {}, sign = h.sign || {}, ap = d.apple || null, pl = d.play || null; + const body = openModal({ title: `${a.name} · App Setup`, subtitle: a.path, size: '1080px' }); if (!body) return; + const HOW = 'cursor-pointer select-none', tk = window.CONNECTORS || {}; + body.innerHTML = `
+
${ICON.rocket}
Release readiness
${asChk(h.envDetected, 'Environment detected', 'set the override below')}${asChk(h.packageId, 'Android applicationId', 'check android/app/build.gradle')}${asChk(h.signing, 'Release signing', 'configure signing below')}${asChk(h.firebaseFiles, 'Firebase config files', 'firebase/ files (optional)')}${asChk(h.gitRemote, 'Git remote', 'optional — for commit links')}
+
${ICON.gear}
Env detection override
apps.json
+ + +
Detected AppMode members: ${(a.members || []).map(esc).join(', ') || 'none detected'}
+
${ICON.layers}
Environments
+

Name each environment, pick which AppMode it switches to, a color, and mark production. These become the build cards. Leave empty to auto-detect dev/demo/qa/prod.

+ ${(a.members || []).map((m) => ` +
${(a.envs || []).map((e) => asEnvRow(e)).join('')}
+
${CXLOGO.firebase}
Firebase App Distribution
+

App ID from the Firebase Console → Project settings → your apps.

+
+
${CXLOGO.apple}
Apple TestFlight
${pill(!!ap, 'per-app', 'global')}
+

This app's own App Store Connect API key. Blank = use the global account (${d.globalApple ? 'set' : 'not set'}).

+
+ + ${ap ? '' : ''} +
How to set up ▸
Generate an App Store Connect API key (role App Manager) at Users and Access → Integrations → Keys, download the .p8 (shown once), then paste Key ID, Issuer ID, and the .p8 here.
+
${CXLOGO.play}
Google Play
${pill(!!pl, 'per-app', 'global')}
+

This app's own Play service-account. ${pl?.email ? `Current: ${esc(pl.email)}. ` : ''}Blank = use the global account (${d.globalPlay ? 'set' : 'not set'}).

+ +
${pl ? '' : ''}
+
${ICON.boxes}
Build defaults
+ +
Default artifacts
${['apk', 'aab', 'ipa'].map((x) => ``).join('')}
+
${ICON.shield}
Android signing
${pill(!!h.signing, 'configured', 'not signed')}
+

build.gradle uses debug signing; generating will attempt to wire it (Groovy) or print instructions (Kotlin).

+
or
+
+
+
${ICON.rocket}
Build commands
custom steps + flutter args
+

Run on this Mac in the project folder during a build. Pre-build steps run before flutter build, post-build after all envs.

+
Pre-build steps
${(set.preBuild && set.preBuild.length ? set.preBuild : ['']).map((c) => asCmdRow('pre-cmd', c)).join('')}
+
Post-build steps
${(set.postBuild && set.postBuild.length ? set.postBuild : ['']).map((c) => asCmdRow('post-cmd', c)).join('')}
+
+

Post-build steps get BH_APP, BH_VERSION, BH_ENVS, BH_ARTIFACTS_DIR in the environment.

+
${ICON.gear}
Scheduled builds
unattended, on a timer
+

Auto-build an environment on a schedule (build number auto-increments). The Mac must be awake and this app running.

+
${(set.schedules || []).map((s) => asSchedRow(a, s)).join('') || '
No schedules yet.
'}
+
+
${ICON.layers}
Issue trackers
per-app override
+

Optional per-app accounts — blank uses the global connector. Task keys are read from commit messages since the last build.

+
${(d.trackerDefs || []).map((t) => { const cv = (d.trackerAccounts || {})[t.id] || {}, perApp = Object.keys(cv).length > 0, hasGlobal = (d.globalTrackers || {})[t.id]; const meta = tk[t.id] || { name: t.label, logo: ICON.layers, blurb: t.authHint }; const fields = t.fields.map(([n, ph]) => ``).join(''); const bd = `
${perApp ? 'per-app override' : (hasGlobal ? 'using global' : 'not set')}${perApp ? `` : ''}
${fields}
`; return (window.connectorCard ? connectorCard(meta, perApp || hasGlobal, bd, { id: 'app-' + t.id, open: false }) : `
${bd}
`); }).join('')}
+
${ICON.users}
Client groups
shared with your team · used by Share → Email
+

Recipient groups (name + emails). Global groups are visible to every app; this app's groups are only for this one.

+
Global groups all apps
Loading…
+
This app's groups
Loading…
+
+
`; + wireAppSetup(d, a, pth); +}; diff --git a/build-helper/public/js/app.js b/build-helper/public/js/app.js index a452a4f..65fb7f7 100644 --- a/build-helper/public/js/app.js +++ b/build-helper/public/js/app.js @@ -24,20 +24,57 @@ const NAV = { function shell(active) { return topnav({ title: BH.toolName, icon: ICON.rocket, tabs: TABS, active, - right: ``, + right: ``, }); } +// Guided-tour steps per screen (selectors resolve against the [data-tour] anchors the views render). +const TOURS = { + dashboard: [ + { sel: '[data-tour=kpis]', title: 'Your build KPIs', body: 'Success rate, counts, and storage — filtered by the range/project/env pickers below.' }, + { sel: '[data-tour=filters]', title: 'Filters & charts', body: 'Slice builds by range, project, and environment. The charts update live.' }, + { sel: '[data-tour=projects]', title: 'Project health', body: 'Every Flutter app: sort columns, drag to reorder, favorite ★, and open to build.' }, + ], + project: [ + { sel: '[data-tour=envs]', title: 'Environments & version', body: 'Tick the envs to build; set version name (bump with M/m/p) and build number per env.' }, + { sel: '[data-tour=outputs]', title: 'Outputs & distribution', body: 'Pick artifacts and where each goes — OneDrive, TestFlight, Firebase, Play.' }, + { sel: '[data-tour=log]', title: 'Live log', body: 'Streams while building and survives navigation. Recent builds and history sit below.' }, + ], +}; +window.startTourFor = (view) => startTour(TOURS[view] || TOURS.dashboard, 'bh-tour-' + (view || 'dashboard')); + // (Re)render the reactive topnav for a tab and wire it once the new DOM exists. #bhbody persists. // Views call this at their start so any entry path (tab click, card click, Back) highlights right. window.setShell = async function (active) { navHtml.value = shell(active); await nextTick(); wireNav(NAV); const g = el('#bh-gear'); if (g) g.onclick = () => openSettings({}); + const t = el('#bh-tour'); if (t) t.onclick = () => startTourFor(BH.view); + const u = el('#bh-update'); if (u) u.onclick = openUpdate; }; +// Version + self-update modal: current version, commits behind, tool changelog, and "Update now". +async function openUpdate() { + const [v, cl] = await Promise.all([ + fetch('/api/version').then((r) => r.json()).catch(() => ({})), + fetch('/api/changelog/tool').then((r) => r.json()).catch(() => ({ commits: [] })), + ]); + const b = openModal({ title: BH.toolName, subtitle: `version ${v.version || '?'}${v.behind ? ` · ${v.behind} behind` : ''}`, size: '560px' }); + b.innerHTML = `
${v.behind ? `${v.behind} update(s) available.` : 'Up to date.'}
+ + +
Recent changes
+
${(cl.commits || []).slice(0, 12).map((c) => `
${esc(c.sha || '')}${esc(c.subject || '')}${esc(c.date || '')}
`).join('') || '
No history.
'}
`; + el('#up-go').onclick = () => { const l = el('#up-log'); l.classList.remove('hidden'); streamSSE('/api/self-update', {}, l, () => toast('Updated — restart to apply', 'ok')); }; +} + async function boot() { try { const m = await API.manifest(); if (m.name) BH.toolName = m.name; } catch {} + const params = new URLSearchParams(location.search); + const as = params.get('appsetup'); + if (as) { await V.dashboard(); return showAppSetup(as); } + const open = params.get('open'); + if (open) return V.project(open); await V.dashboard(); } diff --git a/build-helper/public/js/bh-ui.js b/build-helper/public/js/bh-ui.js new file mode 100644 index 0000000..20d5fe3 --- /dev/null +++ b/build-helper/public/js/bh-ui.js @@ -0,0 +1,62 @@ +// Build Helper frontend — source-faithful UI primitives shared by the views. Reuses kit globals +// (badge/relTime/fmtBytes/esc/pill/toast/stripAnsi from kit-dom, ICON from kit-icons) and only adds +// what the kit lacks: 4 icon glyphs, the env pipeline builder + color maps, the 3 brand logos the +// App Setup modal draws, and the Outputs warning map. Verbatim from the source design. +'use strict'; + +window.$ = (h) => { const t = document.createElement('template'); t.innerHTML = h.trim(); return t.content.firstChild; }; +// No-auth kit: every capability is allowed; nothing is hidden. +window.bhCan = () => true; window.applyBhCaps = () => {}; +window.ENVS = ['dev', 'demo', 'qa', 'prod']; +// Call-time aliases so source-verbatim markup handlers resolve to our views. +window.openProject = (p) => V.project(p); +window.showSecurity = () => window.open('http://localhost:4110', '_blank'); +// Source-format duration (overrides state.js's terser one so every view reads the same). +window.fmtDur = (ms) => { if (!ms) return '—'; const s = Math.round(ms / 1000); return s < 60 ? s + 's' : Math.floor(s / 60) + 'm' + String(s % 60).padStart(2, '0') + 's'; }; + +// --- icon glyphs missing from kit-icons (same 18px stroke factory the kit uses) --- +const _svg = (p) => `${p}`; +window.ICONX = Object.assign(ICON, { + key: _svg(''), + layers: _svg(''), + chevron: _svg(''), + gitBranch: _svg(''), +}); + +// --- env → color, station colors, and the DEV→PROD pipeline builder (source-verbatim) --- +window.ENV_HEX = { dev: 'var(--blue)', demo: 'var(--violet)', qa: 'var(--warn)', prod: 'var(--aqua)' }; +window.COLOR_HEX = { sky: '#4C93F0', blue: '#4C93F0', violet: '#9E8CF2', amber: '#F5B342', emerald: '#34C77B', rose: '#F0556A', cyan: '#22B5C7', fuchsia: '#D26BF0', slate: '#64748b' }; +window.stnHex = (it) => it.hex || COLOR_HEX[it.color] || ENV_HEX[it.key] || '#64748b'; +window.pipelineHtml = function (items, curKey, mini) { + if (!items || !items.length) return ''; + return `
${items.map((it, i) => { + const c = stnHex(it); + const rail = i < items.length - 1 ? `` : ''; + return `${rail}`; + }).join('')}
`; +}; + +// --- Outputs cards: amber warning shown when a target isn't configured (source-verbatim) --- +window.CONNECT = { + onedrive: 'OneDrive not connected — connect it in Settings', + testflight: 'No Apple account — add it in App Setup → Apple', + firebase: 'No Firebase App ID — set it in App Setup → Firebase', + play: 'No Google Play account — add it in App Setup → Google Play', +}; + +// --- brand logos the App Setup cards draw (kit CX_LOGO only has the 6 trackers) --- +window.CXLOGO = { + firebase: '', + apple: '', + play: '', +}; + +// --- floating tooltip driven by any [data-tip] element (charts, station nodes) --- +document.addEventListener('mousemove', (e) => { + const t = el('#tt'); if (!t) return; + const m = e.target.closest && e.target.closest('[data-tip]'); + if (!m) { t.style.display = 'none'; return; } + t.innerHTML = m.getAttribute('data-tip'); t.style.display = 'block'; + t.style.left = Math.min(e.clientX + 14, innerWidth - 250) + 'px'; + t.style.top = Math.min(e.clientY + 14, innerHeight - 60) + 'px'; +}); diff --git a/build-helper/public/js/build-form.js b/build-helper/public/js/build-form.js new file mode 100644 index 0000000..49129b3 --- /dev/null +++ b/build-helper/public/js/build-form.js @@ -0,0 +1,95 @@ +// Build Helper frontend — build-form helpers (source-verbatim): per-project form memory, the release +// note template, version bump, output→artifact mapping, collectPayload (→ /api/build body), and the +// on-disk artifact browser. Consumed by project.js (render) + build-wire.js (event wiring). +'use strict'; + +const memKey = (p) => 'bh:mem:' + p; +const loadMem = (p) => { try { return JSON.parse(localStorage.getItem(memKey(p)) || '{}'); } catch { return {}; } }; +const saveMem = (p, data) => { try { localStorage.setItem(memKey(p), JSON.stringify(data)); } catch {} }; +const baseVer = (v) => { if (!v) return { name: '1.0.0', num: 1 }; const [name, num] = v.split('+'); return { name, num: parseInt(num || '0', 10) }; }; +const fmtSelTasks = (arr) => (arr && arr.length) ? 'Tasks in this release (' + arr.length + '):\n' + arr.map((t) => '- ' + t.key + (t.title ? ' ' + t.title : '') + (t.state ? ' (' + t.state + ')' : '')).join('\n') : ''; + +function noteTemplate(label, mode, branch, commit, changesText, tasksText, created) { + const L = [`${(label || '').toString()} version`, '', 'Developer Notes:']; + L.push(`Environment: ${label || '-'}${mode ? ' → AppMode.' + mode : ''}`); + L.push(`Last Commit details: ${commit && commit.subject ? commit.subject : '----'}`); + if (commit && (commit.author || commit.date)) L.push(`Author: ${commit.author || '-'}${commit.date ? ' · ' + commit.date : ''}`); + L.push(`Commit Id: ${commit && commit.sha ? commit.sha : '---'}`); + if (commit && commit.url) L.push(`Commit link: ${commit.url}`); + L.push(`Branch name: ${branch || '-----'}`); + if (changesText && String(changesText).trim()) L.push('', 'Changes:', String(changesText).trim()); + if (tasksText && String(tasksText).trim()) L.push('', String(tasksText).trim()); + if (created && created.length) L.push('', ...created.map((c) => `Created: ${c.key}${c.summary ? ' ' + c.summary : ''}`)); + return L.join('\n'); +} + +function bumpVersion(v, kind) { + const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v || '0.0.0'); if (!m) return v; + let [, x, y, z] = m.map(Number); + if (kind === 'major') { x++; y = 0; z = 0; } else if (kind === 'minor') { y++; z = 0; } else z++; + return `${x}.${y}.${z}`; +} + +// One environment card: colored left bar, selection, version+bump, notes+fills, Jira picker, and the +// big Outputs matrix (with amber "connect it" links when an account/connector is missing). +function envCard(e, next, bv, mem, av, CONNECT, g) { + const env = e.key, c = e.color || 'slate'; + const num = mem['num_' + env] || next[env] || (bv.num + 1); + const name = mem['name_' + env] || bv.name; + const tpl = noteTemplate(e.label || env, e.mode, g.current, g.lastCommit, ''); + const val = mem['notes_' + env] || tpl; + const outs = [['local', 'Build only', 'Compile locally and keep the APK on this machine — no upload.'], ['onedrive', 'Upload to OneDrive', 'Upload the APK to your shared OneDrive folder; testers get a link + QR.'], ['testflight', 'TestFlight', 'Upload the iOS build to App Store Connect for TestFlight testers.'], ['firebase', 'Firebase (Android)', 'Distribute the Android APK to your Firebase tester groups.'], ['play', 'Google Play', 'Upload the Android App Bundle (AAB) to a Google Play track.']]; + return `
+
+
AppMode.${esc(e.mode || '?')}${e.prod ? 'production' : ''}
+
`; +} + +const ART_FOR_OUT = { local: 'apk', onedrive: 'apk', firebase: 'apk', testflight: 'ipa', play: 'aab' }; +function collectPayload() { + const a = CUR.app; + if (![...document.querySelectorAll('.envchk')].some((c) => c.checked)) { toast('Check at least one environment to build.', 'warn'); return null; } + const envs = [], artSet = new Set(), missing = []; + document.querySelectorAll('[data-env]').forEach((card) => { + const chk = card.querySelector('.envchk'); if (!chk || !chk.checked) return; + const label = card.dataset.label || card.dataset.env; + const outs = [...card.querySelectorAll('.out:checked')].map((x) => x.dataset.out); + const buildName = card.querySelector('.bname').value.trim(), buildNumber = card.querySelector('.bnum').value.trim(), notes = card.querySelector('.notes').value.trim(); + const need = []; if (!buildName) need.push('build version'); if (!buildNumber) need.push('build number'); if (!notes) need.push('release notes'); if (!outs.length) need.push('an output'); + if (need.length) { missing.push(`${label} → ${need.join(', ')}`); return; } + const dest = { onedrive: [], firebase: [], play: [], testflight: [] }; + outs.forEach((o) => { const art = ART_FOR_OUT[o]; artSet.add(art); if (o !== 'local') dest[o].push(art); }); + envs.push({ env: card.dataset.env, prod: card.dataset.prod === '1', buildName, buildNumber, notes, whatsNew: notes, track: card.querySelector('.track')?.value || '', dest, validate: !!card.querySelector('.uvalidate')?.checked, autoNum: !!card.querySelector('.uautonum')?.checked, uploadSymbols: !!card.querySelector('.usymbols')?.checked, jiraTasks: (() => { try { return JSON.parse(card.querySelector('.jiratasks')?.value || '[]'); } catch { return []; } })() }); + }); + if (missing.length) { toast('Fill required fields — ' + missing.join(' · '), 'warn'); return null; } + if (!envs.length) return null; + const branchSel = el('#branch'), branch = branchSel.value.startsWith('(') ? null : branchSel.value; + const commitTag = el('#committag').checked, push = el('#push').checked, runTest = el('#runtest').checked, runAnalyze = el('#runanalyze').checked, gateWarnOnly = el('#gatewarn').checked; + envs.forEach((e) => { e.commitTag = commitTag; e.push = push; }); + const mem = { clean: el('#clean').checked, pubGet: el('#pubget').checked, commitTag, push, runTest, runAnalyze, gateWarnOnly }; + document.querySelectorAll('[data-env]').forEach((card) => { const env = card.dataset.env; mem['name_' + env] = card.querySelector('.bname').value.trim(); mem['num_' + env] = card.querySelector('.bnum').value.trim(); mem['notes_' + env] = card.querySelector('.notes').value.trim(); mem['out_' + env] = [...card.querySelectorAll('.out:checked')].map((x) => x.dataset.out); mem['val_' + env] = !!card.querySelector('.uvalidate')?.checked; mem['auto_' + env] = !!card.querySelector('.uautonum')?.checked; }); + saveMem(a.path, mem); + return { path: a.path, branch, envs, artifacts: [...artSet], clean: el('#clean').checked, pubGet: el('#pubget').checked, runTest, runAnalyze, gateWarnOnly }; +} + +async function loadArtifactBrowser() { + const box = el('#artbrowser'); if (!box) return; + const r = await fetch('/api/artifacts?project=' + encodeURIComponent(CUR.app.name)).then((r) => r.json()).catch(() => ({ files: [] })); + if (!r.files || !r.files.length) { box.innerHTML = '
No collected artifacts yet.
'; return; } + box.innerHTML = r.files.map((f) => `
${esc(f.name)}${fmtBytes(f.size)}↓
`).join(''); + box.querySelectorAll('.revealf').forEach((b) => b.onclick = () => API.reveal(b.dataset.p)); + box.querySelectorAll('.delf').forEach((b) => b.onclick = async () => { await postJson('/api/artifact/delete', { path: b.dataset.p }).catch(() => {}); toast('Deleted', 'ok'); loadArtifactBrowser(); }); +} diff --git a/build-helper/public/js/build-run.js b/build-helper/public/js/build-run.js new file mode 100644 index 0000000..ed13aa8 --- /dev/null +++ b/build-helper/public/js/build-run.js @@ -0,0 +1,119 @@ +// Build Helper frontend — the build runner (source design). POSTs /api/build then drives the UI by +// POLLING /api/build/log (server buffers every event) so the live log survives navigation. Handles +// the step/line log, inline guard prompts, per-env build summary, and Stop. +'use strict'; + +async function syncStatus() { + let st; try { st = await fetch('/api/build/status').then((r) => r.json()); } catch { return null; } + BUILDING = !!st.busy; return st; +} + +function logSetup() { + const box = el('#log'); box.innerHTML = ''; let curBody = null; + const auto = () => { if (el('#autoscroll') && el('#autoscroll').checked) box.scrollTop = box.scrollHeight; }; + return { + step(text, ms) { const d = $(`
▸ ${esc(text)} ${ms != null ? '· ' + (ms / 1000).toFixed(1) + 's' : ''}
`); box.appendChild(d); curBody = d.querySelector('div'); auto(); }, + line(s, cls = '') { const line = stripAnsi(s); const d = document.createElement('div'); if (cls) d.className = cls; else if (/error|exception|✖|failed/i.test(line)) d.className = 'text-rose-400'; else if (/warning|⚠/i.test(line)) d.className = 'text-amber-300'; d.textContent = line; (curBody || box).appendChild(d); auto(); }, + }; +} + +async function tryBuild() { + const st = await syncStatus(); + if (st && st.busy) { toast(`Building ${st.info?.project || ''} ${st.info?.env || ''} — press Stop to cancel`, 'warn'); return; } + const p = collectPayload(); if (!p) return; + const bs = el('#buildsummary'); if (bs) bs.innerHTML = ''; + runOne(p); +} + +function setBuildBtn(running) { + const btn = el('#build'); if (!btn) return; + if (running) { btn.disabled = false; btn.textContent = '■ Stop'; btn.classList.remove('btn-primary'); btn.classList.add('bg-rose-500', 'text-white'); btn.onclick = () => { btn.disabled = true; btn.textContent = 'Stopping…'; fetch('/api/build/stop', { method: 'POST' }); }; } + else { btn.disabled = false; btn.innerHTML = (window.ICON ? ICON.rocket : '') + 'Build'; btn.classList.remove('bg-rose-500', 'text-white'); btn.classList.add('btn-primary'); btn.onclick = tryBuild; } +} + +function applyBuildEvent(t, d) { + if (t === 'log') LOG && LOG.line(d.line); + else if (t === 'step') LOG && LOG.step(d.text, d.elapsedMs); + else if (t === 'done-env') { if (d.record && d.record.buildOk === false) LOG && LOG.line('✖ ' + d.env + ' failed', 'text-rose-400 font-semibold'); else { LOG && LOG.line('✔ ' + d.env + ' done', 'text-emerald-400 font-semibold'); if (d.record && BUILDLOG) BUILDLOG.records.push(d.record); } } + else if (t === 'done') { if (BUILDLOG) BUILDLOG.gotDone = { built: d.built || 0, failed: d.failed || 0 }; } + else if (t === 'error') handleBuildError(d, BUILDLOG && BUILDLOG.payload); +} + +function pollBuildLog() { + if (!BUILDLOG) return; BUILDLOG.timer = null; + fetch('/api/build/log?since=' + (BUILDLOG.seq || 0)).then((r) => r.json()).then((r) => { + if (!BUILDLOG) return; + for (const ev of (r.events || [])) { BUILDLOG.seq = ev.seq; applyBuildEvent(ev.event, ev.data); } + if (r.busy) BUILDLOG.timer = setTimeout(pollBuildLog, 1000); else finalizeBuild(); + }).catch(() => { if (BUILDLOG) BUILDLOG.timer = setTimeout(pollBuildLog, 1500); }); +} + +function finalizeBuild() { + const bl = BUILDLOG; BUILDLOG = null; BUILDING = false; + if (bl && bl.timer) clearTimeout(bl.timer); + setBuildBtn(false); + if (bl && bl.gotDone) toast(`Done — built ${bl.gotDone.built} · failed ${bl.gotDone.failed}`, bl.gotDone.failed ? 'warn' : 'ok'); + const recs = (bl && bl.records) || []; + renderBuildSummary(recs); + if (recs.length && window.offerShare) offerShare(recs); + if (recs.length && window.postTeamCards) postTeamCards(recs); + if (CUR && CUR.app) fetch('/api/project?path=' + encodeURIComponent(CUR.app.path)).then((r) => r.json()).then((d) => { CUR = d; if (el('#history')) renderHistory(d.builds); }); + if (bl && bl.done) bl.done(); +} + +function runOne(payload, extra = {}) { + if (BUILDLOG) { toast('A build is already running', 'warn'); return Promise.resolve(); } + BUILDING = true; LOG = logSetup(); + return new Promise((resolve) => { + BUILDLOG = { seq: 0, payload: { ...payload, ...extra }, records: [], timer: null, active: true, path: (CUR && CUR.app && CUR.app.path) || '', done: resolve }; + setBuildBtn(true); + fetch('/api/build', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...payload, ...extra }) }).then((res) => { const rd = res.body.getReader(); const drain = () => rd.read().then(({ done }) => done ? null : drain()).catch(() => {}); return drain(); }).catch(() => {}); + pollBuildLog(); + }); +} + +function renderBuildSummary(recs) { + const box = el('#buildsummary'); if (!box) return; + if (!recs || !recs.length) { box.innerHTML = ''; return; } + const DLBL = { onedrive: 'OneDrive', firebase: 'Firebase', play: 'Play', testflight: 'TestFlight' }; + const rows = recs.map((r) => { + const env = (r.env || '').toUpperCase(), c = ENV_COLOR[r.env] || 'slate', ok = r.buildOk !== false; + const ver = r.version || ((r.buildName || '') + (r.buildNumber ? '+' + r.buildNumber : '')), up = r.upload || {}; + const link = (dest) => dest === 'onedrive' ? (r.onedriveUrl || ((r.artifacts || []).find((x) => x && x.onedriveUrl) || {}).onedriveUrl || '') : (dest === 'testflight' ? ((r.testflight && r.testflight.url) || '') : ''); + const chips = Object.keys(DLBL).filter((dest) => up[dest] && up[dest] !== 'skipped').map((dest) => { const okd = up[dest] === 'ok', u = link(dest), inner = (okd ? '✓ ' : '✗ ') + DLBL[dest], cls = okd ? 'text-emerald-300 bg-emerald-500/10' : 'text-rose-300 bg-rose-500/10'; return (u && okd) ? `${inner} ↗` : `${inner}`; }).join(' '); + return `
${badge(env, c)}
${esc(ver)} ${ok ? '✓ built' : '✗ failed'}
${chips || 'built locally · no upload'}
`; + }).join(''); + box.innerHTML = `
Build summary
${rows}
`; + const cl = el('#bs-close'); if (cl) cl.onclick = () => { box.innerHTML = ''; }; +} + +function handleBuildError(d, payload) { + const LG = el('#log'); + if (!LG || !payload) { const m = document.createElement('div'); m.className = 'text-rose-400 font-semibold'; m.textContent = '✖ ' + (d.message || ''); if (LG) LG.appendChild(m); toast(d.message || 'Build error', 'err'); return; } + if (d.kind === 'confirm') { const bar = $(`
⚠ ${esc(d.message)}
`); bar.querySelector('button').onclick = () => { bar.remove(); runOne({ ...payload, confirmProd: true }); }; bar.querySelector('.cancel').onclick = () => bar.remove(); LG.appendChild(bar); } + else if (d.kind === 'dirty' || d.kind === 'duplicate') { toast(d.message.split('\n')[0] + ' — click Build anyway', 'warn'); const bar = $(`
${esc(d.message)}
`); bar.querySelector('button').onclick = () => { bar.remove(); runOne({ ...payload, allowDirty: true, allowDuplicate: true }); }; LG.appendChild(bar); } + else if (d.kind === 'lowVersion') { toast(d.message.split('\n')[0] + ' — bump the app version', 'warn'); const useLbl = d.suggested ? `Use ${esc(d.suggested)} & build` : 'Fix & build'; const bar = $(`
⚠ ${esc(d.message)}
`); bar.querySelector('.usefix').onclick = () => { const fixes = d.fixes || []; const envs = (payload.envs || []).map((e) => { const f = fixes.find((x) => x.env === e.env); return f ? { ...e, buildName: f.suggested } : e; }); fixes.forEach((f) => { const inp = document.querySelector(`[data-env="${f.env}"] .bname`); if (inp) inp.value = f.suggested; }); bar.remove(); runOne({ ...payload, envs, allowLowVersion: true }); }; bar.querySelector('.anyway').onclick = () => { bar.remove(); runOne({ ...payload, allowLowVersion: true }); }; LG.appendChild(bar); } + else { const m = document.createElement('div'); m.className = 'text-rose-400 font-semibold'; m.textContent = '✖ ' + d.message; LG.appendChild(m); toast(d.message, 'err'); } +} + +function reattachBuildLog() { + fetch('/api/build/log?since=0').then((r) => r.json()).then((r) => { + if (!r.busy) return; + const forThis = CUR && CUR.app && (r.path === CUR.app.path || (r.info && r.info.project === CUR.app.name)); + if (!forThis) return; + BUILDING = true; LOG = logSetup(); setBuildBtn(true); + if (!BUILDLOG) BUILDLOG = { seq: 0, payload: null, records: [], timer: null, active: true, path: r.path || '' }; + else { if (BUILDLOG.timer) clearTimeout(BUILDLOG.timer); BUILDLOG.seq = 0; } + pollBuildLog(); + }).catch(() => {}); +} + +// Best-effort header TestFlight readout (tolerant of our /api/testflight/latest shape). +async function loadTestFlightLatest(path) { + const set = (html) => { const t = el('#tf-latest'); if (t && CUR && CUR.app && CUR.app.path === path) t.innerHTML = html; }; + try { + const r = await fetch('/api/testflight/latest?path=' + encodeURIComponent(path)).then((r) => r.json()); + if (r && r.url) set(`${esc(r.state || 'App Store Connect')} ▸`); + else set('—'); + } catch { set('unavailable'); } +} diff --git a/build-helper/public/js/build-wire.js b/build-helper/public/js/build-wire.js new file mode 100644 index 0000000..580f3bd --- /dev/null +++ b/build-helper/public/js/build-wire.js @@ -0,0 +1,60 @@ +// Build Helper frontend — build-page wiring (source-verbatim behaviour). Env checkboxes ↔ pipeline, +// outputs reveal Play-track / Validate-IPA, note fills, the Jira task picker, artifact browser, and +// history. Cross-tool refs resolve to our views (showAppSetup→openAppSetup, analytics→V.analytics). +'use strict'; + +function wireProject(a, g, av) { + const showAppSetup = window.openAppSetup, showSetup = () => V.setup(); + document.querySelectorAll('[data-env]').forEach((card) => { const chk = card.querySelector('.envchk'), body = card.querySelector('.env-body'); if (chk) { const apply = () => body.classList.toggle('hidden', !chk.checked); chk.addEventListener('change', apply); chk.addEventListener('change', syncPipeline); apply(); } }); + function syncPipeline() { + const stations = [...document.querySelectorAll('.pipeline .station')]; + stations.forEach((st) => { const c = document.querySelector(`[data-env="${st.dataset.pipe}"]`); const on = !!(c && c.querySelector('.envchk') && c.querySelector('.envchk').checked); st.classList.toggle('sel', on); }); + document.querySelectorAll('.pipeline .rail').forEach((rail, idx) => { const x = stations[idx], y = stations[idx + 1]; rail.classList.toggle('lit', !!(x && y && x.classList.contains('sel') && y.classList.contains('sel'))); }); + } + document.querySelectorAll('.station[data-pipe]').forEach((st) => st.onclick = () => { const c = document.querySelector(`[data-env="${st.dataset.pipe}"]`); if (!c) return; const chk = c.querySelector('.envchk'); if (chk) { chk.checked = !chk.checked; chk.dispatchEvent(new Event('change')); } c.scrollIntoView({ behavior: 'smooth', block: 'center' }); }); + syncPipeline(); + el('#appsetup').onclick = () => showAppSetup(a.path); + ['#setupcta', '#defenv', '#tf-setup'].forEach((id) => { const b = el(id); if (b) b.onclick = () => showAppSetup(a.path); }); + if (av.testflight) loadTestFlightLatest(a.path); + el('#build').onclick = tryBuild; + document.querySelectorAll('[data-env]').forEach((card) => { const sync = () => { const on = (o) => !!card.querySelector(`.out[data-out="${o}"]`)?.checked; card.querySelector('.track-wrap')?.classList.toggle('hidden', !on('play')); card.querySelector('.validate-wrap')?.classList.toggle('hidden', !on('testflight')); }; card.querySelectorAll('.out').forEach((cb) => cb.addEventListener('change', sync)); sync(); }); + document.querySelectorAll('.out-connect').forEach((b) => b.onclick = (e) => { e.preventDefault(); e.stopPropagation(); if (b.dataset.connect === 'onedrive') showSetup(); else showAppSetup(a.path); }); + reattachBuildLog(); + el('#copylog').onclick = () => { navigator.clipboard.writeText(el('#log').innerText); toast('Log copied', 'ok'); }; + el('#dllog').onclick = () => { const b = new Blob([el('#log').innerText], { type: 'text/plain' }); const u = URL.createObjectURL(b); const x = document.createElement('a'); x.href = u; x.download = a.name + '-log.txt'; x.click(); URL.revokeObjectURL(u); }; + document.querySelectorAll('.bump').forEach((btn) => btn.onclick = () => { const inp = btn.closest('.env-body').querySelector('.bname'); inp.value = bumpVersion(inp.value, btn.dataset.kind); }); + document.querySelectorAll('.commitfill').forEach((btn) => btn.onclick = async () => { const card = btn.closest('[data-env]'); const r = await fetch(`/api/changelog?path=${encodeURIComponent(a.path)}&env=${card.dataset.env}&grouped=1`).then((r) => r.json()); card.querySelector('.notes').value = r.text || '(no commits since last build)'; toast('Filled from commits (grouped)', 'ok'); }); + document.querySelectorAll('.trackerfill').forEach((btn) => btn.onclick = async () => { const card = btn.closest('[data-env]'); const r = await fetch(`/api/tracker/tasks?path=${encodeURIComponent(a.path)}&env=${card.dataset.env}`).then((r) => r.json()); if (!r.count) { toast('No tasks found in commits (or no tracker configured)', 'warn'); return; } const ta = card.querySelector('.notes'); ta.value = [ta.value.trim(), r.text].filter(Boolean).join('\n\n'); toast(`Added ${r.count} task${r.count > 1 ? 's' : ''}`, 'ok'); }); + const curBranch = () => { const v = el('#branch').value; return v.startsWith('(') ? '' : v; }; + const getSel = (card) => { try { return JSON.parse(card.querySelector('.jiratasks').value || '[]'); } catch { return []; } }; + const setSel = (card, arr) => { card.querySelector('.jiratasks').value = JSON.stringify(arr); const c = card.querySelector('.jira-count'); if (c) c.textContent = arr.length ? `· ${arr.length} selected` : ''; }; + const refreshNote = async (card, force) => { + const env = card.dataset.env, ta = card.querySelector('.notes'), br = curBranch(); + const [commit, ch] = await Promise.all([fetch(`/api/gitmeta?path=${encodeURIComponent(a.path)}&branch=${encodeURIComponent(br)}`).then((r) => r.json()).catch(() => null), fetch(`/api/changelog?path=${encodeURIComponent(a.path)}&env=${encodeURIComponent(env)}`).then((r) => r.json()).then((r) => r.text).catch(() => '')]); + const sel = getSel(card); + const tpl = noteTemplate(card.dataset.label || env, card.dataset.mode, br, (commit && commit.sha) ? commit : null, ch, fmtSelTasks(sel), sel.filter((t) => t.created).map((t) => ({ key: t.key, summary: t.title }))); + if (force || !ta.value.trim() || ta.value === ta.dataset.tpl) ta.value = tpl; ta.dataset.tpl = tpl; + }; + document.querySelectorAll('.noteregen').forEach((btn) => btn.onclick = () => refreshNote(btn.closest('[data-env]'), true).then(() => toast('Notes regenerated', 'ok'))); + el('#branch').onchange = () => document.querySelectorAll('[data-env]').forEach((card) => refreshNote(card, false)); + document.querySelectorAll('[data-env]').forEach((card) => refreshNote(card, false)); + const renderJiraList = (card, results) => { + const sel = getSel(card), selKeys = new Set(sel.map((t) => t.key)), map = new Map(); sel.forEach((t) => map.set(t.key, { ...t })); + (results || []).forEach((r) => { const k = r.key; if (!map.has(k)) map.set(k, { key: k, title: r.summary || r.title || '', state: r.status || r.state || '' }); }); + const rows = [...map.values()], box = card.querySelector('.jira-list'); + box.innerHTML = rows.length ? rows.map((t) => ``).join('') : '
No tasks yet — search above or + New. (Jira must be connected in Setup.)
'; + box.querySelectorAll('.jira-chk').forEach((chk) => chk.onchange = () => { let s = getSel(card); const key = chk.dataset.key; if (chk.checked) { if (!s.find((t) => t.key === key)) { const info = map.get(key) || { key }; s.push({ key, title: info.title || '', state: info.state || '', created: info.created }); } } else s = s.filter((t) => t.key !== key); setSel(card, s); refreshNote(card, false); }); + }; + document.querySelectorAll('[data-env]').forEach((card) => { + setSel(card, []); + fetch(`/api/tracker/tasks?path=${encodeURIComponent(a.path)}&env=${card.dataset.env}`).then((r) => r.json()).then((r) => { if (r && r.items && r.items.length) { const jira = r.items.filter((i) => i.tracker === 'jira' || /^[A-Z][A-Z0-9]+-\d+$/.test(i.key)); if (jira.length) { setSel(card, jira.map((i) => ({ key: i.key, title: i.title || '', state: i.state || '' }))); refreshNote(card, false); } } renderJiraList(card, []); }).catch(() => renderJiraList(card, [])); + const search = card.querySelector('.jira-search'); let t; + if (search) search.oninput = () => { clearTimeout(t); t = setTimeout(async () => { const r = await fetch(`/api/jira/search?path=${encodeURIComponent(a.path)}&q=${encodeURIComponent(search.value.trim())}`).then((x) => x.json()).catch(() => ({})); if (!r.ok) { card.querySelector('.jira-list').innerHTML = `
${esc(r.error || 'Search failed')}
`; return; } renderJiraList(card, r.issues || []); }, 300); }; + const newBtn = card.querySelector('.jira-new'); + if (newBtn) newBtn.onclick = async () => { const summary = prompt('New Jira issue — summary:'); if (!summary || !summary.trim()) return; newBtn.disabled = true; try { const r = await postJson('/api/tracker/issue', { path: a.path, summary: summary.trim() }); if (!r.ok) { toast(r.error || 'Create failed', 'err'); return; } const s = getSel(card); s.push({ key: r.key, title: r.summary || summary.trim(), state: '', created: true }); setSel(card, s); renderJiraList(card, []); refreshNote(card, false); toast('Created ' + r.key, 'ok'); } catch (e) { toast('Create failed: ' + e.message, 'err'); } finally { newBtn.disabled = false; } }; + }); + el('#refreshart').onclick = loadArtifactBrowser; + el('#analyticslink').onclick = () => (window.V.analytics ? V.analytics(a.path) : null); + loadArtifactBrowser(); + renderHistory(CUR.builds); +} diff --git a/build-helper/public/js/charts.js b/build-helper/public/js/charts.js new file mode 100644 index 0000000..75061a1 --- /dev/null +++ b/build-helper/public/js/charts.js @@ -0,0 +1,65 @@ +// Build Helper frontend — hand-rolled inline-SVG charts (source-verbatim builders). Pure functions: +// bucketed build records in → out, inheriting kit.css color tokens (var(--good)/--crit/--blue). +'use strict'; + +const LABELS = { onedrive: 'OneDrive', firebase: 'Firebase', play: 'Play', testflight: 'TestFlight' }; +function dayKey(d) { const t = new Date(d); return t.getFullYear() + '-' + (t.getMonth() + 1) + '-' + t.getDate(); } + +function bucketDays(builds, days) { + days = Math.min(days || 30, 90) || 30; + const slots = [], idx = {}; const today = new Date(); today.setHours(0, 0, 0, 0); + for (let i = days - 1; i >= 0; i--) { const dt = new Date(today.getTime() - i * 864e5); const k = dayKey(dt); const s = { label: (dt.getMonth() + 1) + '/' + dt.getDate(), ok: 0, fail: 0 }; idx[k] = s; slots.push(s); } + for (const b of builds) { const s = idx[dayKey(b.time)]; if (s) { if (b.buildOk === false) s.fail++; else s.ok++; } } + return slots; +} +const chartEmpty = () => `
No data in range.
`; + +function stackedColumnsSvg(slots) { + if (!slots.some((s) => s.ok || s.fail)) return chartEmpty(); + const n = slots.length, step = Math.max(6, Math.min(16, 300 / n)), W = n * step, H = 104, base = 86, ph = 72; + const max = Math.max(1, ...slots.map((s) => s.ok + s.fail)), bw = step * 0.66, off = (step - bw) / 2; + let bars = '', labs = ''; const sl = Math.ceil(n / 8); + slots.forEach((s, i) => { + const x = i * step + off, tip = `${esc(s.label)}
ok ${s.ok} · failed ${s.fail}`; + const okH = s.ok / max * ph, failH = s.fail / max * ph; let y = base; + if (okH) { y -= okH; bars += ``; } + if (failH) { const fy = y - 2 - failH; bars += ``; } + if (i % sl === 0) labs += `${esc(s.label)}`; + }); + return `${bars}${labs}`; +} + +function lineSvg(pts) { + if (pts.length < 2) return `
Need ≥2 timed builds.
`; + const n = pts.length, W = Math.max(120, n * 12), H = 104, base = 86, ph = 72, max = Math.max(1, ...pts.map((p) => p.ms)); + const X = (i) => (i / (n - 1) * (W - 8) + 4), Y = (v) => base - v / max * ph; + const poly = pts.map((p, i) => `${X(i).toFixed(1)},${Y(p.ms).toFixed(1)}`).join(' '); + const dots = pts.map((p, i) => ``).join(''); + return `${dots}`; +} + +function hbarsSvg(items) { + if (!items.length) return chartEmpty(); + const max = Math.max(1, ...items.map((i) => i.count)), rowH = 22, W = 100, H = items.length * rowH; + let rows = ''; items.forEach((it, i) => { + const y = i * rowH + 4, w = Math.max(0.5, it.count / max * (W - 40)); + rows += `${esc(it.name)}` + + `` + + `${it.count}`; + }); + return `${rows}`; +} + +function uploadTargetsSvg(builds) { + const targets = ['onedrive', 'firebase', 'play', 'testflight']; + const rows = targets.map((t) => { let ok = 0, fail = 0; for (const b of builds) { const v = (b.upload || {})[t]; if (v === 'ok') ok++; else if (v === 'failed') fail++; } return { t, ok, fail }; }).filter((r) => r.ok || r.fail); + if (!rows.length) return `
No uploads in range.
`; + const max = Math.max(1, ...rows.map((r) => r.ok + r.fail)), rowH = 24, W = 100, H = rows.length * rowH; + let out = ''; rows.forEach((r, i) => { + const y = i * rowH + 5, okW = r.ok / max * (W - 42), failW = r.fail / max * (W - 42); + out += `${LABELS[r.t]}`; + let x = 34; if (okW) { out += ``; x += okW + 1.2; } + if (failW) { out += ``; } + }); + return `${out}`; +} diff --git a/build-helper/public/js/cmdk.js b/build-helper/public/js/cmdk.js new file mode 100644 index 0000000..34e0f38 --- /dev/null +++ b/build-helper/public/js/cmdk.js @@ -0,0 +1,72 @@ +// Build Helper frontend — ⌘K quick-jump palette (window.CMDK). Its own overlay (kit openModal owns +// #cmdk), a fuzzy-ish substring filter over nav actions + every scanned project, keyboard-driven. +'use strict'; + +let _ckSel = 0, _items = []; + +function _overlay() { + let o = el('#bh-cmdk'); + if (o) return o; + o = document.createElement('div'); + o.id = 'bh-cmdk'; + o.className = 'fixed inset-0 z-[70] bg-black/60 backdrop-blur-sm hidden items-start justify-center p-4'; + o.style.paddingTop = '14vh'; + o.innerHTML = `
+ +
`; + document.body.appendChild(o); + o.onclick = () => CMDK.close(); + return o; +} + +function _catalog() { + const nav = [ + { label: 'Dashboard', hint: 'view', run: () => V.dashboard() }, + { label: 'Reports', hint: 'view', run: () => V.reports() }, + { label: 'Setup & Doctor', hint: 'view', run: () => V.setup() }, + { label: 'Connectors & team settings', hint: 'action', run: () => openSettings({}) }, + ]; + const projects = (BH.projects || []).map((p) => ({ label: p.name, hint: p.needsConfig ? 'set up' : 'build', run: () => (p.needsConfig && window.openAppSetup ? openAppSetup(p.path) : V.project(p.path)) })); + return nav.concat(projects); +} + +function _render(q) { + const s = (q || '').trim().toLowerCase(); + _items = _catalog().filter((it) => !s || it.label.toLowerCase().includes(s)); + _ckSel = 0; + const list = el('#cmdk-list'); + list.innerHTML = _items.length ? _items.map((it, i) => ``).join('') + : '
No matches.
'; + list.querySelectorAll('[data-i]').forEach((b) => { b.onmouseenter = () => _highlight(+b.dataset.i); b.onclick = () => _run(+b.dataset.i); }); +} + +function _highlight(i) { + _ckSel = Math.max(0, Math.min(_items.length - 1, i)); + el('#cmdk-list').querySelectorAll('.cmdk-row').forEach((r, j) => r.classList.toggle('bg-white/[.05]', j === _ckSel)); +} +function _run(i) { const it = _items[i]; if (!it) return; CMDK.close(); it.run(); } + +window.CMDK = { + open() { + const o = _overlay(); o.classList.remove('hidden'); o.classList.add('flex'); + const inp = el('#cmdk-in'); inp.value = ''; _render(''); inp.focus(); + inp.oninput = () => _render(inp.value); + inp.onkeydown = (e) => { + if (e.key === 'ArrowDown') { e.preventDefault(); _highlight(_ckSel + 1); el('#cmdk-list').children[_ckSel]?.scrollIntoView({ block: 'nearest' }); } + else if (e.key === 'ArrowUp') { e.preventDefault(); _highlight(_ckSel - 1); el('#cmdk-list').children[_ckSel]?.scrollIntoView({ block: 'nearest' }); } + else if (e.key === 'Enter') { e.preventDefault(); _run(_ckSel); } + else if (e.key === 'Escape') this.close(); + }; + }, + close() { const o = el('#bh-cmdk'); if (o) { o.classList.add('hidden'); o.classList.remove('flex'); } }, +}; + +// Global shortcut: ⌘K / Ctrl-K toggles the palette. +document.addEventListener('keydown', (e) => { + if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) { + e.preventDefault(); + const open = el('#bh-cmdk') && !el('#bh-cmdk').classList.contains('hidden'); + open ? CMDK.close() : CMDK.open(); + } +}); diff --git a/build-helper/public/js/components.js b/build-helper/public/js/components.js index 3aec5e2..b74f614 100644 --- a/build-helper/public/js/components.js +++ b/build-helper/public/js/components.js @@ -31,26 +31,6 @@ window.C = { `; }, - // A project tile for the dashboard grid. - projectCard(p) { - const lb = p.lastBuild; - const health = p.needsConfig ? 'warn' : (p.healthOk ? 'ok' : 'warn'); - const lastHtml = lb ? `${lb.buildOk ? '✓' : '✗'} v${esc(verName(lb.version))} · ${esc(lb.env || '')} · ${relTime(lb.time)}` - : 'no builds yet'; - return ``; - }, - // The project-sources manager: scan roots + pinned projects + recursive toggle + suggestions. sourcesPanel(BH, suggest) { const s = BH.sources || { roots: [], pinned: [], recursive: false }; @@ -88,16 +68,6 @@ window.C = {
${a.label}
${a.hint}
`; }, - // One env row in the build form: checkbox + version name + build number. - envRow(e, nextNum, baseVer) { - return ``; - }, - // A build history row (used on the project screen + reports). buildRow(b) { const arts = (b.artifacts || []).map((a) => a.name || a); diff --git a/build-helper/public/js/dashboard.js b/build-helper/public/js/dashboard.js new file mode 100644 index 0000000..0003b8f --- /dev/null +++ b/build-helper/public/js/dashboard.js @@ -0,0 +1,93 @@ +// Build Helper frontend — Dashboard (source design). Renders into #bhbody under the kit nav. +// Release-console header, range/project/env filters, KPI cards, 2×2 charts + security posture, and +// the Project-health table + Storage/Activity right rail. Health rows live in health-table.js. +'use strict'; + +const SECURITY_URL = 'http://localhost:4110'; +const DASH = { data: null, sec: null, range: 30, project: '', env: '', search: '', favOnly: false, sort: { key: '', dir: 1 }, density: (localStorage.getItem('bh:density') || 'comfortable') }; +const deltaStr = (n) => (n > 0 ? `▲ ${n}` : n < 0 ? `▼ ${-n}` : '±0'); +const _gcol = (l) => ({ A: 'emerald', B: 'emerald', C: 'amber', D: 'orange', F: 'rose' }[l] || 'slate'); +const _cut = () => (DASH.range ? Date.now() - DASH.range * 864e5 : 0); +const filteredBuilds = () => (DASH.data ? DASH.data.builds.filter((b) => (!_cut() || +new Date(b.time) >= _cut()) && (!DASH.project || b.project === DASH.project) && (!DASH.env || b.env === DASH.env)) : []); +function prevBuilds() { const d = DASH.data; if (!d || !DASH.range) return []; const now = Date.now(), a = now - 2 * DASH.range * 864e5, b = now - DASH.range * 864e5; return d.builds.filter((x) => { const t = +new Date(x.time); return t >= a && t < b && (!DASH.project || x.project === DASH.project) && (!DASH.env || x.env === DASH.env); }); } + +window.V.dashboard = async function () { + BH.view = 'dashboard'; if (window.setShell) setShell('dashboard'); + _body().innerHTML = '
Loading…
'; + const [d, sec] = await Promise.all([API.dashboard().catch(() => null), fetch(SECURITY_URL + '/api/security/all').then((r) => r.json()).catch(() => null)]); + if (!d) { el('#dashbody').textContent = 'Failed to load.'; return; } + DASH.data = d; DASH.sec = sec; BH.projects = d.projects || []; BH.builds = d.builds || []; window.SECURITY_ONLINE = !!(sec && sec.overall); + renderDashboard(); connectFeed(); +}; +const refreshDashboard = () => V.dashboard(); + +function renderDashboard() { + const d = DASH.data; const B = filteredBuilds(), P = prevBuilds(); + const total = B.length, ok = B.filter((b) => b.buildOk !== false).length, success = total ? Math.round(ok / total * 100) : 0; + const durs = B.filter((b) => b.durationMs).map((b) => b.durationMs), avg = durs.length ? durs.reduce((a, b) => a + b, 0) / durs.length : 0; + const seg = (v, c) => ``; + const kpi = (l, val, sub) => `
${l}
${val}
${sub || ''}
`; + const last = (d.builds || [])[0], lastShip = last ? relTime(last.time) : '—', activeApps = (d.projects || []).filter((p) => p.buildCount).length; + const projOpts = [''].concat(d.projects.filter((p) => p.buildCount).map((p) => ``)).join(''); + const envUnion = [...new Set([...ENVS, ...(d.builds || []).map((b) => b.env)])].filter(Boolean); + const envOpts = [''].concat(envUnion.map((e) => ``)).join(''); + el('#dashbody').innerHTML = ` +
Release console
+

Overview

+ ${total} builds · ${success}% ok · ${activeApps} active app${activeApps === 1 ? '' : 's'} · last ship ${lastShip}
+
+
${seg(7, '7d')}${seg(30, '30d')}${seg(90, '90d')}${seg(0, 'All')}
+ + Export CSV + Report
+
${kpi('Success rate', success + '%', `${ok}/${total} builds ok`)}${kpi('Avg build time', fmtDur(avg), durs.length + ' timed')}${kpi('Builds', total, deltaStr(total - P.length) + ' vs prev')}${kpi('Storage', fmtBytes(d.storage.totalBytes), d.storage.files + ' files')}
+ ${securityPostureHtml()} +
+
Builds over time
okfailed
${stackedColumnsSvg(bucketDays(B, DASH.range || 90))}
+
Build duration
${lineSvg(B.filter((b) => b.durationMs).slice().reverse().map((b) => ({ label: b.project + ' ' + b.version, ms: b.durationMs })))}
+
Environment split
${hbarsSvg(envUnion.map((e) => ({ name: e.toUpperCase(), count: B.filter((b) => b.env === e).length, color: ENV_HEX[e] || '#64748b' })).filter((i) => i.count))}
+
Upload health by target
${uploadTargetsSvg(B)}
+
+
Project health
+ +
+
${['name:App', 'version:Version', 'env:Env', 'last:Last build', 'status:Status', 'buildCount:Builds'].map((c) => { const [k, l] = c.split(':'); return ``; }).join('')}
${l}Security
+
+
Storage
+
${fmtBytes(d.storage.totalBytes)}
+
${Object.entries(d.storage.perProject || {}).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([n, by]) => { const w = Math.round(by / Math.max(1, d.storage.totalBytes) * 100); return `
${esc(n)}${fmtBytes(by)}
`; }).join('') || '
No artifacts.
'}
+
Activity
waiting for build events…
+
`; + renderHealth(); wireDash(); +} + +function wireDash() { + _body().querySelectorAll('.range').forEach((b) => b.onclick = () => { DASH.range = +b.dataset.r; renderDashboard(); }); + el('#fproj').onchange = (e) => { DASH.project = e.target.value; renderDashboard(); }; + el('#fenv').onchange = (e) => { DASH.env = e.target.value; renderDashboard(); }; + el('#density').onchange = (e) => { DASH.density = e.target.value; try { localStorage.setItem('bh:density', DASH.density); } catch {} renderHealth(); }; + el('#favonly').onchange = (e) => { DASH.favOnly = e.target.checked; renderHealth(); }; + el('#cleanup').onclick = () => API.post('/api/cleanup', { olderThanDays: 30 }).then((r) => { toast(`Freed ${fmtBytes(r.freed || 0)}`, 'ok'); refreshDashboard(); }); + _body().querySelectorAll('[data-sort]').forEach((th) => th.onclick = () => { const k = th.dataset.sort; DASH.sort = { key: DASH.sort.key === k ? '' : k, dir: DASH.sort.key === k ? -DASH.sort.dir : 1 }; renderHealth(); }); + const gs = el('#gotosec'); if (gs) gs.onclick = () => window.open(SECURITY_URL, '_blank'); + _body().querySelectorAll('.secjump').forEach((b) => b.onclick = () => window.open(SECURITY_URL, '_blank')); + if (window.tourPrompt && d0().projects.length) tourPrompt('bh-tour-dashboard', 'the dashboard', () => startTourFor('dashboard')); +} +const d0 = () => DASH.data || { projects: [] }; + +function securityPostureHtml() { + if (typeof SECURITY_ONLINE !== 'undefined' && !SECURITY_ONLINE) return ''; + const sec = DASH.sec; if (!sec || !sec.overall) return ''; + const ov = sec.overall, gc = _gcol(ov.letter); + const apps = (sec.apps || []).filter((a) => a.grade).sort((a, b) => a.grade.score - b.grade.score).slice(0, 4); + const banner = ov.totalFail > 0 ? `${ov.totalFail} issue${ov.totalFail === 1 ? '' : 's'} to fix before the security team finds them` : (ov.scanned ? `No failing checks across ${ov.scanned} scanned app${ov.scanned === 1 ? '' : 's'}` : 'No security scans yet — run one from the Security tool'); + return `
${ov.avg != null ? ov.letter : '—'}
Security posture
${banner}
${apps.length ? `
${apps.map((a) => { const c = _gcol(a.grade.letter); return ``; }).join('')}
` : ''}
`; +} + +// One persistent activity-feed connection; appends into #feed when present. +let _feedES = null; +function connectFeed() { + if (_feedES) return; + try { _feedES = new EventSource('/api/feed'); } catch { return; } + _feedES.addEventListener('feed', (e) => { const box = el('#feed'); if (!box) return; let d; try { d = JSON.parse(e.data); } catch { return; } if (box.textContent === 'waiting for build events…') box.innerHTML = ''; const icon = { start: '▶', done: '✅', error: '❌' }[d.kind] || '•'; box.insertAdjacentHTML('afterbegin', `
${new Date(d.time).toLocaleTimeString()} ${icon} ${esc(d.project || '')} ${esc(d.text || '')}
`); }); +} diff --git a/build-helper/public/js/detail.js b/build-helper/public/js/detail.js new file mode 100644 index 0000000..1835cb2 --- /dev/null +++ b/build-helper/public/js/detail.js @@ -0,0 +1,52 @@ +// Build Helper frontend — build detail (V.detail) + per-project analytics (V.analytics), source +// design. Detail: metadata, artifacts (download / share / reveal / copy-path / re-upload), uploads, +// TestFlight re-manage, Play rollback, full log. Rendered into #bhbody under the kit nav. +'use strict'; + +const LABELS_D = { onedrive: 'OneDrive', firebase: 'Firebase', play: 'Play', testflight: 'TestFlight' }; +const artPath = (a) => { try { return new URL(a.url).searchParams.get('path'); } catch { return null; } }; + +window.V.detail = async function (time) { + if (window.setShell) setShell('build'); + _body().innerHTML = '
Loading…
'; + el('#dback').onclick = () => (BH.sel ? V.project(BH.sel) : V.dashboard()); + const r = await fetch('/api/build?time=' + encodeURIComponent(time)).then((r) => r.json()).catch(() => ({})); + if (!r.build) { el('#detbody').textContent = 'Build not found.'; return; } + const b = r.build, c = ENV_COLOR[b.env] || 'slate'; + let logtext = ''; if (b.logFile) { try { logtext = await fetch('/api/log?file=' + encodeURIComponent(b.logFile)).then((x) => x.text()); } catch {} } + const u = b.upload || {}, chip = (k, v) => (!v || v === 'skipped') ? '' : `${LABELS_D[k]}:${v}`; + const DEST_FOR_ART = { apk: ['onedrive', 'firebase'], aab: ['play', 'onedrive', 'firebase'], ipa: ['testflight', 'onedrive'] }; + const DLBL = { onedrive: '☁ OneDrive', firebase: '🔥 Firebase', play: '▶ Play', testflight: '✈️ TestFlight' }; + const arts = (b.artifacts || []).map((a) => { const p = artPath(a); return `
${esc(a.name)}${a.size ? `${fmtBytes(a.size)}` : ''}${p ? `↓ DownloadShare` : ''}
${p ? `
Upload →${(DEST_FOR_ART[a.art] || []).map((t) => ``).join('')}
` : ''}
`; }).join('') || '
No artifacts.
'; + el('#detbody').innerHTML = ` +
${esc(b.project)}
${badge(b.env.toUpperCase(), c)}${b.version}${b.buildOk === false ? '✗ build failed' : '✓ built'}${b.buildOk !== false ? '' : ''}
+
+
Time
${new Date(b.time).toLocaleString()}
Branch
${esc(b.branch || '—')}
${b.by ? `
Built by
${esc(b.by)}
` : ''}
Duration
${b.durationMs ? fmtDur(b.durationMs) : '—'}
Commit
${b.commit ? (r.commitUrl ? `${b.commit.slice(0, 8)}` : b.commit.slice(0, 8)) : '—'}
${b.tag ? `
Tag
🏷 ${esc(b.tag)}
` : ''}
+
Artifacts
${arts}
+ ${b.error ? `
${esc(b.error)}
` : ''} +
Full log
${esc(stripAnsi(logtext || '(no log)'))}
+
Uploads
${chip('onedrive', u.onedrive)}${chip('firebase', u.firebase)}${chip('play', u.play)}${chip('testflight', u.testflight)}${!Object.values(u).some((v) => v && v !== 'skipped') ? 'no uploads' : ''}
+ ${(b.artifacts || []).some((a) => a.art === 'ipa') ? '' : ''} + ${b.playVersionCode ? `
Play rollback (versionCode ${b.playVersionCode})
` : ''}
+ ${b.whatsNew ? `
What's new
${esc(b.whatsNew)}
` : ''}
`; + el('#detbody').querySelectorAll('.drev').forEach((x) => x.onclick = () => API.reveal(x.dataset.p)); + el('#detbody').querySelectorAll('.dcopy').forEach((x) => x.onclick = () => { navigator.clipboard.writeText(x.dataset.p); toast('Path copied', 'ok'); }); + el('#detbody').querySelectorAll('.dup').forEach((x) => x.onclick = async () => { const box = el('#detuplog'); box.classList.remove('hidden'); box.innerHTML = ''; const head = document.createElement('div'); head.className = 'text-sky-300 font-semibold'; head.textContent = `→ ${x.textContent.trim()} · ${x.dataset.name}`; box.appendChild(head); const line = document.createElement('div'); box.appendChild(line); await new Promise((res) => streamSSEInto('/api/retry-upload', { path: b.path, artifactName: x.dataset.name, target: x.dataset.t, track: b.env === 'prod' ? 'production' : 'internal', notes: b.whatsNew }, line, res)); toast('Upload done — see log', 'ok'); }); + if (el('#tfmanage')) el('#tfmanage').onclick = async () => { const box = el('#tfmlog'); box.classList.remove('hidden'); box.innerHTML = ''; await new Promise((res) => streamSSEInto('/api/testflight/manage', { path: b.path, buildNumber: b.buildNumber, notes: b.whatsNew }, box, res)); toast('TestFlight updated', 'ok'); }; + if (el('#detshare')) el('#detshare').onclick = () => showShareCard([b]); + const rb = el('#rbgo'); if (rb) rb.onclick = () => { const box = el('#rblog'); box.classList.remove('hidden'); streamSSEInto('/api/rollback', { path: b.path, versionCode: b.playVersionCode, track: el('#rbtrack').value }, box, null); }; +}; + +window.V.analytics = async function (path) { + if (window.setShell) setShell('build'); + _body().innerHTML = '
Loading…
'; + el('#aback').onclick = () => V.project(path); + const d = await API.dashboard().catch(() => null); if (!d) { el('#pabody').textContent = 'Failed to load.'; return; } + const B = d.builds.filter((b) => b.path === path), name = B[0]?.project || d.projects.find((p) => p.path === path)?.name || path.split('/').pop(); + const ok = B.filter((b) => b.buildOk !== false).length; + const sizePts = B.filter((b) => b.artifacts && b.artifacts[0]?.size).slice().reverse().map((b) => ({ label: b.version, ms: b.artifacts[0].size })); + const durPts = B.filter((b) => b.durationMs).slice().reverse().map((b) => ({ label: b.version, ms: b.durationMs })); + el('#pabody').innerHTML = `
${esc(name)} · analytics
+
Builds
${B.length}
Success
${B.length ? Math.round(ok / B.length * 100) : 0}%
Latest size
${sizePts.length ? fmtBytes(sizePts[sizePts.length - 1].ms) : '—'}
Avg build
${durPts.length ? fmtDur(durPts.reduce((a, b) => a + b.ms, 0) / durPts.length) : '—'}
+
Builds over time
${stackedColumnsSvg(bucketDays(B, 90))}
Duration
${lineSvg(durPts)}
Artifact size
${lineSvg(sizePts)}
Version history
${B.slice(0, 20).map((b) => `
${relTime(b.time)}${badge(b.env.toUpperCase(), ENV_COLOR[b.env] || 'slate')}${b.version}${b.buildOk === false ? '✗' : ''}
`).join('')}
`; +}; diff --git a/build-helper/public/js/health-table.js b/build-helper/public/js/health-table.js new file mode 100644 index 0000000..2d9cdef --- /dev/null +++ b/build-helper/public/js/health-table.js @@ -0,0 +1,72 @@ +// Build Helper frontend — the Project-health table body (source design). Renders rows into +// #healthbody: drag handle · ★ favorite · health dot · app icon (/api/icon, monogram fallback) · +// name · version · env · last build · status · builds · security grade · Set up / Open. Sortable, +// drag-to-reorder (when unsorted/unfiltered), density-aware. Reads the DASH state from dashboard.js. +'use strict'; + +const DEN = { + compact: { pad: 'py-1', icon: 'h-4 w-4', av: 'text-[7px]', text: 'text-[11px]' }, + comfortable: { pad: 'py-1.5', icon: 'h-5 w-5', av: 'text-[8px]', text: 'text-xs' }, + large: { pad: 'py-2.5', icon: 'h-8 w-8', av: 'text-xs', text: 'text-sm' }, +}; +let DRAG = null; + +function renderHealth() { + const body = el('#healthbody'); if (!body || !DASH.data) return; + let rows = DASH.data.projects.slice(); + if (DASH.search) rows = rows.filter((p) => p.name.toLowerCase().includes(DASH.search)); + if (DASH.favOnly) rows = rows.filter((p) => p.favorite); + const { key, dir } = DASH.sort; + rows.sort((a, b) => { + let av, bv; + if (key === 'last') { av = a.lastBuild ? new Date(a.lastBuild.time) : 0; bv = b.lastBuild ? new Date(b.lastBuild.time) : 0; } + else if (key === 'buildCount') { av = a.buildCount; bv = b.buildCount; } + else if (key === 'env') { av = a.lastBuild?.env || ''; bv = b.lastBuild?.env || ''; } + else if (key === 'version') { av = a.version || ''; bv = b.version || ''; } + else if (key === 'name') { av = a.name; bv = b.name; } + else return (b.favorite - a.favorite) || 0; + return (av < bv ? -1 : av > bv ? 1 : 0) * dir; + }); + const D = DEN[DASH.density] || DEN.comfortable; + const dragOn = key === '' && !DASH.search; + const tbl = document.querySelector('table.health'); if (tbl) tbl.className = 'health w-full ' + D.text; + const secByPath = Object.fromEntries(((DASH.sec && DASH.sec.apps) || []).map((a) => [a.path, a.grade])); + body.innerHTML = rows.map((p) => { + const lb = p.lastBuild, c = ENV_COLOR[lb?.env] || 'slate'; + const ic = p.hasIcon ? `` : `
${esc(p.name.slice(0, 2).toUpperCase())}
`; + const status = lb ? (lb.buildOk ? 'ok' : 'failed') : '—'; + const hdot = ``; + const cta = p.needsConfig || !p.healthOk ? `` : 'Open ▸'; + const handle = dragOn ? '⠿' : ''; + const g = secByPath[p.path]; + const gcell = g ? `${g.letter}` : ``; + return ` + ${handle} +
${hdot}${ic}${esc(p.name)}
+ ${esc(p.version || '—')} + ${lb ? badge(lb.env.toUpperCase(), c) : '—'} + ${lb ? relTime(lb.time) : 'never'} + ${status} + ${p.buildCount} + ${gcell} + ${cta}`; + }).join('') || 'No projects.'; + body.querySelectorAll('td.cursor-pointer').forEach((td) => td.onclick = () => openProject(td.closest('[data-open]').dataset.open)); + body.querySelectorAll('.secgrade').forEach((g) => g.onclick = (e) => { e.stopPropagation(); showSecurity(g.dataset.p); }); + body.querySelectorAll('.setup').forEach((b) => b.onclick = (e) => { e.stopPropagation(); showAppSetup(b.dataset.p); }); + body.querySelectorAll('.fav').forEach((b) => b.onclick = async (e) => { e.stopPropagation(); await postJson('/api/app/favorite', { path: b.dataset.p, favorite: b.dataset.f === '1' }); refreshDashboard(); }); + if (dragOn) body.querySelectorAll('tr[data-open]').forEach((tr) => { + tr.ondragstart = (e) => { DRAG = tr.dataset.open; e.dataTransfer.effectAllowed = 'move'; tr.classList.add('opacity-50'); }; + tr.ondragend = () => { DRAG = null; tr.classList.remove('opacity-50'); }; + tr.ondragover = (e) => e.preventDefault(); + tr.ondrop = (e) => { e.preventDefault(); reorderTo(DRAG, tr.dataset.open); }; + }); +} + +function reorderTo(fromPath, toPath) { + if (!fromPath || fromPath === toPath) return; + const ps = DASH.data.projects; const fi = ps.findIndex((p) => p.path === fromPath), ti = ps.findIndex((p) => p.path === toPath); + if (fi < 0 || ti < 0) return; + const [m] = ps.splice(fi, 1); ps.splice(ti, 0, m); + renderHealth(); postJson('/api/app/order', { paths: ps.map((p) => p.path) }); +} diff --git a/build-helper/public/js/history.js b/build-helper/public/js/history.js new file mode 100644 index 0000000..bf12b86 --- /dev/null +++ b/build-helper/public/js/history.js @@ -0,0 +1,75 @@ +// Build Helper frontend — build history (source design): per-build cards with upload chips + retry, +// multi-select bulk (Share / re-upload to a target), Details, and raw log. Plus the small SSE-into-a- +// box streamer used for retry/bulk. showDetail→V.detail, showShareCard→share.js. +'use strict'; + +const ART_FOR = { play: 'aab', testflight: 'ipa', firebase: 'apk', onedrive: 'apk' }; +function artName(b, target) { + const arts = Array.isArray(b.artifacts) ? b.artifacts.filter((x) => typeof x !== 'string') : []; + const want = ART_FOR[target]; + return (arts.find((a) => a.art === want) || arts.find((a) => a.art === 'aab') || arts[0])?.name || ''; +} + +function renderHistory(builds) { + const box = el('#history'); if (!box) return; + if (!builds || !builds.length) { box.innerHTML = '
No builds yet.
'; return; } + const label = { onedrive: 'OneDrive', firebase: 'Firebase', play: 'Play', testflight: 'TestFlight' }; + box.innerHTML = '' + builds.map((b, bi) => { + const c = ENV_COLOR[b.env] || 'slate', u = b.upload || {}; + const chip = (k, v) => { if (!v || v === 'skipped') return ''; if (v === 'failed') { const n = artName(b, k); return `${label[k]}:failed${n ? `${esc(n)}` : ''}`; } return `${label[k]}:ok`; }; + const targets = chip('onedrive', u.onedrive) + chip('firebase', u.firebase) + chip('play', u.play) + chip('testflight', u.testflight); + const arts = Array.isArray(b.artifacts) ? b.artifacts.map((x) => typeof x === 'string' ? x : x.name).join(', ') : ''; + const failed = b.buildOk === false; + return `
+
${failed ? '✗ build failed' : ''}${badge(b.env.toUpperCase(), c)}
+
${new Date(b.time).toLocaleString()} · ${b.branch || '—'} · ${esc(arts)}${b.durationMs ? ` · ${(b.durationMs / 1000).toFixed(0)}s` : ''}${b.by ? ` · by ${esc(b.by)}` : ''}
+ ${failed && b.error ? `
${esc(b.error)}
` : ''} + ${b.tag ? `
🏷 ${esc(b.tag)}
` : ''} + ${targets ? `
${targets}
` : ''} + ${b.whatsNew ? `
✨ ${esc(b.whatsNew)}
` : ''} +
${b.logFile ? `raw log` : ''}
+
`; + }).join(''); + box.querySelectorAll('.detail').forEach((btn) => btn.onclick = () => (window.V.detail ? V.detail(btn.dataset.t) : null)); + box.querySelectorAll('.retry').forEach((btn) => btn.onclick = async () => { const b = builds[+btn.dataset.bi], target = btn.dataset.t, name = artName(b, target); btn.textContent = '…'; btn.disabled = true; await new Promise((res) => streamSSEInto('/api/retry-upload', { path: CUR.app.path, artifactName: name, target, track: b.env === 'prod' ? 'production' : 'internal', notes: b.whatsNew }, el('#log'), res)); fetch('/api/project?path=' + encodeURIComponent(CUR.app.path)).then((r) => r.json()).then((d) => { CUR = d; renderHistory(d.builds); }); }); + const selected = () => [...box.querySelectorAll('.bsel:checked')].map((x) => builds[+x.dataset.i]); + const bar = el('#bulkbar'); + const updateBulk = () => { + const sel = selected(); if (!sel.length) { bar.classList.add('hidden'); return; } + bar.classList.remove('hidden'); + bar.innerHTML = `${sel.length} selected${['onedrive:OneDrive', 'firebase:Firebase', 'testflight:TestFlight', 'play:Play'].map((t) => { const [k, l] = t.split(':'); return ``; }).join('')}`; + bar.querySelectorAll('.bk').forEach((btn) => btn.onclick = () => { const a = btn.dataset.a; if (a === 'share') { if (window.showShareCard) showShareCard(selected()); } else if (a === 'clear') { box.querySelectorAll('.bsel:checked').forEach((x) => x.checked = false); updateBulk(); } else if (a === 'up') bulkUpload(selected(), btn.dataset.t); }); + }; + box.querySelectorAll('.bsel').forEach((x) => x.onchange = () => { updateBulk(); const all = [...box.querySelectorAll('.bsel')]; const sa = el('#selall'); if (sa) sa.checked = all.length && all.every((x) => x.checked); }); + if (el('#selall')) { el('#selall').checked = false; el('#selall').onchange = () => { const on = el('#selall').checked; box.querySelectorAll('.bsel').forEach((x) => x.checked = on); updateBulk(); }; } +} + +async function bulkUpload(records, target) { + const label = { onedrive: 'OneDrive', firebase: 'Firebase', testflight: 'TestFlight', play: 'Play' }[target] || target; + const host = el('#log'); if (host) host.innerHTML = ''; + for (const b of records) { + const name = artName(b, target); + const sub = document.createElement('div'); sub.className = 'mt-2 border-t border-edge pt-1'; + sub.innerHTML = `
→ ${label} · ${b.env.toUpperCase()} ${b.version}${name ? '' : ' — no artifact on disk (rebuild)'}
`; + if (host) host.appendChild(sub); if (!name) continue; + const line = document.createElement('div'); sub.appendChild(line); + await new Promise((res) => streamSSEInto('/api/retry-upload', { path: b.path, artifactName: name, target, track: b.env === 'prod' ? 'production' : 'internal', notes: b.whatsNew }, line, res)); + } + toast(`${label}: done for ${records.length} build${records.length > 1 ? 's' : ''} — see log`, 'ok'); + fetch('/api/project?path=' + encodeURIComponent(CUR.app.path)).then((r) => r.json()).then((d) => { CUR = d; renderHistory(d.builds); }); +} + +function streamSSEInto(url, body, box, onDone) { + box.innerHTML = ''; + const write = (s, cls = '') => { const d = document.createElement('div'); if (cls) d.className = cls; d.textContent = stripAnsi(s); box.appendChild(d); box.scrollTop = box.scrollHeight; }; + fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then((res) => { + const reader = res.body.getReader(), dec = new TextDecoder(); let buf = ''; + const pump = () => reader.read().then(({ done, value }) => { + if (done) { onDone && onDone(); return; } + buf += dec.decode(value, { stream: true }); let i; + while ((i = buf.indexOf('\n\n')) >= 0) { const chunk = buf.slice(0, i); buf = buf.slice(i + 2); const ev = /event: (.+)/.exec(chunk), dt = /data: ([\s\S]+)/.exec(chunk); if (!ev || !dt) continue; const t = ev[1]; let d; try { d = JSON.parse(dt[1]); } catch { continue; } if (t === 'log') write(d.line); else if (t === 'step') write('▸ ' + d.text, 'text-emerald-300 font-semibold'); else if (t === 'error') { write('✖ ' + d.message, 'text-rose-400'); toast(d.message, 'err'); } else if (t === 'done') { write('✔ done', 'text-emerald-400'); toast('Retry ok', 'ok'); } } + return pump(); + }); + return pump(); + }).catch((e) => { write('✖ ' + e.message, 'text-rose-400'); onDone && onDone(); }); +} diff --git a/build-helper/public/js/project.js b/build-helper/public/js/project.js index 8119a7b..fe794f1 100644 --- a/build-helper/public/js/project.js +++ b/build-helper/public/js/project.js @@ -1,110 +1,64 @@ -// Build Helper frontend — Project view: env/flavor + version pickers, artifact selection, the -// build stream, Android signing, and this app's build history. This is the core workflow screen. +// Build Helper frontend — Build (Project) view (source design). Renders into #bhbody under the kit +// nav: header (icon + Current + TestFlight), sticky pipeline bar, colored-left-border env cards with +// the outputs matrix, artifacts-on-disk, live log, and build history. Wiring lives in build-wire.js; +// the runner in build-run.js; form helpers in build-form.js. 'use strict'; -window.V.project = async function (projectPath) { - BH.sel = projectPath; BH.view = 'project'; if (window.setShell) setShell('build'); - _body().innerHTML = _skel(2); - const d = await API.project(projectPath).catch(() => null); - if (!d || d.error) { _body().innerHTML = '
Could not load this project.
'; return; } - BH.project = d; - const app = d.app, nn = d.nextBuildNumbers || {}, base = verName(app.version), h = d.health || {}; - const healthPill = (ok, on, off) => ok ? `${on}` : `${off}`; +window.V.project = async function (path) { + BH.view = 'project'; BH.sel = path; if (window.setShell) setShell('build'); + _body().innerHTML = '
Loading…
'; + let data = null, err = ''; + try { const res = await fetch('/api/project?path=' + encodeURIComponent(path)); data = await res.json().catch(() => null); if (!res.ok || !data || !data.app) err = (data && data.error) || ('HTTP ' + res.status); } catch (e) { err = e.message || 'network error'; } + if (err) { try { localStorage.setItem('bh:lastView', ''); } catch {} toast(`Can't open ${path} — ${err}`, 'err'); return V.dashboard(); } + try { localStorage.setItem('bh:lastView', path); } catch {} + CUR = data; renderProject(); +}; +function renderProject() { + const a = CUR.app, g = CUR.git, next = CUR.nextBuildNumbers || {}; + const bv = baseVer(a.version), mem = loadMem(a.path); + const av = { local: true, onedrive: !!CUR.onedriveConnected, testflight: !!(CUR.apple || CUR.globalApple), firebase: !!(CUR.firebaseApp && CUR.firebaseCli), play: !!(CUR.play || CUR.globalPlay) }; + const CONNECT = { onedrive: 'OneDrive not connected — connect it in Settings', testflight: 'No Apple account — add it in App Setup → Apple', firebase: 'No Firebase App ID — set it in App Setup → Firebase', play: 'No Google Play account — add it in App Setup → Google Play' }; + const envItems = (a.envs || []).map((e) => ({ key: e.key || (e.label || '').toLowerCase(), label: e.label || e.key, color: e.color })); + const curEnv = (a.envs || []).find((e) => e.mode === a.currentMode) || {}; + const curKey = curEnv.key || (curEnv.label || '').toLowerCase() || null; _body().innerHTML = ` - -
-
-
${esc(app.name)}
- v${esc(base)} - ${app.currentMode ? `on ${esc(app.currentMode)}` : ''} -
- ${healthPill(h.envDetected, 'env ok', 'no env')} - ${healthPill(h.signing, 'signed', 'unsigned')} - -
-
-
${esc(app.path)}
- ${app.needsConfig ? '
This app has no detected AppMode env — set it up before building (see README / App Setup).
' : ''} +
+ ${CUR.hasIcon ? `` : ''} +
${a.name}
${a.path}
+ +
Current
${a.version || '—'} · ${a.currentMode || '?'}
+
TestFlight
${av.testflight ? 'checking…' : ''}
+
+ ${a.needsConfig ? `
${ICON.warn}This app needs setup — env constant not auto-detected.
` : ''} +
+ ${envItems.length > 1 ? `Pipeline
${pipelineHtml(envItems, curKey, false)}
` : 'Tick an environment below, then Build.'} +
+ + +
${ICON.gear}Advanced ▾ +
+ ${[['clean', 'clean', mem.clean], ['pubget', 'pub get', mem.pubGet], ['committag', 'commit + tag', mem.commitTag], ['push', 'push', mem.push], ['runanalyze', 'analyze', mem.runAnalyze], ['runtest', 'test', mem.runTest]].map(([id, l, on]) => ``).join('')} + +
+
- -
-
-
1 · Environments & version
-
${(app.envs || []).map((e) => C.envRow(e, nn[e.key] != null ? nn[e.key] : 1, base)).join('') || '
No environments.
'}
-
2 · Artifacts
-
${ARTS.map((a) => C.artChip(a, a.id === 'apk')).join('')}
-
3 · Options
-
- ${['clean:flutter clean', 'pub:pub get', 'analyze:analyze', 'test:test'].map((o) => { const [id, l] = o.split(':'); return ``; }).join('')} -
-
Distribute (configure in Setup)
-
- - - -
- -
- - -
-
-
-
Live log
-
-
Recent builds
-
+
+
+
+ ${(a.envs || []).length ? '' : '
No environments defined for this app.
'} + ${(a.envs || []).map((e) => envCard(e, next, bv, mem, av, CONNECT, g)).join('')} +
Artifacts on disk
+
+
+
+
${ICON.gitBranch}
Live log
+ +
+
build.log
idle.
-
`; - - el('#bh-back').onclick = () => V.dashboard(); - el('#bh-sign').onclick = () => signingModal(app); - _body().querySelectorAll('.bh-art').forEach((a) => a.onclick = () => a.classList.toggle('on')); - el('#bh-build').onclick = doBuild; - el('#bh-stop').onclick = () => API.stopBuild().then(() => toast('Stopping current build…', 'info')); - renderBuilds(d.builds || []); -}; - -function collectBuild() { - const q = (s) => _body().querySelector(s), v = (s) => (q(s) ? q(s).value.trim() : ''); - const on = (id) => !!(el(id) && el(id).checked); - const envs = [..._body().querySelectorAll('.bh-env:checked')].map((c) => ({ - env: c.dataset.env, buildName: v(`.bh-ver[data-env="${c.dataset.env}"]`) || '1.0.0', buildNumber: v(`.bh-num[data-env="${c.dataset.env}"]`) || '1', - uploadOneDrive: on('#bh-od'), uploadFirebase: on('#bh-fb'), uploadStore: on('#bh-store'), - })); - const anyway = on('#bh-anyway'); - return { path: BH.sel, envs, artifacts: [..._body().querySelectorAll('.bh-art.on')].map((a) => a.dataset.art), - clean: on('#bh-clean'), pubGet: on('#bh-pub'), runAnalyze: on('#bh-analyze'), runTest: on('#bh-test'), - allowDirty: anyway, allowDuplicate: anyway, confirmProd: anyway, allowLowVersion: anyway }; -} - -function doBuild() { - const req = collectBuild(); - if (!req.envs.length) return toast('Pick at least one environment', 'warn'); - if (!req.artifacts.length) return toast('Pick at least one artifact (APK/AAB/IPA)', 'warn'); - const box = el('#bh-term'); box.textContent = ''; const btn = el('#bh-build'); btn.disabled = true; - API.stream('/api/build', req, box, () => { btn.disabled = false; API.builds(BH.sel).then((r) => renderBuilds(r.builds || [])); }); -} - -function renderBuilds(builds) { - const box = el('#bh-builds'); if (!box) return; - box.innerHTML = builds.length ? builds.slice(0, 20).map(C.buildRow).join('') : '
No builds yet for this app.
'; -} - -// Generate a fresh upload keystore, or link an existing one — both stream keytool/gradle output. -function signingModal(app) { - const b = openModal({ title: 'Android signing', subtitle: app.name, size: '540px' }); - b.innerHTML = ` -
Generate a release keystore (stored in this tool's data dir) or link one you already have. Writes android/key.properties and wires build.gradle.
-
- - +
Build history
-
- -
-
`; - el('#sg-gen').onclick = () => API.stream('/api/signing/generate', { path: BH.sel, alias: el('#sg-alias').value.trim() || 'upload', password: el('#sg-pw').value }, el('#sg-log')); - el('#sg-do-link').onclick = () => API.stream('/api/signing/link', { path: BH.sel, storeFile: el('#sg-link').value.trim(), storePassword: el('#sg-pw').value, keyAlias: el('#sg-alias').value.trim() || 'upload' }, el('#sg-log')); +
`; + wireProject(a, g, av); } diff --git a/build-helper/public/js/setup.js b/build-helper/public/js/setup.js index 390937c..66c4bc0 100644 --- a/build-helper/public/js/setup.js +++ b/build-helper/public/js/setup.js @@ -8,12 +8,17 @@ window.V.setup = async function () { _body().innerHTML = _skel(3); const [s, doc] = await Promise.all([API.setup().catch(() => ({})), API.doctor().catch(() => ({ checks: [] }))]); _body().innerHTML = ` -
Doctor — toolchain preflight${doc.diskFree ? ' · ' + esc(doc.diskFree) : ''}
+
Doctor — toolchain preflight${doc.diskFree ? ' · ' + esc(doc.diskFree) : ''}
+
${(doc.checks || []).map(_check).join('')}
Distribution connectors
- ${_conn('OneDrive', s.onedriveConnected, `
Base folder: ${esc(s.onedriveBase || '')}
`)} + ${_conn('OneDrive', s.onedriveConnected, ` +
Base folder: ${esc(s.onedriveBase || '')}
+
Shared team folder (own account → owner's folder)
+
+
Add the shared folder to “My files” in OneDrive first, then enter its path.
`)} ${_conn('Firebase App Distribution', s.firebaseCli, `
Then set the Firebase App ID per app in App Setup.
`)}
@@ -30,6 +35,8 @@ window.V.setup = async function () { const logBox = () => { const b = el('#su-log'); b.classList.remove('hidden'); b.textContent = ''; return b; }; el('#cn-od').onclick = () => API.stream('/api/setup/onedrive', {}, logBox(), () => V.setup()); + el('#cn-od-shared').onclick = () => API.stream('/api/setup/onedrive-shared', { base: el('#od-base').value.trim() }, logBox(), () => V.setup()); + el('#cn-doctor-full').onclick = () => API.stream('/api/doctor/full', {}, logBox()); el('#cn-fb').onclick = () => API.stream('/api/setup/firebase-login', {}, logBox(), () => V.setup()); el('#cn-play').onclick = async () => { const r = await API.post('/api/setup/play', { serviceAccountJson: el('#pl-sa').value, defaultTrack: el('#pl-track').value }); r.ok ? (toast('Google Play saved', 'ok'), V.setup()) : toast(r.error || 'Failed', 'err'); }; el('#cn-apple').onclick = async () => { const r = await API.post('/api/setup/apple', { keyId: el('#ap-key').value.trim(), issuerId: el('#ap-iss').value.trim(), p8: el('#ap-p8').value }); r.ok ? (toast('Apple saved', 'ok'), V.setup()) : toast(r.error || 'Failed', 'err'); }; diff --git a/build-helper/public/js/share-actions.js b/build-helper/public/js/share-actions.js new file mode 100644 index 0000000..725577a --- /dev/null +++ b/build-helper/public/js/share-actions.js @@ -0,0 +1,69 @@ +// Build Helper frontend — Share follow-ups (source design): email the build to client groups (BCC) +// and file a Jira "Mobile Release". Both toggle an inline form under the share card and stream +// progress. `jref.cur` carries a created Jira release back to the card's client text. +'use strict'; + +function _sseInto(url, body, box, onEvt) { + const write = (s, cls) => { const d = document.createElement('div'); if (cls) d.className = cls; d.textContent = s; box.appendChild(d); box.scrollTop = box.scrollHeight; }; + return fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(async (res) => { + const reader = res.body.getReader(), dec = new TextDecoder(); let buf = ''; + for (;;) { const { done, value } = await reader.read(); if (done) break; buf += dec.decode(value, { stream: true }); let i; while ((i = buf.indexOf('\n\n')) >= 0) { const chunk = buf.slice(0, i); buf = buf.slice(i + 2); const ev = /event: (.+)/.exec(chunk), dt = /data: ([\s\S]+)/.exec(chunk); if (!ev || !dt) continue; let d; try { d = JSON.parse(dt[1]); } catch { continue; } if (ev[1] === 'step') write('▸ ' + d.text, 'text-emerald-300'); else if (ev[1] === 'log') write(d.line); else if (ev[1] === 'error') write('✖ ' + d.message, 'text-rose-400 font-semibold'); else if (ev[1] === 'done') onEvt && onEvt(d); } } + }).catch((e) => write('✖ ' + e.message, 'text-rose-400')); +} + +async function shareEmailForm(records, canvas, jref) { + const form = el('#emform'); if (!form.classList.contains('hidden')) { form.classList.add('hidden'); return; } + form.classList.remove('hidden'); form.innerHTML = '
Loading groups…
'; + const repo = records[0].repo || records[0].project; + const [gr, st] = await Promise.all([fetch('/api/groups?repo=' + encodeURIComponent(repo)).then((r) => r.json()).catch(() => ({ groups: [] })), fetch('/api/setup').then((r) => r.json()).catch(() => ({}))]); + if (!st.email) { form.innerHTML = '
Email (SMTP) not configured — set it up in Setup → Email or the ⚙ gear.
'; return; } + const groups = [...(gr.global || []).map((g) => ({ ...g, global: true })), ...(gr.own || [])], prod = records.find((r) => r.env === 'prod') || records[0]; + const subject = `${records[0].project} ${prod.version || records[0].version} — ready to test`; + const tf = records.map((r) => r.testflight && r.testflight.tfLink).find(Boolean), od = records.map((r) => qrUrl(r)).find(Boolean), hasWn = records.some((r) => (r.whatsNew || r.notes || '').trim()); + const regen = () => { const d = el('#em-desc'); if (d) d.value = composeEmailBody(records, { whatsNew: el('#em-wn')?.checked, jira: el('#em-jira')?.checked }, jref.cur); }; + form.innerHTML = `
From ${esc(st.emailFromName || st.emailFrom || st.emailUser || '')} · recipients are BCC
+ ${groups.length ? '
Groups:
' + groups.map((g) => ``).join('') + '
' : '
No groups yet — add them in App Setup → Client groups, or enter emails below.
'} +
+
Include:${tf ? '' : ''}${od ? '' : ''}
+
`; + el('#em-cancel').onclick = () => form.classList.add('hidden'); + if (el('#em-wn')) el('#em-wn').onchange = regen; if (el('#em-jira')) el('#em-jira').onchange = regen; + el('#em-testme').onclick = async () => { const box = el('#emlog'); box.classList.remove('hidden'); box.textContent = 'Sending test…'; const r = await fetch('/api/email/test').then((x) => x.json()); box.textContent = r.ok ? '✓ sent' : '✖ ' + (r.error || 'failed'); }; + el('#em-send').onclick = async () => { + const buttons = []; if (tf && el('#em-tf')?.checked) buttons.push({ label: 'Install (TestFlight)', url: tf }); if (od && el('#em-od')?.checked) buttons.push({ label: 'Download (OneDrive)', url: od }); + const body = { repo, appName: records[0].project, version: prod.version || records[0].version, subject: el('#em-subject').value.trim(), description: el('#em-desc').value, groups: [...document.querySelectorAll('.grpsel:checked')].map((x) => x.dataset.name), extra: el('#em-extra').value, buttons }; + if (el('#em-img').checked) { try { body.pngBase64 = canvas.toDataURL('image/png'); } catch {} } + const box = el('#emlog'); box.classList.remove('hidden'); box.innerHTML = ''; const btn = el('#em-send'); btn.disabled = true; btn.textContent = 'Sending…'; + await _sseInto('/api/email/send', body, box, (d) => { toast('Emailed ' + d.count + ' recipient(s)', 'ok'); form.classList.add('hidden'); }); + btn.disabled = false; btn.textContent = 'Send'; + }; +} + +async function shareJiraForm(records, canvas, jref, fname) { + const form = el('#jform'); if (!form.classList.contains('hidden')) { form.classList.add('hidden'); return; } + form.classList.remove('hidden'); form.innerHTML = '
Loading Jira project…
'; + const meta = await fetch('/api/jira/meta?path=' + encodeURIComponent(records[0].path)).then((r) => r.json()).catch(() => ({ ok: false, error: 'network error' })); + if (!meta.ok) { form.innerHTML = `
${esc(meta.error || 'Jira not ready')}
Set the Project key + work type in the ⚙ gear → Jira.
`; return; } + const byEnv = (e) => records.find((r) => r.env === e), dev = byEnv('dev'), demo = byEnv('demo'), prod = byEnv('prod') || records[0]; + const bn = prod.buildNumber || records[0].buildNumber || '', store = primaryLink(prod) || primaryLink(records[0]), notes = [...new Set(records.map((r) => r.whatsNew).filter(Boolean))].join('\n'), today = new Date().toISOString().slice(0, 10); + const summary = `${records[0].project} — ${meta.issueType || 'Mobile Release'} ${(prod.buildName || records[0].buildName || '')}${bn ? ` (build ${bn})` : ''}`; + const has = (k) => !!(meta.fieldMap && meta.fieldMap[k]); + const fld = (id, label, val, type) => `
`; + form.innerHTML = `
Create ${esc(meta.issueType || 'Mobile Release')} in ${esc(meta.projectKey)}
+
+
${fld('jf-devVersion', 'Dev Version', dev && dev.version)}${fld('jf-demoVersion', 'Demo Version', demo && demo.version)}${fld('jf-prodVersion', 'Prod Version', prod && prod.version)}
+
${fld('jf-buildNumber', 'Build Number', bn)}${fld('jf-releaseDate', 'Release Date', today, 'date')}
${fld('jf-storeUrl', 'Store / install URL', store)} +
+
+
+
`; + el('#jf-cancel').onclick = () => form.classList.add('hidden'); + el('#jf-create').onclick = async () => { + const gv = (id) => { const e = el('#' + id); return e && !e.disabled ? e.value.trim() : ''; }; + const body = { path: records[0].path, summary: el('#jf-summary').value.trim(), description: el('#jf-desc').value, assignMe: el('#jf-assign').checked, fields: { devVersion: gv('jf-devVersion'), demoVersion: gv('jf-demoVersion'), prodVersion: gv('jf-prodVersion'), buildNumber: gv('jf-buildNumber'), storeUrl: gv('jf-storeUrl'), releaseNotes: gv('jf-releaseNotes'), releaseDate: gv('jf-releaseDate') }, linkKeys: [...document.querySelectorAll('.jref:checked')].map((x) => x.dataset.k), filename: fname }; + if (el('#jf-img').checked) { try { body.pngBase64 = canvas.toDataURL('image/png'); } catch {} } + const box = el('#jlog'); box.classList.remove('hidden'); box.innerHTML = ''; const btn = el('#jf-create'); btn.disabled = true; btn.textContent = 'Creating…'; + await _sseInto('/api/jira/create-release', body, box, (d) => { jref.cur = { key: d.key, url: d.url }; el('#shtext').value = shareText(records, 'team', jref.cur); form.classList.add('hidden'); toast('Jira release ' + d.key + ' created', 'ok'); }); + btn.disabled = false; btn.textContent = 'Create release'; + }; +} diff --git a/build-helper/public/js/share-card.js b/build-helper/public/js/share-card.js new file mode 100644 index 0000000..ec43047 --- /dev/null +++ b/build-helper/public/js/share-card.js @@ -0,0 +1,91 @@ +// Build Helper frontend — the branded build card drawn on (source-verbatim). Per-env panels +// with platform logo badges, filename chips, status pills, and same-origin QR codes. Plus offerShare +// (a Share button under the log) and postTeamCards (silent team-log upload). +'use strict'; + +const CARD = { bgTop: '#0f1626', bgBot: '#0b0f19', panel: '#131a2a', panelInner: '#0d1420', edge: '#1f2a44', ink: '#e7eaf1', ink2: '#9aa4b8', muted: '#8792ab', mono: '#cfe3ff', good: '#7dd3a8', bad: '#f87171', env: { dev: '#3987e5', demo: '#9085e9', qa: '#fab219', prod: '#199e70' }, envFallback: '#64748b' }; + +function offerShare(records) { + const box = el('#log'); if (!box) return; + const bar = $(`
`); + bar.querySelector('button').onclick = () => showShareCard(records); + box.appendChild(bar); box.scrollTop = box.scrollHeight; +} +const primaryLink = (r) => (r.testflight && r.testflight.tfLink) || r.onedriveUrl || (r.artifacts || []).map((a) => a.onedriveUrl).find(Boolean) || ''; +const qrUrl = (r) => (r.artifacts || []).map((a) => a.onedriveUrl).find(Boolean) || r.onedriveUrl || ''; +const artOf = (name) => { const m = /\.(apk|aab|ipa)$/i.exec(name || ''); return m ? m[1].toLowerCase() : ''; }; +const truncText = (ctx, s, maxW) => { s = String(s); if (ctx.measureText(s).width <= maxW) return s; while (s.length > 1 && ctx.measureText(s + '…').width > maxW) s = s.slice(0, -1); return s + '…'; }; +function wrapText(ctx, text, maxW) { const out = []; for (const para of String(text).split('\n')) { let line = ''; for (const w of para.split(' ')) { const test = line ? line + ' ' + w : w; if (ctx.measureText(test).width > maxW && line) { out.push(line); line = w; } else line = test; } out.push(line); } return out; } + +function envPanels(r) { + const arts = (r.artifacts || []).map((a) => typeof a === 'string' ? { art: artOf(a), name: a, onedriveUrl: null } : a); + const android = arts.find((a) => a.art === 'apk') || arts.find((a) => a.art === 'aab'), ios = arts.find((a) => a.art === 'ipa'), tag = (a) => ((a && a.art) || '').toUpperCase(); + const out = []; + if (android) out.push({ kind: 'android', logo: 'android', label: 'ANDROID', col: '#3ddc84', tag: tag(android), name: android.name, link: android.onedriveUrl || r.onedriveUrl || '', hint: 'scan to install' }); + if (ios) out.push({ kind: 'ios', logo: 'apple', label: 'iOS · TESTFLIGHT', col: '#3987e5', tag: 'IPA', name: ios.name, link: (r.testflight && r.testflight.tfLink) || '', hint: 'scan to open in TestFlight', state: r.testflight && !r.testflight.error ? (r.testflight.state || 'processing') : (r.testflight ? 'failed' : '') }); + if (!out.length && arts[0]) out.push({ kind: 'file', logo: null, label: 'ARTIFACT', col: '#64748b', tag: tag(arts[0]), name: arts[0].name, link: arts[0].onedriveUrl || r.onedriveUrl || '', hint: 'scan to open' }); + return out; +} +const LOGO_SVG = { + apple: 'data:image/svg+xml;utf8,' + encodeURIComponent(''), + android: 'data:image/svg+xml;utf8,' + encodeURIComponent(''), +}; +const _logoCache = {}; +async function loadLogo(kind) { if (!kind) return null; if (kind in _logoCache) return _logoCache[kind]; try { _logoCache[kind] = await loadImg(LOGO_SVG[kind]); } catch { _logoCache[kind] = null; } return _logoCache[kind]; } +const loadImg = (src) => new Promise((res, rej) => { const im = new Image(); im.crossOrigin = 'anonymous'; im.onload = () => res(im); im.onerror = rej; im.src = src; }); +const roundRect = (ctx, x, y, w, h, r) => { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); }; +const roundImg = (ctx, img, x, y, s, r) => { ctx.save(); roundRect(ctx, x, y, s, s, r); ctx.clip(); ctx.drawImage(img, x, y, s, s); ctx.restore(); }; + +async function drawShareCard(records) { + const W = 1080, pad = 48, IN = 24, GAP = 20, QR = 110, HEADH = 150, HH = 78; + const c = document.createElement('canvas'), ctx = c.getContext('2d'), per = records.map((r) => envPanels(r)); + const qrs = await Promise.all(per.map((panels) => Promise.all(panels.map(async (p) => { if (!p.link) return null; try { return await loadImg('/api/qr?size=300&data=' + encodeURIComponent(p.link)); } catch { return null; } })))); + const logos = await Promise.all(per.map((panels) => Promise.all(panels.map((p) => loadLogo(p.logo))))); + const panelH = (p, hasQR) => { const ch = 16 + 36 + 38 + 12 + (p.state ? 34 : 0) + (p.link ? 26 : 0), qh = hasQR ? 16 + QR + 4 + 14 + 16 : 0; return Math.max(ch, qh, 96); }; + const blockH = per.map((panels, i) => { const hs = panels.map((p, j) => panelH(p, !!qrs[i][j])); return HH + (hs.length ? Math.max(...hs) : 96) + 20; }); + const H = HEADH + blockH.reduce((s, h) => s + h + 18, 0) + 56; + c.width = W; c.height = H; + const g = ctx.createLinearGradient(0, 0, W, H); g.addColorStop(0, CARD.bgTop); g.addColorStop(1, CARD.bgBot); ctx.fillStyle = g; ctx.fillRect(0, 0, W, H); + const app = records[0].project; + try { const img = await loadImg('/api/icon?path=' + encodeURIComponent(records[0].path)); roundImg(ctx, img, pad, 40, 64, 16); } catch { ctx.fillStyle = CARD.edge; roundRect(ctx, pad, 40, 64, 64, 16); ctx.fill(); ctx.fillStyle = '#fff'; ctx.font = '700 26px system-ui'; ctx.fillText(app.slice(0, 2).toUpperCase(), pad + 16, 80); } + ctx.fillStyle = CARD.ink; ctx.font = '700 34px system-ui'; ctx.fillText(app, pad + 84, 72); + ctx.fillStyle = CARD.muted; ctx.font = '400 18px system-ui'; ctx.fillText('Build summary · ' + new Date(records[0].time).toLocaleString(), pad + 84, 100); + ctx.strokeStyle = CARD.edge; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(pad, 128); ctx.lineTo(W - pad, 128); ctx.stroke(); + let y = HEADH; + records.forEach((r, i) => { + const panels = per[i], h = blockH[i], col = CARD.env[r.env] || CARD.envFallback; + roundRect(ctx, pad, y, W - 2 * pad, h, 18); ctx.fillStyle = CARD.panel; ctx.fill(); ctx.strokeStyle = CARD.edge; ctx.lineWidth = 1; ctx.stroke(); + ctx.save(); roundRect(ctx, pad, y, W - 2 * pad, h, 18); ctx.clip(); ctx.fillStyle = col; ctx.fillRect(pad, y, 5, h); ctx.restore(); + ctx.font = '700 16px system-ui'; const et = r.env.toUpperCase(), ew = ctx.measureText(et).width + 28; + ctx.fillStyle = col + '26'; roundRect(ctx, pad + IN, y + 20, ew, 32, 16); ctx.fill(); ctx.fillStyle = col; ctx.fillText(et, pad + IN + 14, y + 41); + ctx.fillStyle = CARD.ink; ctx.font = '800 30px system-ui'; ctx.fillText(r.version, pad + IN + ew + 16, y + 46); + ctx.font = '600 15px system-ui'; const br = '⎇ ' + (r.branch || '—'), bw = ctx.measureText(br).width + 24; + ctx.fillStyle = CARD.edge; roundRect(ctx, W - pad - IN - bw, y + 22, bw, 28, 14); ctx.fill(); ctx.fillStyle = CARD.ink2; ctx.fillText(br, W - pad - IN - bw + 12, y + 41); + const area = W - 2 * pad - 2 * IN, n = panels.length || 1, pw = n > 1 ? (area - GAP) / 2 : area; + const hs = panels.map((p, j) => panelH(p, !!qrs[i][j])), ph = hs.length ? Math.max(...hs) : 96; + panels.forEach((p, j) => { + const px = pad + IN + j * (pw + GAP), py = y + HH, q = qrs[i][j]; + roundRect(ctx, px, py, pw, ph, 14); ctx.fillStyle = CARD.panelInner; ctx.fill(); ctx.strokeStyle = p.col + '55'; ctx.lineWidth = 1.5; ctx.stroke(); + const textR = px + pw - 18 - (q ? QR + 18 : 0); let ty = py + 16, lx = px + 18; const lg = logos[i][j]; + if (lg) { const S = 22; ctx.fillStyle = p.col + '22'; roundRect(ctx, lx - 6, ty, S + 12, S + 12, 8); ctx.fill(); ctx.drawImage(lg, lx, ty + 5, S, S); lx += S + 14; } + ctx.fillStyle = p.col; ctx.font = '700 16px system-ui'; ctx.textBaseline = 'middle'; ctx.fillText(p.label, lx, ty + 16); ctx.textBaseline = 'alphabetic'; ty += 36; + const chipX = px + 18, cp = 8, tag = p.tag || ''; ctx.font = '700 12px system-ui'; const tagW = tag ? ctx.measureText(tag).width + 16 : 0, nameX = chipX + cp + (tag ? tagW + 8 : 0); + ctx.font = '500 15px ui-monospace, Menlo, monospace'; const nm = truncText(ctx, p.name, textR - nameX - cp), chipW = Math.min((tag ? tagW + 8 : 0) + ctx.measureText(nm).width + cp * 2, textR - chipX); + ctx.fillStyle = '#ffffff0d'; roundRect(ctx, chipX, ty, chipW, 30, 8); ctx.fill(); + if (tag) { ctx.fillStyle = p.col + '26'; roundRect(ctx, chipX + cp, ty + 6, tagW, 18, 6); ctx.fill(); ctx.fillStyle = p.col; ctx.font = '700 12px system-ui'; ctx.textBaseline = 'middle'; ctx.fillText(tag, chipX + cp + 8, ty + 16); ctx.textBaseline = 'alphabetic'; } + ctx.fillStyle = CARD.mono; ctx.font = '500 15px ui-monospace, Menlo, monospace'; ctx.textBaseline = 'middle'; ctx.fillText(nm, nameX, ty + 16); ctx.textBaseline = 'alphabetic'; ty += 38; + if (p.state) { const sc = p.state === 'failed' ? CARD.bad : CARD.good; ctx.font = '600 14px system-ui'; const sw = ctx.measureText(p.state).width + 34; ctx.fillStyle = sc + '22'; roundRect(ctx, px + 18, ty, sw, 26, 13); ctx.fill(); ctx.fillStyle = sc; ctx.beginPath(); ctx.arc(px + 18 + 16, ty + 13, 4, 0, 7); ctx.fill(); ctx.textBaseline = 'middle'; ctx.fillText(p.state, px + 18 + 26, ty + 13); ctx.textBaseline = 'alphabetic'; ty += 34; } + if (p.link) { ctx.fillStyle = CARD.muted; ctx.font = '400 13px system-ui'; ctx.fillText(truncText(ctx, p.link, textR - (px + 18)), px + 18, ty + 13); } + if (q) { const qx = px + pw - 18 - QR, qy = py + (ph - QR - 18) / 2; ctx.fillStyle = '#fff'; roundRect(ctx, qx - 8, qy - 8, QR + 16, QR + 16, 10); ctx.fill(); ctx.drawImage(q, qx, qy, QR, QR); ctx.fillStyle = CARD.muted; ctx.font = '400 12px system-ui'; ctx.textAlign = 'center'; ctx.fillText(p.hint, qx + QR / 2, qy + QR + 22); ctx.textAlign = 'left'; } + }); + y += h + 18; + }); + ctx.strokeStyle = CARD.edge; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(pad, H - 44); ctx.lineTo(W - pad, H - 44); ctx.stroke(); + ctx.fillStyle = CARD.muted; ctx.font = '400 16px system-ui'; ctx.fillText('Generated with Batta Build Helper', pad, H - 18); + return c; +} + +// Auto-upload each env's card to the team log after a build (best-effort, silent). +async function postTeamCards(records) { + for (const r of records) { if (r.buildOk === false) continue; try { const canvas = await drawShareCard([r]); postJson('/api/share/card', { project: r.repo || r.project, filename: `${(r.repo || r.project)}-${(r.version || '').replace(/[^\w.+-]/g, '')}.png`, pngBase64: canvas.toDataURL('image/png') }).catch(() => {}); } catch {} } +} diff --git a/build-helper/public/js/share.js b/build-helper/public/js/share.js new file mode 100644 index 0000000..ecca1ec --- /dev/null +++ b/build-helper/public/js/share.js @@ -0,0 +1,68 @@ +// Build Helper frontend — the Share modal (source design): the canvas build card + copy/download, +// team/client text, email-to-groups and Jira-release (in share-actions.js), and per-target re-upload. +'use strict'; + +function shareText(records, mode = 'team', jira) { + const app = records[0].project; + if (mode === 'client') { + let t = `🚀 ${app} — new release\n\n`; + for (const r of records) { const link = primaryLink(r); t += `• ${r.env.toUpperCase()} ${r.version}${link ? `\n ${link}` : ''}\n`; } + if (jira && jira.url) t += `\n📋 ${jira.url}`; + return t.trim(); + } + let t = `🚀 ${app} — new build${records.length > 1 ? 's' : ''}\n\n`; + for (const r of records) { + const art = (r.artifacts || []).map((a) => a.name || a).join(', '), link = r.onedriveUrl || (r.artifacts || []).map((a) => a.onedriveUrl).find(Boolean); + t += `• ${r.env.toUpperCase()} ${r.version} — branch: ${r.branch || '—'}\n`; + if (r.whatsNew || r.notes) t += ` What's new: ${(r.whatsNew || r.notes).replace(/\n/g, '; ')}\n`; + if (art) t += ` 📦 ${art}\n`; if (link) t += ` 🔗 ${link}\n`; + if (r.testflight && !r.testflight.error) { t += ` ✈️ TestFlight: ${r.testflight.state || 'processing'}\n`; if (r.testflight.tfLink) t += ` 🧪 Install via TestFlight: ${r.testflight.tfLink}\n`; } + t += '\n'; + } + if (jira && jira.url) t += `📋 Jira release: ${jira.key || ''} ${jira.url}\n\n`; + t += `Built ${new Date(records[0].time).toLocaleDateString()}${records[0].by ? ' by ' + records[0].by : ''} · Batta Build Helper`; + return t.trim(); +} +function composeEmailBody(records, opts, jira) { + opts = opts || {}; let t = `🚀 ${records[0].project} — new release\n\n`; + for (const r of records) { t += `• ${(r.env || '').toUpperCase()} ${r.version || ''}\n`; if (opts.whatsNew) { const wn = (r.whatsNew || r.notes || '').trim(); if (wn) t += ` What's new: ${wn.replace(/\n/g, '; ')}\n`; } if (r.testflight && r.testflight.tfLink) t += ` 📲 Install (TestFlight): ${r.testflight.tfLink}\n`; const od = qrUrl(r); if (od) t += ` ⬇️ Download: ${od}\n`; t += '\n'; } + if (opts.jira && jira && jira.url) t += `📋 Jira release: ${jira.key || ''} ${jira.url}\n\n`; + return t.trim(); +} + +async function showShareCard(records) { + const modal = el('#cmdk'); modal.classList.remove('hidden'); modal.classList.add('flex'); + const upBtns = [['testflight', '✈️ TestFlight'], ['play', '▶ Play'], ['onedrive', '☁ OneDrive'], ['firebase', '🔥 Firebase']].map(([t, l]) => records.some((r) => artName(r, t)) ? `` : '').join(''); + modal.innerHTML = `
+
Share build
+
rendering…
+ +
Card: + + + +
+
Email to client groups
+
Jira release
+
Upload build →${upBtns}
`; + modal.onclick = () => { modal.classList.add('hidden'); modal.classList.remove('flex'); modal.innerHTML = ''; }; + el('#shclose').onclick = modal.onclick; + el('#shtext').value = shareText(records); + const canvas = await drawShareCard(records); + el('#shimg').innerHTML = ''; canvas.style.width = '100%'; canvas.style.display = 'block'; el('#shimg').appendChild(canvas); + const fname = `${records[0].project}-${(records[0].version || '').replace(/[^\w.+-]/g, '')}-card.png`; + const jref = { cur: null }; + el('#shdl').onclick = () => canvas.toBlob((b) => { const u = URL.createObjectURL(b); const a = document.createElement('a'); a.href = u; a.download = fname; a.click(); URL.revokeObjectURL(u); }); + el('#shcopyimg').onclick = () => canvas.toBlob(async (b) => { try { await navigator.clipboard.write([new ClipboardItem({ 'image/png': b })]); toast('Image copied', 'ok'); } catch { toast('Copy image not supported — use Download', 'warn'); } }); + el('#shcopytxt').onclick = () => { navigator.clipboard.writeText(el('#shtext').value); toast('Team text copied', 'ok'); }; + el('#shcopyclient').onclick = () => { navigator.clipboard.writeText(shareText(records, 'client', jref.cur)); toast('Client text copied', 'ok'); }; + el('#emailbtn').onclick = () => shareEmailForm(records, canvas, jref); + const referred = [...new Map(records.flatMap((r) => (r.tasks || []).filter((x) => x.tracker === 'jira')).map((x) => [x.key, x])).values()]; + el('#jreferred').innerHTML = (referred.length ? '
Referred issues to link:
' : '
No Jira issues referenced in commits — you can search when creating.
') + referred.map((i) => ``).join(''); + el('#jcreate').onclick = () => shareJiraForm(records, canvas, jref, fname); + modal.querySelectorAll('.shup').forEach((btn) => btn.onclick = async () => { + const t = btn.dataset.t, label = btn.textContent.trim(), box = el('#shlog'); box.classList.remove('hidden'); box.innerHTML = ''; + for (const r of records) { const name = artName(r, t); const sub = document.createElement('div'); sub.className = 'mt-1 border-t border-edge pt-1'; sub.innerHTML = `
→ ${esc(label)} · ${esc(r.env.toUpperCase())} ${esc(r.version)}${name ? '' : ' — no artifact on disk (rebuild)'}
`; box.appendChild(sub); if (!name) continue; const line = document.createElement('div'); sub.appendChild(line); await new Promise((res) => streamSSEInto('/api/retry-upload', { path: r.path, artifactName: name, target: t, track: r.env === 'prod' ? 'production' : 'internal', notes: r.whatsNew }, line, res)); } + toast(label + ' — done, see log', 'ok'); + }); +} diff --git a/build-helper/public/js/state.js b/build-helper/public/js/state.js index 8138dfb..70555c8 100644 --- a/build-helper/public/js/state.js +++ b/build-helper/public/js/state.js @@ -26,6 +26,9 @@ window.ENV_COLOR = { dev: 'sky', demo: 'violet', qa: 'amber', prod: 'emerald' }; // Views register themselves here so app.js can dispatch by name. window.V = {}; +// Build-page runtime globals (source names): current project payload + the running-build tracker. +var CUR = null, BUILDLOG = null, BUILDING = false, LOG = null; + window.verName = (v) => String(v || '').split('+')[0] || '1.0.0'; window.fmtDur = (ms) => (ms ? Math.round(ms / 1000) + 's' : ''); window.envColor = (k) => window.ENV_COLOR[k] || 'slate'; diff --git a/build-helper/public/js/views.js b/build-helper/public/js/views.js index a69e87c..7dc20a3 100644 --- a/build-helper/public/js/views.js +++ b/build-helper/public/js/views.js @@ -1,67 +1,11 @@ -// Build Helper frontend — Dashboard + Reports views. Each attaches to window.V and renders into the -// persistent #bhbody that Vue owns (see app.js). Imperative render-then-wire, matching the sibling -// tools' pattern; keeps every view file small and independent. +// Build Helper frontend — shared view helpers (_body/_skel, used by every view) + the Reports view. +// The Dashboard lives in dashboard.js, Build in project.js, Setup in setup.js — each attaches to +// window.V and renders into the persistent #bhbody that Vue owns (see app.js). 'use strict'; const _body = () => el('#bhbody'); const _skel = (n = 4) => `
${Array.from({ length: n }, () => '
').join('')}
`; -window.V.dashboard = async function () { - BH.view = 'dashboard'; if (window.setShell) setShell('dashboard'); - _body().innerHTML = _skel(6); - const [d, suggest] = await Promise.all([ - API.dashboard().catch(() => ({ projects: [], builds: [], storage: {}, sources: {} })), - API.suggestRoots(), - ]); - BH.projects = d.projects || []; BH.builds = d.builds || []; BH.storage = d.storage || {}; BH.running = d.running || {}; - BH.sources = d.sources || { roots: [], pinned: [], recursive: false }; - _body().innerHTML = ` - ${C.kpis(d)} - ${C.sourcesPanel(BH, suggest)} -
-
Projects
- - - -
-
`; - wireSources(); - renderGrid(''); - el('#bh-search').oninput = (e) => renderGrid(e.target.value); - el('#bh-refresh').onclick = () => V.dashboard(); -}; - -// Filter the project grid client-side by name/path; (re)wire card open + reveal after each render. -function renderGrid(q) { - const s = (q || '').trim().toLowerCase(); - const list = s ? BH.projects.filter((p) => (p.name + ' ' + p.path).toLowerCase().includes(s)) : BH.projects; - el('#bh-count').textContent = s ? `${list.length} of ${BH.projects.length}` : `${BH.projects.length} app${BH.projects.length === 1 ? '' : 's'}`; - el('#bh-grid').innerHTML = list.length - ? `
${list.map(C.projectCard).join('')}
` - : `
${BH.projects.length ? 'No projects match your search.' : 'No Flutter apps found. Add a scan root or pin a project in Project sources above.'}
`; - el('#bh-grid').querySelectorAll('[data-open]').forEach((b) => b.onclick = () => V.project(b.dataset.open)); - el('#bh-grid').querySelectorAll('[data-reveal]').forEach((b) => b.onclick = (e) => { e.stopPropagation(); API.reveal(b.dataset.reveal); }); -} - -// Wire the sources panel: add/remove roots & pinned, recursive toggle, suggestions, reveal. -function wireSources() { - const save = async () => { - const r = await API.saveSources(BH.sources).catch(() => null); - if (!r) return toast('Could not save sources', 'err'); - BH.sources = r.sources; (r.invalid || []).forEach((i) => toast(`Skipped ${i.path} — ${i.reason}`, 'err')); - V.dashboard(); - }; - const add = (inputId, key) => { const v = el('#' + inputId).value.trim(); if (v) { BH.sources[key] = [...BH.sources[key], v]; save(); } }; - el('#bh-addroot').onclick = () => add('bh-add-root', 'roots'); - el('#bh-add-root').onkeydown = (e) => { if (e.key === 'Enter') add('bh-add-root', 'roots'); }; - el('#bh-addpin').onclick = () => add('bh-add-pin', 'pinned'); - el('#bh-add-pin').onkeydown = (e) => { if (e.key === 'Enter') add('bh-add-pin', 'pinned'); }; - el('#bh-recursive').onchange = (e) => { BH.sources.recursive = e.target.checked; save(); }; - _body().querySelectorAll('[data-rm]').forEach((b) => b.onclick = () => { const key = b.dataset.kind === 'pinned' ? 'pinned' : 'roots'; BH.sources[key] = BH.sources[key].filter((x) => x !== b.dataset.rm); save(); }); - _body().querySelectorAll('[data-suggest]').forEach((b) => b.onclick = () => { BH.sources.roots = [...new Set([...BH.sources.roots, b.dataset.suggest])]; save(); }); - _body().querySelectorAll('[data-reveal]').forEach((b) => b.onclick = (e) => { e.stopPropagation(); API.reveal(b.dataset.reveal); }); -} - window.V.reports = async function () { BH.view = 'reports'; if (window.setShell) setShell('reports'); const b = BH.builds.length ? BH.builds : (await API.dashboard().catch(() => ({ builds: [] }))).builds || []; diff --git a/build-helper/server.js b/build-helper/server.js index 5a8689a..8967dd0 100644 --- a/build-helper/server.js +++ b/build-helper/server.js @@ -26,7 +26,7 @@ kit.connectors.registerRoutes(app); // /api/connectors* (Jira/GitHub/… issue registerRoutes(app); // apps · build · signing · setup · distribute · pages if (process.argv.includes('--selftest')) selftest(); -else app.start(); +else { app.start(); require('./lib/schedule').startScheduler(); } // Self-check for CI: re-register the whole route graph onto a throwaway (never-listening) app to // count routes, then exercise the detection pipeline on a synthetic Flutter project. No network, @@ -35,8 +35,13 @@ function selftest() { try { const fs = require('fs'); const os = require('os'); const probe = createKitServer({ id: 'bh-selftest', name: 'x', defaultPort: 0, repoDir: __dirname, publicDir: '', dataDir: dataDir(tool.id), manifest: {} }); - let n = 0; const orig = probe.r; probe.r = (...a) => { n++; return orig.apply(probe, a); }; + let n = 0; const seen = new Set(); const orig = probe.r; probe.r = (...a) => { n++; seen.add(a[1]); return orig.apply(probe, a); }; kit.team.register(probe); kit.connectors.registerRoutes(probe); registerRoutes(probe); + const assert = require('assert'); + for (const p of ['/api/testflight/manage', '/api/testflight/latest', '/api/qr', '/api/tracker/tasks', '/api/jira/search', '/api/doctor/full', + '/api/security/grades', '/api/version', '/api/changelog/tool', '/api/self-update', '/api/setup/onedrive-shared']) + assert(seen.has(p), `route not registered: ${p}`); + const sch = require('./lib/schedule'); assert(typeof sch.startScheduler === 'function' && typeof sch.checkSchedules === 'function', 'schedule module missing'); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bh-')); fs.writeFileSync(path.join(dir, 'pubspec.yaml'), 'name: sample\nversion: 1.2.3+4\n');