From 13c3b6a58d1a52aeeeb5ff669cb0be089952fb6f Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 20 May 2026 18:31:08 +0000 Subject: [PATCH 1/6] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/link-foundation/start/issues/128 --- .gitkeep | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 0000000..80840de --- /dev/null +++ b/.gitkeep @@ -0,0 +1 @@ +# .gitkeep file auto-generated at 2026-05-20T18:31:08.431Z for PR creation at branch issue-128-9d904e7dd227 for issue https://github.com/link-foundation/start/issues/128 \ No newline at end of file From 38d1fe46f7a02f592c23d0e2a87ad562c5a9e13b Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 20 May 2026 18:48:49 +0000 Subject: [PATCH 2/6] feat: add upload-log support --- README.md | 10 +- docs/USAGE.md | 5 + docs/case-studies/issue-128/README.md | 38 +++++ .../issue-128/data/gh-upload-log-npm.json | 19 +++ .../issue-128/data/gh-upload-log-repo.json | 1 + .../issue-128/data/gist-evidence.txt | 27 ++++ .../issue-128/data/recent-merged-prs.json | 1 + .../issue-128/issue-comments.json | 1 + docs/case-studies/issue-128/issue-data.json | 1 + .../case-studies/issue-128/online-research.md | 36 +++++ docs/case-studies/issue-128/requirements.md | 24 +++ docs/case-studies/issue-128/root-cause.md | 24 +++ docs/case-studies/issue-128/solutions.md | 63 ++++++++ docs/case-studies/issue-128/timeline.md | 12 ++ js/.changeset/issue-128-upload-log.md | 5 + js/src/bin/cli.js | 17 +++ js/src/lib/args-parser.js | 27 +++- js/src/lib/log-uploader.js | 137 ++++++++++++++++++ js/src/lib/usage.js | 4 +- js/test/args-parser-control.js | 8 +- js/test/args-parser.js | 30 ++++ js/test/status-query.js | 92 +++++++++++- rust/changelog.d/issue-128-upload-log.md | 5 + rust/src/bin/main.rs | 22 ++- rust/src/lib/args_parser.rs | 29 +++- rust/src/lib/args_parser_cases.rs | 49 ++++++- rust/src/lib/failure_handler.rs | 56 ++++++- rust/src/lib/log_uploader.rs | 30 ++++ rust/src/lib/mod.rs | 2 + rust/src/lib/usage.rs | 3 + rust/tests/args_parser.rs | 25 +++- 31 files changed, 787 insertions(+), 16 deletions(-) create mode 100644 docs/case-studies/issue-128/README.md create mode 100644 docs/case-studies/issue-128/data/gh-upload-log-npm.json create mode 100644 docs/case-studies/issue-128/data/gh-upload-log-repo.json create mode 100644 docs/case-studies/issue-128/data/gist-evidence.txt create mode 100644 docs/case-studies/issue-128/data/recent-merged-prs.json create mode 100644 docs/case-studies/issue-128/issue-comments.json create mode 100644 docs/case-studies/issue-128/issue-data.json create mode 100644 docs/case-studies/issue-128/online-research.md create mode 100644 docs/case-studies/issue-128/requirements.md create mode 100644 docs/case-studies/issue-128/root-cause.md create mode 100644 docs/case-studies/issue-128/solutions.md create mode 100644 docs/case-studies/issue-128/timeline.md create mode 100644 js/.changeset/issue-128-upload-log.md create mode 100644 js/src/lib/log-uploader.js create mode 100644 rust/changelog.d/issue-128-upload-log.md create mode 100644 rust/src/lib/log_uploader.rs diff --git a/README.md b/README.md index 740fb77..400b0d1 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,9 @@ $ --list # Machine-readable list output $ --list --output-format json +# Upload the stored log for one execution +$ --upload-log 29d6c026-b168-44a6-8a3f-c3919c7e5327 + # Ask a detached isolated execution to stop gracefully $ --stop 29d6c026-b168-44a6-8a3f-c3919c7e5327 @@ -166,6 +169,10 @@ include best-effort `processIds` for tracked wrapper processes and detached screen, tmux, and Docker isolation containers when those native tools can report them. +`--upload-log` accepts either an execution UUID or an isolation session name. It +looks up the stored `logPath`, installs `gh-upload-log` with Bun or npm if the +uploader is missing, and then streams the uploader output directly. + `--stop` and `--terminate` accept either the execution UUID or the isolation session/container name. `--stop` sends the graceful interrupt for the backend (CTRL+C for screen/tmux, `SIGINT` for Docker). `--terminate` uses the backend's @@ -323,7 +330,8 @@ For Docker containers, by default the container filesystem is preserved (appears The tool works in any environment: - **No `gh` CLI?** - Logs are still saved locally, auto-reporting is skipped -- **No `gh-upload-log`?** - Issue can still be created with local log reference +- **No `gh-upload-log` during auto-reporting?** - Issue can still be created with local log reference +- **No `gh-upload-log` during manual `--upload-log`?** - The uploader is installed on demand - **Repository not detected?** - Command runs normally with logging - **No permission to create issue?** - Skipped with a clear message - **Isolation environment not installed?** - Clear error message with installation instructions diff --git a/docs/USAGE.md b/docs/USAGE.md index f805ebb..2ab604c 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -20,12 +20,17 @@ $ echo "Hello World" $ bun test $ git status $ --list +$ --upload-log ``` Use `--status ` to inspect one stored execution, or `--list` to see all stored executions. Query output defaults to Links Notation and can be changed with `--output-format json` or `--output-format text`. +Use `--upload-log ` to look up a stored execution by UUID or session name +and run `gh-upload-log` for its saved log file. If `gh-upload-log` is missing, +the command attempts to install it first with Bun or npm. + For examples checked against the real JavaScript and Rust command output, see [EXAMPLES.md](EXAMPLES.md). diff --git a/docs/case-studies/issue-128/README.md b/docs/case-studies/issue-128/README.md new file mode 100644 index 0000000..d2e71c8 --- /dev/null +++ b/docs/case-studies/issue-128/README.md @@ -0,0 +1,38 @@ +# Issue 128 Case Study: `$ --upload-log` Support + +## Summary + +Issue #128 reports that `$ --upload-log ` is treated as a shell +command and fails with `/bin/sh: 0: Illegal option --`. The requested behavior is +for `start-command` to resolve the tracked execution's `logPath` and run +`gh-upload-log ` so the uploader output is visible to the caller. + +## Evidence Collected + +- Issue data: [issue-data.json](issue-data.json) +- Issue comments: [issue-comments.json](issue-comments.json) +- Uploaded log excerpt: [data/gist-evidence.txt](data/gist-evidence.txt) +- Related package metadata: [data/gh-upload-log-npm.json](data/gh-upload-log-npm.json) +- Related repository metadata: [data/gh-upload-log-repo.json](data/gh-upload-log-repo.json) +- Recent merged PRs reviewed for style: [data/recent-merged-prs.json](data/recent-merged-prs.json) + +## Implemented Plan + +1. Add `--upload-log ` parsing to the JavaScript and Rust + argument parsers. +2. Keep the option mutually exclusive with `--status`, `--list`, `--stop`, + `--terminate`, and `--cleanup`. +3. Resolve the execution record with the existing `ExecutionStore.get()` lookup, + which already supports UUIDs and isolation session names. +4. Validate that the resolved record has an existing `logPath`. +5. Ensure `gh-upload-log` is available, installing it with Bun or npm when + missing. +6. Run `gh-upload-log ` with inherited stdio so the upload progress and + resulting URL are visible. +7. Cover the behavior with parser and CLI tests. + +## Outcome + +The new option is a first-class query action rather than a command passed to the +shell. That removes the reported `/bin/sh` failure mode and reuses the same +execution tracking data that powers `--status`. diff --git a/docs/case-studies/issue-128/data/gh-upload-log-npm.json b/docs/case-studies/issue-128/data/gh-upload-log-npm.json new file mode 100644 index 0000000..0e09260 --- /dev/null +++ b/docs/case-studies/issue-128/data/gh-upload-log-npm.json @@ -0,0 +1,19 @@ +{ + "name": "gh-upload-log", + "version": "0.8.0", + "description": "A tool to upload log files to GitHub as Gists or repositories", + "repository.url": "git+https://github.com/link-foundation/gh-upload-log.git", + "bin": { + "gh-upload-log": "src/cli.js" + }, + "dist-tags": { + "latest": "0.8.0" + }, + "dependencies": { + "command-stream": "^0.7.1", + "lino-arguments": "^0.3.0", + "log-lazy": "^1.0.4", + "use-m": "^8.13.7", + "yargs": "^17.7.2" + } +} diff --git a/docs/case-studies/issue-128/data/gh-upload-log-repo.json b/docs/case-studies/issue-128/data/gh-upload-log-repo.json new file mode 100644 index 0000000..0566a20 --- /dev/null +++ b/docs/case-studies/issue-128/data/gh-upload-log-repo.json @@ -0,0 +1 @@ +{"createdAt":"2025-11-13T08:55:12Z","defaultBranchRef":{"name":"main"},"description":"A tool to upload the log to GitHub","isPrivate":false,"latestRelease":{"name":"0.8.0","tagName":"v0.8.0","url":"https://github.com/link-foundation/gh-upload-log/releases/tag/v0.8.0","publishedAt":"2026-04-25T08:36:21Z"},"nameWithOwner":"link-foundation/gh-upload-log","updatedAt":"2026-04-25T08:36:19Z","url":"https://github.com/link-foundation/gh-upload-log","visibility":"PUBLIC"} diff --git a/docs/case-studies/issue-128/data/gist-evidence.txt b/docs/case-studies/issue-128/data/gist-evidence.txt new file mode 100644 index 0000000..be44ee8 --- /dev/null +++ b/docs/case-studies/issue-128/data/gist-evidence.txt @@ -0,0 +1,27 @@ +18392- • Known tokens: 0 +18393- • Secretlint: 0 detections +18394- • Custom patterns: 0 detections +18395- • Hex tokens: 3 +18396- šŸ”§ Escaping code blocks in log content for safe embedding... +18397- āš ļø Log comment too long (2019386 chars), GitHub limit is 65536 chars +18398: šŸ“Ž Uploading log using gh-upload-log... +18399-public +18400- šŸ” Repository visibility: public, log upload will be public +18401-github.com +18402- āœ“ Logged in to github.com account konard (/home/box/.config/gh/hosts.yml) +18403- - Active account: true +18404- - Git operations protocol: https +-- +18406- - Token scopes: 'gist', 'read:org', 'repo', 'user', 'workflow' +18407- šŸ”’ Sanitized 3 secrets using dual approach: +18408- • Known tokens: 0 +18409- • Secretlint: 0 detections +18410- • Custom patterns: 0 detections +18411- • Hex tokens: 3 +18412: šŸ“¤ Running: gh-upload-log "/tmp/solution-draft-log-pr-1779295404334.txt" --public --description "Solution draft log for https://github.com/ideav/crm/pull/2747" --verbose +18413-Options: { +18414- filePath: "/tmp/solution-draft-log-pr-1779295404334.txt", +18415- isPublic: true, +18416- auto: true, +18417- onlyGist: undefined, +18418- onlyRepository: undefined, diff --git a/docs/case-studies/issue-128/data/recent-merged-prs.json b/docs/case-studies/issue-128/data/recent-merged-prs.json new file mode 100644 index 0000000..2d68175 --- /dev/null +++ b/docs/case-studies/issue-128/data/recent-merged-prs.json @@ -0,0 +1 @@ +[{"headRefName":"issue-126-c5c8579b3ab4","mergedAt":"2026-05-12T21:59:37Z","number":127,"title":"Fix Links Notation process ID formatting","url":"https://github.com/link-foundation/start/pull/127"},{"headRefName":"issue-124-0288905fdd33","mergedAt":"2026-05-10T18:51:46Z","number":125,"title":"Document tested Docker isolation examples","url":"https://github.com/link-foundation/start/pull/125"},{"headRefName":"issue-122-9e4e6b1efac6","mergedAt":"2026-05-03T19:04:45Z","number":123,"title":"Fix CI/CD release publishing and Docker cleanup timeout","url":"https://github.com/link-foundation/start/pull/123"},{"headRefName":"issue-120-22538e16da80","mergedAt":"2026-05-03T17:56:58Z","number":121,"title":"fix(ci): make release preflight accept the Actions installation token","url":"https://github.com/link-foundation/start/pull/121"},{"headRefName":"issue-118-4ed21210786f","mergedAt":"2026-05-03T15:18:55Z","number":119,"title":"Fix all CI/CD issues","url":"https://github.com/link-foundation/start/pull/119"},{"headRefName":"issue-116-01c61894edbd","mergedAt":"2026-05-03T11:07:26Z","number":117,"title":"fix: repair js release pipeline for monorepo layout","url":"https://github.com/link-foundation/start/pull/117"},{"headRefName":"issue-114-8181f01863b9","mergedAt":"2026-05-02T22:47:00Z","number":115,"title":"fix: repair CI/CD release automation","url":"https://github.com/link-foundation/start/pull/115"},{"headRefName":"issue-112-46af7527c0d9","mergedAt":"2026-05-02T07:24:46Z","number":113,"title":"feat: add detached execution controls","url":"https://github.com/link-foundation/start/pull/113"}] diff --git a/docs/case-studies/issue-128/issue-comments.json b/docs/case-studies/issue-128/issue-comments.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/docs/case-studies/issue-128/issue-comments.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/docs/case-studies/issue-128/issue-data.json b/docs/case-studies/issue-128/issue-data.json new file mode 100644 index 0000000..5301353 --- /dev/null +++ b/docs/case-studies/issue-128/issue-data.json @@ -0,0 +1 @@ +{"author":{"id":"MDQ6VXNlcjE0MzE5MDQ=","is_bot":false,"login":"konard","name":"Konstantin Diachenko"},"body":"```\nbox@2df581054dbb:~$ $ --status 41e2617a-0741-41f2-a56e-c9e9cbbe8068\nb10d0dfb-9bb4-45a0-873e-23b98878457b\n uuid b10d0dfb-9bb4-45a0-873e-23b98878457b\n pid 1699843\n processIds\n wrapperPid 1699843\n status executed\n exitCode 0\n command \"solve https://github.com/ideav/crm/issues/2746 --model opus --tool claude --attach-logs --verbose --no-tool-check --disable-report-issue --language en\"\n logPath /tmp/start-command/logs/isolation/screen/b10d0dfb-9bb4-45a0-873e-23b98878457b.log\n startTime \"2026-05-20T16:34:52.810Z\"\n endTime \"2026-05-20T18:27:15.636Z\"\n workingDirectory /home/box\n shell /bin/sh\n platform linux\n options\n isolated screen\n isolationMode detached\n sessionName 41e2617a-0741-41f2-a56e-c9e9cbbe8068\n user false\n keepAlive false\n useCommandStream false\nbox@2df581054dbb:~$ gh-upload-log /tmp/start-command/logs/isolation/screen/b10d0dfb-9bb4-45a0-873e-23b98878457b.log\nā³ Uploading 1.45 MB (šŸ”’ private)...\n- Creating gist b10d0dfb-9bb4-45a0-873e-23b98878457b.log\nāœ“ Created secret gist b10d0dfb-9bb4-45a0-873e-23b98878457b.log\nhttps://gist.github.com/konard/c5a7ef71b33d38631592b7ddd223ff23\nāœ… Gist created (šŸ”’ private)\nšŸ”— https://gist.github.com/konard/c5a7ef71b33d38631592b7ddd223ff23\nšŸ“„ https://gist.githubusercontent.com/konard/c5a7ef71b33d38631592b7ddd223ff23/raw/6e2f607f3914bde708f5749840c433ad48b28398/b10d0dfb-9bb4-45a0-873e-23b98878457b.log\nbox@2df581054dbb:~$ $ --upload-log 41e2617a-0741-41f2-a56e-c9e9cbbe8068\n│ session 433ed252-2145-41c4-afaf-1cfe59838987\n│ start 2026-05-20 18:28:19.150\n│\n$ --upload-log 41e2617a-0741-41f2-a56e-c9e9cbbe8068\n\n/bin/sh: 0: Illegal option --\n\nāœ—\n│ finish 2026-05-20 18:28:19.206\n│ duration 0.056s\n│ exit 2\n│\n│ log /tmp/start-command/logs/direct/433ed252-2145-41c4-afaf-1cfe59838987.log\n│ session 433ed252-2145-41c4-afaf-1cfe59838987\n\nRepository not detected - automatic issue creation skipped\nbox@2df581054dbb:~$ \n```\n\nThe end result of `$ --upload-log` would be getting the log path from status (internally) and execute it as `$ gh-upload-log /tmp/start-command/logs/isolation/screen/b10d0dfb-9bb4-45a0-873e-23b98878457b.log`.\n\nSo the output of gh-upload-log is visible.\n\nThat option should automatically install gh-upload-log if not installed.\n\nWe need to collect data related about the issue to this repository, make sure we compile that data to `./docs/case-studies/issue-{id}` folder, and use it to do deep case study analysis (also make sure to search online for additional facts and data), list of each and all requirements from the issue, and propose possible solutions and solution plans for each requirement (we should also check known existing components/libraries, that solve similar problem or can help in solutions).\n\nPlease plan and execute everything in a single pull request, you have unlimited time and context, as context auto-compacts and you can continue indefinitely, until it is each and every requirement fully addressed, and everything is totally done.","comments":[],"createdAt":"2026-05-20T18:30:32Z","number":128,"state":"OPEN","title":"Add `$ --upload-log ` support","updatedAt":"2026-05-20T18:30:32Z","url":"https://github.com/link-foundation/start/issues/128"} diff --git a/docs/case-studies/issue-128/online-research.md b/docs/case-studies/issue-128/online-research.md new file mode 100644 index 0000000..7e70e8a --- /dev/null +++ b/docs/case-studies/issue-128/online-research.md @@ -0,0 +1,36 @@ +# Online And Repository Research + +## gh-upload-log Package + +- Repository: +- npm package: +- Collected metadata shows latest version `0.8.0`, published as the `latest` + npm dist-tag, with a `gh-upload-log` binary entry. +- The repository describes the tool as a log uploader for GitHub. That matches + the issue's requested integration point. + +## GitHub Gist Behavior + +- GitHub CLI documentation for `gh gist create` says the command creates secret + gists by default and uses `--public` for public gists: + +- GitHub's gist documentation distinguishes public and secret gists and notes + that secret gists are reachable by URL: + + +## Relevant Existing Components + +- `ExecutionStore.get(identifier)` already supports UUID lookup and fallback to + `options.sessionName`. +- `--status` already exposes `logPath`, proving the data is stored in execution + records. +- Existing failure reporting already knows about `gh-upload-log`, but it only + used the uploader opportunistically when installed. The new manual command + adds on-demand installation because that is explicitly required for + `--upload-log`. + +## Related PR Style + +Recent merged PRs show this repository keeps issue-specific case studies, +targeted regression tests, and release fragments in the same PR. See +[data/recent-merged-prs.json](data/recent-merged-prs.json). diff --git a/docs/case-studies/issue-128/requirements.md b/docs/case-studies/issue-128/requirements.md new file mode 100644 index 0000000..2edef2a --- /dev/null +++ b/docs/case-studies/issue-128/requirements.md @@ -0,0 +1,24 @@ +# Requirements + +## Functional Requirements + +1. `$ --upload-log ` must be recognized by `start-command` instead of being + forwarded to the shell. +2. `` must identify a tracked execution by UUID or by isolation session name, + matching the lookup behavior of `--status`. +3. The command must read the execution record's stored `logPath` internally. +4. The command must run `gh-upload-log `. +5. `gh-upload-log` stdout and stderr must remain visible to the caller. +6. If `gh-upload-log` is missing, the command must attempt automatic + installation. +7. Missing execution records, missing log paths, and missing log files must + produce clear errors. +8. Query/control modes must remain mutually exclusive. + +## Repository Process Requirements + +1. Collect issue data under `docs/case-studies/issue-128`. +2. Include deep case-study analysis, requirements, possible solutions, and + relevant online/repository research. +3. Add tests that reproduce the reported bug and verify the fix. +4. Add release metadata for both JavaScript and Rust packages. diff --git a/docs/case-studies/issue-128/root-cause.md b/docs/case-studies/issue-128/root-cause.md new file mode 100644 index 0000000..af6a2ee --- /dev/null +++ b/docs/case-studies/issue-128/root-cause.md @@ -0,0 +1,24 @@ +# Root Cause + +`--upload-log` was not part of the wrapper option model in either implementation. +The argument parser therefore treated it as an unknown option. In the no-separator +syntax, unknown options are interpreted as the beginning of the command to run. + +That made this invocation: + +```bash +$ --upload-log 41e2617a-0741-41f2-a56e-c9e9cbbe8068 +``` + +behave like a shell command roughly equivalent to: + +```bash +/bin/sh -c "--upload-log 41e2617a-0741-41f2-a56e-c9e9cbbe8068" +``` + +The shell then rejected the leading `--upload-log` token as an invalid shell +option, producing the reported `Illegal option --` failure. + +The existing execution tracking store already persisted the required `logPath`, +and `--status` already exposed that path. The missing piece was a dedicated query +action that resolves the record and invokes the uploader directly. diff --git a/docs/case-studies/issue-128/solutions.md b/docs/case-studies/issue-128/solutions.md new file mode 100644 index 0000000..f193c26 --- /dev/null +++ b/docs/case-studies/issue-128/solutions.md @@ -0,0 +1,63 @@ +# Solution Options + +## Option 1: Dedicated Wrapper Option + +Add `--upload-log ` as a first-class wrapper query mode, parallel to +`--status` and `--list`. + +Pros: + +- Reuses `ExecutionStore.get()` for UUID and session-name lookup. +- Avoids parsing status text output. +- Prevents the shell fallback that caused the bug. +- Keeps upload progress visible by inheriting process stdio. + +Cons: + +- Requires JS and Rust parser/CLI updates. + +Decision: selected. + +## Option 2: Compose Through `--status --output-format json` + +Teach a shell script or alias to run `--status`, parse JSON, and call +`gh-upload-log`. + +Pros: + +- Minimal core changes. + +Cons: + +- Leaves `$ --upload-log` unsupported. +- Requires fragile external composition and JSON parsing. +- Does not satisfy automatic installation as part of the command. + +Decision: rejected. + +## Option 3: Extend Automatic Failure Reporting Only + +Install `gh-upload-log` during existing failure auto-reporting. + +Pros: + +- Improves one related path. + +Cons: + +- Does not provide the requested manual upload command. +- Changes automatic failure reporting side effects. + +Decision: rejected for this issue. + +## Implementation Plan + +1. Extend `WrapperOptions` with `uploadLog`/`upload_log`. +2. Parse `--upload-log ` and `--upload-log=`. +3. Include `--upload-log` in mutual-exclusion validation. +4. Add a JS uploader helper for path validation, on-demand installation, and + inherited-stdio execution. +5. Add equivalent Rust CLI handling and failure-handler utilities. +6. Update usage docs and release fragments. +7. Add tests for parsing, mutual exclusion, upload execution, auto-install, and + missing log-file errors. diff --git a/docs/case-studies/issue-128/timeline.md b/docs/case-studies/issue-128/timeline.md new file mode 100644 index 0000000..cae7906 --- /dev/null +++ b/docs/case-studies/issue-128/timeline.md @@ -0,0 +1,12 @@ +# Timeline + +- 2026-05-20 18:30 UTC: Issue #128 opened with a failing `$ --upload-log` + example and a private uploaded log. +- 2026-05-20: Issue details, comments, PR discussion endpoints, gist evidence, + related repository metadata, npm metadata, and recent merged PRs were + collected. +- 2026-05-20: Reproducing tests were added for parser support and CLI + `--upload-log` behavior. +- 2026-05-20: JavaScript and Rust implementations were updated with first-class + `--upload-log` handling. +- 2026-05-20: Targeted JS and Rust tests passed locally. diff --git a/js/.changeset/issue-128-upload-log.md b/js/.changeset/issue-128-upload-log.md new file mode 100644 index 0000000..45aa1cd --- /dev/null +++ b/js/.changeset/issue-128-upload-log.md @@ -0,0 +1,5 @@ +--- +'start-command': minor +--- + +Add `--upload-log ` to upload a stored execution log with `gh-upload-log`, installing the uploader on demand when it is missing. diff --git a/js/src/bin/cli.js b/js/src/bin/cli.js index 74b7009..1a37f57 100644 --- a/js/src/bin/cli.js +++ b/js/src/bin/cli.js @@ -33,6 +33,7 @@ const { handleFailure } = require('../lib/failure-handler'); const { ExecutionStore, ExecutionRecord } = require('../lib/execution-store'); const { queryStatus, listExecutions } = require('../lib/status-formatter'); const { ControlAction, controlExecution } = require('../lib/execution-control'); +const { uploadExecutionLog } = require('../lib/log-uploader'); const { printVersion } = require('../lib/version'); const { createStartBlock, createFinishBlock } = require('../lib/output-blocks'); const { runWithBunSpawn, runWithNodeSpawn } = require('../lib/spawn-helpers'); @@ -201,6 +202,12 @@ if (wrapperOptions.list) { process.exit(0); } +// Handle --upload-log flag +if (wrapperOptions.uploadLog) { + const exitCode = handleUploadLogQuery(wrapperOptions.uploadLog); + process.exit(exitCode); +} + // Handle --stop flag if (wrapperOptions.stop !== null && wrapperOptions.stop !== undefined) { handleControlQuery(wrapperOptions.stop, ControlAction.STOP); @@ -300,6 +307,16 @@ function handleListQuery(outputFormat) { } } +function handleUploadLogQuery(identifier) { + const result = uploadExecutionLog(getExecutionStore(), identifier); + if (result.success) { + return result.exitCode || 0; + } + + console.error(`Error: ${result.error}`); + return result.exitCode || 1; +} + function handleControlQuery(identifier, action) { const result = controlExecution(getExecutionStore(), identifier, action); if (result.success) { diff --git a/js/src/lib/args-parser.js b/js/src/lib/args-parser.js index 2e60792..9d01e0a 100644 --- a/js/src/lib/args-parser.js +++ b/js/src/lib/args-parser.js @@ -21,6 +21,7 @@ * --verbose Enable verbose/debug output (sets START_VERBOSE=1) * --status Show status of a previous command execution by UUID * --list List all tracked command executions + * --upload-log Upload the stored log for a tracked execution * --output-format Output format for status/list (links-notation, json, text) * --stop Send CTRL+C/SIGINT to a detached execution * --terminate Terminate a detached execution immediately @@ -177,6 +178,7 @@ function parseArgs(args) { useCommandStream: false, // Use command-stream library for command execution status: null, // UUID to show status for list: false, // List all tracked execution records + uploadLog: null, // UUID/session name whose stored log should be uploaded outputFormat: null, // Output format for status/list (links-notation, json, text) stop: null, // UUID/session name to stop gracefully terminate: null, // UUID/session name to terminate immediately @@ -450,6 +452,28 @@ function parseOption(args, index, options) { return 1; } + // --upload-log + if (arg === '--upload-log') { + if (index + 1 < args.length && !args[index + 1].startsWith('-')) { + options.uploadLog = args[index + 1]; + return 2; + } else { + throw new Error(`Option ${arg} requires a UUID or session name argument`); + } + } + + // --upload-log= + if (arg.startsWith('--upload-log=')) { + const value = arg.slice('--upload-log='.length); + if (!value) { + throw new Error( + `Option --upload-log requires a UUID or session name argument` + ); + } + options.uploadLog = value; + return 1; + } + // --stop if (arg === '--stop') { if (index + 1 < args.length && !args[index + 1].startsWith('-')) { @@ -724,6 +748,7 @@ function validateOptions(options) { const queryModes = [ hasValue(options.status), options.list, + hasValue(options.uploadLog), hasValue(options.stop), hasValue(options.terminate), options.cleanup, @@ -731,7 +756,7 @@ function validateOptions(options) { if (queryModes > 1) { throw new Error( - 'Cannot combine --status, --list, --stop, --terminate, or --cleanup in the same invocation' + 'Cannot combine --status, --list, --upload-log, --stop, --terminate, or --cleanup in the same invocation' ); } diff --git a/js/src/lib/log-uploader.js b/js/src/lib/log-uploader.js new file mode 100644 index 0000000..aa1d283 --- /dev/null +++ b/js/src/lib/log-uploader.js @@ -0,0 +1,137 @@ +/** + * Helpers for uploading stored execution logs with gh-upload-log. + */ + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +function isExecutable(filePath) { + try { + fs.accessSync(filePath, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +function resolveCommand(commandName) { + const isWindows = process.platform === 'win32'; + const lookupCommand = isWindows ? 'where' : 'which'; + + try { + const result = spawnSync(lookupCommand, [commandName], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + if (result.status === 0 && result.stdout.trim()) { + return result.stdout.trim().split(/\r?\n/)[0]; + } + } catch { + // Fall through to common locations. + } + + if (!isWindows && commandName === 'gh-upload-log') { + const bunGlobalPath = path.join(os.homedir(), '.bun', 'bin', commandName); + if (isExecutable(bunGlobalPath)) { + return bunGlobalPath; + } + } + + return null; +} + +function runInstall(command, args) { + console.log( + `gh-upload-log not found; installing with: ${command} ${args.join(' ')}` + ); + const result = spawnSync(command, args, { stdio: 'inherit' }); + return result.status === 0; +} + +function ensureGhUploadLogAvailable() { + const existing = resolveCommand('gh-upload-log'); + if (existing) { + return { success: true, command: existing }; + } + + const installers = [ + ['bun', ['install', '-g', 'gh-upload-log']], + ['npm', ['install', '-g', 'gh-upload-log']], + ]; + + for (const [command, args] of installers) { + if (!resolveCommand(command)) { + continue; + } + if (runInstall(command, args)) { + const installed = resolveCommand('gh-upload-log'); + if (installed) { + return { success: true, command: installed }; + } + } + } + + return { + success: false, + error: + 'gh-upload-log is not installed and automatic installation did not make it available on PATH.', + }; +} + +function uploadLogPath(logPath) { + if (!logPath) { + return { + success: false, + error: 'Execution record does not have a log path.', + }; + } + if (!fs.existsSync(logPath)) { + return { success: false, error: `Log file not found: ${logPath}` }; + } + + const availability = ensureGhUploadLogAvailable(); + if (!availability.success) { + return availability; + } + + const result = spawnSync(availability.command, [logPath], { + stdio: 'inherit', + }); + + const exitCode = + result.status !== null && result.status !== undefined ? result.status : 1; + if (exitCode !== 0) { + return { + success: false, + exitCode, + error: `gh-upload-log exited with code ${exitCode}`, + }; + } + + return { success: true, exitCode: 0 }; +} + +function uploadExecutionLog(store, identifier) { + if (!store) { + return { success: false, error: 'Execution tracking is disabled.' }; + } + + const record = store.get(identifier); + if (!record) { + return { + success: false, + error: `No execution found with UUID or session name: ${identifier}`, + }; + } + + return uploadLogPath(record.logPath); +} + +module.exports = { + ensureGhUploadLogAvailable, + resolveCommand, + uploadExecutionLog, + uploadLogPath, +}; diff --git a/js/src/lib/usage.js b/js/src/lib/usage.js index dfd2c3e..083b838 100644 --- a/js/src/lib/usage.js +++ b/js/src/lib/usage.js @@ -1,6 +1,6 @@ /** Print usage information */ function printUsage() { - console.log(`Usage: $ [options] [--] | $ --status [--output-format ] | $ --list [--output-format ] | $ --stop | $ --terminate + console.log(`Usage: $ [options] [--] | $ --status [--output-format ] | $ --list [--output-format ] | $ --upload-log | $ --stop | $ --terminate Options: --isolated, -i Run in isolated environment (screen, tmux, docker, ssh) @@ -19,6 +19,7 @@ Options: --use-command-stream Use command-stream library for execution (experimental) --status Show status of execution by UUID or session name (--output-format: links-notation|json|text) --list List all tracked executions (--output-format: links-notation|json|text) + --upload-log Upload the stored log for an execution UUID or session name --stop Send CTRL+C/SIGINT to a detached isolated execution --terminate Terminate a detached isolated execution immediately --cleanup Clean up stale "executing" records (crashed/killed processes) @@ -39,6 +40,7 @@ Examples: $ --isolated-user --keep-user -- npm start $ --list # List stored execution records $ --list --output-format json # List stored records as JSON + $ --upload-log my-screen-session # Upload stored execution log $ --stop my-screen-session # Ask detached execution to stop gracefully $ --terminate my-screen-session # Terminate detached execution immediately $ --use-command-stream echo "Hello" # Use command-stream library`); diff --git a/js/test/args-parser-control.js b/js/test/args-parser-control.js index 65e1858..6010d33 100644 --- a/js/test/args-parser-control.js +++ b/js/test/args-parser-control.js @@ -54,7 +54,13 @@ describe('control options', () => { it('should reject combining query and control modes', () => { assert.throws(() => { parseArgs(['--status', 'uuid-here', '--stop', 'my-session']); - }, /Cannot combine --status, --list, --stop, --terminate, or --cleanup/); + }, /Cannot combine --status, --list, --upload-log, --stop, --terminate, or --cleanup/); + }); + + it('should reject combining upload-log with control modes', () => { + assert.throws(() => { + parseArgs(['--upload-log', 'uuid-here', '--terminate', 'my-session']); + }, /Cannot combine --status, --list, --upload-log, --stop, --terminate, or --cleanup/); }); it('should reject output-format with control modes', () => { diff --git a/js/test/args-parser.js b/js/test/args-parser.js index 962ed5a..3e8c5c8 100644 --- a/js/test/args-parser.js +++ b/js/test/args-parser.js @@ -832,6 +832,36 @@ describe('status option', () => { }); }); +describe('upload-log option', () => { + it('should parse --upload-log with UUID or session name', () => { + const result = parseArgs(['--upload-log', 'my-session']); + assert.strictEqual(result.wrapperOptions.uploadLog, 'my-session'); + assert.strictEqual(result.command, ''); + }); + + it('should parse --upload-log=value format', () => { + const result = parseArgs(['--upload-log=my-session']); + assert.strictEqual(result.wrapperOptions.uploadLog, 'my-session'); + }); + + it('should throw error for missing --upload-log argument', () => { + assert.throws(() => { + parseArgs(['--upload-log']); + }, /requires a UUID or session name argument/); + }); + + it('should throw error for empty --upload-log=value argument', () => { + assert.throws(() => { + parseArgs(['--upload-log=']); + }, /requires a UUID or session name argument/); + }); + + it('should default uploadLog to null', () => { + const result = parseArgs(['echo', 'hello']); + assert.strictEqual(result.wrapperOptions.uploadLog, null); + }); +}); + describe('list option', () => { it('should parse --list flag', () => { const result = parseArgs(['--list']); diff --git a/js/test/status-query.js b/js/test/status-query.js index e2ad907..a5d4d59 100644 --- a/js/test/status-query.js +++ b/js/test/status-query.js @@ -32,7 +32,7 @@ function cleanupTestDir() { // Helper to run CLI command function runCli(args, env = {}) { - const result = spawnSync('bun', [CLI_PATH, ...args], { + const result = spawnSync(process.execPath, [CLI_PATH, ...args], { encoding: 'utf8', env: { ...process.env, @@ -48,6 +48,11 @@ function runCli(args, env = {}) { }; } +function createExecutable(filePath, content) { + fs.writeFileSync(filePath, content, 'utf8'); + fs.chmodSync(filePath, 0o755); +} + describe('--status query functionality', () => { let store; let testRecord; @@ -241,6 +246,91 @@ describe('--status query functionality', () => { }); }); + describe('--upload-log functionality', () => { + it('should run gh-upload-log with the stored execution log path', () => { + const fakeBin = fs.mkdtempSync(path.join(os.tmpdir(), 'upload-log-bin-')); + const logPath = path.join(TEST_APP_FOLDER, 'command.log'); + fs.writeFileSync(logPath, 'captured command output\n', 'utf8'); + createExecutable( + path.join(fakeBin, 'gh-upload-log'), + '#!/bin/sh\necho "fake uploader received: $1"\n' + ); + + testRecord.logPath = logPath; + store.save(testRecord); + + const result = runCli(['--upload-log', testRecord.uuid], { + PATH: `${fakeBin}${path.delimiter}/usr/bin${path.delimiter}/bin`, + HOME: fakeBin, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain(`fake uploader received: ${logPath}`); + expect(result.stderr).toBe(''); + + fs.rmSync(fakeBin, { recursive: true, force: true }); + }); + + it('should install gh-upload-log when it is missing before uploading', () => { + if (process.platform === 'win32') { + console.log(' Skipping: shell fixture uses POSIX scripts'); + return; + } + + const fakeBin = fs.mkdtempSync( + path.join(os.tmpdir(), 'upload-log-install-bin-') + ); + const installMarker = path.join(fakeBin, 'install.log'); + const logPath = path.join(TEST_APP_FOLDER, 'install-command.log'); + fs.writeFileSync(logPath, 'captured command output\n', 'utf8'); + + createExecutable( + path.join(fakeBin, 'bun'), + [ + '#!/bin/sh', + `echo "$@" > "${installMarker}"`, + `cat > "${path.join(fakeBin, 'gh-upload-log')}" <<'SCRIPT'`, + '#!/bin/sh', + 'echo "installed uploader received: $1"', + 'SCRIPT', + `chmod +x "${path.join(fakeBin, 'gh-upload-log')}"`, + 'exit 0', + '', + ].join('\n') + ); + + testRecord.logPath = logPath; + store.save(testRecord); + + const result = runCli(['--upload-log', testRecord.uuid], { + PATH: `${fakeBin}${path.delimiter}/usr/bin${path.delimiter}/bin`, + HOME: fakeBin, + }); + + expect(result.exitCode).toBe(0); + expect(fs.readFileSync(installMarker, 'utf8').trim()).toBe( + 'install -g gh-upload-log' + ); + expect(result.stdout).toContain('gh-upload-log not found'); + expect(result.stdout).toContain( + `installed uploader received: ${logPath}` + ); + + fs.rmSync(fakeBin, { recursive: true, force: true }); + }); + + it('should show an error when the stored log file is missing', () => { + testRecord.logPath = path.join(TEST_APP_FOLDER, 'missing.log'); + store.save(testRecord); + + const result = runCli(['--upload-log', testRecord.uuid]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Log file not found'); + expect(result.stderr).toContain(testRecord.logPath); + }); + }); + describe('executing status', () => { it('should show executing status for ongoing commands', () => { // Create an executing (not completed) record diff --git a/rust/changelog.d/issue-128-upload-log.md b/rust/changelog.d/issue-128-upload-log.md new file mode 100644 index 0000000..ca21dbf --- /dev/null +++ b/rust/changelog.d/issue-128-upload-log.md @@ -0,0 +1,5 @@ +--- +bump: minor +--- + +Add `--upload-log ` to upload a stored execution log with `gh-upload-log`, installing the uploader on demand when it is missing. diff --git a/rust/src/bin/main.rs b/rust/src/bin/main.rs index d4e1d56..b59a261 100644 --- a/rust/src/bin/main.rs +++ b/rust/src/bin/main.rs @@ -30,6 +30,7 @@ use start_command::{ set_current_execution, setup_signal_handlers, status_formatter::{list_executions, query_status}, substitution::{process_command, ProcessOptions}, + upload_execution_log, usage::print_usage, user_manager::{ create_isolated_user, delete_user, get_current_user_groups, has_sudo_access, @@ -136,31 +137,30 @@ fn main() { let wrapper_options = parsed.wrapper_options; let parsed_command = parsed.command.clone(); - // Handle --status flag if let Some(ref uuid) = wrapper_options.status { handle_status_query(&config, uuid, wrapper_options.output_format.as_deref()); process::exit(0); } - // Handle --list flag if wrapper_options.list { handle_list_query(&config, wrapper_options.output_format.as_deref()); process::exit(0); } - // Handle --stop flag + if let Some(ref identifier) = wrapper_options.upload_log { + process::exit(handle_upload_log_query(&config, identifier)); + } + if let Some(ref identifier) = wrapper_options.stop { handle_control_query(&config, identifier, ControlAction::Stop); process::exit(0); } - // Handle --terminate flag if let Some(ref identifier) = wrapper_options.terminate { handle_control_query(&config, identifier, ControlAction::Terminate); process::exit(0); } - // Handle --cleanup flag if wrapper_options.cleanup { handle_cleanup(&config, wrapper_options.cleanup_dry_run); process::exit(0); @@ -342,6 +342,18 @@ fn handle_list_query(config: &Config, output_format: Option<&str>) { } } +/// Handle upload-log query +fn handle_upload_log_query(config: &Config, identifier: &str) -> i32 { + let store = config.create_execution_store(); + match upload_execution_log(store.as_ref(), identifier) { + Ok(code) => code, + Err(error) => { + eprintln!("Error: {}", error); + 1 + } + } +} + /// Handle detached execution control query fn handle_control_query(config: &Config, identifier: &str, action: ControlAction) { let store = config.create_execution_store(); diff --git a/rust/src/lib/args_parser.rs b/rust/src/lib/args_parser.rs index b74fe5a..323e594 100644 --- a/rust/src/lib/args_parser.rs +++ b/rust/src/lib/args_parser.rs @@ -18,6 +18,7 @@ //! --shell Shell to use in isolation environments: auto, bash, zsh, sh (default: auto) //! --status Show status of a tracked execution //! --list List all tracked command executions +//! --upload-log Upload the stored log for a tracked execution //! --stop Send CTRL+C/SIGINT to a detached execution //! --terminate Terminate a detached execution immediately @@ -84,6 +85,8 @@ pub struct WrapperOptions { pub status: Option, /// List all tracked execution records pub list: bool, + /// UUID/session name whose stored log should be uploaded + pub upload_log: Option, /// Output format for status/list (links-notation, json, text) pub output_format: Option, /// UUID/session name to stop gracefully @@ -115,6 +118,7 @@ impl Default for WrapperOptions { use_command_stream: false, status: None, list: false, + upload_log: None, output_format: None, stop: None, terminate: None, @@ -394,6 +398,28 @@ fn parse_option( return Ok(1); } + // --upload-log + if arg == "--upload-log" { + if index + 1 < args.len() && !args[index + 1].starts_with('-') { + options.upload_log = Some(args[index + 1].clone()); + return Ok(2); + } else { + return Err(format!( + "Option {} requires a UUID or session name argument", + arg + )); + } + } + + // --upload-log= + if let Some(value) = arg.strip_prefix("--upload-log=") { + if value.is_empty() { + return Err("Option --upload-log requires a UUID or session name argument".to_string()); + } + options.upload_log = Some(value.to_string()); + return Ok(1); + } + // --stop if arg == "--stop" { if index + 1 < args.len() && !args[index + 1].starts_with('-') { @@ -586,6 +612,7 @@ pub fn validate_options(options: &mut WrapperOptions) -> Result<(), String> { let query_modes = [ options.status.is_some(), options.list, + options.upload_log.is_some(), options.stop.is_some(), options.terminate.is_some(), options.cleanup, @@ -596,7 +623,7 @@ pub fn validate_options(options: &mut WrapperOptions) -> Result<(), String> { if query_modes > 1 { return Err( - "Cannot combine --status, --list, --stop, --terminate, or --cleanup in the same invocation" + "Cannot combine --status, --list, --upload-log, --stop, --terminate, or --cleanup in the same invocation" .to_string(), ); } diff --git a/rust/src/lib/args_parser_cases.rs b/rust/src/lib/args_parser_cases.rs index 3ef1bf4..33e007f 100644 --- a/rust/src/lib/args_parser_cases.rs +++ b/rust/src/lib/args_parser_cases.rs @@ -272,6 +272,39 @@ fn test_status_equals_syntax() { ); } +#[test] +fn test_upload_log_option() { + let args: Vec = vec!["--upload-log", "my-session"] + .into_iter() + .map(String::from) + .collect(); + let result = parse_args(&args).unwrap(); + assert_eq!( + result.wrapper_options.upload_log, + Some("my-session".to_string()) + ); + assert!(result.command.is_empty()); +} + +#[test] +fn test_upload_log_equals_syntax() { + let args: Vec = vec!["--upload-log=my-session"] + .into_iter() + .map(String::from) + .collect(); + let result = parse_args(&args).unwrap(); + assert_eq!( + result.wrapper_options.upload_log, + Some("my-session".to_string()) + ); +} + +#[test] +fn test_upload_log_requires_identifier() { + let args: Vec = vec!["--upload-log"].into_iter().map(String::from).collect(); + assert!(parse_args(&args).is_err()); +} + #[test] fn test_stop_option() { let args: Vec = vec!["--stop", "my-session"] @@ -339,7 +372,21 @@ fn test_query_and_control_modes_are_mutually_exclusive() { .map(String::from) .collect(); let error = parse_args(&args).unwrap_err(); - assert!(error.contains("Cannot combine --status, --list, --stop, --terminate, or --cleanup")); + assert!(error.contains( + "Cannot combine --status, --list, --upload-log, --stop, --terminate, or --cleanup" + )); +} + +#[test] +fn test_upload_log_and_control_modes_are_mutually_exclusive() { + let args: Vec = vec!["--upload-log", "uuid", "--terminate", "my-session"] + .into_iter() + .map(String::from) + .collect(); + let error = parse_args(&args).unwrap_err(); + assert!(error.contains( + "Cannot combine --status, --list, --upload-log, --stop, --terminate, or --cleanup" + )); } #[test] diff --git a/rust/src/lib/failure_handler.rs b/rust/src/lib/failure_handler.rs index fb1dbaa..cba874c 100644 --- a/rust/src/lib/failure_handler.rs +++ b/rust/src/lib/failure_handler.rs @@ -245,9 +245,13 @@ pub fn is_gh_authenticated() -> bool { /// Check if gh-upload-log is available pub fn is_gh_upload_log_available() -> bool { + is_command_available("gh-upload-log") +} + +fn is_command_available(command: &str) -> bool { let which_cmd = if cfg!(windows) { "where" } else { "which" }; Command::new(which_cmd) - .arg("gh-upload-log") + .arg(command) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() @@ -255,6 +259,56 @@ pub fn is_gh_upload_log_available() -> bool { .unwrap_or(false) } +/// Install gh-upload-log with an available JavaScript package manager. +pub fn ensure_gh_upload_log_available() -> Result<(), String> { + if is_gh_upload_log_available() { + return Ok(()); + } + + let installers: [(&str, &[&str]); 2] = [ + ("bun", &["install", "-g", "gh-upload-log"]), + ("npm", &["install", "-g", "gh-upload-log"]), + ]; + + for (command, args) in installers { + if !is_command_available(command) { + continue; + } + + println!( + "gh-upload-log not found; installing with: {} {}", + command, + args.join(" ") + ); + let installed = Command::new(command) + .args(args) + .status() + .map(|status| status.success()) + .unwrap_or(false); + + if installed && is_gh_upload_log_available() { + return Ok(()); + } + } + + Err( + "gh-upload-log is not installed and automatic installation did not make it available on PATH." + .to_string(), + ) +} + +/// Upload a log file with gh-upload-log and stream its output to the terminal. +pub fn upload_log_interactive(log_path: &str) -> Result { + ensure_gh_upload_log_available()?; + + let status = Command::new("gh-upload-log") + .arg(log_path) + .status() + .map_err(|e| format!("Failed to run gh-upload-log: {}", e))?; + + Ok(status.code().unwrap_or(1)) +} + /// Upload log file using gh-upload-log pub fn upload_log(log_path: &str) -> Option { let output = Command::new("gh-upload-log") diff --git a/rust/src/lib/log_uploader.rs b/rust/src/lib/log_uploader.rs new file mode 100644 index 0000000..33d8ada --- /dev/null +++ b/rust/src/lib/log_uploader.rs @@ -0,0 +1,30 @@ +//! Helpers for uploading tracked execution logs. + +use std::path::Path; + +use crate::execution_store::ExecutionStore; +use crate::failure_handler::upload_log_interactive; + +/// Upload the log for a stored execution by UUID or session name. +pub fn upload_execution_log( + store: Option<&ExecutionStore>, + identifier: &str, +) -> Result { + let store = store.ok_or_else(|| "Execution tracking is disabled.".to_string())?; + let record = store.get(identifier).ok_or_else(|| { + format!( + "No execution found with UUID or session name: {}", + identifier + ) + })?; + + if record.log_path.is_empty() { + return Err("Execution record does not have a log path.".to_string()); + } + + if !Path::new(&record.log_path).exists() { + return Err(format!("Log file not found: {}", record.log_path)); + } + + upload_log_interactive(&record.log_path) +} diff --git a/rust/src/lib/mod.rs b/rust/src/lib/mod.rs index 14fa6ff..689cf34 100644 --- a/rust/src/lib/mod.rs +++ b/rust/src/lib/mod.rs @@ -7,6 +7,7 @@ pub mod execution_control; pub mod execution_store; pub mod failure_handler; pub mod isolation; +pub mod log_uploader; pub mod output_blocks; pub mod sequence_parser; pub mod signal_handler; @@ -43,6 +44,7 @@ pub use isolation::{ is_shell_invocation_with_args, run_as_isolated_user, run_isolated, write_log_file, IsolationOptions, IsolationResult, LogHeaderParams, }; +pub use log_uploader::upload_execution_log; #[allow(deprecated)] pub use output_blocks::{ // Timeline format API (formerly "status spine") diff --git a/rust/src/lib/usage.rs b/rust/src/lib/usage.rs index 294a9a0..1f395f0 100644 --- a/rust/src/lib/usage.rs +++ b/rust/src/lib/usage.rs @@ -5,6 +5,7 @@ pub fn print_usage() { start [args...] start --status [--output-format ] start --list [--output-format ] + start --upload-log start --stop start --terminate @@ -25,6 +26,7 @@ Options: --use-command-stream Use command-stream library for execution (experimental) --status Show status of execution by UUID or session name (--output-format: links-notation|json|text) --list List all tracked executions (--output-format: links-notation|json|text) + --upload-log Upload the stored log for an execution UUID or session name --stop Send CTRL+C/SIGINT to a detached isolated execution --terminate Terminate a detached isolated execution immediately --cleanup Clean up stale "executing" records (crashed/killed processes) @@ -46,6 +48,7 @@ Examples: start --status a1b2c3d4 --output-format json start --list start --list --output-format json + start --upload-log my-screen-session start --stop my-screen-session start --terminate my-screen-session start --cleanup-dry-run diff --git a/rust/tests/args_parser.rs b/rust/tests/args_parser.rs index ffb6719..2c46066 100644 --- a/rust/tests/args_parser.rs +++ b/rust/tests/args_parser.rs @@ -559,6 +559,25 @@ mod status_tests { ); } + #[test] + fn should_parse_upload_log_with_identifier() { + let result = parse_args(&args(&["--upload-log", "uuid-here"])).unwrap(); + assert_eq!( + result.wrapper_options.upload_log, + Some("uuid-here".to_string()) + ); + assert_eq!(result.command, ""); + } + + #[test] + fn should_parse_upload_log_equals_format() { + let result = parse_args(&args(&["--upload-log=uuid-here"])).unwrap(); + assert_eq!( + result.wrapper_options.upload_log, + Some("uuid-here".to_string()) + ); + } + #[test] fn should_error_for_stop_without_identifier() { let result = parse_args(&args(&["--stop"])); @@ -587,9 +606,9 @@ mod status_tests { fn should_error_when_combining_query_and_control_modes() { let result = parse_args(&args(&["--status", "uuid-here", "--stop", "my-session"])); assert!(result.is_err()); - assert!(result - .unwrap_err() - .contains("Cannot combine --status, --list, --stop, --terminate, or --cleanup")); + assert!(result.unwrap_err().contains( + "Cannot combine --status, --list, --upload-log, --stop, --terminate, or --cleanup" + )); } #[test] From 381f043c488f560407a4caa6db2b469b899ede84 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 20 May 2026 18:54:30 +0000 Subject: [PATCH 3/6] test: fix upload-log ci coverage --- js/test/status-query.js | 20 ++++++++++++++++---- rust/tests/args_parser.rs | 12 ++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/js/test/status-query.js b/js/test/status-query.js index a5d4d59..e23d4a6 100644 --- a/js/test/status-query.js +++ b/js/test/status-query.js @@ -53,6 +53,21 @@ function createExecutable(filePath, content) { fs.chmodSync(filePath, 0o755); } +function createFakeUploader(fakeBin, outputPrefix) { + if (process.platform === 'win32') { + createExecutable( + path.join(fakeBin, 'gh-upload-log.cmd'), + `@echo off\r\necho ${outputPrefix}: %1\r\n` + ); + return; + } + + createExecutable( + path.join(fakeBin, 'gh-upload-log'), + `#!/bin/sh\necho "${outputPrefix}: $1"\n` + ); +} + describe('--status query functionality', () => { let store; let testRecord; @@ -251,10 +266,7 @@ describe('--status query functionality', () => { const fakeBin = fs.mkdtempSync(path.join(os.tmpdir(), 'upload-log-bin-')); const logPath = path.join(TEST_APP_FOLDER, 'command.log'); fs.writeFileSync(logPath, 'captured command output\n', 'utf8'); - createExecutable( - path.join(fakeBin, 'gh-upload-log'), - '#!/bin/sh\necho "fake uploader received: $1"\n' - ); + createFakeUploader(fakeBin, 'fake uploader received'); testRecord.logPath = logPath; store.save(testRecord); diff --git a/rust/tests/args_parser.rs b/rust/tests/args_parser.rs index 2c46066..a401830 100644 --- a/rust/tests/args_parser.rs +++ b/rust/tests/args_parser.rs @@ -578,6 +578,18 @@ mod status_tests { ); } + #[test] + fn should_error_for_upload_log_without_identifier() { + let result = parse_args(&args(&["--upload-log"])); + assert!(result.is_err()); + } + + #[test] + fn should_error_for_upload_log_with_empty_equals_identifier() { + let result = parse_args(&args(&["--upload-log="])); + assert!(result.is_err()); + } + #[test] fn should_error_for_stop_without_identifier() { let result = parse_args(&args(&["--stop"])); From bb5f373d81817396e19cb332743985cb65e7fe23 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 20 May 2026 18:59:33 +0000 Subject: [PATCH 4/6] fix: run upload-log shims on windows --- js/src/lib/log-uploader.js | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/js/src/lib/log-uploader.js b/js/src/lib/log-uploader.js index aa1d283..e4fda9a 100644 --- a/js/src/lib/log-uploader.js +++ b/js/src/lib/log-uploader.js @@ -42,11 +42,22 @@ function resolveCommand(commandName) { return null; } -function runInstall(command, args) { +function shouldRunThroughShell(command) { + return process.platform === 'win32' && /\.(cmd|bat)$/i.test(command); +} + +function runCommand(command, args, options = {}) { + return spawnSync(command, args, { + ...options, + shell: shouldRunThroughShell(command), + }); +} + +function runInstall(command, displayName, args) { console.log( - `gh-upload-log not found; installing with: ${command} ${args.join(' ')}` + `gh-upload-log not found; installing with: ${displayName} ${args.join(' ')}` ); - const result = spawnSync(command, args, { stdio: 'inherit' }); + const result = runCommand(command, args, { stdio: 'inherit' }); return result.status === 0; } @@ -62,10 +73,11 @@ function ensureGhUploadLogAvailable() { ]; for (const [command, args] of installers) { - if (!resolveCommand(command)) { + const installer = resolveCommand(command); + if (!installer) { continue; } - if (runInstall(command, args)) { + if (runInstall(installer, command, args)) { const installed = resolveCommand('gh-upload-log'); if (installed) { return { success: true, command: installed }; @@ -96,7 +108,7 @@ function uploadLogPath(logPath) { return availability; } - const result = spawnSync(availability.command, [logPath], { + const result = runCommand(availability.command, [logPath], { stdio: 'inherit', }); From c0f823c69f4219158a6f564c59a6757cb347ce9b Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 20 May 2026 19:04:03 +0000 Subject: [PATCH 5/6] fix: resolve upload-log command from path --- js/src/lib/log-uploader.js | 43 ++++++++++++++++++++++++++++++++++++++ js/test/status-query.js | 2 +- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/js/src/lib/log-uploader.js b/js/src/lib/log-uploader.js index e4fda9a..3eebca8 100644 --- a/js/src/lib/log-uploader.js +++ b/js/src/lib/log-uploader.js @@ -16,9 +16,52 @@ function isExecutable(filePath) { } } +function isCommandFile(filePath) { + if (process.platform === 'win32') { + return fs.existsSync(filePath); + } + + return isExecutable(filePath); +} + +function getPathCommandNames(commandName) { + if (process.platform !== 'win32' || path.extname(commandName)) { + return [commandName]; + } + + const extensions = (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD') + .split(';') + .filter(Boolean); + + return [commandName, ...extensions.map((ext) => `${commandName}${ext}`)]; +} + +function resolveCommandFromPath(commandName) { + const pathValue = process.env.PATH || ''; + for (const pathEntry of pathValue.split(path.delimiter)) { + if (!pathEntry) { + continue; + } + + const directory = pathEntry.replace(/^"|"$/g, ''); + for (const candidateName of getPathCommandNames(commandName)) { + const candidate = path.join(directory, candidateName); + if (isCommandFile(candidate)) { + return candidate; + } + } + } + + return null; +} + function resolveCommand(commandName) { const isWindows = process.platform === 'win32'; const lookupCommand = isWindows ? 'where' : 'which'; + const pathMatch = resolveCommandFromPath(commandName); + if (pathMatch) { + return pathMatch; + } try { const result = spawnSync(lookupCommand, [commandName], { diff --git a/js/test/status-query.js b/js/test/status-query.js index e23d4a6..a405695 100644 --- a/js/test/status-query.js +++ b/js/test/status-query.js @@ -272,7 +272,7 @@ describe('--status query functionality', () => { store.save(testRecord); const result = runCli(['--upload-log', testRecord.uuid], { - PATH: `${fakeBin}${path.delimiter}/usr/bin${path.delimiter}/bin`, + PATH: fakeBin, HOME: fakeBin, }); From 8b7c23b96003b56e0823709a297724f50f62ee8b Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 20 May 2026 19:10:20 +0000 Subject: [PATCH 6/6] Revert "Initial commit with task details" This reverts commit 13c3b6a58d1a52aeeeb5ff669cb0be089952fb6f. --- .gitkeep | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep deleted file mode 100644 index 80840de..0000000 --- a/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# .gitkeep file auto-generated at 2026-05-20T18:31:08.431Z for PR creation at branch issue-128-9d904e7dd227 for issue https://github.com/link-foundation/start/issues/128 \ No newline at end of file