Chandra

 view release on metacpan or  search on metacpan

lib/Chandra/Pack.pm  view on Meta::CPAN

    }
    
    # Setter: config(key => val, ...)
    my %args = ref $_[0] eq 'HASH' ? %{$_[0]} : @_;
    
    # Load from env vars if not provided
    $args{identity}        //= $ENV{CHANDRA_IDENTITY};
    $args{apple_id}        //= $ENV{CHANDRA_APPLE_ID};
    $args{team_id}         //= $ENV{CHANDRA_TEAM_ID};
    $args{notary_keychain} //= $ENV{CHANDRA_NOTARY_KEYCHAIN};
    $args{sign_cert}       //= $ENV{CHANDRA_SIGN_CERT};
    $args{sign_password}   //= $ENV{CHANDRA_SIGN_PASSWORD};
    
    for my $key (keys %args) {
        if (exists $CONFIG{$key}) {
            $CONFIG{$key} = $args{$key};
        } else {
            Carp::carp("Unknown config key: $key");
        }
    }
    
    # Save to file if requested
    if (delete $args{save}) {
        _save_config();
    }
    
    return $class;
}

sub _config_file {
    my $home = $ENV{HOME};
    if (!$home && $^O ne 'MSWin32') {
        $home = (getpwuid($<))[7];
    }
    $home ||= $ENV{USERPROFILE} || '.';
    return file_join($home, '.chandra', 'pack.conf');
}

sub _load_config {
    my $file = _config_file();
    return unless file_is_file($file);
    my $content = file_slurp($file);
    for my $line (split /\n/, $content) {
        next if $line =~ /^\s*#/ || $line =~ /^\s*$/;
        if ($line =~ /^(\w+)\s*=\s*(.*)$/) {
            $CONFIG{$1} = $2 if exists $CONFIG{$1};
        }
    }
}

sub _save_config {
    my $file = _config_file();
    my $dir = file_dirname($file);
    file_mkpath($dir) unless file_is_dir($dir);
    my $content = "# Chandra::Pack configuration\n";
    for my $key (sort keys %CONFIG) {
        next unless defined $CONFIG{$key};
        $content .= "$key = $CONFIG{$key}\n";
    }
    file_spew($file, $content);
    chmod 0600, $file;  # Protect credentials
}

# Load config on module load
_load_config();

sub new {
    my ($class, %args) = @_;
    Carp::croak("'script' is required") unless $args{script};
    Carp::croak("Script '$args{script}' not found") unless file_exists($args{script});
    Carp::croak("Script '$args{script}' is not a file") unless file_is_file($args{script});

    my $name = $args{name} || _name_from_script($args{script});
    bless {
        script     => Cwd::abs_path($args{script}),
        name       => $name,
        version    => $args{version}    || '0.0.1',
        icon       => $args{icon},
        assets     => $args{assets},
        output     => $args{output}     || '.',
        platform   => $args{platform}   || _detect_platform(),
        identifier => $args{identifier} || _default_identifier($name),
        perl       => $args{perl}       || $^X,
        include    => $args{include}    || [],
        exclude    => $args{exclude}    || [],
        distribute => $args{distribute} || 0,
        _deps      => undef,
    }, $class;
}

# ── Accessors ────────────────────────────────────────────────────────

sub script     { $_[0]->{script} }
sub name       { $_[0]->{name} }
sub version    { $_[0]->{version} }
sub icon       { $_[0]->{icon} }
sub assets     { $_[0]->{assets} }
sub output     { $_[0]->{output} }
sub platform   { $_[0]->{platform} }
sub identifier { $_[0]->{identifier} }
sub perl       { $_[0]->{perl} }
sub distribute { $_[0]->{distribute} }

# ── Dependency Scanning ──────────────────────────────────────────────

