runcloud 0.2.178 → 0.2.180

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.
@@ -1,6 +1,6 @@
1
1
  import { stdin as input, stdout as output } from 'node:process';
2
2
  import { createInterface } from 'node:readline/promises';
3
- import { ApiClient, friendlyApiError } from '../api.js';
3
+ import { ApiClient, ApiError, friendlyApiError } from '../api.js';
4
4
  import { requireCredentials, resolveProject } from '../config.js';
5
5
  export function renderPendingRegistration(order) {
6
6
  const action = `run \`newly domains connect-registered ${order.domain}\``;
@@ -27,9 +27,9 @@ export function summarizeDomain(candidate) {
27
27
  return `${name} ${state}${pricing}${reason}`;
28
28
  }
29
29
  export function renderProjectDomain(domain) {
30
- return `${domain.id} ${domain.hostname} ${domain.status} ${domain.ownership_mode}`;
30
+ return `${domain.id} ${domain.hostname} ${domain.status} ${domain.ownership_mode} email: ${domain.email_status ?? 'not set up'}`;
31
31
  }
32
- async function confirm(prompt) {
32
+ export async function confirm(prompt) {
33
33
  const rl = createInterface({ input, output });
34
34
  try {
35
35
  const answer = (await rl.question(`${prompt} Type "yes" to continue: `)).trim().toLowerCase();
@@ -116,7 +116,16 @@ export async function runDomainStatus(client, projectId, hostnameOrId, log = con
116
116
  }
117
117
  if (!domain)
118
118
  throw new Error('Domain not found.');
119
- const refreshed = await client.post(`/projects/${projectId}/domains/${domain.id}/refresh`, {});
119
+ let refreshed;
120
+ try {
121
+ refreshed = await client.post(`/projects/${projectId}/domains/${domain.id}/refresh`, {});
122
+ }
123
+ catch (e) {
124
+ if (!(e instanceof ApiError && e.status === 403))
125
+ throw e;
126
+ log(renderProjectDomain(domain));
127
+ return;
128
+ }
120
129
  log(renderProjectDomain(refreshed.domain));
121
130
  if (refreshed.dns_instructions)
122
131
  log(JSON.stringify(refreshed.dns_instructions, null, 2));
@@ -1,13 +1,263 @@
1
- export const EMAIL_RETIRED_MESSAGE = '`newly email` was retired: it never had a server to talk to. ' +
2
- 'There is no CLI replacement; send mail from your API service with your own provider.';
1
+ import { readFileSync, mkdtempSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { ApiClient, ApiError, friendlyApiError } from '../api.js';
5
+ import { requireCredentials, resolveProject } from '../config.js';
6
+ import { confirm } from './domains.js';
7
+ export const ASK_USER_TO_SEND = 'Ask the user to review and send it in Newly';
8
+ export const NEEDS_TERMINAL = 'Sending needs you at a terminal. Review and send it in Newly instead.';
9
+ const stdinIsTty = () => Boolean(process.stdin.isTTY);
10
+ const base = (projectId) => `/projects/${projectId}/email`;
11
+ const show = (log, value) => log(JSON.stringify(value, null, 2));
12
+ const campaignLine = (c) => `${c.id} ${c.status} ${c.name}`;
13
+ export async function runEmailSettings(client, projectId, opts, log = console.log) {
14
+ const body = {
15
+ ...(opts.senderName !== undefined && { sender_name: opts.senderName }),
16
+ ...(opts.postalAddress !== undefined && { postal_address: opts.postalAddress }),
17
+ ...(opts.replyTo !== undefined && { reply_to: opts.replyTo }),
18
+ };
19
+ show(log, Object.keys(body).length
20
+ ? await client.put(`${base(projectId)}/settings`, body)
21
+ : await client.get(`${base(projectId)}/settings`));
22
+ }
23
+ export async function runContactsCount(client, projectId, log = console.log) {
24
+ show(log, await client.get(`${base(projectId)}/contacts/counts`));
25
+ }
26
+ export async function runContactsList(client, projectId, opts, log = console.log) {
27
+ const params = new URLSearchParams();
28
+ if (opts.status)
29
+ params.set('status', opts.status);
30
+ if (opts.q)
31
+ params.set('q', opts.q);
32
+ const query = params.toString();
33
+ show(log, await client.get(`${base(projectId)}/contacts${query ? `?${query}` : ''}`));
34
+ }
35
+ export async function runCampaignList(client, projectId, log = console.log) {
36
+ const { campaigns = [] } = (await client.get(`${base(projectId)}/campaigns`));
37
+ log(campaigns.length ? campaigns.map(campaignLine).join('\n') : 'No campaigns.');
38
+ }
39
+ function readHtml(path) {
40
+ try {
41
+ return readFileSync(path, 'utf8');
42
+ }
43
+ catch {
44
+ throw new Error(`Could not read ${path}`);
45
+ }
46
+ }
47
+ export async function runCampaignDraft(client, projectId, opts, log = console.log) {
48
+ const body = {
49
+ ...(opts.name !== undefined && { name: opts.name }),
50
+ ...(opts.subject !== undefined && { subject: opts.subject }),
51
+ ...(opts.previewText !== undefined && { preview_text: opts.previewText }),
52
+ ...(opts.topic !== undefined && { topic_key: opts.topic }),
53
+ ...(opts.htmlFile !== undefined && { html: readHtml(opts.htmlFile) }),
54
+ };
55
+ if (!opts.id && !opts.name)
56
+ throw new Error('A new campaign needs --name.');
57
+ const saved = (opts.id
58
+ ? await client.patch(`${base(projectId)}/campaigns/${opts.id}`, body)
59
+ : await client.post(`${base(projectId)}/campaigns`, body));
60
+ log(campaignLine(saved));
61
+ }
62
+ export async function runCampaignPreview(client, projectId, id, log = console.log) {
63
+ const campaign = (await client.get(`${base(projectId)}/campaigns/${id}`));
64
+ const file = join(mkdtempSync(join(tmpdir(), 'newly-campaign-')), 'preview.html');
65
+ writeFileSync(file, campaign.html);
66
+ log(file);
67
+ }
68
+ export async function runCampaignTest(client, projectId, id, log = console.log) {
69
+ await client.post(`${base(projectId)}/campaigns/${id}/test`, {});
70
+ log('Test email sent to your account address.');
71
+ }
72
+ export async function runCampaignStatus(client, projectId, id, log = console.log) {
73
+ const campaign = (await client.get(`${base(projectId)}/campaigns/${id}`));
74
+ const estimate = (await client.get(`${base(projectId)}/campaigns/${id}/estimate`));
75
+ log(campaignLine(campaign));
76
+ show(log, { estimate });
77
+ }
78
+ async function confirmedPost(client, projectId, id, action, extra, verb, log, confirmFn, isTty) {
79
+ if (!isTty())
80
+ throw new Error(NEEDS_TERMINAL);
81
+ const campaign = (await client.get(`${base(projectId)}/campaigns/${id}`));
82
+ const estimate = (await client.get(`${base(projectId)}/campaigns/${id}/estimate`));
83
+ log(`${campaign.name}: ${estimate.recipients} recipients, ${estimate.credits} credits.`);
84
+ if (!(await confirmFn(`${verb} "${campaign.name}"?`))) {
85
+ log('Cancelled.');
86
+ return;
87
+ }
88
+ const result = (await client.post(`${base(projectId)}/campaigns/${id}/${action}`, {
89
+ ...extra,
90
+ expected_updated_at: campaign.updated_at,
91
+ }));
92
+ log(campaignLine(result));
93
+ }
94
+ export const runCampaignSend = (client, projectId, id, log = console.log, confirmFn = confirm, isTty = stdinIsTty) => confirmedPost(client, projectId, id, 'send', {}, 'Send', log, confirmFn, isTty);
95
+ export const runCampaignSchedule = (client, projectId, id, opts, log = console.log, confirmFn = confirm, isTty = stdinIsTty) => confirmedPost(client, projectId, id, 'schedule', { at: opts.at, timezone: opts.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone }, `Schedule for ${opts.at}`, log, confirmFn, isTty);
96
+ export const USER_SESSION_ONLY = 'This needs a signed-in user session. Ask the user to do it in Newly.';
97
+ const PENDING_NOTE = 'Newly checks every minute; this usually takes 5 to 30 minutes after the records are added.';
98
+ const relativeName = (name, root) => name === root ? '@' : name.endsWith(`.${root}`) ? name.slice(0, -root.length - 1) : name;
99
+ function recordLine(r, root) {
100
+ const content = r.type === 'TXT' ? r.content.replace(/^"(.*)"$/, '$1') : r.content;
101
+ const value = r.priority === undefined ? content : `${r.priority} ${content}`;
102
+ const note = r.optional ? ' (optional, you already have one)' : '';
103
+ return ` ${r.type} ${r.name} (relative: ${relativeName(r.name, root)}) ${value}${note}`;
104
+ }
105
+ function domainLines(d) {
106
+ const status = d.status ?? 'pending';
107
+ const head = `${d.hostname} ${d.kind} ${status}`;
108
+ if (d.kind !== 'own' || status === 'verified')
109
+ return [head];
110
+ return [
111
+ head,
112
+ ...(d.reason ? [` ${d.reason.replace(/ Press Check now.*/i, '')}`] : []),
113
+ ...(d.records ?? []).map((r) => recordLine(r, d.root_domain)),
114
+ ];
115
+ }
116
+ const RECORDS_POLL_MS = 3_000;
117
+ const RECORDS_POLL_TRIES = 10;
118
+ const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
119
+ async function waitForRecords(client, projectId, claim, sleep) {
120
+ let d = claim;
121
+ for (let i = 0; i < RECORDS_POLL_TRIES && !d.records && d.status !== 'verified' && d.status !== 'failed'; i++) {
122
+ await sleep(RECORDS_POLL_MS);
123
+ const { domains = [] } = (await client.get(`${base(projectId)}/domains`));
124
+ d = domains.find((x) => x.id === claim.id) ?? d;
125
+ }
126
+ return d;
127
+ }
128
+ export async function runEmailDomainAdd(client, projectId, hostname, log = console.log, sleep = realSleep) {
129
+ const claim = (await client.post(`${base(projectId)}/domains`, { hostname }));
130
+ if (claim.kind === 'managed') {
131
+ log(`Newly manages DNS for ${claim.hostname}, so email is set up automatically. There is nothing to add.`);
132
+ return;
133
+ }
134
+ if (claim.status === 'verified') {
135
+ log(`${claim.hostname} is verified. Email sends from it.`);
136
+ return;
137
+ }
138
+ if (!claim.records)
139
+ log(`Preparing the DNS records for ${claim.hostname}...`);
140
+ const d = await waitForRecords(client, projectId, claim, sleep);
141
+ if (d.status === 'failed' && d.reason)
142
+ log(d.reason.replace(/ Press Check now.*/i, ''));
143
+ if (!d.records) {
144
+ log('The records are not ready yet. Run "newly email domain status" in a minute to see them.');
145
+ return;
146
+ }
147
+ log(`Add these DNS records at your DNS provider for ${d.hostname}:`);
148
+ log(d.records.map((r) => recordLine(r, d.root_domain)).join('\n'));
149
+ log(`Status: ${d.status ?? 'pending'}. ${PENDING_NOTE}`);
150
+ }
151
+ export async function runEmailDomainStatus(client, projectId, log = console.log) {
152
+ const { domains = [] } = (await client.get(`${base(projectId)}/domains`));
153
+ log(domains.length ? domains.flatMap(domainLines).join('\n') : 'No email domains.');
154
+ }
155
+ export function emailDomainErrorMessage(e) {
156
+ return e instanceof ApiError && e.status === 403 ? USER_SESSION_ONLY : friendlyApiError(e);
157
+ }
158
+ export function emailErrorMessage(e, isSend = false) {
159
+ return isSend && e instanceof ApiError && e.status === 403 ? ASK_USER_TO_SEND : friendlyApiError(e);
160
+ }
3
161
  export function registerEmail(program) {
4
- program
5
- .command('email')
6
- .description('Retired: send mail from your API service instead')
7
- .allowUnknownOption()
8
- .allowExcessArguments()
9
- .action(() => {
10
- console.error(EMAIL_RETIRED_MESSAGE);
11
- process.exitCode = 1;
12
- });
162
+ const email = program.command('email').description('Marketing email: settings, contacts and campaigns');
163
+ const run = (fn, isSend = false) => async (opts) => {
164
+ try {
165
+ const creds = requireCredentials();
166
+ await fn(new ApiClient(creds.apiUrl, creds.token), resolveProject(process.cwd(), opts.project));
167
+ }
168
+ catch (e) {
169
+ console.error(emailErrorMessage(e, isSend));
170
+ process.exitCode = 1;
171
+ }
172
+ };
173
+ const project = '--project <id>';
174
+ const projectHelp = 'project id (defaults to the linked directory)';
175
+ email
176
+ .command('settings')
177
+ .option(project, projectHelp)
178
+ .option('--sender-name <name>')
179
+ .option('--postal-address <address>')
180
+ .option('--reply-to <email>')
181
+ .description('Show email settings, or update them when a flag is given')
182
+ .action((opts) => run((c, p) => runEmailSettings(c, p, opts))(opts));
183
+ const contacts = email.command('contacts').description('Marketing contacts');
184
+ contacts
185
+ .command('count')
186
+ .option(project, projectHelp)
187
+ .action((opts) => run((c, p) => runContactsCount(c, p))(opts));
188
+ contacts
189
+ .command('list')
190
+ .option(project, projectHelp)
191
+ .option('--status <status>')
192
+ .option('--q <text>', 'search by email')
193
+ .action((opts) => run((c, p) => runContactsList(c, p, opts))(opts));
194
+ const campaign = email.command('campaign').description('Draft, preview, test and send campaigns');
195
+ campaign
196
+ .command('list')
197
+ .option(project, projectHelp)
198
+ .action((opts) => run((c, p) => runCampaignList(c, p))(opts));
199
+ campaign
200
+ .command('draft')
201
+ .option(project, projectHelp)
202
+ .option('--id <id>', 'edit this draft instead of creating one')
203
+ .option('--name <name>')
204
+ .option('--subject <subject>')
205
+ .option('--preview-text <text>')
206
+ .option('--topic <key>')
207
+ .option('--html-file <path>')
208
+ .description('Create a draft campaign, or update one with --id')
209
+ .action((opts) => run((c, p) => runCampaignDraft(c, p, opts))(opts));
210
+ campaign
211
+ .command('preview')
212
+ .argument('<id>')
213
+ .option(project, projectHelp)
214
+ .description('Write the campaign HTML to a temp file and print its path')
215
+ .action((id, opts) => run((c, p) => runCampaignPreview(c, p, id))(opts));
216
+ campaign
217
+ .command('test')
218
+ .argument('<id>')
219
+ .option(project, projectHelp)
220
+ .description('Send a test email to your own account address')
221
+ .action((id, opts) => run((c, p) => runCampaignTest(c, p, id))(opts));
222
+ campaign
223
+ .command('status')
224
+ .argument('<id>')
225
+ .option(project, projectHelp)
226
+ .action((id, opts) => run((c, p) => runCampaignStatus(c, p, id))(opts));
227
+ campaign
228
+ .command('send')
229
+ .argument('<id>')
230
+ .option(project, projectHelp)
231
+ .description('Send a campaign now (needs a terminal and confirmation)')
232
+ .action((id, opts) => run((c, p) => runCampaignSend(c, p, id), true)(opts));
233
+ campaign
234
+ .command('schedule')
235
+ .argument('<id>')
236
+ .requiredOption('--at <iso>', 'ISO date and time')
237
+ .option('--timezone <iana>', 'IANA timezone (defaults to this machine)')
238
+ .option(project, projectHelp)
239
+ .description('Schedule a campaign (needs a terminal and confirmation)')
240
+ .action((id, opts) => run((c, p) => runCampaignSchedule(c, p, id, opts), true)(opts));
241
+ const domain = email.command('domain').description('Send email from a domain you own');
242
+ const runDomain = (fn) => async (opts) => {
243
+ try {
244
+ const creds = requireCredentials();
245
+ await fn(new ApiClient(creds.apiUrl, creds.token), resolveProject(process.cwd(), opts.project));
246
+ }
247
+ catch (e) {
248
+ console.error(emailDomainErrorMessage(e));
249
+ process.exitCode = 1;
250
+ }
251
+ };
252
+ domain
253
+ .command('add')
254
+ .argument('<hostname>')
255
+ .option(project, projectHelp)
256
+ .description('Claim a domain and print the DNS records to add')
257
+ .action((hostname, opts) => runDomain((c, p) => runEmailDomainAdd(c, p, hostname))(opts));
258
+ domain
259
+ .command('status')
260
+ .option(project, projectHelp)
261
+ .description('List email domains and the records still to add')
262
+ .action((opts) => runDomain((c, p) => runEmailDomainStatus(c, p))(opts));
13
263
  }
@@ -0,0 +1,61 @@
1
+ import { ApiClient, ApiError, friendlyApiError } from '../api.js';
2
+ import { requireCredentials, resolveProject } from '../config.js';
3
+ export async function getGithubStatus(client, projectId) {
4
+ return (await client.get(`/projects/${encodeURIComponent(projectId)}/github/status?ahead=1`));
5
+ }
6
+ export async function pullGithubBranch(client, projectId, branch) {
7
+ await client.post(`/projects/${encodeURIComponent(projectId)}/git/branches/${encodeURIComponent(branch)}/push`);
8
+ }
9
+ export function formatGithubBranches(status) {
10
+ if (!status.connected)
11
+ return 'This project is not linked to a GitHub repository.';
12
+ const repo = `${status.owner}/${status.name}`;
13
+ if (status.ahead === undefined)
14
+ return `Linked to ${repo}, but GitHub could not be read right now. Try again shortly.`;
15
+ if (status.ahead.length === 0)
16
+ return `Linked to ${repo}. Newly has every GitHub branch at its current tip.`;
17
+ return [
18
+ `Linked to ${repo}. On GitHub, not yet in Newly at their current tip:`,
19
+ ...status.ahead.map((b) => ` ${b}`),
20
+ 'Bring one in with: newly github pull <branch>',
21
+ ].join('\n');
22
+ }
23
+ export function registerGithub(program) {
24
+ const github = program
25
+ .command('github')
26
+ .description("Work with the project's linked GitHub repository");
27
+ const run = (action) => async (opts) => {
28
+ try {
29
+ const creds = requireCredentials();
30
+ const projectId = resolveProject(process.cwd(), opts.project);
31
+ await action(new ApiClient(creds.apiUrl, creds.token), projectId);
32
+ }
33
+ catch (e) {
34
+ console.error(friendlyApiError(e));
35
+ process.exitCode = 1;
36
+ }
37
+ };
38
+ github
39
+ .command('branches')
40
+ .description('List GitHub branches that Newly does not have yet (or has at an older tip)')
41
+ .option('--project <id>', 'project id (defaults to the linked directory)')
42
+ .action(run(async (client, projectId) => {
43
+ console.log(formatGithubBranches(await getGithubStatus(client, projectId)));
44
+ }));
45
+ github
46
+ .command('pull <branch>')
47
+ .description('Bring one GitHub branch into Newly (a two-way sync of that branch) so it can be fetched and checked out')
48
+ .option('--project <id>', 'project id (defaults to the linked directory)')
49
+ .action((branch, opts) => run(async (client, projectId) => {
50
+ try {
51
+ await pullGithubBranch(client, projectId, branch);
52
+ }
53
+ catch (e) {
54
+ if (e instanceof ApiError && e.status === 403) {
55
+ throw new Error(`Pulling ${branch} needs a project admin. Ask the user to pull it from the GitHub pane in the Deploy tab.`);
56
+ }
57
+ throw e;
58
+ }
59
+ console.log(`${branch} is in Newly, synced both ways with GitHub. Run: git fetch origin ${branch} && git checkout ${branch}`);
60
+ })(opts));
61
+ }
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ import { registerPull } from './commands/pull.js';
10
10
  import { registerDeploy } from './commands/deploy.js';
11
11
  import { registerMisc } from './commands/misc.js';
12
12
  import { registerStack } from './commands/stack.js';
13
+ import { registerGithub } from './commands/github.js';
13
14
  import { registerCreds } from './commands/creds.js';
14
15
  import { registerBuild } from './commands/build.js';
15
16
  import { registerServices } from './commands/services.js';
@@ -37,6 +38,7 @@ registerDeploy(program);
37
38
  registerAi(program);
38
39
  registerMisc(program);
39
40
  registerStack(program);
41
+ registerGithub(program);
40
42
  registerCreds(program);
41
43
  registerBuild(program);
42
44
  registerServices(program);
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const CLI_VERSION = '0.2.178';
1
+ export const CLI_VERSION = '0.2.180';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runcloud",
3
- "version": "0.2.178",
3
+ "version": "0.2.180",
4
4
  "description": "Create and control run.cloud remote mobile simulators and cloud sandboxes",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
Binary file
Binary file