runcloud 0.1.100 → 0.1.102

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,135 +1,59 @@
1
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
- import { basename, join, resolve } from 'node:path';
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
3
  import { ApiClient, ApiError, friendlyApiError } from '../api.js';
4
- import { controlPlaneUrl, requireCredentials, writeEnvironmentSelection, writeProjectLink, } from '../config.js';
5
- import { runEnvironmentDeploy } from './deploy.js';
6
- const POLL_INTERVAL_MS = 2_000;
7
- const POLL_TIMEOUT_MS = 20 * 60 * 1_000;
8
- function projectName(dir, requested) {
9
- const value = requested?.trim() || basename(resolve(dir));
10
- if (value.length < 1 || value.length > 100) {
11
- throw new Error('Project name must be between 1 and 100 characters.');
12
- }
13
- return value;
14
- }
15
- export function scaffoldV3Backend(dir, name) {
16
- const serviceRoot = join(dir, 'backend', 'api-service');
17
- mkdirSync(join(serviceRoot, 'src'), { recursive: true });
18
- writeFileSync(join(dir, 'backend', 'newly.services.json'), `${JSON.stringify({
19
- services: [{
20
- name: 'api',
21
- path: 'api-service',
22
- type: 'worker',
23
- public: true,
24
- bindings: { DATABASE_URL: { database: 'app' } },
25
- }],
26
- }, null, 2)}\n`);
27
- writeFileSync(join(serviceRoot, 'package.json'), `${JSON.stringify({
28
- name: `@newly/${name.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-|-$/g, '') || 'api'}`,
29
- private: true,
30
- type: 'module',
31
- }, null, 2)}\n`);
32
- writeFileSync(join(serviceRoot, 'src', 'index.ts'), `interface Env {
33
- DATABASE_URL: string;
34
- NEWLY_ENVIRONMENT_ID: string;
35
- NEWLY_PROJECT_ID: string;
36
- NEWLY_SERVICE_NAME: string;
37
- }
38
-
39
- function json(value: unknown, status = 200): Response {
40
- return Response.json(value, { status });
41
- }
42
-
43
- export default {
44
- async fetch(request: Request, env: Env): Promise<Response> {
45
- const url = new URL(request.url);
46
- if (url.pathname === "/health") {
47
- return json({ ok: true, environmentId: env.NEWLY_ENVIRONMENT_ID });
48
- }
49
- if (url.pathname === "/") {
50
- return json({
51
- projectId: env.NEWLY_PROJECT_ID,
52
- environmentId: env.NEWLY_ENVIRONMENT_ID,
53
- service: env.NEWLY_SERVICE_NAME,
54
- databaseConfigured: Boolean(env.DATABASE_URL),
55
- });
56
- }
57
- return json({ error: "Not found" }, 404);
58
- },
59
- };
60
- `);
61
- }
62
- async function waitForProduction(client, environmentId, runtime) {
63
- const intervalMs = runtime.intervalMs ?? POLL_INTERVAL_MS;
64
- const deadline = Date.now() + (runtime.timeoutMs ?? POLL_TIMEOUT_MS);
65
- for (;;) {
66
- const environment = await client.get(`/environments/${encodeURIComponent(environmentId)}`);
67
- if (environment.status === 'ready' || environment.status === 'failed')
68
- return environment;
69
- if (Date.now() >= deadline) {
70
- throw new Error(`Timed out provisioning production (${environment.stage}).`);
71
- }
72
- await new Promise((done) => setTimeout(done, intervalMs));
73
- }
74
- }
75
- export async function runCreate(client, dir, opts, runtime = {}) {
4
+ import { requireCredentials, writeProjectLink } from '../config.js';
5
+ import { extractSnapshot } from '../archive.js';
6
+ export async function runCreate(client, dir, opts) {
76
7
  if (existsSync(join(dir, 'backend'))) {
77
8
  throw new Error('A `backend/` directory already exists here. Run `newly create` in a directory without one.');
78
9
  }
79
- const name = projectName(dir, opts.name);
80
- console.log(`Creating ${name} with production PostgreSQL…`);
81
- const created = await client.post('/projects', { name });
10
+ const { project_id, status } = await client.post('/newly_backend/create_project', {
11
+ name: opts.name,
12
+ provision: opts.provision ?? true,
13
+ });
82
14
  writeProjectLink(dir, {
83
- projectId: created.id,
15
+ projectId: project_id,
84
16
  pulledSha: null,
85
17
  pulledAt: new Date().toISOString(),
86
18
  });
87
- scaffoldV3Backend(dir, name);
88
- console.log(`Production database ${created.productionDatabaseId} is provisioning…`);
89
- const production = await waitForProduction(client, created.productionEnvironmentId, runtime);
90
- if (production.status === 'failed') {
91
- throw new Error(`Production provisioning failed at ${production.stage}: ${production.lastError ?? created.lastError ?? 'unknown error'}`);
19
+ try {
20
+ const tar = await client.getBinary('/newly_backend/backend_template');
21
+ await extractSnapshot(tar, dir);
92
22
  }
93
- if (production.class !== 'production' || production.projectId !== created.id) {
94
- throw new Error('The production bootstrap returned an environment for the wrong project.');
23
+ catch (e) {
24
+ const msg = e instanceof Error ? e.message : String(e);
25
+ throw new Error(`Project ${project_id} was created and linked, but scaffolding the backend template failed: ${msg}. The project exists server-side.`);
95
26
  }
96
- writeEnvironmentSelection(dir, {
97
- id: production.id,
98
- name: production.name,
99
- projectId: created.id,
100
- });
101
- console.log(`Using production (${production.id}) in this workspace.`);
102
- if (opts.deploy ?? true) {
103
- console.log('Deploying the starter backend to production…');
104
- await (runtime.deploy ?? runEnvironmentDeploy)(created.id, production.id, client, dir);
105
- console.log(`Created ${name}; production database and backend are ready.`);
27
+ if (status === 'provisioning') {
28
+ console.log(`Created backend project ${project_id} and scaffolded backend/ here. ` +
29
+ `Provisioning the dev backend… check with \`newly status\`. Edit, then \`newly deploy\`.`);
106
30
  }
107
31
  else {
108
- console.log(`Created ${name}; production database is ready. Deploy with: newly deploy`);
32
+ console.log(`Created backend project ${project_id} and scaffolded backend/ here ` +
33
+ `(not provisioned — run \`newly deploy\` when ready).`);
109
34
  }
110
- return created.id;
35
+ return project_id;
111
36
  }
112
37
  export function registerCreate(program) {
113
38
  program
114
39
  .command('create')
115
- .description('Create a v3 project with its production database and backend')
116
- .argument('[name]', 'project name (defaults to the current directory name)')
117
- .option('--no-deploy', 'create production without deploying the starter backend')
40
+ .description('Create a new backend-only Newly project here (scaffold + provision)')
41
+ .argument('[name]', 'project name (generated if omitted)')
42
+ .option('--no-provision', 'create without provisioning the backend')
118
43
  .action(async (name, opts) => {
119
44
  try {
120
45
  if (existsSync(join(process.cwd(), '.newly', 'project.json'))) {
121
46
  throw new Error('This directory is already linked to a project. Use a fresh directory for `newly create`.');
122
47
  }
123
48
  const creds = requireCredentials();
124
- await runCreate(new ApiClient(controlPlaneUrl(creds), creds.token), process.cwd(), {
49
+ const client = new ApiClient(creds.apiUrl, creds.token);
50
+ await runCreate(client, process.cwd(), {
125
51
  name,
126
- deploy: opts.deploy,
52
+ provision: opts.provision,
127
53
  });
128
54
  }
129
- catch (error) {
130
- console.error(error instanceof ApiError
131
- ? friendlyApiError(error)
132
- : String(error instanceof Error ? error.message : error));
55
+ catch (e) {
56
+ console.error(e instanceof ApiError ? friendlyApiError(e) : String(e instanceof Error ? e.message : e));
133
57
  process.exitCode = 1;
134
58
  }
135
59
  });
@@ -1,125 +1,154 @@
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';
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
5
3
  import { ApiClient, ApiError, friendlyApiError } from '../api.js';
6
- import { requireCredentials, requireEnvironmentSelection, resolveProject } from '../config.js';
7
- import { assertKnownServices, readBackendServices } from '../services-manifest.js';
8
- const POLL_INTERVAL_MS = 2_000;
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
9
  const POLL_TIMEOUT_MS = 20 * 60 * 1000;
10
- function sha256(bytes) {
11
- return createHash('sha256').update(bytes).digest('hex');
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
+ }
12
22
  }
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));
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
+ }
22
46
  }
23
47
  }
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'));
27
- }
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}`);
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;
31
56
  }
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);
57
+ return pollRun(client, runId);
50
58
  }
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));
59
+ export function finishOrThrow(run, label) {
60
+ if (run.status === 'succeeded') {
61
+ console.log(`${label} deploy succeeded.`);
62
+ return;
61
63
  }
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}` : ''}`);
64
+ if (run.failure_reason === 'no_backend_code') {
65
+ throw new Error('No backend code to deploy (backend/ missing).');
77
66
  }
78
- console.log(`Deployed ${service.name} (${finished.id}).`);
79
- return finished;
67
+ throw new Error(`${label} deploy failed (${run.failure_reason ?? 'unknown'}):\n${run.output_tail ?? ''}`);
80
68
  }
81
- export async function runEnvironmentDeploy(projectId, environmentId, client, dir, selectedNames = []) {
69
+ export async function runDevDeploy(projectId, client, dir, services = []) {
82
70
  if (!existsSync(join(dir, 'backend'))) {
83
- throw new Error('No backend/ in this directory. Run `newly pull <project-id>` first, or cd into a pulled project.');
71
+ throw new Error('No backend/ in this directory. Run `newly pull <project-id>` first, ' +
72
+ 'or cd into a pulled project.');
84
73
  }
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(', ')}`);
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
+ });
99
106
  }
