@@ -553,6 +553,305 @@ pub export fn gossamer_gsa_get_logs(
553553 return & logs_result_buf ;
554554}
555555
556+ /// Run a GSA helper script (e.g. provision-server.sh, steam-stage.sh) with
557+ /// optional environment variables passed as a JSON object and an optional
558+ /// single-line secret piped to stdin (used for Steam passwords).
559+ ///
560+ /// `script_path` — relative path from repo root, e.g. "scripts/provision-server.sh"
561+ /// `arg` — first positional argument to the script (e.g. profile_id)
562+ /// `env_json` — JSON object of additional env vars, e.g. {"KEY":"val", ...}
563+ /// `stdin_secret`— piped to the process stdin then closed; empty = no stdin
564+ ///
565+ /// Returns a NUL-terminated JSON object:
566+ /// {"success":true,"exit_code":0,"output":"..."} or ERR sentinel.
567+ ///
568+ /// Security: env_json is parsed with std.json — no shell expansion. The script
569+ /// path is constrained to the scripts/ directory (prefix check) to prevent
570+ /// path traversal. stdin_secret is piped directly without shell interpretation.
571+ threadlocal var run_script_buf : [131072 :0 ]u8 = undefined ;
572+
573+ pub export fn gossamer_gsa_run_script (
574+ script_path_z : [* :0 ]const u8 ,
575+ arg_z : [* :0 ]const u8 ,
576+ env_json_z : [* :0 ]const u8 ,
577+ stdin_secret_z :[* :0 ]const u8 ,
578+ ) callconv (.c ) c_int {
579+ const allocator = std .heap .c_allocator ;
580+ const script_path = std .mem .span (script_path_z );
581+ const arg = std .mem .span (arg_z );
582+ const env_json_str = std .mem .span (env_json_z );
583+ const secret = std .mem .span (stdin_secret_z );
584+
585+ // Path constraint: must start with "scripts/"
586+ if (! std .mem .startsWith (u8 , script_path , "scripts/" )) {
587+ main .setErrorStr ("script path must be under scripts/" );
588+ return @intFromEnum (main .GsaResult .permission_denied );
589+ }
590+
591+ // Parse additional env vars from JSON
592+ var env_list : std .ArrayList ([2 ][]const u8 ) = .empty ;
593+ defer env_list .deinit (allocator );
594+
595+ if (env_json_str .len > 2 ) { // "{}" minimum
596+ const parsed = std .json .parseFromSlice (std .json .Value , allocator , env_json_str , .{}) catch null ;
597+ if (parsed ) | p | {
598+ defer p .deinit ();
599+ if (p .value == .object ) {
600+ var it = p .value .object .iterator ();
601+ while (it .next ()) | kv | {
602+ const k = kv .key_ptr .* ;
603+ const v = switch (kv .value_ptr .* ) {
604+ .string = > | s | s ,
605+ else = > continue ,
606+ };
607+ const pair : [2 ][]const u8 = .{ k , v };
608+ env_list .append (allocator , pair ) catch continue ;
609+ }
610+ }
611+ }
612+ }
613+
614+ // Build argv: ["bash", script_path, arg]
615+ const argv : []const []const u8 = if (arg .len > 0 )
616+ &.{ "bash" , script_path , arg }
617+ else
618+ &.{ "bash" , script_path };
619+
620+ // Inherit current env and add extra vars
621+ var env_map = std .process .getEnvMap (allocator ) catch {
622+ main .setErrorStr ("getEnvMap failed" );
623+ return @intFromEnum (main .GsaResult .out_of_memory );
624+ };
625+ defer env_map .deinit ();
626+
627+ for (env_list .items ) | pair | {
628+ env_map .put (pair [0 ], pair [1 ]) catch continue ;
629+ }
630+
631+ // Pipe secret to stdin if provided
632+ var child = std .process .Child .init (argv , allocator );
633+ child .stdout_behavior = .Pipe ;
634+ child .stderr_behavior = .Pipe ; // captured separately, appended to output below
635+ child .env_map = & env_map ;
636+ if (secret .len > 0 ) {
637+ child .stdin_behavior = .Pipe ;
638+ }
639+
640+ child .spawn () catch | err | {
641+ main .setError ("script spawn failed: {s}" , .{@errorName (err )});
642+ return @intFromEnum (main .GsaResult .io_error );
643+ };
644+
645+ if (secret .len > 0 ) {
646+ if (child .stdin ) | stdin | {
647+ stdin .writeAll (secret ) catch {};
648+ stdin .writeAll ("\n " ) catch {};
649+ stdin .close ();
650+ child .stdin = null ;
651+ }
652+ }
653+
654+ const stdout = child .stdout .? .readToEndAlloc (allocator , 4 * 1024 * 1024 ) catch &.{};
655+ defer allocator .free (stdout );
656+ const stderr_out = child .stderr .? .readToEndAlloc (allocator , 512 * 1024 ) catch &.{};
657+ defer allocator .free (stderr_out );
658+
659+ const term = child .wait () catch std.process.Child.Term { .Exited = 1 };
660+ const exit_code : i32 = switch (term ) {
661+ .Exited = > | c | @intCast (c ),
662+ .Signal = > | s | - @as (i32 , @intCast (s )),
663+ else = > -1 ,
664+ };
665+
666+ // Combine stdout + stderr into a single output (stderr appended after newline)
667+ const combined : []const u8 = if (stderr_out .len > 0 )
668+ std .fmt .allocPrint (allocator , "{s}\n {s}" , .{ stdout , stderr_out }) catch stdout
669+ else
670+ stdout ;
671+ defer if (stderr_out .len > 0 ) allocator .free (combined );
672+
673+ // Write JSON result to run_script_buf
674+ var fbs = std .io .fixedBufferStream (& run_script_buf );
675+ const w = fbs .writer ();
676+ w .print ("{{\" success\" :{s},\" exit_code\" :{d},\" output\" :\" " , .{
677+ if (exit_code == 0 ) "true" else "false" , exit_code ,
678+ }) catch return @intFromEnum (main .GsaResult .io_error );
679+
680+ for (combined ) | ch | {
681+ switch (ch ) {
682+ '"' = > w .writeAll ("\\ \" " ) catch break ,
683+ '\\ ' = > w .writeAll ("\\\\ " ) catch break ,
684+ '\n ' = > w .writeAll ("\\ n" ) catch break ,
685+ '\r ' = > w .writeAll ("\\ r" ) catch break ,
686+ '\t ' = > w .writeAll ("\\ t" ) catch break ,
687+ else = > {
688+ if (ch < 0x20 ) {
689+ w .print ("\\ u{x:0>4}" , .{ch }) catch break ;
690+ } else {
691+ w .writeByte (ch ) catch break ;
692+ }
693+ },
694+ }
695+ }
696+
697+ w .writeAll ("\" }" ) catch {};
698+ w .writeByte (0 ) catch {};
699+
700+ if (exit_code == 0 ) main .clearError () else main .setErrorStr ("script exited non-zero" );
701+ return if (exit_code == 0 ) @intFromEnum (main .GsaResult .ok ) else @intFromEnum (main .GsaResult .err );
702+ }
703+
704+ /// Write a game server config file (e.g. Settings.xml) from JSON form values
705+ /// and an operators JSON array. The output path is derived from the profile_id
706+ /// and written to a known volume path so that provision-server.sh picks it up.
707+ ///
708+ /// `profile_id_z` — game profile identifier, e.g. "cryofall"
709+ /// `config_json_z`— JSON object of server settings, e.g. {"ServerName":"..."}
710+ /// `operators_json_z` — JSON array: [{"steam_id":"...","name":"..."}]
711+ ///
712+ /// Returns 0 (GsaResult.ok) on success, negative on error.
713+ pub export fn gossamer_gsa_write_server_config (
714+ profile_id_z : [* :0 ]const u8 ,
715+ config_json_z : [* :0 ]const u8 ,
716+ operators_json_z :[* :0 ]const u8 ,
717+ ) callconv (.c ) c_int {
718+ const allocator = std .heap .c_allocator ;
719+ const profile_id = std .mem .span (profile_id_z );
720+ const config_str = std .mem .span (config_json_z );
721+ const ops_str = std .mem .span (operators_json_z );
722+
723+ if (profile_id .len == 0 ) {
724+ main .setErrorStr ("missing profile_id" );
725+ return @intFromEnum (main .GsaResult .invalid_param );
726+ }
727+
728+ // Parse config fields
729+ const cfg_parsed = std .json .parseFromSlice (std .json .Value , allocator , config_str , .{}) catch {
730+ main .setErrorStr ("invalid config JSON" );
731+ return @intFromEnum (main .GsaResult .parse_error );
732+ };
733+ defer cfg_parsed .deinit ();
734+
735+ const ops_parsed = std .json .parseFromSlice (std .json .Value , allocator , ops_str , .{}) catch {
736+ main .setErrorStr ("invalid operators JSON" );
737+ return @intFromEnum (main .GsaResult .parse_error );
738+ };
739+ defer ops_parsed .deinit ();
740+
741+ const cfg = if (cfg_parsed .value == .object ) cfg_parsed .value .object else {
742+ main .setErrorStr ("config JSON must be an object" );
743+ return @intFromEnum (main .GsaResult .parse_error );
744+ };
745+
746+ // Helper to extract string field with fallback
747+ const S = struct {
748+ fn get (obj : std.json.ObjectMap , key : []const u8 , fallback : []const u8 ) []const u8 {
749+ if (obj .get (key )) | v | {
750+ return switch (v ) { .string = > | s | s , else = > fallback };
751+ }
752+ return fallback ;
753+ }
754+ };
755+
756+ const server_name = S .get (cfg , "ServerName" , "GSA Server" );
757+ const description = S .get (cfg , "ServerDescription" , "Managed by GSA" );
758+ const welcome = S .get (cfg , "WelcomeMessage" , "Welcome!" );
759+ const port = S .get (cfg , "ServerPort" , "6000" );
760+ const max_players = S .get (cfg , "MaxPlayers" , "10" );
761+ const is_private = S .get (cfg , "IsPrivate" , "true" );
762+ const password = S .get (cfg , "ServerPassword" , "" );
763+ const is_pve = S .get (cfg , "IsPvE" , "true" );
764+ const raids = S .get (cfg , "IsRaidsEnabled" , "false" );
765+ const wipe_days = S .get (cfg , "WipePeriodDays" , "0" );
766+ const gather_mult = S .get (cfg , "GatheringSpeedMultiplier" , "2.0" );
767+ const learn_mult = S .get (cfg , "LearningSpeedMultiplier" , "2.0" );
768+ const craft_mult = S .get (cfg , "CraftingSpeedMultiplier" , "2.0" );
769+
770+ // Build operators XML fragment
771+ var ops_xml : std .ArrayList (u8 ) = .empty ;
772+ defer ops_xml .deinit (allocator );
773+
774+ if (ops_parsed .value == .array ) {
775+ for (ops_parsed .value .array .items ) | item | {
776+ if (item != .object ) continue ;
777+ const steam_id = S .get (item .object , "steam_id" , "" );
778+ const name = S .get (item .object , "name" , "" );
779+ if (steam_id .len == 0 ) continue ;
780+ ops_xml .appendSlice (allocator , " <Operator steamId=\" " ) catch continue ;
781+ // Escape steamId attribute (digits only expected, but be safe)
782+ for (steam_id ) | ch | {
783+ if (ch == '"' ) ops_xml .appendSlice (allocator , """ ) catch {}
784+ else ops_xml .append (allocator , ch ) catch {};
785+ }
786+ ops_xml .appendSlice (allocator , "\" name=\" " ) catch continue ;
787+ for (name ) | ch | {
788+ if (ch == '"' ) ops_xml .appendSlice (allocator , """ ) catch {}
789+ else ops_xml .append (allocator , ch ) catch {};
790+ }
791+ ops_xml .appendSlice (allocator , "\" />\n " ) catch continue ;
792+ }
793+ }
794+
795+ // Compose Settings.xml
796+ const xml = std .fmt .allocPrint (allocator ,
797+ \\<?xml version="1.0" encoding="utf-8"?>
798+ \\<!-- Generated by GSA Nexus Setup wizard — edit via Config Editor panel -->
799+ \\<server-settings>
800+ \\ <ServerName>{s}</ServerName>
801+ \\ <ServerDescription>{s}</ServerDescription>
802+ \\ <ServerPort>{s}</ServerPort>
803+ \\ <MaxPlayers>{s}</MaxPlayers>
804+ \\ <IsPrivate>{s}</IsPrivate>
805+ \\ <ServerPassword>{s}</ServerPassword>
806+ \\ <WipePeriodDays>{s}</WipePeriodDays>
807+ \\ <IsPvE>{s}</IsPvE>
808+ \\ <IsRaidsEnabled>{s}</IsRaidsEnabled>
809+ \\ <WelcomeMessage>{s}</WelcomeMessage>
810+ \\ <ServerRates>
811+ \\ <GatheringSpeedMultiplier>{s}</GatheringSpeedMultiplier>
812+ \\ <LearningSpeedMultiplier>{s}</LearningSpeedMultiplier>
813+ \\ <CraftingSpeedMultiplier>{s}</CraftingSpeedMultiplier>
814+ \\ </ServerRates>
815+ \\ <Operators>
816+ \\{s} </Operators>
817+ \\</server-settings>
818+ \\
819+ , .{
820+ server_name , description , port , max_players ,
821+ is_private , password , wipe_days ,
822+ is_pve , raids , welcome ,
823+ gather_mult , learn_mult , craft_mult ,
824+ ops_xml .items ,
825+ }) catch {
826+ main .setErrorStr ("XML format failed: out of memory" );
827+ return @intFromEnum (main .GsaResult .out_of_memory );
828+ };
829+ defer allocator .free (xml );
830+
831+ // Write to container/cryofall/Settings.xml (profile-specific in future)
832+ const out_path = std .fmt .allocPrint (
833+ allocator , "container/{s}/Settings.xml" , .{profile_id },
834+ ) catch {
835+ main .setErrorStr ("path format failed" );
836+ return @intFromEnum (main .GsaResult .out_of_memory );
837+ };
838+ defer allocator .free (out_path );
839+
840+ const file = std .fs .cwd ().createFile (out_path , .{}) catch | err | {
841+ main .setError ("cannot write {s}: {s}" , .{ out_path , @errorName (err ) });
842+ return @intFromEnum (main .GsaResult .io_error );
843+ };
844+ defer file .close ();
845+
846+ file .writeAll (xml ) catch | err | {
847+ main .setError ("write failed: {s}" , .{@errorName (err )});
848+ return @intFromEnum (main .GsaResult .io_error );
849+ };
850+
851+ main .clearError ();
852+ return @intFromEnum (main .GsaResult .ok );
853+ }
854+
556855// ═══════════════════════════════════════════════════════════════════════════════
557856// Unit tests
558857// ═══════════════════════════════════════════════════════════════════════════════
0 commit comments