runcloud 0.1.2

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.
@@ -0,0 +1,155 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { ApiClient, ApiError, friendlyApiError } from '../api.js';
4
+ import { requireCredentials, resolveProject } from '../config.js';
5
+ import { createSnapshot } from '../archive.js';
6
+ import { parseSseFrames } from '../sse.js';
7
+ import { assertKnownServices, parseServiceFilter, readBackendServices } from '../services-manifest.js';
8
+ const POLL_INTERVAL_MS = 5_000;
9
+ const POLL_TIMEOUT_MS = 20 * 60 * 1000;
10
+ export async function pollRun(client, runId, opts = {}) {
11
+ const intervalMs = opts.intervalMs ?? POLL_INTERVAL_MS;
12
+ const timeoutMs = opts.timeoutMs ?? POLL_TIMEOUT_MS;
13
+ const deadline = Date.now() + timeoutMs;
14
+ for (;;) {
15
+ const run = await client.get(`/newly_backend/deploy_runs/${runId}`);
16
+ if (run.status !== 'running')
17
+ return run;
18
+ if (Date.now() > deadline)
19
+ throw new Error(`Deploy run ${runId} timed out after ${timeoutMs / 1000}s`);
20
+ await new Promise((r) => setTimeout(r, intervalMs));
21
+ }
22
+ }
23
+ export async function streamRun(client, runId, print = (s) => process.stdout.write(s)) {
24
+ let buffer = '';
25
+ let started = false;
26
+ try {
27
+ for await (const chunk of client.stream(`/newly_backend/deploy_runs/${runId}/logs/stream`)) {
28
+ started = true;
29
+ buffer = (buffer + chunk).replace(/\r\n/g, '\n');
30
+ const lastSep = buffer.lastIndexOf('\n\n');
31
+ if (lastSep === -1)
32
+ continue;
33
+ const ready = buffer.slice(0, lastSep + 2);
34
+ buffer = buffer.slice(lastSep + 2);
35
+ for (const frame of parseSseFrames(ready)) {
36
+ if (frame.event === 'done') {
37
+ return JSON.parse(frame.data);
38
+ }
39
+ try {
40
+ print(JSON.parse(frame.data));
41
+ }
42
+ catch {
43
+ print(frame.data);
44
+ }
45
+ }
46
+ }
47
+ }
48
+ catch (e) {
49
+ if (e instanceof ApiError && e.status === 404) {
50
+ return pollRun(client, runId);
51
+ }
52
+ if (started) {
53
+ return pollRun(client, runId);
54
+ }
55
+ throw e;
56
+ }
57
+ return pollRun(client, runId);
58
+ }
59
+ export function finishOrThrow(run, label) {
60
+ if (run.status === 'succeeded') {
61
+ console.log(`${label} deploy succeeded.`);
62
+ return;
63
+ }
64
+ if (run.failure_reason === 'no_backend_code') {
65
+ throw new Error('No backend code to deploy (backend/ missing).');
66
+ }
67
+ throw new Error(`${label} deploy failed (${run.failure_reason ?? 'unknown'}):\n${run.output_tail ?? ''}`);
68
+ }
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, ' +
72
+ 'or cd into a pulled project.');
73
+ }
74
+ console.log('Packaging local backend…');
75
+ const snapshot = await createSnapshot(dir);
76
+ const kib = snapshot.length < 1024 ? '<1' : Math.round(snapshot.length / 1024).toString();
77
+ console.log(`Uploading snapshot (${kib} KiB) and deploying server-side…`);
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');
82
+ const run = await streamRun(client, run_id);
83
+ finishOrThrow(run, 'dev');
84
+ }
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
+ });
93
+ const run = await streamRun(client, run_id);
94
+ finishOrThrow(run, 'prod');
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
+ }
112
+ export function registerDeploy(program) {
113
+ program
114
+ .command('deploy')
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')
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')
121
+ .option('--yes', 'skip the prod confirmation prompt', false)
122
+ .action(async (opts) => {
123
+ try {
124
+ if (opts.env !== 'dev' && opts.env !== 'prod')
125
+ throw new Error('--env must be dev or prod');
126
+ const creds = requireCredentials();
127
+ const projectId = resolveProject(process.cwd(), opts.project);
128
+ const client = new ApiClient(creds.apiUrl, creds.token);
129
+ const services = parseServiceFilter(opts.service);
130
+ assertKnownServices(readBackendServices(process.cwd()), services);
131
+ const stackInfo = await client.get(`/newly_backend/stack/${projectId}`);
132
+ if (!stackInfo.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);
148
+ }
149
+ }
150
+ catch (e) {
151
+ console.error(e instanceof ApiError ? friendlyApiError(e) : String(e instanceof Error ? e.message : e));
152
+ process.exitCode = 1;
153
+ }
154
+ });
155
+ }
@@ -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
+ }
@@ -0,0 +1,201 @@
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
+ appUrl: creds.appUrl,
11
+ };
12
+ }
13
+ export function summarizeDomain(candidate) {
14
+ const name = String(candidate.name ?? candidate.domain ?? candidate.domain_name ?? '(unknown)');
15
+ const state = candidate.registrable === true || candidate.available === true
16
+ ? 'available'
17
+ : candidate.registrable === false || candidate.available === false
18
+ ? 'unavailable'
19
+ : 'unknown';
20
+ const pricing = candidate.pricing ? ` ${JSON.stringify(candidate.pricing)}` : '';
21
+ const reason = candidate.reason ? ` ${String(candidate.reason)}` : '';
22
+ return `${name} ${state}${pricing}${reason}`;
23
+ }
24
+ export function renderProjectDomain(domain) {
25
+ return `${domain.id} ${domain.hostname} ${domain.status} ${domain.ownership_mode}`;
26
+ }
27
+ async function confirm(prompt) {
28
+ const rl = createInterface({ input, output });
29
+ try {
30
+ const answer = (await rl.question(`${prompt} Type "yes" to continue: `)).trim().toLowerCase();
31
+ return answer === 'yes';
32
+ }
33
+ finally {
34
+ rl.close();
35
+ }
36
+ }
37
+ export async function runDomainSearch(client, query, limit = '10', log = console.log) {
38
+ const params = new URLSearchParams({ q: query, limit });
39
+ const result = (await client.get(`/domains/search?${params.toString()}`));
40
+ const candidates = result.domains ?? [];
41
+ log(candidates.length ? candidates.map(summarizeDomain).join('\n') : 'No suggestions.');
42
+ }
43
+ export async function runDomainCheck(client, domainNames, log = console.log) {
44
+ const result = (await client.post('/domains/check', { domains: domainNames }));
45
+ const candidates = result.domains ?? [];
46
+ log(candidates.length ? candidates.map(summarizeDomain).join('\n') : 'No results.');
47
+ }
48
+ export async function runDomainBuy(client, projectId, domainName, opts, log = console.log, confirmPurchase = confirm) {
49
+ const quoteResult = (await client.post(`/projects/${projectId}/domains/registrations/quote`, {
50
+ domain: domainName,
51
+ years: opts.years,
52
+ }));
53
+ const quote = quoteResult.quote;
54
+ log(JSON.stringify(quote, null, 2));
55
+ const ok = opts.yes ||
56
+ (await confirmPurchase(`Domain registrations are not refundable once complete. Buy ${domainName}?`));
57
+ if (!ok) {
58
+ log('Cancelled.');
59
+ return;
60
+ }
61
+ const appUrl = (opts.appUrl ?? 'https://newly.app').replace(/\/+$/, '');
62
+ const result = (await client.post(`/projects/${projectId}/domains/registrations/checkout`, {
63
+ domain: domainName,
64
+ years: opts.years,
65
+ success_url: `${appUrl}/project/${projectId}/website?domain_checkout=success`,
66
+ cancel_url: `${appUrl}/project/${projectId}/website?domain_checkout=cancel`,
67
+ }));
68
+ if (!result.checkout_url)
69
+ throw new Error('Backend did not return a checkout URL.');
70
+ log(`Checkout: ${result.checkout_url}`);
71
+ if (result.order?.id)
72
+ log(`Order: ${result.order.id} (${result.order.status ?? 'checkout_created'})`);
73
+ }
74
+ export async function runDomainList(client, projectId, log = console.log) {
75
+ const result = (await client.get(`/projects/${projectId}/domains`));
76
+ const projectDomains = result.domains ?? [];
77
+ log(projectDomains.length ? projectDomains.map(renderProjectDomain).join('\n') : 'No domains.');
78
+ }
79
+ export async function runDomainConnect(client, projectId, hostname, opts, log = console.log) {
80
+ const result = await client.post(`/projects/${projectId}/domains/connect`, {
81
+ hostname,
82
+ deployment_id: opts.deployment,
83
+ ownership_mode: opts.managed ? 'managed_cloudflare' : 'byo_dns',
84
+ });
85
+ log(renderProjectDomain(result.domain));
86
+ log(JSON.stringify(result.dns_instructions, null, 2));
87
+ }
88
+ export async function runDomainStatus(client, projectId, hostnameOrId, log = console.log) {
89
+ const list = (await client.get(`/projects/${projectId}/domains`));
90
+ const projectDomains = list.domains ?? [];
91
+ const domain = hostnameOrId
92
+ ? projectDomains.find((item) => item.id === hostnameOrId || item.hostname === hostnameOrId)
93
+ : projectDomains[0];
94
+ if (!domain)
95
+ throw new Error('Domain not found.');
96
+ const refreshed = await client.post(`/projects/${projectId}/domains/${domain.id}/refresh`, {});
97
+ log(renderProjectDomain(refreshed.domain));
98
+ if (refreshed.dns_instructions)
99
+ log(JSON.stringify(refreshed.dns_instructions, null, 2));
100
+ }
101
+ export function registerDomains(program) {
102
+ const domains = program.command('domains').description('Search, buy, and connect domains');
103
+ domains
104
+ .command('search')
105
+ .argument('<query>')
106
+ .option('--limit <n>', 'number of suggestions', '10')
107
+ .description('Search available domains through Cloudflare Registrar')
108
+ .action(async (query, opts) => {
109
+ try {
110
+ const creds = requireCredentials();
111
+ const client = new ApiClient(creds.apiUrl, creds.token);
112
+ await runDomainSearch(client, query, opts.limit);
113
+ }
114
+ catch (e) {
115
+ console.error(friendlyApiError(e));
116
+ process.exitCode = 1;
117
+ }
118
+ });
119
+ domains
120
+ .command('check')
121
+ .argument('<domains...>')
122
+ .description('Check domain availability through Cloudflare Registrar')
123
+ .action(async (domainNames) => {
124
+ try {
125
+ const creds = requireCredentials();
126
+ const client = new ApiClient(creds.apiUrl, creds.token);
127
+ await runDomainCheck(client, domainNames);
128
+ }
129
+ catch (e) {
130
+ console.error(friendlyApiError(e));
131
+ process.exitCode = 1;
132
+ }
133
+ });
134
+ domains
135
+ .command('buy')
136
+ .argument('<domain>')
137
+ .option('--years <n>', 'registration term in years', '1')
138
+ .option('--project <id>', 'project id (defaults to the linked directory)')
139
+ .option('--yes', 'confirm purchase without an interactive prompt', false)
140
+ .description('Buy a domain through Cloudflare Registrar')
141
+ .action(async (domainName, opts) => {
142
+ try {
143
+ const { client, projectId, appUrl } = makeClient(opts.project);
144
+ await runDomainBuy(client, projectId, domainName, {
145
+ years: Number(opts.years),
146
+ yes: opts.yes,
147
+ appUrl,
148
+ });
149
+ }
150
+ catch (e) {
151
+ console.error(friendlyApiError(e));
152
+ process.exitCode = 1;
153
+ }
154
+ });
155
+ domains
156
+ .command('list')
157
+ .option('--project <id>', 'project id (defaults to the linked directory)')
158
+ .description('List domains connected to a project')
159
+ .action(async (opts) => {
160
+ try {
161
+ const { client, projectId } = makeClient(opts.project);
162
+ await runDomainList(client, projectId);
163
+ }
164
+ catch (e) {
165
+ console.error(friendlyApiError(e));
166
+ process.exitCode = 1;
167
+ }
168
+ });
169
+ domains
170
+ .command('connect')
171
+ .argument('<hostname>')
172
+ .option('--project <id>', 'project id (defaults to the linked directory)')
173
+ .option('--deployment <id>', 'deployment id to serve')
174
+ .option('--managed', 'domain DNS is managed by Newly/Cloudflare', false)
175
+ .description('Connect an existing domain to the published website')
176
+ .action(async (hostname, opts) => {
177
+ try {
178
+ const { client, projectId } = makeClient(opts.project);
179
+ await runDomainConnect(client, projectId, hostname, opts);
180
+ }
181
+ catch (e) {
182
+ console.error(friendlyApiError(e));
183
+ process.exitCode = 1;
184
+ }
185
+ });
186
+ domains
187
+ .command('status')
188
+ .argument('[hostname-or-id]')
189
+ .option('--project <id>', 'project id (defaults to the linked directory)')
190
+ .description('Show or refresh connected domain status')
191
+ .action(async (hostnameOrId, opts) => {
192
+ try {
193
+ const { client, projectId } = makeClient(opts.project);
194
+ await runDomainStatus(client, projectId, hostnameOrId);
195
+ }
196
+ catch (e) {
197
+ console.error(friendlyApiError(e));
198
+ process.exitCode = 1;
199
+ }
200
+ });
201
+ }
@@ -0,0 +1,86 @@
1
+ import { ApiClient, friendlyApiError } from '../api.js';
2
+ import { requireCredentials, resolveProject } from '../config.js';
3
+ import { assertDevOnly } from './misc.js';
4
+ function makeClient(project) {
5
+ const creds = requireCredentials();
6
+ return {
7
+ client: new ApiClient(creds.apiUrl, creds.token),
8
+ projectId: resolveProject(process.cwd(), project),
9
+ };
10
+ }
11
+ export async function getEmailStatus(client, projectId, env) {
12
+ return await client.get(`/newly_backend/email/status?project_id=${encodeURIComponent(projectId)}&env=${env}`);
13
+ }
14
+ export function formatEmailStatus(status) {
15
+ const lines = [
16
+ `email: ${status.configured ? 'configured' : 'not configured'}`,
17
+ `from: ${status.from ?? '(not set)'}`,
18
+ ];
19
+ if (status.requires_deploy)
20
+ lines.push('requires deploy: yes');
21
+ if (status.notes)
22
+ lines.push(`notes: ${status.notes}`);
23
+ return lines.join('\n');
24
+ }
25
+ export async function sendTestEmail(client, opts) {
26
+ return await client.post('/newly_backend/email/test', {
27
+ project_id: opts.projectId,
28
+ env: opts.env,
29
+ to: opts.to,
30
+ from: opts.from,
31
+ subject: opts.subject,
32
+ text: opts.text,
33
+ html: opts.html,
34
+ });
35
+ }
36
+ export function registerEmail(program) {
37
+ const email = program.command('email').description('Manage Newly Email');
38
+ email
39
+ .command('status')
40
+ .description('Show Newly Email configuration for this backend')
41
+ .option('--env <env>', 'dev or prod', 'dev')
42
+ .option('--project <id>', 'project id')
43
+ .action(async (opts) => {
44
+ try {
45
+ assertDevOnly(opts.env, 'email status');
46
+ const { client, projectId } = makeClient(opts.project);
47
+ console.log(formatEmailStatus(await getEmailStatus(client, projectId, opts.env)));
48
+ }
49
+ catch (e) {
50
+ console.error(friendlyApiError(e));
51
+ process.exitCode = 1;
52
+ }
53
+ });
54
+ email
55
+ .command('test')
56
+ .description('Send a test email through the deployed API service')
57
+ .requiredOption('--to <email>', 'recipient email address')
58
+ .option('--from <email>', 'sender email address; defaults to the backend sender setting')
59
+ .option('--subject <subject>', 'email subject', 'Newly email test')
60
+ .option('--text <text>', 'plain-text body', 'This is a test email from your Newly backend.')
61
+ .option('--html <html>', 'optional HTML body')
62
+ .option('--env <env>', 'dev or prod', 'dev')
63
+ .option('--project <id>', 'project id')
64
+ .action(async (opts) => {
65
+ try {
66
+ assertDevOnly(opts.env, 'email test');
67
+ const { client, projectId } = makeClient(opts.project);
68
+ const result = await sendTestEmail(client, {
69
+ projectId,
70
+ env: opts.env,
71
+ to: opts.to,
72
+ from: opts.from,
73
+ subject: opts.subject,
74
+ text: opts.text,
75
+ html: opts.html,
76
+ });
77
+ console.log(result.message_id
78
+ ? `Email sent. message_id=${result.message_id}`
79
+ : 'Email sent.');
80
+ }
81
+ catch (e) {
82
+ console.error(friendlyApiError(e));
83
+ process.exitCode = 1;
84
+ }
85
+ });
86
+ }
@@ -0,0 +1,17 @@
1
+ import { readProjectLink, writeProjectLink } from '../config.js';
2
+ export function registerLink(program) {
3
+ program
4
+ .command('link')
5
+ .argument('<project-id>', 'Newly project id')
6
+ .description('Associate the current directory with a project (no download) so deploy/status/logs work here')
7
+ .action((projectId) => {
8
+ const dir = process.cwd();
9
+ const existing = readProjectLink(dir);
10
+ writeProjectLink(dir, {
11
+ projectId,
12
+ pulledSha: existing?.projectId === projectId ? existing.pulledSha : null,
13
+ pulledAt: new Date().toISOString(),
14
+ });
15
+ console.log(`Linked ${dir} to project ${projectId}. You can now run \`newly deploy\`, \`newly status\`, etc. here.`);
16
+ });
17
+ }