packages feed

seihou-cli 0.12.1.0 → 0.13.0.0

raw patch · 12 files changed

+218/−16 lines, 12 filesdep ~seihou-corePVP ok

version bump matches the API change (PVP)

Dependency ranges changed: seihou-core

API changes (from Hackage documentation)

+ Seihou.CLI.InstallShared: writeOriginMeta :: FilePath -> Text -> Maybe Text -> Maybe Text -> [Text] -> IO ()

Files

data/module-reference.md view
@@ -177,3 +177,19 @@ `seihou upgrade` only refreshes the installed cache. For blocked updates, inspect `seihou agent upgrade TARGET --check` before requesting an agent repair. See `seihou help update`, `seihou help migrations`, and `seihou help manifest`.+++Recipe policy: defaults are overridable fallbacks. Literal member bindings outrank+ambient config and saved input; CLI still wins. Add a recipe input constraint to+reject disallowed overrides. For example, bind `git.githubOwner=topagentnetwork`+and declare `validation = Some "^topagentnetwork\$"`. Binding names allow+`[a-z][A-Za-z0-9.-]*`, including camelCase. Patterns are case-sensitive POSIX+extended regexes matching the whole text value with multiline anchors disabled;+`private|public|internal` accepts exactly those values. Malformed patterns fail,+even on absent optional inputs. Recipe and consumer constraints apply independently+to every selected source; errors name the original source and a supported+correction before generation or commands. Ordinary update revalidates saved inputs.++Recipe and blueprint producers using camelCase bindings or relying on POSIX+pattern enforcement require Seihou 0.13.0.0 or newer. Older runtimes can reject+those binding names or silently accept patterns they do not recognize.
data/upgrade-prompt.md view
@@ -173,10 +173,14 @@ module rather than a change to the project.  ### variable_errors and configuration_failed-A variable has no value or the configuration does not load. Supply values with-`--var KEY=VALUE` or `seihou config set KEY VALUE`, asking the user for anything-that is not already recorded. `seihou vars {{module}} --explain` shows where each-value comes from.+A variable is missing, cannot be converted, violates a declared constraint, or+the configuration does not load. Read the named variable, original source and+constraint in the error. Supply a permitted `--var KEY=VALUE` or correct the+configuration; a malformed pattern requires correcting the producer declaration.+Recipe constraints also validate explicit overrides and saved inputs. Personal+global preferences need no cleanup when a recipe binding supplies its own value.+Ask for required values that are not already recorded. Use+`seihou vars {{module}} --explain` to inspect resolution sources.  ### migration_plan_failed, migration_stage_failed, migration_failed, changed_after_migration_command A module migration between the recorded and the new version failed. The update
help/recipes.md view
@@ -104,3 +104,15 @@ `seihou run RECIPE` asks once; `seihou run RECIPE --var git.createGithub=false` supplies an explicit answer without a terminal. Modules declaring that name receive the answer above their own defaults.+++Recipe policy: defaults are overridable fallbacks. Literal member bindings outrank+ambient config and saved input; CLI still wins. Add a recipe input constraint to+reject disallowed overrides. For example, bind `git.githubOwner=topagentnetwork`+and declare `validation = Some "^topagentnetwork\$"`. Binding names allow+`[a-z][A-Za-z0-9.-]*`, including camelCase. Patterns are case-sensitive POSIX+extended regexes matching the whole text value with multiline anchors disabled;+`private|public|internal` accepts exactly those values. Malformed patterns fail,+even on absent optional inputs. Recipe and consumer constraints apply independently+to every selected source; errors name the original source and a supported+correction before generation or commands. Ordinary update revalidates saved inputs.
help/variables.md view
@@ -72,3 +72,15 @@ values during ordinary preview; conflicts require `--var KEY=VALUE` or `--reconfigure`. Saving prompted values to local config remains optional and respects `--save-prompted` and `--no-save-prompted`; previews do not save them.+++Recipe policy: defaults are overridable fallbacks. Literal member bindings outrank+ambient config and saved input; CLI still wins. Add a recipe input constraint to+reject disallowed overrides. For example, bind `git.githubOwner=topagentnetwork`+and declare `validation = Some "^topagentnetwork\$"`. Binding names allow+`[a-z][A-Za-z0-9.-]*`, including camelCase. Patterns are case-sensitive POSIX+extended regexes matching the whole text value with multiline anchors disabled;+`private|public|internal` accepts exactly those values. Malformed patterns fail,+even on absent optional inputs. Recipe and consumer constraints apply independently+to every selected source; errors name the original source and a supported+correction before generation or commands. Ordinary update revalidates saved inputs.
seihou-cli.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: seihou-cli-version: 0.12.1.0+version: 0.13.0.0 synopsis: CLI for Seihou project scaffolding description:   Command-line interface for Seihou, a composable project scaffolding@@ -144,7 +144,7 @@     generic-lens >=2.2 && <3,     lens >=5.2 && <6,     process >=1.6 && <2,-    seihou-core ^>=0.12.1.0,+    seihou-core ^>=0.13.0.0,     streamly-core >=0.3 && <0.5,     temporary >=1.3 && <2,     text >=2.0 && <3,@@ -232,7 +232,7 @@     optparse-applicative >=0.18 && <1,     process >=1.6 && <2,     seihou-cli-internal,-    seihou-core ^>=0.12.1.0,+    seihou-core ^>=0.13.0.0,     temporary >=1.3 && <2,     text >=2.0 && <3,     time >=1.12 && <2,@@ -328,7 +328,7 @@     lens >=5.2 && <6,     process >=1.6 && <2,     seihou-cli-internal,-    seihou-core ^>=0.12.1.0,+    seihou-core ^>=0.13.0.0,     streamly-core >=0.3 && <0.5,     tasty >=1.4 && <2,     tasty-hspec >=1.2 && <2,
src/Seihou/CLI/InstallShared.hs view
@@ -3,6 +3,7 @@     OriginInfo (..),     OriginMeta (..),     readOriginInfo,+    writeOriginMeta,      -- * Install collisions     InstallCollision (..),@@ -61,8 +62,8 @@ -- "Seihou.Core.ArtifactOriginDetect" because @seihou-core@ needs it to -- classify an artifact directory into a portable manifest origin and cannot -- depend on @seihou-cli-internal@. It is re-exported here so existing--- importers are unaffected. The write side below stays in the CLI, which is--- the only place that installs anything.+-- importers are unaffected. The write side below stays in the CLI, which+-- installs artifacts and stages fetched candidates for project updates.  -- | Write side of @.seihou-origin.json@. Captures everything 'seihou -- install' / 'seihou upgrade' want to record at install time, including@@ -86,6 +87,15 @@         "tags" .= (m ^. #tags)       ] +-- | Record the source used to obtain an artifact, replacing any metadata+-- carried by its files. Shared by installation and temporary update staging+-- so both supply the resolver's provenance checks with the same contract.+writeOriginMeta :: FilePath -> Text -> Maybe Text -> Maybe Text -> [Text] -> IO ()+writeOriginMeta directory source registryName artifactVersion artifactTags = do+  now <- getCurrentTime+  let origin = OriginMeta source registryName (T.pack (iso8601Show now)) artifactVersion artifactTags+  LBS.writeFile (directory </> ".seihou-origin.json") (encodePretty origin)+ -- ---------------------------------------------------------------------------- -- Install collisions -- ----------------------------------------------------------------------------@@ -374,9 +384,7 @@       createDirectoryIfMissing True installDir       copyDirectoryRecursive moduleDir installDir -      now <- getCurrentTime-      let origin = OriginMeta source registryName (T.pack (iso8601Show now)) moduleVersion moduleTags-      LBS.writeFile (installDir </> ".seihou-origin.json") (encodePretty origin)+      writeOriginMeta installDir source registryName moduleVersion moduleTags       pure InstallPerformed  -- | Recursively copy a directory tree, excluding the @.git@ directory.
src/Seihou/CLI/Update.hs view
@@ -1050,7 +1050,7 @@               + length                 [ ()                 | (instanceId, name, value) <- resolvedList,-                  value ^. #source == FromCLI,+                  value ^. #source `elem` [FromCLI, FromRecipeBinding],                   Map.member name (Map.findWithDefault Map.empty instanceId prior)                 ]           newCount =
src/Seihou/CLI/Update/Source.hs view
@@ -5,7 +5,7 @@ where  import Control.Exception (SomeException, displayException, try)-import Control.Monad (foldM, forM)+import Control.Monad (foldM, forM, forM_) import Data.ByteString qualified as BS import Data.Foldable (traverse_) import Data.Generics.Labels ()@@ -16,6 +16,7 @@   ( OriginInfo (..),     cloneRepo,     copyDirectoryRecursive,+    writeOriginMeta,   ) import Seihou.CLI.Update.Types import Seihou.Core.ArtifactIdentity (normalizeOriginUrl)@@ -318,7 +319,13 @@     Nothing -> do       let destination = searchRoot </> T.unpack (candidate ^. #name)       Directory.createDirectoryIfMissing True destination-      copied <- try @SomeException (copyDirectoryRecursive (candidate ^. #sourceDirectory) destination)+      copied <- try @SomeException $ do+        copyDirectoryRecursive (candidate ^. #sourceDirectory) destination+        -- Resolution reads provenance from the staged directory, not from+        -- CandidateCatalog. Use the fetched URL, overriding any origin file+        -- in the repository; local fallbacks get no invented remote origin.+        forM_ (candidate ^. #sourceUrl) $ \source ->+          writeOriginMeta destination source (candidate ^. #repoName) (candidate ^. #version) (candidate ^. #tags)       pure $ case copied of         Left err -> Left (CandidateRepositoryInvalid (candidate ^. #name) [T.pack (displayException err)])         Right () ->
test/Seihou/CLI/RecipeInputsE2ESpec.hs view
@@ -31,8 +31,91 @@   TIO.writeFile (recipe </> "recipe.dhall") "let S = ../demo/schema/package.dhall in S.Recipe::{name=\"recipe-input-probe\",version=Some \"1.0.0\",modules=[S.Dependency::{module=\"demo\"},S.Dependency::{module=\"other\"}],vars=[S.VarDecl::{name=\"feature\",type=\"bool\",required=True}],prompts=[S.Prompt::{var=\"feature\",text=\"Enable feature?\"}]}"   pure binary +-- A harmless command records its rendered destination. Rejected runs must+-- preserve both generated bytes and this command log, including on update.+prepareOwnerPolicy root binding recipePolicy memberPolicy = do+  (binary, modul) <- prepare root+  let recipe = root </> ".seihou/modules/owner-policy"+      constraint enabled = if enabled then ",validation=Some \"^topagentnetwork\\$\"" else ""+      declaration enabled = "S.VarDecl::{name=\"git.githubOwner\",type=\"text\",default=Some \"topagentnetwork\"" <> constraint enabled <> "}"+      writeRecipe bound constrained =+        TIO.writeFile (recipe </> "recipe.dhall") $+          "let S = ../demo/schema/package.dhall in S.Recipe::{name=\"owner-policy\",version=Some \"1.0.0\",modules=[S.Dependency::{module=\"demo\""+            <> (if bound then ",vars=[{name=\"git.githubOwner\",value=\"topagentnetwork\"}]" else "")+            <> "}],vars=["+            <> declaration constrained+            <> "]}"+  D.createDirectoryIfMissing True recipe+  TIO.writeFile (modul </> "module.dhall") $+    "let S = ./schema/package.dhall in S.Module::{name=\"demo\",version=Some \"1.0.0\",vars=[" <> declaration memberPolicy <> "],steps=[S.Step::{strategy=\"template\",src=\"owner\",dest=\"owner.txt\"}],commands=[S.Command::{action=S.Command.Action.Apply \"printf '%s\\n' '{{git.githubOwner}}/probe' >> command-log\"}]}"+  TIO.writeFile (modul </> "files/owner") "{{git.githubOwner}}"+  writeRecipe binding recipePolicy+  pure (binary, writeRecipe)+ tests :: IO TestTree tests = testSpec "Recipe inputs" $ do+  it "uses a camelCase owner binding above global config and rejects CLI violations before effects" $+    withSystemTempDirectory "owner-policy" $ \root -> do+      (binary, _) <- prepareOwnerPolicy root True True False+      _ <- success binary root ["config", "set", "git.githubOwner", "someone-else", "--global"]+      _ <- success binary root ["validate-recipe", root </> ".seihou/modules/owner-policy"]+      _ <- success binary root ["run", "owner-policy", "--dry-run"]+      D.doesPathExist (root </> "owner.txt") `shouldReturn` False+      D.doesPathExist (root </> "command-log") `shouldReturn` False+      (code, out, err) <- invoke binary root ["run", "owner-policy", "--var", "git.githubOwner=someone-else"]+      code `shouldBe` ExitFailure 1+      let message = T.pack (out <> err)+      mapM_ (\needle -> message `shouldSatisfy` T.isInfixOf needle) ["git.githubOwner", "--var", "^topagentnetwork$", "--var git.githubOwner=topagentnetwork"]+      D.doesPathExist (root </> "owner.txt") `shouldReturn` False+      D.doesPathExist (root </> "command-log") `shouldReturn` False+      D.doesPathExist (root </> ".seihou/manifest.json") `shouldReturn` False+      D.doesPathExist (root </> ".seihou/config.dhall") `shouldReturn` False+      _ <- success binary root ["run", "owner-policy"]+      TIO.readFile (root </> "owner.txt") `shouldReturn` "topagentnetwork"+      TIO.readFile (root </> "command-log") `shouldReturn` "topagentnetwork/probe\n"+      before <- LBS.readFile (root </> ".seihou/manifest.json")+      forM_ [[], ["--reconfigure"], ["--json"], ["--reconfigure", "--json"]] $ \extra -> do+        (refused, stdout, stderr) <- invoke binary root (["update", "owner-policy", "--var", "git.githubOwner=someone-else"] <> extra)+        refused `shouldBe` ExitFailure 1+        let output = T.pack (stdout <> stderr)+        output `shouldSatisfy` T.isInfixOf "git.githubOwner"+        if "--json" `elem` extra then output `shouldSatisfy` T.isInfixOf "\"code\":\"variable_errors\"" else pure ()+        LBS.readFile (root </> ".seihou/manifest.json") `shouldReturn` before+        TIO.readFile (root </> "owner.txt") `shouldReturn` "topagentnetwork"+        TIO.readFile (root </> "command-log") `shouldReturn` "topagentnetwork/probe\n"+  forM_ [True, False] $ \recipePolicy ->+    it ("revalidates historical personal owner independently; recipe policy=" <> show recipePolicy) $+      withSystemTempDirectory "owner-history" $ \root -> do+        (binary, writeRecipe) <- prepareOwnerPolicy root False False False+        _ <- success binary root ["run", "owner-policy", "--var", "git.githubOwner=someone-else"]+        if recipePolicy+          then writeRecipe False True+          else do+            let modul = root </> ".seihou/modules/demo/module.dhall"+            body <- TIO.readFile modul+            TIO.writeFile modul (T.replace "default=Some \"topagentnetwork\"" "default=Some \"topagentnetwork\",validation=Some \"^topagentnetwork\\$\"" body)+        before <- LBS.readFile (root </> ".seihou/manifest.json")+        (code, out, err) <- invoke binary root ["update", "owner-policy"]+        code `shouldBe` ExitFailure 1+        T.pack (out <> err) `shouldSatisfy` T.isInfixOf "application"+        TIO.readFile (root </> "owner.txt") `shouldReturn` "someone-else"+        TIO.readFile (root </> "command-log") `shouldReturn` "someone-else/probe\n"+        LBS.readFile (root </> ".seihou/manifest.json") `shouldReturn` before+        -- Adoption owns replacing old accepted input when the producer adds a binding.+        writeRecipe True True+        output <- success binary root ["update", "owner-policy", "--json"]+        value <- TIO.readFile (root </> "owner.txt")+        if value == "topagentnetwork" then pure () else expectationFailure (T.unpack output)+  it "validates global config against a consuming module with the original source" $+    withSystemTempDirectory "owner-consumer" $ \root -> do+      (binary, _) <- prepareOwnerPolicy root False False True+      _ <- success binary root ["config", "set", "git.githubOwner", "someone-else", "--global"]+      (code, out, err) <- invoke binary root ["run", "owner-policy"]+      code `shouldBe` ExitFailure 1+      T.pack (out <> err) `shouldSatisfy` T.isInfixOf "global config"+      D.doesPathExist (root </> "owner.txt") `shouldReturn` False+      D.doesPathExist (root </> "command-log") `shouldReturn` False+   it "refuses missing recipe decisions before writing files or manifest" $     withSystemTempDirectory "recipe-missing" $ \root -> do       binary <- prepareRecipe root
test/Seihou/CLI/UpdateE2ESpec.hs view
@@ -15,6 +15,7 @@     UpdateFixture (..),     installBetaVersion,     prepareSharedPathFixture,+    prepareSourcePinnedUpdateFixture,     prepareUpdateFixture,     publishBetaReleases,   )@@ -35,6 +36,31 @@  spec :: Spec spec = do+  it "updates a source-pinned dependency using its fetched provenance" $+    withSystemTempDirectory "seihou-update-source-pinned" $ \root -> do+      fixture <- prepareSourcePinnedUpdateFixture root Nothing+      binary <- seihouBinary+      TIO.writeFile (fixture ^. #projectFile) "hello accepted by-user\nkeep\nv1\n"+      (exitCode, stdoutText, stderrText) <- runSeihou binary fixture ["update", "demo", "--json"]+      case exitCode of+        ExitSuccess -> pure ()+        ExitFailure code -> expectationFailure ("update exited " <> show code <> "\nstdout:\n" <> T.unpack stdoutText <> "\nstderr:\n" <> T.unpack stderrText)+      TIO.readFile (fixture ^. #projectFile) `shouldReturn` "hello accepted by-user\nkeep\nv2\n"++  it "rejects a source-pinned dependency fetched from another source without changing the project or cache" $+    withSystemTempDirectory "seihou-update-source-mismatch" $ \root -> do+      fixture <- prepareSourcePinnedUpdateFixture root (Just "https://example.com/required-registry.git")+      binary <- seihouBinary+      beforeProject <- LBS.readFile (fixture ^. #projectFile)+      beforeManifest <- LBS.readFile (fixture ^. #manifestPath)+      beforeInstalled <- LBS.readFile (fixture ^. #installedModule </> "module.dhall")+      (exitCode, stdoutText, _) <- runSeihou binary fixture ["update", "demo", "--json"]+      exitCode `shouldBe` ExitFailure 1+      stdoutText `shouldSatisfy` T.isInfixOf "different or unknown provenance"+      LBS.readFile (fixture ^. #projectFile) `shouldReturn` beforeProject+      LBS.readFile (fixture ^. #manifestPath) `shouldReturn` beforeManifest+      LBS.readFile (fixture ^. #installedModule </> "module.dhall") `shouldReturn` beforeInstalled+   it "updates through the executable and preserves a non-overlapping user edit" $     withSystemTempDirectory "seihou-update-executable" $ \root -> do       fixture <- prepareUpdateFixture root
test/Seihou/CLI/UpdateSpec.hs view
@@ -2,6 +2,7 @@   ( tests,     UpdateFixture (..),     prepareUpdateFixture,+    prepareSourcePinnedUpdateFixture,     SharedPathFixture (..),     CoOwnerWriteMode (..),     prepareSharedPathFixture,@@ -780,6 +781,38 @@     baselineContent   LBS.writeFile manifestPath (manifestToJSON manifest)   pure UpdateFixture {projectRoot, projectFile, manifestPath, xdgHome, installedModule, remote, applicationId}++-- | Two fetched repositories with a source-pinned edge between them. The+-- dependency deliberately carries stale origin metadata in its Git content;+-- staging must record the fetched source rather than trust that file.+prepareSourcePinnedUpdateFixture :: FilePath -> Maybe Text -> IO UpdateFixture+prepareSourcePinnedUpdateFixture root requiredSource = do+  fixture <- prepareUpdateFixture root+  let dependencyRemote = root </> "shared-remote"+      sourceUrl = T.pack dependencyRemote+      sharedOrigin = RemoteOrigin sourceUrl "shared" Nothing+      dependency =+        "dependencies = [{ module = \"shared\", source = Some \""+          <> fromMaybe sourceUrl requiredSource+          <> "\", vars = [] : List { name : Text, value : Text } }]"+      demoFile = fixture ^. #remote </> "module.dhall"+  createDirectoryIfMissing True dependencyRemote+  TIO.writeFile (dependencyRemote </> "module.dhall") (moduleDhall "shared" "2.0.0")+  writeOrigin "https://example.com/stale-origin.git" dependencyRemote+  callProcess "git" ["-C", dependencyRemote, "init", "-q"]+  callProcess "git" ["-C", dependencyRemote, "add", "."]+  callProcess "git" ["-C", dependencyRemote, "-c", "user.name=Seihou Test", "-c", "user.email=test@example.com", "commit", "-qm", "shared v2"]+  demo <- TIO.readFile demoFile+  TIO.writeFile demoFile (T.replace "dependencies = [] : List Text" dependency demo)+  callProcess "git" ["-C", fixture ^. #remote, "add", "."]+  callProcess "git" ["-C", fixture ^. #remote, "-c", "user.name=Seihou Test", "-c", "user.email=test@example.com", "commit", "-qm", "pin shared source"]+  manifest <- either fail pure . manifestFromJSON =<< LBS.readFile (fixture ^. #manifestPath)+  let updated =+        manifest+          & #applications .~ map (\app -> app & #instances .~ (app ^. #instances <> [instanceStateFrom "shared" sharedOrigin])) (manifest ^. #applications)+          & #modules .~ (manifest ^. #modules <> [AppliedModule "shared" emptyParentVars sharedOrigin (Just "1.0.0") testTime Nothing])+  LBS.writeFile (fixture ^. #manifestPath) (manifestToJSON updated)+  pure fixture  -- | How the co-owning application @beta@ writes the shared @.gitignore@. data CoOwnerWriteMode
test/Seihou/CLI/ValidateRecipeE2ESpec.hs view
@@ -133,6 +133,7 @@     ("invalid type", replace "type = \"bool\"" "type = \"mystery\"", ["type", "mystery"]),     ("invalid nested type", replace "type = \"bool\"" "type = \"list mystery\"", ["type", "mystery"]),     ("invalid default", replace "type = \"bool\", default = None Text" "type = \"bool\", default = Some \"invalid\"", ["default", "github.enabled"]),+    ("malformed optional pattern", replace "required = True, validation = None Text } ]" "required = False, validation = Some \"[\" } ]", ["vars", "github.owner", "invalid pattern"]),     ("invalid condition syntax", replace "Eq github.enabled true" "Eq github.enabled", ["prompts", "github.owner", "when"]),     ("empty members", replace "modules = [{module = \"demo\", vars = [{name = \"member.only\", value = \"literal\"}]}]" "modules = [] : List Text", ["modules", "at least one"]),     ("invalid name", replace "name = \"my-stack\"" "name = \"BadName\"", ["name", "BadName"]),