newstack 0.2.0__tar.gz → 0.3.0__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 (139) hide show
  1. {newstack-0.2.0 → newstack-0.3.0}/CHANGELOG.md +22 -0
  2. {newstack-0.2.0 → newstack-0.3.0}/PKG-INFO +42 -3
  3. {newstack-0.2.0 → newstack-0.3.0}/README.md +41 -2
  4. {newstack-0.2.0 → newstack-0.3.0}/pyproject.toml +1 -1
  5. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/__init__.py +1 -1
  6. newstack-0.3.0/src/newstack/adopt.py +160 -0
  7. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/cli.py +102 -10
  8. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/django/stack.json +18 -0
  9. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/fastapi/stack.json +15 -0
  10. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/go-cli/stack.json +13 -0
  11. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/node-express-ts/stack.json +15 -0
  12. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/python-cli/stack.json +17 -0
  13. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/python-lib/stack.json +13 -0
  14. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/react-vite-ts/stack.json +16 -0
  15. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/rust-cli/stack.json +13 -0
  16. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/static-site/stack.json +13 -0
  17. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/svelte-kit-ts/stack.json +16 -0
  18. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/registry.py +50 -0
  19. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/render.py +12 -0
  20. newstack-0.3.0/tests/test_adopt.py +192 -0
  21. {newstack-0.2.0 → newstack-0.3.0}/.gitignore +0 -0
  22. {newstack-0.2.0 → newstack-0.3.0}/LICENSE +0 -0
  23. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/__main__.py +0 -0
  24. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/django/template/.github/workflows/ci.yml +0 -0
  25. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/django/template/.gitignore +0 -0
  26. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/django/template/LICENSE +0 -0
  27. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/django/template/README.md +0 -0
  28. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/django/template/config/__init__.py +0 -0
  29. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/django/template/config/asgi.py +0 -0
  30. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/django/template/config/settings.py +0 -0
  31. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/django/template/config/urls.py +0 -0
  32. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/django/template/config/wsgi.py +0 -0
  33. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/django/template/core/__init__.py +0 -0
  34. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/django/template/core/apps.py +0 -0
  35. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/django/template/core/migrations/0001_initial.py +0 -0
  36. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/django/template/core/migrations/__init__.py +0 -0
  37. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/django/template/core/models.py +0 -0
  38. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/django/template/core/urls.py +0 -0
  39. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/django/template/core/views.py +0 -0
  40. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/django/template/manage.py +0 -0
  41. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/django/template/pyproject.toml +0 -0
  42. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/django/template/tests/test_models.py +0 -0
  43. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/django/template/tests/test_views.py +0 -0
  44. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/fastapi/template/.github/workflows/ci.yml +0 -0
  45. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/fastapi/template/.gitignore +0 -0
  46. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/fastapi/template/Dockerfile +0 -0
  47. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/fastapi/template/LICENSE +0 -0
  48. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/fastapi/template/README.md +0 -0
  49. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/fastapi/template/pyproject.toml +0 -0
  50. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/fastapi/template/src/{{package_name}}/__init__.py +0 -0
  51. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/fastapi/template/src/{{package_name}}/main.py +0 -0
  52. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/fastapi/template/tests/test_api.py +0 -0
  53. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/go-cli/template/.github/workflows/ci.yml +0 -0
  54. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/go-cli/template/.gitignore +0 -0
  55. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/go-cli/template/LICENSE +0 -0
  56. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/go-cli/template/README.md +0 -0
  57. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/go-cli/template/go.mod +0 -0
  58. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/go-cli/template/internal/app/app.go +0 -0
  59. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/go-cli/template/internal/app/app_test.go +0 -0
  60. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/go-cli/template/main.go +0 -0
  61. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/node-express-ts/template/.github/workflows/ci.yml +0 -0
  62. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/node-express-ts/template/.gitignore +0 -0
  63. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/node-express-ts/template/LICENSE +0 -0
  64. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/node-express-ts/template/README.md +0 -0
  65. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/node-express-ts/template/package.json +0 -0
  66. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/node-express-ts/template/src/app.ts +0 -0
  67. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/node-express-ts/template/src/server.ts +0 -0
  68. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/node-express-ts/template/tests/app.test.ts +0 -0
  69. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/node-express-ts/template/tsconfig.json +0 -0
  70. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/python-cli/template/.github/workflows/ci.yml +0 -0
  71. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/python-cli/template/.gitignore +0 -0
  72. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/python-cli/template/LICENSE +0 -0
  73. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/python-cli/template/README.md +0 -0
  74. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/python-cli/template/pyproject.toml +0 -0
  75. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/python-cli/template/src/{{package_name}}/__init__.py +0 -0
  76. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/python-cli/template/src/{{package_name}}/__main__.py +0 -0
  77. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/python-cli/template/src/{{package_name}}/cli.py +0 -0
  78. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/python-cli/template/tests/test_cli.py +0 -0
  79. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/python-lib/template/.github/workflows/ci.yml +0 -0
  80. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/python-lib/template/.gitignore +0 -0
  81. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/python-lib/template/LICENSE +0 -0
  82. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/python-lib/template/README.md +0 -0
  83. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/python-lib/template/pyproject.toml +0 -0
  84. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/python-lib/template/src/{{package_name}}/__init__.py +0 -0
  85. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/python-lib/template/src/{{package_name}}/core.py +0 -0
  86. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/python-lib/template/tests/test_core.py +0 -0
  87. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/react-vite-ts/template/.github/workflows/ci.yml +0 -0
  88. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/react-vite-ts/template/.gitignore +0 -0
  89. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/react-vite-ts/template/LICENSE +0 -0
  90. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/react-vite-ts/template/README.md +0 -0
  91. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/react-vite-ts/template/index.html +0 -0
  92. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/react-vite-ts/template/package.json +0 -0
  93. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/react-vite-ts/template/src/App.test.tsx +0 -0
  94. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/react-vite-ts/template/src/App.tsx +0 -0
  95. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/react-vite-ts/template/src/main.tsx +0 -0
  96. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/react-vite-ts/template/src/styles.css +0 -0
  97. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/react-vite-ts/template/tsconfig.json +0 -0
  98. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/react-vite-ts/template/vite.config.ts +0 -0
  99. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/rust-cli/template/.github/workflows/ci.yml +0 -0
  100. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/rust-cli/template/.gitignore +0 -0
  101. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/rust-cli/template/Cargo.toml +0 -0
  102. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/rust-cli/template/LICENSE +0 -0
  103. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/rust-cli/template/README.md +0 -0
  104. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/rust-cli/template/src/lib.rs +0 -0
  105. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/rust-cli/template/src/main.rs +0 -0
  106. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/rust-cli/template/tests/cli.rs +0 -0
  107. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/static-site/template/.github/workflows/deploy-pages.yml +0 -0
  108. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/static-site/template/.gitignore +0 -0
  109. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/static-site/template/LICENSE +0 -0
  110. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/static-site/template/README.md +0 -0
  111. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/static-site/template/index.html +0 -0
  112. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/static-site/template/main.js +0 -0
  113. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/static-site/template/styles.css +0 -0
  114. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/static-site/template/tests/site.test.mjs +0 -0
  115. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/svelte-kit-ts/template/.github/workflows/ci.yml +0 -0
  116. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/svelte-kit-ts/template/.gitignore +0 -0
  117. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/svelte-kit-ts/template/.npmrc +0 -0
  118. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/svelte-kit-ts/template/LICENSE +0 -0
  119. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/svelte-kit-ts/template/README.md +0 -0
  120. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/svelte-kit-ts/template/package.json +0 -0
  121. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/svelte-kit-ts/template/src/app.d.ts +0 -0
  122. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/svelte-kit-ts/template/src/app.html +0 -0
  123. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/svelte-kit-ts/template/src/lib/Counter.svelte +0 -0
  124. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/svelte-kit-ts/template/src/lib/Counter.test.ts +0 -0
  125. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/svelte-kit-ts/template/src/lib/assets/favicon.svg +0 -0
  126. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/svelte-kit-ts/template/src/lib/index.ts +0 -0
  127. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/svelte-kit-ts/template/src/routes/+layout.svelte +0 -0
  128. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/svelte-kit-ts/template/src/routes/+layout.ts +0 -0
  129. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/svelte-kit-ts/template/src/routes/+page.svelte +0 -0
  130. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/svelte-kit-ts/template/src/vitest-setup.ts +0 -0
  131. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/svelte-kit-ts/template/static/robots.txt +0 -0
  132. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/svelte-kit-ts/template/tsconfig.json +0 -0
  133. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/data/stacks/svelte-kit-ts/template/vite.config.ts +0 -0
  134. {newstack-0.2.0 → newstack-0.3.0}/src/newstack/verify.py +0 -0
  135. {newstack-0.2.0 → newstack-0.3.0}/tests/test_cli.py +0 -0
  136. {newstack-0.2.0 → newstack-0.3.0}/tests/test_docs.py +0 -0
  137. {newstack-0.2.0 → newstack-0.3.0}/tests/test_registry.py +0 -0
  138. {newstack-0.2.0 → newstack-0.3.0}/tests/test_render.py +0 -0
  139. {newstack-0.2.0 → newstack-0.3.0}/tests/test_stacks.py +0 -0
