@glasshome/widget-cli 0.8.4 → 0.10.0
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/bin/cli.ts +13 -0
- package/package.json +22 -3
- 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 +100 -0
- 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 +259 -0
- package/src/preview/constraints.ts +240 -0
- package/src/preview/harness/harness.tsx +169 -0
- package/src/preview/harness/index.html +28 -0
- package/src/preview/nix-ld.ts +49 -0
- 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/.gitignore.template +2 -0
- package/template/README.md.template +6 -1
- package/template/package.json.template +3 -2
- package/template/src/index.tsx.template +1 -1
- package/src/commands/dev-registry.ts +0 -86
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The Playwright-downloaded Chromium ships a glibc dynamic linker
|
|
6
|
+
* (`/lib64/ld-linux-x86-64.so.2`) and expects system libs (glib, nss, X11, …)
|
|
7
|
+
* that do not exist on NixOS, so it dies with `libglib-2.0.so.0: cannot open
|
|
8
|
+
* shared object file`. NixOS's nix-ld provides the interpreter shim via
|
|
9
|
+
* `NIX_LD`; it just needs a library search path. This harvests that path from
|
|
10
|
+
* the installed nix Chromium's runtime closure — version-independent and
|
|
11
|
+
* offline — so the downloaded browser resolves every lib.
|
|
12
|
+
*
|
|
13
|
+
* Returns the `:`-joined path (existing `NIX_LD_LIBRARY_PATH` prepended), or
|
|
14
|
+
* null when not on NixOS or no nix Chromium is installed (caller should then
|
|
15
|
+
* launch normally).
|
|
16
|
+
*/
|
|
17
|
+
export function nixLdLibraryPath(): string | null {
|
|
18
|
+
if (!existsSync("/etc/NIXOS")) return null;
|
|
19
|
+
|
|
20
|
+
let chromePath: string;
|
|
21
|
+
try {
|
|
22
|
+
chromePath = execFileSync("bash", ["-lc", 'readlink -f "$(command -v chromium)"'], {
|
|
23
|
+
encoding: "utf-8",
|
|
24
|
+
}).trim();
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
if (!chromePath || !existsSync(chromePath)) return null;
|
|
29
|
+
|
|
30
|
+
let closure: string[];
|
|
31
|
+
try {
|
|
32
|
+
closure = execFileSync("nix-store", ["-qR", chromePath], { encoding: "utf-8" })
|
|
33
|
+
.split("\n")
|
|
34
|
+
.filter(Boolean);
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const dirs: string[] = [];
|
|
40
|
+
for (const p of closure) {
|
|
41
|
+
for (const sub of [`${p}/lib`, `${p}/lib64`]) {
|
|
42
|
+
if (existsSync(sub)) dirs.push(sub);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (dirs.length === 0) return null;
|
|
46
|
+
|
|
47
|
+
const existing = process.env.NIX_LD_LIBRARY_PATH;
|
|
48
|
+
return existing ? `${existing}:${dirs.join(":")}` : dirs.join(":");
|
|
49
|
+
}
|
|
@@ -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",
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
import { log, outro, spinner } from "@clack/prompts";
|
|
2
|
-
import { trpcMutate, trpcQuery } from "../utils/api";
|
|
3
|
-
|
|
4
|
-
interface DevStatusResponse {
|
|
5
|
-
devMode: boolean;
|
|
6
|
-
registries: Array<{ url: string; name: string; isDefault: boolean }>;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export async function runAddDevRegistry(options: {
|
|
10
|
-
registryUrl?: string;
|
|
11
|
-
apiUrl?: string;
|
|
12
|
-
}): Promise<void> {
|
|
13
|
-
const registryUrl = options.registryUrl || "http://localhost:5173/__widgets/registry.json";
|
|
14
|
-
const apiUrl = options.apiUrl || "http://localhost:3333";
|
|
15
|
-
|
|
16
|
-
const s = spinner();
|
|
17
|
-
|
|
18
|
-
// Step 1: Check if dev mode is enabled
|
|
19
|
-
s.start("Checking dev mode status...");
|
|
20
|
-
let devEnabled = false;
|
|
21
|
-
try {
|
|
22
|
-
const status = await trpcQuery<DevStatusResponse>({
|
|
23
|
-
apiUrl,
|
|
24
|
-
path: "appConfig.get",
|
|
25
|
-
});
|
|
26
|
-
devEnabled = status.devMode;
|
|
27
|
-
} catch (err) {
|
|
28
|
-
s.stop("Failed to connect");
|
|
29
|
-
log.error(
|
|
30
|
-
`Could not reach dashboard API at ${apiUrl}: ${err instanceof Error ? err.message : String(err)}`,
|
|
31
|
-
);
|
|
32
|
-
log.info("Make sure the dashboard API server is running and the URL is correct.");
|
|
33
|
-
process.exit(1);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// Step 2: Enable dev mode if needed
|
|
37
|
-
if (!devEnabled) {
|
|
38
|
-
s.message("Enabling dev mode...");
|
|
39
|
-
try {
|
|
40
|
-
const result = await trpcMutate<{ devMode: boolean }>({
|
|
41
|
-
apiUrl,
|
|
42
|
-
path: "appConfig.toggleDevMode",
|
|
43
|
-
input: {},
|
|
44
|
-
});
|
|
45
|
-
if (!result.devMode) {
|
|
46
|
-
s.stop("Failed");
|
|
47
|
-
log.error("Could not enable dev mode");
|
|
48
|
-
process.exit(1);
|
|
49
|
-
}
|
|
50
|
-
} catch (err) {
|
|
51
|
-
s.stop("Failed");
|
|
52
|
-
log.error(`Failed to enable dev mode: ${err instanceof Error ? err.message : String(err)}`);
|
|
53
|
-
process.exit(1);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
s.stop("Dev mode enabled");
|
|
57
|
-
|
|
58
|
-
// Step 3: Add the registry via /dev/registries endpoint
|
|
59
|
-
s.start("Adding dev registry...");
|
|
60
|
-
try {
|
|
61
|
-
const res = await fetch(`${apiUrl.replace(/\/$/, "")}/dev/registries`, {
|
|
62
|
-
method: "POST",
|
|
63
|
-
headers: { "Content-Type": "application/json" },
|
|
64
|
-
body: JSON.stringify({ url: registryUrl, name: "Local Dev Server" }),
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
if (res.status === 409) {
|
|
68
|
-
s.stop("Registry already registered");
|
|
69
|
-
log.info(`Registry ${registryUrl} is already configured`);
|
|
70
|
-
} else if (res.ok) {
|
|
71
|
-
s.stop("Registry added");
|
|
72
|
-
log.success(`Added registry: ${registryUrl}`);
|
|
73
|
-
} else {
|
|
74
|
-
const err = (await res.json()) as { error: string };
|
|
75
|
-
s.stop("Failed");
|
|
76
|
-
log.error(`Failed to add registry: ${err.error}`);
|
|
77
|
-
process.exit(1);
|
|
78
|
-
}
|
|
79
|
-
} catch (err) {
|
|
80
|
-
s.stop("Failed");
|
|
81
|
-
log.error(`Failed to add registry: ${err instanceof Error ? err.message : String(err)}`);
|
|
82
|
-
process.exit(1);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
outro("Done! Refresh your dashboard to see dev widgets.");
|
|
86
|
-
}
|