@newly/cli 0.1.0 → 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,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
+ }
@@ -1,8 +1,8 @@
1
1
  import http from 'node:http';
2
2
  import crypto from 'node:crypto';
3
3
  import { spawn } from 'node:child_process';
4
- import { clearCredentials, saveCredentials } from '../config.js';
5
- const DEFAULT_APP_URL = 'https://newly.app';
4
+ import { clearCredentials, credentialsDisplayPath, saveCredentials } from '../config.js';
5
+ const DEFAULT_APP_URL = process.env.NEWLY_APP_URL ?? 'https://newly.app';
6
6
  const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
7
7
  export async function startCallbackServer(state, appOrigin, timeoutMs) {
8
8
  let resolvePayload;
@@ -96,8 +96,32 @@ export function registerLogin(program) {
96
96
  .description('Log in with your Newly account via the browser')
97
97
  .option('--app-url <url>', 'Newly web app URL', DEFAULT_APP_URL)
98
98
  .option('--manual', 'paste the credential from the browser instead of using the local callback', false)
99
+ .option('--api-url <url>', 'explicit API URL (skips browser; use with --token)')
100
+ .option('--token <token>', 'explicit credential token (skips browser; use with --api-url)')
101
+ .option('--expires-in <seconds>', 'session lifetime when using --token (default 7 days)', (v) => Number(v), 604800)
99
102
  .action(async (opts) => {
100
103
  try {
104
+ const hasApiUrl = typeof opts.apiUrl === 'string' && opts.apiUrl.length > 0;
105
+ const hasToken = typeof opts.token === 'string' && opts.token.length > 0;
106
+ if (hasApiUrl !== hasToken) {
107
+ throw new Error('--api-url and --token must be provided together for headless login.');
108
+ }
109
+ if (hasApiUrl && hasToken) {
110
+ try {
111
+ new URL(opts.apiUrl);
112
+ }
113
+ catch {
114
+ throw new Error(`--api-url is not a valid URL: ${opts.apiUrl}`);
115
+ }
116
+ saveCredentials({
117
+ token: opts.token,
118
+ apiUrl: opts.apiUrl,
119
+ appUrl: opts.appUrl,
120
+ expiresAt: Math.floor(Date.now() / 1000) + opts.expiresIn,
121
+ });
122
+ console.log(`Logged in headlessly. Credentials written for API ${opts.apiUrl}.`);
123
+ return;
124
+ }
101
125
  if (opts.manual) {
102
126
  const { createInterface } = await import('node:readline/promises');
103
127
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -128,7 +152,7 @@ export function registerLogin(program) {
128
152
  appUrl: opts.appUrl,
129
153
  expiresAt: Math.floor(Date.now() / 1000) + payload.expiresIn,
130
154
  });
131
- console.log('Logged in. Credentials saved to ~/.newly/credentials');
155
+ console.log(`Logged in. Credentials saved to ${credentialsDisplayPath()}`);
132
156
  }
133
157
  finally {
134
158
  close();
@@ -30,13 +30,34 @@ export function resolveLogWindow(opts, now = Date.now()) {
30
30
  validateIso(opts.end);
31
31
  return { start, end: opts.end };
32
32
  }
33
- export async function fetchLogs(client, projectId, env, service, n, bounds = {}) {
34
- const body = { project_id: projectId, env, service, n };
33
+ export function formatLogEntry(e) {
34
+ if (!e || typeof e !== 'object')
35
+ return String(e);
36
+ const ts = e.ts ?? '-';
37
+ const sev = (e.severity ?? 'OK').padEnd(5);
38
+ const trace = e.trace_id ? ` trace=${e.trace_id.slice(0, 16)}` : '';
39
+ const name = e.name ?? '';
40
+ return `${ts} ${sev} ${name}${trace}`.trimEnd();
41
+ }
42
+ export async function fetchLogs(client, projectId, env, service, pageSize, bounds = {}, filters = {}) {
43
+ const params = new URLSearchParams();
44
+ params.set('env', env);
45
+ params.set('service', service);
46
+ params.set('page_size', String(pageSize));
35
47
  if (bounds.start)
36
- body.start = bounds.start;
48
+ params.set('start', bounds.start);
37
49
  if (bounds.end)
38
- body.end = bounds.end;
39
- const out = await client.post('/newly_backend/tail_logs', body);
50
+ params.set('end', bounds.end);
51
+ if (filters.severityMin)
52
+ params.set('severity_min', filters.severityMin);
53
+ if (filters.traceId)
54
+ params.set('trace_id', filters.traceId);
55
+ if (filters.textSearch)
56
+ params.set('text_search', filters.textSearch);
57
+ const out = await client.get(`/newly_backend/logs/${encodeURIComponent(projectId)}?${params.toString()}`);
58
+ if (Array.isArray(out.entries)) {
59
+ return out.entries.map((e) => formatLogEntry(e)).join('\n');
60
+ }
40
61
  if (typeof out.logs === 'string')
41
62
  return out.logs;
42
63
  if (Array.isArray(out.lines))
@@ -75,18 +96,24 @@ export function registerMisc(program) {
75
96
  .command('logs')
76
97
  .description('Tail backend service logs')
77
98
  .option('--env <env>', 'dev or prod', 'dev')
78
- .option('--service <service>', 'api or auth', 'api')
79
- .option('-n, --lines <n>', 'number of lines', '200')
99
+ .option('--service <service>', 'api | auth', 'api')
100
+ .option('-n, --lines <n>', 'page size (number of log entries to fetch)', '200')
80
101
  .option('--since <duration>', 'relative window, e.g. 5m, 1h, 30s')
81
102
  .option('--start <iso>', 'window start (ISO 8601 timestamp)')
82
103
  .option('--end <iso>', 'window end (ISO 8601 timestamp)')
104
+ .option('--severity <min>', 'min severity: ok | warn | error')
105
+ .option('--trace <id>', 'restrict to a single trace_id')
106
+ .option('--search <text>', 'substring filter on the entry name')
83
107
  .option('--project <id>', 'project id')
84
108
  .action(async (opts) => {
85
109
  try {
86
110
  assertDevOnly(opts.env, 'logs');
111
+ if (opts.service !== 'api' && opts.service !== 'auth') {
112
+ throw new Error(`--service must be 'api' or 'auth' (got ${JSON.stringify(opts.service)})`);
113
+ }
87
114
  const bounds = resolveLogWindow(opts);
88
115
  const { client, projectId } = makeClient(opts.project);
89
- console.log(await fetchLogs(client, projectId, opts.env, opts.service, Number(opts.lines), bounds));
116
+ console.log(await fetchLogs(client, projectId, opts.env, opts.service, Number(opts.lines), bounds, { severityMin: opts.severity, traceId: opts.trace, textSearch: opts.search }));
90
117
  }
91
118
  catch (e) {
92
119
  console.error(friendlyApiError(e));
@@ -97,7 +124,7 @@ export function registerMisc(program) {
97
124
  secrets
98
125
  .command('list')
99
126
  .option('--env <env>', 'dev or prod', 'dev')
100
- .option('--service <service>', 'api or auth', 'api')
127
+ .option('--service <service>', 'backend service name', 'worker')
101
128
  .option('--project <id>', 'project id')
102
129
  .action(async (opts) => {
103
130
  try {
@@ -115,7 +142,7 @@ export function registerMisc(program) {
115
142
  .argument('<key>')
116
143
  .argument('<value>')
117
144
  .option('--env <env>', 'dev or prod', 'dev')
118
- .option('--service <service>', 'api or auth', 'api')
145
+ .option('--service <service>', 'backend service name', 'worker')
119
146
  .option('--project <id>', 'project id')
120
147
  .action(async (key, value, opts) => {
121
148
  try {
@@ -131,7 +158,7 @@ export function registerMisc(program) {
131
158
  });
132
159
  const db = program.command('db').description('Database helpers');
133
160
  db.command('url')
134
- .description('Print a readonly Postgres URL for the backend database')
161
+ .description('Print a readonly URL for the Newly DB')
135
162
  .option('--env <env>', 'dev or prod', 'dev')
136
163
  .option('--project <id>', 'project id')
137
164
  .action(async (opts) => {