@littlebearapps/platform-admin-sdk 1.0.0 → 1.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/README.md CHANGED
@@ -97,9 +97,67 @@ npx wrangler d1 migrations apply my-platform-metrics --remote
97
97
  npx wrangler deploy -c wrangler.my-platform-usage.jsonc
98
98
  ```
99
99
 
100
+ ## Updating Your Platform
101
+
102
+ Starting with v1.1.0, the Admin SDK writes a `.platform-scaffold.json` manifest that tracks what was generated. This enables safe, incremental upgrades.
103
+
104
+ ### Upgrade an existing project
105
+
106
+ ```bash
107
+ cd my-platform
108
+ npx @littlebearapps/platform-admin-sdk upgrade
109
+ ```
110
+
111
+ The upgrade command:
112
+ - **Creates** new files added in the SDK update
113
+ - **Updates** files you haven't modified (compares content hashes)
114
+ - **Skips** files you've customised (with a warning)
115
+ - **Renumbers** new migrations to avoid conflicts with your own
116
+
117
+ Preview changes without writing:
118
+
119
+ ```bash
120
+ npx @littlebearapps/platform-admin-sdk upgrade --dry-run
121
+ ```
122
+
123
+ Upgrade to a higher tier:
124
+
125
+ ```bash
126
+ npx @littlebearapps/platform-admin-sdk upgrade --tier standard
127
+ ```
128
+
129
+ ### Adopt a pre-v1.1.0 project
130
+
131
+ Projects scaffolded before v1.1.0 don't have a manifest. Run `adopt` first:
132
+
133
+ ```bash
134
+ npx @littlebearapps/platform-admin-sdk adopt . --tier minimal --project-name my-platform --skip-prompts
135
+ ```
136
+
137
+ This hashes your existing files as a baseline and writes `.platform-scaffold.json`. You can then run `upgrade` normally.
138
+
139
+ ## Data Safety
140
+
141
+ - The scaffolder **refuses to overwrite** existing directories
142
+ - All generated migrations are **idempotent** (`ON CONFLICT DO NOTHING`)
143
+ - The scaffolder **never modifies** existing Cloudflare resources (D1, KV, Queues)
144
+ - Re-applying migrations to an existing database is safe
145
+
146
+ ## What's Not Included
147
+
148
+ The scaffolder generates core infrastructure only. It does **not** create:
149
+
150
+ - Dashboards or admin UIs
151
+ - Email workers or notification templates
152
+ - Data connectors (Stripe, GA4, Plausible, etc.)
153
+ - Test suites
154
+ - CI/CD workflows
155
+
156
+ These are project-specific — build them as you need them.
157
+
100
158
  ## Consumer SDK
101
159
 
102
- The generated workers use `@littlebearapps/platform-consumer-sdk` — the Consumer SDK. Install it in your application workers:
160
+ The generated workers use `@littlebearapps/platform-consumer-sdk` — the Consumer SDK. Install it in your application workers to send telemetry to the platform backend:
103
161
 
104
162
  ```bash
