@newly/cli 0.1.1 → 0.1.27

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,154 +1,125 @@
1
- import { existsSync } from 'node:fs';
2
- import { join } from 'node:path';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join, resolve } from 'node:path';
4
+ import { build } from 'esbuild';
3
5
  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;
6
+ import { requireCredentials, requireEnvironmentSelection, resolveProject } from '../config.js';
7
+ import { assertKnownServices, readBackendServices } from '../services-manifest.js';
8
+ const POLL_INTERVAL_MS = 2_000;
9
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
- }
10
+ function sha256(bytes) {
11
+ return createHash('sha256').update(bytes).digest('hex');
22
12
  }
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
- }
13
+ function serviceEntry(dir, service) {
14
+ const serviceRoot = resolve(dir, 'backend', service.path);
15
+ const candidates = [];
16
+ const packagePath = join(serviceRoot, 'package.json');
17
+ if (existsSync(packagePath)) {
18
+ const pkg = JSON.parse(readFileSync(packagePath, 'utf8'));
19
+ for (const value of [pkg.module, pkg.main]) {
20
+ if (typeof value === 'string')
21
+ candidates.push(resolve(serviceRoot, value));
46
22
  }
47
23
  }
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;
24
+ candidates.push(join(serviceRoot, 'src/index.ts'), join(serviceRoot, 'src/index.js'), join(serviceRoot, 'index.ts'), join(serviceRoot, 'index.js'));
25
+ if (service.name === 'api') {
26
+ candidates.push(join(dir, 'backend/src/index.ts'), join(dir, 'backend/src/index.js'));
56
27
  }
57
- return pollRun(client, runId);
28
+ const entry = candidates.find(existsSync);
29
+ if (!entry) {
30
+ throw new Error(`Could not find an entry point for service ${service.name} under backend/${service.path}`);
31
+ }
32
+ return entry;
33
+ }
34
+ export async function bundleService(dir, service) {
35
+ const result = await build({
36
+ entryPoints: [serviceEntry(dir, service)],
37
+ bundle: true,
38
+ write: false,
39
+ format: 'esm',
40
+ platform: 'browser',
41
+ target: 'es2022',
42
+ conditions: ['workerd', 'worker', 'browser'],
43
+ sourcemap: false,
44
+ legalComments: 'none',
45
+ });
46
+ const output = result.outputFiles[0];
47
+ if (!output)
48
+ throw new Error(`Bundling service ${service.name} produced no output`);
49
+ return Buffer.from(output.contents);
58
50
  }
