primo-cli 0.1.14 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/deploy.js +47 -28
- package/dist/commands/dev.js +8 -0
- package/dist/utils/binary.js +1 -1
- package/dist/utils/uploads.d.ts +34 -0
- package/dist/utils/uploads.js +148 -0
- package/package.json +1 -1
package/dist/commands/deploy.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from 'fs/promises';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import chalk from 'chalk';
|
|
4
4
|
import ora from 'ora';
|
|
5
|
-
import { select
|
|
5
|
+
import { select } from '@inquirer/prompts';
|
|
6
6
|
import { execSync, spawn } from 'child_process';
|
|
7
7
|
import { read_server_config, write_server_config, get_server_config_path, SERVER_CONFIG_FILE } from '../utils/server-config.js';
|
|
8
8
|
import { read_site_config, get_site_config_path } from '../utils/site-config.js';
|
|
@@ -301,6 +301,30 @@ async function deploy_to_railway(inventory, auto_push) {
|
|
|
301
301
|
spinner.fail('Failed to set up Railway project');
|
|
302
302
|
throw error;
|
|
303
303
|
}
|
|
304
|
+
// Attach a persistent volume at /app/pb_data BEFORE the first deploy. If the
|
|
305
|
+
// container starts without it, primo writes the SQLite db to ephemeral
|
|
306
|
+
// storage and it's lost on the next image change or restart. Mounting after
|
|
307
|
+
// data already exists starts from an empty volume, so ordering matters.
|
|
308
|
+
spinner.start('Ensuring persistent volume at /app/pb_data...');
|
|
309
|
+
try {
|
|
310
|
+
if (railway_volume_exists(inventory.root_dir, PB_DATA_MOUNT_PATH)) {
|
|
311
|
+
spinner.succeed('Persistent volume already mounted at /app/pb_data');
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
execSync(`railway volume add --mount-path ${PB_DATA_MOUNT_PATH}`, {
|
|
315
|
+
cwd: inventory.root_dir,
|
|
316
|
+
stdio: 'ignore'
|
|
317
|
+
});
|
|
318
|
+
spinner.succeed('Created persistent volume at /app/pb_data');
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
// Volume creation can fail (e.g. plan limits, already-attached on another
|
|
323
|
+
// service). Don't abort the deploy — warn and fall back to the manual
|
|
324
|
+
// dashboard step so the user can still finish.
|
|
325
|
+
spinner.warn('Could not attach volume automatically — mount it manually');
|
|
326
|
+
console.log(chalk.dim(' Settings → Volumes → mount on /app/pb_data (size 1GB+)'));
|
|
327
|
+
}
|
|
304
328
|
console.log('');
|
|
305
329
|
console.log(chalk.dim('Building and deploying...'));
|
|
306
330
|
console.log('');
|
|
@@ -323,10 +347,9 @@ async function deploy_to_railway(inventory, auto_push) {
|
|
|
323
347
|
if (url) {
|
|
324
348
|
await record_workspace_server(inventory.root_dir, url);
|
|
325
349
|
}
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
// to the old "next steps" message so the user can finish by hand.
|
|
350
|
+
// Volume is already attached (before deploy), so just wait for the
|
|
351
|
+
// server to come online and push. If anything fails we fall back to
|
|
352
|
+
// the "next steps" message so the user can finish by hand.
|
|
330
353
|
const pushed = url
|
|
331
354
|
? await finish_railway_deploy(inventory, url, auto_push)
|
|
332
355
|
: false;
|
|
@@ -336,30 +359,33 @@ async function deploy_to_railway(inventory, auto_push) {
|
|
|
336
359
|
});
|
|
337
360
|
}
|
|
338
361
|
async function finish_railway_deploy(inventory, url, auto_push) {
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
console.log(chalk.dim(' Open the project in the Railway dashboard, then:'));
|
|
342
|
-
console.log(chalk.dim(' Settings → Volumes → mount on /app/pb_data (size 1GB+)'));
|
|
343
|
-
console.log('');
|
|
362
|
+
// The persistent volume is attached before deploy, so there's no manual
|
|
363
|
+
// dashboard step left — just wait for the server and push.
|
|
344
364
|
if (!auto_push)
|
|
345
365
|
return false;
|
|
346
|
-
|
|
366
|
+
const ready = await wait_for_ready(url);
|
|
367
|
+
if (!ready)
|
|
368
|
+
return false;
|
|
369
|
+
return await run_auto_push(inventory);
|
|
370
|
+
}
|
|
371
|
+
const PB_DATA_MOUNT_PATH = '/app/pb_data';
|
|
372
|
+
// Check whether the linked Railway project already has a volume mounted at the
|
|
373
|
+
// given path. The list JSON field name has varied across CLI versions, so we
|
|
374
|
+
// accept a few spellings rather than pinning one.
|
|
375
|
+
function railway_volume_exists(cwd, mount_path) {
|
|
347
376
|
try {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
377
|
+
const out = execSync('railway volume list --json', { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
|
|
378
|
+
const volumes = JSON.parse(out);
|
|
379
|
+
if (!Array.isArray(volumes))
|
|
380
|
+
return false;
|
|
381
|
+
return volumes.some((v) => {
|
|
382
|
+
const path = v.mountPath ?? v.mount_path ?? v.mountpath;
|
|
383
|
+
return typeof path === 'string' && path.replace(/\/+$/, '') === mount_path.replace(/\/+$/, '');
|
|
351
384
|
});
|
|
352
385
|
}
|
|
353
386
|
catch {
|
|
354
|
-
// Ctrl+C / non-interactive — bail out, user can run primo push later.
|
|
355
387
|
return false;
|
|
356
388
|
}
|
|
357
|
-
if (!confirmed)
|
|
358
|
-
return false;
|
|
359
|
-
const ready = await wait_for_ready(url);
|
|
360
|
-
if (!ready)
|
|
361
|
-
return false;
|
|
362
|
-
return await run_auto_push(inventory);
|
|
363
389
|
}
|
|
364
390
|
async function try_railway_domain(cwd) {
|
|
365
391
|
try {
|
|
@@ -411,13 +437,6 @@ function print_post_deploy_next_steps(provider, url, ctx) {
|
|
|
411
437
|
}
|
|
412
438
|
console.log(chalk.bold('Next steps'));
|
|
413
439
|
console.log('');
|
|
414
|
-
if (provider === 'railway' && !ctx.auto_push) {
|
|
415
|
-
// User passed --no-push; remind them about the volume since we
|
|
416
|
-
// skipped the prompt that would normally cover it.
|
|
417
|
-
console.log(chalk.dim(' Railway needs one manual setting the CLI can\'t set for you:'));
|
|
418
|
-
console.log(chalk.dim(' Settings → Volumes → mount on /app/pb_data (size 1GB+)'));
|
|
419
|
-
console.log('');
|
|
420
|
-
}
|
|
421
440
|
console.log(chalk.dim(' Upload your workspace into the deployed server:'));
|
|
422
441
|
console.log(chalk.dim(` primo push${url ? '' : ' -s <url>'}`));
|
|
423
442
|
console.log(chalk.dim(' (first push bootstraps the sites; later pushes update them incrementally)'));
|
package/dist/commands/dev.js
CHANGED
|
@@ -1921,6 +1921,14 @@ async function import_site_files(site_dir, api_url, config, port, server_config,
|
|
|
1921
1921
|
if (bootstrap_response.ok) {
|
|
1922
1922
|
try {
|
|
1923
1923
|
const result = await bootstrap_response.json();
|
|
1924
|
+
// Bootstrap runs the same import as the regular path, so it
|
|
1925
|
+
// must write back created ids too — otherwise the very first
|
|
1926
|
+
// push never renames upload files to their canonical suffixed
|
|
1927
|
+
// names or rewrites symbolic refs, and later pushes keep
|
|
1928
|
+
// re-sending the un-suffixed names (the upload dup bug).
|
|
1929
|
+
if (result.created_ids) {
|
|
1930
|
+
await write_created_ids(site_dir, result.created_ids, server_config, workspace_dir);
|
|
1931
|
+
}
|
|
1924
1932
|
warning_count = print_import_warnings(config.name, result.warnings);
|
|
1925
1933
|
}
|
|
1926
1934
|
catch {
|
package/dist/utils/binary.js
CHANGED
|
@@ -9,7 +9,7 @@ import ora from 'ora';
|
|
|
9
9
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
10
|
const PRIMO_HOME = path.join(os.homedir(), '.primo');
|
|
11
11
|
const BIN_DIR = path.join(PRIMO_HOME, 'bin');
|
|
12
|
-
const VERSION = '3.2.
|
|
12
|
+
const VERSION = '3.2.3'; // matches primo releases
|
|
13
13
|
// Path to locally built binary (for development)
|
|
14
14
|
// The binary is at primo/primo (inside the primo repo directory)
|
|
15
15
|
const LOCAL_BINARY = path.resolve(__dirname, '..', '..', '..', 'primo', 'primo');
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export declare class UploadsUnsupportedError extends Error {
|
|
2
|
+
constructor();
|
|
3
|
+
}
|
|
4
|
+
export declare class SiteNotFoundError extends Error {
|
|
5
|
+
constructor();
|
|
6
|
+
}
|
|
7
|
+
export interface LocalUpload {
|
|
8
|
+
filename: string;
|
|
9
|
+
abs_path: string;
|
|
10
|
+
hash: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function collect_local_uploads(site_dir: string): Promise<LocalUpload[]>;
|
|
13
|
+
export interface UploadPutResult {
|
|
14
|
+
filename: string;
|
|
15
|
+
id: string;
|
|
16
|
+
canonical: string;
|
|
17
|
+
hash: string;
|
|
18
|
+
changed: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface SyncUploadsOptions {
|
|
21
|
+
server: string;
|
|
22
|
+
site_id: string;
|
|
23
|
+
token: string | undefined;
|
|
24
|
+
site_dir: string;
|
|
25
|
+
on_progress?: (done: number, total: number) => void;
|
|
26
|
+
concurrency?: number;
|
|
27
|
+
}
|
|
28
|
+
export interface SyncUploadsResult {
|
|
29
|
+
total: number;
|
|
30
|
+
uploaded: number;
|
|
31
|
+
skipped: number;
|
|
32
|
+
results: UploadPutResult[];
|
|
33
|
+
}
|
|
34
|
+
export declare function sync_uploads(opts: SyncUploadsOptions): Promise<SyncUploadsResult>;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
// Incremental upload sync. Instead of packing every binary into the import
|
|
5
|
+
// zip — which made large-media sites exceed the hosting proxy's request
|
|
6
|
+
// timeout on a single synchronous /import call — we push uploads out of band:
|
|
7
|
+
// hash them locally, ask the server which hashes it's missing, and PUT only
|
|
8
|
+
// those, one small request each.
|
|
9
|
+
//
|
|
10
|
+
// Servers predating these endpoints answer /check with 404; callers detect
|
|
11
|
+
// that via UploadsUnsupportedError and fall back to shipping uploads in the
|
|
12
|
+
// zip (the legacy path).
|
|
13
|
+
export class UploadsUnsupportedError extends Error {
|
|
14
|
+
constructor() {
|
|
15
|
+
super('server does not support incremental uploads');
|
|
16
|
+
this.name = 'UploadsUnsupportedError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
// The site doesn't exist on the server yet — its media can't be pushed until
|
|
20
|
+
// it's created (via bootstrap). Distinct from UploadsUnsupportedError so the
|
|
21
|
+
// caller routes to bootstrap instead of the legacy zip-uploads path.
|
|
22
|
+
export class SiteNotFoundError extends Error {
|
|
23
|
+
constructor() {
|
|
24
|
+
super('site not found on server');
|
|
25
|
+
this.name = 'SiteNotFoundError';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// PocketBase's router emits this exact message when no route matches. We use
|
|
29
|
+
// it to tell "endpoint missing" (old server → unsupported) apart from a
|
|
30
|
+
// handler-level 404 like "Site not found" (new server, site absent).
|
|
31
|
+
const ROUTER_NOT_FOUND_MESSAGE = "The requested resource wasn't found.";
|
|
32
|
+
// Classify a 404 body: true = the ROUTE is missing (old server), so uploads
|
|
33
|
+
// are unsupported; false = a handler 404 (e.g. site not found).
|
|
34
|
+
async function is_route_missing(res) {
|
|
35
|
+
try {
|
|
36
|
+
const body = (await res.clone().json());
|
|
37
|
+
return body?.message === ROUTER_NOT_FOUND_MESSAGE;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Non-JSON 404 → treat as route missing (safest: fall back to zip).
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const UPLOADS_DIR = 'uploads';
|
|
45
|
+
const MANIFEST_FILE = '.manifest.json';
|
|
46
|
+
function sha256_hex(buf) {
|
|
47
|
+
return crypto.createHash('sha256').update(buf).digest('hex');
|
|
48
|
+
}
|
|
49
|
+
// Enumerate uploads/ as a flat set of bare filenames, mirroring the server's
|
|
50
|
+
// zip-import filter: skip dotfiles (the manifest lives there) and anything
|
|
51
|
+
// nested. Hashes are read from bytes; a manifest with matching hashes could
|
|
52
|
+
// let us skip re-hashing unchanged files, but hashing local disk is cheap
|
|
53
|
+
// next to the network round-trip we're optimizing, so we always hash.
|
|
54
|
+
export async function collect_local_uploads(site_dir) {
|
|
55
|
+
const dir = path.join(site_dir, UPLOADS_DIR);
|
|
56
|
+
let entries;
|
|
57
|
+
try {
|
|
58
|
+
entries = await fs.readdir(dir);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return []; // no uploads/ folder
|
|
62
|
+
}
|
|
63
|
+
const uploads = [];
|
|
64
|
+
for (const name of entries) {
|
|
65
|
+
if (name.startsWith('.') || name.includes('/'))
|
|
66
|
+
continue;
|
|
67
|
+
const abs_path = path.join(dir, name);
|
|
68
|
+
const stat = await fs.stat(abs_path);
|
|
69
|
+
if (!stat.isFile())
|
|
70
|
+
continue;
|
|
71
|
+
const buf = await fs.readFile(abs_path);
|
|
72
|
+
uploads.push({ filename: name, abs_path, hash: sha256_hex(buf) });
|
|
73
|
+
}
|
|
74
|
+
return uploads;
|
|
75
|
+
}
|
|
76
|
+
async function post_json(url, token, body) {
|
|
77
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
78
|
+
if (token)
|
|
79
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
80
|
+
return fetch(url, { method: 'POST', headers, body: JSON.stringify(body) });
|
|
81
|
+
}
|
|
82
|
+
// Ask the server which of the given hashes it does not already hold.
|
|
83
|
+
// Throws UploadsUnsupportedError on a 404 (old server without the endpoint).
|
|
84
|
+
async function check_missing(server, site_id, token, hashes) {
|
|
85
|
+
const res = await post_json(`${server}/api/primo/uploads/${site_id}/check`, token, { hashes });
|
|
86
|
+
if (res.status === 404) {
|
|
87
|
+
throw (await is_route_missing(res)) ? new UploadsUnsupportedError() : new SiteNotFoundError();
|
|
88
|
+
}
|
|
89
|
+
if (!res.ok)
|
|
90
|
+
throw new Error(await res.text());
|
|
91
|
+
const data = (await res.json());
|
|
92
|
+
return new Set(data.missing || []);
|
|
93
|
+
}
|
|
94
|
+
// PUT a single upload. Returns the server's record id + canonical name so the
|
|
95
|
+
// caller can reconcile local state (rename to the suffixed name, rewrite refs).
|
|
96
|
+
async function put_upload(server, site_id, token, up) {
|
|
97
|
+
const buf = await fs.readFile(up.abs_path);
|
|
98
|
+
const form = new FormData();
|
|
99
|
+
form.append('filename', up.filename);
|
|
100
|
+
form.append('file', new Blob([buf]), up.filename);
|
|
101
|
+
const headers = {};
|
|
102
|
+
if (token)
|
|
103
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
104
|
+
const res = await fetch(`${server}/api/primo/uploads/${site_id}/put`, {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers,
|
|
107
|
+
body: form
|
|
108
|
+
});
|
|
109
|
+
if (res.status === 404) {
|
|
110
|
+
throw (await is_route_missing(res)) ? new UploadsUnsupportedError() : new SiteNotFoundError();
|
|
111
|
+
}
|
|
112
|
+
if (!res.ok)
|
|
113
|
+
throw new Error(await res.text());
|
|
114
|
+
const data = (await res.json());
|
|
115
|
+
return { filename: up.filename, id: data.id, canonical: data.canonical, hash: data.hash, changed: data.changed };
|
|
116
|
+
}
|
|
117
|
+
// Sync uploads incrementally. Returns counts + per-file results, or throws
|
|
118
|
+
// UploadsUnsupportedError if the server lacks the endpoints (caller falls back
|
|
119
|
+
// to the legacy zip path).
|
|
120
|
+
export async function sync_uploads(opts) {
|
|
121
|
+
const { server, site_id, token, site_dir } = opts;
|
|
122
|
+
const concurrency = Math.max(1, opts.concurrency ?? 4);
|
|
123
|
+
const local = await collect_local_uploads(site_dir);
|
|
124
|
+
if (local.length === 0) {
|
|
125
|
+
return { total: 0, uploaded: 0, skipped: 0, results: [] };
|
|
126
|
+
}
|
|
127
|
+
// One small request tells us the exact set to send. Dedup by hash so
|
|
128
|
+
// identical files (different names) are only weighed once server-side.
|
|
129
|
+
const missing_hashes = await check_missing(server, site_id, token, local.map((u) => u.hash));
|
|
130
|
+
const to_send = local.filter((u) => missing_hashes.has(u.hash));
|
|
131
|
+
const skipped = local.length - to_send.length;
|
|
132
|
+
const results = [];
|
|
133
|
+
let done = 0;
|
|
134
|
+
// Simple bounded worker pool over the send list.
|
|
135
|
+
let cursor = 0;
|
|
136
|
+
async function worker() {
|
|
137
|
+
while (cursor < to_send.length) {
|
|
138
|
+
const idx = cursor++;
|
|
139
|
+
const r = await put_upload(server, site_id, token, to_send[idx]);
|
|
140
|
+
results.push(r);
|
|
141
|
+
done++;
|
|
142
|
+
opts.on_progress?.(done, to_send.length);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const workers = Array.from({ length: Math.min(concurrency, to_send.length) }, () => worker());
|
|
146
|
+
await Promise.all(workers);
|
|
147
|
+
return { total: local.length, uploaded: to_send.length, skipped, results };
|
|
148
|
+
}
|