commit 5ed52c279d87a6306116d14be11cafcb399d4b1c Author: mike Date: Thu Jun 25 15:28:43 2026 -0400 Initial commit diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..cfaae4e --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Skill(deep-research)", + "Skill(deep-research:*)" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a08e1da --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.vsix diff --git a/.vscodeignore b/.vscodeignore new file mode 100644 index 0000000..05fb181 --- /dev/null +++ b/.vscodeignore @@ -0,0 +1,8 @@ +src/** +node_modules/** +.vscode/** +tsconfig.json +esbuild.js +**/*.map +**/*.ts +!dist/** diff --git a/esbuild.js b/esbuild.js new file mode 100644 index 0000000..a90dd2b --- /dev/null +++ b/esbuild.js @@ -0,0 +1,47 @@ +const esbuild = require("esbuild"); + +const production = process.argv.includes("--production"); +const watch = process.argv.includes("--watch"); + +const ctx = 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: [ + { + 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}`); + if (location) { + console.error( + ` ${location.file}:${location.line}:${location.column}:` + ); + } + }); + console.log("[watch] build finished"); + }); + }, + }, + ], +}); + +ctx.then(async (c) => { + if (watch) { + await c.watch(); + } else { + await c.rebuild(); + await c.dispose(); + } +}); diff --git a/genieacs_ps_vscode_plugin b/genieacs_ps_vscode_plugin new file mode 160000 index 0000000..c1df3e5 --- /dev/null +++ b/genieacs_ps_vscode_plugin @@ -0,0 +1 @@ +Subproject commit c1df3e5802601a5dbba0cc2d30889ebd41b4bceb diff --git a/info.env.example b/info.env.example new file mode 100644 index 0000000..478d72d --- /dev/null +++ b/info.env.example @@ -0,0 +1,7 @@ +# GenieACS connection settings +GENIEACS_URL=http://localhost:7557 +GENIEACS_USER=admin +GENIEACS_PASS=yourpassword + +# Secret pushed into provisioning scripts by push-secret.js +TPLG_SECRET=your-secret-here diff --git a/media/genieacs.svg b/media/genieacs.svg new file mode 100644 index 0000000..d9e895d --- /dev/null +++ b/media/genieacs.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8791a4a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,489 @@ +{ + "name": "genieacs-editor", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "genieacs-editor", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^20.0.0", + "@types/vscode": "^1.85.0", + "esbuild": "^0.20.0", + "typescript": "^5.3.0" + }, + "engines": { + "vscode": "^1.85.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", + "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", + "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", + "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", + "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", + "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", + "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", + "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", + "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", + "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", + "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", + "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", + "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", + "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", + "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", + "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", + "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", + "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", + "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", + "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", + "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", + "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", + "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", + "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/vscode": { + "version": "1.125.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.125.0.tgz", + "integrity": "sha512-0icm/ZQAaism87P0ekHqi4/Ju9du+Tm0RUW+y7vqRsxY2cY0FNRX1nAnaW7nT6npPt2tfHiheZ55Zm9UhqonFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", + "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.20.2", + "@esbuild/android-arm": "0.20.2", + "@esbuild/android-arm64": "0.20.2", + "@esbuild/android-x64": "0.20.2", + "@esbuild/darwin-arm64": "0.20.2", + "@esbuild/darwin-x64": "0.20.2", + "@esbuild/freebsd-arm64": "0.20.2", + "@esbuild/freebsd-x64": "0.20.2", + "@esbuild/linux-arm": "0.20.2", + "@esbuild/linux-arm64": "0.20.2", + "@esbuild/linux-ia32": "0.20.2", + "@esbuild/linux-loong64": "0.20.2", + "@esbuild/linux-mips64el": "0.20.2", + "@esbuild/linux-ppc64": "0.20.2", + "@esbuild/linux-riscv64": "0.20.2", + "@esbuild/linux-s390x": "0.20.2", + "@esbuild/linux-x64": "0.20.2", + "@esbuild/netbsd-x64": "0.20.2", + "@esbuild/openbsd-x64": "0.20.2", + "@esbuild/sunos-x64": "0.20.2", + "@esbuild/win32-arm64": "0.20.2", + "@esbuild/win32-ia32": "0.20.2", + "@esbuild/win32-x64": "0.20.2" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..280ec87 --- /dev/null +++ b/package.json @@ -0,0 +1,153 @@ +{ + "name": "genieacs-editor", + "displayName": "GenieACS Script Editor", + "description": "Connect to GenieACS, browse and edit provisioning scripts with full JavaScript support", + "version": "0.1.0", + "publisher": "targo", + "engines": { + "vscode": "^1.85.0" + }, + "categories": ["Other"], + "repository": { + "type": "git", + "url": "https://github.com/targo/genieacs-editor" + }, + "activationEvents": [], + "main": "./dist/extension.js", + "contributes": { + "configuration": { + "title": "GenieACS", + "properties": { + "genieacs.serverUrl": { + "type": "string", + "default": "http://localhost:7557", + "description": "GenieACS NBI base URL (e.g. http://localhost:7557)" + }, + "genieacs.username": { + "type": "string", + "default": "", + "description": "GenieACS username (leave empty if no auth)" + }, + "genieacs.password": { + "type": "string", + "default": "", + "description": "GenieACS password" + } + } + }, + "viewsContainers": { + "activitybar": [ + { + "id": "genieacs-explorer", + "title": "GenieACS", + "icon": "media/genieacs.svg" + } + ] + }, + "views": { + "genieacs-explorer": [ + { + "id": "genieacsScripts", + "name": "Provisioning Scripts" + } + ] + }, + "commands": [ + { + "command": "genieacs.refresh", + "title": "Refresh", + "icon": "$(refresh)", + "category": "GenieACS" + }, + { + "command": "genieacs.openScript", + "title": "Open Script", + "category": "GenieACS" + }, + { + "command": "genieacs.newScript", + "title": "New Script", + "icon": "$(add)", + "category": "GenieACS" + }, + { + "command": "genieacs.deleteScript", + "title": "Delete Script", + "icon": "$(trash)", + "category": "GenieACS" + }, + { + "command": "genieacs.renameScript", + "title": "Rename Script", + "category": "GenieACS" + }, + { + "command": "genieacs.configure", + "title": "Configure Server", + "icon": "$(settings-gear)", + "category": "GenieACS" + }, + { + "command": "genieacs.testConnection", + "title": "Test Connection (Diagnostics)", + "category": "GenieACS" + } + ], + "menus": { + "view/title": [ + { + "command": "genieacs.refresh", + "when": "view == genieacsScripts", + "group": "navigation" + }, + { + "command": "genieacs.newScript", + "when": "view == genieacsScripts", + "group": "navigation" + }, + { + "command": "genieacs.configure", + "when": "view == genieacsScripts", + "group": "navigation" + } + ], + "view/item/context": [ + { + "command": "genieacs.deleteScript", + "when": "view == genieacsScripts && viewItem == script", + "group": "inline" + }, + { + "command": "genieacs.renameScript", + "when": "view == genieacsScripts && viewItem == script", + "group": "1_modification" + }, + { + "command": "genieacs.deleteScript", + "when": "view == genieacsScripts && viewItem == script", + "group": "1_modification" + } + ] + }, + "languages": [ + { + "id": "genieacs-script", + "aliases": ["GenieACS Script"], + "extensions": [] + } + ] + }, + "scripts": { + "vscode:prepublish": "npm run build", + "build": "node esbuild.js --production", + "watch": "node esbuild.js --watch", + "compile": "tsc --noEmit", + "package": "vsce package" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/vscode": "^1.85.0", + "esbuild": "^0.20.0", + "typescript": "^5.3.0" + } +} diff --git a/src/extension.ts b/src/extension.ts new file mode 100644 index 0000000..4b8637a --- /dev/null +++ b/src/extension.ts @@ -0,0 +1,256 @@ +import * as vscode from "vscode"; +import { ScriptsProvider, ScriptItem } from "./scriptsProvider"; +import { ScriptFileSystemProvider } from "./scriptFileSystem"; +import { createScript, deleteScript, testConnection } from "./genieacsApi"; + +export function activate(context: vscode.ExtensionContext) { + // --- File system provider --- + const fsProvider = new ScriptFileSystemProvider(); + context.subscriptions.push( + vscode.workspace.registerFileSystemProvider("genieacs", fsProvider, { + isCaseSensitive: true, + isReadonly: false, + }) + ); + + // --- Tree view --- + const scriptsProvider = new ScriptsProvider(); + const treeView = vscode.window.createTreeView("genieacsScripts", { + treeDataProvider: scriptsProvider, + showCollapseAll: false, + }); + context.subscriptions.push(treeView); + + // Trigger initial load + scriptsProvider.reload(); + + // --- Commands --- + + context.subscriptions.push( + vscode.commands.registerCommand("genieacs.refresh", () => { + scriptsProvider.reload(); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand( + "genieacs.openScript", + async (item: ScriptItem) => { + const uri = ScriptFileSystemProvider.uriForScript(item.script._id); + try { + const doc = await vscode.workspace.openTextDocument(uri); + const editor = await vscode.window.showTextDocument(doc, { + preview: false, + }); + // Force JavaScript language mode for syntax highlighting + IntelliSense + await vscode.languages.setTextDocumentLanguage(doc, "javascript"); + // Scroll to top + editor.revealRange(new vscode.Range(0, 0, 0, 0)); + } catch (err) { + vscode.window.showErrorMessage( + `Failed to open script: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + ) + ); + + context.subscriptions.push( + vscode.commands.registerCommand("genieacs.newScript", async () => { + const id = await vscode.window.showInputBox({ + prompt: "Enter a name for the new script", + placeHolder: "e.g. provision-model-xyz", + validateInput: (v) => + v.trim() ? undefined : "Script name cannot be empty", + }); + if (!id) return; + + const trimmed = id.trim(); + const initial = [ + `"use strict";`, + ``, + `// GenieACS provisioning script: ${trimmed}`, + `// Available globals: declare, commit, log`, + ``, + ].join("\n"); + + try { + await createScript(trimmed, initial); + scriptsProvider.reload(); + + // Open the newly created script + const uri = ScriptFileSystemProvider.uriForScript(trimmed); + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc, { preview: false }); + await vscode.languages.setTextDocumentLanguage(doc, "javascript"); + } catch (err) { + vscode.window.showErrorMessage( + `Failed to create script: ${err instanceof Error ? err.message : String(err)}` + ); + } + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand( + "genieacs.deleteScript", + async (item: ScriptItem) => { + const answer = await vscode.window.showWarningMessage( + `Delete script "${item.script._id}"? This cannot be undone.`, + { modal: true }, + "Delete" + ); + if (answer !== "Delete") return; + + try { + await deleteScript(item.script._id); + // Close the open editor for this script if it exists + const uri = ScriptFileSystemProvider.uriForScript(item.script._id); + fsProvider.invalidate(item.script._id); + for (const editor of vscode.window.visibleTextEditors) { + if (editor.document.uri.toString() === uri.toString()) { + await vscode.window.showTextDocument(editor.document, { + viewColumn: editor.viewColumn, + }); + await vscode.commands.executeCommand( + "workbench.action.closeActiveEditor" + ); + } + } + scriptsProvider.reload(); + } catch (err) { + vscode.window.showErrorMessage( + `Failed to delete script: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + ) + ); + + context.subscriptions.push( + vscode.commands.registerCommand( + "genieacs.renameScript", + async (item: ScriptItem) => { + const newId = await vscode.window.showInputBox({ + prompt: "Enter new name for the script", + value: item.script._id, + validateInput: (v) => + v.trim() ? undefined : "Script name cannot be empty", + }); + if (!newId || newId.trim() === item.script._id) return; + + try { + // GenieACS has no rename endpoint — copy then delete + await createScript(newId.trim(), item.script.script); + await deleteScript(item.script._id); + + fsProvider.invalidate(item.script._id); + const oldUri = ScriptFileSystemProvider.uriForScript(item.script._id); + for (const editor of vscode.window.visibleTextEditors) { + if (editor.document.uri.toString() === oldUri.toString()) { + await vscode.window.showTextDocument(editor.document, { + viewColumn: editor.viewColumn, + }); + await vscode.commands.executeCommand( + "workbench.action.closeActiveEditor" + ); + } + } + + scriptsProvider.reload(); + + // Open renamed script + const uri = ScriptFileSystemProvider.uriForScript(newId.trim()); + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc, { preview: false }); + await vscode.languages.setTextDocumentLanguage(doc, "javascript"); + } catch (err) { + vscode.window.showErrorMessage( + `Failed to rename script: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + ) + ); + + context.subscriptions.push( + vscode.commands.registerCommand("genieacs.testConnection", async () => { + const result = await testConnection(); + const panel = vscode.window.createOutputChannel("GenieACS Diagnostics"); + panel.clear(); + panel.appendLine("=== GenieACS Connection Diagnostics ==="); + panel.appendLine(`URL: ${result.url}`); + panel.appendLine(`Status: ${result.status ?? "N/A"}`); + panel.appendLine(`Error: ${result.error ?? "none"}`); + panel.appendLine(`Body: ${result.body ?? "(empty)"}`); + panel.show(); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand("genieacs.configure", async () => { + const url = await vscode.window.showInputBox({ + prompt: "GenieACS NBI URL", + value: + vscode.workspace + .getConfiguration("genieacs") + .get("serverUrl") || "http://localhost:7557", + placeHolder: "http://localhost:7557", + }); + if (url === undefined) return; + + const username = await vscode.window.showInputBox({ + prompt: "Username (leave empty if no authentication)", + value: + vscode.workspace + .getConfiguration("genieacs") + .get("username") || "", + }); + if (username === undefined) return; + + let password = ""; + if (username) { + const pw = await vscode.window.showInputBox({ + prompt: "Password", + password: true, + value: + vscode.workspace + .getConfiguration("genieacs") + .get("password") || "", + }); + if (pw === undefined) return; + password = pw; + } + + const cfg = vscode.workspace.getConfiguration("genieacs"); + await cfg.update("serverUrl", url, vscode.ConfigurationTarget.Global); + await cfg.update("username", username, vscode.ConfigurationTarget.Global); + await cfg.update("password", password, vscode.ConfigurationTarget.Global); + + vscode.window.showInformationMessage("GenieACS configuration saved."); + scriptsProvider.reload(); + }) + ); + + // Auto-save: when a genieacs: document is saved, write back to server + context.subscriptions.push( + vscode.workspace.onDidSaveTextDocument(async (doc) => { + if (doc.uri.scheme !== "genieacs") return; + // writeFile is triggered by VS Code automatically through the filesystem provider + // This handler is just for user feedback + vscode.window.setStatusBarMessage("$(check) Saved to GenieACS", 3000); + }) + ); + + // When a genieacs: document is opened, ensure JS language mode + context.subscriptions.push( + vscode.workspace.onDidOpenTextDocument(async (doc) => { + if (doc.uri.scheme !== "genieacs") return; + if (doc.languageId !== "javascript") { + await vscode.languages.setTextDocumentLanguage(doc, "javascript"); + } + }) + ); +} + +export function deactivate() {} diff --git a/src/genieacsApi.ts b/src/genieacsApi.ts new file mode 100644 index 0000000..e9463e9 --- /dev/null +++ b/src/genieacsApi.ts @@ -0,0 +1,122 @@ +import * as https from "https"; +import * as http from "http"; +import * as vscode from "vscode"; + +export interface GenieScript { + _id: string; + script: string; +} + +function getConfig() { + const cfg = vscode.workspace.getConfiguration("genieacs"); + return { + serverUrl: (cfg.get("serverUrl") || "http://localhost:7557").replace(/\/$/, ""), + username: cfg.get("username") || "", + password: cfg.get("password") || "", + }; +} + +function request( + method: string, + path: string, + body?: string, + contentType = "application/json" +): Promise<{ status: number; data: string }> { + return new Promise((resolve, reject) => { + const { serverUrl, username, password } = getConfig(); + const url = new URL(serverUrl + path); + const isHttps = url.protocol === "https:"; + const lib = isHttps ? https : http; + + const headers: Record = { + "Content-Type": contentType, + }; + + if (username) { + const encoded = Buffer.from(`${username}:${password}`).toString("base64"); + headers["Authorization"] = `Basic ${encoded}`; + } + + const options: http.RequestOptions = { + hostname: url.hostname, + port: url.port || (isHttps ? 443 : 80), + path: url.pathname + url.search, + method, + headers, + }; + + const req = lib.request(options, (res) => { + let data = ""; + res.on("data", (chunk) => (data += chunk)); + res.on("end", () => resolve({ status: res.statusCode ?? 0, data })); + }); + + req.on("error", reject); + + if (body) req.write(body); + req.end(); + }); +} + +export async function listScripts(): Promise { + const res = await request("GET", "/provisions?limit=1000"); + if (res.status !== 200) { + throw new Error(`Failed to list provisions (HTTP ${res.status}): ${res.data}`); + } + return JSON.parse(res.data) as GenieScript[]; +} + +export async function getScript(id: string): Promise { + // GenieACS NBI does not have a single-item GET for provisions; + // filter the list by _id instead. + const res = await request( + "GET", + `/provisions?query=${encodeURIComponent(JSON.stringify({ _id: id }))}` + ); + if (res.status !== 200) { + throw new Error(`Failed to get provision "${id}" (HTTP ${res.status}): ${res.data}`); + } + const list = JSON.parse(res.data) as GenieScript[]; + if (list.length === 0) throw new Error(`Provision "${id}" not found`); + return list[0]; +} + +export async function saveScript(id: string, content: string): Promise { + // GenieACS expects the raw script text as the body, not JSON-wrapped + const res = await request( + "PUT", + `/provisions/${encodeURIComponent(id)}`, + content, + "application/javascript" + ); + if (res.status !== 200 && res.status !== 201 && res.status !== 204) { + throw new Error(`Failed to save provision "${id}" (HTTP ${res.status}): ${res.data}`); + } +} + +export async function createScript(id: string, content: string): Promise { + await saveScript(id, content); +} + +export async function deleteScript(id: string): Promise { + const res = await request("DELETE", `/provisions/${encodeURIComponent(id)}`); + if (res.status !== 200 && res.status !== 204) { + throw new Error(`Failed to delete provision "${id}" (HTTP ${res.status}): ${res.data}`); + } +} + +export async function testConnection(): Promise<{ + url: string; + status?: number; + body?: string; + error?: string; +}> { + const { serverUrl } = getConfig(); + const url = `${serverUrl}/provisions?limit=5`; + try { + const res = await request("GET", "/provisions?limit=5"); + return { url, status: res.status, body: res.data.slice(0, 500) }; + } catch (err) { + return { url, error: err instanceof Error ? err.message : String(err) }; + } +} diff --git a/src/scriptFileSystem.ts b/src/scriptFileSystem.ts new file mode 100644 index 0000000..8c15e63 --- /dev/null +++ b/src/scriptFileSystem.ts @@ -0,0 +1,89 @@ +import * as vscode from "vscode"; +import { getScript, saveScript } from "./genieacsApi"; + +/** + * Virtual filesystem for GenieACS scripts. + * URIs look like: genieacs:/scripts/.js + * + * Opening a URI fetches the script from the API and presents it as a JS file. + * Saving writes back to the API. + */ +export class ScriptFileSystemProvider implements vscode.FileSystemProvider { + private _onDidChangeFile = new vscode.EventEmitter(); + readonly onDidChangeFile = this._onDidChangeFile.event; + + // In-memory cache: scriptId → { content, mtime } + private cache = new Map(); + + static scriptIdFromUri(uri: vscode.Uri): string { + // path is /scripts/.js → strip prefix and .js suffix + const match = uri.path.match(/^\/scripts\/(.+)\.js$/); + if (!match) throw new Error(`Unexpected URI path: ${uri.path}`); + return decodeURIComponent(match[1]); + } + + static uriForScript(id: string): vscode.Uri { + return vscode.Uri.parse(`genieacs:/scripts/${encodeURIComponent(id)}.js`); + } + + watch(): vscode.Disposable { + return { dispose: () => {} }; + } + + stat(uri: vscode.Uri): vscode.FileStat { + const id = ScriptFileSystemProvider.scriptIdFromUri(uri); + const cached = this.cache.get(id); + return { + type: vscode.FileType.File, + ctime: 0, + mtime: cached?.mtime ?? 0, + size: cached?.content.byteLength ?? 0, + }; + } + + readDirectory(): never { + throw vscode.FileSystemError.NoPermissions("readDirectory not supported"); + } + + createDirectory(): never { + throw vscode.FileSystemError.NoPermissions("createDirectory not supported"); + } + + async readFile(uri: vscode.Uri): Promise { + const id = ScriptFileSystemProvider.scriptIdFromUri(uri); + const script = await getScript(id); + const content = Buffer.from(script.script ?? "", "utf8"); + this.cache.set(id, { content, mtime: Date.now() }); + return content; + } + + async writeFile( + uri: vscode.Uri, + content: Uint8Array, + _options: { create: boolean; overwrite: boolean } + ): Promise { + const id = ScriptFileSystemProvider.scriptIdFromUri(uri); + const text = Buffer.from(content).toString("utf8"); + await saveScript(id, text); + + const mtime = Date.now(); + this.cache.set(id, { content, mtime }); + this._onDidChangeFile.fire([{ type: vscode.FileChangeType.Changed, uri }]); + } + + delete(uri: vscode.Uri): void { + const id = ScriptFileSystemProvider.scriptIdFromUri(uri); + this.cache.delete(id); + } + + rename(): never { + throw vscode.FileSystemError.NoPermissions( + "Rename via filesystem not supported — use the tree view context menu" + ); + } + + /** Evict a cached entry so the next open re-fetches from the server. */ + invalidate(id: string): void { + this.cache.delete(id); + } +} diff --git a/src/scriptsProvider.ts b/src/scriptsProvider.ts new file mode 100644 index 0000000..f26c42e --- /dev/null +++ b/src/scriptsProvider.ts @@ -0,0 +1,79 @@ +import * as vscode from "vscode"; +import { GenieScript, listScripts } from "./genieacsApi"; + +export class ScriptItem extends vscode.TreeItem { + constructor( + public readonly script: GenieScript, + public readonly collapsibleState = vscode.TreeItemCollapsibleState.None + ) { + super(script._id, collapsibleState); + this.tooltip = `GenieACS provisioning script: ${script._id}`; + this.description = ""; + this.contextValue = "script"; + this.command = { + command: "genieacs.openScript", + title: "Open Script", + arguments: [this], + }; + this.iconPath = new vscode.ThemeIcon("symbol-file"); + } +} + +export class ScriptsProvider implements vscode.TreeDataProvider { + private _onDidChangeTreeData = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + private scripts: GenieScript[] = []; + private loading = false; + private lastError: string | null = null; + + refresh(): void { + this.scripts = []; + this._onDidChangeTreeData.fire(); + } + + async reload(): Promise { + this.loading = true; + this.lastError = null; + this._onDidChangeTreeData.fire(); + + try { + this.scripts = await listScripts(); + this.scripts.sort((a, b) => a._id.localeCompare(b._id)); + } catch (err) { + this.lastError = err instanceof Error ? err.message : String(err); + this.scripts = []; + } finally { + this.loading = false; + this._onDidChangeTreeData.fire(); + } + } + + getTreeItem(element: ScriptItem): vscode.TreeItem { + return element; + } + + async getChildren(): Promise { + if (this.loading) { + const item = new vscode.TreeItem("Loading scripts..."); + item.iconPath = new vscode.ThemeIcon("loading~spin"); + return [item as unknown as ScriptItem]; + } + + if (this.lastError) { + const item = new vscode.TreeItem("Connection failed — click to configure"); + item.iconPath = new vscode.ThemeIcon("error"); + item.command = { command: "genieacs.configure", title: "Configure" }; + item.tooltip = this.lastError; + return [item as unknown as ScriptItem]; + } + + if (this.scripts.length === 0) { + const item = new vscode.TreeItem("No scripts found"); + item.iconPath = new vscode.ThemeIcon("info"); + return [item as unknown as ScriptItem]; + } + + return this.scripts.map((s) => new ScriptItem(s)); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..fc68a25 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2020", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "sourceMap": true, + "declaration": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}