boilersync 1.2.14__tar.gz → 1.2.16__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 (54) hide show
  1. {boilersync-1.2.14 → boilersync-1.2.16}/.gitignore +7 -1
  2. {boilersync-1.2.14 → boilersync-1.2.16}/PKG-INFO +11 -2
  3. {boilersync-1.2.14 → boilersync-1.2.16}/README.md +10 -1
  4. {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/_version.py +2 -2
  5. {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/commands/pull.py +8 -3
  6. {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/commands/templates.py +133 -0
  7. {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/interpolation_context.py +2 -1
  8. {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/names.py +9 -0
  9. {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/template_processor.py +49 -1
  10. {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/template_sources.py +12 -2
  11. {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/variable_collector.py +25 -0
  12. {boilersync-1.2.14 → boilersync-1.2.16}/docs/ensemble.md +3 -0
  13. {boilersync-1.2.14 → boilersync-1.2.16}/docs/project-metadata.md +4 -1
  14. {boilersync-1.2.14 → boilersync-1.2.16}/docs/template-metadata.md +49 -0
  15. {boilersync-1.2.14 → boilersync-1.2.16}/mkdocs.yml +2 -0
  16. {boilersync-1.2.14 → boilersync-1.2.16}/tests/test_init_runtime_features.py +47 -0
  17. {boilersync-1.2.14 → boilersync-1.2.16}/tests/test_interpolation_context.py +15 -0
  18. {boilersync-1.2.14 → boilersync-1.2.16}/tests/test_template_sources.py +11 -0
  19. {boilersync-1.2.14 → boilersync-1.2.16}/tests/test_templates_commands.py +99 -3
  20. {boilersync-1.2.14 → boilersync-1.2.16}/.cursor/rules/adding-commands.mdc +0 -0
  21. {boilersync-1.2.14 → boilersync-1.2.16}/.cursor/rules/project-description.mdc +0 -0
  22. {boilersync-1.2.14 → boilersync-1.2.16}/.cursor/rules/python-conventions.mdc +0 -0
  23. {boilersync-1.2.14 → boilersync-1.2.16}/.github/workflows/auto-tag.yml +0 -0
  24. {boilersync-1.2.14 → boilersync-1.2.16}/.github/workflows/deploy-docs.yml +0 -0
  25. {boilersync-1.2.14 → boilersync-1.2.16}/.github/workflows/publish.yml +0 -0
  26. {boilersync-1.2.14 → boilersync-1.2.16}/.vscode/launch.json +0 -0
  27. {boilersync-1.2.14 → boilersync-1.2.16}/.vscode/settings.json +0 -0
  28. {boilersync-1.2.14 → boilersync-1.2.16}/.vscode/tasks.json +0 -0
  29. {boilersync-1.2.14 → boilersync-1.2.16}/PROBLEMS.md +0 -0
  30. {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/__init__.py +0 -0
  31. {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/__main__.py +0 -0
  32. {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/cli.py +0 -0
  33. {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/cli_helpers.py +0 -0
  34. {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/commands/__init__.py +0 -0
  35. {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/commands/check_pull.py +0 -0
  36. {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/commands/init.py +0 -0
  37. {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/commands/push.py +0 -0
  38. {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/errors.py +0 -0
  39. {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/logging.py +0 -0
  40. {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/paths.py +0 -0
  41. {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/project_metadata.py +0 -0
  42. {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/utils.py +0 -0
  43. {boilersync-1.2.14 → boilersync-1.2.16}/docs/index.md +0 -0
  44. {boilersync-1.2.14 → boilersync-1.2.16}/pyproject.toml +0 -0
  45. {boilersync-1.2.14 → boilersync-1.2.16}/scripts/build.sh +0 -0
  46. {boilersync-1.2.14 → boilersync-1.2.16}/tests/__init__.py +0 -0
  47. {boilersync-1.2.14 → boilersync-1.2.16}/tests/test_check_pull.py +0 -0
  48. {boilersync-1.2.14 → boilersync-1.2.16}/tests/test_filename_interpolation.py +0 -0
  49. {boilersync-1.2.14 → boilersync-1.2.16}/tests/test_paths.py +0 -0
  50. {boilersync-1.2.14 → boilersync-1.2.16}/tests/test_push_inheritance.py +0 -0
  51. {boilersync-1.2.14 → boilersync-1.2.16}/tests/test_template_inheritance.py +0 -0
  52. {boilersync-1.2.14 → boilersync-1.2.16}/tests/test_templates_init.py +0 -0
  53. {boilersync-1.2.14 → boilersync-1.2.16}/tests/test_variable_collection.py +0 -0
  54. {boilersync-1.2.14 → boilersync-1.2.16}/tests/test_variable_type_conversion.py +0 -0
@@ -186,4 +186,10 @@ staticfiles
186
186
  .env
187
187
  _version.py
188
188
 
189
- playground/
189
+ playground/
190
+
191
+ # BEGIN multi-managed: generated-files
192
+ # These files are generated by multi.
193
+ CLAUDE.md
194
+ AGENTS.md
195
+ # END multi-managed: generated-files
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: boilersync
3
- Version: 1.2.14
3
+ Version: 1.2.16
4
4
  Summary: BoilerSync
5
5
  Project-URL: Repository, https://github.com/gabemontague/boilersync
6
6
  Project-URL: Issues, https://github.com/gabemontague/boilersync/issues
@@ -48,6 +48,11 @@ boilersync templates init https://github.com/your-org/your-templates.git
48
48
  # 2) Initialize a project from a source-qualified template ref
49
49
  boilersync init your-org/your-templates#python/service-template
50
50
 
51
+ # Well-defaulted templates can be initialized without prompts
52
+ mkdir my-app-workspace
53
+ cd my-app-workspace
54
+ boilersync init your-org/your-templates#python/service-template --non-interactive
55
+
51
56
  # 3) Pull template updates into the current project when needed
52
57
  boilersync pull
53
58
 
@@ -58,6 +63,7 @@ boilersync push
58
63
  ## Command Overview
59
64
 
60
65
  - `boilersync init TEMPLATE_REF`: create a project from a template (empty target directory).
66
+ - `boilersync init TEMPLATE_REF --non-interactive`: create a project without prompts when defaults and automatic values cover all inputs.
61
67
  - `boilersync check-pull`: compare the project's recorded template repo commit against the current cached template repo HEAD.
62
68
  - `boilersync pull [TEMPLATE_REF]`: apply template updates to an existing project.
63
69
  - `boilersync push`: review and copy committed project changes back to the template.
@@ -99,8 +105,11 @@ For the field-by-field schema and validation rules, see [docs/project-metadata.m
99
105
 
100
106
  - Files ending in `.boilersync` are rendered and emitted without that extension.
101
107
  - Files with `.starter` as the first extension are starter-only files.
102
- - Every template root must include `template.json`, which supports inheritance (`extends`/`parent`), child templates, hooks, and optional GitHub repo creation.
108
+ - Every template root must include `template.json`, which supports inheritance (`extends`/`parent`), defaults, child templates, hooks, and optional GitHub repo creation.
103
109
  - Project naming is provided through standard template variables such as `name_snake` and `name_pretty`.
110
+ - `template.json` `defaults` can derive variables from naming values, such as `"api_package_name": "$${name_snake}_api"` or `"web_package_name": "$${name_kebab}-web"`.
111
+ - Default project-name inference strips a trailing `-workspace` / `_workspace` suffix from the target directory name.
112
+ - If a template references `github_user`, BoilerSync tries to populate it with `gh api user --jq .login` before prompting or failing in non-interactive mode.
104
113
 
105
114
  ## Documentation Policy
106
115
 
@@ -22,6 +22,11 @@ boilersync templates init https://github.com/your-org/your-templates.git
22
22
  # 2) Initialize a project from a source-qualified template ref
23
23
  boilersync init your-org/your-templates#python/service-template
24
24
 
25
+ # Well-defaulted templates can be initialized without prompts
26
+ mkdir my-app-workspace
27
+ cd my-app-workspace
28
+ boilersync init your-org/your-templates#python/service-template --non-interactive
29
+
25
30
  # 3) Pull template updates into the current project when needed
26
31
  boilersync pull
27
32
 
@@ -32,6 +37,7 @@ boilersync push
32
37
  ## Command Overview
33
38
 
34
39
  - `boilersync init TEMPLATE_REF`: create a project from a template (empty target directory).
40
+ - `boilersync init TEMPLATE_REF --non-interactive`: create a project without prompts when defaults and automatic values cover all inputs.
35
41
  - `boilersync check-pull`: compare the project's recorded template repo commit against the current cached template repo HEAD.
36
42
  - `boilersync pull [TEMPLATE_REF]`: apply template updates to an existing project.
37
43
  - `boilersync push`: review and copy committed project changes back to the template.
@@ -73,8 +79,11 @@ For the field-by-field schema and validation rules, see [docs/project-metadata.m
73
79
 
74
80
  - Files ending in `.boilersync` are rendered and emitted without that extension.
75
81
  - Files with `.starter` as the first extension are starter-only files.
76
- - Every template root must include `template.json`, which supports inheritance (`extends`/`parent`), child templates, hooks, and optional GitHub repo creation.
82
+ - Every template root must include `template.json`, which supports inheritance (`extends`/`parent`), defaults, child templates, hooks, and optional GitHub repo creation.
77
83
  - Project naming is provided through standard template variables such as `name_snake` and `name_pretty`.
84
+ - `template.json` `defaults` can derive variables from naming values, such as `"api_package_name": "$${name_snake}_api"` or `"web_package_name": "$${name_kebab}-web"`.
85
+ - Default project-name inference strips a trailing `-workspace` / `_workspace` suffix from the target directory name.
86
+ - If a template references `github_user`, BoilerSync tries to populate it with `gh api user --jq .login` before prompting or failing in non-interactive mode.
78
87
 
79
88
  ## Documentation Policy
80
89
 
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '1.2.14'
22
- __version_tuple__ = version_tuple = (1, 2, 14)
21
+ __version__ = version = '1.2.16'
22
+ __version_tuple__ = version_tuple = (1, 2, 16)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -8,10 +8,13 @@ import click
8
8
  from git import InvalidGitRepositoryError, Repo
9
9
 
10
10
  from boilersync.interpolation_context import interpolation_context
11
- from boilersync.names import normalize_to_snake, snake_to_pretty
11
+ from boilersync.names import default_project_snake_from_directory_name, snake_to_pretty
12
12
  from boilersync.paths import paths
13
13
  from boilersync.project_metadata import write_project_metadata
14
- from boilersync.template_processor import process_template_directory
14
+ from boilersync.template_processor import (
15
+ apply_template_defaults,
16
+ process_template_directory,
17
+ )
15
18
  from boilersync.template_sources import (
16
19
  TemplateSource,
17
20
  resolve_source_from_boilersync,
@@ -174,6 +177,8 @@ def process_template_directory_excluding_starter(
174
177
  template_dir: Source template directory
175
178
  target_dir: Target directory to process into
176
179
  """
180
+ apply_template_defaults(template_dir)
181
+
177
182
  # First, scan the template for all variables (excluding starter files)
178
183
  template_variables = scan_template_for_variables_excluding_starter(template_dir)
179
184
 
@@ -293,7 +298,7 @@ def _resolve_name_variables(
293
298
  ) -> tuple[dict[str, Any], str, str]:
294
299
  variables = dict(collected_variables or {})
295
300
 
296
- default_snake_name = normalize_to_snake(target_dir.name)
301
+ default_snake_name = default_project_snake_from_directory_name(target_dir.name)
297
302
  resolved_name_snake = str(
298
303
  variables.get("name_snake")
299
304
  or stored_name_snake
@@ -121,6 +121,90 @@ def list_local_templates() -> list[dict[str, Any]]:
121
121
  return templates
122
122
 
123
123
 
124
+ def _git_output(repo_dir: Path, *args: str) -> str | None:
125
+ try:
126
+ result = subprocess.run(
127
+ ["git", "-C", str(repo_dir), *args],
128
+ check=False,
129
+ capture_output=True,
130
+ text=True,
131
+ )
132
+ except OSError:
133
+ return None
134
+
135
+ if result.returncode != 0:
136
+ return None
137
+
138
+ output = result.stdout.strip()
139
+ return output or None
140
+
141
+
142
+ def list_template_sources() -> dict[str, Any]:
143
+ template_root_dir = paths.template_root_dir
144
+ sources: list[dict[str, Any]] = []
145
+
146
+ if template_root_dir.exists():
147
+ for org_dir in sorted(template_root_dir.iterdir()):
148
+ if not org_dir.is_dir() or org_dir.name.startswith("."):
149
+ continue
150
+
151
+ for repo_dir in sorted(org_dir.iterdir()):
152
+ if not repo_dir.is_dir() or repo_dir.name.startswith("."):
153
+ continue
154
+ if not (repo_dir / ".git").exists():
155
+ continue
156
+
157
+ remote_url = _git_output(repo_dir, "config", "--get", "remote.origin.url")
158
+ source = {
159
+ "org": org_dir.name,
160
+ "repo": repo_dir.name,
161
+ "path": str(repo_dir),
162
+ "remote_url": remote_url,
163
+ "branch": _git_output(repo_dir, "branch", "--show-current"),
164
+ "commit": _git_output(repo_dir, "rev-parse", "--short", "HEAD"),
165
+ "template_count": len(_iter_repo_template_subdirs(repo_dir)),
166
+ }
167
+ sources.append(source)
168
+
169
+ paths_by_remote: dict[str, list[str]] = {}
170
+ for source in sources:
171
+ remote_url = source.get("remote_url")
172
+ if not remote_url:
173
+ continue
174
+ paths_by_remote.setdefault(str(remote_url), []).append(str(source["path"]))
175
+
176
+ duplicate_remotes = [
177
+ {
178
+ "remote_url": remote_url,
179
+ "paths": sorted(source_paths),
180
+ }
181
+ for remote_url, source_paths in sorted(paths_by_remote.items())
182
+ if len(source_paths) > 1
183
+ ]
184
+
185
+ return {
186
+ "template_root_dir": str(template_root_dir),
187
+ "source_count": len(sources),
188
+ "sources": sources,
189
+ "duplicate_remotes": duplicate_remotes,
190
+ }
191
+
192
+
193
+ def _find_existing_source_for_remote(
194
+ remote_url: str,
195
+ *,
196
+ exclude_path: Path | None = None,
197
+ ) -> dict[str, Any] | None:
198
+ excluded = exclude_path.resolve() if exclude_path is not None else None
199
+ for source in list_template_sources()["sources"]:
200
+ source_path = Path(str(source["path"])).resolve()
201
+ if excluded is not None and source_path == excluded:
202
+ continue
203
+ if source.get("remote_url") == remote_url:
204
+ return source
205
+ return None
206
+
207
+
124
208
  def _normalize_input_definition(
125
209
  name: str,
126
210
  raw_definition: Any,
@@ -284,6 +368,17 @@ def init_templates(
284
368
  f"Template source directory already exists and is not empty: {target_dir}"
285
369
  )
286
370
 
371
+ duplicate_source = _find_existing_source_for_remote(
372
+ canonical_repo_url,
373
+ exclude_path=target_dir,
374
+ )
375
+ if duplicate_source is not None:
376
+ raise click.ClickException(
377
+ "Template source remote is already initialized at "
378
+ f"{duplicate_source['path']}. Use that source instead of creating "
379
+ f"a duplicate checkout at {target_dir}."
380
+ )
381
+
287
382
  click.echo(f"📦 Cloning template source into: {target_dir}")
288
383
  try:
289
384
  subprocess.run(["git", "clone", canonical_repo_url, str(target_dir)], check=True)
@@ -342,6 +437,43 @@ def templates_list_cmd(json_output: bool) -> None:
342
437
  click.echo(f" path: {template['template_dir']}")
343
438
 
344
439
 
440
+ @click.command(name="sources")
441
+ @click.option("--json", "json_output", is_flag=True, help="Output as JSON.")
442
+ def templates_sources_cmd(json_output: bool) -> None:
443
+ """Show local template source checkouts and duplicate remotes."""
444
+ payload = list_template_sources()
445
+
446
+ if json_output:
447
+ click.echo(json.dumps(payload, indent=2))
448
+ return
449
+
450
+ click.echo(f"Template root: {payload['template_root_dir']}")
451
+ sources = payload["sources"]
452
+ if not sources:
453
+ click.echo("No template sources found.")
454
+ return
455
+
456
+ click.echo("")
457
+ for source in sources:
458
+ click.echo(f"- {source['org']}/{source['repo']}")
459
+ click.echo(f" path: {source['path']}")
460
+ click.echo(f" remote: {source['remote_url'] or '(none)'}")
461
+ click.echo(f" branch: {source['branch'] or '(unknown)'}")
462
+ click.echo(f" commit: {source['commit'] or '(unknown)'}")
463
+ click.echo(f" templates: {source['template_count']}")
464
+
465
+ duplicate_remotes = payload["duplicate_remotes"]
466
+ click.echo("")
467
+ if duplicate_remotes:
468
+ click.echo("Duplicate remotes:")
469
+ for duplicate in duplicate_remotes:
470
+ click.echo(f"- {duplicate['remote_url']}")
471
+ for source_path in duplicate["paths"]:
472
+ click.echo(f" - {source_path}")
473
+ else:
474
+ click.echo("No duplicate remotes found.")
475
+
476
+
345
477
  @click.command(name="details")
346
478
  @click.argument("template_ref")
347
479
  @click.option("--json", "json_output", is_flag=True, help="Output as JSON.")
@@ -382,4 +514,5 @@ def templates_cmd() -> None:
382
514
 
383
515
  templates_cmd.add_command(templates_init_cmd)
384
516
  templates_cmd.add_command(templates_list_cmd)
517
+ templates_cmd.add_command(templates_sources_cmd)
385
518
  templates_cmd.add_command(templates_details_cmd)
@@ -4,6 +4,7 @@ from typing import Any, Dict
4
4
  from boilersync.names import (
5
5
  ProjectNames,
6
6
  create_project_names,
7
+ default_project_snake_from_directory_name,
7
8
  normalize_to_snake,
8
9
  snake_to_kebab,
9
10
  )
@@ -31,7 +32,7 @@ class InterpolationContext:
31
32
  directory: Directory whose name will be used for the project
32
33
  """
33
34
  project_name = directory.name
34
- snake_name = normalize_to_snake(project_name)
35
+ snake_name = default_project_snake_from_directory_name(project_name)
35
36
  self._names = create_project_names(snake_name)
36
37
 
37
38
  def set_project_names(self, snake_name: str, pretty_name: str) -> None:
@@ -53,6 +53,15 @@ def normalize_to_snake(name: str) -> str:
53
53
  return snake
54
54
 
55
55
 
56
+ def default_project_snake_from_directory_name(directory_name: str) -> str:
57
+ """Infer a project name from a directory name."""
58
+ snake_name = normalize_to_snake(directory_name)
59
+ for suffix in ("_workspace",):
60
+ if snake_name.endswith(suffix) and len(snake_name) > len(suffix):
61
+ return snake_name[: -len(suffix)]
62
+ return snake_name
63
+
64
+
56
65
  def snake_to_pascal(snake_name: str) -> str:
57
66
  """Convert snake_case to PascalCase."""
58
67
  return "".join(word.capitalize() for word in snake_name.split("_"))
@@ -1,3 +1,4 @@
1
+ import json
1
2
  import re
2
3
  import shutil
3
4
  from pathlib import Path
@@ -12,7 +13,6 @@ from boilersync.variable_collector import (
12
13
  extract_variables_from_template_content,
13
14
  )
14
15
 
15
-
16
16
  PATH_NAME_VARIABLE_PATTERN = re.compile(r"\b(NAME_[A-Z0-9_]+)\b")
17
17
 
18
18
 
@@ -164,6 +164,52 @@ def process_template_file(file_path: Path, context: Dict[str, Any]) -> None:
164
164
  pass
165
165
 
166
166
 
167
+ def render_template_value(value: Any, context: Dict[str, Any]) -> Any:
168
+ """Render a template metadata value with the current interpolation context."""
169
+ if isinstance(value, str):
170
+ env = create_jinja_environment()
171
+ return env.from_string(value).render(context)
172
+ if isinstance(value, list):
173
+ return [render_template_value(item, context) for item in value]
174
+ if isinstance(value, dict):
175
+ return {
176
+ str(key): render_template_value(child_value, context)
177
+ for key, child_value in value.items()
178
+ }
179
+ return value
180
+
181
+
182
+ def apply_template_defaults(template_dir: Path) -> None:
183
+ """Apply template.json defaults before missing-variable collection."""
184
+ template_json_path = template_dir / "template.json"
185
+ if not template_json_path.exists():
186
+ return
187
+
188
+ try:
189
+ config = json.loads(template_json_path.read_text(encoding="utf-8"))
190
+ except json.JSONDecodeError:
191
+ return
192
+
193
+ defaults = config.get("defaults", {})
194
+ if not defaults:
195
+ return
196
+ if not isinstance(defaults, dict):
197
+ raise ValueError(
198
+ f"Template '{template_dir}' has invalid 'defaults' config: expected object"
199
+ )
200
+
201
+ for key, value in defaults.items():
202
+ key = str(key)
203
+ if interpolation_context.has_variable(key):
204
+ continue
205
+
206
+ context = interpolation_context.get_context()
207
+ interpolation_context.set_collected_variable(
208
+ key,
209
+ render_template_value(value, context),
210
+ )
211
+
212
+
167
213
  def copy_and_process_template(
168
214
  source_dir: Path, target_dir: Path, context: Dict[str, Any]
169
215
  ) -> None:
@@ -222,6 +268,8 @@ def process_template_directory(
222
268
  template_dir: Source template directory
223
269
  target_dir: Target directory to process into
224
270
  """
271
+ apply_template_defaults(template_dir)
272
+
225
273
  # First, scan the template for all variables
226
274
  template_variables = scan_template_for_variables(template_dir)
227
275
 
@@ -8,6 +8,12 @@ from urllib.parse import urlparse
8
8
  from boilersync.paths import paths
9
9
 
10
10
  SOURCE_RESOLUTION = "source_ref"
11
+ REPO_RENAMES = {
12
+ ("openbase-community", "openbase-boilerplate"): (
13
+ "openbase-community",
14
+ "templates",
15
+ ),
16
+ }
11
17
 
12
18
 
13
19
  @dataclass(frozen=True)
@@ -39,6 +45,10 @@ def _normalize_subdir(subdir: str) -> str:
39
45
  return cleaned
40
46
 
41
47
 
48
+ def _canonical_repo_identity(org: str, repo: str) -> tuple[str, str]:
49
+ return REPO_RENAMES.get((org, repo), (org, repo))
50
+
51
+
42
52
  def parse_repo_locator(repo_locator: str) -> tuple[str, str, str]:
43
53
  locator = repo_locator.strip()
44
54
  if not locator:
@@ -65,8 +75,7 @@ def parse_repo_locator(repo_locator: str) -> tuple[str, str, str]:
65
75
  raise ValueError(
66
76
  "Template source repo URL must be in the format https://github.com/org/repo(.git)."
67
77
  )
68
- org = parts[0]
69
- repo = parts[1]
78
+ org, repo = _canonical_repo_identity(parts[0], parts[1])
70
79
  clone_url = f"https://github.com/{org}/{repo}.git"
71
80
  return org, repo, clone_url
72
81
 
@@ -76,6 +85,7 @@ def parse_repo_locator(repo_locator: str) -> tuple[str, str, str]:
76
85
  repo = match.group("repo")
77
86
  if repo.endswith(".git"):
78
87
  repo = repo[: -len(".git")]
88
+ org, repo = _canonical_repo_identity(org, repo)
79
89
  clone_url = f"https://github.com/{org}/{repo}.git"
80
90
  return org, repo, clone_url
81
91
 
@@ -1,3 +1,4 @@
1
+ import subprocess
1
2
  from typing import Any, Set
2
3
 
3
4
  import click
@@ -87,6 +88,8 @@ def collect_missing_variables(template_variables: Set[str], no_input: bool) -> N
87
88
  template_variables: Variables found in template content
88
89
  no_input: If True, raise an error for missing variables instead of prompting
89
90
  """
91
+ apply_automatic_variable_defaults(template_variables)
92
+
90
93
  missing_variables = []
91
94
 
92
95
  for var in template_variables:
@@ -126,3 +129,25 @@ def collect_missing_variables(template_variables: Set[str], no_input: bool) -> N
126
129
 
127
130
  click.echo("=" * 50)
128
131
  click.echo("✅ All variables collected!\n")
132
+
133
+
134
+ def apply_automatic_variable_defaults(template_variables: Set[str]) -> None:
135
+ """Populate defaults that can be discovered from the local environment."""
136
+ if "github_user" not in template_variables:
137
+ return
138
+
139
+ if interpolation_context.has_variable("github_user"):
140
+ return
141
+
142
+ try:
143
+ result = subprocess.run(
144
+ ["gh", "api", "user", "--jq", ".login"],
145
+ check=False,
146
+ capture_output=True,
147
+ text=True,
148
+ )
149
+ except OSError:
150
+ return
151
+ github_user = result.stdout.strip()
152
+ if result.returncode == 0 and github_user:
153
+ interpolation_context.set_collected_variable("github_user", github_user)
@@ -67,6 +67,9 @@ Use for first-time project generation.
67
67
 
68
68
  - Requires an empty target directory
69
69
  - Supports `--var KEY=VALUE` for template inputs, including `name_snake` / `name_pretty`, and non-interactive mode (`--non-interactive`, alias `--no-input`)
70
+ - Uses `template.json` `defaults` before prompting, so well-defaulted templates can be bootstrapped with one command after creating and entering the target directory
71
+ - Infers `name_snake` from the target directory and strips a trailing `-workspace` / `_workspace` suffix
72
+ - Attempts to infer `github_user` from `gh api user --jq .login` when a template references `github_user`
70
73
  - Resolves source-qualified refs
71
74
  - Can run configured hooks and initialize child templates
72
75
 
@@ -57,7 +57,10 @@ This file is the source of truth for template provenance and saved interpolation
57
57
 
58
58
  - Type: `object`
59
59
  - Purpose: saved interpolation variables for repeatable `pull` and `push` flows.
60
- - Notes: keys/values are template-specific.
60
+ - Notes:
61
+ - Keys/values are template-specific.
62
+ - Values populated from `template.json` `defaults` are stored here after init or pull.
63
+ - Explicit values such as `--var KEY=VALUE` take precedence over template defaults.
61
64
 
62
65
  ### `children` (optional)
63
66
 
@@ -8,6 +8,7 @@ BoilerSync templates must include a `template.json` file at the template root.
8
8
 
9
9
  - Inheritance: `extends` (or legacy `parent`)
10
10
  - Input metadata: `variables`, `options`
11
+ - Template input defaults: `defaults`
11
12
  - Runtime behavior: `children`, `hooks`, `github`, `skip_git`
12
13
 
13
14
  Unknown keys are ignored by current CLI commands.
@@ -37,6 +38,12 @@ Unknown keys are ignored by current CLI commands.
37
38
  "default": true
38
39
  }
39
40
  },
41
+ "defaults": {
42
+ "api_package_name": "$${name_snake}_api",
43
+ "web_package_name": "$${name_kebab}-web",
44
+ "api_client_export_name": "$${name_camel}",
45
+ "with_frontend": true
46
+ },
40
47
  "children": [
41
48
  {
42
49
  "template": "acme/templates#python/worker",
@@ -105,6 +112,8 @@ Notes:
105
112
 
106
113
  - `variables` are also auto-discovered from `$${...}` usage in template files and `NAME_*` path placeholders.
107
114
  - Built-in naming variables such as `name_snake` and `name_pretty` are exposed through the normal variable/input flow.
115
+ - If neither `--var name_snake=...` nor saved project metadata provides a name, BoilerSync infers `name_snake` from the target directory name.
116
+ - A trailing `-workspace` / `_workspace` suffix is stripped during default project-name inference. For example, `woo-score-workspace` defaults to `name_snake=woo_score`.
108
117
 
109
118
  Relative `extends` values are resolved within the same template source repository. Example:
110
119
 
@@ -116,6 +125,46 @@ Relative `extends` values are resolved within the same template source repositor
116
125
 
117
126
  When used from `acme/templates#cli`, this resolves to `acme/templates#pip-package`.
118
127
 
128
+ ### `defaults`
129
+
130
+ - Type: object keyed by interpolation variable name.
131
+ - Purpose: Provide template-owned defaults before missing-variable collection.
132
+ - Values: Scalars, arrays, and objects. String values support `$${...}` interpolation.
133
+ - Precedence: Existing values win. BoilerSync does not overwrite values from explicit `--var` flags, saved `.boilersync` project metadata, or defaults already applied by an earlier template in the inheritance chain.
134
+ - Persistence: Applied defaults are saved in generated project `.boilersync` metadata under `variables`.
135
+
136
+ Example:
137
+
138
+ ```json
139
+ {
140
+ "defaults": {
141
+ "api_package_name": "$${name_snake}_api",
142
+ "django_app_name": "$${name_snake}",
143
+ "web_package_name": "$${name_kebab}-web",
144
+ "api_client_package_name": "$${name_kebab}-api-client",
145
+ "api_client_export_name": "$${name_camel}",
146
+ "cdn_base_url": "https://cdn.openbase.app/$${name_kebab}/",
147
+ "with_frontend": true
148
+ }
149
+ }
150
+ ```
151
+
152
+ Defaults are the main mechanism for making a template usable with one non-interactive command:
153
+
154
+ ```bash
155
+ mkdir my-app-workspace
156
+ cd my-app-workspace
157
+ boilersync init acme/templates#django-react-workspace --non-interactive
158
+ ```
159
+
160
+ ### Automatic Environment Defaults
161
+
162
+ BoilerSync can populate a small set of variables from the local environment before prompting:
163
+
164
+ - `github_user`: when a template references this variable and no value is already set, BoilerSync runs `gh api user --jq .login`.
165
+
166
+ If automatic lookup fails, the variable remains missing. Interactive init prompts for it, while `--non-interactive` fails and asks for an explicit `--var github_user=...`.
167
+
119
168
  ### `children`
120
169
 
121
170
  - Type: list of objects.
@@ -1,6 +1,8 @@
1
1
  site_name: BoilerSync CLI
2
2
  site_description: BoilerSync CLI documentation
3
+ site_url: https://boilersync.gabemontague.com
3
4
  repo_url: https://github.com/montaguegabe/boilersync
5
+ use_directory_urls: false
4
6
  theme:
5
7
  name: mkdocs
6
8
  nav:
@@ -188,6 +188,53 @@ class TestInitRuntimeFeatures(unittest.TestCase):
188
188
  self.assertNotIn("source", root_boilersync_data)
189
189
  self.assertEqual(root_boilersync_data["children"], ["demo-workspace-child"])
190
190
 
191
+ def test_template_defaults_are_applied_before_variable_collection(self) -> None:
192
+ target_dir = self.root / "defaulted-workspace"
193
+ target_dir.mkdir()
194
+
195
+ _write_template(
196
+ self.template_root_dir,
197
+ org=self.org,
198
+ repo=self.repo,
199
+ subdir="defaulted-template",
200
+ files={
201
+ "README.md.boilersync": (
202
+ "$${api_package_name} "
203
+ "$${web_package_name} "
204
+ "$${api_client_export_name} "
205
+ "$${with_frontend}\n"
206
+ )
207
+ },
208
+ config={
209
+ "defaults": {
210
+ "api_package_name": "$${name_snake}_api",
211
+ "web_package_name": "$${name_kebab}-web",
212
+ "api_client_export_name": "$${name_camel}",
213
+ "with_frontend": True,
214
+ },
215
+ "skip_git": True,
216
+ },
217
+ )
218
+
219
+ init(
220
+ self._template_ref("defaulted-template"),
221
+ target_dir=target_dir,
222
+ no_input=True,
223
+ template_variables={"name_snake": "demo_workspace"},
224
+ )
225
+
226
+ self.assertEqual(
227
+ (target_dir / "README.md").read_text(encoding="utf-8"),
228
+ "demo_workspace_api demo-workspace-web demoWorkspace True\n",
229
+ )
230
+
231
+ boilersync_data = json.loads((target_dir / ".boilersync").read_text())
232
+ self.assertEqual(
233
+ boilersync_data["variables"]["api_package_name"],
234
+ "demo_workspace_api",
235
+ )
236
+ self.assertTrue(boilersync_data["variables"]["with_frontend"])
237
+
191
238
  def test_init_with_local_child_template_name(self) -> None:
192
239
  target_dir = self.root / "workspace-local-child"
193
240
  target_dir.mkdir()
@@ -11,6 +11,7 @@ from git import Repo
11
11
 
12
12
  from boilersync.commands.init import init
13
13
  from boilersync.interpolation_context import interpolation_context
14
+ from boilersync.names import default_project_snake_from_directory_name
14
15
 
15
16
 
16
17
  def _commit_template_repo(repo_dir: Path) -> None:
@@ -75,6 +76,20 @@ class TestInterpolationContextNameVariants(unittest.TestCase):
75
76
  self.assertEqual(context["api_package_name_kebab"], "custom-kebab")
76
77
 
77
78
 
79
+ class TestDirectoryNameDefaults(unittest.TestCase):
80
+ def test_default_project_name_strips_workspace_suffix(self) -> None:
81
+ self.assertEqual(
82
+ default_project_snake_from_directory_name("woo-score-workspace"),
83
+ "woo_score",
84
+ )
85
+
86
+ def test_default_project_name_preserves_non_workspace_name(self) -> None:
87
+ self.assertEqual(
88
+ default_project_snake_from_directory_name("woo-score"),
89
+ "woo_score",
90
+ )
91
+
92
+
78
93
  class TestDerivedNameVariantsInInit(unittest.TestCase):
79
94
  def setUp(self) -> None:
80
95
  self.temp_dir = tempfile.TemporaryDirectory()
@@ -25,6 +25,17 @@ class TestTemplateSources(unittest.TestCase):
25
25
  self.assertEqual(repo, "template-kit")
26
26
  self.assertEqual(clone_url, "https://github.com/acme/template-kit.git")
27
27
 
28
+ def test_parse_repo_locator_uses_known_repo_renames(self):
29
+ org, repo, clone_url = parse_repo_locator(
30
+ "openbase-community/openbase-boilerplate"
31
+ )
32
+ self.assertEqual(org, "openbase-community")
33
+ self.assertEqual(repo, "templates")
34
+ self.assertEqual(
35
+ clone_url,
36
+ "https://github.com/openbase-community/templates.git",
37
+ )
38
+
28
39
  def test_parse_repo_locator_rejects_non_github_host(self):
29
40
  with self.assertRaises(ValueError):
30
41
  parse_repo_locator("https://gitlab.com/acme/template-kit.git")
@@ -1,5 +1,6 @@
1
1
  import json
2
2
  import os
3
+ import subprocess
3
4
  import tempfile
4
5
  import unittest
5
6
  from pathlib import Path
@@ -7,9 +8,13 @@ from unittest.mock import patch
7
8
 
8
9
  from click.testing import CliRunner
9
10
 
10
- from boilersync.commands.init import init_cmd
11
- from boilersync.commands.init import parse_key_value_options
12
- from boilersync.commands.templates import get_template_details, list_local_templates
11
+ from boilersync.commands.init import init_cmd, parse_key_value_options
12
+ from boilersync.commands.templates import (
13
+ get_template_details,
14
+ list_local_templates,
15
+ list_template_sources,
16
+ templates_cmd,
17
+ )
13
18
 
14
19
 
15
20
  def _write_template(
@@ -118,6 +123,97 @@ class TestTemplatesCommands(unittest.TestCase):
118
123
  self.assertIn("acme/platform#python/service", refs)
119
124
  self.assertNotIn("acme/platform#python/service/src", refs)
120
125
 
126
+ def test_list_template_sources_reports_duplicate_remotes(self) -> None:
127
+ for repo in ("old-name", "new-name"):
128
+ repo_dir = self.template_root_dir / "acme" / repo
129
+ repo_dir.mkdir(parents=True)
130
+ subprocess.run(
131
+ ["git", "init"],
132
+ cwd=repo_dir,
133
+ check=True,
134
+ capture_output=True,
135
+ text=True,
136
+ )
137
+ subprocess.run(
138
+ [
139
+ "git",
140
+ "remote",
141
+ "add",
142
+ "origin",
143
+ "https://github.com/acme/templates.git",
144
+ ],
145
+ cwd=repo_dir,
146
+ check=True,
147
+ capture_output=True,
148
+ text=True,
149
+ )
150
+ template_dir = repo_dir / "service"
151
+ template_dir.mkdir()
152
+ (template_dir / "template.json").write_text("{}", encoding="utf-8")
153
+
154
+ payload = list_template_sources()
155
+
156
+ self.assertEqual(payload["source_count"], 2)
157
+ self.assertEqual(
158
+ payload["duplicate_remotes"],
159
+ [
160
+ {
161
+ "remote_url": "https://github.com/acme/templates.git",
162
+ "paths": [
163
+ str(self.template_root_dir / "acme" / "new-name"),
164
+ str(self.template_root_dir / "acme" / "old-name"),
165
+ ],
166
+ }
167
+ ],
168
+ )
169
+
170
+ def test_templates_sources_cmd_outputs_json(self) -> None:
171
+ _write_template(
172
+ self.template_root_dir,
173
+ org="acme",
174
+ repo="platform",
175
+ subdir="service",
176
+ files={"README.md.boilersync": "hello"},
177
+ config={},
178
+ )
179
+
180
+ result = CliRunner().invoke(templates_cmd, ["sources", "--json"])
181
+
182
+ self.assertEqual(result.exit_code, 0, result.output)
183
+ payload = json.loads(result.output)
184
+ self.assertEqual(payload["template_root_dir"], str(self.template_root_dir))
185
+ self.assertEqual(payload["source_count"], 1)
186
+
187
+ def test_templates_init_rejects_duplicate_remote(self) -> None:
188
+ existing_dir = self.template_root_dir / "acme" / "current-templates"
189
+ existing_dir.mkdir(parents=True)
190
+ subprocess.run(
191
+ ["git", "init"],
192
+ cwd=existing_dir,
193
+ check=True,
194
+ capture_output=True,
195
+ text=True,
196
+ )
197
+ subprocess.run(
198
+ [
199
+ "git",
200
+ "remote",
201
+ "add",
202
+ "origin",
203
+ "https://github.com/acme/old-templates.git",
204
+ ],
205
+ cwd=existing_dir,
206
+ check=True,
207
+ capture_output=True,
208
+ text=True,
209
+ )
210
+
211
+ result = CliRunner().invoke(templates_cmd, ["init", "acme/old-templates"])
212
+
213
+ self.assertNotEqual(result.exit_code, 0)
214
+ self.assertIn("Template source remote is already initialized", result.output)
215
+ self.assertIn(str(existing_dir), result.output)
216
+
121
217
  def test_get_template_details_returns_variables_and_options(self) -> None:
122
218
  _write_template(
123
219
  self.template_root_dir,
File without changes
File without changes
File without changes