@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.
- package/README.md +117 -0
- package/package.json +30 -0
- 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
|
+
}
|