105
163
  npm install @littlebearapps/platform-consumer-sdk
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Adopt an existing scaffold that was created before the manifest system.
3
+ *
4
+ * Hashes all files on disk as a baseline and writes `.platform-scaffold.json`
5
+ * so the project can be upgraded in future.
6
+ */
7
+ import type { Tier } from './prompts.js';
8
+ export interface AdoptOptions {
9
+ projectName: string;
10
+ projectSlug: string;
11
+ githubOrg: string;
12
+ tier: Tier;
13
+ gatusUrl: string;
14
+ defaultAssignee: string;
15
+ /** SDK version that originally generated the scaffold (defaults to current). */
16
+ fromVersion?: string;
17
+ }
18
+ export declare function adopt(projectDir: string, options: AdoptOptions): void;
package/dist/adopt.js ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Adopt an existing scaffold that was created before the manifest system.
3
+ *
4
+ * Hashes all files on disk as a baseline and writes `.platform-scaffold.json`
5
+ * so the project can be upgraded in future.
6
+ */
7
+ import { readFileSync, existsSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import pc from 'picocolors';
10
+ import { getFilesForTier, SDK_VERSION, isMigrationFile } from './templates.js';
11
+ import { hashContent, writeManifest, buildManifest, readManifest, MANIFEST_FILENAME } from './manifest.js';
12
+ import { getMigrationNumber } from './migrations.js';
13
+ export function adopt(projectDir, options) {
14
+ if (!existsSync(projectDir)) {
15
+ throw new Error(`Directory does not exist: ${projectDir}`);
16
+ }
17
+ if (readManifest(projectDir) !== null) {
18
+ throw new Error(`${projectDir} already has a ${MANIFEST_FILENAME}. ` +
19
+ `Use \`platform-admin-sdk upgrade\` instead.`);
20
+ }
21
+ const files = getFilesForTier(options.tier);
22
+ // Build context for path rendering
23
+ const context = {
24
+ projectName: options.projectName,
25
+ projectSlug: options.projectSlug,
26
+ };
27
+ // Hash all files that exist on disk
28
+ const fileHashes = {};
29
+ let matched = 0;
30
+ for (const file of files) {
31
+ let destRelative = file.dest;
32
+ for (const [key, value] of Object.entries(context)) {
33
+ destRelative = destRelative.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value);
34
+ }
35
+ const destPath = join(projectDir, destRelative);
36
+ if (existsSync(destPath)) {
37
+ const content = readFileSync(destPath, 'utf-8');
38
+ fileHashes[destRelative] = hashContent(content);
39
+ matched++;
40
+ }
41
+ }
42
+ // highestScaffoldMigration is the highest migration the SDK owns for this tier,
43
+ // NOT the highest migration on disk (which includes user-created migrations)
44
+ let highestScaffoldMigration = 0;
45
+ for (const file of files.filter(isMigrationFile)) {
46
+ const num = getMigrationNumber(file.dest);
47
+ if (num !== null && num > highestScaffoldMigration) {
48
+ highestScaffoldMigration = num;
49
+ }
50
+ }
51
+ const sdkVersion = options.fromVersion ?? SDK_VERSION;
52
+ const manifest = buildManifest(sdkVersion, options.tier, {
53
+ projectName: options.projectName,
54
+ projectSlug: options.projectSlug,
55
+ githubOrg: options.githubOrg,
56
+ gatusUrl: options.gatusUrl,
57
+ defaultAssignee: options.defaultAssignee,
58
+ }, fileHashes, highestScaffoldMigration);
59
+ writeManifest(projectDir, manifest);
60
+ console.log(` ${pc.green('create')} ${MANIFEST_FILENAME}`);
61
+ console.log(` ${pc.dim(`Matched ${matched} of ${files.length} expected files.`)}`);
62
+ console.log(` ${pc.dim(`SDK migrations: up to ${highestScaffoldMigration || 'none'}`)}`);
63
+ console.log(` ${pc.dim(`Tier: ${options.tier}, SDK: ${sdkVersion}`)}`);
64
+ }
package/dist/index.d.ts CHANGED
@@ -2,15 +2,19 @@
2
2
  /**
3
3
  * @littlebearapps/platform-admin-sdk
4
4
  *
5
- * Scaffolds a Cloudflare Workers platform with SDK integration,
5
+ * Scaffolds and upgrades a Cloudflare Workers platform with SDK integration,
6
6
  * circuit breakers, and cost protection.
7
7
  *
8
8
  * Usage:
9
9
  * npx @littlebearapps/platform-admin-sdk [project-name] [options]
10
+ * npx @littlebearapps/platform-admin-sdk upgrade [project-dir] [options]
11
+ * npx @littlebearapps/platform-admin-sdk adopt [project-dir] [options]
10
12
  *
11
13
  * Examples:
12
14
  * npx @littlebearapps/platform-admin-sdk my-project
13
15
  * npx @littlebearapps/platform-admin-sdk my-project --tier full --github-org myorg
14
- * npx @littlebearapps/platform-admin-sdk my-project --tier minimal --skip-prompts
16
+ * npx @littlebearapps/platform-admin-sdk upgrade ./my-project
17
+ * npx @littlebearapps/platform-admin-sdk upgrade ./my-project --tier standard --dry-run
18
+ * npx @littlebearapps/platform-admin-sdk adopt ./my-project --tier minimal --skip-prompts
15
19
  */
16
20
  export {};
package/dist/index.js CHANGED
@@ -2,53 +2,54 @@
2
2
  /**
3
3
  * @littlebearapps/platform-admin-sdk
4
4
  *
5
- * Scaffolds a Cloudflare Workers platform with SDK integration,
5
+ * Scaffolds and upgrades a Cloudflare Workers platform with SDK integration,
6
6
  * circuit breakers, and cost protection.
7
7
  *
8
8
  * Usage:
9
9
  * npx @littlebearapps/platform-admin-sdk [project-name] [options]
10
+ * npx @littlebearapps/platform-admin-sdk upgrade [project-dir] [options]
11
+ * npx @littlebearapps/platform-admin-sdk adopt [project-dir] [options]
10
12
  *
11
13
  * Examples:
12
14
  * npx @littlebearapps/platform-admin-sdk my-project
13
15
  * npx @littlebearapps/platform-admin-sdk my-project --tier full --github-org myorg
14
- * npx @littlebearapps/platform-admin-sdk my-project --tier minimal --skip-prompts
16
+ * npx @littlebearapps/platform-admin-sdk upgrade ./my-project
17
+ * npx @littlebearapps/platform-admin-sdk upgrade ./my-project --tier standard --dry-run
18
+ * npx @littlebearapps/platform-admin-sdk adopt ./my-project --tier minimal --skip-prompts
15
19
  */
16
20
  import { resolve } from 'node:path';
17
21
  import { Command } from 'commander';
18
22
  import pc from 'picocolors';
19
23
  import { collectOptions, isValidTier } from './prompts.js';
20
24
  import { scaffold } from './scaffold.js';
25
+ import { upgrade } from './upgrade.js';
26
+ import { adopt } from './adopt.js';
27
+ import { SDK_VERSION } from './templates.js';
21
28
  const BANNER = `
22
29
  ${pc.bold(pc.cyan('Platform Admin SDK'))} — Cloudflare Cost Protection
23
30
  ${pc.dim('Scaffold backend infrastructure: circuit breakers, budget enforcement, error collection')}
24
31
  `;
25
- const program = new Command()
26
- .name('platform-admin-sdk')
27
- .description('Scaffold a Cloudflare Workers platform with SDK integration')
28
- .version('1.0.0')
32
+ // --- Scaffold command (default) ---
33
+ const scaffoldCmd = new Command('scaffold')
34
+ .description('Scaffold a new platform project (default)')
29
35
  .argument('[project-name]', 'Name of the project to create')
