Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions src/cli.zig
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const project_config = @import("cli/project_config.zig");
// Submodules
const help = @import("cli/help.zig");
const init = @import("cli/init.zig");
const add = @import("cli/add.zig");
const install = @import("cli/install.zig");
const upgrade = @import("cli/upgrade.zig");
const update = @import("cli/update.zig");
Expand All @@ -44,7 +45,7 @@ const migrate = @import("cli/migrate.zig");
const doctor = @import("cli/doctor.zig");
const sdl_provision = @import("cli/sdl_provision.zig");

const Command = enum { generate, build, run, init_cmd, install_cmd, upgrade_cmd, update_cmd, clean_cmd, ios_cmd, android_cmd, wasm_cmd, help_cmd, version, targets, assembler_cmd, test_cmd, pack_cmd, astc_cmd, audit_cmd, migrate_cmd, doctor_cmd };
const Command = enum { generate, build, run, init_cmd, add_cmd, install_cmd, upgrade_cmd, update_cmd, clean_cmd, ios_cmd, android_cmd, wasm_cmd, help_cmd, version, targets, assembler_cmd, test_cmd, pack_cmd, astc_cmd, audit_cmd, migrate_cmd, doctor_cmd };

const SceneResult = enum { not_scene, parsed, needs_next, err };

