From 6cddb521c74c3eebccb2d35b0f560773153d82a9 Mon Sep 17 00:00:00 2001 From: Adam Driscoll Date: Sun, 29 Jun 2025 11:57:26 -0500 Subject: [PATCH] Local Development --- package.json | 15 ++- src/Universal.VSCode.psm1 | 9 ++ src/commands/downloadUniversal.ts | 70 ------------ src/commands/localDev.ts | 174 ++++++++++++++++++++++++++++++ src/connection-treeview.ts | 14 ++- src/extension.ts | 13 +-- src/settings.ts | 6 +- src/types.ts | 8 ++ src/universal.ts | 28 +++-- 9 files changed, 245 insertions(+), 92 deletions(-) delete mode 100644 src/commands/downloadUniversal.ts create mode 100644 src/commands/localDev.ts diff --git a/package.json b/package.json index c41617d..0a8af56 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "displayName": "PowerShell Universal", "description": "Visual Studio Code tools for PowerShell Universal", "publisher": "ironmansoftware", - "version": "5.5.1", + "version": "5.6.0", "engines": { "vscode": "^1.72.0" }, @@ -147,9 +147,18 @@ }, { "command": "powershell-universal.startUniversal", - "title": "PowerShell Universal: Start", + "title": "PowerShell Universal: Start Local Development", "icon": "$(play)" }, + { + "command": "powershell-universal.clearLocalDatabase", + "title": "PowerShell Universal: Clear Local Development Database", + "icon": "$(delete)" + }, + { + "command": "powershell-universal.connectLocalDevModule", + "title": "PowerShell Universal: Connect Local Development Module" + }, { "command": "powershell-universal.manageDashboards", "title": "PowerShell Universal: Manage Apps", @@ -624,4 +633,4 @@ "temp": "^0.9.1", "yaml": "^1.10.0" } -} +} \ No newline at end of file diff --git a/src/Universal.VSCode.psm1 b/src/Universal.VSCode.psm1 index 9d65a57..d3a38dd 100644 --- a/src/Universal.VSCode.psm1 +++ b/src/Universal.VSCode.psm1 @@ -16,4 +16,13 @@ function Install-UniversalModule { Install-Module @Parameters -Scope CurrentUser -Force -AllowClobber -ErrorAction SilentlyContinue Import-Module @Parameters -ErrorAction SilentlyContinue } +} + +function Import-LocalDevelopmentModule { + param($Version, $Port) + + $ModulePath = [IO.Path]::Combine($ENV:USERPROFILE, ".psu", $Version, "Modules", "Universal", "Universal.psd1") + Import-Module $ModulePath + + Connect-PSUServer -Url "http://localhost:$Port" -Credential (New-Object System.Management.Automation.PSCredential("admin", (ConvertTo-SecureString "admin" -AsPlainText -Force))) } \ No newline at end of file diff --git a/src/commands/downloadUniversal.ts b/src/commands/downloadUniversal.ts deleted file mode 100644 index 57e346e..0000000 --- a/src/commands/downloadUniversal.ts +++ /dev/null @@ -1,70 +0,0 @@ -import * as vscode from 'vscode'; -const os = require('os'); -const https = require('https'); -const fs = require('fs'); -const temp = require('temp'); -var AdmZip = require('adm-zip'); -const path = require('path'); -import { SetServerPath } from './../settings' - -export const downloadUniversal = async () => { - temp.track(); - - let platform = ''; - switch(os.platform()) - { - case 'darwin': - platform = 'osx'; - break; - case 'linux': - platform = 'linux'; - break; - case 'win32': - platform = 'win7'; - break; - default: - vscode.window.showErrorMessage("Unsupported platform"); - return; - } - - return new Promise((resolve, reject) => { - https.get('https://imsreleases.blob.core.windows.net/universal/production/version.txt', (resp : any) => { - let data = ''; - - // A chunk of data has been recieved. - resp.on('data', (chunk : string) => { - data += chunk; - }); - - // The whole response has been received. Print out the result. - resp.on('end', () => { - temp.open('universal', function(err : any, info : any) { - const file = fs.createWriteStream(info.path); - - file.on('finish', function () { - file.close(); - - const universalPath = path.join(process.env.APPDATA, "PowerShellUniversal"); - - var zip = new AdmZip(info.path); - zip.extractAllTo(universalPath, true); - - SetServerPath(path.join(process.env.APPDATA, "PowerShellUniversal")).then(() => resolve(null)); - }); - - https.get(`https://imsreleases.blob.core.windows.net/universal/production/${data}/Universal.${platform}-x64.${data}.zip`, function(response : any) { - response.pipe(file); - }); - }); - }); - - }).on("error", (err : any) => { - vscode.window.showErrorMessage(err.message); - reject(); - }); - }) -} - -export const downloadUniversalCommand = () => { - return vscode.commands.registerCommand('powershell-universal.downloadUniversal', downloadUniversal); -} diff --git a/src/commands/localDev.ts b/src/commands/localDev.ts new file mode 100644 index 0000000..e8d7959 --- /dev/null +++ b/src/commands/localDev.ts @@ -0,0 +1,174 @@ +import * as vscode from 'vscode'; +import { LocalDevConfig } from '../types'; +import { Container } from '../container'; +const os = require('os'); +const https = require('https'); +const fs = require('fs'); +const temp = require('temp'); +var AdmZip = require('adm-zip'); +const path = require('path'); + +export const registerLocalDevCommands = (context: vscode.ExtensionContext) => { + context.subscriptions.push(vscode.commands.registerCommand('powershell-universal.downloadUniversal', downloadUniversal)); + context.subscriptions.push(vscode.commands.registerCommand('powershell-universal.startUniversal', () => startPowerShellUniversal(context))); + context.subscriptions.push(vscode.commands.registerCommand('powershell-universal.clearLocalDatabase', clearLocalDatabase)); + context.subscriptions.push(vscode.commands.registerCommand('powershell-universal.connectLocalDevModule', () => connectModule(context))); +}; + +const startPowerShellUniversal = async (context: vscode.ExtensionContext) => { + const config = getPsuDevConfig(); + if (!config) { + return; + } + + const psuPath = path.join(process.env.USERPROFILE, ".psu"); + if (!fs.existsSync(psuPath)) { + await downloadUniversal(); + } + + let exe = 'Universal.Server.exe'; + if (os.platform() === 'win32') { + exe = 'Universal.Server.exe'; + } else { + exe = 'Universal.Server'; + } + + const universalPath = path.join(psuPath, config.version, exe); + + if (vscode.workspace.workspaceFolders === undefined) { + vscode.window.showErrorMessage("No workspace folder is open. Please open a workspace folder and define a psu.dev.config to download Universal."); + return; + } + + config.databaseType = config.databaseType || "SQLite"; + let connectionString = config.databaseConnectionString || null; + if (connectionString === null) { + const databasePath = path.join(process.env.USERPROFILE, ".psu", "databases", vscode.workspace.name, "psu.db"); + connectionString = `Data Source=${databasePath}`; + } + + vscode.window.createTerminal({ + name: `PowerShell Universal ${config.version}`, + shellPath: universalPath, + shellArgs: ["Mode", "Dev"], + env: { + "Data__RepositoryPath": vscode.workspace.workspaceFolders[0].uri.fsPath, + "Data__ConnectionString": connectionString, + "Plugin__0": config.databaseType, + "PSUDefaultAdminPassword": "admin", + "PSUDefaultAdminName": "admin", + ...config.env, + } + }).show(); + + if (config.browserPort) { + vscode.env.openExternal(vscode.Uri.parse(`http://localhost:${config.browserPort || 5000}`)); + } + + context.globalState.update("psu.dev.config", config); + + context.globalState.update("universal.connection", "Local Development"); + vscode.commands.executeCommand('powershell-universal.refreshAllTreeViews'); +}; + +const clearLocalDatabase = () => { + const dbPath = path.join(process.env.USERPROFILE, ".psu", "databases", vscode.workspace.name, "psu.db"); + if (fs.existsSync(dbPath)) { + fs.unlinkSync(dbPath); + + vscode.window.showInformationMessage(`Local development database cleared at.${dbPath}`); + } +}; + +const downloadUniversal = async () => { + vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: "Downloading Universal" + }, async (progress) => { + temp.track(); + + let platform = ''; + switch (os.platform()) { + case 'darwin': + platform = 'osx'; + break; + case 'linux': + platform = 'linux'; + break; + case 'win32': + platform = 'win'; + break; + default: + vscode.window.showErrorMessage("Unsupported platform"); + return; + } + + const config = getPsuDevConfig(); + if (!config) { + return; + } + + progress.report({ increment: 75, message: `Extracting PowerShell Universal ${config.version}` }); + + var tempPath = path.join(temp.dir, `Universal.${platform}-x64.${config.version}.zip`); + await downloadFile(`https://imsreleases.blob.core.windows.net/universal/production/${config.version}/Universal.${platform}-x64.${config.version}.zip`, tempPath); + + const universalPath = path.join(process.env.USERPROFILE, ".psu", config.version); + + var zip = new AdmZip(tempPath); + zip.extractAllTo(universalPath, true); + + progress.report({ increment: 100, message: `PowerShell Universal ${config.version} downloaded and extracted to ${universalPath}` }); + }); +}; + +const downloadFile = (url: string, dest: string) => { + return new Promise((resolve, reject) => { + const file = fs.createWrite(dest); + file.on('finish', () => { + file.close(resolve); + }); + + file.on('error', (err: any) => { + fs.unlink(dest, () => reject(err)); + }); + + https.get(url, (response: any) => { + if (response.statusCode !== 200) { + return reject(new Error(`Failed to download file: ${response.statusCode}`)); + } + response.pipe(file); + }).on('error', (err: any) => { + fs.unlink(dest, () => reject(err)); + }); + }); +}; + +const getPsuDevConfig = (): LocalDevConfig | null => { + if (vscode.workspace.workspaceFolders === undefined) { + vscode.window.showErrorMessage("No workspace folder is open. Please open a workspace folder and define a psu.dev.config to download Universal."); + return null; + } + + const devDevPath = path.join(vscode.workspace.workspaceFolders[0].uri.fsPath, "psu.dev.json"); + if (!fs.existsSync(devDevPath)) { + vscode.window.showErrorMessage("No psu.dev.json file found in the workspace. Please create one to start PowerShell Universal."); + return null; + } + + const devJson = fs.readFileSync(devDevPath, 'utf8'); + return JSON.parse(devJson); +}; + +const connectModule = async (context: vscode.ExtensionContext) => { + + const config = getPsuDevConfig(); + + if (!config) { + vscode.window.showErrorMessage("No psu.dev.json file found in the workspace. Please create one to connect to PowerShell Universal."); + return; + } + + Container.universal.sendTerminalCommand(`Import-Module (Join-Path '${__dirname}' 'Universal.VSCode.psm1')`); + Container.universal.sendTerminalCommand(`Import-LocalDevelopmentModule -Version '${config.version}' -Port '${config.browserPort || 5000}'`); +} \ No newline at end of file diff --git a/src/connection-treeview.ts b/src/connection-treeview.ts index 4364032..d9171b6 100644 --- a/src/connection-treeview.ts +++ b/src/connection-treeview.ts @@ -1,5 +1,6 @@ import * as vscode from 'vscode'; import { IConnection, load } from './settings'; +import { LocalDevConfig } from './types'; export class ConnectionTreeViewProvider implements vscode.TreeDataProvider { @@ -16,7 +17,7 @@ export class ConnectionTreeViewProvider implements vscode.TreeDataProvider("psu.dev.config"); + + if (localDevConfig) { + items.push(new ConnectionTreeItem({ + name: "Local Development", + url: `http://localhost:${localDevConfig.browserPort || 5000}`, + }, !connectionName || connectionName === 'Local Development')); } return items; diff --git a/src/extension.ts b/src/extension.ts index 99b542f..9d4908a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,13 +1,10 @@ -// The module 'vscode' contains the VS Code extensibility API -// Import the module and reference it with the alias vscode in your code below import * as vscode from 'vscode'; import { Universal } from './universal'; import { Container } from './container'; import { DashboardTreeViewProvider } from './dashboard-treeview'; import { InfoTreeViewProvider } from './info-treeview'; import help from './commands/helpCommand'; -import { downloadUniversalCommand, downloadUniversal } from './commands/downloadUniversal'; -import { load, SetUrl } from './settings'; +import { load } from './settings'; import { registerDashboardCommands } from './commands/dashboards'; import { ApiTreeViewProvider } from './api-treeview'; import { registerEndpointCommands } from './commands/endpoints'; @@ -23,6 +20,8 @@ import { registerTerminalCommands } from './commands/terminals'; import { PlatformTreeViewProvider } from './platform-treeview'; import { registerModuleCommands } from './commands/modules'; import { registerDebuggerCommands } from './commands/debugger'; +import { registerLocalDevCommands } from './commands/localDev'; +import { LocalDevConfig } from './types'; export async function activate(context: vscode.ExtensionContext) { registerConnectCommands(context); @@ -102,7 +101,7 @@ export async function activate(context: vscode.ExtensionContext) { vscode.commands.executeCommand('powershell-universal.refreshPlatformTreeView'); }); - downloadUniversalCommand(); + registerLocalDevCommands(context); help(); registerDashboardCommands(context); registerEndpointCommands(context); @@ -114,7 +113,9 @@ export async function activate(context: vscode.ExtensionContext) { registerModuleCommands(context); registerDebuggerCommands(context); - if (Container.universal.hasConnection()) { + var localDevConfig = context.globalState.get("psu.dev.config"); + + if (Container.universal.hasConnection() && !localDevConfig) { if (await Container.universal.waitForAlive()) { await Container.universal.connectDebugger(); await Container.universal.installAndLoadModule(); diff --git a/src/settings.ts b/src/settings.ts index 52651a2..7bca972 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -11,10 +11,10 @@ export interface ISettings { export interface IConnection { name: string; - appToken: string; + appToken?: string | null; url: string; - allowInvalidCertificate: boolean; - windowsAuth: boolean; + allowInvalidCertificate?: boolean; + windowsAuth?: boolean; } export function load(): ISettings { diff --git a/src/types.ts b/src/types.ts index a3e084f..77c33b5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -185,4 +185,12 @@ export type Runspace = { processId: number; } +export type LocalDevConfig = { + version: string; + databaseType: string | null; + databaseConnectionString: string | null; + env?: { [key: string]: string | null }; + browserPort?: number; +}; + export type XMLHttpRequestResponseType = {} \ No newline at end of file diff --git a/src/universal.ts b/src/universal.ts index 1c04d52..ccb00ec 100644 --- a/src/universal.ts +++ b/src/universal.ts @@ -1,5 +1,5 @@ import * as vscode from 'vscode'; -import { Dashboard, DashboardDiagnostics, Settings, Endpoint, Script, Job, ScriptParameter, JobPagedViewModel, JobLog, FileSystemItem, DashboardPage, Terminal, TerminalInstance, Module, Process, Runspace, Repository, Folder } from './types'; +import { Dashboard, DashboardDiagnostics, Settings, Endpoint, Script, Job, ScriptParameter, JobPagedViewModel, JobLog, FileSystemItem, DashboardPage, Terminal, TerminalInstance, Module, Process, Runspace, Repository, Folder, LocalDevConfig } from './types'; import axios, { AxiosPromise } from 'axios'; import { load, SetAppToken, SetUrl } from './settings'; import { Container } from './container'; @@ -43,13 +43,20 @@ export class Universal { if (connectionName && connectionName !== 'Default') { const connection = settings.connections.find(m => m.name === connectionName); if (connection) { - appToken = connection.appToken; + appToken = connection.appToken || ''; url = connection.url; rejectUnauthorized = !connection.allowInvalidCertificate; - windowsAuth = connection.windowsAuth; + windowsAuth = connection.windowsAuth || false; } } + let basicAuth = ''; + var localDevConfig = this.context.globalState.get("psu.dev.config"); + if (connectionName === 'Local Development' && localDevConfig) { + url = `http://localhost:${localDevConfig.browserPort || 5000}`; + basicAuth = Buffer.from(`admin:admin`).toString('base64'); + } + https.globalAgent.options.rejectUnauthorized = rejectUnauthorized; const agent = new https.Agent({ rejectUnauthorized @@ -61,6 +68,9 @@ export class Universal { if (windowsAuth) { headers['X-Windows-Auth'] = ''; + } + else if (basicAuth) { + headers['Authorization'] = `Basic ${basicAuth}`; } else { headers['Authorization'] = `Bearer ${appToken}`; }; @@ -79,8 +89,8 @@ export class Universal { return new Promise((resolve) => { this.request('/api/v1/version', 'GET')?.then(x => resolve(x.data)).catch(x => { resolve("failed"); - }) - }) + }); + }); } async getReleasedVersion() { @@ -91,7 +101,9 @@ export class Universal { async installAndLoadModule() { try { const settings = load(); - if (settings.checkModules) { + var localDevConfig = this.context.globalState.get("psu.dev.config"); + + if (settings.checkModules && !localDevConfig) { const version = await Container.universal.getVersion(); var appToken = settings.appToken; @@ -101,7 +113,7 @@ export class Universal { if (connectionName && connectionName !== 'Default') { const connection = settings.connections.find(m => m.name === connectionName); if (connection) { - appToken = connection.appToken; + appToken = connection.appToken || ''; url = connection.url; } } @@ -721,7 +733,7 @@ export class Universal { if (this.connectionName && this.connectionName !== 'Default') { const connection = settings.connections.find(m => m.name === this.connectionName); if (connection) { - appToken = connection.appToken; + appToken = connection.appToken || ''; url = connection.url; rejectUnauthorized = !connection.allowInvalidCertificate; }