Repository: kestra-io/kestra
Issue: "from" (local directory path) & "to" (remote namespace files directory) parameters to NamespaceFileUpdate command
Labels: bug

### Explain the bug

Reproducer: start `docker run --rm -it -p 28080:8080  kestra/kestra:develop-full  server local`

https://share.descript.com/view/bG8iWNMi5mk 

Run:
```yaml
id: ci
namespace: prod

variables:
  host: http://host.docker.internal:28080/

tasks:
  - id: deploy
    type: io.kestra.core.tasks.flows.WorkingDirectory
    tasks:
      - id: clone
        type: io.kestra.plugin.git.Clone
        url: https://github.com/kestra-io/scripts
        branch: main

      - id: deploy_files
        type: io.kestra.plugin.scripts.shell.Commands
        warningOnStdErr: false
        runner: PROCESS
        commands:
          - /app/kestra namespace files update prod etl/data_lake/ --server={{vars.host}}
```

### Environment Information

- Kestra Version: develop full
- Operating System and Java Version (if not using Kestra Docker image):


Issue comments:
A bit opiniated for the team (https://kestra-io.slack.com/archives/C04HTFM3VL6/p1699614141351939?thread_ts=1699613554.492519&cid=C04HTFM3VL6). Maybe we should not do it ?
After discussion, the choice is to change the command so that it takes a "from" (local folder path) and "to" (remote namespace files folder) folders so there is no more confusions. It allows multi-teams upload to a same namespace with `from="my_scripts" to="/team_a_scripts"` while keeping the ability to override the whole namespace with `from="all_my_scripts" to="/"`

Pull request review comments:
```suggestion
    void runWithToSpecified() {
```

I have nothing against `_` in test method name but as other methods have With in there name for consistency it's better to use it there.

Patch that resolved the issue:
diff --git a/cli/src/main/java/io/kestra/cli/commands/namespaces/files/NamespaceFilesUpdateCommand.java b/cli/src/main/java/io/kestra/cli/commands/namespaces/files/NamespaceFilesUpdateCommand.java
index 890c1fcca2..285134d756 100644
--- a/cli/src/main/java/io/kestra/cli/commands/namespaces/files/NamespaceFilesUpdateCommand.java
+++ b/cli/src/main/java/io/kestra/cli/commands/namespaces/files/NamespaceFilesUpdateCommand.java
@@ -1,7 +1,7 @@
 package io.kestra.cli.commands.namespaces.files;
 
+import io.kestra.cli.AbstractApiCommand;
 import io.kestra.cli.AbstractValidateCommand;
-import io.kestra.cli.commands.AbstractServiceNamespaceUpdateCommand;
 import io.micronaut.http.HttpRequest;
 import io.micronaut.http.MediaType;
 import io.micronaut.http.client.exceptions.HttpClientResponseException;
@@ -23,20 +23,33 @@
     mixinStandardHelpOptions = true
 )
 @Slf4j
