You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sling.apache.org by ro...@apache.org on 2018/04/18 16:16:10 UTC

[sling-ide-tooling] 05/08: SLING-7587 - Create a CLI-only tool to sync content

This is an automated email from the ASF dual-hosted git repository.

rombert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/sling-ide-tooling.git

commit d5e03944a04039ca606ee039aee039e43112aec2
Author: Robert Munteanu <ro...@apache.org>
AuthorDate: Tue Apr 17 15:09:03 2018 +0300

    SLING-7587 - Create a CLI-only tool to sync content
    
    Add a CLI reactor which includes a CLI bundle and a CLI distribution (WIP)
    based on the feature model.
---
 cli/cli/bnd.bnd                                    |   1 +
 cli/cli/pom.xml                                    | 107 +++++++++++
 .../org/apache/sling/ide/cli/impl/ContentSync.java | 128 +++++++++++++
 .../org/apache/sling/ide/cli/impl/DirWatcher.java  | 202 +++++++++++++++++++++
 .../sling/ide/cli/impl/Slf4jLoggerFactory.java     |  84 +++++++++
 .../apache/sling/ide/cli/impl/DirWatcherTest.java  | 177 ++++++++++++++++++
 cli/dist/.gitignore                                |   3 +
 cli/dist/assemble-app.sh                           |   5 +
 cli/dist/features/clisync.json                     |  28 +++
 cli/dist/pom.xml                                   |  76 ++++++++
 cli/dist/run-app.sh                                |   5 +
 cli/pom.xml                                        |  39 ++++
 12 files changed, 855 insertions(+)

