@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 GlassHome Labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# @glasshome/widget-cli
|
|
2
|
+
|
|
3
|
+
CLI for creating and managing GlassHome widgets.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add -g @glasshome/widget-cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
bunx @glasshome/widget-cli create my-widget
|
|
15
|
+
cd my-widget
|
|
16
|
+
bun install
|
|
17
|
+
bun run dev
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Commands
|
|
21
|
+
|
|
22
|
+
| Command | Description |
|
|
23
|
+
|---------|-------------|
|
|
24
|
+
| `create <name>` | Scaffold a new widget project |
|
|
25
|
+
| `validate` | Validate widget manifest and bundle |
|
|
26
|
+
| `info` | Show widget metadata |
|
|
27
|
+
| `publish` | Publish widget to the registry |
|
|
28
|
+
|
|
29
|
+
## Documentation
|
|
30
|
+
|
|
31
|
+
Full docs at [glasshome.app/docs](https://glasshome.app/docs)
|
|
32
|
+
|
|
33
|
+
## License
|
|
34
|
+
|
|
35
|
+
MIT
|
package/bin/cli.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { intro, log, outro } from "@clack/prompts";
|
|
5
|
+
|
|
6
|
+
function resolveWidgetDir(): string {
|
|
7
|
+
if (explicitDir) {
|
|
8
|
+
const dir = resolve(process.cwd(), explicitDir);
|
|
9
|
+
if (existsSync(resolve(dir, "src"))) return dir;
|
|
10
|
+
log.error(`No src/ directory found in ${dir}`);
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
const cwd = process.cwd();
|
|
14
|
+
if (existsSync(resolve(cwd, "src"))) return cwd;
|
|
15
|
+
const sibling = resolve(cwd, "../widgets");
|
|
16
|
+
if (existsSync(resolve(sibling, "src"))) return sibling;
|
|
17
|
+
return cwd;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const HELP = `
|
|
21
|
+
glasshome-widget <command> [--dir <path>] [options]
|
|
22
|
+
|
|
23
|
+
Commands:
|
|
24
|
+
add Add a new widget to the project
|
|
25
|
+
build Build all widgets (per-widget, self-contained bundles)
|
|
26
|
+
connect <url> Connect to a running dashboard for live testing
|
|
27
|
+
validate [name] Validate all widgets or a specific one
|
|
28
|
+
publish Build all widgets and publish to Hub
|
|
29
|
+
login [hub-url] Authenticate with GlassHome Hub
|
|
30
|
+
info [name] Show widget metadata and bundle info
|
|
31
|
+
upgrade Upgrade @glasshome/widget-sdk to latest version
|
|
32
|
+
help Show this help message
|
|
33
|
+
|
|
34
|
+
Examples:
|
|
35
|
+
glasshome-widget add
|
|
36
|
+
glasshome-widget build
|
|
37
|
+
glasshome-widget connect http://localhost:3333
|
|
38
|
+
glasshome-widget validate
|
|
39
|
+
glasshome-widget validate clock
|
|
40
|
+
glasshome-widget info
|
|
41
|
+
glasshome-widget info clock
|
|
42
|
+
glasshome-widget publish
|
|
43
|
+
glasshome-widget login
|
|
44
|
+
glasshome-widget login https://my-hub.example.com
|
|
45
|
+
glasshome-widget upgrade
|
|
46
|
+
glasshome-widget publish --dir packages/widgets
|
|
47
|
+
`.trim();
|
|
48
|
+
|
|
49
|
+
const rawArgs = process.argv.slice(2);
|
|
50
|
+
const dirFlagIdx = rawArgs.indexOf("--dir");
|
|
51
|
+
let explicitDir: string | undefined;
|
|
52
|
+
if (dirFlagIdx !== -1) {
|
|
53
|
+
explicitDir = rawArgs[dirFlagIdx + 1];
|
|
54
|
+
rawArgs.splice(dirFlagIdx, 2);
|
|
55
|
+
}
|
|
56
|
+
const [command, ...args] = rawArgs;
|
|
57
|
+
|
|
58
|
+
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
59
|
+
console.log(HELP);
|
|
60
|
+
process.exit(0);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
intro(`glasshome-widget ${command}`);
|
|
64
|
+
|
|
65
|
+
switch (command) {
|
|
66
|
+
case "add": {
|
|
67
|
+
const { runAdd } = await import("../src/commands/add");
|
|
68
|
+
await runAdd(resolveWidgetDir());
|
|
69
|
+
outro("Done");
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
case "build": {
|
|
74
|
+
const { runBuild } = await import("../src/commands/build");
|
|
75
|
+
await runBuild(resolveWidgetDir());
|
|
76
|
+
outro("Done");
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
case "connect": {
|
|
81
|
+
const url = args[0];
|
|
82
|
+
if (!url) {
|
|
83
|
+
log.error("Missing required argument: <url>");
|
|
84
|
+
log.info("Usage: glasshome-widget connect <dashboard-api-url>");
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
const { runConnect } = await import("../src/commands/connect");
|
|
88
|
+
await runConnect(url, resolveWidgetDir());
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
case "validate": {
|
|
93
|
+
const widgetName = args[0];
|
|
94
|
+
const { runValidate } = await import("../src/commands/validate");
|
|
95
|
+
const passed = await runValidate(resolveWidgetDir(), widgetName);
|
|
96
|
+
outro(passed ? "All checks passed" : "Validation failed");
|
|
97
|
+
process.exit(passed ? 0 : 1);
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
case "login": {
|
|
102
|
+
const loginHubUrl = args[0];
|
|
103
|
+
const { runLogin } = await import("../src/commands/login");
|
|
104
|
+
try {
|
|
105
|
+
await runLogin(loginHubUrl);
|
|
106
|
+
outro("Done");
|
|
107
|
+
} catch (err) {
|
|
108
|
+
log.error(err instanceof Error ? err.message : "Login failed.");
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
case "publish": {
|
|
115
|
+
const { runPublish } = await import("../src/commands/publish");
|
|
116
|
+
await runPublish(resolveWidgetDir());
|
|
117
|
+
outro("Done");
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
case "info": {
|
|
122
|
+
const widgetName = args[0];
|
|
123
|
+
const { runInfo } = await import("../src/commands/info");
|
|
124
|
+
await runInfo(resolveWidgetDir(), widgetName);
|
|
125
|
+
outro("");
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
case "upgrade": {
|
|
130
|
+
const { runUpgrade } = await import("../src/commands/upgrade");
|
|
131
|
+
await runUpgrade(resolveWidgetDir());
|
|
132
|
+
outro("Done");
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
default: {
|
|
137
|
+
log.error(`Unknown command: ${command}`);
|
|
138
|
+
console.log(HELP);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
}
|
package/bin/create.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import {
|
|
3
|
+
cpSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
statSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { dirname, join, resolve } from "node:path";
|
|
13
|
+
import { cancel, confirm, intro, isCancel, log, outro, spinner, text } from "@clack/prompts";
|
|
14
|
+
import { promptWidgetDetails, scaffoldWidget } from "../src/commands/add";
|
|
15
|
+
|
|
16
|
+
const templateDir = resolve(import.meta.dir, "../template");
|
|
17
|
+
|
|
18
|
+
/** Walk up from cwd looking for a package.json with a `workspaces` field. */
|
|
19
|
+
function findMonorepoRoot(from: string): string | null {
|
|
20
|
+
let dir = resolve(from);
|
|
21
|
+
const root = (dir.match(/^[A-Za-z]:\\/) ?? ["/"])[0];
|
|
22
|
+
while (true) {
|
|
23
|
+
const pkgPath = join(dir, "package.json");
|
|
24
|
+
if (existsSync(pkgPath)) {
|
|
25
|
+
try {
|
|
26
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
27
|
+
if (pkg.workspaces) return dir;
|
|
28
|
+
} catch {}
|
|
29
|
+
}
|
|
30
|
+
const parent = dirname(dir);
|
|
31
|
+
if (parent === dir || parent === root) break;
|
|
32
|
+
dir = parent;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Check if cwd is inside an existing git repository. */
|
|
38
|
+
function isInsideGitRepo(from: string): boolean {
|
|
39
|
+
const result = Bun.spawnSync(["git", "rev-parse", "--is-inside-work-tree"], {
|
|
40
|
+
cwd: from,
|
|
41
|
+
stderr: "pipe",
|
|
42
|
+
});
|
|
43
|
+
return result.exitCode === 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
intro("Create GlassHome Widget Project");
|
|
47
|
+
|
|
48
|
+
// ---- Project-level prompts ----
|
|
49
|
+
|
|
50
|
+
const projectName = await text({
|
|
51
|
+
message: "Project name",
|
|
52
|
+
placeholder: "my-widgets",
|
|
53
|
+
defaultValue: "my-widgets",
|
|
54
|
+
validate(value) {
|
|
55
|
+
if (!value) return "Name is required";
|
|
56
|
+
if (/\s/.test(value)) return "Name cannot contain spaces";
|
|
57
|
+
if (!/^[a-z]/.test(value)) return "Name must start with a lowercase letter";
|
|
58
|
+
if (!/^[a-z][a-z0-9-]*$/.test(value))
|
|
59
|
+
return "Name must be lowercase alphanumeric with optional hyphens";
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
if (isCancel(projectName)) {
|
|
64
|
+
cancel("Operation cancelled.");
|
|
65
|
+
process.exit(0);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const projectDescription = await text({
|
|
69
|
+
message: "Project description (optional)",
|
|
70
|
+
placeholder: "My GlassHome widget collection",
|
|
71
|
+
defaultValue: "",
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (isCancel(projectDescription)) {
|
|
75
|
+
cancel("Operation cancelled.");
|
|
76
|
+
process.exit(0);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---- First widget prompts ----
|
|
80
|
+
|
|
81
|
+
log.info("Let's set up your first widget:");
|
|
82
|
+
|
|
83
|
+
const widgetDetails = await promptWidgetDetails();
|
|
84
|
+
if (!widgetDetails) process.exit(0);
|
|
85
|
+
|
|
86
|
+
const targetDir = resolve(process.cwd(), projectName as string);
|
|
87
|
+
|
|
88
|
+
if (existsSync(targetDir)) {
|
|
89
|
+
cancel(`Directory "${projectName}" already exists.`);
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const s = spinner();
|
|
94
|
+
s.start("Scaffolding widget project...");
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
// Copy template to target
|
|
98
|
+
cpSync(templateDir, targetDir, { recursive: true });
|
|
99
|
+
|
|
100
|
+
// Strip .template suffixes (used to prevent IDE type-checking of template files)
|
|
101
|
+
function processDir(dir: string) {
|
|
102
|
+
for (const entry of readdirSync(dir)) {
|
|
103
|
+
const fullPath = join(dir, entry);
|
|
104
|
+
if (statSync(fullPath).isDirectory()) {
|
|
105
|
+
processDir(fullPath);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (entry.endsWith(".template")) {
|
|
109
|
+
renameSync(fullPath, join(dir, entry.replace(/\.template$/, "")));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
processDir(targetDir);
|
|
114
|
+
|
|
115
|
+
// Remove the template's src/index.tsx (it will be placed into a widget subdir)
|
|
116
|
+
const oldSrcIndex = join(targetDir, "src/index.tsx");
|
|
117
|
+
if (existsSync(oldSrcIndex)) {
|
|
118
|
+
rmSync(oldSrcIndex);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const projectDesc =
|
|
122
|
+
(projectDescription as string) || `A GlassHome widget project: ${projectName}`;
|
|
123
|
+
|
|
124
|
+
// Replace project-level placeholders in package.json
|
|
125
|
+
const pkgPath = join(targetDir, "package.json");
|
|
126
|
+
let pkgContent = readFileSync(pkgPath, "utf-8");
|
|
127
|
+
pkgContent = pkgContent.replace(/PROJECT_NAME/g, projectName as string);
|
|
128
|
+
pkgContent = pkgContent.replace(/PROJECT_DESCRIPTION/g, projectDesc);
|
|
129
|
+
writeFileSync(pkgPath, pkgContent);
|
|
130
|
+
|
|
131
|
+
// Replace project-level placeholders in README.md
|
|
132
|
+
const readmePath = join(targetDir, "README.md");
|
|
133
|
+
let readmeContent = readFileSync(readmePath, "utf-8");
|
|
134
|
+
readmeContent = readmeContent.replace(/PROJECT_NAME/g, projectName as string);
|
|
135
|
+
readmeContent = readmeContent.replace(/PROJECT_DESCRIPTION/g, projectDesc);
|
|
136
|
+
writeFileSync(readmePath, readmeContent);
|
|
137
|
+
|
|
138
|
+
// Create the first widget in src/{widgetName}/
|
|
139
|
+
scaffoldWidget(targetDir, widgetDetails);
|
|
140
|
+
|
|
141
|
+
s.stop("Widget project created!");
|
|
142
|
+
|
|
143
|
+
// Post-scaffold: auto-install
|
|
144
|
+
const monorepoRoot = findMonorepoRoot(process.cwd());
|
|
145
|
+
const installCwd = monorepoRoot ?? targetDir;
|
|
146
|
+
const installHint = monorepoRoot ? " (from monorepo root)" : "";
|
|
147
|
+
|
|
148
|
+
const shouldInstall = await confirm({
|
|
149
|
+
message: `Install dependencies?${installHint}`,
|
|
150
|
+
initialValue: true,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
if (!isCancel(shouldInstall) && shouldInstall) {
|
|
154
|
+
const installSpinner = spinner();
|
|
155
|
+
installSpinner.start("Installing dependencies...");
|
|
156
|
+
const installProc = Bun.spawnSync(["bun", "install"], { cwd: installCwd });
|
|
157
|
+
if (installProc.exitCode === 0) {
|
|
158
|
+
installSpinner.stop("Dependencies installed");
|
|
159
|
+
} else {
|
|
160
|
+
installSpinner.stop(
|
|
161
|
+
monorepoRoot
|
|
162
|
+
? "Install failed — run `bun install` from the monorepo root"
|
|
163
|
+
: "Install failed — you can run `bun install` manually",
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Post-scaffold: git init (skip when already inside a git repo)
|
|
169
|
+
const inGitRepo = isInsideGitRepo(process.cwd());
|
|
170
|
+
|
|
171
|
+
if (inGitRepo) {
|
|
172
|
+
log.info("Skipping git init — already in a git repository");
|
|
173
|
+
} else {
|
|
174
|
+
const shouldGit = await confirm({
|
|
175
|
+
message: "Initialize git repository?",
|
|
176
|
+
initialValue: true,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
if (!isCancel(shouldGit) && shouldGit) {
|
|
180
|
+
const gitSpinner = spinner();
|
|
181
|
+
gitSpinner.start("Initializing git repository...");
|
|
182
|
+
const initProc = Bun.spawnSync(["git", "init"], { cwd: targetDir });
|
|
183
|
+
if (initProc.exitCode === 0) {
|
|
184
|
+
Bun.spawnSync(["git", "add", "-A"], { cwd: targetDir });
|
|
185
|
+
Bun.spawnSync(["git", "commit", "-m", "Initial widget scaffold"], { cwd: targetDir });
|
|
186
|
+
gitSpinner.stop("Git repository initialized");
|
|
187
|
+
} else {
|
|
188
|
+
gitSpinner.stop("Git init failed — you can run `git init` manually");
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Enhanced outro with next-steps guide
|
|
194
|
+
log.info("");
|
|
195
|
+
log.step("Next steps:");
|
|
196
|
+
if (monorepoRoot) {
|
|
197
|
+
log.info(" bun install Run from monorepo root to link workspace packages");
|
|
198
|
+
log.info(` cd ${projectName}`);
|
|
199
|
+
} else {
|
|
200
|
+
log.info(` cd ${projectName}`);
|
|
201
|
+
if (isCancel(shouldInstall) || !shouldInstall) {
|
|
202
|
+
log.info(" bun install");
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
log.info(" bun widget connect <url> Connect to a running dashboard for live testing");
|
|
206
|
+
log.info(" bun widget add Add another widget to the project");
|
|
207
|
+
log.info(" bun run build Build all widgets + generate registry.json");
|
|
208
|
+
log.info(" bun widget validate Validate all widgets");
|
|
209
|
+
log.info(" bun widget publish Build & publish to GlassHome Hub");
|
|
210
|
+
log.info(" bun widget info Show widget metadata");
|
|
211
|
+
log.info(" bun widget upgrade Upgrade @glasshome/widget-sdk");
|
|
212
|
+
|
|
213
|
+
outro("Happy building!");
|
|
214
|
+
} catch (err) {
|
|
215
|
+
s.stop("Failed to scaffold widget project.");
|
|
216
|
+
// Rollback: remove partially created directory
|
|
217
|
+
rmSync(targetDir, { recursive: true, force: true });
|
|
218
|
+
cancel(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
219
|
+
process.exit(1);
|
|
220
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@glasshome/widget-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI for creating and managing GlassHome widgets",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"create-glasshome-widget": "bin/create.ts",
|
|
8
|
+
"glasshome-widget": "bin/cli.ts"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"create": "bun bin/create.ts",
|
|
12
|
+
"cli": "bun bin/cli.ts"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"bin",
|
|
16
|
+
"src",
|
|
17
|
+
"template"
|
|
18
|
+
],
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@clack/prompts": "^0.10.0",
|
|
21
|
+
"@glasshome/widget-sdk": "^0.1.0",
|
|
22
|
+
"open": "^11.0.0",
|
|
23
|
+
"semver": "^7.6.0"
|
|
24
|
+
},
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "https://github.com/glasshome/widget-cli.git"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"glasshome",
|
|
31
|
+
"widget",
|
|
32
|
+
"cli",
|
|
33
|
+
"scaffold",
|
|
34
|
+
"home-assistant",
|
|
35
|
+
"dashboard",
|
|
36
|
+
"smart-home"
|
|
37
|
+
],
|
|
38
|
+
"homepage": "https://github.com/glasshome/widget-cli#readme",
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/glasshome/widget-cli/issues"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"bun": ">=1.0.0"
|
|
47
|
+
},
|
|
48
|
+
"author": "GlassHome Labs",
|
|
49
|
+
"license": "MIT"
|
|
50
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { cancel, isCancel, log, text } from "@clack/prompts";
|
|
4
|
+
|
|
5
|
+
/** Capitalize kebab-case into PascalCase for component names. */
|
|
6
|
+
function capitalize(str: string): string {
|
|
7
|
+
return str
|
|
8
|
+
.split("-")
|
|
9
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
10
|
+
.join("");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Create the widget source files in src/{widgetName}/. */
|
|
14
|
+
export function scaffoldWidget(
|
|
15
|
+
cwd: string,
|
|
16
|
+
opts: {
|
|
17
|
+
widgetName: string;
|
|
18
|
+
tag: string;
|
|
19
|
+
description: string;
|
|
20
|
+
},
|
|
21
|
+
): void {
|
|
22
|
+
const { widgetName, tag, description } = opts;
|
|
23
|
+
const widgetDir = resolve(cwd, "src", widgetName);
|
|
24
|
+
|
|
25
|
+
if (existsSync(widgetDir)) {
|
|
26
|
+
throw new Error(`src/${widgetName}/ already exists`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
mkdirSync(widgetDir, { recursive: true });
|
|
30
|
+
|
|
31
|
+
const displayName = capitalize(widgetName);
|
|
32
|
+
const widgetDescription = description || `A GlassHome dashboard widget: ${widgetName}`;
|
|
33
|
+
|
|
34
|
+
// Read index.tsx template from the template directory
|
|
35
|
+
const templateDir = resolve(import.meta.dir, "../../template");
|
|
36
|
+
let srcContent = readFileSync(resolve(templateDir, "src/index.tsx.template"), "utf-8");
|
|
37
|
+
srcContent = srcContent.replace(/WIDGET_TAG/g, tag);
|
|
38
|
+
srcContent = srcContent.replace(/WIDGET_NAME/g, displayName);
|
|
39
|
+
writeFileSync(resolve(widgetDir, "index.tsx"), srcContent);
|
|
40
|
+
|
|
41
|
+
// Generate manifest.json
|
|
42
|
+
const manifest = {
|
|
43
|
+
tag,
|
|
44
|
+
name: displayName,
|
|
45
|
+
description: widgetDescription,
|
|
46
|
+
minSize: { w: 1, h: 1 },
|
|
47
|
+
maxSize: { w: 4, h: 4 },
|
|
48
|
+
sdkVersion: "^0.2.0",
|
|
49
|
+
version: "0.1.0",
|
|
50
|
+
};
|
|
51
|
+
writeFileSync(resolve(widgetDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Interactive prompts for widget details. Returns null if cancelled. */
|
|
55
|
+
export async function promptWidgetDetails(defaults?: { widgetName?: string }): Promise<{
|
|
56
|
+
widgetName: string;
|
|
57
|
+
tag: string;
|
|
58
|
+
description: string;
|
|
59
|
+
} | null> {
|
|
60
|
+
const widgetName = await text({
|
|
61
|
+
message: "Widget name",
|
|
62
|
+
placeholder: "clock",
|
|
63
|
+
defaultValue: defaults?.widgetName,
|
|
64
|
+
validate(value) {
|
|
65
|
+
if (!value) return "Name is required";
|
|
66
|
+
if (/\s/.test(value)) return "Name cannot contain spaces";
|
|
67
|
+
if (!/^[a-z]/.test(value)) return "Name must start with a lowercase letter";
|
|
68
|
+
if (!/^[a-z][a-z0-9-]*$/.test(value))
|
|
69
|
+
return "Name must be lowercase alphanumeric with optional hyphens";
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
if (isCancel(widgetName)) {
|
|
73
|
+
cancel("Operation cancelled.");
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const tag = await text({
|
|
78
|
+
message: "Custom element tag",
|
|
79
|
+
placeholder: `glasshome-${widgetName}`,
|
|
80
|
+
defaultValue: `glasshome-${widgetName}`,
|
|
81
|
+
validate(value) {
|
|
82
|
+
if (!value) return "Tag is required";
|
|
83
|
+
if (!/^[a-z]/.test(value)) return "Tag must start with a lowercase letter";
|
|
84
|
+
if (!value.includes("-")) return "Custom element tags must contain a hyphen";
|
|
85
|
+
if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)+$/.test(value))
|
|
86
|
+
return "Tag must be lowercase alphanumeric with hyphens (e.g. glasshome-my-widget)";
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
if (isCancel(tag)) {
|
|
90
|
+
cancel("Operation cancelled.");
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const description = await text({
|
|
95
|
+
message: "Description (optional)",
|
|
96
|
+
placeholder: "A GlassHome dashboard widget",
|
|
97
|
+
defaultValue: "",
|
|
98
|
+
});
|
|
99
|
+
if (isCancel(description)) {
|
|
100
|
+
cancel("Operation cancelled.");
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
widgetName: widgetName as string,
|
|
106
|
+
tag: tag as string,
|
|
107
|
+
description: description as string,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function runAdd(cwd: string): Promise<void> {
|
|
112
|
+
// Verify project structure
|
|
113
|
+
const srcDir = resolve(cwd, "src");
|
|
114
|
+
if (!existsSync(srcDir)) {
|
|
115
|
+
log.error("No src/ directory found. Are you in a GlassHome widget project?");
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const details = await promptWidgetDetails();
|
|
120
|
+
if (!details) process.exit(0);
|
|
121
|
+
|
|
122
|
+
const widgetDir = resolve(srcDir, details.widgetName);
|
|
123
|
+
if (existsSync(widgetDir)) {
|
|
124
|
+
log.error(`src/${details.widgetName}/ already exists.`);
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
scaffoldWidget(cwd, details);
|
|
130
|
+
log.success(`Widget "${details.widgetName}" added at src/${details.widgetName}/`);
|
|
131
|
+
log.info("Run `bun run build` to include it in the registry.");
|
|
132
|
+
} catch (err) {
|
|
133
|
+
log.error(`Failed to add widget: ${err instanceof Error ? err.message : String(err)}`);
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { log, spinner } from "@clack/prompts";
|
|
2
|
+
import { buildWidgets } from "@glasshome/widget-sdk/vite";
|
|
3
|
+
|
|
4
|
+
export async function runBuild(cwd: string): Promise<void> {
|
|
5
|
+
const s = spinner();
|
|
6
|
+
s.start("Building widgets...");
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
const solid = (await import("vite-plugin-solid")).default;
|
|
10
|
+
process.chdir(cwd);
|
|
11
|
+
await buildWidgets({
|
|
12
|
+
srcDir: "src",
|
|
13
|
+
outDir: "dist",
|
|
14
|
+
plugins: [solid()],
|
|
15
|
+
});
|
|
16
|
+
s.stop("Build complete");
|
|
17
|
+
} catch (err) {
|
|
18
|
+
s.stop("Build failed");
|
|
19
|
+
log.error(err instanceof Error ? err.message : String(err));
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
}
|