100
- const deployments = [];
101
- for (const service of selected) {
102
- deployments.push(await deployService(client, dir, service, serviceIds.get(service.name)));
107
+ else {
108
+ await client.post(`/newly_backend/provision_prod/${projectId}`);
103
109
  }
104
- return deployments;
110
+ print(`Provisioned ${env} backend.`);
105
111
  }
106
112
  export function registerDeploy(program) {
107
113
  program
108
114
  .command('deploy')
109
- .description('Deploy backend services to the selected environment')
110
- .argument('[services...]', 'service names from backend/newly.services.json')
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')
111
118
  .option('--project <id>', 'project id (defaults to the linked directory)')
112
- .action(async (services, opts) => {
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) => {
113
123
  try {
114
- const dir = process.cwd();
124
+ if (opts.env !== 'dev' && opts.env !== 'prod')
125
+ throw new Error('--env must be dev or prod');
115
126
  const creds = requireCredentials();
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);
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
+ }
120
149
  }
121
- catch (error) {
122
- console.error(error instanceof ApiError ? friendlyApiError(error) : String(error instanceof Error ? error.message : error));
150
+ catch (e) {
151
+ console.error(e instanceof ApiError ? friendlyApiError(e) : String(e instanceof Error ? e.message : e));
123
152
  process.exitCode = 1;
124
153
  }
125
154
  });
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const CLI_VERSION = '0.1.100';
1
+ export const CLI_VERSION = '0.1.102';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runcloud",
3
- "version": "0.1.100",
3
+ "version": "0.1.102",
4
4
  "description": "Create and control run.cloud remote mobile simulators and cloud sandboxes",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [