diff --git a/package.json b/package.json index a4a0769..e819cc9 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "Testing" ], "activationEvents": [ - "onLanguage:json" + "onStartupFinished" ], "repository": { "type": "git", diff --git a/src/codelens.ts b/src/codelens.ts index 5f10099..363bdc2 100644 --- a/src/codelens.ts +++ b/src/codelens.ts @@ -1,7 +1,16 @@ import * as vscode from 'vscode'; -import {isConfigFile, getASTNode, getRangeFromASTNode} from './helpers'; +import { isConfigFile, getASTNode, getRangeFromASTNode } from './helpers'; import parse from 'json-to-ast'; +export const registerCodeLens = (context: vscode.ExtensionContext) => { + context.subscriptions.push( + vscode.languages.registerCodeLensProvider( + { scheme: 'file', language: 'json' }, + pluginLensProvider + ) + ); +}; + export const pluginLensProvider: vscode.CodeLensProvider = { provideCodeLenses( document: vscode.TextDocument, diff --git a/src/commands.ts b/src/commands.ts new file mode 100644 index 0000000..c7dd8dc --- /dev/null +++ b/src/commands.ts @@ -0,0 +1,20 @@ +import * as vscode from 'vscode'; +import { pluginDocs } from './constants'; + +export const registerCommands = (context: vscode.ExtensionContext) => { + context.subscriptions.push( + vscode.commands.registerCommand('dev-proxy-toolkit.install', async (platform: NodeJS.Platform) => { + const url = `https://aka.ms/devproxy/install/${platform === 'darwin' ? 'macos' : 'windows'}`; + vscode.env.openExternal(vscode.Uri.parse(url)); + })); + + context.subscriptions.push( + vscode.commands.registerCommand( + 'dev-proxy-toolkit.openPluginDoc', + pluginName => { + const target = vscode.Uri.parse(pluginDocs[pluginName].url); + vscode.env.openExternal(target); + } + ) + ); +}; \ No newline at end of file diff --git a/src/detect.ts b/src/detect.ts new file mode 100644 index 0000000..91e069b --- /dev/null +++ b/src/detect.ts @@ -0,0 +1,72 @@ +import { exec } from 'child_process'; +import { DevProxyInstall } from './types'; +import os from 'os'; + +const getExecutablePath = async (filename: string): Promise => { + const command = getFindCommand(); + if (command === '') { + return ''; + } + + try { + return await executeCommand(`${command} ${filename}`); + } catch (error) { + console.error(error); + return ''; + } +}; + +const getFindCommand = () => { + const platform = os.platform(); + let command = ''; + if (platform === 'win32') { + command = 'pwsh.exe -c "where.exe devproxy"'; + } + if (platform === 'darwin') { + command = '$SHELL -c "which devproxy"'; + } + return command; +}; + +const getVersion = async (filePath: string) => { + if (filePath === '') { + return ''; + } + try { + const version = await executeCommand(`${filePath.trim()} --version`); + return version.trim(); + } catch (error) { + console.error(error); + return ""; + } +}; + +export const executeCommand = async (cmd: string): Promise => { + return new Promise((resolve, reject) => { + exec(cmd, (error, stdout, stderr) => { + if (error) { + reject(`exec error: ${error}`); + } else if (stderr) { + reject(`stderr: ${stderr}`); + } else { + resolve(stdout); + } + }); + }); +}; + +export const detectDevProxyInstall = async (): Promise => { + const filePath = await getExecutablePath('devproxy'); + const version = await getVersion(filePath); + const isInstalled = filePath !== ''; + const isBeta = version.includes('beta'); + const platform = os.platform(); + + return { + filePath, + version, + isInstalled, + isBeta, + platform + }; +}; \ No newline at end of file diff --git a/src/diagnostics.ts b/src/diagnostics.ts new file mode 100644 index 0000000..7e6cae4 --- /dev/null +++ b/src/diagnostics.ts @@ -0,0 +1,124 @@ +import * as vscode from 'vscode'; +import parse from "json-to-ast"; +import { pluginSnippets } from "./constants"; +import { getASTNode, getRangeFromASTNode } from "./helpers"; + +export const updateDiagnostics = ( + document: vscode.TextDocument, + collection: vscode.DiagnosticCollection + ): void => { + let diagnostics: vscode.Diagnostic[] = []; + + const documentNode = parse(document.getText()) as parse.ObjectNode; + + // check if urlsToWatch is empty + const urlsToWatchNode = getASTNode( + documentNode.children, + 'Identifier', + 'urlsToWatch' + ); + if ( + urlsToWatchNode && + (urlsToWatchNode.value as parse.ArrayNode).children.length === 0 + ) { + diagnostics.push( + new vscode.Diagnostic( + getRangeFromASTNode(urlsToWatchNode), + 'Add at least one url to watch.', + vscode.DiagnosticSeverity.Error + ) + ); + } + + // check validity of plugins + const pluginsNode = getASTNode( + documentNode.children, + 'Identifier', + 'plugins' + ); + if ( + pluginsNode && + (pluginsNode.value as parse.ArrayNode).children.length !== 0 + ) { + const pluginNodes = (pluginsNode.value as parse.ArrayNode) + .children as parse.ObjectNode[]; + + // check for plugins + if (pluginNodes.length === 0) { + diagnostics.push( + new vscode.Diagnostic( + getRangeFromASTNode(pluginsNode), + 'Add at least one plugin', + vscode.DiagnosticSeverity.Error + ) + ); + } + + // does the plugin have a config section? + pluginNodes.forEach((pluginNode: parse.ObjectNode) => { + const pluginNameNode = getASTNode( + pluginNode.children, + 'Identifier', + 'name' + ); + const pluginName = (pluginNameNode?.value as parse.LiteralNode) + .value as string; + const enabledNode = getASTNode( + pluginNode.children, + 'Identifier', + 'enabled' + ); + const isEnabled = (enabledNode?.value as parse.LiteralNode) + .value as boolean; + const pluginSnippet = pluginSnippets[pluginName]; + const requiresConfig = pluginSnippet.config + ? pluginSnippet.config.required + : false; + + if (requiresConfig) { + // check to see if the plugin has a config section + const configSectionNode = getASTNode( + pluginNode.children, + 'Identifier', + 'configSection' + ); + if (!configSectionNode) { + // there is no config section defined on the plugin instance + diagnostics.push( + new vscode.Diagnostic( + getRangeFromASTNode(pluginNode), + `${pluginName} requires a config section.`, + isEnabled + ? vscode.DiagnosticSeverity.Error + : vscode.DiagnosticSeverity.Warning + ) + ); + } else { + // check to see if the config section is in the document + const configSectionName = ( + configSectionNode.value as parse.LiteralNode + ).value as string; + const configSection = getASTNode( + documentNode.children, + 'Identifier', + configSectionName + ); + + if (!configSection) { + diagnostics.push( + new vscode.Diagnostic( + getRangeFromASTNode(configSectionNode.value), + `${configSectionName} config section is missing. Use '${pluginSnippet.config?.name}' snippet to create one.`, + isEnabled + ? vscode.DiagnosticSeverity.Error + : vscode.DiagnosticSeverity.Warning + ) + ); + } + } + } + }); + } + + collection.set(document.uri, diagnostics); + }; \ No newline at end of file diff --git a/src/documents.ts b/src/documents.ts new file mode 100644 index 0000000..96b4582 --- /dev/null +++ b/src/documents.ts @@ -0,0 +1,24 @@ +import * as vscode from 'vscode'; +import { isConfigFile } from './helpers'; +import { updateDiagnostics } from './diagnostics'; + +export const registerDocumentListeners = (context: vscode.ExtensionContext, collection: vscode.DiagnosticCollection) => { + context.subscriptions.push( + vscode.workspace.onDidOpenTextDocument(document => { + if (!isConfigFile(document)) { + return; + } + updateDiagnostics(document, collection); + }) + ); + + context.subscriptions.push( + vscode.workspace.onDidChangeTextDocument(event => { + if (!isConfigFile(event.document)) { + collection.delete(event.document.uri); + return; + } + updateDiagnostics(event.document, collection); + }) + ); +}; \ No newline at end of file diff --git a/src/extension.ts b/src/extension.ts index 035d104..008a26c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,167 +1,19 @@ import * as vscode from 'vscode'; -import parse from 'json-to-ast'; -import {pluginDocs, pluginSnippets} from './constants'; -import {getASTNode, getRangeFromASTNode, isConfigFile} from './helpers'; -import {pluginLensProvider} from './codelens'; +import { registerCommands } from './commands'; +import { detectDevProxyInstall } from './detect'; +import { handleStartNotification, processNotification } from './notifications'; +import { registerDocumentListeners } from './documents'; +import { registerCodeLens } from './codelens'; -export const activate = (context: vscode.ExtensionContext) => { +export const activate = async (context: vscode.ExtensionContext) => { const collection = vscode.languages.createDiagnosticCollection('Dev Proxy'); - - context.subscriptions.push( - vscode.workspace.onDidOpenTextDocument(document => { - if (!isConfigFile(document)) { - return; - } - updateDiagnostics(document, collection); - }) - ); - - context.subscriptions.push( - vscode.workspace.onDidChangeTextDocument(event => { - if (!isConfigFile(event.document)) { - collection.delete(event.document.uri); - return; - } - updateDiagnostics(event.document, collection); - }) - ); - - context.subscriptions.push( - vscode.languages.registerCodeLensProvider( - {scheme: 'file', language: 'json'}, - pluginLensProvider - ) - ); - - context.subscriptions.push( - vscode.commands.registerCommand( - 'dev-proxy-toolkit.openPluginDoc', - pluginName => { - const target = vscode.Uri.parse(pluginDocs[pluginName].url); - vscode.env.openExternal(target); - } - ) - ); -}; - -const updateDiagnostics = ( - document: vscode.TextDocument, - collection: vscode.DiagnosticCollection -): void => { - let diagnostics: vscode.Diagnostic[] = []; - - const documentNode = parse(document.getText()) as parse.ObjectNode; - - // check if urlsToWatch is empty - const urlsToWatchNode = getASTNode( - documentNode.children, - 'Identifier', - 'urlsToWatch' - ); - if ( - urlsToWatchNode && - (urlsToWatchNode.value as parse.ArrayNode).children.length === 0 - ) { - diagnostics.push( - new vscode.Diagnostic( - getRangeFromASTNode(urlsToWatchNode), - 'Add at least one url to watch.', - vscode.DiagnosticSeverity.Error - ) - ); - } - - // check validity of plugins - const pluginsNode = getASTNode( - documentNode.children, - 'Identifier', - 'plugins' - ); - if ( - pluginsNode && - (pluginsNode.value as parse.ArrayNode).children.length !== 0 - ) { - const pluginNodes = (pluginsNode.value as parse.ArrayNode) - .children as parse.ObjectNode[]; - - // check for plugins - if (pluginNodes.length === 0) { - diagnostics.push( - new vscode.Diagnostic( - getRangeFromASTNode(pluginsNode), - 'Add at least one plugin', - vscode.DiagnosticSeverity.Error - ) - ); - } - - // does the plugin have a config section? - pluginNodes.forEach((pluginNode: parse.ObjectNode) => { - const pluginNameNode = getASTNode( - pluginNode.children, - 'Identifier', - 'name' - ); - const pluginName = (pluginNameNode?.value as parse.LiteralNode) - .value as string; - const enabledNode = getASTNode( - pluginNode.children, - 'Identifier', - 'enabled' - ); - const isEnabled = (enabledNode?.value as parse.LiteralNode) - .value as boolean; - const pluginSnippet = pluginSnippets[pluginName]; - const requiresConfig = pluginSnippet.config - ? pluginSnippet.config.required - : false; - - if (requiresConfig) { - // check to see if the plugin has a config section - const configSectionNode = getASTNode( - pluginNode.children, - 'Identifier', - 'configSection' - ); - if (!configSectionNode) { - // there is no config section defined on the plugin instance - diagnostics.push( - new vscode.Diagnostic( - getRangeFromASTNode(pluginNode), - `${pluginName} requires a config section.`, - isEnabled - ? vscode.DiagnosticSeverity.Error - : vscode.DiagnosticSeverity.Warning - ) - ); - } else { - // check to see if the config section is in the document - const configSectionName = ( - configSectionNode.value as parse.LiteralNode - ).value as string; - const configSection = getASTNode( - documentNode.children, - 'Identifier', - configSectionName - ); - - if (!configSection) { - diagnostics.push( - new vscode.Diagnostic( - getRangeFromASTNode(configSectionNode.value), - `${configSectionName} config section is missing. Use '${pluginSnippet.config?.name}' snippet to create one.`, - isEnabled - ? vscode.DiagnosticSeverity.Error - : vscode.DiagnosticSeverity.Warning - ) - ); - } - } - } - }); - } - - collection.set(document.uri, diagnostics); + registerDocumentListeners(context, collection); + registerCommands(context); + registerCodeLens(context); + + const devProxyInstall = await detectDevProxyInstall(); + const notification = handleStartNotification(devProxyInstall); + processNotification(notification); }; -export const deactivate = () => {}; +export const deactivate = () => { }; diff --git a/src/notifications.ts b/src/notifications.ts new file mode 100644 index 0000000..46d7755 --- /dev/null +++ b/src/notifications.ts @@ -0,0 +1,23 @@ +import * as vscode from 'vscode'; +import { DevProxyInstall } from './types'; + +export const handleStartNotification = (devProxyInstall: DevProxyInstall) => { + if (!devProxyInstall.isInstalled) { + return () => { + const message = `Dev Proxy is not installed, or not in PATH.`; + return { + message, + show: async () => { + const result = await vscode.window.showInformationMessage(message, 'Install'); + if (result === 'Install') { + await vscode.commands.executeCommand('dev-proxy-toolkit.install', devProxyInstall.platform); + }; + } + }; + }; + }; +}; + +export const processNotification = (notification: (() => { message: string; show: () => Promise; }) | undefined) => { + if (notification) { notification().show(); }; +}; diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index 3135ba9..bcb4980 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -9,41 +9,16 @@ import { } from '../helpers'; import * as path from 'path'; import parse from 'json-to-ast'; -import {createCodeLensForPluginNodes} from '../codelens'; - -suite('Extension Test Suite', () => { - test('should activate when untitled JSON file is opened', async () => { - const extensionId = 'garrytrinder.dev-proxy-toolkit'; - await vscode.workspace.openTextDocument({ - language: 'json', - content: '', - }); - await sleep(1000); - - const expected = true; - const actual = vscode.extensions.getExtension(extensionId)?.isActive; - assert.strictEqual(actual, expected); - }); - - test('should activate when JSON file is opened from disk', async () => { - const extensionId = 'garrytrinder.dev-proxy-toolkit'; - const fileName = 'foo.json'; - const filePath = path.resolve(__dirname, 'examples', fileName); - await vscode.workspace.openTextDocument(filePath); - await sleep(1000); - - const expected = true; - const actual = vscode.extensions.getExtension(extensionId)?.isActive; - assert.strictEqual(actual, expected); - }); -}); +import { createCodeLensForPluginNodes } from '../codelens'; +import { DevProxyInstall } from '../types'; +import { handleStartNotification } from '../notifications'; suite('urlsToWatch', () => { test('should show error when opening document with no urlsToWatch found', async () => { const fileName = 'config-urls-to-watch-required.json'; const filePath = path.resolve(__dirname, 'examples', fileName); const document = await vscode.workspace.openTextDocument(filePath); - const diagnostics = vscode.languages.getDiagnostics(document.uri); + const diagnostics = vscode.languages.getDiagnostics(document.uri); const expected = { message: 'Add at least one url to watch.', @@ -258,3 +233,79 @@ suite('plugins', () => { assert.strictEqual(actual, expected); }); }); + +suite('notifications', () => { + test('should show install notification when devproxy is not installed on mac', async () => { + const devProxyInstall: DevProxyInstall = { + filePath: '', + version: '', + platform: 'darwin', + isInstalled: false, + isBeta: false, + }; + const notification = handleStartNotification(devProxyInstall); + + const expected = 'Dev Proxy is not installed, or not in PATH.'; + const actual = notification !== undefined && notification().message; + assert.strictEqual(actual, expected); + }); + + test('should show install notification when devproxy is not installed on windows', async () => { + const devProxyInstall: DevProxyInstall = { + filePath: '', + version: '', + platform: 'win32', + isInstalled: false, + isBeta: false, + }; + const notification = handleStartNotification(devProxyInstall); + + const expected = 'Dev Proxy is not installed, or not in PATH.'; + const actual = notification !== undefined && notification().message; + assert.strictEqual(actual, expected); + }); + + test('should not show install notification when devproxy is installed on mac', async () => { + const devProxyInstall: DevProxyInstall = { + filePath: 'somepath/devproxy', + version: '0.1.0', + platform: 'darwin', + isInstalled: true, + isBeta: false, + }; + const notification = handleStartNotification(devProxyInstall); + + const expected = true; + const actual = notification === undefined; + }); + + test('should not show install notification when devproxy is installed on windows', async () => { + const devProxyInstall: DevProxyInstall = { + filePath: 'somepath/devproxy', + version: '0.1.0', + platform: 'win32', + isInstalled: true, + isBeta: false, + }; + const notification = handleStartNotification(devProxyInstall); + + const expected = true; + const actual = notification === undefined; + assert.strictEqual(actual, expected); + }); + + test('should not show install notification when running in unsupported operating system', async () => { + const devProxyInstall: DevProxyInstall = { + filePath: 'somepath/devproxy', + version: '0.1.0', + platform: 'linux', + isInstalled: true, + isBeta: false, + }; + const notification = handleStartNotification(devProxyInstall); + + const expected = true; + const actual = notification === undefined; + assert.strictEqual(actual, expected); + }); +}); \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 6ba361c..12cb2f0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -16,3 +16,11 @@ export type PluginSnippets = { config?: PluginConfig; }; }; + +export type DevProxyInstall = { + filePath: string; + version: string; + isInstalled: boolean; + isBeta: boolean; + platform: NodeJS.Platform; +}; \ No newline at end of file