hearth-dash 1.0.0 → 1.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.
@@ -1,216 +1,262 @@
1
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { homedir } from "node:os";
4
- import { randomBytes } from "node:crypto";
5
- import { getPackageRoot, getNodeMajor } from "../lib/platform.js";
6
- import { ask, confirm, password } from "../lib/prompts.js";
7
- import {
8
- execWrangler, parseD1CreateOutput, parseDeployOutput,
9
- checkWranglerAuth, wranglerLogin, setSecret, executeSchema, listD1Databases,
10
- } from "../lib/wrangler.js";
11
- import { banner, step, bold, dim, cyan, green, yellow, red, success, fail, warn, info, spinner } from "../lib/ui.js";
12
-
13
- const TOTAL_STEPS = 6;
14
- const CONFIG_DIR = join(homedir(), ".hearth-dash");
15
- const CONFIG_PATH = join(CONFIG_DIR, "config.json");
16
-
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { getPackageRoot, getNodeMajor } from "../lib/platform.js";
5
+ import { ask, confirm, password } from "../lib/prompts.js";
6
+ import {
7
+ execWrangler, execCommand, parseD1CreateOutput, parseDeployOutput, parseKvCreateOutput,
8
+ checkWranglerAuth, wranglerLogin, setSecret, executeSchema, listD1Databases, listKvNamespaces,
9
+ } from "../lib/wrangler.js";
10
+ import { banner, step, bold, dim, cyan, green, yellow, red, success, fail, warn, info, spinner } from "../lib/ui.js";
11
+
12
+ const TOTAL_STEPS = 6;
13
+ const CONFIG_DIR = join(homedir(), ".hearth-dash");
14
+ const CONFIG_PATH = join(CONFIG_DIR, "config.json");
15
+
17
16
  function saveConfig(data) {
18
- mkdirSync(CONFIG_DIR, { recursive: true });
19
- let existing = {};
20
- if (existsSync(CONFIG_PATH)) {
21
- try { existing = JSON.parse(readFileSync(CONFIG_PATH, "utf-8")); } catch {}
17
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
18
+ let existing = {};
19
+ if (existsSync(CONFIG_PATH)) {
20
+ try { existing = JSON.parse(readFileSync(CONFIG_PATH, "utf-8")); } catch {}
22
21
  }
23
22
  const merged = { ...existing, ...data };
24
- writeFileSync(CONFIG_PATH, JSON.stringify(merged, null, 2) + "\n", "utf-8");
25
- return merged;
26
- }
27
-
28
- function genSecret() {
29
- return randomBytes(16).toString("base64url");
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
+ return merged;
27
+ }
28
+
29
+ async function createD1OrReuse(dbName, cwd) {
30
+ const result = await execWrangler(["d1", "create", dbName], cwd);
31
+ if (result.code === 0) return parseD1CreateOutput(result);
32
+ const combined = result.stdout + result.stderr;
33
+ if (combined.includes("already exists") || combined.includes("already a database")) {
34
+ warn(`Database "${dbName}" already exists. Looking up ID...`);
35
+ const databases = await listD1Databases(cwd);
36
+ const db = databases.find((d) => d.name === dbName);
37
+ if (db) { info(`Found: ${db.uuid}`); return db.uuid; }
38
+ fail("Could not find existing database ID.");
39
+ return null;
40
+ }
41
+ fail(`Failed to create database: ${combined}`);
42
+ return null;
30
43
  }
31
44
 
32
- async function createD1OrReuse(dbName, cwd) {
33
- const result = await execWrangler(["d1", "create", dbName], cwd);
34
- if (result.code === 0) return parseD1CreateOutput(result);
35
- const combined = result.stdout + result.stderr;
36
- if (combined.includes("already exists") || combined.includes("already a database")) {
37
- warn(`Database "${dbName}" already exists. Looking up ID...`);
38
- const databases = await listD1Databases(cwd);
39
- const db = databases.find((d) => d.name === dbName);
40
- if (db) { info(`Found: ${db.uuid}`); return db.uuid; }
41
- fail("Could not find existing database ID.");
42
- return null;
43
- }
44
- fail(`Failed to create database: ${combined}`);
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}`);
45
52
  return null;
46
53
  }
47
-
48
- export default async function deployCommand(args) {
49
- banner();
50
- console.log(bold(" Full Cloudflare Deployment\n"));
51
-
52
- // ── Step 1: Preflight ────────────────────────────────────
53
-
54
- step(1, TOTAL_STEPS, "Preflight checks");
55
-
56
- const nodeMajor = getNodeMajor();
57
- if (nodeMajor < 18) { fail(`Node.js ${nodeMajor} detected. Need >= 18.`); process.exit(1); }
58
- success(`Node.js ${process.version}`);
59
-
60
- const wranglerCheck = await execWrangler(["--version"], ".");
61
- if (wranglerCheck.code !== 0) {
62
- fail("Wrangler not found. Install: npm install -g wrangler");
63
- process.exit(1);
64
- }
65
- success(`Wrangler installed`);
66
-
67
- const authed = await checkWranglerAuth(".");
68
- if (!authed) {
69
- warn("Not logged into Cloudflare. Opening browser...");
70
- const loginOk = await wranglerLogin(".");
71
- if (!loginOk) { fail("Cloudflare login failed."); process.exit(1); }
72
- }
73
- success("Cloudflare authenticated");
74
-
75
- // ── Step 2: Configuration ────────────────────────────────
76
-
77
- step(2, TOTAL_STEPS, "Configuration");
78
-
54
+
55
+ export default async function deployCommand(args) {
56
+ banner();
57
+ console.log(bold(" Full Cloudflare Deployment\n"));
58
+
59
+ // ── Step 1: Preflight ────────────────────────────────────
60
+
61
+ step(1, TOTAL_STEPS, "Preflight checks");
62
+
63
+ const nodeMajor = getNodeMajor();
64
+ if (nodeMajor < 22) { fail(`Node.js ${nodeMajor} detected. Need >= 22.`); process.exit(1); }
65
+ success(`Node.js ${process.version}`);
66
+
67
+ const wranglerCheck = await execWrangler(["--version"], ".");
68
+ if (wranglerCheck.code !== 0) {
69
+ fail("Wrangler not found. Install: npm install -g wrangler");
70
+ process.exit(1);
71
+ }
72
+ success(`Wrangler installed`);
73
+
74
+ const authed = await checkWranglerAuth(".");
75
+ if (!authed) {
76
+ warn("Not logged into Cloudflare. Opening browser...");
77
+ const loginOk = await wranglerLogin(".");
78
+ if (!loginOk) { fail("Cloudflare login failed."); process.exit(1); }
79
+ }
80
+ success("Cloudflare authenticated");
81
+
82
+ // ── Step 2: Configuration ────────────────────────────────
83
+
84
+ step(2, TOTAL_STEPS, "Configuration");
85
+
79
86
  const partner1 = await ask(" Partner 1 name", "Partner 1");
80
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); }
81
89
  const dashPassword = await password(" Dashboard password");
82
- if (!dashPassword) { fail("Password is required."); process.exit(1); }
83
- const mcpSecret = genSecret();
84
- success("MCP secret generated");
85
-
86
- console.log();
87
- info("Weather requires a free OpenWeatherMap API key.");
88
- info("Get one at: https://openweathermap.org/api");
89
- const weatherKey = await ask(" OpenWeatherMap API key (or skip)");
90
- let weatherLat = "", weatherLon = "";
91
- if (weatherKey) {
92
- info("Find your coordinates at: https://www.latlong.net");
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
+
95
+ console.log();
96
+ info("Weather requires a free OpenWeatherMap API key.");
97
+ info("Get one at: https://openweathermap.org/api");
98
+ const weatherKey = await ask(" OpenWeatherMap API key (or skip)");
99
+ let weatherLat = "", weatherLon = "";
100
+ if (weatherKey) {
101
+ info("Find your coordinates at: https://www.latlong.net");
93
102
  weatherLat = await ask(" Latitude (e.g. 51.5074)", "51.5074");
94
103
  weatherLon = await ask(" Longitude (e.g. -0.1278)", "-0.1278");
95
- }
96
-
97
- // ── Step 3: Create D1 database ───────────────────────────
98
-
99
- step(3, TOTAL_STEPS, "Creating D1 database");
100
-
101
- const pkgRoot = getPackageRoot();
102
- const deployDir = join(CONFIG_DIR, "deploy");
103
- mkdirSync(deployDir, { recursive: true });
104
-
105
- // Copy worker files to deploy dir
106
- const s1 = spinner("Copying source files");
107
- for (const file of ["worker.js", "schema.sql", "wrangler.toml"]) {
108
- const src = join(pkgRoot, file);
109
- if (!existsSync(src)) { s1.fail(`Missing: ${file}`); process.exit(1); }
110
- writeFileSync(join(deployDir, file), readFileSync(src, "utf-8"), "utf-8");
111
- }
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
+ }
110
+
111
+ // ── Step 3: Create D1 database ───────────────────────────
112
+
113
+ step(3, TOTAL_STEPS, "Creating D1 database");
114
+
115
+ const pkgRoot = getPackageRoot();
116
+ const deployDir = join(CONFIG_DIR, "deploy");
117
+ mkdirSync(deployDir, { recursive: true });
118
+
119
+ // Copy worker files to deploy dir
120
+ const s1 = spinner("Copying source files");
121
+ for (const file of ["worker.js", "oauth-entry.js", "schema.sql", "wrangler.toml", "package.json"]) {
122
+ const src = join(pkgRoot, file);
123
+ if (!existsSync(src)) { s1.fail(`Missing: ${file}`); process.exit(1); }
124
+ writeFileSync(join(deployDir, file), readFileSync(src, "utf-8"), "utf-8");
125
+ }
112
126
  s1.stop("Source files copied");
113
127
 
114
- const s2 = spinner("Creating D1 database");
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
+
131
+ const s2 = spinner("Creating D1 database");
115
132
  const dbId = await createD1OrReuse("hearth-dash-db", deployDir);
116
- if (!dbId) { s2.fail("D1 creation failed"); process.exit(1); }
133
+ if (!dbId) { s2.fail("D1 creation failed"); process.exit(1); }
117
134
  s2.stop(`D1 database ready: ${dbId.substring(0, 8)}...`);
118
135
 
119
- // ── Step 4: Create R2 bucket ─────────────────────────────
120
-
121
- step(4, TOTAL_STEPS, "Creating R2 bucket");
122
-
123
- const s3 = spinner("Creating R2 bucket");
124
- const r2Result = await execWrangler(["r2", "bucket", "create", "hearth-dash-photos"], deployDir);
125
- if (r2Result.code !== 0) {
126
- const combined = r2Result.stdout + r2Result.stderr;
127
- if (combined.includes("already exists")) {
128
- s3.stop("R2 bucket already exists");
129
- } else {
130
- s3.fail(`R2 creation failed: ${combined}`);
131
- process.exit(1);
132
- }
133
- } else {
134
- s3.stop("R2 bucket created");
135
- }
136
-
137
- // Patch wrangler.toml with real values
138
- let toml = readFileSync(join(deployDir, "wrangler.toml"), "utf-8");
136
+ const oauthKvId = await createKvOrReuse('hearth-dash-oauth', deployDir);
137
+ if (!oauthKvId) { fail('OAuth storage creation failed'); process.exit(1); }
138
+
139
+ // ── Step 4: Create R2 bucket ─────────────────────────────
140
+
141
+ step(4, TOTAL_STEPS, "Creating R2 bucket");
142
+
143
+ const s3 = spinner("Creating R2 bucket");
144
+ const r2Result = await execWrangler(["r2", "bucket", "create", "hearth-dash-photos"], deployDir);
145
+ if (r2Result.code !== 0) {
146
+ const combined = r2Result.stdout + r2Result.stderr;
147
+ if (combined.includes("already exists")) {
148
+ s3.stop("R2 bucket already exists");
149
+ } else {
150
+ s3.fail(`R2 creation failed: ${combined}`);
151
+ process.exit(1);
152
+ }
153
+ } else {
154
+ s3.stop("R2 bucket created");
155
+ }
156
+
157
+ // Patch wrangler.toml with real values
158
+ let toml = readFileSync(join(deployDir, "wrangler.toml"), "utf-8");
139
159
  toml = toml.replace("YOUR_D1_DATABASE_ID", dbId);
140
- toml = toml.replace('PARTNER_1 = "Partner 1"', `PARTNER_1 = "${partner1}"`);
141
- toml = toml.replace('PARTNER_2 = "Partner 2"', `PARTNER_2 = "${partner2}"`);
142
- if (weatherLat) {
143
- toml = toml.replace('# WEATHER_LAT = "52.5726"', `WEATHER_LAT = "${weatherLat}"`);
144
- toml = toml.replace('# WEATHER_LON = "-0.2405"', `WEATHER_LON = "${weatherLon}"`);
145
- }
146
- writeFileSync(join(deployDir, "wrangler.toml"), toml, "utf-8");
147
-
148
- // ── Step 5: Deploy worker + set secrets ──────────────────
149
-
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
+ if (weatherLat) {
164
+ toml = toml.replace('# WEATHER_LAT = "52.5726"', `WEATHER_LAT = "${weatherLat}"`);
165
+ toml = toml.replace('# WEATHER_LON = "-0.2405"', `WEATHER_LON = "${weatherLon}"`);
166
+ }
167
+ writeFileSync(join(deployDir, "wrangler.toml"), toml, "utf-8");
168
+
169
+ // ── Step 5: Deploy worker + set secrets ──────────────────
170
+
150
171
  step(5, TOTAL_STEPS, "Deploying worker");
151
172
 
152
- const s4 = spinner("Setting secrets");
153
- await setSecret("DASHBOARD_PASSWORD", dashPassword, deployDir);
154
- await setSecret("MCP_SECRET", mcpSecret, deployDir);
155
- if (weatherKey) await setSecret("WEATHER_API_KEY", weatherKey, deployDir);
156
- s4.stop("Secrets configured");
157
-
158
- const s5 = spinner("Deploying to Cloudflare Workers");
159
- const deployResult = await execWrangler(["deploy"], deployDir);
160
- if (deployResult.code !== 0) {
161
- s5.fail("Deploy failed");
162
- console.error(deployResult.stderr || deployResult.stdout);
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);
163
181
  process.exit(1);
164
182
  }
165
- const workerUrl = parseDeployOutput(deployResult);
166
- s5.stop(`Deployed: ${workerUrl}`);
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}`);
167
190
 