sub scan_deps {
    my ($self) = @_;
    return @{ $self->{_deps} } if $self->{_deps};

    my %seen;
    my %exclude = map { $_ => 1 } @{ $self->{exclude} };
    my @queue = ($self->{script});
    my @deps;

    # Add explicit includes
    for my $mod (@{ $self->{include} }) {
        my $file = _mod_to_file($mod);
        my $path = _find_in_inc($file);
        if ($path) {
            push @deps, { module => $mod, file => $path };
            push @queue, $path;

lib/Chandra/Pack.pm  view on Meta::CPAN

    return 1 if $mod =~ /^(strict|warnings|utf8|constant|feature|overload|overloading|lib|vars|base|parent|fields|integer|bigint|bignum|bigrat|bytes|charnames|diagnostics|encoding|if|less|locale|mro|open|ops|re|sigtrap|sort|subs|threads|threads::shar...
    return 1 if $mod =~ /^[a-z]/; # convention: pragmas are lowercase
    return 0;
}

sub _name_from_script {
    my ($script) = @_;
    my $base = file_basename($script);
    $base =~ s/\.pl$//;
    $base =~ s/[_-]/ /g;
    return ucfirst($base);
}

sub _safe_name {
    my ($name) = @_;
    $name =~ s/[^a-zA-Z0-9_]//g;
    return $name || 'App';
}

sub _default_identifier {
    my ($name) = @_;
    my $safe = lc(_safe_name($name));
    return "org.perl.$safe";
}

sub _detect_platform {
    return 'macos'   if $^O eq 'darwin';
    return 'windows' if $^O eq 'MSWin32';
    return 'linux';
}

sub _dir_size {
    my ($dir) = @_;
    return 0 unless file_is_dir($dir);
    my $total = 0;
    my $entries = file_readdir($dir);
    for my $e (@$entries) {
        next if $e eq '.' || $e eq '..';
        my $path = file_join($dir, $e);
        if (file_is_dir($path)) {
            $total += _dir_size($path);
        } else {
            $total += file_size($path);
        }
    }
    return $total;
}

1;

__END__

=head1 NAME

Chandra::Pack - Bundle Chandra apps into distributable packages

=head1 SYNOPSIS

    use Chandra::Pack;

    # Configure credentials (once, persists to ~/.chandra/pack.conf)
    Chandra::Pack->config(
        identity    => 'Developer ID Application: Your Name',
        apple_id    => 'you@example.com',
        team_id     => 'ABCD1234',
        save        => 1,
    );

    my $packer = Chandra::Pack->new(
        script     => 'app.pl',
        name       => 'My App',
        version    => '1.0.0',
        icon       => 'icon.png',
        assets     => 'assets/',
        output     => 'dist/',
        identifier => 'com.example.myapp',
        distribute => 1,  # Full release pipeline
    );

    # Build with distribution (sign, notarize, DMG on macOS; AppImage on Linux)
    $packer->build(sub {
        my ($result) = @_;
        print "Built: $result->{path}\n" if $result->{success};
        print "DMG: $result->{dmg_path}\n" if $result->{dmg_path};
    });

=head1 DESCRIPTION

Chandra::Pack bundles a Perl script and its dependencies into a
distributable application package. It creates C<.app> bundles on macOS,
AppImage-style directories on Linux, and portable directories on Windows.

When C<distribute =E<gt> 1> is set, the full release pipeline runs:

=over 4

=item * B<macOS>: codesign → notarize → staple → DMG

=item * B<Linux>: AppImage (via appimagetool)

=item * B<Windows>: Directory build (installer support planned)

=back

=head1 CLASS METHODS

=head2 config(%args)

Configure credentials for signing and notarization. Call once before
building. Settings can be persisted to C<~/.chandra/pack.conf>.

    Chandra::Pack->config(
        # macOS signing/notarization
        identity         => 'Developer ID Application: ...',
        apple_id         => 'your@email.com',
        team_id          => 'ABCD1234',
        notary_keychain  => 'notary-profile',  # from notarytool store-credentials
        
        # Persist to disk
        save             => 1,
    );

Environment variables are used as fallbacks:

    CHANDRA_IDENTITY
    CHANDRA_APPLE_ID
    CHANDRA_TEAM_ID
    CHANDRA_NOTARY_KEYCHAIN
    CHANDRA_NOTARY_PASSWORD

=head1 INSTANCE METHODS

=head2 new(%args)

    my $packer = Chandra::Pack->new(
        script     => 'app.pl',       # required
        name       => 'My App',       # default: derived from script name
        version    => '1.0.0',        # default: 0.0.1
        icon       => 'icon.png',     # optional
        assets     => 'assets/',      # optional
        output     => 'dist/',        # default: .
        platform   => 'macos',        # default: current platform
        identifier => 'com.x.myapp',  # default: org.perl.<name>
        perl       => '/usr/bin/perl',# default: current perl
        include    => ['DBI'],        # extra modules to include
        exclude    => ['Test::More'], # modules to skip
        distribute => 1,              # run full distribution pipeline
    );

=head2 scan_deps

Returns a list of hashrefs with C<module> and C<file> keys for all
detected dependencies.

=head2 build($callback)

Build for the configured platform. Calls C<$callback> with a result
hashref containing C<success>, C<path>, C<platform>, and C<size>.

When C<distribute =E<gt> 1>:

=over 4

=item * macOS: adds C<signed>, C<notarized>, C<dmg_path>

=item * Linux: adds C<appimage_path>

=back

=head2 build_macos

Build a macOS C<.app> bundle. With C<distribute =E<gt> 1>, also signs,
notarizes, and creates a DMG.

=head2 build_linux

Build a Linux AppImage-style directory. With C<distribute =E<gt> 1>,
runs appimagetool to create a standalone AppImage.

=head2 build_windows

Build a Windows portable directory.

=head1 DISTRIBUTION DETAILS

=head2 macOS Code Signing

Uses hardened runtime with entitlements for JIT and unsigned memory
(required for Perl). Ad-hoc signing (C<identity =E<gt> '-'>) skips
notarization but still works locally.

=head2 macOS Notarization

Requires Apple Developer account. Store credentials once with:

    xcrun notarytool store-credentials notary-profile \
        --apple-id your@email.com \
        --team-id ABCD1234 \
        --password your-app-specific-password

Then configure:

    Chandra::Pack->config(notary_keychain => 'notary-profile');

=head2 Linux AppImage

Requires C<appimagetool> in PATH. Install from:
L<https://github.com/AppImage/AppImageKit/releases>

=head1 SEE ALSO

L<Chandra::App>

=cut



( run in 1.112 second using v1.01-cache-2.11-cpan-140bd7fdf52 )