@@ -6,6 +6,28 @@ All notable changes to this project are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.0] - 2026-11-02
10
+
11
+ ### Added
12
+
13
+ - **`newstack adopt <dir>`** — retrofit a stack's infrastructure onto a project that already
14
+ has code. Additive by construction: an existing file is never opened for writing, and any
15
+ change that would need a merge inside a file is reported as a manual step instead.
16
+ - Auto-detects the stack from the project's own markers (`go.mod`, `Cargo.toml`,
17
+ `manage.py`, `pyproject.toml`/`package.json` contents, a bare `index.html`); ambiguous
18
+ matches print candidates and exit 2; `--stack` overrides.
19
+ - Copies infrastructure only — workflows, `.gitignore`, lint/type config — never a
20
+ README, LICENSE, `pyproject.toml`, `package.json`, `go.mod` or source file.
21
+ - `--dry-run` reports without writing; re-running is a no-op; `--json` for scripting.
22
+ - `--with-tests` (experimental) also copies the stack's starter tests.
23
+ - Manifest keys `detect`, `detect_fallback`, `adopt` and `adopt_tests`, validated by the
24
+ registry and by tests that require every adoptable path to exist and to be infrastructure.
25
+
26
+ ### Changed
27
+
28
+ - `newstack new` and `adopt` share one step formatter, so an unknown `{placeholder}` in a
29
+ manifest's `next_steps` prints literally instead of raising `KeyError`.
30
+
9
31
  ## [0.2.0] - 2026-11-02
