hearth-dash 1.1.0 → 1.1.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/README.md +142 -138
- package/cli/commands/config.js +1 -1
- package/cli/commands/deploy.js +107 -107
- package/cli/commands/mcp.js +12 -12
- package/cli/index.js +1 -1
- package/cli/lib/wrangler.js +31 -31
- package/oauth-entry.js +42 -36
- package/package.json +3 -3
- package/schema.sql +18 -18
- package/worker.js +668 -647
- package/wrangler.toml +10 -10
package/README.md
CHANGED
|
@@ -1,142 +1,146 @@
|
|
|
1
|
-
<div align="center">
|
|
2
|
-
|
|
3
|
-
# Hearth
|
|
4
|
-
|
|
5
|
-
**A cozy personal dashboard on Cloudflare Workers**
|
|
6
|
-
|
|
7
|
-
[](#)
|
|
8
|
-
[](#)
|
|
9
|
-
[](LICENSE)
|
|
10
|
-
|
|
11
|
-
</div>
|
|
12
|
-
|
|
13
|
-
Hearth is a small personal dashboard for two people. It runs as a Cloudflare Worker with D1 storage and an optional R2 photo bucket. The same private data is available through a standards-compliant, OAuth-protected MCP Streamable HTTP endpoint so Claude and other compatible clients can discover and use Hearth's tools.
|
|
14
|
-
|
|
15
|
-
## Features
|
|
16
|
-
|
|
17
|
-
- Dashboard overview, moods, shared notes, moments, important dates and shopping list
|
|
18
|
-
- Food and water diary with private R2 photos and daily reviews
|
|
19
|
-
- Weather, barometric pressure history and pressure-shift alerts
|
|
20
|
-
- Configurable partner names
|
|
21
|
-
- Password-protected web dashboard with signed, expiring sessions
|
|
22
|
-
- Streamable HTTP MCP with JSON-RPC `initialize`, `ping`, `tools/list` and `tools/call`
|
|
23
|
-
- OAuth 2.1 authorization with PKCE, protected-resource discovery, CIMD and Dynamic Client Registration
|
|
24
|
-
- Separate `hearth:read` and `hearth:write` permissions
|
|
25
|
-
- Deployment and connector-configuration CLI
|
|
26
|
-
|
|
27
|
-
The MCP server exposes `hearth_status`, `hearth_mood`, `hearth_note`, `hearth_moment`, `hearth_date`, `hearth_shopping_list`, `hearth_shopping_add`, `hearth_pressure`, `hearth_food_diary_today`, `hearth_food_diary_history`, `hearth_food_review`, and `hearth_water_status`.
|
|
28
|
-
|
|
29
|
-
## Deploy
|
|
30
|
-
|
|
31
|
-
### Bundled CLI
|
|
32
|
-
|
|
33
|
-
```bash
|
|
34
|
-
npx hearth-dash deploy
|
|
35
|
-
npx hearth-dash mcp
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
The deploy command creates the D1 database, R2 bucket and OAuth KV namespace; installs the pinned runtime dependency; prompts for configuration; generates a session-signing secret; applies the schema; and deploys the Worker. It does not save the dashboard password locally.
|
|
39
|
-
|
|
40
|
-
### Manual deployment
|
|
41
|
-
|
|
42
|
-
```bash
|
|
43
|
-
npm install
|
|
44
|
-
|
|
45
|
-
# Create storage and put the returned IDs in wrangler.toml
|
|
46
|
-
npx wrangler d1 create hearth-dash-db
|
|
47
|
-
npx wrangler r2 bucket create hearth-dash-photos
|
|
48
|
-
npx wrangler kv namespace create hearth-dash-oauth
|
|
49
|
-
|
|
50
|
-
# Generate a SESSION_SECRET
|
|
51
|
-
node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))"
|
|
52
|
-
|
|
53
|
-
# Optional weather integration
|
|
54
|
-
# (set WEATHER_API_KEY after provisioning if wanted)
|
|
55
|
-
|
|
56
|
-
# Provision the Worker first, then initialize storage and set secrets
|
|
57
|
-
npm run deploy
|
|
58
|
-
npm run db:init:remote
|
|
59
|
-
npx wrangler secret put DASHBOARD_PASSWORD
|
|
60
|
-
npx wrangler secret put SESSION_SECRET
|
|
61
|
-
npx wrangler secret put WEATHER_API_KEY # optional
|
|
62
|
-
|
|
63
|
-
# Activate the fully configured Worker
|
|
64
|
-
npm run deploy
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
Configuration lives in `wrangler.toml`: partner names under `[vars]`, the D1 binding, OAuth KV binding, R2 bucket binding and optional `WEATHER_LAT` / `WEATHER_LON`. Keep the `global_fetch_strictly_public` compatibility flag: it lets the OAuth provider resolve Claude's Client ID Metadata Document with Cloudflare's SSRF protections. OpenWeatherMap is required only for weather and pressure features.
|
|
68
|
-
|
|
69
|
-
## Connect Claude
|
|
70
|
-
|
|
71
|
-
After deployment, run `npx hearth-dash mcp`. The connector URL is:
|
|
72
|
-
|
|
73
|
-
```text
|
|
74
|
-
https://your-worker.example/mcp
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
There is no secret in that URL.
|
|
78
|
-
|
|
79
|
-
### Claude.ai, Claude Desktop and Claude mobile
|
|
80
|
-
|
|
81
|
-
1. Open **Customize → Connectors → Add custom connector**.
|
|
82
|
-
2. Enter the printed `/mcp` URL.
|
|
83
|
-
3. Choose **Sign in now** if Claude asks for an authentication mode.
|
|
84
|
-
4. Choose **Use Claude's published identity** (recommended) or **Register automatically**. Hearth supports both CIMD and DCR. Do not enter a client secret.
|
|
85
|
-
5. Claude opens Hearth's consent page. Enter the dashboard password, review the read/write permissions and approve.
|
|
86
|
-
6. Claude returns through `https://claude.ai/api/mcp/auth_callback` and stores revocable OAuth tokens. The dashboard password is never sent as an MCP credential.
|
|
87
|
-
|
|
88
|
-
Claude connects from Anthropic's cloud, so the Worker must be publicly reachable.
|
|
89
|
-
|
|
90
|
-
### Claude Code
|
|
91
|
-
|
|
92
|
-
```bash
|
|
93
|
-
claude mcp add --transport http hearth-dash https://your-worker.example/mcp
|
|
94
|
-
```
|
|
95
|
-
|
|
96
|
-
Then open `/mcp` inside Claude Code and complete authentication. Claude Code uses a loopback callback rather than Claude.ai's hosted callback; DCR handles its varying local port.
|
|
97
|
-
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# Hearth
|
|
4
|
+
|
|
5
|
+
**A cozy personal dashboard on Cloudflare Workers**
|
|
6
|
+
|
|
7
|
+
[](#)
|
|
8
|
+
[](#)
|
|
9
|
+
[](LICENSE)
|
|
10
|
+
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
Hearth is a small personal dashboard for two people. It runs as a Cloudflare Worker with D1 storage and an optional R2 photo bucket. The same private data is available through a standards-compliant, OAuth-protected MCP Streamable HTTP endpoint so Claude and other compatible clients can discover and use Hearth's tools.
|
|
14
|
+
|
|
15
|
+
## Features
|
|
16
|
+
|
|
17
|
+
- Dashboard overview, moods, shared notes, moments, important dates and shopping list
|
|
18
|
+
- Food and water diary with private R2 photos and daily reviews
|
|
19
|
+
- Weather, barometric pressure history and pressure-shift alerts
|
|
20
|
+
- Configurable partner names
|
|
21
|
+
- Password-protected web dashboard with signed, expiring sessions
|
|
22
|
+
- Streamable HTTP MCP with JSON-RPC `initialize`, `ping`, `tools/list` and `tools/call`
|
|
23
|
+
- OAuth 2.1 authorization with PKCE, protected-resource discovery, CIMD and Dynamic Client Registration
|
|
24
|
+
- Separate `hearth:read` and `hearth:write` permissions
|
|
25
|
+
- Deployment and connector-configuration CLI
|
|
26
|
+
|
|
27
|
+
The MCP server exposes `hearth_status`, `hearth_mood`, `hearth_note`, `hearth_moment`, `hearth_date`, `hearth_shopping_list`, `hearth_shopping_add`, `hearth_pressure`, `hearth_food_diary_today`, `hearth_food_diary_history`, `hearth_food_review`, and `hearth_water_status`.
|
|
28
|
+
|
|
29
|
+
## Deploy
|
|
30
|
+
|
|
31
|
+
### Bundled CLI
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npx hearth-dash deploy
|
|
35
|
+
npx hearth-dash mcp
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The deploy command creates the D1 database, R2 bucket and OAuth KV namespace; installs the pinned runtime dependency; prompts for configuration; generates a session-signing secret; applies the schema; and deploys the Worker. It does not save the dashboard password locally.
|
|
39
|
+
|
|
40
|
+
### Manual deployment
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npm install
|
|
44
|
+
|
|
45
|
+
# Create storage and put the returned IDs in wrangler.toml
|
|
46
|
+
npx wrangler d1 create hearth-dash-db
|
|
47
|
+
npx wrangler r2 bucket create hearth-dash-photos
|
|
48
|
+
npx wrangler kv namespace create hearth-dash-oauth
|
|
49
|
+
|
|
50
|
+
# Generate a SESSION_SECRET
|
|
51
|
+
node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))"
|
|
52
|
+
|
|
53
|
+
# Optional weather integration
|
|
54
|
+
# (set WEATHER_API_KEY after provisioning if wanted)
|
|
55
|
+
|
|
56
|
+
# Provision the Worker first, then initialize storage and set secrets
|
|
57
|
+
npm run deploy
|
|
58
|
+
npm run db:init:remote
|
|
59
|
+
npx wrangler secret put DASHBOARD_PASSWORD
|
|
60
|
+
npx wrangler secret put SESSION_SECRET
|
|
61
|
+
npx wrangler secret put WEATHER_API_KEY # optional
|
|
62
|
+
|
|
63
|
+
# Activate the fully configured Worker
|
|
64
|
+
npm run deploy
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Configuration lives in `wrangler.toml`: partner names under `[vars]`, the D1 binding, OAuth KV binding, R2 bucket binding and optional `WEATHER_LAT` / `WEATHER_LON`. Keep the `global_fetch_strictly_public` compatibility flag: it lets the OAuth provider resolve Claude's Client ID Metadata Document with Cloudflare's SSRF protections. OpenWeatherMap is required only for weather and pressure features.
|
|
68
|
+
|
|
69
|
+
## Connect Claude
|
|
70
|
+
|
|
71
|
+
After deployment, run `npx hearth-dash mcp`. The connector URL is:
|
|
72
|
+
|
|
73
|
+
```text
|
|
74
|
+
https://your-worker.example/mcp
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
There is no secret in that URL.
|
|
78
|
+
|
|
79
|
+
### Claude.ai, Claude Desktop and Claude mobile
|
|
80
|
+
|
|
81
|
+
1. Open **Customize → Connectors → Add custom connector**.
|
|
82
|
+
2. Enter the printed `/mcp` URL.
|
|
83
|
+
3. Choose **Sign in now** if Claude asks for an authentication mode.
|
|
84
|
+
4. Choose **Use Claude's published identity** (recommended) or **Register automatically**. Hearth supports both CIMD and DCR. Do not enter a client secret.
|
|
85
|
+
5. Claude opens Hearth's consent page. Enter the dashboard password, review the read/write permissions and approve.
|
|
86
|
+
6. Claude returns through `https://claude.ai/api/mcp/auth_callback` and stores revocable OAuth tokens. The dashboard password is never sent as an MCP credential.
|
|
87
|
+
|
|
88
|
+
Claude connects from Anthropic's cloud, so the Worker must be publicly reachable.
|
|
89
|
+
|
|
90
|
+
### Claude Code
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
claude mcp add --transport http hearth-dash https://your-worker.example/mcp
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Then open `/mcp` inside Claude Code and complete authentication. Claude Code uses a loopback callback rather than Claude.ai's hosted callback; DCR handles its varying local port.
|
|
97
|
+
|
|
98
98
|
## Upgrading from 1.0.1
|
|
99
|
-
|
|
100
|
-
Version 1.0.1 labelled a custom `{ "tool": ..., "params": ... }` HTTP handler as MCP. It did not implement MCP JSON-RPC or tool discovery and could not work as a Claude.ai custom connector. Version 1.1.0 replaces it with Streamable HTTP MCP and OAuth 2.1. The old payload and secret-bearing URL formats are intentionally rejected.
|
|
101
|
-
|
|
102
|
-
Existing deployments must:
|
|
103
|
-
|
|
104
|
-
1. Install dependencies with `npm install`.
|
|
105
|
-
2. Create an OAuth KV namespace and add its ID as the `OAUTH_KV` binding in `wrangler.toml`.
|
|
106
|
-
3. Keep `compatibility_flags = ["global_fetch_strictly_public"]`.
|
|
107
|
-
4. Set a new `SESSION_SECRET` Worker secret.
|
|
108
|
-
5. Re-run `schema.sql` remotely to add the rate-limit table.
|
|
109
|
-
6. Deploy `oauth-entry.js` as the Worker entrypoint.
|
|
110
|
-
7. Remove and re-add the custom connector using `https://your-worker.example/mcp`.
|
|
111
|
-
8. Delete the obsolete secret with `npx wrangler secret delete MCP_SECRET` after the new deployment works.
|
|
112
|
-
|
|
99
|
+
|
|
100
|
+
Version 1.0.1 labelled a custom `{ "tool": ..., "params": ... }` HTTP handler as MCP. It did not implement MCP JSON-RPC or tool discovery and could not work as a Claude.ai custom connector. Version 1.1.0 replaces it with Streamable HTTP MCP and OAuth 2.1. The old payload and secret-bearing URL formats are intentionally rejected.
|
|
101
|
+
|
|
102
|
+
Existing deployments must:
|
|
103
|
+
|
|
104
|
+
1. Install dependencies with `npm install`.
|
|
105
|
+
2. Create an OAuth KV namespace and add its ID as the `OAUTH_KV` binding in `wrangler.toml`.
|
|
106
|
+
3. Keep `compatibility_flags = ["global_fetch_strictly_public"]`.
|
|
107
|
+
4. Set a new `SESSION_SECRET` Worker secret.
|
|
108
|
+
5. Re-run `schema.sql` remotely to add the rate-limit table.
|
|
109
|
+
6. Deploy `oauth-entry.js` as the Worker entrypoint.
|
|
110
|
+
7. Remove and re-add the custom connector using `https://your-worker.example/mcp`.
|
|
111
|
+
8. Delete the obsolete secret with `npx wrangler secret delete MCP_SECRET` after the new deployment works.
|
|
112
|
+
|
|
113
113
|
The first-visit password setup page has also been removed. A public, unclaimed setup page allowed the first visitor—not necessarily the owner—to take control of a new deployment. Configure `DASHBOARD_PASSWORD` as a Worker secret instead.
|
|
114
114
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
-
|
|
122
|
-
-
|
|
123
|
-
-
|
|
124
|
-
-
|
|
125
|
-
-
|
|
126
|
-
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
115
|
+
### 1.1.1 dashboard-login fix
|
|
116
|
+
|
|
117
|
+
Version 1.1.1 keeps ordinary dashboard, login and API requests outside the OAuth provider and makes same-origin form validation resilient when a trusted Cloudflare wrapper reconstructs the internal request URL. Cross-site browser submissions remain rejected. Upgrade with `npx hearth-dash@latest deploy` if a 1.1.0 deployment returns plain `Forbidden` after submitting `/login`.
|
|
118
|
+
|
|
119
|
+
## Security notes
|
|
120
|
+
|
|
121
|
+
- There are no functional default credentials, bearer tokens or secret-bearing connector URLs.
|
|
122
|
+
- OAuth uses authorization-code flow, S256 PKCE, RFC 9728 protected-resource metadata, RFC 8414 authorization-server metadata, resource-bound access tokens and refresh-token rotation from Cloudflare's maintained `workers-oauth-provider` library.
|
|
123
|
+
- Access tokens expire after one hour. Rotating refresh tokens have a 30-day TTL. Dynamically registered clients expire after 90 days.
|
|
124
|
+
- Consent requires either a valid signed dashboard session or the dashboard password. Consent POSTs use a short-lived CSRF cookie, same-origin checks and rate limiting.
|
|
125
|
+
- `hearth:read` and `hearth:write` are enforced at tool-call time. Read-only tokens cannot invoke write actions hidden inside mixed read/write tools.
|
|
126
|
+
- Session cookies are signed, expire after seven days and use the `__Host-` prefix plus `Secure`, `HttpOnly` and `SameSite=Strict`.
|
|
127
|
+
- Browser origins, JSON body size and tool arguments are validated. MCP, login, consent and dynamic-registration paths are rate-limited.
|
|
128
|
+
- Private R2 photos are served only through authenticated dashboard routes and use `private, no-store`. MCP results do not expose photo URLs.
|
|
129
|
+
- Compatible clients can revoke their grant through Hearth's OAuth revocation endpoint. Removing a connector always removes its locally stored token, but not every client promises server-side revocation. For emergency revocation of every OAuth grant, replace the `OAUTH_KV` binding with a fresh namespace (or delete all keys in the existing namespace) and redeploy.
|
|
130
|
+
- Hearth's bundled authorization screen represents one dashboard owner, not a multi-tenant identity system. Separate households should use separate deployments.
|
|
131
|
+
|
|
132
|
+
## Development
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
npm test
|
|
136
|
+
npx wrangler deploy --dry-run
|
|
137
|
+
npm run dev
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The unit suite tests MCP protocol behavior, tool validation, scope enforcement and dashboard sessions. OAuth discovery and the full PKCE flow should also be exercised through a local HTTPS Wrangler server or a disposable deployment before release.
|
|
141
|
+
|
|
142
|
+
Requires Node.js 22 or newer. Hearth pins the tested Wrangler release used by its deployment CLI.
|
|
143
|
+
|
|
144
|
+
## License
|
|
145
|
+
|
|
142
146
|
Non-Commercial. Free for personal, educational and non-commercial use. See [LICENSE](LICENSE).
|
package/cli/commands/config.js
CHANGED
|
@@ -20,7 +20,7 @@ export default async function configCommand(args) {
|
|
|
20
20
|
console.log(` ${bold("Partner 1:")} ${config.partner1 || "not set"}`);
|
|
21
21
|
console.log(` ${bold("Partner 2:")} ${config.partner2 || "not set"}`);
|
|
22
22
|
console.log(` ${bold("Database ID:")} ${config.dbId ? config.dbId.substring(0, 8) + "..." : "not set"}`);
|
|
23
|
-
console.log(` ${bold("MCP Auth:")} OAuth 2.1`);
|
|
23
|
+
console.log(` ${bold("MCP Auth:")} OAuth 2.1`);
|
|
24
24
|
console.log(` ${bold("Deployed:")} ${config.deployedAt || "never"}`);
|
|
25
25
|
console.log(`\n ${dim("Config file: " + CONFIG_PATH)}\n`);
|
|
26
26
|
}
|
package/cli/commands/deploy.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from "node:fs";
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { getPackageRoot, getNodeMajor } from "../lib/platform.js";
|
|
5
5
|
import { ask, confirm, password } from "../lib/prompts.js";
|
|
6
6
|
import {
|
|
7
|
-
execWrangler, execCommand, parseD1CreateOutput, parseDeployOutput, parseKvCreateOutput,
|
|
8
|
-
checkWranglerAuth, wranglerLogin, setSecret, executeSchema, listD1Databases, listKvNamespaces,
|
|
7
|
+
execWrangler, execCommand, parseD1CreateOutput, parseDeployOutput, parseKvCreateOutput,
|
|
8
|
+
checkWranglerAuth, wranglerLogin, setSecret, executeSchema, listD1Databases, listKvNamespaces,
|
|
9
9
|
} from "../lib/wrangler.js";
|
|
10
10
|
import { banner, step, bold, dim, cyan, green, yellow, red, success, fail, warn, info, spinner } from "../lib/ui.js";
|
|
11
11
|
|
|
@@ -13,20 +13,20 @@ const TOTAL_STEPS = 6;
|
|
|
13
13
|
const CONFIG_DIR = join(homedir(), ".hearth-dash");
|
|
14
14
|
const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
15
15
|
|
|
16
|
-
function saveConfig(data) {
|
|
17
|
-
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
16
|
+
function saveConfig(data) {
|
|
17
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
18
18
|
let existing = {};
|
|
19
19
|
if (existsSync(CONFIG_PATH)) {
|
|
20
20
|
try { existing = JSON.parse(readFileSync(CONFIG_PATH, "utf-8")); } catch {}
|
|
21
|
-
}
|
|
22
|
-
const merged = { ...existing, ...data };
|
|
23
|
-
delete merged.mcpSecret;
|
|
24
|
-
writeFileSync(CONFIG_PATH, JSON.stringify(merged, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
|
|
25
|
-
try { chmodSync(CONFIG_PATH, 0o600); } catch {}
|
|
21
|
+
}
|
|
22
|
+
const merged = { ...existing, ...data };
|
|
23
|
+
delete merged.mcpSecret;
|
|
24
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(merged, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
|
|
25
|
+
try { chmodSync(CONFIG_PATH, 0o600); } catch {}
|
|
26
26
|
return merged;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
async function createD1OrReuse(dbName, cwd) {
|
|
29
|
+
async function createD1OrReuse(dbName, cwd) {
|
|
30
30
|
const result = await execWrangler(["d1", "create", dbName], cwd);
|
|
31
31
|
if (result.code === 0) return parseD1CreateOutput(result);
|
|
32
32
|
const combined = result.stdout + result.stderr;
|
|
@@ -40,17 +40,17 @@ async function createD1OrReuse(dbName, cwd) {
|
|
|
40
40
|
}
|
|
41
41
|
fail(`Failed to create database: ${combined}`);
|
|
42
42
|
return null;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
async function createKvOrReuse(title, cwd) {
|
|
46
|
-
const existing = await listKvNamespaces(cwd);
|
|
47
|
-
const match = existing.find(namespace => namespace.title === title);
|
|
48
|
-
if (match) { info(`Found OAuth namespace: ${match.id}`); return match.id; }
|
|
49
|
-
const result = await execWrangler(["kv", "namespace", "create", title], cwd);
|
|
50
|
-
if (result.code === 0) return parseKvCreateOutput(result);
|
|
51
|
-
fail(`Failed to create OAuth KV namespace: ${result.stderr || result.stdout}`);
|
|
52
|
-
return null;
|
|
53
|
-
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function createKvOrReuse(title, cwd) {
|
|
46
|
+
const existing = await listKvNamespaces(cwd);
|
|
47
|
+
const match = existing.find(namespace => namespace.title === title);
|
|
48
|
+
if (match) { info(`Found OAuth namespace: ${match.id}`); return match.id; }
|
|
49
|
+
const result = await execWrangler(["kv", "namespace", "create", title], cwd);
|
|
50
|
+
if (result.code === 0) return parseKvCreateOutput(result);
|
|
51
|
+
fail(`Failed to create OAuth KV namespace: ${result.stderr || result.stdout}`);
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
54
|
|
|
55
55
|
export default async function deployCommand(args) {
|
|
56
56
|
banner();
|
|
@@ -61,7 +61,7 @@ export default async function deployCommand(args) {
|
|
|
61
61
|
step(1, TOTAL_STEPS, "Preflight checks");
|
|
62
62
|
|
|
63
63
|
const nodeMajor = getNodeMajor();
|
|
64
|
-
if (nodeMajor < 22) { fail(`Node.js ${nodeMajor} detected. Need >= 22.`); process.exit(1); }
|
|
64
|
+
if (nodeMajor < 22) { fail(`Node.js ${nodeMajor} detected. Need >= 22.`); process.exit(1); }
|
|
65
65
|
success(`Node.js ${process.version}`);
|
|
66
66
|
|
|
67
67
|
const wranglerCheck = await execWrangler(["--version"], ".");
|
|
@@ -83,14 +83,14 @@ export default async function deployCommand(args) {
|
|
|
83
83
|
|
|
84
84
|
step(2, TOTAL_STEPS, "Configuration");
|
|
85
85
|
|
|
86
|
-
const partner1 = await ask(" Partner 1 name", "Partner 1");
|
|
87
|
-
const partner2 = await ask(" Partner 2 name (or AI name)", "AI");
|
|
88
|
-
if (partner1.length > 80 || partner2.length > 80) { fail("Partner names must be 80 characters or fewer."); process.exit(1); }
|
|
89
|
-
const dashPassword = await password(" Dashboard password");
|
|
90
|
-
if (!dashPassword || dashPassword.length < 12) { fail("Password must be at least 12 characters."); process.exit(1); }
|
|
91
|
-
const { randomBytes } = await import('node:crypto');
|
|
92
|
-
const sessionSecret = randomBytes(32).toString('base64url');
|
|
93
|
-
success("Secure session key generated");
|
|
86
|
+
const partner1 = await ask(" Partner 1 name", "Partner 1");
|
|
87
|
+
const partner2 = await ask(" Partner 2 name (or AI name)", "AI");
|
|
88
|
+
if (partner1.length > 80 || partner2.length > 80) { fail("Partner names must be 80 characters or fewer."); process.exit(1); }
|
|
89
|
+
const dashPassword = await password(" Dashboard password");
|
|
90
|
+
if (!dashPassword || dashPassword.length < 12) { fail("Password must be at least 12 characters."); process.exit(1); }
|
|
91
|
+
const { randomBytes } = await import('node:crypto');
|
|
92
|
+
const sessionSecret = randomBytes(32).toString('base64url');
|
|
93
|
+
success("Secure session key generated");
|
|
94
94
|
|
|
95
95
|
console.log();
|
|
96
96
|
info("Weather requires a free OpenWeatherMap API key.");
|
|
@@ -99,13 +99,13 @@ export default async function deployCommand(args) {
|
|
|
99
99
|
let weatherLat = "", weatherLon = "";
|
|
100
100
|
if (weatherKey) {
|
|
101
101
|
info("Find your coordinates at: https://www.latlong.net");
|
|
102
|
-
weatherLat = await ask(" Latitude (e.g. 51.5074)", "51.5074");
|
|
103
|
-
weatherLon = await ask(" Longitude (e.g. -0.1278)", "-0.1278");
|
|
104
|
-
const lat = Number(weatherLat), lon = Number(weatherLon);
|
|
105
|
-
if (!Number.isFinite(lat) || lat < -90 || lat > 90 || !Number.isFinite(lon) || lon < -180 || lon > 180) {
|
|
106
|
-
fail("Latitude must be -90 to 90 and longitude must be -180 to 180.");
|
|
107
|
-
process.exit(1);
|
|
108
|
-
}
|
|
102
|
+
weatherLat = await ask(" Latitude (e.g. 51.5074)", "51.5074");
|
|
103
|
+
weatherLon = await ask(" Longitude (e.g. -0.1278)", "-0.1278");
|
|
104
|
+
const lat = Number(weatherLat), lon = Number(weatherLon);
|
|
105
|
+
if (!Number.isFinite(lat) || lat < -90 || lat > 90 || !Number.isFinite(lon) || lon < -180 || lon > 180) {
|
|
106
|
+
fail("Latitude must be -90 to 90 and longitude must be -180 to 180.");
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
// ── Step 3: Create D1 database ───────────────────────────
|
|
@@ -118,23 +118,23 @@ export default async function deployCommand(args) {
|
|
|
118
118
|
|
|
119
119
|
// Copy worker files to deploy dir
|
|
120
120
|
const s1 = spinner("Copying source files");
|
|
121
|
-
for (const file of ["worker.js", "oauth-entry.js", "schema.sql", "wrangler.toml", "package.json"]) {
|
|
121
|
+
for (const file of ["worker.js", "oauth-entry.js", "schema.sql", "wrangler.toml", "package.json"]) {
|
|
122
122
|
const src = join(pkgRoot, file);
|
|
123
123
|
if (!existsSync(src)) { s1.fail(`Missing: ${file}`); process.exit(1); }
|
|
124
124
|
writeFileSync(join(deployDir, file), readFileSync(src, "utf-8"), "utf-8");
|
|
125
125
|
}
|
|
126
|
-
s1.stop("Source files copied");
|
|
127
|
-
|
|
128
|
-
const install = await execCommand('npm', ['install', '--omit=dev', '--ignore-scripts'], deployDir);
|
|
129
|
-
if (install.code !== 0) { fail(`Dependency install failed: ${install.stderr || install.stdout}`); process.exit(1); }
|
|
126
|
+
s1.stop("Source files copied");
|
|
127
|
+
|
|
128
|
+
const install = await execCommand('npm', ['install', '--omit=dev', '--ignore-scripts'], deployDir);
|
|
129
|
+
if (install.code !== 0) { fail(`Dependency install failed: ${install.stderr || install.stdout}`); process.exit(1); }
|
|
130
130
|
|
|
131
131
|
const s2 = spinner("Creating D1 database");
|
|
132
|
-
const dbId = await createD1OrReuse("hearth-dash-db", deployDir);
|
|
132
|
+
const dbId = await createD1OrReuse("hearth-dash-db", deployDir);
|
|
133
133
|
if (!dbId) { s2.fail("D1 creation failed"); process.exit(1); }
|
|
134
|
-
s2.stop(`D1 database ready: ${dbId.substring(0, 8)}...`);
|
|
135
|
-
|
|
136
|
-
const oauthKvId = await createKvOrReuse('hearth-dash-oauth', deployDir);
|
|
137
|
-
if (!oauthKvId) { fail('OAuth storage creation failed'); process.exit(1); }
|
|
134
|
+
s2.stop(`D1 database ready: ${dbId.substring(0, 8)}...`);
|
|
135
|
+
|
|
136
|
+
const oauthKvId = await createKvOrReuse('hearth-dash-oauth', deployDir);
|
|
137
|
+
if (!oauthKvId) { fail('OAuth storage creation failed'); process.exit(1); }
|
|
138
138
|
|
|
139
139
|
// ── Step 4: Create R2 bucket ─────────────────────────────
|
|
140
140
|
|
|
@@ -156,10 +156,10 @@ export default async function deployCommand(args) {
|
|
|
156
156
|
|
|
157
157
|
// Patch wrangler.toml with real values
|
|
158
158
|
let toml = readFileSync(join(deployDir, "wrangler.toml"), "utf-8");
|
|
159
|
-
toml = toml.replace("YOUR_D1_DATABASE_ID", dbId);
|
|
160
|
-
toml = toml.replace("YOUR_OAUTH_KV_ID", oauthKvId);
|
|
161
|
-
toml = toml.replace('PARTNER_1 = "Partner 1"', `PARTNER_1 = ${JSON.stringify(partner1)}`);
|
|
162
|
-
toml = toml.replace('PARTNER_2 = "Partner 2"', `PARTNER_2 = ${JSON.stringify(partner2)}`);
|
|
159
|
+
toml = toml.replace("YOUR_D1_DATABASE_ID", dbId);
|
|
160
|
+
toml = toml.replace("YOUR_OAUTH_KV_ID", oauthKvId);
|
|
161
|
+
toml = toml.replace('PARTNER_1 = "Partner 1"', `PARTNER_1 = ${JSON.stringify(partner1)}`);
|
|
162
|
+
toml = toml.replace('PARTNER_2 = "Partner 2"', `PARTNER_2 = ${JSON.stringify(partner2)}`);
|
|
163
163
|
if (weatherLat) {
|
|
164
164
|
toml = toml.replace('# WEATHER_LAT = "52.5726"', `WEATHER_LAT = "${weatherLat}"`);
|
|
165
165
|
toml = toml.replace('# WEATHER_LON = "-0.2405"', `WEATHER_LON = "${weatherLon}"`);
|
|
@@ -168,73 +168,73 @@ export default async function deployCommand(args) {
|
|
|
168
168
|
|
|
169
169
|
// ── Step 5: Deploy worker + set secrets ──────────────────
|
|
170
170
|
|
|
171
|
-
step(5, TOTAL_STEPS, "Deploying worker");
|
|
172
|
-
|
|
173
|
-
// A Worker must exist before Wrangler can set secrets non-interactively.
|
|
174
|
-
// This first deployment is inert: application and consent routes return 503
|
|
175
|
-
// until the required secrets are present.
|
|
176
|
-
const provision = spinner("Provisioning Cloudflare Worker");
|
|
177
|
-
const provisionResult = await execWrangler(["deploy"], deployDir);
|
|
178
|
-
if (provisionResult.code !== 0) {
|
|
179
|
-
provision.fail("Worker provisioning failed");
|
|
180
|
-
console.error(provisionResult.stderr || provisionResult.stdout);
|
|
181
|
-
process.exit(1);
|
|
182
|
-
}
|
|
183
|
-
const workerUrl = parseDeployOutput(provisionResult);
|
|
184
|
-
if (!workerUrl) {
|
|
185
|
-
provision.fail("Provisioned Worker, but could not determine its workers.dev URL.");
|
|
186
|
-
console.error(provisionResult.stdout || provisionResult.stderr);
|
|
187
|
-
process.exit(1);
|
|
188
|
-
}
|
|
189
|
-
provision.stop(`Worker provisioned: ${workerUrl}`);
|
|
190
|
-
|
|
191
|
-
const s6 = spinner("Initializing database schema");
|
|
192
|
-
const schemaResult = await executeSchema("hearth-dash-db", join(deployDir, "schema.sql"), deployDir);
|
|
193
|
-
if (!schemaResult.ok) {
|
|
194
|
-
s6.fail("Schema init failed: " + (schemaResult.error || "unknown error"));
|
|
195
|
-
console.error("Deployment is incomplete; no success configuration was saved.");
|
|
196
|
-
process.exit(1);
|
|
197
|
-
}
|
|
198
|
-
s6.stop("Database schema initialized");
|
|
199
|
-
|
|
200
|
-
const s4 = spinner("Setting secrets");
|
|
201
|
-
const secrets = [
|
|
202
|
-
["DASHBOARD_PASSWORD", dashPassword],
|
|
203
|
-
["SESSION_SECRET", sessionSecret],
|
|
204
|
-
];
|
|
205
|
-
if (weatherKey) secrets.push(["WEATHER_API_KEY", weatherKey]);
|
|
206
|
-
for (const [name, value] of secrets) {
|
|
207
|
-
const result = await setSecret(name, value, deployDir);
|
|
208
|
-
if (result.code !== 0) {
|
|
209
|
-
s4.fail(`Could not set ${name}`);
|
|
210
|
-
console.error(result.stderr || result.stdout);
|
|
211
|
-
process.exit(1);
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
s4.stop("Secrets configured");
|
|
215
|
-
|
|
216
|
-
const s5 = spinner("Activating configured Worker");
|
|
217
|
-
const deployResult = await execWrangler(["deploy"], deployDir);
|
|
171
|
+
step(5, TOTAL_STEPS, "Deploying worker");
|
|
172
|
+
|
|
173
|
+
// A Worker must exist before Wrangler can set secrets non-interactively.
|
|
174
|
+
// This first deployment is inert: application and consent routes return 503
|
|
175
|
+
// until the required secrets are present.
|
|
176
|
+
const provision = spinner("Provisioning Cloudflare Worker");
|
|
177
|
+
const provisionResult = await execWrangler(["deploy"], deployDir);
|
|
178
|
+
if (provisionResult.code !== 0) {
|
|
179
|
+
provision.fail("Worker provisioning failed");
|
|
180
|
+
console.error(provisionResult.stderr || provisionResult.stdout);
|
|
181
|
+
process.exit(1);
|
|
182
|
+
}
|
|
183
|
+
const workerUrl = parseDeployOutput(provisionResult);
|
|
184
|
+
if (!workerUrl) {
|
|
185
|
+
provision.fail("Provisioned Worker, but could not determine its workers.dev URL.");
|
|
186
|
+
console.error(provisionResult.stdout || provisionResult.stderr);
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
189
|
+
provision.stop(`Worker provisioned: ${workerUrl}`);
|
|
190
|
+
|
|
191
|
+
const s6 = spinner("Initializing database schema");
|
|
192
|
+
const schemaResult = await executeSchema("hearth-dash-db", join(deployDir, "schema.sql"), deployDir);
|
|
193
|
+
if (!schemaResult.ok) {
|
|
194
|
+
s6.fail("Schema init failed: " + (schemaResult.error || "unknown error"));
|
|
195
|
+
console.error("Deployment is incomplete; no success configuration was saved.");
|
|
196
|
+
process.exit(1);
|
|
197
|
+
}
|
|
198
|
+
s6.stop("Database schema initialized");
|
|
199
|
+
|
|
200
|
+
const s4 = spinner("Setting secrets");
|
|
201
|
+
const secrets = [
|
|
202
|
+
["DASHBOARD_PASSWORD", dashPassword],
|
|
203
|
+
["SESSION_SECRET", sessionSecret],
|
|
204
|
+
];
|
|
205
|
+
if (weatherKey) secrets.push(["WEATHER_API_KEY", weatherKey]);
|
|
206
|
+
for (const [name, value] of secrets) {
|
|
207
|
+
const result = await setSecret(name, value, deployDir);
|
|
208
|
+
if (result.code !== 0) {
|
|
209
|
+
s4.fail(`Could not set ${name}`);
|
|
210
|
+
console.error(result.stderr || result.stdout);
|
|
211
|
+
process.exit(1);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
s4.stop("Secrets configured");
|
|
215
|
+
|
|
216
|
+
const s5 = spinner("Activating configured Worker");
|
|
217
|
+
const deployResult = await execWrangler(["deploy"], deployDir);
|
|
218
218
|
if (deployResult.code !== 0) {
|
|
219
219
|
s5.fail("Deploy failed");
|
|
220
220
|
console.error(deployResult.stderr || deployResult.stdout);
|
|
221
221
|
process.exit(1);
|
|
222
222
|
}
|
|
223
|
-
s5.stop(`Deployed: ${workerUrl}`);
|
|
223
|
+
s5.stop(`Deployed: ${workerUrl}`);
|
|
224
224
|
|
|
225
225
|
// ── Step 6: Save config + print results ──────────────────
|
|
226
226
|
|
|
227
227
|
step(6, TOTAL_STEPS, "Finishing up");
|
|
228
228
|
|
|
229
|
-
const config = saveConfig({
|
|
230
|
-
workerUrl,
|
|
231
|
-
partner1,
|
|
229
|
+
const config = saveConfig({
|
|
230
|
+
workerUrl,
|
|
231
|
+
partner1,
|
|
232
232
|
partner2,
|
|
233
233
|
dbId,
|
|
234
234
|
deployedAt: new Date().toISOString(),
|
|
235
235
|
});
|
|
236
236
|
|
|
237
|
-
const mcpUrl = workerUrl + "/mcp";
|
|
237
|
+
const mcpUrl = workerUrl + "/mcp";
|
|
238
238
|
|
|
239
239
|
console.log(`
|
|
240
240
|
${green(bold(" Done!"))} Your dashboard is live.
|
|
@@ -242,8 +242,8 @@ ${green(bold(" Done!"))} Your dashboard is live.
|
|
|
242
242
|
${bold("Dashboard:")} ${cyan(workerUrl)}
|
|
243
243
|
${bold("Password:")} ${dim("(the one you just set)")}
|
|
244
244
|
|
|
245
|
-
${bold("OAuth MCP Endpoint:")}
|
|
246
|
-
${dim(mcpUrl)}
|
|
245
|
+
${bold("OAuth MCP Endpoint:")}
|
|
246
|
+
${dim(mcpUrl)}
|
|
247
247
|
|
|
248
248
|
${bold("MCP Config")} (add to Claude Code ${dim("~/.claude.json")} or Claude Desktop):
|
|
249
249
|
${dim(" {")}
|
|
@@ -254,7 +254,7 @@ ${dim(" }")}
|
|
|
254
254
|
${dim(" }")}
|
|
255
255
|
${dim(" }")}
|
|
256
256
|
|
|
257
|
-
${bold("Connector URL")} (Claude will open a dashboard-password consent screen):
|
|
257
|
+
${bold("Connector URL")} (Claude will open a dashboard-password consent screen):
|
|
258
258
|
${cyan(mcpUrl)}
|
|
259
259
|
|
|
260
260
|
${dim("Config saved to: " + CONFIG_PATH)}
|