Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ Install via JBang:
jbang app install --name deploy4j --force --fresh dev.deploy4j:deploy4j-cli:0.0.6
```

After installing, run `deploy4j init` to set up your project interactively:

```shell
deploy4j init
```

This will ask for your server address, any secret variable names, and optionally set up AI agent skills (GitHub Copilot or Claude) for your project.

```shell
Usage: deploy4j [--help] [COMMAND]
Deploy web apps anywhere. From bare metal to cloud VMs.
Expand All @@ -57,7 +65,7 @@ Commands:
config Show combined config (including secrets!)
deploy Deploy app to servers
details Show details about all containers
init Create config stub in config/deploy.yml and env stub in .deploy4j/secrets
init Interactively set up deploy4j configuration files for your project
lock Manage the deploy lock
prune Prune old application images and containers
redeploy Deploy app to servers without bootstrapping servers, starting
Expand Down
82 changes: 79 additions & 3 deletions deploy4j-cli/src/main/java/dev/deploy4j/cli/InitCliCommand.java
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
package dev.deploy4j.cli;

import dev.deploy4j.init.InitConfig;
import dev.deploy4j.init.InitConfig.AgentType;
import dev.deploy4j.init.Initializer;
import picocli.CommandLine;

import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;
import java.util.concurrent.Callable;