10
32
 
11
33
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: newstack
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Scaffold a new project on any popular stack — every generated starter is proven green in CI.
5
5
  Project-URL: Homepage, https://github.com/DawnofGenX/newstack
6
6
  Project-URL: Repository, https://github.com/DawnofGenX/newstack
@@ -83,6 +83,7 @@ newstack new react-vite-ts ~/code/app
83
83
  newstack new fastapi ~/code/api -d "Billing service" --author "Your Name"
84
84
  newstack new python-lib ~/code/lib --dry-run # preview, write nothing
85
85
  newstack new python-cli ~/code/tool --github # create the repo and push it too
86
+ newstack adopt ~/code/old-project --dry-run # retrofit CI/lint onto existing code
86
87
  newstack check --all # prove every stack on this machine
87
88
  newstack doctor # which toolchains do I have?
88
89
  ```
@@ -111,6 +112,35 @@ directory to CI-ready project.
111
112
  Each generated project ships its own `.github/workflows/` that is ready to run, plus a
112
113
  README, an MIT licence and a `.gitignore`.
113
114
 
115
+ ## Adopting an existing project
116
+
117
+ Most projects do not start from a scaffold. `adopt` retrofits a stack's *infrastructure*
118
+ onto a directory that already has code — and it is additive by construction: **an existing
119
+ file is never opened for writing.**
120
+
121
+ ```console
122
+ $ newstack adopt ~/sites/analytics-dashboard
123
+ newstack 0.3.0 · adopt → /home/you/sites/analytics-dashboard
124
+
125
+ stack static-site (auto-detected)
126
+ added 2 files
127
+ + .github/workflows/deploy-pages.yml
128
+ + .gitignore
129
+ tests not copied — rerun with --with-tests if you want starter tests
130
+ ```
131
+
132
+ - **Detection** reads the project's own markers (`go.mod`, `Cargo.toml`, `manage.py`, the
133
+ dependencies in `pyproject.toml` / `package.json`, a bare `index.html`). If two stacks
134
+ match, it prints the candidates and exits 2 rather than guessing; `--stack` overrides.
135
+ - **Scope**: workflows, `.gitignore`, and per-stack lint/type config only. It will not copy
136
+ a README, a LICENSE, a `pyproject.toml`, or source files — those carry your project's
137
+ identity, not infrastructure.
138
+ - **Merges are reported, not attempted.** If your `pyproject.toml` needs the stack's
139
+ `[tool.ruff]` block, adopt says so and leaves the file alone.
140
+ - `--dry-run` prints the same report without writing. Re-running is a no-op.
141
+ - `--with-tests` additionally copies the stack's *starter* tests — experimental, because
142
+ those tests import the sample module and usually need adapting to your code.
143
+
114
144
  ## The green guarantee
115
145
 
116
146
  A template is only as good as its last successful run, so `newstack` treats
@@ -154,7 +184,10 @@ without maintaining one template per stack.
154
184
 
155
185
  ## Adding a stack
156
186
 
157
- A stack is a directory with a manifest and a template tree:
187
+ A stack is a directory with a manifest and a template tree. The manifest carries four
188
+ optional keys beyond the basics — `detect` (how a directory is recognised as this stack),
189
+ `detect_fallback` (a generic stack that must lose to a specific match), and the `adopt` /
190
+ `adopt_tests` allow-lists used by `newstack adopt`:
158
191
 
159
192
  ```text
