runcloud 0.1.2 → 0.1.4
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/api.js +78 -37
- package/dist/commands/build.js +244 -13
- package/dist/commands/create.js +106 -30
- package/dist/commands/deploy.js +100 -129
- package/dist/commands/desktop.js +46 -0
- package/dist/commands/environment.js +290 -0
- package/dist/commands/login.js +4 -2
- package/dist/commands/run-cloud.js +297 -77
- package/dist/commands/sandbox.js +136 -0
- package/dist/config.js +50 -9
- package/dist/environmentShell.js +70 -0
- package/dist/index.js +4 -0
- package/dist/run-cloud.js +6 -2
- package/dist/services-manifest.js +30 -1
- package/examples/eight-device-mosaic/README.md +30 -0
- package/examples/eight-device-mosaic/demo.mjs +329 -0
- package/examples/eight-device-mosaic/package.json +13 -0
- package/examples/eight-device-mosaic/verify.mjs +115 -0
- package/examples/live-camera-relay/App.tsx +516 -0
- package/examples/live-camera-relay/README.md +57 -0
- package/examples/live-camera-relay/app.json +17 -0
- package/examples/live-camera-relay/demo.mjs +472 -0
- package/examples/live-camera-relay/index.ts +4 -0
- package/examples/live-camera-relay/package.json +31 -0
- package/examples/live-camera-relay/tsconfig.json +6 -0
- package/examples/live-camera-relay/verify.mjs +151 -0
- package/examples/parallel-simulators/README.md +33 -0
- package/examples/parallel-simulators/demo.mjs +185 -0
- package/examples/parallel-simulators/package.json +13 -0
- package/examples/parallel-simulators/verify.mjs +78 -0
- package/package.json +5 -3
package/dist/commands/create.js
CHANGED
|
@@ -1,59 +1,135 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { basename, join, resolve } from 'node:path';
|
|
3
3
|
import { ApiClient, ApiError, friendlyApiError } from '../api.js';
|
|
4
|
-
import { requireCredentials, writeProjectLink } from '../config.js';
|
|
5
|
-
import {
|
|
6
|
-
|
|
4
|
+
import { 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 = {}) {
|
|
7
76
|
if (existsSync(join(dir, 'backend'))) {
|
|
8
77
|
throw new Error('A `backend/` directory already exists here. Run `newly create` in a directory without one.');
|
|
9
78
|
}
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
});
|
|
79
|
+
const name = projectName(dir, opts.name);
|
|
80
|
+
console.log(`Creating ${name} with production PostgreSQL…`);
|
|
81
|
+
const created = await client.post('/projects', { name });
|
|
14
82
|
writeProjectLink(dir, {
|
|
15
|
-
projectId:
|
|
83
|
+
projectId: created.id,
|
|
16
84
|
pulledSha: null,
|
|
17
85
|
pulledAt: new Date().toISOString(),
|
|
18
86
|
});
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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'}`);
|
|
22
92
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
throw new Error(`Project ${project_id} was created and linked, but scaffolding the backend template failed: ${msg}. The project exists server-side.`);
|
|
93
|
+
if (production.class !== 'production' || production.projectId !== created.id) {
|
|
94
|
+
throw new Error('The production bootstrap returned an environment for the wrong project.');
|
|
26
95
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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.`);
|
|
30
106
|
}
|
|
31
107
|
else {
|
|
32
|
-
console.log(`Created
|
|
33
|
-
`(not provisioned — run \`newly deploy\` when ready).`);
|
|
108
|
+
console.log(`Created ${name}; production database is ready. Deploy with: newly deploy`);
|
|
34
109
|
}
|
|
35
|
-
return
|
|
110
|
+
return created.id;
|
|
36
111
|
}
|
|
37
112
|
export function registerCreate(program) {
|
|
38
113
|
program
|
|
39
114
|
.command('create')
|
|
40
|
-
.description('Create a
|
|
41
|
-
.argument('[name]', 'project name (
|
|
42
|
-
.option('--no-
|
|
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')
|
|
43
118
|
.action(async (name, opts) => {
|
|
44
119
|
try {
|
|
45
120
|
if (existsSync(join(process.cwd(), '.newly', 'project.json'))) {
|
|
46
121
|
throw new Error('This directory is already linked to a project. Use a fresh directory for `newly create`.');
|
|
47
122
|
}
|
|
48
123
|
const creds = requireCredentials();
|
|
49
|
-
|
|
50
|
-
await runCreate(client, process.cwd(), {
|
|
124
|
+
await runCreate(new ApiClient(creds.apiUrl, creds.token), process.cwd(), {
|
|
51
125
|
name,
|
|
52
|
-
|
|
126
|
+
deploy: opts.deploy,
|
|
53
127
|
});
|
|
54
128
|
}
|
|
55
|
-
catch (
|
|
56
|
-
console.error(
|
|
129
|
+
catch (error) {
|
|
130
|
+
console.error(error instanceof ApiError
|
|
131
|
+
? friendlyApiError(error)
|
|
132
|
+
: String(error instanceof Error ? error.message : error));
|
|
57
133
|
process.exitCode = 1;
|
|
58
134
|
}
|
|
59
135
|
});
|
package/dist/commands/deploy.js
CHANGED
|
@@ -1,154 +1,125 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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 {
|
|
6
|
-
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
78
|
+
console.log(`Deployed ${service.name} (${finished.id}).`);
|
|
79
|
+
return finished;
|
|
68
80
|
}
|
|
69
|
-
export async function
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
108
|
-
|
|
100
|
+
const deployments = [];
|
|
101
|
+
for (const service of selected) {
|
|
102
|
+
deployments.push(await deployService(client, dir, service, serviceIds.get(service.name)));
|
|
109
103
|
}
|
|
110
|
-
|
|
104
|
+
return deployments;
|
|
111
105
|
}
|
|
112
106
|
export function registerDeploy(program) {
|
|
113
107
|
program
|
|
114
108
|
.command('deploy')
|
|
115
|
-
.description('
|
|
116
|
-
.
|
|
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
|
-
.
|
|
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
|
-
|
|
125
|
-
throw new Error('--env must be dev or prod');
|
|
114
|
+
const dir = process.cwd();
|
|
126
115
|
const creds = requireCredentials();
|
|
127
|
-
const projectId = resolveProject(
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
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 (
|
|
151
|
-
console.error(
|
|
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,46 @@
|
|
|
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) {
|
|
6
|
+
const url = new URL('newly://run');
|
|
7
|
+
url.searchParams.set('project', resolve(project));
|
|
8
|
+
url.searchParams.set('mode', mode);
|
|
9
|
+
url.searchParams.set('start', start ? '1' : '0');
|
|
10
|
+
url.searchParams.set('request', randomUUID());
|
|
11
|
+
return url.toString();
|
|
12
|
+
}
|
|
13
|
+
export async function startDesktopRun(project, mode, fetchImpl = fetch) {
|
|
14
|
+
const response = await fetchImpl(DESKTOP_RUN_START_URL, {
|
|
15
|
+
method: 'POST',
|
|
16
|
+
headers: { 'content-type': 'application/json' },
|
|
17
|
+
body: JSON.stringify({ cwd: resolve(project), key: 'default', mode }),
|
|
18
|
+
});
|
|
19
|
+
if (response.ok)
|
|
20
|
+
return;
|
|
21
|
+
const detail = await response.text().catch(() => '');
|
|
22
|
+
throw new Error(`Newly Desktop could not start the simulator run (${response.status})${detail ? `: ${detail}` : ''}`);
|
|
23
|
+
}
|
|
24
|
+
export function openDesktopUrl(url, platform = process.platform) {
|
|
25
|
+
if (platform === 'darwin')
|
|
26
|
+
execFileSync('open', [url], { stdio: 'ignore' });
|
|
27
|
+
else if (platform === 'win32')
|
|
28
|
+
execFileSync('cmd', ['/c', 'start', '', url], { stdio: 'ignore' });
|
|
29
|
+
else
|
|
30
|
+
execFileSync('xdg-open', [url], { stdio: 'ignore' });
|
|
31
|
+
}
|
|
32
|
+
export function registerDesktop(program) {
|
|
33
|
+
const desktop = program.command('desktop').description('Control the Newly desktop app');
|
|
34
|
+
desktop
|
|
35
|
+
.command('run')
|
|
36
|
+
.requiredOption('--project <path>', 'local Expo project directory')
|
|
37
|
+
.option('--mode <mode>', 'simulator mode: local or remote', 'local')
|
|
38
|
+
.action(async (opts) => {
|
|
39
|
+
if (opts.mode !== 'local' && opts.mode !== 'remote')
|
|
40
|
+
throw new Error('--mode must be local or remote');
|
|
41
|
+
const url = desktopRunUrl(opts.project, opts.mode, false);
|
|
42
|
+
openDesktopUrl(url);
|
|
43
|
+
await startDesktopRun(opts.project, opts.mode);
|
|
44
|
+
console.log(`Opened Newly and started the ${opts.mode} simulator run for ${resolve(opts.project)}`);
|
|
45
|
+
});
|
|
46
|
+
}
|