30
36
  .option('--tier <tier>', 'Infrastructure tier (minimal, standard, full)')
31
37
  .option('--github-org <org>', 'GitHub organisation for error issue creation')
32
38
  .option('--gatus-url <url>', 'Gatus status page URL for heartbeat monitoring')
33
39
  .option('--default-assignee <user>', 'Default GitHub assignee for error issues')
34
- .option('--skip-prompts', 'Non-interactive mode — fail if required flags are missing');
35
- async function main() {
36
- console.log(BANNER);
37
- program.parse();
38
- const opts = program.opts();
39
- const [projectNameArg] = program.args;
40
- // Validate tier if provided
41
- if (opts.tier && !isValidTier(opts.tier)) {
42
- console.error(pc.red(` Error: Invalid tier "${opts.tier}". Must be one of: minimal, standard, full`));
40
+ .option('--skip-prompts', 'Non-interactive mode — fail if required flags are missing')
41
+ .action(async (projectNameArg, cmdOpts) => {
42
+ if (cmdOpts.tier && !isValidTier(cmdOpts.tier)) {
43
+ console.error(pc.red(` Error: Invalid tier "${cmdOpts.tier}". Must be one of: minimal, standard, full`));
43
44
  process.exit(1);
44
45
  }
45
46
  const options = await collectOptions({
46
47
  projectName: projectNameArg,
47
- tier: opts.tier,
48
- githubOrg: opts.githubOrg,
49
- gatusUrl: opts.gatusUrl,
50
- defaultAssignee: opts.defaultAssignee,
51
- skipPrompts: opts.skipPrompts,
48
+ tier: cmdOpts.tier,
49
+ githubOrg: cmdOpts.githubOrg,
50
+ gatusUrl: cmdOpts.gatusUrl,
51
+ defaultAssignee: cmdOpts.defaultAssignee,
52
+ skipPrompts: cmdOpts.skipPrompts,
52
53
  });
53
54
  const outputDir = resolve(process.cwd(), options.projectName);
54
55
  console.log();
@@ -82,6 +83,115 @@ async function main() {
82
83
  console.log(` ${pc.dim('# In your consumer projects:')}`);
83
84
  console.log(` ${pc.cyan('npm install @littlebearapps/platform-consumer-sdk')}`);
84
85
  console.log();
86
+ });
87
+ // --- Upgrade command ---
88
+ const upgradeCmd = new Command('upgrade')
89
+ .description('Upgrade an existing scaffolded project to the latest SDK version')
90
+ .argument('[project-dir]', 'Path to the project directory', '.')
91
+ .option('--tier <tier>', 'Upgrade to a higher tier (minimal → standard → full)')
92
+ .option('--dry-run', 'Show what would change without writing files')
93
+ .action(async (projectDirArg, cmdOpts) => {
94
+ if (cmdOpts.tier && !isValidTier(cmdOpts.tier)) {
95
+ console.error(pc.red(` Error: Invalid tier "${cmdOpts.tier}". Must be one of: minimal, standard, full`));
96
+ process.exit(1);
97
+ }
98
+ const projectDir = resolve(process.cwd(), projectDirArg);
99
+ console.log();
100
+ console.log(` ${pc.bold('Upgrading')}: ${projectDir}`);
101
+ if (cmdOpts.tier)
102
+ console.log(` ${pc.bold('Target tier')}: ${cmdOpts.tier}`);
103
+ if (cmdOpts.dryRun)
104
+ console.log(` ${pc.yellow('DRY RUN')} — no files will be written`);
105
+ console.log();
106
+ const result = await upgrade(projectDir, {
107
+ tier: cmdOpts.tier,
108
+ dryRun: cmdOpts.dryRun,
109
+ });
110
+ console.log();
111
+ const total = result.created.length + result.updated.length + result.migrations.length;
112
+ if (total === 0 && result.skipped.length === 0) {
113
+ console.log(pc.green(' Already up to date.'));
114
+ }
115
+ else {
116
+ console.log(` ${pc.green(`${result.created.length} created`)}, ${pc.cyan(`${result.updated.length} updated`)}, ${pc.yellow(`${result.skipped.length} skipped`)}, ${pc.green(`${result.migrations.length} migrations`)}`);
117
+ }
118
+ if (result.removed.length > 0) {
119
+ console.log(` ${pc.yellow(`${result.removed.length} files removed from SDK (kept on disk)`)}`);
120
+ }
121
+ console.log();
122
+ if (result.migrations.length > 0 && !cmdOpts.dryRun) {
123
+ console.log(` ${pc.bold('Run migrations:')}`);
124
+ console.log(` ${pc.cyan('npx wrangler d1 migrations apply')} YOUR_DB --remote`);
125
+ console.log();
126
+ }
127
+ });
128
+ // --- Adopt command ---
129
+ const adoptCmd = new Command('adopt')
130
+ .description('Add upgrade support to a project scaffolded before v1.1.0')
131
+ .argument('[project-dir]', 'Path to the project directory', '.')
132
+ .option('--tier <tier>', 'Infrastructure tier (minimal, standard, full)')
133
+ .option('--project-name <name>', 'Project name (as used during scaffold)')
134
+ .option('--project-slug <slug>', 'Project slug (for resource naming)')
135
+ .option('--github-org <org>', 'GitHub organisation')
136
+ .option('--gatus-url <url>', 'Gatus status page URL')
137
+ .option('--default-assignee <user>', 'Default GitHub assignee')
138
+ .option('--from-version <version>', 'SDK version that originally generated the scaffold')
139
+ .option('--skip-prompts', 'Non-interactive mode — fail if required flags are missing')
140
+ .action(async (projectDirArg, cmdOpts) => {
141
+ if (cmdOpts.tier && !isValidTier(cmdOpts.tier)) {
142
+ console.error(pc.red(` Error: Invalid tier "${cmdOpts.tier}". Must be one of: minimal, standard, full`));
143
+ process.exit(1);
144
+ }
145
+ const projectDir = resolve(process.cwd(), projectDirArg);
146
+ // Collect options — reuse the prompt system for adopt
147
+ const options = await collectOptions({
148
+ projectName: cmdOpts.projectName,
149
+ tier: cmdOpts.tier,
150
+ githubOrg: cmdOpts.githubOrg,
151
+ gatusUrl: cmdOpts.gatusUrl,
152
+ defaultAssignee: cmdOpts.defaultAssignee,
153
+ skipPrompts: cmdOpts.skipPrompts,
154
+ });
155
+ console.log();
156
+ console.log(` ${pc.bold('Adopting')}: ${projectDir}`);
157
+ console.log(` ${pc.bold('Tier')}: ${options.tier}`);
158
+ console.log();
159
+ adopt(projectDir, {
160
+ projectName: options.projectName,
161
+ projectSlug: options.projectSlug,
162
+ githubOrg: options.githubOrg,
163
+ tier: options.tier,
164
+ gatusUrl: options.gatusUrl,
165
+ defaultAssignee: options.defaultAssignee,
166
+ fromVersion: cmdOpts.fromVersion,
167
+ });
168
+ console.log();
169
+ console.log(pc.green(pc.bold(' Done!')));
170
+ console.log(` ${pc.dim('You can now run:')} ${pc.cyan('platform-admin-sdk upgrade')}`);
171
+ console.log();
172
+ });
173
+ // --- Main program ---
174
+ const program = new Command()
175
+ .name('platform-admin-sdk')
176
+ .description('Scaffold and upgrade Cloudflare Workers platform infrastructure')
177
+ .version(SDK_VERSION)
178
+ .addCommand(scaffoldCmd)
179
+ .addCommand(upgradeCmd)
180
+ .addCommand(adoptCmd);
181
+ async function main() {
182
+ console.log(BANNER);
183
+ // Backward compat: if first arg isn't a known subcommand, treat it as `scaffold <arg>`
184
+ const args = process.argv.slice(2);
185
+ const subcommands = ['scaffold', 'upgrade', 'adopt', 'help', '--help', '-h', '--version', '-V'];
186
+ if (args.length > 0 && !subcommands.includes(args[0])) {
187
+ // Inject 'scaffold' as the subcommand
188
+ process.argv.splice(2, 0, 'scaffold');
189
+ }
190
+ else if (args.length === 0) {
191
+ // No args at all — default to scaffold (which will prompt interactively)
192
+ process.argv.splice(2, 0, 'scaffold');
193
+ }
194
+ await program.parseAsync();
85
195
  }
86
196
  main().catch((error) => {
87
197
  console.error(pc.red('Error:'), error instanceof Error ? error.message : String(error));
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Scaffold manifest — tracks what was generated so upgrades can detect changes.
3
+ *
4
+ * Written to `.platform-scaffold.json` in the project root.
5
+ */
6
+ import type { Tier } from './prompts.js';
7
+ export declare const MANIFEST_FILENAME = ".platform-scaffold.json";
8
+ export declare const MANIFEST_VERSION = 1;
9
+ export interface ManifestContext {
10
+ projectName: string;
11
+ projectSlug: string;
12
+ githubOrg: string;
13
+ gatusUrl: string;
14
+ defaultAssignee: string;
15
+ }
16
+ export interface ScaffoldManifest {
17
+ /** Manifest format version (for future-proofing). */
18
+ manifestVersion: number;
19
+ /** Admin SDK version that generated this scaffold. */
20
+ sdkVersion: string;
21
+ /** ISO 8601 timestamp of scaffold or last upgrade. */
22
+ generatedAt: string;
23
+ /** Infrastructure tier. */
24
+ tier: Tier;
25
+ /** Persisted context variables for re-rendering templates. */
26
+ context: ManifestContext;
27
+ /** Map of relative output path to SHA-256 hash of the content as-generated. */
28
+ files: Record<string, string>;
29
+ /** Highest migration number owned by the scaffolder (user migrations are higher). */
30
+ highestScaffoldMigration: number;
31
+ }
32
+ /** SHA-256 hash of file content. */
33
+ export declare function hashContent(content: string): string;
34
+ /** Read manifest from a project directory. Returns null if not found. */
35
+ export declare function readManifest(projectDir: string): ScaffoldManifest | null;
36
+ /** Write manifest to a project directory. */
37
+ export declare function writeManifest(projectDir: string, manifest: ScaffoldManifest): void;
38
+ /** Build a new manifest from components. */
39
+ export declare function buildManifest(sdkVersion: string, tier: Tier, context: ManifestContext, fileHashes: Record<string, string>, highestScaffoldMigration: number): ScaffoldManifest;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Scaffold manifest — tracks what was generated so upgrades can detect changes.
3
+ *
4
+ * Written to `.platform-scaffold.json` in the project root.
5
+ */
6
+ import { createHash } from 'node:crypto';
7
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ export const MANIFEST_FILENAME = '.platform-scaffold.json';
10
+ export const MANIFEST_VERSION = 1;
11
+ /** SHA-256 hash of file content. */
12
+ export function hashContent(content) {
13
+ return createHash('sha256').update(content, 'utf-8').digest('hex');
14
+ }
15
+ /** Read manifest from a project directory. Returns null if not found. */
16
+ export function readManifest(projectDir) {
17
+ const manifestPath = join(projectDir, MANIFEST_FILENAME);
18
+ if (!existsSync(manifestPath))
19
+ return null;
20
+ const raw = readFileSync(manifestPath, 'utf-8');
21
+ const parsed = JSON.parse(raw);
22
+ if (parsed.manifestVersion !== MANIFEST_VERSION) {
23
+ throw new Error(`Manifest version ${parsed.manifestVersion} is not supported ` +
24
+ `(expected ${MANIFEST_VERSION}). Please upgrade the Admin SDK.`);
25
+ }
26
+ return parsed;
27
+ }
28
+ /** Write manifest to a project directory. */
29
+ export function writeManifest(projectDir, manifest) {
30
+ const manifestPath = join(projectDir, MANIFEST_FILENAME);
31
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
32
+ }
33
+ /** Build a new manifest from components. */
34
+ export function buildManifest(sdkVersion, tier, context, fileHashes, highestScaffoldMigration) {
35
+ return {
36
+ manifestVersion: MANIFEST_VERSION,
37
+ sdkVersion,
38
+ generatedAt: new Date().toISOString(),
39
+ tier,
40
+ context,
41
+ files: fileHashes,
42
+ highestScaffoldMigration,
43
+ };
44
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Migration numbering utilities for safe upgrades.
3
+ *
4
+ * Handles renumbering scaffold-owned migrations so they don't conflict
5
+ * with user-created migrations.
6
+ */
7
+ /** Parse the highest NNN_ migration number from a directory. Returns 0 if empty. */
8
+ export declare function findHighestMigration(migrationsDir: string): number;
9
+ /** Extract the NNN migration number from a path like `storage/d1/migrations/005_error.sql`. */
10
+ export declare function getMigrationNumber(destPath: string): number | null;
11
+ /** Renumber a migration filename: `005_error.sql` with nextNumber=12 → `012_error.sql`. */
12
+ export declare function renumberMigration(filename: string, nextNumber: number): string;
13
+ export interface PlannedMigration {
14
+ /** New destination path (renumbered). */
15
+ dest: string;
16
+ /** File content (unchanged). */
17
+ content: string;
18
+ /** Original scaffold destination path (before renumbering). */
19
+ originalDest: string;
20
+ }
21
+ /**
22
+ * Plan which migrations are new and what numbers they should get.
23
+ *
24
+ * Filters to migrations with numbers > lastAppliedScaffoldMigration,
25
+ * then renumbers sequentially starting after the user's highest migration.
26
+ */
27
+ export declare function planMigrations(scaffoldMigrations: Array<{
28
+ originalDest: string;
29
+ content: string;
30
+ }>, lastAppliedScaffoldMigration: number, userHighestMigration: number): PlannedMigration[];
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Migration numbering utilities for safe upgrades.
3
+ *
4
+ * Handles renumbering scaffold-owned migrations so they don't conflict
5
+ * with user-created migrations.
6
+ */
7
+ import { readdirSync } from 'node:fs';
8
+ /** Parse the highest NNN_ migration number from a directory. Returns 0 if empty. */
9
+ export function findHighestMigration(migrationsDir) {
10
+ let files;
11
+ try {
12
+ files = readdirSync(migrationsDir);
13
+ }
14
+ catch {
15
+ return 0;
16
+ }
17
+ let highest = 0;
18
+ for (const file of files) {
19
+ const match = file.match(/^(\d{3})_/);
20
+ if (match) {
21
+ const num = parseInt(match[1], 10);
22
+ if (num > highest)
23
+ highest = num;
24
+ }
25
+ }
26
+ return highest;
27
+ }
28
+ /** Extract the NNN migration number from a path like `storage/d1/migrations/005_error.sql`. */
29
+ export function getMigrationNumber(destPath) {
30
+ const match = destPath.match(/(\d{3})_[^/]+\.sql$/);
31
+ return match ? parseInt(match[1], 10) : null;
32
+ }
33
+ /** Renumber a migration filename: `005_error.sql` with nextNumber=12 → `012_error.sql`. */
34
+ export function renumberMigration(filename, nextNumber) {
35
+ return filename.replace(/^\d{3}_/, `${String(nextNumber).padStart(3, '0')}_`);
36
+ }
37
+ /**
38
+ * Plan which migrations are new and what numbers they should get.
39
+ *
40
+ * Filters to migrations with numbers > lastAppliedScaffoldMigration,
41
+ * then renumbers sequentially starting after the user's highest migration.
42
+ */
43
+ export function planMigrations(scaffoldMigrations, lastAppliedScaffoldMigration, userHighestMigration) {
44
+ // Filter to only new scaffold migrations
45
+ const newMigrations = scaffoldMigrations.filter((m) => {
46
+ const num = getMigrationNumber(m.originalDest);
47
+ return num !== null && num > lastAppliedScaffoldMigration;
48
+ });
49
+ // Sort by original number
50
+ newMigrations.sort((a, b) => {
51
+ const aNum = getMigrationNumber(a.originalDest) ?? 0;
52
+ const bNum = getMigrationNumber(b.originalDest) ?? 0;
53
+ return aNum - bNum;
54
+ });
55
+ // Renumber sequentially after user's highest
56
+ let nextNum = userHighestMigration + 1;
57
+ return newMigrations.map((m) => {
58
+ const originalFilename = m.originalDest.split('/').pop();
59
+ const newFilename = renumberMigration(originalFilename, nextNum++);
60
+ const dir = m.originalDest.substring(0, m.originalDest.lastIndexOf('/'));
61
+ return {
62
+ dest: `${dir}/${newFilename}`,
63
+ content: m.content,
64
+ originalDest: m.originalDest,
65
+ };
66
+ });
67
+ }
package/dist/scaffold.js CHANGED
@@ -6,7 +6,9 @@ import { resolve, dirname, join } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import Handlebars from 'handlebars';
8
8
  import pc from 'picocolors';
9
- import { getFilesForTier } from './templates.js';
9
+ import { getFilesForTier, SDK_VERSION } from './templates.js';
10
+ import { hashContent, buildManifest, writeManifest, MANIFEST_FILENAME } from './manifest.js';
11
+ import { findHighestMigration } from './migrations.js';
10
12
  const __filename = fileURLToPath(import.meta.url);
11
13
  const __dirname = dirname(__filename);
12
14
  function getTemplatesDir() {
@@ -27,6 +29,10 @@ function renderString(template, context) {
27
29
  }
28
30
  export async function scaffold(options, outputDir) {
29
31
  if (existsSync(outputDir)) {
32
+ if (existsSync(join(outputDir, MANIFEST_FILENAME))) {
33
+ throw new Error(`${outputDir} is an existing scaffold. Use:\n` +
34
+ ` platform-admin-sdk upgrade ${outputDir}`);
35
+ }
30
36
  throw new Error(`Directory already exists: ${outputDir}`);
31
37
  }
32
38
  const templatesDir = getTemplatesDir();
@@ -38,9 +44,10 @@ export async function scaffold(options, outputDir) {
38
44
  tier: options.tier,
39
45
  gatusUrl: options.gatusUrl,
40
46
  defaultAssignee: options.defaultAssignee,
41
- sdkVersion: '0.2.0',
47
+ sdkVersion: SDK_VERSION,
42
48
  };
43
49
  mkdirSync(outputDir, { recursive: true });
50
+ const fileHashes = {};
44
51
  for (const file of files) {
45
52
  const srcPath = join(templatesDir, file.src);
46
53
  const destPath = join(outputDir, renderString(file.dest, context));
@@ -51,15 +58,29 @@ export async function scaffold(options, outputDir) {
51
58
  continue;
52
59
  }
53
60
  const raw = readFileSync(srcPath, 'utf-8');
61
+ let content;
54
62
  if (file.template) {
55
63
  const compiled = Handlebars.compile(raw, { noEscape: true });
56
- const rendered = compiled(context);
57
- writeFileSync(destPath, rendered);
64
+ content = compiled(context);
58
65
  }
59
66
  else {
60
- writeFileSync(destPath, raw);
67
+ content = raw;
61
68
  }
69
+ writeFileSync(destPath, content);
62
70
  const relDest = destPath.replace(outputDir + '/', '');
71
+ fileHashes[relDest] = hashContent(content);
63
72
  console.log(` ${pc.green('create')} ${relDest}`);
64
73
  }
74
+ // Write manifest for future upgrades
75
+ const migrationsDir = join(outputDir, 'storage/d1/migrations');
76
+ const highestMigration = findHighestMigration(migrationsDir);
77
+ const manifest = buildManifest(SDK_VERSION, options.tier, {
78
+ projectName: options.projectName,
79
+ projectSlug: options.projectSlug,
80
+ githubOrg: options.githubOrg,
81
+ gatusUrl: options.gatusUrl,
82
+ defaultAssignee: options.defaultAssignee,
83
+ }, fileHashes, highestMigration);
84
+ writeManifest(outputDir, manifest);
85
+ console.log(` ${pc.green('create')} ${MANIFEST_FILENAME}`);
65
86
  }
@@ -5,6 +5,12 @@
5
5
  * All other files are copied verbatim.
6
6
  */
7
7
  import type { Tier } from './prompts.js';
8
+ /** Single source of truth for the SDK version. */
9
+ export declare const SDK_VERSION = "1.1.0";
10
+ /** Returns true if `to` is the same or higher tier than `from`. */
11
+ export declare function isTierUpgradeOrSame(from: Tier, to: Tier): boolean;
12
+ /** Check if a template file is a numbered migration (not seed.sql). */
13
+ export declare function isMigrationFile(file: TemplateFile): boolean;
8
14
  export interface TemplateFile {
9
15
  /** Path relative to the templates/ directory */
10
16
  src: string;
package/dist/templates.js CHANGED
@@ -4,6 +4,18 @@
4
4
  * Files ending in .hbs are rendered through Handlebars.
5
5
  * All other files are copied verbatim.
6
6
  */
7
+ /** Single source of truth for the SDK version. */
8
+ export const SDK_VERSION = '1.1.0';
9
+ /** Tier ordering for upgrade validation. */
10
+ const TIER_ORDER = { minimal: 0, standard: 1, full: 2 };
11
+ /** Returns true if `to` is the same or higher tier than `from`. */
12
+ export function isTierUpgradeOrSame(from, to) {
13
+ return TIER_ORDER[to] >= TIER_ORDER[from];
14
+ }
15
+ /** Check if a template file is a numbered migration (not seed.sql). */
16
+ export function isMigrationFile(file) {
17
+ return /migrations\/\d{3}_[^/]+\.sql$/.test(file.dest);
18
+ }
7
19
  const SHARED_FILES = [
8
20
  // Config
9
21
  { src: 'shared/config/services.yaml.hbs', dest: 'platform/config/services.yaml', template: true },
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Upgrade an existing scaffolded project to a newer SDK version.
3
+ *
4
+ * For each file:
5
+ * - New file (not on disk) → create it
6
+ * - Unchanged by user (disk hash == manifest hash) → overwrite with new version
7
+ * - Modified by user (disk hash != manifest hash) → skip with warning
8
+ * - Removed from SDK (in manifest, not in new template list) → warn, don't delete
9
+ *
10
+ * Migrations are renumbered to avoid conflicts with user-created migrations.
11
+ */
12
+ import type { Tier } from './prompts.js';
13
+ export interface UpgradeOptions {
14
+ tier?: Tier;
15
+ dryRun?: boolean;
16
+ }
17
+ export interface UpgradeResult {
18
+ created: string[];
19
+ updated: string[];
20
+ skipped: string[];
21
+ removed: string[];
22
+ migrations: string[];
23
+ }
24
+ export declare function upgrade(projectDir: string, options?: UpgradeOptions): Promise<UpgradeResult>;
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Upgrade an existing scaffolded project to a newer SDK version.
3
+ *
4
+ * For each file:
5
+ * - New file (not on disk) → create it
6
+ * - Unchanged by user (disk hash == manifest hash) → overwrite with new version
7
+ * - Modified by user (disk hash != manifest hash) → skip with warning
8
+ * - Removed from SDK (in manifest, not in new template list) → warn, don't delete
9
+ *
10
+ * Migrations are renumbered to avoid conflicts with user-created migrations.
11
+ */
12
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
13
+ import { resolve, dirname, join } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import Handlebars from 'handlebars';
16
+ import pc from 'picocolors';
17
+ import { getFilesForTier, SDK_VERSION, isMigrationFile, isTierUpgradeOrSame } from './templates.js';
18
+ import { readManifest, writeManifest, buildManifest, hashContent, MANIFEST_FILENAME, } from './manifest.js';
19
+ import { findHighestMigration, getMigrationNumber, planMigrations } from './migrations.js';
20
+ const __filename = fileURLToPath(import.meta.url);
21
+ const __dirname = dirname(__filename);
22
+ function getTemplatesDir() {
23
+ const devPath = resolve(__dirname, '..', 'templates');
24
+ if (existsSync(devPath))
25
+ return devPath;
26
+ return resolve(__dirname, '..', '..', 'templates');
27
+ }
28
+ function renderString(template, context) {
29
+ let result = template;
30
+ for (const [key, value] of Object.entries(context)) {
31
+ result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value);
32
+ }
33
+ return result;
34
+ }
35
+ export async function upgrade(projectDir, options = {}) {
36
+ const manifest = readManifest(projectDir);
37
+ if (!manifest) {
38
+ throw new Error(`No ${MANIFEST_FILENAME} found in ${projectDir}.\n` +
39
+ `If this project was scaffolded before v1.1.0, run:\n` +
40
+ ` platform-admin-sdk adopt ${projectDir}`);
41
+ }
42
+ const targetTier = options.tier ?? manifest.tier;
43
+ if (!isTierUpgradeOrSame(manifest.tier, targetTier)) {
44
+ throw new Error(`Cannot downgrade from "${manifest.tier}" to "${targetTier}". ` +
45
+ `Tier changes must be upgrades (minimal → standard → full).`);
46
+ }
47
+ if (manifest.sdkVersion === SDK_VERSION && manifest.tier === targetTier) {
48
+ console.log(pc.green(` Already up to date (SDK ${SDK_VERSION}, tier ${targetTier}).`));
49
+ return { created: [], updated: [], skipped: [], removed: [], migrations: [] };
50
+ }
51
+ const templatesDir = getTemplatesDir();
52
+ const files = getFilesForTier(targetTier);
53
+ const context = {
54
+ projectName: manifest.context.projectName,
55
+ projectSlug: manifest.context.projectSlug,
56
+ githubOrg: manifest.context.githubOrg,
57
+ tier: targetTier,
58
+ gatusUrl: manifest.context.gatusUrl,
59
+ defaultAssignee: manifest.context.defaultAssignee,
60
+ sdkVersion: SDK_VERSION,
61
+ };
62
+ const result = {
63
+ created: [],
64
+ updated: [],
65
+ skipped: [],
66
+ removed: [],
67
+ migrations: [],
68
+ };
69
+ // Separate regular files from migrations
70
+ const regularFiles = files.filter((f) => !isMigrationFile(f));
71
+ const migrationFiles = files.filter((f) => isMigrationFile(f));
72
+ const newFileHashes = {};
73
+ // --- Process regular files ---
74
+ for (const file of regularFiles) {
75
+ const srcPath = join(templatesDir, file.src);
76
+ const destRelative = renderString(file.dest, context);
77
+ const destPath = join(projectDir, destRelative);
78
+ if (!existsSync(srcPath))
79
+ continue;
80
+ const raw = readFileSync(srcPath, 'utf-8');
81
+ let content;
82
+ if (file.template) {
83
+ const compiled = Handlebars.compile(raw, { noEscape: true });
84
+ content = compiled(context);
85
+ }
86
+ else {
87
+ content = raw;
88
+ }
89
+ const newHash = hashContent(content);
90
+ newFileHashes[destRelative] = newHash;
91
+ if (!existsSync(destPath)) {
92
+ // New file — create it
93
+ if (!options.dryRun) {
94
+ mkdirSync(dirname(destPath), { recursive: true });
95
+ writeFileSync(destPath, content);
96
+ }
97
+ console.log(` ${pc.green('create')} ${destRelative}`);
98
+ result.created.push(destRelative);
99
+ }
100
+ else {
101
+ const diskContent = readFileSync(destPath, 'utf-8');
102
+ const diskHash = hashContent(diskContent);
103
+ const manifestHash = manifest.files[destRelative];
104
+ if (diskHash === manifestHash) {
105
+ // Unmodified by user — safe to overwrite
106
+ if (newHash !== diskHash) {
107
+ if (!options.dryRun) {
108
+ writeFileSync(destPath, content);
109
+ }
110
+ console.log(` ${pc.cyan('update')} ${destRelative}`);
111
+ result.updated.push(destRelative);
112
+ }
113
+ // else: identical content, nothing to do
114
+ }
115
+ else {
116
+ // User has modified this file — skip
117
+ console.log(` ${pc.yellow('skip')} ${destRelative} ${pc.dim('(user modified)')}`);
118
+ result.skipped.push(destRelative);
119
+ // Preserve the user's version in the new manifest
120
+ newFileHashes[destRelative] = diskHash;
121
+ }
122
+ }
123
+ }
124
+ // --- Process migrations ---
125
+ const scaffoldMigrations = [];
126
+ for (const file of migrationFiles) {
127
+ const srcPath = join(templatesDir, file.src);
128
+ if (!existsSync(srcPath))
129
+ continue;
130
+ const content = readFileSync(srcPath, 'utf-8');
131
+ scaffoldMigrations.push({ originalDest: file.dest, content });
132
+ }
133
+ const migrationsDir = join(projectDir, 'storage/d1/migrations');
134
+ const userHighest = findHighestMigration(migrationsDir);
135
+ const planned = planMigrations(scaffoldMigrations, manifest.highestScaffoldMigration, userHighest);
136
+ for (const migration of planned) {
137
+ const destPath = join(projectDir, migration.dest);
138
+ if (!options.dryRun) {
139
+ mkdirSync(dirname(destPath), { recursive: true });
140
+ writeFileSync(destPath, migration.content);
141
+ }
142
+ console.log(` ${pc.green('create')} ${migration.dest} ${pc.dim(`(from ${migration.originalDest.split('/').pop()})`)}`);
143
+ result.migrations.push(migration.dest);
144
+ newFileHashes[migration.dest] = hashContent(migration.content);
145
+ }
146
+ // Carry forward hashes for existing migrations that weren't re-planned
147
+ for (const [path, hash] of Object.entries(manifest.files)) {
148
+ if (path.includes('migrations/') && !newFileHashes[path]) {
149
+ newFileHashes[path] = hash;
150
+ }
151
+ }
152
+ // --- Check for removed files ---
153
+ const newDestSet = new Set([
154
+ ...regularFiles.map((f) => renderString(f.dest, context)),
155
+ ...migrationFiles.map((f) => f.dest),
156
+ ]);
157
+ for (const oldPath of Object.keys(manifest.files)) {
158
+ // Skip migrations from the removed check (they get renumbered)
159
+ if (oldPath.includes('migrations/'))
160
+ continue;
161
+ if (!newDestSet.has(oldPath)) {
162
+ console.log(` ${pc.yellow('warn')} ${oldPath} ${pc.dim('(removed from SDK, keeping on disk)')}`);
163
+ result.removed.push(oldPath);
164
+ }
165
+ }
166
+ // --- Compute new highestScaffoldMigration ---
167
+ let highestScaffoldMig = manifest.highestScaffoldMigration;
168
+ for (const file of migrationFiles) {
169
+ const num = getMigrationNumber(file.dest);
170
+ if (num !== null && num > highestScaffoldMig) {
171
+ highestScaffoldMig = num;
172
+ }
173
+ }
174
+ // --- Write updated manifest ---
175
+ if (!options.dryRun) {
176
+ const newManifest = buildManifest(SDK_VERSION, targetTier, manifest.context, newFileHashes, highestScaffoldMig);
177
+ writeManifest(projectDir, newManifest);
178
+ }
179
+ return result;
180
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@littlebearapps/platform-admin-sdk",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Platform Admin SDK — scaffold backend infrastructure with workers, circuit breakers, and cost protection for Cloudflare",
5
5
  "type": "module",
6
6
  "bin": {