runcloud 0.1.2 → 0.1.3

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,290 @@
1
+ import { createInterface } from 'node:readline/promises';
2
+ import { ApiClient, friendlyApiError } from '../api.js';
3
+ import { runEnvironmentDatabaseShell } from '../environmentShell.js';
4
+ import { clearEnvironmentSelection, readEnvironmentSelection, requireCredentials, requireEnvironmentSelection, resolveProject, writeEnvironmentSelection, } from '../config.js';
5
+ const POLL_INTERVAL_MS = 2_000;
6
+ const POLL_TIMEOUT_MS = 10 * 60 * 1_000;
7
+ function client() {
8
+ const credentials = requireCredentials();
9
+ return new ApiClient(credentials.apiUrl, credentials.token);
10
+ }
11
+ async function projectEnvironments(api, projectId) {
12
+ const response = await api.get(`/projects/${encodeURIComponent(projectId)}/environments`);
13
+ return response.environments;
14
+ }
15
+ function resolveEnvironment(rows, value) {
16
+ const matches = rows.filter((row) => row.id === value || row.name === value);
17
+ if (matches.length === 0)
18
+ throw new Error(`Environment not found: ${value}`);
19
+ if (matches.length > 1)
20
+ throw new Error(`Environment name is ambiguous; use its ID: ${value}`);
21
+ return matches[0];
22
+ }
23
+ export function resolveNamedResource(rows, value, kind) {
24
+ if (!value) {
25
+ if (rows.length === 1)
26
+ return rows[0];
27
+ if (rows.length === 0)
28
+ throw new Error(`This environment has no ${kind}s.`);
29
+ throw new Error(`This environment has multiple ${kind}s; specify a name or ID.`);
30
+ }
31
+ const matches = rows.filter((row) => row.id === value || row.name === value);
32
+ if (matches.length === 0)
33
+ throw new Error(`${kind} not found: ${value}`);
34
+ if (matches.length > 1)
35
+ throw new Error(`${kind} name is ambiguous; use its ID: ${value}`);
36
+ return matches[0];
37
+ }
38
+ export function formatEnvironmentLog(entry) {
39
+ return `${entry.timestamp || '-'} ${entry.level.padEnd(5)} ${entry.message}`;
40
+ }
41
+ export function resolveSourceEnvironment(rows, value) {
42
+ const source = value
43
+ ? resolveEnvironment(rows, value)
44
+ : (() => {
45
+ const production = rows.filter((row) => row.class === 'production');
46
+ if (production.length === 0)
47
+ throw new Error('Production environment is not ready yet.');
48
+ if (production.length > 1)
49
+ throw new Error('Production environment is ambiguous; use --from <id>.');
50
+ return production[0];
51
+ })();
52
+ if (source.class !== 'production') {
53
+ throw new Error('--from must identify the production environment');
54
+ }
55
+ if (source.status !== 'ready') {
56
+ throw new Error(`Production environment is not ready yet (${source.stage}).`);
57
+ }
58
+ return source;
59
+ }
60
+ async function waitForEnvironment(api, environmentId, terminal) {
61
+ const deadline = Date.now() + POLL_TIMEOUT_MS;
62
+ for (;;) {
63
+ const environment = await api.get(`/environments/${encodeURIComponent(environmentId)}`);
64
+ if (terminal.has(environment.status))
65
+ return environment;
66
+ if (Date.now() >= deadline) {
67
+ throw new Error(`Timed out waiting for environment ${environment.name} (${environment.stage}).`);
68
+ }
69
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
70
+ }
71
+ }
72
+ function printEnvironment(environment) {
73
+ console.log(`${environment.name} ${environment.id} ${environment.status} ${environment.region}`);
74
+ for (const database of environment.databases) {
75
+ console.log(`database ${database.name} ${database.status}`);
76
+ }
77
+ for (const service of environment.services) {
78
+ console.log(`service ${service.name} ${service.status}`);
79
+ }
80
+ for (const route of environment.routes) {
81
+ console.log(`route ${route.status} https://${route.hostname}`);
82
+ }
83
+ if (environment.lastError)
84
+ console.log(`error ${environment.lastError}`);
85
+ }
86
+ async function confirmDelete(name) {
87
+ if (!process.stdin.isTTY)
88
+ return false;
89
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
90
+ try {
91
+ const answer = await rl.question(`Delete environment ${name}? Type its name to confirm: `);
92
+ return answer === name;
93
+ }
94
+ finally {
95
+ rl.close();
96
+ }
97
+ }
98
+ export function registerEnvironment(program) {
99
+ const env = program.command('env').description('Create, select, and operate environments');
100
+ env.command('list')
101
+ .description('List environments for the linked project')
102
+ .option('--project <id>', 'project id (defaults to the linked directory)')
103
+ .action(async (opts) => {
104
+ try {
105
+ const projectId = resolveProject(process.cwd(), opts.project);
106
+ const selected = readEnvironmentSelection(process.cwd())?.id;
107
+ for (const row of await projectEnvironments(client(), projectId)) {
108
+ console.log(`${row.id === selected ? '*' : ' '} ${row.name}\t${row.id}\t${row.status}\t${row.region}`);
109
+ }
110
+ }
111
+ catch (error) {
112
+ console.error(friendlyApiError(error));
113
+ process.exitCode = 1;
114
+ }
115
+ });
116
+ env.command('use')
117
+ .description('Select an environment for this workspace')
118
+ .argument('<environment>', 'environment name or id')
119
+ .option('--project <id>', 'project id (defaults to the linked directory)')
120
+ .action(async (value, opts) => {
121
+ try {
122
+ const projectId = resolveProject(process.cwd(), opts.project);
123
+ const selected = resolveEnvironment(await projectEnvironments(client(), projectId), value);
124
+ writeEnvironmentSelection(process.cwd(), {
125
+ id: selected.id,
126
+ name: selected.name,
127
+ projectId,
128
+ });
129
+ console.log(`Using ${selected.name} (${selected.id}) in this workspace.`);
130
+ }
131
+ catch (error) {
132
+ console.error(friendlyApiError(error));
133
+ process.exitCode = 1;
134
+ }
135
+ });
136
+ env.command('current')
137
+ .description('Show the selected environment')
138
+ .action(() => {
139
+ const selected = readEnvironmentSelection(process.cwd());
140
+ if (!selected) {
141
+ console.log('No environment selected.');
142
+ return;
143
+ }
144
+ console.log(`${selected.name} (${selected.id})`);
145
+ });
146
+ env.command('clear')
147
+ .description('Clear the selected environment')
148
+ .action(() => {
149
+ clearEnvironmentSelection(process.cwd());
150
+ console.log('Environment selection cleared.');
151
+ });
152
+ env.command('create')
153
+ .description('Fork production and select the new environment')
154
+ .argument('<name>', 'new environment name')
155
+ .option('--from <environment>', 'source production environment name or id')
156
+ .option('--region <region>', 'region (defaults to the source region)')
157
+ .option('--project <id>', 'project id (defaults to the linked directory)')
158
+ .action(async (name, opts) => {
159
+ try {
160
+ const projectId = resolveProject(process.cwd(), opts.project);
161
+ const api = client();
162
+ const source = resolveSourceEnvironment(await projectEnvironments(api, projectId), opts.from);
163
+ console.log(`Creating environment ${name} from ${source.name}…`);
164
+ const created = await api.post(`/projects/${encodeURIComponent(projectId)}/environments`, {
165
+ name,
166
+ sourceEnvironmentId: source.id,
167
+ region: opts.region ?? source.region,
168
+ });
169
+ const ready = await waitForEnvironment(api, created.id, new Set(['ready', 'failed']));
170
+ if (ready.status === 'failed') {
171
+ throw new Error(`Environment provisioning failed at ${ready.stage}: ${ready.lastError ?? 'unknown error'}`);
172
+ }
173
+ writeEnvironmentSelection(process.cwd(), { id: ready.id, name: ready.name, projectId });
174
+ printEnvironment(ready);
175
+ console.log(`\nUsing ${ready.name} (${ready.id}) in this workspace.`);
176
+ console.log('Deploy with: newly deploy');
177
+ }
178
+ catch (error) {
179
+ console.error(friendlyApiError(error));
180
+ process.exitCode = 1;
181
+ }
182
+ });
183
+ env.command('status')
184
+ .description('Show the selected environment and its resources')
185
+ .option('--project <id>', 'project id (defaults to the linked directory)')
186
+ .action(async (opts) => {
187
+ try {
188
+ const projectId = resolveProject(process.cwd(), opts.project);
189
+ const selected = requireEnvironmentSelection(process.cwd(), projectId);
190
+ printEnvironment(await client().get(`/environments/${encodeURIComponent(selected.id)}`));
191
+ }
192
+ catch (error) {
193
+ console.error(friendlyApiError(error));
194
+ process.exitCode = 1;
195
+ }
196
+ });
197
+ env.command('logs')
198
+ .description('Show logs for a service in the selected environment')
199
+ .argument('[service]', 'service name or id (optional when there is only one)')
200
+ .option('-n, --lines <count>', 'maximum log entries', '100')
201
+ .option('--since <timestamp>', 'ISO timestamp (defaults to one hour ago)')
202
+ .option('-f, --follow', 'keep polling for new logs', false)
203
+ .option('--project <id>', 'project id (defaults to the linked directory)')
204
+ .action(async (value, opts) => {
205
+ try {
206
+ const projectId = resolveProject(process.cwd(), opts.project);
207
+ const selected = requireEnvironmentSelection(process.cwd(), projectId);
208
+ const api = client();
209
+ const environment = await api.get(`/environments/${encodeURIComponent(selected.id)}`);
210
+ const service = resolveNamedResource(environment.services, value, 'service');
211
+ const lines = Number(opts.lines);
212
+ if (!Number.isInteger(lines) || lines < 1 || lines > 2_000) {
213
+ throw new Error('--lines must be an integer from 1 to 2000');
214
+ }
215
+ let since = opts.since ?? new Date(Date.now() - 3_600_000).toISOString();
216
+ if (Number.isNaN(new Date(since).getTime()))
217
+ throw new Error('--since must be an ISO timestamp');
218
+ const seen = new Set();
219
+ for (;;) {
220
+ const result = await api.get(`/environment-services/${encodeURIComponent(service.id)}/logs` +
221
+ `?limit=${lines}&since=${encodeURIComponent(since)}`);
222
+ for (const entry of result.entries) {
223
+ if (seen.has(entry.id))
224
+ continue;
225
+ seen.add(entry.id);
226
+ console.log(formatEnvironmentLog(entry));
227
+ if (entry.timestamp && new Date(entry.timestamp).getTime() >= new Date(since).getTime()) {
228
+ since = entry.timestamp;
229
+ }
230
+ }
231
+ if (!opts.follow)
232
+ break;
233
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
234
+ }
235
+ }
236
+ catch (error) {
237
+ console.error(friendlyApiError(error));
238
+ process.exitCode = 1;
239
+ }
240
+ });
241
+ const database = env.command('db').description('Operate databases in the selected environment');
242
+ database.command('shell')
243
+ .description('Open a Postgres shell for an environment database')
244
+ .argument('[database]', 'database name or id (optional when there is only one)')
245
+ .option('--project <id>', 'project id (defaults to the linked directory)')
246
+ .action(async (value, opts) => {
247
+ try {
248
+ const projectId = resolveProject(process.cwd(), opts.project);
249
+ const selected = requireEnvironmentSelection(process.cwd(), projectId);
250
+ const credentials = requireCredentials();
251
+ const environment = await new ApiClient(credentials.apiUrl, credentials.token).get(`/environments/${encodeURIComponent(selected.id)}`);
252
+ const resource = resolveNamedResource(environment.databases, value, 'database');
253
+ if (resource.status !== 'ready')
254
+ throw new Error(`Database is not ready (${resource.status}).`);
255
+ process.exitCode = await runEnvironmentDatabaseShell({
256
+ apiUrl: credentials.apiUrl,
257
+ token: credentials.token,
258
+ databaseId: resource.id,
259
+ });
260
+ }
261
+ catch (error) {
262
+ console.error(friendlyApiError(error));
263
+ process.exitCode = 1;
264
+ }
265
+ });
266
+ env.command('delete')
267
+ .description('Delete the selected environment')
268
+ .option('--yes', 'skip interactive confirmation', false)
269
+ .option('--project <id>', 'project id (defaults to the linked directory)')
270
+ .action(async (opts) => {
271
+ try {
272
+ const projectId = resolveProject(process.cwd(), opts.project);
273
+ const selected = requireEnvironmentSelection(process.cwd(), projectId);
274
+ if (!opts.yes && !await confirmDelete(selected.name))
275
+ throw new Error('Deletion cancelled.');
276
+ const api = client();
277
+ await api.delete(`/environments/${encodeURIComponent(selected.id)}`);
278
+ const deleted = await waitForEnvironment(api, selected.id, new Set(['deleted', 'failed']));
279
+ if (deleted.status === 'failed') {
280
+ throw new Error(`Environment deletion failed: ${deleted.lastError ?? 'unknown error'}`);
281
+ }
282
+ clearEnvironmentSelection(process.cwd());
283
+ console.log(`Deleted ${selected.name} (${selected.id}).`);
284
+ }
285
+ catch (error) {
286
+ console.error(friendlyApiError(error));
287
+ process.exitCode = 1;
288
+ }
289
+ });
290
+ }
@@ -2,7 +2,6 @@ import http from 'node:http';
2
2
  import crypto from 'node:crypto';
