primo-cli 0.1.18 → 0.1.20

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.
@@ -479,18 +479,26 @@ async function wait_for_ready(url) {
479
479
  // Run the equivalent of `primo push` against the local workspace. push_site
480
480
  // resolves the server URL from server.yaml (which we just wrote), so no extra
481
481
  // flags are needed. We surface failures but don't re-throw — the deploy already
482
- // succeeded and the user can rerun push manually.
482
+ // succeeded and the user can rerun push manually. Only a fully clean push
483
+ // counts as success: a partial upload must not be reported as "complete".
483
484
  async function run_auto_push(inventory) {
484
485
  console.log('');
485
486
  console.log(chalk.cyan('Uploading your workspace...'));
486
487
  try {
487
- await push_site({ dir: inventory.root_dir });
488
+ const failed = await push_site({ dir: inventory.root_dir });
489
+ if (failed.length > 0) {
490
+ // push_site already printed the per-item errors, the summary, and
491
+ // set the exit code. The server itself is fine, so finish the
492
+ // deploy flow — just not as "complete".
493
+ return false;
494
+ }
488
495
  return true;
489
496
  }
490
497
  catch (error) {
491
498
  console.log('');
492
499
  console.log(chalk.yellow(`Auto-push failed: ${error instanceof Error ? error.message : error}`));
493
500
  console.log(chalk.dim(' Your server is live — rerun `primo push` once the issue is sorted.'));
501
+ process.exitCode = 1;
494
502
  return false;
495
503
  }
496
504
  }
@@ -131,21 +131,73 @@ function compute_shrink_delta(prior, next) {
131
131
  const delta = next_lines - prior_lines;
132
132
  return delta < 0 ? delta : null;
133
133
  }
134
- // Write the most recent push outcome to a file the MCP build_preview tool
135
- // reads, so the agent learns when its file changes failed to land in the CMS.
136
- // Without this, build_preview compiles whatever stale DB state existed before
137
- // the failed push and reports ok:true, leaving the agent to chase phantom
138
- // rendering bugs instead of fixing the source error.
139
- async function write_sync_status(site_dir, status) {
134
+ // Write the most recent push outcome to a file the MCP build_preview and
135
+ // get_dev_status tools read, so the agent learns when its file changes failed
136
+ // to land in the CMS and exactly which fields were dropped. Without this,
137
+ // build_preview compiles whatever stale DB state existed before the failed
138
+ // push and reports ok:true, leaving the agent to chase phantom rendering bugs
139
+ // instead of fixing the source error.
140
+ async function write_sync_status(site_dir, status, location = {}) {
140
141
  const status_dir = path.join(site_dir, '.primo');
141
142
  try {
142
143
  await fs.mkdir(status_dir, { recursive: true });
143
- await fs.writeFile(path.join(status_dir, 'sync_status.json'), JSON.stringify(status, null, 2));
144
+ // last_import_at stamps every write so a reader can tell a fresh status
145
+ // from a stale one; port/url tell the agent where the server is.
146
+ const payload = {
147
+ ...status,
148
+ ...(location.port !== undefined ? { port: location.port } : {}),
149
+ ...(location.url !== undefined ? { url: location.url } : {}),
150
+ last_import_at: new Date().toISOString()
151
+ };
152
+ // Write atomically: a plain fs.writeFile truncates-then-writes, so the
153
+ // MCP get_dev_status reader can catch a half-written file and briefly
154
+ // report "not running" mid-write. Write to a temp file in the same dir
155
+ // (same filesystem → rename is atomic on POSIX) and rename over the
156
+ // target, so readers always see a complete old or new file.
157
+ const target = path.join(status_dir, 'sync_status.json');
158
+ const tmp = path.join(status_dir, `sync_status.${process.pid}.${randomInt(1e9)}.tmp`);
159
+ try {
160
+ await fs.writeFile(tmp, JSON.stringify(payload, null, 2));
161
+ await fs.rename(tmp, target);
162
+ }
163
+ catch (err) {
164
+ await fs.unlink(tmp).catch(() => { });
165
+ throw err;
166
+ }
144
167
  }
145
168
  catch {
146
169
  // Status reporting must not break the push.
147
170
  }
148
171
  }
172
+ // Persist a successful import's outcome to sync_status.json — with the full
173
+ // per-field warning details when content was dropped, so an agent can read
174
+ // exactly what was lost without re-running dev. Shared by the boot loop and
175
+ // the file watcher so both readers of the file see the same shape.
176
+ async function write_import_sync_status(site_dir, import_timings, location) {
177
+ // The bootstrap+import fallback can return timings even when nothing
178
+ // imported (both attempts failed and were only logged). Never record a
179
+ // phantom success for that — write ok:false so the agent doesn't build a
180
+ // preview from unchanged CMS state.
181
+ if (!import_timings.ok) {
182
+ await write_sync_status(site_dir, {
183
+ ok: false,
184
+ error: 'Import failed — see the primo dev logs for details.',
185
+ failed_at: new Date().toISOString()
186
+ }, location);
187
+ return;
188
+ }
189
+ if (import_timings.warning_count > 0) {
190
+ await write_sync_status(site_dir, {
191
+ ok: true,
192
+ warnings: import_timings.warning_count,
193
+ warned_at: new Date().toISOString(),
194
+ warning_details: import_timings.warning_details
195
+ }, location);
196
+ }
197
+ else {
198
+ await write_sync_status(site_dir, { ok: true }, location);
199
+ }
200
+ }
149
201
  async function prune_old_trash(workspace_dir) {
150
202
  const trash_dir = path.join(workspace_dir, '.primo', 'trash');
151
203
  try {
@@ -607,6 +659,9 @@ export async function dev_server(options) {
607
659
  process.exit(1);
608
660
  }
609
661
  const api_url = `http://127.0.0.1:${port}`;
662
+ // Stamped into every sync_status.json write so an agent reading the
663
+ // file also learns where the running server is.
664
+ const dev_location = { port, url: api_url };
610
665
  if (is_server_mode) {
611
666
  spinner.text = sync_policy.mode === 'cms' ? 'Pulling shared library...' : 'Loading shared library...';
612
667
  is_importing_library = true;
@@ -625,6 +680,7 @@ export async function dev_server(options) {
625
680
  // Normalize and load all sites
626
681
  spinner.text = `Loading ${sites.length} site${sites.length > 1 ? 's' : ''}...`;
627
682
  const blocked_sites = new Set();
683
+ let dropped_field_count = 0;
628
684
  for (const site of sites) {
629
685
  const use_bootstrap = !await site_exists(api_url, site.config.site_id);
630
686
  if (sync_policy.mode === 'cms' && !use_bootstrap) {
@@ -642,7 +698,21 @@ export async function dev_server(options) {
642
698
  blocked_site_keys.add(site_key);
643
699
  continue;
644
700
  }
701
+ // Write sync_status.json on boot too — otherwise an agent reading
702
+ // right after `primo dev` starts sees stale/absent state, and any
703
+ // dropped fields from the initial import stay invisible until the
704
+ // next file save. Writes ok:false when the import didn't land.
705
+ await write_import_sync_status(site.dir, import_timings, dev_location);
706
+ if (!import_timings.ok) {
707
+ // Bootstrap and its regular-import fallback both failed (only
708
+ // logged, not thrown). Keep the site quarantined and skip the
709
+ // baseline/success path so we don't record a phantom import.
710
+ blocked_sites.add(site.dir);
711
+ blocked_site_keys.add(site_key);
712
+ continue;
713
+ }
645
714
  blocked_site_keys.delete(site_key);
715
+ dropped_field_count += import_timings.dropped_field_count;
646
716
  if (update_site_sync_state_after_import(site, import_timings, sync_policy)) {
647
717
  await update_site_sync_baseline(site, api_url, server_config, base_dir);
648
718
  }
@@ -655,6 +725,15 @@ export async function dev_server(options) {
655
725
  await verify_site_ready(api_url, site.config.site_id);
656
726
  }
657
727
  spinner.succeed('Primo running');
728
+ // Restate any dropped-field warnings next to the banner. The per-field
729
+ // detail already printed above during import, but on a multi-site or
730
+ // long boot it scrolls out of view and the green banner reads as
731
+ // "all good" — so surface the aggregate here as the last thing on
732
+ // screen. See print_import_warnings for the full per-field output.
733
+ if (dropped_field_count > 0) {
734
+ console.log('');
735
+ console.log(chalk.yellow(` ⚠ ${dropped_field_count} field${dropped_field_count === 1 ? '' : 's'} dropped — content not imported (see warnings above)`));
736
+ }
658
737
  console.log('');
659
738
  if (mcp_registration_path) {
660
739
  console.log(` ${chalk.dim(`MCP server registered at ${mcp_registration_path} - agents in this directory can now use the Primo MCP server.`)}`);
@@ -834,7 +913,18 @@ export async function dev_server(options) {
834
913
  // Conflict detection must not block the local push.
835
914
  }
836
915
  }
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));
916
+ // Steady-state pushes go through the additive import
917
+ // endpoint (use_bootstrap=false). But a site whose initial
918
+ // bootstrap+fallback both failed is quarantined and was
919
+ // never created on the server — retrying import against a
920
+ // nonexistent site would fail forever. For those, re-check
921
+ // existence so the retry can bootstrap out of quarantine.
922
+ // Healthy sites are never quarantined, so they skip the
923
+ // extra request and keep the fast path.
924
+ const retry_needs_bootstrap = blocked_site_keys.has(get_site_sync_key(site.dir, site.config))
925
+ ? !await site_exists(api_url, site.config.site_id)
926
+ : false;
927
+ const import_timings = await with_site_import_lock(site.dir, site.config, () => import_site_files(site.dir, api_url, site.config, port, server_config, retry_needs_bootstrap, base_dir));
838
928
  if (import_timings === null) {
839
929
  // Duplicate _ids — import_site_files already printed the
840
930
  // error and wrote sync_status; nothing was pushed. Keep
@@ -869,16 +959,7 @@ export async function dev_server(options) {
869
959
  }
870
960
  console.log(chalk.dim(` ${site.config.name}: normalize ${normalize_ms}ms, zip ${import_timings.zip_ms}ms, ${import_timings.mode} ${import_timings.request_ms}ms${reload_ms ? `, reload ${reload_ms}ms` : ''}`));
871
961
  console.log(chalk.green(` ✓ ${site.config.name} pushed`));
872
- if (import_timings.warning_count > 0) {
873
- await write_sync_status(site.dir, {
874
- ok: true,
875
- warnings: import_timings.warning_count,
876
- warned_at: new Date().toISOString()
877
- });
878
- }
879
- else {
880
- await write_sync_status(site.dir, { ok: true });
881
- }
962
+ await write_import_sync_status(site.dir, import_timings, dev_location);
882
963
  }
883
964
  catch (err) {
884
965
  const message = err instanceof Error ? err.message : String(err);
@@ -887,7 +968,7 @@ export async function dev_server(options) {
887
968
  ok: false,
888
969
  error: message,
889
970
  failed_at: new Date().toISOString()
890
- });
971
+ }, dev_location);
891
972
  }
892
973
  finally {
893
974
  is_importing = false;
@@ -990,12 +1071,15 @@ export async function dev_server(options) {
990
1071
  return;
991
1072
  }
992
1073
  const new_sites = await discover_sites(base_dir);
1074
+ let loaded_count = 0;
1075
+ const quarantined = [];
993
1076
  for (const site of new_sites) {
994
1077
  if (known_sites.has(site.dir))
995
1078
  continue;
996
1079
  known_sites.add(site.dir);
997
1080
  sites.push(site);
998
1081
  const use_bootstrap = !await site_exists(api_url, site.config.site_id);
1082
+ let import_ok = true;
999
1083
  if (sync_policy.mode === 'cms' && !use_bootstrap) {
1000
1084
  await sync_from_cms(site.dir, api_url, site.config, server_config, base_dir, sync_policy);
1001
1085
  }
@@ -1006,19 +1090,46 @@ export async function dev_server(options) {
1006
1090
  // Duplicate _ids on a freshly discovered site — quarantine
1007
1091
  // it from CMS→file polling until a later import succeeds.
1008
1092
  blocked_site_keys.add(get_site_sync_key(site.dir, site.config));
1093
+ quarantined.push(site.config.name);
1094
+ import_ok = false;
1009
1095
  }
1010
- else if (update_site_sync_state_after_import(site, import_timings, sync_policy)) {
1011
- await update_site_sync_baseline(site, api_url, server_config, base_dir);
1096
+ else {
1097
+ await write_import_sync_status(site.dir, import_timings, dev_location);
1098
+ if (!import_timings.ok) {
1099
+ // Bootstrap + fallback both failed (only logged). Quarantine
1100
+ // and skip the success path so we don't announce a site that
1101
+ // didn't actually load — and so `primo new` sees it in the
1102
+ // /reload body's `quarantined` list.
1103
+ blocked_site_keys.add(get_site_sync_key(site.dir, site.config));
1104
+ quarantined.push(site.config.name);
1105
+ import_ok = false;
1106
+ }
1107
+ else if (update_site_sync_state_after_import(site, import_timings, sync_policy)) {
1108
+ await update_site_sync_baseline(site, api_url, server_config, base_dir);
1109
+ }
1012
1110
  }
1013
1111
  }
1014
1112
  setup_site_watchers(site);
1113
+ loaded_count++;
1114
+ if (!import_ok) {
1115
+ console.log(chalk.yellow(` ⚠ ${site.config.name}: import failed — see logs; will retry on the next file change.`));
1116
+ continue;
1117
+ }
1015
1118
  const host = local_dev_host(site.config.name || path.basename(site.dir), port);
1016
1119
  console.log(chalk.green(` ✓ New site loaded: ${site.config.name}`));
1017
1120
  console.log(` ${chalk.dim('Edit:')} http://${host}/admin/site`);
1018
1121
  console.log(` ${chalk.dim('Preview:')} http://${host}/`);
1019
1122
  }
1020
- res.writeHead(200);
1021
- res.end('ok');
1123
+ // Report the outcome so `primo new` can tell whether the site it
1124
+ // just scaffolded actually imported. A quarantined site (duplicate
1125
+ // _ids) returns 200 with loaded:false so a 2xx no longer implies
1126
+ // success — the caller checks the body, not just the status.
1127
+ const body = JSON.stringify({
1128
+ loaded: loaded_count,
1129
+ quarantined
1130
+ });
1131
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1132
+ res.end(body);
1022
1133
  });
1023
1134
  reload_server.on('error', (err) => {
1024
1135
  if (err.code === 'EADDRINUSE') {
@@ -1868,6 +1979,37 @@ async function prepare_site_for_local_dev(site_dir) {
1868
1979
  warnings
1869
1980
  };
1870
1981
  }
1982
+ // Warning kinds where page content was matched to no field and therefore
1983
+ // dropped from the render model. Counted separately from other import
1984
+ // warnings (unknown blocks, orphan uploads, duplicate ids) so the
1985
+ // end-of-boot summary can report "N fields dropped" accurately.
1986
+ const DROPPED_FIELD_KINDS = new Set(['orphaned_field', 'orphaned_page_field']);
1987
+ // Number of dropped-field warnings in an import response. Safe on any input
1988
+ // shape (returns 0 for non-arrays / missing warnings).
1989
+ function count_dropped_fields(warnings) {
1990
+ if (!Array.isArray(warnings))
1991
+ return 0;
1992
+ return warnings.filter(w => DROPPED_FIELD_KINDS.has(w.kind)).length;
1993
+ }
1994
+ // Coerce a raw import-response `warnings` value into a clean ImportWarning[]
1995
+ // for persistence in sync_status.json. Defensive against partial/unknown
1996
+ // server shapes: drops non-object entries and fills missing string fields so
1997
+ // the agent-facing file always has a predictable schema. Returns [] for
1998
+ // non-arrays / missing warnings.
1999
+ function normalize_warning_details(warnings) {
2000
+ if (!Array.isArray(warnings))
2001
+ return [];
2002
+ return warnings
2003
+ .filter((w) => typeof w === 'object' && w !== null)
2004
+ .map(w => ({
2005
+ kind: typeof w.kind === 'string' ? w.kind : '',
2006
+ file: typeof w.file === 'string' ? w.file : '',
2007
+ path: typeof w.path === 'string' ? w.path : '',
2008
+ field: typeof w.field === 'string' ? w.field : '',
2009
+ block: typeof w.block === 'string' ? w.block : '',
2010
+ message: typeof w.message === 'string' ? w.message : ''
2011
+ }));
2012
+ }
1871
2013
  // Loudly surface non-fatal import problems (e.g. orphaned fields whose
1872
2014
  // content would otherwise be silently dropped). Printed in yellow with the
1873
2015
  // full details so agents and humans both see exactly what was lost and where.
@@ -1902,7 +2044,7 @@ async function import_site_files(site_dir, api_url, config, port, server_config,
1902
2044
  ok: false,
1903
2045
  error: message,
1904
2046
  failed_at: new Date().toISOString()
1905
- });
2047
+ }, { port, url: api_url });
1906
2048
  try {
1907
2049
  // Best-effort — older servers don't have this endpoint.
1908
2050
  await fetch(`${api_url}/api/primo/dev/status`, {
@@ -1939,12 +2081,16 @@ async function import_site_files(site_dir, api_url, config, port, server_config,
1939
2081
  }
1940
2082
  // Write created IDs back to files
1941
2083
  let warning_count = 0;
2084
+ let dropped_field_count = 0;
2085
+ let warning_details = [];
1942
2086
  try {
1943
2087
  const result = await import_response.json();
1944
2088
  if (result.created_ids) {
1945
2089
  await write_created_ids(site_dir, result.created_ids, server_config, workspace_dir);
1946
2090
  }
1947
2091
  warning_count = print_import_warnings(config.name, result.warnings);
2092
+ dropped_field_count = count_dropped_fields(result.warnings);
2093
+ warning_details = normalize_warning_details(result.warnings);
1948
2094
  }
1949
2095
  catch {
1950
2096
  // ignore JSON parse errors
@@ -1953,7 +2099,10 @@ async function import_site_files(site_dir, api_url, config, port, server_config,
1953
2099
  zip_ms,
1954
2100
  request_ms,
1955
2101
  mode: 'import',
1956
- warning_count
2102
+ ok: true,
2103
+ warning_count,
2104
+ dropped_field_count,
2105
+ warning_details
1957
2106
  };
1958
2107
  }
1959
2108
  // Retry bootstrap up to 3 times (collections may not be ready immediately)
@@ -1976,6 +2125,8 @@ async function import_site_files(site_dir, api_url, config, port, server_config,
1976
2125
  }, 300000); // 300s timeout for imports
1977
2126
  const bootstrap_ms = Date.now() - bootstrap_started;
1978
2127
  let warning_count = 0;
2128
+ let dropped_field_count = 0;
2129
+ let warning_details = [];
1979
2130
  if (bootstrap_response.ok) {
1980
2131
  try {
1981
2132
  const result = await bootstrap_response.json();
@@ -1988,6 +2139,8 @@ async function import_site_files(site_dir, api_url, config, port, server_config,
1988
2139
  await write_created_ids(site_dir, result.created_ids, server_config, workspace_dir);
1989
2140
  }
1990
2141
  warning_count = print_import_warnings(config.name, result.warnings);
2142
+ dropped_field_count = count_dropped_fields(result.warnings);
2143
+ warning_details = normalize_warning_details(result.warnings);
1991
2144
  }
1992
2145
  catch {
1993
2146
  // ignore JSON parse errors
@@ -1996,7 +2149,10 @@ async function import_site_files(site_dir, api_url, config, port, server_config,
1996
2149
  zip_ms,
1997
2150
  request_ms: bootstrap_ms,
1998
2151
  mode: 'bootstrap',
1999
- warning_count
2152
+ ok: true,
2153
+ warning_count,
2154
+ dropped_field_count,
2155
+ warning_details
2000
2156
  };
2001
2157
  }
2002
2158
  const error_text = await bootstrap_response.text();
@@ -2027,6 +2183,8 @@ async function import_site_files(site_dir, api_url, config, port, server_config,
2027
2183
  await write_created_ids(site_dir, result.created_ids, server_config, workspace_dir);
2028
2184
  }
2029
2185
  warning_count = print_import_warnings(config.name, result.warnings);
2186
+ dropped_field_count = count_dropped_fields(result.warnings);
2187
+ warning_details = normalize_warning_details(result.warnings);
2030
2188
  }
2031
2189
  catch {
2032
2190
  // ignore JSON parse errors
@@ -2036,7 +2194,14 @@ async function import_site_files(site_dir, api_url, config, port, server_config,
2036
2194
  zip_ms,
2037
2195
  request_ms: bootstrap_ms + import_ms,
2038
2196
  mode: 'bootstrap+import',
2039
- warning_count
2197
+ // Bootstrap failed and we fell back to a regular import; ok
2198
+ // reflects whether that fallback actually landed. When both
2199
+ // failed we still return timings (only logged above), so this
2200
+ // guards against recording a phantom success.
2201
+ ok: import_response.ok,
2202
+ warning_count,
2203
+ dropped_field_count,
2204
+ warning_details
2040
2205
  };
2041
2206
  }
2042
2207
  catch (err) {
@@ -50,16 +50,20 @@ export async function new_site(options) {
50
50
  name: 'name',
51
51
  message: 'Site name:',
52
52
  default: 'my-site',
53
- validate: (input) => {
54
- if (!input.trim())
55
- return 'Name is required';
56
- if (!/^[a-z0-9.-]+$/i.test(input))
57
- return 'Use only letters, numbers, dots, and hyphens';
58
- return true;
59
- }
53
+ validate: validate_site_name
60
54
  }]);
61
55
  site_name = name;
62
56
  }
57
+ else {
58
+ // A name passed as a CLI arg skips the prompt — validate it too, or a
59
+ // name like `.foo` slips through: it makes a hidden sites/.foo dir that
60
+ // discover_sites ignores, and an empty display_name downstream.
61
+ const error = validate_site_name(site_name);
62
+ if (error !== true) {
63
+ console.log(chalk.red(error));
64
+ process.exit(1);
65
+ }
66
+ }
63
67
  // Always create site in sites/<name>
64
68
  const site_dir = path.join(sites_dir, site_name);
65
69
  // Check if directory exists
@@ -250,20 +254,67 @@ sections:
250
254
  await fs.writeFile(workspace_agents_path, generate_agent_md());
251
255
  }
