- cli/lib/rt.ts: the runtime layer on node: modules (spawn / spawnSync with Bun's defaults, exit codes and "Executable not found", which with PATHEXT, sleep, serve, Glob, hasher, gunzip, stdin, SQLite, packageRoot, config imports with a type-stripping fallback for Node 20); every Bun.* use in cli/ goes through it - tools/gen-embedded.ts -> cli/lib/embedded.ts: templates/, cli/shots/*.html and Play's Data safety form embedded (replaces Bun text imports; the bundle and the compiled sidecar carry them), checked fresh by bun run ci - package.json: bin dist/storebound.mjs (Node ESM bundle, tools/build-node.ts, prepack), engines node >=20 - bun run ci: node bundle + node smoke (real node: --version, help, check same as Bun, scan, release plan --help, init, MCP handshake + tool call); Forgejo CI sets up Node 20; tests/node-compat.test.ts guards cli/ - install writes npx MCP commands when run with Node; bin/storebound falls back to Node - docs: npx storebound init (Node 20+) or bunx storebound init
105 lines
6.5 KiB
TypeScript
105 lines
6.5 KiB
TypeScript
// Pitfall 26: an agent spent hours handing the owner one click, command or answer at a time. The first rule
|
|
// "Do it, don't ask" must head the agent rules (and so every generated adapter), README and the pitfalls skill, and
|
|
// no skill or rule text may hand navigation, draft saves, exports, uploads or form filling to the owner.
|
|
import { describe, expect, test } from 'bun:test';
|
|
import { readdirSync, readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
|
|
const root = join(import.meta.dir, '..');
|
|
const read = (rel: string) => readFileSync(join(root, rel), 'utf8');
|
|
const RULE = "do it, don't ask";
|
|
|
|
describe("first rule: do it, don't ask", () => {
|
|
test('is the first section of docs/agents-core.md, above the numbered rules', () => {
|
|
const core = read('docs/agents-core.md');
|
|
const firstHeading = /^## (.*)$/m.exec(core)?.[1] ?? '';
|
|
expect(firstHeading.toLowerCase()).toContain(RULE);
|
|
expect(core.indexOf('## First rule')).toBeLessThan(core.indexOf('1. **The owner presses only the owner steps**'));
|
|
for (const owner of ['Submit for review', 'App Privacy Publish', 'legal', 'live prices', 'review replies', 'testers'])
|
|
expect(core).toContain(owner);
|
|
expect(core).toMatch(/ONE batch at the start/);
|
|
});
|
|
|
|
test('reaches every generated adapter, README and the lead of store-pitfalls', () => {
|
|
for (const f of ['AGENTS.md', 'GEMINI.md', '.github/copilot-instructions.md', '.cursor/rules/storebound.mdc'])
|
|
expect(read(f).toLowerCase()).toContain(RULE);
|
|
// the first section of README carries the rule; the full text lives in docs/journey.md
|
|
const readme = read('README.md');
|
|
const first = readme.split('\n## ')[1] ?? '';
|
|
expect(first.toLowerCase()).toContain(RULE);
|
|
expect(read('docs/journey.md')).toContain('## How agents use storebound');
|
|
const pit = read('skills/store-pitfalls/SKILL.md');
|
|
expect(/^## (.*)$/m.exec(pit)?.[1]?.toLowerCase()).toContain(RULE);
|
|
for (const s of ['console-autofill', 'play-first-release', 'appstore-first-release', 'store-declarations'])
|
|
expect(read(`skills/${s}/SKILL.md`).toLowerCase()).toContain(RULE);
|
|
});
|
|
|
|
// Phrases that hand agent work (navigation, draft saves, exports, uploads, form filling, commands) to the owner.
|
|
const HANDOFF: RegExp[] = [
|
|
/\bUI = [^)\n]*by hand/i,
|
|
/\bask the owner to (click|press next|open|navigate|download|upload|export|fill|save|copy|run)\b/i,
|
|
/\b(have|let) the owner (click|open|navigate|download|upload|export|fill|save|copy|run)\b/i,
|
|
/\bthe owner (clicks|opens|navigates|downloads|uploads|exports|fills|copies|pastes)\b/i,
|
|
/\b(click|download|upload|export|navigate)\b[^.\n]{0,60}\byourself\b/i,
|
|
/\b(do|fill|finish) (that|this) (part )?by hand\b/i,
|
|
/\bfor the owner to copy\b/i,
|
|
];
|
|
const files = ['docs/agents-core.md', 'README.md', // the README's former sections, now in docs/
|
|
...['journey', 'getting-started', 'commands', 'rejections', 'growth', 'studios', 'store-art', 'faq', 'safety', 'mcp', 'skills', 'listing-languages', 'api', 'project'].map(n => `docs/${n}.md`),
|
|
...readdirSync(join(root, 'skills')).map(n => `skills/${n}/SKILL.md`).filter(f => { try { read(f); return true; } catch { return false; } })];
|
|
|
|
test('no skill or rule hands navigation, exports, uploads or forms to the owner', () => {
|
|
const hits: string[] = [];
|
|
for (const f of files) {
|
|
read(f).split('\n').forEach((line, i) => {
|
|
if (/never tell the owner/i.test(line)) return; // the rule quoting what not to say
|
|
for (const re of HANDOFF) if (re.test(line)) hits.push(`${f}:${i + 1}: ${line.trim().slice(0, 120)}`);
|
|
});
|
|
}
|
|
expect(hits).toEqual([]);
|
|
});
|
|
|
|
test('the handoff patterns catch the phrases that cost hours', () => {
|
|
const bad = ['## Steps (API = storebound, UI = Play Console by hand)', 'ask the owner to click Next on each page',
|
|
'upload them in the experiment form yourself', 'Do that part by hand (the manual steps follow)',
|
|
'The owner opens App content > Data safety > Export to CSV', 'prints the list for the owner to copy'];
|
|
for (const b of bad) expect(HANDOFF.some(re => re.test(b))).toBe(true);
|
|
const ok = ['drive the page yourself (Claude in Chrome)', 'the owner presses Publish', 'the owner signs in once',
|
|
'press Create app yourself', 'Granting them is the owner\'s (a permission grant)'];
|
|
for (const o of ok) expect(HANDOFF.some(re => re.test(o))).toBe(false);
|
|
});
|
|
|
|
// The CLI's own output: when storebound's scripted part cannot go on, what is left goes to the calling agent (its
|
|
// browser tool with `console plan` / `console verify`, or an edit of the config), not "do it yourself" to the owner.
|
|
// Sign-in, the final Submit / Create / Publish and agreements stay the owner's.
|
|
const CLI_HANDOFF: RegExp[] = [
|
|
/\b(open|fill|answer|check|do|merge|add|set)\b[^.\n]{0,40}\b(yourself|by hand)\b/i,
|
|
/\bdo (this|that) part\b/i,
|
|
/\bto check yourself\b/i,
|
|
];
|
|
const cliFiles = (dir: string): string[] => readdirSync(join(root, dir), { withFileTypes: true }).flatMap(e =>
|
|
e.isDirectory() ? (e.name === 'data' ? [] : cliFiles(`${dir}/${e.name}`))
|
|
// lib/embedded.ts is generated from templates/ (the owner's own files, not CLI output), like data/
|
|
: e.name.endsWith('.ts') && `${dir}/${e.name}` !== 'cli/lib/embedded.ts' ? [`${dir}/${e.name}`] : []);
|
|
|
|
test('CLI output never hands agent work to the owner', () => {
|
|
const hits: string[] = [];
|
|
for (const f of cliFiles('cli')) {
|
|
read(f).split('\n').forEach((line, i) => {
|
|
const t = line.trim();
|
|
if (t.startsWith('//') || t.startsWith('*') || t.startsWith('/*')) return; // comments are for the reader of the code
|
|
for (const re of CLI_HANDOFF) if (re.test(line)) hits.push(`${f}:${i + 1}: ${t.slice(0, 140)}`);
|
|
});
|
|
}
|
|
expect(hits).toEqual([]);
|
|
});
|
|
|
|
test('the CLI patterns catch the old console fill lines and keep the owner-only ones', () => {
|
|
for (const b of ['Open it yourself; storebound keeps watching', 'fill them yourself', 'do this part yourself; storebound keeps watching',
|
|
'3 answer(s) to check yourself.', 'not mapped (do these by hand)'])
|
|
expect([b, CLI_HANDOFF.some(re => re.test(b))]).toEqual([b, true]);
|
|
for (const o of ['review every field and press Submit yourself', 'sign in to App Store Connect yourself (Apple ID, password, two-factor code)',
|
|
'Read and tick them yourself, then press Create app.', 'storebound console plan <form> [--json] [--lookup] (then drive the page yourself'])
|
|
expect([o, CLI_HANDOFF.some(re => re.test(o))]).toEqual([o, false]);
|
|
});
|
|
});
|