wip: initial commit

This commit is contained in:
2025-02-24 00:30:26 +00:00
commit dcb8d20d88
12 changed files with 6309 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
out
dist
node_modules
.vscode-test/
*.vsix

5
.vscode-test.mjs Normal file
View File

@@ -0,0 +1,5 @@
import { defineConfig } from '@vscode/test-cli';
export default defineConfig({
files: 'out/test/**/*.test.js',
});

14
.vscodeignore Normal file
View File

@@ -0,0 +1,14 @@
.vscode/**
.vscode-test/**
out/**
node_modules/**
src/**
.gitignore
.yarnrc
esbuild.js
vsc-extension-quickstart.md
**/tsconfig.json
**/eslint.config.mjs
**/*.map
**/*.ts
**/.vscode-test.*

9
CHANGELOG.md Normal file
View File

@@ -0,0 +1,9 @@
# Change Log
All notable changes to the "make-executable-if-script" extension will be documented in this file.
Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file.
## [Unreleased]
- Initial release

59
README.md Normal file
View File

@@ -0,0 +1,59 @@
# Make Executable If Script
> [!WARNING]
> This extension is currently a unfinished work in progress, and mostly written
> by AI. The idea was simply to get a very quick intro to VSCode extension
> development, with a goal of actually diving deeper to understand things
> properly at a later date.
A VSCode extension that automatically makes files executable when they start
with a hashbang (`#!`).
## Features
- Automatically marks files as executable when saved if they start with `#!`
(enabled by default).
- Manual command to mark current file as executable if it has a hashbang.
- Shows permission changes in a non-intrusive way.
- Works with any script type (bash, python, perl, etc.)
## Usage
### Automatic Mode (Default)
By default, any file you save that starts with `#!` will automatically be made
executable if it isn't already. For example:
```bash
#!/bin/bash
echo "Hello World"
```
Save the file, and it will automatically be made executable.
### Manual Mode
You can also manually mark a file as executable:
1. Open the Command Palette (Cmd+Shift+P / Ctrl+Shift+P)
2. Type "Make Executable If Script"
3. Press Enter
The command will only make the file executable if it starts with `#!`.
## Extension Settings
This extension contributes the following settings:
- `make-executable-if-script.autoMarkOnSave`: Enable/disable automatic marking
of files as executable on save (default: `true`)
- `make-executable-if-script.silent`: Suppress notifications when files are made
executable (default: `false`)
## Platform Support
### Unix-like Systems (macOS, Linux)
- Makes files executable using `chmod +x`
- Preserves existing file extension
- Works with all script types

56
esbuild.js Normal file
View File

@@ -0,0 +1,56 @@
const esbuild = require("esbuild");
const production = process.argv.includes('--production');
const watch = process.argv.includes('--watch');
/**
* @type {import('esbuild').Plugin}
*/
const esbuildProblemMatcherPlugin = {
name: 'esbuild-problem-matcher',
setup(build) {
build.onStart(() => {
console.log('[watch] build started');
});
build.onEnd((result) => {
result.errors.forEach(({ text, location }) => {
console.error(`✘ [ERROR] ${text}`);
console.error(` ${location.file}:${location.line}:${location.column}:`);
});
console.log('[watch] build finished');
});
},
};
async function main() {
const ctx = await esbuild.context({
entryPoints: [
'src/extension.ts'
],
bundle: true,
format: 'cjs',
minify: production,
sourcemap: !production,
sourcesContent: false,
platform: 'node',
outfile: 'dist/extension.js',
external: ['vscode'],
logLevel: 'silent',
plugins: [
/* add to the end of plugins array */
esbuildProblemMatcherPlugin,
],
});
if (watch) {
await ctx.watch();
} else {
await ctx.rebuild();
await ctx.dispose();
}
}
main().catch(e => {
console.error(e);
process.exit(1);
});

28
eslint.config.mjs Normal file
View File

@@ -0,0 +1,28 @@
import typescriptEslint from "@typescript-eslint/eslint-plugin";
import tsParser from "@typescript-eslint/parser";
export default [{
files: ["**/*.ts"],
}, {
plugins: {
"@typescript-eslint": typescriptEslint,
},
languageOptions: {
parser: tsParser,
ecmaVersion: 2022,
sourceType: "module",
},
rules: {
"@typescript-eslint/naming-convention": ["warn", {
selector: "import",
format: ["camelCase", "PascalCase"],
}],
curly: "warn",
eqeqeq: "warn",
"no-throw-literal": "warn",
semi: "warn",
},
}];

5948
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

64
package.json Normal file
View File

