runcloud 0.1.2
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 +139 -0
- package/dist/archive.js +113 -0
- package/dist/commands/build.js +250 -0
- package/dist/commands/create.js +60 -0
- package/dist/commands/creds.js +103 -0
- package/dist/commands/deploy.js +155 -0
- package/dist/commands/dns.js +185 -0
- package/dist/commands/domains.js +201 -0
- package/dist/commands/email.js +86 -0
- package/dist/commands/link.js +17 -0
- package/dist/commands/login.js +173 -0
- package/dist/commands/misc.js +175 -0
- package/dist/commands/pull.js +53 -0
- package/dist/commands/run-cloud.js +272 -0
- package/dist/commands/services.js +39 -0
- package/dist/commands/stack.js +31 -0
- package/dist/commands/status.js +110 -0
- package/dist/commands/website.js +218 -0
- package/dist/config.js +95 -0
- package/dist/diagnose.js +159 -0
- package/dist/index.js +38 -0
- package/dist/newly-dev.js +10 -0
- package/dist/run-cloud.js +10 -0
- package/dist/services-manifest.js +63 -0
- package/dist/sse.js +19 -0
- package/package.json +18 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { friendlyApiError } from '../api.js';
|
|
2
|
+
import { readProjectLink, resolveProject } from '../config.js';
|
|
3
|
+
import { readBackendServices, serviceUrl } from '../services-manifest.js';
|
|
4
|
+
export function renderServices(services = readBackendServices(process.cwd()), opts = {}) {
|
|
5
|
+
const lines = ['name type path public url'];
|
|
6
|
+
for (const service of services) {
|
|
7
|
+
const isPublic = service.public !== false;
|
|
8
|
+
const url = opts.projectId && isPublic ? serviceUrl(opts.projectId, opts.env ?? 'dev', service.name) : '-';
|
|
9
|
+
lines.push([
|
|
10
|
+
service.name.padEnd(5),
|
|
11
|
+
service.type.padEnd(7),
|
|
12
|
+
service.path.padEnd(13),
|
|
13
|
+
String(isPublic).padEnd(7),
|
|
14
|
+
url,
|
|
15
|
+
].join(' '));
|
|
16
|
+
}
|
|
17
|
+
return lines.join('\n');
|
|
18
|
+
}
|
|
19
|
+
export function registerServices(program) {
|
|
20
|
+
const services = program.command('services').description('Inspect backend services');
|
|
21
|
+
services
|
|
22
|
+
.command('list')
|
|
23
|
+
.description('List backend services from backend/newly.services.json')
|
|
24
|
+
.option('--env <env>', 'dev or prod', 'dev')
|
|
25
|
+
.option('--project <id>', 'project id (defaults to the linked directory when present)')
|
|
26
|
+
.action(async (opts) => {
|
|
27
|
+
try {
|
|
28
|
+
if (opts.env !== 'dev' && opts.env !== 'prod')
|
|
29
|
+
throw new Error('--env must be dev or prod');
|
|
30
|
+
const projectId = opts.project ?? readProjectLink(process.cwd())?.projectId;
|
|
31
|
+
const resolved = projectId ? resolveProject(process.cwd(), projectId) : undefined;
|
|
32
|
+
console.log(renderServices(readBackendServices(process.cwd()), { projectId: resolved, env: opts.env }));
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
console.error(friendlyApiError(e));
|
|
36
|
+
process.exitCode = 1;
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { ApiClient, friendlyApiError } from '../api.js';
|
|
2
|
+
import { requireCredentials, resolveProject } from '../config.js';
|
|
3
|
+
export async function getStack(client, projectId) {
|
|
4
|
+
return (await client.get(`/newly_backend/stack/${projectId}`));
|
|
5
|
+
}
|
|
6
|
+
export function formatStack(out) {
|
|
7
|
+
const stack = out.stack ?? 'unknown';
|
|
8
|
+
const cli = out.cli_deployable
|
|
9
|
+
? '✓ CLI-deployable — Newly backend'
|
|
10
|
+
: '✗ not CLI-deployable — use the Newly web app to deploy this project.';
|
|
11
|
+
return `stack: ${stack}\n${cli}`;
|
|
12
|
+
}
|
|
13
|
+
export function registerStack(program) {
|
|
14
|
+
program
|
|
15
|
+
.command('stack')
|
|
16
|
+
.description('Show which backend type a project uses and whether it supports CLI deploys')
|
|
17
|
+
.option('--project <id>', 'project id (defaults to the linked directory)')
|
|
18
|
+
.action(async (opts) => {
|
|
19
|
+
try {
|
|
20
|
+
const creds = requireCredentials();
|
|
21
|
+
const projectId = resolveProject(process.cwd(), opts.project);
|
|
22
|
+
const client = new ApiClient(creds.apiUrl, creds.token);
|
|
23
|
+
const out = await getStack(client, projectId);
|
|
24
|
+
console.log(formatStack(out));
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
console.error(friendlyApiError(e));
|
|
28
|
+
process.exitCode = 1;
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { ApiClient, friendlyApiError } from '../api.js';
|
|
2
|
+
import { requireCredentials, resolveProject } from '../config.js';
|
|
3
|
+
function elapsed(since) {
|
|
4
|
+
if (!since)
|
|
5
|
+
return '';
|
|
6
|
+
const ms = Date.now() - new Date(since).getTime();
|
|
7
|
+
const s = Math.floor(ms / 1000);
|
|
8
|
+
if (s < 60)
|
|
9
|
+
return `${s}s`;
|
|
10
|
+
if (s < 3600)
|
|
11
|
+
return `${Math.floor(s / 60)}m ${s % 60}s`;
|
|
12
|
+
return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
|
|
13
|
+
}
|
|
14
|
+
function renderProvisioningStages(s) {
|
|
15
|
+
const done = ' [✓]';
|
|
16
|
+
const current = ' [→]';
|
|
17
|
+
const todo = ' [ ]';
|
|
18
|
+
const stages = [
|
|
19
|
+
{
|
|
20
|
+
label: 'Project created',
|
|
21
|
+
note: 'backend entry registered in Newly',
|
|
22
|
+
complete: true,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
label: 'Newly server assigned',
|
|
26
|
+
note: 'Newly server workspace allocated for your backend',
|
|
27
|
+
complete: s.cloud_env_assigned,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
label: 'Newly resources provisioned',
|
|
31
|
+
note: 'Newly storage and backend services provisioned',
|
|
32
|
+
complete: s.database_ready,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
label: 'Newly DB created',
|
|
36
|
+
note: 'Newly DB ready for your backend',
|
|
37
|
+
complete: s.database_ready,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
label: 'Backend configuration stored',
|
|
41
|
+
note: 'Newly DB credentials, API keys, and auth settings encrypted and stored',
|
|
42
|
+
complete: s.secrets_wired,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
label: "Run 'newly deploy'",
|
|
46
|
+
note: 'builds and deploys your backend code for the first time',
|
|
47
|
+
complete: false,
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
let foundCurrent = false;
|
|
51
|
+
const lines = [];
|
|
52
|
+
for (const stage of stages) {
|
|
53
|
+
const note = ` — ${stage.note}`;
|
|
54
|
+
if (stage.complete) {
|
|
55
|
+
lines.push(`${done} ${stage.label}${note}`);
|
|
56
|
+
}
|
|
57
|
+
else if (!foundCurrent) {
|
|
58
|
+
foundCurrent = true;
|
|
59
|
+
lines.push(`${current} ${stage.label}${note}`);
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
lines.push(`${todo} ${stage.label}${note}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return lines;
|
|
66
|
+
}
|
|
67
|
+
export function renderStatus(body) {
|
|
68
|
+
const lines = [];
|
|
69
|
+
for (const env of ['dev', 'prod']) {
|
|
70
|
+
const s = body[env];
|
|
71
|
+
if (!s) {
|
|
72
|
+
lines.push(`${env}: not provisioned`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (s.status === 'provisioning') {
|
|
76
|
+
const age = elapsed(s.created_at);
|
|
77
|
+
lines.push(`${env}: provisioning${age ? ` (${age})` : ''}`);
|
|
78
|
+
lines.push(...renderProvisioningStages(s));
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
lines.push(`${env}: ${s.status}`);
|
|
82
|
+
lines.push(` auth: ${s.auth_hostname}`);
|
|
83
|
+
lines.push(` api: ${s.api_hostname}`);
|
|
84
|
+
if (s.current_git_sha)
|
|
85
|
+
lines.push(` deployed sha: ${s.current_git_sha}`);
|
|
86
|
+
}
|
|
87
|
+
if (s.last_error)
|
|
88
|
+
lines.push(` last error: ${s.last_error}`);
|
|
89
|
+
}
|
|
90
|
+
return lines.join('\n');
|
|
91
|
+
}
|
|
92
|
+
export function registerStatus(program) {
|
|
93
|
+
program
|
|
94
|
+
.command('status')
|
|
95
|
+
.description('Show backend status and service URLs')
|
|
96
|
+
.option('--project <id>', 'project id (defaults to the linked directory)')
|
|
97
|
+
.action(async (opts) => {
|
|
98
|
+
try {
|
|
99
|
+
const creds = requireCredentials();
|
|
100
|
+
const projectId = resolveProject(process.cwd(), opts.project);
|
|
101
|
+
const client = new ApiClient(creds.apiUrl, creds.token);
|
|
102
|
+
const body = await client.get(`/newly_backend/status/${projectId}`);
|
|
103
|
+
console.log(renderStatus(body));
|
|
104
|
+
}
|
|
105
|
+
catch (e) {
|
|
106
|
+
console.error(friendlyApiError(e));
|
|
107
|
+
process.exitCode = 1;
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { basename, resolve } from 'node:path';
|
|
3
|
+
import { create as tarCreate } from 'tar';
|
|
4
|
+
import { ApiClient, friendlyApiError } from '../api.js';
|
|
5
|
+
import { createSnapshot } from '../archive.js';
|
|
6
|
+
import { requireCredentials, resolveProject } from '../config.js';
|
|
7
|
+
function makeClient(project) {
|
|
8
|
+
const creds = requireCredentials();
|
|
9
|
+
return {
|
|
10
|
+
client: new ApiClient(creds.apiUrl, creds.token),
|
|
11
|
+
projectId: resolveProject(process.cwd(), project),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
const BUILD_ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
15
|
+
export function renderDeployment(d) {
|
|
16
|
+
const at = d.published_at ?? d.created_at ?? '';
|
|
17
|
+
const suffix = d.error_message ? ` (${d.error_message})` : '';
|
|
18
|
+
return `${d.id} ${d.status}${at ? ` ${at}` : ''}${suffix}`;
|
|
19
|
+
}
|
|
20
|
+
function archiveKib(size) {
|
|
21
|
+
return size < 1024 ? '<1' : Math.round(size / 1024).toString();
|
|
22
|
+
}
|
|
23
|
+
function assertDirectory(path, label) {
|
|
24
|
+
if (!existsSync(path))
|
|
25
|
+
throw new Error(`${label} does not exist: ${path}`);
|
|
26
|
+
if (!statSync(path).isDirectory())
|
|
27
|
+
throw new Error(`${label} is not a directory: ${path}`);
|
|
28
|
+
}
|
|
29
|
+
function collect(value, previous) {
|
|
30
|
+
return [...previous, value];
|
|
31
|
+
}
|
|
32
|
+
function parseBuildEnvPair(pair) {
|
|
33
|
+
const index = pair.indexOf('=');
|
|
34
|
+
if (index <= 0)
|
|
35
|
+
throw new Error(`Invalid --build-env "${pair}" (expected KEY=value).`);
|
|
36
|
+
const key = pair.slice(0, index).trim();
|
|
37
|
+
const value = pair.slice(index + 1);
|
|
38
|
+
if (!BUILD_ENV_KEY.test(key)) {
|
|
39
|
+
throw new Error(`Invalid build env var name "${key}".`);
|
|
40
|
+
}
|
|
41
|
+
return [key, value];
|
|
42
|
+
}
|
|
43
|
+
function unquoteEnvValue(value) {
|
|
44
|
+
const trimmed = value.trim();
|
|
45
|
+
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
|
46
|
+
return trimmed
|
|
47
|
+
.slice(1, -1)
|
|
48
|
+
.replace(/\\n/g, '\n')
|
|
49
|
+
.replace(/\\"/g, '"')
|
|
50
|
+
.replace(/\\\\/g, '\\');
|
|
51
|
+
}
|
|
52
|
+
if (trimmed.length >= 2 && trimmed.startsWith("'") && trimmed.endsWith("'")) {
|
|
53
|
+
return trimmed.slice(1, -1);
|
|
54
|
+
}
|
|
55
|
+
return trimmed;
|
|
56
|
+
}
|
|
57
|
+
export function parseBuildEnvFile(path) {
|
|
58
|
+
const env = {};
|
|
59
|
+
const body = readFileSync(path, 'utf8');
|
|
60
|
+
for (const [index, rawLine] of body.split(/\r?\n/).entries()) {
|
|
61
|
+
const line = rawLine.trim();
|
|
62
|
+
if (!line || line.startsWith('#'))
|
|
63
|
+
continue;
|
|
64
|
+
const equals = line.indexOf('=');
|
|
65
|
+
if (equals <= 0)
|
|
66
|
+
throw new Error(`Invalid env file line ${index + 1}: expected KEY=value.`);
|
|
67
|
+
const key = line.slice(0, equals).trim();
|
|
68
|
+
if (!BUILD_ENV_KEY.test(key))
|
|
69
|
+
throw new Error(`Invalid env file key "${key}".`);
|
|
70
|
+
env[key] = unquoteEnvValue(line.slice(equals + 1));
|
|
71
|
+
}
|
|
72
|
+
return env;
|
|
73
|
+
}
|
|
74
|
+
export function parseBuildEnv(pairs = [], envFile) {
|
|
75
|
+
const env = envFile ? parseBuildEnvFile(envFile) : {};
|
|
76
|
+
for (const pair of pairs) {
|
|
77
|
+
const [key, value] = parseBuildEnvPair(pair);
|
|
78
|
+
env[key] = value;
|
|
79
|
+
}
|
|
80
|
+
return env;
|
|
81
|
+
}
|
|
82
|
+
export async function createStaticSiteArchive(dir) {
|
|
83
|
+
const root = resolve(dir);
|
|
84
|
+
assertDirectory(root, 'Static site directory');
|
|
85
|
+
if (!existsSync(`${root}/index.html`)) {
|
|
86
|
+
throw new Error(`Static site directory must contain index.html at its root: ${root}`);
|
|
87
|
+
}
|
|
88
|
+
const chunks = [];
|
|
89
|
+
const stream = tarCreate({
|
|
90
|
+
gzip: true,
|
|
91
|
+
cwd: root,
|
|
92
|
+
portable: true,
|
|
93
|
+
filter: (path) => {
|
|
94
|
+
const name = basename(path);
|
|
95
|
+
return name !== '.DS_Store' && name !== '.env' && !name.startsWith('.env.');
|
|
96
|
+
},
|
|
97
|
+
}, ['.']);
|
|
98
|
+
for await (const chunk of stream) {
|
|
99
|
+
chunks.push(Buffer.from(chunk));
|
|
100
|
+
}
|
|
101
|
+
return Buffer.concat(chunks);
|
|
102
|
+
}
|
|
103
|
+
export async function runWebsitePublish(client, projectId, opts = {}, log = console.log) {
|
|
104
|
+
let result;
|
|
105
|
+
if (opts.remoteBuild) {
|
|
106
|
+
const sourceDir = opts.localPath ?? '.';
|
|
107
|
+
assertDirectory(sourceDir, 'Website source directory');
|
|
108
|
+
log('Packaging website source...');
|
|
109
|
+
const archive = await createSnapshot(sourceDir);
|
|
110
|
+
log(`Uploading source snapshot (${archiveKib(archive.length)} KiB) for remote build...`);
|
|
111
|
+
const form = new FormData();
|
|
112
|
+
form.set('request', JSON.stringify({
|
|
113
|
+
build_env: opts.buildEnv ?? {},
|
|
114
|
+
build_command: opts.buildCommand,
|
|
115
|
+
output_dir: opts.outputDir,
|
|
116
|
+
}));
|
|
117
|
+
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));
|
|
119
|
+
}
|
|
120
|
+
else if (opts.localPath) {
|
|
121
|
+
log('Packaging static site...');
|
|
122
|
+
const archive = await createStaticSiteArchive(opts.localPath);
|
|
123
|
+
log(`Uploading static site (${archiveKib(archive.length)} KiB)...`);
|
|
124
|
+
result = (await client.upload(`/projects/${projectId}/website/publish-upload`, archive, 'application/gzip'));
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
result = (await client.post(`/projects/${projectId}/website/publish`, {
|
|
128
|
+
source_path: opts.sourcePath ?? 'website/out',
|
|
129
|
+
}));
|
|
130
|
+
}
|
|
131
|
+
log(`Published ${result.deployment.id}`);
|
|
132
|
+
log(`URL: ${result.default_url}`);
|
|
133
|
+
}
|
|
134
|
+
export async function runWebsiteDeployments(client, projectId, log = console.log) {
|
|
135
|
+
const result = (await client.get(`/projects/${projectId}/website/deployments`));
|
|
136
|
+
const deployments = result.deployments ?? [];
|
|
137
|
+
if (deployments.length === 0) {
|
|
138
|
+
log('No website deployments yet.');
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
log(deployments.map(renderDeployment).join('\n'));
|
|
142
|
+
}
|
|
143
|
+
export async function runWebsiteRollback(client, projectId, deploymentId, log = console.log) {
|
|
144
|
+
const result = (await client.post(`/projects/${projectId}/website/rollback`, {
|
|
145
|
+
deployment_id: deploymentId,
|
|
146
|
+
}));
|
|
147
|
+
log(`Rolled back to ${result.deployment.id}`);
|
|
148
|
+
}
|
|
149
|
+
export function registerWebsite(program) {
|
|
150
|
+
const website = program.command('website').description('Publish and manage static website deployments');
|
|
151
|
+
website
|
|
152
|
+
.command('publish')
|
|
153
|
+
.description('Publish a static website directory to Cloudflare')
|
|
154
|
+
.argument('[path]', 'local static output directory to upload, for example ./dist')
|
|
155
|
+
.option('--project <id>', 'project id (defaults to the linked directory)')
|
|
156
|
+
.option('--source-path <path>', 'committed static export path in project storage')
|
|
157
|
+
.option('--remote-build', 'upload source files and ask the backend to build dist remotely', false)
|
|
158
|
+
.option('--build-env <KEY=value>', 'build-time env var for --remote-build (repeatable)', collect, [])
|
|
159
|
+
.option('--build-env-file <path>', 'dotenv-style file of build-time env vars for --remote-build')
|
|
160
|
+
.option('--build-command <command>', 'build command for --remote-build')
|
|
161
|
+
.option('--output-dir <path>', 'static output directory produced by --remote-build')
|
|
162
|
+
.action(async (localPath, opts) => {
|
|
163
|
+
try {
|
|
164
|
+
if (localPath && opts.sourcePath) {
|
|
165
|
+
throw new Error('Pass either a local path or --source-path, not both.');
|
|
166
|
+
}
|
|
167
|
+
if (opts.remoteBuild && opts.sourcePath) {
|
|
168
|
+
throw new Error('--remote-build uploads local source and cannot be combined with --source-path.');
|
|
169
|
+
}
|
|
170
|
+
if (!opts.remoteBuild &&
|
|
171
|
+
(opts.buildEnv.length > 0 || opts.buildEnvFile || opts.buildCommand || opts.outputDir)) {
|
|
172
|
+
throw new Error('--build-env, --build-env-file, --build-command, and --output-dir require --remote-build.');
|
|
173
|
+
}
|
|
174
|
+
const { client, projectId } = makeClient(opts.project);
|
|
175
|
+
await runWebsitePublish(client, projectId, {
|
|
176
|
+
localPath,
|
|
177
|
+
sourcePath: opts.sourcePath,
|
|
178
|
+
remoteBuild: opts.remoteBuild,
|
|
179
|
+
buildEnv: parseBuildEnv(opts.buildEnv, opts.buildEnvFile),
|
|
180
|
+
buildCommand: opts.buildCommand,
|
|
181
|
+
outputDir: opts.outputDir,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
catch (e) {
|
|
185
|
+
console.error(friendlyApiError(e));
|
|
186
|
+
process.exitCode = 1;
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
website
|
|
190
|
+
.command('deployments')
|
|
191
|
+
.description('List website deployments')
|
|
192
|
+
.option('--project <id>', 'project id (defaults to the linked directory)')
|
|
193
|
+
.action(async (opts) => {
|
|
194
|
+
try {
|
|
195
|
+
const { client, projectId } = makeClient(opts.project);
|
|
196
|
+
await runWebsiteDeployments(client, projectId);
|
|
197
|
+
}
|
|
198
|
+
catch (e) {
|
|
199
|
+
console.error(friendlyApiError(e));
|
|
200
|
+
process.exitCode = 1;
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
website
|
|
204
|
+
.command('rollback')
|
|
205
|
+
.description('Rollback website domains to a previous deployment')
|
|
206
|
+
.argument('<deployment-id>')
|
|
207
|
+
.option('--project <id>', 'project id (defaults to the linked directory)')
|
|
208
|
+
.action(async (deploymentId, opts) => {
|
|
209
|
+
try {
|
|
210
|
+
const { client, projectId } = makeClient(opts.project);
|
|
211
|
+
await runWebsiteRollback(client, projectId, deploymentId);
|
|
212
|
+
}
|
|
213
|
+
catch (e) {
|
|
214
|
+
console.error(friendlyApiError(e));
|
|
215
|
+
process.exitCode = 1;
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
function credentialsHome() {
|
|
5
|
+
if (process.env.RUN_CLOUD_HOME)
|
|
6
|
+
return process.env.RUN_CLOUD_HOME;
|
|
7
|
+
if (process.env.NEWLY_HOME)
|
|
8
|
+
return process.env.NEWLY_HOME;
|
|
9
|
+
const invoked = basename(process.argv[1] ?? '');
|
|
10
|
+
if (invoked === 'runcloud' || invoked === 'run-cloud.js')
|
|
11
|
+
return join(homedir(), '.run-cloud');
|
|
12
|
+
return process.env.NEWLY_HOME ?? join(homedir(), '.newly');
|
|
13
|
+
}
|
|
14
|
+
function credentialsPath() {
|
|
15
|
+
return join(credentialsHome(), 'credentials');
|
|
16
|
+
}
|
|
17
|
+
export function credentialsDisplayPath() {
|
|
18
|
+
const path = credentialsPath();
|
|
19
|
+
const home = homedir();
|
|
20
|
+
return path.startsWith(home) ? `~${path.slice(home.length)}` : path;
|
|
21
|
+
}
|
|
22
|
+
function loginCommandName() {
|
|
23
|
+
const invoked = basename(process.argv[1] ?? '');
|
|
24
|
+
return invoked === 'runcloud' || invoked === 'run-cloud.js' ? 'runcloud login' : 'newly login';
|
|
25
|
+
}
|
|
26
|
+
export function normalizeApiUrl(url) {
|
|
27
|
+
const trimmed = url.replace(/\/+$/, '');
|
|
28
|
+
return trimmed.endsWith('/api') ? trimmed : `${trimmed}/api`;
|
|
29
|
+
}
|
|
30
|
+
export function saveCredentials(creds) {
|
|
31
|
+
mkdirSync(credentialsHome(), { recursive: true, mode: 0o700 });
|
|
32
|
+
const normalized = { ...creds, apiUrl: normalizeApiUrl(creds.apiUrl) };
|
|
33
|
+
writeFileSync(credentialsPath(), JSON.stringify(normalized, null, 2) + '\n', { mode: 0o600 });
|
|
34
|
+
chmodSync(credentialsPath(), 0o600);
|
|
35
|
+
}
|
|
36
|
+
export function loadCredentials() {
|
|
37
|
+
const envToken = process.env.NEWLY_API_TOKEN ?? process.env.NEWLY_CLI_TOKEN;
|
|
38
|
+
const envApiUrl = process.env.NEWLY_API_URL ?? process.env.NEWLY_API_HOST ?? process.env.NEXT_PUBLIC_API_HOST;
|
|
39
|
+
if (envToken?.trim() && envApiUrl?.trim()) {
|
|
40
|
+
return {
|
|
41
|
+
token: envToken.trim(),
|
|
42
|
+
apiUrl: normalizeApiUrl(envApiUrl.trim()),
|
|
43
|
+
appUrl: process.env.NEWLY_APP_URL ?? 'https://newly.dev',
|
|
44
|
+
expiresAt: Number.MAX_SAFE_INTEGER,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
if (!existsSync(credentialsPath()))
|
|
48
|
+
return null;
|
|
49
|
+
try {
|
|
50
|
+
const creds = JSON.parse(readFileSync(credentialsPath(), 'utf8'));
|
|
51
|
+
if (!creds.token || creds.expiresAt < Date.now() / 1000)
|
|
52
|
+
return null;
|
|
53
|
+
return { ...creds, apiUrl: normalizeApiUrl(creds.apiUrl) };
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export function clearCredentials() {
|
|
60
|
+
rmSync(credentialsPath(), { force: true });
|
|
61
|
+
}
|
|
62
|
+
export function requireCredentials() {
|
|
63
|
+
const creds = loadCredentials();
|
|
64
|
+
if (!creds) {
|
|
65
|
+
throw new Error(`Not logged in (or session expired). Run: ${loginCommandName()}`);
|
|
66
|
+
}
|
|
67
|
+
return creds;
|
|
68
|
+
}
|
|
69
|
+
export function writeProjectLink(dir, link) {
|
|
70
|
+
const linkDir = join(dir, '.newly');
|
|
71
|
+
mkdirSync(linkDir, { recursive: true });
|
|
72
|
+
const gitignorePath = join(linkDir, '.gitignore');
|
|
73
|
+
if (!existsSync(gitignorePath))
|
|
74
|
+
writeFileSync(gitignorePath, '*\n');
|
|
75
|
+
writeFileSync(join(linkDir, 'project.json'), JSON.stringify(link, null, 2) + '\n');
|
|
76
|
+
}
|
|
77
|
+
export function readProjectLink(dir) {
|
|
78
|
+
const path = join(dir, '.newly', 'project.json');
|
|
79
|
+
if (!existsSync(path))
|
|
80
|
+
return null;
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export function resolveProject(dir, explicit) {
|
|
89
|
+
if (explicit)
|
|
90
|
+
return explicit;
|
|
91
|
+
const link = readProjectLink(dir);
|
|
92
|
+
if (link?.projectId)
|
|
93
|
+
return link.projectId;
|
|
94
|
+
throw new Error('No project linked to this directory. Run `newly pull <project-id>` first, or pass --project <id>.');
|
|
95
|
+
}
|
package/dist/diagnose.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
const DEFAULT_URLS = {
|
|
2
|
+
dev: {
|
|
3
|
+
apiOps: 'https://cp-api-ops-dev.newly.app',
|
|
4
|
+
worker: 'https://cp-worker-dev.newly.app',
|
|
5
|
+
},
|
|
6
|
+
prod: {
|
|
7
|
+
apiOps: 'https://cp-api-ops-prod.newly.app',
|
|
8
|
+
worker: 'https://cp-worker-prod.newly.app',
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
function stripSlash(url) {
|
|
12
|
+
return url.replace(/\/+$/, '');
|
|
13
|
+
}
|
|
14
|
+
export function resolveTargets(env) {
|
|
15
|
+
if (env !== 'dev' && env !== 'prod') {
|
|
16
|
+
throw new Error(`--env must be dev or prod (got "${env}")`);
|
|
17
|
+
}
|
|
18
|
+
const upper = env.toUpperCase();
|
|
19
|
+
const apiOps = process.env[`DIAGNOSE_CP_API_OPS_URL_${upper}`] ??
|
|
20
|
+
process.env.DIAGNOSE_CP_API_OPS_URL ??
|
|
21
|
+
DEFAULT_URLS[env].apiOps;
|
|
22
|
+
const worker = process.env[`DIAGNOSE_CP_WORKER_URL_${upper}`] ??
|
|
23
|
+
process.env.DIAGNOSE_CP_WORKER_URL ??
|
|
24
|
+
DEFAULT_URLS[env].worker;
|
|
25
|
+
return [
|
|
26
|
+
{ service: 'cp-api', url: stripSlash(apiOps) },
|
|
27
|
+
{ service: 'cp-worker', url: stripSlash(worker) },
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
function extractAuthorization(headers) {
|
|
31
|
+
if (headers instanceof Headers) {
|
|
32
|
+
return headers.get('authorization') ?? undefined;
|
|
33
|
+
}
|
|
34
|
+
const h = headers;
|
|
35
|
+
return h?.Authorization ?? h?.authorization;
|
|
36
|
+
}
|
|
37
|
+
export async function resolveOpsAuthHeader(env = process.env) {
|
|
38
|
+
const audience = env.DIAGNOSE_OIDC_AUDIENCE ?? 'newly-diagnose-ops';
|
|
39
|
+
try {
|
|
40
|
+
const { GoogleAuth } = await import('google-auth-library');
|
|
41
|
+
const auth = new GoogleAuth();
|
|
42
|
+
const client = await auth.getIdTokenClient(audience);
|
|
43
|
+
const authz = extractAuthorization(await client.getRequestHeaders());
|
|
44
|
+
if (authz) {
|
|
45
|
+
return authz;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
}
|
|
50
|
+
const token = env.DIAGNOSE_OPS_TOKEN;
|
|
51
|
+
if (token) {
|
|
52
|
+
return `Bearer ${token}`;
|
|
53
|
+
}
|
|
54
|
+
throw new Error('No ops credential available. Run `gcloud auth application-default login` (ADC/OIDC), ' +
|
|
55
|
+
'or export DIAGNOSE_OPS_TOKEN with the break-glass ops token.');
|
|
56
|
+
}
|
|
57
|
+
function truncate(value, max = 200) {
|
|
58
|
+
const trimmed = value.trim();
|
|
59
|
+
return trimmed.length > max ? `${trimmed.slice(0, max)}...` : trimmed;
|
|
60
|
+
}
|
|
61
|
+
export async function fetchDiagnose(target, authHeader) {
|
|
62
|
+
try {
|
|
63
|
+
const res = await fetch(`${target.url}/diagnose`, { headers: { Authorization: authHeader } });
|
|
64
|
+
if (!res.ok) {
|
|
65
|
+
const body = await res.text().catch(() => '');
|
|
66
|
+
return {
|
|
67
|
+
service: target.service,
|
|
68
|
+
error: `HTTP ${res.status}${body ? `: ${truncate(body)}` : ''}`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return (await res.json());
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
return { service: target.service, error: e instanceof Error ? e.message : String(e) };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function isDoc(result) {
|
|
78
|
+
return !('error' in result);
|
|
79
|
+
}
|
|
80
|
+
export function rollupVerdict(services) {
|
|
81
|
+
const names = Object.keys(services);
|
|
82
|
+
const down = names.filter((name) => !isDoc(services[name]));
|
|
83
|
+
if (down.length) {
|
|
84
|
+
return `DOWN - unreachable: ${down.join(', ')}`;
|
|
85
|
+
}
|
|
86
|
+
const degraded = [];
|
|
87
|
+
for (const name of names) {
|
|
88
|
+
const doc = services[name];
|
|
89
|
+
const bad = Object.entries(doc.health)
|
|
90
|
+
.filter(([, probe]) => !probe.ok)
|
|
91
|
+
.map(([probeName]) => probeName);
|
|
92
|
+
if (bad.length) {
|
|
93
|
+
degraded.push(`${name} (${bad.join(', ')})`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (degraded.length) {
|
|
97
|
+
return `DEGRADED - ${degraded.join('; ')}`;
|
|
98
|
+
}
|
|
99
|
+
return `OK - ${names.length} service(s) healthy`;
|
|
100
|
+
}
|
|
101
|
+
export async function fanoutDiagnose(env) {
|
|
102
|
+
const targets = resolveTargets(env);
|
|
103
|
+
const authHeader = await resolveOpsAuthHeader();
|
|
104
|
+
const results = await Promise.all(targets.map((target) => fetchDiagnose(target, authHeader)));
|
|
105
|
+
const services = {};
|
|
106
|
+
results.forEach((result, index) => {
|
|
107
|
+
services[targets[index].service] = result;
|
|
108
|
+
});
|
|
109
|
+
return { rollup: rollupVerdict(services), services };
|
|
110
|
+
}
|
|
111
|
+
export function renderRollup(result, session) {
|
|
112
|
+
const lines = [result.rollup];
|
|
113
|
+
if (session) {
|
|
114
|
+
lines.push(`session: ${session}`);
|
|
115
|
+
}
|
|
116
|
+
lines.push('');
|
|
117
|
+
for (const [name, service] of Object.entries(result.services)) {
|
|
118
|
+
if (!isDoc(service)) {
|
|
119
|
+
lines.push(`${name}: UNREACHABLE - ${service.error}`);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const sha = service.deploy.git_sha ?? '(sha unknown)';
|
|
123
|
+
const revision = service.deploy.revision ?? '(revision unknown)';
|
|
124
|
+
lines.push(`${name}: ${sha} @ ${revision} [env=${service.env}]`);
|
|
125
|
+
const badProbes = Object.entries(service.health)
|
|
126
|
+
.filter(([, probe]) => !probe.ok)
|
|
127
|
+
.map(([probeName, probe]) => `${probeName}${probe.detail ? ` (${probe.detail})` : ''}`);
|
|
128
|
+
lines.push(` health: ${badProbes.length ? `DEGRADED - ${badProbes.join(', ')}` : 'all ok'}`);
|
|
129
|
+
for (const verdict of service.diagnosis) {
|
|
130
|
+
lines.push(` - ${verdict}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return lines.join('\n');
|
|
134
|
+
}
|
|
135
|
+
export function registerDiagnose(program) {
|
|
136
|
+
program
|
|
137
|
+
.command('diagnose')
|
|
138
|
+
.description('Fan out /diagnose across control-plane services and roll up a verdict (dev/ops only)')
|
|
139
|
+
.option('--env <env>', 'dev or prod', 'dev')
|
|
140
|
+
.option('--session <id>', 'annotate the rollup with a session id you are investigating')
|
|
141
|
+
.option('--json', 'emit the merged JSON document instead of the human rollup')
|
|
142
|
+
.action(async (opts) => {
|
|
143
|
+
try {
|
|
144
|
+
if (opts.env !== 'dev' && opts.env !== 'prod') {
|
|
145
|
+
throw new Error('--env must be dev or prod');
|
|
146
|
+
}
|
|
147
|
+
const result = await fanoutDiagnose(opts.env);
|
|
148
|
+
if (opts.json) {
|
|
149
|
+
console.log(JSON.stringify({ session: opts.session ?? null, ...result }, null, 2));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
console.log(renderRollup(result, opts.session));
|
|
153
|
+
}
|
|
154
|
+
catch (e) {
|
|
155
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
156
|
+
process.exitCode = 1;
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
}
|