160
193
  src/newstack/data/stacks/my-stack/
@@ -176,10 +209,16 @@ src/newstack/data/stacks/my-stack/
176
209
  "next_steps": ["cd {path}", "uv run pytest"],
177
210
  "verify": [
178
211
  {"name": "test", "cmd": "uv run pytest", "tools": ["uv"], "timeout": 600}
179
- ]
212
+ ],
213
+ "detect": [{"file": "pyproject.toml", "contains": "my-framework"}],
214
+ "adopt": [".github/workflows/ci.yml", ".gitignore"],
215
+ "adopt_tests": ["tests/test_smoke.py"]
180
216
  }
181
217
  ```
182
218
 
219
+ `adopt` entries must exist in the template and must never be an identity file
220
+ (`README.md`, `LICENSE`, `pyproject.toml`, `package.json`, `go.mod`, `Cargo.toml`) — a
221
+ test enforces both rules.<br>
183
222
  `{{!` escapes a literal `{{`, and `${{ ... }}` (GitHub Actions) is left untouched.
184
223
  Point `NEWSTACK_STACKS` at a directory of your own stacks to use private ones — same
185
224
  manifest format, no fork required.
@@ -59,6 +59,7 @@ newstack new react-vite-ts ~/code/app
59
59
  newstack new fastapi ~/code/api -d "Billing service" --author "Your Name"
60
60
  newstack new python-lib ~/code/lib --dry-run # preview, write nothing
61
61
  newstack new python-cli ~/code/tool --github # create the repo and push it too
62
+ newstack adopt ~/code/old-project --dry-run # retrofit CI/lint onto existing code
62
63
  newstack check --all # prove every stack on this machine
63
64
  newstack doctor # which toolchains do I have?
64
65
  ```
@@ -87,6 +88,35 @@ directory to CI-ready project.
87
88
  Each generated project ships its own `.github/workflows/` that is ready to run, plus a
88
89
  README, an MIT licence and a `.gitignore`.
89
90
 
91
+ ## Adopting an existing project
92
+
93
+ Most projects do not start from a scaffold. `adopt` retrofits a stack's *infrastructure*
94
+ onto a directory that already has code — and it is additive by construction: **an existing
95
+ file is never opened for writing.**
96
+
97
+ ```console
98
+ $ newstack adopt ~/sites/analytics-dashboard
99
+ newstack 0.3.0 · adopt → /home/you/sites/analytics-dashboard
100
+
101
+ stack static-site (auto-detected)
102
+ added 2 files
103
+ + .github/workflows/deploy-pages.yml
104
+ + .gitignore
105
+ tests not copied — rerun with --with-tests if you want starter tests
106
+ ```
107
+
108
+ - **Detection** reads the project's own markers (`go.mod`, `Cargo.toml`, `manage.py`, the
109
+ dependencies in `pyproject.toml` / `package.json`, a bare `index.html`). If two stacks
110
+ match, it prints the candidates and exits 2 rather than guessing; `--stack` overrides.
111
+ - **Scope**: workflows, `.gitignore`, and per-stack lint/type config only. It will not copy
112
+ a README, a LICENSE, a `pyproject.toml`, or source files — those carry your project's
113
+ identity, not infrastructure.
114
+ - **Merges are reported, not attempted.** If your `pyproject.toml` needs the stack's
115
+ `[tool.ruff]` block, adopt says so and leaves the file alone.
116
+ - `--dry-run` prints the same report without writing. Re-running is a no-op.
117
+ - `--with-tests` additionally copies the stack's *starter* tests — experimental, because
118
+ those tests import the sample module and usually need adapting to your code.
119
+
90
120
  ## The green guarantee
91
121
 
92
122
  A template is only as good as its last successful run, so `newstack` treats
@@ -130,7 +160,10 @@ without maintaining one template per stack.
130
160
 
131
161
  ## Adding a stack
132
162
 
133
- A stack is a directory with a manifest and a template tree:
163
+ A stack is a directory with a manifest and a template tree. The manifest carries four
164
+ optional keys beyond the basics — `detect` (how a directory is recognised as this stack),
165
+ `detect_fallback` (a generic stack that must lose to a specific match), and the `adopt` /
166
+ `adopt_tests` allow-lists used by `newstack adopt`:
134
167
 
135
168
  ```text
136
169
  src/newstack/data/stacks/my-stack/
@@ -152,10 +185,16 @@ src/newstack/data/stacks/my-stack/
152
185
  "next_steps": ["cd {path}", "uv run pytest"],
153
186
  "verify": [
154
187
  {"name": "test", "cmd": "uv run pytest", "tools": ["uv"], "timeout": 600}
155
- ]
188
+ ],
189
+ "detect": [{"file": "pyproject.toml", "contains": "my-framework"}],
190
+ "adopt": [".github/workflows/ci.yml", ".gitignore"],
191
+ "adopt_tests": ["tests/test_smoke.py"]
156
192
  }
157
193
  ```
158
194
 
195
+ `adopt` entries must exist in the template and must never be an identity file
196
+ (`README.md`, `LICENSE`, `pyproject.toml`, `package.json`, `go.mod`, `Cargo.toml`) — a
197
+ test enforces both rules.<br>
159
198
  `{{!` escapes a literal `{{`, and `${{ ... }}` (GitHub Actions) is left untouched.
160
199
  Point `NEWSTACK_STACKS` at a directory of your own stacks to use private ones — same
161
200
  manifest format, no fork required.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "newstack"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Scaffold a new project on any popular stack — every generated starter is proven green in CI."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,4 +1,4 @@
1
1
  """newstack — scaffold a new project on any popular stack, proven green in CI."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
4
4
  __all__ = ["__version__"]
@@ -0,0 +1,160 @@
1
+ """Adopt an existing project: copy a stack's infrastructure, additively.
2
+
3
+ The rule that makes this safe: an existing file is never opened for writing. Anything
4
+ already present is reported as "kept", and anything that would need a merge inside a
5
+ file (a ruff block in someone's pyproject, a test script in their package.json) is
6
+ reported as a manual step instead of being attempted.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+
14
+ from .registry import Stack, load_stacks
15
+ from .render import TemplateError, render_tree
16
+
17
+ # Files adopt must never copy into someone else's project: they carry identity
18
+ # (licence, name, entrypoints) rather than infrastructure.
19
+ FORBIDDEN = frozenset(
20
+ {
21
+ "README.md",
22
+ "README.rst",
23
+ "LICENSE",
24
+ "LICENSE.md",
25
+ "pyproject.toml",
26
+ "package.json",
27
+ "go.mod",
28
+ "Cargo.toml",
29
+ }
30
+ )
31
+
32
+
33
+ @dataclass
34
+ class PendingFile:
35
+ rel: str
36
+ payload: bytes
37
+ executable: bool = False
38
+
39
+
40
+ @dataclass
41
+ class AdoptionPlan:
42
+ stack: Stack
43
+ root: Path
44
+ detected: bool = True
45
+ add: list[PendingFile] = field(default_factory=list)
46
+ keep: list[str] = field(default_factory=list)
47
+ manual: list[str] = field(default_factory=list)
48
+ tests_available: bool = False
49
+
50
+ @property
51
+ def added(self) -> list[str]:
52
+ return [item.rel for item in self.add]
53
+
54
+ def as_dict(self) -> dict:
55
+ return {
56
+ "stack": self.stack.name,
57
+ "root": str(self.root),
58
+ "detected": self.detected,
59
+ "added": self.added,
60
+ "kept": self.keep,
61
+ "manual": self.manual,
62
+ "tests_available": self.tests_available,
63
+ }
64
+
65
+
66
+ def detect_stacks(root: Path) -> list[Stack]:
67
+ """Stacks whose markers match *root*; generic fallbacks are dropped on a specific match."""
68
+ stacks = load_stacks()
69
+ matched = [stack for stack in stacks.values() if stack.detect and stack.matches(root)]
70
+ specific = [stack for stack in matched if not stack.detect_fallback]
71
+ fallbacks = [stack for stack in matched if stack.detect_fallback]
72
+ return specific or fallbacks
73
+
74
+
75
+ def _manual_steps(stack: Stack, root: Path) -> list[str]:
76
+ steps: list[str] = []
77
+ if stack.language == "python":
78
+ target = root / "pyproject.toml"
79
+ if not target.is_file():
80
+ steps.append("no pyproject.toml found: `newstack new` is a better fit than adopt")
81
+ else:
82
+ steps.append(
83
+ "merge the stack's [tool.ruff] / [tool.pytest.ini_options] blocks into "
84
+ "pyproject.toml by hand (adopt never edits existing files)"
85
+ )
86
+ elif stack.language in {"node", "html"}:
87
+ target = root / "package.json"
88
+ if target.is_file():
89
+ steps.append(
90
+ "add the stack's lint/test scripts to package.json by hand "
91
+ "(adopt never edits existing files)"
92
+ )
93
+ elif stack.language == "go":
94
+ if not (root / ".gitignore").exists():
95
+ steps.append("consider adding bin/ to your .gitignore for build output")
96
+ elif stack.language == "rust":
97
+ steps.append("Cargo.lock should be committed for binaries; .gitignore keeps /target out")
98
+ return steps
99
+
100
+
101
+ def plan_adoption(
102
+ stack: Stack,
103
+ root: Path,
104
+ context: dict,
105
+ *,
106
+ with_tests: bool = False,
107
+ detected: bool = True,
108
+ ) -> AdoptionPlan:
109
+ """Work out what adopt would do, without touching the filesystem."""
110
+ if not stack.adopt:
111
+ raise TemplateError(f"stack '{stack.name}' declares no adoptable files")
112
+
113
+ wanted = list(stack.adopt)
114
+ if with_tests:
115
+ wanted.extend(stack.adopt_tests)
116
+
117
+ forbidden = sorted(set(wanted) & FORBIDDEN)
118
+ if forbidden:
119
+ raise TemplateError(
120
+ f"stack '{stack.name}' tries to adopt files that carry project identity: "
121
+ f"{', '.join(forbidden)}"
122
+ )
123
+
124
+ entries, _dirs = render_tree(stack.template_dir, context)
125
+ by_path = {rel: (payload, executable) for rel, payload, executable in entries}
126
+
127
+ missing = [rel for rel in wanted if rel not in by_path]
128
+ if missing:
129
+ raise TemplateError(
130
+ f"stack '{stack.name}' lists adoptable files that are not in its template: "
131
+ f"{', '.join(missing)}"
132
+ )
133
+
134
+ plan = AdoptionPlan(stack=stack, root=root, detected=detected)
135
+ plan.tests_available = bool(stack.adopt_tests) and with_tests
136
+ for rel in wanted:
137
+ if (root / rel).exists():
138
+ plan.keep.append(rel)
139
+ continue
140
+ payload, executable = by_path[rel]
141
+ plan.add.append(PendingFile(rel=rel, payload=payload, executable=executable))
142
+
143
+ plan.manual = _manual_steps(stack, root)
144
+ return plan
145
+
146
+
147
+ def apply_plan(plan: AdoptionPlan) -> list[str]:
148
+ """Write the planned files. Only touches paths that did not exist when planned."""
149
+ written: list[str] = []
150
+ for item in plan.add:
151
+ target = plan.root / item.rel
152
+ if target.exists(): # someone else created it since planning: leave it alone
153
+ plan.keep.append(item.rel)
154
+ continue
155
+ target.parent.mkdir(parents=True, exist_ok=True)
156
+ target.write_bytes(item.payload)
157
+ if item.executable:
158
+ target.chmod(0o755)
159
+ written.append(item.rel)
160
+ return written
@@ -12,6 +12,7 @@ from datetime import datetime, timezone
12
12
  from pathlib import Path
13
13
 
14
14
  from . import __version__
15
+ from .adopt import apply_plan, detect_stacks, plan_adoption
15
16
  from .registry import RegistryError, Stack, get_stack, load_stacks
16
17
  from .render import TemplateError, generate
17
18
  from .verify import check_stack, missing_tools
@@ -72,6 +73,24 @@ def _git_config(key: str) -> str:
72
73
  return value
73
74
 
74
75
 
76
+ class _SafeDict(dict):
77
+ """format_map target that leaves unknown keys visible instead of raising."""
78
+
79
+ def __missing__(self, key: str) -> str:
80
+ return "{" + key + "}"
81
+
82
+
83
+ def _format_step(step: str, context: dict, path: Path) -> str:
84
+ return step.format_map(
85
+ _SafeDict(
86
+ path=path,
87
+ project_name=context["project_name"],
88
+ package_name=context["package_name"],
89
+ npm_name=context["npm_name"],
90
+ )
91
+ )
92
+
93
+
75
94
  def build_context(stack: Stack, destination: Path, args: argparse.Namespace) -> dict:
76
95
  project_name = args.name or destination.name
77
96
  author = args.author or _git_config("user.name") or "Your Name"
@@ -200,18 +219,77 @@ def _print_new_report(
200
219
  print(f" {'github':<13} {report['github']}")
201
220
 
202
221
  if stack.next_steps:
203
- steps = [
204
- step.format(
205
- path=destination,
206
- package_name=context["package_name"],
207
- npm_name=context["npm_name"],
208
- project_name=context["project_name"],
222
+ print("\n next:")
223
+ for step in stack.next_steps:
224
+ print(f" {_format_step(step, context, destination)}")
225
+ print()
226
+
227
+
228
+ def cmd_adopt(args: argparse.Namespace) -> int:
229
+ root = Path(args.path).expanduser().resolve()
230
+ if not root.is_dir():
231
+ raise TemplateError(f"not a directory: {root}")
232
+
233
+ if args.stack:
234
+ stack = get_stack(args.stack)
235
+ detected = False
236
+ else:
237
+ candidates = detect_stacks(root)
238
+ if not candidates:
239
+ raise TemplateError(
240
+ "could not recognise this project's stack — pass --stack "
241
+ "(see `newstack list`)"
209
242
  )
210
- for step in stack.next_steps
211
- ]
243
+ if len(candidates) > 1:
244
+ names = ", ".join(candidate.name for candidate in candidates)
245
+ print(f"newstack: this directory matches more than one stack: {names}", file=sys.stderr)
246
+ print("newstack: pass --stack <name> to choose one", file=sys.stderr)
247
+ return 2
248
+ stack = candidates[0]
249
+ detected = True
250
+
251
+ context = build_context(stack, root, args)
252
+ plan = plan_adoption(
253
+ stack, root, context, with_tests=args.with_tests, detected=detected
254
+ )
255
+
256
+ if not args.dry_run:
257
+ apply_plan(plan)
258
+
259
+ if args.json:
260
+ payload = plan.as_dict()
261
+ payload["dry_run"] = args.dry_run
262
+ print(json.dumps(payload, indent=2))
263
+ return 0
264
+
265
+ _print_adopt_report(plan, context, dry_run=args.dry_run)
266
+ return 0
267
+
268
+
269
+ def _print_adopt_report(plan, context: dict, *, dry_run: bool) -> None:
270
+ print(f"\nnewstack {__version__} · adopt → {plan.root}\n")
271
+ how = "auto-detected" if plan.detected else "--stack"
272
+ print(f" {'stack':<12} {plan.stack.name} ({how})")
273
+ verb = "would add" if dry_run else "added"
274
+ print(f" {verb:<12} {len(plan.add)} files")
275
+ for item in plan.add:
276
+ print(f" + {item.rel}")
277
+ if plan.keep:
278
+ print(f" {'kept':<12} {len(plan.keep)} existing files, left untouched")
279
+ for rel in plan.keep:
280
+ print(f" = {rel}")
281
+ if plan.manual:
282
+ print(f" {'manual':<12} {plan.manual[0]}")
283
+ for extra in plan.manual[1:]:
284
+ print(f" {'':<12} {extra}")
285
+ if not plan.add:
286
+ print(f" {'nothing':<12} the infrastructure files are already present")
287
+ if not plan.tests_available and plan.stack.adopt_tests:
288
+ print(f" {'tests':<12} not copied — rerun with --with-tests if you want starter tests")
289
+ if plan.stack.next_steps:
212
290
  print("\n next:")
213
- for step in steps:
214
- print(f" {step}")
291
+ print(f" review the additions: git -C {plan.root} status --short")
292
+ print(" checks run on push: the workflow above is the only thing that changed")
215
293
  print()
216
294
 
217
295
 
@@ -349,6 +427,20 @@ def build_parser() -> argparse.ArgumentParser:
349
427
  new.add_argument("--json", action="store_true", help="machine readable output")
350
428
  new.set_defaults(handler=cmd_new)
351
429
 
430
+ adopt = sub.add_parser(
431
+ "adopt", help="retrofit a stack's CI/lint files onto an existing project"
432
+ )
433
+ adopt.add_argument("path", help="directory of the existing project")
434
+ adopt.add_argument("--stack", help="force a stack instead of auto-detecting")
435
+ adopt.add_argument(
436
+ "--with-tests", action="store_true", help="also copy the stack's starter tests"
437
+ )
438
+ adopt.add_argument("--dry-run", action="store_true", help="report without writing anything")
439
+ adopt.add_argument("--author", help="author name for rendered files")
440
+ adopt.add_argument("--email", help="author email for rendered files")
441
+ adopt.add_argument("--json", action="store_true", help="machine readable output")
442
+ adopt.set_defaults(handler=cmd_adopt, name=None, description=None)
443
+
352
444
  lst = sub.add_parser("list", help="list available stacks")
353
445
  lst.add_argument("--json", action="store_true")
354
446
  lst.set_defaults(handler=cmd_list)
@@ -55,5 +55,23 @@
55
55
  ],
56
56
  "timeout": 600
57
57
  }
58
+ ],
59
+ "detect": [
60
+ {
61
+ "file": "manage.py"
62
+ },
63
+ {
64
+ "file": "pyproject.toml",
65
+ "contains": "django"
66
+ }
67
+ ],
68
+ "detect_fallback": false,
69
+ "adopt": [
70
+ ".github/workflows/ci.yml",
71
+ ".gitignore"
72
+ ],
73
+ "adopt_tests": [
74
+ "tests/test_views.py",
75
+ "tests/test_models.py"
58
76
  ]
59
77
  }
@@ -38,5 +38,20 @@
38
38
  ],
39
39
  "timeout": 300
40
40
  }