diff --git a/cli/cli/bnd.bnd b/cli/cli/bnd.bnd
new file mode 100644
index 0000000..1e09af3
--- /dev/null
+++ b/cli/cli/bnd.bnd
@@ -0,0 +1 @@
+-exportcontents: ${packages;VERSIONED}
\ No newline at end of file
diff --git a/cli/cli/pom.xml b/cli/cli/pom.xml
new file mode 100644
index 0000000..f6b83c0
--- /dev/null
+++ b/cli/cli/pom.xml
@@ -0,0 +1,107 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- Licensed to the Apache Software Foundation (ASF) under one or more contributor 
+    license agreements. See the NOTICE file distributed with this work for additional 
+    information regarding copyright ownership. The ASF licenses this file to 
+    you under the Apache License, Version 2.0 (the "License"); you may not use 
+    this file except in compliance with the License. You may obtain a copy of 
+    the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required 
+    by applicable law or agreed to in writing, software distributed under the 
+    License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS 
+    OF ANY KIND, either express or implied. See the License for the specific 
+    language governing permissions and limitations under the License. -->
+<project
+    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"
+    xmlns="http://maven.apache.org/POM/4.0.0"
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>org.apache.sling</groupId>
+        <artifactId>sling</artifactId>
+        <version>33</version>
+    </parent>
+
+    <artifactId>org.apache.sling.ide.cli</artifactId>
+    <name>Apache Sling IDE Tools CLI</name>
+    <version>1.2.3-SNAPSHOT</version>
+
+    <scm>
+        <connection>scm:git:https://gitbox.apache.org/repos/asf/sling-ide-tooling.git</connection>
+        <developerConnection>scm:git:https://gitbox.apache.org/repos/asf/sling-ide-tooling.git</developerConnection>
+        <url>https://gitbox.apache.org/repos/asf?p=sling-ide-tooling.git</url>
+    </scm>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>biz.aQute.bnd</groupId>
+                <artifactId>bnd-maven-plugin</artifactId>
+            </plugin>
+        </plugins>
+    </build>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.osgi</groupId>
+            <artifactId>osgi.core</artifactId>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.osgi</groupId>
+            <artifactId>osgi.cmpn</artifactId>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.osgi</groupId>
+            <artifactId>org.osgi.service.event</artifactId>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-simple</artifactId>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.sling.ide</groupId>
+            <artifactId>org.apache.sling.ide.api</artifactId>
+            <version>${project.version}</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.sling.ide</groupId>
+            <artifactId>org.apache.sling.ide.sync-fs</artifactId>
+            <version>${project.version}</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.hamcrest</groupId>
+            <artifactId>hamcrest-library</artifactId>
+            <version>1.3</version>
+            <scope>test</scope>
+        </dependency>
+
+        <!-- Note that OSGi annotations are OK since they are not retained at compile time -->        
+        <dependency>
+            <groupId>org.osgi</groupId>
+            <artifactId>org.osgi.annotation.versioning</artifactId>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.osgi</groupId>
+            <artifactId>org.osgi.service.component.annotations</artifactId>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.osgi</groupId>
+            <artifactId>org.osgi.service.metatype.annotations</artifactId>
+            <scope>provided</scope>
+        </dependency>
+    </dependencies>
+    <properties>
+        <sling.java.version>8</sling.java.version>
+    </properties>
+    <groupId>org.apache.sling.ide</groupId>
+</project>
diff --git a/cli/cli/src/main/java/org/apache/sling/ide/cli/impl/ContentSync.java b/cli/cli/src/main/java/org/apache/sling/ide/cli/impl/ContentSync.java
new file mode 100644
index 0000000..9924ced
--- /dev/null
+++ b/cli/cli/src/main/java/org/apache/sling/ide/cli/impl/ContentSync.java
@@ -0,0 +1,128 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sling.ide.cli.impl;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.nio.file.StandardWatchEventKinds;
+
+import org.apache.sling.ide.cli.impl.DirWatcher.Event;
+import org.apache.sling.ide.content.sync.fs.FSResources;
+import org.apache.sling.ide.filter.FilterLocator;
+import org.apache.sling.ide.log.Logger;
+import org.apache.sling.ide.sync.content.SyncCommandFactory;
+import org.apache.sling.ide.sync.content.WorkspacePath;
+import org.apache.sling.ide.sync.content.WorkspacePaths;
+import org.apache.sling.ide.sync.content.WorkspaceProject;
+import org.apache.sling.ide.transport.Command;
+import org.apache.sling.ide.transport.Repository;
+import org.apache.sling.ide.transport.RepositoryFactory;
+import org.apache.sling.ide.transport.RepositoryInfo;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+
+@Component(immediate=true)
+public class ContentSync {
+    
+    @Reference
+    private Logger logger;
+    
+    @Reference
+    private RepositoryFactory repoFactory;
+    
+    @Reference
+    private SyncCommandFactory commandFactory;
+    
+    @Reference
+    private FilterLocator filterLocator;
+    
+    private DirWatcher watcher;
+    
+    private Thread watcherThread;
+    
+
+    protected void activate() throws Exception {
+
+        File projectDir = new File("/home/robert/Documents/workspace/content003");
+        
+        WorkspaceProject prj = FSResources.create(projectDir, projectDir, filterLocator);
+        
+        logger.trace("Working on project {0} at {1}", prj.getName(), prj.getOSPath());
+        
+        Repository repo = repoFactory.connectRepository(new RepositoryInfo("admin", "admin", "http://localhost:8080"));
+        
+        repo.newListChildrenNodeCommand("/").execute();
+        
+        logger.trace("Connected to {0} ", repo.getRepositoryInfo());
+        
+        Path syncDirPath = prj.getSyncDirectory().getOSPath();
+        
+        watcher = new DirWatcher(syncDirPath);
+        
+        logger.trace("Watching syncDir {0}", syncDirPath);
+        
+        watcherThread = new Thread(new Runnable()  {
+            @Override
+            public void run() {
+                try {
+                    while ( ! Thread.currentThread().isInterrupted() ) {
+
+                        Event event = watcher.poll();
+            
+                        Path path = event.getPath();
+                        
+                        WorkspacePath resourceRelativePath = WorkspacePaths.fromOsPath(path);
+                        logger.trace("Change detected in workspace path {0}", resourceRelativePath);
+                        if ( event.getKind() == StandardWatchEventKinds.ENTRY_CREATE || 
+                                event.getKind() == StandardWatchEventKinds.ENTRY_MODIFY ) {
+                            try {
+                                Command<?> cmd = commandFactory.
+                                    newCommandForAddedOrUpdatedResource(repo, prj.getSyncDirectory().getFile(resourceRelativePath));
+                                if ( cmd != null )
+                                    cmd.execute();
+                            } catch (IOException e) {
+                                logger.warn("Sync failed for path " + resourceRelativePath , e);
+                            }
+                        }
+                        
+                        if ( event.getKind() == StandardWatchEventKinds.ENTRY_DELETE ) {
+                            try {
+                                Command<?> cmd = commandFactory.newCommandForRemovedResource(repo, prj.getSyncDirectory().getFile(resourceRelativePath));
+                                if ( cmd != null )
+                                    cmd.execute();
+                            } catch (IOException e) {
+                                logger.warn("Sync failed for path " + resourceRelativePath , e);
+                            }
+                        }
+                    }
+                } catch (InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                }
+            }
+        });
+        
+        watcherThread.start();
+    }
+    
+    protected void deactivate() throws Exception {
+        
+        if ( watcher != null ) {
+            watcherThread.interrupt();
+        }
+    }
+}
diff --git a/cli/cli/src/main/java/org/apache/sling/ide/cli/impl/DirWatcher.java b/cli/cli/src/main/java/org/apache/sling/ide/cli/impl/DirWatcher.java
new file mode 100644
index 0000000..8eb9795
--- /dev/null
+++ b/cli/cli/src/main/java/org/apache/sling/ide/cli/impl/DirWatcher.java
@@ -0,0 +1,202 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sling.ide.cli.impl;
+
+import static java.nio.file.StandardWatchEventKinds.ENTRY_CREATE;
+import static java.nio.file.StandardWatchEventKinds.ENTRY_DELETE;
+import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardWatchEventKinds;
+import java.nio.file.WatchEvent;
+import java.nio.file.WatchEvent.Kind;
+import java.nio.file.WatchKey;
+import java.nio.file.WatchService;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * Watches a whole directory tree for changes
+ * 
+ * <p>This class works on top of the standard  {@link WatchService} API by generating
+ * events for all changes below a given directory.</p>
+ */
+public class DirWatcher implements AutoCloseable {
+    
+    private final Path root;
+    private final WatchService ws;
+    private final DualMap watched = new DualMap();
+    private final Thread poller;
+    private final BlockingQueue<DirWatcher.Event> queue = new LinkedBlockingQueue<>();
+
+    public DirWatcher(Path path) throws IOException {
+        this.root = path;
+        ws = path.getFileSystem().newWatchService();
+
+        poller = new Thread(() ->  {
+            while ( !Thread.currentThread().isInterrupted() ) {
+                try {
+                    queue.addAll(pollInternal());
+                } catch ( InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                    break;
+                }
+            }
+        }, getClass().getSimpleName() +"-Poller");
+        
+        Stream.concat(
+            Stream.of(root),
+            Files.walk(root).filter(p -> p.toFile().isDirectory())
+        ).forEach( this::register);
+        
+        poller.start();
+    }
+    
+    public void close() throws IOException {
+        if ( poller != null )
+            poller.interrupt();
+        if ( ws != null)
+            ws.close();
+    }
+
+    /**
+     * Takes a single event from the queue, blocking if none are available
+     * 
+     * @return the event
+     * @throws InterruptedException interrupted
+     */
+    public Event poll() throws InterruptedException {
+        return queue.take();
+    }
+    
+    // visible for testing
+    int queueSize() {
+        return queue.size();
+    }
+    
+    private void register(Path path) {
+        try {
+            WatchKey key = path.register(ws, ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY);
+            watched.put(key, path);
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    private void unregister(Path path) {
+        WatchKey key = watched.remove(path);
+        if ( key != null )
+            key.cancel();
+        
+    }
+    
+    private List<DirWatcher.Event> pollInternal() throws InterruptedException {
+        final WatchKey key = ws.take();
+        
+        List<DirWatcher.Event> result = key.pollEvents().stream()
+            .filter( e -> e.context() instanceof Path )
+            .map( Event::new )
+            .map( e -> updateTracked(e, key) )
+            .map( e -> adjust(e, key) )
+            .collect( Collectors.toList() );
+        
+        key.reset();
+        
+        return result;
+    }
+
+    private DirWatcher.Event adjust(Event e, WatchKey key) {
+        Path keyPath = watched.get(key);
+        e.path = root.relativize(keyPath.resolve(e.path));
+        return e;
+    }
+    
+    private DirWatcher.Event updateTracked(DirWatcher.Event evt, WatchKey key) {
+        if ( evt.getKind() == StandardWatchEventKinds.ENTRY_CREATE ) {
+            Path fullPath = watched.get(key).resolve(evt.getPath());
+            if ( fullPath.toFile().isDirectory())
+                register(fullPath);            
+        } else if ( evt.getKind() == StandardWatchEventKinds.ENTRY_DELETE ) {
+            Path fullPath = watched.get(key).resolve(evt.getPath());
+            // we can't check if the path pointed to a directory since it is already deleted
+            unregister(fullPath);
+        }
+
+        return evt;
+    }
+    
+    public static class Event {
+        
+        public Event(WatchEvent<?> wrapper) {
+            kind = wrapper.kind();
+            path = (Path) wrapper.context();
+        }
+        
+        private Kind<?> kind;
+        private Path path;
+        
+        public Kind<?> getKind() {
+            return kind;
+        }
+        
+        public Path getPath() {
+            return path;
+        }
+    }
+    
+    static class DualMap {
+
+        private final Map<WatchKey, Path> forward = new HashMap<>();
+        private final Map<Path, WatchKey> reverse = new HashMap<>();
+        private final Object sync = new Object();
+        
+        public void put(WatchKey key, Path path) {
+            synchronized (sync) {
+                forward.put(key, path);
+                reverse.put(path, key);
+            }
+        }
+        
+        public Path get(WatchKey key) {
+            synchronized (sync) {
+                return forward.get(key);
+            }
+        }
+        
+        public WatchKey get(Path path) {
+            synchronized (sync) {
+                return reverse.get(path);
+            }
+        }
+        
+        public WatchKey remove(Path path) {
+            synchronized (sync) {
+                WatchKey key = reverse.get(path);
+                if ( key != null )
+                    forward.remove(key);
+                return key;
+            }
+        }
+    }
+}
diff --git a/cli/cli/src/main/java/org/apache/sling/ide/cli/impl/Slf4jLoggerFactory.java b/cli/cli/src/main/java/org/apache/sling/ide/cli/impl/Slf4jLoggerFactory.java
new file mode 100644
index 0000000..288d142
--- /dev/null
+++ b/cli/cli/src/main/java/org/apache/sling/ide/cli/impl/Slf4jLoggerFactory.java
@@ -0,0 +1,84 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sling.ide.cli.impl;
+
+
+import org.apache.sling.ide.log.Logger;
+import org.osgi.service.component.ComponentContext;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.ServiceScope;
+import org.slf4j.LoggerFactory;
+
+@Component(service = Logger.class, scope = ServiceScope.BUNDLE)
+public class Slf4jLoggerFactory implements Logger {
+
+    private static final long PERF_IGNORE_THRESHOLD = 50;
+
+    private final org.slf4j.Logger wrapped = LoggerFactory.getLogger(Slf4jLoggerFactory.class);
+
+    private String marker;
+    
+    protected void activate(ComponentContext ctx) {
+        marker = "[" + ctx.getUsingBundle().getSymbolicName() + "] ";
+        wrapped.info(marker + "Logger initialized");
+    }
+
+    @Override
+    public void warn(String message, Throwable cause) {
+        wrapped.warn( marker + message, cause);
+    }
+
+    @Override
+    public void warn(String message) {
+        wrapped.warn(marker + message);
+    }
+
+    @Override
+    public void trace(String message, Throwable error) {
+        wrapped.info(marker + message, error);
+    }
+
+    @Override
+    public void trace(String message, Object... arguments) {
+
+        // this is probably a horribly slow implementation, but it does not matter
+        for (int i = 0; i < arguments.length; i++) {
+            message = message.replace("{" + i + "}", String.valueOf(arguments[i]));
+        }
+
+        wrapped.info(marker + message);
+    }
+
+    @Override
+    public void error(String message, Throwable cause) {
+        wrapped.error(marker + message, cause);
+    }
+
+    @Override
+    public void error(String message) {
+        wrapped.error(marker + message);
+    }
+
+    @Override
+    public void tracePerformance(String message, long duration, Object... arguments) {
+        if (duration < PERF_IGNORE_THRESHOLD) {
+            return;
+        }
+        trace(message + " took " + duration + " ms", arguments);
+    }
+
+}
diff --git a/cli/cli/src/test/java/org/apache/sling/ide/cli/impl/DirWatcherTest.java b/cli/cli/src/test/java/org/apache/sling/ide/cli/impl/DirWatcherTest.java
new file mode 100644
index 0000000..4c6b173
--- /dev/null
+++ b/cli/cli/src/test/java/org/apache/sling/ide/cli/impl/DirWatcherTest.java
@@ -0,0 +1,177 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sling.ide.cli.impl;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.nio.file.StandardWatchEventKinds.ENTRY_CREATE;
+import static java.nio.file.StandardWatchEventKinds.ENTRY_DELETE;
+import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY;
+import static org.hamcrest.Matchers.equalTo;
+import static org.junit.Assert.assertThat;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+public class DirWatcherTest {
+
+    @Rule
+    public TemporaryFolder folder = new TemporaryFolder();
+    
+    @Test(timeout = 3000)
+    public void addedFileInRoot() throws IOException, InterruptedException {
+        
+        File watchRoot = folder.newFolder();
+        
+        try ( DirWatcher w = new DirWatcher(watchRoot.toPath()) ) {
+            
+            final File created = new File(watchRoot, "README");
+            created.createNewFile();
+            
+            DirWatcher.Event event = w.poll();
+            
+            assertThat("event.kind", event.getKind(), equalTo(ENTRY_CREATE));
+            assertThat("event.path", event.getPath(), equalTo(Paths.get(created.getName())));
+            
+            assertThat("queue.size", w.queueSize(), equalTo(0));
+        }
+    }
+    
+    @Test(timeout = 3000)
+    public void addedFileInSubdir() throws IOException, InterruptedException {
+        
+        File watchRoot = folder.newFolder();
+        File subDir = new File(watchRoot, "subDir");
+        subDir.mkdir();
+        
+        try ( DirWatcher w = new DirWatcher(watchRoot.toPath()) ) {
+            
+            File created = new File(subDir, "README");
+            created.createNewFile();
+    
+            DirWatcher.Event event = w.poll();
+            
+            assertThat("event.kind", event.getKind(), equalTo(ENTRY_CREATE));
+            assertThat("event.path", event.getPath(), equalTo(Paths.get(subDir.getName(), created.getName())));
+            
+            assertThat("queue.size", w.queueSize(), equalTo(0));
+        }
+
+    }
+    
+    @Test(timeout = 3000)
+    public void addedFileInNewSubdir() throws IOException, InterruptedException {
+
+        File watchRoot = folder.newFolder();
+        
+        try ( DirWatcher w = new DirWatcher(watchRoot.toPath()) ) {
+    
+            File subDir = new File(watchRoot, "subDir");
+            subDir.mkdir();
+    
+            DirWatcher.Event event = w.poll();
+            
+            assertThat("event.kind", event.getKind(), equalTo(ENTRY_CREATE));
+            assertThat("event.path", event.getPath(), equalTo(Paths.get(subDir.getName())));
+            
+            File created = new File(subDir, "README");
+            created.createNewFile();
+            
+            event = w.poll();
+            assertThat("event.kind", event.getKind(), equalTo(ENTRY_CREATE));
+            assertThat("event.path", event.getPath(), equalTo(Paths.get(subDir.getName(), created.getName())));
+            
+            assertThat("queue.size", w.queueSize(), equalTo(0));
+        }
+    }
+    
+    @Test(timeout = 3000)
+    public void deletedFile() throws IOException, InterruptedException {
+        
+        File watchRoot = folder.newFolder();
+        File subDir = new File(watchRoot, "subDir");
+        subDir.mkdir();
+
+        File created = new File(subDir, "README");
+        created.createNewFile();
+
+        try ( DirWatcher w = new DirWatcher(watchRoot.toPath()) ) { 
+
+            created.delete();
+            
+            DirWatcher.Event event = w.poll();
+        
+            assertThat("event.kind", event.getKind(), equalTo(ENTRY_DELETE));
+            assertThat("event.path", event.getPath(), equalTo(Paths.get(subDir.getName(), created.getName())));
+            
+            assertThat("queue.size", w.queueSize(), equalTo(0));
+        }
+    }
+    
+    @Test(timeout = 300000)
+    public void deleteDir() throws IOException, InterruptedException {
+        
+        File watchRoot = folder.newFolder();
+        File subDir = new File(watchRoot, "subDir");
+        subDir.mkdir();
+
+
+        try ( DirWatcher w = new DirWatcher(watchRoot.toPath()) ) { 
+            
+            Files.delete(subDir.toPath());
+            
+            DirWatcher.Event event = w.poll();
+        
+            assertThat("event.kind", event.getKind(), equalTo(ENTRY_DELETE));
+            assertThat("event.path", event.getPath(), equalTo(Paths.get(subDir.getName())));
+            
+            assertThat("queue.size", w.queueSize(), equalTo(0));
+        }
+    }
+
+    @Test(timeout = 3000)
+    public void modifyFile() throws IOException, InterruptedException {
+        
+        File watchRoot = folder.newFolder();
+        final File created = new File(watchRoot, "README");
+        created.createNewFile();        
+        
+        try ( DirWatcher w = new DirWatcher(watchRoot.toPath()) ) { 
+            
+            Files.write(created.toPath(), "hello, world".getBytes(UTF_8));
+            
+            DirWatcher.Event event = w.poll();
+            
+            assertThat("event.kind", event.getKind(), equalTo(ENTRY_MODIFY));
+            assertThat("event.path", event.getPath(), equalTo(Paths.get(created.getName())));
+            
+            Files.write(created.toPath(), "hello, again".getBytes(UTF_8));
+            
+            event = w.poll();
+            
+            assertThat("event.kind", event.getKind(), equalTo(ENTRY_MODIFY));
+            assertThat("event.path", event.getPath(), equalTo(Paths.get(created.getName())));
+            
+            assertThat("queue.size", w.queueSize(), equalTo(0));
+        }
+    }
+}
diff --git a/cli/dist/.gitignore b/cli/dist/.gitignore
new file mode 100644
index 0000000..952774f
--- /dev/null
+++ b/cli/dist/.gitignore
@@ -0,0 +1,3 @@
+/felix-cache/
+/launcher/
+/sling.json
diff --git a/cli/dist/assemble-app.sh b/cli/dist/assemble-app.sh
new file mode 100755
index 0000000..e869306
--- /dev/null
+++ b/cli/dist/assemble-app.sh
@@ -0,0 +1,5 @@
+#!/bin/sh
+
+rm -rf felix-cache launcher
+
+java -cp  ../../../whiteboard/featuremodel/feature-applicationbuilder/target/org.apache.sling.feature.applicationbuilder-0.0.1-SNAPSHOT.jar:${HOME}/.m2/repository/org/apache/felix/org.apache.felix.framework/5.6.8/org.apache.felix.framework-5.6.8.jar   org.apache.sling.feature.applicationbuilder.impl.Main   -d features/   -u file://${HOME}/.m2/repository   -o sling.json
diff --git a/cli/dist/features/clisync.json b/cli/dist/features/clisync.json
new file mode 100644
index 0000000..0fb71fb
--- /dev/null
+++ b/cli/dist/features/clisync.json
@@ -0,0 +1,28 @@
+{
+	"id": "org.apache.sling.ide/org.apache.sling.ide.cli-dist/1.0.0",
+	"bundles": [
+		"org.apache.felix/org.apache.felix.eventadmin/1.4.10",
+		"org.slf4j/slf4j-api/1.7.25",
+		"org.slf4j/slf4j-simple/1.7.25",
+		"org.slf4j/jcl-over-slf4j/1.7.25",
+		"org.apache.felix/org.apache.felix.scr/2.0.12",
+		"org.apache.sling.ide/org.apache.sling.ide.api/1.2.3-SNAPSHOT",
+		"org.apache.sling.ide/org.apache.sling.ide.impl-vlt/1.2.3-SNAPSHOT",
+		"org.apache.sling.ide/org.apache.sling.ide.sync-fs/1.2.3-SNAPSHOT",
+		"org.apache.sling.ide/org.apache.sling.ide.cli/1.2.3-SNAPSHOT",
+		"org.apache.sling.ide/org.apache.sling.ide.vlt-wrapper/1.2.3-SNAPSHOT",
+		"javax.servlet/javax.servlet-api/3.1.0",
+		"commons-collections/commons-collections/3.2.2",
+		"org.apache.sling/org.apache.sling.fragment.xml/1.0.2",
+		"org.apache.geronimo.bundles/commons-httpclient/3.1_2",
+		"commons-codec/commons-codec/1.11",
+		"com.google.code.gson/gson/2.2.4",
+		"commons-io/commons-io/2.6",
+		"org.apache.felix/org.apache.felix.gogo.command/1.0.2",
+		"org.apache.felix/org.apache.felix.gogo.runtime/1.0.6",
+		"org.apache.felix/org.apache.felix.gogo.jline/1.0.0",
+		"org.apache.felix/org.apache.felix.gogo.shell/1.0.0",
+		"org.jline/jline/3.0.1",
+		"org.apache.geronimo.specs/geronimo-atinject_1.0_spec/1.0"
+	]
+}
\ No newline at end of file
diff --git a/cli/dist/pom.xml b/cli/dist/pom.xml
new file mode 100644
index 0000000..e99bdc4
--- /dev/null
+++ b/cli/dist/pom.xml
@@ -0,0 +1,76 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- Licensed to the Apache Software Foundation (ASF) under one or more contributor 
+    license agreements. See the NOTICE file distributed with this work for additional 
+    information regarding copyright ownership. The ASF licenses this file to 
+    you under the Apache License, Version 2.0 (the "License"); you may not use 
+    this file except in compliance with the License. You may obtain a copy of 
+    the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required 
+    by applicable law or agreed to in writing, software distributed under the 
+    License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS 
+    OF ANY KIND, either express or implied. See the License for the specific 
+    language governing permissions and limitations under the License. -->
+<project
+    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"
+    xmlns="http://maven.apache.org/POM/4.0.0"
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>org.apache.sling</groupId>
+        <artifactId>sling</artifactId>
+        <version>33</version>
+    </parent>
+
+    <artifactId>org.apache.sling.ide.cli-dist</artifactId>
+    <name>Apache Sling IDE Tools CLI distribution module</name>
+    <packaging>osgiapp</packaging>
+
+    <scm>
+        <connection>scm:git:https://gitbox.apache.org/repos/asf/sling-ide-tooling.git</connection>
+        <developerConnection>scm:git:https://gitbox.apache.org/repos/asf/sling-ide-tooling.git</developerConnection>
+        <url>https://gitbox.apache.org/repos/asf?p=sling-ide-tooling.git</url>
+    </scm>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.sling</groupId>
+                <artifactId>osgifeature-maven-plugin</artifactId>
+                <version>0.01.7-SNAPSHOT</version>
+                <extensions>true</extensions>
+            </plugin>
+        </plugins>
+    </build>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.osgi</groupId>
+            <artifactId>osgi.core</artifactId>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.osgi</groupId>
+            <artifactId>org.osgi.service.event</artifactId>
+            <scope>provided</scope>
+        </dependency>
+
+        <!-- Note that OSGi annotations are OK since they are not retained at compile time -->        
+        <dependency>
+            <groupId>org.osgi</groupId>
+            <artifactId>org.osgi.annotation.versioning</artifactId>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.osgi</groupId>
+            <artifactId>org.osgi.service.component.annotations</artifactId>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.osgi</groupId>
+            <artifactId>org.osgi.service.metatype.annotations</artifactId>
+            <scope>provided</scope>
+        </dependency>
+    </dependencies>
+    <properties>
+        <sling.java.version>8</sling.java.version>
+    </properties>
+</project>
diff --git a/cli/dist/run-app.sh b/cli/dist/run-app.sh
new file mode 100755
index 0000000..4571e12
--- /dev/null
+++ b/cli/dist/run-app.sh
@@ -0,0 +1,5 @@
+#!/bin/sh
+
+rm -rf felix-cache launcher
+
+java -jar ../../../whiteboard/featuremodel/feature-launcher/target/org.apache.sling.feature.launcher-0.0.1-SNAPSHOT.jar -a sling.json
diff --git a/cli/pom.xml b/cli/pom.xml
new file mode 100644
index 0000000..fd40a59
--- /dev/null
+++ b/cli/pom.xml
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- Licensed to the Apache Software Foundation (ASF) under one or more contributor 
+    license agreements. See the NOTICE file distributed with this work for additional 
+    information regarding copyright ownership. The ASF licenses this file to 
+    you under the Apache License, Version 2.0 (the "License"); you may not use 
+    this file except in compliance with the License. You may obtain a copy of 
+    the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required 
+    by applicable law or agreed to in writing, software distributed under the 
+    License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS 
+    OF ANY KIND, either express or implied. See the License for the specific 
+    language governing permissions and limitations under the License. -->
+<project
+    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"
+    xmlns="http://maven.apache.org/POM/4.0.0"
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>org.apache</groupId>
+        <artifactId>apache</artifactId>
+        <version>14</version>
+    </parent>
+    <groupId>org.apache.sling.ide</groupId>
+    <artifactId>sling-ide-tooling-cli</artifactId>
+    <version>1.2.3-SNAPSHOT</version>
+    <packaging>pom</packaging>
+    <name>Apache Sling IDE Tools - CLI</name>
+    <url>http://sling.apache.org</url>
+
+    <scm>
+        <connection>scm:git:https://gitbox.apache.org/repos/asf/sling-ide-tooling.git</connection>
+        <developerConnection>scm:git:https://gitbox.apache.org/repos/asf/sling-ide-tooling.git</developerConnection>
+        <url>https://gitbox.apache.org/repos/asf?p=sling-ide-tooling.git</url>
+    </scm>
+
+    <modules>
+        <module>cli</module>
+        <module>dist</module>
+    </modules>
+</project>

-- 
To stop receiving notification emails like this one, please contact
rombert@apache.org.