anhedral 0.3.0 → 0.4.1
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/CHANGELOG.md +35 -0
- package/CONTRIBUTING.md +4 -1
- package/LICENSE +201 -26
- package/README.md +79 -15
- package/SECURITY.md +4 -2
- package/SKILL.md +78 -11
- package/assets/anhedral-cli-init.svg +238 -0
- package/assets/images/svg/ably-symbol-color.svg +8 -0
- package/assets/images/svg/logo-white-subtract.svg +4 -0
- package/dist/architecture/contributions.js +7 -0
- package/dist/architecture/modules.d.ts +2 -2
- package/dist/architecture/modules.js +11 -3
- package/dist/bin.js +17 -5
- package/dist/cli.d.ts +2 -2
- package/dist/cli.js +4 -1
- package/dist/composers/root.js +1 -0
- package/dist/dependencies.d.ts +2 -0
- package/dist/dependencies.js +8 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/prompts.d.ts +1 -1
- package/dist/scaffold.d.ts +8 -0
- package/dist/scaffold.js +320 -38
- package/dist/templates/api.js +8 -0
- package/dist/templates/desktop.js +78 -11
- package/dist/templates/electron-updater.d.ts +3 -0
- package/dist/templates/electron-updater.js +358 -0
- package/dist/templates/shared.js +23 -6
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/conventions.md +4 -1
- package/docs/master-stack-map.md +199 -0
- package/docs/output-tree-contract.md +45 -5
- package/{references → docs/references}/manual-scaffolding.md +24 -7
- package/docs/references/provisioning.md +220 -0
- package/package.json +12 -5
- package/scripts/render-master-stack-map.mjs +510 -0
package/dist/scaffold.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { lstatSync, mkdirSync, readFileSync, readdirSync, rmdirSync, rmSync } from 'node:fs';
|
|
1
|
+
import { chmodSync, copyFileSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmdirSync, rmSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { env } from 'node:process';
|
|
4
4
|
import { anhedralPrint } from './print.js';
|
|
@@ -8,6 +8,7 @@ import { resolveToolchainChannel } from './toolchain.js';
|
|
|
8
8
|
import { scaffoldApi } from './templates/api.js';
|
|
9
9
|
import { r2BucketName, scaffoldAssetsPrivateProxy } from './templates/assets-private-proxy.js';
|
|
10
10
|
import { scaffoldDesktop } from './templates/desktop.js';
|
|
11
|
+
import { desktopUpdatesBucketName, scaffoldElectronUpdater, } from './templates/electron-updater.js';
|
|
11
12
|
import { scaffoldExtension } from './templates/extension.js';
|
|
12
13
|
import { scaffoldMobile } from './templates/mobile.js';
|
|
13
14
|
import { scaffoldSharedPackages } from './templates/shared.js';
|
|
@@ -136,6 +137,7 @@ function selectionsFromModules(modules) {
|
|
|
136
137
|
billing: selected.has('billing'),
|
|
137
138
|
storage: selected.has('storage'),
|
|
138
139
|
nativeSubscriptions: selected.has('native-subscriptions'),
|
|
140
|
+
electronUpdater: selected.has('electron-updater'),
|
|
139
141
|
},
|
|
140
142
|
};
|
|
141
143
|
}
|
|
@@ -168,6 +170,23 @@ function optionsFromManifest(manifest, addOptions, modules) {
|
|
|
168
170
|
function toolchainChannelFromManifest(manifest) {
|
|
169
171
|
return resolveToolchainChannel(manifest.toolchain);
|
|
170
172
|
}
|
|
173
|
+
export function isSupportedProjectUpgrade(fromVersion, toVersion) {
|
|
174
|
+
const parseStableVersion = (version) => {
|
|
175
|
+
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(version);
|
|
176
|
+
return match
|
|
177
|
+
? [Number(match[1]), Number(match[2]), Number(match[3])]
|
|
178
|
+
: undefined;
|
|
179
|
+
};
|
|
180
|
+
const from = parseStableVersion(fromVersion);
|
|
181
|
+
const to = parseStableVersion(toVersion);
|
|
182
|
+
if (!from || !to)
|
|
183
|
+
return false;
|
|
184
|
+
const supportedOwnershipMigration = fromVersion === '0.3.0' && to[0] === 0 && to[1] === 4;
|
|
185
|
+
const compatiblePatchUpgrade = from[0] === to[0]
|
|
186
|
+
&& from[1] === to[1]
|
|
187
|
+
&& from[2] < to[2];
|
|
188
|
+
return supportedOwnershipMigration || compatiblePatchUpgrade;
|
|
189
|
+
}
|
|
171
190
|
function ensureScaffoldRoot(root) {
|
|
172
191
|
const rootStat = lstatIfPresent(root);
|
|
173
192
|
if (!rootStat)
|
|
@@ -189,8 +208,15 @@ function selectedAppFilters(apps) {
|
|
|
189
208
|
}
|
|
190
209
|
function rootScripts(options) {
|
|
191
210
|
const filters = selectedAppFilters(options.apps);
|
|
211
|
+
const primaryClient = filters.find((entry) => entry !== './apps/api');
|
|
212
|
+
const primaryFilters = primaryClient
|
|
213
|
+
? [primaryClient, ...(options.apps.api ? ['./apps/api'] : [])]
|
|
214
|
+
: filters.slice(0, 1);
|
|
192
215
|
const scripts = {
|
|
193
|
-
dev:
|
|
216
|
+
dev: primaryFilters.length ? `turbo dev --parallel ${primaryFilters.map((entry) => `--filter=${entry}`).join(' ')}` : 'echo "No app surfaces selected."',
|
|
217
|
+
...(filters.length > primaryFilters.length
|
|
218
|
+
? { 'dev:all': `turbo dev --parallel ${filters.map((entry) => `--filter=${entry}`).join(' ')}` }
|
|
219
|
+
: {}),
|
|
194
220
|
build: 'turbo build',
|
|
195
221
|
typecheck: 'turbo typecheck',
|
|
196
222
|
};
|
|
@@ -232,6 +258,23 @@ function rootScripts(options) {
|
|
|
232
258
|
scripts['verify:desktop'] = 'pnpm --filter ./apps/desktop typecheck && pnpm --filter ./apps/desktop build';
|
|
233
259
|
verify.push('pnpm verify:desktop');
|
|
234
260
|
}
|
|
261
|
+
if (options.features.electronUpdater) {
|
|
262
|
+
const bucketName = desktopUpdatesBucketName(options.projectName);
|
|
263
|
+
const wrangler = `pnpm dlx wrangler@${TOOLCHAIN_DEPENDENCIES.wrangler}`;
|
|
264
|
+
scripts['desktop:updates:cloudflare:login'] = `${wrangler} login`;
|
|
265
|
+
scripts['desktop:updates:bucket:create'] = `${wrangler} r2 bucket create ${bucketName}`;
|
|
266
|
+
scripts['desktop:updates:worker:check'] = 'pnpm --filter ./apps/desktop-updater-worker check';
|
|
267
|
+
scripts['desktop:updates:worker:dev'] = 'pnpm --filter ./apps/desktop-updater-worker dev';
|
|
268
|
+
scripts['desktop:updates:worker:deploy'] = 'pnpm --filter ./apps/desktop-updater-worker deploy';
|
|
269
|
+
scripts['desktop:updates:first-provision'] = 'pnpm desktop:updates:bucket:create && pnpm desktop:updates:worker:deploy';
|
|
270
|
+
scripts['desktop:updates:worker:types'] = 'pnpm --filter ./apps/desktop-updater-worker types';
|
|
271
|
+
scripts['desktop:updates:build:mac'] = 'pnpm --filter ./apps/desktop updates:build:mac';
|
|
272
|
+
scripts['desktop:updates:build:win'] = 'pnpm --filter ./apps/desktop updates:build:win';
|
|
273
|
+
scripts['desktop:updates:build:linux'] = 'pnpm --filter ./apps/desktop updates:build:linux';
|
|
274
|
+
scripts['desktop:updates:publish'] = 'node apps/desktop/scripts/publish-updates.mjs';
|
|
275
|
+
scripts['verify:desktop-updates'] = 'pnpm desktop:updates:worker:check && node --check apps/desktop/scripts/publish-updates.mjs';
|
|
276
|
+
verify.push('pnpm verify:desktop-updates');
|
|
277
|
+
}
|
|
235
278
|
if (options.apps.extension) {
|
|
236
279
|
scripts['dev:extension'] = 'pnpm --filter ./apps/extension dev';
|
|
237
280
|
scripts['extension:zip'] = 'pnpm --filter ./apps/extension zip';
|
|
@@ -663,12 +706,20 @@ function writeRootFiles(root, options, mode, currentRoot, manifest) {
|
|
|
663
706
|
'.env.*',
|
|
664
707
|
'!.env.example',
|
|
665
708
|
'*.tsbuildinfo',
|
|
666
|
-
|
|
709
|
+
options.features.electronUpdater ? 'electron-builder.env' : null,
|
|
710
|
+
].filter((value) => value !== null));
|
|
667
711
|
writeRootEnv(root, options);
|
|
668
712
|
writeDatabaseVerificationScript(root, options);
|
|
669
713
|
const vercelIgnore = path.join(root, '.vercelignore');
|
|
670
714
|
const currentIgnore = pathEntryExists(vercelIgnore) ? readFileSync(vercelIgnore, 'utf8') : '';
|
|
671
|
-
const ignoreLines = [...new Set([
|
|
715
|
+
const ignoreLines = [...new Set([
|
|
716
|
+
...currentIgnore.split('\n').filter(Boolean),
|
|
717
|
+
'apps/extension/.output',
|
|
718
|
+
'apps/mobile/dist',
|
|
719
|
+
'apps/desktop/release',
|
|
720
|
+
'apps/assets-private-proxy',
|
|
721
|
+
'apps/desktop-updater-worker',
|
|
722
|
+
])];
|
|
672
723
|
writeFile(vercelIgnore, ignoreLines.join('\n') + '\n');
|
|
673
724
|
writeFile(path.join(root, '.github/workflows/anhedral-ci.yml'), generatedCi(options));
|
|
674
725
|
}
|
|
@@ -678,14 +729,17 @@ function enabledModuleNames(options) {
|
|
|
678
729
|
function writeProjectDocs(root, options, includeUserDocs) {
|
|
679
730
|
const modules = enabledModuleNames(options);
|
|
680
731
|
const storageBucketName = r2BucketName(options.projectName);
|
|
681
|
-
const
|
|
732
|
+
const environmentSetupCommands = [
|
|
682
733
|
'pnpm install',
|
|
683
734
|
options.apps.api ? 'cp apps/api/.env.example apps/api/.env' : null,
|
|
684
735
|
options.features.database ? 'cp packages/db/.env.example packages/db/.env' : null,
|
|
685
736
|
options.apps.web ? 'cp apps/web/.env.example apps/web/.env.local' : null,
|
|
686
737
|
options.apps.mobile ? 'cp apps/mobile/.env.example apps/mobile/.env' : null,
|
|
687
738
|
options.apps.desktop ? 'cp apps/desktop/.env.example apps/desktop/.env' : null,
|
|
739
|
+
options.features.electronUpdater ? 'cp apps/desktop/electron-builder.env.example apps/desktop/electron-builder.env' : null,
|
|
688
740
|
options.apps.extension ? 'cp apps/extension/.env.example apps/extension/.env' : null,
|
|
741
|
+
].filter((value) => value !== null).join('\n');
|
|
742
|
+
const firstVerificationCommands = [
|
|
689
743
|
options.features.database ? 'pnpm db:generate' : null,
|
|
690
744
|
options.features.database ? 'git add packages/db/migrations' : null,
|
|
691
745
|
'pnpm verify',
|
|
@@ -696,8 +750,9 @@ function writeProjectDocs(root, options, includeUserDocs) {
|
|
|
696
750
|
options.apps.mobile ? '| Mobile | EAS Build + App Store Connect + Google Play Console | EAS creates signed binaries; Apple TestFlight/App Review and Google testing tracks/store review control release. |' : null,
|
|
697
751
|
options.features.database ? '| Database | Neon | Provision the project, set `DATABASE_URL`, review/apply Drizzle migrations, and use separate branches or projects for preview and production. |' : null,
|
|
698
752
|
options.features.storage ? '| Object storage | Private Cloudflare R2 + `assets-private-proxy` Worker | Authenticated uploads use presigned R2 URLs; the Worker streams known-key GET/HEAD downloads through `assets.<domain>` while direct bucket access stays disabled. |' : null,
|
|
753
|
+
options.features.electronUpdater ? '| Desktop updates | Private Cloudflare R2 + `desktop-updater` Worker | Signed native artifacts are uploaded to a private bucket; a custom-domain Worker supplies metadata, ranges, and downloads to `electron-updater`. |' : null,
|
|
699
754
|
options.apps.extension ? '| Chrome extension | Chrome Web Store | Build the production ZIP, test it as an unpublished/trusted-tester item, complete privacy disclosures, and submit it for review. |' : null,
|
|
700
|
-
options.apps.desktop ?
|
|
755
|
+
options.apps.desktop ? `| Desktop | electron-builder artifacts${options.features.electronUpdater ? ' + automatic update channel' : ''} | Build and sign on each target OS, then publish through the release channel chosen for the product. |` : null,
|
|
701
756
|
].filter((value) => value !== null).join('\n');
|
|
702
757
|
const deploymentCommands = [
|
|
703
758
|
options.apps.web || options.apps.api ? 'pnpm deploy:vercel:link' : null,
|
|
@@ -710,17 +765,22 @@ function writeProjectDocs(root, options, includeUserDocs) {
|
|
|
710
765
|
options.features.storage ? 'pnpm r2:bucket:create' : null,
|
|
711
766
|
options.features.storage ? 'pnpm assets:proxy:check' : null,
|
|
712
767
|
options.features.storage ? 'pnpm assets:proxy:deploy' : null,
|
|
768
|
+
options.features.electronUpdater ? 'pnpm desktop:updates:cloudflare:login' : null,
|
|
769
|
+
options.features.electronUpdater ? 'pnpm desktop:updates:first-provision' : null,
|
|
770
|
+
options.features.electronUpdater ? 'pnpm desktop:updates:build:mac' : null,
|
|
771
|
+
options.features.electronUpdater ? 'pnpm desktop:updates:publish -- --platform mac --arch arm64' : null,
|
|
713
772
|
options.apps.extension ? 'pnpm extension:zip' : null,
|
|
714
773
|
].filter((value) => value !== null).join('\n');
|
|
715
774
|
const sourceRows = [
|
|
716
775
|
options.apps.web ? '| Web frontend | `apps/web/app/`, `apps/web/components/`, `apps/web/lib/` | Next.js App Router pages, layouts, components, and web utilities. |' : null,
|
|
717
776
|
options.apps.mobile ? '| Mobile frontend | `apps/mobile/app/`, `apps/mobile/components/` | Expo Router screens, layouts, and native UI. |' : null,
|
|
718
|
-
options.apps.api ? '| Backend API | `apps/api/src/routes
|
|
777
|
+
options.apps.api ? '| Backend API | `apps/api/src/routes/app.ts`, `apps/api/src/<feature>.ts` | User-owned Fastify routes and server-only product modules; Anhedral keeps provider routes in managed wiring. |' : null,
|
|
719
778
|
options.apps.desktop ? '| Desktop app | `apps/desktop/src/renderer/`, `apps/desktop/src/main/` | React UI and privileged Electron main-process code. |' : null,
|
|
720
|
-
options.
|
|
721
|
-
options.apps.
|
|
722
|
-
options.apps.api ? '|
|
|
723
|
-
options.
|
|
779
|
+
options.features.electronUpdater ? '| Desktop update edge | `apps/desktop-updater-worker/`, `apps/desktop/scripts/publish-updates.mjs` | Private R2 delivery Worker, custom domain, and ordered release publisher. |' : null,
|
|
780
|
+
options.apps.extension ? '| Browser extension | `apps/extension/src/entrypoints/`, `apps/extension/src/components/` | WXT entrypoints and extension UI. |' : null,
|
|
781
|
+
options.apps.api ? '| Shared API contracts | `packages/contracts/src/app.ts` | User-owned Zod request and response schemas exported beside managed provider contracts. |' : null,
|
|
782
|
+
options.apps.api ? '| Typed API client | `packages/api-client/src/app.ts` | User-owned client-safe methods exported beside the managed fetch client. |' : null,
|
|
783
|
+
options.features.database ? '| Database | `packages/db/src/app-schema.ts`, `packages/db/migrations/` | User-owned Drizzle tables plus reviewed SQL migrations; provider tables remain generated. |' : null,
|
|
724
784
|
].filter((value) => value !== null).join('\n');
|
|
725
785
|
const moduleCommands = [
|
|
726
786
|
options.apps.web ? '- Web: `pnpm dev:web`' : null,
|
|
@@ -740,15 +800,17 @@ function writeProjectDocs(root, options, includeUserDocs) {
|
|
|
740
800
|
options.features.billing ? '| RevenueCat | Subscription authority | API and native client | https://www.revenuecat.com/docs |' : null,
|
|
741
801
|
options.features.billing ? '| Ably | Realtime invalidation | `packages/realtime` | https://ably.com/docs |' : null,
|
|
742
802
|
options.apps.desktop ? '| Electron | Desktop runtime | `apps/desktop` | https://www.electronjs.org/docs/latest/ |' : null,
|
|
803
|
+
options.features.electronUpdater ? '| electron-updater | Packaged-app update checks and installation | `apps/desktop/src/main/main.ts` | https://www.electron.build/auto-update.html |' : null,
|
|
804
|
+
options.features.electronUpdater ? '| Cloudflare Workers + R2 | Private update storage and custom-domain delivery | `apps/desktop-updater-worker` | https://developers.cloudflare.com/r2/api/workers/workers-api-usage/ |' : null,
|
|
743
805
|
options.apps.extension ? '| WXT | Browser extension tooling | `apps/extension` | https://wxt.dev/guide/installation.html |' : null,
|
|
744
806
|
options.apps.web || options.apps.desktop || options.apps.extension ? '| shadcn/ui | Source-owned DOM UI | each DOM app\'s `components/ui` | https://ui.shadcn.com/docs |' : null,
|
|
745
807
|
'| Turborepo | Workspace tasks | root `turbo.json` | https://turborepo.com/docs |',
|
|
746
808
|
].filter((value) => value !== null).join('\n');
|
|
747
809
|
const developmentSteps = [
|
|
748
|
-
options.apps.api ? 'Define shared Zod schemas in `packages/contracts/src
|
|
749
|
-
options.features.database ? 'Define persistent state in `packages/db/src/schema.ts`. Run `pnpm db:generate`, review the SQL, and commit it. This project uses managed Neon—do not add a local Postgres container.' : null,
|
|
750
|
-
options.apps.api ? '
|
|
751
|
-
options.apps.api ? 'Expose client-safe methods from `packages/api-client/src
|
|
810
|
+
options.apps.api ? 'Define shared Zod schemas in user-owned `packages/contracts/src/app.ts` so the network boundary has one definition.' : null,
|
|
811
|
+
options.features.database ? 'Define persistent product state in user-owned `packages/db/src/app-schema.ts`. Run `pnpm db:generate`, review the SQL, and commit it. This project uses managed Neon—do not add a local Postgres container.' : null,
|
|
812
|
+
options.apps.api ? 'Add server-only behavior in a focused `apps/api/src/<feature>.ts` module and register its validated HTTP boundary in user-owned `apps/api/src/routes/app.ts`.' : null,
|
|
813
|
+
options.apps.api ? 'Expose client-safe methods from user-owned `packages/api-client/src/app.ts`; never import API implementation files into a frontend.' : null,
|
|
752
814
|
options.apps.web ? 'Add web routes in `apps/web/app/<route>/page.tsx` and reusable UI in `apps/web/components/`. Use a client component only for browser state, events, or hooks.' : null,
|
|
753
815
|
options.apps.mobile ? 'Add mobile routes in `apps/mobile/app/` and reusable native UI in `apps/mobile/components/`. Keep platform-only APIs in platform-specific modules.' : null,
|
|
754
816
|
'Test the smallest affected package first, then run `pnpm verify`.',
|
|
@@ -759,10 +821,10 @@ function writeProjectDocs(root, options, includeUserDocs) {
|
|
|
759
821
|
Create \`apps/web/app/<route>/page.tsx\`. Follow Next.js App Router conventions directly. Put reusable components under \`apps/web/components/<feature>/\` and web-only helpers under \`apps/web/lib/\`.` : null,
|
|
760
822
|
options.apps.api ? `### Add an API endpoint
|
|
761
823
|
|
|
762
|
-
Define its Zod contract in \`packages/contracts/src
|
|
824
|
+
Define its Zod contract in \`packages/contracts/src/app.ts\`, implement server-only behavior in \`apps/api/src/<feature>.ts\`, and register the Fastify route in \`apps/api/src/routes/app.ts\`. Authenticate on the server and derive the user ID from the verified session.` : null,
|
|
763
825
|
options.features.database ? `### Change the database
|
|
764
826
|
|
|
765
|
-
Edit \`packages/db/src/schema.ts\`, then run:
|
|
827
|
+
Edit \`packages/db/src/app-schema.ts\`, then run:
|
|
766
828
|
|
|
767
829
|
\`\`\`sh
|
|
768
830
|
pnpm db:generate
|
|
@@ -778,10 +840,62 @@ Frontend applications use their generated Clerk provider and hooks. The API veri
|
|
|
778
840
|
|
|
779
841
|
Use \`@shared/api-client\`. The API authorizes the user and creates a short-lived signed R2 upload; the client uploads directly and confirms through the API. Never put R2 credentials in a client or make the bucket public.` : null,
|
|
780
842
|
].filter((value) => value !== null).join('\n\n');
|
|
843
|
+
const verticalSliceExample = options.apps.api && options.features.database ? `## Copy-paste example: a projects feature
|
|
844
|
+
|
|
845
|
+
These files are user-owned extension seams. Anhedral preserves them during \`add\`, \`ui add\`, and \`upgrade\`.
|
|
846
|
+
|
|
847
|
+
\`packages/contracts/src/app.ts\`:
|
|
848
|
+
|
|
849
|
+
\`\`\`ts
|
|
850
|
+
import { z } from 'zod';
|
|
851
|
+
|
|
852
|
+
export const ProjectSchema = z.object({ id: z.string(), name: z.string() });
|
|
853
|
+
export const ProjectListSchema = z.array(ProjectSchema);
|
|
854
|
+
\`\`\`
|
|
855
|
+
|
|
856
|
+
\`packages/db/src/app-schema.ts\`:
|
|
857
|
+
|
|
858
|
+
\`\`\`ts
|
|
859
|
+
import { pgTable, text } from 'drizzle-orm/pg-core';
|
|
860
|
+
|
|
861
|
+
export const projects = pgTable('projects', {
|
|
862
|
+
id: text('id').primaryKey(),
|
|
863
|
+
name: text('name').notNull(),
|
|
864
|
+
});
|
|
865
|
+
\`\`\`
|
|
866
|
+
|
|
867
|
+
\`apps/api/src/routes/app.ts\`:
|
|
868
|
+
|
|
869
|
+
\`\`\`ts
|
|
870
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
871
|
+
import { ProjectListSchema } from '@shared/contracts';
|
|
872
|
+
import { db } from '@shared/db';
|
|
873
|
+
import { projects } from '@shared/db/schema';
|
|
874
|
+
|
|
875
|
+
export const appRoutes: FastifyPluginAsync = async (app) => {
|
|
876
|
+
app.get('/projects', async () => ProjectListSchema.parse(await db.select().from(projects)));
|
|
877
|
+
};
|
|
878
|
+
\`\`\`
|
|
879
|
+
|
|
880
|
+
${options.apps.web || options.apps.mobile || options.apps.desktop || options.apps.extension ? `\`packages/api-client/src/app.ts\`:
|
|
881
|
+
|
|
882
|
+
\`\`\`ts
|
|
883
|
+
import { ProjectListSchema } from '@shared/contracts';
|
|
884
|
+
import { ApiClient } from './generated';
|
|
885
|
+
|
|
886
|
+
export function listProjects(client: ApiClient) {
|
|
887
|
+
return client.request('/projects', { method: 'GET' }, ProjectListSchema);
|
|
888
|
+
}
|
|
889
|
+
\`\`\`
|
|
890
|
+
|
|
891
|
+
Import \`listProjects\` and the generated client factory in the frontend surface you are building.` : ''}
|
|
892
|
+
|
|
893
|
+
Then run \`pnpm db:generate\`, review and commit the migration, run \`pnpm verify\`, apply it to the intended Neon branch with \`pnpm db:migrate\`, and start the relevant app surfaces.` : '';
|
|
781
894
|
const dependencyLines = [
|
|
782
895
|
options.apps.api ? 'frontend apps -> @shared/api-client -> HTTP -> apps/api' : null,
|
|
783
896
|
options.apps.api ? 'frontend apps -> @shared/contracts <- apps/api' : null,
|
|
784
897
|
options.apps.api && options.features.database ? 'apps/api -> @shared/db -> managed Neon Postgres' : null,
|
|
898
|
+
options.features.electronUpdater ? 'apps/desktop -> updates.<domain> Worker -> private R2 bucket' : null,
|
|
785
899
|
].filter((value) => value !== null).join('\n');
|
|
786
900
|
const uiTaskSection = Object.values(options.apps).some(Boolean) && (options.apps.web || options.apps.mobile || options.apps.desktop || options.apps.extension)
|
|
787
901
|
? `### Add UI primitives
|
|
@@ -799,12 +913,19 @@ This is a complete, readable TypeScript product stack generated by Anhedral ${GE
|
|
|
799
913
|
|
|
800
914
|
## Start here
|
|
801
915
|
|
|
916
|
+
Anhedral installs workspace dependencies during generation unless you used \`--skip-install\`. On a fresh clone, install them and copy the selected environment examples:
|
|
917
|
+
|
|
802
918
|
\`\`\`sh
|
|
803
|
-
|
|
804
|
-
pnpm dev
|
|
919
|
+
${environmentSetupCommands}
|
|
805
920
|
\`\`\`
|
|
806
921
|
|
|
807
|
-
|
|
922
|
+
Stop here and replace every required placeholder in those uncommitted files. In particular, \`DATABASE_URL\` must be the pooled connection string for the intended managed Neon branch when the database module is selected. Then generate the initial migration and verify the workspace:
|
|
923
|
+
|
|
924
|
+
\`\`\`sh
|
|
925
|
+
${firstVerificationCommands}
|
|
926
|
+
\`\`\`
|
|
927
|
+
|
|
928
|
+
After verification succeeds, run \`pnpm dev\`. In a full stack this starts the primary web + API development loop, so opening the project does not also launch Expo, Electron, and WXT. Use \`pnpm dev:all\` when you intentionally want every selected surface, or run one surface:
|
|
808
929
|
|
|
809
930
|
${moduleCommands}
|
|
810
931
|
|
|
@@ -828,9 +949,7 @@ DOM clients use source-owned shadcn/ui. Expo uses React Native Reusables with ${
|
|
|
828
949
|
|
|
829
950
|
## Environment and first verification
|
|
830
951
|
|
|
831
|
-
|
|
832
|
-
${commands}
|
|
833
|
-
\`\`\`
|
|
952
|
+
The setup blocks above are the first-run checklist. Provider secrets stay in their package-local environment files; none belong in client code. This project uses managed Neon and intentionally does not generate or start local Postgres.
|
|
834
953
|
|
|
835
954
|
## Deployment
|
|
836
955
|
|
|
@@ -855,6 +974,8 @@ Anhedral generated this stack; it does not replace the frameworks inside it. Use
|
|
|
855
974
|
|
|
856
975
|
${developmentSteps}
|
|
857
976
|
|
|
977
|
+
${verticalSliceExample}
|
|
978
|
+
|
|
858
979
|
## Common tasks
|
|
859
980
|
|
|
860
981
|
${commonTaskSections}
|
|
@@ -975,7 +1096,17 @@ Accounts required: an Expo/EAS account, an Apple Developer Program membership wi
|
|
|
975
1096
|
3. In the Developer Dashboard, create an item and upload the ZIP from \`apps/extension/.output\`. Complete the listing (description, icons, screenshots, category, regions) and keep the manifest version higher for every update.
|
|
976
1097
|
4. State one narrow purpose, justify every permission/host permission, disclose all handled data, certify limited use, and provide a public privacy-policy URL consistent with actual behavior. Clerk login counts as handling authentication data. Supply reviewer test instructions and credentials when features are gated.
|
|
977
1098
|
5. Publish first to trusted testers/unlisted visibility, verify the store-installed CRX ID matches Clerk and backend allowlists, then submit the production listing for review. Store review and rollout are separate from GitHub/Vercel deployment.` : null,
|
|
978
|
-
options.
|
|
1099
|
+
options.features.electronUpdater ? `## Electron desktop and automatic updates
|
|
1100
|
+
|
|
1101
|
+
The generated update path is \`electron-updater -> updates.<domain> Worker -> private ${desktopUpdatesBucketName(options.projectName)} R2 bucket\`. The application never receives Cloudflare credentials, the Worker exposes reads only, and direct bucket access stays disabled.
|
|
1102
|
+
|
|
1103
|
+
1. In \`apps/desktop-updater-worker/wrangler.jsonc\`, replace both \`updates.example.com\` values with a hostname in an active Cloudflare zone. Run \`pnpm desktop:updates:cloudflare:login\`, then run \`pnpm desktop:updates:first-provision\` exactly once to create the private bucket and deploy the bound Worker, managed DNS record, TLS certificate, logs, and custom domain. For repeat deployments use \`pnpm desktop:updates:worker:deploy\`; bucket creation is intentionally not repeated. Keep the bucket's \`r2.dev\` URL and R2 bucket custom-domain access disabled.
|
|
1104
|
+
2. Copy \`apps/desktop/electron-builder.env.example\` to \`apps/desktop/electron-builder.env\` and set \`DESKTOP_UPDATE_BASE_URL\` to that exact HTTPS origin with no trailing slash. electron-builder writes the platform/architecture URL into the packaged app's update configuration; no update secret is embedded.
|
|
1105
|
+
3. Increase \`apps/desktop/package.json\`'s version for every release. Build on each target OS using \`pnpm desktop:updates:build:mac\`, \`pnpm desktop:updates:build:win\`, or \`pnpm desktop:updates:build:linux\`. Configure macOS signing/notarization and Windows code-signing credentials in CI, never in the repository.
|
|
1106
|
+
4. From the repository root, upload one architecture at a time, for example \`pnpm desktop:updates:publish -- --platform mac --arch arm64\`. The publisher accepts only known platform/architecture values, uploads immutable installers and blockmaps first, then uploads mutable \`latest*.yml\` metadata last. Run it only after signing succeeds.
|
|
1107
|
+
5. Install the previous signed version, publish a newer version, and verify update discovery, range downloads, signature validation, restart/install, rollback behavior, and logs on macOS, Windows, and the supported Linux package path. Test clean install, deep links, Clerk return flow, and OS security warnings too.
|
|
1108
|
+
|
|
1109
|
+
The Worker accepts GET/HEAD only, validates the configured hostname and \`releases/\` prefix, supports conditionals and single-range downloads, serves metadata with \`no-store\`, and serves versioned artifacts as immutable. See \`cloudflare/desktop-updates.md\` for the generated resource contract.` : options.apps.desktop ? `## Electron desktop
|
|
979
1110
|
|
|
980
1111
|
Builds are platform-specific: produce macOS, Windows, and Linux artifacts on their matching CI runners with the package's \`build:mac\`, \`build:win\`, and \`build:linux\` scripts. Configure signing/notarization credentials in CI, never in the repository. Test clean install, upgrade, deep links, Clerk return flow, auto-update strategy, and OS security warnings before publishing artifacts to the chosen release channel.` : null,
|
|
981
1112
|
].filter((section) => section !== null);
|
|
@@ -1004,6 +1135,7 @@ Generator: ${GENERATOR_VERSION}
|
|
|
1004
1135
|
Resolved modules: ${modules.join(', ')}
|
|
1005
1136
|
|
|
1006
1137
|
- \`anhedral add <module> --dry-run\` previews incremental changes.
|
|
1138
|
+
- \`anhedral upgrade --dry-run\` previews a supported generator migration.
|
|
1007
1139
|
- \`anhedral ui add <component> --dry-run\` previews platform-routed component additions.
|
|
1008
1140
|
- \`anhedral doctor\` reports manifest and filesystem drift before incremental changes.
|
|
1009
1141
|
- README and existing user workflows are never rewritten by \`anhedral add\`.
|
|
@@ -1013,21 +1145,23 @@ Resolved modules: ${modules.join(', ')}
|
|
|
1013
1145
|
options.apps.mobile ? `- Mobile lives in \`apps/mobile\`: use Expo Router and React Native Reusables with ${options.nativeStyling}. Keep native-only APIs behind platform-safe modules.` : null,
|
|
1014
1146
|
options.apps.api ? '- HTTP and provider integration lives in `apps/api`: keep Fastify routes thin, validate boundaries, and put secrets only in server environment files.' : null,
|
|
1015
1147
|
options.apps.desktop ? '- Desktop lives in `apps/desktop`: keep Electron main/preload privileges minimal and application UI in the sandboxed React renderer.' : null,
|
|
1148
|
+
options.features.electronUpdater ? '- Desktop update delivery lives in `apps/desktop-updater-worker`: keep the R2 bucket private, the Worker read-only, and the Worker Custom Domain aligned with `DESKTOP_UPDATE_BASE_URL`.' : null,
|
|
1016
1149
|
options.apps.extension ? '- Browser extension code lives in `apps/extension`: use WXT entrypoints and request only permissions required by the feature.' : null,
|
|
1017
1150
|
options.features.storage ? '- Private-bucket asset delivery lives in `apps/assets-private-proxy`: preserve the `ASSETS` R2 binding, streaming responses, method/host restrictions, and Cloudflare-managed custom domain.' : null,
|
|
1018
1151
|
].filter((value) => value !== null).join('\n');
|
|
1019
1152
|
const featureGuidance = [
|
|
1020
1153
|
options.apps.api ? '- Reuse `@shared/contracts` at every network boundary and call the API through `@shared/api-client`; clients must not import server implementation modules.' : null,
|
|
1021
|
-
options.features.database ? '- Neon/Drizzle state is authoritative. Change `packages/db/src/schema.ts`, generate SQL with `pnpm db:generate`, review it, and Git-track the migration.' : null,
|
|
1154
|
+
options.features.database ? '- Neon/Drizzle state is authoritative. Change user-owned `packages/db/src/app-schema.ts`, generate SQL with `pnpm db:generate`, review it, and Git-track the migration.' : null,
|
|
1022
1155
|
options.features.auth ? '- Clerk owns identity and sessions. Never trust a client-supplied user ID; derive identity from verified server authentication.' : null,
|
|
1023
1156
|
options.features.billing ? '- RevenueCat events reconcile into Neon before Ably publishes an invalidation. Clients refetch entitlements instead of treating realtime payloads as authority.' : null,
|
|
1024
1157
|
options.features.storage ? '- R2 credentials and signed-upload policy stay in the API. The `assets-private-proxy` Worker uses its binding instead of S3 credentials and publicly serves GET/HEAD only by known unguessable key.' : null,
|
|
1158
|
+
options.features.electronUpdater ? '- Electron releases use `electron-updater` through the generated private R2 + Worker channel. Build signed native artifacts first and publish mutable channel metadata last.' : null,
|
|
1025
1159
|
].filter((value) => value !== null).join('\n');
|
|
1026
1160
|
const skillFeatureSteps = [
|
|
1027
|
-
options.apps.api ? 'Define shared network schemas in `packages/contracts/src
|
|
1028
|
-
options.features.database ? 'Define persistent state in `packages/db/src/schema.ts`; generate, review, and commit Drizzle SQL. Use managed Neon, never a generated local Postgres service.' : null,
|
|
1029
|
-
options.apps.api ? 'Put backend behavior in `apps/api/src
|
|
1030
|
-
options.apps.api ? 'Add client-safe calls to `packages/api-client/src
|
|
1161
|
+
options.apps.api ? 'Define shared network schemas in user-owned `packages/contracts/src/app.ts`.' : null,
|
|
1162
|
+
options.features.database ? 'Define persistent state in user-owned `packages/db/src/app-schema.ts`; generate, review, and commit Drizzle SQL. Use managed Neon, never a generated local Postgres service.' : null,
|
|
1163
|
+
options.apps.api ? 'Put backend behavior in focused `apps/api/src/<feature>.ts` modules and register product routes in user-owned `apps/api/src/routes/app.ts`.' : null,
|
|
1164
|
+
options.apps.api ? 'Add client-safe calls to user-owned `packages/api-client/src/app.ts`; frontends must not import server implementations.' : null,
|
|
1031
1165
|
options.apps.web ? 'Write web product code with normal Next.js App Router conventions in `apps/web`.' : null,
|
|
1032
1166
|
options.apps.mobile ? 'Write native product code with normal Expo Router conventions in `apps/mobile`.' : null,
|
|
1033
1167
|
'Verify the affected package, then run the root verification before handoff.',
|
|
@@ -1050,8 +1184,8 @@ Before implementing a feature, read \`README.md\` for the source map, \`docs/DEV
|
|
|
1050
1184
|
## Start safely
|
|
1051
1185
|
|
|
1052
1186
|
1. Read \`anhedral.json\` for the selected modules, native styling provider, installed UI components, and file ownership.
|
|
1053
|
-
2. Run \`pnpm dlx anhedral@${GENERATOR_VERSION} doctor\` before generator operations.
|
|
1054
|
-
3. Preview structural changes with \`anhedral add <module> --dry-run
|
|
1187
|
+
2. Run \`pnpm dlx anhedral@${GENERATOR_VERSION} doctor\` before generator operations. If it reports a supported older generator, preview and apply \`anhedral upgrade\` before continuing.
|
|
1188
|
+
3. Preview structural changes with \`anhedral upgrade --dry-run\`, \`anhedral add <module> --dry-run\`, or \`anhedral ui add <component> --dry-run\` as applicable.
|
|
1055
1189
|
4. Never hand-edit \`anhedral.json\`, ownership hashes, or bundled-template provenance.
|
|
1056
1190
|
|
|
1057
1191
|
Resolved modules: ${modules.map((moduleName) => `\`${moduleName}\``).join(', ')}.
|
|
@@ -1076,7 +1210,7 @@ ${skillFeatureSteps}
|
|
|
1076
1210
|
|
|
1077
1211
|
## Ownership rules
|
|
1078
1212
|
|
|
1079
|
-
- \`README.md\` and \`
|
|
1213
|
+
- \`README.md\`, \`PRODUCTION.md\`, product UI, and the backend \`app.ts\`/\`app-schema.ts\` extension seams are user-owned. Root JSON/YAML configuration is mergeable. Other recorded files are generator-managed unless \`anhedral.json\` says otherwise.
|
|
1080
1214
|
- Prefer new application files and narrow composition points over editing managed substrate files. If a managed file must change, understand that future \`anhedral add\` operations will stop until the project is reconciled.
|
|
1081
1215
|
- Never overwrite user changes, bypass a generator conflict, or replace a failed ownership check with fabricated hashes.
|
|
1082
1216
|
|
|
@@ -1088,10 +1222,23 @@ ${skillFeatureSteps}
|
|
|
1088
1222
|
|
|
1089
1223
|
## Deployment conventions
|
|
1090
1224
|
|
|
1225
|
+
### Agent-assisted provisioning
|
|
1226
|
+
|
|
1227
|
+
When the user asks you to provision this project:
|
|
1228
|
+
|
|
1229
|
+
1. Ask for the exact environment and custom domain first. Record canonical resource names and selected provider teams without recording secrets.
|
|
1230
|
+
2. Inspect whether Computer Use/browser control and subagents are available. With Computer Use, navigate provider dashboards and complete approved non-secret setup. Stop for the user at sign-in, password, passkey, MFA, CAPTCHA, paid purchase, destructive replacement, secret generation/reveal/rotation, and final release/store submission.
|
|
1231
|
+
3. If subagents are available, keep one lead agent responsible for mutations. Delegate bounded independent tasks only: one subagent reads this project's docs and environment examples, one performs read-only DNS/hostname checks, and one performs final read-only verification. Never send credentials, cookies, or secret values to a subagent, and never let two agents mutate the same provider or DNS zone.
|
|
1232
|
+
4. At every secret-generation screen, stop before the final button. Tell the user the exact button, variable name, and uncommitted package-local environment file. The user clicks and pastes the value directly into that file or the provider's protected field, never into chat. Validate presence without printing populated environment files.
|
|
1233
|
+
5. Read \`PRODUCTION.md\` completely and follow its selection-specific order. Do not claim completion until cloud resources, environment names, DNS, TLS, application health, and the verification commands below pass.
|
|
1234
|
+
|
|
1235
|
+
Domain topology: Cloudflare is authoritative DNS; Vercel hosts web/API; the Vercel A/CNAME/TXT records are created in Cloudflare DNS and normally remain DNS-only. Generated Worker hostnames such as \`assets.<domain>\` or \`updates.<domain>\` are Cloudflare Worker Custom Domains created by Wrangler, not CNAMEs to Vercel or public R2 endpoints.
|
|
1236
|
+
|
|
1091
1237
|
- Read \`PRODUCTION.md\` before provisioning or changing production resources. It is tailored to this project's selected surfaces and providers.
|
|
1092
1238
|
${options.apps.web || options.apps.api ? '- Prefer GitHub-triggered Vercel Preview and Production deployments. Keep the generated Services routing intact; use manual `deploy:vercel:*` scripts only when explicitly required.' : ''}
|
|
1093
1239
|
${options.features.database ? '- Treat reviewed, committed Drizzle SQL as the release artifact. Apply migrations as a controlled step against the intended Neon environment; never generate migrations during an application build.' : ''}
|
|
1094
1240
|
${options.features.storage ? '- Keep the R2 bucket private and deploy `assets-private-proxy` at the Cloudflare Worker custom domain. Expose only `storage/confirmed/` publicly; use the owner-authorized read-URL endpoint for private objects and keep `CLOUDFLARE_API_TOKEN` operations-only.' : ''}
|
|
1241
|
+
${options.features.electronUpdater ? '- Provision the private desktop-update R2 bucket and deploy `desktop-updater` at `updates.<domain>`. Keep the update origin identical in Wrangler and `electron-builder.env`, then publish signed per-platform artifacts before channel metadata.' : ''}
|
|
1095
1242
|
${options.apps.mobile ? '- Promote mobile artifacts through EAS internal distribution, TestFlight/Google Play testing, then store review. Build-time public variables are not secrets.' : ''}
|
|
1096
1243
|
${options.apps.extension ? '- Publish the exact verified WXT ZIP through trusted/unlisted Chrome Web Store testing before production review; preserve a stable CRX ID and least-privilege permissions.' : ''}
|
|
1097
1244
|
|
|
@@ -1128,6 +1275,14 @@ function collectFiles(root, relativeRoot = '') {
|
|
|
1128
1275
|
return files.sort();
|
|
1129
1276
|
}
|
|
1130
1277
|
function ownerForPath(relativePath) {
|
|
1278
|
+
if (relativePath.startsWith('apps/desktop-updater-worker/'))
|
|
1279
|
+
return 'electron-updater';
|
|
1280
|
+
if (relativePath === 'apps/desktop/scripts/publish-updates.mjs')
|
|
1281
|
+
return 'electron-updater';
|
|
1282
|
+
if (relativePath === 'apps/desktop/electron-builder.env.example')
|
|
1283
|
+
return 'electron-updater';
|
|
1284
|
+
if (relativePath === 'cloudflare/desktop-updates.md')
|
|
1285
|
+
return 'electron-updater';
|
|
1131
1286
|
if (relativePath.startsWith('apps/assets-private-proxy/'))
|
|
1132
1287
|
return 'storage';
|
|
1133
1288
|
if (relativePath.startsWith('cloudflare/'))
|
|
@@ -1144,12 +1299,56 @@ function defaultOwnership(relativePath) {
|
|
|
1144
1299
|
return 'user';
|
|
1145
1300
|
if (relativePath === 'apps/assets-private-proxy/wrangler.jsonc')
|
|
1146
1301
|
return 'user';
|
|
1302
|
+
if (relativePath === 'apps/desktop-updater-worker/wrangler.jsonc')
|
|
1303
|
+
return 'user';
|
|
1147
1304
|
if (relativePath === 'cloudflare/r2-cors.template.json')
|
|
1148
1305
|
return 'user';
|
|
1306
|
+
if (relativePath === 'apps/web/app/page.tsx'
|
|
1307
|
+
|| /^apps\/web\/components\//.test(relativePath)
|
|
1308
|
+
|| relativePath === 'apps/mobile/app/index.tsx'
|
|
1309
|
+
|| /^apps\/mobile\/components\//.test(relativePath)
|
|
1310
|
+
|| relativePath === 'apps/desktop/src/main/app-window.ts'
|
|
1311
|
+
|| /^apps\/desktop\/src\/renderer\/components\//.test(relativePath)
|
|
1312
|
+
|| /^apps\/extension\/src\/components\//.test(relativePath)
|
|
1313
|
+
|| relativePath === 'apps/api/src/routes/app.ts'
|
|
1314
|
+
|| /^apps\/api\/src\/services\//.test(relativePath)
|
|
1315
|
+
|| relativePath === 'packages/contracts/src/app.ts'
|
|
1316
|
+
|| relativePath === 'packages/api-client/src/app.ts'
|
|
1317
|
+
|| relativePath === 'packages/db/src/app-schema.ts')
|
|
1318
|
+
return 'user';
|
|
1149
1319
|
if (ROOT_MERGEABLE_FILES.has(relativePath))
|
|
1150
1320
|
return 'mergeable';
|
|
1151
1321
|
return 'managed';
|
|
1152
1322
|
}
|
|
1323
|
+
function applyCurrentOwnership(manifest) {
|
|
1324
|
+
const files = Object.freeze(Object.fromEntries(Object.entries(manifest.files).map(([relativePath, record]) => [
|
|
1325
|
+
relativePath,
|
|
1326
|
+
record.ownership === 'managed' && defaultOwnership(relativePath) === 'user'
|
|
1327
|
+
? Object.freeze({ ...record, ownership: 'user' })
|
|
1328
|
+
: record,
|
|
1329
|
+
])));
|
|
1330
|
+
return Object.freeze({ ...manifest, files });
|
|
1331
|
+
}
|
|
1332
|
+
function preserveUserOwnedFiles(root, stageRoot, manifest) {
|
|
1333
|
+
for (const [relativePath, record] of Object.entries(manifest.files)) {
|
|
1334
|
+
if (record.ownership !== 'user')
|
|
1335
|
+
continue;
|
|
1336
|
+
const current = path.join(root, relativePath);
|
|
1337
|
+
const staged = path.join(stageRoot, relativePath);
|
|
1338
|
+
const currentStat = lstatIfPresent(current);
|
|
1339
|
+
const stagedStat = lstatIfPresent(staged);
|
|
1340
|
+
if (!currentStat?.isFile())
|
|
1341
|
+
continue;
|
|
1342
|
+
if (stagedStat?.isSymbolicLink() || (stagedStat && !stagedStat.isFile())) {
|
|
1343
|
+
throw new Error(`Refusing to replace user-owned path with a non-file: ${relativePath}`);
|
|
1344
|
+
}
|
|
1345
|
+
if (stagedStat?.isFile() && readFileSync(current).equals(readFileSync(staged)))
|
|
1346
|
+
continue;
|
|
1347
|
+
mkdirSync(path.dirname(staged), { recursive: true });
|
|
1348
|
+
copyFileSync(current, staged);
|
|
1349
|
+
chmodSync(staged, Number(currentStat.mode) & 0o777);
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1153
1352
|
function createProjectManifest(root, options, operation, templates, previous, components = previous?.ui.components ?? []) {
|
|
1154
1353
|
const contributions = new Map();
|
|
1155
1354
|
for (const relativePath of collectFiles(root)) {
|
|
@@ -1191,14 +1390,20 @@ function createProjectManifest(root, options, operation, templates, previous, co
|
|
|
1191
1390
|
function writeManifest(root, manifest) {
|
|
1192
1391
|
writeFile(path.join(root, 'anhedral.json'), serializeManifest(manifest));
|
|
1193
1392
|
}
|
|
1194
|
-
function readProjectManifest(root) {
|
|
1393
|
+
function readProjectManifest(root, options = {}) {
|
|
1195
1394
|
const filePath = path.join(root, 'anhedral.json');
|
|
1196
1395
|
if (!pathEntryExists(filePath))
|
|
1197
1396
|
throw new Error('anhedral.json was not found. Run anhedral init first.');
|
|
1198
1397
|
const manifest = readManifest(readFileSync(filePath, 'utf8'));
|
|
1199
1398
|
if (manifest.generatorVersion !== GENERATOR_VERSION) {
|
|
1399
|
+
if (options.allowSupportedUpgrade && isSupportedProjectUpgrade(manifest.generatorVersion, GENERATOR_VERSION)) {
|
|
1400
|
+
return manifest;
|
|
1401
|
+
}
|
|
1402
|
+
const nextStep = isSupportedProjectUpgrade(manifest.generatorVersion, GENERATOR_VERSION)
|
|
1403
|
+
? 'Run anhedral upgrade before adding modules.'
|
|
1404
|
+
: 'Regenerate the project with the current CLI before adding modules.';
|
|
1200
1405
|
throw new Error(`This project was generated by Anhedral ${manifest.generatorVersion}; current CLI ${GENERATOR_VERSION} only supports exact-current projects. `
|
|
1201
|
-
+
|
|
1406
|
+
+ nextStep);
|
|
1202
1407
|
}
|
|
1203
1408
|
assertTemplateProvenance(manifest.modules, manifest.templates);
|
|
1204
1409
|
return manifest;
|
|
@@ -1245,6 +1450,8 @@ async function writeSelectedModules(root, options) {
|
|
|
1245
1450
|
await scaffoldExtension(root, shared);
|
|
1246
1451
|
if (options.features.storage)
|
|
1247
1452
|
scaffoldAssetsPrivateProxy(root, shared);
|
|
1453
|
+
if (options.features.electronUpdater)
|
|
1454
|
+
scaffoldElectronUpdater(root, shared);
|
|
1248
1455
|
return templates;
|
|
1249
1456
|
}
|
|
1250
1457
|
function printPlan(operation, paths, json) {
|
|
@@ -1272,7 +1479,7 @@ function assertSafeChangedPaths(root, stageRoot, manifest, paths, deleting = new
|
|
|
1272
1479
|
continue;
|
|
1273
1480
|
assertManagedFileMode(relativePath, record, targetStat);
|
|
1274
1481
|
const actualHash = hashContent(readFileSync(target));
|
|
1275
|
-
if (record.ownership === 'user') {
|
|
1482
|
+
if (record.ownership === 'user' && actualHash !== record.hash) {
|
|
1276
1483
|
throw new Error(`Refusing to modify user-owned path: ${relativePath}`);
|
|
1277
1484
|
}
|
|
1278
1485
|
if (record.ownership === 'managed' && actualHash !== record.hash) {
|
|
@@ -1374,7 +1581,7 @@ export async function scaffoldUiComponents(uiOptions) {
|
|
|
1374
1581
|
commitPaths,
|
|
1375
1582
|
dryRun: uiOptions.dryRun,
|
|
1376
1583
|
prepare: () => {
|
|
1377
|
-
manifest = readProjectManifest(root);
|
|
1584
|
+
manifest = applyCurrentOwnership(readProjectManifest(root));
|
|
1378
1585
|
assertManagedFileModes(root, manifest);
|
|
1379
1586
|
const installedTargets = UI_TARGETS.filter((target) => manifest.modules.includes(target));
|
|
1380
1587
|
const targets = uiOptions.targets.length > 0 ? uiOptions.targets : installedTargets;
|
|
@@ -1418,6 +1625,7 @@ export async function scaffoldUiComponents(uiOptions) {
|
|
|
1418
1625
|
installUiComponents(stageRoot, additions);
|
|
1419
1626
|
cleanNestedArtifacts(stageRoot);
|
|
1420
1627
|
rmSync(path.join(stageRoot, 'pnpm-lock.yaml'), { force: true });
|
|
1628
|
+
preserveUserOwnedFiles(root, stageRoot, manifest);
|
|
1421
1629
|
writeManifest(stageRoot, createProjectManifest(stageRoot, options, 'add', manifest.templates, manifest, components));
|
|
1422
1630
|
const diff = stagedFileDiff(root, stageRoot, manifest);
|
|
1423
1631
|
assertSafeChangedPaths(root, stageRoot, manifest, [...diff.changed, ...diff.deleted], new Set(diff.deleted));
|
|
@@ -1459,6 +1667,77 @@ export async function scaffoldUiComponents(uiOptions) {
|
|
|
1459
1667
|
env.ANHEDRAL_TOOLCHAIN = previousChannel;
|
|
1460
1668
|
}
|
|
1461
1669
|
}
|
|
1670
|
+
export async function scaffoldUpgradeProject(upgradeOptions) {
|
|
1671
|
+
const root = path.resolve(process.cwd());
|
|
1672
|
+
let manifest;
|
|
1673
|
+
let options;
|
|
1674
|
+
let noOp = false;
|
|
1675
|
+
const seedPaths = [];
|
|
1676
|
+
const commitPaths = [];
|
|
1677
|
+
const deletePaths = [];
|
|
1678
|
+
const previousChannel = env.ANHEDRAL_TOOLCHAIN;
|
|
1679
|
+
try {
|
|
1680
|
+
const planned = await runStagedTransaction(root, {
|
|
1681
|
+
commitPaths,
|
|
1682
|
+
deletePaths,
|
|
1683
|
+
seedPaths,
|
|
1684
|
+
dryRun: upgradeOptions.dryRun,
|
|
1685
|
+
prepare: () => {
|
|
1686
|
+
manifest = applyCurrentOwnership(readProjectManifest(root, { allowSupportedUpgrade: true }));
|
|
1687
|
+
if (manifest.generatorVersion === GENERATOR_VERSION) {
|
|
1688
|
+
noOp = true;
|
|
1689
|
+
return false;
|
|
1690
|
+
}
|
|
1691
|
+
assertManagedFileModes(root, manifest);
|
|
1692
|
+
options = optionsFromManifest(manifest, {
|
|
1693
|
+
modules: [],
|
|
1694
|
+
skipInstall: upgradeOptions.skipInstall,
|
|
1695
|
+
dryRun: upgradeOptions.dryRun,
|
|
1696
|
+
json: upgradeOptions.json,
|
|
1697
|
+
}, manifest.modules);
|
|
1698
|
+
assertInstallNodeCompatibility(options);
|
|
1699
|
+
env.ANHEDRAL_TOOLCHAIN = options.toolchainChannel;
|
|
1700
|
+
seedPaths.push(...new Set([
|
|
1701
|
+
...Object.keys(manifest.files).filter((relativePath) => pathEntryExists(path.join(root, relativePath))),
|
|
1702
|
+
'anhedral.json',
|
|
1703
|
+
]));
|
|
1704
|
+
anhedralPrint.banner(`Upgrading ${manifest.generatorVersion} to ${GENERATOR_VERSION}`);
|
|
1705
|
+
},
|
|
1706
|
+
build: async (stageRoot) => {
|
|
1707
|
+
if (!manifest || !options)
|
|
1708
|
+
throw new Error('Internal upgrade plan was not prepared.');
|
|
1709
|
+
writeRootFiles(stageRoot, options, 'add', root, manifest);
|
|
1710
|
+
const templates = await writeSelectedModules(stageRoot, options);
|
|
1711
|
+
writeProjectDocs(stageRoot, options, false);
|
|
1712
|
+
cleanNestedArtifacts(stageRoot);
|
|
1713
|
+
preserveUserOwnedFiles(root, stageRoot, manifest);
|
|
1714
|
+
writeManifest(stageRoot, createProjectManifest(stageRoot, options, 'add', templates, manifest, manifest.ui.components));
|
|
1715
|
+
const diff = stagedFileDiff(root, stageRoot, manifest);
|
|
1716
|
+
assertSafeChangedPaths(root, stageRoot, manifest, [...diff.changed, ...diff.deleted], new Set(diff.deleted));
|
|
1717
|
+
commitPaths.push(...diff.changed);
|
|
1718
|
+
deletePaths.push(...diff.deleted);
|
|
1719
|
+
},
|
|
1720
|
+
afterCommit: () => {
|
|
1721
|
+
if (!upgradeOptions.skipInstall)
|
|
1722
|
+
installWorkspaceDependencies(root);
|
|
1723
|
+
},
|
|
1724
|
+
});
|
|
1725
|
+
if (noOp) {
|
|
1726
|
+
if (upgradeOptions.json)
|
|
1727
|
+
console.log(JSON.stringify({ operation: 'upgrade', paths: [] }, null, 2));
|
|
1728
|
+
else
|
|
1729
|
+
anhedralPrint.info(`Project is already on Anhedral ${GENERATOR_VERSION}.`);
|
|
1730
|
+
return;
|
|
1731
|
+
}
|
|
1732
|
+
printPlan('upgrade', planned, upgradeOptions.json);
|
|
1733
|
+
}
|
|
1734
|
+
finally {
|
|
1735
|
+
if (previousChannel == null)
|
|
1736
|
+
delete env.ANHEDRAL_TOOLCHAIN;
|
|
1737
|
+
else
|
|
1738
|
+
env.ANHEDRAL_TOOLCHAIN = previousChannel;
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1462
1741
|
export async function scaffoldAddModules(addOptions) {
|
|
1463
1742
|
const root = path.resolve(process.cwd());
|
|
1464
1743
|
let manifest;
|
|
@@ -1476,7 +1755,7 @@ export async function scaffoldAddModules(addOptions) {
|
|
|
1476
1755
|
seedPaths,
|
|
1477
1756
|
dryRun: addOptions.dryRun,
|
|
1478
1757
|
prepare: () => {
|
|
1479
|
-
manifest = readProjectManifest(root);
|
|
1758
|
+
manifest = applyCurrentOwnership(readProjectManifest(root));
|
|
1480
1759
|
assertManagedFileModes(root, manifest);
|
|
1481
1760
|
const desiredRequested = [...new Set([...manifest.modules, ...addOptions.modules])];
|
|
1482
1761
|
const resolution = resolveModules(desiredRequested);
|
|
@@ -1502,6 +1781,7 @@ export async function scaffoldAddModules(addOptions) {
|
|
|
1502
1781
|
const templates = await writeSelectedModules(stageRoot, options);
|
|
1503
1782
|
writeProjectDocs(stageRoot, options, false);
|
|
1504
1783
|
cleanNestedArtifacts(stageRoot);
|
|
1784
|
+
preserveUserOwnedFiles(root, stageRoot, manifest);
|
|
1505
1785
|
writeManifest(stageRoot, createProjectManifest(stageRoot, options, 'add', templates, manifest));
|
|
1506
1786
|
const diff = stagedFileDiff(root, stageRoot, manifest);
|
|
1507
1787
|
assertSafeChangedPaths(root, stageRoot, manifest, [...diff.changed, ...diff.deleted], new Set(diff.deleted));
|
|
@@ -1556,7 +1836,9 @@ export function doctorProject() {
|
|
|
1556
1836
|
issues.push({
|
|
1557
1837
|
path: 'anhedral.json',
|
|
1558
1838
|
severity: 'error',
|
|
1559
|
-
message:
|
|
1839
|
+
message: isSupportedProjectUpgrade(manifest.generatorVersion, GENERATOR_VERSION)
|
|
1840
|
+
? `Project generator ${manifest.generatorVersion} differs from CLI ${GENERATOR_VERSION}; run anhedral upgrade.`
|
|
1841
|
+
: `Project generator ${manifest.generatorVersion} differs from CLI ${GENERATOR_VERSION}; regenerate with the current CLI.`,
|
|
1560
1842
|
});
|
|
1561
1843
|
}
|
|
1562
1844
|
try {
|