@nestpilot/mcp-app 1.0.1 → 1.0.2

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/dist/cli/init.js CHANGED
@@ -4,12 +4,13 @@
4
4
  * Steps:
5
5
  * 1. Create ~/.nestpilot/ directory structure
6
6
  * 2. Generate encryption key and store in OS keychain
7
- * 3. Optionally provision API key via cloud endpoint
7
+ * 3. Authenticate via Device Code Flow and provision API key
8
8
  * 4. Write config.json with preferences
9
9
  * 5. Copy host config templates
10
10
  * 6. Print success message with next steps
11
11
  *
12
12
  * @feature FEAT-0088
13
+ * @feature FEAT-0091
13
14
  */
14
15
  import fs from "node:fs/promises";
15
16
  import path from "node:path";
@@ -18,6 +19,7 @@ import { stdin, stdout } from "node:process";
18
19
  import { loadLocalConfig, DATA_SUBDIRS, configFilePath, } from "../src/local/local-config.js";
19
20
  import { createKeychainProvider } from "../src/local/keychain.js";
20
21
  import { EncryptionService } from "../src/local/encryption.js";
22
+ import { DeviceAuthClient } from "../src/local/device-auth.js";
21
23
  // ── Init command ─────────────────────────────────────────────────────────
22
24
  export async function initCommand() {
23
25
  console.log("\n🏠 NestPilot Local Setup");
@@ -48,46 +50,36 @@ export async function initCommand() {
48
50
  console.log(" ✓ Encryption key generated and stored in OS keychain");
49
51
  }
50
52
  console.log();
51
- // Step 3: API Key provisioning (optional)
53
+ // Step 3: API Key provisioning via Device Code Flow (FEAT-0091)
52
54
  console.log("3. API Key Provisioning");
53
55
  const existingKey = await keychain.get("nestpilot", "api-key");
54
56
  if (existingKey) {
55
- console.log(" ✓ API key already configured");
57
+ // Verify the stored key is still valid
58
+ const deviceAuth = new DeviceAuthClient(config.cloudApiUrl);
59
+ try {
60
+ const valid = await deviceAuth.verifyApiKey(existingKey);
61
+ if (valid) {
62
+ console.log(" ✓ API key already configured and valid");
63
+ }
64
+ else {
65
+ console.log(" ⚠ Stored API key is expired or revoked. Re-authenticating...");
66
+ await provisionApiKey(config.cloudApiUrl, keychain);
67
+ }
68
+ }
69
+ catch {
70
+ console.log(" ✓ API key configured (cloud verification skipped — offline)");
71
+ }
56
72
  }
57
73
  else {
58
74
  const rl = readline.createInterface({ input: stdin, output: stdout });
59
75
  try {
60
- const email = await rl.question(" Enter your email for free tier (10 forecasts/mo), or press Enter to skip: ");
61
- if (email.trim()) {
62
- try {
63
- const response = await fetch(`${config.cloudApiUrl}/api/auth/provision-key`, {
64
- method: "POST",
65
- headers: { "Content-Type": "application/json" },
66
- body: JSON.stringify({ email: email.trim() }),
67
- signal: AbortSignal.timeout(10_000),
68
- });
69
- if (response.ok) {
70
- const data = (await response.json());
71
- await keychain.set("nestpilot", "api-key", data.apiKey);
72
- console.log(" ✓ API key provisioned (10 forecasts/mo free)");
73
- console.log(" ✓ API key stored in OS keychain");
74
- }
75
- else {
76
- console.log(" ⚠ Could not provision API key (cloud API unavailable).");
77
- console.log(" Local plan creation & storage still works without an API key.");
78
- console.log(" Cloud compute (forecasts, Roth optimization) requires a key.");
79
- console.log(" Set NESTPILOT_API_KEY env var manually, or run init again later.");
80
- }
81
- }
82
- catch {
83
- console.log(" ⚠ Could not reach cloud API for key provisioning.");
84
- console.log(" Local plan creation & storage still works without an API key.");
85
- console.log(" Cloud compute (forecasts, Roth optimization) requires a key.");
86
- console.log(" Set NESTPILOT_API_KEY env var manually, or run init again later.");
87
- }
76
+ const answer = await rl.question(" Sign in to NestPilot for cloud compute (forecasts, Roth optimization)? [Y/n] ");
77
+ if (answer.trim().toLowerCase() !== "n") {
78
+ await provisionApiKey(config.cloudApiUrl, keychain);
88
79
  }
89
80
  else {
90
- console.log(" ⏭ Skipped. Set NESTPILOT_API_KEY env var later.");
81
+ console.log(" ⏭ Skipped. Run 'nestpilot init' again or set NESTPILOT_API_KEY env var later.");
82
+ console.log(" Local plan creation & storage works without an API key.");
91
83
  }
92
84
  }
93
85
  finally {
@@ -125,6 +117,46 @@ export async function initCommand() {
125
117
  console.log("Quick test: npx nestpilot-mcp-server status");
126
118
  console.log();
127
119
  }
120
+ async function provisionApiKey(cloudApiUrl, keychain) {
121
+ const deviceAuth = new DeviceAuthClient(cloudApiUrl);
122
+ try {
123
+ // 1. Initiate device code flow
124
+ const authResponse = await deviceAuth.authorize();
125
+ console.log();
126
+ console.log(" ┌──────────────────────────────────────────┐");
127
+ console.log(` │ Code: ${authResponse.userCode.padEnd(33)}│`);
128
+ console.log(" └──────────────────────────────────────────┘");
129
+ console.log();
130
+ console.log(` Open: ${authResponse.verificationUri}`);
131
+ console.log(" Enter the code above and sign in with your NestPilot account.");
132
+ console.log();
133
+ // 2. Open browser automatically
134
+ deviceAuth.openBrowser(authResponse.verificationUriComplete ?? authResponse.verificationUri);
135
+ // 3. Poll for token completion
136
+ console.log(" ⏳ Waiting for sign-in...");
137
+ const tokenResponse = await deviceAuth.pollForToken(authResponse.deviceCode, authResponse.interval ?? 5, authResponse.expiresIn);
138
+ if (tokenResponse.status !== "completed" || !tokenResponse.accessToken) {
139
+ console.log(` ⚠ Sign-in ${tokenResponse.status}: ${tokenResponse.error ?? "timed out"}`);
140
+ console.log(" Local plan creation & storage still works without an API key.");
141
+ console.log(" Run 'nestpilot init' again to retry.");
142
+ return;
143
+ }
144
+ console.log(" ✓ Signed in successfully");
145
+ // 4. Exchange JWT for a long-lived API key
146
+ const apiKeyResult = await deviceAuth.exchangeForApiKey(tokenResponse.accessToken, `${process.platform}-cli-${new Date().toISOString().slice(0, 10)}`);
147
+ // 5. Store in OS keychain
148
+ await keychain.set("nestpilot", "api-key", apiKeyResult.apiKey);
149
+ console.log(" ✓ API key provisioned and stored in OS keychain");
150
+ console.log(` ✓ Key: ${apiKeyResult.keyPrefix}... (expires: ${apiKeyResult.expiresAt?.slice(0, 10) ?? "never"})`);
151
+ }
152
+ catch (err) {
153
+ const msg = err instanceof Error ? err.message : String(err);
154
+ console.log(` ⚠ Could not complete sign-in: ${msg}`);
155
+ console.log(" Local plan creation & storage still works without an API key.");
156
+ console.log(" Cloud compute (forecasts, Roth optimization) requires a key.");
157
+ console.log(" Run 'nestpilot init' again or set NESTPILOT_API_KEY env var.");
158
+ }
159
+ }
128
160
  // ── Host config writer ───────────────────────────────────────────────────
129
161
  async function writeHostConfigs(dir) {
130
162
  // Goose
@@ -0,0 +1,52 @@
1
+ export interface DeviceCodeResponse {
2
+ deviceCode: string;
3
+ userCode: string;
4
+ verificationUri: string;
5
+ verificationUriComplete?: string;
6
+ expiresIn: number;
7
+ interval: number;
8
+ message?: string;
9
+ }
10
+ export interface DeviceTokenResponse {
11
+ status: "pending" | "completed" | "expired" | "denied" | "error";
12
+ accessToken?: string;
13
+ tokenType?: string;
14
+ expiresIn?: number;
15
+ error?: string;
16
+ errorDescription?: string;
17
+ }
18
+ export interface ApiKeyCreatedResponse {
19
+ id: string;
20
+ apiKey: string;
21
+ keyPrefix: string;
22
+ name: string;
23
+ scopes: string[];
24
+ expiresAt: string;
25
+ createdAt: string;
26
+ message: string;
27
+ }
28
+ export declare class DeviceAuthClient {
29
+ private cloudApiUrl;
30
+ constructor(cloudApiUrl: string);
31
+ /**
32
+ * Step 1: Initiate device code flow.
33
+ */
34
+ authorize(): Promise<DeviceCodeResponse>;
35
+ /**
36
+ * Step 2: Open browser for user authentication.
37
+ */
38
+ openBrowser(url: string): void;
39
+ /**
40
+ * Step 3: Poll for device code completion.
41
+ * Retries at the specified interval until the user completes auth or the code expires.
42
+ */
43
+ pollForToken(deviceCode: string, interval: number, expiresIn: number, onPoll?: () => void): Promise<DeviceTokenResponse>;
44
+ /**
45
+ * Step 4: Exchange JWT for a NestPilot API key.
46
+ */
47
+ exchangeForApiKey(accessToken: string, name?: string): Promise<ApiKeyCreatedResponse>;
48
+ /**
49
+ * Verify an existing API key is still valid.
50
+ */
51
+ verifyApiKey(apiKey: string): Promise<boolean>;
52
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Device Authorization Grant (RFC 8628) client for CLI-based authentication.
3
+ *
4
+ * Flow:
5
+ * 1. POST /api/auth/device/authorize → get user_code + verification_uri
6
+ * 2. Open browser for user to sign in
7
+ * 3. Poll POST /api/auth/device/token until completed
8
+ * 4. Exchange JWT for API key via POST /api/auth/api-keys
9
+ * 5. Store API key in OS keychain
10
+ *
11
+ * @feature FEAT-0091
12
+ */
13
+ import { exec } from "node:child_process";
14
+ import os from "node:os";
15
+ // ── Device Auth Client ───────────────────────────────────────────────────
16
+ export class DeviceAuthClient {
17
+ cloudApiUrl;
18
+ constructor(cloudApiUrl) {
19
+ this.cloudApiUrl = cloudApiUrl;
20
+ // Strip trailing slash
21
+ if (this.cloudApiUrl.endsWith("/")) {
22
+ this.cloudApiUrl = this.cloudApiUrl.slice(0, -1);
23
+ }
24
+ }
25
+ /**
26
+ * Step 1: Initiate device code flow.
27
+ */
28
+ async authorize() {
29
+ const response = await fetch(`${this.cloudApiUrl}/api/auth/device/authorize`, {
30
+ method: "POST",
31
+ headers: { "Content-Type": "application/json" },
32
+ signal: AbortSignal.timeout(10_000),
33
+ });
34
+ if (!response.ok) {
35
+ throw new Error(`Failed to initiate device code flow: ${response.status} ${await response.text()}`);
36
+ }
37
+ return (await response.json());
38
+ }
39
+ /**
40
+ * Step 2: Open browser for user authentication.
41
+ */
42
+ openBrowser(url) {
43
+ const platform = os.platform();
44
+ let command;
45
+ switch (platform) {
46
+ case "darwin":
47
+ command = `open "${url}"`;
48
+ break;
49
+ case "win32":
50
+ command = `start "${url}"`;
51
+ break;
52
+ case "linux":
53
+ command = `xdg-open "${url}"`;
54
+ break;
55
+ default:
56
+ console.log(`\n Please open this URL in your browser:\n ${url}\n`);
57
+ return;
58
+ }
59
+ exec(command, (error) => {
60
+ if (error) {
61
+ console.log(`\n Could not open browser automatically.\n Please open this URL:\n ${url}\n`);
62
+ }
63
+ });
64
+ }
65
+ /**
66
+ * Step 3: Poll for device code completion.
67
+ * Retries at the specified interval until the user completes auth or the code expires.
68
+ */
69
+ async pollForToken(deviceCode, interval, expiresIn, onPoll) {
70
+ const deadline = Date.now() + expiresIn * 1000;
71
+ while (Date.now() < deadline) {
72
+ if (onPoll)
73
+ onPoll();
74
+ const response = await fetch(`${this.cloudApiUrl}/api/auth/device/token`, {
75
+ method: "POST",
76
+ headers: { "Content-Type": "application/json" },
77
+ body: JSON.stringify({ device_code: deviceCode }),
78
+ signal: AbortSignal.timeout(10_000),
79
+ });
80
+ const result = (await response.json());
81
+ if (result.status === "completed" && result.accessToken) {
82
+ return result;
83
+ }
84
+ if (result.status === "expired" || result.status === "denied") {
85
+ return result;
86
+ }
87
+ if (result.status === "error" &&
88
+ result.error !== "authorization_pending") {
89
+ return result;
90
+ }
91
+ // Wait before next poll
92
+ await sleep(interval * 1000);
93
+ }
94
+ return {
95
+ status: "expired",
96
+ error: "expired_token",
97
+ errorDescription: "Device code expired before user completed authentication.",
98
+ };
99
+ }
100
+ /**
101
+ * Step 4: Exchange JWT for a NestPilot API key.
102
+ */
103
+ async exchangeForApiKey(accessToken, name) {
104
+ const response = await fetch(`${this.cloudApiUrl}/api/auth/api-keys`, {
105
+ method: "POST",
106
+ headers: {
107
+ "Content-Type": "application/json",
108
+ Authorization: `Bearer ${accessToken}`,
109
+ },
110
+ body: JSON.stringify({
111
+ name: name ?? `CLI (${os.hostname()})`,
112
+ scopes: ["compute"],
113
+ expiresInDays: 90,
114
+ }),
115
+ signal: AbortSignal.timeout(10_000),
116
+ });
117
+ if (!response.ok) {
118
+ const text = await response.text();
119
+ throw new Error(`Failed to create API key: ${response.status} ${text}`);
120
+ }
121
+ return (await response.json());
122
+ }
123
+ /**
124
+ * Verify an existing API key is still valid.
125
+ */
126
+ async verifyApiKey(apiKey) {
127
+ try {
128
+ const response = await fetch(`${this.cloudApiUrl}/api/auth/api-keys/verify`, {
129
+ method: "GET",
130
+ headers: {
131
+ Authorization: `Bearer ${apiKey}`,
132
+ },
133
+ signal: AbortSignal.timeout(5_000),
134
+ });
135
+ if (!response.ok)
136
+ return false;
137
+ const result = (await response.json());
138
+ return result.valid === true;
139
+ }
140
+ catch {
141
+ return false;
142
+ }
143
+ }
144
+ }
145
+ // ── Utilities ────────────────────────────────────────────────────────────
146
+ function sleep(ms) {
147
+ return new Promise((resolve) => setTimeout(resolve, ms));
148
+ }
@@ -18,3 +18,5 @@ export { scrubPII, validateNoPII } from "./pii-scrubber.js";
18
18
  export type { ScrubResult } from "./pii-scrubber.js";
19
19
  export { CloudComputeClient } from "./cloud-compute-client.js";
20
20
  export type { CloudComputeResult } from "./cloud-compute-client.js";
21
+ export { DeviceAuthClient } from "./device-auth.js";
22
+ export type { DeviceCodeResponse, DeviceTokenResponse, ApiKeyCreatedResponse, } from "./device-auth.js";
@@ -13,3 +13,4 @@ export { createKeychainProvider, FileKeychain, } from "./keychain.js";
13
13
  export { LocalPlanStore } from "./local-plan-store.js";
14
14
  export { scrubPII, validateNoPII } from "./pii-scrubber.js";
15
15
  export { CloudComputeClient } from "./cloud-compute-client.js";
16
+ export { DeviceAuthClient } from "./device-auth.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nestpilot/mcp-app",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "type": "module",
5
5
  "description": "NestPilot MCP App — Retirement planning tools and interactive views",
6
6
  "bin": {