Skip to content

Commit 3757492

Browse files
committed
add private tools
1 parent 7399b24 commit 3757492

14 files changed

Lines changed: 710 additions & 7 deletions

File tree

docs/private-tools-plan.md

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ Add a `--private` flag to `enact publish` that sets tool visibility. Private too
88

99
### Phases
1010

11-
1. **Phase 1**: Basic private/public visibility
12-
2. **Phase 2**: Unlisted visibility + management command
11+
1. **Phase 1**: Basic private/public visibility**COMPLETE**
12+
2. **Phase 2**: Unlisted visibility + management command**COMPLETE** (merged with Phase 1)
1313
3. **Phase 3**: Individual collaborator access grants
1414
4. **Phase 4**: Organization-level tools and membership
1515

@@ -24,9 +24,28 @@ Add a `--private` flag to `enact publish` that sets tool visibility. Private too
2424

2525
---
2626

27-
## Phase 1: Basic Private Tools
27+
## Phase 1: Basic Private Tools ✅ IMPLEMENTED
2828

29-
### 1.1 Database Schema Changes
29+
### Implementation Summary
30+
31+
**Database Migration**: `packages/server/supabase/migrations/20251221000000_add_tool_visibility.sql`
32+
- Added `visibility` column with CHECK constraint
33+
- Updated RLS policies for tools and tool_versions
34+
35+
**Search Migration**: `packages/server/supabase/migrations/20251221000001_update_search_for_visibility.sql`
36+
- Updated `search_tools_hybrid` function to only return public tools
37+
38+
**CLI Changes**:
39+
- `enact publish --private` - Publish as private
40+
- `enact publish --unlisted` - Publish as unlisted
41+
- `enact visibility <tool> <visibility>` - Change tool visibility
42+
43+
**API Changes**:
44+
- Added `visibility` field to publish form data
45+
- Added `PATCH /tools/{name}/visibility` endpoint
46+
- Added `visibility` field to tool metadata responses
47+
48+
### Original 1.1 Database Schema Changes
3049

3150
Add visibility column to the `tools` table:
3251

packages/api/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export type {
6060
BundleInfo,
6161
SubmitAttestationOptions,
6262
AttestationResult,
63+
ToolVisibility,
6364
} from "./publish";
6465

6566
// =============================================================================

packages/api/src/publish.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,11 @@ export async function createBundle(toolDir: string): Promise<BundleInfo> {
102102
throw new Error(`createBundle not yet implemented for: ${toolDir}`);
103103
}
104104

