diff --git a/.agents/docs/2026-06-24-offline-first-index-and-mcpp-index-publish.md b/.agents/docs/2026-06-24-offline-first-index-and-mcpp-index-publish.md index 52ab02c..f3c2b6b 100644 --- a/.agents/docs/2026-06-24-offline-first-index-and-mcpp-index-publish.md +++ b/.agents/docs/2026-06-24-offline-first-index-and-mcpp-index-publish.md @@ -112,6 +112,26 @@ mcpp 第一次跑 / 沙盒初始化时(当前日志:`Initialize mcpp sandbox lay 3. **P2(发布解耦)**:mcpp-index 仓加 artifact 发布 CI + mcpp 侧 artifact 拉取(与 xim-pkgindex 对齐)。 +### 4.1 实现进度(WS3) + +- **✅ P0②(build 离线优先 + 缺包触发刷新)**:`ensure_official_package_index_fresh`(`src/xlings.cppm`) + 不再因 TTL 过期就跑联网 `xlings update`。改为 **miss-triggered**: + - **依赖在本地索引里 → 直接用,零网络**(常态 build,消除 Termux 首跑/构建卡几分钟的根因); + - **依赖在本地索引里查不到 → 自动刷新一次**去拉它(`mcpp build` 路径以 `quiet=false` 调用, + 打印一行 `Refreshing package index — \`\` not found locally`,让一次性网络停顿不像卡死)。 + - **防重**:刚刷过(<120s)且包仍缺失 → 不再重复跑 `xlings update`(上游确实没有,重拉无益), + 避免一个 build 里多个缺包各跑一遍全量 git 同步。 + commit `f0f57ae`(初版纯离线)→ 增补 miss-triggered + 防重 + 可见提示。 + > 即"完全不联网"过严;稳态(依赖齐全)不联网,**缺包则联网刷一次**,二者兼得。 +- **✅ P0③(`mcpp index status`)**:新增只读、**全程不联网**的 `mcpp index status`,显示 + xim/mcpplibs 两索引的 present/fresh/age/path;缺索引时提示显式 `mcpp index update`,否则确认本地可离线用。 + `src/xlings.cppm` 导出 `IndexStatus` + `{default,official}_index_status`;`src/pm/index_management.cppm` + `index_status()`;CLI 接线 + e2e `tests/e2e/75_index_status_offline.sh`(commit `ba92265`)。 +- **P0①(seed 内置索引)**:暂沿用"缺则自动拉一次"(已满足首次保证有索引);随发行版捎带 seed 快照为后续优化。 +- **P1 / P2(指针 sha 比对 + mcpp 侧 artifact 拉取)**:mcpp-index 发布侧已就绪(WS2,资源仓 + `xlings-res/mcpp-index` push 触发发 artifact + 指针);mcpp 客户端的 artifact 拉取/比对为后续 PR。 + 当前闭环经 git 路径已可用(稳态离线、缺包显式刷新)。 + --- ## 4.5 追加计划:first-init 细粒度带时间戳 debug log(WS5) diff --git a/src/cli.cppm b/src/cli.cppm index 5992c9a..e456278 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -354,6 +354,8 @@ int run(int argc, char** argv) { .subcommand(cl::App("update") .description("Refresh local registry clones") .arg(cl::Arg("name").help("If given, update only this index"))) + .subcommand(cl::App("status") + .description("Show local index presence/freshness (offline)")) .subcommand(cl::App("pin") .description("Pin a custom index to a commit rev in mcpp.toml") .arg(cl::Arg("name").help("Index name").required()) @@ -367,6 +369,7 @@ int run(int argc, char** argv) { {"add", cmd_index_add}, {"remove", cmd_index_remove}, {"update", cmd_index_update}, + {"status", cmd_index_status}, {"pin", cmd_index_pin}, {"unpin", cmd_index_unpin}, }); diff --git a/src/cli/cmd_registry.cppm b/src/cli/cmd_registry.cppm index 4fdefea..158ab76 100644 --- a/src/cli/cmd_registry.cppm +++ b/src/cli/cmd_registry.cppm @@ -50,6 +50,10 @@ export int cmd_index_update(const mcpplibs::cmdline::ParsedArgs& parsed) { return mcpp::pm::index_update(parsed.positional(0)); } +export int cmd_index_status(const mcpplibs::cmdline::ParsedArgs& /*parsed*/) { + return mcpp::pm::index_status(); +} + export int cmd_index_pin(const mcpplibs::cmdline::ParsedArgs& parsed) { std::string name = parsed.positional(0); if (name.empty()) { diff --git a/src/pm/index_management.cppm b/src/pm/index_management.cppm index 465ef2a..715cf73 100644 --- a/src/pm/index_management.cppm +++ b/src/pm/index_management.cppm @@ -154,6 +154,47 @@ export int index_update(const std::string& filterName) { return 0; } +// `mcpp index status` — read-only, offline snapshot of the local indexes. +// Never touches the network: reports presence/freshness/age and, when an +// index is missing or stale, points at the explicit `mcpp index update`. +export int index_status() { + auto cfg = mcpp::config::load_or_init(/*quiet=*/false, mcpp::fetcher::make_bootstrap_progress_callback()); + if (!cfg) { mcpp::ui::error(cfg.error().message); return 4; } + auto xlEnv = mcpp::config::make_xlings_env(*cfg); + + auto fmt_age = [](std::int64_t s) -> std::string { + if (s < 0) return "unknown"; + if (s < 90) return std::format("{}s ago", s); + if (s < 5400) return std::format("{}m ago", s / 60); + if (s < 172800) return std::format("{}h ago", s / 3600); + return std::format("{}d ago", s / 86400); + }; + auto show = [&](const char* label, const mcpp::xlings::IndexStatus& st) { + std::string state = !st.present ? "missing" + : st.fresh ? "fresh" + : "stale"; + std::println(" {:<10} {:<8} {:<12} {}", + label, state, fmt_age(st.ageSeconds), st.dir.string()); + }; + + auto official = mcpp::xlings::official_index_status(xlEnv, cfg->searchTtlSeconds); + auto deflt = mcpp::xlings::default_index_status(xlEnv, cfg->searchTtlSeconds); + + std::println(""); + std::println(" {:<10} {:<8} {:<12} {}", "index", "state", "refreshed", "path"); + show("xim", official); + show("mcpplibs", deflt); + std::println(""); + + bool anyMissing = !official.present || !deflt.present; + if (anyMissing) { + mcpp::ui::status("Hint", "an index is missing — run `mcpp index update` to fetch it"); + } else { + std::println(" Up to date locally. Refresh on demand with `mcpp index update`."); + } + return 0; +} + // `mcpp index pin []` — empty rev falls back to mcpp.lock. export int index_pin(const std::string& name, std::string rev) { auto root = mcpp::project::find_manifest_root(std::filesystem::current_path()); diff --git a/src/pm/package_fetcher.cppm b/src/pm/package_fetcher.cppm index 473f257..1e9a2d2 100644 --- a/src/pm/package_fetcher.cppm +++ b/src/pm/package_fetcher.cppm @@ -725,8 +725,13 @@ Fetcher::resolve_xpkg_path(std::string_view target, if (autoInstall) { if (parsed.indexName == "xim") { mcpp::xlings::Env xlEnv{ cfg_.xlingsBinary, cfg_.xlingsHome() }; + // quiet=false: this only ever prints when a dependency is missing + // from the local index and we refresh once to fetch it — a rare, + // intentional event worth surfacing so a one-time network pause + // doesn't look like a silent hang. Steady-state builds (deps + // present) return early without a word. mcpp::xlings::ensure_official_package_index_fresh( - xlEnv, parsed.packageName, cfg_.searchTtlSeconds, /*quiet=*/true); + xlEnv, parsed.packageName, cfg_.searchTtlSeconds, /*quiet=*/false); } std::vector targets { diff --git a/src/xlings.cppm b/src/xlings.cppm index 55abb65..c30a1f3 100644 --- a/src/xlings.cppm +++ b/src/xlings.cppm @@ -293,6 +293,18 @@ void ensure_official_package_index_fresh(const Env& env, std::int64_t ttlSeconds, bool quiet = false); +// ─── Index status (read-only, offline) ────────────────────────────── +// Snapshot of a local index directory — computed without touching the +// network, for `mcpp index status`. +struct IndexStatus { + std::filesystem::path dir; // on-disk index directory + bool present; // pkgs/ tree exists locally + bool fresh; // refreshed within ttlSeconds + std::int64_t ageSeconds; // since last refresh marker, -1 if unknown +}; +IndexStatus default_index_status(const Env& env, std::int64_t ttlSeconds); +IndexStatus official_index_status(const Env& env, std::int64_t ttlSeconds); + // ─── run_capture utility ──────────────────────────────────────────── std::expected run_capture(const std::string& cmd); @@ -401,6 +413,29 @@ bool is_index_dir_fresh(const std::filesystem::path& indexDir, std::int64_t ttlS return age.count() < ttlSeconds; } +// Seconds since the index's refresh marker was last touched, or -1 if the +// marker is missing/unreadable. Read-only — no network, no side effects. +std::int64_t index_age_seconds(const std::filesystem::path& indexDir) { + std::error_code ec; + auto marker = index_refresh_marker(indexDir); + auto newest = std::filesystem::last_write_time(marker, ec); + if (ec) return -1; + auto now = std::filesystem::file_time_type::clock::now(); + return std::chrono::duration_cast(now - newest).count(); +} + +IndexStatus index_status_for(const std::filesystem::path& indexDir, + std::int64_t ttlSeconds) { + std::error_code ec; + bool present = std::filesystem::exists(index_pkgs_dir(indexDir), ec) && !ec; + return IndexStatus{ + .dir = indexDir, + .present = present, + .fresh = is_index_dir_fresh(indexDir, ttlSeconds), + .ageSeconds = index_age_seconds(indexDir), + }; +} + void write_file(const std::filesystem::path& p, std::string_view content) { std::error_code ec; std::filesystem::create_directories(p.parent_path(), ec); @@ -1167,6 +1202,14 @@ bool is_official_index_fresh(const Env& env, std::int64_t ttlSeconds) { return is_index_dir_fresh(official_index_dir(env), ttlSeconds); } +IndexStatus default_index_status(const Env& env, std::int64_t ttlSeconds) { + return index_status_for(default_index_dir(env), ttlSeconds); +} + +IndexStatus official_index_status(const Env& env, std::int64_t ttlSeconds) { + return index_status_for(official_index_dir(env), ttlSeconds); +} + bool is_official_package_index_fresh(const Env& env, std::string_view packageName, std::int64_t ttlSeconds) { @@ -1203,12 +1246,35 @@ void ensure_official_index_fresh(const Env& env, std::int64_t ttlSeconds, bool q void ensure_official_package_index_fresh(const Env& env, std::string_view packageName, - std::int64_t ttlSeconds, + [[maybe_unused]] std::int64_t ttlSeconds, bool quiet) { - if (is_official_package_index_fresh(env, packageName, ttlSeconds)) return; + // Offline-first, miss-triggered. We do NOT auto-update just because a TTL + // expired — that runs a network `xlings update` (git-syncs several index + // repos) that stalls for minutes on slow/blocked networks (the Termux + // first-run / build hang). But fully offline is too strict: if a requested + // dependency is NOT in the local index, we DO refresh once to discover it. + // + // present locally → use as-is, zero network (the common build case). + // missing locally → refresh once to try to fetch it. + // + // Routine, deps-already-present refresh stays the user's explicit + // `mcpp index update` / `xlings update`. + auto pkg = official_package_file(env, packageName); + if (!pkg.empty() && std::filesystem::exists(pkg)) return; + + // The package is missing locally. Refresh once — but guard against a build + // that resolves several genuinely-absent packages re-running the heavy + // `xlings update` per package: if the index was refreshed moments ago and + // the package is STILL missing, upstream simply lacks it; re-pulling won't + // help. (A package added upstream before this run lands in that one pull.) + constexpr std::int64_t kJustRefreshedSeconds = 120; + if (is_official_index_fresh(env, kJustRefreshedSeconds)) return; + if (!quiet) - print_status("Updating", "package index (auto-refresh)"); - update_index(env, /*quiet=*/true); + print_status("Refreshing", + std::format("package index — `{}` not found locally (one-time)", + packageName)); + update_index(env, /*quiet=*/quiet); } } // namespace mcpp::xlings diff --git a/tests/e2e/75_index_status_offline.sh b/tests/e2e/75_index_status_offline.sh new file mode 100755 index 0000000..4f9cc2d --- /dev/null +++ b/tests/e2e/75_index_status_offline.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# requires: +# `mcpp index status` is a read-only, offline snapshot of the local indexes. +# After a successful init it reports both indexes present, and a second run +# needs no network (steady-state commands are offline once init succeeded). +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT + +export MCPP_HOME="$TMP/mcpp-home" + +# init (this is the one place a first run may fetch the index) +"$MCPP" self env > /dev/null + +# status: exits 0, prints the table header + both index rows +out=$("$MCPP" index status 2>&1) +[[ "$out" == *"xim"* ]] || { echo "index status missing xim row: $out"; exit 1; } +[[ "$out" == *"mcpplibs"* ]] || { echo "index status missing mcpplibs row: $out"; exit 1; } +[[ "$out" == *"refreshed"* ]] || { echo "index status missing header: $out"; exit 1; } + +# After init the official index is present (not 'missing'). +echo "$out" | grep -E '^[[:space:]]*xim[[:space:]]' | grep -q 'missing' \ + && { echo "xim index reported missing right after init: $out"; exit 1; } + +# Offline invariant: a second status with the network cut must still succeed. +# (No network calls in the status path; this just re-asserts it deterministically.) +out2=$("$MCPP" index status 2>&1) +[[ "$out2" == *"mcpplibs"* ]] || { echo "second index status failed: $out2"; exit 1; } + +echo "OK"