@glasshome/widget-cli 0.9.0 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -4
- package/src/commands/add.ts +13 -2
- package/src/commands/build.ts +7 -0
- package/src/commands/create.ts +4 -0
- package/src/commands/preview.ts +34 -25
- package/src/commands/publish.ts +21 -7
- package/src/commands/upgrade.ts +25 -10
- package/src/commands/validate.ts +22 -18
- package/src/preview/capture.ts +17 -12
- package/src/preview/harness/harness.tsx +0 -1
- package/src/utils/resolution.ts +22 -0
- package/src/utils/sdk-version.test.ts +85 -0
- package/src/utils/sdk-version.ts +76 -0
- package/src/utils/typecheck.test.ts +128 -0
- package/src/utils/typecheck.ts +84 -0
- package/template/README.md.template +6 -1
- package/template/package.json.template +3 -2
- package/template/src/index.tsx.template +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glasshome/widget-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
4
4
|
"description": "CLI for creating and managing GlassHome widgets",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"cli": "bun bin/cli.ts",
|
|
11
|
+
"test": "bun test",
|
|
11
12
|
"typecheck": "tsc --noEmit"
|
|
12
13
|
},
|
|
13
14
|
"files": [
|
|
@@ -19,16 +20,17 @@
|
|
|
19
20
|
"@iconify/types": "^2.0.0",
|
|
20
21
|
"@types/bun": "latest",
|
|
21
22
|
"@types/semver": "^7.0.0",
|
|
22
|
-
"playwright": "^1.40.0"
|
|
23
|
+
"playwright": "^1.40.0",
|
|
24
|
+
"typescript": "^5"
|
|
23
25
|
},
|
|
24
26
|
"dependencies": {
|
|
25
27
|
"@clack/prompts": "^1.6.0",
|
|
26
28
|
"@fontsource-variable/caveat": "^5.2.8",
|
|
27
29
|
"@fontsource-variable/geist": "^5.2.9",
|
|
28
30
|
"@fontsource-variable/geist-mono": "^5.2.8",
|
|
29
|
-
"@glasshome/sync-layer": "0.
|
|
31
|
+
"@glasshome/sync-layer": "0.5.0",
|
|
30
32
|
"@glasshome/widget-contract": "0.2.0",
|
|
31
|
-
"@glasshome/widget-sdk": "1.
|
|
33
|
+
"@glasshome/widget-sdk": "1.9.0",
|
|
32
34
|
"@iconify-json/lucide": "^1.2.0",
|
|
33
35
|
"@iconify-json/mdi": "^1.2.3",
|
|
34
36
|
"@tailwindcss/vite": "^4.1.13",
|
package/src/commands/add.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { cancel, isCancel, log, text } from "@clack/prompts";
|
|
4
|
+
import { defaultSdkRange, FALLBACK_SDK_RANGE } from "../utils/sdk-version";
|
|
4
5
|
|
|
5
6
|
/** Capitalize kebab-case into PascalCase for component names. */
|
|
6
7
|
function capitalize(str: string): string {
|
|
@@ -30,20 +31,30 @@ export function scaffoldWidget(
|
|
|
30
31
|
const displayName = capitalize(widgetName);
|
|
31
32
|
const widgetDescription = description || `A GlassHome dashboard widget: ${widgetName}`;
|
|
32
33
|
|
|
34
|
+
// The range the host checks before mounting, derived from the SDK this
|
|
35
|
+
// project actually builds against. Hardcoding it produced widgets that
|
|
36
|
+
// declared a 0.2-era range while running on 1.x, which reads as "pre-1.0" to
|
|
37
|
+
// `requiresCapabilities` and quietly excused them from declaring capabilities.
|
|
38
|
+
const sdkRange = defaultSdkRange(cwd) ?? FALLBACK_SDK_RANGE;
|
|
39
|
+
|
|
33
40
|
// Read index.tsx template from the template directory
|
|
34
41
|
const templateDir = resolve(import.meta.dir, "../../template");
|
|
35
42
|
let srcContent = readFileSync(resolve(templateDir, "src/index.tsx.template"), "utf-8");
|
|
36
43
|
srcContent = srcContent.replace(/WIDGET_NAME/g, displayName);
|
|
44
|
+
srcContent = srcContent.replace(/SDK_RANGE/g, sdkRange);
|
|
37
45
|
writeFileSync(resolve(widgetDir, "index.tsx"), srcContent);
|
|
38
46
|
|
|
39
|
-
// Generate manifest.json
|
|
47
|
+
// Generate manifest.json. `capabilities` is required for any SDK >= 1.0.0
|
|
48
|
+
// range and an empty array is the honest default: a fresh widget reads
|
|
49
|
+
// nothing from Home Assistant, and the declaration is what consent shows.
|
|
40
50
|
const manifest = {
|
|
41
51
|
name: displayName,
|
|
42
52
|
description: widgetDescription,
|
|
43
53
|
minSize: { w: 1, h: 1 },
|
|
44
54
|
maxSize: { w: 4, h: 4 },
|
|
45
55
|
defaultSize: { w: 2, h: 2 },
|
|
46
|
-
sdkVersion:
|
|
56
|
+
sdkVersion: sdkRange,
|
|
57
|
+
capabilities: [],
|
|
47
58
|
version: "0.1.0",
|
|
48
59
|
};
|
|
49
60
|
writeFileSync(resolve(widgetDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
package/src/commands/build.ts
CHANGED
|
@@ -2,8 +2,15 @@ import { log, spinner } from "@clack/prompts";
|
|
|
2
2
|
import { buildWidgets } from "@glasshome/widget-sdk/vite";
|
|
3
3
|
import { lintAndReport } from "../utils/lint-source";
|
|
4
4
|
import { withQuietStdout } from "../utils/quiet";
|
|
5
|
+
import { typecheckAndReport } from "../utils/typecheck";
|
|
5
6
|
|
|
6
7
|
export async function runBuild(cwd: string): Promise<void> {
|
|
8
|
+
const check = spinner();
|
|
9
|
+
check.start("Checking types...");
|
|
10
|
+
const typesOk = typecheckAndReport(cwd);
|
|
11
|
+
check.stop(typesOk ? "Types checked" : "Typecheck failed");
|
|
12
|
+
if (!typesOk) process.exit(1);
|
|
13
|
+
|
|
7
14
|
const s = spinner();
|
|
8
15
|
s.start("Building widgets...");
|
|
9
16
|
|
package/src/commands/create.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import { dirname, join, resolve } from "node:path";
|
|
12
12
|
import { cancel, confirm, isCancel, log, note, spinner, text } from "@clack/prompts";
|
|
13
13
|
import color from "picocolors";
|
|
14
|
+
import { defaultSdkRange, FALLBACK_SDK_RANGE } from "../utils/sdk-version";
|
|
14
15
|
import { getCliVersion } from "../utils/version";
|
|
15
16
|
import { promptWidgetDetails, scaffoldWidget } from "./add";
|
|
16
17
|
|
|
@@ -125,6 +126,9 @@ export async function runCreate() {
|
|
|
125
126
|
pkgContent = pkgContent.replace(/PROJECT_NAME/g, projectName as string);
|
|
126
127
|
pkgContent = pkgContent.replace(/PROJECT_DESCRIPTION/g, projectDesc);
|
|
127
128
|
pkgContent = pkgContent.replace(/CLI_VERSION/g, `^${getCliVersion()}`);
|
|
129
|
+
// Nothing is installed yet, so this comes from the SDK the CLI ships
|
|
130
|
+
// against. `bun widget upgrade` re-syncs it and every manifest later.
|
|
131
|
+
pkgContent = pkgContent.replace(/SDK_RANGE/g, defaultSdkRange() ?? FALLBACK_SDK_RANGE);
|
|
128
132
|
writeFileSync(pkgPath, pkgContent);
|
|
129
133
|
|
|
130
134
|
// Replace project-level placeholders in README.md
|
package/src/commands/preview.ts
CHANGED
|
@@ -43,46 +43,55 @@ export async function runPreview(cwd: string, names: string[], isolate: boolean)
|
|
|
43
43
|
process.exit(1);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
log.success(`Captured ${summary.shots} shot(s) across ${summary.widgets} widget(s)`);
|
|
47
|
-
|
|
48
|
-
if (summary.skipped.length) {
|
|
49
|
-
log.info(`No examples (skipped): ${summary.skipped.join(", ")}`);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const network = summary.failures.filter((f) => f.kind === "network");
|
|
53
46
|
const hangs = summary.failures.filter((f) => f.kind === "hang");
|
|
54
47
|
const integrity = summary.failures.filter((f) => f.kind === "integrity");
|
|
48
|
+
const networkWidgets = [
|
|
49
|
+
...new Set(summary.failures.filter((f) => f.kind === "network").map((f) => f.widget)),
|
|
50
|
+
];
|
|
51
|
+
const attempted = summary.shots + hangs.length;
|
|
55
52
|
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
network.map((f) => ` ${f.widget} ${f.detail}`).join("\n"),
|
|
62
|
-
);
|
|
53
|
+
// Headline: plain count of what landed in preview/. A miss only matters if a
|
|
54
|
+
// render could not complete (hangs) or a bundle changed under us (integrity).
|
|
55
|
+
const out = color.cyan("preview/");
|
|
56
|
+
if (hangs.length || integrity.length) {
|
|
57
|
+
log.warn(`Rendered ${summary.shots} of ${attempted} previews into ${out}`);
|
|
63
58
|
} else {
|
|
64
|
-
log.success(
|
|
59
|
+
log.success(`Rendered ${summary.shots} previews (light + dark) into ${out}`);
|
|
65
60
|
}
|
|
66
61
|
|
|
62
|
+
if (summary.skipped.length) {
|
|
63
|
+
log.message(color.dim(`No examples to render: ${summary.skipped.join(", ")}`));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Real miss: a render still too slow after one retry. Its slot is left without
|
|
67
|
+
// a PNG; everything else rendered.
|
|
67
68
|
if (hangs.length) {
|
|
68
|
-
log.
|
|
69
|
-
`
|
|
70
|
-
hangs.map((f) => ` ${f.widget} ${f.detail}`).join("\n"),
|
|
69
|
+
log.warn(
|
|
70
|
+
`Too slow to render, even after a retry (no image written):\n` +
|
|
71
|
+
hangs.map((f) => ` · ${f.widget} ${f.detail}`).join("\n"),
|
|
71
72
|
);
|
|
72
|
-
} else {
|
|
73
|
-
log.success("RENDER: every render settled inside the timeout");
|
|
74
73
|
}
|
|
75
74
|
|
|
75
|
+
// Should never happen: a bundle's bytes changed mid-run. This is a real
|
|
76
|
+
// problem, not a slow render.
|
|
76
77
|
if (integrity.length) {
|
|
77
78
|
log.error(
|
|
78
|
-
`
|
|
79
|
-
integrity.map((f) => ` ${f.widget}
|
|
79
|
+
`A bundle changed while rendering (report this):\n` +
|
|
80
|
+
integrity.map((f) => ` · ${f.widget}`).join("\n"),
|
|
80
81
|
);
|
|
81
|
-
} else {
|
|
82
|
-
log.success("INTEGRITY: every bundle matched its pinned hash");
|
|
83
82
|
}
|
|
84
83
|
|
|
85
|
-
|
|
84
|
+
// Informational, not a failure: some widgets have no offline data and reach
|
|
85
|
+
// for the network. The request is blocked and nothing leaves the machine; the
|
|
86
|
+
// widget renders a placeholder. Expected for camera; worth a glance if another
|
|
87
|
+
// widget shows up here.
|
|
88
|
+
if (networkWidgets.length) {
|
|
89
|
+
log.message(
|
|
90
|
+
color.dim(
|
|
91
|
+
`Used a placeholder (no offline data; network blocked, nothing left the machine): ${networkWidgets.join(", ")}`,
|
|
92
|
+
),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
86
95
|
|
|
87
96
|
// Vite's dev server leaves live handles behind (file watchers, keep-alive
|
|
88
97
|
// sockets from browsers that crashed mid-render), so the process would sit
|
package/src/commands/publish.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
readManifest,
|
|
13
13
|
writeManifest,
|
|
14
14
|
} from "../utils/manifest";
|
|
15
|
+
import { typecheckAndReport } from "../utils/typecheck";
|
|
15
16
|
import { enforceCliVersion } from "../utils/version";
|
|
16
17
|
import { runLogin } from "./login";
|
|
17
18
|
|
|
@@ -28,13 +29,7 @@ export async function runPublish(
|
|
|
28
29
|
): Promise<void> {
|
|
29
30
|
const s = spinner();
|
|
30
31
|
|
|
31
|
-
// Step 1: Validate (quiet)
|
|
32
32
|
const { runValidate } = await import("./validate");
|
|
33
|
-
const valid = await runValidate(cwd, undefined, { quiet: true });
|
|
34
|
-
if (!valid) {
|
|
35
|
-
log.error("Fix validation errors before publishing.");
|
|
36
|
-
process.exit(1);
|
|
37
|
-
}
|
|
38
33
|
|
|
39
34
|
// Step 2: Authenticate
|
|
40
35
|
const hubUrl = hubUrlOverride ?? getHubUrl();
|
|
@@ -167,7 +162,17 @@ export async function runPublish(
|
|
|
167
162
|
writeManifest(cwd, widgetName, manifest);
|
|
168
163
|
}
|
|
169
164
|
|
|
170
|
-
// Step 6:
|
|
165
|
+
// Step 6: Typecheck, then build (async so spinner animates).
|
|
166
|
+
//
|
|
167
|
+
// Checked here rather than left to the project's own build script: a project
|
|
168
|
+
// scaffolded before the template ran tsc builds with a bare `vite build`, and
|
|
169
|
+
// publishing is the irreversible step. A project whose build already
|
|
170
|
+
// typechecks just pays it twice, which is cheap next to a bad publish.
|
|
171
|
+
s.start("Checking types...");
|
|
172
|
+
const typesOk = typecheckAndReport(cwd);
|
|
173
|
+
s.stop(typesOk ? "Types checked" : "Typecheck failed");
|
|
174
|
+
if (!typesOk) process.exit(1);
|
|
175
|
+
|
|
171
176
|
s.start("Building widget...");
|
|
172
177
|
const buildProc = Bun.spawn(["bun", "run", "build"], { cwd, stdout: "pipe", stderr: "pipe" });
|
|
173
178
|
const buildExit = await buildProc.exited;
|
|
@@ -179,6 +184,15 @@ export async function runPublish(
|
|
|
179
184
|
}
|
|
180
185
|
s.stop("Build complete");
|
|
181
186
|
|
|
187
|
+
// Validate AFTER the build, not before: the build is what produces the
|
|
188
|
+
// manifest and bundle that get published, so validating beforehand checks a
|
|
189
|
+
// different document than the one that ships.
|
|
190
|
+
const valid = await runValidate(cwd, undefined, { quiet: true });
|
|
191
|
+
if (!valid) {
|
|
192
|
+
log.error("Fix validation errors before publishing.");
|
|
193
|
+
process.exit(1);
|
|
194
|
+
}
|
|
195
|
+
|
|
182
196
|
// Step 7: Publish
|
|
183
197
|
const distPath = resolve(cwd, "dist", `${widgetName}.js`);
|
|
184
198
|
|
package/src/commands/upgrade.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { dirname, join, resolve } from "node:path";
|
|
3
3
|
import { log, note, spinner } from "@clack/prompts";
|
|
4
4
|
import { discoverWidgets, readManifest, writeManifest } from "../utils/manifest";
|
|
5
|
+
import { getInstalledSdkVersion } from "../utils/sdk-version";
|
|
5
6
|
import { runValidate } from "./validate";
|
|
6
7
|
|
|
7
8
|
/** Walk up from cwd looking for a package.json with a `workspaces` field. */
|
|
@@ -121,16 +122,30 @@ export async function runUpgrade(cwd: string): Promise<void> {
|
|
|
121
122
|
} else {
|
|
122
123
|
log.warn(`Synced to ${sdkVersion} but validation has warnings/errors. Check above.`);
|
|
123
124
|
}
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Standalone project. Bumping the dependency is still the author's call, but
|
|
129
|
+
// syncing the manifests to whatever is installed is not: a manifest range
|
|
130
|
+
// that excludes the installed SDK fails validation, and this is the command
|
|
131
|
+
// that error tells people to run.
|
|
132
|
+
const installed = getInstalledSdkVersion(cwd);
|
|
133
|
+
if (installed) {
|
|
134
|
+
syncManifestSdkVersions(cwd, installed);
|
|
135
|
+
const valid = await runValidate(cwd);
|
|
136
|
+
if (!valid) log.warn(`Synced manifests to ${installed}, but validation found problems above.`);
|
|
124
137
|
} else {
|
|
125
|
-
log.
|
|
126
|
-
note(
|
|
127
|
-
[
|
|
128
|
-
"1. Bump @glasshome/widget-sdk in package.json peerDependencies",
|
|
129
|
-
"2. bun install",
|
|
130
|
-
"3. bun widget upgrade (sync manifest files)",
|
|
131
|
-
"4. bun widget validate (check compatibility)",
|
|
132
|
-
].join("\n"),
|
|
133
|
-
"To upgrade",
|
|
134
|
-
);
|
|
138
|
+
log.warn("No @glasshome/widget-sdk installed here, so manifests were left alone.");
|
|
135
139
|
}
|
|
140
|
+
|
|
141
|
+
log.info("To move to a different SDK version:");
|
|
142
|
+
note(
|
|
143
|
+
[
|
|
144
|
+
"1. Bump @glasshome/widget-sdk in package.json peerDependencies",
|
|
145
|
+
"2. bun install",
|
|
146
|
+
"3. bun widget upgrade (sync manifest files)",
|
|
147
|
+
"4. bun widget validate (check compatibility)",
|
|
148
|
+
].join("\n"),
|
|
149
|
+
"To upgrade",
|
|
150
|
+
);
|
|
136
151
|
}
|
package/src/commands/validate.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
formatSchemaError,
|
|
6
6
|
publishManifestSchema,
|
|
7
7
|
requiresCapabilities,
|
|
8
|
+
satisfiesSdk,
|
|
8
9
|
} from "@glasshome/widget-contract";
|
|
9
10
|
import semver from "semver";
|
|
10
11
|
import {
|
|
@@ -15,7 +16,7 @@ import {
|
|
|
15
16
|
readRegistry,
|
|
16
17
|
type WidgetManifest,
|
|
17
18
|
} from "../utils/manifest";
|
|
18
|
-
import {
|
|
19
|
+
import { getInstalledSdkVersion } from "../utils/sdk-version";
|
|
19
20
|
|
|
20
21
|
interface ValidationResult {
|
|
21
22
|
passed: boolean;
|
|
@@ -23,7 +24,7 @@ interface ValidationResult {
|
|
|
23
24
|
warnings: string[];
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
function validateManifest(manifest: WidgetManifest): ValidationResult {
|
|
27
|
+
function validateManifest(manifest: WidgetManifest, installedSdk: string | null): ValidationResult {
|
|
27
28
|
const errors: string[] = [];
|
|
28
29
|
const warnings: string[] = [];
|
|
29
30
|
const hasValidMinSize =
|
|
@@ -47,6 +48,23 @@ function validateManifest(manifest: WidgetManifest): ValidationResult {
|
|
|
47
48
|
}
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
// The range must admit the SDK actually resolved in node_modules. Comparing
|
|
52
|
+
// it against the range declared in package.json only compares a claim with
|
|
53
|
+
// itself: both said "^0.2.0" while the build ran on 1.7.0, and it passed.
|
|
54
|
+
// An error, not a warning: a pre-1.0 range also excuses the widget from
|
|
55
|
+
// declaring capabilities, so a stale one silently leaves the 1.x contract.
|
|
56
|
+
if (
|
|
57
|
+
installedSdk &&
|
|
58
|
+
manifest.sdkVersion &&
|
|
59
|
+
manifest.sdkVersion !== "*" &&
|
|
60
|
+
semver.validRange(manifest.sdkVersion) &&
|
|
61
|
+
!satisfiesSdk(installedSdk, manifest.sdkVersion)
|
|
62
|
+
) {
|
|
63
|
+
errors.push(
|
|
64
|
+
`sdkVersion "${manifest.sdkVersion}" excludes the installed SDK ${installedSdk}, so it claims a compatibility it was not built for; run \`bun widget upgrade\``,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
50
68
|
// INS-05: "*" sdkVersion is rejected at publish time, authors must declare
|
|
51
69
|
// a real range (e.g. "^0.3.0"). Installed widgets that already ship "*" are
|
|
52
70
|
// still tolerated by the loader with a warn-and-mount, but new publishes
|
|
@@ -157,8 +175,7 @@ export async function runValidate(
|
|
|
157
175
|
|
|
158
176
|
const failed: string[] = [];
|
|
159
177
|
|
|
160
|
-
|
|
161
|
-
const projectSdk = getProjectSdkVersion(cwd);
|
|
178
|
+
const installedSdk = getInstalledSdkVersion(cwd);
|
|
162
179
|
|
|
163
180
|
for (const name of toValidate) {
|
|
164
181
|
if (!quiet) log.step(name);
|
|
@@ -175,7 +192,7 @@ export async function runValidate(
|
|
|
175
192
|
continue;
|
|
176
193
|
}
|
|
177
194
|
|
|
178
|
-
const result = validateManifest(manifest);
|
|
195
|
+
const result = validateManifest(manifest, installedSdk);
|
|
179
196
|
|
|
180
197
|
if (result.errors.length > 0) {
|
|
181
198
|
for (const err of result.errors) {
|
|
@@ -189,19 +206,6 @@ export async function runValidate(
|
|
|
189
206
|
}
|
|
190
207
|
}
|
|
191
208
|
|
|
192
|
-
if (
|
|
193
|
-
!quiet &&
|
|
194
|
-
projectSdk &&
|
|
195
|
-
manifest.sdkVersion &&
|
|
196
|
-
manifest.sdkVersion !== "*" &&
|
|
197
|
-
semver.validRange(manifest.sdkVersion) &&
|
|
198
|
-
!semver.satisfies(projectSdk, manifest.sdkVersion)
|
|
199
|
-
) {
|
|
200
|
-
log.warn(
|
|
201
|
-
` - manifest sdkVersion "${manifest.sdkVersion}" excludes the installed SDK ${projectSdk}; run \`bun widget upgrade\``,
|
|
202
|
-
);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
209
|
// Check bundle exists
|
|
206
210
|
const bundlePath = resolve(cwd, "dist", `${name}.js`);
|
|
207
211
|
if (!existsSync(bundlePath)) {
|
package/src/preview/capture.ts
CHANGED
|
@@ -202,8 +202,8 @@ export async function runPreview(opts: PreviewOptions): Promise<PreviewSummary>
|
|
|
202
202
|
for (const a of lock.attempts) widgetAttempts.add(a);
|
|
203
203
|
};
|
|
204
204
|
|
|
205
|
-
|
|
206
|
-
|
|
205
|
+
const attempt = () =>
|
|
206
|
+
withRenderTimeout(`${widget} / ${label} / ${theme}`, async () => {
|
|
207
207
|
if (shared) {
|
|
208
208
|
const page = await shared.newPage();
|
|
209
209
|
try {
|
|
@@ -220,17 +220,22 @@ export async function runPreview(opts: PreviewOptions): Promise<PreviewSummary>
|
|
|
220
220
|
await withFreshBrowser(shoot);
|
|
221
221
|
}
|
|
222
222
|
});
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
223
|
+
|
|
224
|
+
// Two tries. A render that crosses 30s is usually a heavy widget
|
|
225
|
+
// flaking under load, not a real failure: recycle (the wedged
|
|
226
|
+
// browser is why it stalled) and give it one clean retry before
|
|
227
|
+
// recording a miss.
|
|
228
|
+
let ok = false;
|
|
229
|
+
for (let tries = 0; tries < 2 && !ok; tries++) {
|
|
230
|
+
try {
|
|
231
|
+
await attempt();
|
|
232
|
+
ok = true;
|
|
233
|
+
} catch {
|
|
234
|
+
await shared?.recycle().catch(() => {});
|
|
235
|
+
}
|
|
233
236
|
}
|
|
237
|
+
if (ok) shot++;
|
|
238
|
+
else failures.push({ widget, kind: "hang", detail: `${label} (${theme})` });
|
|
234
239
|
}
|
|
235
240
|
}
|
|
236
241
|
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join, resolve, sep } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Whether a resolved module path is bun's global install cache rather than a
|
|
6
|
+
* real install.
|
|
7
|
+
*
|
|
8
|
+
* With a package absent, bun still resolves its bare name, from
|
|
9
|
+
* `~/.bun/install/cache/typescript@7.0.2@@@1/`. Tools that shell out to what
|
|
10
|
+
* they resolved would then run a version the project never installed.
|
|
11
|
+
*
|
|
12
|
+
* Testing for a `node_modules` segment instead looks equivalent and is not: in
|
|
13
|
+
* a workspace, `@glasshome/widget-sdk` resolves through a symlink to its real
|
|
14
|
+
* source directory, which has no such segment, so that test rejects the very
|
|
15
|
+
* install it should accept and the guard silently does nothing.
|
|
16
|
+
*/
|
|
17
|
+
export function isBunGlobalCache(modulePath: string): boolean {
|
|
18
|
+
const cacheDir =
|
|
19
|
+
process.env.BUN_INSTALL_CACHE_DIR ??
|
|
20
|
+
join(process.env.BUN_INSTALL ?? join(homedir(), ".bun"), "install", "cache");
|
|
21
|
+
return resolve(modulePath).startsWith(resolve(cacheDir) + sep);
|
|
22
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { isBunGlobalCache } from "./resolution";
|
|
6
|
+
import { bareVersion, defaultSdkRange, getInstalledSdkVersion } from "./sdk-version";
|
|
7
|
+
|
|
8
|
+
const tmpDir = mkdtempSync(join(tmpdir(), "glasshome-sdk-version-"));
|
|
9
|
+
|
|
10
|
+
afterAll(() => {
|
|
11
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
let fixtureCount = 0;
|
|
15
|
+
|
|
16
|
+
/** A fake installed SDK, either under node_modules or as a workspace symlink
|
|
17
|
+
to a source tree, which is how a monorepo actually resolves it. */
|
|
18
|
+
function project(opts: { version?: string; asWorkspaceLink?: boolean }): string {
|
|
19
|
+
const dir = join(tmpDir, `project-${fixtureCount++}`);
|
|
20
|
+
mkdirSync(dir, { recursive: true });
|
|
21
|
+
writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "fixture" }));
|
|
22
|
+
|
|
23
|
+
if (opts.version) {
|
|
24
|
+
const target = opts.asWorkspaceLink
|
|
25
|
+
? join(dir, "packages", "widget-sdk")
|
|
26
|
+
: join(dir, "node_modules", "@glasshome", "widget-sdk");
|
|
27
|
+
mkdirSync(target, { recursive: true });
|
|
28
|
+
writeFileSync(
|
|
29
|
+
join(target, "package.json"),
|
|
30
|
+
JSON.stringify({ name: "@glasshome/widget-sdk", version: opts.version }),
|
|
31
|
+
);
|
|
32
|
+
if (opts.asWorkspaceLink) {
|
|
33
|
+
mkdirSync(join(dir, "node_modules", "@glasshome"), { recursive: true });
|
|
34
|
+
symlinkSync(target, join(dir, "node_modules", "@glasshome", "widget-sdk"), "dir");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return dir;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("getInstalledSdkVersion", () => {
|
|
41
|
+
test("reads the version from a normal install", () => {
|
|
42
|
+
expect(getInstalledSdkVersion(project({ version: "1.8.1" }))).toBe("1.8.1");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("reads it through a workspace symlink to a source tree", () => {
|
|
46
|
+
// Regression: an earlier version required a `node_modules` segment in the
|
|
47
|
+
// resolved path. A workspace symlink resolves to the SDK's real source
|
|
48
|
+
// directory, which has none, so the check returned null and every guard
|
|
49
|
+
// built on it silently did nothing in the monorepo.
|
|
50
|
+
expect(getInstalledSdkVersion(project({ version: "1.7.0", asWorkspaceLink: true }))).toBe(
|
|
51
|
+
"1.7.0",
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("returns null when no SDK is installed", () => {
|
|
56
|
+
// Must not fall back to bun's global cache copy.
|
|
57
|
+
expect(getInstalledSdkVersion(project({}))).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("defaultSdkRange", () => {
|
|
62
|
+
test("carets the installed version", () => {
|
|
63
|
+
expect(defaultSdkRange(project({ version: "1.8.1" }))).toBe("^1.8.1");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("falls back to the SDK this CLI ships against", () => {
|
|
67
|
+
// No project SDK, so it must still produce a 1.x range rather than nothing:
|
|
68
|
+
// a pre-1.0 range would excuse the widget from declaring capabilities.
|
|
69
|
+
const range = defaultSdkRange(project({}));
|
|
70
|
+
expect(range).toMatch(/^\^\d+\.\d+\.\d+/);
|
|
71
|
+
expect(bareVersion(range as string).startsWith("0.")).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("isBunGlobalCache", () => {
|
|
76
|
+
test("recognises the cache directory", () => {
|
|
77
|
+
const cache = join(tmpDir, "cache");
|
|
78
|
+
process.env.BUN_INSTALL_CACHE_DIR = cache;
|
|
79
|
+
expect(isBunGlobalCache(join(cache, "typescript@7.0.2@@@1", "package.json"))).toBe(true);
|
|
80
|
+
expect(isBunGlobalCache(join(tmpDir, "node_modules", "typescript", "package.json"))).toBe(
|
|
81
|
+
false,
|
|
82
|
+
);
|
|
83
|
+
delete process.env.BUN_INSTALL_CACHE_DIR;
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { isBunGlobalCache } from "./resolution";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The SDK a widget actually builds against.
|
|
8
|
+
*
|
|
9
|
+
* `@glasshome/widget-sdk` is host-provided (`HOST_PROVIDED_MODULES`), so the
|
|
10
|
+
* build externalizes it and the published bundle runs against whatever SDK the
|
|
11
|
+
* host serves. That is why `manifest.sdkVersion` exists: it is the range the
|
|
12
|
+
* host checks before mounting. It must be derived from, and checked against,
|
|
13
|
+
* the SDK really resolved here — comparing it to the range declared in
|
|
14
|
+
* package.json only ever compares a claim with itself.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const SDK_PKG = "@glasshome/widget-sdk";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Last resort when neither the project nor this CLI can be read. Deliberately a
|
|
21
|
+
* 1.x range: anything below 1.0.0 excuses a widget from declaring capabilities,
|
|
22
|
+
* so a wrong-but-modern default fails loudly instead of silently opting out of
|
|
23
|
+
* the security contract.
|
|
24
|
+
*/
|
|
25
|
+
export const FALLBACK_SDK_RANGE = "^1.0.0";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Version of the installed SDK, or null when none is installed.
|
|
29
|
+
*
|
|
30
|
+
* An ancestor's `node_modules` is fine and expected (hoisted installs put it
|
|
31
|
+
* there), as is a workspace symlink resolving to the SDK's own source tree.
|
|
32
|
+
* Bun's global install cache is not: with nothing installed it still resolves
|
|
33
|
+
* the bare name, and that is not what the build would use.
|
|
34
|
+
*/
|
|
35
|
+
export function getInstalledSdkVersion(cwd: string): string | null {
|
|
36
|
+
try {
|
|
37
|
+
const require = createRequire(join(cwd, "package.json"));
|
|
38
|
+
const pkgPath = resolve(require.resolve(`${SDK_PKG}/package.json`));
|
|
39
|
+
if (isBunGlobalCache(pkgPath)) return null;
|
|
40
|
+
const version = JSON.parse(readFileSync(pkgPath, "utf-8")).version;
|
|
41
|
+
return typeof version === "string" ? version : null;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The SDK this CLI itself was built against, used when scaffolding a project
|
|
49
|
+
* that has nothing installed yet.
|
|
50
|
+
*/
|
|
51
|
+
export function getCliSdkVersion(): string | null {
|
|
52
|
+
try {
|
|
53
|
+
const pkgPath = resolve(import.meta.dir, "../../package.json");
|
|
54
|
+
if (!existsSync(pkgPath)) return null;
|
|
55
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
56
|
+
const range = pkg.dependencies?.[SDK_PKG] ?? pkg.peerDependencies?.[SDK_PKG];
|
|
57
|
+
return typeof range === "string" ? range.replace(/^[\^~]/, "") : null;
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The `^x.y.z` range to write when scaffolding: what the project has installed,
|
|
65
|
+
* else what this CLI ships against. A caret, so patch and minor SDK releases do
|
|
66
|
+
* not invalidate every published widget.
|
|
67
|
+
*/
|
|
68
|
+
export function defaultSdkRange(cwd?: string): string | null {
|
|
69
|
+
const version = (cwd ? getInstalledSdkVersion(cwd) : null) ?? getCliSdkVersion();
|
|
70
|
+
return version ? `^${version}` : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Strip a leading caret/tilde so a range can be compared as a version. */
|
|
74
|
+
export function bareVersion(range: string): string {
|
|
75
|
+
return range.replace(/^[\^~]/, "");
|
|
76
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { cpSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join, sep } from "node:path";
|
|
6
|
+
import { runTypecheck } from "./typecheck";
|
|
7
|
+
|
|
8
|
+
// Inside the package so fixtures can resolve typescript from its node_modules,
|
|
9
|
+
// which is what a hoisted or workspace install looks like.
|
|
10
|
+
const tmpDir = mkdtempSync(join(import.meta.dir, ".typecheck-test-"));
|
|
11
|
+
// Outside it, for the one case that must find no typescript anywhere above it.
|
|
12
|
+
const isolatedDir = mkdtempSync(join(tmpdir(), "glasshome-typecheck-"));
|
|
13
|
+
|
|
14
|
+
afterAll(() => {
|
|
15
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
16
|
+
rmSync(isolatedDir, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A real installed typescript to link into fixtures, or null.
|
|
21
|
+
*
|
|
22
|
+
* Resolved lazily and allowed to be absent: this package does not declare
|
|
23
|
+
* typescript, so in the monorepo it comes from the root's node_modules and in a
|
|
24
|
+
* standalone checkout there may be none at all. A hard resolve at module load
|
|
25
|
+
* would take the whole file down there. The `node_modules` check keeps bun's
|
|
26
|
+
* global-cache fallback out, which would not be a real install.
|
|
27
|
+
*/
|
|
28
|
+
function findTypescript(): string | null {
|
|
29
|
+
try {
|
|
30
|
+
const pkg = createRequire(import.meta.url).resolve("typescript/package.json");
|
|
31
|
+
return pkg.split(sep).includes("node_modules") ? dirname(pkg) : null;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const typescriptDir = findTypescript();
|
|
38
|
+
const noTypescript = typescriptDir === null;
|
|
39
|
+
|
|
40
|
+
let fixtureCount = 0;
|
|
41
|
+
|
|
42
|
+
interface ProjectOptions {
|
|
43
|
+
source?: string;
|
|
44
|
+
tsconfig?: boolean;
|
|
45
|
+
typescript?: boolean;
|
|
46
|
+
/** Somewhere with no node_modules above it, so nothing can be inherited. */
|
|
47
|
+
isolated?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function project({
|
|
51
|
+
source,
|
|
52
|
+
tsconfig = true,
|
|
53
|
+
typescript = true,
|
|
54
|
+
isolated = false,
|
|
55
|
+
}: ProjectOptions): string {
|
|
56
|
+
const dir = join(isolated ? isolatedDir : tmpDir, `project-${fixtureCount++}`);
|
|
57
|
+
mkdirSync(join(dir, "src"), { recursive: true });
|
|
58
|
+
writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "fixture", type: "module" }));
|
|
59
|
+
if (tsconfig) {
|
|
60
|
+
writeFileSync(
|
|
61
|
+
join(dir, "tsconfig.json"),
|
|
62
|
+
JSON.stringify({
|
|
63
|
+
compilerOptions: {
|
|
64
|
+
strict: true,
|
|
65
|
+
noEmit: true,
|
|
66
|
+
module: "ESNext",
|
|
67
|
+
target: "ESNext",
|
|
68
|
+
moduleResolution: "bundler",
|
|
69
|
+
// Hermetic: without this tsc auto-loads every @types package it finds
|
|
70
|
+
// walking up to the monorepo root, which is both irrelevant to what
|
|
71
|
+
// is under test and slow enough to blow the test timeout.
|
|
72
|
+
types: [],
|
|
73
|
+
},
|
|
74
|
+
include: ["src"],
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if (typescript && typescriptDir) {
|
|
79
|
+
mkdirSync(join(dir, "node_modules"), { recursive: true });
|
|
80
|
+
symlinkSync(typescriptDir, join(dir, "node_modules", "typescript"), "dir");
|
|
81
|
+
}
|
|
82
|
+
writeFileSync(join(dir, "src", "index.ts"), source ?? "export const n: number = 1;\n");
|
|
83
|
+
return dir;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe("runTypecheck", () => {
|
|
87
|
+
test.skipIf(noTypescript)("passes a project whose types are sound", () => {
|
|
88
|
+
expect(runTypecheck(project({})).status).toBe("ok");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test.skipIf(noTypescript)("fails on a type error and reports the file and message", () => {
|
|
92
|
+
const result = runTypecheck(project({ source: `export const n: number = "nope";\n` }));
|
|
93
|
+
expect(result.status).toBe("failed");
|
|
94
|
+
if (result.status !== "failed") return;
|
|
95
|
+
expect(result.output).toContain("src/index.ts");
|
|
96
|
+
expect(result.output).toContain("TS2322");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("skips a project with no tsconfig rather than failing it", () => {
|
|
100
|
+
// A JS-only or custom-setup project is not broken, it is just not ours to check.
|
|
101
|
+
const result = runTypecheck(project({ tsconfig: false }));
|
|
102
|
+
expect(result.status).toBe("skipped");
|
|
103
|
+
if (result.status !== "skipped") return;
|
|
104
|
+
expect(result.reason).toContain("tsconfig");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("skips when typescript is not installed in the project", () => {
|
|
108
|
+
// Load-bearing: with nothing installed, bun still resolves a bare
|
|
109
|
+
// "typescript" from its global install cache, so without the node_modules
|
|
110
|
+
// check this would silently check against whatever major is cached there.
|
|
111
|
+
const result = runTypecheck(project({ typescript: false, isolated: true }));
|
|
112
|
+
expect(result.status).toBe("skipped");
|
|
113
|
+
if (result.status !== "skipped") return;
|
|
114
|
+
expect(result.reason).toContain("not installed");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test.skipIf(noTypescript)("uses an ancestor's typescript, as hoisted and workspace installs give", () => {
|
|
118
|
+
// Requiring a copy inside the project itself would skip every monorepo.
|
|
119
|
+
const parent = project({ typescript: true });
|
|
120
|
+
const child = join(parent, "nested");
|
|
121
|
+
mkdirSync(join(child, "src"), { recursive: true });
|
|
122
|
+
cpSync(join(parent, "package.json"), join(child, "package.json"));
|
|
123
|
+
cpSync(join(parent, "tsconfig.json"), join(child, "tsconfig.json"));
|
|
124
|
+
writeFileSync(join(child, "src", "index.ts"), `export const n: number = "nope";\n`);
|
|
125
|
+
|
|
126
|
+
expect(runTypecheck(child).status).toBe("failed");
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { log } from "@clack/prompts";
|
|
5
|
+
import { isBunGlobalCache } from "./resolution";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Typecheck a widget project before building it.
|
|
9
|
+
*
|
|
10
|
+
* Scaffolded projects build with a bare `vite build`, which strips types without
|
|
11
|
+
* checking them, so a widget could bundle and publish with its config type and
|
|
12
|
+
* its `configSchema` disagreeing — including `examples` whose config the widget
|
|
13
|
+
* would never accept. tsc is the only thing that catches that at authoring time.
|
|
14
|
+
*
|
|
15
|
+
* Skips (loudly) rather than failing when the project has no tsconfig or no
|
|
16
|
+
* local typescript: a guard that cannot run has to say so.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type TypecheckResult =
|
|
20
|
+
| { status: "ok" }
|
|
21
|
+
| { status: "failed"; output: string }
|
|
22
|
+
| { status: "skipped"; reason: string };
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* An installed typescript, never bun's global cache.
|
|
26
|
+
*
|
|
27
|
+
* Load-bearing: when a project has no typescript installed, bun still resolves
|
|
28
|
+
* the bare name, from `~/.bun/install/cache/typescript@7.0.2@@@1/`, so the CLI
|
|
29
|
+
* would silently check against whatever major happens to be cached there.
|
|
30
|
+
* Resolving to an *ancestor's* `node_modules` is fine and deliberate: hoisted
|
|
31
|
+
* and workspace installs put it there, so requiring a copy inside the project
|
|
32
|
+
* would skip every monorepo.
|
|
33
|
+
*/
|
|
34
|
+
function resolveTsc(cwd: string): string | null {
|
|
35
|
+
try {
|
|
36
|
+
const require = createRequire(join(cwd, "package.json"));
|
|
37
|
+
const pkg = resolve(require.resolve("typescript/package.json"));
|
|
38
|
+
if (isBunGlobalCache(pkg)) return null;
|
|
39
|
+
const bin = join(dirname(pkg), "bin", "tsc");
|
|
40
|
+
return existsSync(bin) ? bin : null;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function runTypecheck(cwd: string): TypecheckResult {
|
|
47
|
+
const tsconfig = join(cwd, "tsconfig.json");
|
|
48
|
+
if (!existsSync(tsconfig)) return { status: "skipped", reason: "no tsconfig.json" };
|
|
49
|
+
|
|
50
|
+
const tsc = resolveTsc(cwd);
|
|
51
|
+
if (!tsc) {
|
|
52
|
+
return { status: "skipped", reason: "typescript is not installed in this project" };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const proc = Bun.spawnSync([process.execPath, tsc, "--noEmit", "-p", tsconfig], {
|
|
56
|
+
cwd,
|
|
57
|
+
stdout: "pipe",
|
|
58
|
+
stderr: "pipe",
|
|
59
|
+
});
|
|
60
|
+
if (proc.exitCode === 0) return { status: "ok" };
|
|
61
|
+
|
|
62
|
+
const output = [proc.stdout.toString(), proc.stderr.toString()].join("").trim();
|
|
63
|
+
return { status: "failed", output: output || `tsc exited ${proc.exitCode}` };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Typecheck and report. Returns false when the build should stop.
|
|
68
|
+
*
|
|
69
|
+
* Blocking on purpose: a type error here means the bundle that reaches the Hub
|
|
70
|
+
* is built from code the author never checked.
|
|
71
|
+
*/
|
|
72
|
+
export function typecheckAndReport(cwd: string): boolean {
|
|
73
|
+
const result = runTypecheck(cwd);
|
|
74
|
+
if (result.status === "ok") return true;
|
|
75
|
+
|
|
76
|
+
if (result.status === "skipped") {
|
|
77
|
+
log.warn(`Types were NOT checked: ${result.reason}.`);
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
log.error("Typecheck failed:");
|
|
82
|
+
log.message(result.output);
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
@@ -18,7 +18,12 @@ bun install
|
|
|
18
18
|
bun run build
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
This produces `dist/{widget}.js` for each widget and
|
|
21
|
+
This typechecks the project, produces `dist/{widget}.js` for each widget and
|
|
22
|
+
generates `dist/registry.json`. To check types on their own:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
bun run typecheck
|
|
26
|
+
```
|
|
22
27
|
|
|
23
28
|
## Adding widgets
|
|
24
29
|
|
|
@@ -7,13 +7,14 @@
|
|
|
7
7
|
"dist"
|
|
8
8
|
],
|
|
9
9
|
"scripts": {
|
|
10
|
-
"build": "vite build",
|
|
10
|
+
"build": "tsc --noEmit && vite build",
|
|
11
|
+
"typecheck": "tsc --noEmit",
|
|
11
12
|
"dev": "vite build --watch",
|
|
12
13
|
"widget": "glasshome-widget"
|
|
13
14
|
},
|
|
14
15
|
"peerDependencies": {
|
|
15
16
|
"solid-js": "^1.9.11",
|
|
16
|
-
"@glasshome/widget-sdk": "
|
|
17
|
+
"@glasshome/widget-sdk": "SDK_RANGE"
|
|
17
18
|
},
|
|
18
19
|
"devDependencies": {
|
|
19
20
|
"@glasshome/widget-cli": "CLI_VERSION",
|