@nahisaho/katashiro-workspace 0.4.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.
@@ -0,0 +1,311 @@
1
+ /**
2
+ * LocalWorkspace - ローカルファイルシステムワークスペース
3
+ *
4
+ * @requirement REQ-011-02
5
+ */
6
+
7
+ import * as fs from 'node:fs/promises';
8
+ import * as path from 'node:path';
9
+ import { glob } from 'fast-glob';
10
+ import type {
11
+ Workspace,
12
+ LocalWorkspaceConfig,
13
+ FileInfo,
14
+ DirectoryEntry,
15
+ } from './types.js';
16
+ import { WorkspaceError } from './types.js';
17
+
18
+ /**
19
+ * LocalWorkspace - ローカルファイルシステム操作
20
+ *
21
+ * @example
22
+ * ```typescript
23
+ * const workspace = new LocalWorkspace({
24
+ * type: 'local',
25
+ * workingDir: '/path/to/project',
26
+ * });
27
+ *
28
+ * const content = await workspace.read('README.md');
29
+ * await workspace.write('output.txt', 'Hello World');
30
+ * const files = await workspace.search('**\/*.ts');
31
+ * ```
32
+ */
33
+ export class LocalWorkspace implements Workspace {
34
+ readonly type = 'local' as const;
35
+ readonly workingDir: string;
36
+ readonly readOnly: boolean;
37
+
38
+ constructor(config: LocalWorkspaceConfig) {
39
+ this.workingDir = path.resolve(config.workingDir);
40
+ this.readOnly = config.readOnly ?? false;
41
+ }
42
+
43
+ /**
44
+ * 絶対パスを解決
45
+ */
46
+ private resolvePath(filePath: string): string {
47
+ if (path.isAbsolute(filePath)) {
48
+ return filePath;
49
+ }
50
+ return path.join(this.workingDir, filePath);
51
+ }
52
+
53
+ /**
54
+ * ワークスペース内のパスか検証
55
+ */
56
+ private validatePath(filePath: string): void {
57
+ const resolved = this.resolvePath(filePath);
58
+ const normalized = path.normalize(resolved);
59
+
60
+ // パストラバーサル防止
61
+ if (!normalized.startsWith(this.workingDir)) {
62
+ throw new WorkspaceError(
63
+ 'PERMISSION_DENIED',
64
+ `Path traversal detected: ${filePath}`,
65
+ filePath
66
+ );
67
+ }
68
+ }
69
+
70
+ /**
71
+ * 書き込み可能か検証
72
+ */
73
+ private validateWritable(): void {
74
+ if (this.readOnly) {
75
+ throw new WorkspaceError(
76
+ 'READ_ONLY',
77
+ 'Workspace is read-only'
78
+ );
79
+ }
80
+ }
81
+
82
+ async read(filePath: string, encoding: BufferEncoding = 'utf-8'): Promise<string> {
83
+ const resolved = this.resolvePath(filePath);
84
+ this.validatePath(filePath);
85
+
86
+ try {
87
+ return await fs.readFile(resolved, { encoding });
88
+ } catch (error) {
89
+ throw this.handleFsError(error, filePath);
90
+ }
91
+ }
92
+
93
+ async readBuffer(filePath: string): Promise<Buffer> {
94
+ const resolved = this.resolvePath(filePath);
95
+ this.validatePath(filePath);
96
+
97
+ try {
98
+ return await fs.readFile(resolved);
99
+ } catch (error) {
100
+ throw this.handleFsError(error, filePath);
101
+ }
102
+ }
103
+
104
+ async write(filePath: string, content: string): Promise<void> {
105
+ this.validateWritable();
106
+ const resolved = this.resolvePath(filePath);
107
+ this.validatePath(filePath);
108
+
109
+ try {
110
+ // ディレクトリを作成
111
+ await fs.mkdir(path.dirname(resolved), { recursive: true });
112
+ await fs.writeFile(resolved, content, 'utf-8');
113
+ } catch (error) {
114
+ throw this.handleFsError(error, filePath);
115
+ }
116
+ }
117
+
118
+ async writeBuffer(filePath: string, buffer: Buffer): Promise<void> {
119
+ this.validateWritable();
120
+ const resolved = this.resolvePath(filePath);
121
+ this.validatePath(filePath);
122
+
123
+ try {
124
+ await fs.mkdir(path.dirname(resolved), { recursive: true });
125
+ await fs.writeFile(resolved, buffer);
126
+ } catch (error) {
127
+ throw this.handleFsError(error, filePath);
128
+ }
129
+ }
130
+
131
+ async list(dirPath: string): Promise<FileInfo[]> {
132
+ const resolved = this.resolvePath(dirPath);
133
+ this.validatePath(dirPath);
134
+
135
+ try {
136
+ const entries = await fs.readdir(resolved, { withFileTypes: true });
137
+ const fileInfos: FileInfo[] = [];
138
+
139
+ for (const entry of entries) {
140
+ const entryPath = path.join(resolved, entry.name);
141
+ const stats = await fs.stat(entryPath);
142
+
143
+ fileInfos.push({
144
+ name: entry.name,
145
+ path: entryPath,
146
+ size: stats.size,
147
+ isDirectory: entry.isDirectory(),
148
+ modifiedAt: stats.mtime,
149
+ createdAt: stats.birthtime,
150
+ });
151
+ }
152
+
153
+ return fileInfos;
154
+ } catch (error) {
155
+ throw this.handleFsError(error, dirPath);
156
+ }
157
+ }
158
+
159
+ async listEntries(dirPath: string): Promise<DirectoryEntry[]> {
160
+ const resolved = this.resolvePath(dirPath);
161
+ this.validatePath(dirPath);
162
+
163
+ try {
164
+ const entries = await fs.readdir(resolved, { withFileTypes: true });
165
+ return entries.map(entry => ({
166
+ name: entry.name,
167
+ isDirectory: entry.isDirectory(),
168
+ }));
169
+ } catch (error) {
170
+ throw this.handleFsError(error, dirPath);
171
+ }
172
+ }
173
+
174
+ async search(pattern: string): Promise<string[]> {
175
+ try {
176
+ const files = await glob(pattern, {
177
+ cwd: this.workingDir,
178
+ absolute: false,
179
+ onlyFiles: true,
180
+ ignore: ['**/node_modules/**', '**/.git/**'],
181
+ });
182
+ return files;
183
+ } catch (error) {
184
+ throw new WorkspaceError(
185
+ 'OPERATION_FAILED',
186
+ `Search failed: ${error instanceof Error ? error.message : String(error)}`
187
+ );
188
+ }
189
+ }
190
+
191
+ async exists(filePath: string): Promise<boolean> {
192
+ const resolved = this.resolvePath(filePath);
193
+ this.validatePath(filePath);
194
+
195
+ try {
196
+ await fs.access(resolved);
197
+ return true;
198
+ } catch {
199
+ return false;
200
+ }
201
+ }
202
+
203
+ async delete(filePath: string): Promise<void> {
204
+ this.validateWritable();
205
+ const resolved = this.resolvePath(filePath);
206
+ this.validatePath(filePath);
207
+
208
+ try {
209
+ const stats = await fs.stat(resolved);
210
+ if (stats.isDirectory()) {
211
+ await fs.rm(resolved, { recursive: true });
212
+ } else {
213
+ await fs.unlink(resolved);
214
+ }
215
+ } catch (error) {
216
+ throw this.handleFsError(error, filePath);
217
+ }
218
+ }
219
+
220
+ async mkdir(dirPath: string, recursive = true): Promise<void> {
221
+ this.validateWritable();
222
+ const resolved = this.resolvePath(dirPath);
223
+ this.validatePath(dirPath);
224
+
225
+ try {
226
+ await fs.mkdir(resolved, { recursive });
227
+ } catch (error) {
228
+ throw this.handleFsError(error, dirPath);
229
+ }
230
+ }
231
+
232
+ async stat(filePath: string): Promise<FileInfo> {
233
+ const resolved = this.resolvePath(filePath);
234
+ this.validatePath(filePath);
235
+
236
+ try {
237
+ const stats = await fs.stat(resolved);
238
+ return {
239
+ name: path.basename(resolved),
240
+ path: resolved,
241
+ size: stats.size,
242
+ isDirectory: stats.isDirectory(),
243
+ modifiedAt: stats.mtime,
244
+ createdAt: stats.birthtime,
245
+ };
246
+ } catch (error) {
247
+ throw this.handleFsError(error, filePath);
248
+ }
249
+ }
250
+
251
+ async copy(src: string, dest: string): Promise<void> {
252
+ this.validateWritable();
253
+ const srcResolved = this.resolvePath(src);
254
+ const destResolved = this.resolvePath(dest);
255
+ this.validatePath(src);
256
+ this.validatePath(dest);
257
+
258
+ try {
259
+ await fs.mkdir(path.dirname(destResolved), { recursive: true });
260
+ await fs.cp(srcResolved, destResolved, { recursive: true });
261
+ } catch (error) {
262
+ throw this.handleFsError(error, src);
263
+ }
264
+ }
265
+
266
+ async move(src: string, dest: string): Promise<void> {
267
+ this.validateWritable();
268
+ const srcResolved = this.resolvePath(src);
269
+ const destResolved = this.resolvePath(dest);
270
+ this.validatePath(src);
271
+ this.validatePath(dest);
272
+
273
+ try {
274
+ await fs.mkdir(path.dirname(destResolved), { recursive: true });
275
+ await fs.rename(srcResolved, destResolved);
276
+ } catch (error) {
277
+ throw this.handleFsError(error, src);
278
+ }
279
+ }
280
+
281
+ /**
282
+ * FSエラーをWorkspaceErrorに変換
283
+ */
284
+ private handleFsError(error: unknown, filePath: string): WorkspaceError {
285
+ if (error instanceof WorkspaceError) {
286
+ return error;
287
+ }
288
+
289
+ const fsError = error as NodeJS.ErrnoException;
290
+ switch (fsError.code) {
291
+ case 'ENOENT':
292
+ return new WorkspaceError('NOT_FOUND', `Not found: ${filePath}`, filePath, fsError);
293
+ case 'EACCES':
294
+ case 'EPERM':
295
+ return new WorkspaceError('PERMISSION_DENIED', `Permission denied: ${filePath}`, filePath, fsError);
296
+ case 'EEXIST':
297
+ return new WorkspaceError('ALREADY_EXISTS', `Already exists: ${filePath}`, filePath, fsError);
298
+ case 'EISDIR':
299
+ return new WorkspaceError('IS_DIRECTORY', `Is a directory: ${filePath}`, filePath, fsError);
300
+ case 'ENOTDIR':
301
+ return new WorkspaceError('NOT_DIRECTORY', `Not a directory: ${filePath}`, filePath, fsError);
302
+ default:
303
+ return new WorkspaceError(
304
+ 'OPERATION_FAILED',
305
+ `Operation failed: ${fsError.message}`,
306
+ filePath,
307
+ fsError
308
+ );
309
+ }
310
+ }
311
+ }
package/src/types.ts ADDED
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Workspace 型定義
3
+ *
4
+ * @requirement REQ-011
5
+ * @design REQ-011-01〜REQ-011-06
6
+ */
7
+
8
+ // ============================================================================
9
+ // ワークスペースタイプ
10
+ // ============================================================================
11
+
12
+ /**
13
+ * ワークスペースの種類
14
+ */
15
+ export type WorkspaceType = 'local' | 'remote' | 'docker';
16
+
17
+ /**
18
+ * ファイル情報
19
+ */
20
+ export interface FileInfo {
21
+ /** ファイル名 */
22
+ name: string;
23
+ /** フルパス */
24
+ path: string;
25
+ /** ファイルサイズ(バイト) */
26
+ size: number;
27
+ /** ディレクトリかどうか */
28
+ isDirectory: boolean;
29
+ /** 最終更新日時 */
30
+ modifiedAt: Date;
31
+ /** 作成日時 */
32
+ createdAt: Date;
33
+ }
34
+
35
+ /**
36
+ * ディレクトリエントリ
37
+ */
38
+ export interface DirectoryEntry {
39
+ /** ファイル名 */
40
+ name: string;
41
+ /** ディレクトリかどうか */
42
+ isDirectory: boolean;
43
+ }
44
+
45
+ /**
46
+ * ワークスペース設定ベース
47
+ */
48
+ export interface WorkspaceConfigBase {
49
+ /** 作業ディレクトリ */
50
+ workingDir: string;
51
+ /** 読み取り専用モード */
52
+ readOnly?: boolean;
53
+ }
54
+
55
+ /**
56
+ * ローカルワークスペース設定
57
+ */
58
+ export interface LocalWorkspaceConfig extends WorkspaceConfigBase {
59
+ type: 'local';
60
+ }
61
+
62
+ /**
63
+ * リモートワークスペース設定
64
+ */
65
+ export interface RemoteWorkspaceConfig extends WorkspaceConfigBase {
66
+ type: 'remote';
67
+ /** APIベースURL */
68
+ apiUrl: string;
69
+ /** 認証トークン */
70
+ authToken?: string;
71
+ /** タイムアウト(ミリ秒) */
72
+ timeout?: number;
73
+ }
74
+
75
+ /**
76
+ * Dockerワークスペース設定
77
+ */
78
+ export interface DockerWorkspaceConfig extends WorkspaceConfigBase {
79
+ type: 'docker';
80
+ /** コンテナID */
81
+ containerId: string;
82
+ /** Docker ソケット */
83
+ socketPath?: string;
84
+ }
85
+
86
+ /**
87
+ * ワークスペース設定
88
+ */
89
+ export type WorkspaceConfig =
90
+ | LocalWorkspaceConfig
91
+ | RemoteWorkspaceConfig
92
+ | DockerWorkspaceConfig;
93
+
94
+ // ============================================================================
95
+ // ワークスペースインターフェース
96
+ // ============================================================================
97
+
98
+ /**
99
+ * ワークスペースインターフェース(REQ-011-01)
100
+ */
101
+ export interface Workspace {
102
+ /** ワークスペースタイプ */
103
+ readonly type: WorkspaceType;
104
+ /** 作業ディレクトリ */
105
+ readonly workingDir: string;
106
+ /** 読み取り専用かどうか */
107
+ readonly readOnly: boolean;
108
+
109
+ /**
110
+ * ファイルを読み取る
111
+ * @param path ファイルパス
112
+ * @param encoding エンコーディング(デフォルト: utf-8)
113
+ */
114
+ read(path: string, encoding?: BufferEncoding): Promise<string>;
115
+
116
+ /**
117
+ * ファイルをバイナリとして読み取る
118
+ * @param path ファイルパス
119
+ */
120
+ readBuffer(path: string): Promise<Buffer>;
121
+
122
+ /**
123
+ * ファイルに書き込む
124
+ * @param path ファイルパス
125
+ * @param content コンテンツ
126
+ */
127
+ write(path: string, content: string): Promise<void>;
128
+
129
+ /**
130
+ * バイナリを書き込む
131
+ * @param path ファイルパス
132
+ * @param buffer バッファ
133
+ */
134
+ writeBuffer(path: string, buffer: Buffer): Promise<void>;
135
+
136
+ /**
137
+ * ディレクトリの内容をリスト
138
+ * @param path ディレクトリパス
139
+ */
140
+ list(path: string): Promise<FileInfo[]>;
141
+
142
+ /**
143
+ * 簡易ディレクトリリスト(名前のみ)
144
+ * @param path ディレクトリパス
145
+ */
146
+ listEntries(path: string): Promise<DirectoryEntry[]>;
147
+
148
+ /**
149
+ * globパターンでファイルを検索(REQ-011-06)
150
+ * @param pattern globパターン
151
+ */
152
+ search(pattern: string): Promise<string[]>;
153
+
154
+ /**
155
+ * ファイル/ディレクトリの存在確認
156
+ * @param path パス
157
+ */
158
+ exists(path: string): Promise<boolean>;
159
+
160
+ /**
161
+ * ファイル/ディレクトリを削除
162
+ * @param path パス
163
+ */
164
+ delete(path: string): Promise<void>;
165
+
166
+ /**
167
+ * ディレクトリを作成
168
+ * @param path ディレクトリパス
169
+ * @param recursive 再帰的に作成するか
170
+ */
171
+ mkdir(path: string, recursive?: boolean): Promise<void>;
172
+
173
+ /**
174
+ * ファイル情報を取得
175
+ * @param path パス
176
+ */
177
+ stat(path: string): Promise<FileInfo>;
178
+
179
+ /**
180
+ * ファイル/ディレクトリをコピー
181
+ * @param src ソースパス
182
+ * @param dest 宛先パス
183
+ */
184
+ copy(src: string, dest: string): Promise<void>;
185
+
186
+ /**
187
+ * ファイル/ディレクトリを移動
188
+ * @param src ソースパス
189
+ * @param dest 宛先パス
190
+ */
191
+ move(src: string, dest: string): Promise<void>;
192
+
193
+ /**
194
+ * ワークスペースを初期化
195
+ */
196
+ initialize?(): Promise<void>;
197
+
198
+ /**
199
+ * ワークスペースをクリーンアップ
200
+ */
201
+ cleanup?(): Promise<void>;
202
+ }
203
+
204
+ // ============================================================================
205
+ // エラー
206
+ // ============================================================================
207
+
208
+ /**
209
+ * ワークスペースエラーコード
210
+ */
211
+ export type WorkspaceErrorCode =
212
+ | 'NOT_FOUND'
213
+ | 'PERMISSION_DENIED'
214
+ | 'ALREADY_EXISTS'
215
+ | 'IS_DIRECTORY'
216
+ | 'NOT_DIRECTORY'
217
+ | 'READ_ONLY'
218
+ | 'OPERATION_FAILED'
219
+ | 'CONNECTION_FAILED'
220
+ | 'TIMEOUT';
221
+
222
+ /**
223
+ * ワークスペースエラー
224
+ */
225
+ export class WorkspaceError extends Error {
226
+ constructor(
227
+ public readonly code: WorkspaceErrorCode,
228
+ message: string,
229
+ public readonly path?: string,
230
+ public readonly cause?: Error
231
+ ) {
232
+ super(message);
233
+ this.name = 'WorkspaceError';
234
+ }
235
+ }
236
+
237
+ // ============================================================================
238
+ // デフォルト設定
239
+ // ============================================================================
240
+
241
+ export const DEFAULT_REMOTE_TIMEOUT = 30000;
242
+ export const DEFAULT_DOCKER_SOCKET = '/var/run/docker.sock';
@@ -0,0 +1,145 @@
1
+ /**
2
+ * WorkspaceFactory - ワークスペースファクトリー
3
+ *
4
+ * @requirement REQ-011-05
5
+ */
6
+
7
+ import { LocalWorkspace } from './local-workspace.js';
8
+ import { DockerWorkspace } from './docker-workspace.js';
9
+ import type { Workspace, WorkspaceConfig } from './types.js';
10
+ import { WorkspaceError } from './types.js';
11
+
12
+ /**
13
+ * WorkspaceFactory - ワークスペースの生成ユーティリティ
14
+ *
15
+ * @example
16
+ * ```typescript
17
+ * // ローカルワークスペース
18
+ * const local = WorkspaceFactory.create({
19
+ * type: 'local',
20
+ * workingDir: '/path/to/project',
21
+ * });
22
+ *
23
+ * // Dockerワークスペース
24
+ * const docker = WorkspaceFactory.create({
25
+ * type: 'docker',
26
+ * containerId: 'container123',
27
+ * workingDir: '/app',
28
+ * });
29
+ *
30
+ * // 型に依存しない統一操作
31
+ * async function readConfig(workspace: Workspace): Promise<string> {
32
+ * return workspace.read('config.json');
33
+ * }
34
+ * ```
35
+ */
36
+ export class WorkspaceFactory {
37
+ /**
38
+ * ワークスペースを生成(REQ-011-05)
39
+ */
40
+ static create(config: WorkspaceConfig): Workspace {
41
+ switch (config.type) {
42
+ case 'local':
43
+ return new LocalWorkspace(config);
44
+
45
+ case 'docker':
46
+ return new DockerWorkspace(config);
47
+
48
+ case 'remote':
49
+ throw new WorkspaceError(
50
+ 'OPERATION_FAILED',
51
+ 'RemoteWorkspace is not yet implemented'
52
+ );
53
+
54
+ default:
55
+ throw new WorkspaceError(
56
+ 'OPERATION_FAILED',
57
+ `Unknown workspace type: ${(config as WorkspaceConfig).type}`
58
+ );
59
+ }
60
+ }
61
+
62
+ /**
63
+ * ローカルワークスペースを生成
64
+ */
65
+ static createLocal(workingDir: string, options?: { readOnly?: boolean }): LocalWorkspace {
66
+ return new LocalWorkspace({
67
+ type: 'local',
68
+ workingDir,
69
+ readOnly: options?.readOnly,
70
+ });
71
+ }
72
+
73
+ /**
74
+ * Dockerワークスペースを生成
75
+ */
76
+ static createDocker(
77
+ containerId: string,
78
+ workingDir: string,
79
+ options?: { readOnly?: boolean; socketPath?: string }
80
+ ): DockerWorkspace {
81
+ return new DockerWorkspace({
82
+ type: 'docker',
83
+ containerId,
84
+ workingDir,
85
+ readOnly: options?.readOnly,
86
+ socketPath: options?.socketPath,
87
+ });
88
+ }
89
+ }
90
+
91
+ // ============================================================================
92
+ // ユーティリティ関数
93
+ // ============================================================================
94
+
95
+ /**
96
+ * ワークスペースを作成して初期化
97
+ */
98
+ export async function createWorkspace(config: WorkspaceConfig): Promise<Workspace> {
99
+ const workspace = WorkspaceFactory.create(config);
100
+ if (workspace.initialize) {
101
+ await workspace.initialize();
102
+ }
103
+ return workspace;
104
+ }
105
+
106
+ /**
107
+ * ワークスペースでファイルを読み取る簡易関数
108
+ */
109
+ export async function readFile(
110
+ config: WorkspaceConfig,
111
+ filePath: string
112
+ ): Promise<string> {
113
+ const workspace = WorkspaceFactory.create(config);
114
+ if (workspace.initialize) {
115
+ await workspace.initialize();
116
+ }
117
+ try {
118
+ return await workspace.read(filePath);
119
+ } finally {
120
+ if (workspace.cleanup) {
121
+ await workspace.cleanup();
122
+ }
123
+ }
124
+ }
125
+
126
+ /**
127
+ * ワークスペースでファイルを書き込む簡易関数
128
+ */
129
+ export async function writeFile(
130
+ config: WorkspaceConfig,
131
+ filePath: string,
132
+ content: string
133
+ ): Promise<void> {
134
+ const workspace = WorkspaceFactory.create(config);
135
+ if (workspace.initialize) {
136
+ await workspace.initialize();
137
+ }
138
+ try {
139
+ await workspace.write(filePath, content);
140
+ } finally {
141
+ if (workspace.cleanup) {
142
+ await workspace.cleanup();
143
+ }
144
+ }
145
+ }