Skip to content

Commit 220b07c

Browse files
committed
feat(files): add edit command to modify remote files with local editor
1 parent fb302ed commit 220b07c

File tree

4 files changed

+191
-4
lines changed

4 files changed

+191
-4
lines changed

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,12 @@ P.S. These commands consider the current directory as the base path for every op
135135
```
136136
P.S. The `--delete` flag removes files in the remote directory that don't exist locally. The `-r` flag enables recursive synchronization of subdirectories.
137137

138+
**Edit a file**: Edit remote text files using your preferred local text editor.
139+
```bash
140+
puter> edit <file>
141+
```
142+
P.S. This command will download the remote file to your local machine, open it in your default editor, and then upload the changes back to the remote instance. It uses `vim` by default, but you can change it by setting the `EDITOR` environment variable.
143+
138144
#### User Information
139145
```
140146

src/commands/files.js

Lines changed: 163 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
import fs from 'node:fs';
2+
import os from 'node:os';
3+
import { execSync } from 'node:child_process';
24
import { glob } from 'glob';
35
import path from 'path';
46
import { minimatch } from 'minimatch';
57
import chalk from 'chalk';
6-
import ora from 'ora';
78
import Conf from 'conf';
89
import fetch from 'node-fetch';
910
import { API_BASE, BASE_URL, PROJECT_NAME, getHeaders, showDiskSpaceUsage, resolvePath } from '../commons.js';
10-
import { formatDate, formatDateTime, formatSize } from '../utils.js';
11+
import { formatDateTime, formatSize, getSystemEditor } from '../utils.js';
1112
import inquirer from 'inquirer';
1213
import { getAuthToken, getCurrentDirectory, getCurrentUserName } from './auth.js';
1314
import { updatePrompt } from './shell.js';
1415
import crypto from '../crypto.js';
1516

17+
1618
const config = new Conf({ projectName: PROJECT_NAME });
1719

1820

@@ -1379,3 +1381,162 @@ export async function syncDirectory(args = []) {
13791381
console.error(chalk.red(`Error: ${error.message}`));
13801382
}
13811383
}
1384+
1385+
/**
1386+
* Edit a remote file using the local system editor
1387+
* @param {Array} args - The file path to edit
1388+
* @returns {Promise<void>}
1389+
*/
1390+
export async function editFile(args = []) {
1391+
if (args.length < 1) {
1392+
console.log(chalk.red('Usage: edit <file>'));
1393+
return;
1394+
}
1395+
1396+
const filePath = args[0].startsWith('/') ? args[0] : resolvePath(getCurrentDirectory(), args[0]);
1397+
console.log(chalk.green(`Fetching file: ${filePath}`));
1398+
1399+
try {
1400+
// Step 1: Check if file exists
1401+
const statResponse = await fetch(`${API_BASE}/stat`, {
1402+
method: 'POST',
1403+
headers: getHeaders(),
1404+
body: JSON.stringify({ path: filePath })
1405+
});
1406+
1407+
const statData = await statResponse.json();
1408+
if (!statData || !statData.uid || statData.is_dir) {
1409+
console.log(chalk.red(`File not found or is a directory: ${filePath}`));
1410+
return;
1411+
}
1412+
1413+
// Step 2: Download the file content
1414+
const downloadResponse = await fetch(`${API_BASE}/read?file=${encodeURIComponent(filePath)}`, {
1415+
method: 'GET',
1416+
headers: getHeaders()
1417+
});
1418+
1419+
if (!downloadResponse.ok) {
1420+
console.log(chalk.red(`Failed to download file: ${filePath}`));
1421+
return;
1422+
}
1423+
1424+
const fileContent = await downloadResponse.text();
1425+
console.log(chalk.green(`File fetched: ${filePath} (${formatSize(fileContent.length)} bytes)`));
1426+
1427+
// Step 3: Create a temporary file
1428+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'puter-'));
1429+
const tempFilePath = path.join(tempDir, path.basename(filePath));
1430+
fs.writeFileSync(tempFilePath, fileContent, 'utf-8');
1431+
1432+
// Step 4: Determine the editor to use
1433+
const editor = getSystemEditor();
1434+
console.log(chalk.cyan(`Opening file with ${editor}...`));
1435+
1436+
// Step 5: Open the file in the editor using execSync instead of spawn
1437+
// This will block until the editor is closed, which is better for terminal-based editors
1438+
try {
1439+
execSync(`${editor} "${tempFilePath}"`, {
1440+
stdio: 'inherit',
1441+
env: process.env
1442+
});
1443+
1444+
// Read the updated content after editor closes
1445+
const updatedContent = fs.readFileSync(tempFilePath, 'utf8');
1446+
console.log(chalk.cyan('Uploading changes...'));
1447+
1448+
// Step 7: Upload the updated file content
1449+
// Step 7.1: Check disk space
1450+
const dfResponse = await fetch(`${API_BASE}/df`, {
1451+
method: 'POST',
1452+
headers: getHeaders(),
1453+
body: null
1454+
});
1455+
1456+
if (!dfResponse.ok) {
1457+
console.log(chalk.red('Unable to check disk space.'));
1458+
return;
1459+
}
1460+
1461+
const dfData = await dfResponse.json();
1462+
if (dfData.used >= dfData.capacity) {
1463+
console.log(chalk.red('Not enough disk space to upload the file.'));
1464+
showDiskSpaceUsage(dfData); // Display disk usage info
1465+
return;
1466+
}
1467+
1468+
// Step 7.2: Uploading the updated file
1469+
const operationId = crypto.randomUUID(); // Generate a unique operation ID
1470+
const socketId = 'undefined'; // Placeholder socket ID
1471+
const boundary = `----WebKitFormBoundary${crypto.randomUUID().replace(/-/g, '')}`;
1472+
const fileName = path.basename(filePath);
1473+
const dirName = path.dirname(filePath);
1474+
1475+
// Prepare FormData
1476+
const formData = `--${boundary}\r\n` +
1477+
`Content-Disposition: form-data; name="operation_id"\r\n\r\n${operationId}\r\n` +
1478+
`--${boundary}\r\n` +
1479+
`Content-Disposition: form-data; name="socket_id"\r\n\r\n${socketId}\r\n` +
1480+
`--${boundary}\r\n` +
1481+
`Content-Disposition: form-data; name="original_client_socket_id"\r\n\r\n${socketId}\r\n` +
1482+
`--${boundary}\r\n` +
1483+
`Content-Disposition: form-data; name="fileinfo"\r\n\r\n${JSON.stringify({
1484+
name: fileName,
1485+
type: 'text/plain',
1486+
size: Buffer.byteLength(updatedContent, 'utf8')
1487+
})}\r\n` +
1488+
`--${boundary}\r\n` +
1489+
`Content-Disposition: form-data; name="operation"\r\n\r\n${JSON.stringify({
1490+
op: 'write',
1491+
dedupe_name: false,
1492+
overwrite: true,
1493+
operation_id: operationId,
1494+
path: dirName,
1495+
name: fileName,
1496+
item_upload_id: 0
1497+
})}\r\n` +
1498+
`--${boundary}\r\n` +
1499+
`Content-Disposition: form-data; name="file"; filename="${fileName}"\r\n` +
1500+
`Content-Type: text/plain\r\n\r\n${updatedContent}\r\n` +
1501+
`--${boundary}--\r\n`;
1502+
1503+
// Send the upload request
1504+
const uploadResponse = await fetch(`${API_BASE}/batch`, {
1505+
method: 'POST',
1506+
headers: getHeaders(`multipart/form-data; boundary=${boundary}`),
1507+
body: formData
1508+
});
1509+
1510+
if (!uploadResponse.ok) {
1511+
const errorText = await uploadResponse.text();
1512+
console.log(chalk.red(`Failed to save file. Server response: ${errorText}`));
1513+
return;
1514+
}
1515+
1516+
const uploadData = await uploadResponse.json();
1517+
if (uploadData && uploadData.results && uploadData.results.length > 0) {
1518+
const file = uploadData.results[0];
1519+
console.log(chalk.green(`File saved: ${file.path}`));
1520+
} else {
1521+
console.log(chalk.red('Failed to save file. Invalid response from server.'));
1522+
}
1523+
} catch (error) {
1524+
if (error.status === 130) {
1525+
// This is a SIGINT (Ctrl+C), which is normal for some editors
1526+
console.log(chalk.yellow('Editor closed without saving.'));
1527+
} else {
1528+
console.log(chalk.red(`Error during editing: ${error.message}`));
1529+
}
1530+
} finally {
1531+
// Clean up temporary files
1532+
try {
1533+
fs.unlinkSync(tempFilePath);
1534+
fs.rmdirSync(tempDir);
1535+
} catch (e) {
1536+
console.error(chalk.dim(`Failed to clean up temporary files: ${e.message}`));
1537+
}
1538+
}
1539+
} catch (error) {
1540+
console.log(chalk.red(`Error: ${error.message}`));
1541+
}
1542+
}

