specsmith 0.2.1.dev41__tar.gz → 0.2.1.dev43__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {specsmith-0.2.1.dev41/src/specsmith.egg-info → specsmith-0.2.1.dev43}/PKG-INFO +1 -1
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/pyproject.toml +1 -1
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/cli.py +101 -3
- specsmith-0.2.1.dev43/src/specsmith/executor.py +267 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/scaffolder.py +31 -3
- specsmith-0.2.1.dev43/src/specsmith/templates/docs/mkdocs.yml.j2 +34 -0
- specsmith-0.2.1.dev43/src/specsmith/templates/docs/readthedocs.yaml.j2 +22 -0
- specsmith-0.2.1.dev43/src/specsmith/templates/go/go.mod.j2 +3 -0
- specsmith-0.2.1.dev43/src/specsmith/templates/go/main.go.j2 +7 -0
- specsmith-0.2.1.dev43/src/specsmith/templates/js/package.json.j2 +48 -0
- specsmith-0.2.1.dev43/src/specsmith/templates/rust/Cargo.toml.j2 +19 -0
- specsmith-0.2.1.dev43/src/specsmith/templates/rust/main.rs.j2 +15 -0
- specsmith-0.2.1.dev43/src/specsmith/templates/scripts/exec.cmd.j2 +63 -0
- specsmith-0.2.1.dev43/src/specsmith/templates/scripts/exec.sh.j2 +60 -0
- specsmith-0.2.1.dev43/src/specsmith/templates/workflows/release.yml.j2 +101 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/upgrader.py +132 -24
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43/src/specsmith.egg-info}/PKG-INFO +1 -1
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith.egg-info/SOURCES.txt +10 -1
- specsmith-0.2.1.dev41/src/specsmith/templates/scripts/exec.cmd.j2 +0 -33
- specsmith-0.2.1.dev41/src/specsmith/templates/scripts/exec.sh.j2 +0 -38
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/LICENSE +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/README.md +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/setup.cfg +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/__init__.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/__main__.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/architect.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/auditor.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/commands/__init__.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/compressor.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/config.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/credit_analyzer.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/credits.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/differ.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/doctor.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/exporter.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/importer.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/integrations/__init__.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/integrations/aider.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/integrations/base.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/integrations/claude_code.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/integrations/copilot.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/integrations/cursor.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/integrations/gemini.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/integrations/warp.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/integrations/windsurf.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/ledger.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/plugins.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/releaser.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/requirements.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/session.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/agents.md.j2 +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/community/bug_report.md.j2 +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/community/code_of_conduct.md.j2 +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/community/contributing.md.j2 +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/community/feature_request.md.j2 +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/community/license-Apache-2.0.j2 +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/community/license-MIT.j2 +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/community/pull_request_template.md.j2 +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/community/security.md.j2 +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/docs/architecture.md.j2 +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/docs/requirements.md.j2 +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/docs/test-spec.md.j2 +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/docs/workflow.md.j2 +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/editorconfig.j2 +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/gitattributes.j2 +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/gitignore.j2 +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/governance/context-budget.md.j2 +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/governance/drift-metrics.md.j2 +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/governance/roles.md.j2 +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/governance/rules.md.j2 +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/governance/verification.md.j2 +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/governance/workflow.md.j2 +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/ledger.md.j2 +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/python/cli.py.j2 +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/python/init.py.j2 +0 -0
- {specsmith-0.2.1.dev41/src/specsmith/templates → specsmith-0.2.1.dev43/src/specsmith/templates/python}/pyproject.toml.j2 +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/readme.md.j2 +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/scripts/run.cmd.j2 +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/scripts/run.sh.j2 +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/scripts/setup.cmd.j2 +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/scripts/setup.sh.j2 +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/tools.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/updater.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/validator.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/vcs/__init__.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/vcs/base.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/vcs/bitbucket.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/vcs/github.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/vcs/gitlab.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/vcs_commands.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith.egg-info/dependency_links.txt +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith.egg-info/entry_points.txt +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith.egg-info/requires.txt +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith.egg-info/top_level.txt +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/tests/test_auditor.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/tests/test_cli.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/tests/test_compressor.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/tests/test_importer.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/tests/test_integrations.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/tests/test_scaffolder.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/tests/test_smoke.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/tests/test_tools.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/tests/test_validator.py +0 -0
- {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/tests/test_vcs.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "specsmith"
|
|
7
|
-
version = "0.2.1.
|
|
7
|
+
version = "0.2.1.dev43"
|
|
8
8
|
description = "Forge governed project scaffolds from the Agentic AI Development Workflow Specification."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -291,12 +291,23 @@ def compress(project_dir: str, threshold: int, keep_recent: int) -> None:
|
|
|
291
291
|
default=".",
|
|
292
292
|
help="Project root directory.",
|
|
293
293
|
)
|
|
294
|
-
|
|
295
|
-
""
|
|
294
|
+
@click.option(
|
|
295
|
+
"--full",
|
|
296
|
+
is_flag=True,
|
|
297
|
+
default=False,
|
|
298
|
+
help="Full sync: also regenerate exec shims, CI, agent files, create missing community files.",
|
|
299
|
+
)
|
|
300
|
+
def upgrade(spec_version: str | None, project_dir: str, full: bool) -> None:
|
|
301
|
+
"""Update governance files to match a newer spec version.
|
|
302
|
+
|
|
303
|
+
With --full: also regenerates exec shims (PID tracking), CI configs,
|
|
304
|
+
agent integrations, and creates missing community files. Safe: never
|
|
305
|
+
overwrites AGENTS.md, LEDGER.md, or user documentation.
|
|
306
|
+
"""
|
|
296
307
|
from specsmith.upgrader import run_upgrade
|
|
297
308
|
|
|
298
309
|
root = Path(project_dir).resolve()
|
|
299
|
-
result = run_upgrade(root, target_version=spec_version)
|
|
310
|
+
result = run_upgrade(root, target_version=spec_version, full=full)
|
|
300
311
|
console.print(result.message)
|
|
301
312
|
|
|
302
313
|
if result.updated_files:
|
|
@@ -1544,5 +1555,92 @@ def serve(port: int) -> None:
|
|
|
1544
1555
|
)
|
|
1545
1556
|
|
|
1546
1557
|
|
|
1558
|
+
# ---------------------------------------------------------------------------
|
|
1559
|
+
# Process execution and abort
|
|
1560
|
+
# ---------------------------------------------------------------------------
|
|
1561
|
+
|
|
1562
|
+
|
|
1563
|
+
@main.command(name="exec")
|
|
1564
|
+
@click.argument("command")
|
|
1565
|
+
@click.option("--timeout", default=120, help="Timeout in seconds (default: 120).")
|
|
1566
|
+
@click.option("--project-dir", type=click.Path(exists=True), default=".")
|
|
1567
|
+
def exec_cmd(command: str, timeout: int, project_dir: str) -> None:
|
|
1568
|
+
"""Execute a command with PID tracking and timeout enforcement.
|
|
1569
|
+
|
|
1570
|
+
Tracks the process in .specsmith/pids/ so it can be listed (specsmith ps)
|
|
1571
|
+
or aborted (specsmith abort). Logs stdout/stderr to .specsmith/logs/.
|
|
1572
|
+
Works cross-platform: Windows, Linux, macOS.
|
|
1573
|
+
"""
|
|
1574
|
+
from specsmith.executor import run_tracked
|
|
1575
|
+
|
|
1576
|
+
root = Path(project_dir).resolve()
|
|
1577
|
+
console.print(f"[bold]exec[/bold] {command} (timeout={timeout}s)")
|
|
1578
|
+
|
|
1579
|
+
result = run_tracked(root, command, timeout=timeout)
|
|
1580
|
+
|
|
1581
|
+
if result.timed_out:
|
|
1582
|
+
console.print(f"[red]TIMEOUT[/red] after {timeout}s (PID {result.pid})")
|
|
1583
|
+
elif result.exit_code == 0:
|
|
1584
|
+
console.print(f"[green]OK[/green] ({result.duration:.1f}s) — exit code 0")
|
|
1585
|
+
else:
|
|
1586
|
+
console.print(f"[red]FAILED[/red] ({result.duration:.1f}s) — exit code {result.exit_code}")
|
|
1587
|
+
if result.stdout_file:
|
|
1588
|
+
console.print(f" stdout: {result.stdout_file}")
|
|
1589
|
+
if result.stderr_file:
|
|
1590
|
+
console.print(f" stderr: {result.stderr_file}")
|
|
1591
|
+
raise SystemExit(result.exit_code)
|
|
1592
|
+
|
|
1593
|
+
|
|
1594
|
+
@main.command(name="ps")
|
|
1595
|
+
@click.option("--project-dir", type=click.Path(exists=True), default=".")
|
|
1596
|
+
def ps_cmd(project_dir: str) -> None:
|
|
1597
|
+
"""List tracked running processes."""
|
|
1598
|
+
from specsmith.executor import list_processes
|
|
1599
|
+
|
|
1600
|
+
root = Path(project_dir).resolve()
|
|
1601
|
+
procs = list_processes(root)
|
|
1602
|
+
if not procs:
|
|
1603
|
+
console.print("No tracked processes running.")
|
|
1604
|
+
return
|
|
1605
|
+
for p in procs:
|
|
1606
|
+
elapsed = p.elapsed
|
|
1607
|
+
remaining = max(0, p.timeout - elapsed)
|
|
1608
|
+
status = "[red]EXPIRED[/red]" if p.is_expired else f"{remaining:.0f}s left"
|
|
1609
|
+
console.print(f" PID {p.pid} {status} {p.command}")
|
|
1610
|
+
console.print(f"\n {len(procs)} process(es)")
|
|
1611
|
+
|
|
1612
|
+
|
|
1613
|
+
@main.command(name="abort")
|
|
1614
|
+
@click.option("--pid", type=int, default=None, help="Abort a specific PID.")
|
|
1615
|
+
@click.option("--all", "abort_all_flag", is_flag=True, default=False, help="Abort all tracked.")
|
|
1616
|
+
@click.option("--project-dir", type=click.Path(exists=True), default=".")
|
|
1617
|
+
def abort_cmd(pid: int | None, abort_all_flag: bool, project_dir: str) -> None:
|
|
1618
|
+
"""Abort tracked process(es). Sends SIGTERM then SIGKILL (POSIX) or taskkill (Windows)."""
|
|
1619
|
+
from specsmith.executor import abort_all, abort_process, list_processes
|
|
1620
|
+
|
|
1621
|
+
root = Path(project_dir).resolve()
|
|
1622
|
+
|
|
1623
|
+
if abort_all_flag:
|
|
1624
|
+
killed = abort_all(root)
|
|
1625
|
+
if killed:
|
|
1626
|
+
console.print(f"[green]Aborted {len(killed)} process(es): {killed}[/green]")
|
|
1627
|
+
else:
|
|
1628
|
+
console.print("No tracked processes to abort.")
|
|
1629
|
+
elif pid:
|
|
1630
|
+
if abort_process(root, pid):
|
|
1631
|
+
console.print(f"[green]Aborted PID {pid}[/green]")
|
|
1632
|
+
else:
|
|
1633
|
+
console.print(f"[red]Could not abort PID {pid}[/red]")
|
|
1634
|
+
else:
|
|
1635
|
+
procs = list_processes(root)
|
|
1636
|
+
if not procs:
|
|
1637
|
+
console.print("No tracked processes. Use --pid or --all.")
|
|
1638
|
+
return
|
|
1639
|
+
console.print("Tracked processes:")
|
|
1640
|
+
for p in procs:
|
|
1641
|
+
console.print(f" PID {p.pid} {p.command}")
|
|
1642
|
+
console.print("\nUse --pid <N> or --all to abort.")
|
|
1643
|
+
|
|
1644
|
+
|
|
1547
1645
|
if __name__ == "__main__":
|
|
1548
1646
|
main()
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 BitConcepts, LLC. All rights reserved.
|
|
3
|
+
"""Executor — cross-platform process execution with PID tracking and abort.
|
|
4
|
+
|
|
5
|
+
Provides governed command execution with:
|
|
6
|
+
- PID file tracking in .specsmith/pids/
|
|
7
|
+
- Configurable timeout enforcement
|
|
8
|
+
- Cross-platform abort (Windows taskkill / POSIX SIGTERM+SIGKILL)
|
|
9
|
+
- Process listing for agent visibility
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import signal
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
from dataclasses import asdict, dataclass
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class TrackedProcess:
|
|
27
|
+
"""Metadata for a tracked process."""
|
|
28
|
+
|
|
29
|
+
pid: int
|
|
30
|
+
command: str
|
|
31
|
+
started: str # ISO timestamp
|
|
32
|
+
timeout: int # seconds
|
|
33
|
+
pid_file: str = ""
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def started_dt(self) -> datetime:
|
|
37
|
+
return datetime.fromisoformat(self.started)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def elapsed(self) -> float:
|
|
41
|
+
return (datetime.now(tz=timezone.utc) - self.started_dt).total_seconds()
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def is_expired(self) -> bool:
|
|
45
|
+
return self.elapsed > self.timeout
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class ExecResult:
|
|
50
|
+
"""Result of a tracked execution."""
|
|
51
|
+
|
|
52
|
+
command: str
|
|
53
|
+
exit_code: int
|
|
54
|
+
pid: int
|
|
55
|
+
duration: float
|
|
56
|
+
timed_out: bool = False
|
|
57
|
+
aborted: bool = False
|
|
58
|
+
stdout_file: str = ""
|
|
59
|
+
stderr_file: str = ""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _pids_dir(root: Path) -> Path:
|
|
63
|
+
d = root / ".specsmith" / "pids"
|
|
64
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
return d
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _logs_dir(root: Path) -> Path:
|
|
69
|
+
d = root / ".specsmith" / "logs"
|
|
70
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
return d
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _write_pid_file(root: Path, proc: TrackedProcess) -> Path:
|
|
75
|
+
"""Write PID tracking file. Returns path to PID file."""
|
|
76
|
+
pid_file = _pids_dir(root) / f"{proc.pid}.json"
|
|
77
|
+
proc.pid_file = str(pid_file)
|
|
78
|
+
pid_file.write_text(json.dumps(asdict(proc), indent=2), encoding="utf-8")
|
|
79
|
+
return pid_file
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _remove_pid_file(root: Path, pid: int) -> None:
|
|
83
|
+
"""Remove PID tracking file."""
|
|
84
|
+
pid_file = _pids_dir(root) / f"{pid}.json"
|
|
85
|
+
if pid_file.exists():
|
|
86
|
+
pid_file.unlink()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _is_process_alive(pid: int) -> bool:
|
|
90
|
+
"""Check if a process is still running (cross-platform)."""
|
|
91
|
+
try:
|
|
92
|
+
if sys.platform == "win32":
|
|
93
|
+
# Windows: use tasklist to check
|
|
94
|
+
result = subprocess.run(
|
|
95
|
+
["tasklist", "/FI", f"PID eq {pid}", "/NH"],
|
|
96
|
+
capture_output=True,
|
|
97
|
+
text=True,
|
|
98
|
+
timeout=5,
|
|
99
|
+
)
|
|
100
|
+
return str(pid) in result.stdout
|
|
101
|
+
else:
|
|
102
|
+
# POSIX: signal 0 checks existence without killing
|
|
103
|
+
os.kill(pid, 0)
|
|
104
|
+
return True
|
|
105
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _kill_process(pid: int, *, graceful_timeout: float = 5.0) -> bool:
|
|
110
|
+
"""Kill a process cross-platform. Returns True if killed.
|
|
111
|
+
|
|
112
|
+
Strategy:
|
|
113
|
+
- POSIX: SIGTERM → wait → SIGKILL
|
|
114
|
+
- Windows: taskkill → taskkill /F
|
|
115
|
+
"""
|
|
116
|
+
if not _is_process_alive(pid):
|
|
117
|
+
return True # Already dead
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
if sys.platform == "win32":
|
|
121
|
+
# Graceful first
|
|
122
|
+
subprocess.run(
|
|
123
|
+
["taskkill", "/PID", str(pid)],
|
|
124
|
+
capture_output=True,
|
|
125
|
+
timeout=graceful_timeout,
|
|
126
|
+
)
|
|
127
|
+
time.sleep(min(graceful_timeout, 2.0))
|
|
128
|
+
if not _is_process_alive(pid):
|
|
129
|
+
return True
|
|
130
|
+
# Force kill
|
|
131
|
+
subprocess.run(
|
|
132
|
+
["taskkill", "/F", "/PID", str(pid), "/T"],
|
|
133
|
+
capture_output=True,
|
|
134
|
+
timeout=5,
|
|
135
|
+
)
|
|
136
|
+
else:
|
|
137
|
+
# SIGTERM first
|
|
138
|
+
os.kill(pid, signal.SIGTERM)
|
|
139
|
+
deadline = time.monotonic() + graceful_timeout
|
|
140
|
+
while time.monotonic() < deadline:
|
|
141
|
+
if not _is_process_alive(pid):
|
|
142
|
+
return True
|
|
143
|
+
time.sleep(0.2)
|
|
144
|
+
# SIGKILL
|
|
145
|
+
os.kill(pid, signal.SIGKILL)
|
|
146
|
+
except (OSError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
time.sleep(0.5)
|
|
150
|
+
return not _is_process_alive(pid)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def run_tracked(
|
|
154
|
+
root: Path,
|
|
155
|
+
command: str,
|
|
156
|
+
*,
|
|
157
|
+
timeout: int = 120,
|
|
158
|
+
capture: bool = True,
|
|
159
|
+
) -> ExecResult:
|
|
160
|
+
"""Execute a command with PID tracking and timeout enforcement.
|
|
161
|
+
|
|
162
|
+
- Writes PID file to .specsmith/pids/<pid>.json
|
|
163
|
+
- Enforces timeout via subprocess.Popen + polling
|
|
164
|
+
- Logs stdout/stderr to .specsmith/logs/
|
|
165
|
+
- Cleans up PID file on completion
|
|
166
|
+
- Cross-platform: works on Windows, Linux, macOS
|
|
167
|
+
"""
|
|
168
|
+
started = datetime.now(tz=timezone.utc).isoformat()
|
|
169
|
+
ts = datetime.now(tz=timezone.utc).strftime("%Y%m%d_%H%M%S")
|
|
170
|
+
|
|
171
|
+
stdout_path = _logs_dir(root) / f"exec_{ts}.stdout"
|
|
172
|
+
stderr_path = _logs_dir(root) / f"exec_{ts}.stderr"
|
|
173
|
+
|
|
174
|
+
# Determine shell
|
|
175
|
+
if sys.platform == "win32":
|
|
176
|
+
shell_args: list[str] = ["cmd", "/c", command]
|
|
177
|
+
else:
|
|
178
|
+
shell_args = ["bash", "-c", command]
|
|
179
|
+
|
|
180
|
+
stdout_fh = open(stdout_path, "w", encoding="utf-8") if capture else None # noqa: SIM115
|
|
181
|
+
stderr_fh = open(stderr_path, "w", encoding="utf-8") if capture else None # noqa: SIM115
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
proc = subprocess.Popen( # noqa: S603
|
|
185
|
+
shell_args,
|
|
186
|
+
stdout=stdout_fh or subprocess.PIPE,
|
|
187
|
+
stderr=stderr_fh or subprocess.PIPE,
|
|
188
|
+
cwd=str(root),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
tracked = TrackedProcess(
|
|
192
|
+
pid=proc.pid,
|
|
193
|
+
command=command,
|
|
194
|
+
started=started,
|
|
195
|
+
timeout=timeout,
|
|
196
|
+
)
|
|
197
|
+
pid_file = _write_pid_file(root, tracked)
|
|
198
|
+
|
|
199
|
+
start = time.monotonic()
|
|
200
|
+
timed_out = False
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
proc.wait(timeout=timeout)
|
|
204
|
+
except subprocess.TimeoutExpired:
|
|
205
|
+
timed_out = True
|
|
206
|
+
_kill_process(proc.pid)
|
|
207
|
+
proc.wait(timeout=5) # Reap zombie
|
|
208
|
+
|
|
209
|
+
duration = time.monotonic() - start
|
|
210
|
+
exit_code = proc.returncode if proc.returncode is not None else -1
|
|
211
|
+
|
|
212
|
+
# Clean up PID file
|
|
213
|
+
if pid_file.exists():
|
|
214
|
+
pid_file.unlink()
|
|
215
|
+
|
|
216
|
+
return ExecResult(
|
|
217
|
+
command=command,
|
|
218
|
+
exit_code=124 if timed_out else exit_code,
|
|
219
|
+
pid=proc.pid,
|
|
220
|
+
duration=duration,
|
|
221
|
+
timed_out=timed_out,
|
|
222
|
+
stdout_file=str(stdout_path) if capture else "",
|
|
223
|
+
stderr_file=str(stderr_path) if capture else "",
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
finally:
|
|
227
|
+
if stdout_fh:
|
|
228
|
+
stdout_fh.close()
|
|
229
|
+
if stderr_fh:
|
|
230
|
+
stderr_fh.close()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def list_processes(root: Path) -> list[TrackedProcess]:
|
|
234
|
+
"""List all tracked processes. Prunes stale PID files for dead processes."""
|
|
235
|
+
pids_dir = _pids_dir(root)
|
|
236
|
+
result: list[TrackedProcess] = []
|
|
237
|
+
|
|
238
|
+
for pid_file in pids_dir.glob("*.json"):
|
|
239
|
+
try:
|
|
240
|
+
data = json.loads(pid_file.read_text(encoding="utf-8"))
|
|
241
|
+
tp = TrackedProcess(**data)
|
|
242
|
+
if _is_process_alive(tp.pid):
|
|
243
|
+
result.append(tp)
|
|
244
|
+
else:
|
|
245
|
+
# Stale PID file — process already exited
|
|
246
|
+
pid_file.unlink()
|
|
247
|
+
except (json.JSONDecodeError, TypeError, OSError):
|
|
248
|
+
pid_file.unlink(missing_ok=True)
|
|
249
|
+
|
|
250
|
+
return result
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def abort_process(root: Path, pid: int) -> bool:
|
|
254
|
+
"""Abort a specific tracked process by PID. Returns True if killed."""
|
|
255
|
+
killed = _kill_process(pid)
|
|
256
|
+
_remove_pid_file(root, pid)
|
|
257
|
+
return killed
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def abort_all(root: Path) -> list[int]:
|
|
261
|
+
"""Abort all tracked processes. Returns list of killed PIDs."""
|
|
262
|
+
killed: list[int] = []
|
|
263
|
+
for tp in list_processes(root):
|
|
264
|
+
if _kill_process(tp.pid):
|
|
265
|
+
killed.append(tp.pid)
|
|
266
|
+
_remove_pid_file(root, tp.pid)
|
|
267
|
+
return killed
|
|
@@ -137,19 +137,47 @@ def _build_file_map(config: ProjectConfig) -> list[tuple[str, str]]:
|
|
|
137
137
|
# Community / compliance files
|
|
138
138
|
files.extend(_build_community_files(config))
|
|
139
139
|
|
|
140
|
-
#
|
|
140
|
+
# Language-specific project files (#41)
|
|
141
141
|
if config.type in (
|
|
142
142
|
ProjectType.CLI_PYTHON,
|
|
143
143
|
ProjectType.LIBRARY_PYTHON,
|
|
144
144
|
ProjectType.BACKEND_FRONTEND,
|
|
145
145
|
ProjectType.BACKEND_FRONTEND_TRAY,
|
|
146
146
|
):
|
|
147
|
-
files.append(("pyproject.toml.j2", "pyproject.toml"))
|
|
147
|
+
files.append(("python/pyproject.toml.j2", "pyproject.toml"))
|
|
148
148
|
files.append(("python/init.py.j2", f"src/{config.package_name}/__init__.py"))
|
|
149
|
-
|
|
150
149
|
if config.type == ProjectType.CLI_PYTHON:
|
|
151
150
|
files.append(("python/cli.py.j2", f"src/{config.package_name}/cli.py"))
|
|
152
151
|
|
|
152
|
+
elif config.type in (ProjectType.CLI_RUST, ProjectType.LIBRARY_RUST):
|
|
153
|
+
files.append(("rust/Cargo.toml.j2", "Cargo.toml"))
|
|
154
|
+
if config.type == ProjectType.CLI_RUST:
|
|
155
|
+
files.append(("rust/main.rs.j2", "src/main.rs"))
|
|
156
|
+
|
|
157
|
+
elif config.type == ProjectType.CLI_GO:
|
|
158
|
+
files.append(("go/go.mod.j2", "go.mod"))
|
|
159
|
+
files.append(("go/main.go.j2", "cmd/main.go"))
|
|
160
|
+
|
|
161
|
+
elif config.type in (
|
|
162
|
+
ProjectType.WEB_FRONTEND,
|
|
163
|
+
ProjectType.FULLSTACK_JS,
|
|
164
|
+
):
|
|
165
|
+
files.append(("js/package.json.j2", "package.json"))
|
|
166
|
+
|
|
167
|
+
# ReadTheDocs integration (#38) — Python and doc projects
|
|
168
|
+
if config.type in (
|
|
169
|
+
ProjectType.CLI_PYTHON,
|
|
170
|
+
ProjectType.LIBRARY_PYTHON,
|
|
171
|
+
ProjectType.SPEC_DOCUMENT,
|
|
172
|
+
ProjectType.USER_MANUAL,
|
|
173
|
+
):
|
|
174
|
+
files.append(("docs/readthedocs.yaml.j2", ".readthedocs.yaml"))
|
|
175
|
+
files.append(("docs/mkdocs.yml.j2", "mkdocs.yml"))
|
|
176
|
+
|
|
177
|
+
# Release workflow template (#44) — gitflow + GitHub projects
|
|
178
|
+
if config.vcs_platform == "github" and config.branching_strategy == "gitflow":
|
|
179
|
+
files.append(("workflows/release.yml.j2", ".github/workflows/release.yml"))
|
|
180
|
+
|
|
153
181
|
return files
|
|
154
182
|
|
|
155
183
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
site_name: {{ project.name }}
|
|
2
|
+
site_description: {{ project.description or project.name }}
|
|
3
|
+
repo_url: https://github.com/{{ project.name }}
|
|
4
|
+
edit_uri: edit/main/docs/
|
|
5
|
+
|
|
6
|
+
docs_dir: docs/site
|
|
7
|
+
|
|
8
|
+
theme:
|
|
9
|
+
name: material
|
|
10
|
+
palette:
|
|
11
|
+
- scheme: default
|
|
12
|
+
primary: deep purple
|
|
13
|
+
toggle:
|
|
14
|
+
icon: material/brightness-7
|
|
15
|
+
name: Switch to dark mode
|
|
16
|
+
- scheme: slate
|
|
17
|
+
primary: deep purple
|
|
18
|
+
toggle:
|
|
19
|
+
icon: material/brightness-4
|
|
20
|
+
name: Switch to light mode
|
|
21
|
+
features:
|
|
22
|
+
- navigation.sections
|
|
23
|
+
- content.code.copy
|
|
24
|
+
|
|
25
|
+
nav:
|
|
26
|
+
- Home: index.md
|
|
27
|
+
|
|
28
|
+
markdown_extensions:
|
|
29
|
+
- tables
|
|
30
|
+
- admonition
|
|
31
|
+
- pymdownx.highlight
|
|
32
|
+
- pymdownx.superfences
|
|
33
|
+
- toc:
|
|
34
|
+
permalink: true
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
version: 2
|
|
2
|
+
|
|
3
|
+
build:
|
|
4
|
+
os: ubuntu-22.04
|
|
5
|
+
tools:
|
|
6
|
+
{% if project.language == 'python' %}
|
|
7
|
+
python: "3.12"
|
|
8
|
+
{% elif project.language in ('javascript', 'typescript') %}
|
|
9
|
+
nodejs: "20"
|
|
10
|
+
{% endif %}
|
|
11
|
+
|
|
12
|
+
mkdocs:
|
|
13
|
+
configuration: mkdocs.yml
|
|
14
|
+
|
|
15
|
+
{% if project.language == 'python' %}
|
|
16
|
+
python:
|
|
17
|
+
install:
|
|
18
|
+
- method: pip
|
|
19
|
+
path: .
|
|
20
|
+
extra_requirements:
|
|
21
|
+
- docs
|
|
22
|
+
{% endif %}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{ package_name }}",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "{{ project.description or project.name }}",
|
|
5
|
+
"license": "{{ project.license }}",
|
|
6
|
+
{% if project.type.value == 'web-frontend' %}
|
|
7
|
+
"scripts": {
|
|
8
|
+
"dev": "vite",
|
|
9
|
+
"build": "vite build",
|
|
10
|
+
"lint": "eslint src/",
|
|
11
|
+
"test": "vitest"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"vite": "^6.0.0",
|
|
16
|
+
"eslint": "^9.0.0",
|
|
17
|
+
"vitest": "^3.0.0"
|
|
18
|
+
}
|
|
19
|
+
{% elif project.type.value == 'fullstack-js' %}
|
|
20
|
+
"scripts": {
|
|
21
|
+
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
|
|
22
|
+
"dev:server": "node server/src/index.js",
|
|
23
|
+
"dev:client": "vite",
|
|
24
|
+
"build": "vite build",
|
|
25
|
+
"lint": "eslint .",
|
|
26
|
+
"test": "vitest"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"express": "^5.0.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"concurrently": "^9.0.0",
|
|
33
|
+
"eslint": "^9.0.0",
|
|
34
|
+
"vitest": "^3.0.0"
|
|
35
|
+
}
|
|
36
|
+
{% else %}
|
|
37
|
+
"scripts": {
|
|
38
|
+
"start": "node src/index.js",
|
|
39
|
+
"lint": "eslint src/",
|
|
40
|
+
"test": "vitest"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"eslint": "^9.0.0",
|
|
45
|
+
"vitest": "^3.0.0"
|
|
46
|
+
}
|
|
47
|
+
{% endif %}
|
|
48
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "{{ package_name }}"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
edition = "2021"
|
|
5
|
+
description = "{{ project.description or project.name }}"
|
|
6
|
+
license = "{{ project.license }}"
|
|
7
|
+
|
|
8
|
+
[dependencies]
|
|
9
|
+
{% if project.type.value == 'cli-rust' %}
|
|
10
|
+
clap = { version = "4", features = ["derive"] }
|
|
11
|
+
{% endif %}
|
|
12
|
+
|
|
13
|
+
[dev-dependencies]
|
|
14
|
+
|
|
15
|
+
{% if project.type.value == 'cli-rust' %}
|
|
16
|
+
[[bin]]
|
|
17
|
+
name = "{{ package_name }}"
|
|
18
|
+
path = "src/main.rs"
|
|
19
|
+
{% endif %}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
use clap::Parser;
|
|
2
|
+
|
|
3
|
+
/// {{ project.description or project.name }}
|
|
4
|
+
#[derive(Parser, Debug)]
|
|
5
|
+
#[command(version, about)]
|
|
6
|
+
struct Args {
|
|
7
|
+
/// Name to greet
|
|
8
|
+
#[arg(short, long, default_value = "world")]
|
|
9
|
+
name: String,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
fn main() {
|
|
13
|
+
let args = Args::parse();
|
|
14
|
+
println!("Hello, {}!", args.name);
|
|
15
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
@echo off
|
|
2
|
+
REM {{ project.name }} — Command Execution Shim (Windows)
|
|
3
|
+
REM Wraps external commands with PID tracking, timeout enforcement, and abort support.
|
|
4
|
+
REM Usage: scripts\exec.cmd "<command>" [timeout_seconds]
|
|
5
|
+
REM
|
|
6
|
+
REM PID files: .specsmith\pids\<pid>.json (for specsmith ps / specsmith abort)
|
|
7
|
+
REM Logs: .specsmith\logs\exec_<timestamp>.stdout/.stderr
|
|
8
|
+
REM Prefer: specsmith exec "<command>" --timeout <N> (Python-based, full tracking)
|
|
9
|
+
|
|
10
|
+
setlocal enabledelayedexpansion
|
|
11
|
+
|
|
12
|
+
set "COMMAND=%~1"
|
|
13
|
+
set "TIMEOUT_SEC=%~2"
|
|
14
|
+
if "%TIMEOUT_SEC%"=="" set "TIMEOUT_SEC=120"
|
|
15
|
+
|
|
16
|
+
set "PROJECT_ROOT=%~dp0.."
|
|
17
|
+
set "PID_DIR=%PROJECT_ROOT%\.specsmith\pids"
|
|
18
|
+
set "LOG_DIR=%PROJECT_ROOT%\.specsmith\logs"
|
|
19
|
+
if not exist "%PID_DIR%" mkdir "%PID_DIR%"
|
|
20
|
+
if not exist "%LOG_DIR%" mkdir "%LOG_DIR%"
|
|
21
|
+
|
|
22
|
+
for /f "tokens=2 delims==" %%I in ('wmic os get localdatetime /value') do set "DT=%%I"
|
|
23
|
+
set "TIMESTAMP=%DT:~0,4%-%DT:~4,2%-%DT:~6,2%_%DT:~8,2%-%DT:~10,2%-%DT:~12,2%"
|
|
24
|
+
set "STDOUT_LOG=%LOG_DIR%\exec_%TIMESTAMP%.stdout"
|
|
25
|
+
set "STDERR_LOG=%LOG_DIR%\exec_%TIMESTAMP%.stderr"
|
|
26
|
+
|
|
27
|
+
echo [exec] Command : %COMMAND%
|
|
28
|
+
echo [exec] Timeout : %TIMEOUT_SEC%s
|
|
29
|
+
|
|
30
|
+
REM Launch command and capture PID
|
|
31
|
+
start /b cmd /c "%COMMAND%" > "%STDOUT_LOG%" 2> "%STDERR_LOG%"
|
|
32
|
+
for /f "tokens=2" %%P in ('tasklist /fi "imagename eq cmd.exe" /nh ^| findstr /i "cmd"') do (
|
|
33
|
+
set "CMD_PID=%%P"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
REM Write PID file for tracking
|
|
37
|
+
echo {"pid": %CMD_PID%, "command": "%COMMAND%", "timeout": %TIMEOUT_SEC%} > "%PID_DIR%\%CMD_PID%.json"
|
|
38
|
+
|
|
39
|
+
REM Wait with timeout
|
|
40
|
+
set /a ELAPSED=0
|
|
41
|
+
:wait_loop
|
|
42
|
+
tasklist /fi "PID eq %CMD_PID%" /nh 2>nul | findstr /i "%CMD_PID%" >nul
|
|
43
|
+
if errorlevel 1 goto :done
|
|
44
|
+
if %ELAPSED% geq %TIMEOUT_SEC% goto :timeout
|
|
45
|
+
timeout /t 1 /nobreak >nul
|
|
46
|
+
set /a ELAPSED+=1
|
|
47
|
+
goto :wait_loop
|
|
48
|
+
|
|
49
|
+
:timeout
|
|
50
|
+
echo [exec] TIMEOUT after %TIMEOUT_SEC%s — killing PID %CMD_PID%
|
|
51
|
+
taskkill /F /PID %CMD_PID% /T >nul 2>&1
|
|
52
|
+
del "%PID_DIR%\%CMD_PID%.json" >nul 2>&1
|
|
53
|
+
exit /b 124
|
|
54
|
+
|
|
55
|
+
:done
|
|
56
|
+
del "%PID_DIR%\%CMD_PID%.json" >nul 2>&1
|
|
57
|
+
set "EXIT_CODE=%ERRORLEVEL%"
|
|
58
|
+
if %EXIT_CODE% equ 0 (
|
|
59
|
+
echo [exec] OK — exit code 0
|
|
60
|
+
) else (
|
|
61
|
+
echo [exec] FAILED — exit code %EXIT_CODE%
|
|
62
|
+
)
|
|
63
|
+
exit /b %EXIT_CODE%
|