relight-cli 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 +199 -0
- package/package.json +27 -0
- package/src/cli.js +32 -0
- package/src/commands/auth.js +302 -0
- package/src/commands/doctor.js +197 -0
- package/src/lib/clouds/aws.js +223 -0
- package/src/lib/clouds/cf.js +65 -0
- package/src/lib/clouds/gcp.js +120 -0
- package/src/lib/config.js +58 -0
- package/src/lib/link.js +40 -0
- package/src/lib/output.js +103 -0
package/README.md
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# Relight
|
|
2
|
+
|
|
3
|
+
Deploy Docker containers to your cloud with scale-to-zero. Apps sleep when idle and wake on the next request.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
$ relight deploy myapp . --compute gcp
|
|
7
|
+
Building image...
|
|
8
|
+
Pushing to gcr.io/my-project/myapp:v1...
|
|
9
|
+
Deploying to Cloud Run (us-east1)...
|
|
10
|
+
|
|
11
|
+
--> Live at https://myapp-abc123.run.app
|
|
12
|
+
Sleeps after 30s idle. $0 when sleeping.
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## What this is
|
|
16
|
+
|
|
17
|
+
Relight is a CLI that deploys and manages Docker containers across multiple cloud providers. It talks directly to each cloud's API using your own credentials. No vendor infrastructure gets installed in your account.
|
|
18
|
+
|
|
19
|
+
Supported compute backends:
|
|
20
|
+
|
|
21
|
+
| Cloud | Backend | Scale to zero |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| GCP | Cloud Run | Yes |
|
|
24
|
+
| AWS | App Runner | Yes |
|
|
25
|
+
| Cloudflare | Containers (Workers + Durable Objects) | Yes |
|
|
26
|
+
|
|
27
|
+
More backends planned: Azure Container Apps.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
npm install -g relight
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Requires Node.js 20+ and Docker.
|
|
36
|
+
|
|
37
|
+
## Quick start
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
# Authenticate with a cloud provider
|
|
41
|
+
relight auth --compute gcp
|
|
42
|
+
|
|
43
|
+
# Deploy from a Dockerfile
|
|
44
|
+
relight deploy myapp .
|
|
45
|
+
|
|
46
|
+
# Check your apps
|
|
47
|
+
relight apps
|
|
48
|
+
|
|
49
|
+
# Stream logs
|
|
50
|
+
relight logs myapp
|
|
51
|
+
|
|
52
|
+
# Open in browser
|
|
53
|
+
relight open myapp
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The first deploy links the current directory to the app name. After that, `relight deploy` is enough.
|
|
57
|
+
|
|
58
|
+
## Commands
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
relight auth Authenticate with a cloud provider
|
|
62
|
+
relight deploy [name] [path] Deploy an app from a Dockerfile
|
|
63
|
+
relight apps List all deployed apps across all clouds
|
|
64
|
+
relight apps info [name] Show detailed app info
|
|
65
|
+
relight apps destroy [name] Destroy an app and its cloud resources
|
|
66
|
+
relight ps [name] Show running containers and resource usage
|
|
67
|
+
relight logs [name] Stream live logs
|
|
68
|
+
relight open [name] Open app URL in browser
|
|
69
|
+
relight config show [name] Show environment variables
|
|
70
|
+
relight config set KEY=VALUE Set env vars (applied live, no redeploy)
|
|
71
|
+
relight config unset KEY Remove an env var
|
|
72
|
+
relight config import -f .env Import from .env file
|
|
73
|
+
relight scale [name] Show or adjust instance count and resources
|
|
74
|
+
relight domains list [name] List custom domains
|
|
75
|
+
relight domains add [domain] Add a custom domain with DNS setup
|
|
76
|
+
relight domains remove [domain] Remove a custom domain
|
|
77
|
+
relight regions [--compute <cloud>] List available regions for a cloud
|
|
78
|
+
relight cost [name] Show estimated costs
|
|
79
|
+
relight doctor Check local setup and cloud connectivity
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Config changes (`config set`, `config unset`, `scale`, `domains`) are applied live without redeploying the container image.
|
|
83
|
+
|
|
84
|
+
## How it works
|
|
85
|
+
|
|
86
|
+
1. `relight auth` stores a scoped API token locally at `~/.relight/config.json`. One token per cloud provider. No cross-account IAM roles, no OAuth flows, no vendor access to your account.
|
|
87
|
+
|
|
88
|
+
2. `relight deploy` builds a Docker image locally, pushes it to the cloud's container registry (GCR, Cloudflare Registry, ECR), and deploys it using the cloud's native container service.
|
|
89
|
+
|
|
90
|
+
3. The deployed app sleeps after a configurable idle period (default 30s). The next incoming request wakes it. Cold starts are typically 1-5 seconds depending on the cloud and image size.
|
|
91
|
+
|
|
92
|
+
4. All app state (config, scaling, domains) lives in the cloud provider's API. The only local files are your auth token and a `.relight` link file in your project directory. You can manage your apps from any machine.
|
|
93
|
+
|
|
94
|
+
## Fleet view
|
|
95
|
+
|
|
96
|
+
When you authenticate with multiple clouds, `relight apps` shows everything in one table:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
$ relight apps
|
|
100
|
+
|
|
101
|
+
NAME CLOUD STATUS INSTANCES COST/MTD LAST ACTIVE
|
|
102
|
+
myapi gcp sleeping 0/3 $0.12 2h ago
|
|
103
|
+
frontend cf active 2/5 $1.84 now
|
|
104
|
+
dashboard gcp sleeping 0/1 $0.00 3d ago
|
|
105
|
+
───────────────────────────────────────────────────────────────
|
|
106
|
+
TOTAL $1.96
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## BYOC model
|
|
110
|
+
|
|
111
|
+
Relight deploys to cloud accounts you own. You pay the cloud provider directly at their published rates. Relight itself is free for individual use.
|
|
112
|
+
|
|
113
|
+
What this means in practice:
|
|
114
|
+
|
|
115
|
+
- **Your credentials stay local.** The API token is stored on your machine. Relight has no backend service that holds your cloud credentials.
|
|
116
|
+
- **Nothing is installed in your account.** No Kubernetes clusters, no CloudFormation stacks, no VPCs, no agent processes. Relight uses the cloud's existing container services.
|
|
117
|
+
- **You can stop using Relight anytime.** Your apps are standard Cloud Run services / App Runner services / CF Workers. They continue running without Relight. There's nothing to uninstall.
|
|
118
|
+
|
|
119
|
+
## Scaling
|
|
120
|
+
|
|
121
|
+
```sh
|
|
122
|
+
# Set instance count
|
|
123
|
+
relight scale myapp -i 4
|
|
124
|
+
|
|
125
|
+
# Set resources
|
|
126
|
+
relight scale myapp --vcpu 1 --memory 512
|
|
127
|
+
|
|
128
|
+
# Set idle timeout
|
|
129
|
+
relight deploy myapp . --sleep-after 60
|
|
130
|
+
|
|
131
|
+
# Multi-region (cloud support varies)
|
|
132
|
+
relight deploy myapp . --regions us-east1,europe-west1
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Relight does not autoscale. You set the maximum instance count and the cloud provider handles scaling between 0 and that limit based on traffic.
|
|
136
|
+
|
|
137
|
+
## Custom domains
|
|
138
|
+
|
|
139
|
+
```sh
|
|
140
|
+
relight domains add myapp.example.com
|
|
141
|
+
|
|
142
|
+
# Relight creates the DNS record if your domain's DNS is on a supported provider
|
|
143
|
+
# Otherwise it prints the record for you to create manually
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## What Relight doesn't do
|
|
147
|
+
|
|
148
|
+
- **Not a general infrastructure tool.** Relight deploys Docker containers with scale-to-zero. It doesn't manage databases, queues, cron jobs, or other infrastructure. Use Terraform, Pulumi, or cloud consoles for those.
|
|
149
|
+
- **No autoscaling.** You set max instances. The cloud scales between 0 and your max based on traffic. There's no custom autoscaling logic.
|
|
150
|
+
- **No CI/CD.** Relight is a deployment tool, not a build pipeline. Integrate it into your CI by running `relight deploy` in your workflow.
|
|
151
|
+
- **Cold starts.** Sleeping apps take 1-5 seconds to respond to the first request. This is inherent to scale-to-zero and varies by cloud and image size.
|
|
152
|
+
|
|
153
|
+
## Cloud-specific notes
|
|
154
|
+
|
|
155
|
+
### GCP (Cloud Run)
|
|
156
|
+
|
|
157
|
+
- Requires a GCP project with Cloud Run and Artifact Registry APIs enabled.
|
|
158
|
+
- Token: service account key or `gcloud auth print-access-token`.
|
|
159
|
+
- Regions: any Cloud Run region. Run `relight regions --compute gcp` to list.
|
|
160
|
+
- Scale-to-zero is native. Minimum instances can be set to 0.
|
|
161
|
+
|
|
162
|
+
### AWS (App Runner)
|
|
163
|
+
|
|
164
|
+
- Requires an AWS account with App Runner and ECR access.
|
|
165
|
+
- Token: IAM access key with `apprunner:*` and `ecr:*` permissions, or use `aws configure`.
|
|
166
|
+
- Regions: any App Runner region. Run `relight regions --compute aws` to list.
|
|
167
|
+
- Scale-to-zero is native. Services pause when idle and resume on the next request.
|
|
168
|
+
|
|
169
|
+
### Cloudflare (Containers)
|
|
170
|
+
|
|
171
|
+
- Requires a Cloudflare account with Containers access (paid Workers plan).
|
|
172
|
+
- Token: Cloudflare API token with `workers_scripts:edit` and `containers:edit` permissions.
|
|
173
|
+
- Each app is a Worker backed by Durable Objects running your container. The CLI bundles and uploads the Worker template automatically.
|
|
174
|
+
- Regions use Durable Object `locationHints` — placement is best-effort, not guaranteed.
|
|
175
|
+
|
|
176
|
+
## Configuration
|
|
177
|
+
|
|
178
|
+
Auth config is stored at `~/.relight/config.json`:
|
|
179
|
+
|
|
180
|
+
```json
|
|
181
|
+
{
|
|
182
|
+
"clouds": {
|
|
183
|
+
"gcp": { "token": "...", "project": "my-project" },
|
|
184
|
+
"aws": { "accessKeyId": "...", "secretAccessKey": "...", "region": "us-east-1" },
|
|
185
|
+
"cf": { "token": "...", "accountId": "..." }
|
|
186
|
+
},
|
|
187
|
+
"default_compute": "gcp"
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Per-project app linking is stored in a `.relight` file in your project directory:
|
|
192
|
+
|
|
193
|
+
```json
|
|
194
|
+
{ "app": "myapp", "compute": "gcp" }
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## License
|
|
198
|
+
|
|
199
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "relight-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Deploy and manage Docker containers across clouds with scale-to-zero",
|
|
5
|
+
"bin": {
|
|
6
|
+
"relight": "./src/cli.js"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"files": [
|
|
11
|
+
"src"
|
|
12
|
+
],
|
|
13
|
+
"keywords": [
|
|
14
|
+
"docker",
|
|
15
|
+
"deploy",
|
|
16
|
+
"paas",
|
|
17
|
+
"cloudflare",
|
|
18
|
+
"gcp",
|
|
19
|
+
"aws",
|
|
20
|
+
"scale-to-zero",
|
|
21
|
+
"multi-cloud"
|
|
22
|
+
],
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"commander": "^13.0.0",
|
|
25
|
+
"kleur": "^4.1.5"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { auth } from "./commands/auth.js";
|
|
5
|
+
import { doctor } from "./commands/doctor.js";
|
|
6
|
+
|
|
7
|
+
var program = new Command();
|
|
8
|
+
|
|
9
|
+
program
|
|
10
|
+
.name("relight")
|
|
11
|
+
.description("Deploy and manage Docker containers across clouds with scale-to-zero")
|
|
12
|
+
.version("0.1.0");
|
|
13
|
+
|
|
14
|
+
// --- Auth ---
|
|
15
|
+
|
|
16
|
+
program
|
|
17
|
+
.command("auth")
|
|
18
|
+
.description("Authenticate with a cloud provider")
|
|
19
|
+
.option(
|
|
20
|
+
"-c, --cloud <cloud>",
|
|
21
|
+
"Cloud provider (cf, gcp, aws)"
|
|
22
|
+
)
|
|
23
|
+
.action(auth);
|
|
24
|
+
|
|
25
|
+
// --- Doctor ---
|
|
26
|
+
|
|
27
|
+
program
|
|
28
|
+
.command("doctor")
|
|
29
|
+
.description("Check system setup and cloud connectivity")
|
|
30
|
+
.action(doctor);
|
|
31
|
+
|
|
32
|
+
program.parse();
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { createInterface } from "readline";
|
|
2
|
+
import { execSync } from "child_process";
|
|
3
|
+
import { existsSync } from "fs";
|
|
4
|
+
import { resolve } from "path";
|
|
5
|
+
import { success, fatal, hint, fmt } from "../lib/output.js";
|
|
6
|
+
import {
|
|
7
|
+
tryGetConfig,
|
|
8
|
+
saveConfig,
|
|
9
|
+
CLOUD_NAMES,
|
|
10
|
+
CLOUD_IDS,
|
|
11
|
+
} from "../lib/config.js";
|
|
12
|
+
import { TOKEN_URL, verifyToken, listAccounts } from "../lib/clouds/cf.js";
|
|
13
|
+
import {
|
|
14
|
+
readKeyFile,
|
|
15
|
+
mintAccessToken,
|
|
16
|
+
verifyProject,
|
|
17
|
+
} from "../lib/clouds/gcp.js";
|
|
18
|
+
import { verifyCredentials as awsVerify } from "../lib/clouds/aws.js";
|
|
19
|
+
import kleur from "kleur";
|
|
20
|
+
|
|
21
|
+
function prompt(rl, question) {
|
|
22
|
+
return new Promise((resolve) => rl.question(question, resolve));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function auth(options) {
|
|
26
|
+
var compute = options.cloud;
|
|
27
|
+
|
|
28
|
+
var rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
29
|
+
|
|
30
|
+
if (!compute) {
|
|
31
|
+
process.stderr.write(`\n${kleur.bold("Authenticate with a cloud provider")}\n\n`);
|
|
32
|
+
for (var i = 0; i < CLOUD_IDS.length; i++) {
|
|
33
|
+
process.stderr.write(
|
|
34
|
+
` ${kleur.bold(`[${i + 1}]`)} ${CLOUD_NAMES[CLOUD_IDS[i]]}\n`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
process.stderr.write("\n");
|
|
38
|
+
|
|
39
|
+
var choice = await prompt(rl, `Select cloud [1-${CLOUD_IDS.length}]: `);
|
|
40
|
+
var idx = parseInt(choice, 10) - 1;
|
|
41
|
+
|
|
42
|
+
if (isNaN(idx) || idx < 0 || idx >= CLOUD_IDS.length) {
|
|
43
|
+
rl.close();
|
|
44
|
+
fatal("Invalid selection.");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
compute = CLOUD_IDS[idx];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
compute = normalizeCompute(compute);
|
|
51
|
+
if (!CLOUD_NAMES[compute]) {
|
|
52
|
+
rl.close();
|
|
53
|
+
fatal(
|
|
54
|
+
`Unknown cloud: ${compute}`,
|
|
55
|
+
`Supported: ${CLOUD_IDS.join(", ")}`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
process.stderr.write(
|
|
60
|
+
`\n${kleur.bold(`Authenticate with ${CLOUD_NAMES[compute]}`)}\n`
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
var cloudConfig;
|
|
64
|
+
|
|
65
|
+
switch (compute) {
|
|
66
|
+
case "cf":
|
|
67
|
+
cloudConfig = await authCloudflare(rl);
|
|
68
|
+
break;
|
|
69
|
+
case "gcp":
|
|
70
|
+
cloudConfig = await authGCP(rl);
|
|
71
|
+
break;
|
|
72
|
+
case "aws":
|
|
73
|
+
cloudConfig = await authAWS(rl);
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
rl.close();
|
|
78
|
+
|
|
79
|
+
// Merge into existing config
|
|
80
|
+
var config = tryGetConfig() || { clouds: {} };
|
|
81
|
+
if (!config.clouds) config.clouds = {};
|
|
82
|
+
config.clouds[compute] = cloudConfig;
|
|
83
|
+
|
|
84
|
+
if (!config.default_cloud) {
|
|
85
|
+
config.default_cloud = compute;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
saveConfig(config);
|
|
89
|
+
|
|
90
|
+
success(`Authenticated with ${CLOUD_NAMES[compute]}!`);
|
|
91
|
+
|
|
92
|
+
if (config.default_cloud === compute) {
|
|
93
|
+
hint("Default", `${CLOUD_NAMES[compute]} is your default cloud`);
|
|
94
|
+
}
|
|
95
|
+
hint("Next", `relight deploy <name> .`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function normalizeCompute(input) {
|
|
99
|
+
var aliases = {
|
|
100
|
+
cloudflare: "cf",
|
|
101
|
+
cf: "cf",
|
|
102
|
+
gcp: "gcp",
|
|
103
|
+
"google-cloud": "gcp",
|
|
104
|
+
"cloud-run": "gcp",
|
|
105
|
+
aws: "aws",
|
|
106
|
+
amazon: "aws",
|
|
107
|
+
"app-runner": "aws",
|
|
108
|
+
};
|
|
109
|
+
return aliases[input.toLowerCase()] || input.toLowerCase();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// --- Cloudflare ---
|
|
113
|
+
|
|
114
|
+
async function authCloudflare(rl) {
|
|
115
|
+
process.stderr.write(`\n ${kleur.bold("Setup")}\n\n`);
|
|
116
|
+
process.stderr.write(` 1. Log in to the Cloudflare dashboard\n`);
|
|
117
|
+
process.stderr.write(` 2. Open this link to create a pre-filled API token:\n\n`);
|
|
118
|
+
process.stderr.write(` ${fmt.url(TOKEN_URL)}\n\n`);
|
|
119
|
+
process.stderr.write(` 3. Click ${kleur.bold("Continue to summary")} then ${kleur.bold("Create Token")}\n`);
|
|
120
|
+
process.stderr.write(` 4. Copy the token\n\n`);
|
|
121
|
+
|
|
122
|
+
var apiToken = await prompt(rl, "Paste your API token: ");
|
|
123
|
+
apiToken = (apiToken || "").trim();
|
|
124
|
+
if (!apiToken) fatal("No token provided.");
|
|
125
|
+
|
|
126
|
+
process.stderr.write("\nVerifying...\n");
|
|
127
|
+
try {
|
|
128
|
+
await verifyToken(apiToken);
|
|
129
|
+
} catch (e) {
|
|
130
|
+
fatal("Token verification failed.", e.message);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
var accounts = await listAccounts(apiToken);
|
|
134
|
+
if (accounts.length === 0) {
|
|
135
|
+
fatal("No accounts found for this token.");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
var account;
|
|
139
|
+
if (accounts.length === 1) {
|
|
140
|
+
account = accounts[0];
|
|
141
|
+
} else {
|
|
142
|
+
process.stderr.write("\nMultiple accounts found:\n\n");
|
|
143
|
+
for (var i = 0; i < accounts.length; i++) {
|
|
144
|
+
process.stderr.write(
|
|
145
|
+
` ${kleur.bold(`[${i + 1}]`)} ${accounts[i].name} ${fmt.dim(`(${accounts[i].id})`)}\n`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
process.stderr.write("\n");
|
|
149
|
+
|
|
150
|
+
var choice = await prompt(rl, `Select account [1-${accounts.length}]: `);
|
|
151
|
+
var idx = parseInt(choice, 10) - 1;
|
|
152
|
+
if (isNaN(idx) || idx < 0 || idx >= accounts.length) {
|
|
153
|
+
fatal("Invalid selection.");
|
|
154
|
+
}
|
|
155
|
+
account = accounts[idx];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
process.stderr.write(
|
|
159
|
+
` Account: ${fmt.bold(account.name)} ${fmt.dim(`(${account.id})`)}\n`
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
return { token: apiToken, accountId: account.id };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// --- GCP ---
|
|
166
|
+
|
|
167
|
+
async function authGCP(rl) {
|
|
168
|
+
var SA_URL =
|
|
169
|
+
"https://console.cloud.google.com/iam-admin/serviceaccounts/create";
|
|
170
|
+
var ENABLE_APIS =
|
|
171
|
+
"https://console.cloud.google.com/apis/enableflow?apiid=run.googleapis.com,artifactregistry.googleapis.com";
|
|
172
|
+
|
|
173
|
+
process.stderr.write(`\n ${kleur.bold("Setup")}\n\n`);
|
|
174
|
+
process.stderr.write(` 1. Enable the Cloud Run and Artifact Registry APIs:\n`);
|
|
175
|
+
process.stderr.write(` ${fmt.url(ENABLE_APIS)}\n\n`);
|
|
176
|
+
process.stderr.write(` 2. Create a service account:\n`);
|
|
177
|
+
process.stderr.write(` ${fmt.url(SA_URL)}\n\n`);
|
|
178
|
+
process.stderr.write(` Name it ${fmt.val("relight")} and grant these roles:\n`);
|
|
179
|
+
process.stderr.write(` ${fmt.val("Cloud Run Admin")}\n`);
|
|
180
|
+
process.stderr.write(` ${fmt.val("Artifact Registry Admin")}\n`);
|
|
181
|
+
process.stderr.write(` ${fmt.val("Service Account User")}\n\n`);
|
|
182
|
+
process.stderr.write(` 3. Go to the service account → ${kleur.bold("Keys")} tab\n`);
|
|
183
|
+
process.stderr.write(` 4. ${kleur.bold("Add Key")} → ${kleur.bold("Create new key")} → ${kleur.bold("JSON")}\n`);
|
|
184
|
+
process.stderr.write(` 5. Save the downloaded file\n\n`);
|
|
185
|
+
|
|
186
|
+
var keyPath = await prompt(rl, "Path to service account key JSON: ");
|
|
187
|
+
keyPath = (keyPath || "").trim();
|
|
188
|
+
if (!keyPath) fatal("No key file provided.");
|
|
189
|
+
|
|
190
|
+
keyPath = resolve(keyPath.replace(/^~\//, process.env.HOME + "/"));
|
|
191
|
+
if (!existsSync(keyPath)) {
|
|
192
|
+
fatal(`File not found: ${keyPath}`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
var key;
|
|
196
|
+
try {
|
|
197
|
+
key = readKeyFile(keyPath);
|
|
198
|
+
} catch (e) {
|
|
199
|
+
fatal("Failed to read key file.", e.message);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
var project = key.project;
|
|
203
|
+
if (!project) {
|
|
204
|
+
project = await prompt(rl, "GCP project ID: ");
|
|
205
|
+
project = (project || "").trim();
|
|
206
|
+
if (!project) fatal("No project provided.");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
process.stderr.write("\nVerifying...\n");
|
|
210
|
+
|
|
211
|
+
var token;
|
|
212
|
+
try {
|
|
213
|
+
token = await mintAccessToken(key.clientEmail, key.privateKey);
|
|
214
|
+
} catch (e) {
|
|
215
|
+
fatal("Failed to mint access token.", e.message);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
await verifyProject(token, project);
|
|
220
|
+
} catch (e) {
|
|
221
|
+
fatal("Project verification failed.", e.message);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
process.stderr.write(` Project: ${fmt.bold(project)}\n`);
|
|
225
|
+
process.stderr.write(` Service account: ${fmt.dim(key.clientEmail)}\n`);
|
|
226
|
+
|
|
227
|
+
return { clientEmail: key.clientEmail, privateKey: key.privateKey, project };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// --- AWS ---
|
|
231
|
+
|
|
232
|
+
async function authAWS(rl) {
|
|
233
|
+
var IAM_CONSOLE = "https://console.aws.amazon.com/iam/home#/users";
|
|
234
|
+
|
|
235
|
+
process.stderr.write(`\n ${kleur.bold("Setup")}\n\n`);
|
|
236
|
+
process.stderr.write(` 1. Open the IAM console:\n`);
|
|
237
|
+
process.stderr.write(` ${fmt.url(IAM_CONSOLE)}\n\n`);
|
|
238
|
+
process.stderr.write(` 2. Create a user and attach these policies:\n`);
|
|
239
|
+
process.stderr.write(` ${fmt.val("AWSAppRunnerFullAccess")}\n`);
|
|
240
|
+
process.stderr.write(` ${fmt.val("AmazonEC2ContainerRegistryFullAccess")}\n\n`);
|
|
241
|
+
process.stderr.write(` 3. Go to the user's ${kleur.bold("Security credentials")} tab\n`);
|
|
242
|
+
process.stderr.write(` 4. Click ${kleur.bold("Create access key")} → choose ${kleur.bold("Command Line Interface")}\n`);
|
|
243
|
+
process.stderr.write(` 5. Copy the Access Key ID and Secret Access Key\n\n`);
|
|
244
|
+
|
|
245
|
+
// Detect from env vars as fallback
|
|
246
|
+
var detectedKeyId = process.env.AWS_ACCESS_KEY_ID || null;
|
|
247
|
+
var detectedSecret = process.env.AWS_SECRET_ACCESS_KEY || null;
|
|
248
|
+
var detectedRegion =
|
|
249
|
+
process.env.AWS_DEFAULT_REGION || process.env.AWS_REGION || null;
|
|
250
|
+
|
|
251
|
+
var accessKeyId;
|
|
252
|
+
if (detectedKeyId) {
|
|
253
|
+
var input = await prompt(
|
|
254
|
+
rl,
|
|
255
|
+
`AWS Access Key ID [${detectedKeyId.slice(0, 8)}...]: `
|
|
256
|
+
);
|
|
257
|
+
accessKeyId = (input || "").trim() || detectedKeyId;
|
|
258
|
+
} else {
|
|
259
|
+
accessKeyId = await prompt(rl, "AWS Access Key ID: ");
|
|
260
|
+
accessKeyId = (accessKeyId || "").trim();
|
|
261
|
+
if (!accessKeyId) fatal("No access key provided.");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
var secretAccessKey;
|
|
265
|
+
if (detectedSecret && accessKeyId === detectedKeyId) {
|
|
266
|
+
var input = await prompt(rl, "AWS Secret Access Key [detected]: ");
|
|
267
|
+
secretAccessKey = (input || "").trim() || detectedSecret;
|
|
268
|
+
} else {
|
|
269
|
+
secretAccessKey = await prompt(rl, "AWS Secret Access Key: ");
|
|
270
|
+
secretAccessKey = (secretAccessKey || "").trim();
|
|
271
|
+
if (!secretAccessKey) fatal("No secret key provided.");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
var defaultRegion = detectedRegion || "us-east-1";
|
|
275
|
+
var region = await prompt(rl, `AWS Region [${defaultRegion}]: `);
|
|
276
|
+
region = (region || "").trim() || defaultRegion;
|
|
277
|
+
|
|
278
|
+
process.stderr.write("\nVerifying...\n");
|
|
279
|
+
try {
|
|
280
|
+
await awsVerify({ accessKeyId, secretAccessKey }, region);
|
|
281
|
+
} catch (e) {
|
|
282
|
+
fatal("Credential verification failed.", e.message);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
process.stderr.write(` Region: ${fmt.bold(region)}\n`);
|
|
286
|
+
|
|
287
|
+
return { accessKeyId, secretAccessKey, region };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// --- Helpers ---
|
|
291
|
+
|
|
292
|
+
function tryExec(cmd) {
|
|
293
|
+
try {
|
|
294
|
+
return execSync(cmd + " 2>/dev/null", {
|
|
295
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
296
|
+
encoding: "utf-8",
|
|
297
|
+
timeout: 5000,
|
|
298
|
+
}).trim() || null;
|
|
299
|
+
} catch {
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { execSync } from "child_process";
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import {
|
|
4
|
+
tryGetConfig,
|
|
5
|
+
CONFIG_PATH,
|
|
6
|
+
CLOUD_NAMES,
|
|
7
|
+
} from "../lib/config.js";
|
|
8
|
+
import { verifyToken as cfVerify, getWorkersSubdomain } from "../lib/clouds/cf.js";
|
|
9
|
+
import { mintAccessToken, verifyProject as gcpVerifyProject, listRegions as gcpListRegions } from "../lib/clouds/gcp.js";
|
|
10
|
+
import { verifyCredentials as awsVerify, checkAppRunner } from "../lib/clouds/aws.js";
|
|
11
|
+
import kleur from "kleur";
|
|
12
|
+
|
|
13
|
+
var PASS = kleur.green("[ok]");
|
|
14
|
+
var FAIL = kleur.red("[!!]");
|
|
15
|
+
var SKIP = kleur.yellow("[--]");
|
|
16
|
+
|
|
17
|
+
export async function doctor() {
|
|
18
|
+
process.stderr.write(`\n${kleur.bold("relight doctor")}\n`);
|
|
19
|
+
process.stderr.write(`${kleur.dim("─".repeat(50))}\n\n`);
|
|
20
|
+
var allGood = true;
|
|
21
|
+
|
|
22
|
+
// --- General checks ---
|
|
23
|
+
|
|
24
|
+
process.stderr.write(kleur.bold(" System\n"));
|
|
25
|
+
|
|
26
|
+
allGood =
|
|
27
|
+
check("Docker installed", () => {
|
|
28
|
+
execSync("docker --version", { stdio: "pipe" });
|
|
29
|
+
}) && allGood;
|
|
30
|
+
|
|
31
|
+
allGood =
|
|
32
|
+
check("Docker daemon running", () => {
|
|
33
|
+
execSync("docker info", { stdio: "pipe", timeout: 5000 });
|
|
34
|
+
}) && allGood;
|
|
35
|
+
|
|
36
|
+
check("Node.js >= 20", () => {
|
|
37
|
+
var major = parseInt(process.version.slice(1), 10);
|
|
38
|
+
if (major < 20) throw new Error(`Node ${process.version} (need >= 20)`);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
allGood =
|
|
42
|
+
check("Auth config exists", () => {
|
|
43
|
+
if (!existsSync(CONFIG_PATH)) throw new Error("Not found");
|
|
44
|
+
}) && allGood;
|
|
45
|
+
|
|
46
|
+
var config = tryGetConfig();
|
|
47
|
+
var clouds = config && config.clouds ? config.clouds : {};
|
|
48
|
+
var authenticatedClouds = Object.keys(clouds).filter(
|
|
49
|
+
(id) => clouds[id] && Object.keys(clouds[id]).length > 0
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
if (authenticatedClouds.length === 0) {
|
|
53
|
+
process.stderr.write(
|
|
54
|
+
`\n ${SKIP} No clouds configured. Run ${kleur.bold().cyan("relight auth")} to get started.\n`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// --- Per-cloud checks ---
|
|
59
|
+
|
|
60
|
+
for (var cloudId of authenticatedClouds) {
|
|
61
|
+
process.stderr.write(`\n${kleur.bold(` ${CLOUD_NAMES[cloudId] || cloudId}`)}\n`);
|
|
62
|
+
|
|
63
|
+
switch (cloudId) {
|
|
64
|
+
case "cf":
|
|
65
|
+
allGood = (await checkCloudflare(clouds.cf)) && allGood;
|
|
66
|
+
break;
|
|
67
|
+
case "gcp":
|
|
68
|
+
allGood = (await checkGCP(clouds.gcp)) && allGood;
|
|
69
|
+
break;
|
|
70
|
+
case "aws":
|
|
71
|
+
allGood = (await checkAWS(clouds.aws)) && allGood;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// --- Summary ---
|
|
77
|
+
|
|
78
|
+
process.stderr.write(`\n${kleur.dim("─".repeat(50))}\n`);
|
|
79
|
+
if (allGood) {
|
|
80
|
+
process.stderr.write(kleur.green("All checks passed.\n\n"));
|
|
81
|
+
} else {
|
|
82
|
+
process.stderr.write(
|
|
83
|
+
kleur.yellow("Some checks failed. Fix the issues above and re-run.\n\n")
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// --- Cloudflare checks ---
|
|
89
|
+
|
|
90
|
+
async function checkCloudflare(cfg) {
|
|
91
|
+
var ok = true;
|
|
92
|
+
|
|
93
|
+
ok =
|
|
94
|
+
(await asyncCheck("API token valid", async () => {
|
|
95
|
+
await cfVerify(cfg.token);
|
|
96
|
+
})) && ok;
|
|
97
|
+
|
|
98
|
+
ok =
|
|
99
|
+
(await asyncCheck("Account accessible", async () => {
|
|
100
|
+
var { listAccounts } = await import("../lib/clouds/cf.js");
|
|
101
|
+
var accounts = await listAccounts(cfg.token);
|
|
102
|
+
if (!accounts.length) throw new Error("No accounts");
|
|
103
|
+
var match = accounts.find((a) => a.id === cfg.accountId);
|
|
104
|
+
if (!match) throw new Error(`Account ${cfg.accountId} not found`);
|
|
105
|
+
})) && ok;
|
|
106
|
+
|
|
107
|
+
ok =
|
|
108
|
+
(await asyncCheck("Workers subdomain configured", async () => {
|
|
109
|
+
var sub = await getWorkersSubdomain(cfg.accountId, cfg.token);
|
|
110
|
+
if (!sub) throw new Error("Not configured");
|
|
111
|
+
})) && ok;
|
|
112
|
+
|
|
113
|
+
return ok;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --- GCP checks ---
|
|
117
|
+
|
|
118
|
+
async function checkGCP(cfg) {
|
|
119
|
+
var ok = true;
|
|
120
|
+
|
|
121
|
+
var token;
|
|
122
|
+
ok =
|
|
123
|
+
(await asyncCheck("Service account key valid", async () => {
|
|
124
|
+
token = await mintAccessToken(cfg.clientEmail, cfg.privateKey);
|
|
125
|
+
})) && ok;
|
|
126
|
+
|
|
127
|
+
if (token) {
|
|
128
|
+
ok =
|
|
129
|
+
(await asyncCheck("Project accessible", async () => {
|
|
130
|
+
await gcpVerifyProject(token, cfg.project);
|
|
131
|
+
})) && ok;
|
|
132
|
+
|
|
133
|
+
ok =
|
|
134
|
+
(await asyncCheck("Cloud Run API reachable", async () => {
|
|
135
|
+
await gcpListRegions(token, cfg.project);
|
|
136
|
+
})) && ok;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return ok;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// --- AWS checks ---
|
|
143
|
+
|
|
144
|
+
async function checkAWS(cfg) {
|
|
145
|
+
var ok = true;
|
|
146
|
+
|
|
147
|
+
ok =
|
|
148
|
+
(await asyncCheck("Credentials valid (STS)", async () => {
|
|
149
|
+
await awsVerify(
|
|
150
|
+
{ accessKeyId: cfg.accessKeyId, secretAccessKey: cfg.secretAccessKey },
|
|
151
|
+
cfg.region
|
|
152
|
+
);
|
|
153
|
+
})) && ok;
|
|
154
|
+
|
|
155
|
+
ok =
|
|
156
|
+
(await asyncCheck("App Runner accessible", async () => {
|
|
157
|
+
await checkAppRunner(
|
|
158
|
+
{ accessKeyId: cfg.accessKeyId, secretAccessKey: cfg.secretAccessKey },
|
|
159
|
+
cfg.region
|
|
160
|
+
);
|
|
161
|
+
})) && ok;
|
|
162
|
+
|
|
163
|
+
return ok;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// --- Check helpers ---
|
|
167
|
+
|
|
168
|
+
function check(label, fn) {
|
|
169
|
+
try {
|
|
170
|
+
fn();
|
|
171
|
+
process.stderr.write(` ${PASS} ${label}\n`);
|
|
172
|
+
return true;
|
|
173
|
+
} catch (e) {
|
|
174
|
+
process.stderr.write(` ${FAIL} ${label}`);
|
|
175
|
+
if (e.message) process.stderr.write(kleur.dim(` — ${e.message}`));
|
|
176
|
+
process.stderr.write("\n");
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function asyncCheck(label, fn) {
|
|
182
|
+
try {
|
|
183
|
+
await fn();
|
|
184
|
+
process.stderr.write(` ${PASS} ${label}\n`);
|
|
185
|
+
return true;
|
|
186
|
+
} catch (e) {
|
|
187
|
+
process.stderr.write(` ${FAIL} ${label}`);
|
|
188
|
+
if (e.message) process.stderr.write(kleur.dim(` — ${truncate(e.message, 80)}`));
|
|
189
|
+
process.stderr.write("\n");
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function truncate(str, max) {
|
|
195
|
+
if (str.length <= max) return str;
|
|
196
|
+
return str.slice(0, max - 3) + "...";
|
|
197
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { createHmac, createHash } from "crypto";
|
|
2
|
+
|
|
3
|
+
// AWS Signature V4 signing
|
|
4
|
+
|
|
5
|
+
function sha256(data) {
|
|
6
|
+
return createHash("sha256").update(data).digest("hex");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function hmac(key, data) {
|
|
10
|
+
return createHmac("sha256", key).update(data).digest();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getSignatureKey(secretKey, dateStamp, region, service) {
|
|
14
|
+
var kDate = hmac("AWS4" + secretKey, dateStamp);
|
|
15
|
+
var kRegion = hmac(kDate, region);
|
|
16
|
+
var kService = hmac(kRegion, service);
|
|
17
|
+
return hmac(kService, "aws4_request");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function awsApi(method, service, host, path, body, credentials, region) {
|
|
21
|
+
var now = new Date();
|
|
22
|
+
var amzDate = now.toISOString().replace(/[-:]/g, "").replace(/\.\d+/, "");
|
|
23
|
+
var dateStamp = amzDate.slice(0, 8);
|
|
24
|
+
|
|
25
|
+
var bodyStr = body ? JSON.stringify(body) : "";
|
|
26
|
+
var payloadHash = sha256(bodyStr);
|
|
27
|
+
|
|
28
|
+
var canonicalHeaders =
|
|
29
|
+
`content-type:application/x-amz-json-1.0\n` +
|
|
30
|
+
`host:${host}\n` +
|
|
31
|
+
`x-amz-date:${amzDate}\n`;
|
|
32
|
+
var signedHeaders = "content-type;host;x-amz-date";
|
|
33
|
+
|
|
34
|
+
var canonicalRequest = [
|
|
35
|
+
method,
|
|
36
|
+
path,
|
|
37
|
+
"", // query string
|
|
38
|
+
canonicalHeaders,
|
|
39
|
+
signedHeaders,
|
|
40
|
+
payloadHash,
|
|
41
|
+
].join("\n");
|
|
42
|
+
|
|
43
|
+
var credentialScope = `${dateStamp}/${region}/${service}/aws4_request`;
|
|
44
|
+
var stringToSign = [
|
|
45
|
+
"AWS4-HMAC-SHA256",
|
|
46
|
+
amzDate,
|
|
47
|
+
credentialScope,
|
|
48
|
+
sha256(canonicalRequest),
|
|
49
|
+
].join("\n");
|
|
50
|
+
|
|
51
|
+
var signingKey = getSignatureKey(
|
|
52
|
+
credentials.secretAccessKey,
|
|
53
|
+
dateStamp,
|
|
54
|
+
region,
|
|
55
|
+
service
|
|
56
|
+
);
|
|
57
|
+
var signature = createHmac("sha256", signingKey)
|
|
58
|
+
.update(stringToSign)
|
|
59
|
+
.digest("hex");
|
|
60
|
+
|
|
61
|
+
var authHeader =
|
|
62
|
+
`AWS4-HMAC-SHA256 ` +
|
|
63
|
+
`Credential=${credentials.accessKeyId}/${credentialScope}, ` +
|
|
64
|
+
`SignedHeaders=${signedHeaders}, ` +
|
|
65
|
+
`Signature=${signature}`;
|
|
66
|
+
|
|
67
|
+
var res = await fetch(`https://${host}${path}`, {
|
|
68
|
+
method,
|
|
69
|
+
headers: {
|
|
70
|
+
"Content-Type": "application/x-amz-json-1.0",
|
|
71
|
+
"X-Amz-Date": amzDate,
|
|
72
|
+
Authorization: authHeader,
|
|
73
|
+
Host: host,
|
|
74
|
+
},
|
|
75
|
+
body: method === "GET" ? undefined : bodyStr || undefined,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
if (!res.ok) {
|
|
79
|
+
var text = await res.text();
|
|
80
|
+
throw new Error(`AWS ${service} ${method} ${path}: ${res.status} ${text}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
var ct = res.headers.get("content-type") || "";
|
|
84
|
+
if (ct.includes("json")) {
|
|
85
|
+
return res.json();
|
|
86
|
+
}
|
|
87
|
+
return res.text();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function verifyCredentials(credentials, region) {
|
|
91
|
+
// Use STS GetCallerIdentity to verify credentials
|
|
92
|
+
var host = "sts.amazonaws.com";
|
|
93
|
+
var now = new Date();
|
|
94
|
+
var amzDate = now.toISOString().replace(/[-:]/g, "").replace(/\.\d+/, "");
|
|
95
|
+
var dateStamp = amzDate.slice(0, 8);
|
|
96
|
+
|
|
97
|
+
var queryParams = new URLSearchParams({
|
|
98
|
+
Action: "GetCallerIdentity",
|
|
99
|
+
Version: "2011-06-15",
|
|
100
|
+
});
|
|
101
|
+
var queryString = queryParams.toString();
|
|
102
|
+
|
|
103
|
+
var payloadHash = sha256("");
|
|
104
|
+
var canonicalHeaders = `host:${host}\nx-amz-date:${amzDate}\n`;
|
|
105
|
+
var signedHeaders = "host;x-amz-date";
|
|
106
|
+
|
|
107
|
+
var canonicalRequest = [
|
|
108
|
+
"GET",
|
|
109
|
+
"/",
|
|
110
|
+
queryString,
|
|
111
|
+
canonicalHeaders,
|
|
112
|
+
signedHeaders,
|
|
113
|
+
payloadHash,
|
|
114
|
+
].join("\n");
|
|
115
|
+
|
|
116
|
+
var credentialScope = `${dateStamp}/us-east-1/sts/aws4_request`;
|
|
117
|
+
var stringToSign = [
|
|
118
|
+
"AWS4-HMAC-SHA256",
|
|
119
|
+
amzDate,
|
|
120
|
+
credentialScope,
|
|
121
|
+
sha256(canonicalRequest),
|
|
122
|
+
].join("\n");
|
|
123
|
+
|
|
124
|
+
var signingKey = getSignatureKey(
|
|
125
|
+
credentials.secretAccessKey,
|
|
126
|
+
dateStamp,
|
|
127
|
+
"us-east-1",
|
|
128
|
+
"sts"
|
|
129
|
+
);
|
|
130
|
+
var signature = createHmac("sha256", signingKey)
|
|
131
|
+
.update(stringToSign)
|
|
132
|
+
.digest("hex");
|
|
133
|
+
|
|
134
|
+
var authHeader =
|
|
135
|
+
`AWS4-HMAC-SHA256 ` +
|
|
136
|
+
`Credential=${credentials.accessKeyId}/${credentialScope}, ` +
|
|
137
|
+
`SignedHeaders=${signedHeaders}, ` +
|
|
138
|
+
`Signature=${signature}`;
|
|
139
|
+
|
|
140
|
+
var res = await fetch(`https://${host}/?${queryString}`, {
|
|
141
|
+
method: "GET",
|
|
142
|
+
headers: {
|
|
143
|
+
"X-Amz-Date": amzDate,
|
|
144
|
+
Authorization: authHeader,
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
if (!res.ok) {
|
|
149
|
+
var text = await res.text();
|
|
150
|
+
throw new Error(`STS GetCallerIdentity failed: ${res.status} ${text}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return res.text();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function checkAppRunner(credentials, region) {
|
|
157
|
+
var host = `apprunner.${region}.amazonaws.com`;
|
|
158
|
+
var now = new Date();
|
|
159
|
+
var amzDate = now.toISOString().replace(/[-:]/g, "").replace(/\.\d+/, "");
|
|
160
|
+
var dateStamp = amzDate.slice(0, 8);
|
|
161
|
+
|
|
162
|
+
var bodyStr = JSON.stringify({});
|
|
163
|
+
var payloadHash = sha256(bodyStr);
|
|
164
|
+
|
|
165
|
+
var target = "AppRunner.ListServices";
|
|
166
|
+
var canonicalHeaders =
|
|
167
|
+
`content-type:application/x-amz-json-1.0\n` +
|
|
168
|
+
`host:${host}\n` +
|
|
169
|
+
`x-amz-date:${amzDate}\n` +
|
|
170
|
+
`x-amz-target:${target}\n`;
|
|
171
|
+
var signedHeaders = "content-type;host;x-amz-date;x-amz-target";
|
|
172
|
+
|
|
173
|
+
var canonicalRequest = [
|
|
174
|
+
"POST",
|
|
175
|
+
"/",
|
|
176
|
+
"",
|
|
177
|
+
canonicalHeaders,
|
|
178
|
+
signedHeaders,
|
|
179
|
+
payloadHash,
|
|
180
|
+
].join("\n");
|
|
181
|
+
|
|
182
|
+
var credentialScope = `${dateStamp}/${region}/apprunner/aws4_request`;
|
|
183
|
+
var stringToSign = [
|
|
184
|
+
"AWS4-HMAC-SHA256",
|
|
185
|
+
amzDate,
|
|
186
|
+
credentialScope,
|
|
187
|
+
sha256(canonicalRequest),
|
|
188
|
+
].join("\n");
|
|
189
|
+
|
|
190
|
+
var signingKey = getSignatureKey(
|
|
191
|
+
credentials.secretAccessKey,
|
|
192
|
+
dateStamp,
|
|
193
|
+
region,
|
|
194
|
+
"apprunner"
|
|
195
|
+
);
|
|
196
|
+
var signature = createHmac("sha256", signingKey)
|
|
197
|
+
.update(stringToSign)
|
|
198
|
+
.digest("hex");
|
|
199
|
+
|
|
200
|
+
var authHeader =
|
|
201
|
+
`AWS4-HMAC-SHA256 ` +
|
|
202
|
+
`Credential=${credentials.accessKeyId}/${credentialScope}, ` +
|
|
203
|
+
`SignedHeaders=${signedHeaders}, ` +
|
|
204
|
+
`Signature=${signature}`;
|
|
205
|
+
|
|
206
|
+
var res = await fetch(`https://${host}/`, {
|
|
207
|
+
method: "POST",
|
|
208
|
+
headers: {
|
|
209
|
+
"Content-Type": "application/x-amz-json-1.0",
|
|
210
|
+
"X-Amz-Date": amzDate,
|
|
211
|
+
"X-Amz-Target": target,
|
|
212
|
+
Authorization: authHeader,
|
|
213
|
+
},
|
|
214
|
+
body: bodyStr,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
if (!res.ok) {
|
|
218
|
+
var text = await res.text();
|
|
219
|
+
throw new Error(`App Runner ListServices failed: ${res.status} ${text}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return res.json();
|
|
223
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
var CF_API = "https://api.cloudflare.com/client/v4";
|
|
2
|
+
|
|
3
|
+
export var TOKEN_URL =
|
|
4
|
+
"https://dash.cloudflare.com/profile/api-tokens?" +
|
|
5
|
+
"permissionGroupKeys=" +
|
|
6
|
+
encodeURIComponent(
|
|
7
|
+
JSON.stringify([
|
|
8
|
+
{ key: "workers_scripts", type: "edit" },
|
|
9
|
+
{ key: "containers", type: "edit" },
|
|
10
|
+
{ key: "zone", type: "read" },
|
|
11
|
+
{ key: "dns", type: "edit" },
|
|
12
|
+
])
|
|
13
|
+
) +
|
|
14
|
+
"&name=relight-cli";
|
|
15
|
+
|
|
16
|
+
export async function cfApi(method, path, body, apiToken) {
|
|
17
|
+
var headers = {
|
|
18
|
+
Authorization: `Bearer ${apiToken}`,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
if (body && typeof body === "object") {
|
|
22
|
+
headers["Content-Type"] = "application/json";
|
|
23
|
+
body = JSON.stringify(body);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
var res = await fetch(`${CF_API}${path}`, {
|
|
27
|
+
method,
|
|
28
|
+
headers,
|
|
29
|
+
body: method === "GET" ? undefined : body,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (!res.ok) {
|
|
33
|
+
var text = await res.text();
|
|
34
|
+
throw new Error(`CF API ${method} ${path}: ${res.status} ${text}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
var ct = res.headers.get("content-type") || "";
|
|
38
|
+
if (ct.includes("application/json")) {
|
|
39
|
+
return res.json();
|
|
40
|
+
}
|
|
41
|
+
return res.text();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function verifyToken(apiToken) {
|
|
45
|
+
return cfApi("GET", "/user/tokens/verify", null, apiToken);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function listAccounts(apiToken) {
|
|
49
|
+
var res = await cfApi("GET", "/accounts", null, apiToken);
|
|
50
|
+
return res.result || [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function getWorkersSubdomain(accountId, apiToken) {
|
|
54
|
+
try {
|
|
55
|
+
var res = await cfApi(
|
|
56
|
+
"GET",
|
|
57
|
+
`/accounts/${accountId}/workers/subdomain`,
|
|
58
|
+
null,
|
|
59
|
+
apiToken
|
|
60
|
+
);
|
|
61
|
+
return res.result?.subdomain || null;
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { createSign } from "crypto";
|
|
2
|
+
import { readFileSync } from "fs";
|
|
3
|
+
|
|
4
|
+
var RUN_API = "https://run.googleapis.com/v2";
|
|
5
|
+
var CRM_API = "https://cloudresourcemanager.googleapis.com/v1";
|
|
6
|
+
var TOKEN_URI = "https://oauth2.googleapis.com/token";
|
|
7
|
+
var SCOPE = "https://www.googleapis.com/auth/cloud-platform";
|
|
8
|
+
|
|
9
|
+
// --- Service account key file ---
|
|
10
|
+
|
|
11
|
+
export function readKeyFile(path) {
|
|
12
|
+
var raw = readFileSync(path, "utf-8");
|
|
13
|
+
var key = JSON.parse(raw);
|
|
14
|
+
|
|
15
|
+
if (!key.client_email || !key.private_key) {
|
|
16
|
+
throw new Error(
|
|
17
|
+
"Invalid service account key file. Expected client_email and private_key fields."
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
clientEmail: key.client_email,
|
|
23
|
+
privateKey: key.private_key,
|
|
24
|
+
project: key.project_id || null,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// --- JWT → access token ---
|
|
29
|
+
|
|
30
|
+
export async function mintAccessToken(clientEmail, privateKey) {
|
|
31
|
+
var now = Math.floor(Date.now() / 1000);
|
|
32
|
+
|
|
33
|
+
var header = { alg: "RS256", typ: "JWT" };
|
|
34
|
+
var payload = {
|
|
35
|
+
iss: clientEmail,
|
|
36
|
+
scope: SCOPE,
|
|
37
|
+
aud: TOKEN_URI,
|
|
38
|
+
iat: now,
|
|
39
|
+
exp: now + 3600,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
var segments = [
|
|
43
|
+
base64url(JSON.stringify(header)),
|
|
44
|
+
base64url(JSON.stringify(payload)),
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
var sign = createSign("RSA-SHA256");
|
|
48
|
+
sign.update(segments.join("."));
|
|
49
|
+
var signature = sign.sign(privateKey, "base64url");
|
|
50
|
+
|
|
51
|
+
var jwt = segments.join(".") + "." + signature;
|
|
52
|
+
|
|
53
|
+
var res = await fetch(TOKEN_URI, {
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
56
|
+
body: `grant_type=${encodeURIComponent("urn:ietf:params:oauth:grant_type:jwt-bearer")}&assertion=${encodeURIComponent(jwt)}`,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (!res.ok) {
|
|
60
|
+
var text = await res.text();
|
|
61
|
+
throw new Error(`Token exchange failed: ${res.status} ${text}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
var data = await res.json();
|
|
65
|
+
return data.access_token;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function base64url(str) {
|
|
69
|
+
return Buffer.from(str).toString("base64url");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// --- GCP API ---
|
|
73
|
+
|
|
74
|
+
export async function gcpApi(method, url, body, token) {
|
|
75
|
+
var headers = {
|
|
76
|
+
Authorization: `Bearer ${token}`,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
if (body && typeof body === "object") {
|
|
80
|
+
headers["Content-Type"] = "application/json";
|
|
81
|
+
body = JSON.stringify(body);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
var res = await fetch(url, {
|
|
85
|
+
method,
|
|
86
|
+
headers,
|
|
87
|
+
body: method === "GET" ? undefined : body,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
if (!res.ok) {
|
|
91
|
+
var text = await res.text();
|
|
92
|
+
throw new Error(`GCP API ${method} ${url}: ${res.status} ${text}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return res.json();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function verifyProject(token, project) {
|
|
99
|
+
return gcpApi("GET", `${CRM_API}/projects/${project}`, null, token);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function listRegions(token, project) {
|
|
103
|
+
var res = await gcpApi(
|
|
104
|
+
"GET",
|
|
105
|
+
`${RUN_API}/projects/${project}/locations`,
|
|
106
|
+
null,
|
|
107
|
+
token
|
|
108
|
+
);
|
|
109
|
+
return res.locations || [];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function listServices(token, project, region) {
|
|
113
|
+
var res = await gcpApi(
|
|
114
|
+
"GET",
|
|
115
|
+
`${RUN_API}/projects/${project}/locations/${region}/services`,
|
|
116
|
+
null,
|
|
117
|
+
token
|
|
118
|
+
);
|
|
119
|
+
return res.services || [];
|
|
120
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
|
|
5
|
+
var CONFIG_DIR = join(homedir(), ".relight");
|
|
6
|
+
var CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
7
|
+
|
|
8
|
+
export { CONFIG_DIR, CONFIG_PATH };
|
|
9
|
+
|
|
10
|
+
export var CLOUD_NAMES = {
|
|
11
|
+
cf: "Cloudflare",
|
|
12
|
+
gcp: "GCP",
|
|
13
|
+
aws: "AWS",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export var CLOUD_IDS = Object.keys(CLOUD_NAMES);
|
|
17
|
+
|
|
18
|
+
export function getConfig() {
|
|
19
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
20
|
+
console.error("Not authenticated. Run `relight auth` first.");
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function tryGetConfig() {
|
|
27
|
+
if (!existsSync(CONFIG_PATH)) return null;
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function saveConfig(config) {
|
|
36
|
+
if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
|
|
37
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function getCloudConfig(cloudId) {
|
|
41
|
+
var config = getConfig();
|
|
42
|
+
var cloud = config.clouds && config.clouds[cloudId];
|
|
43
|
+
if (!cloud) {
|
|
44
|
+
console.error(
|
|
45
|
+
`Not authenticated with ${CLOUD_NAMES[cloudId] || cloudId}. Run \`relight auth --cloud ${cloudId}\` first.`
|
|
46
|
+
);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
return cloud;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getAuthenticatedClouds() {
|
|
53
|
+
var config = tryGetConfig();
|
|
54
|
+
if (!config || !config.clouds) return [];
|
|
55
|
+
return Object.keys(config.clouds).filter(
|
|
56
|
+
(id) => config.clouds[id] && Object.keys(config.clouds[id]).length > 0
|
|
57
|
+
);
|
|
58
|
+
}
|
package/src/lib/link.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, unlinkSync } from "fs";
|
|
2
|
+
import { fatal, fmt } from "./output.js";
|
|
3
|
+
|
|
4
|
+
var LINK_FILE = ".relight";
|
|
5
|
+
|
|
6
|
+
export function readLink() {
|
|
7
|
+
try {
|
|
8
|
+
var data = JSON.parse(readFileSync(LINK_FILE, "utf-8"));
|
|
9
|
+
return data;
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function linkApp(name, cloud) {
|
|
16
|
+
writeFileSync(LINK_FILE, JSON.stringify({ app: name, cloud }) + "\n");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function unlinkApp() {
|
|
20
|
+
try {
|
|
21
|
+
unlinkSync(LINK_FILE);
|
|
22
|
+
} catch {}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function resolveAppName(name) {
|
|
26
|
+
if (name) return name;
|
|
27
|
+
var linked = readLink();
|
|
28
|
+
if (linked && linked.app) return linked.app;
|
|
29
|
+
fatal(
|
|
30
|
+
"No app specified.",
|
|
31
|
+
`Provide an app name or run ${fmt.cmd("relight deploy")} in this directory first.`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function resolveCloud(cloud) {
|
|
36
|
+
if (cloud) return cloud;
|
|
37
|
+
var linked = readLink();
|
|
38
|
+
if (linked && linked.cloud) return linked.cloud;
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import kleur from "kleur";
|
|
2
|
+
|
|
3
|
+
export function phase(msg) {
|
|
4
|
+
process.stderr.write(`\n${kleur.bold().cyan("==>")} ${kleur.bold(msg)}\n`);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function status(msg) {
|
|
8
|
+
process.stderr.write(` ${msg}\n`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function error(msg, fix) {
|
|
12
|
+
process.stderr.write(`\n${kleur.red("Error:")} ${msg}\n`);
|
|
13
|
+
if (fix) process.stderr.write(` ${fix}\n`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function fatal(msg, fix) {
|
|
17
|
+
error(msg, fix);
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function success(msg) {
|
|
22
|
+
process.stderr.write(`\n${kleur.green("-->")} ${msg}\n`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function hint(label, msg) {
|
|
26
|
+
process.stderr.write(`\n${kleur.dim(label + ":")} ${msg}\n`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export var fmt = {
|
|
30
|
+
app: (name) => kleur.cyan(name),
|
|
31
|
+
cmd: (cmd) => kleur.bold().cyan(cmd),
|
|
32
|
+
key: (k) => kleur.green(k),
|
|
33
|
+
val: (v) => kleur.yellow(v),
|
|
34
|
+
dim: (t) => kleur.dim(t),
|
|
35
|
+
bold: (t) => kleur.bold(t),
|
|
36
|
+
url: (u) => kleur.underline().cyan(u),
|
|
37
|
+
cloud: (c) => kleur.magenta(c),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function stripAnsi(str) {
|
|
41
|
+
return String(str).replace(/\x1B\[[0-9;]*m/g, "");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function padEnd(str, len) {
|
|
45
|
+
var visible = stripAnsi(str).length;
|
|
46
|
+
return str + " ".repeat(Math.max(0, len - visible));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
var ADJECTIVES = [
|
|
50
|
+
"autumn", "bold", "calm", "crimson", "dawn", "dark", "dry", "dusk",
|
|
51
|
+
"fading", "flat", "floral", "fragrant", "frosty", "gentle", "green",
|
|
52
|
+
"hazy", "hidden", "icy", "lively", "long", "misty", "morning", "muddy",
|
|
53
|
+
"nameless", "old", "patient", "plain", "polished", "proud", "purple",
|
|
54
|
+
"quiet", "rapid", "red", "restless", "rough", "shy", "silent", "small",
|
|
55
|
+
"snowy", "solitary", "sparkling", "spring", "still", "summer", "twilight",
|
|
56
|
+
"wandering", "weathered", "white", "wild", "winter", "withered", "young",
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
var NOUNS = [
|
|
60
|
+
"bird", "brook", "bush", "cloud", "creek", "dew", "dream", "dust",
|
|
61
|
+
"field", "fire", "flower", "fog", "forest", "frost", "gale", "gate",
|
|
62
|
+
"glacier", "grass", "grove", "haze", "hill", "lake", "leaf", "light",
|
|
63
|
+
"meadow", "moon", "moss", "night", "paper", "path", "peak", "pine",
|
|
64
|
+
"pond", "rain", "reef", "ridge", "river", "rock", "rose", "sea",
|
|
65
|
+
"shadow", "shore", "sky", "smoke", "snow", "sound", "star", "stone",
|
|
66
|
+
"stream", "sun", "surf", "thunder", "tree", "violet", "water", "wave",
|
|
67
|
+
"wind", "wood",
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
export function generateAppName() {
|
|
71
|
+
var adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
|
|
72
|
+
var noun = NOUNS[Math.floor(Math.random() * NOUNS.length)];
|
|
73
|
+
var num = Math.floor(1000 + Math.random() * 9000);
|
|
74
|
+
return `${adj}-${noun}-${num}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function table(headers, rows) {
|
|
78
|
+
if (rows.length === 0) return "";
|
|
79
|
+
|
|
80
|
+
var allRows = [headers, ...rows];
|
|
81
|
+
var widths = [];
|
|
82
|
+
for (var row of allRows) {
|
|
83
|
+
for (var i = 0; i < row.length; i++) {
|
|
84
|
+
var len = stripAnsi(String(row[i] || "")).length;
|
|
85
|
+
if (!widths[i] || len > widths[i]) widths[i] = len;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
var lines = [];
|
|
90
|
+
lines.push(
|
|
91
|
+
headers
|
|
92
|
+
.map((h, i) => kleur.bold(padEnd(String(h), widths[i] + 2)))
|
|
93
|
+
.join("")
|
|
94
|
+
);
|
|
95
|
+
lines.push(kleur.dim(widths.map((w) => "─".repeat(w)).join(" ")));
|
|
96
|
+
for (var row of rows) {
|
|
97
|
+
lines.push(
|
|
98
|
+
row.map((cell, i) => padEnd(String(cell || ""), widths[i] + 2)).join("")
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return lines.join("\n");
|
|
103
|
+
}
|