@agentlip/workspace 0.1.0

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.
Files changed (3) hide show
  1. package/README.md +117 -0
  2. package/package.json +30 -0
  3. package/src/index.ts +191 -0
package/README.md ADDED
@@ -0,0 +1,117 @@
1
+ # @agentlip/workspace
2
+
3
+ Workspace discovery and initialization for Agentlip.
4
+
5
+ ## Overview
6
+
7
+ This package provides workspace discovery by walking upward from the current directory to find a `.agentlip/db.sqlite3` marker. If no workspace is found within security boundaries, it can initialize a new workspace.
8
+
9
+ ## Security Boundaries
10
+
11
+ The upward traversal stops at:
12
+
13
+ 1. **Filesystem boundary** - Detected by device ID change (prevents crossing mount points)
14
+ 2. **User home directory** - Never traverses above `$HOME`
15
+ 3. **Filesystem root** - Stops at `/` (or drive root on Windows)
16
+
17
+ This prevents:
18
+ - Accidentally discovering workspaces outside the intended scope
19
+ - Traversing into system directories
20
+ - Crossing network mounts or containerized filesystems
21
+
22
+ ## API
23
+
24
+ ### `discoverWorkspaceRoot(startPath?: string)`
25
+
26
+ Discover workspace root by walking upward from `startPath` (defaults to `cwd`).
27
+
28
+ **Returns:** `WorkspaceDiscoveryResult | null`
29
+
30
+ - Returns `null` if no workspace found within security boundaries
31
+ - Returns discovery result if `.agentlip/db.sqlite3` exists in current or parent directory
32
+
33
+ ```typescript
34
+ const result = await discoverWorkspaceRoot();
35
+ if (result) {
36
+ console.log(`Workspace root: ${result.root}`);
37
+ console.log(`Database: ${result.dbPath}`);
38
+ }
39
+ ```
40
+
41
+ ### `ensureWorkspaceInitialized(workspaceRoot: string)`
42
+
43
+ Ensure workspace is initialized at the given directory.
44
+
45
+ **Returns:** `WorkspaceInitResult`
46
+
47
+ Creates:
48
+ - `.agentlip/` directory with mode `0700` (owner rwx only)
49
+ - `db.sqlite3` file with mode `0600` (owner rw only)
50
+
51
+ Idempotent - safe to call multiple times.
52
+
53
+ ```typescript
54
+ const result = await ensureWorkspaceInitialized('/path/to/project');
55
+ console.log(`Created: ${result.created}`);
56
+ console.log(`DB Path: ${result.dbPath}`);
57
+ ```
58
+
59
+ ### `discoverOrInitWorkspace(startPath?: string)`
60
+
61
+ Combined discovery + initialization.
62
+
63
+ **Returns:** `WorkspaceDiscoveryResult` (never null)
64
+
65
+ Workflow:
66
+ 1. Attempts discovery from `startPath` (or cwd)
67
+ 2. If found, returns discovered workspace
68
+ 3. If not found, initializes workspace at `startPath` and returns it
69
+
70
+ ```typescript
71
+ const result = await discoverOrInitWorkspace();
72
+ if (result.discovered) {
73
+ console.log(`Found existing workspace at ${result.root}`);
74
+ } else {
75
+ console.log(`Initialized new workspace at ${result.root}`);
76
+ }
77
+ ```
78
+
79
+ ## Types
80
+
81
+ ```typescript
82
+ interface WorkspaceDiscoveryResult {
83
+ /** Absolute path to workspace root directory */
84
+ root: string;
85
+ /** Absolute path to db.sqlite3 file */
86
+ dbPath: string;
87
+ /** Whether workspace was discovered (true) or initialized (false) */
88
+ discovered: boolean;
89
+ }
90
+
91
+ interface WorkspaceInitResult {
92
+ /** Absolute path to workspace root directory */
93
+ root: string;
94
+ /** Absolute path to db.sqlite3 file */
95
+ dbPath: string;
96
+ /** Whether workspace was newly created (true) or already existed (false) */
97
+ created: boolean;
98
+ }
99
+ ```
100
+
101
+ ## Testing
102
+
103
+ ```bash
104
+ # Run unit tests
105
+ bun test
106
+
107
+ # Run verification script
108
+ bun verify.ts
109
+ ```
110
+
111
+ ## Implementation Notes
112
+
113
+ - Uses `lstat()` to detect filesystem boundaries via device ID
114
+ - Stops at user home directory using `os.homedir()`
115
+ - Creates files with restrictive permissions (Unix-like systems only)
116
+ - Handles symlinks safely by using `lstat()` instead of `stat()`
117
+ - Resolves all paths to absolute form to prevent confusion
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@agentlip/workspace",
3
+ "version": "0.1.0",
4
+ "description": "Workspace discovery and initialization for Agentlip",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "homepage": "https://github.com/phosphorco/agentlip",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/phosphorco/agentlip.git",
11
+ "directory": "packages/workspace"
12
+ },
13
+ "engines": {
14
+ "bun": ">=1.0.0"
15
+ },
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "files": [
20
+ "src/**/*.ts",
21
+ "!src/**/*.test.ts"
22
+ ],
23
+ "exports": {
24
+ ".": "./src/index.ts"
25
+ },
26
+ "scripts": {
27
+ "test": "bun test",
28
+ "verify": "bun verify.ts"
29
+ }
30
+ }
package/src/index.ts ADDED
@@ -0,0 +1,191 @@
1
+ /**
2
+ * @agentlip/workspace - Workspace discovery + initialization
3
+ *
4
+ * Provides upward workspace discovery with security boundaries:
5
+ * - Starts at cwd (or provided path)
6
+ * - Walks upward until .agentlip/db.sqlite3 exists
7
+ * - Stops at filesystem boundary OR user home directory
8
+ * - Initializes workspace at starting directory if not found
9
+ */
10
+
11
+ import { promises as fs } from 'node:fs';
12
+ import { join, dirname, resolve } from 'node:path';
13
+ import { homedir } from 'node:os';
14
+
15
+ const WORKSPACE_MARKER = '.agentlip';
16
+ const DB_FILENAME = 'db.sqlite3';
17
+
18
+ /**
19
+ * Result of workspace discovery
20
+ */
21
+ export interface WorkspaceDiscoveryResult {
22
+ /** Absolute path to workspace root directory */
23
+ root: string;
24
+ /** Absolute path to db.sqlite3 file */
25
+ dbPath: string;
26
+ /** Whether workspace was discovered (true) or needs initialization (false) */
27
+ discovered: boolean;
28
+ }
29
+
30
+ /**
31
+ * Result of workspace initialization
32
+ */
33
+ export interface WorkspaceInitResult {
34
+ /** Absolute path to workspace root directory */
35
+ root: string;
36
+ /** Absolute path to db.sqlite3 file */
37
+ dbPath: string;
38
+ /** Whether workspace was newly created (true) or already existed (false) */
39
+ created: boolean;
40
+ }
41
+
42
+ /**
43
+ * Discover workspace root by walking upward from startPath.
44
+ *
45
+ * Stops at:
46
+ * - Filesystem boundary (device ID change)
47
+ * - User home directory (never traverse above home)
48
+ *
49
+ * @param startPath - Directory to start search from (defaults to cwd)
50
+ * @returns Discovery result or null if no workspace found within boundary
51
+ */
52
+ export async function discoverWorkspaceRoot(
53
+ startPath?: string
54
+ ): Promise<WorkspaceDiscoveryResult | null> {
55
+ const start = resolve(startPath ?? process.cwd());
56
+ const home = resolve(homedir());
57
+
58
+ // Get initial filesystem device ID
59
+ const startStat = await fs.lstat(start);
60
+ const startDevice = startStat.dev;
61
+
62
+ let current = start;
63
+
64
+ while (true) {
65
+ // Check if .agentlip/db.sqlite3 exists at current level
66
+ const workspaceDir = join(current, WORKSPACE_MARKER);
67
+ const dbPath = join(workspaceDir, DB_FILENAME);
68
+
69
+ try {
70
+ await fs.access(dbPath);
71
+ // Found it!
72
+ return {
73
+ root: current,
74
+ dbPath,
75
+ discovered: true
76
+ };
77
+ } catch {
78
+ // Not found, continue upward
79
+ }
80
+
81
+ // Check boundary conditions before going up
82
+ const parent = dirname(current);
83
+
84
+ // Reached filesystem root (parent === current)
85
+ if (parent === current) {
86
+ return null;
87
+ }
88
+
89
+ // Stop traversal at user home directory (security boundary)
90
+ if (current === home) {
91
+ return null;
92
+ }
93
+
94
+ // Check filesystem boundary (device ID change)
95
+ try {
96
+ const parentStat = await fs.lstat(parent);
97
+ if (parentStat.dev !== startDevice) {
98
+ // Crossed filesystem boundary
99
+ return null;
100
+ }
101
+ } catch {
102
+ // Can't stat parent - stop here
103
+ return null;
104
+ }
105
+
106
+ current = parent;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Ensure workspace is initialized at workspaceRoot.
112
+ * Creates .agentlip/ directory and empty db.sqlite3 file if they don't exist.
113
+ *
114
+ * @param workspaceRoot - Directory to initialize workspace in
115
+ * @returns Init result indicating whether workspace was newly created
116
+ */
117
+ export async function ensureWorkspaceInitialized(
118
+ workspaceRoot: string
119
+ ): Promise<WorkspaceInitResult> {
120
+ const root = resolve(workspaceRoot);
121
+ const workspaceDir = join(root, WORKSPACE_MARKER);
122
+ const dbPath = join(workspaceDir, DB_FILENAME);
123
+
124
+ let created = false;
125
+
126
+ // Check if db already exists
127
+ try {
128
+ await fs.access(dbPath);
129
+ // Already initialized
130
+ return { root, dbPath, created: false };
131
+ } catch {
132
+ // Need to initialize
133
+ }
134
+
135
+ // Create .agentlip directory with mode 0700 (owner rwx only)
136
+ try {
137
+ await fs.mkdir(workspaceDir, { mode: 0o700, recursive: true });
138
+ } catch (err: any) {
139
+ // If directory already exists, that's fine
140
+ if (err.code !== 'EEXIST') {
141
+ throw err;
142
+ }
143
+ }
144
+
145
+ // Create empty db.sqlite3 with mode 0600 (owner rw only)
146
+ try {
147
+ const handle = await fs.open(dbPath, 'wx', 0o600); // x = fail if exists
148
+ await handle.close();
149
+ created = true;
150
+ } catch (err: any) {
151
+ if (err.code === 'EEXIST') {
152
+ // File was created between our check and now - that's fine
153
+ created = false;
154
+ } else {
155
+ throw err;
156
+ }
157
+ }
158
+
159
+ return { root, dbPath, created };
160
+ }
161
+
162
+ /**
163
+ * Discover workspace or initialize if not found.
164
+ *
165
+ * Combines discovery + initialization:
166
+ * - First tries to discover workspace by walking upward
167
+ * - If not found, initializes workspace at startPath
168
+ *
169
+ * @param startPath - Directory to start search from (defaults to cwd)
170
+ * @returns Discovery result (never null)
171
+ */
172
+ export async function discoverOrInitWorkspace(
173
+ startPath?: string
174
+ ): Promise<WorkspaceDiscoveryResult> {
175
+ const start = resolve(startPath ?? process.cwd());
176
+
177
+ // Try discovery first
178
+ const discovered = await discoverWorkspaceRoot(start);
179
+ if (discovered) {
180
+ return discovered;
181
+ }
182
+
183
+ // No workspace found - initialize at start path
184
+ const initialized = await ensureWorkspaceInitialized(start);
185
+
186
+ return {
187
+ root: initialized.root,
188
+ dbPath: initialized.dbPath,
189
+ discovered: false
190
+ };
191
+ }