@@ -0,0 +1,64 @@
{
"name": "make-executable-if-script",
"displayName": "Make Executable If Script",
"description": "Mark file as executable (chmod +x) if it starts with `#!`.",
"version": "0.0.1",
"engines": {
"vscode": "^1.97.0"
},
"categories": [
"Other"
],
"activationEvents": [],
"main": "./dist/extension.js",
"contributes": {
"commands": [
{
"command": "make-executable-if-script.makeExecutable",
"title": "Make Executable If Script"
}
],
"configuration": {
"title": "Make Executable If Script",
"properties": {
"make-executable-if-script.autoMarkOnSave": {
"type": "boolean",
"default": true,
"description": "Automatically mark files as executable on save if they start with #! (hashbang)"
},
"make-executable-if-script.silent": {
"type": "boolean",
"default": false,
"description": "Suppress notifications when files are made executable"
}
}
}
},
"scripts": {
"vscode:prepublish": "npm run package",
"compile": "npm run check-types && npm run lint && node esbuild.js",
"watch": "npm-run-all -p watch:*",
"watch:esbuild": "node esbuild.js --watch",
"watch:tsc": "tsc --noEmit --watch --project tsconfig.json",
"package": "npm run check-types && npm run lint && node esbuild.js --production",
"compile-tests": "tsc -p . --outDir out",
"watch-tests": "tsc -p . -w --outDir out",
"pretest": "npm run compile-tests && npm run compile && npm run lint",
"check-types": "tsc --noEmit",
"lint": "eslint src",
"test": "vscode-test"
},
"devDependencies": {
"@types/vscode": "^1.97.0",
"@types/mocha": "^10.0.10",
"@types/node": "20.x",
"@typescript-eslint/eslint-plugin": "^8.22.0",
"@typescript-eslint/parser": "^8.22.0",
"eslint": "^9.19.0",
"esbuild": "^0.24.2",
"npm-run-all": "^4.1.5",
"typescript": "^5.7.3",
"@vscode/test-cli": "^0.0.10",
"@vscode/test-electron": "^2.4.1"
}
}

95
src/extension.ts Normal file
View File

@@ -0,0 +1,95 @@
import * as vscode from 'vscode';
import * as fs from 'fs';
import { promisify } from 'util';
import * as path from 'path';
const open = promisify(fs.open);
const read = promisify(fs.read);
const close = promisify(fs.close);
const chmod = promisify(fs.chmod);
const stat = promisify(fs.stat);
function isExecutable(mode: number): boolean {
return Boolean(mode & 0o111);
}
function getRelativePath(filePath: string): string {
const workspaceFolder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(filePath));
if (workspaceFolder) {
return path.relative(workspaceFolder.uri.fsPath, filePath);
}
return path.basename(filePath);
}
function startsWithHashbang(text: string): boolean {
return text.length >= 2 && text.startsWith('#!');
}
async function readFirstTwoBytes(filePath: string): Promise<Buffer> {
let fd: number | undefined;
try {
fd = await open(filePath, 'r');
const buffer = Buffer.alloc(2);
const { bytesRead } = await read(fd, buffer, 0, 2, 0);
return bytesRead === 2 ? buffer : Buffer.alloc(0);
} finally {
if (fd !== undefined) {
await close(fd).catch(() => { }); // Ignore close errors
}
}
}
export async function makeExecutableIfScript(filePath: string, content?: string): Promise<void> {
try {
const stats = await stat(filePath);
const oldMode = stats.mode & 0o777; // Get only permission bits
if (isExecutable(stats.mode)) {
return;
}
if (content === undefined) {
const buffer = await readFirstTwoBytes(filePath);
content = buffer.toString();
}
if (startsWithHashbang(content)) {
const newMode = stats.mode | 0o111;
await chmod(filePath, newMode);
const config = vscode.workspace.getConfiguration('make-executable-if-script');
if (!config.get<boolean>('silent')) {
const relativePath = getRelativePath(filePath);
const oldModeStr = (oldMode & 0o777).toString(8).padStart(3, '0');
const newModeStr = (newMode & 0o777).toString(8).padStart(3, '0');
await vscode.window.showInformationMessage(
`${relativePath}: Made executable (${oldModeStr}${newModeStr})`
);
}
}
} catch (error) {
vscode.window.showErrorMessage(`Error: ${error instanceof Error ? error.message : String(error)}`);
}
}
export function activate(context: vscode.ExtensionContext) {
const disposable = vscode.commands.registerCommand('make-executable-if-script.makeExecutable', async () => {
const editor = vscode.window.activeTextEditor;
if (!editor) {
return;
}
await makeExecutableIfScript(editor.document.uri.fsPath, editor.document.getText(new vscode.Range(0, 0, 0, 2)));
});
const afterSaveHook = vscode.workspace.onDidSaveTextDocument(async (document) => {
const config = vscode.workspace.getConfiguration('make-executable-if-script');
if (config.get<boolean>('autoMarkOnSave')) {
await makeExecutableIfScript(document.uri.fsPath, document.getText(new vscode.Range(0, 0, 0, 2)));
}
});
context.subscriptions.push(disposable, afterSaveHook);
}
export function deactivate() { }

View File

@@ -0,0 +1,11 @@
import * as assert from 'assert';
import * as vscode from 'vscode';
suite('Extension Test Suite', () => {
vscode.window.showInformationMessage('Start all tests.');
test('Sample test', () => {
assert.strictEqual(-1, [1, 2, 3].indexOf(5));
assert.strictEqual(-1, [1, 2, 3].indexOf(0));
});
});

15
tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"module": "Node16",
"target": "ES2022",
"lib": [
"ES2022"
],
"sourceMap": true,
"rootDir": "src",
"strict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedParameters": true,
}
}