@newly/cli 0.1.0 → 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.
@@ -0,0 +1,272 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
+ import { basename, join, resolve } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { ApiClient, friendlyApiError } from '../api.js';
5
+ import { requireCredentials } from '../config.js';
6
+ const RUN_CLOUD_SKILL = `---
7
+ name: run-cloud-ios-simulator
8
+ description: Build, install, and inspect iOS apps with run.cloud remote simulator sessions.
9
+ version: 0.1.0
10
+ ---
11
+
12
+ # run.cloud iOS Simulator
13
+
14
+ Use this skill when a user asks an agent to run, inspect, smoke test, or debug an iOS app in a remote simulator through run.cloud.
15
+
16
+ ## Requirements
17
+
18
+ - The user must be authenticated with \`runcloud login\`.
19
+ - The account must have run.cloud access and a positive run.cloud balance.
20
+ - App artifacts should be simulator-compatible builds. Device App Store IPAs may fail to install on iOS Simulator.
21
+
22
+ ## Workflow
23
+
24
+ 1. Check access with \`runcloud account --json\`.
25
+ 2. Create a simulator with \`runcloud ios create --install ./build/MyApp.tar.gz --json\`.
26
+ 3. Save the returned session id and URL.
27
+ 4. Use \`runcloud ios open-url <url> --id <session-id>\` when a URL or deep link must be opened.
28
+ 5. Release the session with \`runcloud ios delete <session-id>\`.
29
+
30
+ ## Embedded Iframes
31
+
32
+ - Use \`runcloud ios create --inactivity-timeout 60s --json\` when the embed should auto-close after user inactivity.
33
+ - Omit \`--inactivity-timeout\` or pass \`none\` when the user needs a metered session without idle auto-close.
34
+ - Iframes post \`ios-simulator:status\`, \`ios-simulator:auth-error\`, \`ios-simulator:session-ended\`, and \`ios-simulator:session-restart-requested\` to the parent window. Verify \`event.source\` before acting.
35
+ - If \`ios-simulator:session-restart-requested\` arrives, create a fresh run.cloud session; do not reuse the ended iframe URL.
36
+
37
+ ## Rules
38
+
39
+ - Prefer \`--json\` for parsed output.
40
+ - Always release sessions you create unless the user asks to keep them open.
41
+ - If installation fails, verify that the artifact is a simulator build before attempting code changes.
42
+ - Do not assume a local tunnel is installed on the user's machine.
43
+ - Do not expose iOS simulator tokens in logs or screenshots.
44
+ `;
45
+ function client() {
46
+ const creds = requireCredentials();
47
+ return new ApiClient(creds.apiUrl, creds.token);
48
+ }
49
+ function print(value, opts = {}) {
50
+ if (opts.json) {
51
+ console.log(JSON.stringify(value, null, 2));
52
+ return;
53
+ }
54
+ if (Array.isArray(value)) {
55
+ for (const item of value)
56
+ console.log(formatRecord(item));
57
+ return;
58
+ }
59
+ console.log(formatRecord(value));
60
+ }
61
+ function formatRecord(value) {
62
+ if (!value || typeof value !== 'object')
63
+ return String(value);
64
+ const record = value;
65
+ return Object.entries(record)
66
+ .filter(([, v]) => v !== undefined && v !== null)
67
+ .map(([k, v]) => `${k}: ${typeof v === 'object' ? JSON.stringify(v) : String(v)}`)
68
+ .join('\n');
69
+ }
70
+ function parseLabels(labels) {
71
+ const out = {};
72
+ for (const raw of labels ?? []) {
73
+ const idx = raw.indexOf('=');
74
+ if (idx <= 0)
75
+ throw new Error(`Invalid --label ${raw}; expected key=value`);
76
+ out[raw.slice(0, idx)] = raw.slice(idx + 1);
77
+ }
78
+ return out;
79
+ }
80
+ function fileBlob(path) {
81
+ const resolved = resolve(path);
82
+ if (!existsSync(resolved))
83
+ throw new Error(`File does not exist: ${path}`);
84
+ if (!statSync(resolved).isFile())
85
+ throw new Error(`Not a file: ${path}`);
86
+ const data = readFileSync(resolved);
87
+ return {
88
+ blob: new Blob([new Uint8Array(data)], { type: 'application/octet-stream' }),
89
+ name: basename(resolved),
90
+ };
91
+ }
92
+ function skillBase(agent, scope) {
93
+ if (scope === 'project') {
94
+ if (agent === 'claude')
95
+ return join(process.cwd(), '.claude', 'skills');
96
+ if (agent === 'codex')
97
+ return join(process.cwd(), '.codex', 'skills');
98
+ if (agent === 'cursor')
99
+ return join(process.cwd(), '.agents', 'skills');
100
+ }
101
+ if (agent === 'claude')
102
+ return join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude'), 'skills');
103
+ if (agent === 'codex')
104
+ return join(process.env.CODEX_HOME ?? join(homedir(), '.codex'), 'skills');
105
+ if (agent === 'cursor')
106
+ return join(homedir(), '.agents', 'skills');
107
+ throw new Error(`Unsupported agent: ${agent}`);
108
+ }
109
+ function installSkill(opts) {
110
+ const agents = opts.agents?.length ? opts.agents : ['claude', 'codex', 'cursor'];
111
+ const scope = opts.scope ?? 'project';
112
+ const results = [];
113
+ for (const agent of agents) {
114
+ const target = join(skillBase(agent, scope), 'run-cloud-ios-simulator');
115
+ if (existsSync(target)) {
116
+ if (!opts.force)
117
+ throw new Error(`${target} already exists. Pass --force to overwrite.`);
118
+ rmSync(target, { recursive: true, force: true });
119
+ }
120
+ mkdirSync(target, { recursive: true });
121
+ writeFileSync(join(target, 'SKILL.md'), RUN_CLOUD_SKILL);
122
+ results.push({ agent, path: target, status: 'installed' });
123
+ }
124
+ return results;
125
+ }
126
+ async function pushAsset(path, opts = {}) {
127
+ const { blob, name } = fileBlob(path);
128
+ const form = new FormData();
129
+ form.set('file', blob, name);
130
+ if (opts.name)
131
+ form.set('name', opts.name);
132
+ return (await client().uploadForm('/run-cloud/assets', form));
133
+ }
134
+ async function action(fn) {
135
+ try {
136
+ await fn();
137
+ }
138
+ catch (err) {
139
+ console.error(friendlyApiError(err));
140
+ process.exitCode = 1;
141
+ }
142
+ }
143
+ export function registerRunCloud(program) {
144
+ program
145
+ .command('account')
146
+ .description('Show run.cloud entitlement and metered balance')
147
+ .option('--json', 'output JSON', false)
148
+ .action((opts) => action(async () => {
149
+ print(await client().get('/run-cloud/account'), opts);
150
+ }));
151
+ const ios = program.command('ios').description('Create, list, inspect, and delete iOS simulator sessions');
152
+ ios
153
+ .command('create')
154
+ .description('Create a remote iOS simulator session')
155
+ .option('--model <iphone|ipad>', 'simulator model', 'iphone')
156
+ .option('--region <region>', 'fleet region')
157
+ .option('--display-name <name>', 'human-readable name')
158
+ .option('--label <key=value>', 'label to attach to the session', (v, p) => [...p, v], [])
159
+ .option('--install <file>', 'local .ipa, .zip, .tar.gz, or simulator .app archive to upload and install', (v, p) => [...p, v], [])
160
+ .option('--install-asset <name-or-id>', 'previously uploaded asset to install', (v, p) => [...p, v], [])
161
+ .option('--inactivity-timeout <duration>', 'auto-close idle timeout, e.g. 60s, 3m, or none')
162
+ .option('--hard-timeout <duration>', 'maximum lifetime, e.g. 10m or 1h')
163
+ .option('--rm', 'release the session when this command exits', false)
164
+ .option('--json', 'output JSON', false)
165
+ .action((opts) => action(async () => {
166
+ const uploadedAssets = [];
167
+ for (const file of opts.install ?? [])
168
+ uploadedAssets.push(await pushAsset(file));
169
+ const installAssets = [
170
+ ...(opts.installAsset ?? []),
171
+ ...uploadedAssets
172
+ .map((asset) => asset.id)
173
+ .filter((id) => typeof id === 'string' && id.length > 0),
174
+ ];
175
+ const session = await client().post('/run-cloud/ios', {
176
+ model: opts.model,
177
+ region: opts.region,
178
+ displayName: opts.displayName,
179
+ labels: parseLabels(opts.label),
180
+ installAssets,
181
+ inactivityTimeout: opts.inactivityTimeout,
182
+ hardTimeout: opts.hardTimeout,
183
+ autoDelete: opts.rm,
184
+ });
185
+ print(session, opts);
186
+ }));
187
+ ios
188
+ .command('list')
189
+ .description('List active iOS simulator sessions')
190
+ .option('--all', 'include released sessions', false)
191
+ .option('--json', 'output JSON', false)
192
+ .action((opts) => action(async () => {
193
+ const qs = opts.all ? '?all=1' : '';
194
+ print(await client().get(`/run-cloud/ios${qs}`), opts);
195
+ }));
196
+ ios
197
+ .command('get')
198
+ .description('Show an iOS simulator session')
199
+ .argument('<id>')
200
+ .option('--json', 'output JSON', false)
201
+ .action((id, opts) => action(async () => {
202
+ print(await client().get(`/run-cloud/ios/${encodeURIComponent(id)}`), opts);
203
+ }));
204
+ ios
205
+ .command('delete')
206
+ .description('Release an iOS simulator session')
207
+ .argument('<id>')
208
+ .option('--json', 'output JSON', false)
209
+ .action((id, opts) => action(async () => {
210
+ print(await client().delete(`/run-cloud/ios/${encodeURIComponent(id)}`), opts);
211
+ }));
212
+ ios
213
+ .command('open-url')
214
+ .description('Open a URL or deep link in an iOS simulator session')
215
+ .argument('<url>')
216
+ .requiredOption('--id <id>', 'session id')
217
+ .option('--json', 'output JSON', false)
218
+ .action((url, opts) => action(async () => {
219
+ print(await client().post(`/run-cloud/ios/${encodeURIComponent(opts.id)}/open-url`, { url }), opts);
220
+ }));
221
+ const asset = program
222
+ .command('asset')
223
+ .alias('assets')
224
+ .description('Upload, list, inspect, and delete run.cloud assets');
225
+ asset
226
+ .command('push')
227
+ .description('Upload an app asset for simulator installation')
228
+ .argument('<file>')
229
+ .option('-n, --name <name>', 'asset name')
230
+ .option('--json', 'output JSON', false)
231
+ .action((file, opts) => action(async () => {
232
+ print(await pushAsset(file, opts), opts);
233
+ }));
234
+ asset
235
+ .command('list')
236
+ .description('List uploaded assets')
237
+ .option('--json', 'output JSON', false)
238
+ .action((opts) => action(async () => {
239
+ print(await client().get('/run-cloud/assets'), opts);
240
+ }));
241
+ asset
242
+ .command('pull')
243
+ .description('Download an asset')
244
+ .argument('<id>')
245
+ .option('-o, --output <path>', 'output path')
246
+ .action((id, opts) => action(async () => {
247
+ const body = await client().getBinary(`/run-cloud/assets/${encodeURIComponent(id)}/download`);
248
+ const output = opts.output ?? id;
249
+ writeFileSync(output, body);
250
+ console.log(output);
251
+ }));
252
+ asset
253
+ .command('delete')
254
+ .description('Delete an uploaded asset')
255
+ .argument('<id>')
256
+ .option('--json', 'output JSON', false)
257
+ .action((id, opts) => action(async () => {
258
+ print(await client().delete(`/run-cloud/assets/${encodeURIComponent(id)}`), opts);
259
+ }));
260
+ const skills = program.command('skills').description('Install run.cloud skills for AI coding agents');
261
+ skills
262
+ .command('install')
263
+ .description('Install the run.cloud iOS simulator skill')
264
+ .option('--agents <agent>', 'agent to install for: claude, codex, cursor', (v, p) => [...p, v], [])
265
+ .option('--scope <project|global>', 'install scope', 'project')
266
+ .option('--force', 'overwrite existing skill', false)
267
+ .option('--json', 'output JSON', false)
268
+ .action((opts) => action(async () => {
269
+ const scope = opts.scope === 'global' ? 'global' : 'project';
270
+ print(installSkill({ ...opts, scope }), opts);
271
+ }));
272
+ }
@@ -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
+ }
@@ -1,25 +1,27 @@
1
1
  import { ApiClient, friendlyApiError } from '../api.js';
