From a6b0c33e9fbec8ca0cfcf31d7feecbb6afddf816 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 23 May 2026 13:47:39 +0000 Subject: [PATCH] Unify session-aware CLI parsing and add session-id compatibility --- internal/app/clean.go | 17 +-- internal/app/operator_helpers.go | 113 ++++++-------------- internal/app/plan.go | 38 ++----- internal/app/restore.go | 33 ++---- internal/app/resume.go | 38 ++----- internal/app/run.go | 38 ++----- internal/app/run_stage.go | 122 +++++++--------------- internal/app/session_args.go | 18 ++++ internal/app/session_oriented_cli_test.go | 27 ++++- 9 files changed, 137 insertions(+), 307 deletions(-) diff --git a/internal/app/clean.go b/internal/app/clean.go index 3c107f0..54744a0 100644 --- a/internal/app/clean.go +++ b/internal/app/clean.go @@ -16,7 +16,6 @@ import ( // Clean removes local workspace/spool state while preserving durable cache // state unless cache cleanup is explicitly requested. func Clean(ctx context.Context, args []string, out io.Writer) error { - positionalSessionID, args := pullLeadingSessionID(args) fs := flag.NewFlagSet("clean", flag.ContinueOnError) fs.SetOutput(io.Discard) var flags commonConfigFlags @@ -27,20 +26,8 @@ func Clean(ctx context.Context, args []string, out io.Writer) error { fs.BoolVar(&all, "all", false, "clean all local session work/spool state") fs.BoolVar(&dryRun, "dry-run", false, "print cleanup targets without deleting") fs.BoolVar(&clearCache, "clear-cache", false, "also clear durable S3 audio cache entries") - if err := fs.Parse(args); err != nil { - return fmt.Errorf("clean: invalid flags: %w", err) - } - if positionalSessionID == "" { - if err := applyParsedSessionIDArg("clean", fs, &flags.sessionID); err != nil { - return err - } - } else { - if fs.NArg() != 0 { - return fmt.Errorf("clean: unexpected positional arguments") - } - if err := applyPositionalSessionID("clean", positionalSessionID, &flags.sessionID); err != nil { - return err - } + if err := parseSessionAwareFlags("clean", fs, args, &flags.sessionID); err != nil { + return err } if all { return cleanAllLocal(flags, dryRun, clearCache, out) diff --git a/internal/app/operator_helpers.go b/internal/app/operator_helpers.go index 5ce47a4..0050942 100644 --- a/internal/app/operator_helpers.go +++ b/internal/app/operator_helpers.go @@ -47,6 +47,7 @@ func addCommonConfigFlags(fs *flag.FlagSet, flags *commonConfigFlags) { fs.StringVar(&flags.campaignPath, "campaign", "", "campaign ID") fs.StringVar(&flags.campaignFilePath, "campaign-file", "", "path to campaign.yml") fs.StringVar(&flags.sessionPath, "session", "", "path to session.yml") + fs.StringVar(&flags.sessionID, "session-id", "", "session identifier") fs.StringVar(&flags.previousSessionID, "previous-session-id", "", "expected previous session identifier") } @@ -111,25 +112,12 @@ func Artifacts(ctx context.Context, args []string, out io.Writer) error { // SessionValidate performs a read-only session preflight. func SessionValidate(ctx context.Context, args []string, out io.Writer) error { - positionalSessionID, args := pullLeadingSessionID(args) fs := flag.NewFlagSet("session validate", flag.ContinueOnError) fs.SetOutput(io.Discard) var flags commonConfigFlags addCommonConfigFlags(fs, &flags) - if err := fs.Parse(args); err != nil { - return fmt.Errorf("session validate: invalid flags: %w", err) - } - if positionalSessionID == "" { - if err := applyParsedSessionIDArg("session validate", fs, &flags.sessionID); err != nil { - return err - } - } else { - if fs.NArg() != 0 { - return fmt.Errorf("session validate: unexpected positional arguments") - } - if err := applyPositionalSessionID("session validate", positionalSessionID, &flags.sessionID); err != nil { - return err - } + if err := parseSessionAwareFlags("session validate", fs, args, &flags.sessionID); err != nil { + return err } if strings.TrimSpace(flags.sessionID) == "" { return fmt.Errorf("session validate: session_id is required") @@ -193,25 +181,12 @@ func SessionValidate(ctx context.Context, args []string, out io.Writer) error { // Status reports effective local/remote session state. func Status(ctx context.Context, args []string, out io.Writer) error { - positionalSessionID, args := pullLeadingSessionID(args) fs := flag.NewFlagSet("status", flag.ContinueOnError) fs.SetOutput(io.Discard) var flags commonConfigFlags addCommonConfigFlags(fs, &flags) - if err := fs.Parse(args); err != nil { - return fmt.Errorf("status: invalid flags: %w", err) - } - if positionalSessionID == "" { - if err := applyParsedSessionIDArg("status", fs, &flags.sessionID); err != nil { - return err - } - } else { - if fs.NArg() != 0 { - return fmt.Errorf("status: unexpected positional arguments") - } - if err := applyPositionalSessionID("status", positionalSessionID, &flags.sessionID); err != nil { - return err - } + if err := parseSessionAwareFlags("status", fs, args, &flags.sessionID); err != nil { + return err } if strings.TrimSpace(flags.sessionID) == "" { return fmt.Errorf("status: session_id is required") @@ -282,7 +257,6 @@ func Status(ctx context.Context, args []string, out io.Writer) error { // SessionInit creates a local or remote session.yml skeleton. func SessionInit(ctx context.Context, args []string, out io.Writer) error { - positionalSessionID, args := pullLeadingSessionID(args) fs := flag.NewFlagSet("session init", flag.ContinueOnError) fs.SetOutput(io.Discard) var pipelinePath, campaignPath, campaignFilePath, sessionID, previousSessionID, date, title, output, audioS3Prefix, audioDir string @@ -290,6 +264,7 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error { fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") fs.StringVar(&campaignPath, "campaign", "", "campaign ID") fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml") + fs.StringVar(&sessionID, "session-id", "", "session identifier") fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier") fs.StringVar(&date, "date", "", "session date") fs.StringVar(&title, "title", "", "session title") @@ -298,20 +273,8 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error { fs.StringVar(&audioDir, "audio-dir", "", "local audio directory") fs.BoolVar(&remote, "remote", false, "write session.yml to S3 session prefix") fs.BoolVar(&force, "force", false, "overwrite existing target") - if err := fs.Parse(args); err != nil { - return fmt.Errorf("session init: invalid flags: %w", err) - } - if positionalSessionID == "" { - if err := applyParsedSessionIDArg("session init", fs, &sessionID); err != nil { - return err - } - } else { - if fs.NArg() != 0 { - return fmt.Errorf("session init: unexpected positional arguments") - } - if err := applyPositionalSessionID("session init", positionalSessionID, &sessionID); err != nil { - return err - } + if err := parseSessionAwareFlags("session init", fs, args, &sessionID); err != nil { + return err } if strings.TrimSpace(sessionID) == "" { return fmt.Errorf("session init: session_id is required") @@ -405,27 +368,14 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error { // ArtifactsList lists effective artifact sources. func ArtifactsList(ctx context.Context, args []string, out io.Writer) error { - positionalSessionID, args := pullLeadingSessionID(args) fs := flag.NewFlagSet("artifacts list", flag.ContinueOnError) fs.SetOutput(io.Discard) var flags commonConfigFlags var remote bool addCommonConfigFlags(fs, &flags) fs.BoolVar(&remote, "remote", false, "inspect remote publish availability") - if err := fs.Parse(args); err != nil { - return fmt.Errorf("artifacts list: invalid flags: %w", err) - } - if positionalSessionID == "" { - if err := applyParsedSessionIDArg("artifacts list", fs, &flags.sessionID); err != nil { - return err - } - } else { - if fs.NArg() != 0 { - return fmt.Errorf("artifacts list: unexpected positional arguments") - } - if err := applyPositionalSessionID("artifacts list", positionalSessionID, &flags.sessionID); err != nil { - return err - } + if err := parseSessionAwareFlags("artifacts list", fs, args, &flags.sessionID); err != nil { + return err } if strings.TrimSpace(flags.sessionID) == "" { return fmt.Errorf("artifacts list: session_id is required") @@ -463,25 +413,12 @@ func Locks(ctx context.Context, args []string, out io.Writer) error { // LocksList lists effective publish locks. func LocksList(ctx context.Context, args []string, out io.Writer) error { - positionalSessionID, args := pullLeadingSessionID(args) fs := flag.NewFlagSet("locks", flag.ContinueOnError) fs.SetOutput(io.Discard) var flags commonConfigFlags addCommonConfigFlags(fs, &flags) - if err := fs.Parse(args); err != nil { - return fmt.Errorf("locks: invalid flags: %w", err) - } - if positionalSessionID == "" { - if err := applyParsedSessionIDArg("locks", fs, &flags.sessionID); err != nil { - return err - } - } else { - if fs.NArg() != 0 { - return fmt.Errorf("locks: unexpected positional arguments") - } - if err := applyPositionalSessionID("locks", positionalSessionID, &flags.sessionID); err != nil { - return err - } + if err := parseSessionAwareFlags("locks", fs, args, &flags.sessionID); err != nil { + return err } if strings.TrimSpace(flags.sessionID) == "" { return fmt.Errorf("locks: session_id is required") @@ -515,11 +452,18 @@ func LocksAdd(ctx context.Context, args []string, out io.Writer) error { return fmt.Errorf("locks add: invalid flags: %w", err) } if source == "" { - if fs.NArg() != 2 { + switch fs.NArg() { + case 2: + positionalSessionID = strings.TrimSpace(fs.Arg(0)) + source = strings.TrimSpace(fs.Arg(1)) + case 1: + if strings.TrimSpace(flags.sessionID) == "" { + return fmt.Errorf("locks add: expected session_id and source id") + } + source = strings.TrimSpace(fs.Arg(0)) + default: return fmt.Errorf("locks add: expected session_id and source id") } - positionalSessionID = strings.TrimSpace(fs.Arg(0)) - source = strings.TrimSpace(fs.Arg(1)) } else if fs.NArg() != 0 { return fmt.Errorf("locks add: unexpected positional arguments") } @@ -572,11 +516,18 @@ func LocksRemove(ctx context.Context, args []string, out io.Writer) error { return fmt.Errorf("locks remove: invalid flags: %w", err) } if source == "" { - if fs.NArg() != 2 { + switch fs.NArg() { + case 2: + positionalSessionID = strings.TrimSpace(fs.Arg(0)) + source = strings.TrimSpace(fs.Arg(1)) + case 1: + if strings.TrimSpace(flags.sessionID) == "" { + return fmt.Errorf("locks remove: expected session_id and source id") + } + source = strings.TrimSpace(fs.Arg(0)) + default: return fmt.Errorf("locks remove: expected session_id and source id") } - positionalSessionID = strings.TrimSpace(fs.Arg(0)) - source = strings.TrimSpace(fs.Arg(1)) } else if fs.NArg() != 0 { return fmt.Errorf("locks remove: unexpected positional arguments") } diff --git a/internal/app/plan.go b/internal/app/plan.go index e1999ec..6d1c4ba 100644 --- a/internal/app/plan.go +++ b/internal/app/plan.go @@ -7,7 +7,6 @@ import ( "io" "log/slog" "os" - "strings" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/config" @@ -17,46 +16,21 @@ import ( // Plan validates configuration, prepares the local workdir, and prints stage order. func Plan(ctx context.Context, args []string, out io.Writer) error { - positionalSessionID, args := pullLeadingSessionID(args) fs := flag.NewFlagSet("plan", flag.ContinueOnError) fs.SetOutput(io.Discard) - var pipelinePath string - var campaignPath string - var campaignFilePath string - var sessionPath string - var sessionID string - var previousSessionID string + var flags commonConfigFlags var force bool - fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") - fs.StringVar(&campaignPath, "campaign", "", "campaign ID") - fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml") - fs.StringVar(&sessionPath, "session", "", "path to session.yml") - fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier") + addCommonConfigFlags(fs, &flags) fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)") - if err := fs.Parse(args); err != nil { - return fmt.Errorf("plan: invalid flags: %w", err) + if err := parseSessionAwareFlags("plan", fs, args, &flags.sessionID); err != nil { + return err } - if positionalSessionID == "" { - if err := applyParsedSessionIDArg("plan", fs, &sessionID); err != nil { - return err - } - } else { - if fs.NArg() != 0 { - return fmt.Errorf("plan: unexpected positional arguments") - } - if err := applyPositionalSessionID("plan", positionalSessionID, &sessionID); err != nil { - return err - } - } - if strings.TrimSpace(sessionID) == "" { + if flags.sessionID == "" { return fmt.Errorf("plan: session_id is required") } - cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, campaignFilePath, sessionPath, config.SessionLoadOptions{ - SessionID: sessionID, - PreviousSessionID: previousSessionID, - }) + cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions()) if err != nil { return fmt.Errorf("plan: %w", err) } diff --git a/internal/app/restore.go b/internal/app/restore.go index 874d338..cd01699 100644 --- a/internal/app/restore.go +++ b/internal/app/restore.go @@ -27,20 +27,11 @@ func Restore(ctx context.Context, args []string, out io.Writer) error { fs := flag.NewFlagSet("restore", flag.ContinueOnError) fs.SetOutput(out) - var pipelinePath string - var campaignPath string - var campaignFilePath string - var sessionPath string - var sessionID string - var previousSessionID string + var flags commonConfigFlags var dryRun bool var force bool var includeAudio bool - fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") - fs.StringVar(&campaignPath, "campaign", "", "campaign ID") - fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml") - fs.StringVar(&sessionPath, "session", "", "path to session.yml") - fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier") + addCommonConfigFlags(fs, &flags) fs.BoolVar(&dryRun, "dry-run", false, "plan restore actions without writing local files") fs.BoolVar(&force, "force", false, "overwrite local conflicts with remote state") fs.BoolVar(&includeAudio, "include-audio", false, "include archived session-level audio objects") @@ -57,25 +48,13 @@ func Restore(ctx context.Context, args []string, out io.Writer) error { } return fmt.Errorf("restore: invalid flags: %w", err) } - if positionalSessionID == "" { - if err := applyParsedSessionIDArg("restore", fs, &sessionID); err != nil { - return err - } - } else { - if fs.NArg() != 0 { - return fmt.Errorf("restore: unexpected positional arguments") - } - if err := applyPositionalSessionID("restore", positionalSessionID, &sessionID); err != nil { - return err - } + if err := resolveParsedSessionID("restore", positionalSessionID, fs, &flags.sessionID); err != nil { + return err } - if strings.TrimSpace(sessionID) == "" { + if strings.TrimSpace(flags.sessionID) == "" { return fmt.Errorf("restore: session_id is required") } - cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, campaignFilePath, sessionPath, config.SessionLoadOptions{ - SessionID: sessionID, - PreviousSessionID: previousSessionID, - }) + cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions()) if err != nil { return fmt.Errorf("restore: %w", err) } diff --git a/internal/app/resume.go b/internal/app/resume.go index 9801e3e..d3f2418 100644 --- a/internal/app/resume.go +++ b/internal/app/resume.go @@ -5,7 +5,6 @@ import ( "flag" "fmt" "io" - "strings" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/config" @@ -14,48 +13,23 @@ import ( // Resume continues execution from the first non-succeeded stage in the manifest. func Resume(ctx context.Context, args []string, out io.Writer) error { - positionalSessionID, args := pullLeadingSessionID(args) fs := flag.NewFlagSet("resume", flag.ContinueOnError) fs.SetOutput(io.Discard) - var pipelinePath string - var campaignPath string - var campaignFilePath string - var sessionPath string - var sessionID string - var previousSessionID string + var flags commonConfigFlags var force bool var selectedArtifacts artifactSelectionFlag - fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") - fs.StringVar(&campaignPath, "campaign", "", "campaign ID") - fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml") - fs.StringVar(&sessionPath, "session", "", "path to session.yml") - fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier") + addCommonConfigFlags(fs, &flags) fs.BoolVar(&force, "force", false, "force stage execution") fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to execute and publish (comma-separated or repeatable)") - if err := fs.Parse(args); err != nil { - return fmt.Errorf("resume: invalid flags: %w", err) + if err := parseSessionAwareFlags("resume", fs, args, &flags.sessionID); err != nil { + return err } - if positionalSessionID == "" { - if err := applyParsedSessionIDArg("resume", fs, &sessionID); err != nil { - return err - } - } else { - if fs.NArg() != 0 { - return fmt.Errorf("resume: unexpected positional arguments") - } - if err := applyPositionalSessionID("resume", positionalSessionID, &sessionID); err != nil { - return err - } - } - if strings.TrimSpace(sessionID) == "" { + if flags.sessionID == "" { return fmt.Errorf("resume: session_id is required") } - cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, campaignFilePath, sessionPath, config.SessionLoadOptions{ - SessionID: sessionID, - PreviousSessionID: previousSessionID, - }) + cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions()) if err != nil { return fmt.Errorf("resume: %w", err) } diff --git a/internal/app/run.go b/internal/app/run.go index 1ab0971..53fa3b6 100644 --- a/internal/app/run.go +++ b/internal/app/run.go @@ -5,55 +5,29 @@ import ( "flag" "fmt" "io" - "strings" "gitea.maximumdirect.net/eric/narratio/internal/config" ) // Run executes the pipeline plan and persists manifest state. func Run(ctx context.Context, args []string, out io.Writer) error { - positionalSessionID, args := pullLeadingSessionID(args) fs := flag.NewFlagSet("run", flag.ContinueOnError) fs.SetOutput(io.Discard) - var pipelinePath string - var campaignPath string - var campaignFilePath string - var sessionPath string - var sessionID string - var previousSessionID string + var flags commonConfigFlags var force bool var selectedArtifacts artifactSelectionFlag - fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") - fs.StringVar(&campaignPath, "campaign", "", "campaign ID") - fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml") - fs.StringVar(&sessionPath, "session", "", "path to session.yml") - fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier") + addCommonConfigFlags(fs, &flags) fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)") fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to execute and publish (comma-separated or repeatable)") - if err := fs.Parse(args); err != nil { - return fmt.Errorf("run: invalid flags: %w", err) + if err := parseSessionAwareFlags("run", fs, args, &flags.sessionID); err != nil { + return err } - if positionalSessionID == "" { - if err := applyParsedSessionIDArg("run", fs, &sessionID); err != nil { - return err - } - } else { - if fs.NArg() != 0 { - return fmt.Errorf("run: unexpected positional arguments") - } - if err := applyPositionalSessionID("run", positionalSessionID, &sessionID); err != nil { - return err - } - } - if strings.TrimSpace(sessionID) == "" { + if flags.sessionID == "" { return fmt.Errorf("run: session_id is required") } - cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, campaignFilePath, sessionPath, config.SessionLoadOptions{ - SessionID: sessionID, - PreviousSessionID: previousSessionID, - }) + cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions()) if err != nil { return fmt.Errorf("run: %w", err) } diff --git a/internal/app/run_stage.go b/internal/app/run_stage.go index 76a2221..05d5219 100644 --- a/internal/app/run_stage.go +++ b/internal/app/run_stage.go @@ -23,19 +23,10 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error { fs := flag.NewFlagSet("run-stage", flag.ContinueOnError) fs.SetOutput(io.Discard) - var pipelinePath string - var campaignPath string - var campaignFilePath string - var sessionPath string - var sessionID string - var previousSessionID string + var flags commonConfigFlags var force bool var selectedArtifacts artifactSelectionFlag - fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") - fs.StringVar(&campaignPath, "campaign", "", "campaign ID") - fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml") - fs.StringVar(&sessionPath, "session", "", "path to session.yml") - fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier") + addCommonConfigFlags(fs, &flags) fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)") fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to execute or publish (comma-separated or repeatable)") @@ -47,16 +38,21 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error { case 2: stageName = strings.TrimSpace(fs.Arg(0)) positionalSessionID = strings.TrimSpace(fs.Arg(1)) + case 1: + if strings.TrimSpace(flags.sessionID) == "" { + return fmt.Errorf("run-stage: expected stage name and session_id") + } + stageName = strings.TrimSpace(fs.Arg(0)) default: return fmt.Errorf("run-stage: expected stage name and session_id") } } else if fs.NArg() != 0 { return fmt.Errorf("run-stage: unexpected positional arguments") } - if err := applyPositionalSessionID("run-stage", positionalSessionID, &sessionID); err != nil { + if err := applyPositionalSessionID("run-stage", positionalSessionID, &flags.sessionID); err != nil { return err } - if strings.TrimSpace(sessionID) == "" { + if strings.TrimSpace(flags.sessionID) == "" { return fmt.Errorf("run-stage: session_id is required") } normalizedArtifacts, err := selectedArtifacts.Normalize() @@ -70,12 +66,12 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error { summary, err := runSingleStageCommand(ctx, singleStageCommand{ CommandName: "run-stage", StageName: stageName, - PipelinePath: pipelinePath, - CampaignPath: campaignPath, - CampaignFilePath: campaignFilePath, - SessionPath: sessionPath, - SessionID: sessionID, - PreviousSessionID: previousSessionID, + PipelinePath: flags.pipelinePath, + CampaignPath: flags.campaignPath, + CampaignFilePath: flags.campaignFilePath, + SessionPath: flags.sessionPath, + SessionID: flags.sessionID, + PreviousSessionID: flags.previousSessionID, Force: force, SelectedArtifacts: normalizedArtifacts, }) @@ -97,40 +93,18 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error { // Analyze force-runs the analyze stage. func Analyze(ctx context.Context, args []string, out io.Writer) error { - positionalSessionID, args := pullLeadingSessionID(args) fs := flag.NewFlagSet("analyze", flag.ContinueOnError) fs.SetOutput(io.Discard) - var pipelinePath string - var campaignPath string - var campaignFilePath string - var sessionPath string - var sessionID string - var previousSessionID string + var flags commonConfigFlags var selectedArtifacts artifactSelectionFlag - fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") - fs.StringVar(&campaignPath, "campaign", "", "campaign ID") - fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml") - fs.StringVar(&sessionPath, "session", "", "path to session.yml") - fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier") + addCommonConfigFlags(fs, &flags) fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to execute during analyze (comma-separated or repeatable)") - if err := fs.Parse(args); err != nil { - return fmt.Errorf("analyze: invalid flags: %w", err) + if err := parseSessionAwareFlags("analyze", fs, args, &flags.sessionID); err != nil { + return err } - if positionalSessionID == "" { - if err := applyParsedSessionIDArg("analyze", fs, &sessionID); err != nil { - return err - } - } else { - if fs.NArg() != 0 { - return fmt.Errorf("analyze: unexpected positional arguments") - } - if err := applyPositionalSessionID("analyze", positionalSessionID, &sessionID); err != nil { - return err - } - } - if strings.TrimSpace(sessionID) == "" { + if strings.TrimSpace(flags.sessionID) == "" { return fmt.Errorf("analyze: session_id is required") } normalizedArtifacts, err := selectedArtifacts.Normalize() @@ -141,12 +115,12 @@ func Analyze(ctx context.Context, args []string, out io.Writer) error { summary, err := runSingleStageCommand(ctx, singleStageCommand{ CommandName: "analyze", StageName: "analyze", - PipelinePath: pipelinePath, - CampaignPath: campaignPath, - CampaignFilePath: campaignFilePath, - SessionPath: sessionPath, - SessionID: sessionID, - PreviousSessionID: previousSessionID, + PipelinePath: flags.pipelinePath, + CampaignPath: flags.campaignPath, + CampaignFilePath: flags.campaignFilePath, + SessionPath: flags.sessionPath, + SessionID: flags.sessionID, + PreviousSessionID: flags.previousSessionID, Force: true, SelectedArtifacts: normalizedArtifacts, }) @@ -166,40 +140,18 @@ func Analyze(ctx context.Context, args []string, out io.Writer) error { // Publish force-runs the publish stage. func Publish(ctx context.Context, args []string, out io.Writer) error { - positionalSessionID, args := pullLeadingSessionID(args) fs := flag.NewFlagSet("publish", flag.ContinueOnError) fs.SetOutput(io.Discard) - var pipelinePath string - var campaignPath string - var campaignFilePath string - var sessionPath string - var sessionID string - var previousSessionID string + var flags commonConfigFlags var selectedArtifacts artifactSelectionFlag - fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") - fs.StringVar(&campaignPath, "campaign", "", "campaign ID") - fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml") - fs.StringVar(&sessionPath, "session", "", "path to session.yml") - fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier") + addCommonConfigFlags(fs, &flags) fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to publish (comma-separated or repeatable)") - if err := fs.Parse(args); err != nil { - return fmt.Errorf("publish: invalid flags: %w", err) + if err := parseSessionAwareFlags("publish", fs, args, &flags.sessionID); err != nil { + return err } - if positionalSessionID == "" { - if err := applyParsedSessionIDArg("publish", fs, &sessionID); err != nil { - return err - } - } else { - if fs.NArg() != 0 { - return fmt.Errorf("publish: unexpected positional arguments") - } - if err := applyPositionalSessionID("publish", positionalSessionID, &sessionID); err != nil { - return err - } - } - if strings.TrimSpace(sessionID) == "" { + if strings.TrimSpace(flags.sessionID) == "" { return fmt.Errorf("publish: session_id is required") } normalizedArtifacts, err := selectedArtifacts.Normalize() @@ -210,12 +162,12 @@ func Publish(ctx context.Context, args []string, out io.Writer) error { summary, err := runSingleStageCommand(ctx, singleStageCommand{ CommandName: "publish", StageName: "publish", - PipelinePath: pipelinePath, - CampaignPath: campaignPath, - CampaignFilePath: campaignFilePath, - SessionPath: sessionPath, - SessionID: sessionID, - PreviousSessionID: previousSessionID, + PipelinePath: flags.pipelinePath, + CampaignPath: flags.campaignPath, + CampaignFilePath: flags.campaignFilePath, + SessionPath: flags.sessionPath, + SessionID: flags.sessionID, + PreviousSessionID: flags.previousSessionID, Force: true, SelectedArtifacts: normalizedArtifacts, }) diff --git a/internal/app/session_args.go b/internal/app/session_args.go index c385c31..928756f 100644 --- a/internal/app/session_args.go +++ b/internal/app/session_args.go @@ -41,3 +41,21 @@ func applyParsedSessionIDArg(command string, fs *flag.FlagSet, sessionID *string return fmt.Errorf("%s: unexpected positional arguments", command) } } + +func resolveParsedSessionID(command, positionalSessionID string, fs *flag.FlagSet, sessionID *string) error { + if strings.TrimSpace(positionalSessionID) == "" { + return applyParsedSessionIDArg(command, fs, sessionID) + } + if fs.NArg() != 0 { + return fmt.Errorf("%s: unexpected positional arguments", command) + } + return applyPositionalSessionID(command, positionalSessionID, sessionID) +} + +func parseSessionAwareFlags(command string, fs *flag.FlagSet, args []string, sessionID *string) error { + positionalSessionID, args := pullLeadingSessionID(args) + if err := fs.Parse(args); err != nil { + return fmt.Errorf("%s: invalid flags: %w", command, err) + } + return resolveParsedSessionID(command, positionalSessionID, fs, sessionID) +} diff --git a/internal/app/session_oriented_cli_test.go b/internal/app/session_oriented_cli_test.go index 4dd8492..869fa1e 100644 --- a/internal/app/session_oriented_cli_test.go +++ b/internal/app/session_oriented_cli_test.go @@ -71,15 +71,36 @@ func TestExecutePositionalSessionIDMismatchFails(t *testing.T) { } } -func TestExecuteSessionIDFlagFails(t *testing.T) { +func TestExecuteSessionIDFlagMismatchFails(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"session", "status", "2026-05-03", "--session-id", "2026-05-04"}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } - if !strings.Contains(stderr.String(), "flag provided but not defined: -session-id") { - t.Fatalf("stderr = %q, want invalid --session-id flag", stderr.String()) + if !strings.Contains(stderr.String(), "does not match expected session id") { + t.Fatalf("stderr = %q, want positional/flag mismatch", stderr.String()) + } +} + +func TestExecuteSessionIDFlagAcceptedWithoutPositional(t *testing.T) { + workspaceRoot := t.TempDir() + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Execute([]string{ + "session", "status", + "--session-id", "2026-05-03", + "--config", pipelinePath, + "--campaign-file", campaignPath, + "--session", sessionPath, + }, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "Session: 2026-05-03") { + t.Fatalf("stdout = %q, want status output", stdout.String()) } }