@@ -7,6 +7,7 @@ module PkgCommands
77 , pkgUpgradeCommand
88 , pkgUpdateCommand
99 , pkgSearchCommand
10+ , artifactCommand
1011 , zigPkgAddCommand
1112 , zigPkgRemoveCommand
1213 , PackageEntry (.. )
@@ -21,6 +22,7 @@ module PkgCommands
2122
2223import Prelude hiding (readFile , writeFile )
2324
25+ import qualified Acton.Artifact as Artifact
2426import qualified Acton.BuildSpec as BuildSpec
2527import qualified Acton.CommandLineParser as C
2628import Acton.Compile (loadBuildSpec , throwProjectError )
@@ -30,7 +32,7 @@ import Control.Concurrent (threadDelay)
3032import Control.Monad (filterM , forM , forM_ , unless , when )
3133import Data.Char (isHexDigit , isSpace )
3234import Data.Foldable (toList )
33- import Data.List (dropWhileEnd , isPrefixOf , isSuffixOf , sortOn )
35+ import Data.List (dropWhileEnd , isPrefixOf , isSuffixOf , sort , sortOn )
3436import Data.List.Split (splitOn )
3537import Data.Maybe (isJust )
3638import qualified Data.Map as M
@@ -44,11 +46,12 @@ import Network.HTTP.Client (Manager, Response, httpLbs, parseRequest, requestHea
4446import Network.HTTP.Client.TLS (newTlsManager )
4547import Network.HTTP.Types.Header (Header )
4648import Network.HTTP.Types.Status (statusCode )
47- import System.Directory (Permissions , canonicalizePath , copyFile , createDirectoryIfMissing , doesDirectoryExist , doesFileExist , doesPathExist , getCurrentDirectory , getHomeDirectory , getPermissions , listDirectory , removeFile , setPermissions )
49+ import System.Directory (Permissions , canonicalizePath , copyFile , copyFileWithMetadata , createDirectoryIfMissing , doesDirectoryExist , doesFileExist , doesPathExist , getCurrentDirectory , getHomeDirectory , getPermissions , listDirectory , makeAbsolute , pathIsSymbolicLink , removeFile , setPermissions )
4850import System.Environment (getExecutablePath , lookupEnv )
4951import System.Exit (ExitCode (.. ))
5052import System.FilePath ((</>) , takeDirectory )
5153import System.IO (IOMode (ReadMode , WriteMode ), hClose , hGetContents , hPutStr , hPutStrLn , hSetEncoding , openFile , stderr , utf8 )
54+ import System.IO.Temp (withSystemTempDirectory )
5255import System.Process (CreateProcess (cwd ), proc , readCreateProcessWithExitCode )
5356import qualified Text.Regex.TDFA as TDFA
5457
@@ -303,6 +306,179 @@ pkgSearchCommand _ opts = do
303306 then putStrLn " No packages matched your search."
304307 else forM_ (sortOn pkgName matched) printPkg
305308
309+ artifactCommand :: C. GlobalOptions -> C. ArtifactCommand -> IO ()
310+ artifactCommand gopts cmd =
311+ case cmd of
312+ C. ArtifactPack opts ->
313+ packActonArtifact gopts (C. artifactPackOutput opts)
314+ C. ArtifactPush opts ->
315+ pushActonArtifact gopts opts
316+ C. ArtifactHash opts ->
317+ printActonArtifactHash (C. artifactHashSourcePath opts)
318+
319+ packActonArtifact :: C. GlobalOptions -> String -> IO ()
320+ packActonArtifact gopts outputArg = do
321+ sourceHash <- computeArtifactSourceHash " ."
322+ output <- packActonArtifactTo sourceHash outputArg
323+ unless (C. quiet gopts) $
324+ putStrLn (" Wrote Acton artifact " ++ output)
325+
326+ pushActonArtifact :: C. GlobalOptions -> C. ArtifactPushOptions -> IO ()
327+ pushActonArtifact gopts opts = do
328+ sourceHash <- computeArtifactSourceHash " ."
329+ ref0 <- resolveArtifactRef sourceHash
330+ (C. artifactPushRepoUrl opts)
331+ (C. artifactPushArtifactRepo opts)
332+ ref <- absolutizeLocalArtifactRef ref0
333+ withSystemTempDirectory " acton-artifact-push" $ \ tmp -> do
334+ let archive = tmp </> Artifact. artifactArchiveFile
335+ _ <- packActonArtifactTo sourceHash archive
336+ runProcessChecked (Just tmp) " oras"
337+ ([" push" ]
338+ ++ Artifact. ociRefOrasOptions ref
339+ ++ [ " --artifact-type" , Artifact. artifactType
340+ , Artifact. ociRefOrasTarget ref
341+ , Artifact. artifactArchiveFile ++ " :" ++ Artifact. artifactMediaType
342+ ])
343+ unless (C. quiet gopts) $
344+ putStrLn (" Pushed Acton artifact " ++ ref)
345+
346+ packActonArtifactTo :: String -> String -> IO FilePath
347+ packActonArtifactTo sourceHash outputArg = do
348+ cwd <- getCurrentDirectory
349+ let output0 = if null outputArg then " out" </> Artifact. artifactArchiveFile else outputArg
350+ createDirectoryIfMissing True (takeDirectory output0)
351+ withSystemTempDirectory " acton-artifact-pack" $ \ tmp -> do
352+ Artifact. writeManifest tmp (Artifact. expectedManifest sourceHash)
353+ runProcessChecked Nothing " tar"
354+ [ " -czf" , output0
355+ , " -C" , tmp, Artifact. artifactManifestFile
356+ , " -C" , cwd, " Build.act" , " out/types"
357+ ]
358+ return output0
359+
360+ printActonArtifactHash :: String -> IO ()
361+ printActonArtifactHash source = do
362+ h <- computeArtifactSourceHash source
363+ putStrLn h
364+
365+ computeArtifactSourceHash :: FilePath -> IO String
366+ computeArtifactSourceHash source = do
367+ isDir <- doesDirectoryExist source
368+ unless isDir $
369+ throwProjectError (" ERROR: Artifact source hash expects a package directory: " ++ source)
370+ zigExe <- getZigExe
371+ withSystemTempDirectory " acton-artifact-source" $ \ tmp -> do
372+ let staged = tmp </> " source"
373+ -- TODO(source-hash): Avoid staging a copied package tree here.
374+ --
375+ -- This copy is intentionally simple and conservative, but source hashing is
376+ -- foundational identity machinery and should eventually be computed directly
377+ -- from the package directory. The desired implementation is an Acton-owned
378+ -- package hash walker that follows Zig's package hashing semantics exactly
379+ -- rather than invoking `zig fetch` on a temporary copy.
380+ --
381+ -- Requirements for that replacement:
382+ --
383+ -- * Keep the source boundary independent of git or any other VCS.
384+ -- * Preserve the current Acton package selection rules: include ordinary
385+ -- package inputs, exclude local/generated state such as out/, build.zig,
386+ -- build.zig.zon, lock files, VCS dirs, caches, and artifact files.
387+ -- * Match Zig's path normalization, directory ordering, file metadata/mode
388+ -- treatment, digest algorithm, and final package-hash text encoding.
389+ -- * Keep symlink behavior explicit. Today symlinks are rejected rather than
390+ -- guessed; any future support must match Zig and have test coverage.
391+ -- * Add stable fixture tests that compare Acton's in-place implementation
392+ -- with `zig fetch` for representative package trees before switching over.
393+ --
394+ -- Until then, copying to a clean temp directory keeps generated build output
395+ -- out of the hash while delegating the actual hash algorithm to Zig.
396+ copyCanonicalSource source staged
397+ requireRightWith " ERROR: Failed to compute source hash: " =<< zigFetchHash zigExe staged
398+
399+ copyCanonicalSource :: FilePath -> FilePath -> IO ()
400+ copyCanonicalSource src dst =
401+ copyCanonicalSourceAt [] src dst
402+
403+ copyCanonicalSourceAt :: [FilePath ] -> FilePath -> FilePath -> IO ()
404+ copyCanonicalSourceAt rel src dst = do
405+ createDirectoryIfMissing True dst
406+ entries <- sort <$> listDirectory src
407+ forM_ entries $ \ entry ->
408+ let rel' = rel ++ [entry]
409+ in unless (skipLocalSourceEntry rel') $ do
410+ let srcEntry = src </> entry
411+ dstEntry = dst </> entry
412+ isSymlink <- pathIsSymbolicLink srcEntry
413+ when isSymlink $
414+ throwProjectError (" ERROR: Artifact source hash does not support symbolic links: " ++ srcEntry)
415+ isDir <- doesDirectoryExist srcEntry
416+ if isDir
417+ then copyCanonicalSourceAt rel' srcEntry dstEntry
418+ else do
419+ createDirectoryIfMissing True (takeDirectory dstEntry)
420+ copyFileWithMetadata srcEntry dstEntry
421+
422+ skipLocalSourceEntry :: [FilePath ] -> Bool
423+ skipLocalSourceEntry [] = False
424+ skipLocalSourceEntry rel =
425+ entry `elem`
426+ [ " .git"
427+ , " .hg"
428+ , " .svn"
429+ , " .DS_Store"
430+ , " .zig-cache"
431+ , " zig-cache"
432+ ] ||
433+ rel `elem`
434+ [ [" .acton.lock" ]
435+ , [" .acton.compile.lock" ]
436+ , [" .acton" ]
437+ , [" .acton.cache" ]
438+ , [" .actonc.lock" ]
439+ , [" .build" ]
440+ , [" out" ]
441+ , [" build.zig" ]
442+ , [" build.zig.zon" ]
443+ , [Artifact. artifactManifestFile]
444+ , [Artifact. artifactArchiveFile]
445+ ]
446+ where
447+ entry = last rel
448+
449+ absolutizeLocalArtifactRef :: String -> IO String
450+ absolutizeLocalArtifactRef ref
451+ | Artifact. ociRefIsLocal ref =
452+ case splitLocalOciTarget (Artifact. ociRefOrasTarget ref) of
453+ Just (path, tag) -> do
454+ path' <- makeAbsolute path
455+ return (" oci-layout://" ++ path' ++ " :" ++ tag)
456+ Nothing -> return ref
457+ | otherwise = return ref
458+
459+ splitLocalOciTarget :: String -> Maybe (FilePath , String )
460+ splitLocalOciTarget target =
461+ case break (== ' :' ) (reverse target) of
462+ (revTag, ' :' : revPath)
463+ | not (null revTag) && not (null revPath) ->
464+ Just (reverse revPath, reverse revTag)
465+ _ -> Nothing
466+
467+ resolveArtifactRef :: String -> String -> String -> IO String
468+ resolveArtifactRef sourceHash repoUrl artifactRepo
469+ | not (null artifactRepo) =
470+ case Artifact. ociRefForRepository artifactRepo sourceHash of
471+ Just ref -> return ref
472+ Nothing ->
473+ throwProjectError (" ERROR: Invalid OCI artifact repository " ++ artifactRepo)
474+ | not (null repoUrl) =
475+ case Artifact. deriveOciRef repoUrl sourceHash of
476+ Just ref -> return ref
477+ Nothing ->
478+ throwProjectError (" ERROR: Could not derive OCI artifact ref from " ++ repoUrl)
479+ | otherwise =
480+ throwProjectError " ERROR: Specify --artifact-repo or --repo-url for artifact push"
481+
306482zigPkgAddCommand :: C. GlobalOptions -> C. ZigPkgAddOptions -> IO ()
307483zigPkgAddCommand _ opts = do
308484 let depName = C. zigPkgAddName opts
0 commit comments