primo-cli 0.1.17 → 0.1.18

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.
@@ -224,11 +224,13 @@ async function check_provider_auth(provider) {
224
224
  return false;
225
225
  }
226
226
  }
227
- // Pinned to the upstream-published image. primocms's main.yml workflow
228
- // publishes branch tags for whitelisted prefixes (main, feature/**, rc/**),
229
- // with slashes slugified to dashes (feature/local-dev-cli :feature-local-dev-cli).
230
- // Bump to a release tag (:v3.0.0) when primocms cuts a stable release.
231
- const PRIMO_SERVER_IMAGE = 'ghcr.io/primocms/primo:main';
227
+ // Track the latest stable release. primocms's tag.yml workflow publishes a
228
+ // :latest image (alongside semver tags) on every version tag the comment
229
+ // there calls out that "Railway/self-host deployers follow this tag to track
230
+ // releases". Following :latest means deploys pick up new releases on their next
231
+ // rebuild without a per-release bump here. (main.yml still publishes branch tags
232
+ // like :main / :feature-* for testing; :latest is the released line.)
233
+ const PRIMO_SERVER_IMAGE = 'ghcr.io/primocms/primo:latest';
232
234
  async function generate_dockerfile(inventory) {
233
235
  // One-line Dockerfile: pull the published primo image and run it
234
236
  // unchanged. Workspace data (server.yaml, sites/, library/) is uploaded
@@ -29,6 +29,12 @@ let last_import_time = 0; // Timestamp of last import completion
29
29
  let last_local_change_time = 0; // Timestamp of most recent local watcher event
30
30
  const importing_site_keys = new Set();
31
31
  const pending_local_site_keys = new Set();
32
+ // Sites whose last import returned null (duplicate _ids across files — nothing
33
+ // was pushed). They stay quarantined until a later import succeeds: no CMS→file
34
+ // polling into them (a quarantined site is out of sync with the CMS, so copying
35
+ // remote-only paths in could clobber the local files the user must edit to fix
36
+ // the conflict). Cleared on the next successful import.
37
+ const blocked_site_keys = new Set();
32
38
  let is_importing_library = false;
33
39
  let has_pending_library_local_changes = false;
34
40
  let site_sync_baselines = new Map();
@@ -618,6 +624,7 @@ export async function dev_server(options) {
618
624
  }
619
625
  // Normalize and load all sites
620
626
  spinner.text = `Loading ${sites.length} site${sites.length > 1 ? 's' : ''}...`;
627
+ const blocked_sites = new Set();
621
628
  for (const site of sites) {
622
629
  const use_bootstrap = !await site_exists(api_url, site.config.site_id);
623
630
  if (sync_policy.mode === 'cms' && !use_bootstrap) {
@@ -625,7 +632,17 @@ export async function dev_server(options) {
625
632
  continue;
626
633
  }
627
634
  await normalize_site(site.dir);
635
+ const site_key = get_site_sync_key(site.dir, site.config);
628
636
  const import_timings = await with_site_import_lock(site.dir, site.config, () => import_site_files(site.dir, api_url, site.config, port, server_config, use_bootstrap, base_dir));
637
+ if (import_timings === null) {
638
+ // Duplicate _ids — nothing was pushed; the watcher retries
639
+ // once the user removes a conflicting file. Quarantine it so
640
+ // CMS→file polling doesn't sync into an out-of-sync site.
641
+ blocked_sites.add(site.dir);
642
+ blocked_site_keys.add(site_key);
643
+ continue;
644
+ }
645
+ blocked_site_keys.delete(site_key);
629
646
  if (update_site_sync_state_after_import(site, import_timings, sync_policy)) {
630
647
  await update_site_sync_baseline(site, api_url, server_config, base_dir);
631
648
  }
@@ -633,6 +650,8 @@ export async function dev_server(options) {
633
650
  // Verify all sites are accessible before proceeding
634
651
  spinner.text = 'Verifying sites...';
635
652
  for (const site of sites) {
653
+ if (blocked_sites.has(site.dir))
654
+ continue;
636
655
  await verify_site_ready(api_url, site.config.site_id);
637
656
  }
638
657
  spinner.succeed('Primo running');
@@ -816,6 +835,15 @@ export async function dev_server(options) {
816
835
  }
817
836
  }
818
837
  const import_timings = await with_site_import_lock(site.dir, site.config, () => import_site_files(site.dir, api_url, site.config, port, server_config, false, base_dir));
838
+ if (import_timings === null) {
839
+ // Duplicate _ids — import_site_files already printed the
840
+ // error and wrote sync_status; nothing was pushed. Keep
841
+ // the site quarantined from CMS→file polling.
842
+ blocked_site_keys.add(get_site_sync_key(site.dir, site.config));
843
+ return;
844
+ }
845
+ // Import succeeded — lift any prior quarantine.
846
+ blocked_site_keys.delete(get_site_sync_key(site.dir, site.config));
819
847
  let reload_ms = 0;
820
848
  if (pending_reload) {
821
849
  try {
@@ -974,7 +1002,12 @@ export async function dev_server(options) {
974
1002
  else {
975
1003
  await normalize_site(site.dir);
976
1004
  const import_timings = await with_site_import_lock(site.dir, site.config, () => import_site_files(site.dir, api_url, site.config, port, server_config, use_bootstrap, base_dir));
977
- if (update_site_sync_state_after_import(site, import_timings, sync_policy)) {
1005
+ if (import_timings === null) {
1006
+ // Duplicate _ids on a freshly discovered site — quarantine
1007
+ // it from CMS→file polling until a later import succeeds.
1008
+ blocked_site_keys.add(get_site_sync_key(site.dir, site.config));
1009
+ }
1010
+ else if (update_site_sync_state_after_import(site, import_timings, sync_policy)) {
978
1011
  await update_site_sync_baseline(site, api_url, server_config, base_dir);
979
1012
  }
980
1013
  }
@@ -1013,7 +1046,9 @@ export async function dev_server(options) {
1013
1046
  for (const site of sites) {
1014
1047
  try {
1015
1048
  const site_key = get_site_sync_key(site.dir, site.config);
1016
- if (importing_site_keys.has(site_key) || pending_local_site_keys.has(site_key)) {
1049
+ if (importing_site_keys.has(site_key) ||
1050
+ pending_local_site_keys.has(site_key) ||
1051
+ blocked_site_keys.has(site_key)) {
1017
1052
  continue;
1018
1053
  }
1019
1054
  await sync_from_cms(site.dir, api_url, site.config, server_config, base_dir, sync_policy);
@@ -1678,19 +1713,19 @@ function describe_duplicate(category, id, occurrences) {
1678
1713
  const files = [...new Set(occurrences.map((occurrence) => occurrence.file))].sort();
1679
1714
  switch (category) {
1680
1715
  case 'pages':
1681
- return `duplicate page _id "${id}" in ${files.join(' and ')}; skipping those pages`;
1716
+ return `duplicate page _id "${id}" in ${files.join(' and ')}`;
1682
1717
  case 'page_sections':
1683
- return `duplicate section _id "${id}" in ${files.join(' and ')}; skipping those pages`;
1718
+ return `duplicate section _id "${id}" in ${files.join(' and ')}`;
1684
1719
  case 'blocks':
1685
- return `duplicate block _id "${id}" in ${files.join(' and ')}; skipping those blocks`;
1720
+ return `duplicate block _id "${id}" in ${files.join(' and ')}`;
1686
1721
  case 'page_types':
1687
- return `duplicate page type _id "${id}" in ${files.join(' and ')}; skipping those page types`;
1722
+ return `duplicate page type _id "${id}" in ${files.join(' and ')}`;
1688
1723
  case 'site_fields':
1689
- return `duplicate site field _id "${id}" in ${files.join(' and ')}; skipping site/fields.yaml`;
1724
+ return `duplicate site field _id "${id}" in ${files.join(' and ')}`;
1690
1725
  case 'block_fields':
1691
- return `duplicate block field _id "${id}" in ${files.join(' and ')}; skipping those blocks`;
1726
+ return `duplicate block field _id "${id}" in ${files.join(' and ')}`;
1692
1727
  case 'page_type_fields':
1693
- return `duplicate page type field _id "${id}" in ${files.join(' and ')}; skipping those page types`;
1728
+ return `duplicate page type field _id "${id}" in ${files.join(' and ')}`;
1694
1729
  }
1695
1730
  }
1696
1731
  async function prepare_site_for_local_dev(site_dir) {
@@ -1854,8 +1889,31 @@ async function import_site_files(site_dir, api_url, config, port, server_config,
1854
1889
  const site_id = config.site_id;
1855
1890
  const site_group = resolve_site_group(config, server_config);
1856
1891
  const preparation = await prepare_site_for_local_dev(site_dir);
1857
- for (const warning of preparation.warnings) {
1858
- console.log(chalk.yellow(` ⚠ ${config.name}: ${warning}`));
1892
+ if (preparation.warnings.length > 0) {
1893
+ // Duplicate _ids (the only warning source in prepare_site_for_local_dev)
1894
+ // would leave the server with stale/dangling state if we pushed anyway,
1895
+ // so fail loudly instead of silently excluding the conflicting files.
1896
+ for (const warning of preparation.warnings) {
1897
+ console.log(chalk.red(` ✖ ${config.name}: ${warning}`));
1898
+ }
1899
+ console.log(chalk.dim(' Remove one of the conflicting files and save to retry.'));
1900
+ const message = preparation.warnings[0];
1901
+ await write_sync_status(site_dir, {
1902
+ ok: false,
1903
+ error: message,
1904
+ failed_at: new Date().toISOString()
1905
+ });
1906
+ try {
1907
+ // Best-effort — older servers don't have this endpoint.
1908
+ await fetch(`${api_url}/api/primo/dev/status`, {
1909
+ method: 'POST',
1910
+ headers: { 'Content-Type': 'application/json' },
1911
+ body: JSON.stringify({ status: 'error', message }),
1912
+ signal: AbortSignal.timeout(2000)
1913
+ });
1914
+ }
1915
+ catch { }
1916
+ return null;
1859
1917
  }
1860
1918
  // Create ZIP of site files
1861
1919
  const zip_started = Date.now();
@@ -8,6 +8,10 @@ import { get_auth_token } from '../utils/auth.js';
8
8
  import { authenticate_interactively } from './login.js';
9
9
  import { write_site_config } from '../utils/site-config.js';
10
10
  import { read_server_config, write_server_config, normalize_server_url } from '../utils/server-config.js';
11
+ // Directories owned by the server export. Local files under these that no
12
+ // longer exist in the export are stale (e.g. a page that gained children
13
+ // moved from pages/foo.yaml to pages/foo/index.yaml) and get trashed.
14
+ const MANAGED_DIRS = ['pages', 'blocks', 'page-types', 'site'];
11
15
  async function detect_server() {
12
16
  const ports = [3000, 8080, 5173];
13
17
  for (const port of ports) {
@@ -209,8 +213,27 @@ async function pull_one_site(server, headers, site, site_dir, spinner) {
209
213
  const temp_zip = path.join(site_dir, '.primo-export.zip');
210
214
  await fs.writeFile(temp_zip, Buffer.from(zip_data));
211
215
  spinner.text = `Extracting ${site.name}...`;
212
- await extract(temp_zip, { dir: site_dir });
213
- await fs.unlink(temp_zip);
216
+ const temp_dir = path.join(site_dir, '.primo', `pull-temp-${Date.now()}`);
217
+ let trashed = [];
218
+ try {
219
+ await fs.mkdir(temp_dir, { recursive: true });
220
+ await extract(temp_zip, { dir: temp_dir });
221
+ await fs.unlink(temp_zip);
222
+ trashed = await reconcile_managed_dirs(site_dir, temp_dir);
223
+ await fs.cp(temp_dir, site_dir, { recursive: true });
224
+ }
225
+ finally {
226
+ await fs.rm(temp_dir, { recursive: true, force: true });
227
+ // Also drop the archive: on a successful pull it was already unlinked
228
+ // above, but if extract() threw it's still sitting in site_dir.
229
+ await fs.rm(temp_zip, { force: true });
230
+ }
231
+ if (trashed.length > 0) {
232
+ console.log(chalk.dim(' Removed stale files (moved to .primo/trash):'));
233
+ for (const trashed_path of trashed) {
234
+ console.log(chalk.dim(` ${trashed_path}`));
235
+ }
236
+ }
214
237
  await write_site_config(site_dir, {
215
238
  name: site.name || 'Imported Site',
216
239
  site_id: site.id,
@@ -220,6 +243,92 @@ async function pull_one_site(server, headers, site, site_dir, spinner) {
220
243
  await copy_schemas(site_dir);
221
244
  await add_schema_references(site_dir);
222
245
  }
246
+ // Move local files under MANAGED_DIRS that have no counterpart in the fresh
247
+ // export into .primo/trash/pull-<ts>/, then remove newly-empty directories
248
+ // (best-effort). Returns the trashed paths relative to the site dir.
249
+ async function reconcile_managed_dirs(site_dir, temp_dir) {
250
+ const trashed = [];
251
+ const stamp = Date.now();
252
+ for (const dir of MANAGED_DIRS) {
253
+ const local_root = path.join(site_dir, dir);
254
+ const temp_root = path.join(temp_dir, dir);
255
+ for (const relative of await list_files_recursive(local_root)) {
256
+ // Keep the local file only if the export still has a *file* at the
257
+ // same path. If the counterpart is now a directory (a file→dir
258
+ // transition, e.g. pages/foo.yaml became pages/foo/…), the local
259
+ // file is stale and must be trashed — otherwise the later fs.cp
260
+ // can't lay a directory over the surviving file and the pull fails.
261
+ try {
262
+ const counterpart = await fs.stat(path.join(temp_root, relative));
263
+ if (counterpart.isFile())
264
+ continue;
265
+ }
266
+ catch {
267
+ // Missing from the export — stale.
268
+ }
269
+ const local_path = path.join(local_root, relative);
270
+ // Scope the trash path by the managed dir. Two managed dirs can hold
271
+ // the same relative name (pages/config.yaml, blocks/config.yaml);
272
+ // without the dir segment they'd collide at trash/pull-<ts>/config.yaml
273
+ // and the second move would clobber the first while both are reported.
274
+ const trash_path = path.join(site_dir, '.primo', 'trash', `pull-${stamp}`, dir, relative);
275
+ await fs.mkdir(path.dirname(trash_path), { recursive: true });
276
+ try {
277
+ await fs.rename(local_path, trash_path);
278
+ }
279
+ catch {
280
+ // Cross-device move — fall back to copy + delete.
281
+ await fs.copyFile(local_path, trash_path);
282
+ await fs.unlink(local_path);
283
+ }
284
+ trashed.push(`${dir}/${relative}`);
285
+ }
286
+ await remove_empty_dirs(local_root);
287
+ }
288
+ return trashed;
289
+ }
290
+ async function list_files_recursive(root, prefix = '') {
291
+ let entries;
292
+ try {
293
+ entries = await fs.readdir(root, { withFileTypes: true });
294
+ }
295
+ catch {
296
+ return [];
297
+ }
298
+ const files = [];
299
+ for (const entry of entries) {
300
+ if (entry.name.startsWith('.'))
301
+ continue;
302
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
303
+ if (entry.isDirectory()) {
304
+ files.push(...await list_files_recursive(path.join(root, entry.name), relative));
305
+ }
306
+ else if (entry.isFile()) {
307
+ files.push(relative);
308
+ }
309
+ }
310
+ return files;
311
+ }
312
+ // Remove empty directories bottom-up. rmdir on a non-empty dir fails, which
313
+ // we ignore — only newly-emptied dirs go away.
314
+ async function remove_empty_dirs(root) {
315
+ let entries;
316
+ try {
317
+ entries = await fs.readdir(root, { withFileTypes: true });
318
+ }
319
+ catch {
320
+ return;
321
+ }
322
+ for (const entry of entries) {
323
+ if (entry.isDirectory()) {
324
+ await remove_empty_dirs(path.join(root, entry.name));
325
+ }
326
+ }
327
+ try {
328
+ await fs.rmdir(root);
329
+ }
330
+ catch { }
331
+ }
223
332
  async function pull_library_into(server, headers, root_dir, spinner) {
224
333
  spinner.start('Pulling library...');
225
334
  const response = await fetch(`${server}/api/primo/export-library`, { headers });
@@ -333,14 +333,12 @@ async function try_bootstrap_site(server, token, zip_buffer, config, site_id, gr
333
333
  form.append('group', config.group);
334
334
  if (group_name)
335
335
  form.append('group_name', group_name);
336
- // Register the site against the deploy URL's host so the first visit to
337
- // that domain finds a matching site instead of dropping into CreateSite.
338
- try {
339
- form.append('host', new URL(server).host);
340
- }
341
- catch {
342
- // Malformed server URL — let the server fall back to its own default.
343
- }
336
+ // Host is intentionally not sent. A pushed site is created unassigned
337
+ // the server seeds `host` with a placeholder (the site's own id) so the
338
+ // site is editable in the dashboard but not publicly served until an
339
+ // operator assigns a real domain. The lone exception is bootstrap of the
340
+ // very first site on a fresh instance, where the server falls back to the
341
+ // deploy URL's host so that instance's front door resolves immediately.
344
342
  form.append('file', new Blob([zip_buffer]), 'site.zip');
345
343
  const headers = {};
346
344
  if (token)
@@ -9,7 +9,64 @@ 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.3'; // matches primo releases
12
+ const REPO = 'primocms/primo';
13
+ // Resolve the latest primo release tag at runtime rather than pinning a version
14
+ // here — a pinned constant silently goes stale (it sat on 3.2.3 through two
15
+ // releases). Cached for the process so repeated calls in one CLI run don't
16
+ // re-hit the API. Bounded by a 10s timeout so an already-installed binary can
17
+ // still be reused promptly when GitHub is slow or unreachable.
18
+ let latest_version_cache;
19
+ async function get_latest_version() {
20
+ if (latest_version_cache !== undefined)
21
+ return latest_version_cache;
22
+ try {
23
+ const res = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, {
24
+ headers: { Accept: 'application/vnd.github+json' },
25
+ // Node 18+ ships AbortSignal.timeout; keeps the version check from
26
+ // hanging CLI startup when GitHub stalls.
27
+ signal: AbortSignal.timeout(10_000)
28
+ });
29
+ // Rate limits come back as 403 or 429. Detect them the way GitHub's docs
30
+ // prescribe, since no single signal covers every case:
31
+ // - primary limit: 403 with x-ratelimit-remaining: 0
32
+ // - secondary limit: 403/429 with a Retry-After header, or a body
33
+ // message mentioning a secondary rate limit (remaining may be > 0)
34
+ // - 429 is always a rate limit
35
+ // Treat all of these as "can't tell", never a definitive version —
36
+ // otherwise a throttled run would mask an outdated binary as current. A
37
+ // plain 403 with quota remaining (e.g. a genuine permission error) is a
38
+ // real "unavailable" verdict, not a throttle.
39
+ if (res.status === 403 || res.status === 429) {
40
+ const remaining = res.headers.get('x-ratelimit-remaining');
41
+ const retry_after = res.headers.get('retry-after');
42
+ let rate_limited = res.status === 429 || remaining === '0' || retry_after !== null;
43
+ if (!rate_limited) {
44
+ // Last resort: peek at the body for the secondary-limit message.
45
+ const body = await res.text().catch(() => '');
46
+ rate_limited = /secondary rate limit|rate limit/i.test(body);
47
+ }
48
+ latest_version_cache = rate_limited
49
+ ? { status: 'throttled', reason: `GitHub rate limit (${res.status})` }
50
+ : { status: 'unavailable' };
51
+ return latest_version_cache;
52
+ }
53
+ if (!res.ok)
54
+ throw new Error(`GitHub API ${res.status}`);
55
+ const data = (await res.json());
56
+ const version = parse_semver(data.tag_name ?? null);
57
+ latest_version_cache = version ? { status: 'resolved', version } : { status: 'unavailable' };
58
+ }
59
+ catch (err) {
60
+ // Timeout / DNS / connection reset — indistinguishable from being
61
+ // offline. Treat as throttled (can't confirm) so we don't force a
62
+ // needless re-download of a working binary.
63
+ latest_version_cache = {
64
+ status: 'throttled',
65
+ reason: err instanceof Error ? err.message : 'network error'
66
+ };
67
+ }
68
+ return latest_version_cache;
69
+ }
13
70
  // Path to locally built binary (for development)
14
71
  // The binary is at primo/primo (inside the primo repo directory)
15
72
  const LOCAL_BINARY = path.resolve(__dirname, '..', '..', '..', 'primo', 'primo');
@@ -46,9 +103,12 @@ function get_platform() {
46
103
  return { os: osName, arch: archName, ext };
47
104
  }
48
105
  function get_download_url(platform) {
49
- const base = 'https://github.com/primocms/primo/releases/download';
106
+ // /releases/latest/download/<asset> 302-redirects to the newest release's
107
+ // asset, so we never name a version here — the binary always tracks the
108
+ // latest published release. fetch() follows the redirect automatically.
109
+ const base = `https://github.com/${REPO}/releases/latest/download`;
50
110
  const filename = `primo_${platform.os}_${platform.arch}${platform.ext}`;
51
- return `${base}/v${VERSION}/${filename}`;
111
+ return `${base}/${filename}`;
52
112
  }
53
113
  // Extract a bare semver (e.g. "3.2.1") from a binary's --version output.
54
114
  // The server prints a build banner first, then "<name> version vX.Y.Z", so we
@@ -113,8 +173,8 @@ export async function ensure_binary() {
113
173
  return LOCAL_BINARY;
114
174
  }
115
175
  catch { }
116
- // A managed binary already on disk is reused only when it matches the pinned
117
- // version. A stale binary (older release, or a pre-rename "palacms" build
176
+ // A managed binary already on disk is reused only when it matches the latest
177
+ // release. A stale binary (older release, or a pre-rename "palacms" build
118
178
  // reporting a different version) is re-downloaded so fixes actually reach
119
179
  // users who already have a binary installed.
120
180
  let updating_from = null;
@@ -127,8 +187,17 @@ export async function ensure_binary() {
127
187
  // Need to download - get the target path
128
188
  const platform = get_platform();
129
189
  const binary_path = path.join(BIN_DIR, `primo${platform.ext}`);
190
+ // Resolve the target version for display only (the download URL follows the
191
+ // /latest redirect regardless). Falls back to "latest" when we couldn't
192
+ // confirm the tag; surface a throttle notice so a rate-limited check isn't
193
+ // silent.
194
+ const lookup = await get_latest_version();
195
+ const target_version = lookup.status === 'resolved' ? lookup.version : 'latest';
196
+ if (lookup.status === 'throttled') {
197
+ console.log(chalk.dim(` (couldn't confirm latest version: ${lookup.reason}; downloading current release)`));
198
+ }
130
199
  const spinner = ora(updating_from
131
- ? `Updating primo ${updating_from} → ${VERSION}...`
200
+ ? `Updating primo ${updating_from} → ${target_version}...`
132
201
  : 'Setting up Primo...').start();
133
202
  try {
134
203
  // Create directories
@@ -148,7 +217,7 @@ export async function ensure_binary() {
148
217
  // Make executable, then atomically replace any existing binary.
149
218
  await fs.chmod(tmp_path, 0o755);
150
219
  await fs.rename(tmp_path, binary_path);
151
- spinner.succeed(updating_from ? `Primo updated to ${VERSION}` : 'Primo setup complete');
220
+ spinner.succeed(updating_from ? `Primo updated to ${target_version}` : 'Primo setup complete');
152
221
  return binary_path;
153
222
  }
154
223
  catch (error) {
@@ -184,5 +253,15 @@ export async function get_binary_version() {
184
253
  // developer-chosen and must not be clobbered by a download.
185
254
  async function is_binary_current() {
186
255
  const installed = await get_binary_version();
187
- return installed === VERSION;
256
+ if (!installed)
257
+ return false;
258
+ const latest = await get_latest_version();
259
+ // resolved → compare versions.
260
+ // throttled → can't confirm (rate-limited / offline); keep the installed
261
+ // binary rather than thrashing a re-download, and it'll refresh
262
+ // on the next unthrottled run.
263
+ // unavailable → no usable release to compare against; keep what's on disk.
264
+ if (latest.status !== 'resolved')
265
+ return true;
266
+ return installed === latest.version;
188
267
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primo-cli",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "description": "Local development CLI for Primo",
5
5
  "type": "module",
6
6
  "bin": {