@townco/cli 0.1.53 → 0.1.54
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/commands/login.d.ts +1 -0
- package/dist/commands/login.js +110 -0
- package/dist/commands/run.js +61 -5
- package/dist/commands/whoami.d.ts +1 -0
- package/dist/commands/whoami.js +36 -0
- package/dist/components/TabbedOutput.js +6 -78
- package/dist/index.js +12 -0
- package/dist/lib/auth-fetch.d.ts +19 -0
- package/dist/lib/auth-fetch.js +99 -0
- package/dist/lib/auth-storage.d.ts +38 -0
- package/dist/lib/auth-storage.js +89 -0
- package/package.json +7 -7
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function loginCommand(): Promise<void>;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import inquirer from "inquirer";
|
|
2
|
+
import { clearAuthCredentials, getAuthFilePath, getShedUrl, loadAuthCredentials, saveAuthCredentials, } from "../lib/auth-storage.js";
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Command Implementation
|
|
5
|
+
// ============================================================================
|
|
6
|
+
export async function loginCommand() {
|
|
7
|
+
const shedUrl = getShedUrl();
|
|
8
|
+
console.log("Town Login\n");
|
|
9
|
+
// Check if already logged in
|
|
10
|
+
const existingCredentials = loadAuthCredentials();
|
|
11
|
+
if (existingCredentials) {
|
|
12
|
+
console.log(`Currently logged in as: ${existingCredentials.user.email}`);
|
|
13
|
+
console.log(`Shed URL: ${existingCredentials.shed_url}\n`);
|
|
14
|
+
const { action } = await inquirer.prompt([
|
|
15
|
+
{
|
|
16
|
+
type: "list",
|
|
17
|
+
name: "action",
|
|
18
|
+
message: "What would you like to do?",
|
|
19
|
+
choices: [
|
|
20
|
+
{ name: "Log in with a different account", value: "login" },
|
|
21
|
+
{ name: "Log out", value: "logout" },
|
|
22
|
+
{ name: "Cancel", value: "cancel" },
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
]);
|
|
26
|
+
if (action === "cancel") {
|
|
27
|
+
console.log("Cancelled.");
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (action === "logout") {
|
|
31
|
+
clearAuthCredentials();
|
|
32
|
+
console.log("Logged out successfully.");
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// Prompt for credentials
|
|
37
|
+
const { email } = await inquirer.prompt([
|
|
38
|
+
{
|
|
39
|
+
type: "input",
|
|
40
|
+
name: "email",
|
|
41
|
+
message: "Email:",
|
|
42
|
+
validate: (input) => {
|
|
43
|
+
if (!input.trim()) {
|
|
44
|
+
return "Email is required";
|
|
45
|
+
}
|
|
46
|
+
if (!input.includes("@")) {
|
|
47
|
+
return "Please enter a valid email address";
|
|
48
|
+
}
|
|
49
|
+
return true;
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
]);
|
|
53
|
+
const { password } = await inquirer.prompt([
|
|
54
|
+
{
|
|
55
|
+
type: "password",
|
|
56
|
+
name: "password",
|
|
57
|
+
message: "Password:",
|
|
58
|
+
mask: "*",
|
|
59
|
+
validate: (input) => {
|
|
60
|
+
if (!input) {
|
|
61
|
+
return "Password is required";
|
|
62
|
+
}
|
|
63
|
+
return true;
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
]);
|
|
67
|
+
console.log("\nAuthenticating...");
|
|
68
|
+
try {
|
|
69
|
+
const response = await fetch(`${shedUrl}/api/cli/login`, {
|
|
70
|
+
method: "POST",
|
|
71
|
+
headers: {
|
|
72
|
+
"Content-Type": "application/json",
|
|
73
|
+
},
|
|
74
|
+
body: JSON.stringify({ email, password }),
|
|
75
|
+
});
|
|
76
|
+
if (!response.ok) {
|
|
77
|
+
const errorData = await response
|
|
78
|
+
.json()
|
|
79
|
+
.catch(() => ({ error: "Login failed" }));
|
|
80
|
+
console.error(`\nLogin failed: ${errorData.error || "Unknown error"}`);
|
|
81
|
+
if (response.status === 401) {
|
|
82
|
+
console.log("\nPlease check your email and password and try again.");
|
|
83
|
+
console.log("If you don't have an account, sign up at " + shedUrl);
|
|
84
|
+
}
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
const data = (await response.json());
|
|
88
|
+
// Save credentials
|
|
89
|
+
const credentials = {
|
|
90
|
+
access_token: data.access_token,
|
|
91
|
+
refresh_token: data.refresh_token,
|
|
92
|
+
expires_at: data.expires_at,
|
|
93
|
+
user: data.user,
|
|
94
|
+
shed_url: shedUrl,
|
|
95
|
+
};
|
|
96
|
+
saveAuthCredentials(credentials);
|
|
97
|
+
console.log(`\nLogged in as ${data.user.email}`);
|
|
98
|
+
console.log(`Credentials saved to ${getAuthFilePath()}`);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
if (error instanceof TypeError && error.message.includes("fetch")) {
|
|
102
|
+
console.error(`\nCould not connect to ${shedUrl}`);
|
|
103
|
+
console.error("Please check your internet connection and try again.");
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
console.error(`\nLogin failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
107
|
+
}
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
}
|
package/dist/commands/run.js
CHANGED
|
@@ -233,10 +233,11 @@ export async function runCommand(options) {
|
|
|
233
233
|
if (process.stdin.isTTY) {
|
|
234
234
|
process.stdin.setRawMode(true);
|
|
235
235
|
}
|
|
236
|
-
// Start the agent in HTTP mode
|
|
236
|
+
// Start the agent in HTTP mode with detached process group
|
|
237
237
|
const agentProcess = spawn("bun", [binPath, "http"], {
|
|
238
238
|
cwd: agentPath,
|
|
239
239
|
stdio: ["ignore", "pipe", "pipe"], // Pipe stdout/stderr for capture
|
|
240
|
+
detached: true,
|
|
240
241
|
env: {
|
|
241
242
|
...process.env,
|
|
242
243
|
...configEnvVars,
|
|
@@ -246,20 +247,60 @@ export async function runCommand(options) {
|
|
|
246
247
|
...(noSession ? { TOWN_NO_SESSION: "true" } : {}),
|
|
247
248
|
},
|
|
248
249
|
});
|
|
249
|
-
// Start the GUI dev server (no package.json, run vite directly)
|
|
250
|
+
// Start the GUI dev server (no package.json, run vite directly) with detached process group
|
|
250
251
|
const guiProcess = spawn("bunx", ["vite"], {
|
|
251
252
|
cwd: guiPath,
|
|
252
253
|
stdio: ["ignore", "pipe", "pipe"], // Pipe stdout/stderr for capture
|
|
254
|
+
detached: true,
|
|
253
255
|
env: {
|
|
254
256
|
...process.env,
|
|
255
257
|
...configEnvVars,
|
|
256
258
|
VITE_AGENT_URL: `http://localhost:${availablePort}`,
|
|
259
|
+
VITE_DEBUGGER_URL: "http://localhost:4000",
|
|
257
260
|
},
|
|
258
261
|
});
|
|
262
|
+
// Setup cleanup handlers for agent and GUI processes
|
|
263
|
+
const cleanupProcesses = () => {
|
|
264
|
+
// Kill entire process group by using negative PID
|
|
265
|
+
if (agentProcess.pid) {
|
|
266
|
+
try {
|
|
267
|
+
process.kill(-agentProcess.pid, "SIGTERM");
|
|
268
|
+
}
|
|
269
|
+
catch (e) {
|
|
270
|
+
// Process may already be dead
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (guiProcess.pid) {
|
|
274
|
+
try {
|
|
275
|
+
process.kill(-guiProcess.pid, "SIGTERM");
|
|
276
|
+
}
|
|
277
|
+
catch (e) {
|
|
278
|
+
// Process may already be dead
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
process.on("exit", cleanupProcesses);
|
|
283
|
+
process.on("SIGINT", cleanupProcesses);
|
|
284
|
+
process.on("SIGTERM", cleanupProcesses);
|
|
259
285
|
// Render the tabbed UI with dynamic port detection
|
|
260
286
|
const { waitUntilExit } = render(_jsx(GuiRunner, { agentProcess: agentProcess, guiProcess: guiProcess, agentPort: availablePort, agentPath: agentPath, logger: logger, onExit: () => {
|
|
261
|
-
|
|
262
|
-
|
|
287
|
+
// Kill entire process group by using negative PID
|
|
288
|
+
if (agentProcess.pid) {
|
|
289
|
+
try {
|
|
290
|
+
process.kill(-agentProcess.pid, "SIGTERM");
|
|
291
|
+
}
|
|
292
|
+
catch (e) {
|
|
293
|
+
// Process may already be dead
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (guiProcess.pid) {
|
|
297
|
+
try {
|
|
298
|
+
process.kill(-guiProcess.pid, "SIGTERM");
|
|
299
|
+
}
|
|
300
|
+
catch (e) {
|
|
301
|
+
// Process may already be dead
|
|
302
|
+
}
|
|
303
|
+
}
|
|
263
304
|
} }));
|
|
264
305
|
await waitUntilExit();
|
|
265
306
|
process.exit(0);
|
|
@@ -280,10 +321,11 @@ export async function runCommand(options) {
|
|
|
280
321
|
console.log(` http://localhost:${availablePort}/health - Health check`);
|
|
281
322
|
console.log(` http://localhost:${availablePort}/rpc - RPC endpoint`);
|
|
282
323
|
console.log(` http://localhost:${availablePort}/events - SSE event stream\n`);
|
|
283
|
-
// Run the agent in HTTP mode
|
|
324
|
+
// Run the agent in HTTP mode with detached process group
|
|
284
325
|
const agentProcess = spawn("bun", [binPath, "http"], {
|
|
285
326
|
cwd: agentPath,
|
|
286
327
|
stdio: "inherit",
|
|
328
|
+
detached: true,
|
|
287
329
|
env: {
|
|
288
330
|
...process.env,
|
|
289
331
|
...configEnvVars,
|
|
@@ -293,6 +335,20 @@ export async function runCommand(options) {
|
|
|
293
335
|
...(noSession ? { TOWN_NO_SESSION: "true" } : {}),
|
|
294
336
|
},
|
|
295
337
|
});
|
|
338
|
+
// Setup cleanup handler for agent process
|
|
339
|
+
const cleanupAgentProcess = () => {
|
|
340
|
+
if (agentProcess.pid) {
|
|
341
|
+
try {
|
|
342
|
+
process.kill(-agentProcess.pid, "SIGTERM");
|
|
343
|
+
}
|
|
344
|
+
catch (e) {
|
|
345
|
+
// Process may already be dead
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
process.on("exit", cleanupAgentProcess);
|
|
350
|
+
process.on("SIGINT", cleanupAgentProcess);
|
|
351
|
+
process.on("SIGTERM", cleanupAgentProcess);
|
|
296
352
|
agentProcess.on("error", (error) => {
|
|
297
353
|
logger.error("Failed to start agent", { error: error.message });
|
|
298
354
|
console.error(`Failed to start agent: ${error.message}`);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function whoamiCommand(): Promise<void>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { authGet } from "../lib/auth-fetch.js";
|
|
2
|
+
import { loadAuthCredentials } from "../lib/auth-storage.js";
|
|
3
|
+
export async function whoamiCommand() {
|
|
4
|
+
const credentials = loadAuthCredentials();
|
|
5
|
+
if (!credentials) {
|
|
6
|
+
console.log("Not logged in. Run 'town login' to authenticate.");
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
console.log(`Shed URL: ${credentials.shed_url}`);
|
|
10
|
+
console.log("Verifying credentials...\n");
|
|
11
|
+
try {
|
|
12
|
+
const response = await authGet("/api/cli/me");
|
|
13
|
+
if (!response.ok) {
|
|
14
|
+
const error = await response
|
|
15
|
+
.json()
|
|
16
|
+
.catch(() => ({ error: "Unknown error" }));
|
|
17
|
+
console.error(`Authentication failed: ${error.error}`);
|
|
18
|
+
console.log("\nYour session may have expired. Run 'town login' again.");
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
const data = (await response.json());
|
|
22
|
+
console.log(`Email: ${data.email}`);
|
|
23
|
+
console.log(`User ID: ${data.id}`);
|
|
24
|
+
console.log("\nAuthentication verified successfully.");
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (error instanceof TypeError && error.message.includes("fetch")) {
|
|
28
|
+
console.error(`Could not connect to ${credentials.shed_url}`);
|
|
29
|
+
console.error("Please check your internet connection and try again.");
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
33
|
+
}
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
3
|
-
import { appendFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync } from "node:fs";
|
|
4
3
|
import { join } from "node:path";
|
|
5
|
-
import { Box, useApp, useInput } from "ink";
|
|
4
|
+
import { Box, Text, useApp, useInput } from "ink";
|
|
6
5
|
import { useEffect, useRef, useState } from "react";
|
|
7
|
-
import {
|
|
6
|
+
import { LogsPane } from "./LogsPane.js";
|
|
8
7
|
import { ProcessPane } from "./ProcessPane.js";
|
|
9
8
|
import { StatusLine } from "./StatusLine.js";
|
|
10
9
|
function isProcessTab(tab) {
|
|
@@ -21,7 +20,6 @@ export function TabbedOutput({ processes, customTabs = [], logsDir, onExit, onPo
|
|
|
21
20
|
const [statuses, setStatuses] = useState(processes.map(() => "starting"));
|
|
22
21
|
const [ports, setPorts] = useState(processes.map((p) => p.port));
|
|
23
22
|
const portDetectedRef = useRef(new Set());
|
|
24
|
-
const [logFileOutputs, setLogFileOutputs] = useState({});
|
|
25
23
|
// Ensure logs directory exists if provided
|
|
26
24
|
useEffect(() => {
|
|
27
25
|
if (logsDir && !existsSync(logsDir)) {
|
|
@@ -176,57 +174,7 @@ export function TabbedOutput({ processes, customTabs = [], logsDir, onExit, onPo
|
|
|
176
174
|
}, [processes, onPortDetected, logsDir]);
|
|
177
175
|
// GUI mode: only process tabs, no custom tabs - use merged logs view
|
|
178
176
|
const isGuiMode = customTabs.length === 0 && processes.length > 0;
|
|
179
|
-
//
|
|
180
|
-
useEffect(() => {
|
|
181
|
-
if (!logsDir || !isGuiMode || !existsSync(logsDir)) {
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
const tailProcesses = [];
|
|
185
|
-
const tailedFiles = new Set();
|
|
186
|
-
// Function to start tailing a specific log file
|
|
187
|
-
const tailLogFile = (file) => {
|
|
188
|
-
if (tailedFiles.has(file))
|
|
189
|
-
return;
|
|
190
|
-
tailedFiles.add(file);
|
|
191
|
-
const serviceName = file.replace(".log", "");
|
|
192
|
-
const filePath = join(logsDir, file);
|
|
193
|
-
// Use tail -F (follow by name, retry if file doesn't exist)
|
|
194
|
-
const tail = spawn("tail", ["-F", "-n", "50", filePath]);
|
|
195
|
-
tail.stdout.on("data", (data) => {
|
|
196
|
-
const lines = data
|
|
197
|
-
.toString()
|
|
198
|
-
.split("\n")
|
|
199
|
-
.filter((line) => line.trim());
|
|
200
|
-
setLogFileOutputs((prev) => ({
|
|
201
|
-
...prev,
|
|
202
|
-
[serviceName]: [...(prev[serviceName] || []), ...lines],
|
|
203
|
-
}));
|
|
204
|
-
});
|
|
205
|
-
tail.stderr.on("data", (_data) => {
|
|
206
|
-
// Ignore tail errors (like file not found initially)
|
|
207
|
-
});
|
|
208
|
-
tailProcesses.push(tail);
|
|
209
|
-
};
|
|
210
|
-
// Scan for log files and start tailing them
|
|
211
|
-
const scanLogFiles = () => {
|
|
212
|
-
if (!existsSync(logsDir))
|
|
213
|
-
return;
|
|
214
|
-
const logFiles = readdirSync(logsDir).filter((f) => f.endsWith(".log"));
|
|
215
|
-
for (const file of logFiles) {
|
|
216
|
-
tailLogFile(file);
|
|
217
|
-
}
|
|
218
|
-
};
|
|
219
|
-
// Initial scan
|
|
220
|
-
scanLogFiles();
|
|
221
|
-
// Re-scan every 2 seconds to catch new log files
|
|
222
|
-
const scanInterval = setInterval(scanLogFiles, 2000);
|
|
223
|
-
return () => {
|
|
224
|
-
clearInterval(scanInterval);
|
|
225
|
-
for (const tail of tailProcesses) {
|
|
226
|
-
tail.kill();
|
|
227
|
-
}
|
|
228
|
-
};
|
|
229
|
-
}, [logsDir, isGuiMode]);
|
|
177
|
+
// GUI mode now uses LogsPane which handles log file tailing internally
|
|
230
178
|
const currentTab = allTabs[activeTab];
|
|
231
179
|
if (!currentTab) {
|
|
232
180
|
return null;
|
|
@@ -238,29 +186,9 @@ export function TabbedOutput({ processes, customTabs = [], logsDir, onExit, onPo
|
|
|
238
186
|
return newOutputs;
|
|
239
187
|
});
|
|
240
188
|
};
|
|
241
|
-
const handleClearMergedOutput = (serviceIndex) => {
|
|
242
|
-
setOutputs((prev) => {
|
|
243
|
-
const newOutputs = [...prev];
|
|
244
|
-
newOutputs[serviceIndex] = [];
|
|
245
|
-
return newOutputs;
|
|
246
|
-
});
|
|
247
|
-
};
|
|
248
189
|
if (isGuiMode) {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
output: outputs[idx] || [],
|
|
252
|
-
port: ports[idx],
|
|
253
|
-
status: statuses[idx] || "starting",
|
|
254
|
-
}));
|
|
255
|
-
// Add log file services (internal agent services like adapter, agent-runner, etc.)
|
|
256
|
-
const logFileServices = Object.entries(logFileOutputs).map(([service, output]) => ({
|
|
257
|
-
service,
|
|
258
|
-
output,
|
|
259
|
-
port: undefined,
|
|
260
|
-
status: "running",
|
|
261
|
-
}));
|
|
262
|
-
const allServices = [...serviceOutputs, ...logFileServices];
|
|
263
|
-
return (_jsx(Box, { flexDirection: "column", height: "100%", children: _jsx(MergedLogsPane, { services: allServices, onClear: handleClearMergedOutput }) }));
|
|
190
|
+
// Use LogsPane for GUI mode - reads from log files like TUI mode
|
|
191
|
+
return (_jsx(Box, { flexDirection: "column", height: "100%", children: logsDir ? (_jsx(LogsPane, { logsDir: logsDir })) : (_jsx(Text, { children: "No logs directory configured" })) }));
|
|
264
192
|
}
|
|
265
193
|
// TUI mode: has custom tabs - use tabbed interface
|
|
266
194
|
// Get status content from current tab if available
|
package/dist/index.js
CHANGED
|
@@ -12,8 +12,10 @@ import { match } from "ts-pattern";
|
|
|
12
12
|
import { configureCommand } from "./commands/configure.js";
|
|
13
13
|
import { createCommand } from "./commands/create.js";
|
|
14
14
|
import { createProjectCommand } from "./commands/create-project.js";
|
|
15
|
+
import { loginCommand } from "./commands/login.js";
|
|
15
16
|
import { runCommand } from "./commands/run.js";
|
|
16
17
|
import { upgradeCommand } from "./commands/upgrade.js";
|
|
18
|
+
import { whoamiCommand } from "./commands/whoami.js";
|
|
17
19
|
/**
|
|
18
20
|
* Securely prompt for a secret value without echoing to the terminal
|
|
19
21
|
*/
|
|
@@ -30,6 +32,10 @@ async function promptSecret(secretName) {
|
|
|
30
32
|
}
|
|
31
33
|
const parser = or(command("deploy", constant("deploy"), { brief: message `Deploy a Town.` }), command("configure", constant("configure"), {
|
|
32
34
|
brief: message `Configure environment variables.`,
|
|
35
|
+
}), command("login", constant("login"), {
|
|
36
|
+
brief: message `Log in to Town Shed.`,
|
|
37
|
+
}), command("whoami", constant("whoami"), {
|
|
38
|
+
brief: message `Show current login status.`,
|
|
33
39
|
}), command("upgrade", constant("upgrade"), {
|
|
34
40
|
brief: message `Upgrade dependencies by cleaning and reinstalling.`,
|
|
35
41
|
}), command("create", object({
|
|
@@ -77,6 +83,12 @@ async function main(parser, meta) {
|
|
|
77
83
|
.with("deploy", async () => { })
|
|
78
84
|
.with("configure", async () => {
|
|
79
85
|
await configureCommand();
|
|
86
|
+
})
|
|
87
|
+
.with("login", async () => {
|
|
88
|
+
await loginCommand();
|
|
89
|
+
})
|
|
90
|
+
.with("whoami", async () => {
|
|
91
|
+
await whoamiCommand();
|
|
80
92
|
})
|
|
81
93
|
.with("upgrade", async () => {
|
|
82
94
|
await upgradeCommand();
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type AuthCredentials } from "./auth-storage.js";
|
|
2
|
+
/**
|
|
3
|
+
* Get valid credentials, refreshing if necessary
|
|
4
|
+
* Throws if not logged in or refresh fails
|
|
5
|
+
*/
|
|
6
|
+
export declare function getValidCredentials(): Promise<AuthCredentials>;
|
|
7
|
+
/**
|
|
8
|
+
* Make an authenticated fetch request to shed
|
|
9
|
+
* Automatically refreshes token if expired
|
|
10
|
+
*/
|
|
11
|
+
export declare function authFetch(path: string, options?: RequestInit): Promise<Response>;
|
|
12
|
+
/**
|
|
13
|
+
* Convenience method for GET requests
|
|
14
|
+
*/
|
|
15
|
+
export declare function authGet(path: string): Promise<Response>;
|
|
16
|
+
/**
|
|
17
|
+
* Convenience method for POST requests with JSON body
|
|
18
|
+
*/
|
|
19
|
+
export declare function authPost(path: string, body: unknown): Promise<Response>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { isTokenExpired, loadAuthCredentials, saveAuthCredentials, } from "./auth-storage.js";
|
|
2
|
+
// ============================================================================
|
|
3
|
+
// Token Refresh
|
|
4
|
+
// ============================================================================
|
|
5
|
+
async function refreshAccessToken(credentials) {
|
|
6
|
+
const response = await fetch(`${credentials.shed_url}/api/cli/refresh`, {
|
|
7
|
+
method: "POST",
|
|
8
|
+
headers: {
|
|
9
|
+
"Content-Type": "application/json",
|
|
10
|
+
},
|
|
11
|
+
body: JSON.stringify({
|
|
12
|
+
refresh_token: credentials.refresh_token,
|
|
13
|
+
}),
|
|
14
|
+
});
|
|
15
|
+
if (!response.ok) {
|
|
16
|
+
const error = await response
|
|
17
|
+
.json()
|
|
18
|
+
.catch(() => ({ error: "Token refresh failed" }));
|
|
19
|
+
throw new Error(error.error || "Token refresh failed");
|
|
20
|
+
}
|
|
21
|
+
const data = (await response.json());
|
|
22
|
+
const newCredentials = {
|
|
23
|
+
...credentials,
|
|
24
|
+
access_token: data.access_token,
|
|
25
|
+
refresh_token: data.refresh_token,
|
|
26
|
+
expires_at: data.expires_at,
|
|
27
|
+
};
|
|
28
|
+
saveAuthCredentials(newCredentials);
|
|
29
|
+
return newCredentials;
|
|
30
|
+
}
|
|
31
|
+
// ============================================================================
|
|
32
|
+
// Public API
|
|
33
|
+
// ============================================================================
|
|
34
|
+
/**
|
|
35
|
+
* Get valid credentials, refreshing if necessary
|
|
36
|
+
* Throws if not logged in or refresh fails
|
|
37
|
+
*/
|
|
38
|
+
export async function getValidCredentials() {
|
|
39
|
+
const credentials = loadAuthCredentials();
|
|
40
|
+
if (!credentials) {
|
|
41
|
+
throw new Error("Not logged in. Run 'town login' first.");
|
|
42
|
+
}
|
|
43
|
+
if (isTokenExpired(credentials)) {
|
|
44
|
+
try {
|
|
45
|
+
return await refreshAccessToken(credentials);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
throw new Error(`Session expired. Please run 'town login' again. (${error instanceof Error ? error.message : String(error)})`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return credentials;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Make an authenticated fetch request to shed
|
|
55
|
+
* Automatically refreshes token if expired
|
|
56
|
+
*/
|
|
57
|
+
export async function authFetch(path, options = {}) {
|
|
58
|
+
let credentials = await getValidCredentials();
|
|
59
|
+
const url = path.startsWith("http") ? path : `${credentials.shed_url}${path}`;
|
|
60
|
+
const headers = new Headers(options.headers);
|
|
61
|
+
headers.set("Authorization", `Bearer ${credentials.access_token}`);
|
|
62
|
+
const response = await fetch(url, {
|
|
63
|
+
...options,
|
|
64
|
+
headers,
|
|
65
|
+
});
|
|
66
|
+
// If we get 401, try refreshing once
|
|
67
|
+
if (response.status === 401) {
|
|
68
|
+
try {
|
|
69
|
+
credentials = await refreshAccessToken(credentials);
|
|
70
|
+
headers.set("Authorization", `Bearer ${credentials.access_token}`);
|
|
71
|
+
return fetch(url, {
|
|
72
|
+
...options,
|
|
73
|
+
headers,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
throw new Error("Session expired. Please run 'town login' again.");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return response;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Convenience method for GET requests
|
|
84
|
+
*/
|
|
85
|
+
export async function authGet(path) {
|
|
86
|
+
return authFetch(path, { method: "GET" });
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Convenience method for POST requests with JSON body
|
|
90
|
+
*/
|
|
91
|
+
export async function authPost(path, body) {
|
|
92
|
+
return authFetch(path, {
|
|
93
|
+
method: "POST",
|
|
94
|
+
headers: {
|
|
95
|
+
"Content-Type": "application/json",
|
|
96
|
+
},
|
|
97
|
+
body: JSON.stringify(body),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface AuthCredentials {
|
|
2
|
+
access_token: string;
|
|
3
|
+
refresh_token: string;
|
|
4
|
+
expires_at: number;
|
|
5
|
+
user: {
|
|
6
|
+
id: string;
|
|
7
|
+
email: string;
|
|
8
|
+
};
|
|
9
|
+
shed_url: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Get the shed URL from environment or default
|
|
13
|
+
*/
|
|
14
|
+
export declare function getShedUrl(): string;
|
|
15
|
+
/**
|
|
16
|
+
* Save auth credentials to disk
|
|
17
|
+
*/
|
|
18
|
+
export declare function saveAuthCredentials(credentials: AuthCredentials): void;
|
|
19
|
+
/**
|
|
20
|
+
* Load auth credentials from disk
|
|
21
|
+
*/
|
|
22
|
+
export declare function loadAuthCredentials(): AuthCredentials | null;
|
|
23
|
+
/**
|
|
24
|
+
* Delete auth credentials (logout)
|
|
25
|
+
*/
|
|
26
|
+
export declare function clearAuthCredentials(): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Check if user is logged in (has valid credentials file)
|
|
29
|
+
*/
|
|
30
|
+
export declare function isLoggedIn(): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Check if access token is expired or about to expire (within 5 minutes)
|
|
33
|
+
*/
|
|
34
|
+
export declare function isTokenExpired(credentials: AuthCredentials): boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Get the auth file path (for display purposes)
|
|
37
|
+
*/
|
|
38
|
+
export declare function getAuthFilePath(): string;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
// ============================================================================
|
|
5
|
+
// Constants
|
|
6
|
+
// ============================================================================
|
|
7
|
+
const TOWN_CONFIG_DIR = join(homedir(), ".config", "town");
|
|
8
|
+
const AUTH_FILE = join(TOWN_CONFIG_DIR, "auth.json");
|
|
9
|
+
const DEFAULT_SHED_URL = "http://localhost:3000";
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// Helper Functions
|
|
12
|
+
// ============================================================================
|
|
13
|
+
function ensureConfigDir() {
|
|
14
|
+
if (!existsSync(TOWN_CONFIG_DIR)) {
|
|
15
|
+
mkdirSync(TOWN_CONFIG_DIR, { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// Public API
|
|
20
|
+
// ============================================================================
|
|
21
|
+
/**
|
|
22
|
+
* Get the shed URL from environment or default
|
|
23
|
+
*/
|
|
24
|
+
export function getShedUrl() {
|
|
25
|
+
return process.env.TOWN_SHED_URL || DEFAULT_SHED_URL;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Save auth credentials to disk
|
|
29
|
+
*/
|
|
30
|
+
export function saveAuthCredentials(credentials) {
|
|
31
|
+
ensureConfigDir();
|
|
32
|
+
try {
|
|
33
|
+
const content = JSON.stringify(credentials, null, 2);
|
|
34
|
+
writeFileSync(AUTH_FILE, content, { mode: 0o600 });
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
throw new Error(`Failed to save auth credentials: ${error instanceof Error ? error.message : String(error)}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Load auth credentials from disk
|
|
42
|
+
*/
|
|
43
|
+
export function loadAuthCredentials() {
|
|
44
|
+
if (!existsSync(AUTH_FILE)) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const content = readFileSync(AUTH_FILE, "utf-8");
|
|
49
|
+
return JSON.parse(content);
|
|
50
|
+
}
|
|
51
|
+
catch (_error) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Delete auth credentials (logout)
|
|
57
|
+
*/
|
|
58
|
+
export function clearAuthCredentials() {
|
|
59
|
+
if (!existsSync(AUTH_FILE)) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
unlinkSync(AUTH_FILE);
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
throw new Error(`Failed to clear auth credentials: ${error instanceof Error ? error.message : String(error)}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Check if user is logged in (has valid credentials file)
|
|
72
|
+
*/
|
|
73
|
+
export function isLoggedIn() {
|
|
74
|
+
return loadAuthCredentials() !== null;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Check if access token is expired or about to expire (within 5 minutes)
|
|
78
|
+
*/
|
|
79
|
+
export function isTokenExpired(credentials) {
|
|
80
|
+
const now = Math.floor(Date.now() / 1000);
|
|
81
|
+
const buffer = 5 * 60; // 5 minutes buffer
|
|
82
|
+
return credentials.expires_at <= now + buffer;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Get the auth file path (for display purposes)
|
|
86
|
+
*/
|
|
87
|
+
export function getAuthFilePath() {
|
|
88
|
+
return AUTH_FILE;
|
|
89
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@townco/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.54",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"town": "./dist/index.js"
|
|
@@ -15,18 +15,18 @@
|
|
|
15
15
|
"build": "tsc"
|
|
16
16
|
},
|
|
17
17
|
"devDependencies": {
|
|
18
|
-
"@townco/tsconfig": "0.1.
|
|
18
|
+
"@townco/tsconfig": "0.1.46",
|
|
19
19
|
"@types/bun": "^1.3.1",
|
|
20
20
|
"@types/react": "^19.2.2"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"@optique/core": "^0.6.2",
|
|
24
24
|
"@optique/run": "^0.6.2",
|
|
25
|
-
"@townco/agent": "0.1.
|
|
26
|
-
"@townco/debugger": "0.1.
|
|
27
|
-
"@townco/core": "0.0.
|
|
28
|
-
"@townco/secret": "0.1.
|
|
29
|
-
"@townco/ui": "0.1.
|
|
25
|
+
"@townco/agent": "0.1.54",
|
|
26
|
+
"@townco/debugger": "0.1.4",
|
|
27
|
+
"@townco/core": "0.0.27",
|
|
28
|
+
"@townco/secret": "0.1.49",
|
|
29
|
+
"@townco/ui": "0.1.49",
|
|
30
30
|
"@types/inquirer": "^9.0.9",
|
|
31
31
|
"ink": "^6.4.0",
|
|
32
32
|
"ink-text-input": "^6.0.0",
|