41
+ ],
42
+ "detect": [
43
+ {
44
+ "file": "pyproject.toml",
45
+ "contains": "fastapi"
46
+ }
47
+ ],
48
+ "detect_fallback": false,
49
+ "adopt": [
50
+ ".github/workflows/ci.yml",
51
+ ".gitignore",
52
+ "Dockerfile"
53
+ ],
54
+ "adopt_tests": [
55
+ "tests/test_api.py"
41
56
  ]
42
57
  }
@@ -38,5 +38,18 @@
38
38
  ],
39
39
  "timeout": 600
40
40
  }
41
+ ],
42
+ "detect": [
43
+ {
44
+ "file": "go.mod"
45
+ }
46
+ ],
47
+ "detect_fallback": false,
48
+ "adopt": [
49
+ ".github/workflows/ci.yml",
50
+ ".gitignore"
51
+ ],
52
+ "adopt_tests": [
53
+ "internal/app/app_test.go"
41
54
  ]
42
55
  }
@@ -10,5 +10,20 @@
10
10
  {"name": "install", "cmd": "npm install --no-audit --no-fund --silent", "tools": ["npm"], "timeout": 900},
11
11
  {"name": "typecheck", "cmd": "npm run typecheck", "tools": ["npm"], "timeout": 300},
12
12
  {"name": "test", "cmd": "npm test", "tools": ["npm"], "timeout": 300}
