hypurrcloud 0.0.0-stage → 0.1.0
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 +119 -2
- package/bin/hypurrcloud.js +304 -0
- package/lib/config.js +31 -0
- package/lib/http.js +62 -0
- package/lib/zip.js +116 -0
- package/package.json +22 -4
package/README.md
CHANGED
|
@@ -1,3 +1,120 @@
|
|
|
1
|
-
#
|
|
1
|
+
# hypurrcloud
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Command-line interface for [HypurrCloud](https://hypurrcloud-prod.mdmushtaq.online) deploys and site management.
|
|
4
|
+
|
|
5
|
+
Requires **Node.js 20+**.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g hypurrcloud
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or run without installing:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx hypurrcloud@0.1.0 --help
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Authentication
|
|
20
|
+
|
|
21
|
+
Create a personal access token in the dashboard under **Settings → API tokens** (`/settings/tokens`). Tokens use the `hc_live_` prefix.
|
|
22
|
+
|
|
23
|
+
### Interactive login
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
hypurrcloud login
|
|
27
|
+
hypurrcloud login --api https://api.your-host.example
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Credentials are saved to `~/.hypurrcloud` (JSON with `api` and `token`). The token is never printed by the CLI.
|
|
31
|
+
|
|
32
|
+
### Environment variables
|
|
33
|
+
|
|
34
|
+
| Variable | Description |
|
|
35
|
+
|----------|-------------|
|
|
36
|
+
| `HYPURRCLOUD_API` | API origin (no trailing slash), e.g. `https://api.your-host.example` |
|
|
37
|
+
| `HYPURRCLOUD_TOKEN` | Bearer token (`hc_live_…`) |
|
|
38
|
+
|
|
39
|
+
Environment variables override the config file. Use `hypurrcloud env` to see whether an API URL and token are configured (token value is not shown).
|
|
40
|
+
|
|
41
|
+
### Token scopes
|
|
42
|
+
|
|
43
|
+
| Scope | Typical CLI use |
|
|
44
|
+
|-------|-----------------|
|
|
45
|
+
| `read` | `me`, `sites list`, `projects list/get`, `deployments list/get`, `github status` |
|
|
46
|
+
| `deploy` | `deploy`, `projects deploy/dispatch`, `promote`, `rollback`, `github install` |
|
|
47
|
+
| `full` | Grants `read`, `deploy`, `sites:write`, and `domains:write` for API routes that accept those scopes |
|
|
48
|
+
|
|
49
|
+
API tokens cannot access dashboard-only routes (billing, token management, admin).
|
|
50
|
+
|
|
51
|
+
## Exit codes
|
|
52
|
+
|
|
53
|
+
| Code | Meaning |
|
|
54
|
+
|------|---------|
|
|
55
|
+
| `0` | Success |
|
|
56
|
+
| `1` | Deploy or API failure (including insufficient scope, HTTP 4xx/5xx except auth) |
|
|
57
|
+
| `2` | Usage / invalid arguments |
|
|
58
|
+
| `3` | Authentication failure (missing token, invalid, expired, or HTTP 401) |
|
|
59
|
+
|
|
60
|
+
## Commands
|
|
61
|
+
|
|
62
|
+
Run `hypurrcloud --help` for a short usage summary.
|
|
63
|
+
|
|
64
|
+
```text
|
|
65
|
+
hypurrcloud login [--api <url>]
|
|
66
|
+
hypurrcloud me [--json]
|
|
67
|
+
hypurrcloud env
|
|
68
|
+
hypurrcloud deploy <directory> --site <slug> [--project <id>] [--preview] [--include-dotfiles] [--json]
|
|
69
|
+
hypurrcloud sites list [--json]
|
|
70
|
+
hypurrcloud projects list [--json]
|
|
71
|
+
hypurrcloud projects get <projectId> [--json]
|
|
72
|
+
hypurrcloud projects deploy <projectId> [--json]
|
|
73
|
+
hypurrcloud projects dispatch <projectId> [--json]
|
|
74
|
+
hypurrcloud deployments list [--site <slug>] [--json]
|
|
75
|
+
hypurrcloud deployments get <deploymentId> [--json]
|
|
76
|
+
hypurrcloud promote <deploymentId> [--json]
|
|
77
|
+
hypurrcloud rollback <deploymentId> [--json]
|
|
78
|
+
hypurrcloud github status [--json]
|
|
79
|
+
hypurrcloud github install [--json]
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Global flag: `--api <url>` overrides the API origin for a single invocation.
|
|
83
|
+
|
|
84
|
+
### Examples
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
hypurrcloud me
|
|
88
|
+
hypurrcloud sites list --json
|
|
89
|
+
hypurrcloud deploy ./dist --site my-site --preview
|
|
90
|
+
hypurrcloud projects deploy <project-uuid>
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
PowerShell:
|
|
94
|
+
|
|
95
|
+
```powershell
|
|
96
|
+
hypurrcloud deploy .\dist --site my-site
|
|
97
|
+
$env:HYPURRCLOUD_API = "https://api.your-host.example"
|
|
98
|
+
$env:HYPURRCLOUD_TOKEN = "hc_live_..."
|
|
99
|
+
hypurrcloud me
|
|
100
|
+
echo Exit code: $LASTEXITCODE
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Standalone vs GitHub-linked sites
|
|
104
|
+
|
|
105
|
+
- **Standalone (ZIP):** `deploy ./dist --site <slug>` uploads files to `POST /api/sites/deploy`.
|
|
106
|
+
- **GitHub project:** The CLI detects a linked site and calls `projects deploy` or `projects dispatch` instead. The local directory is not uploaded for repository-backed deploys.
|
|
107
|
+
|
|
108
|
+
## JSON output
|
|
109
|
+
|
|
110
|
+
Pass `--json` on supported commands. Successful responses are written to **stdout** as JSON. With `--json`, errors are also written to stdout as `{"error":"…","details":{…}}` when the API returns a JSON body; a short message may still appear on stderr.
|
|
111
|
+
|
|
112
|
+
`hypurrcloud env` always prints JSON (`api`, `tokenConfigured`).
|
|
113
|
+
|
|
114
|
+
## GitHub Actions
|
|
115
|
+
|
|
116
|
+
See the [deploy-action README](../deploy-action/README.md) in this repository. The action runs `npx hypurrcloud@0.1.0 deploy … --json` with `HYPURRCLOUD_TOKEN` and optional `HYPURRCLOUD_API`.
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
MIT
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import readline from "node:readline/promises";
|
|
3
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { Blob } from "node:buffer";
|
|
6
|
+
import { resolveConfig, saveConfigFile, defaultApiForHost, loadConfigFile } from "../lib/config.js";
|
|
7
|
+
import { apiRequest, EXIT, finishWithExitCode } from "../lib/http.js";
|
|
8
|
+
import { walkDirectory } from "../lib/zip.js";
|
|
9
|
+
|
|
10
|
+
function parseFlags(argv) {
|
|
11
|
+
const flags = { json: false, preview: false, includeDotfiles: false, site: "", project: "", api: "", help: false };
|
|
12
|
+
const positional = [];
|
|
13
|
+
for (let i = 0; i < argv.length; i++) {
|
|
14
|
+
const arg = argv[i];
|
|
15
|
+
if (arg === "--help" || arg === "-h") flags.help = true;
|
|
16
|
+
else if (arg === "--json") flags.json = true;
|
|
17
|
+
else if (arg === "--preview") flags.preview = true;
|
|
18
|
+
else if (arg === "--include-dotfiles") flags.includeDotfiles = true;
|
|
19
|
+
else if (arg === "--site") flags.site = argv[++i] || "";
|
|
20
|
+
else if (arg === "--project") flags.project = argv[++i] || "";
|
|
21
|
+
else if (arg === "--api") flags.api = argv[++i] || "";
|
|
22
|
+
else positional.push(arg);
|
|
23
|
+
}
|
|
24
|
+
return { flags, positional };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function printJson(value) {
|
|
28
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function usage() {
|
|
32
|
+
process.stderr.write(`Usage:
|
|
33
|
+
hypurrcloud [--help]
|
|
34
|
+
hypurrcloud login [--api <url>]
|
|
35
|
+
hypurrcloud me [--json]
|
|
36
|
+
hypurrcloud env
|
|
37
|
+
hypurrcloud deploy <directory> --site <slug> [--project <id>] [--preview] [--include-dotfiles] [--json]
|
|
38
|
+
hypurrcloud sites list [--json]
|
|
39
|
+
hypurrcloud projects list [--json]
|
|
40
|
+
hypurrcloud projects get <projectId> [--json]
|
|
41
|
+
hypurrcloud projects deploy <projectId> [--json]
|
|
42
|
+
hypurrcloud projects dispatch <projectId> [--json]
|
|
43
|
+
hypurrcloud deployments list [--site <slug>] [--json]
|
|
44
|
+
hypurrcloud deployments get <deploymentId> [--json]
|
|
45
|
+
hypurrcloud promote <deploymentId> [--json]
|
|
46
|
+
hypurrcloud rollback <deploymentId> [--json]
|
|
47
|
+
hypurrcloud github status [--json]
|
|
48
|
+
hypurrcloud github install [--json]
|
|
49
|
+
`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function cmdLogin(flags) {
|
|
53
|
+
const file = loadConfigFile();
|
|
54
|
+
const api = flags.api || process.env.HYPURRCLOUD_API || file.api || defaultApiForHost();
|
|
55
|
+
const rl = readline.createInterface({ input, output });
|
|
56
|
+
const apiAnswer = (await rl.question(`API base URL [${api}]: `)).trim();
|
|
57
|
+
const chosenApi = (apiAnswer || api).replace(/\/$/, "");
|
|
58
|
+
process.stdout.write("Paste your API token (from Settings → API tokens): ");
|
|
59
|
+
const token = (await rl.question("")).trim();
|
|
60
|
+
rl.close();
|
|
61
|
+
if (!token.startsWith("hc_live_")) {
|
|
62
|
+
throw Object.assign(new Error("Token must start with hc_live_."), { code: EXIT.USAGE });
|
|
63
|
+
}
|
|
64
|
+
saveConfigFile({ api: chosenApi, token });
|
|
65
|
+
process.stdout.write(`Saved credentials to ~/.hypurrcloud\n`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function cmdMe(config, flags) {
|
|
69
|
+
const data = await apiRequest(config, "/api/me");
|
|
70
|
+
if (flags.json) printJson(data);
|
|
71
|
+
else process.stdout.write(`${data.email} (${data.plan})\n`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function cmdEnv(config) {
|
|
75
|
+
printJson({ api: config.api, tokenConfigured: Boolean(config.token) });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function findProjectForSite(config, siteSlug, projectIdFlag) {
|
|
79
|
+
if (projectIdFlag) {
|
|
80
|
+
const data = await apiRequest(config, `/api/projects/${encodeURIComponent(projectIdFlag)}`);
|
|
81
|
+
return data.project;
|
|
82
|
+
}
|
|
83
|
+
const sitesData = await apiRequest(config, "/api/sites");
|
|
84
|
+
const site = (sitesData.sites || []).find((s) => s.id === siteSlug);
|
|
85
|
+
if (!site || (site.source !== "github-project" && !site.projectId)) return null;
|
|
86
|
+
const projectsData = await apiRequest(config, "/api/projects");
|
|
87
|
+
return (projectsData.projects || []).find((p) => p.siteId === siteSlug) || null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function resolveDeploymentMethod(project) {
|
|
91
|
+
if (project.deploymentMethod) return project.deploymentMethod;
|
|
92
|
+
if (project.deploymentMode === "build") return "github_actions";
|
|
93
|
+
return "byo_artifact";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function cmdProjectDeploy(config, flags, projectId) {
|
|
97
|
+
if (!projectId) throw Object.assign(new Error("projects deploy requires a project id."), { code: EXIT.USAGE });
|
|
98
|
+
const project = (await apiRequest(config, `/api/projects/${encodeURIComponent(projectId)}`)).project;
|
|
99
|
+
const method = resolveDeploymentMethod(project);
|
|
100
|
+
if (method === "github_actions" || method === "byo_runner") {
|
|
101
|
+
return cmdProjectDispatch(config, flags, projectId);
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const data = await apiRequest(config, `/api/projects/${encodeURIComponent(projectId)}/deploy`, { method: "POST" });
|
|
105
|
+
if (flags.json) printJson(data);
|
|
106
|
+
else process.stdout.write(`Deploy queued: ${data.deploymentUrl || data.deployment?.deploymentUrl || project.siteId}\n`);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
error.code = error.code ?? EXIT.DEPLOY_FAILED;
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function cmdProjectDispatch(config, flags, projectId) {
|
|
114
|
+
if (!projectId) throw Object.assign(new Error("projects dispatch requires a project id."), { code: EXIT.USAGE });
|
|
115
|
+
try {
|
|
116
|
+
const data = await apiRequest(config, `/api/projects/${encodeURIComponent(projectId)}/workflow/dispatch`, { method: "POST" });
|
|
117
|
+
if (flags.json) printJson(data);
|
|
118
|
+
else process.stdout.write(`Workflow dispatch queued: ${data.deployment?.id || "ok"}\n`);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
error.code = error.code ?? EXIT.DEPLOY_FAILED;
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function cmdDeploy(config, flags, directory) {
|
|
126
|
+
if (!flags.site) {
|
|
127
|
+
throw Object.assign(new Error("deploy requires a directory and --site <slug>."), { code: EXIT.USAGE });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const project = await findProjectForSite(config, flags.site, flags.project);
|
|
131
|
+
if (project) {
|
|
132
|
+
const method = resolveDeploymentMethod(project);
|
|
133
|
+
if (directory && !flags.json) {
|
|
134
|
+
process.stderr.write(`Note: GitHub project deploy uses the connected repository; local directory "${directory}" is not uploaded.\n`);
|
|
135
|
+
}
|
|
136
|
+
if (method === "github_actions" || method === "byo_runner") {
|
|
137
|
+
return cmdProjectDispatch(config, flags, project.id);
|
|
138
|
+
}
|
|
139
|
+
return cmdProjectDeploy(config, flags, project.id);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!directory) {
|
|
143
|
+
throw Object.assign(new Error("deploy requires a directory and --site <slug>."), { code: EXIT.USAGE });
|
|
144
|
+
}
|
|
145
|
+
const root = path.resolve(directory);
|
|
146
|
+
const files = walkDirectory(root, flags.includeDotfiles);
|
|
147
|
+
if (!files.length) {
|
|
148
|
+
throw Object.assign(new Error("No files to deploy after ignore rules."), { code: EXIT.USAGE });
|
|
149
|
+
}
|
|
150
|
+
const uploadForm = new FormData();
|
|
151
|
+
uploadForm.set("metadata", JSON.stringify({
|
|
152
|
+
siteName: flags.site,
|
|
153
|
+
siteId: flags.site,
|
|
154
|
+
source: "zip",
|
|
155
|
+
...(flags.preview ? { preview: true } : {}),
|
|
156
|
+
}));
|
|
157
|
+
for (const entry of files) {
|
|
158
|
+
const content = await import("node:fs/promises").then((m) => m.readFile(entry.abs));
|
|
159
|
+
uploadForm.append("files", new Blob([content]), entry.rel);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
const data = await apiRequest(config, "/api/sites/deploy", { method: "POST", body: uploadForm });
|
|
164
|
+
if (flags.json) printJson(data);
|
|
165
|
+
else {
|
|
166
|
+
const url = data.previewUrl || data.deploymentUrl;
|
|
167
|
+
process.stdout.write(`Deploy OK: ${url}\n`);
|
|
168
|
+
}
|
|
169
|
+
} catch (error) {
|
|
170
|
+
error.code = error.code ?? EXIT.DEPLOY_FAILED;
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function cmdSitesList(config, flags) {
|
|
176
|
+
const data = await apiRequest(config, "/api/sites");
|
|
177
|
+
if (flags.json) printJson(data);
|
|
178
|
+
else {
|
|
179
|
+
for (const site of data.sites || []) {
|
|
180
|
+
process.stdout.write(`${site.id}\t${site.name}\t${site.source || ""}\t${site.url}\n`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function cmdProjectsList(config, flags) {
|
|
186
|
+
const data = await apiRequest(config, "/api/projects");
|
|
187
|
+
if (flags.json) printJson(data);
|
|
188
|
+
else {
|
|
189
|
+
for (const p of data.projects || []) {
|
|
190
|
+
process.stdout.write(`${p.id}\t${p.siteId}\t${p.name}\t${p.repositoryFullName}\t${p.status}\n`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function cmdProjectsGet(config, flags, projectId) {
|
|
196
|
+
if (!projectId) throw Object.assign(new Error("projects get requires a project id."), { code: EXIT.USAGE });
|
|
197
|
+
const data = await apiRequest(config, `/api/projects/${encodeURIComponent(projectId)}`);
|
|
198
|
+
if (flags.json) printJson(data);
|
|
199
|
+
else {
|
|
200
|
+
const p = data.project;
|
|
201
|
+
process.stdout.write(`${p.id}\t${p.siteId}\t${p.name}\t${p.repositoryFullName}\t${resolveDeploymentMethod(p)}\n`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function cmdDeploymentsList(config, flags) {
|
|
206
|
+
const data = await apiRequest(config, "/api/deployments");
|
|
207
|
+
let deployments = data.deployments || [];
|
|
208
|
+
if (flags.site) deployments = deployments.filter((d) => d.siteId === flags.site);
|
|
209
|
+
if (flags.json) printJson({ deployments });
|
|
210
|
+
else {
|
|
211
|
+
for (const d of deployments) {
|
|
212
|
+
process.stdout.write(`${d.id}\t${d.siteId}\t${d.status}\t${d.previewUrl || d.deploymentUrl || ""}\n`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function cmdDeploymentsGet(config, flags, deploymentId) {
|
|
218
|
+
if (!deploymentId) throw Object.assign(new Error("deployments get requires a deployment id."), { code: EXIT.USAGE });
|
|
219
|
+
const data = await apiRequest(config, `/api/deployments/${encodeURIComponent(deploymentId)}`);
|
|
220
|
+
if (flags.json) printJson(data);
|
|
221
|
+
else {
|
|
222
|
+
const d = data.deployment;
|
|
223
|
+
process.stdout.write(`${d.id}\t${d.siteId}\t${d.status}\t${d.previewUrl || d.deploymentUrl || ""}\n`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function cmdPromote(config, flags, deploymentId) {
|
|
228
|
+
if (!deploymentId) throw Object.assign(new Error("promote requires a deployment id."), { code: EXIT.USAGE });
|
|
229
|
+
const data = await apiRequest(config, `/api/deployments/${encodeURIComponent(deploymentId)}/promote`, { method: "POST" });
|
|
230
|
+
if (flags.json) printJson(data);
|
|
231
|
+
else process.stdout.write("Promoted to production.\n");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function cmdRollback(config, flags, deploymentId) {
|
|
235
|
+
if (!deploymentId) throw Object.assign(new Error("rollback requires a deployment id."), { code: EXIT.USAGE });
|
|
236
|
+
const data = await apiRequest(config, `/api/deployments/${encodeURIComponent(deploymentId)}/rollback`, { method: "POST" });
|
|
237
|
+
if (flags.json) printJson(data);
|
|
238
|
+
else process.stdout.write("Rollback deployment queued.\n");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function cmdGithubStatus(config, flags) {
|
|
242
|
+
const data = await apiRequest(config, "/api/github/status");
|
|
243
|
+
if (flags.json) printJson(data);
|
|
244
|
+
else process.stdout.write(data.connected ? `GitHub connected (installation ${data.installationId})\n` : "GitHub not connected\n");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function cmdGithubInstall(config, flags) {
|
|
248
|
+
const data = await apiRequest(config, "/api/github/install");
|
|
249
|
+
if (flags.json) printJson(data);
|
|
250
|
+
else process.stdout.write(`Open this URL in your browser to connect GitHub:\n${data.url}\n`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function main() {
|
|
254
|
+
const argv = process.argv.slice(2);
|
|
255
|
+
if (!argv.length) {
|
|
256
|
+
usage();
|
|
257
|
+
process.exit(EXIT.USAGE);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const { flags, positional } = parseFlags(argv);
|
|
261
|
+
if (flags.help || (positional.length === 1 && positional[0] === "help")) {
|
|
262
|
+
usage();
|
|
263
|
+
await finishWithExitCode(EXIT.OK);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const config = resolveConfig();
|
|
268
|
+
if (flags.api) config.api = flags.api.replace(/\/$/, "");
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
const [command, sub, ...rest] = positional;
|
|
272
|
+
if (command === "login") await cmdLogin(flags);
|
|
273
|
+
else if (command === "me") await cmdMe(config, flags);
|
|
274
|
+
else if (command === "env") await cmdEnv(config);
|
|
275
|
+
else if (command === "deploy") await cmdDeploy(config, flags, sub);
|
|
276
|
+
else if (command === "sites" && sub === "list") await cmdSitesList(config, flags);
|
|
277
|
+
else if (command === "projects" && sub === "list") await cmdProjectsList(config, flags);
|
|
278
|
+
else if (command === "projects" && sub === "get") await cmdProjectsGet(config, flags, rest[0]);
|
|
279
|
+
else if (command === "projects" && sub === "deploy") await cmdProjectDeploy(config, flags, rest[0]);
|
|
280
|
+
else if (command === "projects" && sub === "dispatch") await cmdProjectDispatch(config, flags, rest[0]);
|
|
281
|
+
else if (command === "deployments" && sub === "list") await cmdDeploymentsList(config, flags);
|
|
282
|
+
else if (command === "deployments" && sub === "get") await cmdDeploymentsGet(config, flags, rest[0]);
|
|
283
|
+
else if (command === "promote") await cmdPromote(config, flags, sub);
|
|
284
|
+
else if (command === "rollback") await cmdRollback(config, flags, sub);
|
|
285
|
+
else if (command === "github" && sub === "status") await cmdGithubStatus(config, flags);
|
|
286
|
+
else if (command === "github" && sub === "install") await cmdGithubInstall(config, flags);
|
|
287
|
+
else {
|
|
288
|
+
usage();
|
|
289
|
+
process.exitCode = EXIT.USAGE;
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
await finishWithExitCode(EXIT.OK);
|
|
293
|
+
} catch (error) {
|
|
294
|
+
const code = error.code ?? (error.status === 401 ? EXIT.AUTH : EXIT.DEPLOY_FAILED);
|
|
295
|
+
process.stderr.write(`${error.message}\n`);
|
|
296
|
+
if (flags.json) printJson({ error: error.message, ...(error.body ? { details: error.body } : {}) });
|
|
297
|
+
await finishWithExitCode(code);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
main().catch(async (error) => {
|
|
302
|
+
process.stderr.write(`${error.message}\n`);
|
|
303
|
+
await finishWithExitCode(EXIT.DEPLOY_FAILED);
|
|
304
|
+
});
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export const CONFIG_PATH = path.join(os.homedir(), ".hypurrcloud");
|
|
6
|
+
|
|
7
|
+
export function loadConfigFile() {
|
|
8
|
+
try {
|
|
9
|
+
const raw = fs.readFileSync(CONFIG_PATH, "utf8");
|
|
10
|
+
return JSON.parse(raw);
|
|
11
|
+
} catch {
|
|
12
|
+
return {};
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function saveConfigFile(config) {
|
|
17
|
+
fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
|
|
18
|
+
fs.writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function resolveConfig() {
|
|
22
|
+
const file = loadConfigFile();
|
|
23
|
+
const api = (process.env.HYPURRCLOUD_API || file.api || "").replace(/\/$/, "");
|
|
24
|
+
const token = process.env.HYPURRCLOUD_TOKEN || file.token || "";
|
|
25
|
+
return { api, token };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Default must match Worker API_PUBLIC_ORIGIN or api.${SITE_BASE_DOMAIN} in wrangler.toml */
|
|
29
|
+
export function defaultApiForHost(siteBase = "hypurrcloud-prod.mdmushtaq.online") {
|
|
30
|
+
return `https://api.${siteBase}`;
|
|
31
|
+
}
|
package/lib/http.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export const EXIT = {
|
|
2
|
+
OK: 0,
|
|
3
|
+
DEPLOY_FAILED: 1,
|
|
4
|
+
USAGE: 2,
|
|
5
|
+
AUTH: 3,
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
/** Release undici keep-alive sockets so the CLI can exit without racing process.exit on Windows. */
|
|
9
|
+
export async function closeHttpSession() {
|
|
10
|
+
try {
|
|
11
|
+
const { getGlobalDispatcher } = await import("node:undici");
|
|
12
|
+
await getGlobalDispatcher().close();
|
|
13
|
+
} catch {
|
|
14
|
+
// node:undici unavailable — natural exit is sufficient
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function finishWithExitCode(code) {
|
|
19
|
+
process.exitCode = code;
|
|
20
|
+
await closeHttpSession();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function apiRequest(config, pathname, options = {}) {
|
|
24
|
+
if (!config.api) {
|
|
25
|
+
throw Object.assign(new Error("HYPURRCLOUD_API is not set. Run hypurrcloud login or set HYPURRCLOUD_API."), { code: EXIT.USAGE });
|
|
26
|
+
}
|
|
27
|
+
if (!config.token) {
|
|
28
|
+
throw Object.assign(new Error("HYPURRCLOUD_TOKEN is not set. Run hypurrcloud login or set HYPURRCLOUD_TOKEN."), { code: EXIT.AUTH });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const url = `${config.api}${pathname.startsWith("/") ? pathname : `/${pathname}`}`;
|
|
32
|
+
const headers = {
|
|
33
|
+
Authorization: `Bearer ${config.token}`,
|
|
34
|
+
Connection: "close",
|
|
35
|
+
...(options.headers || {}),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const response = await fetch(url, { ...options, headers, keepalive: false });
|
|
39
|
+
const text = await response.text();
|
|
40
|
+
let data;
|
|
41
|
+
try {
|
|
42
|
+
data = text ? JSON.parse(text) : {};
|
|
43
|
+
} catch {
|
|
44
|
+
data = { error: text || response.statusText };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (response.status === 401) {
|
|
48
|
+
const err = new Error(data.error === "token_expired" ? "API token expired." : (data.error || "Authentication failed."));
|
|
49
|
+
err.code = EXIT.AUTH;
|
|
50
|
+
err.body = data;
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!response.ok) {
|
|
55
|
+
const err = new Error(data.error || `Request failed (${response.status}).`);
|
|
56
|
+
err.status = response.status;
|
|
57
|
+
err.body = data;
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return data;
|
|
62
|
+
}
|
package/lib/zip.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { Buffer } from "node:buffer";
|
|
4
|
+
import { deflateRawSync } from "node:zlib";
|
|
5
|
+
|
|
6
|
+
const SKIP_DIRS = new Set(["node_modules", ".git"]);
|
|
7
|
+
|
|
8
|
+
export function shouldSkipEntry(relativePath, includeDotfiles) {
|
|
9
|
+
const parts = relativePath.split(/[/\\]/);
|
|
10
|
+
for (const part of parts) {
|
|
11
|
+
if (!includeDotfiles && part.startsWith(".") && part !== "." && part !== "..") return true;
|
|
12
|
+
if (SKIP_DIRS.has(part)) return true;
|
|
13
|
+
}
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function walkDirectory(rootDir, includeDotfiles = false) {
|
|
18
|
+
const files = [];
|
|
19
|
+
function walk(current, prefix = "") {
|
|
20
|
+
for (const name of fs.readdirSync(current)) {
|
|
21
|
+
const abs = path.join(current, name);
|
|
22
|
+
const rel = prefix ? `${prefix}/${name}` : name;
|
|
23
|
+
if (shouldSkipEntry(rel, includeDotfiles)) continue;
|
|
24
|
+
const stat = fs.statSync(abs);
|
|
25
|
+
if (stat.isDirectory()) walk(abs, rel);
|
|
26
|
+
else files.push({ rel: rel.replace(/\\/g, "/"), abs });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
walk(rootDir);
|
|
30
|
+
return files;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function crc32(buf) {
|
|
34
|
+
let crc = 0xffffffff;
|
|
35
|
+
for (let i = 0; i < buf.length; i++) {
|
|
36
|
+
crc ^= buf[i];
|
|
37
|
+
for (let j = 0; j < 8; j++) crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
|
|
38
|
+
}
|
|
39
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function u16(n) {
|
|
43
|
+
const b = Buffer.alloc(2);
|
|
44
|
+
b.writeUInt16LE(n, 0);
|
|
45
|
+
return b;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function u32(n) {
|
|
49
|
+
const b = Buffer.alloc(4);
|
|
50
|
+
b.writeUInt32LE(n, 0);
|
|
51
|
+
return b;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function buildZipBuffer(fileEntries) {
|
|
55
|
+
const parts = [];
|
|
56
|
+
const central = [];
|
|
57
|
+
let offset = 0;
|
|
58
|
+
|
|
59
|
+
for (const { rel, abs } of fileEntries) {
|
|
60
|
+
const data = fs.readFileSync(abs);
|
|
61
|
+
const name = Buffer.from(rel, "utf8");
|
|
62
|
+
const compressed = deflateRawSync(data);
|
|
63
|
+
const local = Buffer.concat([
|
|
64
|
+
u32(0x04034b50),
|
|
65
|
+
u16(20),
|
|
66
|
+
u16(0),
|
|
67
|
+
u16(8),
|
|
68
|
+
u16(0),
|
|
69
|
+
u16(0),
|
|
70
|
+
u32(crc32(data)),
|
|
71
|
+
u32(compressed.length),
|
|
72
|
+
u32(data.length),
|
|
73
|
+
u16(name.length),
|
|
74
|
+
u16(0),
|
|
75
|
+
name,
|
|
76
|
+
compressed,
|
|
77
|
+
]);
|
|
78
|
+
parts.push(local);
|
|
79
|
+
const centralHeader = Buffer.concat([
|
|
80
|
+
u32(0x02014b50),
|
|
81
|
+
u16(20),
|
|
82
|
+
u16(20),
|
|
83
|
+
u16(0),
|
|
84
|
+
u16(8),
|
|
85
|
+
u16(0),
|
|
86
|
+
u16(0),
|
|
87
|
+
u32(crc32(data)),
|
|
88
|
+
u32(compressed.length),
|
|
89
|
+
u32(data.length),
|
|
90
|
+
u16(name.length),
|
|
91
|
+
u16(0),
|
|
92
|
+
u16(0),
|
|
93
|
+
u16(0),
|
|
94
|
+
u16(0),
|
|
95
|
+
u32(0),
|
|
96
|
+
u32(offset),
|
|
97
|
+
name,
|
|
98
|
+
]);
|
|
99
|
+
central.push(centralHeader);
|
|
100
|
+
offset += local.length;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const centralDir = Buffer.concat(central);
|
|
104
|
+
const end = Buffer.concat([
|
|
105
|
+
u32(0x06054b50),
|
|
106
|
+
u16(0),
|
|
107
|
+
u16(0),
|
|
108
|
+
u16(fileEntries.length),
|
|
109
|
+
u16(fileEntries.length),
|
|
110
|
+
u32(centralDir.length),
|
|
111
|
+
u32(offset),
|
|
112
|
+
u16(0),
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
return Buffer.concat([...parts, centralDir, end]);
|
|
116
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hypurrcloud",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "HypurrCloud command-line interface for deploys and site management",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=20"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"hypurrcloud": "bin/hypurrcloud.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"README.md",
|
|
15
|
+
"bin",
|
|
16
|
+
"lib"
|
|
17
|
+
],
|
|
18
|
+
"keywords": [
|
|
19
|
+
"hypurrcloud",
|
|
20
|
+
"deploy",
|
|
21
|
+
"static-site",
|
|
22
|
+
"cli"
|
|
23
|
+
]
|
|
24
|
+
}
|