diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c2e9bb..35d1a0d 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`, `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 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..1440f34 --- /dev/null +++ b/CosmosDBShell.Tests/CommandTests/SprocCommandTests.cs @@ -0,0 +1,116 @@ +// ------------------------------------------------------------ +// 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 ParsePartitionKey_JsonArray_BuildsHierarchicalPartitionKey() + { + var expected = new PartitionKeyBuilder() + .Add("tenant") + .Add("user") + .Build(); + + 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() + { + 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/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.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..45d4961 --- /dev/null +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs @@ -0,0 +1,619 @@ +//------------------------------------------------------------ +// 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 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")] +[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. +- '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. +", + Restricted = true, + Destructive = true, + OpenWorld = true)] +#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; + + [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("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 + /// (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) + { + try + { + return CreatePartitionKeyFromArgument(value); + } + catch (JsonException ex) + { + throw new CommandException("sproc", MessageService.GetString("command-sproc-error-invalid_pk"), ex); + } + } + + 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), + "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), + "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 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)) + { + 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(items)); + commandState.IsPrinted = true; + 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 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(); + bool force = this.Force == true; + + // When a body is supplied (a file or piped input), create directly. This is + // the non-interactive path used by scripts. + 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)) + { + 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)); + commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { id = name, created = false })); + commandState.IsPrinted = true; + 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 })); + commandState.IsPrinted = true; + 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 })); + commandState.IsPrinted = 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; + try + { + var read = await container.Scripts.ReadStoredProcedureAsync(name, cancellationToken: token); + existingBody = read.Resource.Body; + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + throw NotFound(name, ex); + } + + var newBody = await this.LaunchEditorAsync(existingBody, name, token); + + 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 })); + commandState.IsPrinted = true; + return commandState; + } + + var properties = new StoredProcedureProperties { Id = name, Body = newBody }; + var response = await container.Scripts.ReplaceStoredProcedureAsync(properties, cancellationToken: token); + + ShellInterpreter.WriteLine(MessageService.GetArgsString( + "command-sproc-replaced", + "name", + name, + "charge", + response.RequestCharge.ToString("F2"))); + + commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { id = name, changed = true })); + commandState.IsPrinted = 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(null); + 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")) + { + 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); + } + 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/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/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 9e72f79..a549dee 100644 --- a/CosmosDBShell/lang/en.ftl +++ b/CosmosDBShell/lang/en.ftl @@ -288,6 +288,45 @@ 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, 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. +command-sproc-description-force = Replace the stored procedure if it already exists. +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-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-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-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 }'. +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. + 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. @@ -584,7 +623,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 }': @@ -612,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 ba4271b..01e346b 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`, `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 5d3532e..8aa0b86 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 ``` @@ -510,6 +507,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] [-database ] [-container ] + +Arguments: + 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) + +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) + -database, -db + Override database name (Optional) + -container, -con + Override container name (Optional) +``` + +#### Subcommands + +|Subcommand|Behavior| +|-|-| +|`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 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.| + +#### Examples + +```bash +sproc list +sproc show myProc +sproc exists 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`.