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..cb3fd10 100644 --- a/package.json +++ b/package.json @@ -46,12 +46,13 @@ "@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", "@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/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..1c02cc9 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"; @@ -21,6 +22,7 @@ interface PublishOptions { name?: string; bump?: "keep" | "patch" | "minor" | "major"; scope?: string; + allowPrereleaseDash?: boolean; } async function authenticate(hubUrl: string): Promise { @@ -203,6 +205,20 @@ 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; + }, + interactive: process.stdin.isTTY === true, + allowPrereleaseDash: options?.allowPrereleaseDash === true, + }); + 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..e642a2c 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,17 @@ export async function fetchScopes(hubUrl: string, token: string): 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()); +} + 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..1f9d18b --- /dev/null +++ b/src/utils/sdk-floor.test.ts @@ -0,0 +1,295 @@ +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 { type FloorDeps, 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) { + 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" }]); + } + // 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 }); + }, + }); + 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; +} + +const UNTOUCHED = "^1.0.0"; + +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: UNTOUCHED }, 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( + overrides: Partial> & { answer?: boolean } = {}, +) { + const lines: string[] = []; + const asked: string[] = []; + 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 () => { + 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 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({ + 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(UNTOUCHED); + }); + + 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("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({ + 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 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(); + 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 = 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"); + }); + + 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("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 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 new file mode 100644 index 0000000..81e03a0 --- /dev/null +++ b/src/utils/sdk-floor.ts @@ -0,0 +1,167 @@ +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 { 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"; + +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 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`)]; + 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); + const specs = relativeImports(code); + if (specs === null) return null; + for (const spec of specs) 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; + 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, say which Dash it reaches, and write it once publish proceeds. */ +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}`; + if (floor.notice) deps.info(floor.notice); + + let releases: DashRelease[]; + try { + 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 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; +}