13
+ ],
14
+ "detect": [
15
+ {
16
+ "file": "package.json",
17
+ "contains": "\"express\""
18
+ }
19
+ ],
20
+ "detect_fallback": false,
21
+ "adopt": [
22
+ ".github/workflows/ci.yml",
23
+ ".gitignore",
24
+ "tsconfig.json"
25
+ ],
26
+ "adopt_tests": [
27
+ "tests/app.test.ts"
13
28
  ]
14
29
  }
@@ -46,5 +46,22 @@
46
46
  ],
47
47
  "timeout": 120
48
48
  }
49
+ ],
50
+ "detect": [
51
+ {
52
+ "file": "pyproject.toml",
53
+ "contains": "[project.scripts]"
54
+ },
55
+ {
56
+ "file": "src"
57
+ }
58
+ ],
59
+ "detect_fallback": false,
60
+ "adopt": [
61
+ ".github/workflows/ci.yml",
62
+ ".gitignore"
63
+ ],
64
+ "adopt_tests": [
65
+ "tests/test_cli.py"
49
66
  ]
50
67
  }
@@ -38,5 +38,18 @@
38
38
  ],
39
39
  "timeout": 300
40
40
  }
41
+ ],
42
+ "detect": [
43
+ {
44
+ "file": "pyproject.toml"
45
+ }
46
+ ],
47
+ "detect_fallback": true,
48
+ "adopt": [
49
+ ".github/workflows/ci.yml",
50
+ ".gitignore"
51
+ ],
52
+ "adopt_tests": [
53
+ "tests/test_core.py"
41
54
  ]