252
256
  spinner.succeed(`Site created: ${chalk.cyan(site_dir)}`);
253
- // Check if server is already running
254
- const port = 3000;
257
+ // Check if server is already running. Read the workspace's configured
258
+ // port (mirroring `primo dev`, which uses server_config.port) rather than
259
+ // assuming 3000 — otherwise on a custom-port workspace we'd probe the
260
+ // wrong port, miss the running server, and print links to a dead port.
261
+ const port = server_config.port ?? 3000;
255
262
  const server_running = await is_server_running(port);
256
263
  if (server_running) {
257
- // Tell the server to reload and pick up the new site
264
+ // A `primo dev` is already running. Ask it to reload and pick up the
265
+ // new site. The dev server prints its own "New site loaded" + links,
266
+ // but in *its* terminal — so print the same links here too, otherwise
267
+ // this terminal looks like nothing happened.
268
+ // Host is derived from the display name (what becomes config.name),
269
+ // matching how dev_server builds the host — not the raw folder name.
270
+ const host = local_dev_host(display_name, port);
271
+ let outcome = 'unreachable';
258
272
  try {
259
- await fetch(`http://127.0.0.1:${port + 1}/reload`, { method: 'POST' });
273
+ // Bound the request: the reload handler runs discovery + import
274
+ // synchronously before responding, so an unbounded fetch could
275
+ // hang here forever and never reach the warning below.
276
+ const controller = new AbortController();
277
+ const timeout = setTimeout(() => controller.abort(), 30000);
278
+ let res;
279
+ try {
280
+ res = await fetch(`http://127.0.0.1:${port + 1}/reload`, {
281
+ method: 'POST',
282
+ signal: controller.signal
283
+ });
284
+ }
285
+ finally {
286
+ clearTimeout(timeout);
287
+ }
288
+ if (res.ok) {
289
+ // A 2xx no longer implies the site imported — the handler
290
+ // returns { quarantined: [...] } for sites it couldn't load
291
+ // (duplicate _ids). Parse the body to tell the difference.
292
+ const result = await res.json().catch(() => null);
293
+ outcome = result?.quarantined?.includes(display_name)
294
+ ? 'quarantined'
295
+ : 'reloaded';
296
+ }
260
297
  }
261
298
  catch {
262
- // Reload server might not be running (older version)
299
+ // Reload server not running (older `primo dev`, hot reload disabled
300
+ // because the port was in use) or the request timed out. Site files
301
+ // are on disk; restarting `primo dev` will pick them up.
263
302
  }
264
303
  console.log('');
265
- console.log(chalk.dim(` http://${site_name}.localhost:${port}/`));
304
+ console.log(` ${chalk.cyan(display_name)}`);
305
+ console.log(` ${chalk.dim('Edit:')} http://${host}/admin/site`);
306
+ console.log(` ${chalk.dim('Preview:')} http://${host}/`);
266
307
  console.log('');
308
+ if (outcome === 'quarantined') {
309
+ console.log(chalk.yellow(' Site created, but the dev server couldn\'t import it (duplicate IDs).'));
310
+ console.log(chalk.dim(' Check the `primo dev` logs and fix the conflict.'));
311
+ console.log('');
312
+ }
313
+ else if (outcome === 'unreachable') {
314
+ console.log(chalk.yellow(' Couldn\'t reach the running dev server to reload it.'));
315
+ console.log(chalk.dim(' Restart `primo dev` to pick up the new site.'));
316
+ console.log('');
317
+ }
267
318
  }
