@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.
@@ -0,0 +1,70 @@
1
+ import { formatAge, formatCpu, formatMib, formatSeconds, formatState, renderDetails, renderTable, shortDigest, style, } from '../terminal.js';
2
+ function resources(record) {
3
+ const memory = formatMib(record.mem_mb);
4
+ const disk = formatMib(record.disk_mb);
5
+ return [
6
+ formatCpu(record.milli_cpu),
7
+ memory ? `${memory} memory` : undefined,
8
+ disk ? `${disk} disk` : undefined,
9
+ ]
10
+ .filter(Boolean)
11
+ .join(' · ') || undefined;
12
+ }
13
+ function hostname(record) {
14
+ const host = record.hostname ?? record.box?.hostname;
15
+ const port = record.exposedPort ?? record.box?.port;
16
+ return host && port ? `${host}:${port}` : host;
17
+ }
18
+ function title(record, verb) {
19
+ if (verb === 'details')
20
+ return 'Sandbox';
21
+ if (record.state === 'running')
22
+ return `✓ Sandbox ${verb === 'restored' ? 'restored' : 'ready'}`;
23
+ if (record.state === 'building_image')
24
+ return '◐ Sandbox is building its image';
25
+ if (record.state === 'paused' || record.state === 'archived')
26
+ return '○ Sandbox paused';
27
+ return verb === 'restored' ? 'Sandbox restored' : 'Sandbox created';
28
+ }
29
+ export function renderSandbox(record, verb = 'details') {
30
+ const next = verb === 'details'
31
+ ? undefined
32
+ : record.state === 'running'
33
+ ? `${style.dim('Next:')} runcloud sandbox shell ${record.id}`
34
+ : `${style.dim('Follow:')} runcloud sandbox get ${record.id}`;
35
+ return renderDetails(title(record, verb), [
36
+ { label: 'ID', value: record.id },
37
+ { label: 'Name', value: record.name },
38
+ { label: 'State', value: formatState(record.state) },
39
+ { label: 'Image', value: record.image },
40
+ { label: 'Resources', value: resources(record) },
41
+ { label: 'Idle pause', value: formatSeconds(record.idle_pause_seconds) },
42
+ { label: 'Timeout', value: formatSeconds(record.timeout_seconds) },
43
+ { label: 'Hostname', value: hostname(record) },
44
+ { label: 'Image digest', value: shortDigest(record.image_digest) },
45
+ { label: 'Created', value: formatAge(record.created_at) },
46
+ { label: 'Last active', value: verb === 'details' ? formatAge(record.last_active_at) : undefined },
47
+ ], next);
48
+ }
49
+ export function renderSandboxList(records) {
50
+ if (records.length === 0) {
51
+ return `${style.bold('Sandboxes')}\n\nNo sandboxes found.\n\n${style.dim('Create one:')} runcloud sandbox create`;
52
+ }
53
+ return renderTable(`Sandboxes (${records.length})`, [
54
+ { key: 'id', label: 'ID', maxWidth: 24 },
55
+ { key: 'name', label: 'NAME / HOSTNAME', maxWidth: 20 },
56
+ { key: 'state', label: 'STATE', maxWidth: 14 },
57
+ { key: 'image', label: 'IMAGE', maxWidth: 20 },
58
+ { key: 'resources', label: 'CPU / MEMORY', maxWidth: 18 },
59
+ { key: 'age', label: 'AGE' },
60
+ ], records.map((record) => ({
61
+ id: record.id,
62
+ name: hostname(record) ?? record.name ?? '—',
63
+ state: formatState(record.state),
64
+ image: record.image ?? '—',
65
+ resources: [formatCpu(record.milli_cpu)?.replace(' CPU', ''), formatMib(record.mem_mb)]
66
+ .filter(Boolean)
67
+ .join(' / ') || '—',
68
+ age: formatAge(record.created_at) ?? '—',
69
+ })));
70
+ }
@@ -0,0 +1,203 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { ApiClient, friendlyApiError } from '../api.js';
3
+ import { requireCredentials } from '../config.js';
4
+ import { promptHidden } from '../prompt.js';
5
+ function api() {
6
+ const credentials = requireCredentials();
7
+ return new ApiClient(credentials.apiUrl, credentials.token);
8
+ }
9
+ async function run(fn) {
10
+ try {
11
+ await fn();
12
+ }
13
+ catch (err) {
14
+ console.error(friendlyApiError(err));
15
+ process.exitCode = 1;
16
+ }
17
+ }
18
+ function orgQuery(org, extra = {}) {
19
+ const params = new URLSearchParams(extra);
20
+ if (org)
21
+ params.set('orgId', org);
22
+ const qs = params.toString();
23
+ return qs ? `?${qs}` : '';
24
+ }
25
+ export function parseDotenv(text) {
26
+ const entries = {};
27
+ for (const raw of text.split('\n')) {
28
+ const line = raw.trim();
29
+ if (!line || line.startsWith('#'))
30
+ continue;
31
+ const withoutExport = line.startsWith('export ') ? line.slice('export '.length).trim() : line;
32
+ const eq = withoutExport.indexOf('=');
33
+ if (eq <= 0)
34
+ continue;
35
+ const name = withoutExport.slice(0, eq).trim();
36
+ let value = withoutExport.slice(eq + 1).trim();
37
+ if ((value.startsWith('"') && value.endsWith('"') && value.length >= 2) ||
38
+ (value.startsWith("'") && value.endsWith("'") && value.length >= 2)) {
39
+ value = value.slice(1, -1);
40
+ }
41
+ entries[name] = value;
42
+ }
43
+ return entries;
44
+ }
45
+ export function parseJsonEntries(text) {
46
+ const parsed = JSON.parse(text);
47
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
48
+ throw new Error('--from-json needs a JSON object of name → value');
49
+ }
50
+ const entries = {};
51
+ for (const [name, value] of Object.entries(parsed)) {
52
+ if (typeof value !== 'string') {
53
+ throw new Error(`--from-json: "${name}" is not a string; secrets are strings`);
54
+ }
55
+ entries[name] = value;
56
+ }
57
+ return entries;
58
+ }
59
+ async function readStdin() {
60
+ const chunks = [];
61
+ for await (const chunk of process.stdin)
62
+ chunks.push(Buffer.from(chunk));
63
+ return Buffer.concat(chunks).toString('utf8');
64
+ }
65
+ export function registerSecrets(program) {
66
+ const groups = program
67
+ .command('secret-group')
68
+ .description('Create and manage secret groups a sandbox can attach');
69
+ groups
70
+ .command('create')
71
+ .description('Create a group together with its contents')
72
+ .argument('<group>', 'group name, e.g. aws')
73
+ .argument('[unsupported...]', 'not accepted: a value cannot be passed as an argument')
74
+ .option('--from-dotenv <path>', 'read KEY=VALUE lines from a .env file')
75
+ .option('--from-json <path>', 'read a JSON object of name → value')
76
+ .option('--key <name>', 'prompt for this value (hidden); repeatable', collect, [])
77
+ .option('--force', 'replace the group if it already exists', false)
78
+ .option('--org <id>', 'organization to store it in')
79
+ .action((group, unsupported, opts) => run(async () => {
80
+ if (unsupported.length > 0) {
81
+ throw new Error(`values cannot be passed as arguments (got "${unsupported[0]?.split('=')[0]}=…"). ` +
82
+ 'An argument lands in your shell history, in `ps` output, and in CI logs. ' +
83
+ 'Use --from-dotenv <path>, --from-json <path>, or --key <name> for a hidden prompt.');
84
+ }
85
+ const entries = {};
86
+ if (opts.fromDotenv)
87
+ Object.assign(entries, parseDotenv(readFileSync(opts.fromDotenv, 'utf8')));
88
+ if (opts.fromJson)
89
+ Object.assign(entries, parseJsonEntries(readFileSync(opts.fromJson, 'utf8')));
90
+ for (const name of opts.key) {
91
+ entries[name] = await promptHidden(`${name}: `);
92
+ }
93
+ if (Object.keys(entries).length === 0) {
94
+ throw new Error('nothing to store: pass --from-dotenv <path>, --from-json <path>, or --key <name>. ' +
95
+ 'A value cannot be given as an argument — it would land in your shell history.');
96
+ }
97
+ const body = { entries };
98
+ if (opts.force)
99
+ body.force = true;
100
+ await api().put(`/run-cloud/secret-groups/${encodeURIComponent(group)}${orgQuery(opts.org)}`, body);
101
+ console.log(`secret group ${group}: ${Object.keys(entries).length} secret(s) stored`);
102
+ }));
103
+ groups
104
+ .command('list')
105
+ .description('List secret groups')
106
+ .option('--json', 'output JSON', false)
107
+ .option('--org <id>', 'organization to read from')
108
+ .action((opts) => run(async () => {
109
+ const data = await api().get(`/run-cloud/secret-groups${orgQuery(opts.org)}`);
110
+ const rows = (data?.groups ?? []);
111
+ if (opts.json) {
112
+ console.log(JSON.stringify(rows, null, 2));
113
+ return;
114
+ }
115
+ if (rows.length === 0) {
116
+ console.log('no secret groups yet — runcloud secret-group create <name> --from-dotenv <path>');
117
+ return;
118
+ }
119
+ for (const row of rows)
120
+ console.log(`${row.group}\t${row.secretCount} secret(s)\t${row.updatedAt}`);
121
+ }));
122
+ groups
123
+ .command('show')
124
+ .description('List the names in a group (never values)')
125
+ .argument('<group>', 'group name')
126
+ .option('--json', 'output JSON', false)
127
+ .option('--org <id>', 'organization to read from')
128
+ .action((group, opts) => run(async () => {
129
+ const data = await api().get(`/run-cloud/secrets${orgQuery(opts.org)}`);
130
+ const rows = (data?.secrets ?? []).filter((row) => row.group === group);
131
+ if (opts.json) {
132
+ console.log(JSON.stringify(rows, null, 2));
133
+ return;
134
+ }
135
+ if (rows.length === 0) {
136
+ console.log(`no secret group ${group}`);
137
+ return;
138
+ }
139
+ for (const row of rows) {
140
+ console.log(row.kind === 'file' ? `${row.name}\tfile → ${row.filePath}` : `${row.name}\tenv`);
141
+ }
142
+ }));
143
+ groups
144
+ .command('rm')
145
+ .description('Delete a group and every secret in it')
146
+ .argument('<group>', 'group name')
147
+ .option('--org <id>', 'organization to delete from')
148
+ .action((group, opts) => run(async () => {
149
+ await api().delete(`/run-cloud/secret-groups/${encodeURIComponent(group)}${orgQuery(opts.org)}`);
150
+ console.log(`secret group ${group} deleted`);
151
+ }));
152
+ const secrets = program
153
+ .command('secrets')
154
+ .description('Set and remove individual secrets inside a group');
155
+ secrets
156
+ .command('set')
157
+ .description('Store one environment-variable secret, reading the value from stdin')
158
+ .argument('<name>', 'secret name, e.g. AWS_SESSION_TOKEN')
159
+ .requiredOption('--group <group>', 'group to store it in')
160
+ .option('--stdin', 'read the value from stdin', false)
161
+ .option('--org <id>', 'organization to store it in')
162
+ .action((name, opts) => run(async () => {
163
+ const value = opts.stdin ? (await readStdin()).replace(/\n$/, '') : await promptHidden(`${name}: `);
164
+ if (!value)
165
+ throw new Error(`no value read for ${name}`);
166
+ await api().put(`/run-cloud/secrets/${encodeURIComponent(name)}${orgQuery(opts.org)}`, {
167
+ group: opts.group,
168
+ value,
169
+ kind: 'env',
170
+ });
171
+ console.log(`${opts.group}/${name} stored`);
172
+ }));
173
+ secrets
174
+ .command('set-file')
175
+ .description('Store a file secret, written into the sandbox at --path')
176
+ .argument('<name>', 'secret name, e.g. sa.json')
177
+ .requiredOption('--group <group>', 'group to store it in')
178
+ .requiredOption('--path <path>', 'path inside the sandbox, relative to its home')
179
+ .requiredOption('--from-file <path>', 'local file to read the contents from')
180
+ .option('--org <id>', 'organization to store it in')
181
+ .action((name, opts) => run(async () => {
182
+ await api().put(`/run-cloud/secrets/${encodeURIComponent(name)}${orgQuery(opts.org)}`, {
183
+ group: opts.group,
184
+ value: readFileSync(opts.fromFile, 'utf8'),
185
+ kind: 'file',
186
+ filePath: opts.path,
187
+ });
188
+ console.log(`${opts.group}/${name} stored → ${opts.path}`);
189
+ }));
190
+ secrets
191
+ .command('rm')
192
+ .description('Remove one secret from a group')
193
+ .argument('<name>', 'secret name')
194
+ .requiredOption('--group <group>', 'group it lives in')
195
+ .option('--org <id>', 'organization to delete from')
196
+ .action((name, opts) => run(async () => {
197
+ await api().delete(`/run-cloud/secrets/${encodeURIComponent(name)}${orgQuery(opts.org, { group: opts.group })}`);
198
+ console.log(`${opts.group}/${name} deleted`);
199
+ }));
200
+ }
201
+ function collect(value, previous) {
202
+ return [...previous, value];
203
+ }
package/dist/config.js CHANGED
@@ -1,23 +1,54 @@
1
1
  import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