42
55
  }
@@ -11,5 +11,21 @@
11
11
  {"name": "typecheck", "cmd": "npm run typecheck", "tools": ["npm"], "timeout": 300},
12
12
  {"name": "test", "cmd": "npm test", "tools": ["npm"], "timeout": 300},
13
13
  {"name": "build", "cmd": "npm run build", "tools": ["npm"], "timeout": 300}
14
+ ],
15
+ "detect": [
16
+ {
17
+ "file": "package.json",
18
+ "contains": "\"react\""
19
+ }
20
+ ],
21
+ "detect_fallback": false,
22
+ "adopt": [
23
+ ".github/workflows/ci.yml",
24
+ ".gitignore",
25
+ "tsconfig.json",
26
+ "vite.config.ts"
27
+ ],
28
+ "adopt_tests": [
29
+ "src/App.test.tsx"
14
30
  ]
15
31
  }
@@ -46,5 +46,18 @@
46
46
  ],
47
47
  "timeout": 900
48
48
  }
49
+ ],
50
+ "detect": [
51
+ {
52
+ "file": "Cargo.toml"
53
+ }
54
+ ],
55
+ "detect_fallback": false,
56
+ "adopt": [
57
+ ".github/workflows/ci.yml",
58
+ ".gitignore"
59
+ ],
60
+ "adopt_tests": [
61
+ "tests/cli.rs"
49
62
  ]
50
63
  }