@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 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 **newly-backend-stack** projects (Python api-service + Node auth-service) are CLI-deployable. Run `newly stack` to check. Specular-stack projects must be deployed from the Newly web app.
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
- Deploy the backend.
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 Postgres URL for the backend database. **Dev only.**
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
+ }
@@ -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 up\` when ready). Edit, then \`newly deploy\`.`);
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
+ }
@@ -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/api-service missing).');
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', 'api-service'))) {
70
- throw new Error('No backend/api-service in this directory. Run `newly pull <project-id>` first, ' +
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 { run_id } = await client.upload(`/newly_backend/deploy/${projectId}?env=dev`, snapshot, 'application/gzip');
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
- console.log('Deploying prod from the canonical project code (repo HEAD)…');
83
- const { run_id } = await client.post(`/newly_backend/deploy/${projectId}`, { env: 'prod' });
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('Deploy the backend server-side from your local code (dev only; prod ships via Promote in the web app)')
91
- .option('--env <env>', 'dev (prod is not supported via the CLI yet)', '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 is on the Specular stack (a single Node/drizzle backend), which `newly deploy` does not support. ' +
107
- 'Only newly-backend-stack projects (Python api-service + Node auth-service) are CLI-deployable.');
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
+ }