sourcecode 1.35.17__tar.gz → 1.35.19__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.
Files changed (107) hide show
  1. {sourcecode-1.35.17 → sourcecode-1.35.19}/PKG-INFO +1 -1
  2. {sourcecode-1.35.17 → sourcecode-1.35.19}/pyproject.toml +1 -1
  3. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/__init__.py +1 -1
  4. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/cli.py +189 -2
  5. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/license.py +221 -4
  6. sourcecode-1.35.19/src/sourcecode/migrate_check.py +434 -0
  7. {sourcecode-1.35.17 → sourcecode-1.35.19}/.github/workflows/build-windows.yml +0 -0
  8. {sourcecode-1.35.17 → sourcecode-1.35.19}/.gitignore +0 -0
  9. {sourcecode-1.35.17 → sourcecode-1.35.19}/.ruff.toml +0 -0
  10. {sourcecode-1.35.17 → sourcecode-1.35.19}/CHANGELOG.md +0 -0
  11. {sourcecode-1.35.17 → sourcecode-1.35.19}/CONTRIBUTING.md +0 -0
  12. {sourcecode-1.35.17 → sourcecode-1.35.19}/LICENSE +0 -0
  13. {sourcecode-1.35.17 → sourcecode-1.35.19}/README.md +0 -0
  14. {sourcecode-1.35.17 → sourcecode-1.35.19}/SECURITY.md +0 -0
  15. {sourcecode-1.35.17 → sourcecode-1.35.19}/raw +0 -0
  16. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/adaptive_scanner.py +0 -0
  17. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/architecture_analyzer.py +0 -0
  18. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/architecture_summary.py +0 -0
  19. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/ast_extractor.py +0 -0
  20. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/cache.py +0 -0
  21. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/canonical_ir.py +0 -0
  22. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/cir_graphs.py +0 -0
  23. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/classifier.py +0 -0
  24. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/code_notes_analyzer.py +0 -0
  25. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/confidence_analyzer.py +0 -0
  26. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/context_scorer.py +0 -0
  27. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/context_summarizer.py +0 -0
  28. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/contract_model.py +0 -0
  29. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/contract_pipeline.py +0 -0
  30. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/coverage_parser.py +0 -0
  31. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/dependency_analyzer.py +0 -0
  32. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/detectors/__init__.py +0 -0
  33. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/detectors/base.py +0 -0
  34. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/detectors/csproj_parser.py +0 -0
  35. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/detectors/dart.py +0 -0
  36. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/detectors/dotnet.py +0 -0
  37. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/detectors/elixir.py +0 -0
  38. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/detectors/go.py +0 -0
  39. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/detectors/heuristic.py +0 -0
  40. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/detectors/hybrid.py +0 -0
  41. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/detectors/java.py +0 -0
  42. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/detectors/jvm_ext.py +0 -0
  43. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/detectors/nodejs.py +0 -0
  44. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/detectors/parsers.py +0 -0
  45. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/detectors/php.py +0 -0
  46. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/detectors/project.py +0 -0
  47. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/detectors/python.py +0 -0
  48. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/detectors/ruby.py +0 -0
  49. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/detectors/rust.py +0 -0
  50. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/detectors/systems.py +0 -0
  51. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/detectors/terraform.py +0 -0
  52. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/detectors/tooling.py +0 -0
  53. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/doc_analyzer.py +0 -0
  54. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/entrypoint_classifier.py +0 -0
  55. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/env_analyzer.py +0 -0
  56. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/error_schema.py +0 -0
  57. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/explain.py +0 -0
  58. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/file_classifier.py +0 -0
  59. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/flow_analyzer.py +0 -0
  60. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/fqn_utils.py +0 -0
  61. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/git_analyzer.py +0 -0
  62. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/graph_analyzer.py +0 -0
  63. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/mcp/__init__.py +0 -0
  64. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/mcp/onboarding/__init__.py +0 -0
  65. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/mcp/onboarding/applier.py +0 -0
  66. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/mcp/onboarding/backup.py +0 -0
  67. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/mcp/onboarding/detector.py +0 -0
  68. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/mcp/onboarding/planner.py +0 -0
  69. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/mcp/orchestrator.py +0 -0
  70. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/mcp/registry.py +0 -0
  71. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/mcp/runner.py +0 -0
  72. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/mcp/server.py +0 -0
  73. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/mcp_nudge.py +0 -0
  74. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/metrics_analyzer.py +0 -0
  75. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/output_budget.py +0 -0
  76. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/path_filters.py +0 -0
  77. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/pr_comment_renderer.py +0 -0
  78. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/pr_impact.py +0 -0
  79. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/prepare_context.py +0 -0
  80. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/progress.py +0 -0
  81. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/ranking_engine.py +0 -0
  82. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/redactor.py +0 -0
  83. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/relevance_scorer.py +0 -0
  84. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/repo_classifier.py +0 -0
  85. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/repository_ir.py +0 -0
  86. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/ris.py +0 -0
  87. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/runtime_classifier.py +0 -0
  88. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/scanner.py +0 -0
  89. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/schema.py +0 -0
  90. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/semantic_analyzer.py +0 -0
  91. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/serializer.py +0 -0
  92. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/spring_event_topology.py +0 -0
  93. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/spring_findings.py +0 -0
  94. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/spring_impact.py +0 -0
  95. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/spring_model.py +0 -0
  96. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/spring_security_audit.py +0 -0
  97. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/spring_semantic.py +0 -0
  98. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/spring_tx_analyzer.py +0 -0
  99. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/summarizer.py +0 -0
  100. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/telemetry/__init__.py +0 -0
  101. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/telemetry/config.py +0 -0
  102. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/telemetry/consent.py +0 -0
  103. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/telemetry/events.py +0 -0
  104. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/telemetry/filters.py +0 -0
  105. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/telemetry/transport.py +0 -0
  106. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/tree_utils.py +0 -0
  107. {sourcecode-1.35.17 → sourcecode-1.35.19}/src/sourcecode/workspace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 1.35.17
