package: ship declarations (dist/types, the "types" condition) and a Node JS entry for 'storebound'; Bun keeps the .ts source. 0.8.5 exported only cli/lib/config.ts, so tsc (next build, vue-tsc, tsc --noEmit) failed on a scaffolded storebound.config.ts and broke a Next app's build; a test type-checks the template against the packed entry under bundler / node16 / nodenext without @types/node. check fails on the template's own placeholders (Example Studio, bare example.com URLs and addresses)
This commit is contained in:
parent
77f141e223
commit
d717ceb4cd
10 changed files with 138 additions and 7 deletions
|
|
@ -2,6 +2,15 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
- **`import type { ShipConfig } from 'storebound'` type-checks outside Bun.** The package ships declarations
|
||||
(`dist/types`, the "types" condition) and a JS entry for Node; Bun keeps the .ts source. 0.8.5 named only
|
||||
`cli/lib/config.ts`, so tsc (`next build`, vue-tsc, ts-loader, `tsc --noEmit`) failed on a scaffolded
|
||||
storebound.config.ts and a working Next app stopped building. A test packs the entry and type-checks the template
|
||||
under bundler, node16 and nodenext with no @types/node and skipLibCheck off.
|
||||
- **`check` fails on the scaffolder's own placeholders**: "2026 Example Studio" and example.com / .org / .net URLs and
|
||||
addresses in `app`, `accounts` and `reviewAccess` would reach the stores as the app's copyright, contact and legal
|
||||
pages.
|
||||
|
||||
## 0.8.5 · 2026-10-14
|
||||
|
||||
Releases take one command and minutes: every push to main is a release candidate (full CI, the acceptance matrix and
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
// storebound check --aso keyword audit of both listing files (App Store keyword field, Play stuffing); exit 1 on errors
|
||||
// --json structured result ({ ok, items, warnings, hints, problems } or { findings, errors }), exit 1 on problems
|
||||
import { templateBlockers } from './lib/template-answers';
|
||||
import { COPYRIGHT_MISSING } from './lib/validate-rules';
|
||||
import { COPYRIGHT_MISSING, templatePlaceholders } from './lib/validate-rules';
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
import { appPrivacyMismatches, type PrivacyMismatch, trackingDeclaredIssues, type TrackingSources, typeIdOf } from './lib/app-privacy';
|
||||
|
|
@ -145,6 +145,9 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
try { for (const n of (await runScan(ctx.loaded.root, { config: ctx.loaded.path })).report.notes) if (n.startsWith('scan.ignore[')) note(n); } catch (e) { note(`scan.ignore not checked: ${(e as Error).message}`); }
|
||||
}
|
||||
if (config.apple && !config.app.copyright?.trim()) problems.push(COPYRIGHT_MISSING);
|
||||
// the scaffolder's own placeholders would reach the stores as the app's copyright, contact and legal pages
|
||||
const tph = templatePlaceholders(config as never);
|
||||
if (tph.length) problems.push(`template placeholders still in the config: ${tph.join(', ')} ("Example Studio", example.com): the stores would show them as your copyright, contact address and legal pages; put the real values in`);
|
||||
if (config.dataSafety) tryDo('data safety', () => {
|
||||
const rows = dataSafetyRows(config.dataSafety!, config.accounts);
|
||||
// conditional required rows (e.g. the delete-account URL once accounts exist): Play answers 400 without them
|
||||
|
|
|
|||
|
|
@ -144,6 +144,22 @@ const ageOpen = (r: Record<string, unknown>): { open: string[]; soft: string[] }
|
|||
const urlOk = (u: UrlCheck) => u.status !== null && u.status >= 200 && u.status < 300;
|
||||
const urlMsg = (u: UrlCheck) => (u.status === null ? `unreachable (${u.error ?? 'no answer'})` : `HTTP ${u.status}`);
|
||||
|
||||
/** The scaffolder's own placeholder values (templates/*storebound.config.ts) that would reach a store as the app's
|
||||
* legal and contact details: "2026 Example Studio", example.com / example.org / example.net URLs and addresses
|
||||
* (the bare domain; subdomains such as notes.example.com are a project's own test fixtures).
|
||||
* Returns the dotted paths that still hold one. Pure. */
|
||||
export function templatePlaceholders(config: { app?: object; accounts?: object; reviewAccess?: object } & Record<string, unknown>): string[] {
|
||||
const out: string[] = [];
|
||||
const bad = (v: string) => /\bexample studio\b/i.test(v) || /(^|@|\/\/)(www\.)?example\.(com|org|net)(?![\w.-])/i.test(v);
|
||||
const walk = (v: unknown, path: string) => {
|
||||
if (typeof v === 'string') { if (bad(v)) out.push(path); }
|
||||
else if (Array.isArray(v)) v.forEach((x, i) => walk(x, `${path}[${i}]`));
|
||||
else if (v && typeof v === 'object') for (const [k, x] of Object.entries(v)) walk(x, `${path}.${k}`);
|
||||
};
|
||||
for (const k of ['app', 'accounts', 'reviewAccess'] as const) walk(config[k], k);
|
||||
return out;
|
||||
}
|
||||
|
||||
export const COPYRIGHT_MISSING = "app.copyright is missing: App Store Connect requires it. Use your public brand (e.g. '2026 <brand>'); never a company name you keep private.";
|
||||
|
||||
/** Copyright: App Store Connect refuses to submit a version without one; storebound never fills it in. */
|
||||
|
|
|
|||
11
package.json
11
package.json
|
|
@ -28,10 +28,16 @@
|
|||
"storebound": "dist/storebound.mjs"
|
||||
},
|
||||
"exports": {
|
||||
".": "./cli/lib/config.ts"
|
||||
".": {
|
||||
"types": "./dist/types/lib/config.d.ts",
|
||||
"bun": "./cli/lib/config.ts",
|
||||
"default": "./dist/config.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist/storebound.mjs",
|
||||
"dist/config.js",
|
||||
"dist/types",
|
||||
"cli",
|
||||
"templates",
|
||||
"skills",
|
||||
|
|
@ -87,5 +93,6 @@
|
|||
"homepage": "https://git.crt.fyi/CRT/storebound",
|
||||
"bugs": {
|
||||
"url": "https://git.crt.fyi/CRT/storebound/issues"
|
||||
}
|
||||
},
|
||||
"types": "./dist/types/lib/config.d.ts"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { BASE, capture, jsonOut, project, restore } from './helpers/store-mock';
|
|||
afterEach(restore);
|
||||
|
||||
const NAMED: ShipConfig = { ...BASE, app: { ...BASE.app, name: 'STAR DASH' }, apple: { ...BASE.apple!, name: 'STAR DASH: Stone Age to 2100', primaryLocale: 'en-US' } };
|
||||
const PLAIN: ShipConfig = { ...BASE, app: { ...BASE.app, name: 'STAR DASH', copyright: '2026 Example Brand' }, apple: { ...BASE.apple!, primaryLocale: 'en-US' } };
|
||||
const PLAIN: ShipConfig = { ...BASE, app: { ...BASE.app, name: 'STAR DASH', copyright: '2026 Example Brand', privacyPolicyUrl: 'https://rush.example.com/privacy', supportUrl: 'https://rush.example.com/support', website: 'https://rush.example.com', contactEmail: 'support@rush.example.com' }, apple: { ...BASE.apple!, primaryLocale: 'en-US' } };
|
||||
const sec = (loc: string, name?: string) => `## ${loc}\n\n${name ? `### name\n\`\`\`\n${name}\n\`\`\`\n\n` : ''}### subtitle\n\`\`\`\nSub\n\`\`\`\n\n### promotionalText\n\`\`\`\nPromo\n\`\`\`\n\n### keywords\n\`\`\`\nrun,time\n\`\`\`\n\n### description\n\`\`\`\nText.\n\`\`\`\n\n`;
|
||||
|
||||
describe('apple.name', () => {
|
||||
|
|
|
|||
39
tests/package-types.test.ts
Normal file
39
tests/package-types.test.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// The published package as a project's tsc sees it: `import type { ShipConfig } from 'storebound'` in a scaffolded
|
||||
// storebound.config.ts must type-check with plain TypeScript (next build, vue-tsc, ts-loader, tsc --noEmit), not only
|
||||
// under Bun. 0.8.5 shipped "exports": "./cli/lib/config.ts" and no declarations: a Next app stopped building.
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { buildEntry } from '../tools/build-node';
|
||||
|
||||
const ROOT = join(import.meta.dir, '..');
|
||||
const TSC = join(ROOT, 'node_modules', 'typescript', 'bin', 'tsc');
|
||||
|
||||
describe('package types', () => {
|
||||
test('the scaffolded config type-checks against the package under bundler, node16 and nodenext', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sb-pkg-types-'));
|
||||
try {
|
||||
const dist = join(dir, 'dist');
|
||||
buildEntry(dist);
|
||||
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
|
||||
// what npm ships: package.json, the entry files it names, nothing from the checkout's node_modules
|
||||
const sb = join(dir, 'app', 'node_modules', 'storebound');
|
||||
mkdirSync(sb, { recursive: true });
|
||||
writeFileSync(join(sb, 'package.json'), JSON.stringify(pkg));
|
||||
cpSync(dist, join(sb, 'dist'), { recursive: true });
|
||||
expect(pkg.files).toEqual(expect.arrayContaining(['dist/config.js', 'dist/types']));
|
||||
expect(pkg.exports['.'].types).toBe('./dist/types/lib/config.d.ts');
|
||||
symlinkSync(join(ROOT, 'node_modules', 'typescript'), join(dir, 'app', 'node_modules', 'typescript'));
|
||||
cpSync(join(ROOT, 'templates', 'storebound.config.ts'), join(dir, 'app', 'storebound.config.ts'));
|
||||
// an ES module project (node16 / nodenext read the nearest package.json; Next / Vite use bundler resolution)
|
||||
writeFileSync(join(dir, 'app', 'package.json'), JSON.stringify({ name: 'app', private: true, type: 'module' }));
|
||||
for (const [module, moduleResolution] of [['esnext', 'bundler'], ['node16', 'node16'], ['nodenext', 'nodenext']]) {
|
||||
// no @types/node, skipLibCheck off: the strictest project a config can land in
|
||||
writeFileSync(join(dir, 'app', 'tsconfig.json'), JSON.stringify({ compilerOptions: { strict: true, noEmit: true, target: 'es2022', module, moduleResolution, skipLibCheck: false, types: [] }, include: ['*.ts'] }));
|
||||
const r = Bun.spawnSync(['node', TSC, '-p', '.'], { cwd: join(dir, 'app'), stdout: 'pipe', stderr: 'pipe' });
|
||||
expect(`${moduleResolution}: ${r.stdout.toString()}${r.stderr.toString()}`.trim()).toBe(`${moduleResolution}:`);
|
||||
}
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
}, 120_000);
|
||||
});
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
// bun tools/build-node.ts write dist/storebound.mjs (npm pack / publish run it: prepack)
|
||||
// bun tools/build-node.ts --out <f> write elsewhere (tests)
|
||||
// The bundle is checked for this machine's home and checkout paths (like the sidecar): nothing private may ship.
|
||||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { chmodSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, relative, resolve } from 'node:path';
|
||||
import { privateStrings, ROOT } from './build-sidecars';
|
||||
|
|
@ -35,9 +35,51 @@ export function buildNode(out = NODE_BUNDLE): { path: string; bytes: number } {
|
|||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
/** The package's import entry ('storebound', the ShipConfig types of storebound.config.ts): declarations in
|
||||
* dist/types for tsc (next build, vue-tsc, ts-loader: TypeScript never takes a package's .ts source as its types) and
|
||||
* dist/config.js for runtimes other than Bun (Bun keeps importing cli/lib/config.ts). */
|
||||
export function buildEntry(dir = join(ROOT, 'dist')): void {
|
||||
const types = join(dir, 'types');
|
||||
rmSync(types, { recursive: true, force: true });
|
||||
const tsc = Bun.spawnSync([join(ROOT, 'node_modules', '.bin', 'tsc'), '-p', 'tsconfig.types.json', '--outDir', types], { cwd: ROOT, stdout: 'pipe', stderr: 'pipe' });
|
||||
if (tsc.exitCode !== 0) throw new Error(`tsc (declarations) failed\n${tsc.stdout.toString()}${tsc.stderr.toString()}`);
|
||||
if (!statSync(join(types, 'lib', 'config.d.ts'), { throwIfNoEntry: false })) throw new Error('no dist/types/lib/config.d.ts');
|
||||
pruneDeclarations(types, 'lib/config.d.ts');
|
||||
const js = Bun.spawnSync(['bun', 'build', 'cli/lib/config.ts', '--target=node', '--format=esm', ...NODE_EXTERNALS.flatMap(e => ['--external', e]), '--outfile', join(dir, 'config.js')], { cwd: ROOT, stdout: 'pipe', stderr: 'pipe' });
|
||||
if (js.exitCode !== 0) throw new Error(`bun build (config entry) failed\n${js.stderr.toString()}`);
|
||||
const leaked = privateStrings().filter(s => readFileSync(join(dir, 'config.js'), 'utf8').includes(s));
|
||||
if (leaked.length) throw new Error(`dist/config.js contains private paths (${leaked.length})`);
|
||||
}
|
||||
|
||||
/** Keeps the declarations reachable from `entry` (tsc emits one per file of the program) and makes them stand alone
|
||||
* in any project: relative imports get `.js` (node16 / nodenext need it), and `Buffer` becomes a local alias of
|
||||
* Uint8Array where the project may not have @types/node. Pure on the folder; returns the files kept. */
|
||||
export function pruneDeclarations(dir: string, entry: string): string[] {
|
||||
const keep = new Set<string>();
|
||||
const todo = [entry];
|
||||
const spec = /(from\s+|import\()(['"])(\.{1,2}\/[^'"]+?)(?:\.js)?\2/g;
|
||||
while (todo.length) {
|
||||
const f = todo.pop()!;
|
||||
if (keep.has(f)) continue;
|
||||
keep.add(f);
|
||||
for (const m of readFileSync(join(dir, f), 'utf8').matchAll(spec)) todo.push(join(dirname(f), `${m[3]}.d.ts`));
|
||||
}
|
||||
const walk = (d: string): string[] => readdirSync(join(dir, d), { withFileTypes: true }).flatMap(e => e.isDirectory() ? walk(join(d, e.name)) : [join(d, e.name)]);
|
||||
for (const f of walk('.')) {
|
||||
if (!keep.has(f)) { rmSync(join(dir, f)); continue; }
|
||||
let t = readFileSync(join(dir, f), 'utf8').replace(spec, (_m, kw: string, q: string, p: string) => `${kw}${q}${p}.js${q}`);
|
||||
if (/\bBuffer\b/.test(t)) t = `type Buffer = Uint8Array;\n${t}`;
|
||||
if (/['"]node:|\bBun\./.test(t.replace(/\/\*[\s\S]*?\*\/|\/\/.*$/gm, ''))) throw new Error(`${f}: the package's declarations need Node or Bun types`);
|
||||
writeFileSync(join(dir, f), t);
|
||||
}
|
||||
for (const d of walk('.').map(f => dirname(f))) if (!readdirSync(join(dir, d)).length) rmSync(join(dir, d), { recursive: true });
|
||||
return [...keep].sort();
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const i = process.argv.indexOf('--out');
|
||||
const t = Date.now();
|
||||
const r = buildNode(i >= 0 ? resolve(process.argv[i + 1]!) : NODE_BUNDLE);
|
||||
console.log(`node bundle -> ${relative(process.cwd(), r.path) || r.path} (${(r.bytes / 1e6).toFixed(2)} MB, ${((Date.now() - t) / 1000).toFixed(1)}s)`);
|
||||
if (i < 0) { buildEntry(); console.log('package entry -> dist/types (declarations), dist/config.js'); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export interface Rule { id: string; re: RegExp; files?: RegExp; allow?: (m: stri
|
|||
const at = '@';
|
||||
export const RULES: Rule[] = [
|
||||
{ id: 'email', re: new RegExp(`[A-Za-z0-9._%+-]+${at}[A-Za-z0-9.-]+\\.[A-Za-z]{2,}`, 'g'),
|
||||
allow: m => /@example\.(com|org|net)$/i.test(m) || /@users\.noreply\./.test(m) || /@[0-9]x\.(png|jpe?g|webp)$/.test(m) },
|
||||
allow: m => /@([a-z0-9-]+\.)*example\.(com|org|net)$/i.test(m) || /@users\.noreply\./.test(m) || /@[0-9]x\.(png|jpe?g|webp)$/.test(m) },
|
||||
{ id: 'private-key', re: new RegExp('-----BEGIN [A-Z ]*PRIVATE ' + 'KEY-----\\s*[A-Za-z0-9+/=]{20,}', 'g'), wholeFile: true },
|
||||
{ id: 'private_key-json', re: new RegExp('"private' + '_key"\\s*:\\s*"-----BEGIN', 'g') },
|
||||
{ id: 'asc-key-file', re: new RegExp('AuthKey' + '_[A-Z0-9]{10}\\.p8', 'g') },
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@
|
|||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowImportingTsExtensions": true
|
||||
"allowImportingTsExtensions": true,
|
||||
"paths": { "storebound": ["./cli/lib/config.ts"] }
|
||||
},
|
||||
"include": ["cli", "tests", "tools", "templates/storebound.config.ts", "examples/demo-game/storebound.config.ts", "examples/demo-game/serve.ts", "examples/demo-app/storebound.config.ts", "examples/demo-app/serve.ts", "templates/profiles/*/storebound.config.ts", "media/teaser/render.ts"],
|
||||
"exclude": ["tests/fixtures/scan"]
|
||||
|
|
|
|||
14
tsconfig.types.json
Normal file
14
tsconfig.types.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"rootDir": "cli",
|
||||
"outDir": "dist/types",
|
||||
"paths": {}
|
||||
},
|
||||
"include": [
|
||||
"cli/lib/config.ts"
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue