@glasshome/widget-cli 0.1.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/LICENSE +21 -0
- package/README.md +35 -0
- package/bin/cli.ts +141 -0
- package/bin/create.ts +220 -0
- package/package.json +50 -0
- package/src/commands/add.ts +136 -0
- package/src/commands/build.ts +22 -0
- package/src/commands/connect.ts +208 -0
- package/src/commands/dev-registry.ts +86 -0
- package/src/commands/info.ts +76 -0
- package/src/commands/login.ts +163 -0
- package/src/commands/publish.ts +266 -0
- package/src/commands/upgrade.ts +134 -0
- package/src/commands/validate.ts +192 -0
- package/src/utils/api.ts +55 -0
- package/src/utils/auth.ts +95 -0
- package/src/utils/hub-api.ts +104 -0
- package/src/utils/manifest.ts +126 -0
- package/template/.gitignore.template +4 -0
- package/template/.mise.toml.template +3 -0
- package/template/README.md.template +90 -0
- package/template/package.json.template +24 -0
- package/template/src/index.tsx.template +49 -0
- package/template/tsconfig.json.template +14 -0
- package/template/vite.config.ts.template +7 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { cancel, isCancel, log, multiselect, select, spinner } from "@clack/prompts";
|
|
5
|
+
import semver from "semver";
|
|
6
|
+
import { getHubUrl, getToken } from "../utils/auth";
|
|
7
|
+
import { confirmPublish, fetchScopes, requestPublish, uploadToR2 } from "../utils/hub-api";
|
|
8
|
+
import {
|
|
9
|
+
discoverWidgets,
|
|
10
|
+
formatBytes,
|
|
11
|
+
getWidgetBundleInfo,
|
|
12
|
+
readManifest,
|
|
13
|
+
writeManifest,
|
|
14
|
+
} from "../utils/manifest";
|
|
15
|
+
import { runLogin } from "./login";
|
|
16
|
+
|
|
17
|
+
export async function runPublish(cwd: string): Promise<void> {
|
|
18
|
+
// Step 1: Validate all widgets
|
|
19
|
+
log.info("Running validation...");
|
|
20
|
+
const { runValidate } = await import("./validate");
|
|
21
|
+
const valid = await runValidate(cwd);
|
|
22
|
+
if (!valid) {
|
|
23
|
+
log.error("Fix validation errors before publishing.");
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Step 2: Read package.json
|
|
28
|
+
const pkgPath = resolve(cwd, "package.json");
|
|
29
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
30
|
+
const currentVersion = pkg.version ?? "0.0.0";
|
|
31
|
+
|
|
32
|
+
// Step 3: Version bump prompt
|
|
33
|
+
const bump = await select({
|
|
34
|
+
message: `Current version: ${currentVersion}. Bump version?`,
|
|
35
|
+
options: [
|
|
36
|
+
{ value: "keep", label: `Keep ${currentVersion}` },
|
|
37
|
+
{
|
|
38
|
+
value: "patch",
|
|
39
|
+
label: `Patch (${semver.inc(currentVersion, "patch")})`,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
value: "minor",
|
|
43
|
+
label: `Minor (${semver.inc(currentVersion, "minor")})`,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
value: "major",
|
|
47
|
+
label: `Major (${semver.inc(currentVersion, "major")})`,
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (isCancel(bump)) {
|
|
53
|
+
cancel("Publish cancelled.");
|
|
54
|
+
process.exit(0);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let version = currentVersion;
|
|
58
|
+
if (bump !== "keep") {
|
|
59
|
+
version = semver.inc(currentVersion, bump as "patch" | "minor" | "major") ?? currentVersion;
|
|
60
|
+
|
|
61
|
+
pkg.version = version;
|
|
62
|
+
writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
63
|
+
|
|
64
|
+
const allWidgets = discoverWidgets(cwd);
|
|
65
|
+
for (const name of allWidgets) {
|
|
66
|
+
const manifest = readManifest(cwd, name);
|
|
67
|
+
manifest.version = version;
|
|
68
|
+
writeManifest(cwd, name, manifest);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
log.info(`Version bumped to ${version}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Step 4: Rebuild
|
|
75
|
+
const s = spinner();
|
|
76
|
+
s.start("Building widgets...");
|
|
77
|
+
const buildProc = Bun.spawnSync(["bun", "run", "build"], { cwd });
|
|
78
|
+
if (buildProc.exitCode !== 0) {
|
|
79
|
+
s.stop("Build failed");
|
|
80
|
+
log.error(buildProc.stderr.toString());
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
s.stop("Build complete");
|
|
84
|
+
|
|
85
|
+
// Step 5: Show summary
|
|
86
|
+
const widgets = discoverWidgets(cwd);
|
|
87
|
+
log.success(`Ready for publishing: ${pkg.name}@${version}`);
|
|
88
|
+
log.info("");
|
|
89
|
+
|
|
90
|
+
for (const name of widgets) {
|
|
91
|
+
const manifest = readManifest(cwd, name);
|
|
92
|
+
let sizeInfo = "";
|
|
93
|
+
try {
|
|
94
|
+
const info = getWidgetBundleInfo(cwd, name);
|
|
95
|
+
sizeInfo = ` — ${formatBytes(info.raw)} (${formatBytes(info.gzip)} gzipped)`;
|
|
96
|
+
} catch {}
|
|
97
|
+
log.info(
|
|
98
|
+
` ${manifest.tag} [${manifest.minSize.w}x${manifest.minSize.h} - ${manifest.maxSize.w}x${manifest.maxSize.h}]${sizeInfo}`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Step 6: Authenticate with Hub
|
|
103
|
+
const hubUrl = getHubUrl();
|
|
104
|
+
let token = await getToken(hubUrl);
|
|
105
|
+
|
|
106
|
+
if (!token) {
|
|
107
|
+
log.warn("Not authenticated with Hub. Starting login...");
|
|
108
|
+
try {
|
|
109
|
+
await runLogin(hubUrl);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
log.error(err instanceof Error ? err.message : "Login failed.");
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
token = await getToken(hubUrl);
|
|
115
|
+
if (!token) {
|
|
116
|
+
log.error("Authentication failed. Run `bun widget login` manually.");
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Fetch available scopes from hub
|
|
122
|
+
const scopes = await fetchScopes(hubUrl, token!);
|
|
123
|
+
if (scopes.length === 0) {
|
|
124
|
+
log.error("No publishing scopes available. Ensure your account is properly set up.");
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let scope: string;
|
|
129
|
+
if (scopes.length === 1) {
|
|
130
|
+
const only = scopes[0]!;
|
|
131
|
+
scope = only.name;
|
|
132
|
+
log.info(`Publishing as @${scope} (${only.type})`);
|
|
133
|
+
} else {
|
|
134
|
+
const choice = await select({
|
|
135
|
+
message: "Publish under which scope?",
|
|
136
|
+
options: scopes.map((s) => ({
|
|
137
|
+
value: s.name,
|
|
138
|
+
label: s.type === "personal" ? `@${s.name} (personal)` : `@${s.name} (${s.displayName})`,
|
|
139
|
+
})),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
if (isCancel(choice)) {
|
|
143
|
+
cancel("Publish cancelled.");
|
|
144
|
+
process.exit(0);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
scope = choice as string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Step 7: Select widgets to publish
|
|
151
|
+
const widgetOptions = widgets.map((name) => {
|
|
152
|
+
const manifest = readManifest(cwd, name);
|
|
153
|
+
return {
|
|
154
|
+
value: name,
|
|
155
|
+
label: `${manifest.tag} (${manifest.minSize.w}x${manifest.minSize.h} - ${manifest.maxSize.w}x${manifest.maxSize.h})`,
|
|
156
|
+
};
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const selected = await multiselect({
|
|
160
|
+
message: "Select widgets to publish:",
|
|
161
|
+
options: widgetOptions,
|
|
162
|
+
initialValues: widgets,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
if (isCancel(selected)) {
|
|
166
|
+
cancel("Publish cancelled.");
|
|
167
|
+
process.exit(0);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const selectedWidgets = selected as string[];
|
|
171
|
+
if (selectedWidgets.length === 0) {
|
|
172
|
+
log.warn("No widgets selected.");
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Step 8: Publish each selected widget
|
|
177
|
+
const published: Array<{ name: string; cdnUrl: string }> = [];
|
|
178
|
+
const skipped: Array<{ name: string; reason: string }> = [];
|
|
179
|
+
|
|
180
|
+
for (const widgetName of selectedWidgets) {
|
|
181
|
+
const manifest = readManifest(cwd, widgetName);
|
|
182
|
+
const distPath = resolve(cwd, "dist", `${widgetName}.js`);
|
|
183
|
+
|
|
184
|
+
let bundleBuffer: Buffer;
|
|
185
|
+
try {
|
|
186
|
+
bundleBuffer = Buffer.from(readFileSync(distPath));
|
|
187
|
+
} catch {
|
|
188
|
+
log.error(`Bundle not found: dist/${widgetName}.js`);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Compute SHA-256 hash
|
|
193
|
+
const sha256Hash = createHash("sha256").update(bundleBuffer).digest("hex");
|
|
194
|
+
|
|
195
|
+
s.start(`Publishing ${manifest.tag}@${version}...`);
|
|
196
|
+
|
|
197
|
+
// Request publish (get presigned URL)
|
|
198
|
+
let publishData: Awaited<ReturnType<typeof requestPublish>>;
|
|
199
|
+
try {
|
|
200
|
+
publishData = await requestPublish(hubUrl, token!, {
|
|
201
|
+
scope,
|
|
202
|
+
name: widgetName,
|
|
203
|
+
displayName: manifest.name,
|
|
204
|
+
description: manifest.description,
|
|
205
|
+
minSize: manifest.minSize,
|
|
206
|
+
maxSize: manifest.maxSize,
|
|
207
|
+
sdkVersion: manifest.sdkVersion,
|
|
208
|
+
version,
|
|
209
|
+
bundleSize: bundleBuffer.byteLength,
|
|
210
|
+
sha256Hash,
|
|
211
|
+
manifestJson: JSON.stringify(manifest),
|
|
212
|
+
});
|
|
213
|
+
} catch (err: any) {
|
|
214
|
+
if (err.status === 409) {
|
|
215
|
+
s.stop(`Skipped ${manifest.tag}@${version}`);
|
|
216
|
+
log.warn(`${manifest.tag}@${version}: already published (immutable)`);
|
|
217
|
+
skipped.push({ name: manifest.tag, reason: "version already exists" });
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
s.stop(`Failed ${manifest.tag}`);
|
|
221
|
+
log.error(`${manifest.tag}: ${err.message}`);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Upload to R2
|
|
226
|
+
s.message(`Uploading ${manifest.tag} to CDN...`);
|
|
227
|
+
try {
|
|
228
|
+
await uploadToR2(publishData.uploadUrl, bundleBuffer);
|
|
229
|
+
} catch (err: any) {
|
|
230
|
+
s.stop(`Upload failed for ${manifest.tag}`);
|
|
231
|
+
log.error(`${manifest.tag}: ${err.message}`);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Confirm publish
|
|
236
|
+
s.message(`Confirming ${manifest.tag}...`);
|
|
237
|
+
try {
|
|
238
|
+
const result = await confirmPublish(hubUrl, token!, publishData.versionId);
|
|
239
|
+
s.stop(`Published ${manifest.tag}@${version}`);
|
|
240
|
+
published.push({ name: manifest.tag, cdnUrl: result.bundleUrl });
|
|
241
|
+
} catch (err: any) {
|
|
242
|
+
s.stop(`Confirm failed for ${manifest.tag}`);
|
|
243
|
+
log.error(`${manifest.tag}: ${err.message}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Step 9: Summary
|
|
248
|
+
log.info("");
|
|
249
|
+
if (published.length > 0) {
|
|
250
|
+
log.success(`Published ${published.length} widget(s):`);
|
|
251
|
+
for (const w of published) {
|
|
252
|
+
log.info(` ${w.name} -> ${w.cdnUrl}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (skipped.length > 0) {
|
|
257
|
+
log.warn(`Skipped ${skipped.length} widget(s):`);
|
|
258
|
+
for (const w of skipped) {
|
|
259
|
+
log.info(` ${w.name}: ${w.reason}`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (published.length === 0 && skipped.length === 0) {
|
|
264
|
+
log.error("No widgets were published.");
|
|
265
|
+
}
|
|
266
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { log, spinner } from "@clack/prompts";
|
|
4
|
+
import { discoverWidgets, readManifest, writeManifest } from "../utils/manifest";
|
|
5
|
+
import { runValidate } from "./validate";
|
|
6
|
+
|
|
7
|
+
/** Walk up from cwd looking for a package.json with a `workspaces` field. */
|
|
8
|
+
function findMonorepoRoot(from: string): string | null {
|
|
9
|
+
let dir = resolve(from);
|
|
10
|
+
const root = (dir.match(/^[A-Za-z]:\\/) ?? ["/"])[0];
|
|
11
|
+
while (true) {
|
|
12
|
+
const pkgPath = join(dir, "package.json");
|
|
13
|
+
if (existsSync(pkgPath)) {
|
|
14
|
+
try {
|
|
15
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
16
|
+
if (pkg.workspaces) return dir;
|
|
17
|
+
} catch {}
|
|
18
|
+
}
|
|
19
|
+
const parent = dirname(dir);
|
|
20
|
+
if (parent === dir || parent === root) break;
|
|
21
|
+
dir = parent;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Find @glasshome/widget-sdk version from workspace resolution or node_modules. */
|
|
27
|
+
function findWorkspaceSdkVersion(monorepoRoot: string): string | null {
|
|
28
|
+
const sdkPkgPaths = [
|
|
29
|
+
join(monorepoRoot, "widget-sdk/package.json"),
|
|
30
|
+
join(monorepoRoot, "node_modules/@glasshome/widget-sdk/package.json"),
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
for (const sdkPath of sdkPkgPaths) {
|
|
34
|
+
if (existsSync(sdkPath)) {
|
|
35
|
+
try {
|
|
36
|
+
const sdkPkg = JSON.parse(readFileSync(sdkPath, "utf-8"));
|
|
37
|
+
if (sdkPkg.version) return sdkPkg.version;
|
|
38
|
+
} catch {}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Update sdkVersion in all manifest.json files. */
|
|
45
|
+
function syncManifestSdkVersions(cwd: string, newVersion: string): void {
|
|
46
|
+
const widgets = discoverWidgets(cwd);
|
|
47
|
+
if (widgets.length === 0) return;
|
|
48
|
+
|
|
49
|
+
const sdkRange = `^${newVersion}`;
|
|
50
|
+
for (const name of widgets) {
|
|
51
|
+
const manifest = readManifest(cwd, name);
|
|
52
|
+
if (manifest.sdkVersion !== sdkRange) {
|
|
53
|
+
manifest.sdkVersion = sdkRange;
|
|
54
|
+
writeManifest(cwd, name, manifest);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
log.info(`Updated sdkVersion to ${sdkRange} in ${widgets.length} manifest(s)`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function runUpgrade(cwd: string): Promise<void> {
|
|
61
|
+
const pkgPath = resolve(cwd, "package.json");
|
|
62
|
+
if (!existsSync(pkgPath)) {
|
|
63
|
+
log.error("No package.json found in current directory.");
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
68
|
+
const currentVersion =
|
|
69
|
+
pkg.peerDependencies?.["@glasshome/widget-sdk"] ??
|
|
70
|
+
pkg.devDependencies?.["@glasshome/widget-sdk"] ??
|
|
71
|
+
pkg.dependencies?.["@glasshome/widget-sdk"];
|
|
72
|
+
|
|
73
|
+
if (!currentVersion) {
|
|
74
|
+
log.error("@glasshome/widget-sdk is not listed in dependencies or peerDependencies.");
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
log.info(`Current @glasshome/widget-sdk version: ${currentVersion}`);
|
|
79
|
+
|
|
80
|
+
const monorepoRoot = findMonorepoRoot(cwd);
|
|
81
|
+
|
|
82
|
+
if (monorepoRoot) {
|
|
83
|
+
// Workspace mode: read SDK version from workspace, sync with bun install
|
|
84
|
+
const sdkVersion = findWorkspaceSdkVersion(monorepoRoot);
|
|
85
|
+
|
|
86
|
+
if (!sdkVersion) {
|
|
87
|
+
log.warn(
|
|
88
|
+
"Could not find @glasshome/widget-sdk in the workspace. Run `bun install` from the monorepo root.",
|
|
89
|
+
);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const cleanCurrent = currentVersion.replace(/^[\^~]/, "");
|
|
94
|
+
if (cleanCurrent === sdkVersion) {
|
|
95
|
+
log.success(`Already up to date with workspace SDK (${sdkVersion})`);
|
|
96
|
+
// Still sync manifests in case they're out of date
|
|
97
|
+
syncManifestSdkVersions(cwd, sdkVersion);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
log.info(`Workspace SDK version: ${sdkVersion}`);
|
|
102
|
+
|
|
103
|
+
const s = spinner();
|
|
104
|
+
s.start("Syncing workspace dependencies...");
|
|
105
|
+
const installProc = Bun.spawnSync(["bun", "install"], { cwd: monorepoRoot });
|
|
106
|
+
if (installProc.exitCode !== 0) {
|
|
107
|
+
s.stop("Install failed");
|
|
108
|
+
log.error(installProc.stderr.toString());
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
s.stop("Dependencies synced");
|
|
112
|
+
|
|
113
|
+
// Sync sdkVersion in all manifest.json files
|
|
114
|
+
syncManifestSdkVersions(cwd, sdkVersion);
|
|
115
|
+
|
|
116
|
+
// Run validate to check compatibility
|
|
117
|
+
log.info("Checking compatibility...");
|
|
118
|
+
const valid = await runValidate(cwd);
|
|
119
|
+
if (valid) {
|
|
120
|
+
log.success(`Synced with workspace @glasshome/widget-sdk@${sdkVersion}`);
|
|
121
|
+
} else {
|
|
122
|
+
log.warn(`Synced to ${sdkVersion} but validation has warnings/errors. Check above.`);
|
|
123
|
+
}
|
|
124
|
+
} else {
|
|
125
|
+
// Standalone mode: can't fetch from npm (private package), guide user
|
|
126
|
+
log.info("@glasshome/widget-sdk is a workspace package and is not published to npm.");
|
|
127
|
+
log.info("");
|
|
128
|
+
log.step("To upgrade:");
|
|
129
|
+
log.info(" 1. Update the @glasshome/widget-sdk version in your package.json peerDependencies");
|
|
130
|
+
log.info(" 2. Run `bun install` to install the new version");
|
|
131
|
+
log.info(" 3. Run `bun widget upgrade` again to sync manifest files");
|
|
132
|
+
log.info(" 4. Run `bun widget validate` to check compatibility");
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { log, spinner } from "@clack/prompts";
|
|
4
|
+
import {
|
|
5
|
+
discoverWidgets,
|
|
6
|
+
formatBytes,
|
|
7
|
+
getWidgetBundleInfo,
|
|
8
|
+
readManifest,
|
|
9
|
+
readRegistry,
|
|
10
|
+
type WidgetManifest,
|
|
11
|
+
} from "../utils/manifest";
|
|
12
|
+
|
|
13
|
+
const TAG_REGEX = /^[a-z][a-z0-9]*(-[a-z0-9]+)+$/;
|
|
14
|
+
|
|
15
|
+
interface ValidationResult {
|
|
16
|
+
passed: boolean;
|
|
17
|
+
errors: string[];
|
|
18
|
+
warnings: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function validateManifest(manifest: WidgetManifest): ValidationResult {
|
|
22
|
+
const errors: string[] = [];
|
|
23
|
+
const warnings: string[] = [];
|
|
24
|
+
|
|
25
|
+
// Required fields
|
|
26
|
+
if (!manifest.tag) errors.push("Missing required field: tag");
|
|
27
|
+
if (!manifest.name) errors.push("Missing required field: name");
|
|
28
|
+
if (!manifest.sdkVersion) errors.push("Missing required field: sdkVersion");
|
|
29
|
+
|
|
30
|
+
// Tag format
|
|
31
|
+
if (manifest.tag && !TAG_REGEX.test(manifest.tag)) {
|
|
32
|
+
errors.push(
|
|
33
|
+
`Invalid tag format "${manifest.tag}" — must match ${TAG_REGEX} (e.g. glasshome-my-widget)`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// minSize validation
|
|
38
|
+
if (
|
|
39
|
+
!manifest.minSize ||
|
|
40
|
+
typeof manifest.minSize !== "object" ||
|
|
41
|
+
typeof manifest.minSize.w !== "number" ||
|
|
42
|
+
typeof manifest.minSize.h !== "number"
|
|
43
|
+
) {
|
|
44
|
+
errors.push("minSize must be an object with numeric w and h properties");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// maxSize validation
|
|
48
|
+
if (
|
|
49
|
+
!manifest.maxSize ||
|
|
50
|
+
typeof manifest.maxSize !== "object" ||
|
|
51
|
+
typeof manifest.maxSize.w !== "number" ||
|
|
52
|
+
typeof manifest.maxSize.h !== "number"
|
|
53
|
+
) {
|
|
54
|
+
errors.push("maxSize must be an object with numeric w and h properties");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Schema validation
|
|
58
|
+
if (manifest.schema) {
|
|
59
|
+
if (typeof manifest.schema !== "object") {
|
|
60
|
+
errors.push("Schema must be an object");
|
|
61
|
+
} else {
|
|
62
|
+
if (manifest.schema.type !== "object") {
|
|
63
|
+
warnings.push('Schema type should be "object"');
|
|
64
|
+
}
|
|
65
|
+
if (!manifest.schema.properties || typeof manifest.schema.properties !== "object") {
|
|
66
|
+
warnings.push("Schema should have a properties object");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Check defaultConfig matches schema properties
|
|
71
|
+
if (manifest.defaultConfig && manifest.schema.properties) {
|
|
72
|
+
const schemaKeys = Object.keys(manifest.schema.properties as Record<string, unknown>);
|
|
73
|
+
const configKeys = Object.keys(manifest.defaultConfig);
|
|
74
|
+
for (const key of configKeys) {
|
|
75
|
+
if (!schemaKeys.includes(key)) {
|
|
76
|
+
warnings.push(`defaultConfig key "${key}" is not defined in schema properties`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
passed: errors.length === 0,
|
|
84
|
+
errors,
|
|
85
|
+
warnings,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function runValidate(cwd: string, widgetName?: string): Promise<boolean> {
|
|
90
|
+
const widgets = discoverWidgets(cwd);
|
|
91
|
+
|
|
92
|
+
if (widgets.length === 0) {
|
|
93
|
+
log.error("No widgets found in src/. Each widget needs a manifest.json.");
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const toValidate = widgetName ? widgets.filter((w) => w === widgetName) : widgets;
|
|
98
|
+
|
|
99
|
+
if (widgetName && toValidate.length === 0) {
|
|
100
|
+
log.error(`Widget "${widgetName}" not found. Available: ${widgets.join(", ")}`);
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Auto-build if dist/ is missing
|
|
105
|
+
const distDir = resolve(cwd, "dist");
|
|
106
|
+
if (!existsSync(distDir)) {
|
|
107
|
+
const s = spinner();
|
|
108
|
+
s.start("Building widgets (dist/ not found)...");
|
|
109
|
+
const proc = Bun.spawnSync(["bun", "run", "build"], { cwd });
|
|
110
|
+
if (proc.exitCode !== 0) {
|
|
111
|
+
s.stop("Build failed");
|
|
112
|
+
log.error(proc.stderr.toString());
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
s.stop("Build complete");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let allPassed = true;
|
|
119
|
+
|
|
120
|
+
for (const name of toValidate) {
|
|
121
|
+
log.info(`\nValidating widget: ${name}`);
|
|
122
|
+
|
|
123
|
+
// Read and validate manifest.json
|
|
124
|
+
let manifest: WidgetManifest;
|
|
125
|
+
try {
|
|
126
|
+
manifest = readManifest(cwd, name);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
log.error(` Failed to read manifest: ${err instanceof Error ? err.message : String(err)}`);
|
|
129
|
+
allPassed = false;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const result = validateManifest(manifest);
|
|
134
|
+
|
|
135
|
+
if (result.errors.length > 0) {
|
|
136
|
+
for (const err of result.errors) {
|
|
137
|
+
log.error(` - ${err}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (result.warnings.length > 0) {
|
|
142
|
+
for (const warn of result.warnings) {
|
|
143
|
+
log.warn(` - ${warn}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Check bundle exists
|
|
148
|
+
const bundlePath = resolve(cwd, "dist", `${name}.js`);
|
|
149
|
+
if (!existsSync(bundlePath)) {
|
|
150
|
+
log.error(` - dist/${name}.js not found`);
|
|
151
|
+
allPassed = false;
|
|
152
|
+
} else {
|
|
153
|
+
try {
|
|
154
|
+
const info = getWidgetBundleInfo(cwd, name);
|
|
155
|
+
log.info(` Bundle: ${formatBytes(info.raw)} (${formatBytes(info.gzip)} gzipped)`);
|
|
156
|
+
} catch {
|
|
157
|
+
// Ignore size reporting errors
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (result.passed) {
|
|
162
|
+
log.success(` ${name}: passed`);
|
|
163
|
+
} else {
|
|
164
|
+
log.error(` ${name}: failed`);
|
|
165
|
+
allPassed = false;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Validate registry.json
|
|
170
|
+
const registry = readRegistry(cwd);
|
|
171
|
+
if (!registry) {
|
|
172
|
+
log.error("\ndist/registry.json not found");
|
|
173
|
+
allPassed = false;
|
|
174
|
+
} else {
|
|
175
|
+
const registryTags = new Set(registry.widgets.map((w) => w.bundleUrl));
|
|
176
|
+
for (const name of toValidate) {
|
|
177
|
+
if (!registryTags.has(`./${name}.js`)) {
|
|
178
|
+
log.error(` Widget "${name}" not listed in registry.json`);
|
|
179
|
+
allPassed = false;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
log.info(`\nRegistry: ${registry.widgets.length} widget(s)`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (allPassed) {
|
|
186
|
+
log.success("\nAll checks passed");
|
|
187
|
+
} else {
|
|
188
|
+
log.error("\nValidation failed");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return allPassed;
|
|
192
|
+
}
|
package/src/utils/api.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raw HTTP helpers for calling tRPC endpoints without the client library.
|
|
3
|
+
* The API uses no transformer, so plain JSON is sent/received.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
interface TrpcMutationOptions {
|
|
7
|
+
apiUrl: string;
|
|
8
|
+
path: string;
|
|
9
|
+
input: Record<string, unknown>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface TrpcResult<T = unknown> {
|
|
13
|
+
result: { data: T };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function trpcMutate<T = unknown>(opts: TrpcMutationOptions): Promise<T> {
|
|
17
|
+
const url = `${opts.apiUrl.replace(/\/$/, "")}/trpc/${opts.path}`;
|
|
18
|
+
const res = await fetch(url, {
|
|
19
|
+
method: "POST",
|
|
20
|
+
headers: { "Content-Type": "application/json" },
|
|
21
|
+
body: JSON.stringify(opts.input),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
if (!res.ok) {
|
|
25
|
+
const text = await res.text();
|
|
26
|
+
throw new Error(`API error ${res.status}: ${text}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const json = (await res.json()) as TrpcResult<T>;
|
|
30
|
+
return json.result.data;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface TrpcQueryOptions {
|
|
34
|
+
apiUrl: string;
|
|
35
|
+
path: string;
|
|
36
|
+
input?: Record<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function trpcQuery<T = unknown>(opts: TrpcQueryOptions): Promise<T> {
|
|
40
|
+
const base = `${opts.apiUrl.replace(/\/$/, "")}/trpc/${opts.path}`;
|
|
41
|
+
const url = opts.input ? `${base}?input=${encodeURIComponent(JSON.stringify(opts.input))}` : base;
|
|
42
|
+
|
|
43
|
+
const res = await fetch(url, {
|
|
44
|
+
method: "GET",
|
|
45
|
+
headers: { "Content-Type": "application/json" },
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (!res.ok) {
|
|
49
|
+
const text = await res.text();
|
|
50
|
+
throw new Error(`API error ${res.status}: ${text}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const json = (await res.json()) as TrpcResult<T>;
|
|
54
|
+
return json.result.data;
|
|
55
|
+
}
|