268
319
  else if (!options.skipDev) {
269
320
  // No server running, start one
@@ -282,6 +333,26 @@ sections:
282
333
  process.exit(1);
283
334
  }
284
335
  }
336
+ // Shared name validation for both the interactive prompt and the CLI arg.
337
+ // Returns `true` when valid, or an error string (inquirer's contract).
338
+ // Leading dots/hyphens are rejected: a leading dot makes a hidden sites/.<name>
339
+ // directory that discover_sites skips, and strips display_name to empty.
340
+ function validate_site_name(input) {
341
+ if (!input.trim())
342
+ return 'Name is required';
343
+ if (!/^[a-z0-9.-]+$/i.test(input))
344
+ return 'Use only letters, numbers, dots, and hyphens';
345
+ if (/^[.-]/.test(input))
346
+ return 'Name can\'t start with a dot or hyphen';
347
+ return true;
348
+ }
349
+ // Mirror of dev.ts's local_dev_host: slug the display name into a
350
+ // `<slug>.localhost:<port>` host so the links printed here match the ones
351
+ // `primo dev` prints for the same site.
352
+ function local_dev_host(name, port) {
353
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'site';
354
+ return `${slug}.localhost:${port}`;
355
+ }
285
356
  function generate_id() {
286
357
  const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
287
358
  let id = '';
@@ -7,5 +7,5 @@ interface PushOptions {
7
7
  preview?: boolean;
8
8
  dryRun?: boolean;
9
9
  }
10
- export declare function push_site(options: PushOptions): Promise<void>;
10
+ export declare function push_site(options: PushOptions): Promise<string[]>;
11
11
  export {};
@@ -39,6 +39,8 @@ async function resolve_group_name(site_dir, group_id) {
39
39
  }
40
40
  return undefined;
41
41
  }
