-
Notifications
You must be signed in to change notification settings - Fork 0
feat(flutter-packages-builder): create flutter packages builder script #82
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
mrverdant13
merged 6 commits into
main
from
flutter-packages-builder/feat/flutter-packages-builder
Apr 16, 2026
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
5d0d1a8
feat(flutter-packages-builder): create flutter packages builder script
mrverdant13 5f195d0
fix(flutter-packages-builder): use FVM_HOME if available
mrverdant13 3752974
fix(flutter-packages-builder): proper package.json override
mrverdant13 fe55157
fix(flutter-packages-builder): proper error handling when checking fl…
mrverdant13 a44fdae
fix(flutter-packages-builder): proper Flutter packages (with web supp…
mrverdant13 3aa5ba9
fix(flutter-packages-builder): proper Flutter version constraint reso…
mrverdant13 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,298 @@ | ||
| #!/usr/bin/env node | ||
| /** | ||
| * Builds every Flutter package that has a `web/package.json` file, using the | ||
| * lower bound of the Flutter SDK constraint in each package's `pubspec.yaml` | ||
| * (`environment.flutter`). | ||
| * | ||
| * Verification: pinned versions are checked against `fvm list` before | ||
| * building. If a version is missing, the tool exits with an actionable error. | ||
| * | ||
| * After a successful build, writes `build/web/package.json` by merging: | ||
| * - name, description, version → derived from pubspec.yaml | ||
| * - all other fields → carried over from web/package.json | ||
| * | ||
| * A package opts in to this tool by having both `pubspec.yaml` and | ||
| * `web/package.json` inside its directory. | ||
| * | ||
| * Usage: node tool/build-flutter-packages.mjs | ||
| */ | ||
|
|
||
| import { spawnSync } from 'node:child_process'; | ||
| import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; | ||
| import { homedir } from 'node:os'; | ||
| import { dirname, join } from 'node:path'; | ||
| import { fileURLToPath } from 'node:url'; | ||
|
|
||
| const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); | ||
|
|
||
| // ─── pubspec.yaml parser ──────────────────────────────────────────────────── | ||
| // | ||
| // Extracts only the fields this tool needs: | ||
| // name, description, version (top-level scalars / block scalars) | ||
| // environment.flutter (nested scalar) | ||
|
|
||
| function parsePubspec(pubspecPath) { | ||
| const lines = readFileSync(pubspecPath, 'utf8').split('\n'); | ||
| const result = {}; | ||
| let i = 0; | ||
|
|
||
| while (i < lines.length) { | ||
| const line = lines[i]; | ||
| const trimmed = line.trimStart(); | ||
| const indent = line.length - trimmed.length; | ||
|
|
||
| // Only process non-empty, non-comment, top-level lines. | ||
| if (indent !== 0 || !trimmed || trimmed.startsWith('#')) { | ||
| i++; | ||
| continue; | ||
| } | ||
|
|
||
| const colonIdx = trimmed.indexOf(':'); | ||
| if (colonIdx === -1) { i++; continue; } | ||
|
|
||
| const key = trimmed.slice(0, colonIdx).trim(); | ||
| const afterColon = trimmed.slice(colonIdx + 1).trim(); | ||
|
|
||
| if (['name', 'description', 'version'].includes(key)) { | ||
| // Block scalar (">-", ">", "|-", "|"): collect indented continuation lines. | ||
| if (afterColon === '>-' || afterColon === '>' || afterColon === '|-' || afterColon === '|') { | ||
| const folded = afterColon.startsWith('>'); | ||
| const blockLines = []; | ||
| i++; | ||
| while (i < lines.length) { | ||
| const bl = lines[i]; | ||
| // A non-empty line with no leading whitespace means we're back at top-level. | ||
| if (bl.trimStart() === bl && bl.trim() !== '') break; | ||
| if (bl.trim()) blockLines.push(bl.trim()); | ||
| i++; | ||
| } | ||
| result[key] = folded ? blockLines.join(' ') : blockLines.join('\n'); | ||
| continue; | ||
| } | ||
| // Simple / quoted scalar. | ||
| result[key] = afterColon.replace(/^["']|["']$/g, ''); | ||
| } else if (key === 'environment') { | ||
| // Parse the nested environment mapping. | ||
| i++; | ||
| while (i < lines.length) { | ||
| const el = lines[i]; | ||
| const eTrimmed = el.trimStart(); | ||
| const eIndent = el.length - eTrimmed.length; | ||
| // A non-empty, non-comment line at indent 0 means we left the block. | ||
| if (eIndent === 0 && eTrimmed && !eTrimmed.startsWith('#')) break; | ||
| if (eTrimmed.startsWith('flutter:')) { | ||
| result.flutterConstraint = eTrimmed | ||
| .slice('flutter:'.length) | ||
| .trim() | ||
| .replace(/^["']|["']$/g, ''); | ||
| } | ||
| i++; | ||
| } | ||
| continue; | ||
| } | ||
|
|
||
| i++; | ||
| } | ||
|
|
||
| return result; | ||
| } | ||
|
|
||
| // ─── FVM helpers ──────────────────────────────────────────────────────────── | ||
|
|
||
| /** Returns the set of Flutter versions installed locally according to FVM. */ | ||
| function getInstalledFvmVersions() { | ||
| const result = spawnSync('fvm', ['list'], { encoding: 'utf8' }); | ||
| if (result.status !== 0 || result.error) { | ||
| console.error('✗ `fvm list` failed — is FVM installed and on PATH?'); | ||
| process.exit(1); | ||
| } | ||
| // Strip ANSI escape codes before parsing. | ||
| const plain = result.stdout.replace(/\x1b\[[0-9;]*m/g, ''); | ||
| const versions = new Set(); | ||
| for (const line of plain.split('\n')) { | ||
| // Table data rows are delimited by │; the version is the first cell. | ||
| if (!line.includes('│')) continue; | ||
| const firstCell = line.split('│')[1]?.trim() ?? ''; | ||
| if (/^\d+\.\d+\.\d+$/.test(firstCell)) versions.add(firstCell); | ||
| } | ||
| return versions; | ||
| } | ||
|
|
||
| /** | ||
| * Extracts the lower bound version from a Flutter SDK constraint string. | ||
| * | ||
| * Examples: | ||
| * "3.41.1" → "3.41.1" (exact pin is its own lower bound) | ||
| * ">=3.6.0 <4.0.0" → "3.6.0" | ||
| * ">=3.6.0" → "3.6.0" | ||
| * | ||
| * Returns null when no lower bound can be determined (absent, malformed, or | ||
| * uses an exclusive operator — see extractLowerBound callers for the error). | ||
| */ | ||
| function extractLowerBound(constraint) { | ||
| if (!constraint) return null; | ||
| // Exact pin — no operators. | ||
| if (/^\d+\.\d+\.\d+$/.test(constraint)) return constraint; | ||
| // Inclusive lower bound only: ">=x.y.z ..." → x.y.z. | ||
| // Exclusive lower bounds (">x.y.z") are intentionally not matched because | ||
| // x.y.z itself would not satisfy the constraint. | ||
| const m = constraint.match(/>=\s*(\d+\.\d+\.\d+)/); | ||
| return m ? m[1] : null; | ||
| } | ||
|
|
||
| /** | ||
| * Resolves the path to the `flutter` binary for a given FVM-installed version. | ||
| * Returns null if the binary cannot be found on disk. | ||
| */ | ||
| function resolveFvmFlutterBin(version) { | ||
| const fvmHome = process.env.FVM_HOME ?? join(homedir(), 'fvm'); | ||
| const candidate = join(fvmHome, 'versions', version, 'bin', 'flutter'); | ||
| return existsSync(candidate) ? candidate : null; | ||
|
mrverdant13 marked this conversation as resolved.
|
||
| } | ||
|
|
||
| // ─── Package discovery ────────────────────────────────────────────────────── | ||
|
|
||
| /** | ||
| * Returns all workspace packages that have a `web/` directory, sourced from | ||
| * `melos list --dir-exists=web --json` so discovery matches the workspace globs | ||
| * in melos.yaml exactly. | ||
| */ | ||
| function discoverPackages() { | ||
| const result = spawnSync('melos', ['list', '--dir-exists=web', '--json'], { encoding: 'utf8', cwd: repoRoot }); | ||
| if (result.error || result.status !== 0) { | ||
| console.error('✗ `melos list --dir-exists=web --json` failed — is Melos installed and on PATH?'); | ||
| if (result.error) console.error(` ${result.error.message}`); | ||
| process.exit(result.status ?? 1); | ||
| } | ||
|
|
||
| let packages; | ||
| try { | ||
| packages = JSON.parse(result.stdout); | ||
| } catch { | ||
| console.error('✗ Failed to parse `melos list --dir-exists=web --json` output.'); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| return packages.map(({ location }) => ({ | ||
| dir: location, | ||
| pubspecPath: join(location, 'pubspec.yaml'), | ||
| webPkgPath: join(location, 'web', 'package.json'), | ||
| })); | ||
| } | ||
|
Comment on lines
+155
to
+181
|
||
|
|
||
| // ─── Conversion helpers ───────────────────────────────────────────────────── | ||
|
|
||
| //"tap_burst_web_component" → "tap-burst-web-component" | ||
| function toNpmName(n) { | ||
| return n.replaceAll('_', '-'); | ||
| } | ||
|
|
||
| // ─── Main ─────────────────────────────────────────────────────────────────── | ||
|
|
||
| const installedFvmVersions = getInstalledFvmVersions(); | ||
| if (installedFvmVersions.size === 0) { | ||
| console.error('✗ No Flutter versions installed in FVM.'); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| const packages = discoverPackages(); | ||
|
|
||
| if (packages.length === 0) { | ||
| console.error( | ||
| '✗ No packages found.\n' + | ||
| ' Add a web/package.json to a Flutter package directory to opt in.', | ||
| ); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| console.log(`Found ${packages.length} package(s) to build.\n`); | ||
|
|
||
| for (const pkg of packages) { | ||
| const { name, description, version, flutterConstraint } = parsePubspec(pkg.pubspecPath); | ||
|
|
||
| if (!name || !version) { | ||
| console.error(`✗ ${pkg.dir}: pubspec.yaml is missing required fields (name, version).`); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| console.log(`▶ Building ${name}…`); | ||
|
|
||
| // Resolve the Flutter executable to use for this package. | ||
| let flutterCmd; | ||
|
|
||
| const targetVersion = extractLowerBound(flutterConstraint); | ||
|
|
||
| if (!targetVersion) { | ||
| console.error(`✗ ${name}: could not determine a Flutter version from constraint: "${flutterConstraint ?? '(none)'}".`); | ||
|
mrverdant13 marked this conversation as resolved.
|
||
| if (flutterConstraint && /^>\s*\d/.test(flutterConstraint) && !/^>=/.test(flutterConstraint)) { | ||
| console.error(' Exclusive lower bounds (">x.y.z") are not supported. Use ">=" instead.'); | ||
| } | ||
| process.exit(1); | ||
| } | ||
|
|
||
| if (!installedFvmVersions.has(targetVersion)) { | ||
| console.error(`✗ Flutter ${targetVersion} is not installed in FVM.`); | ||
| console.error(` Run: fvm install ${targetVersion}`); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| const bin = resolveFvmFlutterBin(targetVersion); | ||
| if (!bin) { | ||
| console.error( | ||
| `✗ Flutter ${targetVersion} is listed by FVM but the binary was not found on disk.`, | ||
| ); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| flutterCmd = bin; | ||
|
|
||
| // Print the actual Flutter version before building. | ||
| const versionResult = spawnSync(flutterCmd, ['--version'], { encoding: 'utf8' }); | ||
|
mrverdant13 marked this conversation as resolved.
|
||
| if (versionResult.error || versionResult.status !== 0) { | ||
| console.error(`✗ Failed to run \`flutter --version\` for ${name} (${flutterCmd}).`); | ||
| if (versionResult.error) console.error(` ${versionResult.error.message}`); | ||
| process.exit(versionResult.status ?? 1); | ||
| } | ||
| process.stdout.write(`\x1b[32m${versionResult.stdout}\x1b[0m`); | ||
|
|
||
| // Run: <flutter> build web | ||
| const buildResult = spawnSync(flutterCmd, ['build', 'web', '--profile'], { | ||
| cwd: pkg.dir, | ||
| stdio: 'inherit', | ||
| }); | ||
| if (buildResult.status !== 0) { | ||
| console.error(`✗ Flutter build failed for ${name} (exit code ${buildResult.status ?? '?'}).`); | ||
|
mrverdant13 marked this conversation as resolved.
|
||
| process.exit(buildResult.status ?? 1); | ||
| } | ||
|
|
||
| // Write build/web/package.json | ||
| const webPkg = JSON.parse(readFileSync(pkg.webPkgPath, 'utf8')); | ||
| const outputPkg = { | ||
| ...webPkg, | ||
| name: toNpmName(name), | ||
| description: description ?? '', | ||
| version, | ||
| }; | ||
|
|
||
| const outDir = join(pkg.dir, 'build', 'web'); | ||
| const outPath = join(outDir, 'package.json'); | ||
| writeFileSync(outPath, JSON.stringify(outputPkg, null, 2) + '\n', 'utf8'); | ||
|
|
||
| // Pack the build output into a tarball. | ||
| const packResult = spawnSync('npm', ['pack'], { cwd: outDir, stdio: 'inherit' }); | ||
| if (packResult.status !== 0) { | ||
| console.error(`✗ npm pack failed for ${name} (exit code ${packResult.status ?? '?'}).`); | ||
|
mrverdant13 marked this conversation as resolved.
|
||
| process.exit(packResult.status ?? 1); | ||
| } | ||
|
|
||
| // Rename <name>-<version>.tgz → <name>.tgz (drop the version suffix). | ||
| const npmName = toNpmName(name); | ||
| renameSync( | ||
| join(outDir, `${npmName}-${version}.tgz`), | ||
| join(outDir, `${npmName}.tgz`), | ||
| ); | ||
|
mrverdant13 marked this conversation as resolved.
|
||
|
|
||
| console.log(`✓ ${name} → build/web/${npmName}.tgz\n`); | ||
| } | ||
|
|
||
| console.log('All packages built successfully.'); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.