2
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
+ }
3
13
  export function registerStack(program) {
4
14
  program
5
15
  .command('stack')
6
- .description('Show which backend stack a project uses (newly-backend or specular)')
16
+ .description('Show which backend type a project uses and whether it supports CLI deploys')
7
17
  .option('--project <id>', 'project id (defaults to the linked directory)')
8
18
  .action(async (opts) => {
9
19
  try {
10
20
  const creds = requireCredentials();
11
21
  const projectId = resolveProject(process.cwd(), opts.project);
12
22
  const client = new ApiClient(creds.apiUrl, creds.token);
13
- const out = await client.get(`/newly_backend/stack/${projectId}`);
14
- const stack = out.stack ?? 'unknown';
15
- console.log(`stack: ${stack}`);
16
- if (out.cli_deployable) {
17
- console.log('✓ CLI-deployable — Python api-service + Node auth-service');
18
- }
19
- else {
20
- console.log('✗ not CLI-deployable — this project is on the Specular stack ' +
21
- '(a single Node/drizzle backend). `newly deploy` supports only the newly-backend stack.');
22
- }
23
+ const out = await getStack(client, projectId);
24
+ console.log(formatStack(out));
23
25
  }
24
26
  catch (e) {
25
27
  console.error(friendlyApiError(e));
@@ -22,23 +22,23 @@ function renderProvisioningStages(s) {
22
22
  complete: true,
23
23
  },
24
24
  {
25
- label: 'Cloud environment assigned',
26
- note: 'dedicated cloud environment allocated for your backend',
25
+ label: 'Newly server assigned',
26
+ note: 'Newly server workspace allocated for your backend',
27
27
  complete: s.cloud_env_assigned,
28
28
  },
29
29
  {
30
- label: 'Infrastructure provisioned',
31
- note: 'container registry, storage buckets, IAM roles set up',
32
- complete: s.neon_ready,
30
+ label: 'Newly resources provisioned',
31
+ note: 'Newly storage and backend services provisioned',
32
+ complete: s.database_ready,
33
33
  },
34
34
  {
35
- label: 'Database created',
36
- note: 'Postgres database ready for your API service',
37
- complete: s.neon_ready,
35
+ label: 'Newly DB created',
36
+ note: 'Newly DB ready for your backend',
37
+ complete: s.database_ready,
38
38
  },
39
39
  {
40
- label: 'Environment secrets wired',
41
- note: 'DB credentials, API keys and auth tokens encrypted and stored',
40
+ label: 'Backend configuration stored',
41
+ note: 'Newly DB credentials, API keys, and auth settings encrypted and stored',
42
42
  complete: s.secrets_wired,
43
43
  },
44
44
  {
@@ -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 CHANGED
@@ -1,23 +1,49 @@
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
+ 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');
5
12
  return process.env.NEWLY_HOME ?? join(homedir(), '.newly');
6
13
  }
7
14
  function credentialsPath() {
8
- return join(newlyHome(), 'credentials');
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';
9
25
  }
10
26
  export function normalizeApiUrl(url) {
11
27
  const trimmed = url.replace(/\/+$/, '');
12
28
  return trimmed.endsWith('/api') ? trimmed : `${trimmed}/api`;
13
29
  }
14
30
  export function saveCredentials(creds) {
15
- mkdirSync(newlyHome(), { recursive: true, mode: 0o700 });
31
+ mkdirSync(credentialsHome(), { recursive: true, mode: 0o700 });
16
32
  const normalized = { ...creds, apiUrl: normalizeApiUrl(creds.apiUrl) };
17
33
  writeFileSync(credentialsPath(), JSON.stringify(normalized, null, 2) + '\n', { mode: 0o600 });
18
34
  chmodSync(credentialsPath(), 0o600);
19
35
  }
20
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
+ }
21
47
  if (!existsSync(credentialsPath()))
22
48
  return null;
23
49
  try {
@@ -36,7 +62,7 @@ export function clearCredentials() {
36
62
  export function requireCredentials() {
37
63
  const creds = loadCredentials();
38
64
  if (!creds) {
39
- throw new Error('Not logged in (or session expired). Run: newly login');
65
+ throw new Error(`Not logged in (or session expired). Run: ${loginCommandName()}`);
40
66
  }
41
67
  return creds;
42
68
  }