@goodandready/dsh-remote-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/lib/index.js ADDED
@@ -0,0 +1,116 @@
1
+ import z from '@deepseek-ai/schemastery';
2
+ import { SshService } from './ssh-service.js';
3
+ import { RemoteFsService } from './remote-fs-service.js';
4
+ import { MirrorSyncService } from './mirror-sync-service.js';
5
+ import { TunnelService } from './tunnel-service.js';
6
+ import { registerRemoteTools } from './tools.js';
7
+ import { registerApiRoutes } from './routes.js';
8
+
9
+ export const name = '@goodandready/dsh-remote-workspace';
10
+
11
+ const NS = 'dsh-remote-workspace';
12
+
13
+ export const inject = ['tools'];
14
+
15
+ export const Config = z.object({
16
+ profiles: z.array(z.object({
17
+ id: z.string(),
18
+ name: z.string(),
19
+ host: z.string(),
20
+ port: z.number().default(22),
21
+ username: z.string(),
22
+ authType: z.string().default('key'),
23
+ privateKeyPath: z.string().default(''),
24
+ privateKey: z.string().default(''),
25
+ passphrase: z.string().default(''),
26
+ password: z.string().default(''),
27
+ remoteWorkspace: z.string().default(''),
28
+ localMirrorPath: z.string().default('')
29
+ })).default([]).description('List of configured remote machines and workspaces.'),
30
+ activeProfileId: z.string().default('').description('ID of active remote profile.')
31
+ });
32
+
33
+ export function apply(ctx, config = {}) {
34
+ const cfg = config || {};
35
+ let settingsApi = null;
36
+ let currentConfig = {
37
+ profiles: Array.isArray(cfg.profiles) ? cfg.profiles : [],
38
+ activeProfileId: cfg.activeProfileId || null
39
+ };
40
+
41
+ const store = {
42
+ getProfiles() { return currentConfig.profiles || []; },
43
+ getProfile(id) { return (currentConfig.profiles || []).find((p) => p.id === id); },
44
+ getActiveId() { return currentConfig.activeProfileId; },
45
+ getActiveProfile() { return (currentConfig.profiles || []).find((p) => p.id === currentConfig.activeProfileId); },
46
+ async saveProfile(p) {
47
+ const list = [...(currentConfig.profiles || [])];
48
+ const idx = list.findIndex((x) => x.id === p.id);
49
+ if (idx >= 0) list[idx] = { ...list[idx], ...p };
50
+ else list.push(p);
51
+ currentConfig.profiles = list;
52
+ if (!currentConfig.activeProfileId && list.length === 1) {
53
+ currentConfig.activeProfileId = p.id;
54
+ }
55
+ if (settingsApi?.replace) {
56
+ await settingsApi.replace(Config(currentConfig));
57
+ }
58
+ },
59
+ async deleteProfile(id) {
60
+ currentConfig.profiles = (currentConfig.profiles || []).filter((p) => p.id !== id);
61
+ if (currentConfig.activeProfileId === id) {
62
+ currentConfig.activeProfileId = currentConfig.profiles[0]?.id || null;
63
+ }
64
+ if (settingsApi?.replace) {
65
+ await settingsApi.replace(Config(currentConfig));
66
+ }
67
+ },
68
+ async setActiveId(id) {
69
+ currentConfig.activeProfileId = id;
70
+ if (settingsApi?.replace) {
71
+ await settingsApi.replace(Config(currentConfig));
72
+ }
73
+ }
74
+ };
75
+
76
+ // Register settings namespace if settings service is available
77
+ ctx.inject(['settings'], (sctx) => {
78
+ const scope = sctx.settings.register(NS, Config, { base: cfg });
79
+ settingsApi = scope;
80
+ const snap = scope.get();
81
+ if (snap) {
82
+ if (Array.isArray(snap.profiles)) currentConfig.profiles = snap.profiles;
83
+ if (snap.activeProfileId !== undefined) currentConfig.activeProfileId = snap.activeProfileId;
84
+ }
85
+ sctx.effect(() => () => {
86
+ settingsApi = null;
87
+ });
88
+ });
89
+
90
+ const sshService = new SshService(ctx);
91
+ const remoteFs = new RemoteFsService(sshService);
92
+ const mirrorSync = new MirrorSyncService(remoteFs, sshService);
93
+ const tunnelService = new TunnelService(sshService);
94
+
95
+ ctx.provide('remoteSsh');
96
+ ctx.provide('remoteFs');
97
+ ctx.provide('remoteSync');
98
+ ctx.provide('remoteTunnel');
99
+
100
+ ctx.remoteSsh = sshService;
101
+ ctx.remoteFs = remoteFs;
102
+ ctx.remoteSync = mirrorSync;
103
+ ctx.remoteTunnel = tunnelService;
104
+
105
+ // Tools registration
106
+ registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunnelService, () => store.getActiveProfile());
107
+
108
+ // API Routes
109
+ registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelService, store);
110
+
111
+ // Cleanup on unload
112
+ ctx.on('dispose', () => {
113
+ tunnelService.stopAll();
114
+ sshService.disconnectAll();
115
+ });
116
+ }
@@ -0,0 +1,180 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+
5
+ export class MirrorSyncService {
6
+ /**
7
+ * @param {import('./remote-fs-service.js').RemoteFsService} remoteFs
8
+ * @param {import('./ssh-service.js').SshService} sshService
9
+ */
10
+ constructor(remoteFs, sshService) {
11
+ this.remoteFs = remoteFs;
12
+ this.sshService = sshService;
13
+ this.defaultIgnore = [
14
+ '.git',
15
+ 'node_modules',
16
+ '.worktrees',
17
+ 'dist',
18
+ 'build',
19
+ '.next',
20
+ '.cache',
21
+ '*.pyc',
22
+ '__pycache__'
23
+ ];
24
+ }
25
+
26
+ computeHash(content) {
27
+ return crypto.createHash('sha256').update(content).digest('hex');
28
+ }
29
+
30
+ isIgnored(relPath) {
31
+ const parts = relPath.split(/[\\/]/);
32
+ for (const pattern of this.defaultIgnore) {
33
+ if (pattern.startsWith('*.')) {
34
+ const ext = pattern.slice(1);
35
+ if (relPath.endsWith(ext)) return true;
36
+ } else {
37
+ if (parts.includes(pattern)) return true;
38
+ }
39
+ }
40
+ return false;
41
+ }
42
+
43
+ /**
44
+ * 3-way conflict aware pull: Remote -> Local Mirror
45
+ */
46
+ async pull(profile, remoteDir, localDir, dryRun = false) {
47
+ fs.mkdirSync(localDir, { recursive: true });
48
+ const snapshotFile = path.join(localDir, '.dsh-sync-snapshot.json');
49
+ let lastSnapshot = {};
50
+ if (fs.existsSync(snapshotFile)) {
51
+ try {
52
+ lastSnapshot = JSON.parse(fs.readFileSync(snapshotFile, 'utf8'));
53
+ } catch (_) {}
54
+ }
55
+
56
+ const conflicts = [];
57
+ const updated = [];
58
+ const newSnapshot = { ...lastSnapshot };
59
+
60
+ // Find remote files using find over ssh for speed
61
+ const findCmd = `find . -maxdepth 5 -type f ! -path '*/.*' ! -path '*/node_modules/*' -printf '%P\\t%s\\t%T@\\n'`;
62
+ const execRes = await this.sshService.exec(profile, findCmd, remoteDir);
63
+ if (execRes.code !== 0) {
64
+ throw new Error(`Failed to scan remote dir: ${execRes.stderr}`);
65
+ }
66
+
67
+ const lines = execRes.stdout.split('\n').filter(Boolean);
68
+ for (const line of lines) {
69
+ const [relPath] = line.split('\t');
70
+ if (!relPath || this.isIgnored(relPath)) continue;
71
+
72
+ const remoteFullPath = path.posix.join(remoteDir, relPath);
73
+ const localFullPath = path.join(localDir, relPath);
74
+
75
+ const remoteContent = await this.remoteFs.readFile(profile, remoteFullPath, 'utf8');
76
+ const remoteHash = this.computeHash(remoteContent);
77
+ const baseHash = lastSnapshot[relPath];
78
+
79
+ let localContent = null;
80
+ let localHash = null;
81
+ if (fs.existsSync(localFullPath)) {
82
+ localContent = fs.readFileSync(localFullPath, 'utf8');
83
+ localHash = this.computeHash(localContent);
84
+ }
85
+
86
+ // Check conflict: both changed compared to last baseline
87
+ if (baseHash && localHash && remoteHash !== baseHash && localHash !== baseHash && remoteHash !== localHash) {
88
+ conflicts.push({ path: relPath, reason: 'Modified on both remote and local' });
89
+ continue;
90
+ }
91
+
92
+ if (localHash !== remoteHash) {
93
+ updated.push(relPath);
94
+ if (!dryRun) {
95
+ fs.mkdirSync(path.dirname(localFullPath), { recursive: true });
96
+ fs.writeFileSync(localFullPath, remoteContent, 'utf8');
97
+ newSnapshot[relPath] = remoteHash;
98
+ }
99
+ } else {
100
+ newSnapshot[relPath] = remoteHash;
101
+ }
102
+ }
103
+
104
+ if (!dryRun) {
105
+ fs.writeFileSync(snapshotFile, JSON.stringify(newSnapshot, null, 2), 'utf8');
106
+ }
107
+
108
+ return {
109
+ success: conflicts.length === 0,
110
+ pulled: updated,
111
+ conflicts,
112
+ dryRun
113
+ };
114
+ }
115
+
116
+ /**
117
+ * 3-way conflict aware push: Local Mirror -> Remote
118
+ */
119
+ async push(profile, localDir, remoteDir, force = false, dryRun = false) {
120
+ const snapshotFile = path.join(localDir, '.dsh-sync-snapshot.json');
121
+ let snapshot = {};
122
+ if (fs.existsSync(snapshotFile)) {
123
+ try {
124
+ snapshot = JSON.parse(fs.readFileSync(snapshotFile, 'utf8'));
125
+ } catch (_) {}
126
+ }
127
+
128
+ const pushed = [];
129
+ const conflicts = [];
130
+
131
+ const walkLocal = (dir, rootDir) => {
132
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
133
+ let files = [];
134
+ for (const entry of entries) {
135
+ const full = path.join(dir, entry.name);
136
+ const rel = path.relative(rootDir, full);
137
+ if (this.isIgnored(rel)) continue;
138
+ if (entry.isDirectory()) {
139
+ files = files.concat(walkLocal(full, rootDir));
140
+ } else if (entry.isFile()) {
141
+ files.push(rel);
142
+ }
143
+ }
144
+ return files;
145
+ };
146
+
147
+ const localFiles = walkLocal(localDir, localDir);
148
+ for (const rel of localFiles) {
149
+ const localFull = path.join(localDir, rel);
150
+ const remoteFull = path.posix.join(remoteDir, rel.replace(/\\/g, '/'));
151
+ const content = fs.readFileSync(localFull, 'utf8');
152
+ const localHash = this.computeHash(content);
153
+ const baseHash = snapshot[rel];
154
+
155
+ if (baseHash && baseHash === localHash && !force) {
156
+ continue; // Unchanged locally
157
+ }
158
+
159
+ if (!dryRun) {
160
+ // Ensure remote parent dir exists
161
+ const parent = path.posix.dirname(remoteFull);
162
+ await this.remoteFs.mkdir(profile, parent);
163
+ await this.remoteFs.writeFile(profile, remoteFull, content, 'utf8');
164
+ snapshot[rel] = localHash;
165
+ }
166
+ pushed.push(rel);
167
+ }
168
+
169
+ if (!dryRun) {
170
+ fs.writeFileSync(snapshotFile, JSON.stringify(snapshot, null, 2), 'utf8');
171
+ }
172
+
173
+ return {
174
+ success: true,
175
+ pushed,
176
+ conflicts,
177
+ dryRun
178
+ };
179
+ }
180
+ }
@@ -0,0 +1,117 @@
1
+ import path from 'node:path';
2
+
3
+ export class RemoteFsService {
4
+ /**
5
+ * @param {import('./ssh-service.js').SshService} sshService
6
+ */
7
+ constructor(sshService) {
8
+ this.sshService = sshService;
9
+ }
10
+
11
+ /**
12
+ * Read directory entries
13
+ */
14
+ async listDir(profile, remotePath) {
15
+ const sftp = await this.sshService.getSftp(profile);
16
+ return new Promise((resolve, reject) => {
17
+ sftp.readdir(remotePath, (err, list) => {
18
+ if (err) return reject(err);
19
+ const entries = list.map((item) => ({
20
+ filename: item.filename,
21
+ isDirectory: (item.attrs.mode & 0o40000) !== 0,
22
+ isFile: (item.attrs.mode & 0o100000) !== 0,
23
+ size: item.attrs.size,
24
+ mtime: item.attrs.mtime,
25
+ permissions: (item.attrs.mode & 0o777).toString(8)
26
+ }));
27
+ resolve(entries);
28
+ });
29
+ });
30
+ }
31
+
32
+ /**
33
+ * Stat remote path
34
+ */
35
+ async stat(profile, remotePath) {
36
+ const sftp = await this.sshService.getSftp(profile);
37
+ return new Promise((resolve, reject) => {
38
+ sftp.stat(remotePath, (err, stats) => {
39
+ if (err) return reject(err);
40
+ resolve({
41
+ isDirectory: (stats.mode & 0o40000) !== 0,
42
+ isFile: (stats.mode & 0o100000) !== 0,
43
+ size: stats.size,
44
+ mtime: stats.mtime
45
+ });
46
+ });
47
+ });
48
+ }
49
+
50
+ /**
51
+ * Read file content with buffer/string conversion
52
+ */
53
+ async readFile(profile, remotePath, encoding = 'utf8') {
54
+ const sftp = await this.sshService.getSftp(profile);
55
+ return new Promise((resolve, reject) => {
56
+ const chunks = [];
57
+ const stream = sftp.createReadStream(remotePath);
58
+ stream.on('data', (chunk) => chunks.push(chunk));
59
+ stream.on('end', () => {
60
+ const buf = Buffer.concat(chunks);
61
+ resolve(encoding ? buf.toString(encoding) : buf);
62
+ });
63
+ stream.on('error', (err) => reject(err));
64
+ });
65
+ }
66
+
67
+ /**
68
+ * Atomic file write using temp file and rename
69
+ */
70
+ async writeFile(profile, remotePath, content, encoding = 'utf8') {
71
+ const sftp = await this.sshService.getSftp(profile);
72
+ const tempPath = `${remotePath}.dsh-tmp.${Date.now()}`;
73
+ const data = Buffer.isBuffer(content) ? content : Buffer.from(content, encoding);
74
+
75
+ await new Promise((resolve, reject) => {
76
+ const stream = sftp.createWriteStream(tempPath);
77
+ stream.on('finish', () => resolve());
78
+ stream.on('error', (err) => reject(err));
79
+ stream.end(data);
80
+ });
81
+
82
+ return new Promise((resolve, reject) => {
83
+ sftp.rename(tempPath, remotePath, (err) => {
84
+ if (err) {
85
+ // If rename fails, try unlink temp
86
+ sftp.unlink(tempPath, () => {});
87
+ return reject(err);
88
+ }
89
+ resolve({ success: true, bytes: data.length });
90
+ });
91
+ });
92
+ }
93
+
94
+ /**
95
+ * Create remote directory recursively
96
+ */
97
+ async mkdir(profile, remotePath) {
98
+ const cmd = `mkdir -p ${JSON.stringify(remotePath)}`;
99
+ const res = await this.sshService.exec(profile, cmd);
100
+ if (res.code !== 0) {
101
+ throw new Error(`Failed to create remote dir ${remotePath}: ${res.stderr}`);
102
+ }
103
+ return { success: true };
104
+ }
105
+
106
+ /**
107
+ * Remove remote file or directory
108
+ */
109
+ async remove(profile, remotePath, recursive = false) {
110
+ const cmd = recursive ? `rm -rf ${JSON.stringify(remotePath)}` : `rm -f ${JSON.stringify(remotePath)}`;
111
+ const res = await this.sshService.exec(profile, cmd);
112
+ if (res.code !== 0) {
113
+ throw new Error(`Failed to remove remote path ${remotePath}: ${res.stderr}`);
114
+ }
115
+ return { success: true };
116
+ }
117
+ }
package/lib/routes.js ADDED
@@ -0,0 +1,164 @@
1
+ function writeJson(res, code, data) {
2
+ try {
3
+ res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
4
+ res.end(JSON.stringify(data));
5
+ } catch {}
6
+ }
7
+
8
+ function readBody(req, maxBytes = 256 * 1024) {
9
+ return new Promise((resolve, reject) => {
10
+ const chunks = [];
11
+ let size = 0;
12
+ req.on('data', (c) => {
13
+ size += c.length;
14
+ if (size > maxBytes) {
15
+ reject(new Error('Payload too large'));
16
+ req.destroy();
17
+ return;
18
+ }
19
+ chunks.push(c);
20
+ });
21
+ req.on('end', () => {
22
+ try {
23
+ const raw = Buffer.concat(chunks).toString('utf8');
24
+ resolve(raw ? JSON.parse(raw) : {});
25
+ } catch (err) {
26
+ reject(new Error('Invalid JSON'));
27
+ }
28
+ });
29
+ req.on('error', reject);
30
+ });
31
+ }
32
+
33
+ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelService, store) {
34
+ ctx.inject(['webServer'], (sctx) => {
35
+ // 1. GET /dsh-remote-workspace/state
36
+ sctx.effect(() => sctx.webServer.register({
37
+ kind: 'exact',
38
+ path: '/dsh-remote-workspace/state',
39
+ handler: async (req, res) => {
40
+ if (req.method !== 'GET') return writeJson(res, 405, { error: 'GET only' });
41
+ const profiles = store.getProfiles();
42
+ const activeId = store.getActiveId();
43
+ const tunnels = tunnelService.listActiveTunnels();
44
+ writeJson(res, 200, { ok: true, profiles, activeId, tunnels });
45
+ }
46
+ }), 'dsh-remote-workspace: /state');
47
+
48
+ // 2. POST /dsh-remote-workspace/profiles/save
49
+ sctx.effect(() => sctx.webServer.register({
50
+ kind: 'exact',
51
+ path: '/dsh-remote-workspace/profiles/save',
52
+ handler: async (req, res) => {
53
+ if (req.method !== 'POST') return writeJson(res, 405, { error: 'POST only' });
54
+ try {
55
+ const profile = await readBody(req);
56
+ if (!profile.id || !profile.host) {
57
+ return writeJson(res, 400, { ok: false, error: 'ID and Host are required' });
58
+ }
59
+ await store.saveProfile(profile);
60
+ writeJson(res, 200, { ok: true, profile });
61
+ } catch (err) {
62
+ writeJson(res, 500, { ok: false, error: err.message });
63
+ }
64
+ }
65
+ }), 'dsh-remote-workspace: /profiles/save');
66
+
67
+ // 3. POST /dsh-remote-workspace/profiles/delete
68
+ sctx.effect(() => sctx.webServer.register({
69
+ kind: 'exact',
70
+ path: '/dsh-remote-workspace/profiles/delete',
71
+ handler: async (req, res) => {
72
+ if (req.method !== 'POST') return writeJson(res, 405, { error: 'POST only' });
73
+ try {
74
+ const { id } = await readBody(req);
75
+ if (!id) return writeJson(res, 400, { ok: false, error: 'ID is required' });
76
+ await store.deleteProfile(id);
77
+ writeJson(res, 200, { ok: true });
78
+ } catch (err) {
79
+ writeJson(res, 500, { ok: false, error: err.message });
80
+ }
81
+ }
82
+ }), 'dsh-remote-workspace: /profiles/delete');
83
+
84
+ // 4. POST /dsh-remote-workspace/profiles/active
85
+ sctx.effect(() => sctx.webServer.register({
86
+ kind: 'exact',
87
+ path: '/dsh-remote-workspace/profiles/active',
88
+ handler: async (req, res) => {
89
+ if (req.method !== 'POST') return writeJson(res, 405, { error: 'POST only' });
90
+ try {
91
+ const { id } = await readBody(req);
92
+ await store.setActiveId(id || null);
93
+ writeJson(res, 200, { ok: true, activeId: store.getActiveId() });
94
+ } catch (err) {
95
+ writeJson(res, 500, { ok: false, error: err.message });
96
+ }
97
+ }
98
+ }), 'dsh-remote-workspace: /profiles/active');
99
+
100
+ // 5. POST /dsh-remote-workspace/test
101
+ sctx.effect(() => sctx.webServer.register({
102
+ kind: 'exact',
103
+ path: '/dsh-remote-workspace/test',
104
+ handler: async (req, res) => {
105
+ if (req.method !== 'POST') return writeJson(res, 405, { error: 'POST only' });
106
+ try {
107
+ const body = await readBody(req);
108
+ const profile = body.profile || (body.id ? store.getProfile(body.id) : body);
109
+ if (!profile || !profile.host) {
110
+ return writeJson(res, 400, { ok: false, error: 'Invalid profile configuration' });
111
+ }
112
+ const result = await sshService.testConnection(profile);
113
+ writeJson(res, 200, { ok: result.success, ...result });
114
+ } catch (err) {
115
+ writeJson(res, 200, { ok: false, error: err.message });
116
+ }
117
+ }
118
+ }), 'dsh-remote-workspace: /test');
119
+
120
+ // 6. POST /dsh-remote-workspace/browse
121
+ sctx.effect(() => sctx.webServer.register({
122
+ kind: 'exact',
123
+ path: '/dsh-remote-workspace/browse',
124
+ handler: async (req, res) => {
125
+ if (req.method !== 'POST') return writeJson(res, 405, { error: 'POST only' });
126
+ try {
127
+ const body = await readBody(req);
128
+ const profile = body.profile || (body.profileId ? store.getProfile(body.profileId) : store.getActiveProfile());
129
+ if (!profile || !profile.host) {
130
+ return writeJson(res, 400, { ok: false, error: 'No profile specified' });
131
+ }
132
+ const remotePath = body.remotePath || '/';
133
+ const entries = await remoteFs.listDir(profile, remotePath);
134
+ writeJson(res, 200, { ok: true, currentPath: remotePath, entries });
135
+ } catch (err) {
136
+ writeJson(res, 500, { ok: false, error: err.message });
137
+ }
138
+ }
139
+ }), 'dsh-remote-workspace: /browse');
140
+
141
+ // 7. POST /dsh-remote-workspace/sync
142
+ sctx.effect(() => sctx.webServer.register({
143
+ kind: 'exact',
144
+ path: '/dsh-remote-workspace/sync',
145
+ handler: async (req, res) => {
146
+ if (req.method !== 'POST') return writeJson(res, 405, { error: 'POST only' });
147
+ try {
148
+ const { profileId, direction } = await readBody(req);
149
+ const profile = store.getProfile(profileId) || store.getActiveProfile();
150
+ if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
151
+ if (!profile.remoteWorkspace || !profile.localMirrorPath) {
152
+ return writeJson(res, 400, { ok: false, error: 'Both remoteWorkspace and localMirrorPath must be set' });
153
+ }
154
+ const resSync = direction === 'push'
155
+ ? await mirrorSync.push(profile, profile.localMirrorPath, profile.remoteWorkspace)
156
+ : await mirrorSync.pull(profile, profile.remoteWorkspace, profile.localMirrorPath);
157
+ writeJson(res, 200, { ok: resSync.success, ...resSync });
158
+ } catch (err) {
159
+ writeJson(res, 500, { ok: false, error: err.message });
160
+ }
161
+ }
162
+ }), 'dsh-remote-workspace: /sync');
163
+ });
164
+ }