3
+ Version: 1.35.19
4
4
  Summary: Persistent structural context and ultra-fast repeated analysis for AI coding agents
5
5
  License-File: LICENSE
6
6
  Keywords: agents,ai,codebase,context,developer-tools,llm
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sourcecode"
7
- version = "1.35.17"
7
+ version = "1.35.19"
8
8
  description = "Persistent structural context and ultra-fast repeated analysis for AI coding agents"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,3 +1,3 @@
1
1
  """sourcecode — Deterministic codebase context maps for AI coding agents."""
2
2
 
3
- __version__ = "1.35.17"
3
+ __version__ = "1.35.19"
@@ -167,6 +167,11 @@ Cold scan: 2–10s depending on repo size. Warm cache: 0.3–0.6s.
167
167
  sourcecode --compact --git-context include git hotspots and uncommitted files
168
168
  sourcecode --agent full structured JSON for AI agents
169
169
 
170
+ [bold]Auth commands:[/bold]
171
+ auth login [dim]# authenticate via browser (device code)[/dim]
172
+ auth status [dim]# show current plan and auth state[/dim]
173
+ auth logout [dim]# remove local credentials[/dim]
174
+
170
175
  [bold]Cache commands:[/bold]
171
176
  cache status [dim]# cache size, hit keys, last-warmed timestamp[/dim]
172
177
  cache warm [dim]# pre-build cache ahead of an agent session[/dim]
@@ -221,8 +226,8 @@ _SUBCOMMANDS: frozenset[str] = frozenset(
221
226
  "repo-ir", "mcp", "endpoints", "impact",
222
227
  # Enterprise workflow commands
223
228
  "onboard", "modernize", "fix-bug", "review-pr",
224
- # License
225
- "activate",
229
+ # License / auth
230
+ "activate", "auth",
226
231
  # Cache observability
227
232
  "cache",
228
233
  # RIS bootstrap
@@ -235,6 +240,8 @@ _SUBCOMMANDS: frozenset[str] = frozenset(
235
240
  "pr-impact",
236
241
  # Class architectural summary
237
242
  "explain",
243
+ # Spring Boot 2→3 migration readiness
244
+ "migrate-check",
238
245
  }
239
246
  )
240
247
 