-public class NamespaceFilesUpdateCommand extends AbstractServiceNamespaceUpdateCommand {
+public class NamespaceFilesUpdateCommand extends AbstractApiCommand {
+    @CommandLine.Parameters(index = "0", description = "the namespace to update")
+    public String namespace;
+
+    @CommandLine.Parameters(index = "1", description = "the local directory containing files for current namespace")
+    public Path from;
+
+    @CommandLine.Parameters(index = "2", description = "the remote namespace path to upload files to", defaultValue = "/")
+    public String to;
+
+    @CommandLine.Option(names = {"--delete"}, negatable = true, description = "if missing should be deleted")
+    public boolean delete = false;
 
     private static final String KESTRA_IGNORE_FILE = ".kestraignore";
 
     @Override
     public Integer call() throws Exception {
         super.call();
+        to = to.startsWith("/") ? to : "/" + to;
+        to = to.endsWith("/") ? to : to + "/";
 
-        try (var files = Files.walk(directory); DefaultHttpClient client = client()) {
+        try (var files = Files.walk(from); DefaultHttpClient client = client()) {
             if (delete) {
-                client.toBlocking().exchange(this.requestOptions(HttpRequest.DELETE(apiUri("/namespaces/") + namespace + "/files?path=/", null)));
+                client.toBlocking().exchange(this.requestOptions(HttpRequest.DELETE(apiUri("/namespaces/") + namespace + "/files?path=" + to, null)));
             }
 
-            GitIgnore gitIgnore = parseKestraIgnore(directory);
+            GitIgnore gitIgnore = parseKestraIgnore(from);
 
             List<Path> paths = files
                 .filter(Files::isRegularFile)
@@ -47,16 +60,17 @@ public Integer call() throws Exception {
                 MultipartBody body = MultipartBody.builder()
                     .addPart("fileContent", path.toFile())
                     .build();
-                Path dest = directory.relativize(path);
+                String relativizedPath = from.relativize(path).toString();
+                String destination = to + relativizedPath;
                 client.toBlocking().exchange(
                     this.requestOptions(
                         HttpRequest.POST(
-                            apiUri("/namespaces/") + namespace + "/files?path=/" + dest,
+                            apiUri("/namespaces/") + namespace + "/files?path=" + destination,
                             body
                         ).contentType(MediaType.MULTIPART_FORM_DATA)
                     )
                 );
-                stdOut("Successfully uploaded {0} to /{1}", path, dest);
+                stdOut("Successfully uploaded {0} to {1}", path.toString(), destination);
             });
         } catch (HttpClientResponseException e) {
             AbstractValidateCommand.handleHttpException(e, "namespace");


Test patch:
diff --git a/cli/src/test/java/io/kestra/cli/commands/namespaces/files/NamespaceFilesUpdateCommandTest.java b/cli/src/test/java/io/kestra/cli/commands/namespaces/files/NamespaceFilesUpdateCommandTest.java
index be72eb19d8..3ffb97fd54 100644
--- a/cli/src/test/java/io/kestra/cli/commands/namespaces/files/NamespaceFilesUpdateCommandTest.java
+++ b/cli/src/test/java/io/kestra/cli/commands/namespaces/files/NamespaceFilesUpdateCommandTest.java
@@ -4,10 +4,13 @@
 import io.micronaut.context.ApplicationContext;
 import io.micronaut.context.env.Environment;
 import io.micronaut.runtime.server.EmbeddedServer;
+import org.hamcrest.Matcher;
 import org.junit.jupiter.api.Test;
 
+import javax.annotation.Nullable;
 import java.io.ByteArrayOutputStream;
 import java.io.PrintStream;
+import java.net.URISyntaxException;
 import java.net.URL;
 
 import static org.hamcrest.MatcherAssert.assertThat;
@@ -16,7 +19,7 @@
 
 class NamespaceFilesUpdateCommandTest {
     @Test
-    void runWithoutIgnore() {
+    void runWithToSpecified() {
         URL directory = NamespaceFilesUpdateCommandTest.class.getClassLoader().getResource("namespacefiles/noignore");
         ByteArrayOutputStream out = new ByteArrayOutputStream();
         System.setOut(new PrintStream(out));
@@ -26,6 +29,7 @@ void runWithoutIgnore() {
             EmbeddedServer embeddedServer = ctx.getBean(EmbeddedServer.class);
             embeddedServer.start();
 
+            String to = "/some/directory";
             String[] args = {
                 "--server",
                 embeddedServer.getURL().toString(),
@@ -34,19 +38,49 @@ void runWithoutIgnore() {
                 "--delete",
                 "io.kestra.cli",
                 directory.getPath(),
+                to
             };
             PicocliRunner.call(NamespaceFilesUpdateCommand.class, ctx, args);
 
-            assertThat(out.toString(), containsString("namespacefiles/noignore/2 to /2"));
-            assertThat(out.toString(), containsString("namespacefiles/noignore/flows/flow.yml"));
-            assertThat(out.toString(), containsString("namespacefiles/noignore/1 to /1"));
+            assertTransferMessage(out, "2", to);
+            assertTransferMessage(out, "1", to);
+            assertTransferMessage(out, "flows/flow.yml", to);
             out.reset();
         }
     }
 
     @Test
-    void runWithIgnore() {
-        URL directory = NamespaceFilesUpdateCommandTest.class.getClassLoader().getResource("namespacefiles/ignore");
+    void runWithoutIgnore() throws URISyntaxException {
+        URL directory = NamespaceFilesUpdateCommandTest.class.getClassLoader().getResource("namespacefiles/noignore/");
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        System.setOut(new PrintStream(out));
+
+        try (ApplicationContext ctx = ApplicationContext.run(Environment.CLI, Environment.TEST)) {
+
+            EmbeddedServer embeddedServer = ctx.getBean(EmbeddedServer.class);
+            embeddedServer.start();
+
+            String[] args = {
+                "--server",
+                embeddedServer.getURL().toString(),
+                "--user",
+                "myuser:pass:word",
+                "--delete",
+                "io.kestra.cli",
+                directory.getPath()
+            };
+            PicocliRunner.call(NamespaceFilesUpdateCommand.class, ctx, args);
+
+            assertTransferMessage(out, "2", null);
+            assertTransferMessage(out, "1", null);
+            assertTransferMessage(out, "flows/flow.yml", null);
+            out.reset();
+        }
+    }
+
+    @Test
+    void runWithIgnore() throws URISyntaxException {
+        URL directory = NamespaceFilesUpdateCommandTest.class.getClassLoader().getResource("namespacefiles/ignore/");
         ByteArrayOutputStream out = new ByteArrayOutputStream();
         System.setOut(new PrintStream(out));
 
@@ -66,10 +100,20 @@ void runWithIgnore() {
             };
             PicocliRunner.call(NamespaceFilesUpdateCommand.class, ctx, args);
 
-            assertThat(out.toString(), containsString("namespacefiles/ignore/2 to /2"));
-            assertThat(out.toString(), containsString("namespacefiles/ignore/1 to /1"));
-            assertThat(out.toString(), not(containsString("namespacefiles/ignore/flows/flow.yml")));
+            assertTransferMessage(out, "2", null);
+            assertTransferMessage(out, "1", null);
+            assertTransferMessage(out, "flows/flow.yml", null, false);
             out.reset();
         }
     }
+
+    private void assertTransferMessage(ByteArrayOutputStream out, String from, String to) {
+        assertTransferMessage(out, from, to, true);
+    }
+
+    private void assertTransferMessage(ByteArrayOutputStream out, String relativePath, @Nullable String to, boolean present) {
+        to = to == null ? "" : to;
+        Matcher<String> matcher = containsString(relativePath + " to " + to + "/" + relativePath);
+        assertThat(out.toString(), present ? matcher : not(matcher));
+    }
 }
\ No newline at end of file