release: alpha2.0 — session persistence, Tanuki stability, login flow
Auth & Session:
- Add credentials: include to all frontend fetch calls (queryClient,
auth-context, tanuki-trades, symbol-selector, settings, verify)
- Redis-backed session store with connect-redis, 7-day cookie expiry,
sameSite=lax, httpOnly=true
- Add app.set('trust proxy', 1) so Express honors X-Forwarded-Proto from
nginx — fixes secure cookies never being sent over HTTPS
- Cookie secure flag also checks APP_URL for reverse proxy deployments
Login Flow:
- Fix post-login redirect race condition: use full page reload
(window.location.href) instead of hash navigation so auth cookie is
established before ProtectedRoute checks session
- Disable SymbolSelector query when not authenticated to prevent blocking
fetches on login page
- SymbolSelector returns null when not authenticated — no UI rendered
Tanuki Trades:
- Add ErrorBoundary component to isolate Tanuki page crashes
- Use unique queryKey ['tanuki-tickers'] to prevent React Query cache
collision with symbol selector
- Defensive query syntax with async/await for error isolation
Testing:
- Verified login → redirect → refresh → session persists (no 401)
- Verified Tanuki page no longer crashes the entire app
- Verified /api/auth/me returns user data across requests with cookie
master
alpha2.0
parent
14d079bb35
commit
fd40f75897
|
|
@ -18,6 +18,7 @@ import ScreenerPage from "@/pages/screener";
|
||||||
import FuturesLevelsPage from "@/pages/futures-levels";
|
import FuturesLevelsPage from "@/pages/futures-levels";
|
||||||
import TanukiTradesPage from "@/pages/tanuki-trades";
|
import TanukiTradesPage from "@/pages/tanuki-trades";
|
||||||
import SettingsPage from "@/pages/settings";
|
import SettingsPage from "@/pages/settings";
|
||||||
|
import { ErrorBoundary } from "@/components/error-boundary";
|
||||||
import AccountPage from "@/pages/account";
|
import AccountPage from "@/pages/account";
|
||||||
import LoginPage from "@/pages/login";
|
import LoginPage from "@/pages/login";
|
||||||
import VerifyPage from "@/pages/verify";
|
import VerifyPage from "@/pages/verify";
|
||||||
|
|
@ -96,7 +97,11 @@ function AppShell() {
|
||||||
<Route path="/expiry" component={() => <ProtectedRoute component={ExpiryMatrixPage} />} />
|
<Route path="/expiry" component={() => <ProtectedRoute component={ExpiryMatrixPage} />} />
|
||||||
<Route path="/screener" component={() => <ProtectedRoute component={ScreenerPage} />} />
|
<Route path="/screener" component={() => <ProtectedRoute component={ScreenerPage} />} />
|
||||||
<Route path="/futures" component={() => <ProtectedRoute component={FuturesLevelsPage} />} />
|
<Route path="/futures" component={() => <ProtectedRoute component={FuturesLevelsPage} />} />
|
||||||
<Route path="/tanuki" component={() => <ProtectedRoute component={TanukiTradesPage} />} />
|
<Route path="/tanuki" component={() => <ProtectedRoute component={() => (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<TanukiTradesPage />
|
||||||
|
</ErrorBoundary>
|
||||||
|
)} />} />
|
||||||
<Route path="/settings" component={() => <ProtectedRoute component={SettingsPage} />} />
|
<Route path="/settings" component={() => <ProtectedRoute component={SettingsPage} />} />
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { Component, ErrorInfo, ReactNode } from "react";
|
||||||
|
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
console.error("[ErrorBoundary] Caught:", error, errorInfo.componentStack);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
if (this.props.fallback) {
|
||||||
|
return this.props.fallback;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Card className="m-4">
|
||||||
|
<CardContent className="flex flex-col items-center justify-center gap-4 p-8 text-center">
|
||||||
|
<AlertTriangle className="h-10 w-10 text-destructive" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">Something went wrong</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{this.state.error?.message || "An unexpected error occurred"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => this.setState({ hasError: false, error: null })}
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3 w-3 mr-1" />
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,7 +26,10 @@ export function SymbolSelector() {
|
||||||
const [showAdd, setShowAdd] = useState(false);
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
const isAuthenticated = !!user;
|
const isAuthenticated = !!user;
|
||||||
|
|
||||||
const { data } = useQuery<SymbolInfo[]>({ queryKey: ["/api/symbols"] });
|
const { data } = useQuery<SymbolInfo[]>({
|
||||||
|
queryKey: ["/api/symbols"],
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
});
|
||||||
const symbols = data ?? [];
|
const symbols = data ?? [];
|
||||||
const builtInSymbols = symbols.filter((s) => !s.custom);
|
const builtInSymbols = symbols.filter((s) => !s.custom);
|
||||||
const customSymbols = symbols.filter((s) => s.custom);
|
const customSymbols = symbols.filter((s) => s.custom);
|
||||||
|
|
@ -37,6 +40,7 @@ export function SymbolSelector() {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
|
credentials: "include",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json();
|
const err = await res.json();
|
||||||
|
|
@ -54,7 +58,7 @@ export function SymbolSelector() {
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: async (ticker: string) => {
|
mutationFn: async (ticker: string) => {
|
||||||
const res = await fetch(`/api/symbols/${ticker}`, { method: "DELETE" });
|
const res = await fetch(`/api/symbols/${ticker}`, { method: "DELETE", credentials: "include" });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json();
|
const err = await res.json();
|
||||||
throw new Error(err.error || "Failed to delete symbol");
|
throw new Error(err.error || "Failed to delete symbol");
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/auth/me")
|
fetch("/api/auth/me", { credentials: "include" })
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
if (r.ok) return r.json();
|
if (r.ok) return r.json();
|
||||||
throw new Error("not authenticated");
|
throw new Error("not authenticated");
|
||||||
|
|
@ -49,6 +49,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password }),
|
||||||
|
credentials: "include",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ error: "Authentication failed" }));
|
const err = await res.json().catch(() => ({ error: "Authentication failed" }));
|
||||||
|
|
@ -57,8 +58,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
const data: User = await res.json();
|
const data: User = await res.json();
|
||||||
setUser(data);
|
setUser(data);
|
||||||
// Use full page navigation to dashboard - bypasses hash router issues
|
// Full page reload to ensure auth state is established before ProtectedRoute runs
|
||||||
window.location.hash = "/";
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
window.location.href = "/#/";
|
||||||
};
|
};
|
||||||
|
|
||||||
const register = async (name: string, email: string, password: string) => {
|
const register = async (name: string, email: string, password: string) => {
|
||||||
|
|
@ -67,6 +69,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name, email, password }),
|
body: JSON.stringify({ name, email, password }),
|
||||||
|
credentials: "include",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ error: "Registration failed" }));
|
const err = await res.json().catch(() => ({ error: "Registration failed" }));
|
||||||
|
|
@ -79,7 +82,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
await fetch("/api/auth/logout", { method: "POST" });
|
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setLocation("/login");
|
setLocation("/login");
|
||||||
};
|
};
|
||||||
|
|
@ -90,6 +93,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
|
credentials: "include",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ error: "Update failed" }));
|
const err = await res.json().catch(() => ({ error: "Update failed" }));
|
||||||
|
|
@ -107,6 +111,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ currentPassword: current, newPassword: newPass }),
|
body: JSON.stringify({ currentPassword: current, newPassword: newPass }),
|
||||||
|
credentials: "include",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ error: "Password change failed" }));
|
const err = await res.json().catch(() => ({ error: "Password change failed" }));
|
||||||
|
|
@ -123,6 +128,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ email }),
|
body: JSON.stringify({ email }),
|
||||||
|
credentials: "include",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ error: "Request failed" }));
|
const err = await res.json().catch(() => ({ error: "Request failed" }));
|
||||||
|
|
@ -139,6 +145,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ email }),
|
body: JSON.stringify({ email }),
|
||||||
|
credentials: "include",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ error: "Failed to resend" }));
|
const err = await res.json().catch(() => ({ error: "Failed to resend" }));
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ export async function apiRequest(
|
||||||
method,
|
method,
|
||||||
headers: data ? { "Content-Type": "application/json" } : {},
|
headers: data ? { "Content-Type": "application/json" } : {},
|
||||||
body: data ? JSON.stringify(data) : undefined,
|
body: data ? JSON.stringify(data) : undefined,
|
||||||
|
credentials: "include",
|
||||||
});
|
});
|
||||||
|
|
||||||
await throwIfResNotOk(res);
|
await throwIfResNotOk(res);
|
||||||
|
|
@ -30,7 +31,9 @@ export const getQueryFn: <T>(options: {
|
||||||
}) => QueryFunction<T> =
|
}) => QueryFunction<T> =
|
||||||
({ on401: unauthorizedBehavior }) =>
|
({ on401: unauthorizedBehavior }) =>
|
||||||
async ({ queryKey }) => {
|
async ({ queryKey }) => {
|
||||||
const res = await fetch(`${API_BASE}${queryKey.join("/")}`);
|
const res = await fetch(`${API_BASE}${queryKey.join("/")}`, {
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
if (unauthorizedBehavior === "returnNull" && res.status === 401) {
|
if (unauthorizedBehavior === "returnNull" && res.status === 401) {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,10 @@ export default function SettingsPage() {
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: async (key: string) => {
|
mutationFn: async (key: string) => {
|
||||||
const res = await fetch("/api/orats/config", {
|
const res = await fetch("/api/orats/config", {
|
||||||
method: "POST",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ apiKey: key, baseUrl }),
|
body: JSON.stringify({ apiKey: key, baseUrl }),
|
||||||
|
credentials: "include",
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error("Failed to save API key");
|
if (!res.ok) throw new Error("Failed to save API key");
|
||||||
return res.json();
|
return res.json();
|
||||||
|
|
@ -40,7 +41,7 @@ export default function SettingsPage() {
|
||||||
|
|
||||||
const clearMutation = useMutation({
|
const clearMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const res = await fetch("/api/orats/config", { method: "DELETE" });
|
const res = await fetch("/api/orats/config", { method: "DELETE", credentials: "include" });
|
||||||
if (!res.ok) throw new Error("Failed to clear API key");
|
if (!res.ok) throw new Error("Failed to clear API key");
|
||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -44,8 +44,8 @@ export default function TanukiTradesPage() {
|
||||||
|
|
||||||
// Fetch all available symbols
|
// Fetch all available symbols
|
||||||
const symbolsQuery = useQuery<string[]>({
|
const symbolsQuery = useQuery<string[]>({
|
||||||
queryKey: ["/api/symbols"],
|
queryKey: ["/api/symbols/tickers"],
|
||||||
queryFn: () => fetch("/api/symbols").then(r => r.json()).then((data: any[]) =>
|
queryFn: () => fetch("/api/symbols", { credentials: "include" }).then(r => r.json()).then((data: any[]) =>
|
||||||
data.map((s: any) => s.ticker)
|
data.map((s: any) => s.ticker)
|
||||||
),
|
),
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
|
|
@ -64,18 +64,24 @@ export default function TanukiTradesPage() {
|
||||||
// Single symbol query — uses ORATS-derived levels (fallback when Tanuki not configured)
|
// Single symbol query — uses ORATS-derived levels (fallback when Tanuki not configured)
|
||||||
const tanukiQuery = useQuery<TanukiLevels>({
|
const tanukiQuery = useQuery<TanukiLevels>({
|
||||||
queryKey: ["/api/tanuki", selectedSymbol],
|
queryKey: ["/api/tanuki", selectedSymbol],
|
||||||
queryFn: () => fetch(`/api/tanuki/${selectedSymbol}`).then(r => {
|
queryFn: async () => {
|
||||||
if (!r.ok) throw new Error(r.statusText);
|
const r = await fetch(`/api/tanuki/${selectedSymbol}`, { credentials: "include" });
|
||||||
|
if (!r.ok) {
|
||||||
|
if (r.status === 404) {
|
||||||
|
return await r.json();
|
||||||
|
}
|
||||||
|
throw new Error(`Tanuki fetch failed: ${r.status}`);
|
||||||
|
}
|
||||||
return r.json();
|
return r.json();
|
||||||
}),
|
},
|
||||||
enabled: !compareMode,
|
enabled: !compareMode && !!selectedSymbol,
|
||||||
staleTime: 2 * 60 * 1000,
|
staleTime: 2 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Multi-symbol comparison query — uses all symbols
|
// Multi-symbol comparison query — uses all symbols
|
||||||
const compareQuery = useQuery<Record<string, TanukiLevels | { error: string }>>({
|
const compareQuery = useQuery<Record<string, TanukiLevels | { error: string }>>({
|
||||||
queryKey: ["/api/tanuki", "compare"],
|
queryKey: ["/api/tanuki", "compare"],
|
||||||
queryFn: () => fetch(`/api/tanuki?symbols=${allSymbols.join(",")}`).then(r => {
|
queryFn: () => fetch(`/api/tanuki?symbols=${allSymbols.join(",")}`, { credentials: "include" }).then(r => {
|
||||||
if (!r.ok) throw new Error(r.statusText);
|
if (!r.ok) throw new Error(r.statusText);
|
||||||
return r.json();
|
return r.json();
|
||||||
}),
|
}),
|
||||||
|
|
@ -86,14 +92,14 @@ export default function TanukiTradesPage() {
|
||||||
// Status query
|
// Status query
|
||||||
const statusQuery = useQuery({
|
const statusQuery = useQuery({
|
||||||
queryKey: ["/api/tanuki/status"],
|
queryKey: ["/api/tanuki/status"],
|
||||||
queryFn: () => fetch("/api/tanuki/status").then(r => r.json()),
|
queryFn: () => fetch("/api/tanuki/status", { credentials: "include" }).then(r => r.json()),
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const tanukiConfigured = statusQuery.data?.configured;
|
const tanukiConfigured = statusQuery.data?.configured;
|
||||||
const oratsStatusQuery = useQuery({
|
const oratsStatusQuery = useQuery({
|
||||||
queryKey: ["/api/orats/status"],
|
queryKey: ["/api/orats/status"],
|
||||||
queryFn: () => fetch("/api/orats/status").then(r => r.json()),
|
queryFn: () => fetch("/api/orats/status", { credentials: "include" }).then(r => r.json()),
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
});
|
});
|
||||||
const oratsConfigured = oratsStatusQuery.data?.configured;
|
const oratsConfigured = oratsStatusQuery.data?.configured;
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ export default function VerifyPage() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch(`/api/auth/verify/${token}`)
|
fetch(`/api/auth/verify/${token}`, { credentials: "include" })
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ declare module "http" {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trust reverse proxy (nginx) for protocol/headers so session cookies work over HTTPS
|
||||||
|
app.set("trust proxy", 1);
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
express.json({
|
express.json({
|
||||||
verify: (req, _res, buf) => {
|
verify: (req, _res, buf) => {
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,7 @@ export async function registerRoutes(httpServer: Server, app: Express): Promise<
|
||||||
resave: false,
|
resave: false,
|
||||||
saveUninitialized: false,
|
saveUninitialized: false,
|
||||||
cookie: {
|
cookie: {
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === "production" || (process.env.APP_URL || "").startsWith("https"),
|
||||||
httpOnly: true, // Prevent XSS from accessing session cookie
|
httpOnly: true, // Prevent XSS from accessing session cookie
|
||||||
sameSite: "lax", // Allow same-site + top-level navigation
|
sameSite: "lax", // Allow same-site + top-level navigation
|
||||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||||
|
|
@ -106,7 +106,7 @@ export async function registerRoutes(httpServer: Server, app: Express): Promise<
|
||||||
resave: false,
|
resave: false,
|
||||||
saveUninitialized: false,
|
saveUninitialized: false,
|
||||||
cookie: {
|
cookie: {
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === "production" || (process.env.APP_URL || "").startsWith("https"),
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
#!/bin/bash
|
||||||
|
cd /home/chuck/projects/gammadesk
|
||||||
|
NODE_ENV=development npx tsx server/index.ts "$@"
|
||||||
Loading…
Reference in New Issue