@newly/cli 0.1.0 → 0.1.1

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,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/index.js CHANGED
@@ -6,9 +6,15 @@ import { registerLink } from './commands/link.js';
6
6
  import { registerCreate } from './commands/create.js';
7
7
  import { registerPull } from './commands/pull.js';
8
8
  import { registerDeploy } from './commands/deploy.js';
9
- import { registerUp } from './commands/up.js';
10
9
  import { registerMisc } from './commands/misc.js';
11
10
  import { registerStack } from './commands/stack.js';
11
+ import { registerCreds } from './commands/creds.js';
12
+ import { registerBuild } from './commands/build.js';
13
+ import { registerServices } from './commands/services.js';
14
+ import { registerEmail } from './commands/email.js';
15
+ import { registerWebsite } from './commands/website.js';
16
+ import { registerDomains } from './commands/domains.js';
17
+ import { registerDns } from './commands/dns.js';
12
18
  const program = new Command('newly')
13
19
  .description('Develop, provision, and deploy Newly backends')
14
20
  .version('0.1.0');
@@ -18,7 +24,13 @@ registerLink(program);
18
24
  registerCreate(program);
19
25
  registerPull(program);
20
26
  registerDeploy(program);
21
- registerUp(program);
22
27
  registerMisc(program);
23
28
  registerStack(program);
29
+ registerCreds(program);
30
+ registerBuild(program);
31
+ registerServices(program);
32
+ registerEmail(program);
33
+ registerWebsite(program);
34
+ registerDomains(program);
35
+ registerDns(program);
24
36
  await program.parseAsync(process.argv);
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ if (!process.env.NEWLY_HOME) {
5
+ process.env.NEWLY_HOME = process.env.NEWLY_DEV_HOME ?? join(homedir(), '.newly-dev');
6
+ }
7
+ if (!process.env.NEWLY_APP_URL && process.env.NEWLY_DEV_APP_URL) {
8
+ process.env.NEWLY_APP_URL = process.env.NEWLY_DEV_APP_URL;
9
+ }
10
+ await import('./index.js');
@@ -0,0 +1,63 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ export const DEFAULT_BACKEND_SERVICES = [
4
+ { name: 'api', path: 'api-service', type: 'worker', schema: 'api', secrets: 'api', public: true },
5
+ { name: 'auth', path: 'auth-service', type: 'worker', schema: 'auth', secrets: 'auth', public: true },
6
+ ];
7
+ const SERVICE_NAME = /^[a-z][a-z0-9-]{0,31}$/;
8
+ export function readBackendServices(dir) {
9
+ const manifestPath = join(dir, 'backend', 'newly.services.json');
10
+ if (!existsSync(manifestPath))
11
+ return DEFAULT_BACKEND_SERVICES;
12
+ const parsed = JSON.parse(readFileSync(manifestPath, 'utf8'));
13
+ if (!Array.isArray(parsed.services) || parsed.services.length === 0) {
14
+ throw new Error('backend/newly.services.json must contain a non-empty services array');
15
+ }
16
+ return parsed.services.map((raw) => {
17
+ if (!raw || typeof raw !== 'object')
18
+ throw new Error('Invalid service entry in newly.services.json');
19
+ const entry = raw;
20
+ const name = String(entry.name ?? '');
21
+ const path = String(entry.path ?? '');
22
+ const type = String(entry.type ?? 'worker');
23
+ if (!SERVICE_NAME.test(name))
24
+ throw new Error(`Invalid service name in newly.services.json: ${name}`);
25
+ if (!path)
26
+ throw new Error(`Service ${name} is missing path`);
27
+ if (type !== 'worker')
28
+ throw new Error(`Service ${name} has unsupported type: ${type}`);
29
+ return {
30
+ name,
31
+ path,
32
+ type,
33
+ schema: entry.schema === undefined ? undefined : entry.schema === null ? null : String(entry.schema),
34
+ secrets: entry.secrets === undefined ? undefined : String(entry.secrets),
35
+ public: entry.public === undefined ? true : Boolean(entry.public),
36
+ };
37
+ });
38
+ }
39
+ export function parseServiceFilter(value) {
40
+ if (!value)
41
+ return [];
42
+ const names = value
43
+ .split(',')
44
+ .map((s) => s.trim())
45
+ .filter(Boolean);
46
+ for (const name of names) {
47
+ if (!SERVICE_NAME.test(name))
48
+ throw new Error(`Invalid service name: ${name}`);
49
+ }
50
+ return names;
51
+ }
52
+ export function assertKnownServices(services, selected) {
53
+ if (selected.length === 0)
54
+ return;
55
+ const known = new Set(services.map((s) => s.name));
56
+ const unknown = selected.filter((s) => !known.has(s));
57
+ if (unknown.length)
58
+ throw new Error(`Unknown service(s): ${unknown.join(', ')}`);
59
+ }
60
+ export function serviceUrl(projectId, env, service) {
61
+ const suffix = env === 'prod' ? 'api.newlyapp.com' : 'api-dev.newlyapp.com';
62
+ return `https://${service}-${projectId}-${env}.${suffix}`;
63
+ }
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@newly/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Develop, provision, and deploy Newly backends from your terminal",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
7
- "bin": { "newly": "dist/index.js" },
7
+ "bin": { "newly": "dist/index.js", "newly-dev": "dist/newly-dev.js" },
8
8
  "files": ["dist"],
