Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/OUTPUT-CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Legend: **[static]** check by reading files, **[cmd]** run a command, **[device]
| 1.1 | No substrate tokens left (`Shop`, `Shopkeeper`, `ItemTag`, `NativeAppTemplate`, all case forms; 店舗, アイテムタグ, 番号タグ) outside CHANGELOGs | [static] Layer 1 covers code; also `rg` the generated `Info.plist` strings, mail views and web views |
| 1.2 | No article disagreement in user-facing strings, including the product name: `an <noun>` / `a <Product>` | [static] `rg -i '\ban (<noun>)s?\b'` and `rg '\ba <Product>'` over `out/<slug>/`. *Orange Baton: Android notification channel "when an baton is completed"; iOS `NSLocalNetworkUsageDescription` "Connects to a OrangeBaton API server"* |
| 1.3 | The display name, not the Pascal name, in user-facing copy (mails, web pages, permission prompts) | [static] [device] read a confirmation mail and an invitation mail. *Orange Baton: mails said "OrangeBaton"* |
| 1.4 | Brand assets replaced: app icons, the logo on the API's web pages (password reset, confirmation result) | [device] open a password-reset link in a browser. *Orange Baton: the reset page still shows the NativeAppTemplate "N"* |
| 1.4 | Brand assets replaced: app icons, the logo on the API's web pages (password reset, confirmation result), and the placeholder mark on the onboarding and sign-up-or-sign-in screens (iOS `sparkles`, Android `Icons.Outlined.AutoAwesome`) | [device] open a password-reset link in a browser; launch signed out. *Orange Baton: the reset page still shows the NativeAppTemplate "N"* |
| 1.5 | Non-domain identifiers not renamed (SUBSTRATE-CONTRACT rule 5), e.g. the agent's own name in comments | [static] `rg -i '<slug>-agent'` |
| 1.6 | The app's domain and store IDs are the ones you want (`--domain`, #157): no `nativeapptemplate.com` left; iOS bundle ID and Android applicationId follow the domain's reverse; no `com/<flat>` package directories left behind with a custom domain. The API's APNs `topic` lives in its credentials, not in the copied files: set it to the iOS bundle ID | [static] `rg -n 'nativeapptemplate\.com\|com\.nativeapptemplate' out/<slug>`, `rg -n PRODUCT_BUNDLE_IDENTIFIER out/<slug>/ios`, `rg -n applicationId out/<slug>/android/app/build.gradle.kts`; compare with the report's "App identifiers" |