59
- export function finishOrThrow(run, label) {
60
- if (run.status === 'succeeded') {
61
- console.log(`${label} deploy succeeded.`);
62
- return;
51
+ export async function pollDeployment(client, deploymentId, opts = {}) {
52
+ const intervalMs = opts.intervalMs ?? POLL_INTERVAL_MS;
53
+ const deadline = Date.now() + (opts.timeoutMs ?? POLL_TIMEOUT_MS);
54
+ for (;;) {
55
+ const deployment = (await client.get(`/service-deployments/${deploymentId}`));
56
+ if (['ready', 'failed', 'superseded'].includes(deployment.status))
57
+ return deployment;
58
+ if (Date.now() > deadline)
59
+ throw new Error(`Deployment ${deploymentId} timed out`);
60
+ await new Promise((done) => setTimeout(done, intervalMs));
63
61
  }
64
- if (run.failure_reason === 'no_backend_code') {
65
- throw new Error('No backend code to deploy (backend/ missing).');
62
+ }
63
+ export async function deployService(client, dir, service, serviceId) {
64
+ console.log(`Building ${service.name}…`);
65
+ const bundle = await bundleService(dir, service);
66
+ const sourceDigest = sha256(bundle);
67
+ const configurationSpec = {
68
+ name: service.name,
69
+ type: service.type,
70
+ public: service.public ?? true,
71
+ bindings: service.bindings ?? {},
72
+ };
73
+ const deployment = (await client.postWithHeaders(`/environment-services/${serviceId}/deployments`, { bundleBase64: bundle.toString('base64'), sourceDigest, configurationSpec }, { 'Idempotency-Key': randomUUID() }));
74
+ const finished = await pollDeployment(client, deployment.id);
75
+ if (finished.status !== 'ready') {
76
+ throw new Error(`Deployment ${finished.id} finished with status ${finished.status}${finished.lastError ? `: ${finished.lastError}` : ''}`);
66
77
  }
67
- throw new Error(`${label} deploy failed (${run.failure_reason ?? 'unknown'}):\n${run.output_tail ?? ''}`);
78
+ console.log(`Deployed ${service.name} (${finished.id}).`);
79
+ return finished;
68
80
  }
69
- export async function runDevDeploy(projectId, client, dir, services = []) {
81
+ export async function runEnvironmentDeploy(projectId, environmentId, client, dir, selectedNames = []) {
70
82
  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.');
83
+ throw new Error('No backend/ in this directory. Run `newly pull <project-id>` first, or cd into a pulled project.');
73
84
  }
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
- });
85
+ const manifestServices = readBackendServices(dir);
86
+ assertKnownServices(manifestServices, selectedNames);
87
+ const selected = selectedNames.length
88
+ ? manifestServices.filter((service) => selectedNames.includes(service.name))
89
+ : manifestServices;
90
+ const environment = (await client.get(`/environments/${environmentId}`));
91
+ if (environment.projectId !== projectId)
92
+ throw new Error('The selected environment belongs to another project.');
93
+ if (environment.status !== 'ready')
94
+ throw new Error(`Environment is ${environment.status}; wait until it is ready.`);
95
+ const serviceIds = new Map(environment.services.map((service) => [service.name, service.id]));
96
+ const undeclared = selected.filter((service) => !serviceIds.has(service.name));
97
+ if (undeclared.length) {
98
+ throw new Error(`Environment does not declare service(s): ${undeclared.map((service) => service.name).join(', ')}`);
106
99
  }
107
- else {
108
- await client.post(`/newly_backend/provision_prod/${projectId}`);
100
+ const deployments = [];
101
+ for (const service of selected) {
102
+ deployments.push(await deployService(client, dir, service, serviceIds.get(service.name)));
109
103
  }
110
- print(`Provisioned ${env} backend.`);
104
+ return deployments;
111
105
  }
112
106
  export function registerDeploy(program) {
113
107
  program
114
108
  .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')
109
+ .description('Deploy backend services to the selected environment')
110
+ .argument('[services...]', 'service names from backend/newly.services.json')
118
111
  .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) => {
112
+ .action(async (services, opts) => {
123
113
  try {
124
- if (opts.env !== 'dev' && opts.env !== 'prod')
125
- throw new Error('--env must be dev or prod');
114
+ const dir = process.cwd();
126
115
  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
- }
116
+ const projectId = resolveProject(dir, opts.project);
117
+ const environment = requireEnvironmentSelection(dir, projectId);
118
+ console.log(`Deploying to ${environment.name} (${environment.id})…`);
119
+ await runEnvironmentDeploy(projectId, environment.id, new ApiClient(creds.apiUrl, creds.token), dir, services);
149
120
  }
150
- catch (e) {
151
- console.error(e instanceof ApiError ? friendlyApiError(e) : String(e instanceof Error ? e.message : e));
121
+ catch (error) {
122
+ console.error(error instanceof ApiError ? friendlyApiError(error) : String(error instanceof Error ? error.message : error));
152
123
  process.exitCode = 1;
153
124
  }
154
125
  });
@@ -0,0 +1,55 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { resolve } from 'node:path';
4
+ const DESKTOP_RUN_START_URL = 'http://127.0.0.1:4500/run/start';
5
+ export function desktopRunUrl(project, mode, start = true, platform = 'ios') {
6
+ const url = new URL('newly://run');
7
+ url.searchParams.set('project', resolve(project));
8
+ url.searchParams.set('mode', mode);
9
+ url.searchParams.set('platform', platform);
10
+ url.searchParams.set('start', start ? '1' : '0');
11
+ url.searchParams.set('request', randomUUID());
12
+ return url.toString();
13
+ }
14
+ export async function startDesktopRun(project, mode, platform = 'ios', key = 'default', sessionId, fetchImpl = fetch) {
15
+ const response = await fetchImpl(DESKTOP_RUN_START_URL, {
16
+ method: 'POST',
17
+ headers: { 'content-type': 'application/json' },
18
+ body: JSON.stringify({ cwd: resolve(project), key, mode, platform, ...(sessionId ? { sessionId } : {}) }),
19
+ });
20
+ if (response.ok)
21
+ return;
22
+ const detail = await response.text().catch(() => '');
23
+ throw new Error(`Newly Desktop could not start the simulator run (${response.status})${detail ? `: ${detail}` : ''}`);
24
+ }
25
+ export function openDesktopUrl(url, platform = process.platform) {
26
+ if (platform === 'darwin')
27
+ execFileSync('open', [url], { stdio: 'ignore' });
28
+ else if (platform === 'win32')
29
+ execFileSync('cmd', ['/c', 'start', '', url], { stdio: 'ignore' });
30
+ else
31
+ execFileSync('xdg-open', [url], { stdio: 'ignore' });
32
+ }
33
+ export function registerDesktop(program) {
34
+ const desktop = program.command('desktop').description('Control the Newly desktop app');
35
+ desktop
36
+ .command('run')
37
+ .alias('simulator')
38
+ .requiredOption('--project <path>', 'local Expo project directory')
39
+ .option('--mode <mode>', 'simulator mode: local or remote', 'local')
40
+ .option('--platform <platform>', 'simulator platform: ios or android', 'ios')
41
+ .option('--key <key>', 'run lane key in the desktop sidecar', 'default')
42
+ .option('--session-id <id>', 'control-plane session id for session-scoped remote simulator builds')
43
+ .option('--no-open', 'skip the newly:// opener and call the running sidecar directly')
44
+ .action(async (opts) => {
45
+ if (opts.mode !== 'local' && opts.mode !== 'remote')
46
+ throw new Error('--mode must be local or remote');
47
+ if (opts.platform !== 'ios' && opts.platform !== 'android')
48
+ throw new Error('--platform must be ios or android');
49
+ const url = desktopRunUrl(opts.project, opts.mode, false, opts.platform);
50
+ if (opts.open)
51
+ openDesktopUrl(url);
52
+ await startDesktopRun(opts.project, opts.mode, opts.platform, opts.key, opts.sessionId);
53
+ console.log(`${opts.open ? 'Opened Newly and started' : 'Started'} the ${opts.mode} ${opts.platform} simulator run for ${resolve(opts.project)}`);
54
+ });
55
+ }
@@ -0,0 +1,290 @@
1
+ import { createInterface } from 'node:readline/promises';
2
+ import { ApiClient, friendlyApiError } from '../api.js';
3
+ import { runEnvironmentDatabaseShell } from '../shellSession.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
+ }