src/executor.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@ import { listSites, createSite, deleteSite, infoSite } from './commands/sites.js
55
import { listFiles, makeDirectory, renameFileOrDirectory,
66
removeFileOrDirectory, emptyTrash, changeDirectory, showCwd,
77
getInfo, getDiskUsage, createFile, readFile, uploadFile,
8-
downloadFile, copyFile, syncDirectory } from './commands/files.js';
8+
downloadFile, copyFile, syncDirectory, editFile } from './commands/files.js';
99
import { getUserInfo, getUsageInfo } from './commands/auth.js';
1010
import { PROJECT_NAME, API_BASE, getHeaders } from './commons.js';
1111
import inquirer from 'inquirer';
1212
import { exec } from 'node:child_process';
13-
import { parseArgs } from './utils.js';
13+
import { parseArgs, getSystemEditor } from './utils.js';
1414
import { rl } from './commands/shell.js';
1515
import { ErrorAPI } from './modules/ErrorModule.js';
1616

@@ -144,6 +144,7 @@ const commands = {
144144
push: uploadFile,
145145
pull: downloadFile,
146146
update: syncDirectory,
147+
edit: editFile,
147148
sites: listSites,
148149
site: infoSite,
149150
'site:delete': deleteSite,
@@ -327,6 +328,13 @@ function showHelp(command) {
327328
Sync local directory with remote cloud.
328329
Example: update /local/path /remote/path
329330
`,
331+
edit: `
332+
${chalk.cyan('edit <file>')}
333+
Edit a remote file using your local text editor.
334+
Example: edit /path/to/file
335+
336+
System editor: ${chalk.green(getSystemEditor())}
337+
`,
330338
sites: `
331339
${chalk.cyan('sites')}
332340
List sites and subdomains.

src/utils.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,4 +120,16 @@ export function isValidAppUuid (uuid) {
120120
export function is_valid_uuid4 (uuid) {
121121
const uuidV4Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
122122
return uuidV4Regex.test(uuid);
123+
}
124+
125+
/**
126+
* Get system editor
127+
* @returns {string} - System editor
128+
* @example
129+
* getSystemEditor()
130+
* // => 'nano'
131+
*/
132+
export function getSystemEditor() {
133+
return process.env.EDITOR || process.env.VISUAL ||
134+
(process.platform === 'win32' ? 'notepad' : 'vi')
123135
}

0 commit comments

Comments
 (0)