Expand Down Expand Up @@ -641,6 +642,11 @@ pub fn main(proc_init: std.process.Init) !void {
} else if (std.mem.eql(u8, first, "audit")) {
parsed_args.command = .audit_cmd;
try collectExtraArgs(&args, &parsed_args);
} else if (std.mem.eql(u8, first, "add")) {
// `add pack <name>` / `add feature <kind> <name>` — forwarded
// verbatim to the assembler's `add` subcommand (Packs #271).
parsed_args.command = .add_cmd;
try collectExtraArgs(&args, &parsed_args);
} else if (std.mem.eql(u8, first, "migrate")) {
parsed_args.command = .migrate_cmd;
try collectExtraArgs(&args, &parsed_args);
Expand Down Expand Up @@ -750,6 +756,7 @@ pub fn main(proc_init: std.process.Init) !void {
.version => return help.printVersion(),
.targets => return help.printTargets(),
.init_cmd => return init.cmdInit(allocator, parsed_args.extra_args[0..parsed_args.extra_count]),
.add_cmd => return add.cmdAdd(allocator, parsed_args.extra_args[0..parsed_args.extra_count]),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require a project before scaffolding add output

Because add is returned from the standalone-command switch, it skips the existing config.readProjectConfig guard below and will invoke the assembler even when the current directory is not a Labelle project. In that situation a mistyped cwd such as a parent directory or home directory creates packs/, components/, or scripts/ there instead of producing the same “No project.labelle found” error as project-scoped commands; validate the manifest or route through the normal project-dir path before forwarding.

Useful? React with 👍 / 👎.

.install_cmd => return install.cmdInstall(allocator, parsed_args.extra_args[0..parsed_args.extra_count]),
.update_cmd => return update.cmdUpdate(allocator, parsed_args.extra_args[0..parsed_args.extra_count]),
.clean_cmd => return clean.cmdClean(allocator, parsed_args.extra_args[0..parsed_args.extra_count]),
Expand Down Expand Up @@ -801,11 +808,7 @@ pub fn main(proc_init: std.process.Init) !void {
defer arena.deinit();
var parsed = config.readProjectConfig(arena.allocator(), project_dir) catch |err| {
if (err == error.FileNotFound) {
std.debug.print("\n No project.labelle found in '{s}'.\n\n", .{project_dir});
std.debug.print(" To create a new project:\n", .{});
std.debug.print(" labelle init <name>\n\n", .{});
std.debug.print(" To see all commands:\n", .{});
std.debug.print(" labelle help\n\n", .{});
config.printNoProjectError(project_dir);
}
return;
};
Expand Down Expand Up @@ -907,7 +910,7 @@ pub fn main(proc_init: std.process.Init) !void {
// env var > assembler_version in project.labelle > auto-downloaded
// default) and reuse the located binary for both the cache-populate
// step and code generation below.
const asm_bin = try assembler_proc.resolve(allocator, project_dir);
const asm_bin = try assembler_proc.resolve(allocator, project_dir, "generate");
defer asm_bin.deinit(allocator);
std.debug.print(" using assembler: {s}\n", .{asm_bin.path});

Expand Down
39 changes: 39 additions & 0 deletions src/cli/add.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
const std = @import("std");
const assembler_proc = @import("assembler_proc.zig");
const config = @import("config.zig");

/// Scaffold a pack or a feature-unit by delegating to the assembler binary.
///
/// Packs initiative (umbrella labelle-engine#651, RFC-packs §7, #271).
/// `labelle add pack <name>` / `labelle add feature <kind> <name>` scaffold
/// the two authoring units the Packs RFC defines. Like `labelle init`, the
/// heavy lifting is assembler knowledge — the `pack.labelle` schema and the
/// game-root convention layout are the assembler's — so the CLI is a thin
/// forwarder: it hands `add ...` to `labelle-assembler add ...` verbatim
/// and propagates the exit code.
///
/// `cmd_args` is `["pack", name]` or `["feature", kind, name]` (plus any
/// flags), collected unchanged by the cli.zig arg parser. The assembler
/// validates the shape and prints diagnostics.
///
/// Resolution: `add` runs inside an existing project, so `resolve` reads
/// the pinned `assembler_version` from `./project.labelle` (falling back to
/// the CLI-paired default when there is none). The `add` subcommand needs
/// assembler protocol >= 4.
///
/// Project guard: `add` is dispatched from cli.zig's standalone-command
/// switch, so it never passes through the `readProjectConfig` guard that
/// project-scoped commands (`generate`/`build`/`run`) use. Without an
/// explicit check here, `labelle add pack foo` run in the wrong cwd (home
/// dir, a mistyped path) would scaffold `packs/`/`components/`/`scripts/`
/// THERE. Require a `project.labelle` in cwd first and emit the same
/// "No project.labelle found" error the project-scoped guard does.
pub fn cmdAdd(allocator: std.mem.Allocator, cmd_args: []const []const u8) !void {
if (!config.projectExists(".")) {
config.printNoProjectError(".");
// Match the project-scoped guard's non-zero outcome without a
// Zig error-return trace (same pattern as `android doctor`).
std.process.exit(1);
}
try assembler_proc.runSubcommand(allocator, ".", "add", cmd_args);
}
112 changes: 93 additions & 19 deletions src/cli/assembler_proc.zig
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,36 @@ const std = @import("std");
const config = @import("config.zig");
const assembler = @import("assembler.zig");

/// Minimum assembler subcommand protocol this CLI requires. The CLI
/// delegates `install`/`clean`/`upgrade` (added at protocol 2) and
/// `init` (protocol 3); an older binary lacks subcommands the CLI
/// depends on. `resolve` checks this and fails early with a clear
/// message instead of letting a stale binary reject a subcommand.
pub const REQUIRED_PROTOCOL: u32 = 3;
/// Global floor: every subcommand the CLI delegates needs at least this
/// protocol (`install`/`clean`/`upgrade` arrived at protocol 2, `init` at
/// protocol 3). Crucially, this floor also gates the *auto-downloaded*
/// `DEFAULT_ASSEMBLER_VERSION` (see `assembler.resolveDefault`): a fresh
/// install with no project pin runs the default binary, so raising this
/// number would reject that default before ANY command can run — a breaking
/// change for every existing user. Keep the floor at what the paired default
/// speaks (protocol 3) and gate newer subcommands per-command via
/// `minProtocolFor` instead of bumping this.
pub const MIN_PROTOCOL: u32 = 3;

/// Per-subcommand minimum protocol. Most subcommands need only the global
/// floor (`MIN_PROTOCOL`); a subcommand that depends on an assembler feature
/// added after the paired default declares a higher minimum here. Modelling
/// this as a small lookup (rather than one global constant) means gating a
/// new subcommand is a one-line entry that never raises the floor for
/// existing commands or the auto-downloaded default.
///
/// Extensible: sibling work adds `check` → 5 (#273) as another entry.
fn minProtocolFor(subcommand: []const u8) u32 {
const Gate = struct { name: []const u8, min: u32 };
const gates = [_]Gate{
.{ .name = "add", .min = 4 }, // Packs scaffold (#271)
// .{ .name = "check", .min = 5 }, // reserved for #273
};
for (gates) |g| {
if (std.mem.eql(u8, g.name, subcommand)) return g.min;
}
return MIN_PROTOCOL;
}

/// A located assembler binary, ready to be invoked. Returned by `resolve`
/// so a caller that runs several subcommands can resolve once and reuse.
Expand Down Expand Up @@ -71,20 +95,28 @@ pub const Assembler = struct {
/// for commands that may run outside a project (the resolver tolerates a
/// missing manifest and falls through to step 3).
///
/// `subcommand` is the assembler subcommand about to be delegated; it
/// selects the minimum protocol via `minProtocolFor` (so `add` can require
/// a newer binary than `generate` without raising the global floor).
///
/// Caller owns the returned `Assembler` and must `deinit` it.
pub fn resolve(allocator: std.mem.Allocator, project_dir: []const u8) !Assembler {
pub fn resolve(allocator: std.mem.Allocator, project_dir: []const u8, subcommand: []const u8) !Assembler {
const path = try assembler.resolveAssembler(allocator, project_dir) orelse
try assembler.resolveDefault(allocator);
errdefer allocator.free(path);
try checkProtocol(allocator, path);
try checkProtocol(allocator, path, subcommand);
return .{ .path = path };
}

/// Verify the resolved binary speaks a protocol this CLI understands.
/// `labelle-assembler --protocol-version` prints its integer protocol
/// to stdout; fail fast and legibly here rather than letting an
/// outdated binary reject a delegated subcommand opaquely.
fn checkProtocol(allocator: std.mem.Allocator, path: []const u8) !void {
/// Verify the resolved binary speaks a protocol high enough for
/// `subcommand`. `labelle-assembler --protocol-version` prints its integer
/// protocol to stdout; fail fast and legibly here rather than letting an
/// outdated binary reject a delegated subcommand opaquely. The required
/// minimum is per-subcommand (`minProtocolFor`) so newer subcommands don't
/// reject a binary that older subcommands (and the auto-downloaded default)
/// still work with.
fn checkProtocol(allocator: std.mem.Allocator, path: []const u8, subcommand: []const u8) !void {
const required = minProtocolFor(subcommand);
const res = std.process.run(allocator, config.globalIo(), .{
.argv = &.{ path, "--protocol-version" },
}) catch |err| {
Expand All @@ -96,15 +128,15 @@ fn checkProtocol(allocator: std.mem.Allocator, path: []const u8) !void {
const reported = std.mem.trim(u8, res.stdout, " \t\r\n");
const proto = std.fmt.parseInt(u32, reported, 10) catch {
std.debug.print(
"labelle: assembler '{s}' did not report a protocol version (too old?); this CLI needs protocol >= {d}\n",
.{ path, REQUIRED_PROTOCOL },
"labelle: assembler '{s}' did not report a protocol version (too old?); '{s}' needs protocol >= {d}\n",
.{ path, subcommand, required },
);
return error.AssemblerFailed;
};
if (proto < REQUIRED_PROTOCOL) {
if (proto < required) {
std.debug.print(
"labelle: assembler '{s}' speaks protocol {d}, but this CLI needs >= {d} — pin a newer 'assembler_version' in project.labelle\n",
.{ path, proto, REQUIRED_PROTOCOL },
"labelle: '{s}' needs assembler protocol >= {d}, but '{s}' speaks {d} — pin a newer 'assembler_version' in project.labelle\n",
.{ subcommand, required, path, proto },
);
return error.AssemblerFailed;
}
Expand All @@ -121,7 +153,7 @@ pub fn runSubcommand(
subcommand: []const u8,
args: []const []const u8,
) !void {
const asm_bin = try resolve(allocator, project_dir);
const asm_bin = try resolve(allocator, project_dir, subcommand);
defer asm_bin.deinit(allocator);
try asm_bin.run(allocator, subcommand, args);
}
Expand Down Expand Up @@ -237,6 +269,48 @@ pub const BuildGenerateArgsSpec = struct {
};
};

/// Codex review (#272): the `add` subcommand needs a newer assembler
/// (protocol 4) than the global floor, but that requirement must NOT be
/// raised globally — the auto-downloaded `DEFAULT_ASSEMBLER_VERSION` only
/// speaks the floor (protocol 3), so a global bump breaks every fresh
/// install before any command runs. These specs pin the per-subcommand
/// map: `add` gates at 4, the floor commands stay at 3, and an unknown
/// subcommand falls back to the floor. #273 will add `check` → 5.
pub const MinProtocolForSpec = struct {
pub const add_gates_higher = struct {
test "add requires protocol 4" {
try std.testing.expectEqual(@as(u32, 4), minProtocolFor("add"));
}
};

pub const floor_commands = struct {
test "generate accepts the global floor (3)" {
try std.testing.expectEqual(MIN_PROTOCOL, minProtocolFor("generate"));
try std.testing.expectEqual(@as(u32, 3), minProtocolFor("generate"));
}

test "install/clean/upgrade/init stay at the floor" {
for ([_][]const u8{ "install", "clean", "upgrade", "init" }) |cmd| {
try std.testing.expectEqual(MIN_PROTOCOL, minProtocolFor(cmd));
}
}
};

pub const unknown_falls_back = struct {
test "unknown subcommand uses the floor" {
try std.testing.expectEqual(MIN_PROTOCOL, minProtocolFor("does-not-exist"));
}
};

pub const gate_exceeds_floor = struct {
test "a protocol-3 binary would be rejected for add but not generate" {
const proto: u32 = 3;
try std.testing.expect(proto < minProtocolFor("add"));
try std.testing.expect(proto >= minProtocolFor("generate"));
}
};
};

/// Spawn `exe_path <subcommand> [args...]`, inherit stdio, wait, and map
/// the result onto a Zig error. Shared by `Assembler.run` and
/// `runSubcommand`.
Expand Down
64 changes: 64 additions & 0 deletions src/cli/config.zig
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,28 @@ pub fn readProjectConfigQuiet(allocator: std.mem.Allocator, project_dir: []const
return readProjectConfigImpl(allocator, project_dir, false);
}

/// True when `<project_dir>/project.labelle` exists — i.e. `project_dir`
/// is (the root of) a Labelle project. Used by standalone-dispatched
/// commands that still require a project (e.g. `add`, #271) to reject
/// running outside one before they mutate the filesystem.
pub fn projectExists(project_dir: []const u8) bool {
const labelle_path = std.fs.path.join(std.heap.page_allocator, &.{ project_dir, "project.labelle" }) catch return false;
defer std.heap.page_allocator.free(labelle_path);
std.Io.Dir.cwd().access(globalIo(), labelle_path, .{}) catch return false;
return true;
}

/// Print the standard "no project.labelle found" guidance. Shared so a
/// standalone-dispatched command that requires a project emits the exact
/// message the main project-config guard (see cli.zig) prints.
pub fn printNoProjectError(project_dir: []const u8) void {
std.debug.print("\n No project.labelle found in '{s}'.\n\n", .{project_dir});
std.debug.print(" To create a new project:\n", .{});
std.debug.print(" labelle init <name>\n\n", .{});
std.debug.print(" To see all commands:\n", .{});
std.debug.print(" labelle help\n\n", .{});
}

fn readProjectConfigImpl(allocator: std.mem.Allocator, project_dir: []const u8, verbose: bool) !project_config.ProjectConfig {
// Raise branch quota for std.zon.parse.fromSlice — ProjectConfig has many
// fields (including nested IosConfig) that exceed the default 1100 limit.
Expand Down Expand Up @@ -79,3 +101,45 @@ fn readProjectConfigImpl(allocator: std.mem.Allocator, project_dir: []const u8,
return error.ParseError;
};
}

const expect = @import("zspec").expect;

test {
@import("zspec").runAll(@This());
}

/// Codex review (#272): `add` is dispatched from the standalone-command
/// switch, skipping the `readProjectConfig` guard project-scoped commands
/// use. `projectExists` is the guard `cli/add.zig` calls before scaffolding
/// so `labelle add ...` in a non-project cwd errors instead of writing
/// `packs/`/`components/`/`scripts/` into the wrong directory.
pub const ProjectExistsSpec = struct {
pub const with_manifest = struct {
test "returns true when project.labelle is present" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const io = globalIo();
try tmp.dir.writeFile(io, .{ .sub_path = "project.labelle", .data = ".{}" });

var buf: [std.Io.Dir.max_path_bytes]u8 = undefined;
const n = try tmp.dir.realPath(io, &buf);
try expect.toBeTrue(projectExists(buf[0..n]));
}
};

pub const without_manifest = struct {
test "returns false in a directory with no project.labelle" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const io = globalIo();

var buf: [std.Io.Dir.max_path_bytes]u8 = undefined;
const n = try tmp.dir.realPath(io, &buf);
try expect.toBeFalse(projectExists(buf[0..n]));
}

test "returns false for a path that does not exist" {
try expect.toBeFalse(projectExists("/no/such/labelle/dir/xyzzy"));
}
};
};
4 changes: 4 additions & 0 deletions src/cli/help.zig
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ pub fn printHelp() void {
\\
\\Commands:
\\ init <name> [dir] Create a new labelle project
\\ add pack <name> Scaffold a pack (packs/<name>/ + pack.labelle)
\\ add feature <kind> <name> Scaffold a feature-unit (kind: need, role, status)
\\ generate [dir] [--scene=<name>] [--platform=<p>] [--optimize=<mode>] Generate .labelle/ assembler files
\\ build [dir] [--scene=<name>] [--platform=<p>] [--optimize=<mode>] [--docker] [--target=<t>] Generate + build the project
\\ run [dir] [--timeout=<dur>] [--scene=<name>] [--platform=<p>] [--optimize=<mode>] [--docker] [--target=<t>] [--screenshot=<path> [--after=<dur>]] [--headless] [--uncapped] [--ticks=<N>] [--profile] [-- <args>...] Generate + build + run (default; `--screenshot` captures one frame; `--headless` runs windowless, `--uncapped` removes the frame sleep, `--ticks=<N>` exits after N frames — last two imply `--headless`; `--profile` enables the engine frame profiler (per-script/per-plugin ms to the log); `--` forwards trailing args to the game)
Expand All @@ -29,6 +31,8 @@ pub fn printHelp() void {
\\
\\Examples:
\\ labelle init my-game
\\ labelle add pack citizens
\\ labelle add feature need boredom
\\ labelle generate
\\ labelle run
\\ labelle run --timeout=30s
Expand Down
Loading