view release on metacpan or search on metacpan
link, and live date-time line under that mode while leaving
`/system/status`, bookmark rendering, and `dashboard ps1` unchanged
- added focused web, config, CLI smoke, and browser coverage proving the
no-indicators mode persists across restart and only affects the web
interface chrome
2.52 2026-04-18
- added `dashboard serve --no-editor` and the compatibility alias
`dashboard serve --no-endit` so the web UI can run in a persisted
read-only mode without exposing the bookmark editor chrome
- blocked bookmark editor GET and POST routes plus saved bookmark source
routes under that mode, while keeping normal saved-page render routes
available and hiding the Share, Play, and View Source links
- added focused web, config, and CLI smoke coverage proving read-only
serve persistence across restart and direct POST-bypass denial, and
synced the manuals for the new serve mode
2.51 2026-04-18
- added `dashboard docker list` with `--enabled` and `--disabled`
filters so users can inspect isolated compose service marker state
without scanning layered docker folders by hand
printf 'alpha.beta=5\n' | dashboard propq alpha.beta
dashboard jq file.json '$d'
```
Start the local app:
```bash
dashboard serve
```
Open the root path with no bookmark path to get the free-form bookmark editor directly. If you start the web service with `dashboard serve --no-editor` or `dashboard serve --no-endit`, the browser stays read-only instead and direct editor/source rout...
Stop the local app and collector loops:
```bash
dashboard stop
```
Interactive terminal runs now print a task board on `stderr` first, then mark each stop step as it finishes so the command does not appear hung while the runtime waits for managed shutdown.
Restart the local app and configured collector loops:
integration/blank-env/run-integration.pl view on Meta::CPAN
'dashboard docker compose --dry-run',
'dashboard docker compose --project ' . _shell_quote($compose) . ' --dry-run config'
);
_assert_match( $docker_dry->{stdout}, qr/"command"\s*:/, 'dashboard docker compose dry-run returns resolved command' );
_assert_match( $docker_dry->{stdout}, qr/compose\.yaml/, 'dashboard docker compose dry-run includes compose file' );
my $serve = _run_shell( 'dashboard serve', $project_cd . 'dashboard serve' );
_assert_match( $serve->{stdout}, qr/"pid"\s*:/, 'dashboard serve starts background web service' );
_wait_for_http( 'http://127.0.0.1:7890/', 200 );
my $blocked_transient = _run_shell(
'curl transient token denied by default',
'curl -sS -o /tmp/transient-denied.body -w \'%{http_code}\' ' . _shell_quote( 'http://127.0.0.1:7890/?token=' . $token ),
);
_assert_match( $blocked_transient->{stdout}, qr/^403$/, 'loopback transient token route is denied by default' );
_assert_match( _read_text('/tmp/transient-denied.body'), qr/Transient token URLs are disabled/, 'loopback transient token denial explains the policy' );
my $root = _run_shell( 'curl loopback root', q{curl -fsS http://127.0.0.1:7890/} );
_assert_match( $root->{stdout}, qr/instruction-editor/, 'loopback root serves the bookmark editor' );
my $root_dom = _run_browser_dom( 'browser loopback root', 'http://127.0.0.1:7890/', user_data_dir => $profile );
_assert_match( $root_dom, qr/instruction-editor/, 'browser loopback root renders the editor DOM' );
_assert_match( $root_dom, qr/TITLE:\s+Developer Dashboard/, 'browser loopback root shows bookmark source text' );
my $project_dom = _run_browser_dom( 'browser fake project page', 'http://127.0.0.1:7890/app/project-home', user_data_dir => $profile );
_assert_match( $project_dom, qr/project-marker/, 'browser renders fake project bookmark page' );
lib/Developer/Dashboard.pm view on Meta::CPAN
printf '{"alpha":{"beta":2}}' | dashboard jq alpha.beta
printf 'alpha:\n beta: 3\n' | dashboard yq alpha.beta
printf '[alpha]\nbeta = 4\n' | dashboard tomq alpha.beta
printf 'alpha.beta=5\n' | dashboard propq alpha.beta
dashboard jq file.json '$d'
Start the local app:
dashboard serve
Open the root path with no bookmark path to get the free-form bookmark editor directly. If you start the web service with C<dashboard serve --no-editor> or C<dashboard serve --no-endit>, the browser stays read-only instead and direct editor/source ro...
Stop the local app and collector loops:
dashboard stop
Interactive terminal runs now print a task board on C<stderr> first, then
mark each stop step as it finishes so the command does not appear hung while
the runtime waits for managed shutdown.
Restart the local app and configured collector loops:
share/private-cli/serve view on Meta::CPAN
dashboard serve --foreground
Run the public built-in command path that stages or re-enters this helper.
Example 2:
dashboard serve --no-editor
Start the dashboard in read-only browser mode so bookmark edit and source
routes stay blocked.
Example 3:
dashboard serve --no-indicators
Start the dashboard with the top-right browser context and indicator strip
removed while leaving the left-side page chrome and terminal prompt behavior
unchanged.
Example 4:
t/03-web-app.t view on Meta::CPAN
like(decode('UTF-8', $status_icon_body), qr/"alias"\s*:\s*"ð"/, 'legacy status endpoint exposes configured collector indicator icons instead of collector names');
like($app->_prompt_summary, qr/ð/, 'page top-right prompt summary prefers the configured collector indicator icon');
$config->save_global_web_settings( no_editor => 1 );
my ($readonly_render_code, undef, $readonly_render_body) = @{ $app->handle(path => '/app/welcome', query => '', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' }) };
is($readonly_render_code, 200, 'no-editor mode still renders saved pages');
unlike($readonly_render_body, qr/id="share-url"/, 'no-editor mode hides the share link from render views');
unlike($readonly_render_body, qr/id="view-source-url"/, 'no-editor mode hides the view-source link from render views');
unlike($readonly_render_body, qr/id="play-url"/, 'no-editor mode hides the play link from render views');
my ($readonly_source_code, $readonly_source_type, $readonly_source_body) = @{ $app->handle(path => '/app/welcome/source', query => '', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' }) };
is($readonly_source_code, 403, 'no-editor mode blocks saved page source routes');
like($readonly_source_type, qr/text\/plain/, 'no-editor blocked source route returns plain text');
like($readonly_source_body, qr/read-only|no-editor/i, 'no-editor blocked source route explains the read-only restriction');
my ($readonly_edit_code, $readonly_edit_type, $readonly_edit_body) = @{ $app->handle(path => '/app/welcome/edit', query => '', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' }) };
is($readonly_edit_code, 403, 'no-editor mode blocks saved page editor routes');
like($readonly_edit_type, qr/text\/plain/, 'no-editor blocked editor route returns plain text');
like($readonly_edit_body, qr/read-only|no-editor/i, 'no-editor blocked editor route explains the read-only restriction');
my $readonly_post_instruction = uri_escape("TITLE: Changed\n:--------------------------------------------------------------------------------:\nBOOKMARK: welcome\n:--------------------------------------------------------------------------------:\nHTM...
my ($readonly_post_code, $readonly_post_type, $readonly_post_body) = @{ $app->handle(
path => '/app/welcome/edit',
method => 'POST',
body => 'instruction=' . $readonly_post_instruction,
remote_addr => '127.0.0.1',
headers => { host => '127.0.0.1' },
) };
is($readonly_post_code, 403, 'no-editor mode blocks saved page editor POST saves');
like($readonly_post_type, qr/text\/plain/, 'no-editor blocked editor POST returns plain text');
like($readonly_post_body, qr/read-only|no-editor/i, 'no-editor blocked editor POST explains the read-only restriction');
my $welcome_after_block = $store->load_saved_page('welcome');
is($welcome_after_block->as_hash->{layout}{body}, 'hello from app [% stash.name %]', 'no-editor mode leaves the saved bookmark unchanged after a blocked POST');
my ($readonly_root_code, $readonly_root_type, $readonly_root_body, $readonly_root_headers) = @{ $app->handle(path => '/', query => '', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' }) };
is($readonly_root_code, 302, 'no-editor mode still lets the root route redirect to a saved index page');
like($readonly_root_type, qr/text\/plain/, 'no-editor root redirect still returns the standard plain-text redirect body');
is($readonly_root_headers->{Location}, '/app/index', 'no-editor root redirect still targets the saved index page');
$config->save_global_web_settings( no_editor => 0 );
$config->save_global_web_settings( no_indicators => 1 );
my ($noind_render_code, undef, $noind_render_body) = @{ $app->handle(path => '/app/welcome', query => '', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' }) };
is($noind_render_code, 200, 'no-indicators mode still renders saved pages');
unlike($noind_render_body, qr/id="status-on-top"/, 'no-indicators mode hides the top-right indicator strip');
unlike($noind_render_body, qr/id="status-datetime"/, 'no-indicators mode hides the top-right date-time marker');
t/03-web-app.t view on Meta::CPAN
open my $fh, '>', $store->page_file('legacy-forward-override') or die $!;
print {$fh} '/ajax/demo.json?type=text&status=default';
close $fh;
}
my ($code9, undef, $body9) = @{ $app->handle(path => '/app/legacy-forward-override', query => 'status=override', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' }) };
is($code9, 200, 'legacy /app saved-url forwarding with override works');
like(drain_stream_body($body9), qr/"ok"\s*:\s*1/, 'forwarded saved-url override still reaches ajax payload through the stream response');
{
local $ENV{DEVELOPER_DASHBOARD_ALLOW_TRANSIENT_URLS} = 0;
my $manual_ajax_token = uri_escape( encode_payload(q{print j { blocked => 1 };}) );
my ($blocked_ajax_code, $blocked_ajax_type, $blocked_ajax_body) = @{ $app->handle(path => '/ajax', query => "token=$manual_ajax_token&type=json", remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' }) };
is($blocked_ajax_code, 403, 'legacy ajax token route is denied when transient token URLs are disabled');
like($blocked_ajax_type, qr/text\/plain/, 'legacy ajax token denial returns plain text');
like($blocked_ajax_body, qr/Transient token URLs are disabled/, 'legacy ajax token denial explains the policy');
}
{
local $ENV{DEVELOPER_DASHBOARD_ALLOW_TRANSIENT_URLS} = 1;
my $manual_ajax_token = uri_escape( encode_payload(q{die "token ajax died\n";}) );
my ($manual_ajax_error_code, $manual_ajax_error_type, $manual_ajax_error_body) = @{ $app->handle(path => '/ajax', query => "token=$manual_ajax_token&type=text", remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' }) };
is($manual_ajax_error_code, 200, 'legacy ajax token runtime errors still return the streaming response shape');
like($manual_ajax_error_type, qr/text\/plain/, 'legacy ajax token runtime errors keep the requested content type');
like(drain_stream_body($manual_ajax_error_body), qr/token ajax died/, 'legacy ajax token runtime errors stream the runtime error text');
}
t/07-core-units.t view on Meta::CPAN
ok( !-e $cleanup_result_path, '_cleanup_temp_files removes old runtime result temp files' );
ok(
grep( { $_->{kind} eq 'ajax-temp-file' && $_->{path} eq $cleanup_ajax_path } @removed ),
'_cleanup_temp_files reports removed ajax temp files',
);
ok(
grep( { $_->{kind} eq 'result-temp-file' && $_->{path} eq $cleanup_result_path } @removed ),
'_cleanup_temp_files reports removed runtime result temp files',
);
my $blocked_tmp = tempdir( CLEANUP => 1 );
my ( $ajax_fh, $ajax_path ) = tempfile(
'developer-dashboard-ajax-FAIL-XXXXXX',
DIR => $blocked_tmp,
UNLINK => 0,
);
print {$ajax_fh} "still here";
close $ajax_fh or die "Unable to close $ajax_path: $!";
utime time - 7200, time - 7200, $ajax_path or die "Unable to age $ajax_path: $!";
if ( $> == 0 ) {
pass('_cleanup_ajax_temp_files unlink-failure branch is skipped under root because root can still remove the temp file despite directory permission tightening');
}
else {
chmod 0555, $blocked_tmp or die "Unable to chmod $blocked_tmp: $!";
{
no warnings qw(redefine once);
local *File::Spec::tmpdir = sub { return $blocked_tmp };
dies_like(
sub {
$ajax_keeper->_cleanup_temp_files(
min_age_seconds => 60,
scanned => { state_roots => 0, ajax_temp_files => 0, result_temp_files => 0 },
);
},
qr/Unable to remove stale Ajax temp file/,
'_cleanup_temp_files dies when unlink fails and the temp file still exists',
);
}
chmod 0755, $blocked_tmp or die "Unable to restore $blocked_tmp permissions: $!";
}
unlink $ajax_path or die "Unable to remove $ajax_path after ajax unlink failure coverage: $!";
}
dies_like( sub { Developer::Dashboard::UpdateManager->new }, qr/Missing config/, 'update manager requires config' );
{
package Local::EnvLoader::Functions;
sub from_env {
t/08-web-update-coverage.t view on Meta::CPAN
my ( $root_index_code, undef, undef, $root_index_headers ) = @{ $app->handle( path => '/', query => '', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
is( $root_index_code, 302, 'root route redirects to the saved index page when it exists' );
is( $root_index_headers->{Location}, '/app/index', 'root route redirects to the canonical saved index bookmark path' );
unlink $store->page_file('index') or die "Unable to remove temporary index bookmark: $!";
my ( $apps_code, undef, undef, $apps_headers ) = @{ $app->handle( path => '/apps', query => '', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
is( $apps_code, 302, '/apps redirects to default index bookmark' );
is( $apps_headers->{Location}, '/app/index', '/apps uses index bookmark as default target' );
my $token = uri_escape( $store->encode_page($page) );
my ( $blocked_code, $blocked_type, $blocked_body ) = @{ $app->handle( path => '/', query => "token=$token", remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
is( $blocked_code, 403, 'transient edit route is denied by default' );
like( $blocked_type, qr/text\/plain/, 'denied transient edit route returns plain text' );
like( $blocked_body, qr/Transient token URLs are disabled/, 'denied transient edit route explains the policy' );
local $ENV{DEVELOPER_DASHBOARD_ALLOW_TRANSIENT_URLS} = 1;
my ( $edit_code, $edit_type, $edit_body ) = @{ $app->handle( path => '/', query => "token=$token", remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
is( $edit_code, 200, 'transient edit route responds with success' );
like( $edit_body, qr/<textarea[^>]*name="instruction"/, 'edit route renders editable source textarea' );
unlike( $edit_body, qr/request_host|request_path|request_remote_addr/, 'edit route does not persist synthetic request metadata into source' );
my ( $render_code, undef, $render_body ) = @{ $app->handle( path => '/', query => "mode=render&token=$token", remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
is( $render_code, 200, 'transient render route responds with success' );
t/10-extension-action-docker.t view on Meta::CPAN
permissions => {},
);
my $transient_page = $transient;
eval {
$actions->run_page_action(
action => { id => 'run', label => 'Run', kind => 'command', command => 'printf nope' },
page => $transient_page,
source => 'transient',
);
};
like( $@, qr/not trusted/, 'transient encoded page command action is blocked by default' );
eval {
$actions->run_page_action(
action => { id => 'bad', kind => 'weird' },
page => $saved_page,
source => 'saved',
);
};
like( $@, qr/Unsupported action kind/, 'unsupported action kinds are rejected' );
t/10-extension-action-docker.t view on Meta::CPAN
actions => [
{ id => 'state', label => 'State', kind => 'builtin', builtin => 'page.state', safe => 1 },
],
state => { beta => 'two' },
);
my $token = $actions->encode_action_payload(
action => { id => 'state', label => 'State', kind => 'builtin', builtin => 'page.state', safe => 1 },
page => $transient_safe,
source => 'transient',
);
my ( $transient_blocked_code, undef, $transient_blocked_body ) = @{ $app->handle(
path => '/action',
method => 'POST',
query => 'atoken=' . uri_escape($token),
remote_addr => '127.0.0.1',
headers => { host => '127.0.0.1' },
) };
is( $transient_blocked_code, 403, 'transient encoded builtin action route is denied by default' );
like( $transient_blocked_body, qr/Transient token URLs are disabled/, 'transient encoded builtin action denial explains the policy' );
{
local $ENV{DEVELOPER_DASHBOARD_ALLOW_TRANSIENT_URLS} = 1;
my ( $transient_code, $transient_type, $transient_body ) = @{ $app->handle(
path => '/action',
method => 'POST',
query => 'atoken=' . uri_escape($token),
remote_addr => '127.0.0.1',
headers => { host => '127.0.0.1' },
) };
t/11-coverage-closure.t view on Meta::CPAN
cwd => $repo,
background => 1,
timeout_ms => 1000,
);
ok( $background_result->{pid} > 0, 'background action forks a child process' );
waitpid( $background_result->{pid}, 0 );
ok( !kill( 0, $background_result->{pid} ), 'background action child exits cleanly after running' );
ok(
!$actions->_is_action_trusted(
action => { id => 'blocked' },
page => Developer::Dashboard::PageDocument->new( permissions => { allow_untrusted_actions => 1, trusted_actions => ['other'] } ),
source => 'transient',
),
'action runner rejects transient actions missing from trusted_actions allowlist',
);
ok(
!$actions->_is_action_trusted(
action => { id => 'blocked' },
page => Developer::Dashboard::PageDocument->new( permissions => { allow_untrusted_actions => 1 } ),
source => 'transient',
),
'action runner rejects transient actions without a trusted_actions array',
);
my $collector = Developer::Dashboard::Collector->new( paths => $paths );
my $indicators = Developer::Dashboard::IndicatorStore->new( paths => $paths );
my $runner = Developer::Dashboard::CollectorRunner->new(
collectors => $collector,
t/14-coverage-closure-extra.t view on Meta::CPAN
);
}
my $transient_action_page = Developer::Dashboard::PageDocument->new(
title => 'Transient Action',
state => { value => 'hello' },
actions => [ { id => 'page-state', kind => 'builtin', builtin => 'page.state' } ],
permissions => { allow_untrusted_actions => 1 },
);
my $token = $store->encode_page($transient_action_page);
my ( $blocked_status, $blocked_type, $blocked_body ) = @{ $app->handle(
path => '/action',
method => 'POST',
query => '',
body => 'token=' . uri_escape($token) . '&id=page-state',
remote_addr => '127.0.0.1',
headers => { host => '127.0.0.1' },
) };
is( $blocked_status, 403, 'transient action fallback route is denied by default' );
like( $blocked_type, qr/text\/plain/, 'transient action fallback denial returns plain-text content type' );
like( $blocked_body, qr/Transient token URLs are disabled/, 'transient action fallback denial explains the policy' );
local $ENV{DEVELOPER_DASHBOARD_ALLOW_TRANSIENT_URLS} = 1;
my ( $status, $type, $body ) = @{ $app->handle(
path => '/action',
method => 'POST',
query => '',
body => 'token=' . uri_escape($token) . '&id=page-state',
remote_addr => '127.0.0.1',
headers => { host => '127.0.0.1' },
) };
t/38-web-no-editor-browser.t view on Meta::CPAN
=head1 PURPOSE
This test is the executable browser regression contract for the no-editor web
mode. Use it when changing served bookmark chrome, bookmark editor access
rules, or any route that is supposed to stay read-only in browser mode.
=head1 WHY IT EXISTS
It exists because a cosmetic-only lock would be too weak here. The browser can
hide links while the real edit and source routes still exist, so this test
keeps one real Chromium render path and a couple of blocked sad paths under
the repository gate.
=head1 WHEN TO USE
Use this file when changing C<dashboard serve>, bookmark editor exposure, top
chrome links, or read-only browser behavior.
=head1 HOW TO USE
Run it directly with C<prove -lv t/38-web-no-editor-browser.t> when Chromium
is available. It starts an isolated foreground dashboard server in
C<--no-editor> mode, dumps the rendered DOM for one saved page, then checks
the blocked edit and source routes through the same browser.
=head1 WHAT USES IT
Developers during TDD, the full repository suite, and release verification all
use this test to keep the read-only browser promise honest.
=head1 EXAMPLES
Example 1:
t/web_app_static_files.t view on Meta::CPAN
{
my $app = create_mock_app();
my $ct = $app->_get_content_type('others', 'unknown.xyz');
is($ct, 'application/octet-stream', 'Unknown content type is octet-stream');
}
# Test: _serve_static_file with directory traversal (security)
{
my $app = create_mock_app();
my $response = $app->_serve_static_file('js', '../../../etc/passwd');
is($response->[0], 400, 'Directory traversal blocked');
}
# Test: _serve_static_file with nonexistent file
{
my $app = create_mock_app();
my $response = $app->_serve_static_file('js', 'nonexistent.js');
is($response->[0], 404, 'Nonexistent file returns 404');
}
# Test: built-in jquery shim route exists for bookmark compatibility