105+
/**
106+
* Tool visibility levels
107+
*/
108+
export type ToolVisibility = "public" | "private" | "unlisted";
109+
105110
/**
106111
* Publish a tool to the registry (v2 - multipart upload)
107112
*
@@ -116,7 +121,8 @@ export async function createBundle(toolDir: string): Promise<BundleInfo> {
116121
* name: "alice/utils/greeter",
117122
* manifest: { enact: "2.0.0", name: "alice/utils/greeter", version: "1.2.0", ... },
118123
* bundle: bundle.data,
119-
* rawManifest: "---\nenact: 2.0.0\n...\n---\n# My Tool\n\nDescription..."
124+
* rawManifest: "---\nenact: 2.0.0\n...\n---\n# My Tool\n\nDescription...",
125+
* visibility: "private"
120126
* });
121127
* console.log(`Published: ${result.bundleHash}`);
122128
* ```
@@ -129,9 +135,11 @@ export async function publishTool(
129135
bundle: ArrayBuffer | Uint8Array;
130136
/** The raw enact.md file content (frontmatter + markdown documentation) */
131137
rawManifest?: string | undefined;
138+
/** Tool visibility: public, private, or unlisted */
139+
visibility?: ToolVisibility | undefined;
132140
}
133141
): Promise<PublishResult> {
134-
const { name, manifest, bundle, rawManifest } = options;
142+
const { name, manifest, bundle, rawManifest, visibility = "private" } = options;
135143

136144
// Create FormData for multipart upload
137145
const formData = new FormData();
@@ -151,6 +159,9 @@ export async function publishTool(
151159
formData.append("raw_manifest", rawManifest);
152160
}
153161

162+
// Add visibility
163+
formData.append("visibility", visibility);
164+
154165
// Make multipart request (v2 endpoint is POST /tools/{name})
155166
const response = await fetch(`${client.getBaseUrl()}/tools/${name}`, {
156167
method: "POST",

packages/api/src/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@ export interface VersionMetadata {
5151
| undefined;
5252
}
5353

54+
/**
55+
* Tool visibility levels
56+
*/
57+
export type ToolVisibility = "public" | "private" | "unlisted";
58+
5459
/**
5560
* Tool search result item
5661
*/
@@ -67,6 +72,8 @@ export interface ToolSearchResult {
6772
author: ApiAuthor;
6873
/** Total downloads */
6974
downloads: number;
75+
/** Tool visibility (only included for owner's own tools) */
76+
visibility?: ToolVisibility | undefined;
7077
/** Trust status */
7178
trust_status?:
7279
| {
@@ -93,6 +100,8 @@ export interface ToolMetadata {
93100
repository?: string | undefined;
94101
/** Homepage URL */
95102
homepage?: string | undefined;
103+
/** Tool visibility: public, private, or unlisted */
104+
visibility?: ToolVisibility | undefined;
96105
/** Creation timestamp */
97106
created_at: string;
98107
/** Last update timestamp */

packages/api/tests/publish.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,73 @@ describe("publish module", () => {
9696
expect(result.name).toBeTruthy();
9797
});
9898

99+
test("supports visibility option", async () => {
100+
const client = createApiClient({ authToken: "valid-token" });
101+
const bundle = new TextEncoder().encode("mock bundle content");
102+
103+
// Test private visibility (default)
104+
const privateResult = await publishTool(client, {
105+
name: "alice/utils/private-tool",
106+
manifest: {
107+
enact: "2.0.0",
108+
name: "alice/utils/private-tool",
109+
version: "1.0.0",
110+
description: "A private tool",
111+
},
112+
bundle,
113+
visibility: "private",
114+
});
115+
expect(privateResult.name).toBeTruthy();
116+
117+
// Test public visibility
118+
const publicResult = await publishTool(client, {
119+
name: "alice/utils/public-tool",
120+
manifest: {
121+
enact: "2.0.0",
122+
name: "alice/utils/public-tool",
123+
version: "1.0.0",
124+
description: "A public tool",
125+
},
126+
bundle,
127+
visibility: "public",
128+
});
129+
expect(publicResult.name).toBeTruthy();
130+
131+
// Test unlisted visibility
132+
const unlistedResult = await publishTool(client, {
133+
name: "alice/utils/unlisted-tool",
134+
manifest: {
135+
enact: "2.0.0",
136+
name: "alice/utils/unlisted-tool",
137+
version: "1.0.0",
138+
description: "An unlisted tool",
139+
},
140+
bundle,
141+
visibility: "unlisted",
142+
});
143+
expect(unlistedResult.name).toBeTruthy();
144+
});
145+
146+
test("defaults to private visibility when not specified", async () => {
147+
const client = createApiClient({ authToken: "valid-token" });
148+
const bundle = new TextEncoder().encode("mock bundle content");
149+
150+
// When visibility is not specified, it should default to private
151+
const result = await publishTool(client, {
152+
name: "alice/utils/default-visibility",
153+
manifest: {
154+
enact: "2.0.0",
155+
name: "alice/utils/default-visibility",
156+
version: "1.0.0",
157+
description: "Default visibility test",
158+
},
159+
bundle,
160+
// No visibility specified - should default to "private"
161+
});
162+
163+
expect(result.name).toBeTruthy();
164+
});
165+
99166
test("requires authentication", async () => {
100167
const client = createApiClient({ baseUrl: "http://localhost" });
101168
const bundle = new TextEncoder().encode("mock bundle content");

packages/cli/src/commands/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,6 @@ export { configureInspectCommand } from "./inspect";
3030
// API v2 migration commands
3131
export { configureYankCommand } from "./yank";
3232
export { configureUnyankCommand } from "./unyank";
33+
34+
// Private tools (Phase - visibility management)
35+
export { configureVisibilityCommand } from "./visibility";

packages/cli/src/commands/publish/index.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,15 @@ import { loadGitignore, shouldIgnore } from "../../utils/ignore";
3939
const AUTH_NAMESPACE = "enact:auth";
4040
const ACCESS_TOKEN_KEY = "access_token";
4141

42+
/** Tool visibility levels */
43+
export type ToolVisibility = "public" | "private" | "unlisted";
44+
4245
interface PublishOptions extends GlobalOptions {
4346
dryRun?: boolean;
4447
tag?: string;
4548
skipAuth?: boolean;
49+
public?: boolean;
50+
unlisted?: boolean;
4651
}
4752

4853
/**
@@ -203,10 +208,18 @@ async function publishHandler(
203208
header(`Publishing ${toolName}@${version}`);
204209
newline();
205210

211+
// Determine visibility (private by default for security)
212+
const visibility: ToolVisibility = options.public
213+
? "public"
214+
: options.unlisted
215+
? "unlisted"
216+
: "private";
217+
206218
// Show what we're publishing
207219
keyValue("Name", toolName);
208220
keyValue("Version", version);
209221
keyValue("Description", manifest.description);
222+
keyValue("Visibility", visibility);
210223
if (manifest.tags && manifest.tags.length > 0) {
211224
keyValue("Tags", manifest.tags.join(", "));
212225
}
@@ -290,6 +303,7 @@ async function publishHandler(
290303
info("Would publish to registry:");
291304
keyValue("Tool", toolName);
292305
keyValue("Version", version);
306+
keyValue("Visibility", visibility);
293307
keyValue("Source", toolDir);
294308

295309
// Show files that would be bundled
@@ -326,6 +340,7 @@ async function publishHandler(
326340
manifest: manifest as unknown as Record<string, unknown>,
327341
bundle,
328342
rawManifest: rawManifestContent,
343+
visibility,
329344
});
330345
});
331346

@@ -337,10 +352,15 @@ async function publishHandler(
337352

338353
// Success output
339354
newline();
340-
success(`Published ${result.name}@${result.version}`);
355+
success(`Published ${result.name}@${result.version} (${visibility})`);
341356
keyValue("Bundle Hash", result.bundleHash);
342357
keyValue("Published At", result.publishedAt.toISOString());
343358
newline();
359+
if (visibility === "private") {
360+
dim("This tool is private - only you can access it.");
361+
} else if (visibility === "unlisted") {
362+
dim("This tool is unlisted - accessible via direct link, not searchable.");
363+
}
344364
dim(`Install with: enact install ${toolName}`);
345365
}
346366

@@ -356,6 +376,8 @@ export function configurePublishCommand(program: Command): void {
356376
.option("-v, --verbose", "Show detailed output")
357377
.option("--skip-auth", "Skip authentication (for local development)")
358378
.option("--json", "Output as JSON")
379+
.option("--public", "Publish as public (searchable by everyone)")
380+
.option("--unlisted", "Publish as unlisted (accessible via direct link, not searchable)")
359381
.action(async (pathArg: string | undefined, options: PublishOptions) => {
360382
const resolvedPath = pathArg ?? ".";
361383
const ctx: CommandContext = {

0 commit comments

Comments
 (0)