feat(cli): pick the app of the project the launcher belongs to

Discovery is per user, so every project's launcher sees every debug app
running, and with two of them up every command refused to guess and
asked for -i, though the agent in one project meant its own app. Two
copies of the example, both named basic, could only be told apart by
id.

The launcher now exports CRAFTER_DEBUG_PROJECT, its project's directory
(flatpak-spawn gets it spelled out), and the client counts an app as
the project's when its executable lies under it, as `-r` runs it from
<project>/bin/<variant>/. With other projects' apps running, commands
pick this project's; two copies of it still need -i. Another project's
app is never picked by default: with this project's app not up yet, or
crashed, the agent would otherwise drive, or quit, someone else's. It
is listed instead, for -i.

`ls` lists this project's apps first, marked, `--json` says which are,
and `--wait` waits for one of them rather than any app; the exit code
says whether one runs. A client started without the launcher has no
project and picks the one app running, as before, and the command the
protocol check prints for an older app's own client now names it with
-i.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-11-01 18:33:59 +01:00
commit 2217037d72
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
5 changed files with 126 additions and 16 deletions

View file

@ -230,7 +230,13 @@ namespace {
class Client {
public:
Client(Environment& environment, Options options) : env(environment), opts(std::move(options)) {}
Client(Environment& environment, Options options) : env(environment), opts(std::move(options)) {
if (!env.project.empty()) {
std::error_code ec;
project = fs::weakly_canonical(env.project, ec);
if (ec) project = env.project.lexically_normal();
}
}
std::int32_t Execute() {
const std::string& c = opts.command;
@ -265,6 +271,8 @@ namespace {
private:
Environment& env;
Options opts;
// env.project, resolved like the executables it is compared with.
fs::path project;
std::optional<Instance> instance;
std::unique_ptr<Connection> connection;
@ -278,13 +286,42 @@ namespace {
// ---- choosing the instance ----
// Whether `record` is an app of this client's project: crafter-build
// runs it from <project>/bin/<variant>/, so its executable lies under
// the project's directory.
bool Ours(const InstanceRecord& record) const {
if (project.empty() || record.exe.empty()) return false;
std::error_code ec;
fs::path exe = fs::weakly_canonical(fs::path(record.exe), ec);
if (ec) exe = fs::path(record.exe).lexically_normal();
const fs::path relative = exe.lexically_relative(project);
return !relative.empty() && *relative.begin() != "..";
}
std::int32_t Select() {
std::vector<Instance> live = env.list();
if (!opts.instance.empty()) {
const bool named = !opts.instance.empty();
if (named) {
std::erase_if(live, [&](const Instance& candidate) {
return candidate.record.name != opts.instance && !candidate.record.instanceId.starts_with(opts.instance);
});
}
// This project's apps, when it runs any: an agent working here
// means one of its own. Another project's app only through -i,
// never by default: with this project's app not up yet, or
// crashed, the agent would otherwise drive, or quit, someone
// else's.
std::vector<Instance> ours;
std::ranges::copy_if(live, std::back_inserter(ours), [&](const Instance& candidate) { return Ours(candidate.record); });
if (!ours.empty()) {
live = std::move(ours);
} else if (!named && !project.empty() && !live.empty()) {
Err() << std::format("crafter-debug: nothing from this project ({}) is running. Start its app in the background (e.g. crafter-build --debug-api -r), "
"or pick one of these with -i <name|id>:\n",
project.string());
for (const Instance& other : live) Err() << std::format(" {} id {} up {} {}\n", other.record.name, other.record.instanceId, Age(other.record.startedAt), other.record.exe);
return exitNoInstance;
}
if (live.empty()) {
if (!opts.instance.empty()) {
std::string running;
@ -310,7 +347,7 @@ namespace {
} else {
Err() << " Restart the app from the current build (crafter-build <debug flag> -r), or talk to it with its own client:\n";
}
Err() << std::format(" flatpak run --die-with-parent --command={} net.catcrafts.CrafterBuild {}\n", record.client, opts.command);
Err() << std::format(" flatpak run --die-with-parent --command={} net.catcrafts.CrafterBuild -i {} {}\n", record.client, record.instanceId, opts.command);
return exitVersion;
}
if (!record.sourceId.empty() && record.sourceId != Detail::SourceId()) {
@ -436,21 +473,29 @@ namespace {
return Usage(std::format("unknown argument '{}'", opts.rest[i]));
}
}
// With a project, what counts is one of its apps: --wait waits for
// one, and the exit code says whether one runs. The others are
// listed after it all the same, for -i.
const auto found = [&](const std::vector<Instance>& list) {
return project.empty() ? !list.empty() : std::ranges::any_of(list, [&](const Instance& entry) { return Ours(entry.record); });
};
const auto deadline = std::chrono::steady_clock::now() + wait.value_or(0ms);
std::vector<Instance> live = env.list();
while (live.empty() && wait && std::chrono::steady_clock::now() < deadline) {
while (!found(live) && wait && std::chrono::steady_clock::now() < deadline) {
std::this_thread::sleep_for(200ms);
live = env.list();
}
std::ranges::stable_partition(live, [&](const Instance& entry) { return Ours(entry.record); });
if (opts.json) {
Value list = Value::MakeArray();
for (const Instance& entry : live) {
Value record = Encode(entry.record);
record.Erase("token");
if (!project.empty()) record.Set("thisProject", Value(Ours(entry.record)));
list.Push(std::move(record));
}
Out() << list.Dump() << '\n';
return live.empty() ? exitNoInstance : exitOk;
return found(live) ? exitOk : exitNoInstance;
}
if (live.empty()) {
Err() << "no running instances. Start the app from its debug build in the background (e.g. crafter-build --debug-api -r)"
@ -459,9 +504,15 @@ namespace {
}
for (const Instance& entry : live) {
const InstanceRecord& r = entry.record;
Out() << std::format("{} id {} pid {}{} up {} port {}{}\n", r.name, r.instanceId, r.pid,
Out() << std::format("{} id {} pid {}{} up {} port {}{}{}\n", r.name, r.instanceId, r.pid,
r.hostInitPid > 0 ? std::format(" (sandbox {})", r.hostInitPid) : std::string(), Age(r.startedAt), r.port,
r.protocol != protocolVersion ? std::format(" protocol {} (this client {})", r.protocol, protocolVersion) : std::string());
r.protocol != protocolVersion ? std::format(" protocol {} (this client {})", r.protocol, protocolVersion) : std::string(),
Ours(r) ? std::string(" (this project)") : std::string());
}
if (!found(live)) {
Err() << std::format("none of these is from this project ({}); start its app in the background (e.g. crafter-build --debug-api -r){}\n",
project.string(), wait ? ", and wait longer if it is still starting" : std::string(", then: crafter-debug ls --wait 60s"));
return exitNoInstance;
}
return exitOk;
}
@ -987,7 +1038,8 @@ memory are a debugger's job; `crafter-debug debugger` prints how to attach one.
## Commands
ls [--wait 60s]
ls [--wait 60s] running apps, this project's first; --wait
waits for one of this project's
status
cmds [glob] name(args = defaults) -> result -- description
help <command|watch>
@ -1015,8 +1067,11 @@ memory are a debugger's job; `crafter-debug debugger` prints how to attach one.
debugger how to attach lldb or gdb
version
Global: -i <name|id> picks an instance when several run; --json prints raw
JSON (never truncated); --timeout 30s bounds the request (waits included).
Global: commands pick this project's app; when another project's apps run
too they are left alone, and `ls` marks this project's. -i <name|id> picks
one by name or id: another project's app, or one of several copies of this
one. --json prints raw JSON (never truncated); --timeout 30s bounds the
request (waits included).
## Conditions

View file

@ -17,6 +17,8 @@ int main(int argc, char** argv) {
{
Cli::Environment environment;
environment.connect = Cli::ConnectQuic;
// Set by the project's launcher, build/crafter-debug.
if (const char* project = std::getenv("CRAFTER_DEBUG_PROJECT"); project != nullptr && *project != '\0') environment.project = project;
environment.lldb = [] {
for (const char* path : { "/app/bin/lldb", "/usr/bin/lldb" }) {
if (std::filesystem::exists(path)) return std::string(path);