@arm8tron/smart-deploy 0.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -0
- package/dist/auth.d.ts +6 -0
- package/dist/auth.js +63 -0
- package/dist/git.d.ts +11 -0
- package/dist/git.js +37 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +116 -0
- package/dist/state.d.ts +11 -0
- package/dist/state.js +49 -0
- package/package.json +17 -0
package/README.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Smart Deploy CLI
|
|
2
|
+
|
|
3
|
+
> Beta release: login and local project selection are available today. Smart Analysis and deployment commands are still in development.
|
|
4
|
+
|
|
5
|
+
Smart Deploy is a preview-first application deployment platform. This CLI connects a local Git checkout to your Smart Deploy account.
|
|
6
|
+
|
|
7
|
+
## Install or run
|
|
8
|
+
|
|
9
|
+
Use Node.js 20 or later.
|
|
10
|
+
|
|
11
|
+
~~~bash
|
|
12
|
+
npx @arm8tron/smart-deploy@beta login
|
|
13
|
+
~~~
|
|
14
|
+
|
|
15
|
+
## Commands
|
|
16
|
+
|
|
17
|
+
~~~text
|
|
18
|
+
smart-deploy login
|
|
19
|
+
smart-deploy logout
|
|
20
|
+
smart-deploy init [--repo URL] [--branch NAME] [--service NAME]
|
|
21
|
+
smart-deploy repo show
|
|
22
|
+
smart-deploy repo use URL [--branch NAME]
|
|
23
|
+
smart-deploy service select NAME
|
|
24
|
+
smart-deploy config show
|
|
25
|
+
~~~
|
|
26
|
+
|
|
27
|
+
Login opens a browser authorization flow and uses your existing Smart Deploy GitHub connection. The CLI never receives your GitHub OAuth token.
|
|
28
|
+
|
|
29
|
+
Init stores the selected repository, branch, and service in .smartdeploy/state.json, which is ignored by Git. It does not store secrets or deployment credentials in the repository.
|
|
30
|
+
|
|
31
|
+
Install beta releases explicitly:
|
|
32
|
+
|
|
33
|
+
~~~bash
|
|
34
|
+
npx @arm8tron/smart-deploy@beta
|
|
35
|
+
~~~
|
|
36
|
+
|
|
37
|
+
See the [Smart Deploy repository](https://github.com/anirudh-makuluri/smart-deploy) for source, issues, and release status.
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare function getApiUrl(): string;
|
|
2
|
+
export declare function readCliToken(): Promise<string | null>;
|
|
3
|
+
export declare function writeCliToken(token: string): Promise<void>;
|
|
4
|
+
export declare function clearCliToken(): Promise<void>;
|
|
5
|
+
export declare function login(): Promise<void>;
|
|
6
|
+
export declare function logout(): Promise<void>;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
const DEFAULT_API_URL = "https://smart-deploy.xyz";
|
|
5
|
+
function credentialPath() {
|
|
6
|
+
const base = process.env.SMART_DEPLOY_CONFIG_DIR?.trim() || process.env.APPDATA || join(homedir(), ".config");
|
|
7
|
+
return join(base, "smart-deploy", "credentials.json");
|
|
8
|
+
}
|
|
9
|
+
export function getApiUrl() {
|
|
10
|
+
return (process.env.SMART_DEPLOY_API_URL?.trim() || DEFAULT_API_URL).replace(/\/$/, "");
|
|
11
|
+
}
|
|
12
|
+
export async function readCliToken() {
|
|
13
|
+
try {
|
|
14
|
+
const content = await readFile(credentialPath(), "utf8");
|
|
15
|
+
const value = JSON.parse(content);
|
|
16
|
+
return typeof value.token === "string" && value.token.trim() ? value.token.trim() : null;
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (error.code === "ENOENT")
|
|
20
|
+
return null;
|
|
21
|
+
throw new Error("Smart Deploy CLI credentials are invalid. Run smart-deploy logout and log in again.");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export async function writeCliToken(token) {
|
|
25
|
+
const path = credentialPath();
|
|
26
|
+
const temporary = path + "." + process.pid + ".tmp";
|
|
27
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
28
|
+
await writeFile(temporary, JSON.stringify({ token }) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
29
|
+
await rename(temporary, path);
|
|
30
|
+
}
|
|
31
|
+
export async function clearCliToken() {
|
|
32
|
+
await rm(credentialPath(), { force: true });
|
|
33
|
+
}
|
|
34
|
+
export async function login() {
|
|
35
|
+
const start = await fetch(getApiUrl() + "/api/cli/device/start", { method: "POST" });
|
|
36
|
+
if (!start.ok)
|
|
37
|
+
throw new Error("Could not begin CLI login.");
|
|
38
|
+
const request = await start.json();
|
|
39
|
+
console.log("Open this URL in a browser and approve Smart Deploy CLI:");
|
|
40
|
+
console.log(request.verificationUri);
|
|
41
|
+
const deadline = Date.now() + request.expiresIn * 1000;
|
|
42
|
+
while (Date.now() < deadline) {
|
|
43
|
+
await new Promise((resolve) => setTimeout(resolve, request.interval * 1000));
|
|
44
|
+
const response = await fetch(getApiUrl() + "/api/cli/device/token", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ deviceCode: request.deviceCode }) });
|
|
45
|
+
if (response.status === 202)
|
|
46
|
+
continue;
|
|
47
|
+
if (!response.ok)
|
|
48
|
+
throw new Error("CLI login failed.");
|
|
49
|
+
const token = await response.json();
|
|
50
|
+
if (typeof token.accessToken !== "string")
|
|
51
|
+
throw new Error("CLI login returned no access token.");
|
|
52
|
+
await writeCliToken(token.accessToken);
|
|
53
|
+
console.log("Smart Deploy CLI is connected.");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
throw new Error("CLI authorization expired. Run smart-deploy login again.");
|
|
57
|
+
}
|
|
58
|
+
export async function logout() {
|
|
59
|
+
const token = await readCliToken();
|
|
60
|
+
if (token)
|
|
61
|
+
await fetch(getApiUrl() + "/api/cli/logout", { method: "POST", headers: { Authorization: "Bearer " + token } });
|
|
62
|
+
await clearCliToken();
|
|
63
|
+
}
|
package/dist/git.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type GitCommandRunner = (args: string[], workdir: string) => string;
|
|
2
|
+
export type GitRepositoryContext = {
|
|
3
|
+
rootDirectory: string;
|
|
4
|
+
repoUrl: string;
|
|
5
|
+
branch: string;
|
|
6
|
+
commitSha: string;
|
|
7
|
+
isWorkingTreeClean: boolean;
|
|
8
|
+
};
|
|
9
|
+
export declare function normalizeGitHubRemote(remote: string): string;
|
|
10
|
+
export declare const runGitCommand: GitCommandRunner;
|
|
11
|
+
export declare function readGitRepositoryContext(workdir: string, runCommand?: GitCommandRunner): GitRepositoryContext;
|
package/dist/git.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
export function normalizeGitHubRemote(remote) {
|
|
3
|
+
const trimmed = remote.trim();
|
|
4
|
+
const sshMatch = trimmed.match(/^git@github\.com:([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i);
|
|
5
|
+
const httpsMatch = trimmed.match(/^https:\/\/github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i);
|
|
6
|
+
const match = sshMatch ?? httpsMatch;
|
|
7
|
+
if (!match) {
|
|
8
|
+
throw new Error("Smart Deploy currently requires a GitHub origin remote.");
|
|
9
|
+
}
|
|
10
|
+
return `https://github.com/${match[1]}/${match[2]}`;
|
|
11
|
+
}
|
|
12
|
+
export const runGitCommand = (args, workdir) => execFileSync("git", args, { cwd: workdir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
13
|
+
export function readGitRepositoryContext(workdir, runCommand = runGitCommand) {
|
|
14
|
+
try {
|
|
15
|
+
const rootDirectory = runCommand(["rev-parse", "--show-toplevel"], workdir);
|
|
16
|
+
const remote = runCommand(["config", "--get", "remote.origin.url"], rootDirectory);
|
|
17
|
+
const branch = runCommand(["branch", "--show-current"], rootDirectory);
|
|
18
|
+
const commitSha = runCommand(["rev-parse", "HEAD"], rootDirectory);
|
|
19
|
+
const workingTreeStatus = runCommand(["status", "--porcelain"], rootDirectory);
|
|
20
|
+
if (!branch)
|
|
21
|
+
throw new Error("Smart Deploy cannot initialize from a detached Git HEAD.");
|
|
22
|
+
if (!commitSha)
|
|
23
|
+
throw new Error("Smart Deploy could not resolve the current Git commit.");
|
|
24
|
+
return {
|
|
25
|
+
rootDirectory,
|
|
26
|
+
repoUrl: normalizeGitHubRemote(remote),
|
|
27
|
+
branch,
|
|
28
|
+
commitSha,
|
|
29
|
+
isWorkingTreeClean: !workingTreeStatus,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (error instanceof Error && error.message.startsWith("Smart Deploy"))
|
|
34
|
+
throw error;
|
|
35
|
+
throw new Error("Smart Deploy must run inside a Git repository with an origin remote.");
|
|
36
|
+
}
|
|
37
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { cwd, stdin, stdout } from "node:process";
|
|
3
|
+
import { createInterface } from "node:readline/promises";
|
|
4
|
+
import { normalizeGitHubRemote, readGitRepositoryContext } from "./git.js";
|
|
5
|
+
import { readProjectState, writeProjectState } from "./state.js";
|
|
6
|
+
import { login, logout } from "./auth.js";
|
|
7
|
+
function parseArguments(args) {
|
|
8
|
+
const positionals = [];
|
|
9
|
+
const options = new Map();
|
|
10
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
11
|
+
const current = args[index];
|
|
12
|
+
if (!current.startsWith("--")) {
|
|
13
|
+
positionals.push(current);
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
const [name, inlineValue] = current.slice(2).split("=", 2);
|
|
17
|
+
const nextValue = inlineValue ?? args[index + 1];
|
|
18
|
+
if (!name || !nextValue || nextValue.startsWith("--")) {
|
|
19
|
+
throw new Error(`Option --${name || current} requires a value.`);
|
|
20
|
+
}
|
|
21
|
+
options.set(name, nextValue);
|
|
22
|
+
if (inlineValue === undefined)
|
|
23
|
+
index += 1;
|
|
24
|
+
}
|
|
25
|
+
return { positionals, options };
|
|
26
|
+
}
|
|
27
|
+
async function ask(question, defaultValue) {
|
|
28
|
+
const prompt = createInterface({ input: stdin, output: stdout });
|
|
29
|
+
try {
|
|
30
|
+
const answer = (await prompt.question(`${question} [${defaultValue}]: `)).trim();
|
|
31
|
+
return answer || defaultValue;
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
prompt.close();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function printHelp() {
|
|
38
|
+
console.log(`Smart Deploy CLI
|
|
39
|
+
|
|
40
|
+
Usage:
|
|
41
|
+
smart-deploy init [--repo URL] [--branch NAME] [--service NAME]
|
|
42
|
+
smart-deploy login | logout
|
|
43
|
+
smart-deploy repo show
|
|
44
|
+
smart-deploy repo use URL [--branch NAME]
|
|
45
|
+
smart-deploy service select NAME
|
|
46
|
+
smart-deploy config show
|
|
47
|
+
|
|
48
|
+
The current foundation stores only repository, branch, and service selection locally. Analysis, domain, secrets, and deployment commands follow in the next slice.`);
|
|
49
|
+
}
|
|
50
|
+
async function initialize(options) {
|
|
51
|
+
const context = readGitRepositoryContext(cwd());
|
|
52
|
+
const repoUrl = normalizeGitHubRemote(options.get("repo") ?? await ask("Repository URL", context.repoUrl));
|
|
53
|
+
const branch = options.get("branch") ?? await ask("Branch", context.branch);
|
|
54
|
+
const serviceName = options.get("service") ?? null;
|
|
55
|
+
const state = { version: 1, repoUrl, branch, serviceName };
|
|
56
|
+
await writeProjectState(context.rootDirectory, state);
|
|
57
|
+
console.log(`Saved Smart Deploy project selection in ${context.rootDirectory}\\.smartdeploy\\state.json`);
|
|
58
|
+
console.log(`Repository: ${repoUrl}\nBranch: ${branch}\nService: ${serviceName ?? "not selected"}`);
|
|
59
|
+
if (!context.isWorkingTreeClean) {
|
|
60
|
+
console.log("\nNote: your working tree has uncommitted changes. Analysis and deploy will require a pushed commit.");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async function showConfig() {
|
|
64
|
+
const context = readGitRepositoryContext(cwd());
|
|
65
|
+
const state = await readProjectState(context.rootDirectory);
|
|
66
|
+
if (!state)
|
|
67
|
+
throw new Error("No Smart Deploy project selection found. Run smart-deploy init first.");
|
|
68
|
+
console.log(JSON.stringify(state, null, 2));
|
|
69
|
+
}
|
|
70
|
+
async function selectRepository(positionals, options) {
|
|
71
|
+
const repoUrlInput = positionals[2]?.trim();
|
|
72
|
+
if (!repoUrlInput)
|
|
73
|
+
throw new Error("Usage: smart-deploy repo use <repository-url> [--branch NAME]");
|
|
74
|
+
const repoUrl = normalizeGitHubRemote(repoUrlInput);
|
|
75
|
+
const context = readGitRepositoryContext(cwd());
|
|
76
|
+
const current = await readProjectState(context.rootDirectory);
|
|
77
|
+
const branch = options.get("branch") ?? current?.branch ?? context.branch;
|
|
78
|
+
await writeProjectState(context.rootDirectory, { version: 1, repoUrl, branch, serviceName: null });
|
|
79
|
+
console.log(`Selected repository ${repoUrl} on ${branch}. Choose a service with smart-deploy service select <name>.`);
|
|
80
|
+
}
|
|
81
|
+
async function selectService(positionals) {
|
|
82
|
+
const serviceName = positionals[2]?.trim();
|
|
83
|
+
if (!serviceName)
|
|
84
|
+
throw new Error("Usage: smart-deploy service select <name>");
|
|
85
|
+
const context = readGitRepositoryContext(cwd());
|
|
86
|
+
const current = await readProjectState(context.rootDirectory);
|
|
87
|
+
if (!current)
|
|
88
|
+
throw new Error("No Smart Deploy project selection found. Run smart-deploy init first.");
|
|
89
|
+
await writeProjectState(context.rootDirectory, { ...current, serviceName });
|
|
90
|
+
console.log(`Selected service ${serviceName}.`);
|
|
91
|
+
}
|
|
92
|
+
async function main() {
|
|
93
|
+
const { positionals, options } = parseArguments(process.argv.slice(2));
|
|
94
|
+
const [command, subcommand] = positionals;
|
|
95
|
+
if (!command || command === "help" || command === "--help")
|
|
96
|
+
return printHelp();
|
|
97
|
+
if (command === "login")
|
|
98
|
+
return login();
|
|
99
|
+
if (command === "logout")
|
|
100
|
+
return logout();
|
|
101
|
+
if (command === "init")
|
|
102
|
+
return initialize(options);
|
|
103
|
+
if (command === "config" && subcommand === "show")
|
|
104
|
+
return showConfig();
|
|
105
|
+
if (command === "repo" && subcommand === "show")
|
|
106
|
+
return showConfig();
|
|
107
|
+
if (command === "repo" && subcommand === "use")
|
|
108
|
+
return selectRepository(positionals, options);
|
|
109
|
+
if (command === "service" && subcommand === "select")
|
|
110
|
+
return selectService(positionals);
|
|
111
|
+
throw new Error(`Unknown command: ${positionals.join(" ")}`);
|
|
112
|
+
}
|
|
113
|
+
void main().catch((error) => {
|
|
114
|
+
console.error(error instanceof Error ? error.message : "Smart Deploy command failed.");
|
|
115
|
+
process.exitCode = 1;
|
|
116
|
+
});
|
package/dist/state.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare const PROJECT_STATE_DIRECTORY = ".smartdeploy";
|
|
2
|
+
export declare const PROJECT_STATE_FILE = "state.json";
|
|
3
|
+
export type ProjectState = {
|
|
4
|
+
version: 1;
|
|
5
|
+
repoUrl: string;
|
|
6
|
+
branch: string;
|
|
7
|
+
serviceName: string | null;
|
|
8
|
+
};
|
|
9
|
+
export declare function getProjectStatePath(workdir: string): string;
|
|
10
|
+
export declare function readProjectState(workdir: string): Promise<ProjectState | null>;
|
|
11
|
+
export declare function writeProjectState(workdir: string, state: ProjectState): Promise<void>;
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
export const PROJECT_STATE_DIRECTORY = ".smartdeploy";
|
|
4
|
+
export const PROJECT_STATE_FILE = "state.json";
|
|
5
|
+
function asNonEmptyString(value) {
|
|
6
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
7
|
+
}
|
|
8
|
+
function parseProjectState(value) {
|
|
9
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
10
|
+
throw new Error("Smart Deploy state must be an object.");
|
|
11
|
+
}
|
|
12
|
+
const state = value;
|
|
13
|
+
const repoUrl = asNonEmptyString(state.repoUrl);
|
|
14
|
+
const branch = asNonEmptyString(state.branch);
|
|
15
|
+
const serviceName = state.serviceName === null ? null : asNonEmptyString(state.serviceName);
|
|
16
|
+
if (state.version !== 1 || !repoUrl || !branch || (state.serviceName !== null && !serviceName)) {
|
|
17
|
+
throw new Error("Smart Deploy state is invalid. Run `smart-deploy init` again.");
|
|
18
|
+
}
|
|
19
|
+
return { version: 1, repoUrl, branch, serviceName };
|
|
20
|
+
}
|
|
21
|
+
export function getProjectStatePath(workdir) {
|
|
22
|
+
return join(workdir, PROJECT_STATE_DIRECTORY, PROJECT_STATE_FILE);
|
|
23
|
+
}
|
|
24
|
+
export async function readProjectState(workdir) {
|
|
25
|
+
const statePath = getProjectStatePath(workdir);
|
|
26
|
+
try {
|
|
27
|
+
const content = await readFile(statePath, "utf8");
|
|
28
|
+
return parseProjectState(JSON.parse(content));
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (error.code === "ENOENT")
|
|
32
|
+
return null;
|
|
33
|
+
if (error instanceof SyntaxError) {
|
|
34
|
+
throw new Error("Smart Deploy state is not valid JSON. Run `smart-deploy init` again.");
|
|
35
|
+
}
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export async function writeProjectState(workdir, state) {
|
|
40
|
+
const directory = join(workdir, PROJECT_STATE_DIRECTORY);
|
|
41
|
+
const statePath = getProjectStatePath(workdir);
|
|
42
|
+
const temporaryPath = `${statePath}.${process.pid}.tmp`;
|
|
43
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
44
|
+
await writeFile(temporaryPath, `${JSON.stringify(parseProjectState(state), null, 2)}\n`, {
|
|
45
|
+
encoding: "utf8",
|
|
46
|
+
mode: 0o600,
|
|
47
|
+
});
|
|
48
|
+
await rename(temporaryPath, statePath);
|
|
49
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@arm8tron/smart-deploy",
|
|
3
|
+
"version": "0.1.0-beta.1",
|
|
4
|
+
"description": "Command-line interface for Smart Deploy",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "smart-deploy": "./dist/index.js" },
|
|
7
|
+
"files": ["dist", "README.md"],
|
|
8
|
+
"publishConfig": { "access": "public", "tag": "beta" },
|
|
9
|
+
"scripts": { "build": "tsc -p tsconfig.json", "prepack": "npm run build", "pack:dry-run": "npm pack --dry-run" },
|
|
10
|
+
"engines": { "node": ">=20" },
|
|
11
|
+
"keywords": ["smart-deploy", "deployment", "cli", "aws"],
|
|
12
|
+
"license": "Apache-2.0",
|
|
13
|
+
"homepage": "https://smart-deploy.xyz",
|
|
14
|
+
"repository": { "type": "git", "url": "git+https://github.com/anirudh-makuluri/smart-deploy.git", "directory": "packages/cli" },
|
|
15
|
+
"bugs": { "url": "https://github.com/anirudh-makuluri/smart-deploy/issues" },
|
|
16
|
+
"devDependencies": { "@types/node": "^20.19.0", "typescript": "^5.0.0" }
|
|
17
|
+
}
|