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>
_install_workflow_from_catalog is new in this PR and has seven failure
branches after the mkdir/download step, each independently rmtree'ing
workflow_dir: redirect-to-non-HTTPS rejection, a generic download
exception, invalid downloaded YAML, a validate_workflow failure, a
workflow-id/catalog-key mismatch, a version mismatch, and (fixed in the
prior commit) a registry.add() OSError. Only the last one had been
special-cased to spare a prior working install on reinstall; the other
six still unconditionally deleted the whole directory, so re-adding an
already-installed catalog workflow and hitting any of those six earlier
failures destroyed the working install even though nothing about it had
actually changed.
Replaced all seven ad hoc rmtree call sites with a single local
_cleanup_failed_install() helper that closes over the existed_before /
prior_workflow_bytes captured once at the top of the function: restore
the prior workflow.yml for a reinstall, or rmtree only a directory that
this attempt itself created. Every failure branch now calls this one
helper, so the fix is structural rather than duplicated, and every
existing error message/exit code is unchanged -- only the cleanup
performed before each message is different.
Added a parametrized regression test covering the four early-failure
trigger points reachable from plain workflow add (redirect rejection,
download exception, invalid YAML, ID mismatch): each installs a catalog
workflow, re-adds it while forcing that specific failure, and asserts a
clean error plus the original workflow.yml surviving byte-for-byte.
Confirmed red against the unfixed code (all four raised FileNotFoundError
reading the deleted file) before applying the helper, green after.
Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
_install_workflow_from_catalog's final registry.add() failure handler
unconditionally rmtree'd workflow_dir. That's safe for a brand-new
install, but plain `workflow add <catalog-id>` also allows re-adding an
already-installed workflow, downloading the new version over the
existing directory first. If registry.add() then failed to save, the
unconditional rmtree deleted the prior working install while the
registry (after its own rollback) still reported it installed -- data
loss with no way back. workflow_update already avoids this via an outer
backup/restore around this function, but plain add has no such caller.
Fix mirrors _validate_and_install_local's existed-before/backup-aware
handling: capture whether workflow_dir existed and back up its
workflow.yml bytes before any download write, then on a registry.add()
OSError, restore those bytes for a reinstall or rmtree only a
brand-new directory. Only one file (workflow.yml) is ever written by
this path, so no further per-file bookkeeping is needed.
Added a failing-first regression: install a catalog workflow, re-add it
with a simulated registry save OSError, and assert a clean error, the
original workflow.yml restored byte-for-byte, and the registry still
reporting the original version installed. Confirmed red (prior file
deleted) before the fix, green after.
Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Critical: WorkflowRegistry.remove() deleted the in-memory entry then
called save() with no rollback, unlike add(). Combined with
workflow_remove deleting the workflow directory before calling
registry.remove(), a save failure permanently destroyed the workflow's
files, left the on-disk registry still claiming it installed, and
surfaced a raw unhandled OSError with no CLI message.
- WorkflowRegistry.remove() now rolls back the in-memory entry on a
save() OSError, mirroring add()'s existing rollback pattern.
- workflow_remove persists the registry removal (registry.remove(),
wrapped in try/except OSError -> clean escaped message) before
deleting any files, so a save failure never touches the workflow
directory.
Important sibling paths: workflow add (local/--dev/--from and catalog),
enable, and disable all called registry.add() without catching its
deliberate OSError, so a save failure surfaced either an orphaned
install directory (fresh local/catalog installs) or a raw/unhandled
exception with no clean CLI output.
- _validate_and_install_local (backs local/--dev/--from) now removes
the freshly created directory on a fresh install, or restores the
prior workflow.yml bytes on a reinstall-over-existing-local install,
before raising a clean escaped error.
- _install_workflow_from_catalog wraps the final registry.add() using
the function's own established convention (rmtree the just-downloaded
workflow_dir, then a clean escaped error) -- workflow_update's
existing backup/restore around this function is unaffected.
- workflow_enable/workflow_disable catch registry.add()'s OSError and
print a clean escaped message instead of leaking the exception.
Added failing-first tests proving each behavior (registry-unit rollback
test, CLI-level remove/add/enable/disable save-failure tests
parametrized where they share one root cause), all confirmed red before
the fix and green after.
Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- workflow add's plain local-path fallback (no --dev) checked wf_file.exists()
before installing, so a directory literally named workflow.yml passed the
guard and _validate_and_install_local() leaked an uncaught
IsADirectoryError instead of the documented CLI error. Use is_file(),
matching the --dev branch's existing guard.
- WorkflowRegistry._load() treated any OSError while reading an existing
registry the same as corrupted JSON, resetting to an empty in-memory
registry. A later save() would then silently persist that empty state via
os.replace, discarding every previously installed workflow entry. Track a
_load_error flag on OSError-during-read and have save() refuse to write
when it is set, so a transient I/O failure can no longer overwrite intact
data on disk.
- docs/reference/workflows.md: document `--from <url>` with its value
placeholder, matching extensions.md and presets.md.
Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
All three workflow download sites (add --from, catalog install, step
install) passed no redirect_validator to open_url, so an HTTPS URL
redirecting to cleartext HTTP issued the insecure request before the
post-hoc geturl() check reported it. Shared validator now rejects
non-HTTPS redirects (loopback HTTP allowed) pre-follow, matching the
preset download path.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- WorkflowRegistry now mirrors StepRegistry: _load refuses symlinked
parents/registry file and normalizes a non-dict workflows field;
save() rejects symlinked paths before writing.
- workflow add --dev requires workflow.yml to be a regular file so a
directory named workflow.yml gets the documented CLI error instead of
an uncaught IsADirectoryError.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- save() now uses tempfile.mkstemp in the workflows dir (matching the
engine's atomic writer), so a pre-created symlink at a predictable
.tmp path cannot redirect the write and concurrent processes cannot
collide.
- The direct-path disabled guard derives the owning project from the
resolved file path instead of the caller's cwd, so running an
installed workflow's YAML from outside the project still refuses when
disabled.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- save() wrote the registry with open('w'), so a failed dump truncated
the file and the next load reset every entry. Write to a sibling temp
file and os.replace into place.
- workflow update no longer claims all workflows are up to date when
some targets were skipped; it reports checked-only status with a
skipped count.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- A truthy non-string catalog url (e.g. 123) reached urlparse and raised
AttributeError, escaping the clean error path; validate it is a string.
- enable/disable mutated the live registry entry before add(), so add's
rollback snapshot captured the already-toggled object; pass a fresh
mapping instead.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- WorkflowRegistry.add now rolls back its in-memory mutation when save()
raises, so a later successful save cannot persist metadata for a
failed update alongside the restored YAML backup.
- workflow run uses the same truthiness check for 'enabled' as list and
disable, so malformed values like 0 or null refuse to run.
- workflow update reports 'No workflows were eligible for update' when
every target was skipped instead of claiming all are up to date.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
'workflow add foo --from ""' fell through 'from_url or ...' to a
catalog install. Distinguish None from empty string so explicit values
stay on the URL-validation path and fail closed.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Running the installed copy's YAML directly (specify workflow run
.specify/workflows/align-wf/workflow.yml) skipped the registry check.
File sources resolving inside .specify/workflows/<id>/ now map back to
the workflow ID and refuse to run while disabled.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Path-equivalent spellings like "align-wf/" previously bypassed the
registry disabled check because the engine normalizes the path while the
registry matches the raw string. workflow run now validates non-file
sources against the workflow ID pattern before lookup.
Also updates docs/reference/workflows.md with --dev/--from install
options, update/enable/disable commands, and the search --author flag.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The update path never verified the downloaded workflow carries the catalog
version that triggered the update, so a stale or misconfigured URL could
report success while leaving the old version installed or downgrading it.
Pass the expected version into the install helper and fail the update when
the downloaded definition does not match.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
urlparse raises ValueError on e.g. an unbalanced IPv6 literal before the
invalid-URL branch is reached; on workflow update that also bypassed the
per-workflow handler and aborted the whole command. Convert the parse
failure into a clean error so add fails cleanly and update skips just the
affected workflow.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Workflow names and ids come from user-controlled YAML or external catalog
data; printing them unescaped lets bracket characters be interpreted as
Rich tags. Escape them in the add/catalog-install success messages and the
remaining catalog error paths, matching the rest of the output hardening.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Covers the last few review threads not yet addressed:
- Escape yaml.YAMLError text in the local workflow add install path
(matches the already-escaped download/catalog paths).
- Escape the non---dev local directory fallback's "No workflow.yml
found in <path>" message (the --dev branch already escaped it).
- Escape the redirected final_url in the --from non-HTTPS redirect
error (IPv6 literals like http://[::1]/... are legal and contain
brackets).
- Escape the "Downloaded workflow is invalid" exception message in
_install_workflow_from_catalog, matching the sibling catalog-install
exception handler a few lines above it.
Adds regression tests for each in TestWorkflowCliAlignment, following
the existing escaping-test pattern in this class.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Transient FS errors (perms, disk full) from backup read or write no
longer abort the whole update run. The restore is wrapped in its own
try/except so a failed write only warns, and the offending workflow
is reported via 'Failed to update' like other per-workflow failures.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
workflow list now escapes id/name/version/description before printing,
matching how extensions render user-editable fields. The catalog install
helper computes safe_wf_id once and uses it for every early error path
plus the final failure message.
workflow update wraps _safe_workflow_id_dir and the backup read inside
the try/except typer.Exit block so an unsafe id in a corrupted registry
fails that one workflow and the rest continue.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The two id-mismatch error paths interpolated repr() into Rich markup, so
a stray bracket in a user typo could be parsed as markup. Route both
through rich.markup.escape.
`workflow add <source> --from <url>` also validated the source only
after downloading. Validate it up front so a URL/path/typo fails
without a network fetch.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
workflow list now skips non-dict registry entries with a warning instead
of crashing, matching update/enable/disable. The broad except in
_install_workflow_from_catalog no longer swallows typer.Exit, so precise
errors like the non-HTTPS redirect message are not duplicated.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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#2342
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(workflows): validate scalar types before string operations in workflow validation
YAML parses unquoted scalars like version: 1.0 and id: 123 as
float/int, which crashed validate_workflow and workflow add with raw
tracebacks. Type-check id, name, version and step ids before regex
and string operations so these surface as validation errors. Accept
an unquoted schema_version: 1.0 instead of printing a self-identical
rejection message.
Fixes#3420
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(workflows): treat falsey non-strings as type errors, not missing fields
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(workflows): only accept schema_version 1.0 so the error message is accurate
The check also accepted "1" while the error said Expected '1.0'.
Unquoted YAML 1.0 still works via str(); plain 1 is now rejected with
the message that matches.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(cli): exit cleanly on malformed IPv6 URLs in extension/preset/workflow add
extension add --from, preset add --from, and workflow add <url> parsed
the user-supplied URL with a bare urlparse before their HTTPS/host
validation, so an unclosed IPv6 bracket escaped as a raw ValueError
traceback. Wrap each parse and emit the surrounding validation's clean
error style + typer.Exit(1) instead.
Fixes#3368
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(cli): convert malformed redirect URLs to URLError in shared redirect handler
Parse the redirect target once in _StripAuthOnRedirect.redirect_request
before the validator and stdlib handler run, converting ValueError into
URLError which every download path already catches. Also escape from_url
in the preset install message so IPv6 brackets don't break Rich markup.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
workflow run/resume --json is contracted to emit a single JSON object on
stdout, but every error path (workflow-not-found, invalid workflow,
validation failure, execute/resume failure, and the shared
_parse_input_values invalid-input error) used console.print, landing the
human error text on stdout and corrupting the machine-readable stream.
Route those messages through err_console when --json is set (a no-op for
normal text mode), mirroring the stderr-only error routing already used
by 'specify bundle' (_fail) and err_console elsewhere in this module.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(cli): honor SPECIFY_INIT_DIR in the specify CLI project resolver
The shell resolver honors SPECIFY_INIT_DIR (#2892), but the Python CLI did
not: it resolved the project as Path.cwd() + a .specify/ check and never read
the override. So setup-plan.sh respected it while `specify integration install`
ignored it, and you still had to cd into the member project.
Route project resolution through a shared _resolve_init_dir_override() that
applies the shell resolver's validation rules (relative to cwd, must exist and
contain .specify/, hard error, no fallback, same error strings). It's wired into
_require_specify_project() — the chokepoint for every project-scoped subcommand
(integration/extension/workflow/preset/...) — and the `workflow run <file>`
standalone path, which re-applies its symlinked-.specify guard on the override
branch too. init is unchanged: it creates .specify/, so the must-pre-exist rule
doesn't apply.
The resolver canonicalizes symlinks via Path.resolve() while the shell keeps the
logical path; they agree for non-symlinked paths (documented in the resolver).
Tests in tests/test_init_dir_cli.py mirror the strict cases from test_init_dir.py
through the CLI; conftest now strips SPECIFY_* for the whole suite so a stray
export can't perturb the now-env-reading resolver. Docs note the CLI applies the
same rules.
Discussion: github/spec-kit#2834
(Disclosure: I used an AI coding agent to audit the call sites and resolver,
draft the change, and run an adversarial code review; reviewed by me.)
* fix(cli): honor SPECIFY_INIT_DIR for bundle commands
Assisted-by: Codex (model: GPT-5, autonomous)
* fix(bundler): refuse symlinked .specify on the SPECIFY_INIT_DIR override path
find_project_root refuses a symlinked .specify (following it could read/write
outside the tree, and a test pins that), but the SPECIFY_INIT_DIR override added
for bundle commands returned early and skipped that guard:
_resolve_init_dir_override validates .specify with is_dir(), which follows
symlinks. So `specify bundle` accepted via the override a layout the cwd path
rejects. Re-check the override result with the same guard, plus a regression test.
(Disclosure: found via an AI code review and fixed with an AI coding agent;
reviewed by me.)
* fix(cli): keep SPECIFY_INIT_DIR strict for bundles
Treat an explicit symlinked SPECIFY_INIT_DIR project as a hard bundle error instead of returning no project, which could initialize the current directory. Align the docs with the actual unset resolver behavior.
Assisted-by: Codex (model: GPT-5, autonomous)
* docs(core): note symlinked .specify handling differs across CLI surfaces
A symlinked .specify is followed by integration/extension/workflow (matching the
shell resolver) but refused by bundle and workflow run <file> (write
confinement). Document the asymmetry so it reads as intentional.
(Disclosure: AI-assisted; reviewed by me.)
* docs(core): reframe symlinked .specify note around the override invariant
Per maintainer feedback on #3186: SPECIFY_INIT_DIR relocates where the project
is, not how a surface treats symlinks. Each surface keeps its cwd-path stance
(write surfaces refuse a symlinked .specify, read/config surfaces follow it),
so the split is one policy relocated, not an inconsistency.
* docs: address Copilot review on resolver docstrings
- _project.py: the error messages "mirror" the shell wording rather than
"match" it (the CLI renders a Rich `Error:` line, the shell a plain `ERROR:`).
- find_project_root: document that honoring SPECIFY_INIT_DIR when start is None
can raise typer.Exit / BundlerError, so the Path | None signature isn't
surprising to direct callers.
* docs(bundler): note require_project_root inherits the override raise behavior
find_project_root can raise typer.Exit / BundlerError under the SPECIFY_INIT_DIR
override (start=None); require_project_root inherits that, so document it
alongside its own BundlerError-on-missing-project.
* docs: clarify symlinked project root behavior
Assisted-by: OpenAI Codex (model: GPT-5, autonomous)
* Address SPECIFY_INIT_DIR review feedback
Assisted-by: OpenAI Codex (model: GPT-5, autonomous)
* Route workflow JSON errors to stderr
Assisted-by: OpenAI Codex (model: GPT-5, autonomous)
* refactor: move workflow command handlers to workflows/_commands.py (PR-8/8)
Final PR of the __init__.py split. Moves the workflow command group out of
__init__.py into the existing workflows/ package, completing the domain-dir
layout established in PR-5 (integrations), PR-6 (presets) and PR-7
(extensions).
- New workflows/_commands.py holds the four Typer apps (workflow / catalog /
step / step-catalog), all 25 command handlers, the six workflow-only
helpers (_parse_input_values, _workflow_run_payload, _emit_workflow_json,
_stdout_to_stderr_when, _validate_step_id_or_exit,
_resolve_steps_base_dir_or_exit), and a register(app) entry point.
- workflows is already a package, so no rename is needed; intra-package
imports change from `.workflows.x` to `.x`. The only root-helper dep
(_require_specify_project) is reached through a call-time shim so test
monkeypatching of specify_cli._require_specify_project keeps working.
- __init__.py drops ~1445 lines (2066 -> 621); the workflow group is
re-attached via register(app). Dead `contextlib` import removed.
- tests/test_workflows.py: import the now-relocated _stdout_to_stderr_when
helper from its new home (workflows._commands) instead of the package root.
No behavior change. Full suite green (3847 passed), ruff clean.
* Prevent workflow state writes through symlinked storage
Workflow commands persist run state under .specify/workflows/runs, so the command-local project shim now rejects symlinked workflow storage before any workflow command proceeds. The standalone YAML path uses the same guard because it intentionally bypasses the normal project requirement while still creating workflow state under the current directory.
Constraint: Local YAML workflow runs do not require an existing .specify project directory but still create .specify/workflows/runs state
Rejected: Guard only .specify in the file-source path | .specify/workflows and runs can independently redirect writes
Confidence: high
Scope-risk: narrow
Directive: Keep workflow storage symlink checks centralized before constructing WorkflowEngine
Tested: .venv/bin/python -m pytest tests/test_workflow_run_without_project.py tests/test_workflows.py::TestWorkflowAddSymlinkGuard -v
Tested: .venv/bin/python -m py_compile src/specify_cli/workflows/_commands.py tests/test_workflow_run_without_project.py tests/test_workflows.py
Not-tested: Ruff lint; ruff is not installed in the repo virtualenv
Assisted-by: OpenAI Codex (model: GPT-5, autonomous)
* fix(workflows): pass github_hosts allowlist to GHES release asset resolver
workflow add resolved GitHub release download URLs without forwarding the
github_provider_hosts() allowlist, so resolve_github_release_asset_api_url
never treated any host as GHES. This regressed GitHub Enterprise Server
release asset resolution and diverged from presets/extensions, which already
pass github_hosts. Forward github_provider_hosts() at both the direct-URL and
catalog install call sites. The allowlist remains the anti-SSRF gate.
* fix(workflows): reject symlinked/traversal <id> dir on workflow install
Local/URL and catalog installs wrote to .specify/workflows/<id>/workflow.yml
without guarding the <id> segment. A pre-planted symlink at <id> or
<id>/workflow.yml let mkdir+copy/download follow it and write outside the
project root; a non-directory <id> made mkdir raise unhandled.
Add _safe_workflow_id_dir() to reject path traversal, symlinked or
non-directory <id>, and a symlinked workflow.yml leaf before any write.
Fold the catalog branch's existing traversal check into the helper.
* fix(workflows): harden _safe_workflow_id_dir output and leaf checks
- Reorder symlink/non-directory check before resolve() so a symlinked
<id> reports as symlinked instead of misleading "Invalid workflow ID"
- Reject a pre-existing <id>/workflow.yml that is not a file, avoiding an
unhandled IsADirectoryError on later write/copy2
- Escape workflow_id in Rich output to prevent markup injection; escape
the repr (not the raw id) so repr-added backslashes cannot re-expose
brackets, matching extensions/_commands.py hardening
- Add tests for workflow.yml-as-directory and markup-escaped invalid id
* Avoid stale lint failures from config helper imports
Move PyYAML loading into the helpers that read and write agent-context configuration, and replace the broad Any annotation with object. The runtime behavior stays the same while the module no longer exposes top-level imports that can be flagged as unused when CI analyzes a narrower code shape.
* Prevent workflow commands from targeting reserved storage
Workflow install and removal paths are derived from workflow IDs before any catalog download, local copy, or directory deletion. Validate that IDs are single workflow-id path segments and reject names reserved for workflow runtime storage so commands cannot target .specify/workflows/runs or .specify/workflows/steps.