diff --git a/.vscode/launch.json b/.vscode/launch.json index 6a33081f..356d879b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -75,7 +75,7 @@ "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", "args": [ "--timeout", - "0" + "987654" ], "internalConsoleOptions": "openOnSessionStart" } @@ -104,4 +104,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/package-lock.json b/package-lock.json index e3ccfa37..af747242 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "iconv-lite": "0.4.24", "jszip": "^3.10.1", "just-throttle": "^4.0.1", + "md5": "^2.3.0", "net": "^1.0.2", "node-cache": "^4.2.0", "node-ssdp": "^4.0.0", @@ -55,8 +56,10 @@ "@types/clone-deep": "^4.0.3", "@types/fs-extra": "^5.0.4", "@types/glob": "^7.1.1", + "@types/lodash": "^4.17.7", + "@types/md5": "^2.3.5", "@types/mocha": "^7.0.2", - "@types/node": "^12.12.0", + "@types/node": "^20.14.10", "@types/node-ssdp": "^3.3.0", "@types/prompt": "^1.1.2", "@types/resolve": "^1.20.6", @@ -72,11 +75,12 @@ "chalk": "^4.1.2", "changelog-parser": "^2.8.0", "coveralls-next": "^4.2.0", - "dayjs": "^1.11.7", + "dayjs": "^1.11.12", "deferred": "^0.7.11", "eslint": "^8.10.0", "eslint-plugin-github": "^4.3.5", "eslint-plugin-no-only-tests": "^2.6.0", + "lodash": "^4.17.21", "mocha": "^9.1.3", "node-notifier": "^10.0.1", "nyc": "^15.0.0", @@ -1410,6 +1414,18 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/lodash": { + "version": "4.17.7", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz", + "integrity": "sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==", + "dev": true + }, + "node_modules/@types/md5": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@types/md5/-/md5-2.3.5.tgz", + "integrity": "sha512-/i42wjYNgE6wf0j2bcTX6kuowmdL/6PE4IVitMpm2eYKBUuYCprdcWVK+xEF0gcV6ufMCRhtxmReGfc6hIK7Jw==", + "dev": true + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -1428,9 +1444,12 @@ "dev": true }, "node_modules/@types/node": { - "version": "12.20.55", - "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz", - "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==" + "version": "20.14.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.10.tgz", + "integrity": "sha512-MdiXf+nDuMvY0gJKxyfZ7/6UFsETO7mGKF54MVD/ekJS6HdFtpZFBgrh6Pseu64XTb2MLyFPlbW6hj8HYRQNOQ==", + "dependencies": { + "undici-types": "~5.26.4" + } }, "node_modules/@types/node-ssdp": { "version": "3.3.1", @@ -2982,6 +3001,14 @@ "changelog-parser": "bin/cli.js" } }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "engines": { + "node": "*" + } + }, "node_modules/check-error": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", @@ -3435,6 +3462,14 @@ "node": ">= 8" } }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "engines": { + "node": "*" + } + }, "node_modules/css-select": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", @@ -3523,9 +3558,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.7", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.7.tgz", - "integrity": "sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ==" + "version": "1.11.12", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.12.tgz", + "integrity": "sha512-Rt2g+nTbLlDWZTwwrIXjy9MeiZmSDI375FvZs72ngxx8PDC6YXOeR3q5LAuPzjZQxhiWdRKac7RKV+YyQYfYIg==" }, "node_modules/debounce": { "version": "1.2.1", @@ -6120,6 +6155,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -7036,6 +7076,16 @@ "node": ">= 12" } }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, "node_modules/mdurl": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", @@ -10611,6 +10661,11 @@ "integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==", "dev": true }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + }, "node_modules/universalify": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", diff --git a/package.json b/package.json index 971acba9..41235b51 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "iconv-lite": "0.4.24", "jszip": "^3.10.1", "just-throttle": "^4.0.1", + "md5": "^2.3.0", "net": "^1.0.2", "node-cache": "^4.2.0", "node-ssdp": "^4.0.0", @@ -97,8 +98,10 @@ "@types/clone-deep": "^4.0.3", "@types/fs-extra": "^5.0.4", "@types/glob": "^7.1.1", + "@types/lodash": "^4.17.7", + "@types/md5": "^2.3.5", "@types/mocha": "^7.0.2", - "@types/node": "^12.12.0", + "@types/node": "^20.14.10", "@types/node-ssdp": "^3.3.0", "@types/prompt": "^1.1.2", "@types/resolve": "^1.20.6", @@ -114,11 +117,12 @@ "chalk": "^4.1.2", "changelog-parser": "^2.8.0", "coveralls-next": "^4.2.0", - "dayjs": "^1.11.7", + "dayjs": "^1.11.12", "deferred": "^0.7.11", "eslint": "^8.10.0", "eslint-plugin-github": "^4.3.5", "eslint-plugin-no-only-tests": "^2.6.0", + "lodash": "^4.17.21", "mocha": "^9.1.3", "node-notifier": "^10.0.1", "nyc": "^15.0.0", @@ -1810,6 +1814,12 @@ "description": "Path to the BrighterScript module to use for the BrightScript and BrighterScript language features", "scope": "resource" }, + "brightscript.npmCacheRetentionDays": { + "type": "number", + "description": "How long should the extension keep around unused extension-managed npm packages such as brighterscript", + "default": 45, + "scope": "resource" + }, "brightscript.enableLanguageServer": { "type": "boolean", "description": "Enable the Language Server, which includes things like syntax checking, intellisense, completions, etc.", @@ -3084,6 +3094,12 @@ "title": "Open SceneGraph Inspector In New Window", "category": "BrighterScript", "icon": "$(link-external)" + }, + { + "command": "extension.brightscript.clearNpmPackageCache", + "title": "Clear the extension's local node_modules cache", + "category": "BrighterScript", + "icon": "$(link-external)" } ], "keybindings": [ diff --git a/src/ActiveDeviceManager.ts b/src/ActiveDeviceManager.ts index cc50e90b..cfb46576 100644 --- a/src/ActiveDeviceManager.ts +++ b/src/ActiveDeviceManager.ts @@ -261,8 +261,8 @@ class RokuFinder extends EventEmitter { } private readonly client: Client; - private intervalId: NodeJS.Timer | null = null; - private timeoutId: NodeJS.Timer | null = null; + private intervalId: NodeJS.Timeout | null = null; + private timeoutId: NodeJS.Timeout | null = null; private running = false; public start(timeout: number) { diff --git a/src/BrightScriptCommands.spec.ts b/src/BrightScriptCommands.spec.ts index 5e135554..dfa80c38 100644 --- a/src/BrightScriptCommands.spec.ts +++ b/src/BrightScriptCommands.spec.ts @@ -22,7 +22,7 @@ describe('BrightScriptFileUtils ', () => { let languagesMock; beforeEach(() => { - commands = new BrightScriptCommands({} as any, {} as any, {} as any, {} as any, {} as any); + commands = new BrightScriptCommands({} as any, {} as any, {} as any, {} as any, {} as any, {} as any); commandsMock = sinon.mock(commands); languagesMock = sinon.mock(vscode.languages); }); diff --git a/src/BrightScriptCommands.ts b/src/BrightScriptCommands.ts index 2a8a67ad..c81e722a 100644 --- a/src/BrightScriptCommands.ts +++ b/src/BrightScriptCommands.ts @@ -14,6 +14,8 @@ import type { ActiveDeviceManager } from './ActiveDeviceManager'; import * as xml2js from 'xml2js'; import { firstBy } from 'thenby'; import type { UserInputManager } from './managers/UserInputManager'; +import { clearNpmPackageCacheCommand } from './commands/ClearNpmPackageCacheCommand'; +import type { LocalPackageManager } from './managers/LocalPackageManager'; export class BrightScriptCommands { @@ -22,7 +24,8 @@ export class BrightScriptCommands { private whatsNewManager: WhatsNewManager, private context: vscode.ExtensionContext, private activeDeviceManager: ActiveDeviceManager, - private userInputManager: UserInputManager + private userInputManager: UserInputManager, + private localPackageManager: LocalPackageManager ) { this.fileUtils = new BrightScriptFileUtils(); } @@ -36,9 +39,10 @@ export class BrightScriptCommands { public registerCommands() { brighterScriptPreviewCommand.register(this.context); - languageServerInfoCommand.register(this.context); + languageServerInfoCommand.register(this.context, this.localPackageManager); captureScreenshotCommand.register(this.context, this); rekeyAndPackageCommand.register(this.context, this, this.userInputManager); + clearNpmPackageCacheCommand.register(this.context, this.localPackageManager); this.registerGeneralCommands(); diff --git a/src/GlobalStateManager.ts b/src/GlobalStateManager.ts index 29c2f12d..0145b627 100644 --- a/src/GlobalStateManager.ts +++ b/src/GlobalStateManager.ts @@ -14,6 +14,7 @@ export class GlobalStateManager { sendRemoteTextHistory: 'sendRemoteTextHistory', debugProtocolPopupSnoozeUntilDate: 'debugProtocolPopupSnoozeUntilDate', debugProtocolPopupSnoozeValue: 'debugProtocolPopupSnoozeValue' + }; private remoteTextHistoryLimit: number; private remoteTextHistoryEnabled: boolean; @@ -38,7 +39,6 @@ export class GlobalStateManager { void this.context.globalState.update(this.keys.lastSeenReleaseNotesVersion, value); } - public get debugProtocolPopupSnoozeUntilDate(): Date { const epoch = this.context.globalState.get(this.keys.debugProtocolPopupSnoozeUntilDate); if (epoch) { diff --git a/src/LanguageServerManager.spec.ts b/src/LanguageServerManager.spec.ts index 581cfbd6..c19bcd4d 100644 --- a/src/LanguageServerManager.spec.ts +++ b/src/LanguageServerManager.spec.ts @@ -15,6 +15,9 @@ import { LanguageClient, State } from 'vscode-languageclient/node'; +import { util } from './util'; +import { GlobalStateManager } from './GlobalStateManager'; +import { LocalPackageManager } from './managers/LocalPackageManager'; const Module = require('module'); const sinon = createSandbox(); @@ -33,21 +36,32 @@ Module.prototype.require = function hijacked(file) { const tempDir = s`${process.cwd()}/.tmp`; describe('LanguageServerManager', () => { + const storageDir = s`${tempDir}/brighterscript-storage`; + let languageServerManager: LanguageServerManager; - beforeEach(() => { + beforeEach(function() { + //deleting certain directories take a while + this.timeout(30_000); + languageServerManager = new LanguageServerManager(); languageServerManager['definitionRepository'] = new DefinitionRepository( new DeclarationProvider() ); languageServerManager['context'] = { + ...vscode.context, asAbsolutePath: vscode.context.asAbsolutePath, - subscriptions: [], - globalState: { - get: () => { }, - update: () => { } - } + subscriptions: [] } as unknown as ExtensionContext; + languageServerManager['globalStateManager'] = new GlobalStateManager(languageServerManager['context']); + languageServerManager['localPackageManager'] = new LocalPackageManager(storageDir, languageServerManager['context']); + + fsExtra.removeSync(storageDir); + (languageServerManager['context'] as any).globalStorageUri = URI.file(storageDir); + + //this delay is used to clean up old versions. for testing, have it trigger instantly so it doesn't keep the testing process alive + languageServerManager['outdatedBscVersionDeleteDelay'] = 0; + }); function stubConstructClient(processor?: (LanguageClient) => void) { @@ -64,7 +78,10 @@ describe('LanguageServerManager', () => { }); } - afterEach(() => { + afterEach(function() { + //deleting certain directories take a while + this.timeout(30_000); + sinon.restore(); fsExtra.removeSync(tempDir); }); @@ -75,7 +92,7 @@ describe('LanguageServerManager', () => { //disable starting so we can manually test sinon.stub(languageServerManager, 'syncVersionAndTryRun').callsFake(() => Promise.resolve()); - await languageServerManager.init(languageServerManager['context'], languageServerManager['definitionRepository']); + await languageServerManager.init(languageServerManager['context'], languageServerManager['definitionRepository'], languageServerManager['localPackageManager']); languageServerManager['lspRunTracker'].debounceDelay = 100; @@ -168,7 +185,7 @@ describe('LanguageServerManager', () => { }); }); - describe('getBsdkPath', () => { + describe('getBsdkVersionInfo', () => { const embeddedPath = path.resolve(s`${__dirname}/../node_modules/brighterscript`); function setConfig(filePath: string, settings: any) { @@ -184,7 +201,7 @@ describe('LanguageServerManager', () => { it('returns embedded version when not in workspace and no settings exist', async () => { expect( - s(await languageServerManager['getBsdkPath']()) + s(await languageServerManager['getBsdkVersionInfo']()) ).to.eql(embeddedPath); }); @@ -193,7 +210,7 @@ describe('LanguageServerManager', () => { fsExtra.outputFileSync(vscode.workspace.workspaceFile.fsPath, ''); expect( - s(await languageServerManager['getBsdkPath']()) + s(await languageServerManager['getBsdkVersionInfo']()) ).to.eql(embeddedPath); }); @@ -205,7 +222,7 @@ describe('LanguageServerManager', () => { }); expect( - s(await languageServerManager['getBsdkPath']()) + s(await languageServerManager['getBsdkVersionInfo']()) ).to.eql(embeddedPath); }); @@ -216,7 +233,7 @@ describe('LanguageServerManager', () => { }); expect( - s(await languageServerManager['getBsdkPath']()) + s(await languageServerManager['getBsdkVersionInfo']()) ).to.eql(embeddedPath); }); @@ -231,7 +248,7 @@ describe('LanguageServerManager', () => { }); expect( - s(await languageServerManager['getBsdkPath']()) + s(await languageServerManager['getBsdkVersionInfo']()) ).to.eql(embeddedPath); }); @@ -243,7 +260,7 @@ describe('LanguageServerManager', () => { }); expect( - s(await languageServerManager['getBsdkPath']()) + s(await languageServerManager['getBsdkVersionInfo']()) ).to.eql( path.resolve( s`${tempDir}/relative/path` @@ -262,7 +279,7 @@ describe('LanguageServerManager', () => { }); expect( - s(await languageServerManager['getBsdkPath']()) + s(await languageServerManager['getBsdkVersionInfo']()) ).to.eql( path.resolve( s`${tempDir}/app1/folder/path` @@ -283,7 +300,7 @@ describe('LanguageServerManager', () => { }); expect( - s(await languageServerManager['getBsdkPath']()) + s(await languageServerManager['getBsdkVersionInfo']()) ).to.eql( path.resolve( s`${tempDir}/app1/folder/path` @@ -316,11 +333,141 @@ describe('LanguageServerManager', () => { uri: URI.file(`${tempDir}/app2`) }); const stub = sinon.stub(languageServerInfoCommand, 'selectBrighterScriptVersion').returns(Promise.resolve(null)); - const bsdkPath = await languageServerManager['getBsdkPath'](); + const bsdkPath = await languageServerManager['getBsdkVersionInfo'](); expect(stub.called).to.be.true; //should get null since that's what the 'selectBrighterScriptVersion' function returns from our stub expect(bsdkPath).to.eql(null); }); }); + + describe('ensureBscVersionInstalled', function() { + //these tests take a long time (due to running `npm install`) + this.timeout(20_000); + + it('installs a bsc version when not present', async () => { + const info = await languageServerManager['ensureBscVersionInstalled']('0.65.0'); + expect(info).to.eql({ + packageDir: s`${storageDir}/brighterscript/0.65.0/node_modules/brighterscript`, + versionInfo: '0.65.0', + version: '0.65.0' + }); + expect( + fsExtra.pathExistsSync(info.packageDir) + ).to.be.true; + }); + + it.skip('does not run multiple installs for the same version at the same time', async () => { + let spy = sinon.stub(util, 'spawnNpmAsync').callsFake(async (command, options) => { + //simulate that the bsc code was installed + fsExtra.outputFileSync(`${options.cwd}/node_modules/brighterscript/dist/index.js`, ''); + //ensure both requests have the opportunity to run at same time + await util.sleep(1000); + }); + //request the install multiple times without waiting for them + const promises = [ + languageServerManager['ensureBscVersionInstalled']('0.65.0'), + languageServerManager['ensureBscVersionInstalled']('0.65.0'), + languageServerManager['ensureBscVersionInstalled']('0.65.1') + ]; + + //now wait for them to finish + expect( + (await Promise.all(promises)).map(x => x.packageDir) + ).to.eql([ + s`${storageDir}/brighterscript/0.65.0/node_modules/brighterscript`, + s`${storageDir}/brighterscript/0.65.0/node_modules/brighterscript`, + s`${storageDir}/brighterscript/0.65.1/node_modules/brighterscript` + ]); + + //the spy should have only been called once for each unique version + expect(spy.getCalls().map(x => x.args[1].cwd)).to.eql([ + s`${storageDir}/brighterscript/0.65.0`, + s`${storageDir}/brighterscript/0.65.1` + ]); + }); + + it.skip('reuses the same bsc version when already exists', async () => { + let spy = sinon.spy(util, 'spawnNpmAsync'); + fsExtra.ensureDirSync( + s`${storageDir}/brighterscript/0.65.0/node_modules/brighterscript/dist/index.js` + ); + expect( + await languageServerManager['ensureBscVersionInstalled']('0.65.0') + ).to.eql({ + packageDir: s`${storageDir}/brighterscript/0.65.0/node_modules/brighterscript`, + version: '0.65.0', + versionInfo: '0.65.0' + }); + expect( + fsExtra.pathExistsSync(s`${storageDir}/brighterscript/0.65.0/node_modules/brighterscript`) + ).to.be.true; + + //the install should not have been called + expect(spy.called).to.be.false; + }); + + it('installs from url', async () => { + fsExtra.ensureDirSync( + s`${storageDir}/brighterscript/0.65.0/node_modules/brighterscript/dist/index.js` + ); + expect( + await languageServerManager['ensureBscVersionInstalled']( + 'https://github.com/rokucommunity/brighterscript/releases/download/v0.67.6/brighterscript-0.67.6.tgz' + ) + ).to.eql({ + packageDir: s`${storageDir}/brighterscript/71f52abe1087b924a1ded1e6d8a71022/node_modules/brighterscript`, + version: '0.67.6', + versionInfo: 'https://github.com/rokucommunity/brighterscript/releases/download/v0.67.6/brighterscript-0.67.6.tgz' + }); + expect( + fsExtra.pathExistsSync(s`${storageDir}/brighterscript/71f52abe1087b924a1ded1e6d8a71022/node_modules/brighterscript`) + ).to.be.true; + }); + + it('repairs a broken bsc version', async () => { + + //mock the actual installation process (since we're handling when it crashes) + let callCount = 0; + sinon.stub(util, 'spawnNpmAsync').callsFake(async (args, options) => { + callCount++; + //fail first time, pass all others + if (callCount === 1) { + throw new Error('failed'); + } + await fsExtra.outputJson(`${options.cwd}/node_modules/brighterscript/package.json`, { + version: '0.65.0' + }); + }); + + //install the package + expect( + await languageServerManager['ensureBscVersionInstalled']('0.65.0') + ).to.eql({ + packageDir: s`${storageDir}/brighterscript/0.65.0/node_modules/brighterscript`, + version: '0.65.0', + versionInfo: '0.65.0' + }); + }); + }); + + describe('deleteOutdatedBscVersions', () => { + beforeEach(() => { + //prevent lsp from actually running + sinon.stub(languageServerManager as any, 'syncVersionAndTryRun').returns(Promise.resolve()); + }); + + it('runs after a short delay after init', async () => { + const stub = sinon.stub(languageServerManager as any, 'deleteOutdatedBscVersions').callsFake(() => { }); + + languageServerManager['outdatedBscVersionDeleteDelay'] = 50; + + await languageServerManager.init(languageServerManager['context'], languageServerManager['definitionRepository'], languageServerManager['localPackageManager']); + + expect(stub.called).to.be.false; + + await util.sleep(100); + expect(stub.called).to.be.true; + }); + }); }); diff --git a/src/LanguageServerManager.ts b/src/LanguageServerManager.ts index 9e264bff..69449d64 100644 --- a/src/LanguageServerManager.ts +++ b/src/LanguageServerManager.ts @@ -1,22 +1,11 @@ -import type { - LanguageClientOptions, - ServerOptions, - ExecuteCommandParams, - StateChangeEvent -} from 'vscode-languageclient/node'; -import { - LanguageClient, - State, - TransportKind -} from 'vscode-languageclient/node'; +import type { LanguageClientOptions, ServerOptions, ExecuteCommandParams, StateChangeEvent } from 'vscode-languageclient/node'; +import { LanguageClient, State, TransportKind } from 'vscode-languageclient/node'; import * as vscode from 'vscode'; import * as path from 'path'; import type { Disposable } from 'vscode'; -import { - window, - workspace -} from 'vscode'; -import { BusyStatus, NotificationName, Logger } from 'brighterscript'; +import { window, workspace } from 'vscode'; +import { BusyStatus, NotificationName, standardizePath as s } from 'brighterscript'; +import { Logger } from '@rokucommunity/logger'; import { CustomCommands, Deferred } from 'brighterscript'; import type { CodeWithSourceMap } from 'source-map'; import BrightScriptDefinitionProvider from './BrightScriptDefinitionProvider'; @@ -29,6 +18,8 @@ import { util } from './util'; import { LanguageServerInfoCommand, languageServerInfoCommand } from './commands/LanguageServerInfoCommand'; import * as fsExtra from 'fs-extra'; import { EventEmitter } from 'eventemitter3'; +import * as dayjs from 'dayjs'; +import type { LocalPackageManager, ParsedVersionInfo } from './managers/LocalPackageManager'; /** * Tracks the running/stopped state of the language server. When the lsp crashes, vscode will restart it. After the 5th crash, they'll leave it permanently crashed. @@ -70,16 +61,24 @@ export const LANGUAGE_SERVER_NAME = 'BrighterScript Language Server'; export class LanguageServerManager { constructor() { this.deferred = new Deferred(); + const brighterscriptDir = require.resolve('brighterscript').replace(/[\\\/]dist[\\\/]index.js/i, ''); + const version = fsExtra.readJsonSync(`${brighterscriptDir}/package.json`).version; this.embeddedBscInfo = { - path: require.resolve('brighterscript').replace(/[\\\/]dist[\\\/]index.js/i, ''), - // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires - version: require('brighterscript/package.json').version + packageDir: brighterscriptDir, + versionInfo: version, + version: version }; //default to the embedded bsc version this.selectedBscInfo = this.embeddedBscInfo; } + /** + * Information about the embedded brighterscript version + */ public embeddedBscInfo: BscInfo; + /** + * Information about the currently selected brighterscript version (the one that's running right now) + */ public selectedBscInfo: BscInfo; private context: vscode.ExtensionContext; @@ -88,13 +87,34 @@ export class LanguageServerManager { return this.definitionRepository.provider; } + private localPackageManager: LocalPackageManager; + + /** + * The delay after init before we delete any outdated bsc versions + */ + private outdatedBscVersionDeleteDelay = 5 * 60 * 1000; + public async init( context: vscode.ExtensionContext, - definitionRepository: DefinitionRepository + definitionRepository: DefinitionRepository, + localPackageManager: LocalPackageManager ) { this.context = context; + this.definitionRepository = definitionRepository; + this.localPackageManager = localPackageManager; + + //anytime the window changes focus, save the current brighterscript version + vscode.window.onDidChangeWindowState(async (e) => { + await this.localPackageManager.setUsage('brighterscript', this.selectedBscInfo.versionInfo); + }); + + //in about 5 minutes, clean up any outdated bsc versions (delayed to prevent slower startup times) + setTimeout(() => { + void this.deleteOutdatedBscVersions(); + }, this.outdatedBscVersionDeleteDelay); + //if the lsp is permanently stopped by vscode, ask the user if they want to restart it again. this.lspRunTracker.on('stopped', async () => { //stop the statusbar spinner @@ -113,7 +133,10 @@ export class LanguageServerManager { //dynamically enable or disable the language server based on user settings vscode.workspace.onDidChangeConfiguration(async (configuration) => { - await this.syncVersionAndTryRun(); + //if we've changed the bsdk setting, restart the language server + if (configuration.affectsConfiguration('brightscript.bsdk')) { + await this.syncVersionAndTryRun(); + } }); await this.syncVersionAndTryRun(); } @@ -160,7 +183,7 @@ export class LanguageServerManager { //give the runner the specific version of bsc to run const args = [ - this.selectedBscInfo.path, + this.selectedBscInfo.packageDir, (this.context.extensionMode === vscode.ExtensionMode.Development).toString() ]; // If the extension is launched in debug mode then the debug server options are used @@ -266,7 +289,7 @@ export class LanguageServerManager { if (event.status === BusyStatus.busy) { timeoutHandle = setTimeout(() => { const delay = Date.now() - event.timestamp; - this.client.outputChannel.appendLine(`${logger.getTimestamp()} language server has been 'busy' for ${delay}ms. most recent busyStatus event: ${JSON.stringify(event, undefined, 4)}`); + this.client.outputChannel.appendLine(`${logger.formatTimestamp(new Date())} language server has been 'busy' for ${delay}ms. most recent busyStatus event: ${JSON.stringify(event, undefined, 4)}`); }, 60_000); //clear any existing timeout @@ -386,24 +409,22 @@ export class LanguageServerManager { * and if different, re-launch the specific version of the language server' */ public async syncVersionAndTryRun() { - const bsdkPath = await this.getBsdkPath(); + const versionInfo = await this.getBsdkVersionInfo(); //if the path to bsc is different, spin down the old server and start a new one - if (bsdkPath !== this.selectedBscInfo.path) { + if (versionInfo !== this.selectedBscInfo.packageDir) { await this.disableLanguageServer(); } + //ensure the version of the language server is installed and available + //try to load the package version. try { - this.selectedBscInfo = { - path: bsdkPath, - // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires - version: fsExtra.readJsonSync(`${bsdkPath}/package.json`).version - }; + this.selectedBscInfo = await this.ensureBscVersionInstalled(versionInfo); } catch (e) { console.error(e); //fall back to the embedded version, and show a popup - await vscode.window.showErrorMessage(`Can't find language server at "${bsdkPath}". Did you forget to run \`npm install\`? Using embedded version v${this.embeddedBscInfo.version} instead.`); + await vscode.window.showErrorMessage(`Can't find language server for "${versionInfo}". Did you forget to run \`npm install\`? Using embedded version v${this.embeddedBscInfo.version} instead.`); this.selectedBscInfo = this.embeddedBscInfo; } @@ -414,40 +435,59 @@ export class LanguageServerManager { } } + public parseVersionInfo(versionInfo: string, cwd = process.cwd()): ParsedVersionInfo { + if (versionInfo === 'embedded') { + return { + type: 'dir', + value: this.embeddedBscInfo.packageDir + }; + } else { + return this.localPackageManager.parseVersionInfo(versionInfo, cwd); + } + } + /** - * Get the full path to the brighterscript module where the LanguageServer should be run + * Get the value for `brightscript.bsdk` from the following locations (in order). First one found wins: + * - use `brightscript.bsdk` value from the current `.code-workspace` file + * - if there is only 1 workspaceFolder with a `brightscript.bsdk` value, use that. + * - if there are multiple workspace folders with `brightscript.bsdk` values, prompt the user to pick which one to use + * - if there are no `brightscript.bsdk` values, use the embedded version + * @returns an absolute path to a directory for the bsdk, or the non-path value (i.e. a URL or a version number) */ - private async getBsdkPath() { - //if there's a bsdk entry in the workspace settings, assume the path is relative to the workspace + private async getBsdkVersionInfo(): Promise { + + //use bsdk entry in the code-workspace file if (this.workspaceConfigIncludesBsdkKey()) { - let bsdk = vscode.workspace.getConfiguration('brightscript', vscode.workspace.workspaceFile).get('bsdk'); - return bsdk === 'embedded' - ? this.embeddedBscInfo.path - : path.resolve(path.dirname(vscode.workspace.workspaceFile.fsPath), bsdk); + let result = this.parseVersionInfo( + vscode.workspace.getConfiguration('brightscript', vscode.workspace.workspaceFile).get('bsdk')?.trim?.(), + path.dirname(vscode.workspace.workspaceFile.fsPath) + ); + if (result) { + return result.value; + } } - const folderResults = new Set(); - //look for a bsdk entry in each of the workspace folders - for (const folder of vscode.workspace.workspaceFolders) { - const bsdk = vscode.workspace.getConfiguration('brightscript', folder).get('bsdk'); - if (bsdk) { - folderResults.add( - bsdk === 'embedded' - ? this.embeddedBscInfo.path - : path.resolve(folder.uri.fsPath, bsdk) - ); + //collect `brightscript.bsdk` setting value from each workspaceFolder + const folderResults = vscode.workspace.workspaceFolders.reduce((acc, workspaceFolder) => { + const versionInfo = vscode.workspace.getConfiguration('brightscript', workspaceFolder).get('bsdk'); + const parsed = this.parseVersionInfo(versionInfo, workspaceFolder.uri.fsPath); + if (parsed) { + acc.set(parsed.value, parsed); } - } - const values = [...folderResults.values()]; - //there's no bsdk configuration in folder settings. - if (values.length === 0) { - return this.embeddedBscInfo.path; + return acc; + }, new Map()); + + //no results found, use the embedded version + if (folderResults.size === 0) { + return this.embeddedBscInfo.packageDir; //we have exactly one result. use it - } else if (values.length === 1) { - return values[0]; - } else { + } else if (folderResults.size === 1) { + return [...folderResults.values()][0].value; + //there were multiple versions. make the user pick which to use + } else { + //TODO should we prompt for just these items? return languageServerInfoCommand.selectBrighterScriptVersion(); } } @@ -460,17 +500,116 @@ export class LanguageServerManager { ).toString() ); } + + /** + * Ensure that the specified bsc version is installed in the global storage directory. + * @param version + * @param retryCount the number of times we should retry before giving up + * @returns full path to the root of where the brighterscript module is installed + */ + @OneAtATime({ timeout: 3 * 60 * 1000 }) + private async ensureBscVersionInstalled(versionInfo: string, retryCount = 1, showProgress = true): Promise { + const parsed = this.parseVersionInfo(versionInfo); + + //if this is a directory, use it as-is + if (parsed.type === 'dir') { + return { + packageDir: parsed.value, + version: fsExtra.readJsonSync(s`${parsed.value}/package.json`, { throws: false })?.version ?? parsed.value, + versionInfo: versionInfo + }; + } + + //install this version of brighterscript + try { + const packageInfo = await util.runWithProgress({ + title: 'Installing brighterscript language server ' + versionInfo, + location: vscode.ProgressLocation.Notification, + cancellable: false, + //show a progress spinner if configured to do so + showProgress: showProgress && !this.localPackageManager.isInstalled('brighterscript', versionInfo) + }, async () => { + return this.localPackageManager.install('brighterscript', versionInfo); + }); + return { + packageDir: packageInfo.packageDir, + version: packageInfo.version, + versionInfo: versionInfo + }; + + } catch (e) { + if (retryCount > 0) { + console.error('Failed to install brighterscript', versionInfo, e); + + //if the install failed for some reason, uninstall the package and try again + await this.localPackageManager.uninstall('brighterscript', versionInfo); + return await this.ensureBscVersionInstalled(versionInfo, retryCount - 1, showProgress); + } else { + throw e; + } + } + } + + /** + * Delete any brighterscript versions that haven't been used in a while + */ + private async deleteOutdatedBscVersions() { + const npmCacheRetentionDays = vscode.workspace.getConfiguration('brightscript')?.get?.('npmCacheRetentionDays', 45) ?? 45; + + //build the cutoff date (i.e. 45 days ago) + const cutoffDate = dayjs().subtract(npmCacheRetentionDays, 'days'); + await this.localPackageManager.deletePackagesNotUsedSince(cutoffDate.toDate()); + } } export const languageServerManager = new LanguageServerManager(); interface BscInfo { /** - * The full path to the brighterscript module + * The full path to the brighterscript module (i.e. the folder where its `package.json` is located + */ + packageDir: string; + /** + * The versionInfo of the brighterscript module. Typically this is a semantic version, but it could be a URL or a folder path. + * Anything that can go inside a `package.json` file is acceptable as well */ - path: string; + versionInfo: string; /** - * The version of the brighterscript module + * The version of the brighterscript module from its package.json. This is displayed in the statusbar */ version: string; } + + +/** + * Force method calls to run one-at-a-time, waiting for the completion of the previous call before running the next. + */ +function OneAtATime(options: { timeout?: number }) { + return function OneAtATime(target: any, propertyKey: string, descriptor: PropertyDescriptor) { + let originalMethod = descriptor.value; + + //wrap the original method + descriptor.value = function value(...args: any[]) { + //ensure the promise structure exists for this call + target.__oneAtATime ??= {}; + target.__oneAtATime[propertyKey] ??= Promise.resolve(); + + const timer = util.sleep(options.timeout > 0 ? options.timeout : Number.MAX_SAFE_INTEGER); + + return Promise.race([ + //race for the last task to resolve + target.__oneAtATime[propertyKey].finally(() => { + timer?.cancel?.(); + }), + //race for the timeout to expire (we give up waiting for the previous task to complete) + timer.then(() => { + //our timer fired before we had a chance to cancel it. Report the error and move on + console.error(`timer expired waiting for the previous ${propertyKey} to complete. Running the next instance`, target); + }) + //now we can move on to the actual task + ]).then(() => { + return originalMethod.apply(this, args); + }); + }; + }; +} diff --git a/src/commands/ClearNpmPackageCacheCommand.ts b/src/commands/ClearNpmPackageCacheCommand.ts new file mode 100644 index 00000000..673e4663 --- /dev/null +++ b/src/commands/ClearNpmPackageCacheCommand.ts @@ -0,0 +1,14 @@ +import * as vscode from 'vscode'; +import { VscodeCommand } from './VscodeCommand'; +import type { LocalPackageManager } from '../managers/LocalPackageManager'; + +export class ClearNpmPackageCacheCommand { + + public register(context: vscode.ExtensionContext, localPackageManager: LocalPackageManager) { + context.subscriptions.push(vscode.commands.registerCommand(VscodeCommand.clearNpmPackageCache, async () => { + await localPackageManager.removeAll(); + })); + } +} + +export const clearNpmPackageCacheCommand = new ClearNpmPackageCacheCommand(); diff --git a/src/commands/LanguageServerInfoCommand.spec.ts b/src/commands/LanguageServerInfoCommand.spec.ts index 4500a7c3..511dd72b 100644 --- a/src/commands/LanguageServerInfoCommand.spec.ts +++ b/src/commands/LanguageServerInfoCommand.spec.ts @@ -35,6 +35,22 @@ describe('LanguageServerInfoCommand', () => { fsExtra.removeSync(tempDir); }); + describe('getBscVersionsFromNpm', function() { + this.timeout(20_000); + it('returns a list of versions', async () => { + const results = await command['getBscVersionsFromNpm'](); + // `results` is entire list of all bsc versions, live from npm. so we obviously can't make a test that ensure they're all correct. + // so just check that certain values are sorted correctly + expect(results.map(x => x.version).filter(x => x.startsWith('0.64'))).to.eql([ + '0.64.4', + '0.64.3', + '0.64.2', + '0.64.1', + '0.64.0' + ]); + }); + }); + describe('discoverBrighterScriptVersions', () => { function writePackage(version: string) { fsExtra.outputJsonSync(s`${tempDir}/package.json`, { @@ -57,7 +73,8 @@ describe('LanguageServerInfoCommand', () => { command['discoverBrighterScriptVersions']([tempDir]) ).to.eql([{ label: `Use VSCode's version`, - description: embeddedBscVersion + description: embeddedBscVersion, + value: 'embedded' }]); }); @@ -75,7 +92,8 @@ describe('LanguageServerInfoCommand', () => { command['discoverBrighterScriptVersions']([tempDir]) ).to.eql([{ label: `Use VSCode's version`, - description: embeddedBscVersion + description: embeddedBscVersion, + value: 'embedded' }]); }); @@ -85,11 +103,13 @@ describe('LanguageServerInfoCommand', () => { command['discoverBrighterScriptVersions']([tempDir]) ).to.eql([{ label: `Use VSCode's version`, - description: embeddedBscVersion + description: embeddedBscVersion, + value: 'embedded' }, { label: `Use Workspace Version`, description: '1.2.3', - detail: 'node_modules/brighterscript' + detail: 'node_modules/brighterscript', + value: 'node_modules/brighterscript' }]); }); @@ -99,11 +119,13 @@ describe('LanguageServerInfoCommand', () => { command['discoverBrighterScriptVersions']([tempDir]) ).to.eql([{ label: `Use VSCode's version`, - description: embeddedBscVersion + description: embeddedBscVersion, + value: 'embedded' }, { label: `Use Workspace Version`, description: '1.2.3', - detail: 'node_modules/brighterscript' + detail: 'node_modules/brighterscript', + value: 'node_modules/brighterscript' }]); writePackage('2.3.4'); @@ -111,11 +133,13 @@ describe('LanguageServerInfoCommand', () => { command['discoverBrighterScriptVersions']([tempDir]) ).to.eql([{ label: `Use VSCode's version`, - description: embeddedBscVersion + description: embeddedBscVersion, + value: 'embedded' }, { label: `Use Workspace Version`, description: '2.3.4', - detail: 'node_modules/brighterscript' + detail: 'node_modules/brighterscript', + value: 'node_modules/brighterscript' }]); }); @@ -125,11 +149,13 @@ describe('LanguageServerInfoCommand', () => { command['discoverBrighterScriptVersions']([tempDir]) ).to.eql([{ label: `Use VSCode's version`, - description: embeddedBscVersion + description: embeddedBscVersion, + value: 'embedded' }, { label: `Use Workspace Version`, description: '1.2.3', - detail: 'node_modules/brighterscript' + detail: 'node_modules/brighterscript', + value: 'node_modules/brighterscript' }]); fsExtra.removeSync(`${tempDir}/node_modules`); @@ -137,7 +163,8 @@ describe('LanguageServerInfoCommand', () => { command['discoverBrighterScriptVersions']([tempDir]) ).to.eql([{ label: `Use VSCode's version`, - description: embeddedBscVersion + description: embeddedBscVersion, + value: 'embedded' }]); }); }); diff --git a/src/commands/LanguageServerInfoCommand.ts b/src/commands/LanguageServerInfoCommand.ts index cdd8e5e3..59060393 100644 --- a/src/commands/LanguageServerInfoCommand.ts +++ b/src/commands/LanguageServerInfoCommand.ts @@ -3,11 +3,26 @@ import { LANGUAGE_SERVER_NAME, languageServerManager } from '../LanguageServerMa import * as path from 'path'; import * as resolve from 'resolve'; import * as fsExtra from 'fs-extra'; +import { firstBy } from 'thenby'; +import { VscodeCommand } from './VscodeCommand'; +import URI from 'vscode-uri'; +import * as relativeTime from 'dayjs/plugin/relativeTime'; +import { util } from '../util'; +import { type LocalPackageManager } from '../managers/LocalPackageManager'; +import * as semver from 'semver'; +import { standardizePath as s } from 'brighterscript'; +import type { QuickPickItem } from 'vscode'; +import * as dayjs from 'dayjs'; +dayjs.extend(relativeTime); export class LanguageServerInfoCommand { public static commandName = 'extension.brightscript.languageServer.info'; - public register(context: vscode.ExtensionContext) { + public localPackageManager: LocalPackageManager; + + public register(context: vscode.ExtensionContext, localPackageManager: LocalPackageManager) { + this.localPackageManager = localPackageManager; + context.subscriptions.push(vscode.commands.registerCommand(LanguageServerInfoCommand.commandName, async () => { const commands = [{ label: `Change Selected BrighterScript Version`, @@ -21,6 +36,27 @@ export class LanguageServerInfoCommand { label: `View language server logs`, description: ``, command: this.focusLanguageServerOutputChannel.bind(this) + }, { + label: `View BrighterScript version cache folder`, + description: ``, + command: async () => { + await vscode.commands.executeCommand('revealFileInOS', URI.file(s`${localPackageManager.storageLocation}/brighterscript`)); + } + }, { + label: `Remove cached brighterscript versions`, + description: ``, + command: async () => { + await util.runWithProgress({ + title: 'Removing cached brighterscript versions' + }, async () => { + await vscode.commands.executeCommand(VscodeCommand.clearNpmPackageCache); + }); + + void vscode.window.showInformationMessage('All cached brighterscript versions have been removed'); + + //restart the language server since we might have just removed the one we're using + await this.restartLanguageServer(); + } }]; let selection = await vscode.window.showQuickPick(commands, { placeHolder: `BrighterScript Project Info` }); @@ -40,10 +76,11 @@ export class LanguageServerInfoCommand { await vscode.commands.executeCommand('extension.brightscript.languageServer.restart'); } - private discoverBrighterScriptVersions(workspaceFolders: string[]): BscVersionInfo[] { - const versions: BscVersionInfo[] = [{ + private discoverBrighterScriptVersions(workspaceFolders: string[]): QuickPickItemEnhanced[] { + const versions: QuickPickItemEnhanced[] = [{ label: `Use VSCode's version`, - description: languageServerManager.embeddedBscInfo.version + description: languageServerManager.embeddedBscInfo.version, + value: 'embedded' }]; //look for brighterscript in node_modules from all workspace folders @@ -69,37 +106,99 @@ export class LanguageServerInfoCommand { versions.push({ label: 'Use Workspace Version', description: version, - detail: bscPath.replace(/\\+/g, '/') + detail: bscPath.replace(/\\+/g, '/'), + value: bscPath.replace(/\\+/g, '/') }); } } + return versions; } + private async getBscVersionsFromNpm() { + + const json = await util.exec(`npm view brighterscript time --json`); + + const versions = JSON.parse(json); + + //delete a few keys that aren't actual versions + delete versions.created; + delete versions.modified; + + return Object.entries(versions) + .map(x => { + return { + version: x[0], + date: x[1] as string + }; + }) + .sort(firstBy(x => x.date, -1)); + } + /** * If this changes the user/folder/workspace settings, that will trigger a reload of the language server so there's no need to * call the reload manually */ - public async selectBrighterScriptVersion() { - const versions = this.discoverBrighterScriptVersions( + public async selectBrighterScriptVersion(): Promise { + const quickPickItems = this.discoverBrighterScriptVersions( vscode.workspace.workspaceFolders.map(x => this.getWorkspaceOrFolderPath(x.uri.fsPath)) ); - let selection = await vscode.window.showQuickPick(versions, { placeHolder: `Select the BrighterScript version used for BrightScript and BrighterScript language features` }); + + //start the request right now, we will leverage it later + const versionsFromNpmPromise = this.getBscVersionsFromNpm(); + + //get the full list of versions from npm + quickPickItems.push({ + label: '$(package) Install from npm', + description: '', + detail: '', + command: async () => { + let versionsFromNpm: QuickPickItemEnhanced[] = (await versionsFromNpmPromise).filter(x => !semver.prerelease(x.version)).map(x => { + return { + label: x.version, + value: x.version, + description: `${dayjs(x.date).fromNow(true)} ago` + }; + }); + return await vscode.window.showQuickPick(versionsFromNpm, { placeHolder: `Select the BrighterScript version used for BrightScript and BrighterScript language features` }) as any; + } + } as any); + + //get the full list of versions from npm + quickPickItems.push({ + label: '$(package) Install from npm (insider builds)', + description: '', + detail: '', + command: async () => { + let versionsFromNpm: QuickPickItemEnhanced[] = (await versionsFromNpmPromise).filter(x => semver.prerelease(x.version)).map(x => { + return { + label: x.version, + value: x.version, + description: `${dayjs(x.date).fromNow(true)} ago` + }; + }); + return await vscode.window.showQuickPick(versionsFromNpm, { placeHolder: `Select the BrighterScript version used for BrightScript and BrighterScript language features` }) as any; + } + } as any); + + let selection: QuickPickItemEnhanced = await vscode.window.showQuickPick(quickPickItems, { placeHolder: `Select the BrighterScript version used for BrightScript and BrighterScript language features` }) as any; + + //if the selection has a command, run it before continuing; + selection = await selection?.command?.() ?? selection; + if (selection) { const config = vscode.workspace.getConfiguration('brightscript'); - //quickly clear the setting, then set it again so we are guaranteed to trigger a change event - await config.update('bsdk', undefined); - - //if the user picked "use embedded version", then remove the setting - if (versions.indexOf(selection) === 0) { - //setting to undefined means "remove" - await config.update('bsdk', 'embedded'); - return 'embedded'; + const currentValue = config.get('bsdk') ?? 'embedded'; + + //if the user chose the same value that's already there, just restart the language server + if (selection.value === currentValue) { + await this.restartLanguageServer(); + //set the new value } else { //save this to workspace/folder settings (vscode automatically decides if it goes into the code-workspace settings or the folder settings) - await config.update('bsdk', selection.detail); - return selection.detail; + await config.update('bsdk', selection.value); } + return selection.value; } } @@ -113,10 +212,6 @@ export class LanguageServerInfoCommand { } } -interface BscVersionInfo { - label: string; - description: string; - detail?: string; -} +type QuickPickItemEnhanced = QuickPickItem & { value: string; command?: () => Promise }; export const languageServerInfoCommand = new LanguageServerInfoCommand(); diff --git a/src/commands/VscodeCommand.ts b/src/commands/VscodeCommand.ts index 2b1c9aa4..15bb8d8f 100644 --- a/src/commands/VscodeCommand.ts +++ b/src/commands/VscodeCommand.ts @@ -19,5 +19,6 @@ export enum VscodeCommand { rokuAppOverlaysViewRemoveAllOverlays = 'extension.brightscript.rokuAppOverlaysView.removeAllOverlays', rokuFileSystemViewRefresh = 'extension.brightscript.rokuFileSystemView.refresh', disconnectFromDevice = 'extension.brightscript.disconnectFromDevice', - openSceneGraphInspectorInPanel = 'extension.brightscript.openSceneGraphInspectorInPanel' + openSceneGraphInspectorInPanel = 'extension.brightscript.openSceneGraphInspectorInPanel', + clearNpmPackageCache = 'extension.brightscript.clearNpmPackageCache' } diff --git a/src/extension.ts b/src/extension.ts index e18cf065..1f56665c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -30,6 +30,8 @@ import { ViewProviderId } from './viewProviders/ViewProviderId'; import { DiagnosticManager } from './managers/DiagnosticManager'; import { EXTENSION_ID } from './constants'; import { UserInputManager } from './managers/UserInputManager'; +import { LocalPackageManager } from './managers/LocalPackageManager'; +import { standardizePath as s } from 'brighterscript'; export class Extension { public outputChannel: vscode.OutputChannel; @@ -63,6 +65,11 @@ export class Extension { }) ); + let localPackageManager = new LocalPackageManager( + s`${context.globalStorageUri.fsPath}/packages`, + context + ); + this.telemetryManager.sendStartupEvent(); let activeDeviceManager = new ActiveDeviceManager(); let userInputManager = new UserInputManager( @@ -75,7 +82,8 @@ export class Extension { this.whatsNewManager, context, activeDeviceManager, - userInputManager + userInputManager, + localPackageManager ); this.rtaManager = new RtaManager(context); @@ -102,7 +110,7 @@ export class Extension { const definitionRepo = new DefinitionRepository(declarationProvider); //initialize the LanguageServerManager - void languageServerManager.init(context, definitionRepo); + void languageServerManager.init(context, definitionRepo, localPackageManager); //register a tree data provider for this extension's "RENDEZVOUS" view in the debug area let rendezvousViewProvider = new RendezvousViewProvider(context); diff --git a/src/managers/LocalPackageManager.spec.ts b/src/managers/LocalPackageManager.spec.ts new file mode 100644 index 00000000..f4da7cc4 --- /dev/null +++ b/src/managers/LocalPackageManager.spec.ts @@ -0,0 +1,474 @@ +import { vscode } from '../mockVscode.spec'; +import type { PackageCatalogPackageInfo } from './LocalPackageManager'; +import { LocalPackageManager } from './LocalPackageManager'; +import { standardizePath as s } from 'brighterscript'; +import * as fsExtra from 'fs-extra'; +import { expect } from 'chai'; +import { util } from '../util'; +import * as dayjs from 'dayjs'; +import * as md5 from 'md5'; +import { createSandbox } from 'sinon'; +const sinon = createSandbox(); + +const cwd = s`${__dirname}/../../`; +const tempDir = s`${cwd}/.tmp`; + +describe('LocalPackageManager', () => { + + const packageUrl = 'https://github.com/rokucommunity/brighterscript/releases/download/v0.67.6/brighterscript-0.67.6.tgz'; + + const storageDir = s`${tempDir}/storage`; + let manager: LocalPackageManager; + + beforeEach(() => { + manager = new LocalPackageManager(storageDir, vscode.context); + + fsExtra.emptyDirSync(storageDir); + sinon.restore(); + + //mock the npm install command to speed up the tests + sinon.stub(util, 'spawnNpmAsync').callsFake(async (args: string[], options) => { + let spawnCwd = s`${options.cwd?.toString()}`; + if (args[0] !== 'install' || !spawnCwd.startsWith(storageDir)) { + throw new Error(`Invalid cwd: ${spawnCwd}`); + } + + //get the dependency name + const packageName = Object.keys( + fsExtra.readJsonSync(`${spawnCwd}/package.json`).dependencies + )[0]; + await fsExtra.outputJson(s`${spawnCwd}/node_modules/${packageName}/package.json`, {}); + }); + }); + + afterEach(() => { + fsExtra.removeSync(storageDir); + sinon.restore(); + }); + + function expectCatalogEquals(expectedCatalog: { packages: Record>> }) { + const catalog = fsExtra.readJsonSync(manager['catalogPath']); + //remove the `lastUpdated` property because it's not deterministic + for (let packageName in catalog.packages) { + for (let version in catalog.packages[packageName]) { + delete catalog.packages[packageName][version].installDate; + } + } + expect(catalog).to.eql(expectedCatalog); + } + + describe('install', function() { + this.timeout(10_000); + + it('actually works with real npm package', async () => { + //remove the mock npm install + sinon.restore(); + + await manager.install('is-odd', '1.0.0'); + expect(fsExtra.pathExistsSync(`${storageDir}/is-odd/1.0.0/node_modules/is-odd/package.json`)).to.be.true; + }); + + it('installs a package when missing', async () => { + await manager.install('is-odd', '1.0.0'); + expect(fsExtra.pathExistsSync(`${storageDir}/is-odd/1.0.0/node_modules/is-odd/package.json`)).to.be.true; + }); + + it('skips install when package is already there', async () => { + const stub = sinon.stub(util, 'spawnAsync').callsFake(() => Promise.resolve()); + + fsExtra.ensureDirSync(`${storageDir}/is-odd/1.0.0/node_modules/is-odd`); + fsExtra.outputJsonSync(`${storageDir}/is-odd/1.0.0/node_modules/is-odd/package.json`, { + name: 'is-odd', + customKey: 'test' + }); + + expect( + await manager.install('is-odd', '1.0.0') + ).to.include({ + packageDir: s`${storageDir}/is-odd/1.0.0/node_modules/is-odd`, + packageName: 'is-odd', + rootDir: s`${storageDir}/is-odd/1.0.0`, + versionDirName: '1.0.0', + versionInfo: '1.0.0' + }); + + expect( + fsExtra.readJsonSync(`${storageDir}/is-odd/1.0.0/node_modules/is-odd/package.json`).customKey + ).to.eql('test'); + + expect(stub.called).to.be.false; + }); + + it('installs multiple versions at the same time', async () => { + await Promise.all([ + manager.install('is-odd', '1.0.0'), + manager.install('is-odd', '2.0.0'), + manager.install('is-even', '1.0.0') + ]); + expectCatalogEquals({ + packages: { + 'is-odd': { + '1.0.0': { + versionDirName: '1.0.0' + }, + '2.0.0': { + versionDirName: '2.0.0' + } + }, + 'is-even': { + '1.0.0': { + versionDirName: '1.0.0' + } + } + } + }); + + expect(fsExtra.pathExistsSync(`${storageDir}/is-odd/1.0.0/node_modules/is-odd/package.json`)).to.be.true; + expect(fsExtra.pathExistsSync(`${storageDir}/is-odd/2.0.0/node_modules/is-odd/package.json`)).to.be.true; + expect(fsExtra.pathExistsSync(`${storageDir}/is-even/1.0.0/node_modules/is-even/package.json`)).to.be.true; + }); + + // it('installs packages from a URL', async () => { + // const url = 'https://github.com/rokucommunity/brighterscript/releases/download/v0.0.0-packages/brighterscript-0.67.5-lsp-refactor.20240806164122.tgz'; + // await manager.install('brighterscript', url); + // const info = manager['getPackageInfo']('brighterscript', url); + // expect( + // fsExtra.pathExistsSync(info.packageDir) + // ).to.be.true; + // }); + }); + + describe('getPackageInfo', () => { + it('transforms URLs into a filesystem-safe name', () => { + const info = manager['getPackageInfo']('brighterscript', packageUrl); + expect( + info.versionDirName + ).to.match(/^[a-z0-9_]+$/i); + }); + }); + + describe('remove', () => { + it('removes a specific package version', async () => { + await Promise.all([ + manager.install('is-odd', '1.0.0'), + manager.install('is-odd', '2.0.0'), + manager.install('is-even', '1.0.0') + ]); + + await manager.uninstall('is-odd', '2.0.0'); + + expect(fsExtra.pathExistsSync(`${storageDir}/is-odd/1.0.0/node_modules/is-odd/package.json`)).to.be.true; + expect(fsExtra.pathExistsSync(`${storageDir}/is-odd/2.0.0/node_modules/is-odd/package.json`)).to.be.false; + expect(fsExtra.pathExistsSync(`${storageDir}/is-even/1.0.0/node_modules/is-even/package.json`)).to.be.true; + + expectCatalogEquals({ + packages: { + 'is-odd': { + '1.0.0': { + versionDirName: '1.0.0' + } + }, + 'is-even': { + '1.0.0': { + versionDirName: '1.0.0' + } + } + } + }); + + }); + + it('does not crash when removing missing package', async () => { + await manager.uninstall('is-odd', '1.0.0'); + }); + }); + + describe('withCatalog', () => { + it('loads the default catalog when not supplied', async () => { + + //install a package so the catalog is non-empty + await manager.install('is-odd', '1.0.0'); + await manager.install('is-odd', '2.0.0'); + + //ensure the catalog is populated correctly + expect(manager['getCatalog']().packages['is-odd']['1.0.0'].versionDirName).to.eql('1.0.0'); + expect(manager['getCatalog']().packages['is-odd']['2.0.0'].versionDirName).to.eql('2.0.0'); + + const spy = sinon.spy(manager as any, 'setCatalog'); + + await manager['withCatalog']((catalog) => { + //did it load the correct catalog? + expect(catalog.packages['is-odd']['1.0.0'].versionDirName).to.eql('1.0.0'); + expect(catalog.packages['is-odd']['2.0.0'].versionDirName).to.eql('2.0.0'); + + //delete the entry from the catalog + delete catalog.packages['is-odd']['2.0.0']; + }); + + expect(manager['getCatalog']().packages['is-odd']['1.0.0'].versionDirName).to.eql('1.0.0'); + expect(manager['getCatalog']().packages['is-odd']?.['2.0.0']).to.be.undefined; + + expect(spy.called).to.be.true; + }); + + it('uses the given catalog', async () => { + //install a package so the catalog is non-empty + await manager.install('is-odd', '1.0.0'); + + const actualCatalog = manager['getCatalog'](); + + const spy = sinon.spy(manager as any, 'setCatalog'); + + await manager['withCatalog']((catalog) => { + expect(catalog).to.equal(catalog); + }, actualCatalog); + + //when we provide a catalog, it shouldn't write to disk itself + expect(spy.called).to.be.false; + }); + + }); + + describe('removePackage', () => { + it('removes entries from the catalog', async () => { + await manager.install('is-odd', '1.0.0'); + await manager.removePackage('is-odd'); + }); + + it('handles undefined package name', async () => { + await manager.removePackage(undefined as string); + }); + + it('removes all packages', async () => { + fsExtra.ensureDirSync(`${storageDir}/is-odd/1.0.0/node_modules/is-odd`); + fsExtra.ensureDirSync(`${storageDir}/is-odd/2.0.0/node_modules/is-odd`); + fsExtra.ensureDirSync(`${storageDir}/is-even/1.0.0/node_modules/is-even`); + + await manager.removePackage('is-odd'); + + expect(fsExtra.pathExistsSync(`${storageDir}/is-odd`)).to.be.false; + expect(fsExtra.pathExistsSync(`${storageDir}/is-even`)).to.be.true; + }); + }); + + describe('removeAll', () => { + it('removes everything from the storage dir', async () => { + await manager.install('is-odd', '1.0.0'); + + expect(fsExtra.pathExistsSync(`${storageDir}/catalog.json`)).to.be.true; + expect(fsExtra.pathExistsSync(`${storageDir}/is-odd`)).to.be.true; + + await manager.removeAll(); + + expect(fsExtra.pathExistsSync(`${storageDir}/catalog.json`)).to.be.false; + expect(fsExtra.pathExistsSync(`${storageDir}/is-odd`)).to.be.false; + }); + }); + + describe('usage', () => { + it('uses a default date when not specified', async () => { + const now = new Date(); + + await manager.setUsage('is-odd', '1.0.0'); + + const usage = manager['getPackageInfo']('is-odd', '1.0.0'); + + expect(usage.lastUsedDate).to.be.within(now, new Date()); + }); + + it('marks a package as used right now', async () => { + await manager.install('is-odd', '1.0.0'); + + const now = new Date(); + const littleAfterNow = dayjs(now).add(1, 'minute').toDate(); + const yesterday = dayjs(now).subtract(1, 'days').toDate(); + + await manager.setUsage('is-odd', '1.0.0', now); + + await manager.deletePackagesNotUsedSince(yesterday); + + //package was not deleted + expect( + manager.isInstalled('is-odd', '1.0.0') + ).to.be.true; + + await manager.setUsage('is-odd', '1.0.0', now); + + await manager.deletePackagesNotUsedSince(littleAfterNow); + //package was deleted because it was not used since the cutoff date + expect( + manager.isInstalled('is-odd', '1.0.0') + ).to.be.false; + }); + }); + + describe('dispose', () => { + it('works', () => { + manager.dispose(); + }); + }); + + describe('getVersionDirName', () => { + it('fetches the catalog when not supplied', () => { + expect( + manager['getVersionDirName']('brighterscript', '1.0.0') + ).to.eql('1.0.0'); + }); + + it('creates a hash', () => { + expect( + manager['getVersionDirName']('brighterscript', packageUrl) + ).to.eql(md5(packageUrl)); + }); + + it('uses a pre-existing hash when available', async () => { + const packageUrl2 = `${packageUrl}2`; + //need to do some hackery here to force a hash to already exist (since hash collisions are hard to reproduce...) + await manager.install('brighterscript', packageUrl2); + + await manager['withCatalog']((catalog) => { + //override the hash to be the hash of `packageUrl` + catalog.packages['brighterscript'][packageUrl2].versionDirName = md5(packageUrl); + }); + + //ask for the dir name, it should come back with the hash of the packageUrl + expect( + manager['getVersionDirName']('brighterscript', packageUrl2) + ).to.eql(md5(packageUrl)); + + //now ask for the dir name, it should come with a number appended to it since that hash already exists + expect( + manager['getVersionDirName']('brighterscript', packageUrl) + ).to.eql(`${md5(packageUrl)}-1`); + }); + }); + + describe('parseVersionInfo', () => { + it('returns undefined for bad values', () => { + expect( + manager['parseVersionInfo'](undefined, process.cwd()) + ).to.be.undefined; + + expect( + manager['parseVersionInfo'](null, process.cwd()) + ).to.be.undefined; + + expect( + manager['parseVersionInfo']('', process.cwd()) + ).to.be.undefined; + + expect( + manager['parseVersionInfo'](' ', process.cwd()) + ).to.be.undefined; + }); + it('detects valid semver versions', () => { + expect( + manager['parseVersionInfo']('1.0.0', process.cwd()) + ).to.eql({ + value: '1.0.0', + type: 'semver-exact' + }); + + expect( + manager['parseVersionInfo']('1.0.0-alpha.2', process.cwd()) + ).to.eql({ + value: '1.0.0-alpha.2', + type: 'semver-exact' + }); + }); + + it('detects valid semver version ranges', () => { + expect( + manager['parseVersionInfo']('~1.0.0', process.cwd()) + ).to.eql({ + value: '~1.0.0', + type: 'semver-range' + }); + + expect( + manager['parseVersionInfo']('^1.0.0', process.cwd()) + ).to.eql({ + value: '^1.0.0', + type: 'semver-range' + }); + + expect( + manager['parseVersionInfo']('1.2.x', process.cwd()) + ).to.eql({ + value: '1.2.x', + type: 'semver-range' + }); + + expect( + manager['parseVersionInfo']('1.2.0 || >=1.2.2 <1.3.0', process.cwd()) + ).to.eql({ + value: '1.2.0 || >=1.2.2 <1.3.0', + type: 'semver-range' + }); + }); + + it('detects valid dist tags', () => { + expect( + manager['parseVersionInfo']('@next', process.cwd()) + ).to.eql({ + value: '@next', + type: 'dist-tag' + }); + }); + + it('detects valid URLs', () => { + expect( + manager['parseVersionInfo']('https://github.com', process.cwd()) + ).to.eql({ + value: 'https://github.com', + type: 'url' + }); + + expect( + manager['parseVersionInfo'](packageUrl, process.cwd()) + ).to.eql({ + value: packageUrl, + type: 'url' + }); + }); + + it('detects paths to tgz', () => { + expect( + manager['parseVersionInfo']('./something.tgz', process.cwd()) + ).to.eql({ + value: './something.tgz', + type: 'tgz-path' + }); + + expect( + manager['parseVersionInfo'](s`${tempDir}/thing.tgz`, process.cwd()) + ).to.eql({ + value: s`${tempDir}/thing.tgz`, + type: 'tgz-path' + }); + }); + + it('detects paths to directories', () => { + expect( + manager['parseVersionInfo']('./something', s`${process.cwd()}`) + ).to.eql({ + value: s`${process.cwd()}/something`, + type: 'dir' + }); + + expect( + manager['parseVersionInfo']('./something', cwd) + ).to.eql({ + value: s`${cwd}/something`, + type: 'dir' + }); + + expect( + manager['parseVersionInfo'](s`${tempDir}/thing`, process.cwd()) + ).to.eql({ + value: s`${tempDir}/thing`, + type: 'dir' + }); + }); + }); +}); diff --git a/src/managers/LocalPackageManager.ts b/src/managers/LocalPackageManager.ts new file mode 100644 index 00000000..5ea9bf1c --- /dev/null +++ b/src/managers/LocalPackageManager.ts @@ -0,0 +1,415 @@ +/* eslint-disable @typescript-eslint/consistent-indexed-object-style */ +import * as fsExtra from 'fs-extra'; +import { standardizePath as s } from 'brighterscript'; +import { util } from '../util'; +import type { ExtensionContext } from 'vscode'; +import * as lodash from 'lodash'; +import * as md5 from 'md5'; +import * as semver from 'semver'; +import * as path from 'path'; + +const USAGE_KEY = 'local-package-usage'; + +/** + * Manages all node_module packages that are installed by this extension + */ +export class LocalPackageManager { + constructor( + public readonly storageLocation: string, + public readonly context: ExtensionContext + ) { + this.catalogPath = s`${this.storageLocation}/catalog.json`; + } + + private catalogPath: string; + + /** + * Load the catalog object from disk + */ + private getCatalog(): PackageCatalog { + //load from disk + return fsExtra.readJsonSync(this.catalogPath, { throws: false }) ?? {}; + } + + /** + * Write the catalog object to disk + */ + private setCatalog(catalog: PackageCatalog) { + fsExtra.outputJsonSync(this.catalogPath, catalog); + } + + private setCatalogPackageInfo(packageName: string, version: string, info: PackageCatalogPackageInfo) { + const catalog = this.getCatalog(); + + lodash.set(catalog, ['packages', packageName, version], info); + + this.setCatalog(catalog); + } + + /** + * Is the given package installed + * @param packageName name of the package + * @param versionInfo versionInfo of the package + * @returns true if the package is installed, false if not + */ + public isInstalled(packageName: string, versionInfo: string) { + return this.getPackageInfo(packageName, versionInfo).isInstalled; + } + + /** + * Install a package with the given name and version information + * @param packageName the name of the package + * @param versionInfo the versionInfo of the package. See {versionInfo} for more details + * @returns the absolute path to the installed package + */ + public async install(packageName: string, versionInfo: string): Promise { + const packageInfo = this.getPackageInfo(packageName, versionInfo); + + //if this package is already installed, skip the install + if (packageInfo.isInstalled) { + return packageInfo; + } + + fsExtra.ensureDirSync(packageInfo.rootDir); + + //write a simple package.json file referencing the version of brighterscript we want + await fsExtra.outputJson(`${packageInfo.rootDir}/package.json`, { + name: 'vscode-brighterscript-host', + private: true, + version: '1.0.0', + dependencies: { + [packageName]: versionInfo + } + }); + + //install the package + await util.spawnNpmAsync(['install'], { + cwd: packageInfo.rootDir + }); + + //update the catalog + this.setCatalogPackageInfo(packageName, versionInfo, { + versionDirName: packageInfo.versionDirName, + installDate: Date.now() + }); + + return this.getPackageInfo(packageName, versionInfo); + } + + /** + * Remove a specific version of a package + * @param packageName name of the package + * @param version version of the package to remove + */ + public async uninstall(packageName: string, version: VersionInfo, catalog?: PackageCatalog) { + await this.withCatalog(async (catalog) => { + const info = this.getPackageInfo(packageName, version, catalog); + await fsExtra.remove(info.rootDir); + delete catalog.packages?.[packageName]?.[version]; + }, catalog); + } + + /** + * Run an action with a given catalog object. If no catalog is provided, the catalog will be loaded from disk and saved back to disk after the action is complete. + * If a catalog is provided, it's assumed the outside caller will handle saving the catalog to disk + */ + private async withCatalog(callback: (catalog: PackageCatalog) => T | PromiseLike, catalog?: PackageCatalog): Promise { + let hasExternalCatalog = !!catalog; + catalog ??= this.getCatalog(); + + const result = await Promise.resolve( + callback(catalog) + ); + + if (!hasExternalCatalog) { + this.setCatalog(catalog); + } + return result; + } + + /** + * Remove all packages with the given name + * @param packageName the name of the package that will have all versions removed + */ + public async removePackage(packageName: string) { + //delete the package folder + await fsExtra.remove(s`${this.storageLocation}/${packageName}`); + + const catalog = this.getCatalog(); + delete catalog.packages?.[packageName]; + this.setCatalog(catalog); + } + + /** + * Remove all packages and their versions + */ + public async removeAll() { + await fsExtra.emptyDir(this.storageLocation); + } + + /** + * Create a filesystem-safe name for the given version. This will be used as the directory name for the package version. + * Will also handle collisions with existing directories by appending a number to the end of the directory name if we already have + * a directory with the same name for this package + * @param version + * @returns + */ + private getVersionDirName(packageName: string, version: string, catalog = this.getCatalog()) { + const existingVersionDirName = catalog.packages?.[packageName]?.[version]?.versionDirName; + + //if there's already a directory for this package, return it + if (existingVersionDirName) { + return existingVersionDirName; + } + + //this is a valid semver number, so we can use it as the directory name + if (semver.valid(version)) { + return version; + } else { + + //hash the string to create a unique folder name. There is next to zero possibility these will clash, but we'll handle collisions anyway + const hash = md5(version.trim()); + const existingHashes = Object.values(catalog.packages?.[packageName] ?? {}).map(x => x.versionDirName); + let newHash = hash; + let i = 1; + while (existingHashes.includes(newHash)) { + newHash = `${hash}-${i++}`; + } + return newHash; + } + } + + /** + * Get info about this package (regardless of whether it's installed or not). + * If the package is not installed, all + * @param packageName name of the package + * @param versionInfo versionInfo of the package + * @param catalog the catalog object. If not provided, it will be loaded from disk + * @returns + */ + private getPackageInfo(packageName: string, versionInfo: VersionInfo, catalog = this.getCatalog()): PackageInfo { + //TODO derive a better name for some edge cases (like urls or tags) + const versionDirName = this.getVersionDirName(packageName, versionInfo, catalog); + + const rootDir = s`${this.storageLocation}/${packageName}/${versionDirName}`; + const packageDir = s`${rootDir}/node_modules/${packageName}`; + const packageInfo = (catalog.packages?.[packageName]?.[versionInfo] ?? {}) as PackageCatalogPackageInfo; + const lastUseDate = this.context.globalState.get(USAGE_KEY, {})[packageName]?.[versionInfo]; + return { + packageName: packageName, + versionInfo: versionInfo, + rootDir: rootDir, + packageDir: packageDir, + versionDirName: versionDirName, + version: fsExtra.readJsonSync(s`${packageDir}/package.json`, { throws: false })?.version, + isInstalled: fsExtra.pathExistsSync(packageDir), + lastUsedDate: lastUseDate ? new Date(lastUseDate) : undefined, + installDate: packageInfo.installDate ? new Date(packageInfo.installDate) : undefined + }; + } + + /** + * Mark a package as being used by the user right now. This can help with determining which packages are safe to remove after a period of time. + * @param packageName the name of the package + * @param version the version of the package + */ + public async setUsage(packageName: string, version: VersionInfo, dateUsed: Date = new Date()) { + const usage = this.context.globalState.get(USAGE_KEY, {}); + lodash.set(usage, [packageName, version], dateUsed.getTime()); + await this.context.globalState.update(USAGE_KEY, usage); + } + + /** + * Delete packages that havent been used since the given cutoff date + * @param cutoffDate any package not used since this date will be deleted + */ + public async deletePackagesNotUsedSince(cutoffDate: Date) { + //get the list of directories from the storage folder (these are our package names) + const packageNames = (await fsExtra.readdir(this.storageLocation)) + .filter(x => x !== 'catalog.json'); + + let onDiskPackages = {}; + + //get every version folder for each package + await Promise.all( + packageNames.map(async (packageName) => { + onDiskPackages[packageName] = {}; + for (const versionDirName of await fsExtra.readdir(s`${this.storageLocation}/${packageName}`)) { + //set to the oldest date possible + onDiskPackages[packageName][versionDirName] = 0; + } + }) + ); + + const catalog = this.getCatalog(); + + //now get the actual usage dates + const usage = this.context.globalState.get(USAGE_KEY, {}); + for (const [packageName, versions] of Object.entries(usage)) { + for (const [version, dateUsed] of Object.entries(versions)) { + const packageInfo = this.getPackageInfo(packageName, version, catalog); + onDiskPackages[packageName][packageInfo.versionDirName] = dateUsed; + } + } + + let cutoffDateMs = cutoffDate.getTime(); + //now delete every directory that's older than our date + for (const [packageName, versions] of Object.entries(onDiskPackages)) { + for (const [versionDirName, lastUsedDate] of Object.entries(versions)) { + if (lastUsedDate < cutoffDateMs) { + await this.uninstall(packageName, versionDirName); + } + } + } + this.setCatalog(catalog); + } + + /** + * Parse the versionInfo string into a ParsedVersionInfo object which gives us more details about how to handle it + * @param versionInfo the string to evaluate + * @param cwd a current working directory to use when resolving relative paths + * @returns an object with parsed information about the versionInfo + */ + public parseVersionInfo(versionInfo: string, cwd: string): ParsedVersionInfo { + //is empty string or undefined, return undefined + if (!util.isNonEmptyString(versionInfo)) { + return undefined; + + //is an exact semver value + } else if (semver.valid(versionInfo)) { + return { + type: 'semver-exact', + value: versionInfo + }; + //is a semver range + } else if (semver.validRange(versionInfo)) { + return { + type: 'semver-range', + value: versionInfo + }; + //is a dist tag (like @next, @latest, etc...) + } else if (/^@[a-z0-9-_]*$/i.test(versionInfo)) { + return { + type: 'dist-tag', + value: versionInfo + }; + + //is a url, return as-is + } else if (/^(http|https):\/\//.test(versionInfo)) { + return { + type: 'url', + value: versionInfo + }; + + //path to a tgz + } else if (/\.tgz$/i.test(versionInfo)) { + return { + type: 'tgz-path', + value: versionInfo + }; + + //an absolute path + } else if (path.isAbsolute(versionInfo)) { + return { + type: 'dir', + value: versionInfo + }; + + //assume relative path, resolve it to the cwd + } else { + return { + type: 'dir', + value: path.resolve(cwd, versionInfo) + }; + } + } + + public dispose() { + + } +} + +/** + * The versionInfo of a package. This can be: + * - specific version number (i.e. `1.0.0`, `2.3.4-alpha.1`) + * - a url to a package (i.e. `https://github.com/rokucommunity/brighterscript/releases/download/v0.0.0-packages/brighterscript-0.67.5-lsp-refactor.20240806164122.tgz`) + * - TODO: a path to a local package (i.e. `file:/path/to/package.tgz`) + * - TODO: a release tag (i.e. `@latest`, `@next`) + * - TODO: a release line (i.e. `insider:lsp-rewrite`) + */ +export type VersionInfo = string; + +export interface PackageCatalog { + packages: { + [packageName: string]: { + [version: string]: PackageCatalogPackageInfo; + }; + }; +} + +export interface PackageCatalogPackageInfo { + versionDirName: string; + installDate: number; +} + +export interface PackageInfo { + /** + * The name of the package + */ + packageName: string; + /** + * The versionInfo of the package. + */ + versionInfo: VersionInfo; + /** + * The directory where the top-level folder for this package and version will be located. (i.e. `${storageDir}/${packageName}/${versionDirName}`). + * Due to how how we install packages, this will be the root directory for the package which contains a barebones `package.json` file, + * and once installed, will also contain the `node_modules/${packageName}` folder. + */ + rootDir: string; + /** + * Directory where this package will actually be located (i.e. `${packageDir}/node_modules/${packageName}`) + */ + packageDir: string; + /** + * The version from this package's `package.json` file. Will be `undefined` if unable to read the file + */ + version?: string; + /** + * The name of the directory representing this version. If versionInfo is a semantic version, we'll use that for the dirName. + * Otherwise, we'll create a unique hash of the versionInfo + */ + versionDirName: string; + /** + * Is this package currently installed + */ + isInstalled: boolean; + /** + * Date this package was installed + */ + installDate: Date; + /** + * Date this package was last used by vscode + */ + lastUsedDate: Date; +} + +export type ParsedVersionInfo = { + type: 'url'; + value: string; +} | { + type: 'tgz-path'; + value: string; +} | { + type: 'semver-exact'; + value: string; +} | { + type: 'semver-range'; + value: string; +} | { + type: 'dist-tag'; + value: string; +} | { + type: 'dir'; + value: string; +}; diff --git a/src/managers/WebviewViewProviderManager.spec.ts b/src/managers/WebviewViewProviderManager.spec.ts index fd073fad..cc111979 100644 --- a/src/managers/WebviewViewProviderManager.spec.ts +++ b/src/managers/WebviewViewProviderManager.spec.ts @@ -15,7 +15,7 @@ describe('WebviewViewProviderManager', () => { const config = {} as BrightScriptLaunchConfiguration; let webviewViewProviderManager: WebviewViewProviderManager; let rtaManager: RtaManager; - const brightScriptCommands = new BrightScriptCommands({} as any, {} as any, {} as any, {} as any, {} as any); + const brightScriptCommands = new BrightScriptCommands({} as any, {} as any, {} as any, {} as any, {} as any, {} as any); before(() => { context = { diff --git a/src/mockVscode.spec.ts b/src/mockVscode.spec.ts index 79f3f279..fd4a5dae 100644 --- a/src/mockVscode.spec.ts +++ b/src/mockVscode.spec.ts @@ -1,6 +1,11 @@ import { EventEmitter } from 'eventemitter3'; import type { Command, Range, TreeDataProvider, TreeItemCollapsibleState, Uri, WorkspaceFolder, ConfigurationScope, ExtensionContext, WorkspaceConfiguration, OutputChannel, QuickPickItem } from 'vscode'; +import URI from 'vscode-uri'; import * as path from 'path'; +import { standardizePath as s } from 'brighterscript'; + +const cwd = s`${__dirname}/../`; +const tempDir = s`${cwd}/.tmp`; //copied from vscode to help with unit tests enum QuickPickItemKind { @@ -34,6 +39,9 @@ export let vscode = { CodeAction: class { }, Diagnostic: class { }, CallHierarchyItem: class { }, + ProgressLocation: { + Notification: 1 + }, QuickPickItemKind: QuickPickItemKind, StatusBarAlignment: { Left: 1, @@ -96,8 +104,8 @@ export let vscode = { update: function(key: string, value: any) { this._data[key] = value; }, - get: function(key: string) { - return this._data[key]; + get: function(key: string, defaultData) { + return this._data[key] ?? defaultData; } } as any, workspaceState: { @@ -109,7 +117,7 @@ export let vscode = { return this._data[key]; } } as any, - globalStorageUri: undefined as Uri, + globalStorageUri: URI.file(tempDir), environmentVariableCollection: {} as any, logUri: undefined as Uri, logPath: '', @@ -161,6 +169,9 @@ export let vscode = { onDidCloseTextDocument: () => { } }, window: { + withProgress: (options, action) => { + return action(); + }, showInputBox: () => { }, createStatusBarItem: () => { return { @@ -170,6 +181,7 @@ export let vscode = { dispose: () => { } }; }, + onDidChangeWindowState: () => { }, createQuickPick: () => { class QuickPick { private emitter = new EventEmitter(); diff --git a/src/util.ts b/src/util.ts index be5799f7..df6b3ab7 100644 --- a/src/util.ts +++ b/src/util.ts @@ -10,6 +10,7 @@ import { EXTENSION_ID, ROKU_DEBUG_VERSION } from './constants'; import type { DeviceInfo } from 'roku-deploy'; import * as request from 'postman-request'; import type { Response, CoreOptions } from 'request'; +import * as childProcess from 'child_process'; class Util { public async readDir(dirPath: string) { @@ -268,9 +269,14 @@ class Util { * Get a promise that resolves after the given number of milliseconds. */ public sleep(milliseconds: number) { - return new Promise((resolve) => { - setTimeout(resolve, milliseconds); - }); + let handle: NodeJS.Timeout; + const promise = new Promise((resolve) => { + handle = setTimeout(resolve, milliseconds); + }) as Promise & { cancel: () => void }; + promise.cancel = () => { + clearTimeout(handle); + }; + return promise; } /** @@ -424,6 +430,90 @@ class Util { spinner.dispose(); } } + + /** + * Execute a command and get a promise for when it finishes. + * @param command the command to execute + * @param options the options to pass to exec + * @returns the stdout if successful, or an error if failed + */ + public async exec(command: string, options?: childProcess.ExecOptions): Promise { + return new Promise((resolve, reject) => { + childProcess.exec(command, options, (error, stdout) => { + if (error) { + reject(error); + } else { + resolve(stdout); + } + }); + }); + } + + /** + * Determine if the current OS is running a version of windows + */ + private isWindowsPlatform() { + return process.platform.startsWith('win'); + } + + /** + * Spawn an npm command and return a promise. + * This is necessary because spawn requires the file extension (.cmd) on windows. + * @param args - the list of args to pass to npm. Any undefined args will be removed from the list, so feel free to use ternary outside to simplify things + */ + spawnNpmAsync(args: Array, options?: childProcess.SpawnOptions) { + //filter out undefined args + args = args.filter(arg => arg !== undefined); + + if (this.isWindowsPlatform()) { + return this.spawnAsync('npm.cmd', args, { + ...options, + shell: true, + detached: false, + windowsHide: true + }); + } else { + return this.spawnAsync('npm', args, options); + } + } + + /** + * Executes an exec command and returns a promise that completes when it's finished + */ + spawnAsync(command: string, args?: string[], options?: childProcess.SpawnOptions) { + return new Promise((resolve, reject) => { + const child = childProcess.spawn(command, args ?? [], { + ...(options ?? {}), + stdio: 'inherit' + }); + child.addListener('error', reject); + child.addListener('exit', resolve); + }); + } + + + /** + * Run an action with option for a progress spinner. If `showProgress` is `false` then no progress is shown and instead the action is run directly + */ + public async runWithProgress(options: Partial & { showProgress?: boolean }, action: () => PromiseLike): Promise { + //show a progress spinner if configured to do so + if (options?.showProgress !== false) { + return vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + cancellable: false, + ...options + }, action); + } else { + return action(); + } + } + + /** + * Is the value a non-empty string? + */ + public isNonEmptyString(value: any): value is string { + return typeof value === 'string' && value.trim() !== ''; + } } const util = new Util(); diff --git a/tsconfig.json b/tsconfig.json index 566e1385..1d8232b1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,7 +14,9 @@ "strictNullChecks": false, "noUnusedParameters": false, "allowSyntheticDefaultImports": true, - "forceConsistentCasingInFileNames": true + "forceConsistentCasingInFileNames": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true }, "include": [ "src/**/*"