Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 38 additions & 15 deletions build-helper/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions build-helper/lib/dashboard/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 14 additions & 2 deletions build-helper/lib/dashboard/doctor.js
Original file line number Diff line number Diff line change
@@ -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');

Expand Down Expand Up @@ -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 };
25 changes: 24 additions & 1 deletion build-helper/lib/onedrive.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
19 changes: 19 additions & 0 deletions build-helper/lib/qr.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
'use strict';
// Same-origin QR proxy. The share-card <canvas> 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 };
12 changes: 12 additions & 0 deletions build-helper/lib/routes/apps.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 }) => {
Expand Down
23 changes: 22 additions & 1 deletion build-helper/lib/routes/dist.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@
// Distribution + reporting routes: rollback (re-promote a stored Play versionCode) and retry-upload
// (re-run one upload target without rebuilding), share-card upload + OneDrive folder prep, artifact
// cleanup, CSV / printable build reports, and Jira release / issue creation.
const fs = require('fs');
const path = require('path');
const { ARTIFACTS_DIR } = require('../state');
const { handleRollback, handleRetryUpload, handleShareImage, handleMkdirs, handleTeamCard,
cleanupArtifacts, reportRows, reportCsv, reportHtml } = require('../dashboard');
const { handleCreateRelease, handleCreateIssue } = require('../trackers');
const { handleCreateRelease, handleCreateIssue, handleTasks, jiraSearchFor, handleJiraMeta } = require('../trackers');

function register(app) {
const J = app.sendJson;
Expand All @@ -14,8 +17,26 @@ function register(app) {
app.r('POST', '/api/share/mkdirs', ({ req, res }) => handleMkdirs(req, res));
app.r('POST', '/api/share/card', ({ res, body }) => handleTeamCard(res, body), { body: true });
app.r('POST', '/api/cleanup', ({ res, body }) => J(res, 200, cleanupArtifacts(body)), { body: true });
// On-disk artifacts for one project (build page browser) + delete-one.
app.r('GET', '/api/artifacts', ({ res, q }) => {
const dir = path.join(ARTIFACTS_DIR, path.basename(q.get('project') || ''));
let files = []; try { files = fs.readdirSync(dir).filter((n) => !n.startsWith('_')).map((n) => { const p = path.join(dir, n); return { name: n, path: p, size: fs.statSync(p).size }; }); } catch {}
return J(res, 200, { files });
});
app.r('POST', '/api/artifact/delete', ({ res, body }) => {
const p = path.resolve(String((body || {}).path || ''));
if (!p.startsWith(ARTIFACTS_DIR + path.sep)) return J(res, 400, { error: 'outside artifacts dir' });
try { fs.unlinkSync(p); } catch (e) { return J(res, 400, { error: e.message }); }
return J(res, 200, { ok: true });
}, { body: true });
app.r('POST', '/api/tracker/release', ({ req, res, body }) => handleCreateRelease(req, res, body), { body: true });
app.r('POST', '/api/tracker/issue', ({ res, body }) => handleCreateIssue(res, body), { body: true });
// Build-page Jira picker: commit-scraped tasks (?path=&env=) and issue search to link (?path=&q=).
app.r('GET', '/api/tracker/tasks', ({ res, q }) => handleTasks(res, q.get('path'), q.get('env')));
app.r('GET', '/api/jira/search', ({ res, q }) => jiraSearchFor(res, q.get('path'), q.get('q')));
app.r('GET', '/api/jira/meta', ({ res, q }) => handleJiraMeta(res, q.get('path')));
app.r('POST', '/api/jira/create-release', ({ req, res, body }) => handleCreateRelease(req, res, body), { body: true });
app.r('POST', '/api/jira/create-issue', ({ res, body }) => handleCreateIssue(res, body), { body: true });
// Reports filter by ?range=<days>&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"' });
Expand Down
2 changes: 2 additions & 0 deletions build-helper/lib/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
};
9 changes: 7 additions & 2 deletions build-helper/lib/routes/pages.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
const fs = require('fs');
const path = require('path');
const { installPage, manifestPlist } = require('../pages');
const { proxyQr } = require('../qr');
const { readGroups, handleGroupsSave, handleEmailTest, handleEmailSend } = require('../messaging');
const { feedClients, feedHistory, ARTIFACTS_DIR } = require('../state');
const { feedClients, feedHistory, ARTIFACTS_DIR, LOGS_DIR } = require('../state');

function register(app) {
const J = app.sendJson;
Expand All @@ -16,7 +17,11 @@ function register(app) {
feedClients.add(res);
req.on('close', () => feedClients.delete(res));
});
app.r('GET', '/api/groups', ({ res, q }) => { const g = readGroups(); const repo = q.get('repo') || ''; return J(res, 200, { groups: [...(g['*'] || []), ...(repo ? g[repo] || [] : [])] }); });
// Same-origin QR PNG proxy — the share-card canvas needs an untainted image source.
app.r('GET', '/api/qr', ({ res, q }) => proxyQr(res, q.get('text') || q.get('data'), q.get('size')));
app.r('GET', '/api/groups', ({ res, q }) => { const g = readGroups(); const repo = q.get('repo') || ''; return J(res, 200, { groups: [...(g['*'] || []), ...(repo ? g[repo] || [] : [])], own: repo ? g[repo] || [] : [], global: g['*'] || [] }); });
// Raw build log by its LOGS_DIR-relative path (from a build record's logFile).
app.r('GET', '/api/log', ({ res, q }) => { const p = path.normalize(path.join(LOGS_DIR, q.get('file') || '')); if (!p.startsWith(LOGS_DIR + path.sep) || !fs.existsSync(p)) { res.writeHead(404); return res.end('not found'); } res.writeHead(200, { 'Content-Type': 'text/plain' }); fs.createReadStream(p).pipe(res); });
app.r('POST', '/api/groups/save', ({ res, body }) => handleGroupsSave(res, body), { body: true });
app.r('GET', '/api/email/test', ({ res }) => handleEmailTest(res));
app.r('POST', '/api/email/send', ({ req, res, body }) => handleEmailSend(req, res, body), { body: true });
Expand Down
12 changes: 10 additions & 2 deletions build-helper/lib/routes/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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.
Expand Down
Loading
Loading