@@ -512,6 +519,9 @@ app.add_typer(mcp_app, name="mcp")
512
519
  cache_app = typer.Typer(help="Cache inspection and management.", rich_markup_mode="rich")
513
520
  app.add_typer(cache_app, name="cache")
514
521
 
522
+ auth_app = typer.Typer(help="Authentication: login, status, logout.", rich_markup_mode="rich")
523
+ app.add_typer(auth_app, name="auth")
524
+
515
525
 
516
526
  def _maybe_ask_consent() -> None:
517
527
  """Show first-run consent prompt once, on interactive TTYs only."""
@@ -3890,6 +3900,116 @@ def spring_audit_cmd(
3890
3900
  typer.echo("✓ copied to clipboard", err=True)
3891
3901
 
3892
3902
 
3903
+ # ── Spring Boot Migration Check ───────────────────────────────────────────────
3904
+
3905
+
3906
+ @app.command("migrate-check")
3907
+ def migrate_check_cmd(
3908
+ path: Path = typer.Argument(
3909
+ Path("."),
3910
+ help="Repository path to scan (default: current directory)",
3911
+ ),
3912
+ output_path: Optional[Path] = typer.Option(
3913
+ None, "--output", "-o",
3914
+ help="Write output to a file instead of stdout.",
3915
+ ),
3916
+ format: str = typer.Option(
3917
+ "json",
3918
+ "--format",
3919
+ "-f",
3920
+ help="Output format: json (default) or text.",
3921
+ show_default=True,
3922
+ ),
3923
+ copy: bool = typer.Option(
3924
+ False,
3925
+ "--copy",
3926
+ "-c",
3927
+ help="Copy output to system clipboard after a successful run.",
3928
+ ),
3929
+ min_severity: str = typer.Option(
3930
+ "low",
3931
+ "--min-severity",
3932
+ help="Minimum severity to include: critical, high, medium, or low (default).",
3933
+ show_default=True,
3934
+ ),
3935
+ ) -> None:
3936
+ """Spring Boot 2→3 migration readiness: detect javax→jakarta namespace blockers.
3937
+
3938
+ \b
3939
+ Detects:
3940
+ MIG-001 javax.persistence import (CRITICAL — JPA will not compile)
3941
+ MIG-002 javax.servlet import (HIGH — Servlet API changed)
3942
+ MIG-003 javax.validation import (HIGH — Bean Validation changed)
3943
+ MIG-004 javax.transaction import (HIGH — TX API changed)
3944
+ MIG-005 extends WebSecurityConfigurerAdapter (HIGH — removed in Spring 6)
3945
+ MIG-006 javax.annotation import (MEDIUM)
3946
+ MIG-007 javax.inject import (MEDIUM)
3947
+ MIG-008 javax.ws.rs import (MEDIUM — JAX-RS changed)
3948
+
3949
+ \b
3950
+ Examples:
3951
+ sourcecode migrate-check .
3952
+ sourcecode migrate-check /path/to/repo --format text
3953
+ sourcecode migrate-check . --min-severity high
3954
+ sourcecode migrate-check . --output migration.json
3955
+ """
3956
+ from sourcecode.repository_ir import find_java_files
3957
+ from sourcecode.migrate_check import run_migrate_check
3958
+
3959
+ target = path.resolve()
3960
+ if not target.exists() or not target.is_dir():
3961
+ _emit_error_json(
3962
+ INVALID_INPUT_CODE,
3963
+ f"'{target}' is not a valid directory.",
3964
+ path=str(target),
3965
+ hint="Pass an existing repository directory.",
3966
+ expected="A directory path.",
3967
+ )
3968
+ raise typer.Exit(code=1)
3969
+
3970
+ if format not in ("json", "text"):
3971
+ _emit_error_json(
3972
+ INVALID_INPUT_CODE,
3973
+ f"Invalid format '{format}'.",
3974
+ hint="format must be one of: json, text.",
3975
+ expected="json | text",
3976
+ )
3977
+ raise typer.Exit(code=1)
3978
+
3979
+ if min_severity not in ("critical", "high", "medium", "low"):
3980
+ _emit_error_json(
3981
+ INVALID_INPUT_CODE,
3982
+ f"Invalid min-severity '{min_severity}'.",
3983
+ hint="min-severity must be one of: critical, high, medium, low.",
3984
+ expected="critical | high | medium | low",
3985
+ )
3986
+ raise typer.Exit(code=1)
3987
+
3988
+ file_list = find_java_files(target)
3989
+ report = run_migrate_check(file_list, target, min_severity=min_severity)
3990
+
3991
+ if format == "text":
3992
+ output = report.to_text(min_severity=min_severity)
3993
+ else:
3994
+ output = _serialize_dict(report.to_dict(), "json")
3995
+
3996
+ if output_path is not None:
3997
+ output_path.write_text(output, encoding="utf-8")
3998
+ total = report.summary.get("total_findings", 0)
3999
+ typer.echo(
4000
+ f"Migration check written to {output_path} "
4001
+ f"(score: {report.readiness_score}/100, {total} findings)",
4002
+ err=True,
4003
+ )
4004
+ else:
4005
+ sys.stdout.buffer.write(output.encode("utf-8"))
4006
+ sys.stdout.buffer.write(b"\n")
4007
+ sys.stdout.buffer.flush()
4008
+ if copy:
4009
+ if _copy_to_clipboard(output):
4010
+ typer.echo("✓ copied to clipboard", err=True)
4011
+
4012
+
3893
4013
  # ── Spring Impact Chain ───────────────────────────────────────────────────────
3894
4014
 
3895
4015
 
@@ -4799,6 +4919,73 @@ def activate_cmd(
4799
4919
  _activate(license_key)
4800
4920
 
4801
4921
 
4922
+ # ---------------------------------------------------------------------------
4923
+ # Auth commands (device-flow login / status / logout)
4924
+ # ---------------------------------------------------------------------------
4925
+
4926
+ @auth_app.command("login")
4927
+ def auth_login_cmd() -> None:
4928
+ """Authenticate via browser (device code flow).
4929
+
4930
+ \b
4931
+ The CLI shows a URL. Open it in your browser, log in with your account,
4932
+ and the CLI completes authentication automatically.
4933
+ Credentials are stored in ~/.sourcecode/license.json (30-min cache; Supabase is source of truth).
4934
+
4935
+ \b
4936
+ Examples:
4937
+ sourcecode auth login
4938
+ """
4939
+ from sourcecode.license import auth_login as _auth_login
4940
+ _auth_login()
4941
+
4942
+
4943
+ @auth_app.command("status")
4944
+ def auth_status_cmd() -> None:
4945
+ """Show current authentication and plan status."""
4946
+ import json as _json
4947
+ try:
4948
+ from sourcecode.license import _license_data as _ld, is_pro as _ip
4949
+ except Exception:
4950
+ _ld = None
4951
+ _ip = False
4952
+
4953
+ if not _ld:
4954
+ out: dict = {"status": "unauthenticated", "pro": False}
4955
+ sys.stdout.write(_json.dumps(out, ensure_ascii=False) + "\n")
4956
+ sys.stdout.flush()
4957
+ return
4958
+
4959
+ out = {
4960
+ "status": "authenticated",
4961
+ "auth_method": _ld.get("auth_method", "license_key"),
4962
+ "email": _ld.get("email", ""),
4963
+ "plan": _ld.get("plan", "unknown"),
4964
+ "plan_status": _ld.get("status", "unknown"),
4965
+ "pro": _ip,
4966
+ "validated_at": _ld.get("validated_at") or _ld.get("activated_at") or "",
4967
+ }
4968
+ sys.stdout.write(_json.dumps(out, indent=2, ensure_ascii=False) + "\n")
4969
+ sys.stdout.flush()
4970
+
4971
+
4972
+ @auth_app.command("logout")
4973
+ def auth_logout_cmd() -> None:
4974
+ """Remove local credentials (does not cancel your subscription)."""
4975
+ import json as _json
4976
+ _lf = Path.home() / ".sourcecode" / "license.json"
4977
+ if _lf.exists():
4978
+ try:
4979
+ _lf.unlink()
4980
+ out: dict = {"status": "logged_out", "message": "Local credentials removed."}
4981
+ except Exception as _exc:
4982
+ out = {"status": "error", "message": str(_exc)}
4983
+ else:
4984
+ out = {"status": "logged_out", "message": "No local credentials found."}
4985
+ sys.stdout.write(_json.dumps(out, ensure_ascii=False) + "\n")
4986
+ sys.stdout.flush()
4987
+
4988
+
4802
4989
  @app.command("version")
4803
4990
  def version_cmd() -> None:
4804
4991
  """Show version and exit.
@@ -42,8 +42,17 @@ if _SUPABASE_URL != _DEFAULT_SUPABASE_URL:
42
42
  _LICENSE_DIR: Path = Path.home() / ".sourcecode"
43
43
  _LICENSE_FILE: Path = _LICENSE_DIR / "license.json"
44
44
  _DELTA_RUNS_FILE: Path = _LICENSE_DIR / "delta_runs.json"
45
- _CACHE_TTL_SECONDS: int = 86400 # 24 hours
45
+ _CACHE_TTL_SECONDS: int = 1800 # 30 minutes default; CI env overrides to 24h (see _get_cache_ttl)
46
+ _CACHE_TTL_CI_SECONDS: int = 86400 # 24 hours — CI containers must not re-validate mid-run
47
+
48
+
49
+ def _get_cache_ttl() -> int:
50
+ """Return TTL in seconds. CI containers get 24h to avoid mid-run network calls."""
51
+ return _CACHE_TTL_CI_SECONDS if os.environ.get("SOURCECODE_CI") else _CACHE_TTL_SECONDS
46
52
  _DELTA_FREE_LIMIT: int = 30
53
+ _DEVICE_POLL_INTERVAL_S: float = 2.5
54
+ _DEVICE_POLL_TIMEOUT_S: float = 300.0 # 5-minute window for user to complete browser auth
55
+ _AUTH_BASE_URL: str = "https://sourcecode.dev"
47
56
  _LICENSE_KEY_RE = re.compile(r"^[A-Za-z0-9_\-]{1,200}$")
48
57
 
49
58
  # ---------------------------------------------------------------------------
@@ -213,6 +222,78 @@ def _call_get_license(license_key: str) -> Optional[dict]:
213
222
  return None # Network error — caller decides what to do
214
223
 
215
224
 
225
+ def _generate_device_code() -> str:
226
+ """Generate a human-readable device code: XXXX-XXXX-XXXX."""
227
+ import uuid
228
+ raw = uuid.uuid4().hex.upper()
229
+ return f"{raw[:4]}-{raw[4:8]}-{raw[8:12]}"
230
+
231
+
232
+ def _call_device_check(device_code: str) -> Optional[dict]:
233
+ """Poll /device-check edge function. Returns dict or None on network error.
234
+
235
+ Expected responses:
236
+ {"status": "pending"}
237
+ {"status": "complete", "device_token": "...", "email": "...", "plan": "pro", ...}
238
+ {"status": "error", "message": "..."}
239
+ """
240
+ import urllib.error
241
+ import urllib.request
242
+
243
+ if not _SUPABASE_ANON_KEY:
244
+ return None
245
+
246
+ url = f"{_SUPABASE_URL}/functions/v1/device-check"
247
+ body = json.dumps({"device_code": device_code}).encode("utf-8")
248
+ req = urllib.request.Request(url, data=body, method="POST")
249
+ req.add_header("apikey", _SUPABASE_ANON_KEY)
250
+ req.add_header("Authorization", f"Bearer {_SUPABASE_ANON_KEY}")
251
+ req.add_header("Content-Type", "application/json")
252
+ req.add_header("Accept", "application/json")
253
+ try:
254
+ with urllib.request.urlopen(req, timeout=8) as resp:
255
+ return json.loads(resp.read().decode("utf-8"))
256
+ except urllib.error.HTTPError as exc:
257
+ try:
258
+ return json.loads(exc.read().decode("utf-8", errors="replace"))
259
+ except Exception:
260
+ return {"status": "error", "message": f"HTTP {exc.code}"}
261
+ except Exception:
262
+ return None
263
+
264
+
265
+ def _call_get_user_plan(device_token: str) -> Optional[dict]:
266
+ """Fetch current plan/status for an authenticated device token.
267
+
268
+ Expected response:
269
+ {"valid": true, "plan": "pro", "status": "active", "features": [...], "email": "..."}
270
+ {"valid": false, "error": "token_revoked"}
271
+ """
272
+ import urllib.error
273
+ import urllib.request
274
+
275
+ if not _SUPABASE_ANON_KEY:
276
+ return None
277
+
278
+ url = f"{_SUPABASE_URL}/functions/v1/get-user-plan"
279
+ body = json.dumps({"device_token": device_token}).encode("utf-8")
280
+ req = urllib.request.Request(url, data=body, method="POST")
281
+ req.add_header("apikey", _SUPABASE_ANON_KEY)
282
+ req.add_header("Authorization", f"Bearer {_SUPABASE_ANON_KEY}")
283
+ req.add_header("Content-Type", "application/json")
284
+ req.add_header("Accept", "application/json")
285
+ try:
286
+ with urllib.request.urlopen(req, timeout=8) as resp:
287
+ return json.loads(resp.read().decode("utf-8"))
288
+ except urllib.error.HTTPError as exc:
289
+ try:
290
+ return json.loads(exc.read().decode("utf-8", errors="replace"))
291
+ except Exception:
292
+ return {"valid": False, "error": f"HTTP {exc.code}"}
293
+ except Exception:
294
+ return None
295
+
296
+
216
297
  def _maybe_revalidate() -> None:
217
298
  """Re-validate cached license if stale. Mutates globals; never raises."""
218
299
  global _license_data, is_pro
@@ -220,18 +301,55 @@ def _maybe_revalidate() -> None:
220
301
  if not _license_data:
221
302
  return
222
303
 
223
- validated_at_str = _license_data.get("validated_at") or _license_data.get("activated_at")
304
+ validated_at_str = (
305
+ _license_data.get("validated_at")
306
+ or _license_data.get("activated_at")
307
+ or _license_data.get("authenticated_at")
308
+ )
224
309
  if validated_at_str:
225
310
  try:
226
311
  validated_at = datetime.fromisoformat(validated_at_str)
227
312
  if validated_at.tzinfo is None:
228
313
  validated_at = validated_at.replace(tzinfo=timezone.utc)
229
314
  age = (datetime.now(timezone.utc) - validated_at).total_seconds()
230
- if age < _CACHE_TTL_SECONDS:
315
+ if age < _get_cache_ttl():
231
316
  return
232
317
  except Exception:
233
318
  pass
234
319
 
320
+ auth_method = _license_data.get("auth_method")
321
+
322
+ if auth_method == "device_flow":
323
+ device_token = _license_data.get("device_token")
324
+ if not device_token:
325
+ return
326
+ result = _call_get_user_plan(device_token)
327
+ if result is None:
328
+ return # Network error — keep cached (offline-first)
329
+ if not result.get("valid", True):
330
+ _license_data = None
331
+ is_pro = False
332
+ try:
333
+ if _LICENSE_FILE.exists():
334
+ _LICENSE_FILE.unlink()
335
+ except Exception:
336
+ pass
337
+ return
338
+ _license_data["plan"] = result.get("plan", "free")
339
+ _license_data["status"] = result.get("status", "active")
340
+ _license_data["features"] = result.get("features", [])
341
+ _license_data["validated_at"] = datetime.now(timezone.utc).isoformat()
342
+ is_pro = (
343
+ _license_data.get("plan") == "pro"
344
+ and _license_data.get("status", "active") != "inactive"
345
+ )
346
+ try:
347
+ _write_license_file(_license_data)
348
+ except Exception:
349
+ pass
350
+ return
351
+
352
+ # Key-based auth (existing flow / legacy)
235
353
  key = _license_data.get("license_key")
236
354
  if not key:
237
355
  return
@@ -266,6 +384,7 @@ def _init() -> None:
266
384
  is_pro = (
267
385
  _license_data is not None
268
386
  and _license_data.get("plan") == "pro"
387
+ and _license_data.get("status", "active") != "inactive"
269
388
  )
270
389
 
271
390
 
@@ -354,7 +473,105 @@ def require_pro(feature_name: str) -> None:
354
473
 
355
474
 
356
475
  # ---------------------------------------------------------------------------
357
- # Activation
476
+ # Device-flow authentication
477
+ # ---------------------------------------------------------------------------
478
+
479
+ def _finish_device_auth(result: dict) -> None:
480
+ """Persist device-flow credentials and emit success JSON. Exits on error."""
481
+ global _license_data, is_pro
482
+
483
+ device_token = result.get("device_token") or result.get("access_token") or ""
484
+ email = result.get("email", "")
485
+ plan = result.get("plan", "free")
486
+ plan_status = (
487
+ result.get("status_detail")
488
+ or result.get("user_status")
489
+ or result.get("status", "active")
490
+ )
491
+ features = result.get("features") or []
492
+
493
+ if not device_token:
494
+ sys.stderr.write("\n")
495
+ _fail("auth_error", "Authentication completed but no session token received. Contact support.")
496
+
497
+ _LICENSE_DIR.mkdir(parents=True, exist_ok=True)
498
+ now = datetime.now(timezone.utc).isoformat()
499
+ data: dict = {
500
+ "auth_method": "device_flow",
501
+ "device_token": device_token,
502
+ "email": email,
503
+ "plan": plan,
504
+ "status": plan_status,
505
+ "features": features,
506
+ "authenticated_at": now,
507
+ "validated_at": now,
508
+ }
509
+ _write_license_file(data)
510
+ _license_data = data
511
+ is_pro = plan == "pro" and plan_status != "inactive"
512
+
513
+ sys.stderr.write(f"\n Authenticated as {email}. Plan: {plan}\n\n")
514
+ sys.stderr.flush()
515
+
516
+ output: dict = {"status": "authenticated", "email": email, "plan": plan, "pro": is_pro}
517
+ if not is_pro:
518
+ output["upgrade_hint"] = "https://sourcecode.dev/pricing"
519
+ else:
520
+ output["features"] = features
521
+ sys.stdout.write(json.dumps(output, ensure_ascii=False) + "\n")
522
+ sys.stdout.flush()
523
+
524
+
525
+ def auth_login() -> None:
526
+ """Device code authentication flow.
527
+
528
+ Shows a browser URL; polls the backend every 2.5 s until the user
529
+ completes authentication or the 5-minute window expires.
530
+ Writes credentials to ~/.sourcecode/license.json on success.
531
+ Exits 0 on success, 1 on any failure.
532
+ """
533
+ import time
534
+
535
+ device_code = _generate_device_code()
536
+ activate_url = f"{_AUTH_BASE_URL}/activate?code={device_code}"
537
+
538
+ sys.stderr.write(f"\n Open this URL to authenticate:\n {activate_url}\n\n Waiting")
539
+ sys.stderr.flush()
540
+
541
+ deadline = time.monotonic() + _DEVICE_POLL_TIMEOUT_S
542
+ _tick = 0
543
+
544
+ while time.monotonic() < deadline:
545
+ time.sleep(_DEVICE_POLL_INTERVAL_S)
546
+ _tick += 1
547
+ if _tick % 4 == 0:
548
+ sys.stderr.write(".")
549
+ sys.stderr.flush()
550
+
551
+ result = _call_device_check(device_code)
552
+ if result is None:
553
+ continue # network blip — keep polling
554
+
555
+ status = result.get("status")
556
+ if status == "pending":
557
+ continue
558
+
559
+ if status == "complete":
560
+ _finish_device_auth(result)
561
+ return
562
+
563
+ if status == "error" or result.get("error"):
564
+ sys.stderr.write("\n")
565
+ _fail("auth_error", result.get("message") or result.get("error") or "Authentication failed.")
566
+
567
+ # Unknown status — keep polling
568
+
569
+ sys.stderr.write("\n")
570
+ _fail("auth_timeout", "Authentication timed out after 5 minutes. Please try again.")
571
+
572
+
573
+ # ---------------------------------------------------------------------------
574
+ # Activation (key-based — legacy / direct key entry)
358
575
  # ---------------------------------------------------------------------------
359
576
 
360
577
  def activate_license(license_key: str) -> None: