runcloud 0.1.133 → 0.1.135
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.
- package/dist/commands/deploy.js +39 -5
- package/dist/commands/website.js +57 -9
- package/dist/deploy-bundle.js +148 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/runcloud-0.1.135.tgz +0 -0
- package/runcloud-0.1.133.tgz +0 -0
package/dist/commands/deploy.js
CHANGED
|
@@ -4,7 +4,8 @@ import { ApiClient, ApiError, friendlyApiError } from '../api.js';
|
|
|
4
4
|
import { requireCredentials, resolveProject } from '../config.js';
|
|
5
5
|
import { createSnapshot } from '../archive.js';
|
|
6
6
|
import { parseSseFrames } from '../sse.js';
|
|
7
|
-
import { assertKnownServices, parseServiceFilter, readBackendServices } from '../services-manifest.js';
|
|
7
|
+
import { assertKnownServices, parseServiceFilter, readBackendServices, } from '../services-manifest.js';
|
|
8
|
+
import { buildDeployBundle, spawnRun, NEWLY_BACKEND_DEPLOY_BUNDLE_CONTENT_TYPE, NEWLY_BACKEND_DEPLOY_MODE_BUNDLE, } from '../deploy-bundle.js';
|
|
8
9
|
const POLL_INTERVAL_MS = 5_000;
|
|
9
10
|
const POLL_TIMEOUT_MS = 20 * 60 * 1000;
|
|
10
11
|
export async function pollRun(client, runId, opts = {}) {
|
|
@@ -93,6 +94,32 @@ export async function runProdDeploy(projectId, client, gitSha = 'HEAD', services
|
|
|
93
94
|
const run = await streamRun(client, run_id);
|
|
94
95
|
finishOrThrow(run, 'prod');
|
|
95
96
|
}
|
|
97
|
+
export function isBundleDeployMode(stackInfo) {
|
|
98
|
+
return stackInfo.deploy_mode === NEWLY_BACKEND_DEPLOY_MODE_BUNDLE;
|
|
99
|
+
}
|
|
100
|
+
export async function runBundleDeploy(projectId, client, dir, env, allServices, selected = [], runCmd = spawnRun) {
|
|
101
|
+
const targets = selected.length ? allServices.filter((s) => selected.includes(s.name)) : allServices;
|
|
102
|
+
const bundleServices = targets.map((s) => ({
|
|
103
|
+
name: s.name,
|
|
104
|
+
path: s.path,
|
|
105
|
+
schema: s.schema ?? undefined,
|
|
106
|
+
secrets: s.secrets,
|
|
107
|
+
}));
|
|
108
|
+
const bundle = await buildDeployBundle({
|
|
109
|
+
projectDir: dir,
|
|
110
|
+
services: bundleServices,
|
|
111
|
+
run: runCmd,
|
|
112
|
+
log: (line) => console.log(line),
|
|
113
|
+
});
|
|
114
|
+
const kib = Math.max(1, Math.round(bundle.length / 1024));
|
|
115
|
+
console.log(`Uploading deploy bundle (${kib} KiB)…`);
|
|
116
|
+
const params = new URLSearchParams({ env });
|
|
117
|
+
for (const service of selected)
|
|
118
|
+
params.append('services', service);
|
|
119
|
+
const { run_id } = await client.upload(`/newly_backend/deploy/${projectId}?${params}`, bundle, NEWLY_BACKEND_DEPLOY_BUNDLE_CONTENT_TYPE);
|
|
120
|
+
const run = await streamRun(client, run_id);
|
|
121
|
+
finishOrThrow(run, env);
|
|
122
|
+
}
|
|
96
123
|
export async function ensureBackendProvisioned(client, projectId, env, opts = {}) {
|
|
97
124
|
const print = opts.print ?? console.log;
|
|
98
125
|
const status = await client.get(`/newly_backend/status/${projectId}`);
|
|
@@ -127,12 +154,14 @@ export function registerDeploy(program) {
|
|
|
127
154
|
const projectId = resolveProject(process.cwd(), opts.project);
|
|
128
155
|
const client = new ApiClient(creds.apiUrl, creds.token);
|
|
129
156
|
const services = parseServiceFilter(opts.service);
|
|
130
|
-
|
|
131
|
-
|
|
157
|
+
const manifestServices = readBackendServices(process.cwd());
|
|
158
|
+
assertKnownServices(manifestServices, services);
|
|
159
|
+
const stackInfo = (await client.get(`/newly_backend/stack/${projectId}`));
|
|
132
160
|
if (!stackInfo.cli_deployable) {
|
|
133
161
|
throw new Error('This project does not support CLI deploys yet. Use the Newly web app to deploy it.');
|
|
134
162
|
}
|
|
135
|
-
|
|
163
|
+
const bundleMode = isBundleDeployMode(stackInfo);
|
|
164
|
+
if ((opts.env === 'dev' || bundleMode) && !existsSync(join(process.cwd(), 'backend'))) {
|
|
136
165
|
throw new Error('No backend/ in this directory. Run `newly pull <project-id>` first, ' +
|
|
137
166
|
'or cd into a pulled project.');
|
|
138
167
|
}
|
|
@@ -140,7 +169,12 @@ export function registerDeploy(program) {
|
|
|
140
169
|
region: opts.region,
|
|
141
170
|
print: console.log,
|
|
142
171
|
});
|
|
143
|
-
if (
|
|
172
|
+
if (bundleMode) {
|
|
173
|
+
if (opts.gitSha !== 'HEAD')
|
|
174
|
+
console.log('Note: --git-sha is ignored; this project deploys the local backend code.');
|
|
175
|
+
await runBundleDeploy(projectId, client, process.cwd(), opts.env, manifestServices, services);
|
|
176
|
+
}
|
|
177
|
+
else if (opts.env === 'prod') {
|
|
144
178
|
await runProdDeploy(projectId, client, opts.gitSha, services);
|
|
145
179
|
}
|
|
146
180
|
else {
|
package/dist/commands/website.js
CHANGED
|
@@ -4,6 +4,7 @@ import { create as tarCreate } from 'tar';
|
|
|
4
4
|
import { ApiClient, friendlyApiError } from '../api.js';
|
|
5
5
|
import { createSnapshot } from '../archive.js';
|
|
6
6
|
import { requireCredentials, resolveProject } from '../config.js';
|
|
7
|
+
import { isBundleDeployMode } from './deploy.js';
|
|
7
8
|
function makeClient(project) {
|
|
8
9
|
const creds = requireCredentials();
|
|
9
10
|
return {
|
|
@@ -12,6 +13,8 @@ function makeClient(project) {
|
|
|
12
13
|
};
|
|
13
14
|
}
|
|
14
15
|
const BUILD_ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
16
|
+
const PUBLISH_POLL_INTERVAL_MS = 3_000;
|
|
17
|
+
const PUBLISH_POLL_TIMEOUT_MS = 10 * 60 * 1000;
|
|
15
18
|
export function renderDeployment(d) {
|
|
16
19
|
const at = d.published_at ?? d.created_at ?? '';
|
|
17
20
|
const suffix = d.error_message ? ` (${d.error_message})` : '';
|
|
@@ -100,8 +103,39 @@ export async function createStaticSiteArchive(dir) {
|
|
|
100
103
|
}
|
|
101
104
|
return Buffer.concat(chunks);
|
|
102
105
|
}
|
|
103
|
-
|
|
104
|
-
|
|
106
|
+
function assertBundleModeExport(dir) {
|
|
107
|
+
if (!existsSync(`${resolve(dir)}/index.html`)) {
|
|
108
|
+
throw new Error(`No static export at ${dir}. Build it first, then run \`newly website publish ${dir}\`.`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async function finishPublish(client, projectId, result, log, pollOpts = {}) {
|
|
112
|
+
if (result.deployment.status !== 'building') {
|
|
113
|
+
log(`Published ${result.deployment.id}`);
|
|
114
|
+
log(`URL: ${result.default_url}`);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const intervalMs = pollOpts.intervalMs ?? PUBLISH_POLL_INTERVAL_MS;
|
|
118
|
+
const timeoutMs = pollOpts.timeoutMs ?? PUBLISH_POLL_TIMEOUT_MS;
|
|
119
|
+
log(`Publishing ${result.deployment.id}...`);
|
|
120
|
+
const deadline = Date.now() + timeoutMs;
|
|
121
|
+
for (;;) {
|
|
122
|
+
const { deployments = [] } = (await client.get(`/projects/${projectId}/website/deployments`));
|
|
123
|
+
const match = deployments.find((d) => d.id === result.deployment.id);
|
|
124
|
+
if (match?.status === 'active') {
|
|
125
|
+
log(`Published ${match.id}`);
|
|
126
|
+
log(`URL: ${result.default_url}`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (match?.status === 'failed') {
|
|
130
|
+
throw new Error(match.error_message ?? `Publish ${result.deployment.id} failed.`);
|
|
131
|
+
}
|
|
132
|
+
if (Date.now() > deadline) {
|
|
133
|
+
throw new Error(`Publish ${result.deployment.id} timed out after ${timeoutMs / 1000}s`);
|
|
134
|
+
}
|
|
135
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
export async function runWebsitePublish(client, projectId, opts = {}, log = console.log, pollOpts = {}) {
|
|
105
139
|
if (opts.remoteBuild) {
|
|
106
140
|
const sourceDir = opts.localPath ?? '.';
|
|
107
141
|
assertDirectory(sourceDir, 'Website source directory');
|
|
@@ -115,19 +149,33 @@ export async function runWebsitePublish(client, projectId, opts = {}, log = cons
|
|
|
115
149
|
output_dir: opts.outputDir,
|
|
116
150
|
}));
|
|
117
151
|
form.set('source', new Blob([new Uint8Array(archive)], { type: 'application/gzip' }), 'source.tar.gz');
|
|
118
|
-
result = (await client.uploadForm(`/projects/${projectId}/website/build-upload`, form));
|
|
152
|
+
const result = (await client.uploadForm(`/projects/${projectId}/website/build-upload`, form));
|
|
153
|
+
log(`Published ${result.deployment.id}`);
|
|
154
|
+
log(`URL: ${result.default_url}`);
|
|
155
|
+
return;
|
|
119
156
|
}
|
|
120
|
-
|
|
157
|
+
if (opts.localPath) {
|
|
121
158
|
log('Packaging static site...');
|
|
122
159
|
const archive = await createStaticSiteArchive(opts.localPath);
|
|
123
160
|
log(`Uploading static site (${archiveKib(archive.length)} KiB)...`);
|
|
124
|
-
result = (await client.upload(`/projects/${projectId}/website/publish-upload`, archive, 'application/gzip'));
|
|
161
|
+
const result = (await client.upload(`/projects/${projectId}/website/publish-upload`, archive, 'application/gzip'));
|
|
162
|
+
await finishPublish(client, projectId, result, log, pollOpts);
|
|
163
|
+
return;
|
|
125
164
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
165
|
+
const stackInfo = (await client.get(`/newly_backend/stack/${projectId}`));
|
|
166
|
+
if (isBundleDeployMode(stackInfo)) {
|
|
167
|
+
const dir = opts.sourcePath ?? 'website/out';
|
|
168
|
+
assertBundleModeExport(dir);
|
|
169
|
+
log('Packaging static site...');
|
|
170
|
+
const archive = await createStaticSiteArchive(dir);
|
|
171
|
+
log(`Uploading static site (${archiveKib(archive.length)} KiB)...`);
|
|
172
|
+
const result = (await client.upload(`/projects/${projectId}/website/publish-upload`, archive, 'application/gzip'));
|
|
173
|
+
await finishPublish(client, projectId, result, log, pollOpts);
|
|
174
|
+
return;
|
|
130
175
|
}
|
|
176
|
+
const result = (await client.post(`/projects/${projectId}/website/publish`, {
|
|
177
|
+
source_path: opts.sourcePath ?? 'website/out',
|
|
178
|
+
}));
|
|
131
179
|
log(`Published ${result.deployment.id}`);
|
|
132
180
|
log(`URL: ${result.default_url}`);
|
|
133
181
|
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { Buffer } from 'node:buffer';
|
|
6
|
+
import { create as tarCreate } from 'tar';
|
|
7
|
+
export const NEWLY_BACKEND_DEPLOY_BUNDLE_CONTENT_TYPE = 'application/vnd.newly.deploy-bundle+gzip';
|
|
8
|
+
export const NEWLY_BACKEND_DEPLOY_MODE_BUNDLE = 'bundle';
|
|
9
|
+
const MANIFEST_PATH = 'newly-deploy.json';
|
|
10
|
+
const MAX_BUNDLE_BYTES = 25 * 1024 * 1024;
|
|
11
|
+
const REPLACE_TOKEN = /REPLACE_[A-Z_]+/g;
|
|
12
|
+
const OUTPUT_CAP_BYTES = 64 * 1024;
|
|
13
|
+
export class DeployBuildError extends Error {
|
|
14
|
+
constructor(message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = 'DeployBuildError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export async function buildDeployBundle(options) {
|
|
20
|
+
const { projectDir, services, run, log } = options;
|
|
21
|
+
const bunVersion = await run('bun', ['--version'], projectDir);
|
|
22
|
+
if (bunVersion.code !== 0) {
|
|
23
|
+
throw new DeployBuildError('newly deploy needs bun on your PATH (https://bun.sh)');
|
|
24
|
+
}
|
|
25
|
+
const bundleDir = mkdtempSync(join(tmpdir(), 'newly-deploy-bundle-'));
|
|
26
|
+
const servicesDir = join(bundleDir, 'services');
|
|
27
|
+
mkdirSync(servicesDir, { recursive: true });
|
|
28
|
+
try {
|
|
29
|
+
const manifestServices = [];
|
|
30
|
+
for (const service of services) {
|
|
31
|
+
manifestServices.push(await buildService(service, projectDir, servicesDir, run, log));
|
|
32
|
+
}
|
|
33
|
+
const gitSha = await resolveGitSha(run, projectDir);
|
|
34
|
+
const manifest = { version: 1, git_sha: gitSha, services: manifestServices };
|
|
35
|
+
writeFileSync(join(bundleDir, MANIFEST_PATH), JSON.stringify(manifest, null, 2));
|
|
36
|
+
const bundle = await tarBundle(bundleDir);
|
|
37
|
+
if (bundle.length > MAX_BUNDLE_BYTES) {
|
|
38
|
+
const mib = (bundle.length / (1024 * 1024)).toFixed(1);
|
|
39
|
+
throw new DeployBuildError(`Deploy bundle is too large (${mib} MiB, max 25 MiB).`);
|
|
40
|
+
}
|
|
41
|
+
return bundle;
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
rmSync(bundleDir, { recursive: true, force: true });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async function buildService(service, projectDir, servicesDir, run, log) {
|
|
48
|
+
const dir = join(projectDir, 'backend', service.path);
|
|
49
|
+
if (!existsSync(dir)) {
|
|
50
|
+
throw new DeployBuildError(`No backend code to deploy (backend/${service.path} missing)`);
|
|
51
|
+
}
|
|
52
|
+
const configPath = join(dir, 'wrangler.toml');
|
|
53
|
+
if (!existsSync(configPath)) {
|
|
54
|
+
throw new DeployBuildError(`No wrangler.toml in backend/${service.path}`);
|
|
55
|
+
}
|
|
56
|
+
log(`Building ${service.name} (bun install, wrangler bundle)…`);
|
|
57
|
+
const install = await run('bun', ['install'], dir);
|
|
58
|
+
if (install.code !== 0) {
|
|
59
|
+
throw new DeployBuildError(`bun install failed for ${service.name}:\n${install.output}`);
|
|
60
|
+
}
|
|
61
|
+
const originalConfig = readFileSync(configPath, 'utf8');
|
|
62
|
+
const script = await wranglerBundle(service, dir, originalConfig, run);
|
|
63
|
+
const serviceDir = join(servicesDir, service.name);
|
|
64
|
+
mkdirSync(serviceDir, { recursive: true });
|
|
65
|
+
writeFileSync(join(serviceDir, 'index.js'), script);
|
|
66
|
+
writeFileSync(join(serviceDir, 'wrangler.toml'), originalConfig);
|
|
67
|
+
const migrations = stageMigrations(dir, serviceDir, service.name);
|
|
68
|
+
return {
|
|
69
|
+
name: service.name,
|
|
70
|
+
schema: service.schema ?? null,
|
|
71
|
+
secrets: service.secrets ?? null,
|
|
72
|
+
script: `services/${service.name}/index.js`,
|
|
73
|
+
config: `services/${service.name}/wrangler.toml`,
|
|
74
|
+
migrations,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
async function wranglerBundle(service, dir, originalConfig, run) {
|
|
78
|
+
const deployConfigName = 'wrangler.newly-deploy.toml';
|
|
79
|
+
const deployConfigPath = join(dir, deployConfigName);
|
|
80
|
+
const outDir = mkdtempSync(join(tmpdir(), 'newly-deploy-wrangler-'));
|
|
81
|
+
try {
|
|
82
|
+
writeFileSync(deployConfigPath, originalConfig.replace(REPLACE_TOKEN, 'newly-placeholder'));
|
|
83
|
+
const dryRun = await run('bunx', ['wrangler', 'deploy', '--dry-run', '--outdir', outDir, '--config', deployConfigName], dir);
|
|
84
|
+
if (dryRun.code !== 0) {
|
|
85
|
+
throw new DeployBuildError(`wrangler bundle failed for ${service.name}:\n${dryRun.output}`);
|
|
86
|
+
}
|
|
87
|
+
const jsFiles = readdirSync(outDir).filter((f) => f.endsWith('.js'));
|
|
88
|
+
if (jsFiles.length !== 1) {
|
|
89
|
+
throw new DeployBuildError(`wrangler produced ${jsFiles.length} script file(s) for ${service.name}, expected exactly one`);
|
|
90
|
+
}
|
|
91
|
+
return readFileSync(join(outDir, jsFiles[0]));
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
rmSync(deployConfigPath, { force: true });
|
|
95
|
+
rmSync(outDir, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function stageMigrations(dir, serviceDir, name) {
|
|
99
|
+
const drizzleDir = join(dir, 'drizzle');
|
|
100
|
+
const journalPath = join(drizzleDir, 'meta', '_journal.json');
|
|
101
|
+
if (!existsSync(journalPath))
|
|
102
|
+
return null;
|
|
103
|
+
const outMetaDir = join(serviceDir, 'drizzle', 'meta');
|
|
104
|
+
mkdirSync(outMetaDir, { recursive: true });
|
|
105
|
+
writeFileSync(join(outMetaDir, '_journal.json'), readFileSync(journalPath));
|
|
106
|
+
for (const entry of readdirSync(drizzleDir)) {
|
|
107
|
+
if (entry.endsWith('.sql')) {
|
|
108
|
+
writeFileSync(join(serviceDir, 'drizzle', entry), readFileSync(join(drizzleDir, entry)));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return `services/${name}/drizzle`;
|
|
112
|
+
}
|
|
113
|
+
async function resolveGitSha(run, projectDir) {
|
|
114
|
+
const result = await run('git', ['rev-parse', 'HEAD'], projectDir);
|
|
115
|
+
if (result.code !== 0)
|
|
116
|
+
return null;
|
|
117
|
+
const sha = result.output.trim();
|
|
118
|
+
return sha || null;
|
|
119
|
+
}
|
|
120
|
+
async function tarBundle(bundleDir) {
|
|
121
|
+
const chunks = [];
|
|
122
|
+
const stream = tarCreate({ gzip: true, cwd: bundleDir, portable: true }, [MANIFEST_PATH, 'services']);
|
|
123
|
+
for await (const chunk of stream) {
|
|
124
|
+
chunks.push(Buffer.from(chunk));
|
|
125
|
+
}
|
|
126
|
+
return Buffer.concat(chunks);
|
|
127
|
+
}
|
|
128
|
+
export function spawnRun(command, args, cwd) {
|
|
129
|
+
const shell = process.platform === 'win32';
|
|
130
|
+
const argv = shell ? args.map((arg) => (/[\s"]/.test(arg) ? `"${arg.replace(/"/g, '\\"')}"` : arg)) : args;
|
|
131
|
+
return new Promise((resolve) => {
|
|
132
|
+
const child = spawn(command, argv, {
|
|
133
|
+
cwd,
|
|
134
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
135
|
+
shell,
|
|
136
|
+
});
|
|
137
|
+
let output = '';
|
|
138
|
+
const append = (chunk) => {
|
|
139
|
+
output += chunk.toString('utf8');
|
|
140
|
+
if (output.length > OUTPUT_CAP_BYTES)
|
|
141
|
+
output = output.slice(-OUTPUT_CAP_BYTES);
|
|
142
|
+
};
|
|
143
|
+
child.stdout?.on('data', append);
|
|
144
|
+
child.stderr?.on('data', append);
|
|
145
|
+
child.on('error', (error) => resolve({ code: 127, output: error.message }));
|
|
146
|
+
child.on('close', (code) => resolve({ code: code ?? 1, output }));
|
|
147
|
+
});
|
|
148
|
+
}
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = '0.1.
|
|
1
|
+
export const CLI_VERSION = '0.1.135';
|
package/package.json
CHANGED
|
Binary file
|
package/runcloud-0.1.133.tgz
DELETED
|
Binary file
|