From 1c664cc2016f87ae082678351da3c906d32c6c35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Wed, 10 Jun 2026 12:53:15 +0200 Subject: [PATCH 1/8] Add stored procedure command --- CHANGELOG.md | 6 + .../CommandTests/SprocCommandTests.cs | 99 ++++ CosmosDBShell.Tests/ToolOperationsTests.cs | 15 + .../SprocCommand.cs | 528 ++++++++++++++++++ CosmosDBShell/lang/en.ftl | 30 + README.md | 1 + docs/commands.md | 51 ++ docs/mcp.md | 2 + 8 files changed, 732 insertions(+) create mode 100644 CosmosDBShell.Tests/CommandTests/SprocCommandTests.cs create mode 100644 CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c2e9bb..1ee0dd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### New features + +- `sproc` command to manage Cosmos DB for NoSQL stored procedures on the current container: `list`, `show`, `create` (from a JavaScript file or piped body, with `--force` to replace), `exec` (with a JSON argument array and `--partition-key`), `edit` (interactive external editor), and `delete`. ([#103](https://github.com/Azure/CosmosDBShell/issues/103)) + ## 1.1.4-preview — 2026-05-21 First release on the 1.1 line. A pretty packed cycle. The headline change is **ARM-based control plane for database and container management**, but there’s also a fully reworked CLI, two new item commands, a much friendlier shell experience for newcomers, and a long list of paper-cut fixes. diff --git a/CosmosDBShell.Tests/CommandTests/SprocCommandTests.cs b/CosmosDBShell.Tests/CommandTests/SprocCommandTests.cs new file mode 100644 index 0000000..57b7c53 --- /dev/null +++ b/CosmosDBShell.Tests/CommandTests/SprocCommandTests.cs @@ -0,0 +1,99 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.CommandTests; + +using System.Text.Json; +using Azure.Data.Cosmos.Shell.Commands; +using Azure.Data.Cosmos.Shell.Core; +using Microsoft.Azure.Cosmos; + +/// +/// Unit tests for the pure helpers on : subcommand +/// normalization, execution-parameter parsing, and partition-key parsing. +/// +public class SprocCommandTests +{ + [Theory] + [InlineData("LIST", "list")] + [InlineData(" Show ", "show")] + [InlineData("Create", "create")] + [InlineData(null, "")] + [InlineData("", "")] + public void NormalizeSubcommand_TrimsAndLowercases(string? input, string expected) + { + Assert.Equal(expected, SprocCommand.NormalizeSubcommand(input)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ParseExecParams_NullOrWhitespace_ReturnsEmpty(string? input) + { + Assert.Empty(SprocCommand.ParseExecParams(input)); + } + + [Fact] + public void ParseExecParams_JsonArray_ReturnsElements() + { + var parameters = SprocCommand.ParseExecParams("[\"a\", 1, true]"); + + Assert.Equal(3, parameters.Length); + Assert.Equal("a", ((JsonElement)parameters[0]).GetString()); + Assert.Equal(1, ((JsonElement)parameters[1]).GetInt32()); + Assert.True(((JsonElement)parameters[2]).GetBoolean()); + } + + [Fact] + public void ParseExecParams_EmptyArray_ReturnsEmpty() + { + Assert.Empty(SprocCommand.ParseExecParams("[]")); + } + + [Theory] + [InlineData("not json")] + [InlineData("{\"a\":1}")] + [InlineData("\"justAString\"")] + [InlineData("42")] + public void ParseExecParams_NonArray_Throws(string input) + { + Assert.Throws(() => SprocCommand.ParseExecParams(input)); + } + + [Fact] + public void ParsePartitionKey_String_MatchesStringPartitionKey() + { + Assert.Equal(new PartitionKey("pk1").ToString(), SprocCommand.ParsePartitionKey("pk1").ToString()); + } + + [Fact] + public void ParsePartitionKey_QuotedString_PreservesStringType() + { + Assert.Equal(new PartitionKey("pk1").ToString(), SprocCommand.ParsePartitionKey("\"pk1\"").ToString()); + } + + [Fact] + public void ParsePartitionKey_Number_PreservesNumericType() + { + Assert.Equal(new PartitionKey(42).ToString(), SprocCommand.ParsePartitionKey("42").ToString()); + } + + [Fact] + public void ParsePartitionKey_Boolean_PreservesBooleanType() + { + Assert.Equal(new PartitionKey(true).ToString(), SprocCommand.ParsePartitionKey("true").ToString()); + } + + [Fact] + public void DefaultStoredProcedureBody_IsValidSeedTemplate() + { + var body = SprocCommand.DefaultStoredProcedureBody(); + + Assert.False(string.IsNullOrWhiteSpace(body)); + Assert.Contains("function sample", body); + Assert.Contains("getContext().getCollection()", body); + Assert.Contains("queryDocuments", body); + } +} diff --git a/CosmosDBShell.Tests/ToolOperationsTests.cs b/CosmosDBShell.Tests/ToolOperationsTests.cs index 2570011..c265b7f 100644 --- a/CosmosDBShell.Tests/ToolOperationsTests.cs +++ b/CosmosDBShell.Tests/ToolOperationsTests.cs @@ -105,6 +105,21 @@ public void GetTool_AppendsUserOnlyWarningForRestrictedCommands() Assert.Contains("cannot be invoked through MCP", tool.Description, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void GetTool_MarksStoredProceduresRestrictedForMcp() + { + var factory = new CommandRunner().Commands["sproc"]; + + Assert.True(factory.McpRestricted); + + var tool = ToolOperations.GetTool(factory); + + Assert.Contains("cannot be invoked through MCP", tool.Description, StringComparison.OrdinalIgnoreCase); + Assert.NotNull(tool.Annotations); + Assert.True(tool.Annotations!.DestructiveHint); + Assert.True(tool.Annotations.OpenWorldHint); + } + [Fact] public void GetTool_DoesNotAppendWarningForUnrestrictedCommands() { diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs new file mode 100644 index 0000000..a85100e --- /dev/null +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs @@ -0,0 +1,528 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +namespace Azure.Data.Cosmos.Shell.Commands; + +using System.Net; +using System.Text.Json; +using Azure.Data.Cosmos.Shell.Mcp; +using Azure.Data.Cosmos.Shell.Parser; +using Azure.Data.Cosmos.Shell.Util; +using global::Azure.Data.Cosmos.Shell.Core; +using global::Azure.Data.Cosmos.Shell.States; +using Microsoft.Azure.Cosmos.Scripts; +using Spectre.Console; + +[CosmosCommand("sproc")] +[CosmosExample("sproc list", Description = "List the stored procedures in the current container")] +[CosmosExample("sproc show myProc", Description = "Display the body of a stored procedure")] +[CosmosExample("sproc create myProc ./myProc.js", Description = "Create a stored procedure from a JavaScript file")] +[CosmosExample("sproc create myProc ./myProc.js --force", Description = "Create or replace a stored procedure")] +[CosmosExample("sproc edit myProc", Description = "Edit a stored procedure body in an external editor")] +[CosmosExample("sproc exec myProc '[\"param1\", \"param2\"]' --partition-key pk1", Description = "Execute a stored procedure with parameters")] +[CosmosExample("sproc delete myProc", Description = "Delete a stored procedure")] +#pragma warning disable SA1118 // Parameter should not span multiple lines +[McpAnnotation( + Title = "Stored Procedures", + Description = @" +Manages JavaScript stored procedures on the current Cosmos DB container through subcommands: +- 'list' returns the stored procedure ids in the container. +- 'show ' returns the body of a stored procedure. +- 'create ' creates a stored procedure from a JavaScript file. Pass --force to replace an existing one. +- 'exec [params]' executes a stored procedure. 'params' is a JSON array of arguments and --partition-key selects the target partition. +- 'delete ' removes a stored procedure. +This command is restricted in MCP. Run it manually in the shell. +", + Restricted = true, + Destructive = true, + OpenWorld = true)] +#pragma warning restore SA1118 // Parameter should not span multiple lines +internal class SprocCommand : CosmosCommand +{ + [CosmosParameter("subcommand", RequiredErrorKey = "command-sproc-error-missing_subcommand")] + public string Subcommand { get; init; } = string.Empty; + + [CosmosParameter("name", IsRequired = false)] + public string? Name { get; init; } + + [CosmosParameter("value", IsRequired = false)] + public string? Value { get; init; } + + [CosmosOption("partition-key", "pk")] + public string? PartitionKey { get; init; } + + [CosmosOption("force", "f")] + public bool? Force { get; init; } + + [CosmosOption("editor")] + public string? Editor { get; init; } + + [CosmosOption("database", "db")] + public string? Database { get; init; } + + [CosmosOption("container", "con")] + public string? Container { get; init; } + + /// + /// Normalizes a subcommand token to its canonical lower-case form. + /// + internal static string NormalizeSubcommand(string? value) => (value ?? string.Empty).Trim().ToLowerInvariant(); + + /// + /// Parses the JSON array of stored procedure arguments. Returns an empty array + /// when no value is supplied. Each argument is preserved as a + /// so the Cosmos SDK serializes it with the correct type. + /// + internal static object[] ParseExecParams(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return []; + } + + JsonDocument document; + try + { + document = JsonDocument.Parse(json); + } + catch (JsonException ex) + { + throw new CommandException("sproc", MessageService.GetString("command-sproc-error-invalid_params"), ex); + } + + using (document) + { + if (document.RootElement.ValueKind != JsonValueKind.Array) + { + throw new CommandException("sproc", MessageService.GetString("command-sproc-error-invalid_params")); + } + + var parameters = new List(); + foreach (var element in document.RootElement.EnumerateArray()) + { + parameters.Add(element.Clone()); + } + + return [.. parameters]; + } + } + + /// + /// Parses the --partition-key value, preserving its JSON type when possible + /// and falling back to a string value otherwise. + /// + internal static PartitionKey ParsePartitionKey(string value) + { + try + { + using var document = JsonDocument.Parse(value); + return CreatePartitionKey(document.RootElement); + } + catch (JsonException) + { + return new PartitionKey(value); + } + } + + private static CommandException NotFound(string name, Exception inner) => + new("sproc", MessageService.GetArgsString("command-sproc-error-not_found", "name", name), inner); + + /// + /// Returns a sample stored procedure body used to seed the editor when creating + /// a new stored procedure without supplying a file. Mirrors the template offered + /// by the Azure Databases extension for Visual Studio Code. + /// + internal static string DefaultStoredProcedureBody() => + """ + function sample(prefix) { + var collection = getContext().getCollection(); + + // Query documents and take 1st item. + var isAccepted = collection.queryDocuments( + collection.getSelfLink(), + 'SELECT * FROM root r', + function (err, feed, options) { + if (err) throw err; + + // Check the feed and if empty, set the body to 'no docs found', + // else take 1st element from feed. + if (!feed || !feed.length) { + var response = getContext().getResponse(); + response.setBody('no docs found'); + } else { + var response = getContext().getResponse(); + var body = { prefix: prefix, feed: feed[0] }; + response.setBody(JSON.stringify(body)); + } + }); + + if (!isAccepted) throw new Error('The query was not accepted by the server.'); + } + + """; + + public async override Task ExecuteAsync(ShellInterpreter shell, CommandState commandState, string commandText, CancellationToken token) + { + var subcommand = NormalizeSubcommand(this.Subcommand); + if (subcommand.Length == 0) + { + throw new CommandException("sproc", MessageService.GetString("command-sproc-error-missing_subcommand")); + } + + if (shell.State is not ConnectedState connectedState) + { + throw new NotConnectedException("sproc"); + } + + var (_, _, container) = await ResolveContainerAsync( + connectedState.Client, + shell.State, + this.Database, + this.Container, + "sproc", + token); + + return subcommand switch + { + "list" or "ls" => await this.ListAsync(container, commandState, token), + "show" or "cat" => await this.ShowAsync(container, commandState, token), + "create" or "set" => await this.CreateAsync(container, shell, commandState, token), + "exec" or "run" => await this.ExecAsync(container, commandState, token), + "edit" => await this.EditAsync(container, shell, commandState, token), + "delete" or "rm" => await this.DeleteAsync(container, commandState, token), + _ => throw new CommandException( + "sproc", + MessageService.GetArgsString("command-sproc-error-invalid_subcommand", "subcommand", subcommand)), + }; + } + + private async Task ListAsync(Container container, CommandState commandState, CancellationToken token) + { + var ids = new List(); + using var iterator = container.Scripts.GetStoredProcedureQueryIterator(); + while (iterator.HasMoreResults) + { + foreach (var properties in await iterator.ReadNextAsync(token)) + { + ids.Add(properties.Id); + } + } + + commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(ids)); + return commandState; + } + + private async Task ShowAsync(Container container, CommandState commandState, CancellationToken token) + { + var name = this.RequireName(); + + try + { + var response = await container.Scripts.ReadStoredProcedureAsync(name, cancellationToken: token); + commandState.Result = new ShellText(response.Resource.Body); + return commandState; + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + throw NotFound(name, ex); + } + } + + private async Task CreateAsync(Container container, ShellInterpreter shell, CommandState commandState, CancellationToken token) + { + var name = this.RequireName(); + bool force = this.Force == true; + + // When a body is supplied (a file or piped input), create directly. This is + // the path used by scripts and the MCP server. + var explicitBody = this.TryReadExplicitBody(commandState); + if (explicitBody is not null) + { + return await this.WriteCreateAsync(container, commandState, name, explicitBody, force, token); + } + + // No body supplied: seed a default stored procedure and open the editor so an + // interactive user can author it, then confirm before creating. Seeding needs a + // real terminal, so scripts and MCP must pass a file instead. + if (Console.IsInputRedirected || !string.IsNullOrEmpty(shell.CurrentScriptFileName)) + { + throw new CommandException("sproc", MessageService.GetString("command-sproc-error-missing_file")); + } + + // Check for an existing stored procedure before opening the editor so the user + // is not asked to author a body that cannot be saved. When --force is set, seed + // the editor with the existing body so it can be edited in place. + string? existingBody = null; + try + { + var read = await container.Scripts.ReadStoredProcedureAsync(name, cancellationToken: token); + existingBody = read.Resource.Body; + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + existingBody = null; + } + + if (existingBody is not null && !force) + { + throw new CommandException( + "sproc", + MessageService.GetArgsString("command-sproc-error-already_exists", "name", name)); + } + + var seed = existingBody ?? DefaultStoredProcedureBody(); + var edited = await this.LaunchEditorAsync(seed, name, token); + + if (string.IsNullOrWhiteSpace(edited) || !ShellInterpreter.Confirm("command-sproc-create-confirm")) + { + ShellInterpreter.WriteLine(MessageService.GetArgsString("command-sproc-create-discarded", "name", name)); + commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { id = name, created = false })); + return commandState; + } + + return await this.WriteCreateAsync(container, commandState, name, edited, force, token); + } + + private async Task WriteCreateAsync(Container container, CommandState commandState, string name, string body, bool force, CancellationToken token) + { + var properties = new StoredProcedureProperties { Id = name, Body = body }; + + StoredProcedureResponse response; + bool replaced; + if (force) + { + try + { + response = await container.Scripts.ReplaceStoredProcedureAsync(properties, cancellationToken: token); + replaced = true; + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + response = await container.Scripts.CreateStoredProcedureAsync(properties, cancellationToken: token); + replaced = false; + } + } + else + { + try + { + response = await container.Scripts.CreateStoredProcedureAsync(properties, cancellationToken: token); + replaced = false; + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.Conflict) + { + throw new CommandException( + "sproc", + MessageService.GetArgsString("command-sproc-error-already_exists", "name", name), + ex); + } + } + + ShellInterpreter.WriteLine(MessageService.GetArgsString( + replaced ? "command-sproc-replaced" : "command-sproc-created", + "name", + name, + "charge", + response.RequestCharge.ToString("F2"))); + + commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { id = name })); + return commandState; + } + + private async Task ExecAsync(Container container, CommandState commandState, CancellationToken token) + { + var name = this.RequireName(); + + if (string.IsNullOrWhiteSpace(this.PartitionKey)) + { + throw new CommandException("sproc", MessageService.GetString("command-sproc-error-missing_partition_key")); + } + + var parameters = ParseExecParams(this.Value); + var partitionKey = ParsePartitionKey(this.PartitionKey); + + try + { + var response = await container.Scripts.ExecuteStoredProcedureAsync( + name, + partitionKey, + parameters, + cancellationToken: token); + + ShellInterpreter.WriteLine(MessageService.GetArgsString( + "command-sproc-executed", + "name", + name, + "charge", + response.RequestCharge.ToString("F2"))); + + commandState.Result = new ShellJson(response.Resource.Clone()); + return commandState; + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + throw NotFound(name, ex); + } + } + + private async Task DeleteAsync(Container container, CommandState commandState, CancellationToken token) + { + var name = this.RequireName(); + + try + { + var response = await container.Scripts.DeleteStoredProcedureAsync(name, cancellationToken: token); + ShellInterpreter.WriteLine(MessageService.GetArgsString( + "command-sproc-deleted", + "name", + name, + "charge", + response.RequestCharge.ToString("F2"))); + + commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { id = name, deleted = true })); + return commandState; + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + throw NotFound(name, ex); + } + } + + private async Task EditAsync(Container container, ShellInterpreter shell, CommandState commandState, CancellationToken token) + { + var name = this.RequireName(); + + if (Console.IsInputRedirected || !string.IsNullOrEmpty(shell.CurrentScriptFileName)) + { + throw new CommandException("sproc", MessageService.GetString("command-sproc-error-not_interactive")); + } + + string existingBody = string.Empty; + bool exists = false; + try + { + var read = await container.Scripts.ReadStoredProcedureAsync(name, cancellationToken: token); + existingBody = read.Resource.Body; + exists = true; + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + existingBody = string.Empty; + } + + var newBody = await this.LaunchEditorAsync(exists ? existingBody : DefaultStoredProcedureBody(), name, token); + + if (exists && string.Equals(newBody, existingBody, StringComparison.Ordinal)) + { + ShellInterpreter.WriteLine(MessageService.GetArgsString("command-sproc-edit-unchanged", "name", name)); + commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { id = name, changed = false })); + return commandState; + } + + var properties = new StoredProcedureProperties { Id = name, Body = newBody }; + var response = exists + ? await container.Scripts.ReplaceStoredProcedureAsync(properties, cancellationToken: token) + : await container.Scripts.CreateStoredProcedureAsync(properties, cancellationToken: token); + + ShellInterpreter.WriteLine(MessageService.GetArgsString( + exists ? "command-sproc-replaced" : "command-sproc-created", + "name", + name, + "charge", + response.RequestCharge.ToString("F2"))); + + commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { id = name, changed = true })); + return commandState; + } + + /// + /// Writes the supplied body to a temporary .js file, opens it in the + /// external editor, waits for the editor to close, and returns the edited + /// contents. The temporary file is always removed. + /// + private async Task LaunchEditorAsync(string initialBody, string name, CancellationToken token) + { + var editor = ExternalEditor.Resolve(this.Editor); + if (editor is null) + { + throw new CommandException("sproc", MessageService.GetString("command-sproc-error-no_editor")); + } + + var tempPath = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + $"cosmos-sproc-{Guid.NewGuid():N}.js"); + + try + { + await File.WriteAllTextAsync(tempPath, initialBody, token); + + AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetArgsString( + "command-sproc-edit-launching", + "name", + name, + "editor", + editor.DisplayName))); + + using (var process = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = editor.FileName, + Arguments = editor.BuildArguments(tempPath), + UseShellExecute = false, + }) ?? throw new InvalidOperationException("Process.Start returned null")) + { + await process.WaitForExitAsync(token); + if (process.ExitCode != 0) + { + throw new CommandException( + "sproc", + MessageService.GetArgsString("command-sproc-edit-exit-nonzero", "editor", editor.DisplayName, "code", process.ExitCode)); + } + } + + return await File.ReadAllTextAsync(tempPath, token); + } + finally + { + try + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + catch (IOException) + { + // Best-effort cleanup of the temporary file. + } + } + } + + private string RequireName() + { + if (string.IsNullOrWhiteSpace(this.Name)) + { + throw new CommandException("sproc", MessageService.GetString("command-sproc-error-missing_name")); + } + + return this.Name; + } + + private string? TryReadExplicitBody(CommandState commandState) + { + if (!string.IsNullOrWhiteSpace(this.Value)) + { + if (!File.Exists(this.Value)) + { + throw new CommandException( + "sproc", + MessageService.GetArgsString("command-sproc-error-file_not_found", "file", this.Value)); + } + + return File.ReadAllText(this.Value); + } + + var piped = commandState.Result?.ConvertShellObject(DataType.Text) as string; + return string.IsNullOrEmpty(piped) ? null : piped; + } +} diff --git a/CosmosDBShell/lang/en.ftl b/CosmosDBShell/lang/en.ftl index 9e72f79..b139354 100644 --- a/CosmosDBShell/lang/en.ftl +++ b/CosmosDBShell/lang/en.ftl @@ -288,6 +288,36 @@ command-index-error-show_no_args = 'index show' does not take any arguments. Use command-index-error-invalid_policy = Invalid indexing policy JSON. Please provide a valid Cosmos DB indexing policy. command-index-error-no_policy = The container has no indexing policy configured. +command-sproc-description = Manages stored procedures on a container via list, show, create, exec, edit, and delete subcommands. +command-sproc-description-subcommand = The action to perform: list, show, create, exec, edit, or delete. +command-sproc-description-name = The stored procedure id. +command-sproc-description-value = The JavaScript file to read for create, or the JSON array of arguments for exec. +command-sproc-description-partition-key = The partition key used to target a partition when executing a stored procedure. +command-sproc-description-force = Replace the stored procedure if it already exists. +command-sproc-description-editor = External editor to launch for 'sproc edit' (defaults to $VISUAL, $EDITOR, then a platform default). +command-sproc-description-database = The database containing the container. +command-sproc-description-container = The container that owns the stored procedures. +command-sproc-created = Created stored procedure '{ $name }' (RU charge: { $charge }). +command-sproc-replaced = Replaced stored procedure '{ $name }' (RU charge: { $charge }). +command-sproc-deleted = Deleted stored procedure '{ $name }' (RU charge: { $charge }). +command-sproc-executed = Executed stored procedure '{ $name }' (RU charge: { $charge }). +command-sproc-edit-launching = Editing stored procedure '{ $name }' with { $editor }. +command-sproc-edit-unchanged = Stored procedure '{ $name }' was not changed. +command-sproc-edit-exit-nonzero = Editor '{ $editor }' exited with status { $code }. +command-sproc-create-confirm = Create this stored procedure +command-sproc-create-discarded = Discarded stored procedure '{ $name }'. +command-sproc-error-missing_subcommand = Missing subcommand. Use one of: list, show, create, exec, edit, delete. +command-sproc-error-invalid_subcommand = Unknown subcommand '{ $subcommand }'. Use one of: list, show, create, exec, edit, delete. +command-sproc-error-missing_name = Missing stored procedure name. Specify the id, for example: sproc show myProc. +command-sproc-error-missing_file = No source provided. Specify a JavaScript file or pipe the body in, for example: sproc create myProc ./myProc.js. +command-sproc-error-file_not_found = File not found: '{ $file }'. +command-sproc-error-already_exists = Stored procedure '{ $name }' already exists. Use --force to replace it. +command-sproc-error-not_found = Stored procedure '{ $name }' was not found. +command-sproc-error-invalid_params = Invalid parameters. Provide a JSON array of arguments, for example: '["a", 1, true]'. +command-sproc-error-missing_partition_key = A partition key is required to execute a stored procedure. Use --partition-key. +command-sproc-error-not_interactive = 'sproc edit' needs an interactive terminal and cannot run from a script or piped input. +command-sproc-error-no_editor = No editor found. Set $VISUAL or $EDITOR to your preferred editor. + command-ls-description = List resources in the current context. command-ls-description-filter = The filter pattern. command-ls-description-max = Maximum number of items returned when listing container items. Defaults to 100 if omitted. Use 0 or a negative value for no limit. diff --git a/README.md b/README.md index ba4271b..8e3f094 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Lightweight CLI for Azure Cosmos DB. - Inspect the current location with `pwd` - Create, query, replace, patch, delete: `mkdb`, `mkcon`, `mkitem`, `query`, `replace`, `patch`, `rm` - Manage container indexing policies with `index` (`show`, `add`, `remove`, `set`) +- Manage stored procedures with `sproc` (`list`, `show`, `create`, `exec`, `edit`, `delete`) - Tail the change feed of a container with `watch` (alias `tail`) - Database and container management commands prefer Azure Resource Manager when connected with Entra ID, with data-plane fallback for key, emulator, and static-token connections - Pipelines and scripting with variables, loops, functions diff --git a/docs/commands.md b/docs/commands.md index 5d3532e..6022043 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -510,6 +510,57 @@ index set --mode=consistent --automatic=true index set '{"indexingMode":"consistent","automatic":true,"includedPaths":[{"path":"/*"}],"excludedPaths":[]}' ``` +### sproc + +Manage JavaScript stored procedures on a container through subcommands. + +```text +Usage: sproc subcommand [name] [value] [-partition-key ] [-force] [-editor ] [-database ] [-container ] + +Arguments: + subcommand list, show, create, exec, edit, or delete + [name] The stored procedure id + [value] A JavaScript file (for create) or a JSON array of arguments (for exec) + +Options: + -partition-key, -pk + Partition key used to target a partition when executing (required for exec) + -force, -f Replace the stored procedure if it already exists (create) + -editor External editor to launch for 'sproc edit' + (defaults to $VISUAL, $EDITOR, then a platform default) + -database, -db + Override database name (Optional) + -container, -con + Override container name (Optional) +``` + +#### Subcommands + +|Subcommand|Behavior| +|-|-| +|`list`|Returns the stored procedure ids in the current container.| +|`show `|Returns the body of a stored procedure.| +|`create `|Creates a stored procedure from a JavaScript file. The body can also be piped in. Pass `--force` to replace an existing one.| +|`create `|With no file or piped body, seeds a sample stored procedure, opens it in an external editor, and prompts to create or discard on exit. Interactive sessions only; scripts and MCP must pass a file.| +|`exec [params]`|Executes a stored procedure. `params` is a JSON array of arguments, and `--partition-key` selects the target partition.| +|`edit `|Opens the stored procedure body in an external editor and saves it on exit. Interactive sessions only; not available over MCP or from scripts.| +|`delete `|Deletes a stored procedure.| + +#### Examples + +```bash +sproc list +sproc show myProc +sproc create myProc ./myProc.js +sproc create myProc ./myProc.js --force +sproc create myProc +sproc edit myProc +sproc exec myProc '["param1", "param2"]' --partition-key pk1 +sproc delete myProc +``` + +Stored procedures are a Cosmos DB for NoSQL feature. The `sproc` command operates on the current container, the same scope as `index`. + ## Utilities ### az diff --git a/docs/mcp.md b/docs/mcp.md index 9bab3a8..40d0b20 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -40,6 +40,8 @@ The MCP server runs locally with your user permissions. Connected clients can ex - Query and retrieve documents - Create, update, and delete resources +Stored procedure management and execution (`sproc`) are restricted from MCP. Run those commands manually in the shell. + Database and container resource actions are executed through Azure Resource Manager when an ARM context is attached (Entra ID connections). MCP sessions connected with account keys, emulator credentials, or static data-plane tokens fall back to the Cosmos DB data plane for these actions. For deterministic ARM routing in multi-subscription environments, start the shell with `--connect-subscription` and `--connect-resource-group`. From 0d9872f4c021c58fa607bf9a0c4d9b588893ea65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Wed, 10 Jun 2026 13:02:50 +0200 Subject: [PATCH 2/8] Remove --editor option from theme and sproc commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editor is now always resolved from $VISUAL, then $EDITOR, then a platform default, matching the dit command. Drops the --editor option and its localized help/docs for heme edit and sproc edit. --- .../Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs | 5 +---- .../Azure.Data.Cosmos.Shell.Commands/ThemeCommand.cs | 5 +---- CosmosDBShell/lang/en.ftl | 2 -- docs/commands.md | 9 ++------- 4 files changed, 4 insertions(+), 17 deletions(-) diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs index a85100e..a45c6be 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs @@ -55,9 +55,6 @@ internal class SprocCommand : CosmosCommand [CosmosOption("force", "f")] public bool? Force { get; init; } - [CosmosOption("editor")] - public string? Editor { get; init; } - [CosmosOption("database", "db")] public string? Database { get; init; } @@ -443,7 +440,7 @@ private async Task EditAsync(Container container, ShellInterpreter /// private async Task LaunchEditorAsync(string initialBody, string name, CancellationToken token) { - var editor = ExternalEditor.Resolve(this.Editor); + var editor = ExternalEditor.Resolve(null); if (editor is null) { throw new CommandException("sproc", MessageService.GetString("command-sproc-error-no_editor")); diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ThemeCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ThemeCommand.cs index da86448..fe8537d 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ThemeCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ThemeCommand.cs @@ -43,9 +43,6 @@ internal class ThemeCommand : CosmosCommand [CosmosOption("strict")] public bool Strict { get; init; } - [CosmosOption("editor")] - public string? Editor { get; init; } - public override Task ExecuteAsync(ShellInterpreter shell, CommandState commandState, string commandText, CancellationToken token) { var action = (this.Action ?? "current").Trim().ToLowerInvariant(); @@ -655,7 +652,7 @@ private CommandState RunEdit(CommandState commandState) return ReportUnknownTheme(commandState, requested); } - var editor = ExternalEditor.Resolve(this.Editor); + var editor = ExternalEditor.Resolve(null); if (editor is null) { var message = MessageService.GetString("command-theme-edit-no-editor"); diff --git a/CosmosDBShell/lang/en.ftl b/CosmosDBShell/lang/en.ftl index b139354..9c3d8cd 100644 --- a/CosmosDBShell/lang/en.ftl +++ b/CosmosDBShell/lang/en.ftl @@ -294,7 +294,6 @@ command-sproc-description-name = The stored procedure id. command-sproc-description-value = The JavaScript file to read for create, or the JSON array of arguments for exec. command-sproc-description-partition-key = The partition key used to target a partition when executing a stored procedure. command-sproc-description-force = Replace the stored procedure if it already exists. -command-sproc-description-editor = External editor to launch for 'sproc edit' (defaults to $VISUAL, $EDITOR, then a platform default). command-sproc-description-database = The database containing the container. command-sproc-description-container = The container that owns the stored procedures. command-sproc-created = Created stored procedure '{ $name }' (RU charge: { $charge }). @@ -614,7 +613,6 @@ command-theme-description-name = Theme name (for show/use/save/edit) or path to command-theme-description-path = Optional path. For 'save' the file path to write (default: ~/.cosmosdbshell/themes/.toml). For 'load' and 'validate' the TOML file to read. command-theme-description-force = Overwrite an existing file when saving, or seed the built-in profile when editing. command-theme-description-strict = Treat warnings as errors when validating. -command-theme-description-editor = External editor to launch (defaults to $VISUAL, $EDITOR, then a platform default). command-theme-active = Active theme: { $name } command-theme-applied = Switched to theme: { $name } command-theme-sample-heading = Sample of theme '{ $name }': diff --git a/docs/commands.md b/docs/commands.md index 6022043..d6174d0 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -107,7 +107,7 @@ pwd # /MyDb/MyContainer Inspect, switch, load, validate, save, edit, open, and reload shell color themes. ```text -Usage: theme [action] [name] [path] [-force] [-strict] [-editor ] +Usage: theme [action] [name] [path] [-force] [-strict] Arguments: [action] What to do: current (default), list, show, use (alias: set), @@ -120,8 +120,6 @@ Options: -force, -f Overwrite an existing file when saving, or seed the built-in profile to a user file when editing -strict Treat warnings as errors during validate - -editor External editor to launch for `theme edit` - (defaults to $VISUAL, $EDITOR, then a platform default) ``` Examples: @@ -136,7 +134,6 @@ theme validate ~/.cosmosdbshell/themes theme validate my-theme --strict theme save my-theme --force theme edit my-theme -theme edit dark --force --editor "code --wait" theme open theme reload ``` @@ -515,7 +512,7 @@ index set '{"indexingMode":"consistent","automatic":true,"includedPaths":[{"path Manage JavaScript stored procedures on a container through subcommands. ```text -Usage: sproc subcommand [name] [value] [-partition-key ] [-force] [-editor ] [-database ] [-container ] +Usage: sproc subcommand [name] [value] [-partition-key ] [-force] [-database ] [-container ] Arguments: subcommand list, show, create, exec, edit, or delete @@ -526,8 +523,6 @@ Options: -partition-key, -pk Partition key used to target a partition when executing (required for exec) -force, -f Replace the stored procedure if it already exists (create) - -editor External editor to launch for 'sproc edit' - (defaults to $VISUAL, $EDITOR, then a platform default) -database, -db Override database name (Optional) -container, -con From f45f6a9f712bed33d576afb85c6b2952bc61a405 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Wed, 10 Jun 2026 13:18:19 +0200 Subject: [PATCH 3/8] Add quick-exit editor prompt and preview before sproc create confirm --- .../SprocCommand.cs | 29 +++++++++++++++++++ CosmosDBShell/lang/en.ftl | 2 ++ 2 files changed, 31 insertions(+) diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs index a45c6be..2f044e3 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs @@ -40,6 +40,13 @@ This command is restricted in MCP. Run it manually in the shell. #pragma warning restore SA1118 // Parameter should not span multiple lines internal class SprocCommand : CosmosCommand { + /// + /// When the launched editor returns faster than this, assume it handed the + /// file to a background instance (for example Windows notepad or 'code' + /// without --wait) and prompt the user before reading the file back. + /// + private static readonly TimeSpan QuickEditorExit = TimeSpan.FromSeconds(2); + [CosmosParameter("subcommand", RequiredErrorKey = "command-sproc-error-missing_subcommand")] public string Subcommand { get; init; } = string.Empty; @@ -271,6 +278,15 @@ private async Task CreateAsync(Container container, ShellInterpret var seed = existingBody ?? DefaultStoredProcedureBody(); var edited = await this.LaunchEditorAsync(seed, name, token); + if (!string.IsNullOrWhiteSpace(edited)) + { + AnsiConsole.Clear(); + ShellInterpreter.WriteLine(MessageService.GetArgsString("command-sproc-create-preview", "name", name)); + ShellInterpreter.WriteLine(); + ShellInterpreter.WriteLine(edited); + ShellInterpreter.WriteLine(); + } + if (string.IsNullOrWhiteSpace(edited) || !ShellInterpreter.Confirm("command-sproc-create-confirm")) { ShellInterpreter.WriteLine(MessageService.GetArgsString("command-sproc-create-discarded", "name", name)); @@ -468,13 +484,26 @@ private async Task LaunchEditorAsync(string initialBody, string name, Ca UseShellExecute = false, }) ?? throw new InvalidOperationException("Process.Start returned null")) { + var launched = System.Diagnostics.Stopwatch.StartNew(); await process.WaitForExitAsync(token); + launched.Stop(); if (process.ExitCode != 0) { throw new CommandException( "sproc", MessageService.GetArgsString("command-sproc-edit-exit-nonzero", "editor", editor.DisplayName, "code", process.ExitCode)); } + + // Some editors (for example Windows notepad, or 'code' without + // --wait) hand the file to a running instance and exit right + // away instead of blocking until the window closes. When that + // happens, wait for the user to confirm they finished editing + // before reading the file back. + if (launched.Elapsed < QuickEditorExit) + { + ShellInterpreter.WriteLine(MessageService.GetString("command-sproc-edit-wait")); + Console.ReadLine(); + } } return await File.ReadAllTextAsync(tempPath, token); diff --git a/CosmosDBShell/lang/en.ftl b/CosmosDBShell/lang/en.ftl index 9c3d8cd..ca4d294 100644 --- a/CosmosDBShell/lang/en.ftl +++ b/CosmosDBShell/lang/en.ftl @@ -303,6 +303,8 @@ command-sproc-executed = Executed stored procedure '{ $name }' (RU charge: { $ch command-sproc-edit-launching = Editing stored procedure '{ $name }' with { $editor }. command-sproc-edit-unchanged = Stored procedure '{ $name }' was not changed. command-sproc-edit-exit-nonzero = Editor '{ $editor }' exited with status { $code }. +command-sproc-edit-wait = The editor returned immediately. Finish editing, save the file, then press Enter to continue... +command-sproc-create-preview = Stored procedure '{ $name }': command-sproc-create-confirm = Create this stored procedure command-sproc-create-discarded = Discarded stored procedure '{ $name }'. command-sproc-error-missing_subcommand = Missing subcommand. Use one of: list, show, create, exec, edit, delete. From 798880f3112934e8393c96dad73b7d974ca08b5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Wed, 10 Jun 2026 13:20:27 +0200 Subject: [PATCH 4/8] Fail sproc edit when stored procedure does not exist --- .../SprocCommand.cs | 16 ++++++---------- docs/commands.md | 2 +- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs index 2f044e3..49174fb 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs @@ -411,22 +411,20 @@ private async Task EditAsync(Container container, ShellInterpreter throw new CommandException("sproc", MessageService.GetString("command-sproc-error-not_interactive")); } - string existingBody = string.Empty; - bool exists = false; + string existingBody; try { var read = await container.Scripts.ReadStoredProcedureAsync(name, cancellationToken: token); existingBody = read.Resource.Body; - exists = true; } catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) { - existingBody = string.Empty; + throw NotFound(name, ex); } - var newBody = await this.LaunchEditorAsync(exists ? existingBody : DefaultStoredProcedureBody(), name, token); + var newBody = await this.LaunchEditorAsync(existingBody, name, token); - if (exists && string.Equals(newBody, existingBody, StringComparison.Ordinal)) + if (string.Equals(newBody, existingBody, StringComparison.Ordinal)) { ShellInterpreter.WriteLine(MessageService.GetArgsString("command-sproc-edit-unchanged", "name", name)); commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { id = name, changed = false })); @@ -434,12 +432,10 @@ private async Task EditAsync(Container container, ShellInterpreter } var properties = new StoredProcedureProperties { Id = name, Body = newBody }; - var response = exists - ? await container.Scripts.ReplaceStoredProcedureAsync(properties, cancellationToken: token) - : await container.Scripts.CreateStoredProcedureAsync(properties, cancellationToken: token); + var response = await container.Scripts.ReplaceStoredProcedureAsync(properties, cancellationToken: token); ShellInterpreter.WriteLine(MessageService.GetArgsString( - exists ? "command-sproc-replaced" : "command-sproc-created", + "command-sproc-replaced", "name", name, "charge", diff --git a/docs/commands.md b/docs/commands.md index d6174d0..56a1277 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -538,7 +538,7 @@ Options: |`create `|Creates a stored procedure from a JavaScript file. The body can also be piped in. Pass `--force` to replace an existing one.| |`create `|With no file or piped body, seeds a sample stored procedure, opens it in an external editor, and prompts to create or discard on exit. Interactive sessions only; scripts and MCP must pass a file.| |`exec [params]`|Executes a stored procedure. `params` is a JSON array of arguments, and `--partition-key` selects the target partition.| -|`edit `|Opens the stored procedure body in an external editor and saves it on exit. Interactive sessions only; not available over MCP or from scripts.| +|`edit `|Opens an existing stored procedure body in an external editor and saves it on exit. Fails if the stored procedure does not exist; use `create` to add a new one. Interactive sessions only; not available over MCP or from scripts.| |`delete `|Deletes a stored procedure.| #### Examples From 628bcc44b46e18acc6834e26ea5bfb8419edefbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Wed, 10 Jun 2026 13:34:35 +0200 Subject: [PATCH 5/8] Render sproc list as a table and suppress JSON echo for status messages --- .../SprocCommand.cs | 46 +++++++++++++++++-- CosmosDBShell/lang/en.ftl | 5 ++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs index 49174fb..c2edf87 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs @@ -203,17 +203,52 @@ public async override Task ExecuteAsync(ShellInterpreter shell, Co private async Task ListAsync(Container container, CommandState commandState, CancellationToken token) { - var ids = new List(); + var items = new List(); + var rows = new List<(string Id, string Modified, int BodyLength)>(); using var iterator = container.Scripts.GetStoredProcedureQueryIterator(); while (iterator.HasMoreResults) { foreach (var properties in await iterator.ReadNextAsync(token)) { - ids.Add(properties.Id); + items.Add(new + { + id = properties.Id, + lastModified = properties.LastModified, + etag = properties.ETag, + bodyLength = properties.Body?.Length ?? 0, + }); + rows.Add(( + properties.Id, + properties.LastModified?.ToString("u") ?? string.Empty, + properties.Body?.Length ?? 0)); + } + } + + if (rows.Count == 0) + { + AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetString("command-sproc-list-empty"))); + } + else + { + AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-sproc-list-title"))); + var table = new Table(); + table.AddColumn(new TableColumn(Theme.FormatSectionHeader(MessageService.GetString("command-sproc-list-column-id")))); + table.AddColumn(new TableColumn(Theme.FormatSectionHeader(MessageService.GetString("command-sproc-list-column-modified")))); + table.AddColumn(new TableColumn(Theme.FormatSectionHeader(MessageService.GetString("command-sproc-list-column-size"))).RightAligned()); + + foreach (var row in rows) + { + table.AddRow( + Theme.FormatTableValue(row.Id), + Theme.FormatTableValue(row.Modified), + Theme.FormatTableValue(row.BodyLength.ToString())); } + + AnsiConsole.Write(table); } - commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(ids)); + commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(items)); + commandState.IsPrinted = true; return commandState; } @@ -291,6 +326,7 @@ private async Task CreateAsync(Container container, ShellInterpret { ShellInterpreter.WriteLine(MessageService.GetArgsString("command-sproc-create-discarded", "name", name)); commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { id = name, created = false })); + commandState.IsPrinted = true; return commandState; } @@ -340,6 +376,7 @@ private async Task WriteCreateAsync(Container container, CommandSt response.RequestCharge.ToString("F2"))); commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { id = name })); + commandState.IsPrinted = true; return commandState; } @@ -394,6 +431,7 @@ private async Task DeleteAsync(Container container, CommandState c response.RequestCharge.ToString("F2"))); commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { id = name, deleted = true })); + commandState.IsPrinted = true; return commandState; } catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) @@ -428,6 +466,7 @@ private async Task EditAsync(Container container, ShellInterpreter { ShellInterpreter.WriteLine(MessageService.GetArgsString("command-sproc-edit-unchanged", "name", name)); commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { id = name, changed = false })); + commandState.IsPrinted = true; return commandState; } @@ -442,6 +481,7 @@ private async Task EditAsync(Container container, ShellInterpreter response.RequestCharge.ToString("F2"))); commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { id = name, changed = true })); + commandState.IsPrinted = true; return commandState; } diff --git a/CosmosDBShell/lang/en.ftl b/CosmosDBShell/lang/en.ftl index ca4d294..098f941 100644 --- a/CosmosDBShell/lang/en.ftl +++ b/CosmosDBShell/lang/en.ftl @@ -307,6 +307,11 @@ command-sproc-edit-wait = The editor returned immediately. Finish editing, save command-sproc-create-preview = Stored procedure '{ $name }': command-sproc-create-confirm = Create this stored procedure command-sproc-create-discarded = Discarded stored procedure '{ $name }'. +command-sproc-list-empty = No stored procedures found. +command-sproc-list-title = Stored procedures +command-sproc-list-column-id = Id +command-sproc-list-column-modified = Last Modified +command-sproc-list-column-size = Size (chars) command-sproc-error-missing_subcommand = Missing subcommand. Use one of: list, show, create, exec, edit, delete. command-sproc-error-invalid_subcommand = Unknown subcommand '{ $subcommand }'. Use one of: list, show, create, exec, edit, delete. command-sproc-error-missing_name = Missing stored procedure name. Specify the id, for example: sproc show myProc. From e849abbc4258fcfc3e9d1cd0430a1d6d3d798f6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Wed, 10 Jun 2026 14:13:00 +0200 Subject: [PATCH 6/8] Add sproc exists subcommand and fix IsPrinted leaking into command branches Adds 'sproc exists ' which returns a ShellBool so it can be used directly in if/while conditions. Also fixes CommandStatement.RunAsync so it resets IsPrinted at the start of each statement; previously a prior command that set IsPrinted (such as a condition command in an if) leaked the flag and suppressed the branch command's output in PrintState. --- CHANGELOG.md | 2 +- .../Integration/ControlFlowTests.cs | 37 ++++++++++++++++++- .../SprocCommand.cs | 27 ++++++++++++++ .../Statement/CommandStatement.cs | 5 +++ CosmosDBShell/lang/en.ftl | 6 ++- docs/commands.md | 4 +- 6 files changed, 76 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ee0dd5..35d1a0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### New features -- `sproc` command to manage Cosmos DB for NoSQL stored procedures on the current container: `list`, `show`, `create` (from a JavaScript file or piped body, with `--force` to replace), `exec` (with a JSON argument array and `--partition-key`), `edit` (interactive external editor), and `delete`. ([#103](https://github.com/Azure/CosmosDBShell/issues/103)) +- `sproc` command to manage Cosmos DB for NoSQL stored procedures on the current container: `list`, `show`, `exists` (returns a boolean usable in `if`/`while` conditions), `create` (from a JavaScript file or piped body, with `--force` to replace), `exec` (with a JSON argument array and `--partition-key`), `edit` (interactive external editor), and `delete`. ([#103](https://github.com/Azure/CosmosDBShell/issues/103)) ## 1.1.4-preview — 2026-05-21 diff --git a/CosmosDBShell.Tests/Integration/ControlFlowTests.cs b/CosmosDBShell.Tests/Integration/ControlFlowTests.cs index af851b8..49d3eac 100644 --- a/CosmosDBShell.Tests/Integration/ControlFlowTests.cs +++ b/CosmosDBShell.Tests/Integration/ControlFlowTests.cs @@ -75,4 +75,39 @@ public async Task DoWhile_ExecutesAtLeastOnce() var n = Assert.IsType(i); Assert.Equal(1, n.Value); } -} + + [Fact] + public async Task IfElse_CommandExpressionCondition_TrueBranch() + { + var state = await RunScriptAsync("if (echo \"true\") { $x = 1 } else { $x = 2 }"); + + Assert.False(state.IsError, FormatError(state)); + var x = GetVariable("x"); + var n = Assert.IsType(x); + Assert.Equal(1, n.Value); + } + + [Fact] + public async Task IfElse_CommandExpressionCondition_FalseBranch() + { + var state = await RunScriptAsync("if (echo \"false\") { $x = 1 } else { $x = 2 }"); + + Assert.False(state.IsError, FormatError(state)); + var x = GetVariable("x"); + var n = Assert.IsType(x); + Assert.Equal(2, n.Value); + } + + [Fact] + public async Task CommandStatement_DoesNotInheritIsPrintedFromPriorCommand() + { + // A prior command that already printed its own output sets IsPrinted on the shared + // state. The following command's output must still be printed and not suppressed. + var outputFile = CaptureOutputFile(); + var state = await RunScriptAsync("{ version\necho \"AFTER\" }"); + + Assert.False(state.IsError, FormatError(state)); + var output = await ReadRedirectAsync(outputFile); + Assert.Contains("AFTER", output, StringComparison.Ordinal); + } +} \ No newline at end of file diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs index c2edf87..a070231 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs @@ -17,6 +17,7 @@ namespace Azure.Data.Cosmos.Shell.Commands; [CosmosCommand("sproc")] [CosmosExample("sproc list", Description = "List the stored procedures in the current container")] [CosmosExample("sproc show myProc", Description = "Display the body of a stored procedure")] +[CosmosExample("sproc exists myProc", Description = "Check whether a stored procedure exists (usable in if conditions)")] [CosmosExample("sproc create myProc ./myProc.js", Description = "Create a stored procedure from a JavaScript file")] [CosmosExample("sproc create myProc ./myProc.js --force", Description = "Create or replace a stored procedure")] [CosmosExample("sproc edit myProc", Description = "Edit a stored procedure body in an external editor")] @@ -191,6 +192,7 @@ public async override Task ExecuteAsync(ShellInterpreter shell, Co { "list" or "ls" => await this.ListAsync(container, commandState, token), "show" or "cat" => await this.ShowAsync(container, commandState, token), + "exists" => await this.ExistsAsync(container, commandState, token), "create" or "set" => await this.CreateAsync(container, shell, commandState, token), "exec" or "run" => await this.ExecAsync(container, commandState, token), "edit" => await this.EditAsync(container, shell, commandState, token), @@ -268,6 +270,31 @@ private async Task ShowAsync(Container container, CommandState com } } + private async Task ExistsAsync(Container container, CommandState commandState, CancellationToken token) + { + var name = this.RequireName(); + + bool exists; + try + { + await container.Scripts.ReadStoredProcedureAsync(name, cancellationToken: token); + exists = true; + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + exists = false; + } + + ShellInterpreter.WriteLine(MessageService.GetArgsString( + exists ? "command-sproc-exists-yes" : "command-sproc-exists-no", + "name", + name)); + + commandState.Result = new ShellBool(exists); + commandState.IsPrinted = true; + return commandState; + } + private async Task CreateAsync(Container container, ShellInterpreter shell, CommandState commandState, CancellationToken token) { var name = this.RequireName(); diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/Statement/CommandStatement.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/Statement/CommandStatement.cs index 571233a..5aec1f9 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/Statement/CommandStatement.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/Statement/CommandStatement.cs @@ -139,6 +139,11 @@ public override int Length /// public override async Task RunAsync(ShellInterpreter shell, CommandState commandState, CancellationToken token) { + // Each command statement re-establishes its own printed state. Without this reset a + // prior command that set IsPrinted (for example a condition command in an if/while) + // would leak the flag into this statement and suppress its output in PrintState. + commandState.IsPrinted = false; + // Wire redirections onto the shell so both the command itself (if it inspects // StdOutRedirect/ErrOutRedirect) and the post-execution PrintState honor them. // Only assign when this statement declares its own redirect so values pre-set by diff --git a/CosmosDBShell/lang/en.ftl b/CosmosDBShell/lang/en.ftl index 098f941..0ccdd1b 100644 --- a/CosmosDBShell/lang/en.ftl +++ b/CosmosDBShell/lang/en.ftl @@ -312,8 +312,10 @@ command-sproc-list-title = Stored procedures command-sproc-list-column-id = Id command-sproc-list-column-modified = Last Modified command-sproc-list-column-size = Size (chars) -command-sproc-error-missing_subcommand = Missing subcommand. Use one of: list, show, create, exec, edit, delete. -command-sproc-error-invalid_subcommand = Unknown subcommand '{ $subcommand }'. Use one of: list, show, create, exec, edit, delete. +command-sproc-exists-yes = Stored procedure '{ $name }' exists. +command-sproc-exists-no = Stored procedure '{ $name }' does not exist. +command-sproc-error-missing_subcommand = Missing subcommand. Use one of: list, show, exists, create, exec, edit, delete. +command-sproc-error-invalid_subcommand = Unknown subcommand '{ $subcommand }'. Use one of: list, show, exists, create, exec, edit, delete. command-sproc-error-missing_name = Missing stored procedure name. Specify the id, for example: sproc show myProc. command-sproc-error-missing_file = No source provided. Specify a JavaScript file or pipe the body in, for example: sproc create myProc ./myProc.js. command-sproc-error-file_not_found = File not found: '{ $file }'. diff --git a/docs/commands.md b/docs/commands.md index 56a1277..cf4fcec 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -515,7 +515,7 @@ Manage JavaScript stored procedures on a container through subcommands. Usage: sproc subcommand [name] [value] [-partition-key ] [-force] [-database ] [-container ] Arguments: - subcommand list, show, create, exec, edit, or delete + subcommand list, show, exists, create, exec, edit, or delete [name] The stored procedure id [value] A JavaScript file (for create) or a JSON array of arguments (for exec) @@ -535,6 +535,7 @@ Options: |-|-| |`list`|Returns the stored procedure ids in the current container.| |`show `|Returns the body of a stored procedure.| +|`exists `|Returns a boolean indicating whether a stored procedure exists. The boolean result can be used directly in `if` and `while` conditions.| |`create `|Creates a stored procedure from a JavaScript file. The body can also be piped in. Pass `--force` to replace an existing one.| |`create `|With no file or piped body, seeds a sample stored procedure, opens it in an external editor, and prompts to create or discard on exit. Interactive sessions only; scripts and MCP must pass a file.| |`exec [params]`|Executes a stored procedure. `params` is a JSON array of arguments, and `--partition-key` selects the target partition.| @@ -546,6 +547,7 @@ Options: ```bash sproc list sproc show myProc +sproc exists myProc sproc create myProc ./myProc.js sproc create myProc ./myProc.js --force sproc create myProc From 37323f53006bf05c8b00bed9dd52ce3135bd6c14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Wed, 10 Jun 2026 14:21:00 +0200 Subject: [PATCH 7/8] Use CreatePartitionKeyFromArgument for sproc partition keys (hierarchical PK support) + test --- CosmosDBShell.Tests/CommandTests/SprocCommandTests.cs | 11 +++++++++++ .../Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs | 6 +++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/CosmosDBShell.Tests/CommandTests/SprocCommandTests.cs b/CosmosDBShell.Tests/CommandTests/SprocCommandTests.cs index 57b7c53..fb3c1eb 100644 --- a/CosmosDBShell.Tests/CommandTests/SprocCommandTests.cs +++ b/CosmosDBShell.Tests/CommandTests/SprocCommandTests.cs @@ -86,6 +86,17 @@ public void ParsePartitionKey_Boolean_PreservesBooleanType() Assert.Equal(new PartitionKey(true).ToString(), SprocCommand.ParsePartitionKey("true").ToString()); } + [Fact] + public void ParsePartitionKey_JsonArray_BuildsHierarchicalPartitionKey() + { + var expected = new PartitionKeyBuilder() + .Add("tenant") + .Add("user") + .Build(); + + Assert.Equal(expected.ToString(), SprocCommand.ParsePartitionKey("[\"tenant\",\"user\"]").ToString()); + } + [Fact] public void DefaultStoredProcedureBody_IsValidSeedTemplate() { diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs index a070231..313c3d5 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs @@ -115,14 +115,14 @@ internal static object[] ParseExecParams(string? json) /// /// Parses the --partition-key value, preserving its JSON type when possible - /// and falling back to a string value otherwise. + /// (including JSON arrays for hierarchical partition keys) and falling back to a + /// string value otherwise. /// internal static PartitionKey ParsePartitionKey(string value) { try { - using var document = JsonDocument.Parse(value); - return CreatePartitionKey(document.RootElement); + return CreatePartitionKeyFromArgument(value); } catch (JsonException) { From df91e9063e3d7c547466d5ae76c4430cc3335b1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Thu, 11 Jun 2026 11:25:26 +0200 Subject: [PATCH 8/8] Address Copilot review feedback on sproc command - Reject object-shaped/malformed --partition-key instead of silent string fallback (new command-sproc-error-invalid_pk) - List exists/edit subcommands in the MCP annotation description - Drop misleading 'MCP server' mention from the create comment - Add exists to sproc help description strings - Fix obsolete '--editor' hint in command-theme-edit-no-editor - Add exists to the sproc feature list in README - Document sproc list JSON metadata and that sproc is MCP-restricted in docs - Add unit test for object-shaped partition key rejection --- .../CommandTests/SprocCommandTests.cs | 6 ++++++ .../Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs | 12 +++++++----- CosmosDBShell/lang/en.ftl | 7 ++++--- README.md | 2 +- docs/commands.md | 4 ++-- 5 files changed, 20 insertions(+), 11 deletions(-) diff --git a/CosmosDBShell.Tests/CommandTests/SprocCommandTests.cs b/CosmosDBShell.Tests/CommandTests/SprocCommandTests.cs index fb3c1eb..1440f34 100644 --- a/CosmosDBShell.Tests/CommandTests/SprocCommandTests.cs +++ b/CosmosDBShell.Tests/CommandTests/SprocCommandTests.cs @@ -97,6 +97,12 @@ public void ParsePartitionKey_JsonArray_BuildsHierarchicalPartitionKey() Assert.Equal(expected.ToString(), SprocCommand.ParsePartitionKey("[\"tenant\",\"user\"]").ToString()); } + [Fact] + public void ParsePartitionKey_JsonObject_Throws() + { + Assert.Throws(() => SprocCommand.ParsePartitionKey("{\"a\":1}")); + } + [Fact] public void DefaultStoredProcedureBody_IsValidSeedTemplate() { diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs index 313c3d5..45d4961 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs @@ -30,8 +30,10 @@ namespace Azure.Data.Cosmos.Shell.Commands; Manages JavaScript stored procedures on the current Cosmos DB container through subcommands: - 'list' returns the stored procedure ids in the container. - 'show ' returns the body of a stored procedure. +- 'exists ' returns whether a stored procedure exists. - 'create ' creates a stored procedure from a JavaScript file. Pass --force to replace an existing one. - 'exec [params]' executes a stored procedure. 'params' is a JSON array of arguments and --partition-key selects the target partition. +- 'edit ' opens an existing stored procedure body in an external editor. - 'delete ' removes a stored procedure. This command is restricted in MCP. Run it manually in the shell. ", @@ -115,8 +117,8 @@ internal static object[] ParseExecParams(string? json) /// /// Parses the --partition-key value, preserving its JSON type when possible - /// (including JSON arrays for hierarchical partition keys) and falling back to a - /// string value otherwise. + /// (including JSON arrays for hierarchical partition keys). Object-shaped or otherwise + /// malformed JSON is rejected with a clear error rather than silently coerced. /// internal static PartitionKey ParsePartitionKey(string value) { @@ -124,9 +126,9 @@ internal static PartitionKey ParsePartitionKey(string value) { return CreatePartitionKeyFromArgument(value); } - catch (JsonException) + catch (JsonException ex) { - return new PartitionKey(value); + throw new CommandException("sproc", MessageService.GetString("command-sproc-error-invalid_pk"), ex); } } @@ -301,7 +303,7 @@ private async Task CreateAsync(Container container, ShellInterpret bool force = this.Force == true; // When a body is supplied (a file or piped input), create directly. This is - // the path used by scripts and the MCP server. + // the non-interactive path used by scripts. var explicitBody = this.TryReadExplicitBody(commandState); if (explicitBody is not null) { diff --git a/CosmosDBShell/lang/en.ftl b/CosmosDBShell/lang/en.ftl index 0ccdd1b..a549dee 100644 --- a/CosmosDBShell/lang/en.ftl +++ b/CosmosDBShell/lang/en.ftl @@ -288,8 +288,8 @@ command-index-error-show_no_args = 'index show' does not take any arguments. Use command-index-error-invalid_policy = Invalid indexing policy JSON. Please provide a valid Cosmos DB indexing policy. command-index-error-no_policy = The container has no indexing policy configured. -command-sproc-description = Manages stored procedures on a container via list, show, create, exec, edit, and delete subcommands. -command-sproc-description-subcommand = The action to perform: list, show, create, exec, edit, or delete. +command-sproc-description = Manages stored procedures on a container via list, show, exists, create, exec, edit, and delete subcommands. +command-sproc-description-subcommand = The action to perform: list, show, exists, create, exec, edit, or delete. command-sproc-description-name = The stored procedure id. command-sproc-description-value = The JavaScript file to read for create, or the JSON array of arguments for exec. command-sproc-description-partition-key = The partition key used to target a partition when executing a stored procedure. @@ -322,6 +322,7 @@ command-sproc-error-file_not_found = File not found: '{ $file }'. command-sproc-error-already_exists = Stored procedure '{ $name }' already exists. Use --force to replace it. command-sproc-error-not_found = Stored procedure '{ $name }' was not found. command-sproc-error-invalid_params = Invalid parameters. Provide a JSON array of arguments, for example: '["a", 1, true]'. +command-sproc-error-invalid_pk = Invalid partition key. Provide a JSON scalar, or a JSON array for a hierarchical partition key. command-sproc-error-missing_partition_key = A partition key is required to execute a stored procedure. Use --partition-key. command-sproc-error-not_interactive = 'sproc edit' needs an interactive terminal and cannot run from a script or piped input. command-sproc-error-no_editor = No editor found. Set $VISUAL or $EDITOR to your preferred editor. @@ -649,7 +650,7 @@ command-theme-edit-missing-name = 'theme edit' requires a theme name or path. Ru command-theme-edit-builtin-needs-force = '{ $name }' is a built-in theme and has no editable file. Pass --force to copy it to { $path } and edit the copy. command-theme-edit-seeded = Seeded built-in theme '{ $name }' to { $path } command-theme-edit-launching = Editing { $path } with { $editor } -command-theme-edit-no-editor = No editor found. Set $VISUAL or $EDITOR, or pass --editor=. +command-theme-edit-no-editor = No editor found. Set $VISUAL or $EDITOR to your preferred editor. command-theme-edit-launch-failed = Failed to launch editor '{ $editor }' for { $path }: { $message } command-theme-edit-exit-nonzero = Editor '{ $editor }' exited with status { $code }; theme was not reloaded. command-theme-edit-reload-failed = Theme file '{ $path }' could not be reloaded: { $message } diff --git a/README.md b/README.md index 8e3f094..01e346b 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Lightweight CLI for Azure Cosmos DB. - Inspect the current location with `pwd` - Create, query, replace, patch, delete: `mkdb`, `mkcon`, `mkitem`, `query`, `replace`, `patch`, `rm` - Manage container indexing policies with `index` (`show`, `add`, `remove`, `set`) -- Manage stored procedures with `sproc` (`list`, `show`, `create`, `exec`, `edit`, `delete`) +- Manage stored procedures with `sproc` (`list`, `show`, `exists`, `create`, `exec`, `edit`, `delete`) - Tail the change feed of a container with `watch` (alias `tail`) - Database and container management commands prefer Azure Resource Manager when connected with Entra ID, with data-plane fallback for key, emulator, and static-token connections - Pipelines and scripting with variables, loops, functions diff --git a/docs/commands.md b/docs/commands.md index cf4fcec..8aa0b86 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -533,11 +533,11 @@ Options: |Subcommand|Behavior| |-|-| -|`list`|Returns the stored procedure ids in the current container.| +|`list`|Lists the stored procedures in the current container. The interactive table shows id, last modified, and body size; the structured JSON result contains `id`, `lastModified`, `etag`, and `bodyLength` for each.| |`show `|Returns the body of a stored procedure.| |`exists `|Returns a boolean indicating whether a stored procedure exists. The boolean result can be used directly in `if` and `while` conditions.| |`create `|Creates a stored procedure from a JavaScript file. The body can also be piped in. Pass `--force` to replace an existing one.| -|`create `|With no file or piped body, seeds a sample stored procedure, opens it in an external editor, and prompts to create or discard on exit. Interactive sessions only; scripts and MCP must pass a file.| +|`create `|With no file or piped body, seeds a sample stored procedure, opens it in an external editor, and prompts to create or discard on exit. Interactive sessions only; scripts must pass a file. The `sproc` command is not available over MCP.| |`exec [params]`|Executes a stored procedure. `params` is a JSON array of arguments, and `--partition-key` selects the target partition.| |`edit `|Opens an existing stored procedure body in an external editor and saves it on exit. Fails if the stored procedure does not exist; use `create` to add a new one. Interactive sessions only; not available over MCP or from scripts.| |`delete `|Deletes a stored procedure.|