@littlebearapps/platform-admin-sdk 2.2.0 → 2.3.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.
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Pre-flight check for upgrades — detects potential issues before writing files.
3
+ *
4
+ * Checks for:
5
+ * - Template naming collisions
6
+ * - Placeholder values left in wrangler configs (e.g., "YOUR_DB_ID")
7
+ * - Migration number conflicts
8
+ * - Excluded files from the manifest
9
+ */
10
+ import type { Tier } from './prompts.js';
11
+ export interface CheckResult {
12
+ currentVersion: string;
13
+ targetVersion: string;
14
+ currentTier: string;
15
+ targetTier: string;
16
+ placeholders: string[];
17
+ collisions: string[];
18
+ migrationConflicts: string[];
19
+ excludedFiles: string[];
20
+ ok: boolean;
21
+ }
22
+ /**
23
+ * Run pre-flight checks for an upgrade without modifying any files.
24
+ *
25
+ * @param projectDir - Path to the project directory
26
+ * @param targetTier - Optional tier to upgrade to (defaults to current tier)
27
+ * @returns CheckResult with all detected issues
28
+ */
29
+ export declare function checkUpgrade(projectDir: string, targetTier?: Tier): CheckResult;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Pre-flight check for upgrades — detects potential issues before writing files.
3
+ *
4
+ * Checks for:
5
+ * - Template naming collisions
6
+ * - Placeholder values left in wrangler configs (e.g., "YOUR_DB_ID")
7
+ * - Migration number conflicts
8
+ * - Excluded files from the manifest
9
+ */
10
+ import { readFileSync, existsSync, readdirSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { detectCollisions, SDK_VERSION, getFilesForTier, isMigrationFile } from './templates.js';
13
+ import { readManifest, MANIFEST_FILENAME } from './manifest.js';
14
+ import { findHighestMigration, getMigrationNumber } from './migrations.js';
15
+ /** Patterns that indicate placeholder values left by scaffolding. */
16
+ const PLACEHOLDER_PATTERNS = [
17
+ /YOUR_DB_ID/,
18
+ /YOUR_KV_ID/,
19
+ /YOUR_QUEUE_ID/,
20
+ /YOUR_R2_BUCKET/,
21
+ /PLACEHOLDER/,
22
+ /your-database-id/,
23
+ /your-kv-namespace-id/,
24
+ /your-queue-id/,
25
+ ];
26
+ /**
27
+ * Run pre-flight checks for an upgrade without modifying any files.
28
+ *
29
+ * @param projectDir - Path to the project directory
30
+ * @param targetTier - Optional tier to upgrade to (defaults to current tier)
31
+ * @returns CheckResult with all detected issues
32
+ */
33
+ export function checkUpgrade(projectDir, targetTier) {
34
+ const manifest = readManifest(projectDir);
35
+ if (!manifest) {
36
+ throw new Error(`No ${MANIFEST_FILENAME} found in ${projectDir}.\n` +
37
+ `Run \`platform-admin-sdk adopt\` first.`);
38
+ }
39
+ const effectiveTier = targetTier ?? manifest.tier;
40
+ const result = {
41
+ currentVersion: manifest.sdkVersion,
42
+ targetVersion: SDK_VERSION,
43
+ currentTier: manifest.tier,
44
+ targetTier: effectiveTier,
45
+ placeholders: [],
46
+ collisions: [],
47
+ migrationConflicts: [],
48
+ excludedFiles: [...(manifest.excludeFromUpgrade ?? [])],
49
+ ok: true,
50
+ };
51
+ // 1. Check for naming collisions
52
+ result.collisions = detectCollisions(effectiveTier);
53
+ // 2. Scan wrangler.*.jsonc files for placeholder values
54
+ if (existsSync(projectDir)) {
55
+ let dirEntries;
56
+ try {
57
+ dirEntries = readdirSync(projectDir);
58
+ }
59
+ catch {
60
+ dirEntries = [];
61
+ }
62
+ const wranglerFiles = dirEntries.filter((f) => f.startsWith('wrangler') && (f.endsWith('.jsonc') || f.endsWith('.json')));
63
+ for (const wf of wranglerFiles) {
64
+ const filePath = join(projectDir, wf);
65
+ const content = readFileSync(filePath, 'utf-8');
66
+ for (const pattern of PLACEHOLDER_PATTERNS) {
67
+ if (pattern.test(content)) {
68
+ result.placeholders.push(`${wf}: contains ${pattern.source}`);
69
+ }
70
+ }
71
+ }
72
+ }
73
+ // 3. Check for migration conflicts
74
+ const migrationsDir = join(projectDir, 'storage/d1/migrations');
75
+ const userHighest = findHighestMigration(migrationsDir);
76
+ const files = getFilesForTier(effectiveTier);
77
+ const migrationFiles = files.filter((f) => isMigrationFile(f));
78
+ // Find new scaffold migrations that would need to be renumbered
79
+ const newScaffoldMigrations = migrationFiles.filter((f) => {
80
+ const num = getMigrationNumber(f.dest);
81
+ return num !== null && num > manifest.highestScaffoldMigration;
82
+ });
83
+ // Check if any new scaffold migration numbers overlap with existing user migrations
84
+ for (const mf of newScaffoldMigrations) {
85
+ const num = getMigrationNumber(mf.dest);
86
+ if (num !== null && num <= userHighest && num > manifest.highestScaffoldMigration) {
87
+ const filename = mf.dest.split('/').pop() ?? mf.dest;
88
+ result.migrationConflicts.push(`${filename} (number ${num}) overlaps with existing user migrations (highest: ${userHighest}) — will be renumbered`);
89
+ }
90
+ }
91
+ // 4. Determine overall ok status
92
+ result.ok =
93
+ result.collisions.length === 0 &&
94
+ result.placeholders.length === 0 &&
95
+ result.migrationConflicts.length === 0;
96
+ return result;
97
+ }
package/dist/index.js CHANGED
@@ -24,6 +24,7 @@ import { collectOptions, isValidTier } from './prompts.js';
24
24
  import { scaffold } from './scaffold.js';
25
25
  import { upgrade } from './upgrade.js';
26
26
  import { adopt } from './adopt.js';
27
+ import { checkUpgrade } from './check-upgrade.js';
27
28
  import { SDK_VERSION } from './templates.js';
28
29
  const BANNER = `
29
30
  ${pc.bold(pc.cyan('Platform Admin SDK'))} — Cloudflare Cost Protection
@@ -109,11 +110,15 @@ const upgradeCmd = new Command('upgrade')
109
110
  });
110
111
  console.log();
111
112
  const total = result.created.length + result.updated.length + result.migrations.length;
112
- if (total === 0 && result.skipped.length === 0) {
113
+ if (total === 0 && result.skipped.length === 0 && result.excluded.length === 0) {
113
114
  console.log(pc.green(' Already up to date.'));
114
115
  }
115
116
  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
+ let summary = ` ${pc.green(`${result.created.length} created`)}, ${pc.cyan(`${result.updated.length} updated`)}, ${pc.yellow(`${result.skipped.length} skipped`)}, ${pc.green(`${result.migrations.length} migrations`)}`;
118
+ if (result.excluded.length > 0) {
119
+ summary += `, ${pc.dim(`${result.excluded.length} excluded`)}`;
120
+ }
121
+ console.log(summary);
117
122
  }
118
123
  if (result.removed.length > 0) {
119
124
  console.log(` ${pc.yellow(`${result.removed.length} files removed from SDK (kept on disk)`)}`);
@@ -170,6 +175,55 @@ const adoptCmd = new Command('adopt')
170
175
  console.log(` ${pc.dim('You can now run:')} ${pc.cyan('platform-admin-sdk upgrade')}`);
171
176
  console.log();
172
177
  });
178
+ // --- Check-upgrade command ---
179
+ const checkUpgradeCmd = new Command('check-upgrade')
180
+ .description('Pre-flight check for upgrades — detect issues without writing files')
181
+ .argument('[project-dir]', 'Path to the project directory', '.')
182
+ .option('--tier <tier>', 'Target tier to check against')
183
+ .action(async (projectDirArg, cmdOpts) => {
184
+ if (cmdOpts.tier && !isValidTier(cmdOpts.tier)) {
185
+ console.error(pc.red(` Error: Invalid tier "${cmdOpts.tier}". Must be one of: minimal, standard, full`));
186
+ process.exit(1);
187
+ }
188
+ const projectDir = resolve(process.cwd(), projectDirArg);
189
+ console.log(` ${pc.bold('Checking')}: ${projectDir}`);
190
+ console.log();
191
+ const result = checkUpgrade(projectDir, cmdOpts.tier);
192
+ console.log(` ${pc.bold('Current')}: v${result.currentVersion} (${result.currentTier})`);
193
+ console.log(` ${pc.bold('Target')}: v${result.targetVersion} (${result.targetTier})`);
194
+ console.log();
195
+ if (result.collisions.length > 0) {
196
+ console.log(pc.red(` Collisions (${result.collisions.length}):`));
197
+ for (const c of result.collisions)
198
+ console.log(` - ${c}`);
199
+ console.log();
200
+ }
201
+ if (result.placeholders.length > 0) {
202
+ console.log(pc.yellow(` Placeholders (${result.placeholders.length}):`));
203
+ for (const p of result.placeholders)
204
+ console.log(` - ${p}`);
205
+ console.log();
206
+ }
207
+ if (result.migrationConflicts.length > 0) {
208
+ console.log(pc.yellow(` Migration conflicts (${result.migrationConflicts.length}):`));
209
+ for (const m of result.migrationConflicts)
210
+ console.log(` - ${m}`);
211
+ console.log();
212
+ }
213
+ if (result.excludedFiles.length > 0) {
214
+ console.log(pc.dim(` Excluded files (${result.excludedFiles.length}):`));
215
+ for (const e of result.excludedFiles)
216
+ console.log(` - ${e}`);
217
+ console.log();
218
+ }
219
+ if (result.ok) {
220
+ console.log(pc.green(' All checks passed. Safe to upgrade.'));
221
+ }
222
+ else {
223
+ console.log(pc.yellow(' Issues found. Review before upgrading.'));
224
+ }
225
+ console.log();
226
+ });
173
227
  // --- Main program ---
174
228
  const program = new Command()
175
229
  .name('platform-admin-sdk')
@@ -177,12 +231,13 @@ const program = new Command()
177
231
  .version(SDK_VERSION)
178
232
  .addCommand(scaffoldCmd)
179
233
  .addCommand(upgradeCmd)
180
- .addCommand(adoptCmd);
234
+ .addCommand(adoptCmd)
235
+ .addCommand(checkUpgradeCmd);
181
236
  async function main() {
182
237
  console.log(BANNER);
183
238
  // Backward compat: if first arg isn't a known subcommand, treat it as `scaffold <arg>`
184
239
  const args = process.argv.slice(2);
185
- const subcommands = ['scaffold', 'upgrade', 'adopt', 'help', '--help', '-h', '--version', '-V'];
240
+ const subcommands = ['scaffold', 'upgrade', 'adopt', 'check-upgrade', 'help', '--help', '-h', '--version', '-V'];
186
241
  if (args.length > 0 && !subcommands.includes(args[0])) {
187
242
  // Inject 'scaffold' as the subcommand
188
243
  process.argv.splice(2, 0, 'scaffold');
@@ -28,6 +28,8 @@ export interface ScaffoldManifest {
28
28
  files: Record<string, string>;
29
29
  /** Highest migration number owned by the scaffolder (user migrations are higher). */
30
30
  highestScaffoldMigration: number;
31
+ /** Relative file paths to skip during upgrade (user-managed files). */
32
+ excludeFromUpgrade?: string[];
31
33
  }
32
34
  /** SHA-256 hash of file content. */
33
35
  export declare function hashContent(content: string): string;
package/dist/scaffold.js CHANGED
@@ -6,7 +6,7 @@ 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, SDK_VERSION } from './templates.js';
9
+ import { getFilesForTier, SDK_VERSION, detectCollisions } from './templates.js';
10
10
  import { hashContent, buildManifest, writeManifest, MANIFEST_FILENAME } from './manifest.js';
11
11
  import { findHighestMigration } from './migrations.js';
12
12
  const __filename = fileURLToPath(import.meta.url);
@@ -35,6 +35,10 @@ export async function scaffold(options, outputDir) {
35
35
  }
36
36
  throw new Error(`Directory already exists: ${outputDir}`);
37
37
  }
38
+ const collisions = detectCollisions(options.tier);
39
+ if (collisions.length > 0) {
40
+ throw new Error(`Template naming collisions detected:\n${collisions.map((c) => ` - ${c}`).join('\n')}`);
41
+ }
38
42
  const templatesDir = getTemplatesDir();
39
43
  const files = getFilesForTier(options.tier);
40
44
  const context = {
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import type { Tier } from './prompts.js';
8
8
  /** Single source of truth for the SDK version. */
9
- export declare const SDK_VERSION = "2.2.0";
9
+ export declare const SDK_VERSION = "2.3.0";
10
10
  /** Returns true if `to` is the same or higher tier than `from`. */
11
11
  export declare function isTierUpgradeOrSame(from: Tier, to: Tier): boolean;
12
12
  /** Check if a template file is a numbered migration (not seed.sql). */
@@ -19,4 +19,9 @@ export interface TemplateFile {
19
19
  /** Whether this file uses Handlebars templating */
20
20
  template: boolean;
21
21
  }
22
+ /**
23
+ * Detect naming collisions where two template files resolve to the same destination path.
24
+ * Returns an array of collision descriptions (empty = no collisions).
25
+ */
26
+ export declare function detectCollisions(tier: Tier): string[];
22
27
  export declare function getFilesForTier(tier: Tier): TemplateFile[];
package/dist/templates.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * All other files are copied verbatim.
6
6
  */
7
7
  /** Single source of truth for the SDK version. */
8
- export const SDK_VERSION = '2.2.0';
8
+ export const SDK_VERSION = '2.3.0';
9
9
  /** Tier ordering for upgrade validation. */
10
10
  const TIER_ORDER = { minimal: 0, standard: 1, full: 2 };
11
11
  /** Returns true if `to` is the same or higher tier than `from`. */
@@ -566,6 +566,26 @@ const FULL_FILES = [
566
566
  { src: 'full/tests/integration/r2-archive.test.ts', dest: 'tests/integration/r2-archive.test.ts', template: false },
567
567
  { src: 'full/tests/integration/feedback-schema.test.ts', dest: 'tests/integration/feedback-schema.test.ts', template: false },
568
568
  ];
569
+ /**
570
+ * Detect naming collisions where two template files resolve to the same destination path.
571
+ * Returns an array of collision descriptions (empty = no collisions).
572
+ */
573
+ export function detectCollisions(tier) {
574
+ const files = getFilesForTier(tier);
575
+ const seen = new Map();
576
+ const collisions = [];
577
+ for (const file of files) {
578
+ const dest = file.dest;
579
+ const existing = seen.get(dest);
580
+ if (existing) {
581
+ collisions.push(`"${existing}" and "${file.src}" both resolve to "${dest}"`);
582
+ }
583
+ else {
584
+ seen.set(dest, file.src);
585
+ }
586
+ }
587
+ return collisions;
588
+ }
569
589
  export function getFilesForTier(tier) {
570
590
  const files = [...SHARED_FILES];
571
591
  if (tier === 'standard' || tier === 'full') {
package/dist/upgrade.d.ts CHANGED
@@ -20,5 +20,6 @@ export interface UpgradeResult {
20
20
  skipped: string[];
21
21
  removed: string[];
22
22
  migrations: string[];
23
+ excluded: string[];
23
24
  }
24
25
  export declare function upgrade(projectDir: string, options?: UpgradeOptions): Promise<UpgradeResult>;
package/dist/upgrade.js CHANGED
@@ -14,7 +14,7 @@ import { resolve, dirname, join } from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import Handlebars from 'handlebars';
16
16
  import pc from 'picocolors';
17
- import { getFilesForTier, SDK_VERSION, isMigrationFile, isTierUpgradeOrSame } from './templates.js';
17
+ import { getFilesForTier, SDK_VERSION, isMigrationFile, isTierUpgradeOrSame, detectCollisions } from './templates.js';
18
18
  import { readManifest, writeManifest, buildManifest, hashContent, MANIFEST_FILENAME, } from './manifest.js';
19
19
  import { findHighestMigration, getMigrationNumber, planMigrations } from './migrations.js';
20
20
  const __filename = fileURLToPath(import.meta.url);
@@ -46,7 +46,11 @@ export async function upgrade(projectDir, options = {}) {
46
46
  }
47
47
  if (manifest.sdkVersion === SDK_VERSION && manifest.tier === targetTier) {
48
48
  console.log(pc.green(` Already up to date (SDK ${SDK_VERSION}, tier ${targetTier}).`));
49
- return { created: [], updated: [], skipped: [], removed: [], migrations: [] };
49
+ return { created: [], updated: [], skipped: [], removed: [], migrations: [], excluded: [] };
50
+ }
51
+ const collisions = detectCollisions(targetTier);
52
+ if (collisions.length > 0) {
53
+ throw new Error(`Template naming collisions detected:\n${collisions.map((c) => ` - ${c}`).join('\n')}`);
50
54
  }
51
55
  const templatesDir = getTemplatesDir();
52
56
  const files = getFilesForTier(targetTier);
@@ -59,12 +63,14 @@ export async function upgrade(projectDir, options = {}) {
59
63
  defaultAssignee: manifest.context.defaultAssignee,
60
64
  sdkVersion: SDK_VERSION,
61
65
  };
66
+ const excludeSet = new Set(manifest.excludeFromUpgrade ?? []);
62
67
  const result = {
63
68
  created: [],
64
69
  updated: [],
65
70
  skipped: [],
66
71
  removed: [],
67
72
  migrations: [],
73
+ excluded: [],
68
74
  };
69
75
  // Separate regular files from migrations
70
76
  const regularFiles = files.filter((f) => !isMigrationFile(f));
@@ -75,6 +81,16 @@ export async function upgrade(projectDir, options = {}) {
75
81
  const srcPath = join(templatesDir, file.src);
76
82
  const destRelative = renderString(file.dest, context);
77
83
  const destPath = join(projectDir, destRelative);
84
+ if (excludeSet.has(destRelative)) {
85
+ console.log(` ${pc.dim('exclude')} ${destRelative} ${pc.dim('(in excludeFromUpgrade)')}`);
86
+ result.excluded.push(destRelative);
87
+ // Preserve existing hash in new manifest if file is on disk
88
+ if (existsSync(destPath)) {
89
+ const diskContent = readFileSync(destPath, 'utf-8');
90
+ newFileHashes[destRelative] = hashContent(diskContent);
91
+ }
92
+ continue;
93
+ }
78
94
  if (!existsSync(srcPath))
79
95
  continue;
80
96
  const raw = readFileSync(srcPath, 'utf-8');
@@ -174,6 +190,9 @@ export async function upgrade(projectDir, options = {}) {
174
190
  // --- Write updated manifest ---
175
191
  if (!options.dryRun) {
176
192
  const newManifest = buildManifest(SDK_VERSION, targetTier, manifest.context, newFileHashes, highestScaffoldMig);
193
+ if (manifest.excludeFromUpgrade && manifest.excludeFromUpgrade.length > 0) {
194
+ newManifest.excludeFromUpgrade = manifest.excludeFromUpgrade;
195
+ }
177
196
  writeManifest(projectDir, newManifest);
178
197
  }
179
198
  return result;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@littlebearapps/platform-admin-sdk",
3
- "version": "2.2.0",
3
+ "version": "2.3.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": {