@CommandLine.Command(
name = "init",
description = "Create config stub in config/deploy.yml and env stub in .deploy4j/secrets")
description = "Interactively set up deploy4j configuration files for your project")
public class InitCliCommand implements Callable<Integer> {

@CommandLine.Mixin
Expand All @@ -19,12 +25,82 @@ public class InitCliCommand implements Callable<Integer> {
@Override
public Integer call() throws Exception {

Initializer initializer = new Initializer();
initializer.init(bundle);
try (Scanner scanner = new Scanner(System.in)) {

System.out.println();
System.out.println("👋 Welcome to deploy4j init!");
System.out.println(" Let's get your project set up for deployment.");
System.out.println();

// Step 1: QuickStart details - hostname
System.out.print("? Server IP address or hostname [localhost]: ");
String hostname = scanner.nextLine().trim();
if (hostname.isEmpty()) {
hostname = InitConfig.DEFAULT_HOSTNAME;
}

// Step 2: QuickStart details - secret env var names
System.out.println();
System.out.print("? Secret environment variable names (comma-separated, press Enter to skip): ");
String secretsInput = scanner.nextLine().trim();
List<String> extraSecretNames = new ArrayList<>();
if (!secretsInput.isEmpty()) {
for (String name : secretsInput.split(",")) {
String trimmed = name.trim();
if (!trimmed.isEmpty()) {
extraSecretNames.add(trimmed);
}
}
}

// Step 3: Detect AI agent and confirm
AgentType agentType = detectAgentType();
if (agentType != AgentType.NONE) {
System.out.println();
System.out.printf("? Detected %s — add deploy4j skills? [Y/n]: ", agentDisplayName(agentType));
String answer = scanner.nextLine().trim().toLowerCase();
if (answer.equals("n") || answer.equals("no")) {
agentType = AgentType.NONE;
}
}

System.out.println();
System.out.println("📁 Setting up files...");
System.out.println();

InitConfig config = new InitConfig(bundle, hostname, extraSecretNames, agentType);
new Initializer().init(config);

System.out.println();
System.out.println("✅ Done! Next steps:");
System.out.println(" 1. Review and edit config/deploy.yml");
System.out.println(" 2. Fill in your secrets in .deploy4j/secrets");
System.out.println(" 3. Run 'deploy4j setup <version>' for first-time deployment");
System.out.println();

}

return 0;

}

private AgentType detectAgentType() {
if (new File("CLAUDE.md").exists()) {
return AgentType.CLAUDE;
}
if (new File(".github/copilot-instructions.md").exists()) {
return AgentType.COPILOT;
}
return AgentType.NONE;
}

private String agentDisplayName(AgentType agentType) {
return switch (agentType) {
case COPILOT -> "GitHub Copilot";
case CLAUDE -> "Claude";
default -> "AI agent";
};
}

}

45 changes: 45 additions & 0 deletions deploy4j-core/src/main/java/dev/deploy4j/init/InitConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package dev.deploy4j.init;

import java.util.List;

public class InitConfig {

public static final String DEFAULT_HOSTNAME = "localhost";

public enum AgentType {
COPILOT, CLAUDE, NONE
}

private final boolean bundle;
private final String hostname;
private final List<String> extraSecretNames;
private final AgentType agentType;

public InitConfig(boolean bundle, String hostname, List<String> extraSecretNames, AgentType agentType) {
this.bundle = bundle;
this.hostname = (hostname != null && !hostname.isBlank()) ? hostname : DEFAULT_HOSTNAME;
this.extraSecretNames = extraSecretNames != null ? List.copyOf(extraSecretNames) : List.of();
this.agentType = agentType != null ? agentType : AgentType.NONE;
}

public static InitConfig defaults(boolean bundle) {
return new InitConfig(bundle, DEFAULT_HOSTNAME, List.of(), AgentType.NONE);
}

public boolean isBundle() {
return bundle;
}

public String getHostname() {
return hostname;
}

public List<String> getExtraSecretNames() {
return extraSecretNames;
}

public AgentType getAgentType() {
return agentType;
}

}
94 changes: 82 additions & 12 deletions deploy4j-core/src/main/java/dev/deploy4j/init/Initializer.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;

public class Initializer {

Expand All @@ -13,47 +16,114 @@ public class Initializer {
* Create config stub in config/deploy.yml and env stub in .deploy4j/secrets
*/
public void init(boolean bundle) {
init(InitConfig.defaults(bundle));
}

/**
* Create config stub in config/deploy.yml, env stub in .deploy4j/secrets,
* and optionally an AI agent skills file, using the provided {@link InitConfig}.
*/
public void init(InitConfig config) {

File deployFile = new File("config/deploy.yml");
if (deployFile.exists()) {
log.info("Config file already exists in config/deploy.yml (remove first to create a new one)");
} else {
deployFile.getParentFile().mkdirs();
try {
FileUtils.copyInputStreamToFile(
getClass().getClassLoader().getResourceAsStream("templates/deploy.yml"),
deployFile
);
String template = readTemplate("templates/deploy.yml");
String content = template.replace("- localhost", "- " + config.getHostname());
FileUtils.writeStringToFile(deployFile, content, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new RuntimeException(e);
}
log.info("Created configuration file in config/deploy.yml");
}

File secretsFile = new File(".deploy4j/secrets");
if(secretsFile.exists()) {
if (secretsFile.exists()) {
log.info("Secrets file already exists in .deploy4j/secrets (remove first to create a new one)");
} else {
secretsFile.getParentFile().mkdirs();
try {
FileUtils.copyInputStreamToFile(
getClass().getClassLoader().getResourceAsStream("templates/secrets"),
secretsFile
);
String template = readTemplate("templates/secrets");
StringBuilder secrets = new StringBuilder(template);
for (String name : config.getExtraSecretNames()) {
if (!name.isBlank()) {
secrets.append(name.trim()).append("=\n");
}
}
FileUtils.writeStringToFile(secretsFile, secrets.toString(), StandardCharsets.UTF_8);
} catch (IOException e) {
throw new RuntimeException(e);
}
log.info("Created secrets file");
log.info("Created secrets file in .deploy4j/secrets");
}

File hooksFolder = new File(".deploy4j/hooks");
if(!hooksFolder.exists()) {
if (!hooksFolder.exists()) {
hooksFolder.mkdirs();
log.info("Created hooks folder");
log.info("Created hooks folder in .deploy4j/hooks");
}

initAgentSkills(config.getAgentType());

// TODO: bundle add maven dependency?

}

private static final String AGENT_SKILLS_MARKER = "deploy4j Deployment";

private void initAgentSkills(InitConfig.AgentType agentType) {
if (agentType == InitConfig.AgentType.COPILOT) {
File copilotFile = new File(".github/copilot-instructions.md");
copilotFile.getParentFile().mkdirs();
try {
String skills = readTemplate("templates/copilot-skills.md");
if (copilotFile.exists()) {
String existing = FileUtils.readFileToString(copilotFile, StandardCharsets.UTF_8);
if (!existing.contains(AGENT_SKILLS_MARKER)) {
FileUtils.writeStringToFile(copilotFile, existing + "\n" + skills, StandardCharsets.UTF_8);
log.info("Added deploy4j skills to .github/copilot-instructions.md");
} else {
log.info("deploy4j skills already present in .github/copilot-instructions.md");
}
} else {
FileUtils.writeStringToFile(copilotFile, skills, StandardCharsets.UTF_8);
log.info("Created .github/copilot-instructions.md with deploy4j skills");
}
} catch (IOException e) {
throw new RuntimeException(e);
}
} else if (agentType == InitConfig.AgentType.CLAUDE) {
File claudeFile = new File("CLAUDE.md");
try {
String skills = readTemplate("templates/claude-skills.md");
if (claudeFile.exists()) {
String existing = FileUtils.readFileToString(claudeFile, StandardCharsets.UTF_8);
if (!existing.contains(AGENT_SKILLS_MARKER)) {
FileUtils.writeStringToFile(claudeFile, existing + "\n" + skills, StandardCharsets.UTF_8);
log.info("Added deploy4j skills to CLAUDE.md");
} else {
log.info("deploy4j skills already present in CLAUDE.md");
}
} else {
FileUtils.writeStringToFile(claudeFile, skills, StandardCharsets.UTF_8);
log.info("Created CLAUDE.md with deploy4j skills");
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}

private String readTemplate(String resourcePath) throws IOException {
try (InputStream is = getClass().getClassLoader().getResourceAsStream(resourcePath)) {
if (is == null) {
throw new IOException("Template not found: " + resourcePath);
}
return new String(is.readAllBytes(), StandardCharsets.UTF_8);
}
}

}
41 changes: 41 additions & 0 deletions deploy4j-core/src/main/resources/templates/claude-skills.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
## deploy4j Deployment

This project is deployed using [deploy4j](https://deploy4j.dev/).

### Deployment Configuration

- Configuration: `config/deploy.yml`
- Secrets: `.deploy4j/secrets`
- Hooks: `.deploy4j/hooks/`

### Common Deployment Commands

```shell
# Deploy a new version
deploy4j deploy --version <version>

# First-time setup (bootstraps server, starts Traefik, deploys)
deploy4j setup <version>

# Redeploy without bootstrapping
deploy4j redeploy --version <version>

# Roll back to a previous version
deploy4j rollback <version>

# View running containers
deploy4j app details

# Push updated environment variables
deploy4j env push

# View Traefik status
deploy4j traefik details
```

### Deployment Workflow

1. Update version in `pom.xml`
2. Edit `config/deploy.yml` for any infrastructure changes
3. Update `.deploy4j/secrets` for new secret values, then run `deploy4j env push`
4. Run `deploy4j deploy --version <version>` to deploy
41 changes: 41 additions & 0 deletions deploy4j-core/src/main/resources/templates/copilot-skills.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
## deploy4j Deployment

This project is deployed using [deploy4j](https://deploy4j.dev/).

### Deployment Configuration

- Configuration: `config/deploy.yml`
- Secrets: `.deploy4j/secrets`
- Hooks: `.deploy4j/hooks/`

### Common Deployment Commands

```shell
# Deploy a new version
deploy4j deploy --version <version>

# First-time setup (bootstraps server, starts Traefik, deploys)
deploy4j setup <version>

# Redeploy without bootstrapping
deploy4j redeploy --version <version>

# Roll back to a previous version
deploy4j rollback <version>

# View running containers
deploy4j app details

# Push updated environment variables
deploy4j env push

# View Traefik status
deploy4j traefik details
```

### Deployment Workflow

1. Update version in `pom.xml`
2. Edit `config/deploy.yml` for any infrastructure changes
3. Update `.deploy4j/secrets` for new secret values, then run `deploy4j env push`
4. Run `deploy4j deploy --version <version>` to deploy
Loading