9
9
  "engines": { "node": ">=20" },
10
10
  "scripts": {
11
+ "prebuild": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
11
12
  "build": "tsc",
12
13
  "dev": "tsx src/index.ts",
13
14
  "lint": "eslint src",
@@ -1,85 +0,0 @@
1
- import { existsSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { ApiClient, ApiError, friendlyApiError } from '../api.js';
4
- import { requireCredentials, resolveProject } from '../config.js';
5
- import { runDevDeploy, runProdDeploy } from './deploy.js';
6
- function defaultHasLocalBackend(dir) {
7
- return existsSync(join(dir, 'backend', 'scripts', 'deploy.sh'));
8
- }
9
- export async function runUp(deps) {
10
- const { client, projectId, env, region, cwd, print, hasLocalBackend = defaultHasLocalBackend, deployDev = runDevDeploy, deployProd = runProdDeploy, } = deps;
11
- const stackInfo = await client.get(`/newly_backend/stack/${projectId}`);
12
- if (!stackInfo.cli_deployable) {
13
- throw new Error('This project is on the Specular stack (a single Node/drizzle backend), which `newly up` does not support. ' +
14
- 'Only newly-backend-stack projects (Python api-service + Node auth-service) are CLI-deployable.');
15
- }
16
- const status = await client.get(`/newly_backend/status/${projectId}`);
17
- if (status[env]) {
18
- print(`${env} backend already provisioned (status: ${status[env].status}).`);
19
- }
20
- else {
21
- print(`Provisioning ${env} backend…`);
22
- if (env === 'dev') {
23
- await client.post(`/newly_backend/provision_dev/${projectId}`, {
24
- dev_region: region,
25
- });
26
- print('Provisioned dev backend.');
27
- }
28
- else {
29
- await client.post(`/newly_backend/provision_prod/${projectId}`);
30
- print('Provisioned prod backend.');
31
- }
32
- }
33
- if (env === 'dev') {
34
- if (!hasLocalBackend(cwd)) {
35
- print('Provisioned. No backend code in this directory — nothing to deploy yet.\n' +
36
- 'Run `newly pull ' + projectId + '` to get the project code, or develop on the ' +
37
- 'platform; the backend goes active on its first deploy.');
38
- return;
39
- }
40
- await deployDev(projectId, client, cwd);
41
- }
42
- else {
43
- await deployProd(projectId, client);
44
- }
45
- const finalStatus = await client.get(`/newly_backend/status/${projectId}`);
46
- const s = finalStatus[env];
47
- if (s) {
48
- print(`\n${env} backend: ${s.status}`);
49
- print(` auth: ${s.auth_hostname}`);
50
- print(` api: ${s.api_hostname}`);
51
- }
52
- }
53
- export function registerUp(program) {
54
- program
55
- .command('up')
56
- .description('Provision (if needed) and deploy the backend (dev only; prod ships via Promote in the web app)')
57
- .option('--env <env>', 'dev (prod is not supported via the CLI yet)', 'dev')
58
- .option('--region <region>', 'region for dev provisioning', 'us-central1')
59
- .option('--project <id>', 'project id (defaults to the linked directory)')
60
- .option('--yes', 'skip the prod confirmation prompt', false)
61
- .action(async (opts) => {
62
- try {
63
- if (opts.env !== 'dev' && opts.env !== 'prod')
64
- throw new Error('--env must be dev or prod');
65
- if (opts.env === 'prod') {
66
- throw new Error('Prod provisioning/deploys are not supported via the CLI yet — use Promote in the Newly web app.');
67
- }
68
- const creds = requireCredentials();
69
- const projectId = resolveProject(process.cwd(), opts.project);
70
- const client = new ApiClient(creds.apiUrl, creds.token);
71
- await runUp({
72
- client,
73
- projectId,
74
- env: opts.env,
75
- region: opts.region,
76
- cwd: process.cwd(),
77
- print: console.log,
78
- });
79
- }
80
- catch (e) {
81
- console.error(e instanceof ApiError ? friendlyApiError(e) : String(e instanceof Error ? e.message : e));
82
- process.exitCode = 1;
83
- }
84
- });
85
- }