Expand All @@ -31,8 +31,9 @@ Legend: **[static]** check by reading files, **[cmd]** run a command, **[device]
| 2.2 | RevenueCat key and Sentry DSN empty: iOS `RevenueCat.xcconfig` / `Sentry.xcconfig`, Android `<PRODUCT>_REVENUECAT_GOOGLE_API_KEY` / `<PRODUCT>_SENTRY_DSN`, API `SENTRY_DSN` | [static] |
| 2.3 | **Firebase config is not the substrate's.** `android/app/google-services.json` is the template's committed placeholder, never the substrate developer's working-tree file (#147). Push needs your own Firebase project's file | [static] `project_number` is the placeholder's, not the substrate's. [device] with your own file, logcat shows no `FirebaseMessaging: Failed to get FIS auth token`. *Orange Baton (before #147)* |
| 2.4 | **Store URLs are not the substrate's.** iOS `Constants.appStoreUrl` (forced-update button), API `config/settings.yml` `app_store_url` / `play_store_url` (invitation mail) | [static] `rg -n 'apps.apple.com\|play.google.com' out/<slug>`. *Orange Baton: the iOS update button opened MyTurnTag Creator's App Store page* |
| 2.5 | Production credentials: `config/credentials/production.yml.enc` is the substrate's and its key is not copied; regenerate before deploying | [static] |
| 2.5 | Production credentials: the substrate's `config/credentials/production.yml.enc` (and its key) are not copied; make your own before deploying with `bin/rails credentials:edit --environment production` | [static] `ls rails/config/credentials`. *Orange Baton: the copied file could not be opened without the template's key* |
| 2.6 | The env bridge mirrored `<PRODUCT>_API_*` into `~/.gradle/gradle.properties`. The managed block keeps one set of lines per product (#148); before that it held only the most recent run's product, so an earlier output's Debug build silently fell back to the production host | [static] `grep <PRODUCT>_API ~/.gradle/gradle.properties`; [cmd] check `DOMAIN` in the generated `BuildConfig.java`. *Orange Baton, after a later clinic-queue run (before #148)* |
| 2.7 | **Container image owner.** `config/deploy.yml` `image` and `registry.username` are derived from the product name, but the GitHub user or organization that owns the registry may be named otherwise; set them to the account `KAMAL_REGISTRY_PASSWORD` belongs to | [static] `rg -n 'image:\|username:' rails/config/deploy.yml`. *Orange Baton: no GitHub account named `orangebaton`* |

## 3. Tests and linters

Expand Down
2 changes: 2 additions & 0 deletions docs/SUBSTRATE-CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ The error monitoring (Sentry) layer follows it too: it reports the signed-in use

References to the template itself are not the product name and are never renamed: any `github.com/nativeapptemplate/<repo>` link (with or without `https://`, any path after it such as `/issues` or `/blob/main/docs/...`, or the SSH form `git@github.com:nativeapptemplate/...`) and the bare name `nativeapptemplate-agent` (and `nativeapptemplate-agent-mcp`) stay byte-for-byte as written, including any `Shop` / `ItemTag` in a URL path, so generated apps keep working links to the template's repos. `rename.rb` masks them before substituting (`PROTECTED_REFERENCE`), and Layer 1 skips them (`src/validation/protected-references.ts`). The product's domains and emails (`nativeapptemplate.com`, `support@nativeapptemplate.com`) are not protected: they become the app's own. Write any other mention of the template (another host, a prose name like "NativeAppTemplate-iOS" without a link) knowing it will be renamed.

Don't state facts about a renamed token that stop being true after the rename. The README's "the default host `api.nativeapptemplate.com` has been shut down" became "`api.orangebaton.com` has been shut down" about the generated app's own live API (found in Orange Baton). Write what the developer does with it instead ("point the app at your own API server").

### 6. Comments and docstrings: same rules

Comments are scanned by the rename pipeline and treated like any other text. If a comment says "an item tag," it'll become "an patient" the same way a UI string does. Apply rule 1 to comments too:
Expand Down
2 changes: 2 additions & 0 deletions src/agents/workers/android.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { applicationIdFromGradle, renameAppDomainInput, type RenameAppDomain } f
import { japaneseRenamePairs, renamedTokens } from "../../japanese-nouns.js";
import { copySubstrate, SENTRY_CREDENTIAL_FILES, type CopySkipRules } from "./copy.js";
import { runFormatter, traceFormat } from "../../validation/autoformat.js";
import { resetAndroidVersion } from "../../app-version.js";
import { slugToPascal } from "../../slug.js";
import type { DomainSpec, RenamePair, WorkerResult } from "../types.js";

Expand Down Expand Up @@ -75,6 +76,7 @@ export async function runAndroidWorker(domain: DomainSpec): Promise<WorkerResult

const appId = await readApplicationId(outDir);
if (appId) trace("android", `applicationId: ${appId}`);
await resetAndroidVersion(outDir);
const format = await runFormatter("android", outDir);
traceFormat("android", format);
await initGit(outDir);
Expand Down
2 changes: 2 additions & 0 deletions src/agents/workers/ios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { japaneseRenamePairs, renamedTokens } from "../../japanese-nouns.js";
import { copySubstrate, SENTRY_CREDENTIAL_FILES, type CopySkipRules } from "./copy.js";
import { runFormatter, traceFormat } from "../../validation/autoformat.js";
import { slugToPascal } from "../../slug.js";
import { resetIosVersion } from "../../app-version.js";
import type { DomainSpec, RenamePair, WorkerResult } from "../types.js";

type RenameStats = {
Expand Down Expand Up @@ -70,6 +71,7 @@ export async function runIosWorker(domain: DomainSpec): Promise<WorkerResult> {

const appId = await readBundleId(outDir);
if (appId) trace("ios", `bundle ID: ${appId}`);
await resetIosVersion(outDir);
const format = await runFormatter("ios", outDir);
traceFormat("ios", format);
await initGit(outDir);
Expand Down
4 changes: 3 additions & 1 deletion src/agents/workers/rails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ type RenameStats = {
};

export const RAILS_SKIP_RULES: CopySkipRules = {
rootPaths: [".git", ".claude", "node_modules", "tmp", "log", "vendor/bundle"],
// production.yml.enc is encrypted with the template's production.key, which
// stays behind: the generated API makes its own before the first deploy.
rootPaths: [".git", ".claude", "node_modules", "tmp", "log", "vendor/bundle", "config/credentials/production.yml.enc"],
anySegment: SENTRY_CREDENTIAL_FILES,
// Gitignored but needed: .env holds the HOST/PORT rails-lifecycle.ts reads
// from the generated dir, and development.key decrypts the committed
Expand Down
31 changes: 31 additions & 0 deletions src/app-version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { readdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";

// A generated app is a new product, so its first store upload is 1.0.0 (1),
// not the template's own release number.
export const FIRST_VERSION_NAME = "1.0.0";
export const FIRST_BUILD_NUMBER = 1;

export function resetPbxprojVersion(pbxproj: string): string {
return pbxproj
.replace(/MARKETING_VERSION = [^;]+;/g, `MARKETING_VERSION = ${FIRST_VERSION_NAME};`)
.replace(/CURRENT_PROJECT_VERSION = [^;]+;/g, `CURRENT_PROJECT_VERSION = ${FIRST_BUILD_NUMBER};`);
}

export function resetGradleVersion(buildGradle: string): string {
return buildGradle
.replace(/versionCode = \d+/, `versionCode = ${FIRST_BUILD_NUMBER}`)
.replace(/versionName = "[^"]*"/, `versionName = "${FIRST_VERSION_NAME}"`);
}

export async function resetIosVersion(outDir: string): Promise<void> {
const project = (await readdir(outDir)).find((name) => name.endsWith(".xcodeproj"));
if (!project) return;
const path = join(outDir, project, "project.pbxproj");
await writeFile(path, resetPbxprojVersion(await readFile(path, "utf8")));
}

export async function resetAndroidVersion(outDir: string): Promise<void> {
const path = join(outDir, "app", "build.gradle.kts");
await writeFile(path, resetGradleVersion(await readFile(path, "utf8")));
}
57 changes: 57 additions & 0 deletions tests/smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4062,6 +4062,63 @@ test("every platform keeps its gitignored files the generated app needs", async
assert.deepEqual(RAILS_SKIP_RULES.keepIgnored, [".env", "config/credentials/development.key"]);
});

// The template's production credentials are encrypted with the template's
// production.key, which never comes along, so a generated API can't open them
// and had to make new ones before its first deploy (found in Orange Baton).
test("copySubstrate leaves the API's production credentials behind and keeps the development ones", async () => {
const { copySubstrate } = await import("../src/agents/workers/copy.js");
const { RAILS_SKIP_RULES } = await import("../src/agents/workers/rails.js");
const { existsSync } = await import("node:fs");
const base = mkdtempSync(join(tmpdir(), "copy-credentials-"));
const src = join(base, "src");
mkdirSync(join(src, "config", "credentials"), { recursive: true });
// Mirrors nativeapptemplateapi: the *.yml.enc are tracked, the keys ignored.
writeFileSync(join(src, ".gitignore"), "/config/credentials/*.key\n");
for (const env of ["development", "production", "test"]) {
writeFileSync(join(src, "config", "credentials", `${env}.yml.enc`), "encrypted");
writeFileSync(join(src, "config", "credentials", `${env}.key`), "key");
}
gitIn(src, "init", "-q");
gitIn(src, "add", ".");
gitIn(src, "commit", "-q", "-m", "init");
const dest = join(base, "dest");
await copySubstrate(src, dest, RAILS_SKIP_RULES);
for (const kept of ["development.yml.enc", "development.key", "test.yml.enc"]) {
assert.equal(existsSync(join(dest, "config", "credentials", kept)), true, `${kept} must be copied`);
}
for (const left of ["production.yml.enc", "production.key"]) {
assert.equal(existsSync(join(dest, "config", "credentials", left)), false, `${left} must not be copied`);
}
});

// A generated app is a new product: its first store upload is 1.0.0 (1), not
// the template's own release. Orange Baton started at the template's 3.3.3 (13)
// and had to be set back before its first TestFlight build.
test("resetPbxprojVersion / resetGradleVersion start the generated app at 1.0.0 (1)", async () => {
const { resetPbxprojVersion, resetGradleVersion } = await import("../src/app-version.js");
const pbxproj = [
"\t\t\t\tCURRENT_PROJECT_VERSION = 13;",
"\t\t\t\tMARKETING_VERSION = 3.3.3;",
"\t\t\t\tCURRENT_PROJECT_VERSION = 1;",
"\t\t\t\tMARKETING_VERSION = 1.0;",
"\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";",
"",
].join("\n");
assert.equal(
resetPbxprojVersion(pbxproj),
[
"\t\t\t\tCURRENT_PROJECT_VERSION = 1;",
"\t\t\t\tMARKETING_VERSION = 1.0.0;",
"\t\t\t\tCURRENT_PROJECT_VERSION = 1;",
"\t\t\t\tMARKETING_VERSION = 1.0.0;",
"\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";",
"",
].join("\n"),
);
const gradle = ' minSdk = 26\n versionCode = 11\n versionName = "3.3.0"\n';
assert.equal(resetGradleVersion(gradle), ' minSdk = 26\n versionCode = 1\n versionName = "1.0.0"\n');
});

test("copySubstrate of a non-git iOS substrate still leaves out build/ and .xcresult bundles", async () => {
const { copySubstrate } = await import("../src/agents/workers/copy.js");
const { IOS_SKIP_RULES } = await import("../src/agents/workers/ios.js");
Expand Down
Loading