Skip to content

Commit 998a737

Browse files
committed
feat: implement per-environment package management
- Added environment selection logic in RequireAuth to redirect users to the environment picker when accessing routes that require an environment context. - Created new route for managing packages within specific environments, allowing users to install, enable, disable, and uninstall packages. - Introduced useEnvironmentPackages hook for fetching and managing installed packages in an environment. - Updated ObjectStackClient to support package management operations per environment. - Implemented backend API endpoints for package installation, retrieval, enabling, disabling, and uninstallation. - Defined new schemas for environment package installations and requests in the spec package. - Removed legacy packages route and adjusted related components to align with the new per-environment package management model.
1 parent bd17c49 commit 998a737

15 files changed

Lines changed: 871 additions & 79 deletions

File tree

apps/studio/src/components/app-sidebar.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -576,8 +576,15 @@ export function AppSidebar({
576576
<SidebarMenuItem>
577577
<SidebarMenuButton
578578
tooltip="Packages"
579-
isActive={location.pathname === '/packages'}
580-
onClick={() => navigate({ to: '/packages' })}
579+
isActive={location.pathname.endsWith('/packages')}
580+
onClick={() => {
581+
const envId = params.environmentId as string | undefined;
582+
if (envId) {
583+
navigate({ to: '/environments/$environmentId/packages', params: { environmentId: envId } });
584+
} else {
585+
navigate({ to: '/environments' });
586+
}
587+
}}
581588
>
582589
<Package className="h-4 w-4" />
583590
<span>Packages</span>
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { useState, useEffect, useCallback } from 'react';
4+
import { useClient } from '@objectstack/client-react';
5+
6+
export interface EnvPackage {
7+
id: string;
8+
environmentId: string;
9+
packageId: string;
10+
version: string;
11+
status: string;
12+
enabled: boolean;
13+
installedAt: string;
14+
installedBy?: string;
15+
settings?: Record<string, unknown>;
16+
upgradeHistory?: Array<{ fromVersion: string; toVersion: string; upgradedAt: string; status: string }>;
17+
}
18+
19+
export function useEnvironmentPackages(environmentId: string | undefined) {
20+
const client = useClient() as any;
21+
const [packages, setPackages] = useState<EnvPackage[]>([]);
22+
const [loading, setLoading] = useState(false);
23+
const [error, setError] = useState<Error | null>(null);
24+
25+
const load = useCallback(async () => {
26+
if (!client || !environmentId) return;
27+
setLoading(true);
28+
setError(null);
29+
try {
30+
const result = await client.environments.packages.list(environmentId);
31+
// Normalize snake_case DB fields to camelCase interface
32+
const rows = (result?.packages ?? []).map((r: any) => ({
33+
id: r.id,
34+
environmentId: r.environment_id ?? r.environmentId,
35+
packageId: r.package_id ?? r.packageId,
36+
version: r.version,
37+
status: r.status,
38+
enabled: r.enabled,
39+
installedAt: r.installed_at ?? r.installedAt,
40+
installedBy: r.installed_by ?? r.installedBy,
41+
settings: r.settings,
42+
upgradeHistory: r.upgrade_history ?? r.upgradeHistory,
43+
}));
44+
setPackages(rows);
45+
} catch (e) {
46+
setError(e as Error);
47+
} finally {
48+
setLoading(false);
49+
}
50+
}, [client, environmentId]);
51+
52+
useEffect(() => {
53+
load();
54+
}, [load]);
55+
56+
const install = useCallback(async (body: {
57+
packageId: string;
58+
version?: string;
59+
settings?: Record<string, unknown>;
60+
enableOnInstall?: boolean;
61+
}) => {
62+
if (!client || !environmentId) return;
63+
const result = await client.environments.packages.install(environmentId, body);
64+
await load();
65+
return result?.package;
66+
}, [client, environmentId, load]);
67+
68+
const uninstall = useCallback(async (pkgId: string) => {
69+
if (!client || !environmentId) return;
70+
await client.environments.packages.uninstall(environmentId, pkgId);
71+
await load();
72+
}, [client, environmentId, load]);
73+
74+
const enable = useCallback(async (pkgId: string) => {
75+
if (!client || !environmentId) return;
76+
await client.environments.packages.enable(environmentId, pkgId);
77+
await load();
78+
}, [client, environmentId, load]);
79+
80+
const disable = useCallback(async (pkgId: string) => {
81+
if (!client || !environmentId) return;
82+
await client.environments.packages.disable(environmentId, pkgId);
83+
await load();
84+
}, [client, environmentId, load]);
85+
86+
return { packages, loading, error, install, uninstall, enable, disable, reload: load };
87+
}

apps/studio/src/routeTree.gen.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { Route as OrgsNewRouteImport } from './routes/orgs.new'
2222
import { Route as OrgsOrgIdRouteImport } from './routes/orgs.$orgId'
2323
import { Route as EnvironmentsEnvironmentIdRouteImport } from './routes/environments.$environmentId'
2424
import { Route as EnvironmentsEnvironmentIdIndexRouteImport } from './routes/environments.$environmentId.index'
25+
import { Route as EnvironmentsEnvironmentIdPackagesRouteImport } from './routes/environments.$environmentId.packages'
2526
import { Route as PackageObjectsNameRouteImport } from './routes/$package.objects.$name'
2627
import { Route as PackageMetadataTypeNameRouteImport } from './routes/$package.metadata.$type.$name'
2728

@@ -92,6 +93,12 @@ const EnvironmentsEnvironmentIdIndexRoute =
9293
path: '/',
9394
getParentRoute: () => EnvironmentsEnvironmentIdRoute,
9495
} as any)
96+
const EnvironmentsEnvironmentIdPackagesRoute =
97+
EnvironmentsEnvironmentIdPackagesRouteImport.update({
98+
id: '/packages',
99+
path: '/packages',
100+
getParentRoute: () => EnvironmentsEnvironmentIdRoute,
101+
} as any)
95102
const PackageObjectsNameRoute = PackageObjectsNameRouteImport.update({
96103
id: '/objects/$name',
97104
path: '/objects/$name',
@@ -117,6 +124,7 @@ export interface FileRoutesByFullPath {
117124
'/environments/': typeof EnvironmentsIndexRoute
118125
'/orgs/': typeof OrgsIndexRoute
119126
'/$package/objects/$name': typeof PackageObjectsNameRoute
127+
'/environments/$environmentId/packages': typeof EnvironmentsEnvironmentIdPackagesRoute
120128
'/environments/$environmentId/': typeof EnvironmentsEnvironmentIdIndexRoute
121129
'/$package/metadata/$type/$name': typeof PackageMetadataTypeNameRoute
122130
}
@@ -132,6 +140,7 @@ export interface FileRoutesByTo {
132140
'/environments': typeof EnvironmentsIndexRoute
133141
'/orgs': typeof OrgsIndexRoute
134142
'/$package/objects/$name': typeof PackageObjectsNameRoute
143+
'/environments/$environmentId/packages': typeof EnvironmentsEnvironmentIdPackagesRoute
135144
'/environments/$environmentId': typeof EnvironmentsEnvironmentIdIndexRoute
136145
'/$package/metadata/$type/$name': typeof PackageMetadataTypeNameRoute
137146
}
@@ -150,6 +159,7 @@ export interface FileRoutesById {
150159
'/environments/': typeof EnvironmentsIndexRoute
151160
'/orgs/': typeof OrgsIndexRoute
152161
'/$package/objects/$name': typeof PackageObjectsNameRoute
162+
'/environments/$environmentId/packages': typeof EnvironmentsEnvironmentIdPackagesRoute
153163
'/environments/$environmentId/': typeof EnvironmentsEnvironmentIdIndexRoute
154164
'/$package/metadata/$type/$name': typeof PackageMetadataTypeNameRoute
155165
}
@@ -169,6 +179,7 @@ export interface FileRouteTypes {
169179
| '/environments/'
170180
| '/orgs/'
171181
| '/$package/objects/$name'
182+
| '/environments/$environmentId/packages'
172183
| '/environments/$environmentId/'
173184
| '/$package/metadata/$type/$name'
174185
fileRoutesByTo: FileRoutesByTo
@@ -184,6 +195,7 @@ export interface FileRouteTypes {
184195
| '/environments'
185196
| '/orgs'
186197
| '/$package/objects/$name'
198+
| '/environments/$environmentId/packages'
187199
| '/environments/$environmentId'
188200
| '/$package/metadata/$type/$name'
189201
id:
@@ -201,6 +213,7 @@ export interface FileRouteTypes {
201213
| '/environments/'
202214
| '/orgs/'
203215
| '/$package/objects/$name'
216+
| '/environments/$environmentId/packages'
204217
| '/environments/$environmentId/'
205218
| '/$package/metadata/$type/$name'
206219
fileRoutesById: FileRoutesById
@@ -312,6 +325,13 @@ declare module '@tanstack/react-router' {
312325
preLoaderRoute: typeof EnvironmentsEnvironmentIdIndexRouteImport
313326
parentRoute: typeof EnvironmentsEnvironmentIdRoute
314327
}
328+
'/environments/$environmentId/packages': {
329+
id: '/environments/$environmentId/packages'
330+
path: '/packages'
331+
fullPath: '/environments/$environmentId/packages'
332+
preLoaderRoute: typeof EnvironmentsEnvironmentIdPackagesRouteImport
333+
parentRoute: typeof EnvironmentsEnvironmentIdRoute
334+
}
315335
'/$package/objects/$name': {
316336
id: '/$package/objects/$name'
317337
path: '/objects/$name'
@@ -345,11 +365,14 @@ const PackageRouteWithChildren =
345365
PackageRoute._addFileChildren(PackageRouteChildren)
346366

347367
interface EnvironmentsEnvironmentIdRouteChildren {
368+
EnvironmentsEnvironmentIdPackagesRoute: typeof EnvironmentsEnvironmentIdPackagesRoute
348369
EnvironmentsEnvironmentIdIndexRoute: typeof EnvironmentsEnvironmentIdIndexRoute
349370
}
350371

351372
const EnvironmentsEnvironmentIdRouteChildren: EnvironmentsEnvironmentIdRouteChildren =
352373
{
374+
EnvironmentsEnvironmentIdPackagesRoute:
375+
EnvironmentsEnvironmentIdPackagesRoute,
353376
EnvironmentsEnvironmentIdIndexRoute: EnvironmentsEnvironmentIdIndexRoute,
354377
}
355378

apps/studio/src/routes/__root.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,35 @@ import { SessionProvider, useSession } from '../hooks/useSession';
1616
/** Routes that don't require authentication. */
1717
const PUBLIC_ROUTES = new Set(['/login', '/register']);
1818

19+
/**
20+
* Routes where an environment selection is NOT required.
21+
* Everything under /environments (list + detail), org mgmt, auth pages.
22+
*/
23+
function isEnvExemptPath(pathname: string): boolean {
24+
return (
25+
pathname === '/' ||
26+
pathname.startsWith('/login') ||
27+
pathname.startsWith('/register') ||
28+
pathname.startsWith('/orgs') ||
29+
pathname.startsWith('/environments')
30+
);
31+
}
32+
1933
function RequireAuth({ children }: { children: React.ReactNode }) {
2034
const { user, loading } = useSession();
2135
const navigate = useNavigate();
2236
const location = useLocation();
2337
const isPublic = PUBLIC_ROUTES.has(location.pathname);
2438

39+
// Redirect to environment picker when the user hits a route that requires
40+
// an environment context (e.g. /$package/*) but isn't already under /environments.
41+
useEffect(() => {
42+
if (loading || !user) return;
43+
if (!isEnvExemptPath(location.pathname)) {
44+
navigate({ to: '/environments' });
45+
}
46+
}, [user, loading, location.pathname, navigate]);
47+
2548
useEffect(() => {
2649
if (loading) return;
2750
if (!user && !isPublic) {

apps/studio/src/routes/environments.$environmentId.index.tsx

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
RefreshCw,
2020
Trash,
2121
AlertTriangle,
22+
Package,
2223
} from 'lucide-react';
2324
import { AppSidebar } from '@/components/app-sidebar';
2425
import { SiteHeader } from '@/components/site-header';
@@ -114,12 +115,25 @@ function EnvironmentOverviewComponent() {
114115
{env.id}
115116
</p>
116117
</div>
117-
<Button
118-
variant="outline"
119-
onClick={() => navigate({ to: '/environments' })}
120-
>
121-
Back to list
122-
</Button>
118+
<div className="flex gap-2">
119+
<Button
120+
variant="outline"
121+
className="gap-2"
122+
onClick={() => navigate({
123+
to: '/environments/$environmentId/packages',
124+
params: { environmentId: env.id },
125+
})}
126+
>
127+
<Package className="h-4 w-4" />
128+
Packages
129+
</Button>
130+
<Button
131+
variant="outline"
132+
onClick={() => navigate({ to: '/environments' })}
133+
>
134+
Back to list
135+
</Button>
136+
</div>
123137
</div>
124138

125139
{env.envType === 'production' && (

0 commit comments

Comments
 (0)