@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.
@@ -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
+ }
@@ -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
- agentProcess.kill();
262
- guiProcess.kill();
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 { spawn } from "node:child_process";
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 { MergedLogsPane } from "./MergedLogsPane.js";
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
- // Tail log files in GUI mode to capture internal service logs
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
- const serviceOutputs = processes.map((proc, idx) => ({
250
- service: proc.name,
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.53",
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.45",
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.53",
26
- "@townco/debugger": "0.1.3",
27
- "@townco/core": "0.0.26",
28
- "@townco/secret": "0.1.48",
29
- "@townco/ui": "0.1.48",
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",