From 2d3ebfbc3afe93ac864345e14149c6ceb4133536 Mon Sep 17 00:00:00 2001 From: typebasedio Date: Fri, 8 May 2026 14:01:07 -0400 Subject: [PATCH] feat(v0.5.3): cmd new -- persistent preflight before workspace.Create; delegate to runWorkspaceEntry --- cmd/new.go | 52 +++++++++++++++++++++++++++++++------- cmd/new_persistent_test.go | 49 +++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 9 deletions(-) create mode 100644 cmd/new_persistent_test.go diff --git a/cmd/new.go b/cmd/new.go index bce7eb2..e75b53a 100644 --- a/cmd/new.go +++ b/cmd/new.go @@ -6,7 +6,6 @@ import ( "github.com/spf13/cobra" "github.com/warrenronsiek/ctask/internal/config" - "github.com/warrenronsiek/ctask/internal/session" "github.com/warrenronsiek/ctask/internal/shell" "github.com/warrenronsiek/ctask/internal/workspace" ) @@ -27,6 +26,7 @@ var ( newAgent string newNoLaunch bool newProject bool + newDirect bool ) func init() { @@ -36,6 +36,7 @@ func init() { newCmd.Flags().BoolVar(&newShell, "shell", false, "Open interactive shell instead of agent") newCmd.Flags().StringVarP(&newAgent, "agent", "a", "", "Command to exec as the agent") newCmd.Flags().BoolVar(&newNoLaunch, "no-launch", false, "Create workspace only, do not launch") + newCmd.Flags().BoolVar(&newDirect, "direct", false, "Bypass persistent session mode for this command") rootCmd.AddCommand(newCmd) } @@ -45,6 +46,13 @@ func runNew(cmd *cobra.Command, args []string) error { return nil } + // Persistent-mode preflight runs BEFORE workspace.Create — a missing + // tmux install must not leave a half-initialized workspace on disk. + tmuxPath, err := newPersistentPreflight(newDirect) + if err != nil { + return err + } + agent := newAgent if agent == "" { agent = config.ResolveAgent() @@ -119,16 +127,42 @@ func runNew(cmd *cobra.Command, args []string) error { return nil } - envVars := config.EnvVars(ws.Meta.Slug, ws.Meta.Mode, root, ws.Path, ws.Meta.Category, workspace.EffectiveType(ws.Meta), ws.Meta.LaunchDir) - - return session.Run(session.LaunchOpts{ - WsDir: ws.Path, - EnvVars: envVars, + // Re-set the workspace's root: workspace.Create returned ws but our + // computed `root` is what should be exported via CTASK_ROOT (it may + // differ from ws-derived defaults when --project + CTASK_PROJECT_ROOT + // are in play). + return runWorkspaceEntry(WorkspaceEntryOptions{ + WsPath: ws.Path, + WsRoot: root, + WsMeta: ws.Meta, Agent: agent, - Mode: ws.Meta.Mode, - Slug: ws.Meta.Slug, Shell: newShell, - LaunchDir: ws.Meta.LaunchDir, + Direct: newDirect, + CommandName: "new", + TmuxPath: tmuxPath, NewlyCreated: true, }) } + +// newPersistentPreflight runs the persistent-mode preflight for `ctask new`, +// returning the validated tmux path on success or "" when persistent mode +// is not in effect (or --direct was passed). When persistent mode IS in +// effect and --direct was passed, prints the bypass warning and returns +// ("", nil) — the workspace can still be created in direct mode. +// +// `new` is the only command where preflight runs *before* the workspace +// exists; a tmux failure must not leave a half-initialized directory on +// disk. (resume / last / open / attach run preflight inside +// runWorkspaceEntry, after their own resolution step.) +func newPersistentPreflight(directFlag bool) (string, error) { + mode := config.ResolveSessionMode() + if mode != "persistent" { + return "", nil + } + if directFlag { + fmt.Fprintln(os.Stderr, + "[ctask] warning: --direct bypassing persistent mode (no tmux session exists for this workspace)") + return "", nil + } + return preflightPersistentEntry("new") +} diff --git a/cmd/new_persistent_test.go b/cmd/new_persistent_test.go new file mode 100644 index 0000000..015acae --- /dev/null +++ b/cmd/new_persistent_test.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "os" + "strings" + "testing" +) + +// Tests in this file mutate package globals (isTTYCheck, runWorkspaceEntry). +// Do not run with t.Parallel(). + +func TestNewDirectModeSkipsPreflight(t *testing.T) { + os.Unsetenv("CTASK_SESSION_MODE") + // Direct mode (default): preflight is a no-op even when --direct is unset. + if _, err := newPersistentPreflight(false); err != nil { + t.Errorf("direct mode preflight should be no-op: %v", err) + } +} + +func TestNewDirectFlagUnderPersistentEmitsWarningAndProceeds(t *testing.T) { + os.Setenv("CTASK_SESSION_MODE", "persistent") + t.Cleanup(func() { os.Unsetenv("CTASK_SESSION_MODE") }) + + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + orig := os.Stderr + os.Stderr = w + t.Cleanup(func() { os.Stderr = orig }) + + tmuxPath, err := newPersistentPreflight(true) // --direct + w.Close() + if err != nil { + t.Errorf("--direct under persistent should not error pre-create: %v", err) + } + if tmuxPath != "" { + t.Errorf("expected empty tmuxPath under --direct: got %q", tmuxPath) + } + buf := make([]byte, 4096) + n, _ := r.Read(buf) + if !strings.Contains(string(buf[:n]), "--direct bypassing") { + t.Errorf("expected bypass warning on stderr, got %q", string(buf[:n])) + } +} + +// The behavioral assertion that runNew forwards the workspace produced by +// workspace.Create into runWorkspaceEntry lives in cmd/entry_test.go +// (Task 10). It uses the entry seam directly; we don't duplicate that here.