mirror of
https://github.com/MeshEnvy/mesh-forge.git
synced 2026-07-04 17:02:03 +02:00
Enhance repo scanning and tag handling by integrating meshforge.yaml parsing. Added support for environment capabilities and meshforge configuration in scan and tag operations. Updated related functions to aggregate and filter environment names and tags based on the meshforge profile.
This commit is contained in:
Vendored
+2
@@ -14,6 +14,7 @@ import type * as auth from "../auth.js";
|
||||
import type * as deviceReports from "../deviceReports.js";
|
||||
import type * as helpers from "../helpers.js";
|
||||
import type * as http from "../http.js";
|
||||
import type * as lib_meshforgeYaml from "../lib/meshforgeYaml.js";
|
||||
import type * as lib_platformioScan from "../lib/platformioScan.js";
|
||||
import type * as lib_r2 from "../lib/r2.js";
|
||||
import type * as lib_tagSemver from "../lib/tagSemver.js";
|
||||
@@ -35,6 +36,7 @@ declare const fullApi: ApiFromModules<{
|
||||
deviceReports: typeof deviceReports;
|
||||
helpers: typeof helpers;
|
||||
http: typeof http;
|
||||
"lib/meshforgeYaml": typeof lib_meshforgeYaml;
|
||||
"lib/platformioScan": typeof lib_platformioScan;
|
||||
"lib/r2": typeof lib_r2;
|
||||
"lib/tagSemver": typeof lib_tagSemver;
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
export interface MeshforgeTagsConfig {
|
||||
/** JS regex tested against each tag name. Tags not matching are hidden. */
|
||||
include?: string
|
||||
}
|
||||
|
||||
export interface MeshforgeTargetsConfig {
|
||||
/** JS regex tested against each env name. Non-matching envs are hidden. */
|
||||
include?: string
|
||||
/**
|
||||
* Template string that becomes a regex after substituting captures from the tag matched
|
||||
* against tags.include. Supported placeholders: ${captureName}, ${captureName_snake},
|
||||
* ${captureName_camel}, ${captureName_pascal}, ${1}, ${2}, …
|
||||
* Each substituted segment is regex-escaped before insertion.
|
||||
* When the current tag matches tags.include and this field is set, the expanded pattern
|
||||
* replaces targets.include for filtering. Falls back to targets.include when the tag
|
||||
* does not match or this field is absent.
|
||||
*/
|
||||
include_template?: string
|
||||
/** AND-filter: every listed capability must be present on the env (e.g. ["wifi"]). */
|
||||
require_capabilities?: string[]
|
||||
}
|
||||
|
||||
export interface MeshforgeConfig {
|
||||
tags?: MeshforgeTagsConfig
|
||||
targets?: MeshforgeTargetsConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal parser for the meshforge.yaml format. Only the known schema is handled;
|
||||
* unknown keys are silently ignored.
|
||||
*
|
||||
* Supports:
|
||||
* - 2-space YAML-like indentation (0 / 2 / 4 spaces)
|
||||
* - Quoted ("…" or '…') and unquoted scalar values
|
||||
* - Inline flow lists [a, b, c]
|
||||
* - Line comments starting with #
|
||||
*/
|
||||
export function parseMeshforgeYaml(raw: string): MeshforgeConfig | null {
|
||||
const config: MeshforgeConfig = {}
|
||||
let inMeshforge = false
|
||||
let section: 'tags' | 'targets' | null = null
|
||||
|
||||
for (const rawLine of raw.split(/\r?\n/)) {
|
||||
const stripped = rawLine.replace(/#.*$/, '').trimEnd()
|
||||
if (!stripped.trim()) continue
|
||||
|
||||
const indent = stripped.length - stripped.trimStart().length
|
||||
const content = stripped.trimStart()
|
||||
|
||||
if (indent === 0) {
|
||||
inMeshforge = content === 'meshforge:'
|
||||
section = null
|
||||
continue
|
||||
}
|
||||
|
||||
if (!inMeshforge) continue
|
||||
|
||||
if (indent === 2) {
|
||||
if (content === 'tags:') {
|
||||
section = 'tags'
|
||||
if (!config.tags) config.tags = {}
|
||||
} else if (content === 'targets:') {
|
||||
section = 'targets'
|
||||
if (!config.targets) config.targets = {}
|
||||
} else {
|
||||
section = null
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (indent === 4 && section) {
|
||||
const kv = content.match(/^(\w+):\s*(.*)$/)
|
||||
if (!kv) continue
|
||||
const [, key, rawVal] = kv
|
||||
if (section === 'tags') {
|
||||
if (key === 'include') config.tags!.include = parseScalar(rawVal)
|
||||
} else if (section === 'targets') {
|
||||
if (key === 'include') config.targets!.include = parseScalar(rawVal)
|
||||
else if (key === 'include_template') config.targets!.include_template = parseScalar(rawVal)
|
||||
else if (key === 'require_capabilities') config.targets!.require_capabilities = parseInlineList(rawVal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!config.tags && !config.targets) return null
|
||||
return config
|
||||
}
|
||||
|
||||
function parseScalar(raw: string): string {
|
||||
const s = raw.trim()
|
||||
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
||||
return s.slice(1, -1)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
function parseInlineList(raw: string): string[] {
|
||||
const s = raw.trim()
|
||||
if (s.startsWith('[') && s.endsWith(']')) {
|
||||
return s
|
||||
.slice(1, -1)
|
||||
.split(',')
|
||||
.map(p => parseScalar(p.trim()))
|
||||
.filter(Boolean)
|
||||
}
|
||||
return s ? [parseScalar(s)] : []
|
||||
}
|
||||
@@ -7,17 +7,17 @@ export function parseIniSections(content: string): Record<string, Record<string,
|
||||
let current: string | null = null
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith(";") || trimmed.startsWith("#")) continue
|
||||
if (!trimmed || trimmed.startsWith(';') || trimmed.startsWith('#')) continue
|
||||
const sec = trimmed.match(/^\[(.+)\]$/)
|
||||
if (sec) {
|
||||
current = sec[1]
|
||||
if (!sections[current]) sections[current] = {}
|
||||
continue
|
||||
}
|
||||
if (current && trimmed.includes("=")) {
|
||||
const [k, ...rest] = trimmed.split("=")
|
||||
if (current && trimmed.includes('=')) {
|
||||
const [k, ...rest] = trimmed.split('=')
|
||||
const key = k.trim()
|
||||
const value = rest.join("=").trim()
|
||||
const value = rest.join('=').trim()
|
||||
sections[current][key] = value
|
||||
}
|
||||
}
|
||||
@@ -35,14 +35,14 @@ export function extractEnvNamesFromSections(sections: Record<string, Record<stri
|
||||
|
||||
export type VirtualFileMap = Record<string, string>
|
||||
|
||||
/** Strip first path segment (GitHub zip root folder). */
|
||||
/** Strip first path segment (GitHub zip root folder). Captures *.ini and meshforge.yaml. */
|
||||
export function normalizeZipPaths(files: Record<string, Uint8Array>, decode: (u: Uint8Array) => string): VirtualFileMap {
|
||||
const out: VirtualFileMap = {}
|
||||
for (const path of Object.keys(files)) {
|
||||
const parts = path.split("/").filter(Boolean)
|
||||
const parts = path.split('/').filter(Boolean)
|
||||
if (parts.length < 2) continue
|
||||
const rel = parts.slice(1).join("/")
|
||||
if (!rel.endsWith(".ini")) continue
|
||||
const rel = parts.slice(1).join('/')
|
||||
if (!rel.endsWith('.ini') && rel !== 'meshforge.yaml') continue
|
||||
try {
|
||||
out[rel] = decode(files[path])
|
||||
} catch {
|
||||
@@ -52,14 +52,83 @@ export function normalizeZipPaths(files: Record<string, Uint8Array>, decode: (u:
|
||||
return out
|
||||
}
|
||||
|
||||
export function collectPlatformioEnvsFromFiles(files: VirtualFileMap): { envNames: string[]; grouped: { flat: string[] } } {
|
||||
const allEnvs = new Set<string>()
|
||||
for (const content of Object.values(files)) {
|
||||
const sections = parseIniSections(content)
|
||||
for (const n of extractEnvNamesFromSections(sections)) {
|
||||
allEnvs.add(n)
|
||||
}
|
||||
/** Aggregate all PlatformIO sections from every .ini file in the virtual file map. */
|
||||
function aggregateIniSections(files: VirtualFileMap): Record<string, Record<string, string>> {
|
||||
const allSections: Record<string, Record<string, string>> = {}
|
||||
for (const [path, content] of Object.entries(files)) {
|
||||
if (!path.endsWith('.ini')) continue
|
||||
Object.assign(allSections, parseIniSections(content))
|
||||
}
|
||||
const envNames = [...allEnvs].sort()
|
||||
return { envNames, grouped: { flat: envNames } }
|
||||
return allSections
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the value of a given key for a PlatformIO section, following `extends` chains.
|
||||
* Returns null when the key is not found or the chain is circular / broken.
|
||||
*/
|
||||
function resolveKey(
|
||||
sectionName: string,
|
||||
key: string,
|
||||
allSections: Record<string, Record<string, string>>,
|
||||
visited: Set<string> = new Set()
|
||||
): string | null {
|
||||
if (visited.has(sectionName)) return null
|
||||
visited.add(sectionName)
|
||||
const sec = allSections[sectionName]
|
||||
if (!sec) return null
|
||||
if (sec[key] !== undefined) return sec[key]
|
||||
const ext = sec['extends']
|
||||
if (!ext) return null
|
||||
for (const parent of ext.split(',').map(s => s.trim())) {
|
||||
const val = resolveKey(parent, key, allSections, new Set(visited))
|
||||
if (val !== null) return val
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive capability strings from a platform identifier and optional board name.
|
||||
* - espressif32 (any variant, including pioarduino URLs) → wifi, ble
|
||||
* - nordicnrf52 → ble
|
||||
* - raspberrypi / platform-raspberrypi → wifi + ble only for boards ending in _w / picow
|
||||
* - everything else → no capabilities assumed
|
||||
*/
|
||||
function capabilitiesFromPlatform(platform: string, board: string): string[] {
|
||||
const p = platform.toLowerCase()
|
||||
const b = board.toLowerCase()
|
||||
if (p.includes('espressif32')) return ['wifi', 'ble']
|
||||
if (p.includes('nordicnrf52')) return ['ble']
|
||||
if (p.includes('raspberrypi') || p.includes('platform-raspberrypi')) {
|
||||
if (b.includes('picow') || b.endsWith('_w')) return ['wifi', 'ble']
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect capabilities for each env by resolving `platform` and `board` through the
|
||||
* full extends chain across all aggregated ini sections.
|
||||
*/
|
||||
export function detectEnvCapabilities(
|
||||
envNames: string[],
|
||||
allSections: Record<string, Record<string, string>>
|
||||
): Record<string, string[]> {
|
||||
const result: Record<string, string[]> = {}
|
||||
for (const name of envNames) {
|
||||
const sectionName = `env:${name}`
|
||||
const platform = resolveKey(sectionName, 'platform', allSections) ?? ''
|
||||
const board = resolveKey(sectionName, 'board', allSections) ?? ''
|
||||
result[name] = capabilitiesFromPlatform(platform, board)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function collectPlatformioEnvsFromFiles(files: VirtualFileMap): {
|
||||
envNames: string[]
|
||||
grouped: { flat: string[] }
|
||||
envCapabilities: Record<string, string[]>
|
||||
} {
|
||||
const allSections = aggregateIniSections(files)
|
||||
const envNames = extractEnvNamesFromSections(allSections)
|
||||
const envCapabilities = detectEnvCapabilities(envNames, allSections)
|
||||
return { envNames, grouped: { flat: envNames }, envCapabilities }
|
||||
}
|
||||
|
||||
+46
-2
@@ -2,6 +2,7 @@ import { unzipSync, strFromU8 } from "fflate"
|
||||
import { v } from "convex/values"
|
||||
import { api, internal } from "./_generated/api"
|
||||
import { collectPlatformioEnvsFromFiles, normalizeZipPaths } from "./lib/platformioScan"
|
||||
import { parseMeshforgeYaml } from "./lib/meshforgeYaml"
|
||||
import { action, internalMutation, mutation, query } from "./_generated/server"
|
||||
|
||||
export const getByRepoSha = query({
|
||||
@@ -57,7 +58,33 @@ export const ensureScan = mutation({
|
||||
.first()
|
||||
|
||||
if (existing?.scanStatus === "complete") {
|
||||
return { scanId: existing._id, status: "complete" as const }
|
||||
const names = existing.envNames ?? []
|
||||
const caps = existing.envCapabilities as Record<string, unknown> | undefined
|
||||
const capsComplete =
|
||||
caps != null &&
|
||||
typeof caps === "object" &&
|
||||
(names.length === 0 || names.every(n => Array.isArray(caps[n])))
|
||||
if (capsComplete) {
|
||||
return { scanId: existing._id, status: "complete" as const }
|
||||
}
|
||||
// Completed before envCapabilities existed (or partial write) — rescan same SHA.
|
||||
await ctx.db.patch(existing._id, {
|
||||
scanStatus: "in_progress",
|
||||
envNames: undefined,
|
||||
grouped: undefined,
|
||||
envCapabilities: undefined,
|
||||
meshforgeConfig: undefined,
|
||||
scanError: undefined,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
await ctx.scheduler.runAfter(0, api.repoScans.runArchiveScan, {
|
||||
scanId: existing._id,
|
||||
owner: args.owner,
|
||||
repo: args.repo,
|
||||
ref: args.ref,
|
||||
resolvedSourceSha: args.resolvedSourceSha,
|
||||
})
|
||||
return { scanId: existing._id, status: "in_progress" as const }
|
||||
}
|
||||
if (existing?.scanStatus === "in_progress") {
|
||||
return { scanId: existing._id, status: "in_progress" as const }
|
||||
@@ -91,12 +118,16 @@ export const completeScanInternal = internalMutation({
|
||||
scanId: v.id("repoRefScan"),
|
||||
envNames: v.array(v.string()),
|
||||
grouped: v.any(),
|
||||
envCapabilities: v.any(),
|
||||
meshforgeConfig: v.optional(v.any()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await ctx.db.patch(args.scanId, {
|
||||
scanStatus: "complete",
|
||||
envNames: args.envNames,
|
||||
grouped: args.grouped,
|
||||
envCapabilities: args.envCapabilities,
|
||||
meshforgeConfig: args.meshforgeConfig,
|
||||
scannedAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
scanError: undefined,
|
||||
@@ -143,11 +174,24 @@ export const runArchiveScan = action({
|
||||
}
|
||||
const files = unzipSync(buf)
|
||||
const virtual = normalizeZipPaths(files, u => strFromU8(u, true))
|
||||
const { envNames, grouped } = collectPlatformioEnvsFromFiles(virtual)
|
||||
const { envNames, grouped, envCapabilities } = collectPlatformioEnvsFromFiles(virtual)
|
||||
|
||||
let meshforgeConfig: ReturnType<typeof parseMeshforgeYaml> = null
|
||||
const yamlContent = virtual['meshforge.yaml']
|
||||
if (yamlContent) {
|
||||
try {
|
||||
meshforgeConfig = parseMeshforgeYaml(yamlContent)
|
||||
} catch {
|
||||
// ignore malformed yaml
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.runMutation(internal.repoScans.completeScanInternal, {
|
||||
scanId: args.scanId,
|
||||
envNames,
|
||||
grouped,
|
||||
envCapabilities,
|
||||
meshforgeConfig: meshforgeConfig ?? undefined,
|
||||
})
|
||||
} catch (e) {
|
||||
await ctx.runMutation(internal.repoScans.failScanInternal, {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { v } from "convex/values"
|
||||
import { internal } from "./_generated/api"
|
||||
import { action, internalMutation, query } from "./_generated/server"
|
||||
import { parseMeshforgeYaml, type MeshforgeConfig } from "./lib/meshforgeYaml"
|
||||
import { sortTagEntries, type TagEntry } from "./lib/tagSemver"
|
||||
|
||||
const TAG_TTL_MS = 120_000
|
||||
@@ -44,6 +45,7 @@ export const upsertFromGitHub = internalMutation({
|
||||
etag: v.optional(v.string()),
|
||||
description: v.string(),
|
||||
homepage: v.string(),
|
||||
meshforgeConfig: v.optional(v.any()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const existing = await ctx.db
|
||||
@@ -59,6 +61,7 @@ export const upsertFromGitHub = internalMutation({
|
||||
etag: args.etag,
|
||||
description: args.description,
|
||||
homepage: args.homepage,
|
||||
meshforgeConfig: args.meshforgeConfig,
|
||||
}
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, doc)
|
||||
@@ -89,6 +92,22 @@ export const refresh = action({
|
||||
const description = (repoJson.description ?? "").trim()
|
||||
const homepage = (repoJson.homepage ?? "").trim()
|
||||
|
||||
// Fetch meshforge.yaml from the default branch (no ref = default branch).
|
||||
let meshforgeConfig: MeshforgeConfig | null = null
|
||||
const yamlRes = await fetch(
|
||||
`https://api.github.com/repos/${args.owner}/${args.repo}/contents/meshforge.yaml`,
|
||||
{ headers }
|
||||
)
|
||||
if (yamlRes.ok) {
|
||||
try {
|
||||
const yamlJson = (await yamlRes.json()) as { content?: string }
|
||||
const raw = decodeBase64Utf8(yamlJson.content ?? "")
|
||||
meshforgeConfig = parseMeshforgeYaml(raw)
|
||||
} catch {
|
||||
// ignore fetch/parse errors — profile is optional
|
||||
}
|
||||
}
|
||||
|
||||
const rawTags: TagEntry[] = []
|
||||
let page = 1
|
||||
const perPage = 100
|
||||
@@ -114,6 +133,7 @@ export const refresh = action({
|
||||
tags,
|
||||
description,
|
||||
homepage,
|
||||
meshforgeConfig: meshforgeConfig ?? undefined,
|
||||
})
|
||||
return { ok: true as const, tagCount: tags.length }
|
||||
},
|
||||
|
||||
@@ -18,6 +18,8 @@ export const repoTagListFields = {
|
||||
description: v.optional(v.string()),
|
||||
/** GitHub REST `homepage` (often meshtastic.org–style URL). */
|
||||
homepage: v.optional(v.string()),
|
||||
/** Parsed meshforge.yaml from the repo's default branch, if present. */
|
||||
meshforgeConfig: v.optional(v.any()),
|
||||
}
|
||||
|
||||
export const repoRefScanFields = {
|
||||
@@ -27,6 +29,10 @@ export const repoRefScanFields = {
|
||||
scanStatus: v.union(v.literal("in_progress"), v.literal("complete"), v.literal("failed")),
|
||||
envNames: v.optional(v.array(v.string())),
|
||||
grouped: v.optional(v.any()),
|
||||
/** Detected capability sets keyed by env name, e.g. { "LilyGo_TDeck_repeater": ["wifi","ble"] }. */
|
||||
envCapabilities: v.optional(v.any()),
|
||||
/** Parsed meshforge.yaml config from the scanned source tree, if present. */
|
||||
meshforgeConfig: v.optional(v.any()),
|
||||
scanError: v.optional(v.string()),
|
||||
scannedAt: v.optional(v.number()),
|
||||
scanRunnerRequestId: v.optional(v.string()),
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { MeshforgeConfig } from '@/convex/lib/meshforgeYaml'
|
||||
|
||||
export type { MeshforgeConfig }
|
||||
|
||||
/** Escape a string for literal use inside a RegExp pattern. */
|
||||
export function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a raw string to snake_case.
|
||||
* CamelCase boundaries and non-alphanumeric runs become underscores.
|
||||
* e.g. "clientRole" → "client_role", "client-role" → "client_role"
|
||||
*/
|
||||
export function toSnakeCase(s: string): string {
|
||||
return s
|
||||
.replace(/([A-Z])/g, '_$1')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
}
|
||||
|
||||
/** Convert to lowerCamelCase. e.g. "client_role" → "clientRole" */
|
||||
export function toCamelCase(s: string): string {
|
||||
const parts = toSnakeCase(s).split('_').filter(Boolean)
|
||||
if (!parts.length) return ''
|
||||
return parts[0] + parts.slice(1).map(p => p.charAt(0).toUpperCase() + p.slice(1)).join('')
|
||||
}
|
||||
|
||||
/** Convert to PascalCase. e.g. "client_role" → "ClientRole" */
|
||||
export function toPascalCase(s: string): string {
|
||||
return toSnakeCase(s)
|
||||
.split('_')
|
||||
.filter(Boolean)
|
||||
.map(p => p.charAt(0).toUpperCase() + p.slice(1))
|
||||
.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the effective target RegExp given the currently selected tag and profile config.
|
||||
*
|
||||
* Precedence:
|
||||
* 1. targets.include_template — expanded using captures from the tag matched against
|
||||
* tags.include (when the tag matches and the template is set)
|
||||
* 2. targets.include — used as a static regex
|
||||
* 3. null — no target filter
|
||||
*
|
||||
* Placeholder syntax in include_template:
|
||||
* ${captureName} raw value of the named capture group
|
||||
* ${captureName_snake} snake_case transform
|
||||
* ${captureName_camel} lowerCamelCase transform
|
||||
* ${captureName_pascal} PascalCase transform
|
||||
* ${1}, ${2}, … numbered capture groups
|
||||
* Each substituted segment is regex-escaped before insertion.
|
||||
*/
|
||||
export function buildTargetRegex(tag: string, config: MeshforgeConfig): RegExp | null {
|
||||
const tagsInclude = config.tags?.include
|
||||
const template = config.targets?.include_template
|
||||
|
||||
if (template && tagsInclude) {
|
||||
let tagMatch: RegExpExecArray | null = null
|
||||
try {
|
||||
tagMatch = new RegExp(tagsInclude).exec(tag)
|
||||
} catch {
|
||||
// malformed regex — fall through
|
||||
}
|
||||
if (tagMatch) {
|
||||
const pattern = template.replace(/\$\{([^}]+)\}/g, (_, placeholder: string) => {
|
||||
// Numbered capture: ${1}, ${2}, …
|
||||
const num = Number(placeholder)
|
||||
if (Number.isInteger(num) && num >= 1) {
|
||||
return escapeRegex(tagMatch![num] ?? '')
|
||||
}
|
||||
|
||||
// Detect trailing case suffix
|
||||
let baseName = placeholder
|
||||
let suffix: 'snake' | 'camel' | 'pascal' | '' = ''
|
||||
if (placeholder.endsWith('_snake')) {
|
||||
baseName = placeholder.slice(0, -6)
|
||||
suffix = 'snake'
|
||||
} else if (placeholder.endsWith('_camel')) {
|
||||
baseName = placeholder.slice(0, -6)
|
||||
suffix = 'camel'
|
||||
} else if (placeholder.endsWith('_pascal')) {
|
||||
baseName = placeholder.slice(0, -7)
|
||||
suffix = 'pascal'
|
||||
}
|
||||
|
||||
const rawCapture = tagMatch!.groups?.[baseName] ?? ''
|
||||
let value: string
|
||||
if (suffix === 'snake') value = toSnakeCase(rawCapture)
|
||||
else if (suffix === 'camel') value = toCamelCase(rawCapture)
|
||||
else if (suffix === 'pascal') value = toPascalCase(rawCapture)
|
||||
else value = rawCapture
|
||||
return escapeRegex(value)
|
||||
})
|
||||
try {
|
||||
return new RegExp(pattern)
|
||||
} catch {
|
||||
// malformed expanded pattern — fall through
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (config.targets?.include) {
|
||||
try {
|
||||
return new RegExp(config.targets.include)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** Filter tag list using config.tags.include. Returns full list when no filter is set. */
|
||||
export function filterTagNames(tags: string[], config: MeshforgeConfig): string[] {
|
||||
const inc = config.tags?.include
|
||||
if (!inc) return tags
|
||||
let rx: RegExp
|
||||
try {
|
||||
rx = new RegExp(inc)
|
||||
} catch {
|
||||
return tags
|
||||
}
|
||||
return tags.filter(t => rx.test(t))
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter env names using the effective target regex and capability requirements.
|
||||
* Returns full list when no filter is configured.
|
||||
*
|
||||
* @param envNames All env names from the scan
|
||||
* @param config Parsed meshforge.yaml (or null → no filtering)
|
||||
* @param capabilities envCapabilities map from the scan
|
||||
* @param currentTag Currently selected tag (used for include_template interpolation)
|
||||
*/
|
||||
export function filterEnvNames(
|
||||
envNames: string[],
|
||||
config: MeshforgeConfig | null,
|
||||
capabilities: Record<string, string[]>,
|
||||
currentTag: string
|
||||
): string[] {
|
||||
if (!config) return envNames
|
||||
const rx = buildTargetRegex(currentTag, config)
|
||||
const reqCaps = config.targets?.require_capabilities ?? []
|
||||
|
||||
const isFiltering = rx !== null || reqCaps.length > 0
|
||||
if (isFiltering) {
|
||||
console.debug(
|
||||
'[meshforge] filterEnvNames — tag=%o regex=%o require_capabilities=%o',
|
||||
currentTag,
|
||||
rx?.source ?? '(none)',
|
||||
reqCaps
|
||||
)
|
||||
}
|
||||
if (
|
||||
reqCaps.length > 0 &&
|
||||
envNames.length > 0 &&
|
||||
envNames.every(n => (capabilities[n]?.length ?? 0) === 0)
|
||||
) {
|
||||
console.warn(
|
||||
'[meshforge] envCapabilities is empty for every env — this repoRefScan was likely completed before capability scanning shipped. Open the repo again (ensureScan will rescan) or wait for in_progress to finish.'
|
||||
)
|
||||
}
|
||||
|
||||
return envNames.filter(name => {
|
||||
if (rx && !rx.test(name)) {
|
||||
console.debug('[meshforge] ✗ %o — rejected by regex (%o)', name, rx.source)
|
||||
return false
|
||||
}
|
||||
if (reqCaps.length > 0) {
|
||||
const envCaps = capabilities[name] ?? []
|
||||
const missing = reqCaps.filter(c => !envCaps.includes(c))
|
||||
if (missing.length > 0) {
|
||||
console.debug('[meshforge] ✗ %o — missing capabilities %o (has %o)', name, missing, envCaps)
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (isFiltering) {
|
||||
console.debug('[meshforge] ✓ %o', name)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -8,21 +8,6 @@ function encodeTreePath(ref: string) {
|
||||
return ref.split("/").map(encodeURIComponent).join("/")
|
||||
}
|
||||
|
||||
const DEMO_REPOS: { label: string; owner: string; repo: string; githubUrl: string }[] = [
|
||||
{
|
||||
label: "meshtastic/firmware",
|
||||
owner: "meshtastic",
|
||||
repo: "firmware",
|
||||
githubUrl: "https://github.com/meshtastic/firmware",
|
||||
},
|
||||
{
|
||||
label: "meshcore-dev/MeshCore",
|
||||
owner: "meshcore-dev",
|
||||
repo: "MeshCore",
|
||||
githubUrl: "https://github.com/meshcore-dev/MeshCore",
|
||||
},
|
||||
]
|
||||
|
||||
export default function HomePage() {
|
||||
const navigate = useNavigate()
|
||||
const [input, setInput] = useState("")
|
||||
@@ -77,34 +62,6 @@ export default function HomePage() {
|
||||
<Button className="w-full bg-cyan-600 hover:bg-cyan-700" type="button" onClick={go}>
|
||||
Open a firmware repo
|
||||
</Button>
|
||||
<div className="pt-2 space-y-2">
|
||||
<p className="text-xs text-slate-500 text-center">Try a demo</p>
|
||||
<ul className="space-y-2">
|
||||
{DEMO_REPOS.map(d => (
|
||||
<li
|
||||
key={d.githubUrl}
|
||||
className="flex flex-col sm:flex-row gap-1 sm:gap-3 sm:items-center sm:justify-between rounded-lg border border-slate-800/80 bg-slate-900/40 px-3 py-2"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="text-slate-300 hover:text-cyan-400 text-sm justify-start h-auto py-1 px-0 font-normal"
|
||||
onClick={() => navigate(`/${encodeURIComponent(d.owner)}/${encodeURIComponent(d.repo)}`)}
|
||||
>
|
||||
Open {d.label}
|
||||
</Button>
|
||||
<a
|
||||
className="text-xs text-slate-600 hover:text-slate-400 sm:text-right shrink-0"
|
||||
href={d.githubUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{d.githubUrl.replace(/^https:\/\//, "")}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+38
-13
@@ -23,6 +23,7 @@ import DeviceFlasher from "../components/DeviceFlasher"
|
||||
import { normalizeBuildKey } from "../lib/buildKey"
|
||||
import { buildFailurePresentation } from "../lib/formatBuildErrorSummary"
|
||||
import { homepageHref, homepageLabel } from "../lib/githubHomepage"
|
||||
import { filterEnvNames, filterTagNames, type MeshforgeConfig } from "../lib/meshforgeApplyProfile"
|
||||
import { resolveReadmeRelativeUrl } from "../lib/readmeAssetUrl"
|
||||
import { buildTreeSplatPath, parseTreeSplat } from "../lib/repoTreeUrl"
|
||||
|
||||
@@ -74,8 +75,11 @@ export default function RepoPage() {
|
||||
if (tagData === undefined || !tagData.row) return
|
||||
const tags = tagData.row.tags
|
||||
if (tags.length === 0) return
|
||||
const sorted = sortTagNames(tags.map(t => t.name))
|
||||
const latest = sorted[0]
|
||||
const allSorted = sortTagNames(tags.map(t => t.name))
|
||||
const cfg = tagData.row.meshforgeConfig as MeshforgeConfig | null | undefined
|
||||
const candidates = cfg ? filterTagNames(allSorted, cfg) : allSorted
|
||||
// Fall back to unfiltered list when the profile leaves nothing (e.g. no matching tags yet)
|
||||
const latest = (candidates.length > 0 ? candidates : allSorted)[0]
|
||||
if (!latest) return
|
||||
navigate(`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(latest, null)}`, { replace: true })
|
||||
}, [owner, repo, sourceRef, tagData, navigate, ownerParam, repoParam])
|
||||
@@ -170,8 +174,6 @@ export default function RepoPage() {
|
||||
)
|
||||
|
||||
const envNames = scan?.scanStatus === "complete" ? (scan.envNames ?? []) : []
|
||||
const resolvedTargetEnv =
|
||||
hasRef && targetFromUrl && envNames.length > 0 && envNames.includes(targetFromUrl) ? targetFromUrl : ""
|
||||
|
||||
const [tagDraft, setTagDraft] = useState("")
|
||||
useEffect(() => {
|
||||
@@ -199,6 +201,27 @@ export default function RepoPage() {
|
||||
return sortTagNames(merged)
|
||||
}, [tagData?.row?.tags, sourceRef])
|
||||
|
||||
// meshforgeConfig comes from the default branch (stored on the tag list, available before
|
||||
// a tag is selected so the tag dropdown itself can be filtered).
|
||||
const meshforgeConfig = (tagData?.row?.meshforgeConfig ?? null) as MeshforgeConfig | null
|
||||
const envCapabilities = (scan?.scanStatus === "complete" ? scan.envCapabilities : null) as Record<
|
||||
string,
|
||||
string[]
|
||||
> | null
|
||||
const filteredTagOptions = useMemo(
|
||||
() => filterTagNames(tagOptions, meshforgeConfig ?? {}),
|
||||
[tagOptions, meshforgeConfig]
|
||||
)
|
||||
const filteredEnvNames = useMemo(
|
||||
() => filterEnvNames(envNames, meshforgeConfig, envCapabilities ?? {}, tagDraft),
|
||||
[envNames, meshforgeConfig, envCapabilities, tagDraft]
|
||||
)
|
||||
|
||||
const resolvedTargetEnv =
|
||||
hasRef && targetFromUrl && filteredEnvNames.length > 0 && filteredEnvNames.includes(targetFromUrl)
|
||||
? targetFromUrl
|
||||
: ""
|
||||
|
||||
const [envDraft, setEnvDraft] = useState("")
|
||||
useEffect(() => {
|
||||
if (!hasRef) {
|
||||
@@ -389,7 +412,7 @@ export default function RepoPage() {
|
||||
!resolvedSha ||
|
||||
Boolean(refError) ||
|
||||
!resolvedTargetEnv ||
|
||||
!envNames.includes(resolvedTargetEnv) ||
|
||||
!filteredEnvNames.includes(resolvedTargetEnv) ||
|
||||
!scanReady
|
||||
|
||||
const showCiCard = build && (build.status !== "failed" || witnessedCiFailure)
|
||||
@@ -402,8 +425,10 @@ export default function RepoPage() {
|
||||
? "Scanning…"
|
||||
: scan.scanStatus === "failed"
|
||||
? "Scan failed"
|
||||
: envNames.length === 0
|
||||
? "No targets"
|
||||
: filteredEnvNames.length === 0
|
||||
? envNames.length === 0
|
||||
? "No targets"
|
||||
: "No targets match profile"
|
||||
: "--target--"
|
||||
|
||||
const backToRepoPath = `/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(sourceRef, resolvedTargetEnv || null)}`
|
||||
@@ -619,7 +644,7 @@ export default function RepoPage() {
|
||||
label="Tag"
|
||||
layout="inline"
|
||||
id="mesh-forge-tag"
|
||||
options={tagOptions}
|
||||
options={filteredTagOptions}
|
||||
value={tagDraft}
|
||||
placeholder="--tag--"
|
||||
clearSelectionLabel="Clear tag"
|
||||
@@ -629,18 +654,18 @@ export default function RepoPage() {
|
||||
navigate(`/${ownerParam}/${repoParam}`)
|
||||
return
|
||||
}
|
||||
if (tagOptions.includes(v)) {
|
||||
if (filteredTagOptions.includes(v)) {
|
||||
navigate(`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(v, targetFromUrl)}`)
|
||||
}
|
||||
}}
|
||||
disabled={tagOptions.length === 0}
|
||||
disabled={filteredTagOptions.length === 0}
|
||||
/>
|
||||
{hasRef && scanReady && envNames.length > 0 ? (
|
||||
{hasRef && scanReady && filteredEnvNames.length > 0 ? (
|
||||
<ComboboxField
|
||||
label="Target"
|
||||
layout="inline"
|
||||
id="mesh-forge-target"
|
||||
options={envNames}
|
||||
options={filteredEnvNames}
|
||||
value={envDraft}
|
||||
filterNormalize
|
||||
placeholder="--target--"
|
||||
@@ -654,7 +679,7 @@ export default function RepoPage() {
|
||||
})
|
||||
return
|
||||
}
|
||||
if (envNames.includes(v)) {
|
||||
if (filteredEnvNames.includes(v)) {
|
||||
navigate(`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(sourceRef, v)}`)
|
||||
}
|
||||
}}
|
||||
|
||||
Vendored
+1
-1
Submodule vendor/meshcore-lotato updated: bfd4800f59...deb338927c
Reference in New Issue
Block a user