42
+ // Returns the labels (site slugs / 'library') that failed to push so callers
43
+ // like `primo deploy` can tell a clean run from a partial one. Empty = success.
42
44
  export async function push_site(options) {
43
45
  const root_dir = path.resolve(options.dir);
44
46
  const has_site_yaml = await path_exists(get_site_config_path(root_dir));
@@ -61,17 +63,27 @@ export async function push_site(options) {
61
63
  : options;
62
64
  if (options.dryRun) {
63
65
  await print_push_dry_run(root_dir, has_site_yaml, has_server_yaml, effective_options);
64
- return;
66
+ return [];
65
67
  }
66
68
  // Server-folder mode: walk site subfolders + push library
67
69
  if (!has_site_yaml && has_server_yaml) {
68
- await push_server(root_dir, effective_options);
69
- return;
70
+ const failed = await push_server(root_dir, effective_options);
71
+ if (failed.length > 0) {
72
+ console.log('');
73
+ console.log(chalk.red(`Push incomplete — failed: ${failed.join(', ')}`));
74
+ console.log(chalk.dim(' Fix the errors above and rerun `primo push` (completed pushes are safe to repeat).'));
75
+ console.log('');
76
+ // exitCode (not process.exit) so an in-process caller like `primo
77
+ // deploy` can still finish its own reporting before the process ends.
78
+ process.exitCode = 1;
79
+ }
80
+ return failed;
70
81
  }
71
82
  // Single-site mode (cwd is a site folder, or --dir points at one)
72
83
  const spinner = ora('Reading local files...').start();
73
84
  try {
74
85
  await push_single_site(root_dir, effective_options, spinner);
86
+ return [];
75
87
  }
76
88
  catch (error) {
77
89
  spinner.fail(`Push failed: ${error instanceof Error ? error.message : error}`);
@@ -166,6 +178,9 @@ async function print_push_dry_run(root_dir, has_site_yaml, has_server_yaml, opti
166
178
  console.log(chalk.dim(' No requests sent. Run without --dry-run to push.'));
167
179
  console.log('');
168
180
  }
181
+ // Pushes every site folder plus the library, continuing past individual
182
+ // failures. Returns the labels that failed — the caller decides how loudly a
183
+ // partial push should fail.
169
184
  async function push_server(root_dir, options) {
170
185
  // Sites live under sites/<slug>/
171
186
  const sites_root = path.join(root_dir, 'sites');
@@ -204,9 +219,10 @@ async function push_server(root_dir, options) {
204
219
  print_auth_hint();
205
220
  process.exit(1);
206
221
  }
207
- return;
222
+ return [];
208
223
  }
209
224
  let saw_auth_error = false;
225
+ const failed = [];
210
226
  // Push each site
211
227
  for (const site_dir of site_dirs) {
212
228
  const spinner = ora(`Pushing ${chalk.cyan(path.basename(site_dir))}...`).start();
@@ -217,6 +233,7 @@ async function push_server(root_dir, options) {
217
233
  spinner.fail(`${path.basename(site_dir)}: ${error instanceof Error ? error.message : error}`);
218
234
  if (is_auth_error(error))
219
235
  saw_auth_error = true;
236
+ failed.push(path.basename(site_dir));
220
237
  // Continue to remaining sites rather than abort the whole push
221
238
  }
222
239
  }
@@ -231,10 +248,12 @@ async function push_server(root_dir, options) {
231
248
  spinner.fail(`library: ${error instanceof Error ? error.message : error}`);
232
249
  if (is_auth_error(error))
233
250
  saw_auth_error = true;
251
+ failed.push('library');
234
252
  }
235
253
  }
236
254
  if (saw_auth_error)
237
255
  print_auth_hint();
256
+ return failed;
238
257
  }
239
258
  async function push_single_site(site_dir, options, spinner) {
240
259
  let config = null;
package/dist/index.js CHANGED
@@ -95,7 +95,7 @@ ${chalk.bold('See also')}
95
95
  primo deploy Stand up a new hosted Primo server
96
96
  primo login Authenticate with a hosted Primo server
97
97
  `)
98
- .action((server, options) => push_site({ ...options, server: server || options.server }));
98
+ .action(async (server, options) => { await push_site({ ...options, server: server || options.server }); });
99
99
  program
100
100
  .command('pull [server] [dir]')
101
101
  .description('Pull entire server (all sites + library) to local files (defaults to ./<server-hostname>)')
@@ -169,4 +169,13 @@ program.on('command:*', (operands) => {
169
169
  console.error('');
170
170
  process.exit(1);
171
171
  });
172
- program.parse();
172
+ // parseAsync so async action handlers are awaited inside commander's
173
+ // lifecycle — with plain parse() a rejected handler becomes an unhandled
174
+ // rejection (ugly stack, engine-dependent exit) instead of the clean
175
+ // message + exit(1) below.
176
+ program.parseAsync().catch((error) => {
177
+ console.error('');
178
+ console.error(chalk.red(error instanceof Error ? error.message : String(error)));
179
+ console.error('');
180
+ process.exit(1);
181
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primo-cli",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "description": "Local development CLI for Primo",
5
5
  "type": "module",
6
6
  "bin": {