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
17 changes: 9 additions & 8 deletions build-helper/lib/dashboard/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
// artifact disk usage. The client computes KPIs/series/filters locally from `builds`.
const fs = require('fs');
const path = require('path');
const { ARTIFACTS_DIR, BUILDS_JSON, readJson, readConfig, projectsRoot, running } = require('../state');
const { scanProjects, appHealth } = require('../project');
const { ARTIFACTS_DIR, BUILDS_JSON, readJson, readConfig, projectsRoot, projectSources, running } = require('../state');
const { scanAll, appHealth } = require('../project');

// Total + per-project artifact bytes under artifacts/.
function storageData() {
Expand All @@ -21,15 +21,16 @@ function storageData() {

// The per-app scan is the expensive part; cache it briefly so nav-backs and feed refreshes reuse it.
let _dashCache = { key: '', t: 0, projects: null };
function dashboardData(root) {
const rt = root || projectsRoot();
function dashboardData() {
const sources = projectSources();
const key = JSON.stringify(sources);
const all = readJson(BUILDS_JSON, []);
const apps = readConfig().apps || {};
const now = Date.now();
let projects;
if (_dashCache.projects && _dashCache.key === rt && now - _dashCache.t < 4000) { projects = _dashCache.projects; }
if (_dashCache.projects && _dashCache.key === key && now - _dashCache.t < 4000) { projects = _dashCache.projects; }
else {
projects = scanProjects(rt).map((p) => {
projects = scanAll(sources).map((p) => {
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 });
Expand All @@ -43,9 +44,9 @@ function dashboardData(root) {
|| ((a.order == null ? 1e9 : a.order) - (b.order == null ? 1e9 : b.order))
|| ((b.lastBuild ? new Date(b.lastBuild.time) : 0) - (a.lastBuild ? new Date(a.lastBuild.time) : 0))
|| a.name.localeCompare(b.name));
_dashCache = { key: rt, t: now, projects };
_dashCache = { key, t: now, projects };
}
return { builds: all.slice(0, 500), projects, storage: storageData(), running: { busy: running.busy, info: running.info }, root: projectsRoot() };
return { builds: all.slice(0, 500), projects, storage: storageData(), running: { busy: running.busy, info: running.info }, root: projectsRoot(), sources };
}

function cleanupArtifacts(body) {
Expand Down
6 changes: 3 additions & 3 deletions build-helper/lib/dashboard/share.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
// upload a generated card for a build. Single-user + local rclone (no team log to attach to).
const fs = require('fs');
const path = require('path');
const { ROOT, RCLONE, ARTIFACTS_DIR, readConfig, projectsRoot, sendJson } = require('../state');
const { ROOT, RCLONE, ARTIFACTS_DIR, readConfig, projectSources, sendJson } = require('../state');
const { run, captureCmd, rc } = require('../shell');
const { scanProjects, detectApp } = require('../project');
const { scanAll, detectApp } = require('../project');

async function handleShareImage(req, res, body) {
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
Expand Down Expand Up @@ -36,7 +36,7 @@ async function handleMkdirs(req, res) {
const cfg = readConfig();
const base = `${cfg.onedrive.remote}:${cfg.onedrive.base}`;
const mk = async (p) => { try { await run(RCLONE, rc(['mkdir', p]), ROOT, () => {}); log('✓ ' + p); } catch (e) { log('✖ ' + p + ' — ' + (e.message || '').split('\n')[0]); } };
const projects = scanProjects(projectsRoot());
const projects = scanAll(projectSources());
log(`Creating folders for ${projects.length} project(s)…`);
for (const pr of projects) {
const a = detectApp(pr.path); const name = a.repo || a.name;
Expand Down
35 changes: 28 additions & 7 deletions build-helper/lib/project/scan.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,39 @@ function findAppIcon(projectPath) {
return null;
}

function scanProjects(root) {
// Dirs that never contain a sibling app — pruned so recursive scans stay fast.
const PRUNE = new Set(['node_modules', '.dart_tool', 'build', '.git', 'ios', 'android', 'macos', 'windows', 'linux', 'web', '.symlinks', '.fvm', '.idea', 'Pods']);
const isFlutter = (p) => fs.existsSync(path.join(p, 'pubspec.yaml')) && fs.existsSync(path.join(p, 'lib'));

// One directory → the dashboard card projection, or null if it isn't a Flutter app.
function detectCard(p) {
if (!isFlutter(p)) return null;
const a = detectApp(p);
return { name: a.name, path: a.path, version: a.version, currentMode: a.currentMode, modes: Object.keys(a.modes), needsConfig: a.needsConfig, firebase: !!a.firebase, hasIcon: !!findAppIcon(p) };
}

// Scan a root for Flutter apps. recursive → descend up to `depth` levels (a Flutter dir is a leaf).
function scanProjects(root, recursive, depth = 3) {
let ents; try { ents = fs.readdirSync(root, { withFileTypes: true }); } catch { return []; }
const out = [];
for (const e of ents) {
if (!e.isDirectory()) continue;
if (!e.isDirectory() || PRUNE.has(e.name) || e.name.startsWith('.')) continue;
const p = path.join(root, e.name);
if (!fs.existsSync(path.join(p, 'pubspec.yaml'))) continue;
if (!fs.existsSync(path.join(p, 'lib'))) continue;
const a = detectApp(p);
out.push({ name: a.name, path: a.path, version: a.version, currentMode: a.currentMode, modes: Object.keys(a.modes), needsConfig: a.needsConfig, firebase: !!a.firebase, hasIcon: !!findAppIcon(p) });
const card = detectCard(p);
if (card) out.push(card);
else if (recursive && depth > 1) out.push(...scanProjects(p, true, depth - 1));
}
return out.sort((x, y) => x.name.localeCompare(y.name));
}

module.exports = { findAppIcon, scanProjects };
// Every card across all scan roots + pinned project paths, deduped by absolute path.
function scanAll(sources) {
const { roots = [], pinned = [], recursive = false } = sources || {};
const seen = new Set(); const out = [];
for (const c of [...roots.flatMap((r) => scanProjects(r, recursive)), ...pinned.map(detectCard)]) {
if (c && !seen.has(c.path)) { seen.add(c.path); out.push(c); }
}
return out;
}

module.exports = { findAppIcon, scanProjects, detectCard, scanAll };
11 changes: 10 additions & 1 deletion build-helper/lib/routes/apps.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Project + per-app routes: the dashboard payload, project list/detail, per-app config saves,
// health, git metadata, changelog, and the app icon image.
const fs = require('fs');
const { spawn } = require('child_process');
const { BUILDS_JSON, readJson, readConfig, projectsRoot } = require('../state');
const { dashboardData, handleAppSave, handleFavorite, handleReorder } = require('../dashboard');
const { detectApp, scanProjects, gitBranches, gitLastCommit, gitChangelog, groupConventional, nextBuildNumber, appHealth, findAppIcon } = require('../project');
Expand All @@ -11,8 +12,16 @@ const { appleAuth, playAccount } = require('../signing-config');

function register(app) {
const J = app.sendJson;
app.r('GET', '/api/dashboard', ({ res, q }) => J(res, 200, dashboardData(q.get('root'))));
app.r('GET', '/api/dashboard', ({ res }) => J(res, 200, dashboardData()));
app.r('GET', '/api/projects', ({ res, q }) => { const root = q.get('root') || projectsRoot(); return J(res, 200, { root, projects: scanProjects(root) }); });
// Open a project's folder in the OS file manager (Finder / Explorer / xdg). Args array — no shell.
app.r('POST', '/api/reveal', ({ res, body }) => {
const p = String((body || {}).path || '').trim();
if (!p || !fs.existsSync(p)) return J(res, 400, { error: 'path not found' });
const cmd = process.platform === 'darwin' ? 'open' : (process.platform === 'win32' ? 'explorer' : 'xdg-open');
try { spawn(cmd, [p], { detached: true, stdio: 'ignore' }).unref(); } catch {}
return J(res, 200, { ok: true });
}, { body: true });
app.r('GET', '/api/project', ({ res, q }) => {
const p = q.get('path');
if (!p || !fs.existsSync(p)) return J(res, 404, { error: 'not found' });
Expand Down
24 changes: 22 additions & 2 deletions build-helper/lib/routes/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,29 @@
// build-notification + email config, the two OAuth/login SSE flows (OneDrive, Firebase CLI), and the
// environment preflight (doctor). Issue-tracker config is NOT re-wired here — the shared kit
// connectors layer (/api/connectors*) already owns that store; setup.js handleSetupTracker is unused.
const os = require('os');
const fs = require('fs');
const path = require('path');
const { setupStatus, handleSetupPlay, handleSetupApple, handleSetupFirebaseApp, handleSetupFirebaseLogin, handleSetupNotify, handleSetupEmail } = require('../setup');
const { setProjectsRoot } = require('../state');
const { setSources, validateSource } = require('../state');
const { handleSetupRclone } = require('../onedrive');
const { doctorData, doctorInstallCmd } = require('../dashboard');
const { run } = require('../shell');

function register(app) {
const J = app.sendJson;
app.r('GET', '/api/setup', ({ res }) => J(res, 200, setupStatus()));
app.r('POST', '/api/setup/root', ({ res, body }) => J(res, 200, { ok: true, root: setProjectsRoot((body || {}).root) }), { body: true });
// Persist project sources (roots + pinned paths + recursive). Validates each entry; returns the
// saved set plus any `invalid` ones the UI should flag (kept out of the persisted config).
app.r('POST', '/api/setup/sources', ({ res, body }) => {
const s = body || {}; const invalid = [];
const keep = (arr, pinned) => (arr || []).filter((p) => { const v = validateSource(p, pinned); if (!v.ok) invalid.push({ path: p, kind: pinned ? 'pinned' : 'root', reason: v.reason }); return v.ok; });
const sources = setSources({ roots: keep(s.roots, false), pinned: keep(s.pinned, true), recursive: s.recursive });
return J(res, 200, { ok: true, sources, invalid });
}, { body: true });
app.r('GET', '/api/setup/suggest-roots', ({ res }) => J(res, 200, { roots: suggestRoots() }));
// Back-compat single-root save (superseded by the sources manager UI). ponytail: drop once no caller.
app.r('POST', '/api/setup/root', ({ res, body }) => { const r = String((body || {}).root || '').trim(); const s = setSources({ roots: r ? [r] : [], pinned: [], recursive: false }); return J(res, 200, { ok: true, root: s.roots[0] || '' }); }, { body: true });
app.r('POST', '/api/setup/play', ({ res, body }) => handleSetupPlay(res, body), { body: true });
app.r('POST', '/api/setup/apple', ({ res, body }) => handleSetupApple(res, body), { body: true });
app.r('POST', '/api/setup/firebase-app', ({ res, body }) => handleSetupFirebaseApp(res, body), { body: true });
Expand All @@ -37,4 +50,11 @@ function installTool(res, body) {
.catch((e) => { send('error', { message: e.message }); res.end(); });
}

// Common Flutter-project locations under $HOME that actually exist — one-click add suggestions.
function suggestRoots() {
const home = os.homedir();
return ['Projects', 'dev', 'Development', 'StudioProjects', 'AndroidStudioProjects', 'mobileApps', 'code', 'src']
.map((d) => path.join(home, d)).filter((p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } });
}

module.exports = { register };
27 changes: 23 additions & 4 deletions build-helper/lib/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,28 @@ function readConfig() {
return c;
}
function writeConfig(c) { writeJson(CONFIG_JSON, c); }
function projectsRoot() { return readConfig().root || process.env.FLUTTER_PROJECTS || path.join(os.homedir(), 'mobileApps'); }
// Persist (or clear) the scanned projects root. Blank → fall back to FLUTTER_PROJECTS / ~/mobileApps.
function setProjectsRoot(root) { const c = readConfig(); const v = (root || '').trim(); if (v) c.root = v; else delete c.root; writeConfig(c); return projectsRoot(); }
// Where Build Helper looks for apps: N scan roots + individually pinned project paths, plus a
// recursive flag. Migrates the legacy single `root` (or FLUTTER_PROJECTS / ~/mobileApps) into roots[].
function projectSources() {
const c = readConfig();
const roots = Array.isArray(c.roots) ? c.roots : [c.root || process.env.FLUTTER_PROJECTS || path.join(os.homedir(), 'mobileApps')];
return { roots, pinned: Array.isArray(c.pinned) ? c.pinned : [], recursive: !!c.recursive };
}
function projectsRoot() { return projectSources().roots[0] || path.join(os.homedir(), 'mobileApps'); } // back-compat single root
const _uniq = (a) => [...new Set((a || []).map((x) => String(x || '').trim()).filter(Boolean))];
function setSources(s) {
const c = readConfig();
c.roots = _uniq(s.roots); c.pinned = _uniq(s.pinned); c.recursive = !!s.recursive; delete c.root; // migrated
writeConfig(c); return projectSources();
}
// A source is usable if it exists; a pinned path must also look like a Flutter project (pubspec + lib).
function validateSource(p, isPinned) {
const v = String(p || '').trim();
if (!v) return { ok: false, reason: 'empty' };
if (!fs.existsSync(v)) return { ok: false, reason: 'not found' };
if (isPinned && !(fs.existsSync(path.join(v, 'pubspec.yaml')) && fs.existsSync(path.join(v, 'lib')))) return { ok: false, reason: 'not a Flutter project' };
return { ok: true };
}

// ---------- running-build tracker (for Stop + one-at-a-time lock) ----------
const running = { children: new Set(), aborted: false, busy: false, info: null, log: [], seq: 0 };
Expand All @@ -62,6 +81,6 @@ function sendJson(res, code, obj) {
module.exports = {
ID, DATA, ROOT, PORT, FLUTTER, RCLONE, ONEDRIVE_REMOTE, ONEDRIVE_BASE,
CONFIG_JSON, BUILDS_JSON, CREDS_DIR, ARTIFACTS_DIR, LOGS_DIR, CHANGELOGS_DIR, LOGICAL_ENVS,
readJson, writeJson, esc, readConfig, writeConfig, projectsRoot, setProjectsRoot,
readJson, writeJson, esc, readConfig, writeConfig, projectsRoot, projectSources, setSources, validateSource,
running, stopRunning, feedClients, feedHistory, broadcast, sendJson,
};
4 changes: 3 additions & 1 deletion build-helper/public/js/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ window.API = {
signing: (path) => _get('/api/signing/status?path=' + encodeURIComponent(path)),
changelog: (path, env) => _get('/api/changelog?' + _q({ path, env, grouped: 1 })),

saveRoot: (root) => postJson('/api/setup/root', { root }), // persist the projects root
saveSources: (sources) => postJson('/api/setup/sources', sources), // { roots, pinned, recursive }
suggestRoots: () => _get('/api/setup/suggest-roots').then((r) => r.roots || []).catch(() => []),
reveal: (path) => postJson('/api/reveal', { path }), // open a folder in Finder
post: (url, body) => postJson(url, body), // kit.js
stream: (url, body, box, done) => streamSSE(url, body, box, done), // kit.js
stopBuild: () => postJson('/api/build/stop', {}),
Expand Down
32 changes: 32 additions & 0 deletions build-helper/public/js/components.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ window.C = {
${C.dot(health)}
<span class="font-display font-semibold text-[14px] truncate flex-1">${esc(p.name)}</span>
${p.favorite ? '<span class="text-amber-300 text-xs">★</span>' : ''}
<span class="bh-reveal cursor-pointer text-slate-500 hover:text-slate-200 [&>svg]:w-3.5 [&>svg]:h-3.5" data-reveal="${esc(p.path)}" title="Reveal in Finder">${ICON.folder}</span>
<span class="text-[11px] font-mono text-slate-500">v${esc(verName(p.version))}</span>
</div>
${C.envChips(p)}
Expand All @@ -50,6 +51,37 @@ window.C = {
</button>`;
},

// The project-sources manager: scan roots + pinned projects + recursive toggle + suggestions.
sourcesPanel(BH, suggest) {
const s = BH.sources || { roots: [], pinned: [], recursive: false };
const row = (val, kind) => `<div class="flex items-center gap-2 py-1">
<span class="text-[12px] font-mono truncate flex-1" title="${esc(val)}">${esc(val)}</span>
<button class="btn btn-ghost text-[11px] !px-1.5 !py-0.5 [&>svg]:w-3.5 [&>svg]:h-3.5" data-reveal="${esc(val)}" title="Reveal in Finder">${ICON.folder}</button>
<button class="btn btn-ghost text-[11px] !px-1.5 !py-0.5 text-rose-300" data-rm="${esc(val)}" data-kind="${kind}" title="Remove">✕</button>
</div>`;
const list = (arr, kind, empty) => arr.length ? arr.map((v) => row(v, kind)).join('') : `<div class="text-[11px] text-slate-500 py-1">${empty}</div>`;
const sugg = (suggest || []).filter((p) => !s.roots.includes(p));
return `<div class="surface p-4 mb-4">
<div class="flex items-center gap-2 mb-2">
<div class="eyebrow">Project sources</div>
<label class="ml-auto flex items-center gap-1.5 text-[12px] text-slate-400 cursor-pointer"><input id="bh-recursive" type="checkbox" ${s.recursive ? 'checked' : ''}/> Recursive scan</label>
</div>
<div class="grid sm:grid-cols-2 gap-5">
<div>
<div class="text-[11px] text-slate-500 mb-1">Scan roots</div>
${list(s.roots, 'root', 'No roots — add one below.')}
<div class="flex gap-1.5 mt-2"><input id="bh-add-root" class="field text-[12px] font-mono py-1 px-2 flex-1" placeholder="/path/to/projects" spellcheck="false"/><button id="bh-addroot" class="btn btn-secondary text-xs px-2 py-1">Add root</button></div>
${sugg.length ? `<div class="flex flex-wrap gap-1.5 mt-2">${sugg.map((p) => `<button class="btn btn-ghost text-[11px] !px-2 !py-0.5" data-suggest="${esc(p)}">+ ${esc(p.replace(/^.*\//, '~/'))}</button>`).join('')}</div>` : ''}
</div>
<div>
<div class="text-[11px] text-slate-500 mb-1">Pinned projects</div>
${list(s.pinned, 'pinned', 'No pinned projects.')}
<div class="flex gap-1.5 mt-2"><input id="bh-add-pin" class="field text-[12px] font-mono py-1 px-2 flex-1" placeholder="/path/to/one/flutter/app" spellcheck="false"/><button id="bh-addpin" class="btn btn-secondary text-xs px-2 py-1">Pin app</button></div>
</div>
</div>
</div>`;
},

// One artifact toggle chip for the build form.
artChip(a, on) {
return `<button class="bh-art surface px-3 py-2 text-left ${on ? 'on' : ''}" data-art="${a.id}">
Expand Down
1 change: 1 addition & 0 deletions build-helper/public/js/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
// local tool, so a plain object is plenty.
window.BH = {
view: 'dashboard', root: '', toolName: 'Build Helper',
sources: { roots: [], pinned: [], recursive: false }, // scan roots + pinned project paths
projects: [], builds: [], storage: {}, running: { busy: false },
project: null, // the /api/project payload for the open project
sel: null, // open project path
Expand Down
Loading
Loading