@newly/cli 0.1.0 → 0.1.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 +69 -10
- package/dist/api.js +26 -0
- package/dist/commands/build.js +12 -0
- package/dist/commands/create.js +1 -1
- package/dist/commands/creds.js +103 -0
- package/dist/commands/deploy.js +55 -16
- package/dist/commands/dns.js +185 -0
- package/dist/commands/domains.js +201 -0
- package/dist/commands/email.js +86 -0
- package/dist/commands/login.js +25 -1
- package/dist/commands/misc.js +38 -11
- package/dist/commands/services.js +39 -0
- package/dist/commands/stack.js +13 -11
- package/dist/commands/status.js +10 -10
- package/dist/commands/website.js +218 -0
- package/dist/index.js +14 -2
- package/dist/newly-dev.js +10 -0
- package/dist/services-manifest.js +63 -0
- package/package.json +3 -2
- package/dist/commands/up.js +0 -85
package/README.md
CHANGED
|
@@ -18,6 +18,8 @@ newly pull <project-id> # download project code + link this directory
|
|
|
18
18
|
# edit code…
|
|
19
19
|
newly deploy # deploy to dev
|
|
20
20
|
newly deploy --env prod # ship to prod
|
|
21
|
+
newly services list # inspect backend services
|
|
22
|
+
newly email status # inspect Newly Email setup
|
|
21
23
|
```
|
|
22
24
|
|
|
23
25
|
## Auth
|
|
@@ -47,7 +49,7 @@ Most commands target the project linked to the current directory, so you don't p
|
|
|
47
49
|
|
|
48
50
|
Prod actions prompt for confirmation. Pass `--yes` to skip it in scripts.
|
|
49
51
|
|
|
50
|
-
> Only
|
|
52
|
+
> Only projects on the Newly backend stack are CLI-deployable. Run `newly stack` to check. Other projects must be deployed from the Newly web app.
|
|
51
53
|
|
|
52
54
|
## Commands
|
|
53
55
|
|
|
@@ -73,19 +75,20 @@ Show dev and prod status, service URLs, deployed SHA, and last error.
|
|
|
73
75
|
Show which backend stack the project uses and whether it's CLI-deployable.
|
|
74
76
|
- `--project <id>`
|
|
75
77
|
|
|
76
|
-
### `newly up`
|
|
77
|
-
Provision the backend (if not already) and deploy.
|
|
78
|
-
- `--env <dev|prod>` (default `dev`)
|
|
79
|
-
- `--region <region>` — region for dev provisioning (default `us-central1`)
|
|
80
|
-
- `--project <id>`
|
|
81
|
-
- `--yes` — skip the prod confirmation prompt
|
|
82
|
-
|
|
83
78
|
### `newly deploy`
|
|
84
|
-
|
|
79
|
+
Provision the backend if needed, then deploy.
|
|
85
80
|
- `--env <dev|prod>` (default `dev`)
|
|
81
|
+
- `--region <region>` — region for first-time dev provisioning (default `auto`)
|
|
86
82
|
- `--project <id>`
|
|
83
|
+
- `--service <names>` — comma-separated service names from `backend/newly.services.json`
|
|
84
|
+
- `--git-sha <sha>` — prod git revision to deploy (default `HEAD`)
|
|
87
85
|
- `--yes` — skip the prod confirmation prompt
|
|
88
86
|
|
|
87
|
+
### `newly services list`
|
|
88
|
+
List backend services from `backend/newly.services.json`.
|
|
89
|
+
- `--env <dev|prod>` (default `dev`) — used for rendered public URLs
|
|
90
|
+
- `--project <id>` — include expected public URLs for this project
|
|
91
|
+
|
|
89
92
|
### `newly logs`
|
|
90
93
|
Tail backend service logs. **Dev only.**
|
|
91
94
|
- `--service <api|auth>` (default `api`)
|
|
@@ -105,10 +108,66 @@ Set a backend secret. **Dev only.** Redeploy to apply.
|
|
|
105
108
|
- `--project <id>`
|
|
106
109
|
|
|
107
110
|
### `newly db url`
|
|
108
|
-
Print a readonly
|
|
111
|
+
Print a readonly URL for the Newly DB. **Dev only.**
|
|
112
|
+
- `--project <id>`
|
|
113
|
+
|
|
114
|
+
### `newly email status`
|
|
115
|
+
Show Newly Email configuration for the API service. **Dev only.**
|
|
109
116
|
- `--project <id>`
|
|
110
117
|
|
|
118
|
+
### `newly email test`
|
|
119
|
+
Send a test email through the deployed API service. **Dev only.**
|
|
120
|
+
- `--to <email>` — recipient address
|
|
121
|
+
- `--from <email>` — optional sender; defaults to the backend sender setting
|
|
122
|
+
- `--subject <subject>`
|
|
123
|
+
- `--text <text>`
|
|
124
|
+
- `--html <html>`
|
|
125
|
+
- `--project <id>`
|
|
126
|
+
|
|
127
|
+
## Dev mode
|
|
128
|
+
|
|
129
|
+
`newly-dev` is a convenience wrapper that points credentials at `~/.newly-dev` so dev and prod credentials never collide. The prod `newly` binary still defaults to `https://newly.app` and `~/.newly/credentials`, untouched.
|
|
130
|
+
|
|
131
|
+
### Headless login
|
|
132
|
+
|
|
133
|
+
For CI and dev modal-backend deploys where no web app is running to mint a CLI token, skip the browser entirely:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
newly-dev login \
|
|
137
|
+
--api-url https://your-dev-modal.modal.run/api \
|
|
138
|
+
--token <jwt> \
|
|
139
|
+
--expires-in 86400
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`--expires-in` is seconds. The same flags work on the prod `newly` binary.
|
|
143
|
+
|
|
144
|
+
### Env vars
|
|
145
|
+
|
|
146
|
+
| Var | Effect |
|
|
147
|
+
|---|---|
|
|
148
|
+
| `NEWLY_HOME` | Credentials directory. Explicit value always wins. |
|
|
149
|
+
| `NEWLY_DEV_HOME` | Credentials directory used by `newly-dev` (default: `~/.newly-dev`). |
|
|
150
|
+
| `NEWLY_APP_URL` | Default for `--app-url` at module load. |
|
|
151
|
+
| `NEWLY_DEV_APP_URL` | When set, `newly-dev` surfaces this as `NEWLY_APP_URL`. |
|
|
152
|
+
|
|
153
|
+
### Typical dev workflow
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
# 1. Deploy a personal dev modal-backend.
|
|
157
|
+
cd modal-backend
|
|
158
|
+
uv run ./deploy.sh --terminate
|
|
159
|
+
|
|
160
|
+
# 2. Grab the deployed URL, then log the dev CLI in headlessly.
|
|
161
|
+
newly-dev login --api-url https://your-dev-modal.modal.run/api --token <jwt>
|
|
162
|
+
|
|
163
|
+
# 3. Use newly-dev the same way as newly.
|
|
164
|
+
newly-dev create my-test
|
|
165
|
+
newly-dev status
|
|
166
|
+
newly-dev deploy
|
|
167
|
+
```
|
|
168
|
+
|
|
111
169
|
## Files
|
|
112
170
|
|
|
113
171
|
- `~/.newly/credentials` — session token (mode `600`). Override the directory with `NEWLY_HOME`.
|
|
172
|
+
- `~/.newly-dev/credentials` — `newly-dev` session token. Override the directory with `NEWLY_DEV_HOME`.
|
|
114
173
|
- `.newly/project.json` — per-directory project link.
|
package/dist/api.js
CHANGED
|
@@ -41,6 +41,12 @@ export class ApiClient {
|
|
|
41
41
|
post(path, body) {
|
|
42
42
|
return this.request('POST', path, body);
|
|
43
43
|
}
|
|
44
|
+
patch(path, body) {
|
|
45
|
+
return this.request('PATCH', path, body);
|
|
46
|
+
}
|
|
47
|
+
delete(path, body) {
|
|
48
|
+
return this.request('DELETE', path, body);
|
|
49
|
+
}
|
|
44
50
|
async upload(path, body, contentType) {
|
|
45
51
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
46
52
|
method: 'POST',
|
|
@@ -62,6 +68,26 @@ export class ApiClient {
|
|
|
62
68
|
const contentType_ = res.headers.get('content-type') ?? '';
|
|
63
69
|
return contentType_.includes('application/json') ? res.json() : res;
|
|
64
70
|
}
|
|
71
|
+
async uploadForm(path, form) {
|
|
72
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: {
|
|
75
|
+
Authorization: `Bearer ${this.token}`,
|
|
76
|
+
},
|
|
77
|
+
body: form,
|
|
78
|
+
});
|
|
79
|
+
if (!res.ok) {
|
|
80
|
+
let detail = await res.text();
|
|
81
|
+
try {
|
|
82
|
+
detail = JSON.parse(detail).detail ?? detail;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
}
|
|
86
|
+
throw new ApiError(res.status, detail);
|
|
87
|
+
}
|
|
88
|
+
const contentType = res.headers.get('content-type') ?? '';
|
|
89
|
+
return contentType.includes('application/json') ? res.json() : res;
|
|
90
|
+
}
|
|
65
91
|
async getBinary(path) {
|
|
66
92
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
67
93
|
method: 'GET',
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function buildPlaceholderMessage(platform) {
|
|
2
|
+
return (`In-house ${platform} builds are coming in an upcoming release.\n` +
|
|
3
|
+
'Set up iOS signing first with `newly creds apple` (check readiness with `newly creds apple status`).');
|
|
4
|
+
}
|
|
5
|
+
export function registerBuild(program) {
|
|
6
|
+
program
|
|
7
|
+
.command('build [platform]')
|
|
8
|
+
.description('Build the mobile app (coming soon)')
|
|
9
|
+
.action((platform = 'ios') => {
|
|
10
|
+
console.log(buildPlaceholderMessage(platform));
|
|
11
|
+
});
|
|
12
|
+
}
|
package/dist/commands/create.js
CHANGED
|
@@ -30,7 +30,7 @@ export async function runCreate(client, dir, opts) {
|
|
|
30
30
|
}
|
|
31
31
|
else {
|
|
32
32
|
console.log(`Created backend project ${project_id} and scaffolded backend/ here ` +
|
|
33
|
-
`(not provisioned — run \`newly
|
|
33
|
+
`(not provisioned — run \`newly deploy\` when ready).`);
|
|
34
34
|
}
|
|
35
35
|
return project_id;
|
|
36
36
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { ApiClient, friendlyApiError } from '../api.js';
|
|
3
|
+
import { requireCredentials, resolveProject } from '../config.js';
|
|
4
|
+
const TEAM_BUNDLE_CMD = 'newly creds apple --team-id <TEAM_ID> --bundle-id <BUNDLE_ID>';
|
|
5
|
+
const ASC_KEY_CMD = 'newly creds apple --asc-key <key.p8> --key-id <KEY_ID> --issuer-id <ISSUER_ID>';
|
|
6
|
+
export function buildReadiness(s) {
|
|
7
|
+
const missing = [];
|
|
8
|
+
const remediations = [];
|
|
9
|
+
if (!s.team_id)
|
|
10
|
+
missing.push('team id');
|
|
11
|
+
if (!s.bundle_id)
|
|
12
|
+
missing.push('bundle id');
|
|
13
|
+
if (!s.team_id || !s.bundle_id)
|
|
14
|
+
remediations.push(TEAM_BUNDLE_CMD);
|
|
15
|
+
if (!s.asc_api_key?.has_private_key) {
|
|
16
|
+
missing.push('ASC API key');
|
|
17
|
+
remediations.push(ASC_KEY_CMD);
|
|
18
|
+
}
|
|
19
|
+
return { ready: missing.length === 0, missing, remediations };
|
|
20
|
+
}
|
|
21
|
+
export function renderAppleStatus(s) {
|
|
22
|
+
const r = buildReadiness(s);
|
|
23
|
+
const lines = [
|
|
24
|
+
`team id: ${s.team_id ?? '— not set'}`,
|
|
25
|
+
`bundle id: ${s.bundle_id ?? '— not set'}`,
|
|
26
|
+
`ASC API key: ${s.asc_api_key?.has_private_key ? 'configured' : '— not set'}`,
|
|
27
|
+
];
|
|
28
|
+
if (r.ready) {
|
|
29
|
+
lines.push('', '✓ iOS build-ready.');
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
lines.push('', `Missing: ${r.missing.join(', ')}. To fix:`);
|
|
33
|
+
for (const cmd of r.remediations)
|
|
34
|
+
lines.push(` ${cmd}`);
|
|
35
|
+
}
|
|
36
|
+
return lines.join('\n');
|
|
37
|
+
}
|
|
38
|
+
export function planAppleCreds(opts) {
|
|
39
|
+
const setTeamInfo = Boolean(opts.teamId || opts.bundleId);
|
|
40
|
+
if (setTeamInfo && !(opts.teamId && opts.bundleId)) {
|
|
41
|
+
throw new Error('--team-id and --bundle-id must be provided together.');
|
|
42
|
+
}
|
|
43
|
+
const uploadAscKey = Boolean(opts.ascKey);
|
|
44
|
+
if (uploadAscKey && !opts.keyId) {
|
|
45
|
+
throw new Error('--asc-key requires --key-id.');
|
|
46
|
+
}
|
|
47
|
+
if (!setTeamInfo && !uploadAscKey) {
|
|
48
|
+
throw new Error('Nothing to do. Pass --team-id/--bundle-id and/or --asc-key. See `newly creds apple status`.');
|
|
49
|
+
}
|
|
50
|
+
return { setTeamInfo, uploadAscKey };
|
|
51
|
+
}
|
|
52
|
+
export async function runAppleCreds(opts, client, projectId, log = console.log) {
|
|
53
|
+
const plan = planAppleCreds(opts);
|
|
54
|
+
if (plan.setTeamInfo) {
|
|
55
|
+
await client.post(`/newly_backend/apple/team_info/${projectId}`, {
|
|
56
|
+
team_id: opts.teamId,
|
|
57
|
+
bundle_id: opts.bundleId,
|
|
58
|
+
});
|
|
59
|
+
log(`Set team id ${opts.teamId} and bundle id ${opts.bundleId}.`);
|
|
60
|
+
}
|
|
61
|
+
if (plan.uploadAscKey) {
|
|
62
|
+
const privateKeyBase64 = readFileSync(opts.ascKey).toString('base64');
|
|
63
|
+
await client.post(`/newly_backend/apple/asc_key/${projectId}`, {
|
|
64
|
+
key_id: opts.keyId,
|
|
65
|
+
issuer_id: opts.issuerId ?? '',
|
|
66
|
+
private_key_base64: privateKeyBase64,
|
|
67
|
+
});
|
|
68
|
+
log('Uploaded ASC API key.');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export function registerCreds(program) {
|
|
72
|
+
const creds = program.command('creds').description('Manage project credentials');
|
|
73
|
+
const apple = creds
|
|
74
|
+
.command('apple')
|
|
75
|
+
.argument('[action]', 'use "status" to show Apple credential readiness')
|
|
76
|
+
.description('Set Apple iOS build credentials (project owner only)')
|
|
77
|
+
.option('--team-id <id>', 'Apple Developer team ID')
|
|
78
|
+
.option('--bundle-id <id>', 'iOS bundle identifier')
|
|
79
|
+
.option('--asc-key <file>', 'path to an App Store Connect API key (.p8)')
|
|
80
|
+
.option('--key-id <id>', 'ASC API key ID (required with --asc-key)')
|
|
81
|
+
.option('--issuer-id <id>', 'ASC API issuer ID')
|
|
82
|
+
.option('--project <id>', 'project id (defaults to the linked directory)');
|
|
83
|
+
apple.action(async (action, opts) => {
|
|
84
|
+
try {
|
|
85
|
+
const creds = requireCredentials();
|
|
86
|
+
const projectId = resolveProject(process.cwd(), opts.project);
|
|
87
|
+
const client = new ApiClient(creds.apiUrl, creds.token);
|
|
88
|
+
if (action === 'status') {
|
|
89
|
+
const status = await client.get(`/newly_backend/apple/status/${projectId}`);
|
|
90
|
+
console.log(renderAppleStatus(status));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (action) {
|
|
94
|
+
throw new Error(`Unknown creds apple action "${action}". Use "status" or pass credential options.`);
|
|
95
|
+
}
|
|
96
|
+
await runAppleCreds(opts, client, projectId);
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
console.error(friendlyApiError(e));
|
|
100
|
+
process.exitCode = 1;
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
package/dist/commands/deploy.js
CHANGED
|
@@ -4,6 +4,7 @@ import { ApiClient, ApiError, friendlyApiError } from '../api.js';
|
|
|
4
4
|
import { requireCredentials, resolveProject } from '../config.js';
|
|
5
5
|
import { createSnapshot } from '../archive.js';
|
|
6
6
|
import { parseSseFrames } from '../sse.js';
|
|
7
|
+
import { assertKnownServices, parseServiceFilter, readBackendServices } from '../services-manifest.js';
|
|
7
8
|
const POLL_INTERVAL_MS = 5_000;
|
|
8
9
|
const POLL_TIMEOUT_MS = 20 * 60 * 1000;
|
|
9
10
|
export async function pollRun(client, runId, opts = {}) {
|
|
@@ -61,52 +62,90 @@ export function finishOrThrow(run, label) {
|
|
|
61
62
|
return;
|
|
62
63
|
}
|
|
63
64
|
if (run.failure_reason === 'no_backend_code') {
|
|
64
|
-
throw new Error('No backend code to deploy (backend/
|
|
65
|
+
throw new Error('No backend code to deploy (backend/ missing).');
|
|
65
66
|
}
|
|
66
67
|
throw new Error(`${label} deploy failed (${run.failure_reason ?? 'unknown'}):\n${run.output_tail ?? ''}`);
|
|
67
68
|
}
|
|
68
|
-
export async function runDevDeploy(projectId, client, dir) {
|
|
69
|
-
if (!existsSync(join(dir, 'backend'
|
|
70
|
-
throw new Error('No backend/
|
|
69
|
+
export async function runDevDeploy(projectId, client, dir, services = []) {
|
|
70
|
+
if (!existsSync(join(dir, 'backend'))) {
|
|
71
|
+
throw new Error('No backend/ in this directory. Run `newly pull <project-id>` first, ' +
|
|
71
72
|
'or cd into a pulled project.');
|
|
72
73
|
}
|
|
73
74
|
console.log('Packaging local backend…');
|
|
74
75
|
const snapshot = await createSnapshot(dir);
|
|
75
76
|
const kib = snapshot.length < 1024 ? '<1' : Math.round(snapshot.length / 1024).toString();
|
|
76
77
|
console.log(`Uploading snapshot (${kib} KiB) and deploying server-side…`);
|
|
77
|
-
const
|
|
78
|
+
const params = new URLSearchParams({ env: 'dev' });
|
|
79
|
+
for (const service of services)
|
|
80
|
+
params.append('services', service);
|
|
81
|
+
const { run_id } = await client.upload(`/newly_backend/deploy/${projectId}?${params}`, snapshot, 'application/gzip');
|
|
78
82
|
const run = await streamRun(client, run_id);
|
|
79
83
|
finishOrThrow(run, 'dev');
|
|
80
84
|
}
|
|
81
|
-
export async function runProdDeploy(projectId, client) {
|
|
82
|
-
|
|
83
|
-
|
|
85
|
+
export async function runProdDeploy(projectId, client, gitSha = 'HEAD', services = []) {
|
|
86
|
+
const label = gitSha === 'HEAD' ? 'repo HEAD' : gitSha;
|
|
87
|
+
console.log(`Deploying prod from the canonical project code (${label})…`);
|
|
88
|
+
const { run_id } = await client.post(`/newly_backend/deploy/${projectId}`, {
|
|
89
|
+
env: 'prod',
|
|
90
|
+
git_sha: gitSha,
|
|
91
|
+
services,
|
|
92
|
+
});
|
|
84
93
|
const run = await streamRun(client, run_id);
|
|
85
94
|
finishOrThrow(run, 'prod');
|
|
86
95
|
}
|
|
96
|
+
export async function ensureBackendProvisioned(client, projectId, env, opts = {}) {
|
|
97
|
+
const print = opts.print ?? console.log;
|
|
98
|
+
const status = await client.get(`/newly_backend/status/${projectId}`);
|
|
99
|
+
if (status[env])
|
|
100
|
+
return;
|
|
101
|
+
print(`Provisioning ${env} backend…`);
|
|
102
|
+
if (env === 'dev') {
|
|
103
|
+
await client.post(`/newly_backend/provision_dev/${projectId}`, {
|
|
104
|
+
dev_region: opts.region ?? 'auto',
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
await client.post(`/newly_backend/provision_prod/${projectId}`);
|
|
109
|
+
}
|
|
110
|
+
print(`Provisioned ${env} backend.`);
|
|
111
|
+
}
|
|
87
112
|
export function registerDeploy(program) {
|
|
88
113
|
program
|
|
89
114
|
.command('deploy')
|
|
90
|
-
.description('
|
|
91
|
-
.option('--env <env>', 'dev
|
|
115
|
+
.description('Provision if needed, then deploy the backend server-side')
|
|
116
|
+
.option('--env <env>', 'dev or prod', 'dev')
|
|
117
|
+
.option('--region <region>', "Newly server region hint for first-time dev provisioning (defaults to 'auto')", 'auto')
|
|
92
118
|
.option('--project <id>', 'project id (defaults to the linked directory)')
|
|
119
|
+
.option('--service <names>', 'comma-separated service names from backend/newly.services.json')
|
|
120
|
+
.option('--git-sha <sha>', 'prod git revision to deploy from canonical project code', 'HEAD')
|
|
93
121
|
.option('--yes', 'skip the prod confirmation prompt', false)
|
|
94
122
|
.action(async (opts) => {
|
|
95
123
|
try {
|
|
96
124
|
if (opts.env !== 'dev' && opts.env !== 'prod')
|
|
97
125
|
throw new Error('--env must be dev or prod');
|
|
98
|
-
if (opts.env === 'prod') {
|
|
99
|
-
throw new Error('Prod deploys are not supported via the CLI yet — use Promote in the Newly web app.');
|
|
100
|
-
}
|
|
101
126
|
const creds = requireCredentials();
|
|
102
127
|
const projectId = resolveProject(process.cwd(), opts.project);
|
|
103
128
|
const client = new ApiClient(creds.apiUrl, creds.token);
|
|
129
|
+
const services = parseServiceFilter(opts.service);
|
|
130
|
+
assertKnownServices(readBackendServices(process.cwd()), services);
|
|
104
131
|
const stackInfo = await client.get(`/newly_backend/stack/${projectId}`);
|
|
105
132
|
if (!stackInfo.cli_deployable) {
|
|
106
|
-
throw new Error('This project
|
|
107
|
-
|
|
133
|
+
throw new Error('This project does not support CLI deploys yet. Use the Newly web app to deploy it.');
|
|
134
|
+
}
|
|
135
|
+
if (opts.env === 'dev' && !existsSync(join(process.cwd(), 'backend'))) {
|
|
136
|
+
throw new Error('No backend/ in this directory. Run `newly pull <project-id>` first, ' +
|
|
137
|
+
'or cd into a pulled project.');
|
|
138
|
+
}
|
|
139
|
+
await ensureBackendProvisioned(client, projectId, opts.env, {
|
|
140
|
+
region: opts.region,
|
|
141
|
+
print: console.log,
|
|
142
|
+
});
|
|
143
|
+
if (opts.env === 'prod') {
|
|
144
|
+
await runProdDeploy(projectId, client, opts.gitSha, services);
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
await runDevDeploy(projectId, client, process.cwd(), services);
|
|
108
148
|
}
|
|
109
|
-
await runDevDeploy(projectId, client, process.cwd());
|
|
110
149
|
}
|
|
111
150
|
catch (e) {
|
|
112
151
|
console.error(e instanceof ApiError ? friendlyApiError(e) : String(e instanceof Error ? e.message : e));
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
2
|
+
import { createInterface } from 'node:readline/promises';
|
|
3
|
+
import { ApiClient, friendlyApiError } from '../api.js';
|
|
4
|
+
import { requireCredentials, resolveProject } from '../config.js';
|
|
5
|
+
function makeClient(project) {
|
|
6
|
+
const creds = requireCredentials();
|
|
7
|
+
return {
|
|
8
|
+
client: new ApiClient(creds.apiUrl, creds.token),
|
|
9
|
+
projectId: resolveProject(process.cwd(), project),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export async function resolveDomain(client, projectId, domainNameOrId) {
|
|
13
|
+
const result = (await client.get(`/projects/${projectId}/domains`));
|
|
14
|
+
const domains = result.domains ?? [];
|
|
15
|
+
const domain = domains.find((item) => item.id === domainNameOrId ||
|
|
16
|
+
item.hostname === domainNameOrId ||
|
|
17
|
+
item.root_domain === domainNameOrId);
|
|
18
|
+
if (!domain)
|
|
19
|
+
throw new Error(`Domain ${domainNameOrId} is not connected to this project.`);
|
|
20
|
+
return domain;
|
|
21
|
+
}
|
|
22
|
+
export function renderRecord(record) {
|
|
23
|
+
const ttl = record.ttl === undefined ? '' : ` ttl=${record.ttl}`;
|
|
24
|
+
const proxied = record.proxied === undefined ? '' : ` proxied=${record.proxied}`;
|
|
25
|
+
const priority = record.priority === undefined ? '' : ` priority=${record.priority}`;
|
|
26
|
+
return `${record.id} ${record.type} ${record.name} ${record.content}${ttl}${proxied}${priority}`;
|
|
27
|
+
}
|
|
28
|
+
async function confirm(prompt) {
|
|
29
|
+
const rl = createInterface({ input, output });
|
|
30
|
+
try {
|
|
31
|
+
const answer = (await rl.question(`${prompt} Type "yes" to continue: `)).trim().toLowerCase();
|
|
32
|
+
return answer === 'yes';
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
rl.close();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function numberOrUndefined(value, optionName = 'number') {
|
|
39
|
+
if (value === undefined)
|
|
40
|
+
return undefined;
|
|
41
|
+
const trimmed = value.trim();
|
|
42
|
+
const parsed = Number(trimmed);
|
|
43
|
+
if (!trimmed) {
|
|
44
|
+
throw new Error(`${optionName} must be a finite number.`);
|
|
45
|
+
}
|
|
46
|
+
if (!Number.isFinite(parsed)) {
|
|
47
|
+
throw new Error(`${optionName} must be a finite number.`);
|
|
48
|
+
}
|
|
49
|
+
return parsed;
|
|
50
|
+
}
|
|
51
|
+
export async function runDnsList(client, projectId, domainName, log = console.log) {
|
|
52
|
+
const domain = await resolveDomain(client, projectId, domainName);
|
|
53
|
+
const result = (await client.get(`/projects/${projectId}/domains/${domain.id}/dns-records`));
|
|
54
|
+
if (!result.editable) {
|
|
55
|
+
log(JSON.stringify(result.instructions, null, 2));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const records = result.records ?? [];
|
|
59
|
+
log(records.length ? records.map(renderRecord).join('\n') : 'No DNS records.');
|
|
60
|
+
}
|
|
61
|
+
export async function runDnsAdd(client, projectId, domainName, opts, log = console.log) {
|
|
62
|
+
const ttl = numberOrUndefined(opts.ttl, 'ttl');
|
|
63
|
+
const priority = numberOrUndefined(opts.priority, 'priority');
|
|
64
|
+
const domain = await resolveDomain(client, projectId, domainName);
|
|
65
|
+
const result = await client.post(`/projects/${projectId}/domains/${domain.id}/dns-records`, {
|
|
66
|
+
type: opts.type,
|
|
67
|
+
name: opts.name,
|
|
68
|
+
content: opts.content,
|
|
69
|
+
ttl,
|
|
70
|
+
proxied: opts.proxied,
|
|
71
|
+
priority,
|
|
72
|
+
comment: opts.comment,
|
|
73
|
+
});
|
|
74
|
+
log(renderRecord(result.record));
|
|
75
|
+
}
|
|
76
|
+
export async function runDnsEdit(client, projectId, domainName, recordId, opts, log = console.log) {
|
|
77
|
+
const body = {
|
|
78
|
+
type: opts.type,
|
|
79
|
+
name: opts.name,
|
|
80
|
+
content: opts.content,
|
|
81
|
+
ttl: numberOrUndefined(opts.ttl, 'ttl'),
|
|
82
|
+
proxied: opts.proxied,
|
|
83
|
+
priority: numberOrUndefined(opts.priority, 'priority'),
|
|
84
|
+
comment: opts.comment,
|
|
85
|
+
};
|
|
86
|
+
if (Object.values(body).every((value) => value === undefined)) {
|
|
87
|
+
throw new Error('Nothing to edit. Pass at least one record field.');
|
|
88
|
+
}
|
|
89
|
+
const domain = await resolveDomain(client, projectId, domainName);
|
|
90
|
+
const result = await client.patch(`/projects/${projectId}/domains/${domain.id}/dns-records/${recordId}`, body);
|
|
91
|
+
log(renderRecord(result.record));
|
|
92
|
+
}
|
|
93
|
+
export async function runDnsDelete(client, projectId, domainName, recordId, opts, log = console.log, confirmDelete = confirm) {
|
|
94
|
+
const domain = await resolveDomain(client, projectId, domainName);
|
|
95
|
+
const ok = opts.yes || (await confirmDelete(`Delete DNS record ${recordId} from ${domain.hostname}?`));
|
|
96
|
+
if (!ok) {
|
|
97
|
+
log('Cancelled.');
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
await client.delete(`/projects/${projectId}/domains/${domain.id}/dns-records/${recordId}`, {
|
|
101
|
+
confirm: true,
|
|
102
|
+
});
|
|
103
|
+
log(`Deleted ${recordId}.`);
|
|
104
|
+
}
|
|
105
|
+
export function registerDns(program) {
|
|
106
|
+
const dns = program.command('dns').description('Manage DNS records through Newly backend');
|
|
107
|
+
dns
|
|
108
|
+
.command('list')
|
|
109
|
+
.argument('<domain>')
|
|
110
|
+
.option('--project <id>', 'project id (defaults to the linked directory)')
|
|
111
|
+
.description('List DNS records for a managed domain')
|
|
112
|
+
.action(async (domainName, opts) => {
|
|
113
|
+
try {
|
|
114
|
+
const { client, projectId } = makeClient(opts.project);
|
|
115
|
+
await runDnsList(client, projectId, domainName);
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
console.error(friendlyApiError(e));
|
|
119
|
+
process.exitCode = 1;
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
dns
|
|
123
|
+
.command('add')
|
|
124
|
+
.argument('<domain>')
|
|
125
|
+
.requiredOption('--type <type>', 'record type')
|
|
126
|
+
.requiredOption('--name <name>', 'record name, or @ for apex')
|
|
127
|
+
.requiredOption('--content <content>', 'record content')
|
|
128
|
+
.option('--ttl <seconds>', 'record TTL', '300')
|
|
129
|
+
.option('--proxied', 'enable Cloudflare proxying')
|
|
130
|
+
.option('--priority <n>', 'MX priority')
|
|
131
|
+
.option('--comment <text>', 'record comment')
|
|
132
|
+
.option('--project <id>', 'project id (defaults to the linked directory)')
|
|
133
|
+
.description('Create a DNS record for a managed domain')
|
|
134
|
+
.action(async (domainName, opts) => {
|
|
135
|
+
try {
|
|
136
|
+
const { client, projectId } = makeClient(opts.project);
|
|
137
|
+
await runDnsAdd(client, projectId, domainName, opts);
|
|
138
|
+
}
|
|
139
|
+
catch (e) {
|
|
140
|
+
console.error(friendlyApiError(e));
|
|
141
|
+
process.exitCode = 1;
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
dns
|
|
145
|
+
.command('edit')
|
|
146
|
+
.argument('<domain>')
|
|
147
|
+
.argument('<record-id>')
|
|
148
|
+
.option('--type <type>', 'record type')
|
|
149
|
+
.option('--name <name>', 'record name, or @ for apex')
|
|
150
|
+
.option('--content <content>', 'record content')
|
|
151
|
+
.option('--ttl <seconds>', 'record TTL')
|
|
152
|
+
.option('--proxied', 'enable Cloudflare proxying')
|
|
153
|
+
.option('--no-proxied', 'disable Cloudflare proxying')
|
|
154
|
+
.option('--priority <n>', 'MX priority')
|
|
155
|
+
.option('--comment <text>', 'record comment')
|
|
156
|
+
.option('--project <id>', 'project id (defaults to the linked directory)')
|
|
157
|
+
.description('Edit a DNS record for a managed domain')
|
|
158
|
+
.action(async (domainName, recordId, opts) => {
|
|
159
|
+
try {
|
|
160
|
+
const { client, projectId } = makeClient(opts.project);
|
|
161
|
+
await runDnsEdit(client, projectId, domainName, recordId, opts);
|
|
162
|
+
}
|
|
163
|
+
catch (e) {
|
|
164
|
+
console.error(friendlyApiError(e));
|
|
165
|
+
process.exitCode = 1;
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
dns
|
|
169
|
+
.command('delete')
|
|
170
|
+
.argument('<domain>')
|
|
171
|
+
.argument('<record-id>')
|
|
172
|
+
.option('--project <id>', 'project id (defaults to the linked directory)')
|
|
173
|
+
.option('--yes', 'skip confirmation prompt', false)
|
|
174
|
+
.description('Delete a DNS record for a managed domain')
|
|
175
|
+
.action(async (domainName, recordId, opts) => {
|
|
176
|
+
try {
|
|
177
|
+
const { client, projectId } = makeClient(opts.project);
|
|
178
|
+
await runDnsDelete(client, projectId, domainName, recordId, opts);
|
|
179
|
+
}
|
|
180
|
+
catch (e) {
|
|
181
|
+
console.error(friendlyApiError(e));
|
|
182
|
+
process.exitCode = 1;
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
}
|