3
3
  import { spawn } from 'node:child_process';
4
4
  import { clearCredentials, credentialsDisplayPath, saveCredentials } from '../config.js';
5
- const DEFAULT_APP_URL = process.env.NEWLY_APP_URL ?? 'https://newly.app';
6
5
  const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
7
6
  export async function startCallbackServer(state, appOrigin, timeoutMs) {
8
7
  let resolvePayload;
@@ -91,10 +90,13 @@ export function parseManualCredential(blob) {
91
90
  return { token: c.token, expiresIn: c.expiresIn, apiUrl: c.apiUrl };
92
91
  }
93
92
  export function registerLogin(program) {
93
+ const defaultAppUrl = program.name() === 'runcloud'
94
+ ? process.env.RUN_CLOUD_APP_URL ?? 'https://run.cloud'
95
+ : process.env.NEWLY_APP_URL ?? 'https://newly.app';
94
96
  program
95
97
  .command('login')
96
98
  .description('Log in with your Newly account via the browser')
97
- .option('--app-url <url>', 'Newly web app URL', DEFAULT_APP_URL)
99
+ .option('--app-url <url>', 'product web app URL', defaultAppUrl)
98
100
  .option('--manual', 'paste the credential from the browser instead of using the local callback', false)
99
101
  .option('--api-url <url>', 'explicit API URL (skips browser; use with --token)')
100
102
  .option('--token <token>', 'explicit credential token (skips browser; use with --api-url)')