- import { join } from 'node:path';
4
- function newlyHome() {
3
+ import { basename, join } from 'node:path';
4
+ export function isRunCloud() {
5
+ const names = [process.argv[1], process.execPath].map((path) => basename(path ?? ''));
6
+ return names.some((name) => name === 'runcloud' || name === 'run-cloud' || name === 'run-cloud.js');
7
+ }
8
+ function credentialsHome() {
9
+ if (isRunCloud())
10
+ return process.env.RUN_CLOUD_HOME ?? join(homedir(), '.run-cloud');
5
11
  return process.env.NEWLY_HOME ?? join(homedir(), '.newly');
6
12
  }
7
13
  function credentialsPath() {
8
- return join(newlyHome(), 'credentials');
14
+ return join(credentialsHome(), 'credentials');
15
+ }
16
+ export function credentialsDisplayPath() {
17
+ const path = credentialsPath();
18
+ const home = homedir();
19
+ return path.startsWith(home) ? `~${path.slice(home.length)}` : path;
20
+ }
21
+ export function loginCommandName() {
22
+ return isRunCloud() ? 'runcloud login' : 'newly login';
9
23
  }
10
24
  export function normalizeApiUrl(url) {
11
- const trimmed = url.replace(/\/+$/, '');
12
- return trimmed.endsWith('/api') ? trimmed : `${trimmed}/api`;
25
+ return url.replace(/\/+$/, '');
13
26
  }
14
27
  export function saveCredentials(creds) {
15
- mkdirSync(newlyHome(), { recursive: true, mode: 0o700 });
28
+ mkdirSync(credentialsHome(), { recursive: true, mode: 0o700 });
16
29
  const normalized = { ...creds, apiUrl: normalizeApiUrl(creds.apiUrl) };
17
30
  writeFileSync(credentialsPath(), JSON.stringify(normalized, null, 2) + '\n', { mode: 0o600 });
18
31
  chmodSync(credentialsPath(), 0o600);
19
32
  }
20
33
  export function loadCredentials() {
34
+ const envToken = isRunCloud()
35
+ ? process.env.RUN_CLOUD_API_TOKEN ??
36
+ process.env.RUN_CLOUD_API_KEY ??
37
+ process.env.RUN_CLOUD_CI_ENVIRONMENT_TOKEN
38
+ : process.env.NEWLY_API_TOKEN ?? process.env.NEWLY_CLI_TOKEN;
39
+ const envApiUrl = isRunCloud()
40
+ ? process.env.RUN_CLOUD_API_URL
41
+ : process.env.NEWLY_API_URL;
42
+ if (envToken?.trim() && (isRunCloud() || envApiUrl?.trim())) {
43
+ return {
44
+ token: envToken.trim(),
45
+ apiUrl: normalizeApiUrl(envApiUrl?.trim() || 'https://api.run.cloud'),
46
+ appUrl: isRunCloud()
47
+ ? process.env.RUN_CLOUD_APP_URL ?? 'https://run.cloud'
48
+ : process.env.NEWLY_APP_URL ?? 'https://newly.app',
49
+ expiresAt: Number.MAX_SAFE_INTEGER,
50
+ };
51
+ }
21
52
  if (!existsSync(credentialsPath()))
22
53
  return null;
23
54
  try {
@@ -30,13 +61,47 @@ export function loadCredentials() {
30
61
  return null;
31
62
  }
32
63
  }
64
+ function environmentSelectionPath(dir) {
65
+ return join(dir, '.newly', 'environment.json');
66
+ }
67
+ export function writeEnvironmentSelection(dir, selection) {
68
+ const selectionDir = join(dir, '.newly');
69
+ mkdirSync(selectionDir, { recursive: true });
70
+ const gitignorePath = join(selectionDir, '.gitignore');
71
+ if (!existsSync(gitignorePath))
72
+ writeFileSync(gitignorePath, '*\n');
73
+ writeFileSync(environmentSelectionPath(dir), JSON.stringify(selection, null, 2) + '\n');
74
+ }
75
+ export function readEnvironmentSelection(dir) {
76
+ try {
77
+ const parsed = JSON.parse(readFileSync(environmentSelectionPath(dir), 'utf8'));
78
+ if (!parsed.id || !parsed.name || !parsed.projectId)
79
+ return null;
80
+ return parsed;
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ }
86
+ export function clearEnvironmentSelection(dir) {
87
+ rmSync(environmentSelectionPath(dir), { force: true });
88
+ }
89
+ export function requireEnvironmentSelection(dir, projectId) {
90
+ const selection = readEnvironmentSelection(dir);
91
+ if (!selection)
92
+ throw new Error('No environment selected. Run `newly env use <name>` first.');
93
+ if (selection.projectId !== projectId) {
94
+ throw new Error('The selected environment belongs to a different linked project. Run `newly env clear`.');
95
+ }
96
+ return selection;
97
+ }
33
98
  export function clearCredentials() {
34
99
  rmSync(credentialsPath(), { force: true });
35
100
  }
36
101
  export function requireCredentials() {
37
102
  const creds = loadCredentials();
38
103
  if (!creds) {
39
- throw new Error('Not logged in (or session expired). Run: newly login');
104
+ throw new Error(`Not logged in (or session expired). Run: ${loginCommandName()}`);
40
105
  }
41
106
  return creds;
42
107
  }
@@ -0,0 +1,48 @@
1
+ import { normalizeApiUrl } from "./config.js";
2
+ export async function runDeviceLogin(deps) {
3
+ const fetchFn = deps.fetchFn ?? fetch;
4
+ const log = deps.log ?? ((m) => console.log(m));
5
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
6
+ const now = deps.now ?? (() => Date.now());
7
+ const base = normalizeApiUrl(deps.apiUrl);
8
+ const startRes = await fetchFn(`${base}/run-cloud/cli-auth/device`, {
9
+ method: "POST",
10
+ headers: { "content-type": "application/json" },
11
+ body: "{}",
12
+ });
13
+ if (!startRes.ok)
14
+ throw new Error(`Could not start login (${startRes.status}).`);
15
+ const start = (await startRes.json());
16
+ log(`\nTo authorize this terminal, confirm the code below in your browser:\n\n ${start.user_code}\n`);
17
+ log(`Opening ${start.verification_uri} … (if it doesn't open, go there and enter the code)\n`);
18
+ const approvalUrl = new URL(start.verification_uri_complete);
19
+ if (deps.provider)
20
+ approvalUrl.searchParams.set("provider", deps.provider);
21
+ deps.openBrowser?.(approvalUrl.toString());
22
+ let intervalMs = Math.max(0, start.interval) * 1000;
23
+ const deadline = now() + start.expires_in * 1000;
24
+ for (;;) {
25
+ if (now() >= deadline)
26
+ throw new Error("Login timed out. Run `runcloud login` again.");
27
+ await sleep(intervalMs);
28
+ const res = await fetchFn(`${base}/run-cloud/cli-auth/token`, {
29
+ method: "POST",
30
+ headers: { "content-type": "application/json" },
31
+ body: JSON.stringify({ device_code: start.device_code }),
32
+ });
33
+ if (res.ok) {
34
+ const body = (await res.json());
35
+ return { token: body.token, apiUrl: body.apiUrl, expiresIn: body.expiresIn };
36
+ }
37
+ const { error } = (await res.json().catch(() => ({ error: "unknown" })));
38
+ if (error === "authorization_pending")
39
+ continue;
40
+ if (error === "slow_down") {
41
+ intervalMs += 5000;
42
+ continue;
43
+ }
44
+ if (error === "access_denied")
45
+ throw new Error("Authorization was denied. Run `runcloud login` again.");
46
+ throw new Error("The login code expired. Run `runcloud login` again.");
47
+ }
48
+ }
@@ -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
+ }
package/dist/index.js CHANGED
@@ -15,6 +15,10 @@ import { registerEmail } from './commands/email.js';
15
15
  import { registerWebsite } from './commands/website.js';
16
16
  import { registerDomains } from './commands/domains.js';
17
17
  import { registerDns } from './commands/dns.js';
18
+ import { registerDiagnose } from './diagnose.js';
19
+ import { registerDesktop } from './commands/desktop.js';
20
+ import { registerEnvironment } from './commands/environment.js';
21
+ import { registerAppStore } from './commands/appStore.js';
18
22
  const program = new Command('newly')
19
23
  .description('Develop, provision, and deploy Newly backends')
20
24
  .version('0.1.0');
@@ -33,4 +37,8 @@ registerEmail(program);
33
37
  registerWebsite(program);
34
38
  registerDomains(program);
35
39
  registerDns(program);
40
+ registerDiagnose(program);
41
+ registerDesktop(program);
42
+ registerEnvironment(program);
43
+ registerAppStore(program);
36
44
  await program.parseAsync(process.argv);