feat(workflows): align workflow CLI with extension command surface#3419
feat(workflows): align workflow CLI with extension command surface#3419marcelsafin wants to merge 38 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR aligns the specify workflow CLI with the established extension/preset command surface, adding missing flags and lifecycle commands so workflows can be installed from dev paths/URLs, searched by author, updated to newer catalog versions, and toggled enabled/disabled without removal.
Changes:
- Added
workflow add --dev <path>andworkflow add <id> --from <url>(with ID mismatch enforcement for--frominstalls). - Added
workflow update [id]to update catalog-installed workflows (with confirmation + backup/restore on failure). - Added
workflow enable/disable <id>and enforced disabled workflows inworkflow runandworkflow listUI.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
src/specify_cli/workflows/_commands.py |
Implements the aligned CLI surface (add --dev/--from, update, enable/disable) and enforces disabled workflows at run/list time. |
src/specify_cli/workflows/catalog.py |
Extends catalog search to support exact (case-insensitive) --author filtering. |
tests/test_workflows.py |
Adds a dedicated test suite covering the new CLI behaviors and edge cases described in #2342. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if not is_file_source: | ||
| from .catalog import WorkflowRegistry | ||
|
|
||
| installed_meta = WorkflowRegistry(project_root).get(source) | ||
| if installed_meta is not None and installed_meta.get("enabled", True) is False: |
There was a problem hiding this comment.
Done — the run guard now checks isinstance(installed_meta, dict), so a corrupted entry no longer crashes (and doesn't block the run, matching the pre-existing behavior of having no check).
| metadata = installed.get(wf_id) or {} | ||
| if metadata.get("source") != "catalog": | ||
| console.print(f"⚠ {safe_id}: Installed from a local path or URL — re-add to update (skipping)") | ||
| continue |
There was a problem hiding this comment.
Done — workflow update now skips non-dict registry entries with a "Registry entry is corrupted (skipping)" warning. Test added.
| registry.add(workflow_id, { | ||
| "name": definition.name or info.get("name", workflow_id), | ||
| "version": definition.version or info.get("version", "0.0.0"), | ||
| "description": definition.description or info.get("description", ""), | ||
| "source": "catalog", | ||
| "catalog_name": info.get("_catalog_name", ""), | ||
| "url": workflow_url, | ||
| }) |
There was a problem hiding this comment.
Good catch — fixed in the shared _install_workflow_from_catalog helper: a prior enabled: False is preserved across updates/reinstalls. Regression test added.
| metadata = registry.get(workflow_id) | ||
| if metadata is None: | ||
| console.print(f"[red]Error:[/red] Workflow '{_escape_markup(workflow_id)}' is not installed") | ||
| raise typer.Exit(1) | ||
| if metadata.get("enabled", True): | ||
| console.print(f"[yellow]Workflow '{_escape_markup(workflow_id)}' is already enabled[/yellow]") | ||
| raise typer.Exit(0) |
There was a problem hiding this comment.
Done — workflow enable exits cleanly with a corrupted-entry error for non-dict values. Test covers both enable and disable.
| metadata = registry.get(workflow_id) | ||
| if metadata is None: | ||
| console.print(f"[red]Error:[/red] Workflow '{_escape_markup(workflow_id)}' is not installed") | ||
| raise typer.Exit(1) | ||
| if not metadata.get("enabled", True): | ||
| console.print(f"[yellow]Workflow '{_escape_markup(workflow_id)}' is already disabled[/yellow]") | ||
| raise typer.Exit(0) |
There was a problem hiding this comment.
Done — same guard added to workflow disable.
| for wf_id, wf_data in installed.items(): | ||
| console.print(f" [bold]{wf_data.get('name', wf_id)}[/bold] ({wf_id}) v{wf_data.get('version', '?')}") | ||
| marker = "" if wf_data.get("enabled", True) else " [red]\\[disabled][/red]" | ||
| console.print(f" [bold]{wf_data.get('name', wf_id)}[/bold] ({wf_id}) v{wf_data.get('version', '?')}{marker}") |
There was a problem hiding this comment.
Fixed in a2ef4a0. workflow list now skips non-dict entries with a warning and keeps listing valid ones. Covered by test_list_skips_corrupted_registry_entry.
| except Exception as exc: | ||
| if workflow_dir.exists(): | ||
| import shutil | ||
| shutil.rmtree(workflow_dir, ignore_errors=True) | ||
| console.print(f"[red]Error:[/red] Failed to install workflow '{source}' from catalog: {exc}") | ||
| console.print(f"[red]Error:[/red] Failed to install workflow '{workflow_id}' from catalog: {exc}") | ||
| raise typer.Exit(1) |
There was a problem hiding this comment.
Fixed in a2ef4a0. Added except typer.Exit: raise before the generic handler, matching the --from download path, so the non-HTTPS redirect error is no longer duplicated.
| # Try as URL (http/https) — either the positional source is a URL, or an | ||
| # explicit --from URL names where to fetch it (mirrors `extension add --from`). | ||
| download_url = from_url or ( | ||
| source if source.startswith(("http://", "https://")) else None | ||
| ) |
There was a problem hiding this comment.
Fixed in 4fcfd1a. workflow add <source> --from <url> now runs _validate_workflow_id_or_exit(source) up front, so a URL/path/uppercase source fails without touching the network. Regression covered by test_add_from_rejects_invalid_source_id_without_fetch.
| console.print( | ||
| f"[red]Error:[/red] Workflow ID in YAML ({definition.id!r}) " | ||
| f"does not match the requested workflow ID ({expected_id!r})." | ||
| ) |
There was a problem hiding this comment.
Fixed in 4fcfd1a. Both repr() values now go through rich.markup.escape so a bracketed typo can't be parsed as markup.
| console.print( | ||
| f"[red]Error:[/red] Workflow ID in YAML ({definition.id!r}) " | ||
| f"does not match catalog key ({source!r}). " | ||
| f"does not match catalog key ({workflow_id!r}). " | ||
| f"The catalog entry may be misconfigured." | ||
| ) |
There was a problem hiding this comment.
Fixed in 4fcfd1a. Same treatment for the catalog-mismatch message: both repr() values wrapped in _escape_markup.
| if not isinstance(wf_data, dict): | ||
| console.print(f" [yellow]Warning:[/yellow] Skipping corrupted registry entry '{wf_id}'.\n") | ||
| continue | ||
| marker = "" if wf_data.get("enabled", True) else " [red]\\[disabled][/red]" | ||
| console.print(f" [bold]{wf_data.get('name', wf_id)}[/bold] ({wf_id}) v{wf_data.get('version', '?')}{marker}") | ||
| desc = wf_data.get("description", "") | ||
| if desc: | ||
| console.print(f" {desc}") |
There was a problem hiding this comment.
Fixed in 3225b4e. workflow list now routes id, name, version and description through _escape_markup before printing. Regression covered by test_list_escapes_rich_markup_in_registry_fields.
| if not info: | ||
| console.print(f"[red]Error:[/red] Workflow '{source}' not found in catalog") | ||
| console.print(f"[red]Error:[/red] Workflow '{workflow_id}' not found in catalog") | ||
| raise typer.Exit(1) | ||
|
|
||
| if not info.get("_install_allowed", True): | ||
| console.print(f"[yellow]Warning:[/yellow] Workflow '{source}' is from a discovery-only catalog") | ||
| console.print(f"[yellow]Warning:[/yellow] Workflow '{workflow_id}' is from a discovery-only catalog") | ||
| console.print("Direct installation is not enabled for this catalog source.") | ||
| raise typer.Exit(1) | ||
|
|
||
| workflow_url = info.get("url") | ||
| if not workflow_url: | ||
| console.print(f"[red]Error:[/red] Workflow '{source}' does not have an install URL in the catalog") | ||
| console.print(f"[red]Error:[/red] Workflow '{workflow_id}' does not have an install URL in the catalog") | ||
| raise typer.Exit(1) |
There was a problem hiding this comment.
Fixed in 3225b4e. _install_workflow_from_catalog now computes safe_wf_id = _escape_markup(workflow_id) once and uses it for every error path (not-found, discovery-only, missing URL, bad scheme, redirect, generic failure).
| for update in updates_available: | ||
| # Installed workflows are a single workflow.yml — back it up so a | ||
| # failed download/validation doesn't destroy the working copy. | ||
| wf_dir = _safe_workflow_id_dir(workflows_dir, update["id"]) | ||
| wf_file = wf_dir / "workflow.yml" | ||
| backup = wf_file.read_bytes() if wf_file.is_file() else None | ||
| try: | ||
| _install_workflow_from_catalog(project_root, registry, workflows_dir, update["id"]) | ||
| except typer.Exit: | ||
| if backup is not None: | ||
| wf_dir.mkdir(parents=True, exist_ok=True) | ||
| wf_file.write_bytes(backup) | ||
| failed.append(update["id"]) |
There was a problem hiding this comment.
Fixed in 3225b4e. _safe_workflow_id_dir and the backup read are now inside the per-workflow try/except typer.Exit, so an unsafe id in a corrupted registry fails that one entry and the loop continues. Regression covered by test_update_reports_unsafe_registry_id_per_workflow.
| @@ -684,7 +742,13 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None: | |||
| console.print(f"[red]Error:[/red] Failed to download workflow: {exc}") | |||
There was a problem hiding this comment.
Fixed. The download exception is now wrapped in _escape_markup(str(exc)), matching the catalog install path.
| try: | ||
| wf_dir = _safe_workflow_id_dir(workflows_dir, update["id"]) | ||
| wf_file = wf_dir / "workflow.yml" | ||
| backup = wf_file.read_bytes() if wf_file.is_file() else None | ||
| _install_workflow_from_catalog(project_root, registry, workflows_dir, update["id"]) | ||
| except typer.Exit: | ||
| if backup is not None and wf_dir is not None and wf_file is not None: | ||
| wf_dir.mkdir(parents=True, exist_ok=True) | ||
| wf_file.write_bytes(backup) | ||
| failed.append(update["id"]) |
There was a problem hiding this comment.
Fixed in d0de7e0. The per-workflow loop now catches (typer.Exit, OSError), the restore write is wrapped in its own try/except so a failed restore only warns, and OSError paths report through the existing 'Failed to update' summary. Regression covered by test_update_survives_oserror_from_backup_read.
| catalog = WorkflowCatalog(project_root) | ||
|
|
||
| try: | ||
| results = catalog.search(query=query, tag=tag) | ||
| results = catalog.search(query=query, tag=tag, author=author) | ||
| except WorkflowCatalogError as exc: |
There was a problem hiding this comment.
Fixed. workflow search now escapes name, id, version, description and tags before printing, matching extension search and the workflow list fix. Regression covered by test_search_escapes_rich_markup_in_catalog_fields.
| console.print("[red]Error:[/red] Workflow validation failed:") | ||
| for err in errors: | ||
| console.print(f" \u2022 {err}") | ||
| raise typer.Exit(1) |
There was a problem hiding this comment.
Fixed in 3bf431f. Validation errors are now escaped before printing in workflow add, and the same fix went into workflow run's validation output since it prints the same strings. Regression test added: a workflow with version: "[bold]bad[/bold]" fails add with the literal value in the output.
| console.print("[red]Error:[/red] Downloaded workflow validation failed:") | ||
| for err in errors: | ||
| console.print(f" \u2022 {err}") | ||
| raise typer.Exit(1) |
There was a problem hiding this comment.
Fixed in 3bf431f. _install_workflow_from_catalog now escapes each validation error before printing.
mnriem
left a comment
There was a problem hiding this comment.
Please address Copilot feedback
| try: | ||
| definition = WorkflowDefinition.from_yaml(yaml_path) |
| final_url = resp.geturl() | ||
| final_parsed = urlparse(final_url) | ||
| final_host = final_parsed.hostname or "" |
| return | ||
|
|
Adds the missing workflow commands and flags so the workflow CLI matches the extension/preset pattern: add --dev and --from, search --author, update, enable and disable. Disabled workflows are blocked from running and marked in list output. Fixes github#2342 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… failure workflow_remove now persists registry.remove() before deleting any files (fixed previously), but if the registry write succeeds and the subsequent shutil.rmtree(workflow_dir) then fails, the registry was left claiming the workflow uninstalled while its directory remained on disk -- an orphaned install with no path back to a clean state. workflow_step_remove already handles this exact sequencing by capturing the registry entry before removal and restoring it directly into registry.data plus save() (bypassing add(), which would stamp a new updated_at) if the directory removal fails afterwards. Applied the same pattern to workflow_remove: capture registry_metadata via registry.get() before registry.remove(), and on an rmtree OSError, write it straight back into registry.data["workflows"][workflow_id] and save(), matching workflow_step_remove's restore-failure handling (a yellow warning, not a hard failure, since the primary error is already about to be reported). Existing error message and exit behavior for the rmtree failure are unchanged. Added a failing-first regression: install a workflow, monkeypatch shutil.rmtree to raise OSError, and assert a clean existing error message, the directory remaining (rmtree never actually deleted anything), and the registry entry restored byte-for-byte identical (including installed_at/updated_at) -- proving the fix bypasses add() and doesn't re-stamp timestamps. Confirmed red (registry entry stayed None) before the fix, green after. Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Addressed the remaining review findings in 3763e6f, 86de599, fb64ddd, 49f6fb6, and 812050a: workflow errors are escaped, registry writes are atomic and rollback-safe, and add/update/remove failure paths now preserve registry/filesystem consistency for fresh installs and reinstalls. Full suite: 3971 passed, 110 skipped. Posted on behalf of @marcelsafin by GitHub Copilot (model: GPT-5.6 Sol). @copilot review |
| # A direct YAML path may still point at an installed workflow's own | ||
| # file; map it back to its owning project and ID from the canonical | ||
| # path itself so the guard is independent of the caller's cwd. | ||
| resolved = source_path.resolve() |
| except OSError: | ||
| # An I/O failure (e.g. permissions, transient FS issue) is not | ||
| # the same as a corrupted file: the real data may still be | ||
| # intact on disk. Flag it so save() refuses to overwrite that | ||
| # data with this in-memory default instead of silently | ||
| # discarding every prior entry. | ||
| self._load_error = True | ||
| return default_registry |
|
|
||
| # Try as URL (http/https) | ||
| if source.startswith("http://") or source.startswith("https://"): | ||
| shutil.copy2(yaml_path, dest_file) |
| if metadata.get("source") != "catalog": | ||
| console.print(f"⚠ {safe_id}: Installed from a local path or URL — re-add to update (skipping)") |
1. workflow run ownership check followed symlinks via Path.resolve() before mapping a direct YAML path back to its installed workflow ID. A symlinked .specify/workflows/<id>/workflow.yml resolved outside the tree, missed the ownership match entirely, and let the disabled-workflow guard be silently skipped while engine.load_workflow still followed the symlink. Now maps ownership from a lexically-normalized path (os.path. normpath, no symlink following) and explicitly refuses to run if the installed <id> directory or workflow.yml leaf is itself a symlink. Direct external workflow paths that don't match .specify/workflows/... are unaffected. 2. WorkflowRegistry._load() caught a read OSError and silently fell back to an empty in-memory registry, only blocking a later save(). Callers that only query is_installed()/get()/list() before writing a file (e.g. commands/init.py's bundled speckit install, which overwrites workflow.yml once is_installed() reports false) could act on that false-empty state and destroy real data before ever reaching save(). _load() now raises OSError immediately so an unreadable registry fails closed at construction, before any query or side effect is possible. Added _open_workflow_registry() to give every CLI command a consistent clean-error boundary around registry construction. 3. _validate_and_install_local's mkdir/copy2 ran before the try/except that protected registry.add(); a copy2 failure (e.g. a truncating partial write on a reinstall) was not caught at all, so the existing backup-restore cleanup never ran and the prior working workflow.yml was corrupted with a raw traceback surfaced to the user. mkdir/copy2 now run inside the same rollback-protected section as registry.add(), sharing one _cleanup_failed_install() helper. 4. workflow update's skip message claimed any non-catalog source was installed "from a local path or URL", which is wrong for the bundled speckit workflow (source: "bundled"). Message is now source-neutral. Verified all 4 threads are current (not outdated) via GraphQL review thread query on PR github#3419, HEAD 812050a. Tests: strict TDD per fix (red test proving each bug, minimal production change, green). tests/test_workflows.py: 474 passed. Full suite: 3976 passed, 110 skipped. ruff check: all checks passed on touched files and full src tree. Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
workflow run's ownership check derived registry_root/registered_id from the lexical path, then checked the id directory and workflow.yml leaf for symlinks -- but never checked .specify or .specify/workflows themselves for that derived root. _reject_unsafe_workflow_storage only guards the cwd's project_root, which can differ from the path-derived registry_root (a direct path into an unrelated project, or that project's own .specify being a symlink to an attacker-controlled tree). WorkflowRegistry's own symlinked-parent handling silently substitutes an empty registry instead of raising, so a query against it (is_installed/get returning "not found") is not a safety signal a caller can rely on: with a symlinked .specify, the disabled check saw no registry entry and let a disabled workflow run anyway. Fix: reject an unsafe .specify/.specify-workflows for the actual derived registry_root before ever consulting the registry, reusing the existing _reject_unsafe_dir helper already used by _reject_unsafe_workflow_storage. Red-first end-to-end repro: victim project's .specify symlinked to an attacker-controlled tree containing a disabled workflow entry, run invoked with a direct path from an unrelated cwd -- confirmed the disabled workflow executed (exit 0) before the fix, now refused cleanly. Tests: tests/test_workflows.py 475 passed. Full suite: 3977 passed, 110 skipped. ruff check: all checks passed. Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
remove_bundle() had no exception handling around its component removal loop, unlike install_bundle() which converts any raw exception into a clean BundlerError. Since WorkflowRegistry now fails closed (raises OSError) on an unreadable registry file, and _WorkflowKindManager.__init__ constructs WorkflowRegistry with no try/except, an unreadable workflow registry surfaced as a raw OSError through remove_bundle(). The bundle_remove CLI command only catches BundlerError, so the raw OSError propagated uncaught, producing exit_code=1 with empty output instead of a clean, actionable message. Wrap remove_bundle()'s component loop in the same try/except BundlerError: raise / except Exception: raise BundlerError(...) from exc pattern already used by install_bundle(), converting any raw exception at this shared boundary. save_records() remains outside the try block, so a failure still leaves the bundle's record untouched (no removal side effects recorded). Tests: - tests/integration/test_bundler_install_flow.py::test_remove_converts_raw_installer_exception_to_bundler_error (function-level regression: a raw OSError from installer.is_installed must become a clean BundlerError, and the bundle record must survive) - tests/contract/test_bundle_cli.py::test_remove_reports_clean_error_when_primitive_raises_raw_exception (CLI-level regression: `specify bundle remove` must print a clean actionable message and exit non-zero instead of raw/empty output) Both tests were confirmed red beforehand: the raw OSError propagated uncaught out of remove_bundle(), and the CLI-level CliRunner result showed exit_code=1 with empty output. Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Fixed the latest review findings in 6a127b3, 12d985b, and ce3e0ab: workflow ownership checks are lexical and reject symlinked owning storage, registry read failures now fail closed before side effects, local-copy reinstalls roll back safely, bundled update messaging is accurate, and bundle removal converts workflow-registry failures into clean errors. Full suite: 3979 passed, 110 skipped. Posted on behalf of @marcelsafin by GitHub Copilot (model: GPT-5.6 Sol). @copilot review |
| # Defense-in-depth: refuse to read through symlinked parents or a | ||
| # symlinked registry file (mirrors StepRegistry._load). | ||
| if self._has_symlinked_parent() or self.registry_path.is_symlink(): | ||
| return default_registry |
| if existed_before: | ||
| if backup_bytes is not None: | ||
| dest_file.write_bytes(backup_bytes) | ||
| else: | ||
| shutil.rmtree(dest_dir, ignore_errors=True) |
| existed_before = dest_dir.is_dir() | ||
| backup_bytes = ( | ||
| dest_file.read_bytes() if existed_before and dest_file.is_file() else None | ||
| ) |
| existed_before = workflow_dir.is_dir() | ||
| prior_workflow_bytes = ( | ||
| workflow_file.read_bytes() if existed_before and workflow_file.is_file() else None | ||
| ) |
| except Exception as exc: # noqa: BLE001 | ||
| raise BundlerError( | ||
| f"Failed to remove bundle '{bundle_id}': {exc}. " | ||
| "No changes were recorded." | ||
| ) from exc |
| console.print( | ||
| f"[yellow]Warning:[/yellow] Failed to restore registry entry " | ||
| f"for '{safe_id}' after directory removal failure: {restore_exc}" | ||
| ) |
| console.print( | ||
| f"[red]Error:[/red] Failed to remove workflow directory {workflow_dir}: {exc}" | ||
| ) |
| if existed_before: | ||
| if prior_workflow_bytes is not None: | ||
| workflow_file.write_bytes(prior_workflow_bytes) | ||
| else: | ||
| import shutil | ||
| shutil.rmtree(workflow_dir, ignore_errors=True) |
…ck orphans, backup-read boundaries, and Rich escaping 1. WorkflowRegistry._load(): a symlinked .specify/.specify/workflows parent (or a symlinked registry file) silently returned an empty registry instead of raising, unlike an unreadable-file read failure. A read-only caller (notably the bundler's remove path) querying is_installed() before ever writing could conclude an installed workflow is absent, skip removing it, then delete the bundle record -- leaving the workflow untracked but still on disk. Now raises OSError immediately, matching the existing unreadable-file fail-closed behavior. 2/8. _validate_and_install_local and _install_workflow_from_catalog: when the destination directory already existed but had no prior workflow.yml (e.g. a leftover empty dir), existed_before was True but there were no backup bytes to restore, so the rollback closure did nothing on a later failure -- leaving the newly copied/ downloaded file behind. Both now unlink the newly created file in this case, restoring the pre-existing directory to its prior (empty) state. 3/4. Both install paths read the prior workflow.yml bytes (to seed the reinstall rollback) *before* any try/except boundary: a read failure on the existing file (e.g. a transient permission/FS issue) leaked a raw, unescaped OSError instead of the same clean CLI error used by every other failure branch in these functions. Both reads are now guarded by their own try/except OSError, with no writes attempted before the read succeeds (so there is nothing to roll back on this specific failure). 5. remove_bundle's exception-conversion message unconditionally claimed "No changes were recorded," even though a failure can occur after earlier components in the same bundle have already been removed from disk (save_records never runs on this path, so the record is left claiming the bundle fully installed). The message now reports how many components were already removed when that happened, instead of asserting no changes occurred. 6/7. workflow_remove's new post-registry-removal directory-failure error and its restore-failure warning interpolated workflow_dir and the exception values into Rich markup unescaped. A project path or OS error message containing Rich-markup-like brackets could be parsed as markup and hide/corrupt the displayed text. Both now use the existing _escape_markup helper, consistent with every other error path in this file. Tests (tests/test_workflows.py unless noted): - TestWorkflowRegistry::test_load_symlinked_workflows_dir_fails_closed_not_silently_empty (1) - TestWorkflowCliAlignment::test_add_dev_fresh_install_into_preexisting_empty_dir_cleans_new_file (2) - TestWorkflowCliAlignment::test_add_catalog_fresh_install_into_preexisting_empty_dir_cleans_new_file (8) - TestWorkflowCliAlignment::test_add_dev_reinstall_backup_read_failure_gives_clean_error (3) - TestWorkflowCliAlignment::test_add_catalog_reinstall_backup_read_failure_gives_clean_error (4) - tests/integration/test_bundler_install_flow.py::test_remove_partial_failure_message_reflects_partial_state (5) - TestWorkflowRemoveGuard::test_remove_directory_and_restore_failure_escapes_rich_markup (6/7) All seven were confirmed red beforehand, matching each thread's described failure mode exactly (silent empty registry instead of a raise; orphaned new file left behind; raw unescaped OSError leaking; a misleading "no changes were recorded" claim; Rich markup consuming bracketed path/exception text). Also updated test_registry_save_refuses_symlinked_parent, a pre-existing test that asserted the symlinked-parent raise at add()/save() time -- it now raises at construction instead, per fix github#1, so the test was adjusted to match without weakening its guarantee (still asserts no writes occur under the symlinked target). Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Addressed all 8 review findings in 6b593e3: registry loading now fails closed on unsafe symlinks; install rollback/backup paths preserve prior state and handle read failures cleanly; bundle-removal state is accurate; Rich dynamics are escaped. Full suite: 3986 passed, 110 skipped; changed files pass ruff. Posted on behalf of @marcelsafin by GitHub Copilot (model: GPT-5.6 Sol). @copilot review |
| except BundlerError: | ||
| raise | ||
| except Exception as exc: # noqa: BLE001 |
| with tempfile.NamedTemporaryFile(suffix=".yml", delete=False) as tmp: | ||
| tmp.write(resp.read()) |
| f"[red]Error:[/red] Workflow '{safe_wf_id}' redirected to non-HTTPS URL: {_escape_markup(final_url)}" | ||
| ) | ||
| raise typer.Exit(1) | ||
| workflow_file.write_bytes(response.read()) |
… removal, bounded workflow downloads 1. bundle remove: BundlerError raised by the primitive installer itself (e.g. from a kind manager) bypassed the partial-removal bookkeeping message added previously via a bare `except BundlerError: raise`. Now routes through the same detail-construction logic as generic exceptions, so a mid-loop BundlerError after an earlier successful removal still reports that the project may be partially uninstalled, while a zero-removal BundlerError still reports "No components were removed." Both preserve the original exception message and chain `from exc`. 2/3. workflow add --from and catalog install/update downloads used unbounded `response.read()`, buffering the entire server-controlled body into memory before any size check, and trusted Content-Length alone where checked at all. Added a single shared `_read_response_within_limit()` helper reused by both call sites: it fails fast on an oversized declared Content-Length, and separately enforces the same cap while streaming in 64KiB chunks so a chunked or Content-Length-less response cannot bypass the limit by lying about or omitting its size. Chose 5 MiB as the cap: workflow YAML definitions are small step/metadata text, not binaries, so this is generous headroom against a malicious/misbehaving server without affecting any legitimate workflow definition. Both call sites already route any raised exception through their existing clean-error and rollback (`_cleanup_failed_install`) paths, so no additional error-handling plumbing was needed. Tests: extended the shared `_FakeResponse` test helper (and 5 duplicate per-test FakeResponse classes) to support `.read(amt)` chunked reads with an internal cursor (backward compatible with existing bare `.read()` callers) plus header simulation. Added red-first tests for: BundlerError after partial removal reporting partial state, BundlerError with zero removals reporting no changes, --from oversized-Content-Length rejection, --from oversized-streamed-body-without-Content-Length rejection, and the same two cases for the catalog install path (asserting no orphan directory/registry mutation on rejection). tests/integration/test_bundler_install_flow.py: 17 passed tests/test_workflows.py: 485 passed tests -q: 3992 passed, 110 skipped ruff check: clean on all touched files Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…est assertions workflow_add's --from download path opened a NamedTemporaryFile(delete=False) -- which creates the file on disk immediately -- then wrote the size-limited response body before assigning `tmp_path`. If `_read_response_within_limit` raised (oversized declared Content-Length, or an over-cap streamed body with no/understated Content-Length), the exception propagated out of the `with` block before `tmp_path` was ever set, so the outer except handler had no path to clean up: a 0-byte `.yml` temp file was left behind permanently on every rejected/failed --from download. Fixed by assigning `tmp_path` immediately after the file is opened (before the size-limited read/write), and unlinking it in the except branch when set. Normal post-download cleanup in the existing `finally: tmp_path.unlink(missing_ok=True)` is unchanged. Verified (not assumed) the catalog install path has no equivalent leak: it writes the response bytes directly to `workflow_file` inside `workflow_dir` (no separate temp file), and any read/size-limit failure is already caught by the existing `except Exception: _cleanup_failed_install()` handler, which correctly restores a reinstalled file or removes a freshly-created directory. While investigating, found the previous round's 4 size-limit tests were false positives: `_read_response_within_limit`'s `max_bytes` parameter had its default bound to `_MAX_WORKFLOW_YAML_BYTES` at function-definition time, so monkeypatching the module attribute in tests had no effect on the function's actual behavior -- the tests were passing because the oversized mock bodies failed downstream YAML/id validation instead of the size check. Fixed by resolving `max_bytes` from the module attribute at call time (default `None`, resolved inside the function body) so tests can actually override the effective limit, and strengthened all 4 tests' assertions to match the specific size-limit error text (whitespace-collapsed to tolerate Rich's line-wrapping), so they now prove the real code path fires. Tests: added 2 red-first regression tests (oversized-streamed-body and oversized-Content-Length --from downloads leave no leftover temp file, verified against a scratch tempfile.tempdir), confirmed red (real 0-byte file found) before the fix and green after. Strengthened the pre-existing 4 --from/catalog size-limit tests to assert on the actual error message instead of generic exit-code/non-empty-output checks. tests/test_workflows.py: 487 passed tests -k bundler: 186 passed tests -q: 3994 passed, 110 skipped ruff check: clean on all touched files Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Addressed the 3 latest findings plus download-cleanup follow-up in db45f6c and b8269c8: BundlerError now reports partial removal accurately, both workflow download paths share a streamed 5 MiB cap, failed --from downloads remove their temp file, and tests assert the actual cap path. Full suite: 3994 passed, 110 skipped; changed files pass ruff. Posted on behalf of @marcelsafin by GitHub Copilot (model: GPT-5.6 Sol). @copilot review |
| shutil.rmtree(workflow_dir) | ||
| except OSError as exc: | ||
| # The registry removal already succeeded; restore the original | ||
| # entry verbatim so the registry doesn't claim this workflow is | ||
| # uninstalled while its directory is still sitting on disk. | ||
| try: | ||
| if registry_metadata is not None: | ||
| registry.data["workflows"][workflow_id] = registry_metadata | ||
| registry.save() |
| if existed_before: | ||
| if backup_bytes is not None: | ||
| dest_file.write_bytes(backup_bytes) | ||
| else: | ||
| dest_file.unlink(missing_ok=True) | ||
| else: | ||
| shutil.rmtree(dest_dir, ignore_errors=True) |
| if existed_before: | ||
| if prior_workflow_bytes is not None: | ||
| workflow_file.write_bytes(prior_workflow_bytes) | ||
| else: | ||
| workflow_file.unlink(missing_ok=True) | ||
| else: | ||
| import shutil | ||
| shutil.rmtree(workflow_dir, ignore_errors=True) |
| # symlinked-parent handling below (it silently substitutes | ||
| # an empty registry instead of raising, so a query against | ||
| # it can't be trusted as a safety signal here). |
| fd, tmp = tempfile.mkstemp( | ||
| dir=str(self.registry_path.parent), | ||
| prefix=f".{self.registry_path.name}.", | ||
| suffix=".tmp", | ||
| ) | ||
| try: | ||
| with os.fdopen(fd, "w", encoding="utf-8") as f: | ||
| json.dump(self.data, f, indent=2) | ||
| os.replace(tmp, self.registry_path) |
Addresses 5 Copilot review findings on HEAD b8269c8, all centered on transaction integrity around workflow install/remove/registry writes, following the atomic_write_json pattern already used in _utils.py: 1. WorkflowRegistry.save() now preserves the existing registry file's mode (e.g. 0640/0644) across a save instead of silently downgrading it to mkstemp's 0600 default; a brand-new registry still gets the secure 0600 default. 2. workflow_remove now stages the install directory out of the way via an atomic rename *before* the registry write, rather than deleting it directly with shutil.rmtree after the registry already claims it removed. This closes a real data-integrity gap: a partially-failed rmtree could no longer leave a damaged directory re-marked "installed" by the old manual restore-after-rmtree-failure code (now deleted -- it's structurally impossible to need it). A registry-write failure renames the staged directory back (guarded, with an explicit warning if the restore-back rename itself fails); a registry-write success is durable, so a later failure to delete the staged directory is now a warning (exit 0), not a contradictory "Error: Failed to remove" (exit 1) that used to claim failure while the registry already recorded success. 3. Local (--dev/--from/plain path) and catalog install/reinstall now write new content to a same-directory staging file and commit it onto the destination workflow.yml via a single atomic swap, instead of writing/downloading directly into the destination file. A prior file (reinstall) is renamed aside rather than overwritten in place, so it can be restored via rename -- never a content rewrite -- if registry.add() subsequently fails; a rollback failure is now explicitly reported as a warning instead of escaping unguarded and masking the original clean error. This also removes the need to read the prior file's bytes into memory before installing (that read-before-write step and its failure mode are now unreachable), and both local and catalog installs share the same four small helpers (_stage_workflow_file / _commit_workflow_file / _discard_staged_workflow_file / _rollback_committed_workflow_file, plus guarded wrappers) rather than duplicating the logic. 4. Updated a stale comment (workflow_run's ownership-guard rationale) that still described WorkflowRegistry._load() as silently substituting an empty registry; it now fails closed by raising OSError, which the comment now states plainly. Tests: rewrote the two workflow_remove tests whose assertions encoded the old (incoherent) rmtree-then-restore contract to instead prove the new stage-then-commit contract (post-registry-success cleanup failure is a warning+exit 0; pre-registry-success stage-restore failure is guarded and escapes markup correctly). Rewrote the local/catalog "backup read failure" tests, which tested a step the new design no longer performs, into "restore-rename failure" tests proving the new guarded rollback boundary. Added registry file-mode preservation tests. All other existing install/remove/reinstall tests (save-failure cleanup, pre-existing-empty-dir handling, early-failure-during- reinstall parametrized cases, Rich markup escaping) continue to pass unmodified against the new implementation. Verified via GraphQL that all 5 threads are current (not outdated/ resolved) before fixing. Full suite: 3996 passed, 110 skipped. Ruff clean on all touched files. Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
_commit_workflow_file() renames a prior workflow.yml aside to workflow.yml.bak so it can be restored if registry.add() subsequently fails. Neither the local install/reinstall path nor the catalog install/reinstall path ever cleaned up that backup after a successful registry.add() -- every successful reinstall permanently left a workflow.yml.bak sibling, which later reinstalls would silently overwrite/re-orphan. Add a shared _discard_committed_backup_file() helper, called from both success paths right after registry.add() durably succeeds (and before the final "installed" message, preserving output ordering). A fresh install (backup_file is None) is a no-op. A cleanup failure is reported as a warning (exit 0), not a failure, since the install itself already succeeded -- consistent with workflow_remove's post-commit cleanup warning semantics. Add red-first regression tests proving: (1) successful local reinstall leaves no workflow.yml.bak sibling, (2) successful catalog reinstall leaves no workflow.yml.bak sibling, (3) a cleanup failure on the backup file after a successful reinstall reports a warning and still exits 0 with the registry correctly reflecting the new install. Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
_stage_workflow_file() does dest_dir.mkdir(parents=True, exist_ok=True) then tempfile.mkstemp(dir=dest_dir, ...). For a fresh install (no prior directory), if mkdir succeeds but mkstemp then raises (disk full/EMFILE/quota), the exception previously propagated straight past both the local-install and catalog-install call sites without any cleanup, leaving the newly-created empty workflow directory orphaned on disk with no error indicating why. Fix at the shared _stage_workflow_file() boundary instead of duplicating cleanup at each call site: track whether this call created dest_dir: on a mkstemp failure, remove that directory via a guarded rmdir (never a broad rmtree, so any concurrently written content would be left untouched) before re-raising the original OSError unchanged. A pre-existing (reinstall) dest_dir is never touched by this cleanup, and a cleanup failure is reported as its own warning without masking the original error. Add red-first regression tests proving: a fresh local install (--dev, plain local path, --from) and a fresh catalog install both clean up the orphaned directory on a simulated mkstemp failure, and a reinstall over a pre-existing directory is left untouched by the same failure. Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Addressed all 5 latest findings plus transaction-cleanup follow-ups in 00465c3, 7c6bc90, and 915caf8: workflow files/directories now use atomic staging and guarded rollback, registry saves preserve existing mode, successful reinstalls discard backups, and failed staging leaves no workflow directory. Full suite: 4004 passed, 110 skipped; changed files pass ruff. Posted on behalf of @marcelsafin by GitHub Copilot (model: GPT-5.6 Sol). @copilot review |
| for i in range(len(parts) - 2): | ||
| if parts[i] == ".specify" and parts[i + 1] == "workflows": | ||
| registry_root = Path(*parts[:i]) if i else Path(lexical.anchor or ".") | ||
| registered_id = parts[i + 2] |
| finally: | ||
| tmp_path.unlink(missing_ok=True) |
| if backup is not None and wf_dir is not None and wf_file is not None: | ||
| try: | ||
| wf_dir.mkdir(parents=True, exist_ok=True) | ||
| wf_file.write_bytes(backup) |
| try: | ||
| registry.add(workflow_id, {**metadata, "enabled": False}) | ||
| except OSError as exc: |
| WorkflowRegistry(project_dir) | ||
| assert not (outside / "workflows").exists() | ||
|
|
||
| def test_registry_save_preserves_existing_file_mode(self, project_dir): |
| mode = stat.S_IMODE(registry.registry_path.stat().st_mode) | ||
| assert mode == 0o644, f"expected 0644, got {oct(mode)}" | ||
|
|
||
| def test_registry_save_on_new_registry_uses_secure_default_mode(self, project_dir): |
|
|
||
| assert seen["validator"] is _reject_insecure_download_redirect | ||
|
|
||
| def test_registry_save_failure_preserves_file_on_disk(self, project_dir, monkeypatch): |
| else: | ||
| result.skipped.append(component) | ||
| detail = "No components were removed." |
| # out of .specify/workflows, fail to find an owner, and let | ||
| # engine.load_workflow below run the symlink target unchecked -- | ||
| # silently bypassing a disabled workflow's guard. | ||
| lexical = Path(os.path.normpath(str(source_path.absolute()))) |
Fixes #2342
Root cause
The workflow CLI grew after the extension CLI and never picked up the full command surface.
workflow addonly accepted catalog IDs, URLs and plain file paths, and there was no way to update an installed workflow, filter search by author, or disable a workflow without removing it.Change
Mirrors the extension/preset commands, same flag names and behavior:
workflow add --dev <path>installs from a local directory or YAML file for development.workflow add <id> --from <url>installs from an explicit URL and fails if the downloaded workflow ID does not match, so nothing is registered under a wrong name.workflow search --author <name>filters catalog results by author (case-insensitive exact match, same as extensions).workflow update [id]updates catalog-installed workflows when a newer version exists, with a confirm prompt. Local and URL installs are skipped with a hint to re-add. The previousworkflow.ymlis backed up and restored if the download fails.workflow enable/disable <id>toggles anenabledflag in the registry.workflow runrefuses disabled workflows andworkflow listmarks them[disabled].Left out
set-priority(no priority concept for workflows) andsearch --verified(catalog has no verification data), per the issue discussion.The catalog install block in
workflow_addmoved verbatim into a module-level_install_workflow_from_cataloghelper soupdatereuses the exact same download/validate/register path instead of duplicating it.Testing
17 new tests in
TestWorkflowCliAlignmentcover: dev install from directory and file, missing path and missing workflow.yml errors, URL install, ID mismatch rejection, author filtering, update with no workflows, unknown ID, non-catalog skip, newer-version update, up-to-date short-circuit, backup restore on failed download, disable blocking run, enable restoring, list marker, unknown-ID errors and idempotent enable/disable warnings.Full suite: 3851 passed, 107 skipped.
ruff checkclean.