From 5b135b12ebf52be32fdc9c4a33d73e08989154e2 Mon Sep 17 00:00:00 2001 From: Ihsen Bouallegue Date: Mon, 19 Oct 2026 18:55:56 +0200 Subject: [PATCH 1/4] feat(cli): publish derives the sdkVersion floor from the built bundle --- src/commands/create.ts | 3 +- src/commands/publish.ts | 15 ++- src/commands/upgrade.test.ts | 33 +++++- src/commands/upgrade.ts | 27 +---- src/commands/validate.ts | 2 +- src/utils/hub-api.ts | 8 ++ src/utils/sdk-floor.test.ts | 204 +++++++++++++++++++++++++++++++++++ src/utils/sdk-floor.ts | 132 +++++++++++++++++++++++ 8 files changed, 392 insertions(+), 32 deletions(-) create mode 100644 src/utils/sdk-floor.test.ts create mode 100644 src/utils/sdk-floor.ts diff --git a/src/commands/create.ts b/src/commands/create.ts index d64f7df..f094dbe 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -127,8 +127,7 @@ export async function runCreate() { pkgContent = pkgContent.replace(/PROJECT_NAME/g, projectName); pkgContent = pkgContent.replace(/PROJECT_DESCRIPTION/g, projectDesc); pkgContent = pkgContent.replace(/CLI_VERSION/g, `^${getCliVersion()}`); - // Nothing is installed yet, so this comes from the SDK the CLI ships - // against. `bun widget upgrade` re-syncs it and every manifest later. + // Nothing is installed yet, so this comes from the SDK the CLI ships against. pkgContent = pkgContent.replace(/SDK_RANGE/g, defaultSdkRange() ?? FALLBACK_SDK_RANGE); writeFileSync(pkgPath, pkgContent); diff --git a/src/commands/publish.ts b/src/commands/publish.ts index 07c22ec..93f7af3 100644 --- a/src/commands/publish.ts +++ b/src/commands/publish.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; -import { cancel, log, select, spinner } from "@clack/prompts"; +import { cancel, confirm, log, select, spinner } from "@clack/prompts"; import semver from "semver"; import { getHubUrl, getToken, renewHubToken, renewingCaller } from "../utils/auth"; import { confirmPublish, fetchScopes, requestPublish, uploadToR2 } from "../utils/hub-api"; @@ -13,6 +13,7 @@ import { readManifest, writeManifest, } from "../utils/manifest"; +import { settleSdkFloor } from "../utils/sdk-floor"; import { typecheckAndReport } from "../utils/typecheck"; import { enforceCliVersion } from "../utils/version"; import { runLogin } from "./login"; @@ -203,6 +204,18 @@ export async function runPublish( } s.stop("Build complete"); + const floor = await settleSdkFloor(cwd, widgetName, hubUrl, { + info: (line) => log.info(line), + confirm: async (message) => { + const answer = await confirm({ message, initialValue: false }); + return !cancelled(answer) && answer; + }, + }); + if (!floor.ok) { + log.error(floor.message); + process.exit(1); + } + // Validate AFTER the build, not before: the build is what produces the // manifest and bundle that get published, so validating beforehand checks a // different document than the one that ships. diff --git a/src/commands/upgrade.test.ts b/src/commands/upgrade.test.ts index a28e557..62f4521 100644 --- a/src/commands/upgrade.test.ts +++ b/src/commands/upgrade.test.ts @@ -1,5 +1,34 @@ -import { describe, expect, test } from "bun:test"; -import { sdkDependencySection } from "./upgrade"; +import { afterAll, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runUpgrade, sdkDependencySection } from "./upgrade"; + +const tmpDir = mkdtempSync(join(tmpdir(), "glasshome-upgrade-")); + +afterAll(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("runUpgrade", () => { + test("leaves every manifest sdkVersion as publish wrote it", async () => { + writeFileSync(join(tmpDir, "package.json"), JSON.stringify({ workspaces: ["widgets"] })); + mkdirSync(join(tmpDir, "widget-sdk"), { recursive: true }); + writeFileSync(join(tmpDir, "widget-sdk", "package.json"), JSON.stringify({ version: "1.9.0" })); + const project = join(tmpDir, "widgets"); + mkdirSync(join(project, "src", "demo"), { recursive: true }); + writeFileSync( + join(project, "package.json"), + JSON.stringify({ peerDependencies: { "@glasshome/widget-sdk": "^1.9.0" } }), + ); + const manifestPath = join(project, "src", "demo", "manifest.json"); + writeFileSync(manifestPath, JSON.stringify({ name: "Demo", sdkVersion: "^1.5.0" })); + + await runUpgrade(project); + + expect(JSON.parse(readFileSync(manifestPath, "utf-8")).sdkVersion).toBe("^1.5.0"); + }); +}); describe("sdkDependencySection", () => { test("finds the SDK in whichever section declares it", () => { diff --git a/src/commands/upgrade.ts b/src/commands/upgrade.ts index d191007..cd5f569 100644 --- a/src/commands/upgrade.ts +++ b/src/commands/upgrade.ts @@ -2,7 +2,6 @@ import { existsSync, readFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { log, spinner } from "@clack/prompts"; import { installedGuide, writeAgentStubs } from "../utils/agent-guide"; -import { discoverWidgets, readManifest, writeManifest } from "../utils/manifest"; import { getInstalledSdkVersion } from "../utils/sdk-version"; import { runValidate } from "./validate"; @@ -55,22 +54,6 @@ function refreshAgentStubs(cwd: string): void { } } -/** Update sdkVersion in all manifest.json files. */ -function syncManifestSdkVersions(cwd: string, newVersion: string): void { - const widgets = discoverWidgets(cwd); - if (widgets.length === 0) return; - - const sdkRange = `^${newVersion}`; - for (const name of widgets) { - const manifest = readManifest(cwd, name); - if (manifest.sdkVersion !== sdkRange) { - manifest.sdkVersion = sdkRange; - writeManifest(cwd, name, manifest); - } - } - log.info(`Updated sdkVersion to ${sdkRange} in ${widgets.length} manifest(s)`); -} - const SDK_PKG = "@glasshome/widget-sdk"; type DependencySection = "peerDependencies" | "devDependencies" | "dependencies"; @@ -147,8 +130,6 @@ export async function runUpgrade(cwd: string, options: UpgradeOptions = {}): Pro const cleanCurrent = currentVersion.replace(/^[\^~]/, ""); if (cleanCurrent === sdkVersion) { log.success(`Already up to date with workspace SDK (${sdkVersion})`); - // Still sync manifests in case they're out of date - syncManifestSdkVersions(cwd, sdkVersion); refreshAgentStubs(cwd); return; } @@ -165,8 +146,6 @@ export async function runUpgrade(cwd: string, options: UpgradeOptions = {}): Pro } s.stop("Dependencies synced"); - // Sync sdkVersion in all manifest.json files - syncManifestSdkVersions(cwd, sdkVersion); refreshAgentStubs(cwd); // Run validate to check compatibility @@ -180,17 +159,13 @@ export async function runUpgrade(cwd: string, options: UpgradeOptions = {}): Pro return; } - // Standalone project: bump the dependency, then sync every manifest to what - // is now installed. A manifest range that excludes the installed SDK fails - // validation, and this is the command that error tells people to run. if (!bumpSdkDependency(cwd, section, options.to ?? "latest")) process.exit(1); const installed = getInstalledSdkVersion(cwd); if (!installed) { - log.warn("No @glasshome/widget-sdk installed here, so manifests were left alone."); + log.warn("No @glasshome/widget-sdk installed here."); return; } - syncManifestSdkVersions(cwd, installed); refreshAgentStubs(cwd); const valid = await runValidate(cwd); if (valid) { diff --git a/src/commands/validate.ts b/src/commands/validate.ts index 36dbfd0..2fbae8d 100644 --- a/src/commands/validate.ts +++ b/src/commands/validate.ts @@ -65,7 +65,7 @@ function validateManifest(manifest: WidgetManifest, installedSdk: string | null) !satisfiesSdk(installedSdk, manifest.sdkVersion) ) { errors.push( - `sdkVersion "${manifest.sdkVersion}" excludes the installed SDK ${installedSdk}, so it claims a compatibility it was not built for; run \`bun widget upgrade\``, + `sdkVersion "${manifest.sdkVersion}" excludes the installed SDK ${installedSdk}, so it claims a compatibility it was not built for; \`bun widget publish\` sets it from the built bundle`, ); } diff --git a/src/utils/hub-api.ts b/src/utils/hub-api.ts index 1e170b0..7867b28 100644 --- a/src/utils/hub-api.ts +++ b/src/utils/hub-api.ts @@ -1,3 +1,5 @@ +import { type DashRelease, dashReleasesSchema } from "@glasshome/widget-contract/dash-releases"; + interface PublishScope { name: string; type: "personal" | "organization"; @@ -15,6 +17,12 @@ export async function fetchScopes(hubUrl: string, token: string): Promise { + const res = await fetch(`${hubUrl}/api/widgets/dash-releases`); + if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`); + return dashReleasesSchema.parse(await res.json()); +} + import type { GridSize } from "@glasshome/widget-sdk"; interface PublishRequestParams { diff --git a/src/utils/sdk-floor.test.ts b/src/utils/sdk-floor.test.ts new file mode 100644 index 0000000..6c7ccd6 --- /dev/null +++ b/src/utils/sdk-floor.test.ts @@ -0,0 +1,204 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { DashRelease } from "@glasshome/widget-contract/dash-releases"; +import { settleSdkFloor } from "./sdk-floor"; + +const tmpDir = mkdtempSync(join(tmpdir(), "glasshome-sdk-floor-")); +let fixtureCount = 0; + +const HISTORY = { + "1.4.0": ["Widget"], + "1.5.0": ["Widget", "useEntity"], + "1.9.0": ["Widget", "useEntity", "useLight"], +}; + +function release(version: string, stage: DashRelease["stage"], widgetSdk: string): DashRelease { + return { version, stage, date: "2026-10-01", widgetSdk, ui: "1.0.0", syncLayer: "0.1.0" }; +} + +let releases: DashRelease[] = []; +let hubUrl = ""; +let server: ReturnType; + +beforeAll(() => { + server = Bun.serve({ + port: 0, + fetch(req) { + if (new URL(req.url).pathname === "/api/widgets/dash-releases") { + return Response.json(releases); + } + return new Response("not found", { status: 404 }); + }, + }); + hubUrl = `http://localhost:${server.port}`; +}); + +afterAll(async () => { + await server.stop(true); + rmSync(tmpDir, { recursive: true, force: true }); +}); + +interface FixtureOptions { + files: Record; + history?: Record | null; + sdkVersion?: string; +} + +function project(opts: FixtureOptions): string { + const dir = join(tmpDir, `project-${fixtureCount++}`); + mkdirSync(join(dir, "src", "demo"), { recursive: true }); + mkdirSync(join(dir, "dist"), { recursive: true }); + writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "fixture" })); + writeFileSync( + join(dir, "src", "demo", "manifest.json"), + `${JSON.stringify({ name: "Demo", sdkVersion: "^1.0.0" }, null, 2)}\n`, + ); + for (const [path, code] of Object.entries(opts.files)) { + mkdirSync(join(dir, "dist", path, ".."), { recursive: true }); + writeFileSync(join(dir, "dist", path), code); + } + const sdk = join(dir, "node_modules", "@glasshome", "widget-sdk"); + mkdirSync(join(sdk, "dist"), { recursive: true }); + const history = opts.history === undefined ? HISTORY : opts.history; + writeFileSync( + join(sdk, "package.json"), + JSON.stringify({ + name: "@glasshome/widget-sdk", + version: opts.sdkVersion ?? "1.9.0", + exports: { + "./package.json": "./package.json", + ...(history ? { "./export-history": "./dist/export-history.json" } : {}), + }, + }), + ); + if (history) writeFileSync(join(sdk, "dist", "export-history.json"), JSON.stringify(history)); + return dir; +} + +function writtenFloor(dir: string): string { + return JSON.parse(readFileSync(join(dir, "src", "demo", "manifest.json"), "utf-8")).sdkVersion; +} + +function recorder(answer = true) { + const lines: string[] = []; + const asked: string[] = []; + return { + lines, + asked, + deps: { + info: (line: string) => lines.push(line), + confirm: async (message: string) => { + asked.push(message); + return answer; + }, + }, + }; +} + +const ENTRY_WITH_LAZY_CHUNK = [ + 'import{Widget as e}from"@glasshome/widget-sdk";', + 'const t=()=>import("./assets/panel-abc123.js");', + "export default e(t);", +].join("\n"); +const LAZY_CHUNK = 'import{useEntity as u}from"@glasshome/widget-sdk";export const p=u;'; + +describe("settleSdkFloor", () => { + test("writes the floor derived from the entry and its lazy chunk, and prints the reach", async () => { + releases = [ + release("1.2.0", "stable", "1.4.0"), + release("1.3.0-beta.1", "beta", "1.5.0"), + release("1.3.0", "stable", "1.5.0"), + release("1.4.0", "stable", "1.9.0"), + ]; + const dir = project({ + files: { "demo.js": ENTRY_WITH_LAZY_CHUNK, "assets/panel-abc123.js": LAZY_CHUNK }, + }); + const out = recorder(); + const result = await settleSdkFloor(dir, "demo", hubUrl, out.deps); + expect(result).toEqual({ ok: true }); + expect(writtenFloor(dir)).toBe("^1.5.0"); + expect(out.lines).toContain("Runs on Dash 1.3.0 and later"); + expect(out.asked).toEqual([]); + }); + + test("a name no released SDK exports aborts before anything is written", async () => { + releases = [release("1.4.0", "stable", "1.9.0")]; + const dir = project({ + files: { "demo.js": 'import{brandNew as b}from"@glasshome/widget-sdk";export default b;' }, + }); + const result = await settleSdkFloor(dir, "demo", hubUrl, recorder().deps); + expect(result).toEqual({ + ok: false, + message: "brandNew is not in any released SDK. Release the SDK first.", + }); + expect(writtenFloor(dir)).toBe("^1.0.0"); + }); + + test("a namespace import keeps the floor at the build SDK and says why", async () => { + releases = [release("1.4.0", "stable", "1.9.0")]; + const dir = project({ + files: { "demo.js": 'import*as s from"@glasshome/widget-sdk";export default s.Widget;' }, + }); + const out = recorder(); + expect(await settleSdkFloor(dir, "demo", hubUrl, out.deps)).toEqual({ ok: true }); + expect(writtenFloor(dir)).toBe("^1.9.0"); + expect(out.lines).toContain("Floor kept at 1.9.0: the widget imports the SDK as a namespace"); + expect(out.lines).toContain("Runs on Dash 1.4.0 and later"); + }); + + test("an SDK without export history keeps the floor at the build SDK", async () => { + releases = [release("1.4.0", "stable", "1.9.0")]; + const dir = project({ + history: null, + files: { "demo.js": 'import{Widget as e}from"@glasshome/widget-sdk";export default e;' }, + }); + const out = recorder(); + expect(await settleSdkFloor(dir, "demo", hubUrl, out.deps)).toEqual({ ok: true }); + expect(writtenFloor(dir)).toBe("^1.9.0"); + expect(out.lines).toContain("Floor kept at 1.9.0: the installed SDK predates export history"); + }); + + test("a chunk the entry names but the build did not write keeps the floor at the build SDK", async () => { + releases = [release("1.4.0", "stable", "1.9.0")]; + const dir = project({ files: { "demo.js": ENTRY_WITH_LAZY_CHUNK } }); + const out = recorder(); + expect(await settleSdkFloor(dir, "demo", hubUrl, out.deps)).toEqual({ ok: true }); + expect(writtenFloor(dir)).toBe("^1.9.0"); + expect(out.lines).toContain("Floor kept at 1.9.0: the built bundle could not be read"); + }); + + test("no stable Dash carrying the floor asks before publishing", async () => { + releases = [release("1.2.0", "stable", "1.4.0"), release("1.3.0-beta.1", "beta", "1.9.0")]; + const dir = project({ + files: { "demo.js": 'import{useLight as l}from"@glasshome/widget-sdk";export default l;' }, + }); + const yes = recorder(true); + expect(await settleSdkFloor(dir, "demo", hubUrl, yes.deps)).toEqual({ ok: true }); + expect(yes.asked).toEqual(["No stable Dash runs this yet. Publish anyway?"]); + + const no = recorder(false); + expect(await settleSdkFloor(dir, "demo", hubUrl, no.deps)).toEqual({ + ok: false, + message: "Publish cancelled.", + }); + }); + + test("an unreachable hub fails closed", async () => { + const dir = project({ + files: { "demo.js": 'import{Widget as e}from"@glasshome/widget-sdk";export default e;' }, + }); + const result = await settleSdkFloor(dir, "demo", "http://127.0.0.1:1", recorder().deps); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.message).toStartWith("Could not read Dash releases from"); + }); + + test("a hub that does not serve the table fails closed", async () => { + const dir = project({ + files: { "demo.js": 'import{Widget as e}from"@glasshome/widget-sdk";export default e;' }, + }); + const result = await settleSdkFloor(dir, "demo", `${hubUrl}/elsewhere`, recorder().deps); + expect(result.ok).toBe(false); + }); +}); diff --git a/src/utils/sdk-floor.ts b/src/utils/sdk-floor.ts new file mode 100644 index 0000000..8d5d702 --- /dev/null +++ b/src/utils/sdk-floor.ts @@ -0,0 +1,132 @@ +import { existsSync, readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join, relative, resolve, sep } from "node:path"; +import { type DashRelease, dashRequirement } from "@glasshome/widget-contract/dash-releases"; +import { deriveFloor } from "@glasshome/widget-contract/sdk-floor"; +import { fetchDashReleases } from "./hub-api"; +import { readManifest, writeManifest } from "./manifest"; +import { isBunGlobalCache } from "./resolution"; +import { getInstalledSdkVersion } from "./sdk-version"; + +const RELATIVE_JS_RE = /["'`](\.{1,2}\/[^"'`\s]+?\.m?js)["'`]/g; + +/** The widget's entry plus every chunk it reaches by relative path; null when one cannot be read. */ +function readBuiltChunks(cwd: string, widgetName: string): string | null { + const distDir = resolve(cwd, "dist"); + const pending = [join(distDir, `${widgetName}.js`)]; + const seen = new Set(); + const parts: string[] = []; + while (pending.length > 0) { + const file = pending.pop(); + if (file === undefined || seen.has(file)) continue; + seen.add(file); + const inside = relative(distDir, file); + if (inside.startsWith("..") || inside.startsWith(sep) || !existsSync(file)) return null; + let code: string; + try { + code = readFileSync(file, "utf-8"); + } catch { + return null; + } + parts.push(code); + for (const match of code.matchAll(RELATIVE_JS_RE)) { + const spec = match[1]; + if (spec) pending.push(resolve(dirname(file), spec)); + } + } + return parts.join("\n"); +} + +function readExportHistory(cwd: string): Record | null { + try { + const require = createRequire(join(cwd, "package.json")); + const path = require.resolve("@glasshome/widget-sdk/export-history"); + if (isBunGlobalCache(path)) return null; + return JSON.parse(readFileSync(path, "utf-8")); + } catch { + return null; + } +} + +type Settled = { ok: true } | { ok: false; message: string }; + +type Floor = { ok: true; floor: string; notice: string | null } | { ok: false; message: string }; + +const KEPT_BECAUSE = { + "namespace-import": "the widget imports the SDK as a namespace", + "direct-ui-import": "the widget imports @glasshome/ui directly", + "no-history": "the installed SDK predates export history", + unreadable: "the built bundle could not be read", +} as const; + +function kept(buildSdk: string, why: keyof typeof KEPT_BECAUSE): Floor { + return { ok: true, floor: buildSdk, notice: `Floor kept at ${buildSdk}: ${KEPT_BECAUSE[why]}` }; +} + +function floorOf(cwd: string, widgetName: string): Floor { + const buildSdk = getInstalledSdkVersion(cwd); + if (!buildSdk) { + return { + ok: false, + message: "No @glasshome/widget-sdk installed here, so no floor can be set.", + }; + } + const history = readExportHistory(cwd); + if (!history) return kept(buildSdk, "no-history"); + const code = readBuiltChunks(cwd, widgetName); + if (code === null) return kept(buildSdk, "unreadable"); + + const result = deriveFloor({ code, history, buildSdk }); + if ("refused" in result) { + const verb = result.refused.length === 1 ? "is" : "are"; + return { + ok: false, + message: `${result.refused.join(", ")} ${verb} not in any released SDK. Release the SDK first.`, + }; + } + if (result.reason === "derived") return { ok: true, floor: result.floor, notice: null }; + return kept(result.floor, result.reason); +} + +/** Oldest stable Dash whose SDK satisfies `range`, as a Dash on no channel would be told to update to. */ +function oldestStableDash(range: string, releases: DashRelease[]): string | null { + const needs = dashRequirement(range, releases, { version: "0.0.0", stage: null }); + return needs?.kind === "update" ? needs.version : null; +} + +export interface FloorDeps { + info: (line: string) => void; + confirm: (message: string) => Promise; +} + +/** Derive the widget's sdkVersion floor from its built bundle, write it, and say which Dash it reaches. */ +export async function settleSdkFloor( + cwd: string, + widgetName: string, + hubUrl: string, + deps: FloorDeps, +): Promise { + const floor = floorOf(cwd, widgetName); + if (!floor.ok) return floor; + const range = `^${floor.floor}`; + const manifest = readManifest(cwd, widgetName); + manifest.sdkVersion = range; + writeManifest(cwd, widgetName, manifest); + if (floor.notice) deps.info(floor.notice); + + let releases: DashRelease[]; + try { + releases = await fetchDashReleases(hubUrl); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + return { ok: false, message: `Could not read Dash releases from ${hubUrl}: ${detail}` }; + } + + const oldest = oldestStableDash(range, releases); + if (oldest) { + deps.info(`Runs on Dash ${oldest} and later`); + return { ok: true }; + } + if (await deps.confirm("No stable Dash runs this yet. Publish anyway?")) return { ok: true }; + return { ok: false, message: "Publish cancelled." }; +} From e07566f8c1edee81aed3dff04048b35356fbd998 Mon Sep 17 00:00:00 2001 From: Ihsen Bouallegue Date: Mon, 19 Oct 2026 19:00:51 +0200 Subject: [PATCH 2/4] fix(cli): floor settles reach before writing, follows real imports only, never hangs without a terminal --- bin/cli.ts | 5 +- package.json | 1 + src/commands/publish.ts | 3 + src/utils/hub-api.ts | 9 ++- src/utils/sdk-floor.test.ts | 150 ++++++++++++++++++++++++++++-------- src/utils/sdk-floor.ts | 71 ++++++++++++----- 6 files changed, 186 insertions(+), 53 deletions(-) diff --git a/bin/cli.ts b/bin/cli.ts index 95a0155..52f882d 100755 --- a/bin/cli.ts +++ b/bin/cli.ts @@ -38,9 +38,10 @@ Commands: --eval prints an expression per render (root = widget shadow root) validate [name] Validate all widgets or a specific one publish [hub-url] Build and publish a widget to Hub + --allow-prerelease-dash publishes when only a beta or rc Dash runs it login [hub-url] Authenticate with GlassHome Hub info [name] Show widget metadata and bundle info - upgrade Upgrade @glasshome/widget-sdk (latest, or --to ) and sync manifests + upgrade Upgrade @glasshome/widget-sdk (latest, or --to ) migrate config Rewrite widgets' raw-zod config to defineConfig + field.* --name targets one; --dry previews without writing help Show this help message @@ -82,6 +83,7 @@ const { values: flags, positionals } = parseArgs({ scope: { type: "string" }, to: { type: "string" }, "re-auth": { type: "boolean" }, + "allow-prerelease-dash": { type: "boolean" }, isolate: { type: "boolean" }, sizes: { type: "string" }, theme: { type: "string" }, @@ -213,6 +215,7 @@ switch (effectiveCommand) { name: str(flags.name), bump: str(flags.bump) as "keep" | "patch" | "minor" | "major" | undefined, scope: str(flags.scope), + allowPrereleaseDash: flags["allow-prerelease-dash"] === true, }); outro("Done"); break; diff --git a/package.json b/package.json index b62ad21..9fef891 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@iconify-json/mdi": "^1.2.3", "@iconify/utils": "^3.1.4", "@tailwindcss/vite": "^4.1.13", + "es-module-lexer": "^2.3.2", "iconify-icon": "^3.0.2", "open": "^11.0.0", "picocolors": "^1.1.1", diff --git a/src/commands/publish.ts b/src/commands/publish.ts index 93f7af3..1c02cc9 100644 --- a/src/commands/publish.ts +++ b/src/commands/publish.ts @@ -22,6 +22,7 @@ interface PublishOptions { name?: string; bump?: "keep" | "patch" | "minor" | "major"; scope?: string; + allowPrereleaseDash?: boolean; } async function authenticate(hubUrl: string): Promise { @@ -210,6 +211,8 @@ export async function runPublish( const answer = await confirm({ message, initialValue: false }); return !cancelled(answer) && answer; }, + interactive: process.stdin.isTTY === true, + allowPrereleaseDash: options?.allowPrereleaseDash === true, }); if (!floor.ok) { log.error(floor.message); diff --git a/src/utils/hub-api.ts b/src/utils/hub-api.ts index 7867b28..e642a2c 100644 --- a/src/utils/hub-api.ts +++ b/src/utils/hub-api.ts @@ -17,8 +17,13 @@ export async function fetchScopes(hubUrl: string, token: string): Promise { - const res = await fetch(`${hubUrl}/api/widgets/dash-releases`); +const DASH_RELEASES_TIMEOUT_MS = 10_000; + +export async function fetchDashReleases( + hubUrl: string, + signal: AbortSignal = AbortSignal.timeout(DASH_RELEASES_TIMEOUT_MS), +): Promise { + const res = await fetch(`${hubUrl}/api/widgets/dash-releases`, { signal }); if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`); return dashReleasesSchema.parse(await res.json()); } diff --git a/src/utils/sdk-floor.test.ts b/src/utils/sdk-floor.test.ts index 6c7ccd6..2e45d0b 100644 --- a/src/utils/sdk-floor.test.ts +++ b/src/utils/sdk-floor.test.ts @@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "nod import { tmpdir } from "node:os"; import { join } from "node:path"; import type { DashRelease } from "@glasshome/widget-contract/dash-releases"; -import { settleSdkFloor } from "./sdk-floor"; +import { type FloorDeps, settleSdkFloor } from "./sdk-floor"; const tmpDir = mkdtempSync(join(tmpdir(), "glasshome-sdk-floor-")); let fixtureCount = 0; @@ -26,9 +26,12 @@ beforeAll(() => { server = Bun.serve({ port: 0, fetch(req) { - if (new URL(req.url).pathname === "/api/widgets/dash-releases") { - return Response.json(releases); + const path = new URL(req.url).pathname; + if (path === "/api/widgets/dash-releases") return Response.json(releases); + if (path === "/garbled/api/widgets/dash-releases") { + return Response.json([{ version: 1, stage: "nightly" }]); } + if (path === "/silent/api/widgets/dash-releases") return new Promise(() => {}); return new Response("not found", { status: 404 }); }, }); @@ -46,6 +49,8 @@ interface FixtureOptions { sdkVersion?: string; } +const UNTOUCHED = "^1.0.0"; + function project(opts: FixtureOptions): string { const dir = join(tmpDir, `project-${fixtureCount++}`); mkdirSync(join(dir, "src", "demo"), { recursive: true }); @@ -53,7 +58,7 @@ function project(opts: FixtureOptions): string { writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "fixture" })); writeFileSync( join(dir, "src", "demo", "manifest.json"), - `${JSON.stringify({ name: "Demo", sdkVersion: "^1.0.0" }, null, 2)}\n`, + `${JSON.stringify({ name: "Demo", sdkVersion: UNTOUCHED }, null, 2)}\n`, ); for (const [path, code] of Object.entries(opts.files)) { mkdirSync(join(dir, "dist", path, ".."), { recursive: true }); @@ -81,28 +86,36 @@ function writtenFloor(dir: string): string { return JSON.parse(readFileSync(join(dir, "src", "demo", "manifest.json"), "utf-8")).sdkVersion; } -function recorder(answer = true) { +function recorder( + overrides: Partial> & { answer?: boolean } = {}, +) { const lines: string[] = []; const asked: string[] = []; - return { - lines, - asked, - deps: { - info: (line: string) => lines.push(line), - confirm: async (message: string) => { - asked.push(message); - return answer; - }, + const deps: FloorDeps = { + info: (line) => lines.push(line), + confirm: async (message) => { + asked.push(message); + return overrides.answer ?? true; }, + interactive: overrides.interactive ?? true, + allowPrereleaseDash: overrides.allowPrereleaseDash ?? false, + releasesSignal: overrides.releasesSignal, }; + return { lines, asked, deps }; } +const PLAIN = 'import{Widget as e}from"@glasshome/widget-sdk";export default e;'; +const NEEDS_1_9 = 'import{useLight as l}from"@glasshome/widget-sdk";export default l;'; const ENTRY_WITH_LAZY_CHUNK = [ 'import{Widget as e}from"@glasshome/widget-sdk";', 'const t=()=>import("./assets/panel-abc123.js");', "export default e(t);", ].join("\n"); const LAZY_CHUNK = 'import{useEntity as u}from"@glasshome/widget-sdk";export const p=u;'; +const ONLY_PRERELEASE = [ + release("1.2.0", "stable", "1.4.0"), + release("1.3.0-beta.1", "beta", "1.9.0"), +]; describe("settleSdkFloor", () => { test("writes the floor derived from the entry and its lazy chunk, and prints the reach", async () => { @@ -123,6 +136,20 @@ describe("settleSdkFloor", () => { expect(out.asked).toEqual([]); }); + test("a relative path in a string literal is no import and is not followed", async () => { + releases = [release("1.4.0", "stable", "1.9.0")]; + const dir = project({ + files: { + "demo.js": + 'import{Widget as e}from"@glasshome/widget-sdk";const c="./copy.js";export default e(c);', + }, + }); + const out = recorder(); + expect(await settleSdkFloor(dir, "demo", hubUrl, out.deps)).toEqual({ ok: true }); + expect(writtenFloor(dir)).toBe("^1.4.0"); + expect(out.lines).not.toContain("Floor kept at 1.9.0: the built bundle could not be read"); + }); + test("a name no released SDK exports aborts before anything is written", async () => { releases = [release("1.4.0", "stable", "1.9.0")]; const dir = project({ @@ -133,7 +160,7 @@ describe("settleSdkFloor", () => { ok: false, message: "brandNew is not in any released SDK. Release the SDK first.", }); - expect(writtenFloor(dir)).toBe("^1.0.0"); + expect(writtenFloor(dir)).toBe(UNTOUCHED); }); test("a namespace import keeps the floor at the build SDK and says why", async () => { @@ -148,19 +175,30 @@ describe("settleSdkFloor", () => { expect(out.lines).toContain("Runs on Dash 1.4.0 and later"); }); - test("an SDK without export history keeps the floor at the build SDK", async () => { + test("a direct ui import keeps the floor at the build SDK and says why", async () => { releases = [release("1.4.0", "stable", "1.9.0")]; const dir = project({ - history: null, - files: { "demo.js": 'import{Widget as e}from"@glasshome/widget-sdk";export default e;' }, + files: { + "demo.js": + 'import{Widget as e}from"@glasshome/widget-sdk";import{Card as c}from"@glasshome/ui/solid";export default e(c);', + }, }); const out = recorder(); expect(await settleSdkFloor(dir, "demo", hubUrl, out.deps)).toEqual({ ok: true }); expect(writtenFloor(dir)).toBe("^1.9.0"); + expect(out.lines).toContain("Floor kept at 1.9.0: the widget imports @glasshome/ui directly"); + }); + + test("an SDK without export history keeps the floor at the build SDK", async () => { + releases = [release("1.4.0", "stable", "1.9.0")]; + const dir = project({ history: null, files: { "demo.js": PLAIN } }); + const out = recorder(); + expect(await settleSdkFloor(dir, "demo", hubUrl, out.deps)).toEqual({ ok: true }); + expect(writtenFloor(dir)).toBe("^1.9.0"); expect(out.lines).toContain("Floor kept at 1.9.0: the installed SDK predates export history"); }); - test("a chunk the entry names but the build did not write keeps the floor at the build SDK", async () => { + test("a chunk the entry imports but the build did not write keeps the floor at the build SDK", async () => { releases = [release("1.4.0", "stable", "1.9.0")]; const dir = project({ files: { "demo.js": ENTRY_WITH_LAZY_CHUNK } }); const out = recorder(); @@ -170,35 +208,83 @@ describe("settleSdkFloor", () => { }); test("no stable Dash carrying the floor asks before publishing", async () => { - releases = [release("1.2.0", "stable", "1.4.0"), release("1.3.0-beta.1", "beta", "1.9.0")]; - const dir = project({ - files: { "demo.js": 'import{useLight as l}from"@glasshome/widget-sdk";export default l;' }, - }); - const yes = recorder(true); + releases = ONLY_PRERELEASE; + const dir = project({ files: { "demo.js": NEEDS_1_9 } }); + const yes = recorder({ answer: true }); expect(await settleSdkFloor(dir, "demo", hubUrl, yes.deps)).toEqual({ ok: true }); expect(yes.asked).toEqual(["No stable Dash runs this yet. Publish anyway?"]); + expect(writtenFloor(dir)).toBe("^1.9.0"); + }); - const no = recorder(false); - expect(await settleSdkFloor(dir, "demo", hubUrl, no.deps)).toEqual({ + test("declining leaves the manifest unchanged", async () => { + releases = ONLY_PRERELEASE; + const dir = project({ files: { "demo.js": NEEDS_1_9 } }); + expect(await settleSdkFloor(dir, "demo", hubUrl, recorder({ answer: false }).deps)).toEqual({ ok: false, message: "Publish cancelled.", }); + expect(writtenFloor(dir)).toBe(UNTOUCHED); }); - test("an unreachable hub fails closed", async () => { - const dir = project({ - files: { "demo.js": 'import{Widget as e}from"@glasshome/widget-sdk";export default e;' }, + test("without a terminal and without the flag, no stable Dash fails closed naming the flag", async () => { + releases = ONLY_PRERELEASE; + const dir = project({ files: { "demo.js": NEEDS_1_9 } }); + const out = recorder({ interactive: false }); + expect(await settleSdkFloor(dir, "demo", hubUrl, out.deps)).toEqual({ + ok: false, + message: "No stable Dash runs this yet. Pass --allow-prerelease-dash to publish anyway.", }); + expect(out.asked).toEqual([]); + expect(writtenFloor(dir)).toBe(UNTOUCHED); + }); + + test("--allow-prerelease-dash publishes without asking", async () => { + releases = ONLY_PRERELEASE; + const dir = project({ files: { "demo.js": NEEDS_1_9 } }); + const out = recorder({ interactive: false, allowPrereleaseDash: true }); + expect(await settleSdkFloor(dir, "demo", hubUrl, out.deps)).toEqual({ ok: true }); + expect(out.asked).toEqual([]); + expect(writtenFloor(dir)).toBe("^1.9.0"); + }); + + test("an unreachable hub fails closed and leaves the manifest unchanged", async () => { + const dir = project({ files: { "demo.js": PLAIN } }); const result = await settleSdkFloor(dir, "demo", "http://127.0.0.1:1", recorder().deps); expect(result.ok).toBe(false); if (!result.ok) expect(result.message).toStartWith("Could not read Dash releases from"); + expect(writtenFloor(dir)).toBe(UNTOUCHED); }); - test("a hub that does not serve the table fails closed", async () => { - const dir = project({ - files: { "demo.js": 'import{Widget as e}from"@glasshome/widget-sdk";export default e;' }, + test("a hub that never answers is cut off by the signal and fails closed", async () => { + const dir = project({ files: { "demo.js": PLAIN } }); + const timedOut = new AbortController(); + const settling = settleSdkFloor( + dir, + "demo", + `${hubUrl}/silent`, + recorder({ releasesSignal: () => timedOut.signal }).deps, + ); + timedOut.abort(new DOMException("The operation timed out.", "TimeoutError")); + const result = await settling; + expect(result).toEqual({ + ok: false, + message: `Could not read Dash releases from ${hubUrl}/silent: The operation timed out.`, }); + expect(writtenFloor(dir)).toBe(UNTOUCHED); + }); + + test("a hub that does not serve the table fails closed", async () => { + const dir = project({ files: { "demo.js": PLAIN } }); const result = await settleSdkFloor(dir, "demo", `${hubUrl}/elsewhere`, recorder().deps); expect(result.ok).toBe(false); + expect(writtenFloor(dir)).toBe(UNTOUCHED); + }); + + test("a hub answering a table of the wrong shape fails closed", async () => { + const dir = project({ files: { "demo.js": PLAIN } }); + const result = await settleSdkFloor(dir, "demo", `${hubUrl}/garbled`, recorder().deps); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.message).toStartWith("Could not read Dash releases from"); + expect(writtenFloor(dir)).toBe(UNTOUCHED); }); }); diff --git a/src/utils/sdk-floor.ts b/src/utils/sdk-floor.ts index 8d5d702..81e03a0 100644 --- a/src/utils/sdk-floor.ts +++ b/src/utils/sdk-floor.ts @@ -3,14 +3,24 @@ import { createRequire } from "node:module"; import { dirname, join, relative, resolve, sep } from "node:path"; import { type DashRelease, dashRequirement } from "@glasshome/widget-contract/dash-releases"; import { deriveFloor } from "@glasshome/widget-contract/sdk-floor"; +import { initSync, parse } from "es-module-lexer"; import { fetchDashReleases } from "./hub-api"; import { readManifest, writeManifest } from "./manifest"; import { isBunGlobalCache } from "./resolution"; import { getInstalledSdkVersion } from "./sdk-version"; -const RELATIVE_JS_RE = /["'`](\.{1,2}\/[^"'`\s]+?\.m?js)["'`]/g; +function relativeImports(code: string): string[] | null { + initSync(); + try { + return parse(code)[0] + .map((imp) => imp.n) + .filter((n): n is string => n !== undefined && (n.startsWith("./") || n.startsWith("../"))); + } catch { + return null; + } +} -/** The widget's entry plus every chunk it reaches by relative path; null when one cannot be read. */ +/** The widget's entry plus every chunk it imports by relative path; null when one cannot be read. */ function readBuiltChunks(cwd: string, widgetName: string): string | null { const distDir = resolve(cwd, "dist"); const pending = [join(distDir, `${widgetName}.js`)]; @@ -29,10 +39,9 @@ function readBuiltChunks(cwd: string, widgetName: string): string | null { return null; } parts.push(code); - for (const match of code.matchAll(RELATIVE_JS_RE)) { - const spec = match[1]; - if (spec) pending.push(resolve(dirname(file), spec)); - } + const specs = relativeImports(code); + if (specs === null) return null; + for (const spec of specs) pending.push(resolve(dirname(file), spec)); } return parts.join("\n"); } @@ -97,9 +106,38 @@ function oldestStableDash(range: string, releases: DashRelease[]): string | null export interface FloorDeps { info: (line: string) => void; confirm: (message: string) => Promise; + interactive: boolean; + allowPrereleaseDash: boolean; + releasesSignal?: () => AbortSignal; +} + +const NO_STABLE_DASH = "No stable Dash runs this yet."; + +async function acceptReach( + range: string, + releases: DashRelease[], + deps: FloorDeps, +): Promise { + const oldest = oldestStableDash(range, releases); + if (oldest) { + deps.info(`Runs on Dash ${oldest} and later`); + return { ok: true }; + } + if (deps.allowPrereleaseDash) { + deps.info(`${NO_STABLE_DASH} Publishing anyway (--allow-prerelease-dash).`); + return { ok: true }; + } + if (!deps.interactive) { + return { + ok: false, + message: `${NO_STABLE_DASH} Pass --allow-prerelease-dash to publish anyway.`, + }; + } + if (await deps.confirm(`${NO_STABLE_DASH} Publish anyway?`)) return { ok: true }; + return { ok: false, message: "Publish cancelled." }; } -/** Derive the widget's sdkVersion floor from its built bundle, write it, and say which Dash it reaches. */ +/** Derive the widget's sdkVersion floor from its built bundle, say which Dash it reaches, and write it once publish proceeds. */ export async function settleSdkFloor( cwd: string, widgetName: string, @@ -109,24 +147,21 @@ export async function settleSdkFloor( const floor = floorOf(cwd, widgetName); if (!floor.ok) return floor; const range = `^${floor.floor}`; - const manifest = readManifest(cwd, widgetName); - manifest.sdkVersion = range; - writeManifest(cwd, widgetName, manifest); if (floor.notice) deps.info(floor.notice); let releases: DashRelease[]; try { - releases = await fetchDashReleases(hubUrl); + releases = await fetchDashReleases(hubUrl, deps.releasesSignal?.()); } catch (err) { const detail = err instanceof Error ? err.message : String(err); return { ok: false, message: `Could not read Dash releases from ${hubUrl}: ${detail}` }; } - const oldest = oldestStableDash(range, releases); - if (oldest) { - deps.info(`Runs on Dash ${oldest} and later`); - return { ok: true }; - } - if (await deps.confirm("No stable Dash runs this yet. Publish anyway?")) return { ok: true }; - return { ok: false, message: "Publish cancelled." }; + const reach = await acceptReach(range, releases, deps); + if (!reach.ok) return reach; + + const manifest = readManifest(cwd, widgetName); + manifest.sdkVersion = range; + writeManifest(cwd, widgetName, manifest); + return reach; } From 943d70948b727a5ff021ac31de5ac22f41368ad5 Mon Sep 17 00:00:00 2001 From: Ihsen Bouallegue Date: Wed, 21 Oct 2026 00:06:36 +0200 Subject: [PATCH 3/4] test(cli): the silent hub settles when the caller aborts, so teardown never outwaits the hook --- src/utils/sdk-floor.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/utils/sdk-floor.test.ts b/src/utils/sdk-floor.test.ts index 2e45d0b..1f9d18b 100644 --- a/src/utils/sdk-floor.test.ts +++ b/src/utils/sdk-floor.test.ts @@ -31,7 +31,12 @@ beforeAll(() => { if (path === "/garbled/api/widgets/dash-releases") { return Response.json([{ version: 1, stage: "nightly" }]); } - if (path === "/silent/api/widgets/dash-releases") return new Promise(() => {}); + // Settles once the caller gives up, or server.stop waits on it past the hook timeout. + if (path === "/silent/api/widgets/dash-releases") { + return new Promise((resolve) => + req.signal.addEventListener("abort", () => resolve(new Response(null, { status: 499 }))), + ); + } return new Response("not found", { status: 404 }); }, }); From bd2150c35b10f973c5ca96b2f1d991f1fdbaf452 Mon Sep 17 00:00:00 2001 From: Ihsen Bouallegue Date: Wed, 21 Oct 2026 01:04:37 +0200 Subject: [PATCH 4/4] fix(deps): widget-contract 0.5.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9fef891..cb3fd10 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "@fontsource-variable/geist": "^5.2.9", "@fontsource-variable/geist-mono": "^5.2.8", "@glasshome/sync-layer": "0.12.3", - "@glasshome/widget-contract": "0.4.1", + "@glasshome/widget-contract": "0.5.0", "@glasshome/widget-sdk": "1.24.0", "@iconify-json/lucide": "^1.2.0", "@iconify-json/mdi": "^1.2.3",