@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.
|
|
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 (
|
|
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
|
-
|
|
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
|
|
61
|
-
if (
|
|
62
|
-
|
|
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.
|
|
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";
|