From 7432d8d5bcfbbc0d4d79944ab3e6bd625a9432f7 Mon Sep 17 00:00:00 2001 From: Hassan Al-Najjar Date: Sat, 12 Sep 2026 16:33:10 +0300 Subject: [PATCH 1/2] Build Helper: full feature parity with the legacy tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port every JigsawTools build-helper feature into the platform-kit architecture (server → routes → lib, ≤150-line files, barrels, zero deps, no-auth). Excludes auth/enterprise pieces (login/pair/license, company vault, agent auto-update) per the no-auth + local-only invariants. Backend - routes/testflight.js: /api/testflight/manage + /latest - lib/qr.js + /api/qr: same-origin QR proxy for the share canvas - /api/tracker/tasks + /api/jira/search (Jira picker) - /api/doctor/full: live `flutter doctor -v` - /api/security/grades: best-effort grades from mobile-security - lib/schedule.js: 1-min scheduler for unattended per-app builds - lib/selfupdate.js + routes/tool.js: /api/version, /changelog/tool, /self-update - onedrive.js: shared team-folder mode (/api/setup/onedrive-shared) Frontend (14 new ≤150-line modules) - charts, health-table, dashboard: SVG charts + filters + sortable/ draggable health table + security grade + ⌘K + guided tour - build-form, notes, build-run: env pipeline, M/m/p bump, per-env outputs matrix + track, release-notes fill + Jira picker, branch, Advanced menu, nav-surviving live log, inline guard prompts - history, detail: bulk re-upload, build detail (re-upload/TestFlight/rollback) - share, share-actions: canvas build card + QR, copy/email/Jira release - app-setup(+more): per-app modal (custom envs, accounts, build defaults, scheduled builds, trackers, groups) - self-update nav button Docs + README updated. check-lines clean; --selftest asserts all new routes. Co-Authored-By: Claude Opus 4.8 (1M context) --- build-helper/README.md | 53 +++++++++---- build-helper/lib/dashboard/doctor.js | 16 +++- build-helper/lib/onedrive.js | 25 +++++- build-helper/lib/qr.js | 19 +++++ build-helper/lib/routes/apps.js | 12 +++ build-helper/lib/routes/dist.js | 5 +- build-helper/lib/routes/index.js | 2 + build-helper/lib/routes/pages.js | 5 +- build-helper/lib/routes/setup.js | 12 ++- build-helper/lib/routes/testflight.js | 37 +++++++++ build-helper/lib/routes/tool.js | 13 ++++ build-helper/lib/schedule.js | 47 ++++++++++++ build-helper/lib/selfupdate.js | 35 +++++++++ build-helper/lib/trackers.js | 8 +- build-helper/public/index.html | 13 ++++ build-helper/public/js/api.js | 9 +++ build-helper/public/js/app-setup-more.js | 83 ++++++++++++++++++++ build-helper/public/js/app-setup.js | 95 +++++++++++++++++++++++ build-helper/public/js/app.js | 34 ++++++++- build-helper/public/js/build-form.js | 75 ++++++++++++++++++ build-helper/public/js/build-run.js | 84 +++++++++++++++++++++ build-helper/public/js/charts.js | 73 ++++++++++++++++++ build-helper/public/js/cmdk.js | 72 ++++++++++++++++++ build-helper/public/js/components.js | 30 -------- build-helper/public/js/dashboard.js | 96 ++++++++++++++++++++++++ build-helper/public/js/detail.js | 43 +++++++++++ build-helper/public/js/health-table.js | 67 +++++++++++++++++ build-helper/public/js/history.js | 41 ++++++++++ build-helper/public/js/notes.js | 58 ++++++++++++++ build-helper/public/js/project.js | 82 +++++++------------- build-helper/public/js/setup.js | 11 ++- build-helper/public/js/share-actions.js | 41 ++++++++++ build-helper/public/js/share.js | 62 +++++++++++++++ build-helper/public/js/views.js | 62 +-------------- build-helper/server.js | 9 ++- 35 files changed, 1258 insertions(+), 171 deletions(-) create mode 100644 build-helper/lib/qr.js create mode 100644 build-helper/lib/routes/testflight.js create mode 100644 build-helper/lib/routes/tool.js create mode 100644 build-helper/lib/schedule.js create mode 100644 build-helper/lib/selfupdate.js create mode 100644 build-helper/public/js/app-setup-more.js create mode 100644 build-helper/public/js/app-setup.js create mode 100644 build-helper/public/js/build-form.js create mode 100644 build-helper/public/js/build-run.js create mode 100644 build-helper/public/js/charts.js create mode 100644 build-helper/public/js/cmdk.js create mode 100644 build-helper/public/js/dashboard.js create mode 100644 build-helper/public/js/detail.js create mode 100644 build-helper/public/js/health-table.js create mode 100644 build-helper/public/js/history.js create mode 100644 build-helper/public/js/notes.js create mode 100644 build-helper/public/js/share-actions.js create mode 100644 build-helper/public/js/share.js 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/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..2fa5c60 100644 --- a/build-helper/lib/routes/dist.js +++ b/build-helper/lib/routes/dist.js @@ -4,7 +4,7 @@ // cleanup, CSV / printable build reports, and Jira release / issue creation. const { handleRollback, handleRetryUpload, handleShareImage, handleMkdirs, handleTeamCard, cleanupArtifacts, reportRows, reportCsv, reportHtml } = require('../dashboard'); -const { handleCreateRelease, handleCreateIssue } = require('../trackers'); +const { handleCreateRelease, handleCreateIssue, handleTasks, jiraSearchFor } = require('../trackers'); function register(app) { const J = app.sendJson; @@ -16,6 +16,9 @@ function register(app) { app.r('POST', '/api/cleanup', ({ res, body }) => J(res, 200, cleanupArtifacts(body)), { 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'))); // 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..5825311 100644 --- a/build-helper/lib/routes/pages.js +++ b/build-helper/lib/routes/pages.js @@ -4,6 +4,7 @@ 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'); @@ -16,7 +17,9 @@ 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('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['*'] || [] }); }); 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..562a602 --- /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, running } = require('./state'); +const { detectApp } = require('./project'); +const { handleBuild } = require('./build'); + +const _fired = new Set(); // "path|env|time|YYYY-MM-DDTHH:MM" — de-dupe within a minute + +function runScheduled(projectPath, sched) { + const app = detectApp(projectPath); + const buildName = (app.version || '1.0.0').split('+')[0] || '1.0.0'; + const body = { + path: projectPath, artifacts: sched.artifacts || [], + envs: [{ env: sched.env, buildName, buildNumber: '1', autoNum: true, dest: { onedrive: [], firebase: [], play: [], testflight: [] } }], + 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 (${(sched.artifacts || []).join(',')})`); + handleBuild({ on() {} }, res, body).catch((e) => console.error('[schedule] build error:', e.message)); +} + +// Fire the first schedule matching `now`. Skips while a build is running (next tick retries). +function checkSchedules(now) { + now = now || new Date(); + if (running.busy) return; + const day = now.getDay(); + const hhmm = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`; + const stamp = `${now.toISOString().slice(0, 10)}T${hhmm}`; + const apps = readConfig().apps || {}; + for (const [p, cfg] of Object.entries(apps)) { + for (const s of (cfg.schedules || [])) { + if (!s.env || !(s.artifacts || []).length || !(s.days || []).includes(day) || s.time !== hhmm) continue; + const key = `${p}|${s.env}|${s.time}|${stamp}`; + if (_fired.has(key)) continue; + _fired.add(key); if (_fired.size > 300) _fired.delete(_fired.values().next().value); + 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..6e059e4 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,10 @@ 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)); } + // ---------- 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 +86,5 @@ async function handleCreateIssue(res, body) { module.exports = { jiraBase, TRACKERS, trackerById, resolveTracker, configuredTrackers, collectTasks, - jiraMeta, handleCreateRelease, handleCreateIssue, readStore, saveConnector, + jiraMeta, handleCreateRelease, handleCreateIssue, handleTasks, jiraSearchFor, readStore, saveConnector, }; diff --git a/build-helper/public/index.html b/build-helper/public/index.html index 30c837e..1296230 100644 --- a/build-helper/public/index.html +++ b/build-helper/public/index.html @@ -39,9 +39,22 @@ + + + + + + + + + + + + + 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..5893c29 --- /dev/null +++ b/build-helper/public/js/app-setup-more.js @@ -0,0 +1,83 @@ +// Build Helper frontend — per-app Setup, remaining tabs (registered into the AS registry from +// app-setup.js): Build defaults, Scheduled builds, per-app Trackers, and client email Groups. +'use strict'; + +const _lines = (s) => String(s || '').split('\n').map((x) => x.trim()).filter(Boolean); +const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + +AS.reg('build', 'Build', { + render(d) { + const s = d.settings || {}; + return `
+ + + + +
`; + }, + wire(d) { el('#bs-save').onclick = () => AS.save(d.app.path, { settings: { flavor: el('#bs-flavor').value.trim(), buildArgs: el('#bs-args').value.trim(), preBuild: _lines(el('#bs-pre').value), postBuild: _lines(el('#bs-post').value) } }, 'Build defaults saved'); }, +}); + +AS.reg('schedule', 'Schedule', { + render(d) { + const rows = (d.settings?.schedules || []).map((s) => AS.schedRow(d, s)).join(''); + return `
Unattended builds run by the scheduler (checks every minute). Times are the server's local time.
+
${rows || '
No schedules.
'}
+ +
`; + }, + wire(d, body) { + const rowsBox = el('#sc-rows'); + el('#sc-add').onclick = () => { if (rowsBox.querySelector('.text-slate-500')) rowsBox.innerHTML = ''; rowsBox.insertAdjacentHTML('beforeend', AS.schedRow(d, {})); }; + body.addEventListener('click', (e) => { if (e.target.closest('.sc-rm')) e.target.closest('.sc-row').remove(); }); + el('#sc-save').onclick = () => { + const schedules = [...body.querySelectorAll('.sc-row')].map((r) => ({ + env: r.querySelector('.sc-env').value, time: r.querySelector('.sc-time').value || '09:00', + days: [...r.querySelectorAll('.sc-day:checked')].map((c) => +c.value), + artifacts: [...r.querySelectorAll('.sc-art:checked')].map((c) => c.value), + })).filter((s) => s.env && s.artifacts.length && s.days.length); + AS.save(d.app.path, { settings: { schedules } }, 'Schedules saved'); + }; + }, +}); +AS.schedRow = (d, s) => `
+ + + ${DAYS.map((n, i) => ``).join('')} + ${ARTS.map((a) => ``).join('')} +
`; + +AS.reg('trackers', 'Trackers', { + render(d) { + const defs = d.trackerDefs || [], acc = d.trackerAccounts || {}; + return `
Per-app tracker credentials override the shared connectors for this project.
+
${defs.map((t) => `
${esc(t.label)} ${d.globalTrackers?.[t.id] ? '(global set)' : ''}
+
${(t.fields || []).map((f) => ``).join('')}
`).join('')}
+
`; + }, + wire(d, body) { + el('#tk-save').onclick = () => { + const trackers = {}; + body.querySelectorAll('[data-tid]').forEach((box) => { const o = {}; box.querySelectorAll('.tk-f').forEach((i) => o[i.dataset.f] = i.value.trim()); trackers[box.dataset.tid] = o; }); + AS.save(d.app.path, { trackers }, 'Trackers saved'); + }; + }, +}); + +AS.reg('groups', 'Groups', { + render() { return '
Loading groups…
'; }, + async wire(d, body) { + const g = await API.appGroups(d.app.repo || ''); + const row = (x) => `
`; + body.innerHTML = `
Client email groups for ${esc(d.app.repo || d.app.name)} (used when emailing a build).
+
${(g.own || []).map(row).join('') || row({})}
+ +
${g.global && g.global.length ? `
Global groups (all apps): ${g.global.map((x) => esc(x.name)).join(', ')}
` : ''}
`; + el('#gp-add').onclick = () => el('#gp-rows').insertAdjacentHTML('beforeend', row({})); + body.addEventListener('click', (e) => { if (e.target.closest('.gp-rm')) e.target.closest('.gp-row').remove(); }); + el('#gp-save').onclick = () => { + const groups = [...body.querySelectorAll('.gp-row')].map((r) => ({ name: r.querySelector('.gp-name').value.trim() || 'Group', emails: r.querySelector('.gp-em').value.split(/[\s,;]+/).filter(Boolean) })).filter((x) => x.emails.length); + API.post('/api/groups/save', { repo: d.app.repo || '', groups }).then((r) => toast(r.ok ? 'Groups saved' : (r.error || 'Failed'), r.ok ? 'ok' : 'err')); + }; + }, +}); diff --git a/build-helper/public/js/app-setup.js b/build-helper/public/js/app-setup.js new file mode 100644 index 0000000..82a46f5 --- /dev/null +++ b/build-helper/public/js/app-setup.js @@ -0,0 +1,95 @@ +// Build Helper frontend — per-app Setup (window.openAppSetup). A tabbed modal registered via a small +// AS registry (this file adds Overview/Environments/Accounts; app-setup-more.js adds the rest). Each +// tab renders its own HTML and saves its slice through /api/app/save (or /api/groups/save). +'use strict'; + +window.AS = { + tabs: {}, order: [], + reg(id, label, def) { this.tabs[id] = Object.assign({ label }, def); if (!this.order.includes(id)) this.order.push(id); }, + save(path, patch, msg) { return API.post('/api/app/save', { path, ...patch }).then((r) => toast(r.ok ? (msg || 'Saved') : (r.error || 'Failed'), r.ok ? 'ok' : 'err')); }, +}; + +window.openAppSetup = window.showAppSetup = async function (path) { + const d = await API.project(path).catch(() => null); + if (!d || d.error) return toast('Could not load this app.', 'err'); + const tabs = AS.order.map((id) => ({ id, label: AS.tabs[id].label })); + const body = openModal({ title: 'App Setup', subtitle: d.app.name, size: '800px', tabs, active: AS.order[0], onTab: (id) => render(id) }); + const render = (id) => { body.innerHTML = AS.tabs[id].render(d); if (AS.tabs[id].wire) AS.tabs[id].wire(d, body); }; + render(AS.order[0]); +}; + +const _stat = (ok, label) => `
${ok ? 'ok' : 'todo'}${label}
`; + +AS.reg('overview', 'Overview', { + render(d) { + const h = d.health || {}; + return `
Release-readiness for ${esc(d.app.name)}.
+
+ ${_stat(h.envDetected, 'AppMode environment detected')}${_stat(h.signing, 'Android release signing configured')} + ${_stat(h.packageId, 'Android package id resolved')}${_stat(!!d.hasIcon, 'Launcher icon found')} + ${_stat(!!d.firebaseApp, 'Firebase App ID set (for App Distribution)')} + ${_stat(!!(d.apple || d.globalApple), 'Apple / TestFlight account')}${_stat(!!(d.play || d.globalPlay), 'Google Play account')} + ${_stat(!!d.onedriveConnected, 'OneDrive connected')} +
+ `; + }, + wire(d) { const b = el('#as-sign'); if (b) b.onclick = () => signingModal(d.app); }, +}); + +AS.reg('env', 'Environments', { + render(d) { + const a = d.app, rows = (a.envs || []).map((e, i) => AS.envRow(e, i)).join(''); + return `
+ + +
+
Environments (key · label · AppMode member · color · prod)
+
${rows}
+ +
Detected enum members: ${(a.members || []).join(', ') || '—'}
+
`; + }, + wire(d, body) { + const add = () => el('#as-envs').insertAdjacentHTML('beforeend', AS.envRow({ key: '', label: '', mode: '', color: 'slate', prod: false }, Math.random())); + el('#as-addenv').onclick = add; + body.addEventListener('click', (e) => { if (e.target.closest('.as-rmenv')) e.target.closest('.as-envrow').remove(); }); + el('#as-saveenv').onclick = () => { + const envs = [...body.querySelectorAll('.as-envrow')].map((r) => ({ key: r.querySelector('.e-key').value.trim(), label: r.querySelector('.e-label').value.trim() || r.querySelector('.e-key').value.trim().toUpperCase(), mode: r.querySelector('.e-mode').value.trim(), color: r.querySelector('.e-color').value.trim() || 'slate', prod: r.querySelector('.e-prod').checked })).filter((x) => x.key && x.mode); + AS.save(d.app.path, { override: { constName: el('#as-const').value.trim(), envFile: el('#as-envfile').value.trim(), envs } }, 'Environments saved').then(() => V.project(d.app.path)); + }; + }, +}); +AS.envRow = (e, i) => `
+ + + + + +
`; + +AS.reg('accounts', 'Accounts', { + render(d) { + return `
+
Firebase App ID
+ +
+
Apple / TestFlight ${d.globalApple ? '(global set)' : ''}
+
+ +
+
Google Play ${d.globalPlay ? '(global set)' : ''}
+ +
+
+
`; + }, + wire(d) { + const p = d.app.path; + el('#as-fbsave').onclick = () => AS.save(p, { firebaseApp: el('#as-fb').value.trim() }, 'Firebase App ID saved'); + el('#as-asave').onclick = () => AS.save(p, { apple: { keyId: el('#as-akey').value.trim(), issuerId: el('#as-aiss').value.trim(), p8: el('#as-ap8').value } }, 'Apple saved'); + el('#as-aclear').onclick = () => AS.save(p, { apple: { clear: true } }, 'Apple cleared'); + el('#as-plsave').onclick = () => AS.save(p, { play: { serviceAccountJson: el('#as-plsa').value, defaultTrack: el('#as-pltrack').value } }, 'Play saved'); + el('#as-plclear').onclick = () => AS.save(p, { play: { clear: true } }, 'Play cleared'); + if (d.play && d.play.defaultTrack) el('#as-pltrack').value = d.play.defaultTrack; + }, +}); diff --git a/build-helper/public/js/app.js b/build-helper/public/js/app.js index a452a4f..67a4548 100644 --- a/build-helper/public/js/app.js +++ b/build-helper/public/js/app.js @@ -24,18 +24,50 @@ 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 {} await V.dashboard(); diff --git a/build-helper/public/js/build-form.js b/build-helper/public/js/build-form.js new file mode 100644 index 0000000..ac71315 --- /dev/null +++ b/build-helper/public/js/build-form.js @@ -0,0 +1,75 @@ +// Build Helper frontend — the build form (window.BF): per-env cards (version + M/m/p bump, build +// number + auto, per-env output matrix + Play track, release notes + fill/Jira), the artifact chips, +// the branch selector, and the Advanced menu. collectBuild() shapes the /api/build request body. +'use strict'; + +function _bump(v, kind) { + const p = String(v || '1.0.0').split('.').map((n) => parseInt(n, 10) || 0); while (p.length < 3) p.push(0); + if (kind === 'major') { p[0]++; p[1] = 0; p[2] = 0; } else if (kind === 'minor') { p[1]++; p[2] = 0; } else p[2]++; + return p.slice(0, 3).join('.'); +} + +window.BF = { + bump: _bump, + // One environment card: selection, version+bump, build number+auto, outputs, notes. + envCard(e, nextNum, base, f) { + const out = (id, label, dis, title) => ``; + return `
+
+ + ${_envChip(e)} + +
${['major:M', 'minor:m', 'patch:p'].map((k) => ``).join('')}
+ + +
+
+ ${out('onedrive', 'OneDrive', !f.onedriveConnected, 'Connect in Setup')} + ${out('firebase', 'Firebase', !f.firebaseCli, 'Login in Setup')} + ${out('play', 'Play', !(f.globalPlay || f.play), 'Configure Play')} + + ${out('testflight', 'TestFlight', !(f.globalApple || f.apple), 'Configure Apple')} +
+
+ +
+ ${['template:Template', 'commits:Commits', 'tracker:Tasks'].map((s) => ``).join('')} + + +
+
+
`; + }, + advanced() { + const opt = (id, l) => ``; + return `
Advanced +
+ ${opt('clean', 'flutter clean')}${opt('pub', 'pub get')}${opt('analyze', 'analyze')}${opt('test', 'test')} + ${opt('warn', 'gate: warn only')}${opt('tag', 'git commit + tag')}${opt('push', 'git push')} + ${opt('validate', 'validate IPA')}${opt('symbols', 'upload crash symbols')} +
`; + }, + branchSelect(git) { + const bs = (git && git.branches) || []; const cur = (git && git.current) || ''; + if (!bs.length) return ''; + return ``; + }, + // Read the whole form into a /api/build body. `allow` merges guard-override flags on retry. + collectBuild(allow) { + const on = (id) => !!(el(id) && el(id).checked); + const arts = [..._body().querySelectorAll('.bh-art.on')].map((a) => a.dataset.art); + const validate = on('#adv-validate'), symbols = on('#adv-symbols'), tag = on('#adv-tag'), push = on('#adv-push'); + const envs = [..._body().querySelectorAll('.bh-envcard')].filter((c) => c.querySelector('.bh-env').checked).map((c) => { + const env = c.dataset.env, q = (s) => c.querySelector(s), outOn = (id) => !!c.querySelector(`.bh-out[data-out="${id}"]`)?.checked; + return { + env, buildName: q('.bh-ver').value.trim() || '1.0.0', buildNumber: q('.bh-num').value.trim() || '1', autoNum: q('.bh-auto').checked, + track: q('.bh-track').value, validate, uploadSymbols: symbols, commitTag: tag, push, whatsNew: q('.bh-notes').value.trim(), notes: q('.bh-notes').value.trim(), + prod: env === 'prod', jiraTasks: (BH.jiraSel && BH.jiraSel[env]) || [], + dest: { onedrive: outOn('onedrive') ? arts.slice() : [], firebase: outOn('firebase') ? arts.filter((a) => a === 'apk' || a === 'aab') : [], play: outOn('play') ? ['aab'] : [], testflight: outOn('testflight') ? ['ipa'] : [] }, + }; + }); + return { path: BH.sel, branch: (el('#bh-branch') && el('#bh-branch').value) || '', envs, artifacts: arts, + clean: on('#adv-clean'), pubGet: on('#adv-pub'), runAnalyze: on('#adv-analyze'), runTest: on('#adv-test'), gateWarnOnly: on('#adv-warn'), + allowDirty: false, allowDuplicate: false, confirmProd: false, allowLowVersion: false, ...(allow || {}) }; + }, +}; diff --git a/build-helper/public/js/build-run.js b/build-helper/public/js/build-run.js new file mode 100644 index 0000000..6b6c134 --- /dev/null +++ b/build-helper/public/js/build-run.js @@ -0,0 +1,84 @@ +// Build Helper frontend — running a build. Fires /api/build, then drives the UI by POLLING +// /api/build/log (the server buffers every event in running.log) so the live log survives navigating +// away and back. Handles inline guard prompts, per-env summaries, and the build-history list. +'use strict'; + +let _pollTimer = null, _lastSeq = 0; + +function writeLine(box, s, cls) { + if (!box) return; + const d = document.createElement('div'); if (cls) d.className = cls; + d.textContent = window.stripAnsi ? stripAnsi(s) : s; + box.appendChild(d); box.scrollTop = box.scrollHeight; +} + +function doBuild(allow) { + const req = BF.collectBuild(allow); + 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'); + ['#bh-term', '#bh-guard', '#bh-summary'].forEach((s) => { const b = el(s); if (b) b.innerHTML = ''; }); + const btn = el('#bh-build'); if (btn) btn.disabled = true; + _lastSeq = 0; + // Fire-and-forget: the server keeps building even if this response is never read; the poller drives UI. + fetch('/api/build', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(req) }).catch(() => {}); + setTimeout(() => pollLog(true), 250); +} + +async function pollLog(reset) { + if (BH.view !== 'project') { clearTimeout(_pollTimer); return; } // stop when we leave the build screen + if (reset) _lastSeq = 0; + const r = await API.buildLog(_lastSeq).catch(() => null); + const term = el('#bh-term'); + if (r && term && (!r.path || r.path === BH.sel)) for (const e of (r.events || [])) { _lastSeq = Math.max(_lastSeq, e.seq); handleEvent(e.event, e.data); } + const btn = el('#bh-build'); if (btn) btn.disabled = !!(r && r.busy); + if (r && r.busy) _pollTimer = setTimeout(() => pollLog(false), 1200); +} + +function handleEvent(event, data) { + const term = el('#bh-term'); + if (event === 'log') writeLine(term, data.line); + else if (event === 'step') writeLine(term, '▸ ' + data.text, 'text-emerald-300 font-semibold mt-1'); + else if (event === 'error') data.kind ? renderGuard(data) : writeLine(term, '✖ ' + data.message, 'text-rose-400 font-semibold'); + else if (event === 'done-env') renderEnvSummary(data.record); + else if (event === 'done') { writeLine(term, `✔ done — ${data.built || 0} built, ${data.failed || 0} failed`, 'text-emerald-400 font-semibold'); API.builds(BH.sel).then((x) => renderBuilds(x.builds || [])); } +} + +// Inline guard prompt: dirty / duplicate / prod-confirm / low-version → override + rebuild. +function renderGuard(g) { + const box = el('#bh-guard'); if (!box) return; + const flag = { dirty: 'allowDirty', duplicate: 'allowDuplicate', confirm: 'confirmProd', lowVersion: 'allowLowVersion' }[g.kind]; + const use = g.kind === 'lowVersion' ? `` : ''; + box.innerHTML = `
+
${esc(g.message)}
+
${use}
`; + el('#g-anyway').onclick = () => { box.innerHTML = ''; doBuild({ [flag]: true }); }; + el('#g-cancel').onclick = () => { box.innerHTML = ''; }; + const u = el('#g-use'); if (u) u.onclick = () => { (g.fixes || []).forEach((f) => { const inp = _body().querySelector(`.bh-ver[data-env="${f.env}"]`); if (inp) inp.value = f.suggested; }); box.innerHTML = ''; doBuild({ allowLowVersion: true }); }; +} + +// Per-env result card appended under the log as each env finishes. +function renderEnvSummary(r) { + const box = el('#bh-summary'); if (!box || !r) return; + const up = Object.entries(r.upload || {}).filter(([, v]) => v === 'ok').map(([k]) => k); + const arts = (r.artifacts || []).map((a) => `${esc(a.name)}`).join(' · '); + box.insertAdjacentHTML('beforeend', `
+
${r.buildOk === false ? '✗' : '✓'} + ${esc(r.env || '')} v${esc(verName(r.version))} ${up.length ? '↑ ' + up.join(', ') : ''} +
+ ${arts ? `
${arts}
` : ''}
`); + const last = box.lastElementChild; + last.querySelector('[data-share]').onclick = () => (window.openShare ? openShare(r) : toast('Open the build detail to share.', 'info')); + last.querySelector('[data-detail]').onclick = () => (window.V.detail ? V.detail(r.time) : null); +} + +function renderBuilds(builds) { + const box = el('#bh-builds'); if (!box) return; + window.renderHistory ? renderHistory(box, builds) : (box.innerHTML = builds.length ? builds.slice(0, 20).map((b) => `
${C.buildRow(b)}
`).join('') : '
No builds yet for this app.
'); + if (!window.renderHistory) box.querySelectorAll('[data-detail]').forEach((d) => d.onclick = () => (window.V.detail ? V.detail(d.dataset.detail) : null)); +} + +// On entering the build screen: if a build for THIS project is already running, replay + keep polling. +async function reattachBuild() { + const r = await API.buildLog(0).catch(() => null); + if (r && r.busy && r.path === BH.sel) pollLog(true); +} diff --git a/build-helper/public/js/charts.js b/build-helper/public/js/charts.js new file mode 100644 index 0000000..d1bfe84 --- /dev/null +++ b/build-helper/public/js/charts.js @@ -0,0 +1,73 @@ +// Build Helper frontend — hand-rolled inline-SVG charts (window.CH). Zero chart libs: each builder +// takes plain build records and returns an string that inherits kit.css color tokens +// (var(--good)/var(--crit)/var(--brand) and per-env var(--dev|demo|qa|prod)). Pure functions. +'use strict'; + +const _day = (t) => new Date(t).toISOString().slice(0, 10); +const _envVar = (e) => `var(--${['dev', 'demo', 'qa', 'prod'].includes(e) ? e : 'brand'})`; +const _empty = (h, msg) => `
${msg || 'No data yet.'}
`; + +// Group builds into the last N day-buckets (oldest→newest), each with its records. +function bucketDays(builds, days) { + const n = days || 14; + const today = new Date(); today.setHours(0, 0, 0, 0); + const out = []; + for (let i = n - 1; i >= 0; i--) { const d = new Date(today - i * 864e5); out.push({ key: d.toISOString().slice(0, 10), label: d.toISOString().slice(5, 10), items: [] }); } + const idx = Object.fromEntries(out.map((b, i) => [b.key, i])); + for (const b of builds || []) { const k = _day(b.time); if (k in idx) out[idx[k]].items.push(b); } + return out; +} + +window.CH = { + bucketDays, + // Builds per day, stacked ok (green) over failed (red). + stackedColumns(builds, days) { + const bk = bucketDays(builds, days || 14); + const max = Math.max(1, ...bk.map((b) => b.items.length)); + const W = 640, H = 130, pad = 18, bw = (W - pad * 2) / bk.length; + if (!(builds || []).length) return _empty(H); + const cols = bk.map((b, i) => { + const ok = b.items.filter((x) => x.buildOk !== false).length, fail = b.items.length - ok; + const x = pad + i * bw + bw * 0.15, w = bw * 0.7; + const okH = (ok / max) * (H - pad * 2), failH = (fail / max) * (H - pad * 2); + const okY = H - pad - okH, failY = okY - failH; + return `` + + (fail ? `` : '') + + (i % 2 === 0 ? `${b.label}` : ''); + }).join(''); + return `${cols}`; + }, + // Average build duration (seconds) per day as a line. + line(builds, days) { + const bk = bucketDays((builds || []).filter((b) => b.durationMs), days || 14); + const avg = bk.map((b) => b.items.length ? b.items.reduce((s, x) => s + (x.durationMs || 0), 0) / b.items.length / 1000 : 0); + const max = Math.max(1, ...avg), W = 640, H = 130, pad = 18; + if (!avg.some((v) => v)) return _empty(H, 'No timed builds yet.'); + const pts = avg.map((v, i) => `${(pad + i * ((W - pad * 2) / (bk.length - 1 || 1))).toFixed(1)},${(H - pad - (v / max) * (H - pad * 2)).toFixed(1)}`); + const dots = pts.map((p) => { const [x, y] = p.split(','); return ``; }).join(''); + return `${dots}peak ${Math.round(max)}s`; + }, + // Upload health per target: ok vs failed counts as horizontal bars. + hbars(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 _empty(96, 'No uploads yet.'); + const max = Math.max(1, ...rows.map((r) => r.ok + r.fail)), W = 640, rh = 22; + const bars = rows.map((r, i) => { + const y = i * rh + 4, okW = (r.ok / max) * (W - 160), failW = (r.fail / max) * (W - 160); + return `${r.t}` + + `` + + `` + + `${r.ok}✓ ${r.fail}✗`; + }).join(''); + return `${bars}`; + }, + // Builds per environment as colored bars. + envSplit(builds) { + const by = {}; for (const b of builds || []) if (b.env) by[b.env] = (by[b.env] || 0) + 1; + const entries = Object.entries(by); if (!entries.length) return _empty(96); + const max = Math.max(1, ...entries.map(([, n]) => n)), W = 640, rh = 22; + const bars = entries.map(([e, n], i) => { const y = i * rh + 4, w = (n / max) * (W - 140); return `${e}${n}`; }).join(''); + return `${bars}`; + }, +}; diff --git a/build-helper/public/js/cmdk.js b/build-helper/public/js/cmdk.js new file mode 100644 index 0000000..a911503 --- /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 _sel = 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)); + _sel = 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) { + _sel = 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 === _sel)); +} +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(_sel + 1); el('#cmdk-list').children[_sel]?.scrollIntoView({ block: 'nearest' }); } + else if (e.key === 'ArrowUp') { e.preventDefault(); _highlight(_sel - 1); el('#cmdk-list').children[_sel]?.scrollIntoView({ block: 'nearest' }); } + else if (e.key === 'Enter') { e.preventDefault(); _run(_sel); } + 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..666a2fc --- /dev/null +++ b/build-helper/public/js/dashboard.js @@ -0,0 +1,96 @@ +// Build Helper frontend — Dashboard view (window.V.dashboard). KPIs + range/project/env filters + +// hand-rolled charts + the project-sources manager + the sortable/draggable health table. Loaded +// after views.js (reuses _body/_skel) and charts.js/health-table.js. +'use strict'; + +const _F = () => (BH.filter = BH.filter || { range: '30', project: '', env: '', search: '' }); +const _cutoff = (r) => (r && +r ? Date.now() - +r * 864e5 : 0); +function _filteredBuilds() { + const f = _F(), c = _cutoff(f.range); + return (BH.builds || []).filter((b) => (!c || +new Date(b.time) >= c) && (!f.project || b.path === f.project) && (!f.env || b.env === f.env)); +} + +window.V.dashboard = async function () { + BH.view = 'dashboard'; if (window.setShell) setShell('dashboard'); + _body().innerHTML = _skel(6); + const [d, suggest, grades] = await Promise.all([ + API.dashboard().catch(() => ({ projects: [], builds: [], storage: {}, sources: {} })), + API.suggestRoots(), API.securityGrades(), + ]); + BH.projects = (d.projects || []).map((p) => ({ ...p, grade: grades[p.path] || null })); + BH.builds = d.builds || []; BH.storage = d.storage || {}; BH.running = d.running || {}; + BH.sources = d.sources || { roots: [], pinned: [], recursive: false }; + const f = _F(); const envs = [...new Set(BH.builds.map((b) => b.env).filter(Boolean))]; + const opt = (v, l, sel) => ``; + _body().innerHTML = ` +
+
+
+ + + + +
+
+
+ ${C.sourcesPanel(BH, suggest)} +
+
Projects
+ + + +
+
`; + renderStats(); renderTable(); wireSources(); + const onFilter = (k) => (e) => { _F()[k] = e.target.value; renderStats(); if (k === 'project') renderTable(); }; + el('#f-range').onchange = onFilter('range'); el('#f-project').onchange = onFilter('project'); el('#f-env').onchange = onFilter('env'); + el('#bh-search').oninput = (e) => { _F().search = e.target.value; renderTable(); }; + el('#f-density').onclick = () => { localStorage.setItem('bh-density', HT.density() === 'compact' ? 'comfortable' : 'compact'); V.dashboard(); }; + el('#bh-refresh').onclick = () => V.dashboard(); + if (window.tourPrompt && BH.projects.length) tourPrompt('bh-tour-dashboard', 'the dashboard', () => startTourFor('dashboard')); +}; + +// KPI tiles + charts, recomputed from the current filter. +function renderStats() { + const b = _filteredBuilds(); + const k = el('#bh-kpis'); if (k) k.innerHTML = C.kpis({ builds: b, projects: BH.projects, storage: BH.storage }); + const box = el('#bh-charts'); if (!box) return; + const panel = (label, svg) => `
${label}
${svg}
`; + const days = +(_F().range) >= 90 || +(_F().range) === 0 ? 30 : 14; + box.innerHTML = panel('Builds over time', CH.stackedColumns(b, days)) + + panel('Build duration', CH.line(b, days)) + panel('Upload health', CH.hbars(b)) + panel('By environment', CH.envSplit(b)); +} + +// The health table, filtered by search + the project dropdown. +function renderTable() { + const f = _F(), s = (f.search || '').trim().toLowerCase(); + let list = BH.projects; + if (f.project) list = list.filter((p) => p.path === f.project); + if (s) list = list.filter((p) => (p.name + ' ' + p.path).toLowerCase().includes(s)); + const cnt = el('#bh-count'); if (cnt) cnt.textContent = s || f.project ? `${list.length} of ${BH.projects.length}` : `${BH.projects.length} app${BH.projects.length === 1 ? '' : 's'}`; + HT.render(el('#bh-table'), list, { + open: (p) => V.project(p), + setup: (p) => (window.openAppSetup ? openAppSetup(p) : V.project(p)), + reveal: (p) => API.reveal(p), + favorite: (p, fav) => API.post('/api/app/favorite', { path: p, favorite: fav }).then(() => V.dashboard()), + reorder: (paths) => API.post('/api/app/order', { paths }).then(() => toast('Order saved', 'ok')), + }); +} + +// 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); }); +} diff --git a/build-helper/public/js/detail.js b/build-helper/public/js/detail.js new file mode 100644 index 0000000..362d9fe --- /dev/null +++ b/build-helper/public/js/detail.js @@ -0,0 +1,43 @@ +// Build Helper frontend — build detail (window.V.detail). Opens a modal for one build record: +// metadata, per-artifact download / copy-path / re-upload to a target, TestFlight re-manage, and +// Play rollback/re-promote. Streams every action into a shared log box in the modal. +'use strict'; + +function _upBtns(r, art) { + const targets = [['onedrive', 'OneDrive'], ['firebase', 'Firebase'], ['play', 'Play'], ['testflight', 'TestFlight']]; + return targets.map(([id, l]) => ``).join(''); +} + +window.V.detail = async function (time) { + const d = await API.buildDetail(time).catch(() => null); + if (!d || !d.build) return toast('Build not found', 'err'); + const r = d.build; + const b = openModal({ title: `${r.project} v${verName(r.version)}`, subtitle: `${r.env} · ${relTime(r.time)}`, size: '680px' }); + const up = Object.entries(r.upload || {}).map(([k, v]) => `${esc(k)} ${v}`).join(' '); + b.innerHTML = ` +
+ build ${esc(String(r.buildNumber || ''))} + ${r.branch ? `branch ${esc(r.branch)}` : ''} + ${r.commit ? `commit ${d.commitUrl ? `${esc(String(r.commit).slice(0, 8))}` : esc(String(r.commit).slice(0, 8))}` : ''} + ${r.durationMs ? `${fmtDur(r.durationMs)}` : ''}${r.by ? `by ${esc(r.by)}` : ''}
+
${up || 'no uploads recorded'}
+
Artifacts
+
${(r.artifacts || []).length ? r.artifacts.map((a) => `
+
${esc(a.name)} + ${fmtBytes(a.size || 0)} + Download +
+
${_upBtns(r, a)}
`).join('') : '
No artifacts on disk.
'}
+
+ + + ${r.playVersionCode ? `` : ''} +
+ `; + const logBox = () => { const x = el('#dt-log'); x.classList.remove('hidden'); return x; }; + b.querySelectorAll('.bh-cp').forEach((x) => x.onclick = () => navigator.clipboard.writeText(x.dataset.p).then(() => toast('Path copied', 'ok'))); + b.querySelectorAll('.bh-reup').forEach((x) => x.onclick = () => API.stream('/api/retry-upload', { path: r.path, artifactName: x.dataset.art, target: x.dataset.target, notes: r.whatsNew || '' }, logBox())); + el('#dt-share').onclick = () => (window.openShare ? openShare(r) : null); + el('#dt-tf').onclick = () => API.stream('/api/testflight/manage', { path: r.path, buildNumber: r.buildNumber, notes: r.whatsNew || '' }, logBox()); + const rb = el('#dt-rb'); if (rb) rb.onclick = () => API.stream('/api/rollback', { path: r.path, versionCode: r.playVersionCode, track: el('#dt-track').value }, logBox()); +}; diff --git a/build-helper/public/js/health-table.js b/build-helper/public/js/health-table.js new file mode 100644 index 0000000..64a9049 --- /dev/null +++ b/build-helper/public/js/health-table.js @@ -0,0 +1,67 @@ +// Build Helper frontend — the project-health table (window.HT). Sortable columns, drag-to-reorder +// (persisted via /api/app/order, only while unsorted), favorite toggle, density toggle, per-row +// security grade, and a "Set up" / "Open" call-to-action. Renders into a container + wires events; +// data ops call back into the dashboard, which re-fetches and re-renders. +'use strict'; + +const _gradeCls = (l) => ({ A: 'good', B: 'brand', C: 'warn', D: 'crit', F: 'crit' }[l] || 'muted'); +function _gradeBadge(g) { + if (!g || !g.letter) return '—'; + return ` ${esc(g.letter)}`; +} +const _sorters = { + name: (a, b) => a.name.localeCompare(b.name), + builds: (a, b) => (b.buildCount || 0) - (a.buildCount || 0), + last: (a, b) => (b.lastBuild ? +new Date(b.lastBuild.time) : 0) - (a.lastBuild ? +new Date(a.lastBuild.time) : 0), + grade: (a, b) => ((b.grade ? b.grade.score : -1) - (a.grade ? a.grade.score : -1)), +}; + +window.HT = { + density() { return localStorage.getItem('bh-density') || 'comfortable'; }, + render(container, projects, h) { + const sort = BH.tableSort || ''; + const list = sort ? [...projects].sort(_sorters[sort]) : projects; // server order = favorites+order + const dense = this.density() === 'compact'; + const pad = dense ? 'py-1' : 'py-2'; + const th = (key, label, extra) => `${label}${sort === key ? ' ↓' : ''}`; + container.innerHTML = list.length ? `
+ ${th('name', 'App')}${th('', 'Envs')}${th('', 'Version')}${th('last', 'Last build')}${th('builds', 'Builds')}${th('grade', 'Sec')} + ${list.map((p) => this.row(p, pad, !sort)).join('')}
` + : `
${projects.length ? 'No projects match your search.' : 'No Flutter apps found. Add a scan root or pin a project in Project sources above.'}
`; + this.wire(container, list, h, !sort); + }, + row(p, pad, canDrag) { + const lb = p.lastBuild, health = p.needsConfig ? 'warn' : (p.healthOk ? 'ok' : 'warn'); + const last = lb ? `${lb.buildOk ? '✓' : '✗'} v${esc(verName(lb.version))} · ${esc(lb.env || '')} · ${relTime(lb.time)}` : '—'; + const cta = p.needsConfig ? `` : ``; + return ` + ${C.dot(health)} + + ${C.envChips(p)} + v${esc(verName(p.version))} + ${last} + ${p.buildCount || 0} + ${_gradeBadge(p.grade)} + + + ${cta ? `${cta}` : ''}`; + }, + wire(container, list, h, canDrag) { + container.querySelectorAll('[data-sort]').forEach((t) => t.onclick = () => { BH.tableSort = BH.tableSort === t.dataset.sort ? '' : t.dataset.sort; this.render(container, list, h); }); + container.querySelectorAll('[data-open]').forEach((b) => b.onclick = () => h.open(b.dataset.open)); + container.querySelectorAll('[data-setup]').forEach((b) => b.onclick = () => h.setup(b.dataset.setup)); + container.querySelectorAll('[data-reveal]').forEach((b) => b.onclick = (e) => { e.stopPropagation(); h.reveal(b.dataset.reveal); }); + container.querySelectorAll('[data-fav]').forEach((b) => b.onclick = () => h.favorite(b.dataset.fav, !list.find((p) => p.path === b.dataset.fav)?.favorite)); + if (canDrag) this.wireDrag(container, h); + }, + // HTML5 drag-reorder: on drop, emit the new path order for /api/app/order. + wireDrag(container, h) { + let from = null; + container.querySelectorAll('tr[draggable]').forEach((tr) => { + tr.ondragstart = () => { from = tr; tr.style.opacity = '.4'; }; + tr.ondragend = () => { tr.style.opacity = ''; }; + tr.ondragover = (e) => e.preventDefault(); + tr.ondrop = (e) => { e.preventDefault(); if (!from || from === tr) return; const rows = [...container.querySelectorAll('tr[draggable]')]; const fi = rows.indexOf(from), ti = rows.indexOf(tr); tr.parentNode.insertBefore(from, fi < ti ? tr.nextSibling : tr); h.reorder([...container.querySelectorAll('tr[draggable]')].map((r) => r.dataset.path)); }; + }); + }, +}; diff --git a/build-helper/public/js/history.js b/build-helper/public/js/history.js new file mode 100644 index 0000000..69c2bcb --- /dev/null +++ b/build-helper/public/js/history.js @@ -0,0 +1,41 @@ +// Build Helper frontend — build-history list (window.renderHistory) with multi-select bulk actions: +// re-upload selected builds' artifacts to a target, share, or open details. Rows also open the +// detail view. Used by build-run.js renderBuilds() on the project screen. +'use strict'; + +function _selected(box) { return [...box.querySelectorAll('.bh-hsel:checked')].map((c) => c.dataset.time); } +function _byTime(t) { return (BH.project && BH.project.builds || []).find((b) => b.time === t) || (BH.builds || []).find((b) => b.time === t); } + +function renderHistory(box, builds) { + BH.project = BH.project || {}; BH.project.builds = builds; + if (!builds.length) { box.innerHTML = '
No builds yet for this app.
'; return; } + box.innerHTML = builds.slice(0, 30).map((b) => `
+ +
${C.buildRow(b)}
+
`).join(''); + const bulk = el('#bh-bulk'); + const sync = () => { const n = _selected(box).length; if (bulk) bulk.innerHTML = n ? `${n} selected + + ` : ''; wireBulk(box); }; + box.querySelectorAll('.bh-hsel').forEach((c) => c.onchange = sync); + box.querySelectorAll('[data-detail]').forEach((d) => d.onclick = () => (window.V.detail ? V.detail(d.dataset.detail) : null)); + box.querySelectorAll('[data-share]').forEach((d) => d.onclick = () => { const b = _byTime(d.dataset.share); if (b && window.openShare) openShare(b); }); + sync(); +} + +// Bulk re-upload: run /api/retry-upload for each selected build's first artifact into a shared log. +function wireBulk(box) { + const up = el('#bulk-up'); if (!up) return; + up.onclick = async () => { + const target = el('#bulk-target').value, times = _selected(box); + const b = openModal({ title: `Re-upload ${times.length} build(s) → ${target}`, size: '560px' }); + b.innerHTML = '
'; + const log = el('#bulk-log'); + for (const t of times) { + const rec = _byTime(t); const art = (rec && rec.artifacts || [])[0]; + if (!art) continue; + await new Promise((res) => API.stream('/api/retry-upload', { path: rec.path, artifactName: art.name, target, notes: rec.whatsNew || '' }, log, res)); + } + toast('Re-upload finished', 'ok'); + }; +} diff --git a/build-helper/public/js/notes.js b/build-helper/public/js/notes.js new file mode 100644 index 0000000..e179a24 --- /dev/null +++ b/build-helper/public/js/notes.js @@ -0,0 +1,58 @@ +// Build Helper frontend — release-notes helpers (window.NOTES): fill a per-env notes box from a +// template, the grouped commits since the last build, or the tracker tasks referenced in commits; +// plus the Jira issue picker (search existing + create new) whose selection rides along in the build. +'use strict'; + +const _tpl = (env) => `What's new in ${env.toUpperCase()}:\n- \n\nFixes:\n- `; +function _sel(env) { BH.jiraSel = BH.jiraSel || {}; return (BH.jiraSel[env] = BH.jiraSel[env] || []); } +function _renderChips(card, env) { + const s = _sel(env), lbl = card.querySelector('.bh-jira-sel'); + if (lbl) lbl.textContent = s.length ? s.map((t) => t.key).join(', ') : ''; +} + +window.NOTES = { + // Fill the notes textarea in the clicked button's env card. + fill(btn) { + const card = btn.closest('.bh-envcard'), env = btn.dataset.env, ta = card.querySelector('.bh-notes'); + if (btn.dataset.src === 'template') { ta.value = _tpl(env); return; } + if (btn.dataset.src === 'commits') { ta.value = 'Loading commits…'; API.changelog(BH.sel, env).then((r) => ta.value = (r.text || '').trim() || 'No new commits since the last build.'); return; } + // tracker: pull commit-scraped tasks, fill notes + preselect them in the Jira picker. + ta.value = 'Loading tasks…'; + API.tasks(BH.sel, env).then((r) => { + ta.value = (r.text || '').trim() || 'No tracker tasks referenced in commits.'; + BH.jiraSel = BH.jiraSel || {}; BH.jiraSel[env] = (r.items || []).map((i) => ({ key: i.key, title: i.title || '', state: i.state || '' })); + _renderChips(card, env); + }); + }, + // Modal: search existing Jira issues to attach, or create a new one. Selection stored per env. + jiraPicker(btn) { + const env = btn.dataset.env, card = btn.closest('.bh-envcard'); + const b = openModal({ title: 'Jira issues', subtitle: env.toUpperCase(), size: '560px' }); + b.innerHTML = `
+
+
Create a new issue
+
`; + const cur = () => new Set(_sel(env).map((t) => t.key)); + const search = async () => { + el('#jres').innerHTML = '
Searching…
'; + const r = await API.jiraSearch(BH.sel, el('#jq').value.trim()); + const set = cur(); + el('#jres').innerHTML = (r.issues || []).length ? r.issues.map((i) => ``).join('') + : `
${r.error ? esc(r.error) : 'No matches (is Jira connected in Setup?).'}
`; + el('#jres').querySelectorAll('.jpick').forEach((c) => c.onchange = () => { + const s = _sel(env), key = c.dataset.key; + if (c.checked) { if (!s.find((t) => t.key === key)) s.push({ key, title: c.dataset.title, state: '' }); } + else BH.jiraSel[env] = s.filter((t) => t.key !== key); + _renderChips(card, env); + }); + }; + el('#jgo').onclick = search; el('#jq').onkeydown = (e) => { if (e.key === 'Enter') search(); }; + el('#jcreate').onclick = async () => { + const summary = el('#jnew').value.trim(); if (!summary) return; + const r = await API.post('/api/tracker/issue', { path: BH.sel, summary }); + if (!r.ok) return toast(r.error || 'Create failed', 'err'); + _sel(env).push({ key: r.key, title: summary, state: '' }); _renderChips(card, env); toast(`Created ${r.key}`, 'ok'); el('#jnew').value = ''; + }; + search(); + }, +}; diff --git a/build-helper/public/js/project.js b/build-helper/public/js/project.js index 8119a7b..8ef6a39 100644 --- a/build-helper/public/js/project.js +++ b/build-helper/public/js/project.js @@ -1,15 +1,17 @@ -// 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 — Project (Build) view: composes the build form (build-form.js), release +// notes / Jira picker (notes.js), and the poll-based runner (build-run.js). Renders the header, +// wires every control, and reattaches a running build's log on entry. 'use strict'; window.V.project = async function (projectPath) { - BH.sel = projectPath; BH.view = 'project'; if (window.setShell) setShell('build'); + BH.sel = projectPath; BH.view = 'project'; BH.jiraSel = {}; 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}`; + const pill = (ok, on, off) => ok ? `${on}` : `${off}`; + const flags = { onedriveConnected: d.onedriveConnected, firebaseCli: d.firebaseCli, globalPlay: d.globalPlay, play: d.play, globalApple: d.globalApple, apple: d.apple }; _body().innerHTML = ` @@ -19,79 +21,53 @@ window.V.project = async function (projectPath) { v${esc(base)} ${app.currentMode ? `on ${esc(app.currentMode)}` : ''}
- ${healthPill(h.envDetected, 'env ok', 'no env')} - ${healthPill(h.signing, 'signed', 'unsigned')} + ${pill(h.envDetected, 'env ok', 'no env')}${pill(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).
' : ''} + ${app.needsConfig ? '
No AppMode env detected — open App Setup to configure it before building.
' : ''}
-
-
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)
-
- - - -
- +
+
Environments & version
+ ${(app.envs || []).map((e) => BF.envCard(e, nn[e.key] != null ? nn[e.key] : 1, base, flags)).join('') || '
No environments detected.
'} +
Artifacts
+
${ARTS.map((a) => C.artChip(a, a.id === 'apk')).join('')}
+
${BF.branchSelect(d.git)}
+ ${BF.advanced()} +
-
Live log
-
-
Recent builds
+
Live log
+
+
+
Recent builds
`; el('#bh-back').onclick = () => V.dashboard(); el('#bh-sign').onclick = () => signingModal(app); + el('#bh-appsetup').onclick = () => (window.openAppSetup ? openAppSetup(BH.sel) : toast('App Setup coming soon', 'info')); + el('#bh-analytics').onclick = () => (window.V.analytics ? V.analytics(BH.sel) : toast('Analytics coming soon', 'info')); _body().querySelectorAll('.bh-art').forEach((a) => a.onclick = () => a.classList.toggle('on')); - el('#bh-build').onclick = doBuild; + _body().querySelectorAll('.bh-bump').forEach((b) => b.onclick = () => { const inp = _body().querySelector(`.bh-ver[data-env="${b.dataset.env}"]`); inp.value = BF.bump(inp.value, b.dataset.k); }); + _body().querySelectorAll('.bh-fill').forEach((b) => b.onclick = () => NOTES.fill(b)); + _body().querySelectorAll('.bh-jira').forEach((b) => b.onclick = () => NOTES.jiraPicker(b)); + el('#bh-build').onclick = () => doBuild(); el('#bh-stop').onclick = () => API.stopBuild().then(() => toast('Stopping current build…', 'info')); renderBuilds(d.builds || []); + reattachBuild(); }; -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' }); 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..8e2b601 --- /dev/null +++ b/build-helper/public/js/share-actions.js @@ -0,0 +1,41 @@ +// Build Helper frontend — share follow-ups (window.shareEmail / shareJira). Rendered inline under the +// share card: email the build to client groups (BCC via /api/email/send) or file a Jira "Mobile +// Release" with the card attached (/api/tracker/release). Both stream progress into a log box. +'use strict'; + +async function shareEmail(r, cv) { + const box = el('#sc-extra'); + const groups = await API.groups(r.repo || ''); + const art = (r.artifacts || [])[0]; + box.innerHTML = `
+
Client groups
+
${groups.length ? groups.map((g) => ``).join('') : 'No groups — set them in App Setup, or add addresses below.'}
+ + + + +
`; + el('#em-send').onclick = () => { + const chosen = [...box.querySelectorAll('.em-g:checked')].map((c) => c.value); + const buttons = art ? [{ label: 'Install', url: location.origin + API.installUrl(art.url) }] : []; + const log = el('#em-log'); log.classList.remove('hidden'); + API.stream('/api/email/send', { repo: r.repo || '', appName: r.project, version: verName(r.version), description: el('#em-desc').value, groups: chosen, extra: el('#em-extra').value, subject: el('#em-subject').value, buttons }, log); + }; +} + +function shareJira(r, cv) { + const box = el('#sc-extra'); + box.innerHTML = `
+ + + + +
`; + el('#jr-go').onclick = () => { + const fields = { [`${r.env}Version`]: verName(r.version), buildNumber: String(r.buildNumber || ''), releaseNotes: r.whatsNew || '', releaseDate: (r.time || '').slice(0, 10) }; + const body = { path: r.path, summary: el('#jr-sum').value, description: el('#jr-desc').value, fields, linkKeys: (r.tasks || []).map((t) => t.key) }; + if (el('#jr-attach').checked) { body.pngBase64 = cv.toDataURL('image/png'); body.filename = `${r.project}-${verName(r.version)}.png`; } + const log = el('#jr-log'); log.classList.remove('hidden'); + API.stream('/api/tracker/release', body, log); + }; +} diff --git a/build-helper/public/js/share.js b/build-helper/public/js/share.js new file mode 100644 index 0000000..56e9c62 --- /dev/null +++ b/build-helper/public/js/share.js @@ -0,0 +1,62 @@ +// Build Helper frontend — share a build (window.openShare). Draws a branded card on with a +// same-origin QR (so toDataURL stays untainted), copy/download, team + client text, and buttons that +// hand off to share-actions.js for emailing client groups and filing a Jira release. +'use strict'; + +function _installFull(p) { return location.origin + API.installUrl(p); } +function _upList(r) { return Object.entries(r.upload || {}).filter(([, v]) => v === 'ok').map(([k]) => k); } + +function teamText(r) { + const up = _upList(r); + return `✅ ${r.project} v${verName(r.version)} (${r.env}) built` + + (up.length ? `\n↑ ${up.join(', ')}` : '') + + (r.onedriveUrl ? `\nOneDrive: ${r.onedriveUrl}` : '') + + (r.tasks && r.tasks.length ? `\nTasks: ${r.tasks.map((t) => t.key).join(', ')}` : ''); +} +function clientText(r) { + const art = (r.artifacts || [])[0]; + return `${r.project} ${verName(r.version)} is ready to test.\n` + + (art ? `Install: ${_installFull(art.url)}\n` : '') + + (r.whatsNew ? `\nWhat's new:\n${r.whatsNew}` : ''); +} + +// Draw the card. Colors pulled from the live kit.css tokens so it matches the theme. +function drawCard(cv, r) { + const g = cv.getContext('2d'), W = cv.width, H = cv.height; + const css = getComputedStyle(document.documentElement), c = (v, f) => (css.getPropertyValue(v).trim() || f); + g.fillStyle = c('--panel', '#131a2a'); g.fillRect(0, 0, W, H); + g.fillStyle = c('--brand', '#6366f1'); g.fillRect(0, 0, W, 12); + g.fillStyle = c('--ink', '#e7eaf1'); g.font = '700 46px Inter, system-ui, sans-serif'; + g.fillText(r.project || 'App', 48, 96); + g.font = '600 30px Inter, system-ui, sans-serif'; g.fillStyle = c('--brand', '#8b93ff'); + g.fillText(`v${verName(r.version)} · ${(r.env || '').toUpperCase()}`, 48, 146); + g.font = '400 20px Inter, system-ui, sans-serif'; g.fillStyle = c('--muted', '#93a0b8'); + const up = _upList(r); g.fillText(up.length ? `Distributed to: ${up.join(', ')}` : 'Build ready', 48, 196); + const arts = (r.artifacts || []).map((a) => a.name.split('.').pop().toUpperCase()); + g.fillText(arts.length ? `Artifacts: ${[...new Set(arts)].join(', ')}` : '', 48, 232); + if (r.whatsNew) { g.font = '400 18px Inter, system-ui'; g.fillStyle = c('--ink', '#cbd5e1'); r.whatsNew.split('\n').slice(0, 4).forEach((ln, i) => g.fillText(ln.slice(0, 60), 48, 288 + i * 26)); } + const art = (r.artifacts || [])[0]; + if (art) { const img = new Image(); img.onload = () => { g.fillStyle = '#fff'; g.fillRect(W - 236, H - 236, 196, 196); g.drawImage(img, W - 228, H - 228, 180, 180); }; img.src = API.qrUrl(_installFull(art.url), 300); } +} + +window.openShare = function (r) { + const b = openModal({ title: 'Share build', subtitle: `${r.project} v${verName(r.version)} (${r.env})`, size: '680px' }); + b.innerHTML = ` +
+ + + + + + +
+
`; + const cv = el('#sc'); drawCard(cv, r); + const copyText = (t, ok) => navigator.clipboard.writeText(t).then(() => toast(ok, 'ok')).catch(() => toast('Copy failed', 'err')); + el('#sc-dl').onclick = () => { const a = document.createElement('a'); a.download = `${r.project}-${verName(r.version)}.png`; a.href = cv.toDataURL('image/png'); a.click(); }; + el('#sc-copy').onclick = () => cv.toBlob((bl) => navigator.clipboard.write([new ClipboardItem({ 'image/png': bl })]).then(() => toast('Image copied', 'ok')).catch(() => toast('Copy not supported here', 'err'))); + el('#sc-team').onclick = () => copyText(teamText(r), 'Team text copied'); + el('#sc-client').onclick = () => copyText(clientText(r), 'Client text copied'); + el('#sc-email').onclick = () => shareEmail(r, cv); + el('#sc-jira').onclick = () => shareJira(r, cv); +}; 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'); From e4176891536a06d459cf500e18a5460f64289aeb Mon Sep 17 00:00:00 2001 From: Hassan Al-Najjar Date: Sat, 12 Sep 2026 17:47:40 +0300 Subject: [PATCH 2/2] Build Helper: match the source tool's exact UI/design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier port replicated the features/logic but rebuilt the frontend in a flat, generic style. This reworks the presentation layer to mirror the source JigsawTools build-helper pixel-for-pixel, reusing the design system already in platform-kit/kit.css (which is a superset of the source's app.css) — so almost no CSS was added, only faithful markup. Frontend (source-verbatim markup, kit nav kept) - bh-ui.js: pipeline builder, env/station colors, 4 missing icon glyphs merged into kit ICON, brand logos (firebase/apple/play), CONNECT map, $/tooltip, no-auth capability stubs. - Dashboard (dashboard.js + health-table.js + charts.js): Release-console header, range/project/env filters, KPI cards, 2x2 SVG charts + security posture, and the project-health table with real app icons (/api/icon), drag reorder, ★ favorites, density, per-row security grade, Storage + Activity rail. - Build page (project.js + build-form.js + build-wire.js + build-run.js): header (icon + Current + TestFlight), sticky DEV→PROD pipeline stations, colored left-border env cards, M/m/p bump, notes fills + Jira picker, big outputs cards with "connect it" warnings, macOS traffic-light live log (poll-based, survives nav), build summary, history + bulk actions. - App Setup (app-setup.js + app-setup-more.js): one tall modal — release readiness, env override, environments editor, Firebase/Apple/Play, build defaults, Android signing, build commands, scheduled builds, issue-tracker grid with brand logos, client groups, sticky save. - Share (share-card.js + share.js + share-actions.js): canvas build card with QR + platform badges, download/copy, team/client text, email groups, Jira release, re-upload. Detail + analytics (detail.js) ported to source markup. - index.html: station CSS + tooltip, new script modules; deleted dead notes.js. Backend (small additions for the ported UI) - /api/artifacts + /api/artifact/delete (build-page browser) - /api/jira/meta, /api/jira/create-release, /api/jira/create-issue (share modal) - /api/log (raw build log); /api/qr accepts data= ; dashboard rows carry hasIcon - scheduler matches the source schedule shape (enabled/days/dest, once per day) Verified headless (Chrome): dashboard, build page, and App Setup render matching the source screenshots; no console errors. check-lines clean; --selftest 83 routes. Co-Authored-By: Claude Opus 4.8 (1M context) --- build-helper/lib/dashboard/data.js | 4 +- build-helper/lib/routes/dist.js | 20 ++- build-helper/lib/routes/pages.js | 6 +- build-helper/lib/schedule.js | 32 ++--- build-helper/lib/trackers.js | 3 +- build-helper/public/index.html | 20 ++- build-helper/public/js/app-setup-more.js | 133 +++++++----------- build-helper/public/js/app-setup.js | 156 +++++++++------------ build-helper/public/js/app.js | 5 + build-helper/public/js/bh-ui.js | 62 +++++++++ build-helper/public/js/build-form.js | 162 ++++++++++++---------- build-helper/public/js/build-run.js | 167 ++++++++++++++--------- build-helper/public/js/build-wire.js | 60 ++++++++ build-helper/public/js/charts.js | 126 ++++++++--------- build-helper/public/js/cmdk.js | 14 +- build-helper/public/js/dashboard.js | 165 +++++++++++----------- build-helper/public/js/detail.js | 85 ++++++------ build-helper/public/js/health-table.js | 131 +++++++++--------- build-helper/public/js/history.js | 104 +++++++++----- build-helper/public/js/notes.js | 58 -------- build-helper/public/js/project.js | 134 ++++++++---------- build-helper/public/js/share-actions.js | 96 ++++++++----- build-helper/public/js/share-card.js | 91 ++++++++++++ build-helper/public/js/share.js | 118 ++++++++-------- build-helper/public/js/state.js | 3 + 25 files changed, 1104 insertions(+), 851 deletions(-) create mode 100644 build-helper/public/js/bh-ui.js create mode 100644 build-helper/public/js/build-wire.js delete mode 100644 build-helper/public/js/notes.js create mode 100644 build-helper/public/js/share-card.js 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/routes/dist.js b/build-helper/lib/routes/dist.js index 2fa5c60..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, handleTasks, jiraSearchFor } = require('../trackers'); +const { handleCreateRelease, handleCreateIssue, handleTasks, jiraSearchFor, handleJiraMeta } = require('../trackers'); function register(app) { const J = app.sendJson; @@ -14,11 +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/pages.js b/build-helper/lib/routes/pages.js index 5825311..49be442 100644 --- a/build-helper/lib/routes/pages.js +++ b/build-helper/lib/routes/pages.js @@ -6,7 +6,7 @@ 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; @@ -18,8 +18,10 @@ function register(app) { req.on('close', () => feedClients.delete(res)); }); // 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('size'))); + 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/schedule.js b/build-helper/lib/schedule.js index 562a602..d92d7b5 100644 --- a/build-helper/lib/schedule.js +++ b/build-helper/lib/schedule.js @@ -4,39 +4,39 @@ // 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, running } = require('./state'); +const { readConfig, writeConfig, running } = require('./state'); const { detectApp } = require('./project'); const { handleBuild } = require('./build'); -const _fired = new Set(); // "path|env|time|YYYY-MM-DDTHH:MM" — de-dupe within a minute - 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 || [], - envs: [{ env: sched.env, buildName, buildNumber: '1', autoNum: true, dest: { onedrive: [], firebase: [], play: [], testflight: [] } }], + 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 (${(sched.artifacts || []).join(',')})`); + 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 schedule matching `now`. Skips while a build is running (next tick retries). +// 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(); + const day = now.getDay(), isWeekday = day >= 1 && day <= 5; const hhmm = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`; - const stamp = `${now.toISOString().slice(0, 10)}T${hhmm}`; - const apps = readConfig().apps || {}; - for (const [p, cfg] of Object.entries(apps)) { - for (const s of (cfg.schedules || [])) { - if (!s.env || !(s.artifacts || []).length || !(s.days || []).includes(day) || s.time !== hhmm) continue; - const key = `${p}|${s.env}|${s.time}|${stamp}`; - if (_fired.has(key)) continue; - _fired.add(key); if (_fired.size > 300) _fired.delete(_fired.values().next().value); + 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 } } diff --git a/build-helper/lib/trackers.js b/build-helper/lib/trackers.js index 6e059e4..1e9771a 100644 --- a/build-helper/lib/trackers.js +++ b/build-helper/lib/trackers.js @@ -38,6 +38,7 @@ async function collectTasks(projectPath, env) { // 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) { @@ -86,5 +87,5 @@ async function handleCreateIssue(res, body) { module.exports = { jiraBase, TRACKERS, trackerById, resolveTracker, configuredTrackers, collectTasks, - jiraMeta, handleCreateRelease, handleCreateIssue, handleTasks, jiraSearchFor, 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 1296230..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,15 +54,17 @@ + - + + diff --git a/build-helper/public/js/app-setup-more.js b/build-helper/public/js/app-setup-more.js index 5893c29..7522436 100644 --- a/build-helper/public/js/app-setup-more.js +++ b/build-helper/public/js/app-setup-more.js @@ -1,83 +1,56 @@ -// Build Helper frontend — per-app Setup, remaining tabs (registered into the AS registry from -// app-setup.js): Build defaults, Scheduled builds, per-app Trackers, and client email Groups. +// 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 _lines = (s) => String(s || '').split('\n').map((x) => x.trim()).filter(Boolean); -const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; +const _slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); -AS.reg('build', 'Build', { - render(d) { - const s = d.settings || {}; - return `
- - - - -
`; - }, - wire(d) { el('#bs-save').onclick = () => AS.save(d.app.path, { settings: { flavor: el('#bs-flavor').value.trim(), buildArgs: el('#bs-args').value.trim(), preBuild: _lines(el('#bs-pre').value), postBuild: _lines(el('#bs-post').value) } }, 'Build defaults saved'); }, -}); - -AS.reg('schedule', 'Schedule', { - render(d) { - const rows = (d.settings?.schedules || []).map((s) => AS.schedRow(d, s)).join(''); - return `
Unattended builds run by the scheduler (checks every minute). Times are the server's local time.
-
${rows || '
No schedules.
'}
- -
`; - }, - wire(d, body) { - const rowsBox = el('#sc-rows'); - el('#sc-add').onclick = () => { if (rowsBox.querySelector('.text-slate-500')) rowsBox.innerHTML = ''; rowsBox.insertAdjacentHTML('beforeend', AS.schedRow(d, {})); }; - body.addEventListener('click', (e) => { if (e.target.closest('.sc-rm')) e.target.closest('.sc-row').remove(); }); - el('#sc-save').onclick = () => { - const schedules = [...body.querySelectorAll('.sc-row')].map((r) => ({ - env: r.querySelector('.sc-env').value, time: r.querySelector('.sc-time').value || '09:00', - days: [...r.querySelectorAll('.sc-day:checked')].map((c) => +c.value), - artifacts: [...r.querySelectorAll('.sc-art:checked')].map((c) => c.value), - })).filter((s) => s.env && s.artifacts.length && s.days.length); - AS.save(d.app.path, { settings: { schedules } }, 'Schedules saved'); - }; - }, -}); -AS.schedRow = (d, s) => `
- - - ${DAYS.map((n, i) => ``).join('')} - ${ARTS.map((a) => ``).join('')} -
`; - -AS.reg('trackers', 'Trackers', { - render(d) { - const defs = d.trackerDefs || [], acc = d.trackerAccounts || {}; - return `
Per-app tracker credentials override the shared connectors for this project.
-
${defs.map((t) => `
${esc(t.label)} ${d.globalTrackers?.[t.id] ? '(global set)' : ''}
-
${(t.fields || []).map((f) => ``).join('')}
`).join('')}
-
`; - }, - wire(d, body) { - el('#tk-save').onclick = () => { - const trackers = {}; - body.querySelectorAll('[data-tid]').forEach((box) => { const o = {}; box.querySelectorAll('.tk-f').forEach((i) => o[i.dataset.f] = i.value.trim()); trackers[box.dataset.tid] = o; }); - AS.save(d.app.path, { trackers }, 'Trackers saved'); - }; - }, -}); - -AS.reg('groups', 'Groups', { - render() { return '
Loading groups…
'; }, - async wire(d, body) { - const g = await API.appGroups(d.app.repo || ''); - const row = (x) => `
`; - body.innerHTML = `
Client email groups for ${esc(d.app.repo || d.app.name)} (used when emailing a build).
-
${(g.own || []).map(row).join('') || row({})}
- -
${g.global && g.global.length ? `
Global groups (all apps): ${g.global.map((x) => esc(x.name)).join(', ')}
` : ''}
`; - el('#gp-add').onclick = () => el('#gp-rows').insertAdjacentHTML('beforeend', row({})); - body.addEventListener('click', (e) => { if (e.target.closest('.gp-rm')) e.target.closest('.gp-row').remove(); }); - el('#gp-save').onclick = () => { - const groups = [...body.querySelectorAll('.gp-row')].map((r) => ({ name: r.querySelector('.gp-name').value.trim() || 'Group', emails: r.querySelector('.gp-em').value.split(/[\s,;]+/).filter(Boolean) })).filter((x) => x.emails.length); - API.post('/api/groups/save', { repo: d.app.repo || '', groups }).then((r) => toast(r.ok ? 'Groups saved' : (r.error || 'Failed'), r.ok ? 'ok' : 'err')); - }; - }, -}); +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 index 82a46f5..d2da439 100644 --- a/build-helper/public/js/app-setup.js +++ b/build-helper/public/js/app-setup.js @@ -1,95 +1,69 @@ -// Build Helper frontend — per-app Setup (window.openAppSetup). A tabbed modal registered via a small -// AS registry (this file adds Overview/Environments/Accounts; app-setup-more.js adds the rest). Each -// tab renders its own HTML and saves its slice through /api/app/save (or /api/groups/save). +// 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'; -window.AS = { - tabs: {}, order: [], - reg(id, label, def) { this.tabs[id] = Object.assign({ label }, def); if (!this.order.includes(id)) this.order.push(id); }, - save(path, patch, msg) { return API.post('/api/app/save', { path, ...patch }).then((r) => toast(r.ok ? (msg || 'Saved') : (r.error || 'Failed'), r.ok ? 'ok' : 'err')); }, -}; +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.openAppSetup = window.showAppSetup = async function (path) { - const d = await API.project(path).catch(() => null); - if (!d || d.error) return toast('Could not load this app.', 'err'); - const tabs = AS.order.map((id) => ({ id, label: AS.tabs[id].label })); - const body = openModal({ title: 'App Setup', subtitle: d.app.name, size: '800px', tabs, active: AS.order[0], onTab: (id) => render(id) }); - const render = (id) => { body.innerHTML = AS.tabs[id].render(d); if (AS.tabs[id].wire) AS.tabs[id].wire(d, body); }; - render(AS.order[0]); +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); }; - -const _stat = (ok, label) => `
${ok ? 'ok' : 'todo'}${label}
`; - -AS.reg('overview', 'Overview', { - render(d) { - const h = d.health || {}; - return `
Release-readiness for ${esc(d.app.name)}.
-
- ${_stat(h.envDetected, 'AppMode environment detected')}${_stat(h.signing, 'Android release signing configured')} - ${_stat(h.packageId, 'Android package id resolved')}${_stat(!!d.hasIcon, 'Launcher icon found')} - ${_stat(!!d.firebaseApp, 'Firebase App ID set (for App Distribution)')} - ${_stat(!!(d.apple || d.globalApple), 'Apple / TestFlight account')}${_stat(!!(d.play || d.globalPlay), 'Google Play account')} - ${_stat(!!d.onedriveConnected, 'OneDrive connected')} -
- `; - }, - wire(d) { const b = el('#as-sign'); if (b) b.onclick = () => signingModal(d.app); }, -}); - -AS.reg('env', 'Environments', { - render(d) { - const a = d.app, rows = (a.envs || []).map((e, i) => AS.envRow(e, i)).join(''); - return `
- - -
-
Environments (key · label · AppMode member · color · prod)
-
${rows}
- -
Detected enum members: ${(a.members || []).join(', ') || '—'}
-
`; - }, - wire(d, body) { - const add = () => el('#as-envs').insertAdjacentHTML('beforeend', AS.envRow({ key: '', label: '', mode: '', color: 'slate', prod: false }, Math.random())); - el('#as-addenv').onclick = add; - body.addEventListener('click', (e) => { if (e.target.closest('.as-rmenv')) e.target.closest('.as-envrow').remove(); }); - el('#as-saveenv').onclick = () => { - const envs = [...body.querySelectorAll('.as-envrow')].map((r) => ({ key: r.querySelector('.e-key').value.trim(), label: r.querySelector('.e-label').value.trim() || r.querySelector('.e-key').value.trim().toUpperCase(), mode: r.querySelector('.e-mode').value.trim(), color: r.querySelector('.e-color').value.trim() || 'slate', prod: r.querySelector('.e-prod').checked })).filter((x) => x.key && x.mode); - AS.save(d.app.path, { override: { constName: el('#as-const').value.trim(), envFile: el('#as-envfile').value.trim(), envs } }, 'Environments saved').then(() => V.project(d.app.path)); - }; - }, -}); -AS.envRow = (e, i) => `
- - - - - -
`; - -AS.reg('accounts', 'Accounts', { - render(d) { - return `
-
Firebase App ID
- -
-
Apple / TestFlight ${d.globalApple ? '(global set)' : ''}
-
- -
-
Google Play ${d.globalPlay ? '(global set)' : ''}
- -
-
-
`; - }, - wire(d) { - const p = d.app.path; - el('#as-fbsave').onclick = () => AS.save(p, { firebaseApp: el('#as-fb').value.trim() }, 'Firebase App ID saved'); - el('#as-asave').onclick = () => AS.save(p, { apple: { keyId: el('#as-akey').value.trim(), issuerId: el('#as-aiss').value.trim(), p8: el('#as-ap8').value } }, 'Apple saved'); - el('#as-aclear').onclick = () => AS.save(p, { apple: { clear: true } }, 'Apple cleared'); - el('#as-plsave').onclick = () => AS.save(p, { play: { serviceAccountJson: el('#as-plsa').value, defaultTrack: el('#as-pltrack').value } }, 'Play saved'); - el('#as-plclear').onclick = () => AS.save(p, { play: { clear: true } }, 'Play cleared'); - if (d.play && d.play.defaultTrack) el('#as-pltrack').value = d.play.defaultTrack; - }, -}); diff --git a/build-helper/public/js/app.js b/build-helper/public/js/app.js index 67a4548..65fb7f7 100644 --- a/build-helper/public/js/app.js +++ b/build-helper/public/js/app.js @@ -70,6 +70,11 @@ async function openUpdate() { 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 index ac71315..49129b3 100644 --- a/build-helper/public/js/build-form.js +++ b/build-helper/public/js/build-form.js @@ -1,75 +1,95 @@ -// Build Helper frontend — the build form (window.BF): per-env cards (version + M/m/p bump, build -// number + auto, per-env output matrix + Play track, release notes + fill/Jira), the artifact chips, -// the branch selector, and the Advanced menu. collectBuild() shapes the /api/build request body. +// 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'; -function _bump(v, kind) { - const p = String(v || '1.0.0').split('.').map((n) => parseInt(n, 10) || 0); while (p.length < 3) p.push(0); - if (kind === 'major') { p[0]++; p[1] = 0; p[2] = 0; } else if (kind === 'minor') { p[1]++; p[2] = 0; } else p[2]++; - return p.slice(0, 3).join('.'); +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 }; } -window.BF = { - bump: _bump, - // One environment card: selection, version+bump, build number+auto, outputs, notes. - envCard(e, nextNum, base, f) { - const out = (id, label, dis, title) => ``; - return `
-
- - ${_envChip(e)} - -
${['major:M', 'minor:m', 'patch:p'].map((k) => ``).join('')}
- - -
-
- ${out('onedrive', 'OneDrive', !f.onedriveConnected, 'Connect in Setup')} - ${out('firebase', 'Firebase', !f.firebaseCli, 'Login in Setup')} - ${out('play', 'Play', !(f.globalPlay || f.play), 'Configure Play')} - - ${out('testflight', 'TestFlight', !(f.globalApple || f.apple), 'Configure Apple')} -
-
- -
- ${['template:Template', 'commits:Commits', 'tracker:Tasks'].map((s) => ``).join('')} - - -
-
-
`; - }, - advanced() { - const opt = (id, l) => ``; - return `
Advanced -
- ${opt('clean', 'flutter clean')}${opt('pub', 'pub get')}${opt('analyze', 'analyze')}${opt('test', 'test')} - ${opt('warn', 'gate: warn only')}${opt('tag', 'git commit + tag')}${opt('push', 'git push')} - ${opt('validate', 'validate IPA')}${opt('symbols', 'upload crash symbols')} -
`; - }, - branchSelect(git) { - const bs = (git && git.branches) || []; const cur = (git && git.current) || ''; - if (!bs.length) return ''; - return ``; - }, - // Read the whole form into a /api/build body. `allow` merges guard-override flags on retry. - collectBuild(allow) { - const on = (id) => !!(el(id) && el(id).checked); - const arts = [..._body().querySelectorAll('.bh-art.on')].map((a) => a.dataset.art); - const validate = on('#adv-validate'), symbols = on('#adv-symbols'), tag = on('#adv-tag'), push = on('#adv-push'); - const envs = [..._body().querySelectorAll('.bh-envcard')].filter((c) => c.querySelector('.bh-env').checked).map((c) => { - const env = c.dataset.env, q = (s) => c.querySelector(s), outOn = (id) => !!c.querySelector(`.bh-out[data-out="${id}"]`)?.checked; - return { - env, buildName: q('.bh-ver').value.trim() || '1.0.0', buildNumber: q('.bh-num').value.trim() || '1', autoNum: q('.bh-auto').checked, - track: q('.bh-track').value, validate, uploadSymbols: symbols, commitTag: tag, push, whatsNew: q('.bh-notes').value.trim(), notes: q('.bh-notes').value.trim(), - prod: env === 'prod', jiraTasks: (BH.jiraSel && BH.jiraSel[env]) || [], - dest: { onedrive: outOn('onedrive') ? arts.slice() : [], firebase: outOn('firebase') ? arts.filter((a) => a === 'apk' || a === 'aab') : [], play: outOn('play') ? ['aab'] : [], testflight: outOn('testflight') ? ['ipa'] : [] }, - }; - }); - return { path: BH.sel, branch: (el('#bh-branch') && el('#bh-branch').value) || '', envs, artifacts: arts, - clean: on('#adv-clean'), pubGet: on('#adv-pub'), runAnalyze: on('#adv-analyze'), runTest: on('#adv-test'), gateWarnOnly: on('#adv-warn'), - allowDirty: false, allowDuplicate: false, confirmProd: false, allowLowVersion: false, ...(allow || {}) }; - }, -}; +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 index 6b6c134..ed13aa8 100644 --- a/build-helper/public/js/build-run.js +++ b/build-helper/public/js/build-run.js @@ -1,84 +1,119 @@ -// Build Helper frontend — running a build. Fires /api/build, then drives the UI by POLLING -// /api/build/log (the server buffers every event in running.log) so the live log survives navigating -// away and back. Handles inline guard prompts, per-env summaries, and the build-history list. +// 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'; -let _pollTimer = null, _lastSeq = 0; +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 writeLine(box, s, cls) { - if (!box) return; - const d = document.createElement('div'); if (cls) d.className = cls; - d.textContent = window.stripAnsi ? stripAnsi(s) : s; - box.appendChild(d); box.scrollTop = box.scrollHeight; +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 doBuild(allow) { - const req = BF.collectBuild(allow); - 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'); - ['#bh-term', '#bh-guard', '#bh-summary'].forEach((s) => { const b = el(s); if (b) b.innerHTML = ''; }); - const btn = el('#bh-build'); if (btn) btn.disabled = true; - _lastSeq = 0; - // Fire-and-forget: the server keeps building even if this response is never read; the poller drives UI. - fetch('/api/build', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(req) }).catch(() => {}); - setTimeout(() => pollLog(true), 250); +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); }); } -async function pollLog(reset) { - if (BH.view !== 'project') { clearTimeout(_pollTimer); return; } // stop when we leave the build screen - if (reset) _lastSeq = 0; - const r = await API.buildLog(_lastSeq).catch(() => null); - const term = el('#bh-term'); - if (r && term && (!r.path || r.path === BH.sel)) for (const e of (r.events || [])) { _lastSeq = Math.max(_lastSeq, e.seq); handleEvent(e.event, e.data); } - const btn = el('#bh-build'); if (btn) btn.disabled = !!(r && r.busy); - if (r && r.busy) _pollTimer = setTimeout(() => pollLog(false), 1200); +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 handleEvent(event, data) { - const term = el('#bh-term'); - if (event === 'log') writeLine(term, data.line); - else if (event === 'step') writeLine(term, '▸ ' + data.text, 'text-emerald-300 font-semibold mt-1'); - else if (event === 'error') data.kind ? renderGuard(data) : writeLine(term, '✖ ' + data.message, 'text-rose-400 font-semibold'); - else if (event === 'done-env') renderEnvSummary(data.record); - else if (event === 'done') { writeLine(term, `✔ done — ${data.built || 0} built, ${data.failed || 0} failed`, 'text-emerald-400 font-semibold'); API.builds(BH.sel).then((x) => renderBuilds(x.builds || [])); } +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(); + }); } -// Inline guard prompt: dirty / duplicate / prod-confirm / low-version → override + rebuild. -function renderGuard(g) { - const box = el('#bh-guard'); if (!box) return; - const flag = { dirty: 'allowDirty', duplicate: 'allowDuplicate', confirm: 'confirmProd', lowVersion: 'allowLowVersion' }[g.kind]; - const use = g.kind === 'lowVersion' ? `` : ''; - box.innerHTML = `
-
${esc(g.message)}
-
${use}
`; - el('#g-anyway').onclick = () => { box.innerHTML = ''; doBuild({ [flag]: true }); }; - el('#g-cancel').onclick = () => { box.innerHTML = ''; }; - const u = el('#g-use'); if (u) u.onclick = () => { (g.fixes || []).forEach((f) => { const inp = _body().querySelector(`.bh-ver[data-env="${f.env}"]`); if (inp) inp.value = f.suggested; }); box.innerHTML = ''; doBuild({ allowLowVersion: true }); }; +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 = ''; }; } -// Per-env result card appended under the log as each env finishes. -function renderEnvSummary(r) { - const box = el('#bh-summary'); if (!box || !r) return; - const up = Object.entries(r.upload || {}).filter(([, v]) => v === 'ok').map(([k]) => k); - const arts = (r.artifacts || []).map((a) => `${esc(a.name)}`).join(' · '); - box.insertAdjacentHTML('beforeend', `
-
${r.buildOk === false ? '✗' : '✓'} - ${esc(r.env || '')} v${esc(verName(r.version))} ${up.length ? '↑ ' + up.join(', ') : ''} -
- ${arts ? `
${arts}
` : ''}
`); - const last = box.lastElementChild; - last.querySelector('[data-share]').onclick = () => (window.openShare ? openShare(r) : toast('Open the build detail to share.', 'info')); - last.querySelector('[data-detail]').onclick = () => (window.V.detail ? V.detail(r.time) : null); +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 renderBuilds(builds) { - const box = el('#bh-builds'); if (!box) return; - window.renderHistory ? renderHistory(box, builds) : (box.innerHTML = builds.length ? builds.slice(0, 20).map((b) => `
${C.buildRow(b)}
`).join('') : '
No builds yet for this app.
'); - if (!window.renderHistory) box.querySelectorAll('[data-detail]').forEach((d) => d.onclick = () => (window.V.detail ? V.detail(d.dataset.detail) : null)); +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(() => {}); } -// On entering the build screen: if a build for THIS project is already running, replay + keep polling. -async function reattachBuild() { - const r = await API.buildLog(0).catch(() => null); - if (r && r.busy && r.path === BH.sel) pollLog(true); +// 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 index d1bfe84..75061a1 100644 --- a/build-helper/public/js/charts.js +++ b/build-helper/public/js/charts.js @@ -1,73 +1,65 @@ -// Build Helper frontend — hand-rolled inline-SVG charts (window.CH). Zero chart libs: each builder -// takes plain build records and returns an string that inherits kit.css color tokens -// (var(--good)/var(--crit)/var(--brand) and per-env var(--dev|demo|qa|prod)). Pure functions. +// 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 _day = (t) => new Date(t).toISOString().slice(0, 10); -const _envVar = (e) => `var(--${['dev', 'demo', 'qa', 'prod'].includes(e) ? e : 'brand'})`; -const _empty = (h, msg) => `
${msg || 'No data yet.'}
`; +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(); } -// Group builds into the last N day-buckets (oldest→newest), each with its records. function bucketDays(builds, days) { - const n = days || 14; - const today = new Date(); today.setHours(0, 0, 0, 0); - const out = []; - for (let i = n - 1; i >= 0; i--) { const d = new Date(today - i * 864e5); out.push({ key: d.toISOString().slice(0, 10), label: d.toISOString().slice(5, 10), items: [] }); } - const idx = Object.fromEntries(out.map((b, i) => [b.key, i])); - for (const b of builds || []) { const k = _day(b.time); if (k in idx) out[idx[k]].items.push(b); } - return out; + 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.
`; -window.CH = { - bucketDays, - // Builds per day, stacked ok (green) over failed (red). - stackedColumns(builds, days) { - const bk = bucketDays(builds, days || 14); - const max = Math.max(1, ...bk.map((b) => b.items.length)); - const W = 640, H = 130, pad = 18, bw = (W - pad * 2) / bk.length; - if (!(builds || []).length) return _empty(H); - const cols = bk.map((b, i) => { - const ok = b.items.filter((x) => x.buildOk !== false).length, fail = b.items.length - ok; - const x = pad + i * bw + bw * 0.15, w = bw * 0.7; - const okH = (ok / max) * (H - pad * 2), failH = (fail / max) * (H - pad * 2); - const okY = H - pad - okH, failY = okY - failH; - return `` + - (fail ? `` : '') + - (i % 2 === 0 ? `${b.label}` : ''); - }).join(''); - return `${cols}`; - }, - // Average build duration (seconds) per day as a line. - line(builds, days) { - const bk = bucketDays((builds || []).filter((b) => b.durationMs), days || 14); - const avg = bk.map((b) => b.items.length ? b.items.reduce((s, x) => s + (x.durationMs || 0), 0) / b.items.length / 1000 : 0); - const max = Math.max(1, ...avg), W = 640, H = 130, pad = 18; - if (!avg.some((v) => v)) return _empty(H, 'No timed builds yet.'); - const pts = avg.map((v, i) => `${(pad + i * ((W - pad * 2) / (bk.length - 1 || 1))).toFixed(1)},${(H - pad - (v / max) * (H - pad * 2)).toFixed(1)}`); - const dots = pts.map((p) => { const [x, y] = p.split(','); return ``; }).join(''); - return `${dots}peak ${Math.round(max)}s`; - }, - // Upload health per target: ok vs failed counts as horizontal bars. - hbars(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 _empty(96, 'No uploads yet.'); - const max = Math.max(1, ...rows.map((r) => r.ok + r.fail)), W = 640, rh = 22; - const bars = rows.map((r, i) => { - const y = i * rh + 4, okW = (r.ok / max) * (W - 160), failW = (r.fail / max) * (W - 160); - return `${r.t}` + - `` + - `` + - `${r.ok}✓ ${r.fail}✗`; - }).join(''); - return `${bars}`; - }, - // Builds per environment as colored bars. - envSplit(builds) { - const by = {}; for (const b of builds || []) if (b.env) by[b.env] = (by[b.env] || 0) + 1; - const entries = Object.entries(by); if (!entries.length) return _empty(96); - const max = Math.max(1, ...entries.map(([, n]) => n)), W = 640, rh = 22; - const bars = entries.map(([e, n], i) => { const y = i * rh + 4, w = (n / max) * (W - 140); return `${e}${n}`; }).join(''); - return `${bars}`; - }, -}; +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 index a911503..34e0f38 100644 --- a/build-helper/public/js/cmdk.js +++ b/build-helper/public/js/cmdk.js @@ -2,7 +2,7 @@ // #cmdk), a fuzzy-ish substring filter over nav actions + every scanned project, keyboard-driven. 'use strict'; -let _sel = 0, _items = []; +let _ckSel = 0, _items = []; function _overlay() { let o = el('#bh-cmdk'); @@ -33,7 +33,7 @@ function _catalog() { function _render(q) { const s = (q || '').trim().toLowerCase(); _items = _catalog().filter((it) => !s || it.label.toLowerCase().includes(s)); - _sel = 0; + _ckSel = 0; const list = el('#cmdk-list'); list.innerHTML = _items.length ? _items.map((it, i) => ``).join('') @@ -42,8 +42,8 @@ function _render(q) { } function _highlight(i) { - _sel = 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 === _sel)); + _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(); } @@ -53,9 +53,9 @@ window.CMDK = { 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(_sel + 1); el('#cmdk-list').children[_sel]?.scrollIntoView({ block: 'nearest' }); } - else if (e.key === 'ArrowUp') { e.preventDefault(); _highlight(_sel - 1); el('#cmdk-list').children[_sel]?.scrollIntoView({ block: 'nearest' }); } - else if (e.key === 'Enter') { e.preventDefault(); _run(_sel); } + 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(); }; }, diff --git a/build-helper/public/js/dashboard.js b/build-helper/public/js/dashboard.js index 666a2fc..0003b8f 100644 --- a/build-helper/public/js/dashboard.js +++ b/build-helper/public/js/dashboard.js @@ -1,96 +1,93 @@ -// Build Helper frontend — Dashboard view (window.V.dashboard). KPIs + range/project/env filters + -// hand-rolled charts + the project-sources manager + the sortable/draggable health table. Loaded -// after views.js (reuses _body/_skel) and charts.js/health-table.js. +// 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 _F = () => (BH.filter = BH.filter || { range: '30', project: '', env: '', search: '' }); -const _cutoff = (r) => (r && +r ? Date.now() - +r * 864e5 : 0); -function _filteredBuilds() { - const f = _F(), c = _cutoff(f.range); - return (BH.builds || []).filter((b) => (!c || +new Date(b.time) >= c) && (!f.project || b.path === f.project) && (!f.env || b.env === f.env)); -} +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 = _skel(6); - const [d, suggest, grades] = await Promise.all([ - API.dashboard().catch(() => ({ projects: [], builds: [], storage: {}, sources: {} })), - API.suggestRoots(), API.securityGrades(), - ]); - BH.projects = (d.projects || []).map((p) => ({ ...p, grade: grades[p.path] || null })); - BH.builds = d.builds || []; BH.storage = d.storage || {}; BH.running = d.running || {}; - BH.sources = d.sources || { roots: [], pinned: [], recursive: false }; - const f = _F(); const envs = [...new Set(BH.builds.map((b) => b.env).filter(Boolean))]; - const opt = (v, l, sel) => ``; - _body().innerHTML = ` -
-
-
- - - - -
-
-
- ${C.sourcesPanel(BH, suggest)} -
-
Projects
- - - -
-
`; - renderStats(); renderTable(); wireSources(); - const onFilter = (k) => (e) => { _F()[k] = e.target.value; renderStats(); if (k === 'project') renderTable(); }; - el('#f-range').onchange = onFilter('range'); el('#f-project').onchange = onFilter('project'); el('#f-env').onchange = onFilter('env'); - el('#bh-search').oninput = (e) => { _F().search = e.target.value; renderTable(); }; - el('#f-density').onclick = () => { localStorage.setItem('bh-density', HT.density() === 'compact' ? 'comfortable' : 'compact'); V.dashboard(); }; - el('#bh-refresh').onclick = () => V.dashboard(); - if (window.tourPrompt && BH.projects.length) tourPrompt('bh-tour-dashboard', 'the dashboard', () => startTourFor('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(); +} -// KPI tiles + charts, recomputed from the current filter. -function renderStats() { - const b = _filteredBuilds(); - const k = el('#bh-kpis'); if (k) k.innerHTML = C.kpis({ builds: b, projects: BH.projects, storage: BH.storage }); - const box = el('#bh-charts'); if (!box) return; - const panel = (label, svg) => `
${label}
${svg}
`; - const days = +(_F().range) >= 90 || +(_F().range) === 0 ? 30 : 14; - box.innerHTML = panel('Builds over time', CH.stackedColumns(b, days)) + - panel('Build duration', CH.line(b, days)) + panel('Upload health', CH.hbars(b)) + panel('By environment', CH.envSplit(b)); +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: [] }; -// The health table, filtered by search + the project dropdown. -function renderTable() { - const f = _F(), s = (f.search || '').trim().toLowerCase(); - let list = BH.projects; - if (f.project) list = list.filter((p) => p.path === f.project); - if (s) list = list.filter((p) => (p.name + ' ' + p.path).toLowerCase().includes(s)); - const cnt = el('#bh-count'); if (cnt) cnt.textContent = s || f.project ? `${list.length} of ${BH.projects.length}` : `${BH.projects.length} app${BH.projects.length === 1 ? '' : 's'}`; - HT.render(el('#bh-table'), list, { - open: (p) => V.project(p), - setup: (p) => (window.openAppSetup ? openAppSetup(p) : V.project(p)), - reveal: (p) => API.reveal(p), - favorite: (p, fav) => API.post('/api/app/favorite', { path: p, favorite: fav }).then(() => V.dashboard()), - reorder: (paths) => API.post('/api/app/order', { paths }).then(() => toast('Order saved', 'ok')), - }); +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('')}
` : ''}
`; } -// 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); }); +// 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 index 362d9fe..1835cb2 100644 --- a/build-helper/public/js/detail.js +++ b/build-helper/public/js/detail.js @@ -1,43 +1,52 @@ -// Build Helper frontend — build detail (window.V.detail). Opens a modal for one build record: -// metadata, per-artifact download / copy-path / re-upload to a target, TestFlight re-manage, and -// Play rollback/re-promote. Streams every action into a shared log box in the modal. +// 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'; -function _upBtns(r, art) { - const targets = [['onedrive', 'OneDrive'], ['firebase', 'Firebase'], ['play', 'Play'], ['testflight', 'TestFlight']]; - return targets.map(([id, l]) => ``).join(''); -} +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) { - const d = await API.buildDetail(time).catch(() => null); - if (!d || !d.build) return toast('Build not found', 'err'); - const r = d.build; - const b = openModal({ title: `${r.project} v${verName(r.version)}`, subtitle: `${r.env} · ${relTime(r.time)}`, size: '680px' }); - const up = Object.entries(r.upload || {}).map(([k, v]) => `${esc(k)} ${v}`).join(' '); - b.innerHTML = ` -
- build ${esc(String(r.buildNumber || ''))} - ${r.branch ? `branch ${esc(r.branch)}` : ''} - ${r.commit ? `commit ${d.commitUrl ? `${esc(String(r.commit).slice(0, 8))}` : esc(String(r.commit).slice(0, 8))}` : ''} - ${r.durationMs ? `${fmtDur(r.durationMs)}` : ''}${r.by ? `by ${esc(r.by)}` : ''}
-
${up || 'no uploads recorded'}
-
Artifacts
-
${(r.artifacts || []).length ? r.artifacts.map((a) => `
-
${esc(a.name)} - ${fmtBytes(a.size || 0)} - Download -
-
${_upBtns(r, a)}
`).join('') : '
No artifacts on disk.
'}
-
- - - ${r.playVersionCode ? `` : ''} -
- `; - const logBox = () => { const x = el('#dt-log'); x.classList.remove('hidden'); return x; }; - b.querySelectorAll('.bh-cp').forEach((x) => x.onclick = () => navigator.clipboard.writeText(x.dataset.p).then(() => toast('Path copied', 'ok'))); - b.querySelectorAll('.bh-reup').forEach((x) => x.onclick = () => API.stream('/api/retry-upload', { path: r.path, artifactName: x.dataset.art, target: x.dataset.target, notes: r.whatsNew || '' }, logBox())); - el('#dt-share').onclick = () => (window.openShare ? openShare(r) : null); - el('#dt-tf').onclick = () => API.stream('/api/testflight/manage', { path: r.path, buildNumber: r.buildNumber, notes: r.whatsNew || '' }, logBox()); - const rb = el('#dt-rb'); if (rb) rb.onclick = () => API.stream('/api/rollback', { path: r.path, versionCode: r.playVersionCode, track: el('#dt-track').value }, logBox()); + 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 index 64a9049..2d9cdef 100644 --- a/build-helper/public/js/health-table.js +++ b/build-helper/public/js/health-table.js @@ -1,67 +1,72 @@ -// Build Helper frontend — the project-health table (window.HT). Sortable columns, drag-to-reorder -// (persisted via /api/app/order, only while unsorted), favorite toggle, density toggle, per-row -// security grade, and a "Set up" / "Open" call-to-action. Renders into a container + wires events; -// data ops call back into the dashboard, which re-fetches and re-renders. +// 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 _gradeCls = (l) => ({ A: 'good', B: 'brand', C: 'warn', D: 'crit', F: 'crit' }[l] || 'muted'); -function _gradeBadge(g) { - if (!g || !g.letter) return '—'; - return ` ${esc(g.letter)}`; -} -const _sorters = { - name: (a, b) => a.name.localeCompare(b.name), - builds: (a, b) => (b.buildCount || 0) - (a.buildCount || 0), - last: (a, b) => (b.lastBuild ? +new Date(b.lastBuild.time) : 0) - (a.lastBuild ? +new Date(a.lastBuild.time) : 0), - grade: (a, b) => ((b.grade ? b.grade.score : -1) - (a.grade ? a.grade.score : -1)), +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; -window.HT = { - density() { return localStorage.getItem('bh-density') || 'comfortable'; }, - render(container, projects, h) { - const sort = BH.tableSort || ''; - const list = sort ? [...projects].sort(_sorters[sort]) : projects; // server order = favorites+order - const dense = this.density() === 'compact'; - const pad = dense ? 'py-1' : 'py-2'; - const th = (key, label, extra) => `${label}${sort === key ? ' ↓' : ''}`; - container.innerHTML = list.length ? `
- ${th('name', 'App')}${th('', 'Envs')}${th('', 'Version')}${th('last', 'Last build')}${th('builds', 'Builds')}${th('grade', 'Sec')} - ${list.map((p) => this.row(p, pad, !sort)).join('')}
` - : `
${projects.length ? 'No projects match your search.' : 'No Flutter apps found. Add a scan root or pin a project in Project sources above.'}
`; - this.wire(container, list, h, !sort); - }, - row(p, pad, canDrag) { - const lb = p.lastBuild, health = p.needsConfig ? 'warn' : (p.healthOk ? 'ok' : 'warn'); - const last = lb ? `${lb.buildOk ? '✓' : '✗'} v${esc(verName(lb.version))} · ${esc(lb.env || '')} · ${relTime(lb.time)}` : '—'; - const cta = p.needsConfig ? `` : ``; - return ` - ${C.dot(health)} - - ${C.envChips(p)} - v${esc(verName(p.version))} - ${last} - ${p.buildCount || 0} - ${_gradeBadge(p.grade)} - - - ${cta ? `${cta}` : ''}`; - }, - wire(container, list, h, canDrag) { - container.querySelectorAll('[data-sort]').forEach((t) => t.onclick = () => { BH.tableSort = BH.tableSort === t.dataset.sort ? '' : t.dataset.sort; this.render(container, list, h); }); - container.querySelectorAll('[data-open]').forEach((b) => b.onclick = () => h.open(b.dataset.open)); - container.querySelectorAll('[data-setup]').forEach((b) => b.onclick = () => h.setup(b.dataset.setup)); - container.querySelectorAll('[data-reveal]').forEach((b) => b.onclick = (e) => { e.stopPropagation(); h.reveal(b.dataset.reveal); }); - container.querySelectorAll('[data-fav]').forEach((b) => b.onclick = () => h.favorite(b.dataset.fav, !list.find((p) => p.path === b.dataset.fav)?.favorite)); - if (canDrag) this.wireDrag(container, h); - }, - // HTML5 drag-reorder: on drop, emit the new path order for /api/app/order. - wireDrag(container, h) { - let from = null; - container.querySelectorAll('tr[draggable]').forEach((tr) => { - tr.ondragstart = () => { from = tr; tr.style.opacity = '.4'; }; - tr.ondragend = () => { tr.style.opacity = ''; }; - tr.ondragover = (e) => e.preventDefault(); - tr.ondrop = (e) => { e.preventDefault(); if (!from || from === tr) return; const rows = [...container.querySelectorAll('tr[draggable]')]; const fi = rows.indexOf(from), ti = rows.indexOf(tr); tr.parentNode.insertBefore(from, fi < ti ? tr.nextSibling : tr); h.reorder([...container.querySelectorAll('tr[draggable]')].map((r) => r.dataset.path)); }; - }); - }, -}; +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 index 69c2bcb..bf12b86 100644 --- a/build-helper/public/js/history.js +++ b/build-helper/public/js/history.js @@ -1,41 +1,75 @@ -// Build Helper frontend — build-history list (window.renderHistory) with multi-select bulk actions: -// re-upload selected builds' artifacts to a target, share, or open details. Rows also open the -// detail view. Used by build-run.js renderBuilds() on the project screen. +// 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'; -function _selected(box) { return [...box.querySelectorAll('.bh-hsel:checked')].map((c) => c.dataset.time); } -function _byTime(t) { return (BH.project && BH.project.builds || []).find((b) => b.time === t) || (BH.builds || []).find((b) => b.time === t); } - -function renderHistory(box, builds) { - BH.project = BH.project || {}; BH.project.builds = builds; - if (!builds.length) { box.innerHTML = '
No builds yet for this app.
'; return; } - box.innerHTML = builds.slice(0, 30).map((b) => `
- -
${C.buildRow(b)}
-
`).join(''); - const bulk = el('#bh-bulk'); - const sync = () => { const n = _selected(box).length; if (bulk) bulk.innerHTML = n ? `${n} selected - - ` : ''; wireBulk(box); }; - box.querySelectorAll('.bh-hsel').forEach((c) => c.onchange = sync); - box.querySelectorAll('[data-detail]').forEach((d) => d.onclick = () => (window.V.detail ? V.detail(d.dataset.detail) : null)); - box.querySelectorAll('[data-share]').forEach((d) => d.onclick = () => { const b = _byTime(d.dataset.share); if (b && window.openShare) openShare(b); }); - sync(); +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 || ''; } -// Bulk re-upload: run /api/retry-upload for each selected build's first artifact into a shared log. -function wireBulk(box) { - const up = el('#bulk-up'); if (!up) return; - up.onclick = async () => { - const target = el('#bulk-target').value, times = _selected(box); - const b = openModal({ title: `Re-upload ${times.length} build(s) → ${target}`, size: '560px' }); - b.innerHTML = '
'; - const log = el('#bulk-log'); - for (const t of times) { - const rec = _byTime(t); const art = (rec && rec.artifacts || [])[0]; - if (!art) continue; - await new Promise((res) => API.stream('/api/retry-upload', { path: rec.path, artifactName: art.name, target, notes: rec.whatsNew || '' }, log, res)); - } - toast('Re-upload finished', 'ok'); +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/notes.js b/build-helper/public/js/notes.js deleted file mode 100644 index e179a24..0000000 --- a/build-helper/public/js/notes.js +++ /dev/null @@ -1,58 +0,0 @@ -// Build Helper frontend — release-notes helpers (window.NOTES): fill a per-env notes box from a -// template, the grouped commits since the last build, or the tracker tasks referenced in commits; -// plus the Jira issue picker (search existing + create new) whose selection rides along in the build. -'use strict'; - -const _tpl = (env) => `What's new in ${env.toUpperCase()}:\n- \n\nFixes:\n- `; -function _sel(env) { BH.jiraSel = BH.jiraSel || {}; return (BH.jiraSel[env] = BH.jiraSel[env] || []); } -function _renderChips(card, env) { - const s = _sel(env), lbl = card.querySelector('.bh-jira-sel'); - if (lbl) lbl.textContent = s.length ? s.map((t) => t.key).join(', ') : ''; -} - -window.NOTES = { - // Fill the notes textarea in the clicked button's env card. - fill(btn) { - const card = btn.closest('.bh-envcard'), env = btn.dataset.env, ta = card.querySelector('.bh-notes'); - if (btn.dataset.src === 'template') { ta.value = _tpl(env); return; } - if (btn.dataset.src === 'commits') { ta.value = 'Loading commits…'; API.changelog(BH.sel, env).then((r) => ta.value = (r.text || '').trim() || 'No new commits since the last build.'); return; } - // tracker: pull commit-scraped tasks, fill notes + preselect them in the Jira picker. - ta.value = 'Loading tasks…'; - API.tasks(BH.sel, env).then((r) => { - ta.value = (r.text || '').trim() || 'No tracker tasks referenced in commits.'; - BH.jiraSel = BH.jiraSel || {}; BH.jiraSel[env] = (r.items || []).map((i) => ({ key: i.key, title: i.title || '', state: i.state || '' })); - _renderChips(card, env); - }); - }, - // Modal: search existing Jira issues to attach, or create a new one. Selection stored per env. - jiraPicker(btn) { - const env = btn.dataset.env, card = btn.closest('.bh-envcard'); - const b = openModal({ title: 'Jira issues', subtitle: env.toUpperCase(), size: '560px' }); - b.innerHTML = `
-
-
Create a new issue
-
`; - const cur = () => new Set(_sel(env).map((t) => t.key)); - const search = async () => { - el('#jres').innerHTML = '
Searching…
'; - const r = await API.jiraSearch(BH.sel, el('#jq').value.trim()); - const set = cur(); - el('#jres').innerHTML = (r.issues || []).length ? r.issues.map((i) => ``).join('') - : `
${r.error ? esc(r.error) : 'No matches (is Jira connected in Setup?).'}
`; - el('#jres').querySelectorAll('.jpick').forEach((c) => c.onchange = () => { - const s = _sel(env), key = c.dataset.key; - if (c.checked) { if (!s.find((t) => t.key === key)) s.push({ key, title: c.dataset.title, state: '' }); } - else BH.jiraSel[env] = s.filter((t) => t.key !== key); - _renderChips(card, env); - }); - }; - el('#jgo').onclick = search; el('#jq').onkeydown = (e) => { if (e.key === 'Enter') search(); }; - el('#jcreate').onclick = async () => { - const summary = el('#jnew').value.trim(); if (!summary) return; - const r = await API.post('/api/tracker/issue', { path: BH.sel, summary }); - if (!r.ok) return toast(r.error || 'Create failed', 'err'); - _sel(env).push({ key: r.key, title: summary, state: '' }); _renderChips(card, env); toast(`Created ${r.key}`, 'ok'); el('#jnew').value = ''; - }; - search(); - }, -}; diff --git a/build-helper/public/js/project.js b/build-helper/public/js/project.js index 8ef6a39..fe794f1 100644 --- a/build-helper/public/js/project.js +++ b/build-helper/public/js/project.js @@ -1,86 +1,64 @@ -// Build Helper frontend — Project (Build) view: composes the build form (build-form.js), release -// notes / Jira picker (notes.js), and the poll-based runner (build-run.js). Renders the header, -// wires every control, and reattaches a running build's log on entry. +// 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'; BH.jiraSel = {}; 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 pill = (ok, on, off) => ok ? `${on}` : `${off}`; - const flags = { onedriveConnected: d.onedriveConnected, firebaseCli: d.firebaseCli, globalPlay: d.globalPlay, play: d.play, globalApple: d.globalApple, apple: d.apple }; +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)}` : ''} -
- ${pill(h.envDetected, 'env ok', 'no env')}${pill(h.signing, 'signed', 'unsigned')} - - - -
-
-
${esc(app.path)}
- ${app.needsConfig ? '
No AppMode env detected — open App Setup to configure it before building.
' : ''} +
+ ${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('')} + +
+
- -
-
-
Environments & version
- ${(app.envs || []).map((e) => BF.envCard(e, nn[e.key] != null ? nn[e.key] : 1, base, flags)).join('') || '
No environments detected.
'} -
Artifacts
-
${ARTS.map((a) => C.artChip(a, a.id === 'apk')).join('')}
-
${BF.branchSelect(d.git)}
- ${BF.advanced()} -
-
- - -
-
-
-
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); - el('#bh-appsetup').onclick = () => (window.openAppSetup ? openAppSetup(BH.sel) : toast('App Setup coming soon', 'info')); - el('#bh-analytics').onclick = () => (window.V.analytics ? V.analytics(BH.sel) : toast('Analytics coming soon', 'info')); - _body().querySelectorAll('.bh-art').forEach((a) => a.onclick = () => a.classList.toggle('on')); - _body().querySelectorAll('.bh-bump').forEach((b) => b.onclick = () => { const inp = _body().querySelector(`.bh-ver[data-env="${b.dataset.env}"]`); inp.value = BF.bump(inp.value, b.dataset.k); }); - _body().querySelectorAll('.bh-fill').forEach((b) => b.onclick = () => NOTES.fill(b)); - _body().querySelectorAll('.bh-jira').forEach((b) => b.onclick = () => NOTES.jiraPicker(b)); - el('#bh-build').onclick = () => doBuild(); - el('#bh-stop').onclick = () => API.stopBuild().then(() => toast('Stopping current build…', 'info')); - renderBuilds(d.builds || []); - reattachBuild(); -}; - -// 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/share-actions.js b/build-helper/public/js/share-actions.js index 8e2b601..725577a 100644 --- a/build-helper/public/js/share-actions.js +++ b/build-helper/public/js/share-actions.js @@ -1,41 +1,69 @@ -// Build Helper frontend — share follow-ups (window.shareEmail / shareJira). Rendered inline under the -// share card: email the build to client groups (BCC via /api/email/send) or file a Jira "Mobile -// Release" with the card attached (/api/tracker/release). Both stream progress into a log box. +// 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'; -async function shareEmail(r, cv) { - const box = el('#sc-extra'); - const groups = await API.groups(r.repo || ''); - const art = (r.artifacts || [])[0]; - box.innerHTML = `
-
Client groups
-
${groups.length ? groups.map((g) => ``).join('') : 'No groups — set them in App Setup, or add addresses below.'}
- - - - -
`; - el('#em-send').onclick = () => { - const chosen = [...box.querySelectorAll('.em-g:checked')].map((c) => c.value); - const buttons = art ? [{ label: 'Install', url: location.origin + API.installUrl(art.url) }] : []; - const log = el('#em-log'); log.classList.remove('hidden'); - API.stream('/api/email/send', { repo: r.repo || '', appName: r.project, version: verName(r.version), description: el('#em-desc').value, groups: chosen, extra: el('#em-extra').value, subject: el('#em-subject').value, buttons }, log); +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'; }; } -function shareJira(r, cv) { - const box = el('#sc-extra'); - box.innerHTML = `
- - - - -
`; - el('#jr-go').onclick = () => { - const fields = { [`${r.env}Version`]: verName(r.version), buildNumber: String(r.buildNumber || ''), releaseNotes: r.whatsNew || '', releaseDate: (r.time || '').slice(0, 10) }; - const body = { path: r.path, summary: el('#jr-sum').value, description: el('#jr-desc').value, fields, linkKeys: (r.tasks || []).map((t) => t.key) }; - if (el('#jr-attach').checked) { body.pngBase64 = cv.toDataURL('image/png'); body.filename = `${r.project}-${verName(r.version)}.png`; } - const log = el('#jr-log'); log.classList.remove('hidden'); - API.stream('/api/tracker/release', body, log); +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 index 56e9c62..ecca1ec 100644 --- a/build-helper/public/js/share.js +++ b/build-helper/public/js/share.js @@ -1,62 +1,68 @@ -// Build Helper frontend — share a build (window.openShare). Draws a branded card on with a -// same-origin QR (so toDataURL stays untainted), copy/download, team + client text, and buttons that -// hand off to share-actions.js for emailing client groups and filing a Jira release. +// 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 _installFull(p) { return location.origin + API.installUrl(p); } -function _upList(r) { return Object.entries(r.upload || {}).filter(([, v]) => v === 'ok').map(([k]) => k); } - -function teamText(r) { - const up = _upList(r); - return `✅ ${r.project} v${verName(r.version)} (${r.env}) built` - + (up.length ? `\n↑ ${up.join(', ')}` : '') - + (r.onedriveUrl ? `\nOneDrive: ${r.onedriveUrl}` : '') - + (r.tasks && r.tasks.length ? `\nTasks: ${r.tasks.map((t) => t.key).join(', ')}` : ''); +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 clientText(r) { - const art = (r.artifacts || [])[0]; - return `${r.project} ${verName(r.version)} is ready to test.\n` - + (art ? `Install: ${_installFull(art.url)}\n` : '') - + (r.whatsNew ? `\nWhat's new:\n${r.whatsNew}` : ''); +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(); } -// Draw the card. Colors pulled from the live kit.css tokens so it matches the theme. -function drawCard(cv, r) { - const g = cv.getContext('2d'), W = cv.width, H = cv.height; - const css = getComputedStyle(document.documentElement), c = (v, f) => (css.getPropertyValue(v).trim() || f); - g.fillStyle = c('--panel', '#131a2a'); g.fillRect(0, 0, W, H); - g.fillStyle = c('--brand', '#6366f1'); g.fillRect(0, 0, W, 12); - g.fillStyle = c('--ink', '#e7eaf1'); g.font = '700 46px Inter, system-ui, sans-serif'; - g.fillText(r.project || 'App', 48, 96); - g.font = '600 30px Inter, system-ui, sans-serif'; g.fillStyle = c('--brand', '#8b93ff'); - g.fillText(`v${verName(r.version)} · ${(r.env || '').toUpperCase()}`, 48, 146); - g.font = '400 20px Inter, system-ui, sans-serif'; g.fillStyle = c('--muted', '#93a0b8'); - const up = _upList(r); g.fillText(up.length ? `Distributed to: ${up.join(', ')}` : 'Build ready', 48, 196); - const arts = (r.artifacts || []).map((a) => a.name.split('.').pop().toUpperCase()); - g.fillText(arts.length ? `Artifacts: ${[...new Set(arts)].join(', ')}` : '', 48, 232); - if (r.whatsNew) { g.font = '400 18px Inter, system-ui'; g.fillStyle = c('--ink', '#cbd5e1'); r.whatsNew.split('\n').slice(0, 4).forEach((ln, i) => g.fillText(ln.slice(0, 60), 48, 288 + i * 26)); } - const art = (r.artifacts || [])[0]; - if (art) { const img = new Image(); img.onload = () => { g.fillStyle = '#fff'; g.fillRect(W - 236, H - 236, 196, 196); g.drawImage(img, W - 228, H - 228, 180, 180); }; img.src = API.qrUrl(_installFull(art.url), 300); } +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'); + }); } - -window.openShare = function (r) { - const b = openModal({ title: 'Share build', subtitle: `${r.project} v${verName(r.version)} (${r.env})`, size: '680px' }); - b.innerHTML = ` -
- - - - - - -
-
`; - const cv = el('#sc'); drawCard(cv, r); - const copyText = (t, ok) => navigator.clipboard.writeText(t).then(() => toast(ok, 'ok')).catch(() => toast('Copy failed', 'err')); - el('#sc-dl').onclick = () => { const a = document.createElement('a'); a.download = `${r.project}-${verName(r.version)}.png`; a.href = cv.toDataURL('image/png'); a.click(); }; - el('#sc-copy').onclick = () => cv.toBlob((bl) => navigator.clipboard.write([new ClipboardItem({ 'image/png': bl })]).then(() => toast('Image copied', 'ok')).catch(() => toast('Copy not supported here', 'err'))); - el('#sc-team').onclick = () => copyText(teamText(r), 'Team text copied'); - el('#sc-client').onclick = () => copyText(clientText(r), 'Client text copied'); - el('#sc-email').onclick = () => shareEmail(r, cv); - el('#sc-jira').onclick = () => shareJira(r, cv); -}; 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';