diff --git a/package.json b/package.json index 724cd4753..078b97944 100644 --- a/package.json +++ b/package.json @@ -587,6 +587,12 @@ "default": true, "markdownDescription": "If set to `true`, the extension will bring up the GRPC server." }, + "runme.experiments.shellWarning": { + "type": "boolean", + "scope": "window", + "default": false, + "markdownDescription": "If set to `true`, the extension will display an error message if an unsupported shell is detected." + }, "runme.checkout.projectDir": { "type": "string", "scope": "machine", @@ -844,6 +850,12 @@ "type": "string", "default": "http://localhost:8877/api", "description": "The base URL of the AI service." + }, + "runme.app.docsUrl": { + "type": "string", + "scope": "window", + "default": "https://docs.runme.dev", + "markdownDescription": "Documentation Base URL" } } } diff --git a/src/client/components/configuration/annotations.ts b/src/client/components/configuration/annotations.ts index 4ccd9f870..f0dcde2eb 100644 --- a/src/client/components/configuration/annotations.ts +++ b/src/client/components/configuration/annotations.ts @@ -2,7 +2,12 @@ import { LitElement, html } from 'lit' import { customElement, property } from 'lit/decorators.js' import { when } from 'lit/directives/when.js' -import type { ClientMessage, CellAnnotations, CellAnnotationsErrorResult } from '../../../types' +import type { + ClientMessage, + CellAnnotations, + CellAnnotationsErrorResult, + Settings, +} from '../../../types' import { CellAnnotationsSchema, AnnotationSchema } from '../../../schema' import { ClientMessages, @@ -90,6 +95,9 @@ export class Annotations extends LitElement { @property({ type: Array }) categories: string[] = [] + @property({ type: Object }) + settings: Settings = {} + #getTargetValue(e: Target) { switch (e.target.type) { case 'text': @@ -228,7 +236,7 @@ export class Annotations extends LitElement { } renderDocsLink(id: string) { - const link = `https://docs.runme.dev/r/extension/${id}` + const link = `${this.settings?.docsUrl}/r/extension/${id}` return html`(docs ${ExternalLinkIcon})` } @@ -298,6 +306,7 @@ export class Annotations extends LitElement { identifier="${id}" @onChange=${this.onCategorySelectorChange} @onCreateNewCategory=${this.createNewCategoryClick} + @settings=${this.settings} > ` diff --git a/src/client/components/configuration/categorySelector.ts b/src/client/components/configuration/categorySelector.ts index 2d8788c41..c60e368b4 100644 --- a/src/client/components/configuration/categorySelector.ts +++ b/src/client/components/configuration/categorySelector.ts @@ -5,6 +5,7 @@ import type { TextField } from '@vscode/webview-ui-toolkit' import { ExternalLinkIcon } from '../icons/external' import { CATEGORY_SEPARATOR } from '../../../constants' +import { Settings } from '../../../types' export interface ISelectedCategory { name: string @@ -302,6 +303,9 @@ export class CategorySelector extends LitElement { @property() identifier: string | undefined + @property({ type: Object }) + settings: Settings = {} + private dispatchComponentEvent(name: string, e: Event) { if (e.defaultPrevented) { e.preventDefault() @@ -334,7 +338,8 @@ export class CategorySelector extends LitElement { } renderLink() { - return html`(docs ${ExternalLinkIcon})` } diff --git a/src/client/index.ts b/src/client/index.ts index 6c0d20fd3..9bc0b5825 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,4 +1,4 @@ -import type { ActivationFunction, RendererContext } from 'vscode-notebook-renderer' +import type { ActivationFunction } from 'vscode-notebook-renderer' import { OutputType, RENDERERS } from '../constants' import type { CellOutput } from '../types' @@ -13,7 +13,7 @@ import './components' // rendering logic inside of the `render()` function. // ---------------------------------------------------------------------------- -export const activate: ActivationFunction = (context: RendererContext) => { +export const activate: ActivationFunction = (context) => { setContext(context) return { renderOutputItem(outputItem, element) { @@ -90,6 +90,7 @@ export const activate: ActivationFunction = (context: RendererContext) => 'validationErrors', JSON.stringify(payload.output.validationErrors ?? []), ) + annoElem.setAttribute('settings', JSON.stringify(payload.output.settings)) element.appendChild(annoElem) break case OutputType.terminal: diff --git a/src/extension/cell.ts b/src/extension/cell.ts index 1f6f5a9a9..44bcd78f8 100644 --- a/src/extension/cell.ts +++ b/src/extension/cell.ts @@ -32,6 +32,7 @@ import { } from '../types' import { Mutex } from '../utils/sync' import { + getDocsUrl, getNotebookTerminalConfigurations, getSessionOutputs, isPlatformAuthEnabled, @@ -169,6 +170,9 @@ export class NotebookCellOutputManager { annotations: getAnnotations(cell), validationErrors: validateAnnotations(cell), id: cell.metadata['runme.dev/id'], + settings: { + docsUrl: getDocsUrl(), + }, }, } diff --git a/src/extension/executors/aws.ts b/src/extension/executors/aws.ts index 6727c9f2a..37154ecd8 100644 --- a/src/extension/executors/aws.ts +++ b/src/extension/executors/aws.ts @@ -14,7 +14,7 @@ import { resolveProgramOptionsScript } from './runner' import { IKernelExecutor } from '.' export const aws: IKernelExecutor = async (executor) => { - const { cellText, exec, runner, runnerEnv, doc, outputs, context } = executor + const { cellText, exec, runner, runnerEnv, doc, outputs, kernel } = executor const annotations = getAnnotations(exec.cell) @@ -41,67 +41,19 @@ export const aws: IKernelExecutor = async (executor) => { cellId, }) - // todo(sebastian): move down into kernel? switch (programOptions.exec?.type) { case 'script': - { - programOptions.exec.script = 'echo $AWS_PROFILE' - } + programOptions.exec.script = 'echo $AWS_PROFILE' break } - const program = await runner.createProgramSession(programOptions) - context.subscriptions.push(program) + let profile = '' + const result = await kernel.runProgram(programOptions) - let execRes: string | undefined - const onData = (data: string | Uint8Array) => { - if (execRes === undefined) { - execRes = '' - } - execRes += data.toString() + if (result) { + profile = result } - program.onDidWrite(onData) - program.onDidErr(onData) - program.run() - - const success = await new Promise((resolve, reject) => { - program.onDidClose(async (code) => { - if (code !== 0) { - return resolve(false) - } - return resolve(true) - }) - - program.onInternalErr((e) => { - reject(e) - }) - - const exitReason = program.hasExited() - - // unexpected early return, likely an error - if (exitReason) { - switch (exitReason.type) { - case 'error': - { - reject(exitReason.error) - } - break - - case 'exit': - { - resolve(exitReason.code === 0) - } - break - - default: { - resolve(false) - } - } - } - }) - - const profile = success ? execRes?.trim() : 'default' credentials = fromIni({ profile }) switch (awsResolver.view) { diff --git a/src/extension/executors/resource.ts b/src/extension/executors/resource.ts index 10798f806..03a838760 100644 --- a/src/extension/executors/resource.ts +++ b/src/extension/executors/resource.ts @@ -69,9 +69,8 @@ export const uri: IKernelRunner = async ({ program.onDidWrite(onData) program.onDidErr(onData) - program.run() - const success = await new Promise((resolve, reject) => { + const success = new Promise((resolve, reject) => { program.onDidClose(async (code) => { if (code !== 0) { return resolve(false) @@ -107,6 +106,9 @@ export const uri: IKernelRunner = async ({ } }) - const cellText = success ? execRes?.trim() : undefined - return runScript?.(cellText) || success + program.run() + const result = await success + + const cellText = result ? execRes?.trim() : undefined + return runScript?.(cellText) || result } diff --git a/src/extension/extension.ts b/src/extension/extension.ts index 6730d2b63..bbab8caf1 100644 --- a/src/extension/extension.ts +++ b/src/extension/extension.ts @@ -15,6 +15,7 @@ import Channel from 'tangle/webviews' import { NotebookUiEvent, Serializer, SyncSchema } from '../types' import { + getDocsUrlFor, getForceNewWindowConfig, getRunmeAppUrl, getSessionOutputs, @@ -79,6 +80,8 @@ import { NotebookPanel as EnvStorePanel } from './panels/notebook' import { NotebookCellStatusBarProvider } from './provider/cellStatusBar/notebook' import { SessionOutputCellStatusBarProvider } from './provider/cellStatusBar/sessionOutput' import * as manager from './ai/manager' +import getLogger from './logger' + export class RunmeExtension { protected serializer?: SerializerBase @@ -356,6 +359,40 @@ export class RunmeExtension { } await bootFile(context) + + if (kernel.hasExperimentEnabled('shellWarning', false)) { + const showUnsupportedShellMessage = async () => { + const showMore = 'Show more' + + const answer = await window.showErrorMessage('Unsupported shell', showMore) + if (answer === showMore) { + const url = getDocsUrlFor('/r/extension/supported-shells') + env.openExternal(Uri.parse(url)) + } + } + + const logger = getLogger('runme.beta.shellWarning') + + kernel + .runProgram('echo $SHELL') + .then((output) => { + if (output === false) { + return + } + + const supportedShells = ['bash', 'zsh'] + const isSupported = supportedShells.some((sh) => output?.includes(sh)) + logger.info(`Shell: ${output}`) + + if (!isSupported) { + showUnsupportedShellMessage() + } + }) + .catch((e) => { + logger.error(e) + showUnsupportedShellMessage() + }) + } } protected handleMasking(kernel: Kernel, maskingIsOn: boolean): (e: NotebookUiEvent) => void { diff --git a/src/extension/kernel.ts b/src/extension/kernel.ts index b40703263..1d804e3af 100644 --- a/src/extension/kernel.ts +++ b/src/extension/kernel.ts @@ -68,7 +68,7 @@ import { import { getSystemShellPath, isShellLanguage } from './executors/utils' import './wasm/wasm_exec.js' import { RpcError } from './grpc/client' -import { IRunner, IRunnerReady } from './runner' +import { IRunner, IRunnerReady, RunProgramOptions } from './runner' import { IRunnerEnvironment } from './runner/environment' import { IKernelRunnerOptions, executeRunner } from './executors/runner' import { ITerminalState, NotebookTerminalType } from './terminal/terminalState' @@ -141,6 +141,7 @@ export class Kernel implements Disposable { this.#experiments.set('escalationButton', config.get('escalationButton', false)) this.#experiments.set('smartEnvStore', config.get('smartEnvStore', false)) this.#experiments.set('aiLogs', config.get('aiLogs', false)) + this.#experiments.set('shellWarning', config.get('shellWarning', false)) this.cellManager = new NotebookCellManager(this.#controller) this.#controller.supportsExecutionOrder = getNotebookExecutionOrder() @@ -1102,4 +1103,89 @@ export class Kernel implements Disposable { public getPlainCache(cacheId: string): Promise | undefined { return this.serializer?.getPlainCache(cacheId) } + + async runProgram(program?: RunProgramOptions | string) { + let programOptions: RunProgramOptions + const logger = getLogger('runProgram') + + if (!this.runner) { + logger.error('No runner available') + return false + } + + if (typeof program === 'object') { + programOptions = program + } else if (typeof program === 'string') { + programOptions = { + programName: 'bash', + background: false, + exec: { + type: 'script', + script: program, + }, + languageId: 'sh', + commandMode: CommandModeEnum().INLINE_SHELL, + storeLastOutput: false, + tty: false, + } + } else { + logger.error('Invalid program options') + return + } + + const programSession = await this.runner.createProgramSession(programOptions) + + this.context.subscriptions.push(programSession) + + let execRes: string | undefined + const onData = (data: string | Uint8Array) => { + if (execRes === undefined) { + execRes = '' + } + execRes += data.toString() + } + + programSession.onDidWrite(onData) + programSession.onDidErr(onData) + + const success = new Promise((resolve, reject) => { + programSession.onDidClose(async (code) => { + if (code !== 0) { + return resolve(false) + } + return resolve(true) + }) + + programSession.onInternalErr((e) => { + reject(e) + }) + + const exitReason = programSession.hasExited() + + if (exitReason) { + switch (exitReason.type) { + case 'error': + { + reject(exitReason.error) + } + break + + case 'exit': + { + resolve(exitReason.code === 0) + } + break + + default: { + resolve(false) + } + } + } + }) + + programSession.run() + const result = await success + + return result ? execRes?.trim() : undefined + } } diff --git a/src/types.ts b/src/types.ts index ab183e179..d90819180 100644 --- a/src/types.ts +++ b/src/types.ts @@ -361,16 +361,18 @@ export interface AWSEKSClustersState { view: AWSSupportedView.EKSClusters } +export interface AnnotationsPayload { + annotations?: CellAnnotations + validationErrors?: CellAnnotationsErrorResult + id?: string +} + interface Payload { [OutputType.error]: string [OutputType.deno]?: DenoState [OutputType.vercel]: VercelState [OutputType.outputItems]: OutputItemsPayload - [OutputType.annotations]: { - annotations?: CellAnnotations - validationErrors?: CellAnnotationsErrorResult - id?: string - } + [OutputType.annotations]: AnnotationsPayload & { settings: Settings } [OutputType.terminal]: TerminalConfiguration & { ['runme.dev/id']: string content?: string @@ -719,3 +721,7 @@ export type NotebookUiEvent = { } ui: boolean } + +export type Settings = { + docsUrl?: string +} diff --git a/src/utils/configuration.ts b/src/utils/configuration.ts index 000b46d19..0684617ff 100644 --- a/src/utils/configuration.ts +++ b/src/utils/configuration.ts @@ -23,6 +23,7 @@ const DEFAULT_WORKSPACE_FILE_ORDER = ['.env.local', '.env'] const DEFAULT_RUNME_APP_API_URL = 'https://platform.stateful.com' const DEFAULT_RUNME_BASE_DOMAIN = 'platform.stateful.com' const DEFAULT_RUNME_REMOTE_DEV = 'staging.platform.stateful.com' +const DEFAULT_DOCS_URL = 'https://docs.runme.dev' const APP_LOOPBACKS = ['127.0.0.1', 'localhost'] const APP_LOOPBACK_MAPPING = new Map([ ['api.', ':4000'], @@ -90,6 +91,7 @@ const configurationSchema = { maskOutputs: z.boolean().default(true), loginPrompt: z.boolean().default(true), platformAuth: z.boolean().default(false), + docsUrl: z.string().default(DEFAULT_DOCS_URL), }, } @@ -432,6 +434,15 @@ const isPlatformAuthEnabled = (): boolean => { return getCloudConfigurationValue('platformAuth', false) } +const getDocsUrl = (): string => { + return getCloudConfigurationValue('docsUrl', DEFAULT_DOCS_URL) +} + +const getDocsUrlFor = (path: string): string => { + const baseUrl = getDocsUrl() + return `${baseUrl}${path}` +} + export { enableServerLogs, getActionsOpenViewInEditor, @@ -462,4 +473,6 @@ export { getSessionOutputs, getMaskOutputs, getLoginPrompt, + getDocsUrlFor, + getDocsUrl, }