@newly/cli 0.1.0

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/README.md ADDED
@@ -0,0 +1,114 @@
1
+ # newly CLI
2
+
3
+ Develop, provision, and deploy your [Newly](https://newly.app) backend from the terminal.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm i -g @newly/cli
9
+ ```
10
+
11
+ Installs the `newly` binary. Requires **Node ≥ 20**.
12
+
13
+ ## Quick start
14
+
15
+ ```bash
16
+ newly login # authenticate in the browser
17
+ newly pull <project-id> # download project code + link this directory
18
+ # edit code…
19
+ newly deploy # deploy to dev
20
+ newly deploy --env prod # ship to prod
21
+ ```
22
+
23
+ ## Auth
24
+
25
+ ```bash
26
+ newly login # opens a browser, stores a session token in ~/.newly/credentials
27
+ newly login --manual # paste the credential instead of using the browser callback
28
+ newly logout # delete stored credentials
29
+ ```
30
+
31
+ The token expires. Re-run `newly login` when commands report you're logged out.
32
+
33
+ ## Project linking
34
+
35
+ Most commands target the project linked to the current directory, so you don't pass `--project` every time.
36
+
37
+ - `newly pull <project-id>` and `newly create` link automatically.
38
+ - `newly link <project-id>` links a directory without downloading anything.
39
+ - Pass `--project <id>` to any command to override the link.
40
+
41
+ ## Dev vs prod deploys
42
+
43
+ | | Source | Notes |
44
+ |---|---|---|
45
+ | `newly deploy` (dev) | **your local code** | Fast inner loop. Ephemeral — overwritten the next time the project is built on the Newly platform. Not a rollback target. |
46
+ | `newly deploy --env prod` | **project code at HEAD** | Runs server-side, ignores local edits. Push your changes into the project first. |
47
+
48
+ Prod actions prompt for confirmation. Pass `--yes` to skip it in scripts.
49
+
50
+ > Only **newly-backend-stack** projects (Python api-service + Node auth-service) are CLI-deployable. Run `newly stack` to check. Specular-stack projects must be deployed from the Newly web app.
51
+
52
+ ## Commands
53
+
54
+ ### `newly pull [project-id]`
55
+ Download the project working tree (app + backend) into the current directory and link it.
56
+ - `--branch <name>` — pull a specific branch (default: project's active branch).
57
+ - `--force` — overwrite a non-empty directory (discards local changes).
58
+
59
+ ### `newly create [name]`
60
+ Create a new backend-only project here, scaffold `backend/`, and link the directory. Provisions the dev backend by default.
61
+ - `--no-provision` — create without provisioning.
62
+
63
+ Run in a directory with no `backend/` and no existing link.
64
+
65
+ ### `newly link <project-id>`
66
+ Link the current directory to a project without downloading code.
67
+
68
+ ### `newly status`
69
+ Show dev and prod status, service URLs, deployed SHA, and last error.
70
+ - `--project <id>`
71
+
72
+ ### `newly stack`
73
+ Show which backend stack the project uses and whether it's CLI-deployable.
74
+ - `--project <id>`
75
+
76
+ ### `newly up`
77
+ Provision the backend (if not already) and deploy.
78
+ - `--env <dev|prod>` (default `dev`)
79
+ - `--region <region>` — region for dev provisioning (default `us-central1`)
80
+ - `--project <id>`
81
+ - `--yes` — skip the prod confirmation prompt
82
+
83
+ ### `newly deploy`
84
+ Deploy the backend.
85
+ - `--env <dev|prod>` (default `dev`)
86
+ - `--project <id>`
87
+ - `--yes` — skip the prod confirmation prompt
88
+
89
+ ### `newly logs`
90
+ Tail backend service logs. **Dev only.**
91
+ - `--service <api|auth>` (default `api`)
92
+ - `-n, --lines <n>` (default `200`)
93
+ - `--since <duration>` — relative window, e.g. `30s`, `5m`, `2h`
94
+ - `--start <iso>` / `--end <iso>` — absolute window (ISO 8601)
95
+ - `--project <id>`
96
+
97
+ ### `newly secrets list`
98
+ List the secret keys set for a service (names only). **Dev only.**
99
+ - `--service <api|auth>` (default `api`)
100
+ - `--project <id>`
101
+
102
+ ### `newly secrets set <key> <value>`
103
+ Set a backend secret. **Dev only.** Redeploy to apply.
104
+ - `--service <api|auth>` (default `api`)
105
+ - `--project <id>`
106
+
107
+ ### `newly db url`
108
+ Print a readonly Postgres URL for the backend database. **Dev only.**
109
+ - `--project <id>`
110
+
111
+ ## Files
112
+
113
+ - `~/.newly/credentials` — session token (mode `600`). Override the directory with `NEWLY_HOME`.
114
+ - `.newly/project.json` — per-directory project link.
package/dist/api.js ADDED
@@ -0,0 +1,113 @@
1
+ export class ApiError extends Error {
2
+ status;
3
+ detail;
4
+ constructor(status, detail) {
5
+ super(`API ${status}: ${detail}`);
6
+ this.status = status;
7
+ this.detail = detail;
8
+ }
9
+ }
10
+ export class ApiClient {
11
+ baseUrl;
12
+ token;
13
+ constructor(baseUrl, token) {
14
+ this.baseUrl = baseUrl;
15
+ this.token = token;
16
+ }
17
+ async request(method, path, body) {
18
+ const res = await fetch(`${this.baseUrl}${path}`, {
19
+ method,
20
+ headers: {
21
+ Authorization: `Bearer ${this.token}`,
22
+ ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
23
+ },
24
+ body: body !== undefined ? JSON.stringify(body) : undefined,
25
+ });
26
+ if (!res.ok) {
27
+ let detail = await res.text();
28
+ try {
29
+ detail = JSON.parse(detail).detail ?? detail;
30
+ }
31
+ catch {
32
+ }
33
+ throw new ApiError(res.status, detail);
34
+ }
35
+ const contentType = res.headers.get('content-type') ?? '';
36
+ return contentType.includes('application/json') ? res.json() : res;
37
+ }
38
+ get(path) {
39
+ return this.request('GET', path);
40
+ }
41
+ post(path, body) {
42
+ return this.request('POST', path, body);
43
+ }
44
+ async upload(path, body, contentType) {
45
+ const res = await fetch(`${this.baseUrl}${path}`, {
46
+ method: 'POST',
47
+ headers: {
48
+ Authorization: `Bearer ${this.token}`,
49
+ 'Content-Type': contentType,
50
+ },
51
+ body: body,
52
+ });
53
+ if (!res.ok) {
54
+ let detail = await res.text();
55
+ try {
56
+ detail = JSON.parse(detail).detail ?? detail;
57
+ }
58
+ catch {
59
+ }
60
+ throw new ApiError(res.status, detail);
61
+ }
62
+ const contentType_ = res.headers.get('content-type') ?? '';
63
+ return contentType_.includes('application/json') ? res.json() : res;
64
+ }
65
+ async getBinary(path) {
66
+ const res = await fetch(`${this.baseUrl}${path}`, {
67
+ method: 'GET',
68
+ headers: { Authorization: `Bearer ${this.token}` },
69
+ });
70
+ if (!res.ok) {
71
+ const detail = await res.text().catch(() => '');
72
+ throw new ApiError(res.status, detail || res.statusText);
73
+ }
74
+ return Buffer.from(await res.arrayBuffer());
75
+ }
76
+ async *stream(path) {
77
+ const res = await fetch(`${this.baseUrl}${path}`, {
78
+ method: 'GET',
79
+ headers: { Authorization: `Bearer ${this.token}`, Accept: 'text/event-stream' },
80
+ });
81
+ if (!res.ok) {
82
+ let detail = await res.text().catch(() => '');
83
+ try {
84
+ detail = JSON.parse(detail).detail ?? detail;
85
+ }
86
+ catch {
87
+ }
88
+ throw new ApiError(res.status, detail || res.statusText);
89
+ }
90
+ const decoder = new TextDecoder();
91
+ for await (const chunk of res.body) {
92
+ yield decoder.decode(chunk, { stream: true });
93
+ }
94
+ const tail = decoder.decode();
95
+ if (tail)
96
+ yield tail;
97
+ }
98
+ }
99
+ export function friendlyApiError(err) {
100
+ if (!(err instanceof ApiError))
101
+ return String(err);
102
+ if (err.status === 401)
103
+ return 'Session expired or invalid — run `newly login`.';
104
+ if (err.status === 403)
105
+ return err.detail ? `Forbidden: ${err.detail}` : 'Forbidden.';
106
+ if (err.status === 404)
107
+ return `Not found: ${err.detail}`;
108
+ if (err.status === 409)
109
+ return err.detail;
110
+ if (err.status === 503)
111
+ return 'The platform is at capacity right now — please try again in a few minutes.';
112
+ return err.message;
113
+ }
@@ -0,0 +1,113 @@
1
+ import { mkdirSync, readdirSync, rmSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { create as tarCreate, extract as tarExtract, list as tarList } from 'tar';
4
+ import { Buffer } from 'node:buffer';
5
+ export const SNAPSHOT_EXCLUDE = new Set([
6
+ '.git',
7
+ 'node_modules',
8
+ '.venv',
9
+ '.expo',
10
+ '.next',
11
+ '.turbo',
12
+ '.vercel',
13
+ '.cache',
14
+ 'dist',
15
+ 'build',
16
+ 'web-build',
17
+ '.natively',
18
+ 'android',
19
+ 'ios',
20
+ '.local',
21
+ '.npm',
22
+ '.pnpm-store',
23
+ '.yarn',
24
+ '.bun',
25
+ '__pycache__',
26
+ 'agent_activity',
27
+ 'branches',
28
+ 'secrets',
29
+ ]);
30
+ export const SNAPSHOT_EXCLUDE_FILE_GLOBS = [
31
+ '.env',
32
+ '.env.*',
33
+ '.env*.local',
34
+ '*.pem',
35
+ '*.key',
36
+ '*.p8',
37
+ '*.p12',
38
+ '*.jks',
39
+ '*.mobileprovision',
40
+ '.DS_Store',
41
+ '*.orig.*',
42
+ '*.tsbuildinfo',
43
+ ];
44
+ const FILE_GLOB_REGEXPS = SNAPSHOT_EXCLUDE_FILE_GLOBS.map((glob) => new RegExp(`^${glob.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\\\*/g, '.*')}$`));
45
+ function isExcluded(path) {
46
+ return path
47
+ .replace(/^\.\//, '')
48
+ .split('/')
49
+ .some((part) => SNAPSHOT_EXCLUDE.has(part));
50
+ }
51
+ function isExcludedFile(basename) {
52
+ return FILE_GLOB_REGEXPS.some((re) => re.test(basename));
53
+ }
54
+ function basenameOf(path) {
55
+ const parts = path.split('/');
56
+ return parts[parts.length - 1];
57
+ }
58
+ export async function createSnapshot(dir) {
59
+ const chunks = [];
60
+ const stream = tarCreate({
61
+ gzip: true,
62
+ cwd: dir,
63
+ filter: (p) => !isExcluded(p) && !isExcludedFile(basenameOf(p)),
64
+ portable: true,
65
+ }, ['.']);
66
+ for await (const chunk of stream) {
67
+ chunks.push(Buffer.from(chunk));
68
+ }
69
+ return Buffer.concat(chunks);
70
+ }
71
+ export async function extractSnapshot(data, dir) {
72
+ mkdirSync(dir, { recursive: true });
73
+ await new Promise((resolve, reject) => {
74
+ const stream = tarExtract({ cwd: dir });
75
+ stream.on('error', reject);
76
+ stream.on('finish', resolve);
77
+ stream.end(data);
78
+ });
79
+ }
80
+ export async function snapshotTopLevelEntries(data) {
81
+ const top = new Set();
82
+ for (const name of await listSnapshot(data)) {
83
+ const rel = name.replace(/^\.?\//, '');
84
+ const first = rel.split('/')[0];
85
+ if (first && first !== '.')
86
+ top.add(first);
87
+ }
88
+ return top;
89
+ }
90
+ export function pruneStaleTopLevel(dir, keep) {
91
+ const removed = [];
92
+ for (const entry of readdirSync(dir)) {
93
+ if (entry === '.newly')
94
+ continue;
95
+ if (keep.has(entry))
96
+ continue;
97
+ if (isExcluded(entry) || isExcludedFile(entry))
98
+ continue;
99
+ rmSync(join(dir, entry), { recursive: true, force: true });
100
+ removed.push(entry);
101
+ }
102
+ return removed;
103
+ }
104
+ export async function listSnapshot(data) {
105
+ const names = [];
106
+ await new Promise((resolve, reject) => {
107
+ const stream = tarList({ onentry: (e) => names.push(e.path) });
108
+ stream.on('error', reject);
109
+ stream.on('finish', resolve);
110
+ stream.end(data);
111
+ });
112
+ return names;
113
+ }
@@ -0,0 +1,60 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { ApiClient, ApiError, friendlyApiError } from '../api.js';
4
+ import { requireCredentials, writeProjectLink } from '../config.js';
5
+ import { extractSnapshot } from '../archive.js';
6
+ export async function runCreate(client, dir, opts) {
7
+ if (existsSync(join(dir, 'backend'))) {
8
+ throw new Error('A `backend/` directory already exists here. Run `newly create` in a directory without one.');
9
+ }
10
+ const { project_id, status } = await client.post('/newly_backend/create_project', {
11
+ name: opts.name,
12
+ provision: opts.provision ?? true,
13
+ });
14
+ writeProjectLink(dir, {
15
+ projectId: project_id,
16
+ pulledSha: null,
17
+ pulledAt: new Date().toISOString(),
18
+ });
19
+ try {
20
+ const tar = await client.getBinary('/newly_backend/backend_template');
21
+ await extractSnapshot(tar, dir);
22
+ }
23
+ catch (e) {
24
+ const msg = e instanceof Error ? e.message : String(e);
25
+ throw new Error(`Project ${project_id} was created and linked, but scaffolding the backend template failed: ${msg}. The project exists server-side.`);
26
+ }
27
+ if (status === 'provisioning') {
28
+ console.log(`Created backend project ${project_id} and scaffolded backend/ here. ` +
29
+ `Provisioning the dev backend… check with \`newly status\`. Edit, then \`newly deploy\`.`);
30
+ }
31
+ else {
32
+ console.log(`Created backend project ${project_id} and scaffolded backend/ here ` +
33
+ `(not provisioned — run \`newly up\` when ready). Edit, then \`newly deploy\`.`);
34
+ }
35
+ return project_id;
36
+ }
37
+ export function registerCreate(program) {
38
+ program
39
+ .command('create')
40
+ .description('Create a new backend-only Newly project here (scaffold + provision)')
41
+ .argument('[name]', 'project name (generated if omitted)')
42
+ .option('--no-provision', 'create without provisioning the backend')
43
+ .action(async (name, opts) => {
44
+ try {
45
+ if (existsSync(join(process.cwd(), '.newly', 'project.json'))) {
46
+ throw new Error('This directory is already linked to a project. Use a fresh directory for `newly create`.');
47
+ }
48
+ const creds = requireCredentials();
49
+ const client = new ApiClient(creds.apiUrl, creds.token);
50
+ await runCreate(client, process.cwd(), {
51
+ name,
52
+ provision: opts.provision,
53
+ });
54
+ }
55
+ catch (e) {
56
+ console.error(e instanceof ApiError ? friendlyApiError(e) : String(e instanceof Error ? e.message : e));
57
+ process.exitCode = 1;
58
+ }
59
+ });
60
+ }
@@ -0,0 +1,116 @@
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 { createSnapshot } from '../archive.js';
6
+ import { parseSseFrames } from '../sse.js';
7
+ const POLL_INTERVAL_MS = 5_000;
8
+ const POLL_TIMEOUT_MS = 20 * 60 * 1000;
9
+ export async function pollRun(client, runId, opts = {}) {
10
+ const intervalMs = opts.intervalMs ?? POLL_INTERVAL_MS;
11
+ const timeoutMs = opts.timeoutMs ?? POLL_TIMEOUT_MS;
12
+ const deadline = Date.now() + timeoutMs;
13
+ for (;;) {
14
+ const run = await client.get(`/newly_backend/deploy_runs/${runId}`);
15
+ if (run.status !== 'running')
16
+ return run;
17
+ if (Date.now() > deadline)
18
+ throw new Error(`Deploy run ${runId} timed out after ${timeoutMs / 1000}s`);
19
+ await new Promise((r) => setTimeout(r, intervalMs));
20
+ }
21
+ }
22
+ export async function streamRun(client, runId, print = (s) => process.stdout.write(s)) {
23
+ let buffer = '';
24
+ let started = false;
25
+ try {
26
+ for await (const chunk of client.stream(`/newly_backend/deploy_runs/${runId}/logs/stream`)) {
27
+ started = true;
28
+ buffer = (buffer + chunk).replace(/\r\n/g, '\n');
29
+ const lastSep = buffer.lastIndexOf('\n\n');
30
+ if (lastSep === -1)
31
+ continue;
32
+ const ready = buffer.slice(0, lastSep + 2);
33
+ buffer = buffer.slice(lastSep + 2);
34
+ for (const frame of parseSseFrames(ready)) {
35
+ if (frame.event === 'done') {
36
+ return JSON.parse(frame.data);
37
+ }
38
+ try {
39
+ print(JSON.parse(frame.data));
40
+ }
41
+ catch {
42
+ print(frame.data);
43
+ }
44
+ }
45
+ }
46
+ }
47
+ catch (e) {
48
+ if (e instanceof ApiError && e.status === 404) {
49
+ return pollRun(client, runId);
50
+ }
51
+ if (started) {
52
+ return pollRun(client, runId);
53
+ }
54
+ throw e;
55
+ }
56
+ return pollRun(client, runId);
57
+ }
58
+ export function finishOrThrow(run, label) {
59
+ if (run.status === 'succeeded') {
60
+ console.log(`${label} deploy succeeded.`);
61
+ return;
62
+ }
63
+ if (run.failure_reason === 'no_backend_code') {
64
+ throw new Error('No backend code to deploy (backend/api-service missing).');
65
+ }
66
+ throw new Error(`${label} deploy failed (${run.failure_reason ?? 'unknown'}):\n${run.output_tail ?? ''}`);
67
+ }
68
+ export async function runDevDeploy(projectId, client, dir) {
69
+ if (!existsSync(join(dir, 'backend', 'api-service'))) {
70
+ throw new Error('No backend/api-service in this directory. Run `newly pull <project-id>` first, ' +
71
+ 'or cd into a pulled project.');
72
+ }
73
+ console.log('Packaging local backend…');
74
+ const snapshot = await createSnapshot(dir);
75
+ const kib = snapshot.length < 1024 ? '<1' : Math.round(snapshot.length / 1024).toString();
76
+ console.log(`Uploading snapshot (${kib} KiB) and deploying server-side…`);
77
+ const { run_id } = await client.upload(`/newly_backend/deploy/${projectId}?env=dev`, snapshot, 'application/gzip');
78
+ const run = await streamRun(client, run_id);
79
+ finishOrThrow(run, 'dev');
80
+ }
81
+ export async function runProdDeploy(projectId, client) {
82
+ console.log('Deploying prod from the canonical project code (repo HEAD)…');
83
+ const { run_id } = await client.post(`/newly_backend/deploy/${projectId}`, { env: 'prod' });
84
+ const run = await streamRun(client, run_id);
85
+ finishOrThrow(run, 'prod');
86
+ }
87
+ export function registerDeploy(program) {
88
+ program
89
+ .command('deploy')
90
+ .description('Deploy the backend server-side from your local code (dev only; prod ships via Promote in the web app)')
91
+ .option('--env <env>', 'dev (prod is not supported via the CLI yet)', 'dev')
92
+ .option('--project <id>', 'project id (defaults to the linked directory)')
93
+ .option('--yes', 'skip the prod confirmation prompt', false)
94
+ .action(async (opts) => {
95
+ try {
96
+ if (opts.env !== 'dev' && opts.env !== 'prod')
97
+ throw new Error('--env must be dev or prod');
98
+ if (opts.env === 'prod') {
99
+ throw new Error('Prod deploys are not supported via the CLI yet — use Promote in the Newly web app.');
100
+ }
101
+ const creds = requireCredentials();
102
+ const projectId = resolveProject(process.cwd(), opts.project);
103
+ const client = new ApiClient(creds.apiUrl, creds.token);
104
+ const stackInfo = await client.get(`/newly_backend/stack/${projectId}`);
105
+ if (!stackInfo.cli_deployable) {
106
+ throw new Error('This project is on the Specular stack (a single Node/drizzle backend), which `newly deploy` does not support. ' +
107
+ 'Only newly-backend-stack projects (Python api-service + Node auth-service) are CLI-deployable.');
108
+ }
109
+ await runDevDeploy(projectId, client, process.cwd());
110
+ }
111
+ catch (e) {
112
+ console.error(e instanceof ApiError ? friendlyApiError(e) : String(e instanceof Error ? e.message : e));
113
+ process.exitCode = 1;
114
+ }
115
+ });
116
+ }
@@ -0,0 +1,17 @@
1
+ import { readProjectLink, writeProjectLink } from '../config.js';
2
+ export function registerLink(program) {
3
+ program
4
+ .command('link')
5
+ .argument('<project-id>', 'Newly project id')
6
+ .description('Associate the current directory with a project (no download) so deploy/status/logs work here')
7
+ .action((projectId) => {
8
+ const dir = process.cwd();
9
+ const existing = readProjectLink(dir);
10
+ writeProjectLink(dir, {
11
+ projectId,
12
+ pulledSha: existing?.projectId === projectId ? existing.pulledSha : null,
13
+ pulledAt: new Date().toISOString(),
14
+ });
15
+ console.log(`Linked ${dir} to project ${projectId}. You can now run \`newly deploy\`, \`newly status\`, etc. here.`);
16
+ });
17
+ }
@@ -0,0 +1,149 @@
1
+ import http from 'node:http';
2
+ import crypto from 'node:crypto';
3
+ import { spawn } from 'node:child_process';
4
+ import { clearCredentials, saveCredentials } from '../config.js';
5
+ const DEFAULT_APP_URL = 'https://newly.app';
6
+ const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
7
+ export async function startCallbackServer(state, appOrigin, timeoutMs) {
8
+ let resolvePayload;
9
+ let rejectPayload;
10
+ const payloadPromise = new Promise((resolve, reject) => {
11
+ resolvePayload = resolve;
12
+ rejectPayload = reject;
13
+ });
14
+ const timer = setTimeout(() => rejectPayload(new Error(`Login timed out after ${timeoutMs / 1000}s`)), timeoutMs);
15
+ const corsHeaders = {
16
+ 'Access-Control-Allow-Origin': appOrigin,
17
+ 'Access-Control-Allow-Methods': 'POST, OPTIONS',
18
+ 'Access-Control-Allow-Headers': 'content-type',
19
+ };
20
+ const server = http.createServer((req, res) => {
21
+ if (req.method === 'OPTIONS') {
22
+ res.writeHead(204, corsHeaders);
23
+ res.end();
24
+ return;
25
+ }
26
+ if (req.method !== 'POST') {
27
+ res.writeHead(405, corsHeaders);
28
+ res.end();
29
+ return;
30
+ }
31
+ let body = '';
32
+ req.on('data', (chunk) => (body += chunk));
33
+ req.on('end', () => {
34
+ try {
35
+ const parsed = JSON.parse(body);
36
+ if (parsed.state !== state) {
37
+ res.writeHead(403, corsHeaders);
38
+ res.end('state mismatch');
39
+ return;
40
+ }
41
+ res.writeHead(200, { ...corsHeaders, 'Content-Type': 'application/json' });
42
+ res.end(JSON.stringify({ ok: true }));
43
+ clearTimeout(timer);
44
+ resolvePayload(parsed);
45
+ }
46
+ catch {
47
+ res.writeHead(400, corsHeaders);
48
+ res.end('bad request');
49
+ }
50
+ });
51
+ });
52
+ const port = await new Promise((resolve) => {
53
+ server.listen(0, '127.0.0.1', () => {
54
+ const addr = server.address();
55
+ resolve(typeof addr === 'object' && addr ? addr.port : 0);
56
+ });
57
+ });
58
+ return {
59
+ port,
60
+ waitForToken: () => payloadPromise,
61
+ close: () => {
62
+ clearTimeout(timer);
63
+ server.close();
64
+ },
65
+ };
66
+ }
67
+ function openBrowser(url) {
68
+ const [cmd, args] = process.platform === 'darwin'
69
+ ? ['open', [url]]
70
+ : process.platform === 'win32'
71
+ ? ['cmd', ['/c', 'start', '', url]]
72
+ : ['xdg-open', [url]];
73
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
74
+ child.on('error', () => {
75
+ });
76
+ child.unref();
77
+ }
78
+ export function parseManualCredential(blob) {
79
+ let parsed;
80
+ try {
81
+ parsed = JSON.parse(blob);
82
+ }
83
+ catch {
84
+ throw new Error('Could not parse the pasted credential as JSON. Copy the entire blob shown in the browser and try again.');
85
+ }
86
+ const c = parsed;
87
+ if (typeof c.token !== 'string' || typeof c.expiresIn !== 'number' || typeof c.apiUrl !== 'string') {
88
+ throw new Error('Pasted credential is missing required fields (expected JSON with token, expiresIn, apiUrl). ' +
89
+ 'Copy the entire blob shown in the browser.');
90
+ }
91
+ return { token: c.token, expiresIn: c.expiresIn, apiUrl: c.apiUrl };
92
+ }
93
+ export function registerLogin(program) {
94
+ program
95
+ .command('login')
96
+ .description('Log in with your Newly account via the browser')
97
+ .option('--app-url <url>', 'Newly web app URL', DEFAULT_APP_URL)
98
+ .option('--manual', 'paste the credential from the browser instead of using the local callback', false)
99
+ .action(async (opts) => {
100
+ try {
101
+ if (opts.manual) {
102
+ const { createInterface } = await import('node:readline/promises');
103
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
104
+ const blob = await rl.question('Paste the credential from the browser: ');
105
+ rl.close();
106
+ const parsed = parseManualCredential(blob);
107
+ saveCredentials({
108
+ token: parsed.token,
109
+ apiUrl: parsed.apiUrl,
110
+ appUrl: opts.appUrl,
111
+ expiresAt: Math.floor(Date.now() / 1000) + parsed.expiresIn,
112
+ });
113
+ console.log('Logged in.');
114
+ return;
115
+ }
116
+ const state = crypto.randomBytes(16).toString('hex');
117
+ const appOrigin = new URL(opts.appUrl).origin;
118
+ const { port, waitForToken, close } = await startCallbackServer(state, appOrigin, LOGIN_TIMEOUT_MS);
119
+ const loginUrl = `${opts.appUrl}/cli-auth?port=${port}&state=${state}`;
120
+ console.log(`Opening your browser to authorize the CLI…\n ${loginUrl}\n`);
121
+ console.log("If the browser doesn't open, paste the URL above into it manually.");
122
+ openBrowser(loginUrl);
123
+ try {
124
+ const payload = await waitForToken();
125
+ saveCredentials({
126
+ token: payload.token,
127
+ apiUrl: payload.apiUrl,
128
+ appUrl: opts.appUrl,
129
+ expiresAt: Math.floor(Date.now() / 1000) + payload.expiresIn,
130
+ });
131
+ console.log('Logged in. Credentials saved to ~/.newly/credentials');
132
+ }
133
+ finally {
134
+ close();
135
+ }
136
+ }
137
+ catch (e) {
138
+ console.error(e instanceof Error ? e.message : String(e));
139
+ process.exitCode = 1;
140
+ }
141
+ });
142
+ program
143
+ .command('logout')
144
+ .description('Delete the stored CLI credential')
145
+ .action(() => {
146
+ clearCredentials();
147
+ console.log('Logged out.');
148
+ });
149
+ }
@@ -0,0 +1,148 @@
1
+ import { ApiClient, friendlyApiError } from '../api.js';
2
+ import { requireCredentials, resolveProject } from '../config.js';
3
+ export function assertDevOnly(env, command) {
4
+ if (env !== 'dev') {
5
+ throw new Error(`\`newly ${command}\` is dev-only from the CLI. Prod ${command} requires the Newly web app (a browser session).`);
6
+ }
7
+ }
8
+ export function parseDuration(s) {
9
+ const m = /^(\d+)(s|m|h)$/.exec(s.trim());
10
+ if (!m)
11
+ throw new Error(`Invalid duration "${s}" (use e.g. 30s, 5m, 2h)`);
12
+ const n = Number(m[1]);
13
+ const unit = m[2];
14
+ const mult = unit === 's' ? 1_000 : unit === 'm' ? 60_000 : 3_600_000;
15
+ return n * mult;
16
+ }
17
+ const ISO_8601 = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})?)?$/;
18
+ function validateIso(s) {
19
+ if (!ISO_8601.test(s) || Number.isNaN(Date.parse(s))) {
20
+ throw new Error(`Invalid ISO 8601 timestamp "${s}" (expected e.g. 2026-06-15 or 2026-06-15T08:00:00Z)`);
21
+ }
22
+ }
23
+ export function resolveLogWindow(opts, now = Date.now()) {
24
+ let start = opts.start;
25
+ if (!start && opts.since)
26
+ start = new Date(now - parseDuration(opts.since)).toISOString();
27
+ if (opts.start)
28
+ validateIso(opts.start);
29
+ if (opts.end)
30
+ validateIso(opts.end);
31
+ return { start, end: opts.end };
32
+ }
33
+ export async function fetchLogs(client, projectId, env, service, n, bounds = {}) {
34
+ const body = { project_id: projectId, env, service, n };
35
+ if (bounds.start)
36
+ body.start = bounds.start;
37
+ if (bounds.end)
38
+ body.end = bounds.end;
39
+ const out = await client.post('/newly_backend/tail_logs', body);
40
+ if (typeof out.logs === 'string')
41
+ return out.logs;
42
+ if (Array.isArray(out.lines))
43
+ return out.lines.join('\n');
44
+ return JSON.stringify(out, null, 2);
45
+ }
46
+ export async function listSecrets(client, projectId, env, service) {
47
+ const out = await client.get(`/newly_backend/read_secrets?project_id=${encodeURIComponent(projectId)}&env=${env}&service=${service}`);
48
+ if (Array.isArray(out.keys))
49
+ return [...out.keys].sort().join('\n');
50
+ const secrets = out.secrets ?? out;
51
+ return Object.keys(secrets).sort().join('\n');
52
+ }
53
+ export async function setSecret(client, projectId, env, service, key, value) {
54
+ await client.post('/newly_backend/set_secret', {
55
+ project_id: projectId,
56
+ env,
57
+ service,
58
+ key,
59
+ value,
60
+ });
61
+ }
62
+ export async function getDbUrl(client, projectId, env) {
63
+ const out = await client.get(`/newly_backend/readonly_db_url?project_id=${encodeURIComponent(projectId)}&env=${env}`);
64
+ return out.database_url ?? out.url ?? JSON.stringify(out);
65
+ }
66
+ function makeClient(project) {
67
+ const creds = requireCredentials();
68
+ return {
69
+ client: new ApiClient(creds.apiUrl, creds.token),
70
+ projectId: resolveProject(process.cwd(), project),
71
+ };
72
+ }
73
+ export function registerMisc(program) {
74
+ program
75
+ .command('logs')
76
+ .description('Tail backend service logs')
77
+ .option('--env <env>', 'dev or prod', 'dev')
78
+ .option('--service <service>', 'api or auth', 'api')
79
+ .option('-n, --lines <n>', 'number of lines', '200')
80
+ .option('--since <duration>', 'relative window, e.g. 5m, 1h, 30s')
81
+ .option('--start <iso>', 'window start (ISO 8601 timestamp)')
82
+ .option('--end <iso>', 'window end (ISO 8601 timestamp)')
83
+ .option('--project <id>', 'project id')
84
+ .action(async (opts) => {
85
+ try {
86
+ assertDevOnly(opts.env, 'logs');
87
+ const bounds = resolveLogWindow(opts);
88
+ const { client, projectId } = makeClient(opts.project);
89
+ console.log(await fetchLogs(client, projectId, opts.env, opts.service, Number(opts.lines), bounds));
90
+ }
91
+ catch (e) {
92
+ console.error(friendlyApiError(e));
93
+ process.exitCode = 1;
94
+ }
95
+ });
96
+ const secrets = program.command('secrets').description('Manage backend secrets');
97
+ secrets
98
+ .command('list')
99
+ .option('--env <env>', 'dev or prod', 'dev')
100
+ .option('--service <service>', 'api or auth', 'api')
101
+ .option('--project <id>', 'project id')
102
+ .action(async (opts) => {
103
+ try {
104
+ assertDevOnly(opts.env, 'secrets list');
105
+ const { client, projectId } = makeClient(opts.project);
106
+ console.log(await listSecrets(client, projectId, opts.env, opts.service));
107
+ }
108
+ catch (e) {
109
+ console.error(friendlyApiError(e));
110
+ process.exitCode = 1;
111
+ }
112
+ });
113
+ secrets
114
+ .command('set')
115
+ .argument('<key>')
116
+ .argument('<value>')
117
+ .option('--env <env>', 'dev or prod', 'dev')
118
+ .option('--service <service>', 'api or auth', 'api')
119
+ .option('--project <id>', 'project id')
120
+ .action(async (key, value, opts) => {
121
+ try {
122
+ assertDevOnly(opts.env, 'secrets set');
123
+ const { client, projectId } = makeClient(opts.project);
124
+ await setSecret(client, projectId, opts.env, opts.service, key, value);
125
+ console.log(`Secret ${key} set for ${opts.service} (${opts.env}). Redeploy to apply.`);
126
+ }
127
+ catch (e) {
128
+ console.error(friendlyApiError(e));
129
+ process.exitCode = 1;
130
+ }
131
+ });
132
+ const db = program.command('db').description('Database helpers');
133
+ db.command('url')
134
+ .description('Print a readonly Postgres URL for the backend database')
135
+ .option('--env <env>', 'dev or prod', 'dev')
136
+ .option('--project <id>', 'project id')
137
+ .action(async (opts) => {
138
+ try {
139
+ assertDevOnly(opts.env, 'db url');
140
+ const { client, projectId } = makeClient(opts.project);
141
+ console.log(await getDbUrl(client, projectId, opts.env));
142
+ }
143
+ catch (e) {
144
+ console.error(friendlyApiError(e));
145
+ process.exitCode = 1;
146
+ }
147
+ });
148
+ }
@@ -0,0 +1,53 @@
1
+ import { readdirSync } from 'node:fs';
2
+ import { ApiClient, friendlyApiError } from '../api.js';
3
+ import { requireCredentials, resolveProject, writeProjectLink } from '../config.js';
4
+ import { extractSnapshot, pruneStaleTopLevel, snapshotTopLevelEntries } from '../archive.js';
5
+ export function assertPullTarget(dir, force) {
6
+ const entries = readdirSync(dir).filter((e) => e !== '.newly');
7
+ if (entries.length > 0 && !force) {
8
+ throw new Error(`Target directory is not empty. Re-run with --force to overwrite local files (this discards local changes).`);
9
+ }
10
+ }
11
+ export async function extractTarball(data, dir) {
12
+ await extractSnapshot(data, dir);
13
+ }
14
+ export function registerPull(program) {
15
+ program
16
+ .command('pull')
17
+ .argument('[project-id]', 'Newly project id (defaults to the linked project in this directory)')
18
+ .description('Download the project working tree (app + backend) into the current directory')
19
+ .option('--force', 'overwrite files in a non-empty directory', false)
20
+ .option('--branch <name>', 'branch to pull (defaults to the project active branch / main)')
21
+ .action(async (projectIdArg, opts) => {
22
+ try {
23
+ const creds = requireCredentials();
24
+ const dir = process.cwd();
25
+ const projectId = resolveProject(dir, projectIdArg);
26
+ assertPullTarget(dir, opts.force);
27
+ const client = new ApiClient(creds.apiUrl, creds.token);
28
+ const query = opts.branch ? `?branch=${encodeURIComponent(opts.branch)}` : '';
29
+ console.log(`Downloading project ${projectId}${opts.branch ? ` (branch ${opts.branch})` : ''}…`);
30
+ const res = (await client.get(`/newly_backend/repo_archive/${projectId}${query}`));
31
+ const data = Buffer.from(await res.arrayBuffer());
32
+ if (opts.force) {
33
+ const keep = await snapshotTopLevelEntries(data);
34
+ const removed = pruneStaleTopLevel(dir, keep);
35
+ if (removed.length > 0) {
36
+ console.log(`Removed ${removed.length} stale top-level ${removed.length === 1 ? 'entry' : 'entries'} not in the pulled tree: ${removed.join(', ')}`);
37
+ }
38
+ }
39
+ await extractTarball(data, dir);
40
+ const pulledBranch = res.headers.get('x-newly-branch');
41
+ writeProjectLink(dir, {
42
+ projectId,
43
+ pulledSha: res.headers.get('x-newly-head-sha'),
44
+ pulledAt: new Date().toISOString(),
45
+ });
46
+ console.log(`Pulled${pulledBranch ? ` branch ${pulledBranch}` : ''} into ${dir} and linked to project ${projectId}.`);
47
+ }
48
+ catch (e) {
49
+ console.error(friendlyApiError(e));
50
+ process.exitCode = 1;
51
+ }
52
+ });
53
+ }
@@ -0,0 +1,29 @@
1
+ import { ApiClient, friendlyApiError } from '../api.js';
2
+ import { requireCredentials, resolveProject } from '../config.js';
3
+ export function registerStack(program) {
4
+ program
5
+ .command('stack')
6
+ .description('Show which backend stack a project uses (newly-backend or specular)')
7
+ .option('--project <id>', 'project id (defaults to the linked directory)')
8
+ .action(async (opts) => {
9
+ try {
10
+ const creds = requireCredentials();
11
+ const projectId = resolveProject(process.cwd(), opts.project);
12
+ 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
+ }
24
+ catch (e) {
25
+ console.error(friendlyApiError(e));
26
+ process.exitCode = 1;
27
+ }
28
+ });
29
+ }
@@ -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: 'Cloud environment assigned',
26
+ note: 'dedicated cloud environment allocated for your backend',
27
+ complete: s.cloud_env_assigned,
28
+ },
29
+ {
30
+ label: 'Infrastructure provisioned',
31
+ note: 'container registry, storage buckets, IAM roles set up',
32
+ complete: s.neon_ready,
33
+ },
34
+ {
35
+ label: 'Database created',
36
+ note: 'Postgres database ready for your API service',
37
+ complete: s.neon_ready,
38
+ },
39
+ {
40
+ label: 'Environment secrets wired',
41
+ note: 'DB credentials, API keys and auth tokens 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,85 @@
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
+ }
package/dist/config.js ADDED
@@ -0,0 +1,69 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ function newlyHome() {
5
+ return process.env.NEWLY_HOME ?? join(homedir(), '.newly');
6
+ }
7
+ function credentialsPath() {
8
+ return join(newlyHome(), 'credentials');
9
+ }
10
+ export function normalizeApiUrl(url) {
11
+ const trimmed = url.replace(/\/+$/, '');
12
+ return trimmed.endsWith('/api') ? trimmed : `${trimmed}/api`;
13
+ }
14
+ export function saveCredentials(creds) {
15
+ mkdirSync(newlyHome(), { recursive: true, mode: 0o700 });
16
+ const normalized = { ...creds, apiUrl: normalizeApiUrl(creds.apiUrl) };
17
+ writeFileSync(credentialsPath(), JSON.stringify(normalized, null, 2) + '\n', { mode: 0o600 });
18
+ chmodSync(credentialsPath(), 0o600);
19
+ }
20
+ export function loadCredentials() {
21
+ if (!existsSync(credentialsPath()))
22
+ return null;
23
+ try {
24
+ const creds = JSON.parse(readFileSync(credentialsPath(), 'utf8'));
25
+ if (!creds.token || creds.expiresAt < Date.now() / 1000)
26
+ return null;
27
+ return { ...creds, apiUrl: normalizeApiUrl(creds.apiUrl) };
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
33
+ export function clearCredentials() {
34
+ rmSync(credentialsPath(), { force: true });
35
+ }
36
+ export function requireCredentials() {
37
+ const creds = loadCredentials();
38
+ if (!creds) {
39
+ throw new Error('Not logged in (or session expired). Run: newly login');
40
+ }
41
+ return creds;
42
+ }
43
+ export function writeProjectLink(dir, link) {
44
+ const linkDir = join(dir, '.newly');
45
+ mkdirSync(linkDir, { recursive: true });
46
+ const gitignorePath = join(linkDir, '.gitignore');
47
+ if (!existsSync(gitignorePath))
48
+ writeFileSync(gitignorePath, '*\n');
49
+ writeFileSync(join(linkDir, 'project.json'), JSON.stringify(link, null, 2) + '\n');
50
+ }
51
+ export function readProjectLink(dir) {
52
+ const path = join(dir, '.newly', 'project.json');
53
+ if (!existsSync(path))
54
+ return null;
55
+ try {
56
+ return JSON.parse(readFileSync(path, 'utf8'));
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ export function resolveProject(dir, explicit) {
63
+ if (explicit)
64
+ return explicit;
65
+ const link = readProjectLink(dir);
66
+ if (link?.projectId)
67
+ return link.projectId;
68
+ throw new Error('No project linked to this directory. Run `newly pull <project-id>` first, or pass --project <id>.');
69
+ }
package/dist/index.js ADDED
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { registerLogin } from './commands/login.js';
4
+ import { registerStatus } from './commands/status.js';
5
+ import { registerLink } from './commands/link.js';
6
+ import { registerCreate } from './commands/create.js';
7
+ import { registerPull } from './commands/pull.js';
8
+ import { registerDeploy } from './commands/deploy.js';
9
+ import { registerUp } from './commands/up.js';
10
+ import { registerMisc } from './commands/misc.js';
11
+ import { registerStack } from './commands/stack.js';
12
+ const program = new Command('newly')
13
+ .description('Develop, provision, and deploy Newly backends')
14
+ .version('0.1.0');
15
+ registerLogin(program);
16
+ registerStatus(program);
17
+ registerLink(program);
18
+ registerCreate(program);
19
+ registerPull(program);
20
+ registerDeploy(program);
21
+ registerUp(program);
22
+ registerMisc(program);
23
+ registerStack(program);
24
+ await program.parseAsync(process.argv);
package/dist/sse.js ADDED
@@ -0,0 +1,19 @@
1
+ export function parseSseFrames(raw) {
2
+ const frames = [];
3
+ const blocks = raw.replace(/\r\n/g, '\n').split('\n\n');
4
+ blocks.pop();
5
+ for (const block of blocks) {
6
+ if (!block.trim())
7
+ continue;
8
+ let event = 'message';
9
+ const dataLines = [];
10
+ for (const line of block.split('\n')) {
11
+ if (line.startsWith('event:'))
12
+ event = line.slice(6).trim();
13
+ else if (line.startsWith('data:'))
14
+ dataLines.push(line.slice(5).replace(/^ /, ''));
15
+ }
16
+ frames.push({ event, data: dataLines.join('\n') });
17
+ }
18
+ return frames;
19
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@newly/cli",
3
+ "version": "0.1.0",
4
+ "description": "Develop, provision, and deploy Newly backends from your terminal",
5
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "bin": { "newly": "dist/index.js" },
8
+ "files": ["dist"],
9
+ "engines": { "node": ">=20" },
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "dev": "tsx src/index.ts",
13
+ "lint": "eslint src",
14
+ "test": "vitest run",
15
+ "prepublishOnly": "pnpm lint && pnpm test && pnpm build"
16
+ },
17
+ "dependencies": {
18
+ "commander": "^12.1.0",
19
+ "tar": "^7.4.3"
20
+ },
21
+ "devDependencies": {
22
+ "@eslint/js": "^9.0.0",
23
+ "@types/node": "^20.0.0",
24
+ "eslint": "^9.0.0",
25
+ "tsx": "^4.0.0",
26
+ "typescript": "^5.0.0",
27
+ "typescript-eslint": "^8.0.0",
28
+ "vitest": "^3.0.0"
29
+ }
30
+ }