@horka/app-forge 0.1.0
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.
- package/LICENSE +32 -0
- package/README.md +99 -0
- package/bin/cli.js +371 -0
- package/bin/cli.test.js +91 -0
- package/package.json +43 -0
- package/templates/core/CLAUDE.md +36 -0
- package/templates/core/claude/memory/ARCHITECTURE.md +20 -0
- package/templates/core/claude/memory/COMMANDS.md +13 -0
- package/templates/core/claude/memory/DECISIONS.md +5 -0
- package/templates/core/claude/memory/NEXT_STEPS.md +11 -0
- package/templates/core/claude/memory/PROJECT_STATE.md +24 -0
- package/templates/core/claude/skills/kickoff/SKILL.md +84 -0
- package/templates/core/claude/skills/product-owner/SKILL.md +58 -0
- package/templates/core/claude/skills/restore-context/SKILL.md +29 -0
- package/templates/core/claude/skills/save-context/SKILL.md +35 -0
- package/templates/core/docs-architecture/ANTI_PATTERNS.md +180 -0
- package/templates/core/docs-architecture/ARCHITECTURE_PRINCIPLES.md +134 -0
- package/templates/core/docs-architecture/DELIVERY.md +68 -0
- package/templates/core/docs-architecture/DOCS_PLACEMENT.md +151 -0
- package/templates/core/docs-architecture/MULTI_REPO_CONTRACT.md +158 -0
- package/templates/core/docs-architecture/SDK_CONTRACT.md +214 -0
- package/templates/core/docs-architecture/SECURITY_USER_URLS.md +152 -0
- package/templates/core/gitignore +15 -0
- package/templates/core/mcp.json +8 -0
- package/templates/packs/nuxt-web/CLAUDE.md +74 -0
- package/templates/packs/nuxt-web/app/app.vue +5 -0
- package/templates/packs/nuxt-web/app/assets/css/main.css +18 -0
- package/templates/packs/nuxt-web/app/assets/css/tokens.css +41 -0
- package/templates/packs/nuxt-web/app/designSystem/DSButton/components/DSButton.vue +70 -0
- package/templates/packs/nuxt-web/app/designSystem/DSButton/index.ts +4 -0
- package/templates/packs/nuxt-web/app/designSystem/DSButton/tests/DSButton.spec.ts +34 -0
- package/templates/packs/nuxt-web/app/designSystem/DSButton/types/dsButton.ts +5 -0
- package/templates/packs/nuxt-web/app/domain/.gitkeep +0 -0
- package/templates/packs/nuxt-web/app/features/.gitkeep +0 -0
- package/templates/packs/nuxt-web/app/pages/index.vue +36 -0
- package/templates/packs/nuxt-web/app/utils/.gitkeep +0 -0
- package/templates/packs/nuxt-web/claude/memory/COMMANDS.md +21 -0
- package/templates/packs/nuxt-web/docs-architecture/ARCHITECTURE.md +169 -0
- package/templates/packs/nuxt-web/docs-architecture/CONVENTIONS.md +140 -0
- package/templates/packs/nuxt-web/docs-architecture/I18N.md +102 -0
- package/templates/packs/nuxt-web/docs-architecture/OPS_WEB.md +176 -0
- package/templates/packs/nuxt-web/docs-architecture/SEO_AND_ROUTING.md +118 -0
- package/templates/packs/nuxt-web/gitignore +18 -0
- package/templates/packs/nuxt-web/nuxt.config.ts +49 -0
- package/templates/packs/nuxt-web/pack.json +11 -0
- package/templates/packs/nuxt-web/package.json +31 -0
- package/templates/packs/nuxt-web/playwright.config.ts +39 -0
- package/templates/packs/nuxt-web/server/api/health.get.ts +7 -0
- package/templates/packs/nuxt-web/tests/e2e/home.spec.ts +19 -0
- package/templates/packs/nuxt-web/tsconfig.json +4 -0
- package/templates/packs/nuxt-web/vitest.config.ts +23 -0
- package/templates/packs/swift-ios/CLAUDE.md +64 -0
- package/templates/packs/swift-ios/Packages/DataLayer/Package.swift +21 -0
- package/templates/packs/swift-ios/Packages/DataLayer/Sources/DataLayer/DataLayer.swift +11 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Package.swift +20 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Domain/SampleItem.swift +15 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Engine/SampleEngine.swift +14 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Repository/SampleItemRepository.swift +27 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Tests/{{PROJECT_NAME}}CoreTests/SampleEngineTests.swift +32 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Package.swift +17 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/Color+DS.swift +18 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/Components/DSCard.swift +22 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/DS.swift +36 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/DSFont.swift +26 -0
- package/templates/packs/swift-ios/claude/memory/COMMANDS.md +18 -0
- package/templates/packs/swift-ios/docs-architecture/ARCHITECTURE.md +246 -0
- package/templates/packs/swift-ios/docs-architecture/CLOUDKIT_GUIDE.md +224 -0
- package/templates/packs/swift-ios/docs-architecture/CONVENTIONS.md +246 -0
- package/templates/packs/swift-ios/docs-architecture/DESIGN_SYSTEM.md +272 -0
- package/templates/packs/swift-ios/docs-architecture/NAVIGATION.md +241 -0
- package/templates/packs/swift-ios/docs-architecture/TESTING.md +176 -0
- package/templates/packs/swift-ios/docs-architecture/WORKFLOW.md +165 -0
- package/templates/packs/swift-ios/github/workflows/ci.yml +48 -0
- package/templates/packs/swift-ios/gitignore +5 -0
- package/templates/packs/swift-ios/mcp.json +8 -0
- package/templates/packs/swift-ios/pack.json +11 -0
- package/templates/packs/swift-ios/project.yml +33 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/App/App.swift +32 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/App/AppNamespace.swift +4 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/Module/.gitkeep +0 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/Store/.gitkeep +0 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/Tools/.gitkeep +0 -0
- package/templates/packs/ts-sdk/CHANGELOG.md +9 -0
- package/templates/packs/ts-sdk/CLAUDE.md +72 -0
- package/templates/packs/ts-sdk/MIGRATION.md +28 -0
- package/templates/packs/ts-sdk/claude/memory/COMMANDS.md +21 -0
- package/templates/packs/ts-sdk/docs-architecture/ARCHITECTURE.md +132 -0
- package/templates/packs/ts-sdk/docs-architecture/CONVENTIONS_TS.md +152 -0
- package/templates/packs/ts-sdk/gitignore +6 -0
- package/templates/packs/ts-sdk/pack.json +11 -0
- package/templates/packs/ts-sdk/package.json +55 -0
- package/templates/packs/ts-sdk/scripts/verify-dist.mjs +67 -0
- package/templates/packs/ts-sdk/src/clients/AuthClient.ts +168 -0
- package/templates/packs/ts-sdk/src/core/HttpClient.ts +85 -0
- package/templates/packs/ts-sdk/src/core/Logger.ts +27 -0
- package/templates/packs/ts-sdk/src/core/SDKContext.ts +40 -0
- package/templates/packs/ts-sdk/src/core/withTimeout.ts +19 -0
- package/templates/packs/ts-sdk/src/errors/ApiError.ts +93 -0
- package/templates/packs/ts-sdk/src/index.ts +62 -0
- package/templates/packs/ts-sdk/src/types/index.ts +33 -0
- package/templates/packs/ts-sdk/tests/apiError.test.ts +58 -0
- package/templates/packs/ts-sdk/tests/httpClient.test.ts +60 -0
- package/templates/packs/ts-sdk/tests/singleFlight.test.ts +191 -0
- package/templates/packs/ts-sdk/tsconfig.json +15 -0
- package/templates/packs/ts-sdk/tsup.config.ts +22 -0
- package/templates/packs/ts-sdk/vitest.config.ts +8 -0
- package/templates/packs/vapor-api/CLAUDE.md +73 -0
- package/templates/packs/vapor-api/Dockerfile +80 -0
- package/templates/packs/vapor-api/Package.swift +68 -0
- package/templates/packs/vapor-api/Sources/App/App.swift +5 -0
- package/templates/packs/vapor-api/Sources/App/Configure/AppConfig.swift +108 -0
- package/templates/packs/vapor-api/Sources/App/Configure/configure.swift +74 -0
- package/templates/packs/vapor-api/Sources/App/Configure/entrypoint.swift +47 -0
- package/templates/packs/vapor-api/Sources/App/Configure/routes.swift +21 -0
- package/templates/packs/vapor-api/Sources/App/Error/Failed.swift +73 -0
- package/templates/packs/vapor-api/Sources/App/Error/FailedMiddleware.swift +56 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/AppItem.swift +38 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Controllers/ItemControllersCrud.swift +41 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/DTO/ItemDTO.swift +22 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Entities/ItemEntity.swift +30 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Migrations/ItemMigrationCreate.swift +25 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Repositories/ItemRepository.swift +32 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Services/ItemService.swift +57 -0
- package/templates/packs/vapor-api/Sources/App/Registry/ControllersRegister.swift +17 -0
- package/templates/packs/vapor-api/Sources/App/Registry/MiddlewaresRegister.swift +15 -0
- package/templates/packs/vapor-api/Sources/App/Registry/MigrationsRegister.swift +18 -0
- package/templates/packs/vapor-api/Sources/Monitoring/Logging/JSONLogHandler.swift +59 -0
- package/templates/packs/vapor-api/Sources/Monitoring/Middleware/HTTPLoggingMiddleware.swift +50 -0
- package/templates/packs/vapor-api/Sources/Monitoring/Monitoring.swift +110 -0
- package/templates/packs/vapor-api/Sources/{{PROJECT_NAME}}Foundation/String+Trimmed.swift +15 -0
- package/templates/packs/vapor-api/Tests/AppTests/AppTests.swift +155 -0
- package/templates/packs/vapor-api/claude/memory/COMMANDS.md +30 -0
- package/templates/packs/vapor-api/docs-architecture/ARCHITECTURE.md +144 -0
- package/templates/packs/vapor-api/docs-architecture/CONVENTIONS.md +121 -0
- package/templates/packs/vapor-api/docs-architecture/GOTCHAS_LINUX_SWIFT.md +109 -0
- package/templates/packs/vapor-api/docs-architecture/OPS.md +102 -0
- package/templates/packs/vapor-api/env_dist +29 -0
- package/templates/packs/vapor-api/gitignore +7 -0
- package/templates/packs/vapor-api/pack.json +11 -0
- package/templates/packs/vapor-api/scripts/generate-error-codes.sh +73 -0
- package/templates/packs/vapor-api/scripts/validate-env-vars.sh +72 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Joey Barbier
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
TEMPLATE OUTPUT EXCEPTION
|
|
26
|
+
|
|
27
|
+
The contents of the templates/ directory, when rendered into a project by the
|
|
28
|
+
app-forge CLI (`init` or `update`), are provided without any license obligation:
|
|
29
|
+
generated projects belong entirely to their authors, who may use, modify,
|
|
30
|
+
relicense and distribute them freely, with no attribution required.
|
|
31
|
+
This exception applies to generated output only; the app-forge tool and its
|
|
32
|
+
source repository remain under the MIT License above.
|
package/README.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# ⚒️ AppForge
|
|
2
|
+
|
|
3
|
+
**Turn Claude Code into an autonomous app factory — for any platform.**
|
|
4
|
+
|
|
5
|
+
One command scaffolds a project where Claude Code isn't an assistant, it's the **team
|
|
6
|
+
lead**: it interviews you, writes the PRD, plans vertical slices, then builds your app
|
|
7
|
+
autonomously — with tests, builds and on-screen proof at every step.
|
|
8
|
+
|
|
9
|
+
Everything in here was extracted from real production apps built ~100% with Claude Code,
|
|
10
|
+
including the bugs that cost days — written down so no one pays for them twice.
|
|
11
|
+
|
|
12
|
+
## Quick start
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npx @horka/app-forge init MyApp # questionnaire: platform, identifier
|
|
16
|
+
cd MyApp
|
|
17
|
+
claude
|
|
18
|
+
> /kickoff # describe your idea — Claude builds it
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## The Lego model
|
|
22
|
+
|
|
23
|
+
AppForge separates **what is universal** from **what is platform-specific**:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
┌─────────────────────────── UNIVERSAL CORE (always installed) ───────────────────────────┐
|
|
27
|
+
│ ARCHITECTURE_PRINCIPLES.md the 6-layer lego model (L0 Foundation → L5 Features) │
|
|
28
|
+
│ DELIVERY.md vertical slices, proof-over-claims, memory protocol │
|
|
29
|
+
│ skills/ /kickoff (team lead) · /product-owner (PRD) · │
|
|
30
|
+
│ /restore-context · /save-context │
|
|
31
|
+
│ .claude/memory/ persistent project memory (anti-hallucination) │
|
|
32
|
+
│ .mcp.json context7 (up-to-date docs for any library) │
|
|
33
|
+
└──────────────────────────────────────────────────────────────────────────────────────────┘
|
|
34
|
+
+
|
|
35
|
+
┌─────────────────────────── PLATFORM PACK (chosen at init) ──────────────────────────────┐
|
|
36
|
+
│ swift-ios ✅ Swift 6.2 · SwiftUI · CloudKit/CKShare war stories · design-system │
|
|
37
|
+
│ vapor-api ✅ Swift API: feature modules · typed errors · env discipline · Docker │
|
|
38
|
+
│ nuxt-web ✅ Nuxt 4: DS modules · i18n tooling · SEO/auth recipes · real tests │
|
|
39
|
+
│ ts-sdk ✅ typed SDK contract: single-flight auth · tagged releases · exports map │
|
|
40
|
+
│ kotlin-android 🔜 … │
|
|
41
|
+
└──────────────────────────────────────────────────────────────────────────────────────────┘
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Pick a platform we don't cover yet? AppForge asks for confirmation and installs the
|
|
45
|
+
universal core alone — Claude still gets the architecture, the delivery method, the
|
|
46
|
+
memory system and the PO flow, and establishes the stack's build loop itself.
|
|
47
|
+
|
|
48
|
+
## What `/kickoff` does
|
|
49
|
+
|
|
50
|
+
1. **Interview** — the `/product-owner` skill asks one focused round of questions.
|
|
51
|
+
2. **PRD** — a lean one-pager (domain glossary, epics/stories) you approve.
|
|
52
|
+
3. **Slice plan** — vertical, always-shippable slices you approve (last blocking step).
|
|
53
|
+
4. **Autonomous build** — per slice: Core + tests → DataLayer → UI bricks → screens →
|
|
54
|
+
full build → **eyes-on proof** (simulator screenshot / browser / curl) → memory update.
|
|
55
|
+
It only stops for genuine ambiguity, paid dependencies, or actions only you can do.
|
|
56
|
+
|
|
57
|
+
Works standalone; detects and uses [BMAD](https://github.com/bmad-code-org/BMAD-METHOD)
|
|
58
|
+
if installed.
|
|
59
|
+
|
|
60
|
+
## Why it works so well with AI agents
|
|
61
|
+
|
|
62
|
+
- **Fast ground truth** — every layer builds/tests alone in seconds, no full-app builds
|
|
63
|
+
to find a typo.
|
|
64
|
+
- **Grep-visible boundaries** — UI imports in the domain layer or hardcoded colors are
|
|
65
|
+
caught by one-line searches.
|
|
66
|
+
- **Gotchas are pre-paid** — the platform packs ship the production war stories
|
|
67
|
+
(CloudKit share acceptance landing on the scene delegate, empty-list record fields,
|
|
68
|
+
schema deploys before TestFlight…) as symptom → cause → fix.
|
|
69
|
+
- **Memory across sessions** — session #20 doesn't re-discover or contradict session #3.
|
|
70
|
+
|
|
71
|
+
## The swift-ios pack (first pack)
|
|
72
|
+
|
|
73
|
+
`docs-architecture/` — 7 dense guides extracted & verified from production code:
|
|
74
|
+
ARCHITECTURE (SPM layering) · CONVENTIONS (Swift 6.2 strict concurrency, VVM-I) ·
|
|
75
|
+
NAVIGATION (root gating, routers, scene-delegate traps) · CLOUDKIT_GUIDE (full CKShare
|
|
76
|
+
lifecycle + 13 production gotchas) · DESIGN_SYSTEM (token package) · TESTING
|
|
77
|
+
(Swift Testing, deterministic engines) · WORKFLOW (agent build loops).
|
|
78
|
+
|
|
79
|
+
Plus a skeleton that **builds and passes tests from minute zero**:
|
|
80
|
+
three SPM packages (`MyAppDS`, `MyAppCore`, `DataLayer`), an XcodeGen manifest, and a
|
|
81
|
+
running SwiftUI app shell. Requirements: Xcode 26+, `brew install xcodegen`.
|
|
82
|
+
|
|
83
|
+
## Adding a platform pack
|
|
84
|
+
|
|
85
|
+
A pack is just a folder in `templates/packs/<id>/` with a `pack.json` manifest plus the
|
|
86
|
+
bricks it contributes: docs, skeleton, MCP servers, memory overrides. PRs welcome —
|
|
87
|
+
extract YOUR production war stories into a pack.
|
|
88
|
+
|
|
89
|
+
## Philosophy
|
|
90
|
+
|
|
91
|
+
- **MVP first** — working vertical slices over perfect horizontal layers.
|
|
92
|
+
- **Proof over claims** — nothing is "done" without green tests, a green build, and a
|
|
93
|
+
proof someone actually looked at.
|
|
94
|
+
- **Knowledge compounds** — every gotcha written down: symptom → cause → fix.
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
MIT — with a **template output exception**: everything `app-forge` generates into your
|
|
99
|
+
project is 100% yours. No attribution, no obligations. (See LICENSE.)
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* app-forge — scaffold a Claude-Code-first project, any platform.
|
|
4
|
+
*
|
|
5
|
+
* npx @horka/app-forge init MyApp [--platform swift-ios] [--id com.me.myapp] [--yes]
|
|
6
|
+
*
|
|
7
|
+
* Zero dependencies. Assembles the UNIVERSAL CORE (architecture principles, delivery
|
|
8
|
+
* method, memory system, kickoff/product-owner skills, context7 MCP) + a PLATFORM PACK
|
|
9
|
+
* (language conventions, platform gotchas, buildable skeleton, platform MCPs).
|
|
10
|
+
* Unsupported platform → core only, after explicit confirmation.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require("fs");
|
|
14
|
+
const path = require("path");
|
|
15
|
+
const readline = require("readline");
|
|
16
|
+
const { execSync } = require("child_process");
|
|
17
|
+
|
|
18
|
+
const pkg = require("../package.json");
|
|
19
|
+
|
|
20
|
+
const TEMPLATES = path.join(__dirname, "..", "templates");
|
|
21
|
+
const CORE = path.join(TEMPLATES, "core");
|
|
22
|
+
const PACKS_DIR = path.join(TEMPLATES, "packs");
|
|
23
|
+
const MANIFEST = ".appforge.json";
|
|
24
|
+
|
|
25
|
+
// Entries renamed on copy (npm strips dotfiles from packages, so templates store them un-dotted).
|
|
26
|
+
const RENAMES = { claude: ".claude", "mcp.json": ".mcp.json", gitignore: ".gitignore", github: ".github" };
|
|
27
|
+
// Files merged (not overwritten) when both core and pack provide them.
|
|
28
|
+
const MERGED = new Set(["mcp.json", "gitignore"]);
|
|
29
|
+
|
|
30
|
+
function ask(question, fallback) {
|
|
31
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
32
|
+
return new Promise((resolve) =>
|
|
33
|
+
rl.question(`${question}${fallback ? ` (${fallback})` : ""}: `, (answer) => {
|
|
34
|
+
rl.close();
|
|
35
|
+
resolve(answer.trim() || fallback || "");
|
|
36
|
+
})
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The identifier is substituted RAW into JSON (package.json "name") and YAML
|
|
41
|
+
// (project.yml bundleIdPrefix). Without validation, `--id 'evil","x":"y'` injects
|
|
42
|
+
// JSON keys and `--id $'a\nKEY: v'` injects YAML keys. Allow only characters that
|
|
43
|
+
// cover both reverse-DNS (com.me.app) and npm scoped names (@org/pkg): letters,
|
|
44
|
+
// digits, dot, underscore, at, slash, hyphen. Everything else — quotes, braces,
|
|
45
|
+
// colons, whitespace, newlines, backslashes — is rejected.
|
|
46
|
+
function validateId(id) {
|
|
47
|
+
if (typeof id !== "string" || id.length === 0 || id.length > 100 || !/^[A-Za-z0-9._@/-]+$/.test(id)) {
|
|
48
|
+
console.error(`✗ "${id}" is not a valid identifier. Use only letters, digits and . _ @ / - (covers reverse-DNS like com.me.app and npm names like @org/pkg), max 100 chars. No quotes, braces, colons, spaces or newlines.`);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
return id;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function loadPacks() {
|
|
55
|
+
if (!fs.existsSync(PACKS_DIR)) return [];
|
|
56
|
+
return fs
|
|
57
|
+
.readdirSync(PACKS_DIR, { withFileTypes: true })
|
|
58
|
+
.filter((e) => e.isDirectory() && fs.existsSync(path.join(PACKS_DIR, e.name, "pack.json")))
|
|
59
|
+
.map((e) => ({ dir: path.join(PACKS_DIR, e.name), ...JSON.parse(fs.readFileSync(path.join(PACKS_DIR, e.name, "pack.json"), "utf8")) }));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function substitute(text, vars) {
|
|
63
|
+
return text
|
|
64
|
+
.replaceAll("{{PROJECT_NAME}}", vars.name)
|
|
65
|
+
.replaceAll("{{BUNDLE_ID}}", vars.bundleId)
|
|
66
|
+
.replaceAll("{{PACK_LABEL}}", vars.packLabel);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function copyTree(src, dest, vars) {
|
|
70
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
71
|
+
if (entry.name === "pack.json") continue; // manifest, not project content
|
|
72
|
+
const renamed = RENAMES[entry.name] ?? entry.name;
|
|
73
|
+
const target = path.join(dest, substitute(renamed, vars));
|
|
74
|
+
if (entry.isDirectory()) {
|
|
75
|
+
fs.mkdirSync(target, { recursive: true });
|
|
76
|
+
copyTree(path.join(src, entry.name), target, vars);
|
|
77
|
+
} else {
|
|
78
|
+
const srcPath = path.join(src, entry.name);
|
|
79
|
+
const content = substitute(fs.readFileSync(srcPath, "utf8"), vars);
|
|
80
|
+
if (MERGED.has(entry.name) && fs.existsSync(target)) {
|
|
81
|
+
if (entry.name === "mcp.json") {
|
|
82
|
+
const base = JSON.parse(fs.readFileSync(target, "utf8"));
|
|
83
|
+
const extra = JSON.parse(content);
|
|
84
|
+
base.mcpServers = { ...base.mcpServers, ...extra.mcpServers };
|
|
85
|
+
fs.writeFileSync(target, JSON.stringify(base, null, 2) + "\n");
|
|
86
|
+
} else {
|
|
87
|
+
fs.appendFileSync(target, content.endsWith("\n") ? content : content + "\n");
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
// Preserve the source mode so executable templates (e.g. vapor-api
|
|
91
|
+
// scripts/*.sh shipped 755) keep their exec bit instead of landing 644.
|
|
92
|
+
const mode = fs.statSync(srcPath).mode;
|
|
93
|
+
fs.writeFileSync(target, content, { mode });
|
|
94
|
+
if (mode & 0o111) fs.chmodSync(target, mode); // re-assert exec bit (mode on write only applies on create / is umask-masked)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function init(args) {
|
|
101
|
+
const flag = (name) => {
|
|
102
|
+
const i = args.indexOf(name);
|
|
103
|
+
return i !== -1 ? args[i + 1] : undefined;
|
|
104
|
+
};
|
|
105
|
+
const assumeYes = args.includes("--yes") || args.includes("-y");
|
|
106
|
+
const packs = loadPacks();
|
|
107
|
+
|
|
108
|
+
// 1. Project name — first POSITIONAL token, by index (skip value-taking flags' values).
|
|
109
|
+
// (Value-string exclusion would wrongly drop a name that equals the --platform/--id value,
|
|
110
|
+
// e.g. `init swift-ios --platform swift-ios`, then hang at a non-TTY prompt.)
|
|
111
|
+
const valueFlags = new Set(["--platform", "--id", "--bundle"]);
|
|
112
|
+
let name;
|
|
113
|
+
for (let i = 0; i < args.length; i++) {
|
|
114
|
+
if (args[i].startsWith("-")) { if (valueFlags.has(args[i])) i++; continue; }
|
|
115
|
+
name = args[i];
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
if (!name) name = await ask("Project name (UpperCamelCase, e.g. MyApp)");
|
|
119
|
+
if (!/^[A-Z][A-Za-z0-9]{0,63}$/.test(name)) {
|
|
120
|
+
console.error(`✗ "${name}" must be UpperCamelCase, max 64 chars (it becomes module/type names).`);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 2. Platform
|
|
125
|
+
let pack = null;
|
|
126
|
+
const wanted = flag("--platform");
|
|
127
|
+
if (wanted) {
|
|
128
|
+
pack = packs.find((p) => p.id === wanted) ?? null;
|
|
129
|
+
if (!pack && !assumeYes) {
|
|
130
|
+
const go = await ask(`No best-practices pack for "${wanted}" yet. Continue with the universal core only? [y/N]`, "N");
|
|
131
|
+
if (!/^y(es)?$/i.test(go)) process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
} else {
|
|
134
|
+
console.log("\nWhich platform?");
|
|
135
|
+
packs.forEach((p, i) => console.log(` ${i + 1}. ${p.label}`));
|
|
136
|
+
console.log(` ${packs.length + 1}. Other (universal core only — no platform best practices yet)`);
|
|
137
|
+
const choice = parseInt(await ask("Choice", "1"), 10);
|
|
138
|
+
if (choice >= 1 && choice <= packs.length) {
|
|
139
|
+
pack = packs[choice - 1];
|
|
140
|
+
} else {
|
|
141
|
+
const stack = await ask("Which stack? (informational — written into the project memory)");
|
|
142
|
+
const go = assumeYes ? "y" : await ask(`No best-practices pack for "${stack || "that stack"}" yet. Continue with the universal core only? [y/N]`, "N");
|
|
143
|
+
if (!/^y(es)?$/i.test(go)) process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Collision guard — check BEFORE prompting for the identifier, so an existing
|
|
148
|
+
// directory fails fast instead of after a needless prompt.
|
|
149
|
+
const dest = path.resolve(process.cwd(), name);
|
|
150
|
+
if (fs.existsSync(dest)) {
|
|
151
|
+
console.error(`✗ ${dest} already exists.`);
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 3. Identifier
|
|
156
|
+
// Pack-appropriate default: a pack may carry an explicit `idDefault`; otherwise
|
|
157
|
+
// packs whose prompt is about an npm/package name get an npm-style default, and
|
|
158
|
+
// everything else gets the reverse-DNS default.
|
|
159
|
+
const idPrompt = pack?.idPrompt ?? "App identifier (reverse-DNS)";
|
|
160
|
+
const idDefault =
|
|
161
|
+
pack?.idDefault ??
|
|
162
|
+
(/npm|package/i.test(idPrompt) ? name.toLowerCase() : `com.example.${name.toLowerCase()}`);
|
|
163
|
+
// --yes (or a non-TTY stdin, where ask() would hang at EOF and scaffold nothing)
|
|
164
|
+
// makes init non-interactive: use the default identifier instead of prompting.
|
|
165
|
+
let bundleId = flag("--id") ?? flag("--bundle");
|
|
166
|
+
if (bundleId === undefined) {
|
|
167
|
+
bundleId = (assumeYes || !process.stdin.isTTY) ? idDefault : await ask(idPrompt, idDefault);
|
|
168
|
+
}
|
|
169
|
+
validateId(bundleId);
|
|
170
|
+
|
|
171
|
+
const vars = { name, bundleId, packLabel: pack ? pack.label : "none (universal core only)" };
|
|
172
|
+
|
|
173
|
+
// Honest mcp.json summary: context7 always ships (core); a platform MCP only
|
|
174
|
+
// appears when the chosen pack actually adds an mcp.json beyond core.
|
|
175
|
+
const packHasMcp = !!(pack && fs.existsSync(path.join(pack.dir, "mcp.json")));
|
|
176
|
+
|
|
177
|
+
console.log(`\n⚒️ Forging ${name}${pack ? ` [${pack.id}]` : " [core only]"}…`);
|
|
178
|
+
// Wrap the whole scaffold: a partial directory from EACCES/ENOSPC would otherwise
|
|
179
|
+
// be left behind and the collision guard would then block any retry. On failure,
|
|
180
|
+
// remove the partial dir and exit with a clear message.
|
|
181
|
+
try {
|
|
182
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
183
|
+
copyTree(CORE, dest, vars); // universal bricks
|
|
184
|
+
if (pack) copyTree(pack.dir, dest, vars); // platform bricks (override core, merge mcp/gitignore)
|
|
185
|
+
fs.writeFileSync(
|
|
186
|
+
path.join(dest, MANIFEST),
|
|
187
|
+
JSON.stringify({ version: pkg.version, pack: pack ? pack.id : null, projectName: name, bundleId }, null, 2) + "\n"
|
|
188
|
+
); // update manifest — lets `app-forge update` re-render knowledge files later
|
|
189
|
+
} catch (err) {
|
|
190
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
191
|
+
console.error(`✗ Failed to scaffold ${name}: ${err.message}\n Removed the partial directory — fix the cause (permissions / disk space) and retry.`);
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
execSync("git init -q", { cwd: dest });
|
|
197
|
+
} catch {
|
|
198
|
+
console.log(" (git not found — skipped git init)");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
console.log(`
|
|
202
|
+
✅ ${name} is ready.
|
|
203
|
+
|
|
204
|
+
cd ${name}
|
|
205
|
+
claude # open Claude Code
|
|
206
|
+
/kickoff # describe your idea — Claude builds it
|
|
207
|
+
|
|
208
|
+
Installed bricks:
|
|
209
|
+
CLAUDE.md + docs-architecture/ Operating manual + knowledge base${pack ? " (core + " + pack.id + ")" : " (universal core)"}
|
|
210
|
+
.claude/skills/ /kickoff, /product-owner, /restore-context, /save-context
|
|
211
|
+
.claude/memory/ Persistent project memory (anti-hallucination)
|
|
212
|
+
.mcp.json MCP servers (context7 docs${packHasMcp ? " + platform tooling" : ""})
|
|
213
|
+
.appforge.json Update manifest — \`npx @horka/app-forge update\` refreshes docs/skills later
|
|
214
|
+
${pack?.requirements?.length ? "\nRequirements: " + pack.requirements.join(" · ") : ""}${pack?.notes ? "\n" + pack.notes.replaceAll("{{PROJECT_NAME}}", name) : ""}
|
|
215
|
+
`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// --- update -----------------------------------------------------------------
|
|
219
|
+
// Knowledge evolves (new gotchas, fixed docs, improved skills); `app-forge update`
|
|
220
|
+
// re-renders the boilerplate-OWNED files from the current templates without ever
|
|
221
|
+
// touching what the user owns after init (.claude/memory/**, source code, config).
|
|
222
|
+
const OWNED = (rel) => rel === "CLAUDE.md" || rel.startsWith("docs-architecture/") || rel.startsWith(".claude/skills/");
|
|
223
|
+
|
|
224
|
+
function collectOwned(src, rel, vars, out) {
|
|
225
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
226
|
+
if (entry.name === "pack.json") continue; // manifest, not project content
|
|
227
|
+
const renamed = RENAMES[entry.name] ?? entry.name;
|
|
228
|
+
const target = rel ? `${rel}/${substitute(renamed, vars)}` : substitute(renamed, vars);
|
|
229
|
+
if (entry.isDirectory()) collectOwned(path.join(src, entry.name), target, vars, out);
|
|
230
|
+
else if (OWNED(target)) out.set(target, substitute(fs.readFileSync(path.join(src, entry.name), "utf8"), vars));
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function listFiles(dir, rel, out) {
|
|
236
|
+
if (!fs.existsSync(dir)) return out;
|
|
237
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
238
|
+
if (entry.isDirectory()) listFiles(path.join(dir, entry.name), `${rel}/${entry.name}`, out);
|
|
239
|
+
else out.push(`${rel}/${entry.name}`);
|
|
240
|
+
}
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function update(args) {
|
|
245
|
+
const flag = (name) => {
|
|
246
|
+
const i = args.indexOf(name);
|
|
247
|
+
return i !== -1 ? args[i + 1] : undefined;
|
|
248
|
+
};
|
|
249
|
+
const root = process.cwd();
|
|
250
|
+
const manifestPath = path.join(root, MANIFEST);
|
|
251
|
+
const packs = loadPacks();
|
|
252
|
+
|
|
253
|
+
// 1. Manifest — written at init; rebuildable from flags for older projects.
|
|
254
|
+
let manifest;
|
|
255
|
+
if (fs.existsSync(manifestPath)) {
|
|
256
|
+
try {
|
|
257
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
258
|
+
} catch {
|
|
259
|
+
console.error(`✗ ${MANIFEST} is not valid JSON — fix or delete it, then re-run with --pack.`);
|
|
260
|
+
process.exit(1);
|
|
261
|
+
}
|
|
262
|
+
} else if (flag("--pack")) {
|
|
263
|
+
const wanted = flag("--pack");
|
|
264
|
+
if (wanted !== "none" && !packs.some((p) => p.id === wanted)) {
|
|
265
|
+
console.error(`✗ Unknown pack "${wanted}". Available: ${packs.map((p) => p.id).join(", ")} (or "none" for core only).`);
|
|
266
|
+
process.exit(1);
|
|
267
|
+
}
|
|
268
|
+
const name = flag("--name") ?? path.basename(root);
|
|
269
|
+
const id = flag("--id") ?? `com.example.${name.toLowerCase()}`;
|
|
270
|
+
validateId(id); // same raw-substitution sink as init — keep the identifier injection-safe here too
|
|
271
|
+
manifest = { version: "pre-manifest", pack: wanted === "none" ? null : wanted, projectName: name, bundleId: id };
|
|
272
|
+
console.log(` (no ${MANIFEST} — rebuilt from flags as ${manifest.projectName} / ${manifest.bundleId}; written on apply)`);
|
|
273
|
+
} else {
|
|
274
|
+
console.error(`✗ No ${MANIFEST} here — this project predates update support (or wasn't made by app-forge).
|
|
275
|
+
Re-run with --pack <id> ("none" for core only; see \`app-forge packs\`), plus optional
|
|
276
|
+
--name <ProjectName> --id <bundle.id>, and the manifest will be created on apply.`);
|
|
277
|
+
process.exit(1);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// 2. Pack — may have been renamed/removed since init; fall back to core only.
|
|
281
|
+
let pack = null;
|
|
282
|
+
if (manifest.pack) {
|
|
283
|
+
pack = packs.find((p) => p.id === manifest.pack) ?? null;
|
|
284
|
+
if (!pack) console.log(`⚠️ Pack "${manifest.pack}" no longer ships with app-forge ${pkg.version} — updating universal core files only; pack-owned docs stay as they are.`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// 3. Re-render owned files from current templates (same substitutions as init).
|
|
288
|
+
const vars = { name: manifest.projectName, bundleId: manifest.bundleId, packLabel: pack ? pack.label : "none (universal core only)" };
|
|
289
|
+
const desired = collectOwned(CORE, "", vars, new Map());
|
|
290
|
+
if (pack) collectOwned(pack.dir, "", vars, desired); // pack overrides core, like init
|
|
291
|
+
|
|
292
|
+
// 4. Diff by content against the project.
|
|
293
|
+
const added = [], changed = [], unchanged = [];
|
|
294
|
+
for (const rel of [...desired.keys()].sort()) {
|
|
295
|
+
const file = path.join(root, rel);
|
|
296
|
+
if (!fs.existsSync(file)) added.push(rel);
|
|
297
|
+
else if (fs.readFileSync(file, "utf8") !== desired.get(rel)) changed.push(rel);
|
|
298
|
+
else unchanged.push(rel);
|
|
299
|
+
}
|
|
300
|
+
const kept = [...listFiles(path.join(root, "docs-architecture"), "docs-architecture", []), ...listFiles(path.join(root, ".claude", "skills"), ".claude/skills", [])]
|
|
301
|
+
.filter((rel) => !desired.has(rel))
|
|
302
|
+
.sort();
|
|
303
|
+
|
|
304
|
+
console.log(`\n🔄 ${manifest.projectName}${pack ? ` [${pack.id}]` : " [core only]"} — manifest ${manifest.version}, templates ${pkg.version}\n`);
|
|
305
|
+
added.forEach((rel) => console.log(` + added ${rel}`));
|
|
306
|
+
changed.forEach((rel) => console.log(` ~ changed ${rel}`));
|
|
307
|
+
unchanged.forEach((rel) => console.log(` = unchanged ${rel}`));
|
|
308
|
+
kept.forEach((rel) => console.log(` · kept ${rel} (not template-owned — left intact)`));
|
|
309
|
+
|
|
310
|
+
const pending = [...added, ...changed];
|
|
311
|
+
if (!pending.length) {
|
|
312
|
+
console.log(`\n✅ Knowledge files already match templates ${pkg.version}. Nothing to do.`);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// 5. Dry run by default; --apply (or interactive y) writes.
|
|
317
|
+
let apply = args.includes("--apply");
|
|
318
|
+
if (!apply && process.stdin.isTTY) {
|
|
319
|
+
apply = /^y(es)?$/i.test(await ask(`\nApply ${pending.length} file(s)? Memory, source code and config are never touched. [y/N]`, "N"));
|
|
320
|
+
}
|
|
321
|
+
if (!apply) {
|
|
322
|
+
console.log(`\n Dry run — nothing written. Re-run with --apply to update the ${pending.length} file(s) above.`);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const rootReal = fs.realpathSync(root);
|
|
326
|
+
let written = 0;
|
|
327
|
+
for (const rel of pending) {
|
|
328
|
+
const file = path.join(root, rel);
|
|
329
|
+
const dir = path.dirname(file);
|
|
330
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
331
|
+
// Never write through a symlink that escapes the project tree (a hostile clone could
|
|
332
|
+
// plant one at an owned path). Guard BOTH the file itself and its (real) parent dir.
|
|
333
|
+
try {
|
|
334
|
+
if (fs.lstatSync(file).isSymbolicLink()) {
|
|
335
|
+
console.log(` ⚠ skipped ${rel} (is a symlink — refusing to write through it)`);
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
} catch { /* ENOENT: brand-new file, fine */ }
|
|
339
|
+
const dirReal = fs.realpathSync(dir);
|
|
340
|
+
if (dirReal !== rootReal && !dirReal.startsWith(rootReal + path.sep)) {
|
|
341
|
+
console.log(` ⚠ skipped ${rel} (path escapes the project tree — refusing)`);
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
fs.writeFileSync(file, desired.get(rel));
|
|
345
|
+
written++;
|
|
346
|
+
}
|
|
347
|
+
fs.writeFileSync(manifestPath, JSON.stringify({ ...manifest, version: pkg.version }, null, 2) + "\n");
|
|
348
|
+
const skipped = pending.length - written;
|
|
349
|
+
console.log(`\n✅ Updated ${written} file(s) to templates ${pkg.version} (${MANIFEST} bumped).${skipped ? ` ${skipped} skipped (symlink/escape guard).` : ""}`);
|
|
350
|
+
console.log(` Note: your own incidents/notes belong in .claude/memory/ (never touched); docs-architecture/ is curated and refreshed here.`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const [, , command, ...rest] = process.argv;
|
|
354
|
+
if (command === "init") {
|
|
355
|
+
init(rest);
|
|
356
|
+
} else if (command === "update") {
|
|
357
|
+
update(rest);
|
|
358
|
+
} else if (command === "packs") {
|
|
359
|
+
const packs = loadPacks();
|
|
360
|
+
console.log("Available platform packs:");
|
|
361
|
+
packs.forEach((p) => console.log(` ${p.id.padEnd(14)} ${p.label}`));
|
|
362
|
+
} else {
|
|
363
|
+
console.log(`app-forge — Claude-Code-first project factory (any platform)
|
|
364
|
+
|
|
365
|
+
Usage:
|
|
366
|
+
npx @horka/app-forge init <ProjectName> [--platform swift-ios] [--id com.me.app] [--yes]
|
|
367
|
+
npx @horka/app-forge update [--apply] (inside a generated project — refresh docs/skills, never your code or memory)
|
|
368
|
+
npx @horka/app-forge packs
|
|
369
|
+
`);
|
|
370
|
+
if (command) process.exit(1);
|
|
371
|
+
}
|
package/bin/cli.test.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Regression tests for the security-sensitive CLI surface. Zero deps — node:test (Node 18+).
|
|
2
|
+
// node --test
|
|
3
|
+
const { test } = require("node:test");
|
|
4
|
+
const assert = require("node:assert");
|
|
5
|
+
const { execFileSync } = require("node:child_process");
|
|
6
|
+
const fs = require("node:fs");
|
|
7
|
+
const os = require("node:os");
|
|
8
|
+
const path = require("node:path");
|
|
9
|
+
|
|
10
|
+
const CLI = path.join(__dirname, "cli.js");
|
|
11
|
+
|
|
12
|
+
function run(args, cwd) {
|
|
13
|
+
try {
|
|
14
|
+
const out = execFileSync("node", [CLI, ...args], { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], input: "" });
|
|
15
|
+
return { code: 0, out };
|
|
16
|
+
} catch (e) {
|
|
17
|
+
return { code: e.status ?? 1, out: (e.stdout || "") + (e.stderr || "") };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function tmp() {
|
|
22
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "appforge-test-"));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// --- Injection guard: identifiers that break out of JSON/YAML must be rejected, no dir left ---
|
|
26
|
+
for (const bad of ['evil","x":"y', "a\nKEY: v", "has space", "{brace}", "col:on", "back\\slash"]) {
|
|
27
|
+
test(`rejects injection id: ${JSON.stringify(bad)}`, () => {
|
|
28
|
+
const dir = tmp();
|
|
29
|
+
const r = run(["init", "Inj", "--platform", "swift-ios", "--id", bad, "--yes"], dir);
|
|
30
|
+
assert.strictEqual(r.code, 1, "must exit 1");
|
|
31
|
+
assert.ok(!fs.existsSync(path.join(dir, "Inj")), "no partial project dir");
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// --- Valid identifiers (reverse-DNS + npm scoped) are accepted ---
|
|
36
|
+
for (const good of ["com.me.app", "@org/my-sdk", "io.example.thing_2"]) {
|
|
37
|
+
test(`accepts valid id: ${good}`, () => {
|
|
38
|
+
const dir = tmp();
|
|
39
|
+
const r = run(["init", "Ok", "--platform", "ts-sdk", "--id", good, "--yes"], dir);
|
|
40
|
+
assert.strictEqual(r.code, 0, r.out);
|
|
41
|
+
assert.ok(fs.existsSync(path.join(dir, "Ok", ".appforge.json")));
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// --- Non-interactive: --yes with no --id must scaffold, not hang ---
|
|
46
|
+
test("--yes scaffolds without --id (non-interactive)", () => {
|
|
47
|
+
const dir = tmp();
|
|
48
|
+
const r = run(["init", "Auto", "--platform", "swift-ios", "--yes"], dir);
|
|
49
|
+
assert.strictEqual(r.code, 0, r.out);
|
|
50
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(dir, "Auto", ".appforge.json"), "utf8"));
|
|
51
|
+
assert.strictEqual(manifest.bundleId, "com.example.auto");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// --- Positional name by index: a name equal to a flag value is NOT dropped ---
|
|
55
|
+
test("name equal to platform value is kept", () => {
|
|
56
|
+
const dir = tmp();
|
|
57
|
+
// 'Swiftios' is a valid UpperCamelCase name; ensure index-based parse keeps it
|
|
58
|
+
const r = run(["init", "Swiftios", "--platform", "swift-ios", "--yes"], dir);
|
|
59
|
+
assert.strictEqual(r.code, 0, r.out);
|
|
60
|
+
assert.ok(fs.existsSync(path.join(dir, "Swiftios")));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// --- update: memory is never touched, and a symlink at an owned path is refused ---
|
|
64
|
+
test("update --apply never writes through a symlink / outside the tree", () => {
|
|
65
|
+
const dir = tmp();
|
|
66
|
+
assert.strictEqual(run(["init", "Proj", "--platform", "swift-ios", "--yes"], dir).code, 0);
|
|
67
|
+
const proj = path.join(dir, "Proj");
|
|
68
|
+
|
|
69
|
+
// memory sentinel
|
|
70
|
+
const mem = path.join(proj, ".claude/memory/PROJECT_STATE.md");
|
|
71
|
+
fs.appendFileSync(mem, "\nSENTINEL_DO_NOT_LOSE\n");
|
|
72
|
+
const memBefore = fs.readFileSync(mem, "utf8");
|
|
73
|
+
|
|
74
|
+
// plant a symlink at an OWNED path pointing OUTSIDE the project
|
|
75
|
+
const outside = path.join(dir, "OUTSIDE.txt");
|
|
76
|
+
fs.writeFileSync(outside, "PRECIOUS");
|
|
77
|
+
const owned = path.join(proj, "CLAUDE.md");
|
|
78
|
+
fs.rmSync(owned);
|
|
79
|
+
fs.symlinkSync(outside, owned);
|
|
80
|
+
|
|
81
|
+
// tamper an owned regular file so update has something to restore
|
|
82
|
+
const doc = path.join(proj, "docs-architecture/DELIVERY.md");
|
|
83
|
+
fs.appendFileSync(doc, "\n<!-- local tamper -->\n");
|
|
84
|
+
|
|
85
|
+
const r = run(["update", "--apply"], proj);
|
|
86
|
+
assert.strictEqual(r.code, 0, r.out);
|
|
87
|
+
|
|
88
|
+
assert.strictEqual(fs.readFileSync(outside, "utf8"), "PRECIOUS", "out-of-tree file must be untouched");
|
|
89
|
+
assert.strictEqual(fs.readFileSync(mem, "utf8"), memBefore, "memory must be byte-identical");
|
|
90
|
+
assert.ok(!fs.readFileSync(doc, "utf8").includes("local tamper"), "owned doc restored");
|
|
91
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@horka/app-forge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Scaffold projects where Claude Code is the team lead, not the assistant. One command installs a proven 6-layer architecture, conventions, production gotchas, a memory system, and a /kickoff skill that takes your idea from interview to PRD to a built, tested, running app. Packs: iOS/SwiftUI, Vapor API, Nuxt, TypeScript SDK.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"app-forge": "bin/cli.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"bin",
|
|
10
|
+
"templates",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"claude",
|
|
19
|
+
"claude-code",
|
|
20
|
+
"ai",
|
|
21
|
+
"boilerplate",
|
|
22
|
+
"scaffold",
|
|
23
|
+
"architecture",
|
|
24
|
+
"ios",
|
|
25
|
+
"swift",
|
|
26
|
+
"swiftui",
|
|
27
|
+
"cloudkit",
|
|
28
|
+
"multi-platform"
|
|
29
|
+
],
|
|
30
|
+
"author": "Joey Barbier (https://github.com/joey-barbier)",
|
|
31
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
32
|
+
"homepage": "https://github.com/joey-barbier/app-forge#readme",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/joey-barbier/app-forge.git"
|
|
36
|
+
},
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/joey-barbier/app-forge/issues"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"test": "node --test"
|
|
42
|
+
}
|
|
43
|
+
}
|