primo-cli 0.1.18 → 0.1.19
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/dev.js +192 -27
- package/dist/commands/new.js +84 -13
- package/package.json +1 -1
package/dist/commands/dev.js
CHANGED
|
@@ -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
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1011
|
-
await
|
|
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
|
-
|
|
1021
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/commands/new.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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(
|
|
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 = '';
|