168
- // Init schema
169
191
  const s6 = spinner("Initializing database schema");
170
192
  const schemaResult = await executeSchema("hearth-dash-db", join(deployDir, "schema.sql"), deployDir);
171
193
  if (!schemaResult.ok) {
172
194
  s6.fail("Schema init failed: " + (schemaResult.error || "unknown error"));
173
- warn("You can retry manually: npx wrangler d1 execute hearth-dash-db --remote --file schema.sql");
174
- } else {
175
- s6.stop("Database schema initialized");
195
+ console.error("Deployment is incomplete; no success configuration was saved.");
196
+ process.exit(1);
176
197
  }
198
+ s6.stop("Database schema initialized");
177
199
 
178
- // ── Step 6: Save config + print results ──────────────────
179
-
180
- step(6, TOTAL_STEPS, "Finishing up");
181
-
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
+ if (deployResult.code !== 0) {
219
+ s5.fail("Deploy failed");
220
+ console.error(deployResult.stderr || deployResult.stdout);
221
+ process.exit(1);
222
+ }
223
+ s5.stop(`Deployed: ${workerUrl}`);
224
+
225
+ // ── Step 6: Save config + print results ──────────────────
226
+
227
+ step(6, TOTAL_STEPS, "Finishing up");
228
+
182
229
  const config = saveConfig({
183
230
  workerUrl,
184
- mcpSecret,
185
231
  partner1,
186
- partner2,
187
- dbId,
188
- deployedAt: new Date().toISOString(),
189
- });
190
-
191
- const mcpUrl = workerUrl + "/mcp/" + mcpSecret;
192
-
193
- console.log(`
194
- ${green(bold(" Done!"))} Your dashboard is live.
195
-
196
- ${bold("Dashboard:")} ${cyan(workerUrl)}
197
- ${bold("Password:")} ${dim("(the one you just set)")}
198
-
199
- ${bold("MCP Endpoint:")}
232
+ partner2,
233
+ dbId,
234
+ deployedAt: new Date().toISOString(),
235
+ });
236
+
237
+ const mcpUrl = workerUrl + "/mcp";
238
+
239
+ console.log(`
240
+ ${green(bold(" Done!"))} Your dashboard is live.
241
+
242
+ ${bold("Dashboard:")} ${cyan(workerUrl)}
243
+ ${bold("Password:")} ${dim("(the one you just set)")}
244
+
245
+ ${bold("OAuth MCP Endpoint:")}
200
246
  ${dim(mcpUrl)}
201
-
202
- ${bold("MCP Config")} (add to Claude Code ${dim("~/.claude.json")} or Claude Desktop):
203
- ${dim(" {")}
204
- ${dim(' "mcpServers": {')}
205
- ${dim(' "hearth-dash": {')}
206
- ${dim(' "url": "' + mcpUrl + '"')}
207
- ${dim(" }")}
208
- ${dim(" }")}
209
- ${dim(" }")}
210
-
211
- ${bold("Connector URL")} (for Claude.ai mobile):
212
- ${cyan(mcpUrl)}
213
-
214
- ${dim("Config saved to: " + CONFIG_PATH)}
215
- `);
216
- }
247
+
248
+ ${bold("MCP Config")} (add to Claude Code ${dim("~/.claude.json")} or Claude Desktop):
249
+ ${dim(" {")}
250
+ ${dim(' "mcpServers": {')}
251
+ ${dim(' "hearth-dash": {')}
252
+ ${dim(' "url": "' + mcpUrl + '"')}
253
+ ${dim(" }")}
254
+ ${dim(" }")}
255
+ ${dim(" }")}
256
+
257
+ ${bold("Connector URL")} (Claude will open a dashboard-password consent screen):
258
+ ${cyan(mcpUrl)}
259
+
260
+ ${dim("Config saved to: " + CONFIG_PATH)}
261
+ `);
262
+ }
@@ -1,69 +1,73 @@
1
- import { readFileSync, existsSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { homedir } from "node:os";
4
- import { banner, bold, dim, cyan, warn, info } from "../lib/ui.js";
5
-
6
- const CONFIG_PATH = join(homedir(), ".hearth-dash", "config.json");
7
-
8
- export default async function mcpCommand(args) {
9
- banner();
10
-
11
- if (!existsSync(CONFIG_PATH)) {
12
- warn("No config found. Run 'hearth-dash deploy' first.");
13
- process.exit(1);
14
- }
15
-
16
- const config = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
17
- const mcpUrl = config.workerUrl + "/mcp/" + config.mcpSecret;
18
-
19
- console.log(bold(" MCP Configuration\n"));
20
-
1
+ import { readFileSync, existsSync, chmodSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { banner, bold, dim, cyan, warn, info } from "../lib/ui.js";
5
+
6
+ const CONFIG_PATH = join(homedir(), ".hearth-dash", "config.json");
7
+
8
+ export default async function mcpCommand(args) {
9
+ banner();
10
+
11
+ if (!existsSync(CONFIG_PATH)) {
12
+ warn("No config found. Run 'hearth-dash deploy' first.");
13
+ process.exit(1);
14
+ }
15
+
16
+ const config = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
17
+ const mcpUrl = config.workerUrl + "/mcp";
18
+
19
+ console.log(bold(" MCP Configuration\n"));
20
+
21
21
  console.log(` ${bold("Endpoint:")} ${cyan(mcpUrl)}\n`);
22
-
23
- console.log(` ${bold("For Claude Code")} (add to ${dim("~/.claude.json")}):\n`);
24
- console.log(` {`);
25
- console.log(` "mcpServers": {`);
26
- console.log(` "hearth-dash": {`);
27
- console.log(` "url": "${mcpUrl}"`);
28
- console.log(` }`);
29
- console.log(` }`);
30
- console.log(` }\n`);
31
-
22
+ info("Hearth uses OAuth 2.1. Your dashboard password stays out of the connector URL and Claude stores only revocable tokens.");
23
+ console.log();
24
+
25
+ console.log(` ${bold("For Claude Code")} (add to ${dim("~/.claude.json")}):\n`);
26
+ console.log(` {`);
27
+ console.log(` "mcpServers": {`);
28
+ console.log(` "hearth-dash": {`);
29
+ console.log(` "url": "${mcpUrl}"`);
30
+ console.log(` }`);
31
+ console.log(` }`);
32
+ console.log(` }\n`);
33
+
32
34
  console.log(` ${bold("For Claude Desktop")} (Settings > Developer > MCP):\n`);
33
- console.log(` Name: hearth-dash`);
35
+ console.log(` Name: hearth-dash`);
34
36
  console.log(` URL: ${mcpUrl}\n`);
35
-
36
- console.log(` ${bold("Connector URL")} (for Claude.ai mobile):`);
37
- console.log(` ${cyan(mcpUrl)}\n`);
38
-
39
- console.log(` ${bold("Available MCP tools:")}`);
40
- const tools = [
41
- "hearth_status — Dashboard overview",
42
- "hearth_mood — Get/set moods",
43
- "hearth_note — Get/leave fridge notes",
44
- "hearth_moment — List/add moments",
45
- "hearth_date — Upcoming/add dates",
46
- "hearth_shopping_list — Get shopping list",
47
- "hearth_shopping_add — Add shopping item",
48
- "hearth_pressure — Barometric pressure data",
49
- "hearth_food_diary_today — Today's meals + water",
50
- "hearth_food_diary_history — Historical food data",
51
- "hearth_food_review — Post food review",
52
- "hearth_water_status — Water intake status",
53
- ];
54
- tools.forEach(t => console.log(` ${dim(t)}`));
55
- console.log();
56
-
57
- if (args.includes("--install")) {
58
- const { writeFileSync } = await import("node:fs");
59
- const claudeConfigPath = join(homedir(), ".claude.json");
60
- let claudeConfig = {};
61
- if (existsSync(claudeConfigPath)) {
62
- try { claudeConfig = JSON.parse(readFileSync(claudeConfigPath, "utf-8")); } catch {}
63
- }
64
- if (!claudeConfig.mcpServers) claudeConfig.mcpServers = {};
37
+ console.log(` ${dim("When prompted, sign in with the Hearth dashboard password and approve access.")}\n`);
38
+
39
+ console.log(` ${bold("Connector URL")} (for Claude.ai mobile):`);
40
+ console.log(` ${cyan(mcpUrl)}\n`);
41
+
42
+ console.log(` ${bold("Available MCP tools:")}`);
43
+ const tools = [
44
+ "hearth_status — Dashboard overview",
45
+ "hearth_mood — Get/set moods",
46
+ "hearth_note — Get/leave fridge notes",
47
+ "hearth_moment — List/add moments",
48
+ "hearth_date — Upcoming/add dates",
49
+ "hearth_shopping_list — Get shopping list",
50
+ "hearth_shopping_add — Add shopping item",
51
+ "hearth_pressure — Barometric pressure data",
52
+ "hearth_food_diary_today — Today's meals + water",
53
+ "hearth_food_diary_history — Historical food data",
54
+ "hearth_food_review — Post food review",
55
+ "hearth_water_status — Water intake status",
56
+ ];
57
+ tools.forEach(t => console.log(` ${dim(t)}`));
58
+ console.log();
59
+
60
+ if (args.includes("--install")) {
61
+ const { writeFileSync } = await import("node:fs");
62
+ const claudeConfigPath = join(homedir(), ".claude.json");
63
+ let claudeConfig = {};
64
+ if (existsSync(claudeConfigPath)) {
65
+ try { claudeConfig = JSON.parse(readFileSync(claudeConfigPath, "utf-8")); } catch {}
66
+ }
67
+ if (!claudeConfig.mcpServers) claudeConfig.mcpServers = {};
65
68
  claudeConfig.mcpServers["hearth-dash"] = { url: mcpUrl };
66
69
  writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2) + "\n", "utf-8");
70
+ try { chmodSync(claudeConfigPath, 0o600); } catch {}
67
71
  info("MCP config written to " + claudeConfigPath);
68
- }
69
- }
72
+ }
73
+ }