Unify session-aware CLI parsing and add session-id compatibility

This commit is contained in:
2026-05-23 13:47:39 +00:00
parent 96b886e711
commit a6b0c33e9f
9 changed files with 137 additions and 307 deletions

View File

@@ -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)

View File

@@ -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")
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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,
})

View File

@@ -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)
}

View File

@@ -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())
}
}