Merge branch 'flow/distribution/P3.1' into distribution-plan
This commit is contained in:
15
Makefile
15
Makefile
@@ -6,19 +6,24 @@ SX ?= /Users/agra/projects/sx/zig-out/bin/sx
|
||||
|
||||
BUILD_DIR := build
|
||||
|
||||
# Programs compiled by `make build`. Currently just the smoke program;
|
||||
# product entry points under src/ get added here as they land (P1.2+).
|
||||
# Programs compiled by `make build`: the smoke program and the `dist`
|
||||
# product entry point. Further entry points under src/ get added here as
|
||||
# they land.
|
||||
SMOKE := tests/smoke.sx
|
||||
DIST := src/dist.sx
|
||||
|
||||
.PHONY: build test publish-example clean
|
||||
|
||||
# Compile the smoke program (and, later, product sources) without running.
|
||||
# Compile the product sources (and the smoke program) without running.
|
||||
build:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(SX) build -o $(BUILD_DIR)/smoke $(SMOKE)
|
||||
$(SX) build -o $(BUILD_DIR)/dist $(DIST)
|
||||
|
||||
# Run the test runner over every tests/**/*.sx. Exits non-zero on any failure.
|
||||
test:
|
||||
# Run the test runner over every tests/**/*.sx. Exits non-zero on any
|
||||
# failure. Depends on `build` so the CLI acceptance test (tests/cli_*.sx)
|
||||
# finds a fresh `build/dist` to drive.
|
||||
test: build
|
||||
@SX="$(SX)" ./tests/run.sh
|
||||
|
||||
# Placeholder for the end-to-end publish flow — becomes real in P3.4.
|
||||
|
||||
157
src/dist.sx
Normal file
157
src/dist.sx
Normal file
@@ -0,0 +1,157 @@
|
||||
// =====================================================================
|
||||
// dist.sx — the `dist` distribution CLI entry point (subplan 03, Slice 1).
|
||||
//
|
||||
// Wires the real process argv (via `std.cli`'s `os_args`) to subcommand
|
||||
// handlers through `cli.parse`. The three groups/commands are dispatched
|
||||
// to STUB handlers for now — they acknowledge and emit a known result; the
|
||||
// real publish/promote/rollback logic lands in P3.4/P3.5.
|
||||
//
|
||||
// dist ci publish
|
||||
// dist release promote
|
||||
// dist release rollback
|
||||
//
|
||||
// EXIT-CODE CONTRACT (sysexits, via std.cli): success ends with
|
||||
// `exit_ok()` (EX_OK = 0); a no-command / unknown-or-missing
|
||||
// group/command/flag ends with `exit_usage()` (EX_USAGE = 64). An
|
||||
// explicit `-h`/`--help` is not an error and ends 0.
|
||||
//
|
||||
// `--json` PURITY: every command accepts the reserved global `--json`
|
||||
// flag (surfaced by the parser as `parsed.json`). In json mode stdout
|
||||
// carries ONLY the machine-readable JSON object (emitted via `std.json`,
|
||||
// isolated in `json_out.sx`); ALL human-readable text (help, progress,
|
||||
// errors) goes to stderr. In non-json mode human text on stdout is fine.
|
||||
// =====================================================================
|
||||
|
||||
#import "modules/std.sx";
|
||||
#import "modules/std/cli.sx";
|
||||
jout :: #import "json_out.sx";
|
||||
|
||||
// Direct stderr writer (fd 2), so human help/usage/progress never lands on
|
||||
// stdout's data stream. `out` (std builtin) targets stdout (fd 1).
|
||||
cstdlib :: #library "c";
|
||||
fd_write :: (fd: s32, buf: [*]u8, count: usize) -> isize #foreign cstdlib "write";
|
||||
|
||||
eputs :: (s: string) { if s.len > 0 { fd_write(2, s.ptr, xx s.len); } }
|
||||
|
||||
// Human text router: stderr in json mode (keep stdout pure), stdout
|
||||
// otherwise.
|
||||
emit_human :: (s: string, json_mode: bool) {
|
||||
if json_mode { eputs(s); } else { out(s); }
|
||||
}
|
||||
|
||||
HELP :: "dist — application distribution CLI\n\nUsage:\n dist <group> <command> [flags]\n\nGroups & commands:\n ci\n ci publish publish a release from CI (stub)\n release\n release promote promote a release onto a channel (stub)\n release rollback roll a channel back to a prior release (stub)\n\nGlobal flags:\n --json emit machine-readable JSON on stdout; human text to stderr\n -h, --help show this help and exit\n\nExit codes:\n 0 success\n 64 usage error (no command, or an unknown/missing command or flag)\n";
|
||||
|
||||
// True if `name` appears as a token in `args`.
|
||||
has_flag :: (args: []string, name: string) -> bool {
|
||||
i := 0;
|
||||
while i < args.len {
|
||||
if args[i] == name { return true; }
|
||||
i += 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Human phrase for a parser failure, used in the stderr diagnostic.
|
||||
error_phrase :: (e: CliError) -> string {
|
||||
if e == error.UnknownCommand { return "unknown or missing command"; }
|
||||
if e == error.UnknownFlag { return "unknown flag"; }
|
||||
if e == error.MissingValue { return "flag is missing its value"; }
|
||||
if e == error.MissingRequired { return "missing required flag"; }
|
||||
if e == error.TooManyFlags { return "too many flags for command"; }
|
||||
return "usage error";
|
||||
}
|
||||
|
||||
// ── Stub handlers ────────────────────────────────────────────────────
|
||||
// Honest stubs: acknowledge the command and emit a known result. NO real
|
||||
// publish/promote/rollback logic (that is P3.4/P3.5).
|
||||
|
||||
handle_ci_publish :: (p: *Parsed, json_mode: bool) {
|
||||
ack("ci publish", json_mode);
|
||||
}
|
||||
handle_release_promote :: (p: *Parsed, json_mode: bool) {
|
||||
ack("release promote", json_mode);
|
||||
}
|
||||
handle_release_rollback :: (p: *Parsed, json_mode: bool) {
|
||||
ack("release rollback", json_mode);
|
||||
}
|
||||
|
||||
// Emit a stub command's acknowledgement. In json mode: a single JSON
|
||||
// object on stdout (and a human progress note on stderr). Otherwise: a
|
||||
// human acknowledgement on stdout.
|
||||
ack :: (cmd: string, json_mode: bool) {
|
||||
if !json_mode {
|
||||
out(concat(concat("dist: ", cmd), " (stub) ok\n"));
|
||||
return;
|
||||
}
|
||||
|
||||
eputs(concat(concat("dist: ", cmd), " (stub) acknowledged\n"));
|
||||
|
||||
raw : [4096]u8 = ---;
|
||||
werr := false;
|
||||
n := jout.write_stub(cmd, string.{ ptr = @raw[0], len = 4096 }) catch { werr = true; 0 };
|
||||
if werr {
|
||||
eputs("dist: internal error: JSON serialization failed\n");
|
||||
exit_usage();
|
||||
}
|
||||
out(string.{ ptr = @raw[0], len = n });
|
||||
out("\n");
|
||||
}
|
||||
|
||||
// Route a parsed (group, command) to its stub handler. `parse` only
|
||||
// returns a (group, command) present in the table, so one arm always
|
||||
// matches.
|
||||
dispatch :: (p: *Parsed, json_mode: bool) {
|
||||
if p.group == "ci" and p.command == "publish" { handle_ci_publish(p, json_mode); return; }
|
||||
if p.group == "release" and p.command == "promote" { handle_release_promote(p, json_mode); return; }
|
||||
if p.group == "release" and p.command == "rollback" { handle_release_rollback(p, json_mode); return; }
|
||||
eputs("dist: internal error: unrouted command\n");
|
||||
exit_usage();
|
||||
}
|
||||
|
||||
main :: () -> ! {
|
||||
// Real process argv -> logical args: drop argv[0] (the program path).
|
||||
storage : [64]string = ---;
|
||||
argbuf : []string = ---;
|
||||
argbuf.ptr = @storage[0];
|
||||
argbuf.len = 64;
|
||||
argv := os_args(argbuf);
|
||||
args : []string = if argv.len <= 1 then argv[argv.len ..] else argv[1 ..];
|
||||
|
||||
json_mode := has_flag(args, "--json");
|
||||
|
||||
if has_flag(args, "-h") or has_flag(args, "--help") {
|
||||
emit_human(HELP, json_mode);
|
||||
exit_ok();
|
||||
}
|
||||
|
||||
if args.len == 0 {
|
||||
eputs("dist: no command given\n\n");
|
||||
eputs(HELP);
|
||||
exit_usage();
|
||||
}
|
||||
|
||||
// Command table + flag specs live in this scope; `Parsed` holds VIEWS
|
||||
// into them, used before `main` returns. Per-command flags arrive with
|
||||
// the real handlers in P3.4/P3.5 — the global `--json` is recognized by
|
||||
// the parser without being declared here.
|
||||
no_flags : []FlagSpec = .[];
|
||||
cmds : []Command = .[
|
||||
Command.{ group = "ci", command = "publish", flags = no_flags },
|
||||
Command.{ group = "release", command = "promote", flags = no_flags },
|
||||
Command.{ group = "release", command = "rollback", flags = no_flags },
|
||||
];
|
||||
|
||||
diag : Diag = .{};
|
||||
p, perr := parse(args, cmds, @diag);
|
||||
if perr {
|
||||
eputs(concat(concat(concat("dist: ", error_phrase(perr)), ": "), diag.token));
|
||||
eputs("\n\n");
|
||||
eputs(HELP);
|
||||
exit_usage();
|
||||
}
|
||||
|
||||
if !perr {
|
||||
dispatch(@p, json_mode);
|
||||
}
|
||||
exit_ok();
|
||||
}
|
||||
24
src/json_out.sx
Normal file
24
src/json_out.sx
Normal file
@@ -0,0 +1,24 @@
|
||||
// =====================================================================
|
||||
// json_out.sx — `--json` mode output for `dist`, built with `std.json`.
|
||||
//
|
||||
// The machine-readable JSON object a `--json` run writes to stdout is
|
||||
// built here, keeping the `std.json` writer in one place, separate from
|
||||
// the `std.cli` dispatch in dist.sx.
|
||||
// =====================================================================
|
||||
|
||||
#import "modules/std.sx";
|
||||
#import "modules/std/json.sx";
|
||||
|
||||
// Serialize a stub command's machine result — `{ "command": <cmd>,
|
||||
// "status": "ok", "stub": true }` — into the caller-owned `dst`, returning
|
||||
// the number of bytes written. Overflow surfaces on the error channel.
|
||||
write_stub :: (cmd: string, dst: []u8) -> (s64, !JsonError) {
|
||||
gpa := GPA.init();
|
||||
obj : Object = .{};
|
||||
obj.put("command", .str(cmd), xx gpa);
|
||||
obj.put("status", .str("ok"), xx gpa);
|
||||
obj.put("stub", .bool_(true), xx gpa);
|
||||
root : Value = .object(obj);
|
||||
n := try write_to_buffer(root, dst);
|
||||
return n;
|
||||
}
|
||||
102
tests/cli_dispatch.sx
Normal file
102
tests/cli_dispatch.sx
Normal file
@@ -0,0 +1,102 @@
|
||||
// =====================================================================
|
||||
// cli_dispatch.sx — acceptance for the `dist` CLI entrypoint (P3.1).
|
||||
//
|
||||
// Drives the BUILT `build/dist` binary through `process.run` (the binary,
|
||||
// not `sx run src/dist.sx` — only a real executable sees its own argv;
|
||||
// under `sx run` the process argv is the interpreter's). Asserts the
|
||||
// std.cli exit-code contract and the `--json` stdout-purity contract:
|
||||
//
|
||||
// 1. no args → human help/usage on STDERR + EX_USAGE (64).
|
||||
// 2. unknown command → human error on STDERR + EX_USAGE (64).
|
||||
// 3. `ci publish --json` → STDOUT is a SINGLE valid JSON object (parses
|
||||
// via std.json with no trailing junk); the human acknowledgement is
|
||||
// on STDERR, never stdout.
|
||||
// 4. `--help` → lists the `ci` / `release` groups, exits 0.
|
||||
//
|
||||
// `make test` depends on `build`, so `build/dist` exists before this runs;
|
||||
// the relative path resolves from the repo root (the `make test` cwd).
|
||||
// =====================================================================
|
||||
|
||||
#import "modules/std.sx";
|
||||
proc :: #import "modules/process.sx";
|
||||
json :: #import "modules/std/json.sx";
|
||||
|
||||
// True iff `needle` occurs in `hay`. Plain scan — the captured streams are
|
||||
// small, and the test only needs presence, not position.
|
||||
contains :: (hay: string, needle: string) -> bool {
|
||||
if needle.len == 0 { return true; }
|
||||
if needle.len > hay.len { return false; }
|
||||
i := 0;
|
||||
while i + needle.len <= hay.len {
|
||||
j := 0;
|
||||
ok := true;
|
||||
while j < needle.len {
|
||||
if hay[i + j] != needle[j] { ok = false; break; }
|
||||
j += 1;
|
||||
}
|
||||
if ok { return true; }
|
||||
i += 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
main :: () -> s32 {
|
||||
gpa := GPA.init();
|
||||
|
||||
// ── 1. No command → readable usage on stderr, EX_USAGE (64) ───────
|
||||
// `2>&1 1>/dev/null` routes the command's stderr into the captured
|
||||
// pipe and discards its stdout, so `r.stdout` here IS the stderr text.
|
||||
if r := proc.run("build/dist 2>&1 1>/dev/null") {
|
||||
proc.assert(r.exit_code == 64, "no-args must exit EX_USAGE (64)");
|
||||
proc.assert(r.stdout.len > 0, "no-args must print human help/usage to stderr");
|
||||
proc.assert(contains(r.stdout, "Usage"), "no-args help must carry a usage line");
|
||||
} else {
|
||||
proc.assert(false, "spawn build/dist (no args) failed");
|
||||
}
|
||||
|
||||
// ── 2. Unknown command → readable error on stderr, EX_USAGE (64) ──
|
||||
if r := proc.run("build/dist bogus 2>&1 1>/dev/null") {
|
||||
proc.assert(r.exit_code == 64, "unknown command must exit EX_USAGE (64)");
|
||||
proc.assert(contains(r.stdout, "unknown"), "unknown command must name the failure on stderr");
|
||||
} else {
|
||||
proc.assert(false, "spawn build/dist bogus failed");
|
||||
}
|
||||
|
||||
// ── 3a. `--json` stdout purity: a single valid JSON object, nothing
|
||||
// else. `2>/dev/null` drops the human note so the pipe carries
|
||||
// ONLY stdout; std.json.parse rejects trailing junk. ─────────
|
||||
if r := proc.run("build/dist ci publish --json 2>/dev/null") {
|
||||
proc.assert(r.exit_code == 0, "stub --json command must succeed (EX_OK)");
|
||||
v, e := json.parse(r.stdout, xx gpa);
|
||||
proc.assert(!e, "stdout in --json mode must be a single valid JSON object (parse failed / trailing junk)");
|
||||
if !e {
|
||||
o := v.object;
|
||||
proc.assert(o.len == 3, "stub json object carries command/status/stub");
|
||||
proc.assert(o.items[0].key == "command" and o.items[0].val.str == "ci publish",
|
||||
"stub json names the dispatched command");
|
||||
proc.assert(o.items[1].key == "status" and o.items[1].val.str == "ok",
|
||||
"stub json reports status ok");
|
||||
}
|
||||
} else {
|
||||
proc.assert(false, "spawn build/dist ci publish --json failed");
|
||||
}
|
||||
|
||||
// ── 3b. `--json` mode keeps human text on STDERR (not stdout) ──────
|
||||
if r := proc.run("build/dist ci publish --json 2>&1 1>/dev/null") {
|
||||
proc.assert(r.stdout.len > 0, "--json mode must still emit human text to stderr");
|
||||
} else {
|
||||
proc.assert(false, "spawn build/dist ci publish --json (stderr) failed");
|
||||
}
|
||||
|
||||
// ── 4. `--help` lists the ci / release groups, exits 0 ────────────
|
||||
if r := proc.run("build/dist --help 2>/dev/null") {
|
||||
proc.assert(r.exit_code == 0, "--help exits 0");
|
||||
proc.assert(contains(r.stdout, "ci"), "--help lists the ci group");
|
||||
proc.assert(contains(r.stdout, "release"), "--help lists the release group");
|
||||
} else {
|
||||
proc.assert(false, "spawn build/dist --help failed");
|
||||
}
|
||||
|
||||
print("cli_dispatch: ok\n");
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user