desktop: More tools tab, one typed form per CLI command without a screen (schema from system.methods; reads run, writes dry run + native dialog + token) and the owner steps with their terminal command; en + tr

This commit is contained in:
CRTkafa 2026-10-21 05:02:20 +07:00
parent ce1b1edcb8
commit a95aa6dd49
7 changed files with 231 additions and 3 deletions

View file

@ -11,6 +11,14 @@ import type { CatalogDiff, HealthSnapshot, MetricRow, Review, TestFlightStatus,
import { editableFixture, liveFixture } from './editor-fixtures';
import { APPLE_STATUS_READY, APPLE_STATUS_UNRESOLVED, consolePlan, consoleVerify, ownerDry, PLAY_STATUS } from './submit-fixtures';
import { ANALYTICS, ASO, DECLARATIONS, LISTING_TEXT, PPO, PPO_STORE, PRICE_DIFF, PRICES, renderArt, RENDERS } from './fixtures-more';
import { TOOLBOX } from '../../../../cli/lib/desktop-coverage';
/** system.methods for the More tools tab: the toolbox methods with a small schema each (reads end in a read verb). */
const toolMethods = () => TOOLBOX.flatMap(g => g.methods).map(m => {
const read = /status|list|devices|check|diff|next_|app_id|info|wait|plan|verify|rank|competitors|suggest|scan|manifest|triage|originality|aso/.test(m);
return { method: m, title: m.replace('.', ' ').replace(/_/g, ' '), description: `Fixture descriptor of ${m}.`, kind: read ? 'read' : 'write', network: m.startsWith('apple.') || m.startsWith('play.'), destructive: false, json: false,
args: { type: 'object', additionalProperties: false, properties: m === 'play.upload' ? { track: { type: 'string', enum: ['internal', 'alpha', 'beta'], default: 'internal' }, name: { type: 'string', description: 'Release name' } } : m === 'agent.install' ? { agent: { type: 'string', enum: ['claude', 'codex'] }, no_mcp: { type: 'boolean' } } : {}, ...(m === 'agent.install' ? { required: ['agent'] } : {}) } };
});
export const FIXTURE_MARKER = 'storebound-dev-fixtures';
@ -581,6 +589,7 @@ async function answer(cmd: string, a: Record<string, unknown>): Promise<unknown>
case 'cache_clear': return null;
case 'sidecar_call': {
const method = a.method as string;
if (method === 'system.methods') return { methods: toolMethods() };
if (method === 'system.tools') return { tools: (await answer('sidecar_status', {}) as { tools: { tools: unknown[] } }).tools.tools, androidSdk: { path: `${HOME}/Library/Android/sdk`, source: 'default', checked: ['ANDROID_HOME: not set', 'ANDROID_SDK_ROOT: not set', `Android Studio default folder: ${HOME}/Library/Android/sdk`] } };
const id = a.projectId as string;
if (NETWORK.has(method)) await progressLines(job, [`[${method}] reading App Store Connect`, `[${method}] reading Google Play`, `[${method}] done`]);

View file

@ -1805,4 +1805,40 @@ export const en = {
'ed.targetAudience': 'Target audience and ads (Play)',
'ed.appContent': 'App content declarations (Play)',
'ed.appContentHint': 'Government, financial and health features, advertising ID. No API: the console plan step uses them.',
'tab.tools': 'More tools',
'tools.lead': 'Every CLI command without a tab of its own, as a form with the command\'s own options. Reads run here; writes show the dry run and ask in a native dialog first. Owner steps stay yours: copy the terminal command.',
'tools.g.build': 'Build and upload',
'tools.g.adhoc': 'Ad-hoc devices',
'tools.g.store': 'Store features',
'tools.g.text': 'Text and ASO',
'tools.g.capture': 'Captures',
'tools.g.quality': 'Quality',
'tools.g.ads': 'Apple Ads',
'tools.g.flows': 'Workflows and AI tools',
'tools.count': '{count} commands',
'tools.count_one': '1 command',
'tools.show': 'Show',
'tools.hide': 'Hide',
'tools.read': 'read-only',
'tools.write': 'write (dry run first)',
'tools.network': 'store',
'tools.run': 'Run',
'tools.dryRun': 'Dry run, then apply',
'tools.done': '{title}: done',
'tools.default': 'default: {value}',
'tools.exit': 'exit {code}',
'tools.copy': 'Copy',
'tools.copied': 'Copied',
'tools.owner': 'Owner steps (terminal)',
'tools.ownerSub': 'Public or price changes only you decide; run them in a terminal in the app folder',
'tools.o.offer_codes': 'App Store offer codes for one-time purchases',
'tools.o.win_back': 'Win-back offers for lapsed subscribers',
'tools.o.promoted': 'Turn on promoted in-app purchases',
'tools.o.preorder': 'Enable or end an App Store pre-order',
'tools.o.cpp_submit': 'Submit a custom product page (then make it visible)',
'tools.o.events_submit': 'Submit an in-app event',
'tools.o.tf_expire': 'Expire old TestFlight builds',
'tools.o.ads_change': 'Apple Ads bid, budget or pause',
'tools.o.rc_sync': 'Sync the catalog to RevenueCat',
'tools.o.shots_login': 'Sign in to the demo account for screenshots (by hand, Enter saves)',
} as const;

View file

@ -1807,4 +1807,40 @@ export const tr: Record<keyof typeof en, string> = {
'ed.targetAudience': 'Hedef kitle ve reklamlar (Play)',
'ed.appContent': 'Uygulama içeriği beyanları (Play)',
'ed.appContentHint': 'Devlet, finans ve sağlık özellikleri, reklam kimliği. API yok: konsol planı adımı bunları kullanır.',
'tab.tools': 'Diğer araçlar',
'tools.lead': 'Kendi sekmesi olmayan her CLI komutu, komutun kendi seçenekleriyle bir form olarak. Okumalar burada çalışır; yazmalar önce prova sonucunu gösterir ve yerel bir pencerede onay ister. Sahip adımları sizindir: terminal komutunu kopyalayın.',
'tools.g.build': 'Derleme ve yükleme',
'tools.g.adhoc': 'Ad-hoc cihazlar',
'tools.g.store': 'Mağaza özellikleri',
'tools.g.text': 'Metin ve ASO',
'tools.g.capture': 'Çekimler',
'tools.g.quality': 'Kalite',
'tools.g.ads': 'Apple Ads',
'tools.g.flows': 'İş akışları ve yapay zekâ araçları',
'tools.count': '{count} komut',
'tools.count_one': '1 komut',
'tools.show': 'Göster',
'tools.hide': 'Gizle',
'tools.read': 'salt okunur',
'tools.write': 'yazma (önce prova)',
'tools.network': 'mağaza',
'tools.run': 'Çalıştır',
'tools.dryRun': 'Prova, sonra uygula',
'tools.done': '{title}: tamam',
'tools.default': 'varsayılan: {value}',
'tools.exit': 'çıkış {code}',
'tools.copy': 'Kopyala',
'tools.copied': 'Kopyalandı',
'tools.owner': 'Sahip adımları (terminal)',
'tools.ownerSub': 'Yalnızca sizin karar verdiğiniz herkese açık veya fiyat değişiklikleri; uygulama klasöründe bir terminalde çalıştırın',
'tools.o.offer_codes': 'Tek seferlik satın almalar için App Store teklif kodları',
'tools.o.win_back': 'Ayrılan aboneler için geri kazanma teklifleri',
'tools.o.promoted': 'Öne çıkan uygulama içi satın almaları aç',
'tools.o.preorder': 'App Store ön siparişini aç veya bitir',
'tools.o.cpp_submit': 'Özel ürün sayfasını gönder (sonra görünür yap)',
'tools.o.events_submit': 'Uygulama içi etkinliği gönder',
'tools.o.tf_expire': 'Eski TestFlight derlemelerinin süresini bitir',
'tools.o.ads_change': 'Apple Ads teklif, bütçe veya duraklatma',
'tools.o.rc_sync': 'Kataloğu RevenueCat ile eşitle',
'tools.o.shots_login': 'Ekran görüntüleri için demo hesaba giriş (elle, Enter kaydeder)',
};

View file

@ -4,7 +4,7 @@ import { createContext, useContext } from 'react';
import type { Key } from './i18n';
import type { IconName } from './ui/icons';
export const APP_TABS = ['releases', 'catalog', 'prices', 'listings', 'art', 'health', 'reviews', 'testflight', 'growth', 'appinfo', 'checks'] as const;
export const APP_TABS = ['releases', 'catalog', 'prices', 'listings', 'art', 'health', 'reviews', 'testflight', 'growth', 'appinfo', 'checks', 'tools'] as const;
export type AppTab = (typeof APP_TABS)[number];
/** Tabs of a store-only app: store reads only (catalog, prices, app info and checks need the app folder; listings and
* store art are the live ones; Growth shows the Product Page Optimization tests only). */
@ -12,11 +12,11 @@ export const STORE_TABS: readonly AppTab[] = ['releases', 'listings', 'art', 'he
export const tabsFor = (hasFolder: boolean): readonly AppTab[] => (hasFolder ? APP_TABS : STORE_TABS);
export const TAB_KEY: Record<AppTab, Key> = {
releases: 'tab.releases', catalog: 'tab.catalog', prices: 'tab.prices', listings: 'tab.listings', art: 'tab.art', health: 'tab.health', reviews: 'tab.reviews',
testflight: 'tab.testflight', growth: 'tab.growth', appinfo: 'tab.appinfo', checks: 'tab.checks',
testflight: 'tab.testflight', growth: 'tab.growth', appinfo: 'tab.appinfo', checks: 'tab.checks', tools: 'tab.tools',
};
export const TAB_ICON: Record<AppTab, IconName> = {
releases: 'rocket', catalog: 'tag', prices: 'coins', listings: 'text', art: 'grid', health: 'activity', reviews: 'message', testflight: 'plane', growth: 'chart',
appinfo: 'clipboard', checks: 'shield',
appinfo: 'clipboard', checks: 'shield', tools: 'sliders',
};
export const WORKSPACE = ['apps', 'accounts', 'ignored', 'dependencies', 'settings'] as const;
export type WorkspaceScreen = (typeof WORKSPACE)[number];

View file

@ -24,6 +24,7 @@ import { PricesTab } from './tabs/Prices';
import { ReleasesTab } from './tabs/Releases';
import { ReviewsTab } from './tabs/Reviews';
import { TestFlightTab } from './tabs/TestFlight';
import { ToolsTab } from './tabs/Tools';
import { EDIT_TABS, TabEditor, useUnsavedGuard } from './editors/TabEditor';
import { StoreListingEditor } from './editors/StoreListingEditor';
import { useEdits } from '../ui/Editor';
@ -171,6 +172,7 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) {
{tab === 'growth' && <GrowthTab app={app} />}
{tab === 'appinfo' && app.project && <AppInfoTab project={app.project} />}
{tab === 'checks' && app.project && <ChecksTab project={app.project} />}
{tab === 'tools' && app.project && <ToolsTab project={app.project} />}
{tab !== 'listings' && app.project && !app.project.missing && EDIT_TABS[tab] && <TabEditor tab={tab} project={app.project} />}
</ErrorBoundary>
</div>

View file

@ -0,0 +1,140 @@
// More tools: the CLI commands without a screen of their own (cli/lib/desktop-coverage.ts TOOLBOX), one typed form
// per serve method built from its argument schema (system.methods). Never a free-text shell: only the method's own
// options, validated by the sidecar. Reads run here; writes go through the dry run, the native dialog and the confirm
// token (useWrite). Owner-only steps without a button are listed with their terminal command to copy.
import { useEffect, useState } from 'react';
import type { JsonSchemaLite } from '../../../../../cli/lib/desktop-protocol';
import { OWNER_STEPS, TOOLBOX } from '../../../../../cli/lib/desktop-coverage';
import { api, errorText, type MethodDescriptor, newJob, type Project, type ToolResult } from '../../api';
import { type Key, t } from '../../i18n';
import { useData, useWrite } from '../../store';
import { Icon } from '../../ui/icons';
import { Card, Chip, ErrorNotice, Notice, Skeleton } from '../../ui/kit';
import { WriteButton } from '../../ui/WriteButton';
type Args = Record<string, unknown>;
/** Only the values the owner set (empty fields are left out, so the command's defaults apply). */
export function cleanArgs(schema: MethodDescriptor['args'], v: Args): Args {
const out: Args = {};
for (const [k, s] of Object.entries(schema.properties)) {
const x = v[k];
if (x === undefined || x === '' || x === null) continue;
if (s.type === 'boolean') { if (x === true) out[k] = true; continue; }
out[k] = s.type === 'number' || s.type === 'integer' ? Number(x) : x;
}
return out;
}
function Field({ name, s, value, required, onChange }: { name: string; s: JsonSchemaLite; value: unknown; required: boolean; onChange: (v: unknown) => void }) {
const label = <span className="label">{name.replace(/_/g, ' ')}{required ? ' *' : ''}</span>;
if (s.type === 'boolean') {
return <label className="row" style={{ gap: 6 }} title={s.description}><input type="checkbox" checked={value === true} onChange={e => onChange(e.target.checked)} />{label}</label>;
}
if (s.enum) {
return (
<label className="stack" style={{ gap: 2 }} title={s.description}>{label}
<select className="input" value={String(value ?? '')} onChange={e => onChange(e.target.value)}>
<option value="">{s.default !== undefined ? t('tools.default', { value: String(s.default) }) : '–'}</option>
{s.enum.map(o => <option key={String(o)} value={String(o)}>{String(o)}</option>)}
</select>
</label>
);
}
const num = s.type === 'number' || s.type === 'integer';
return (
<label className="stack" style={{ gap: 2 }} title={s.description}>{label}
<input className="input" type={num ? 'number' : 'text'} value={String(value ?? '')} placeholder={s.description ?? ''}
pattern={s.pattern} min={s.minimum} max={s.maximum} onChange={e => onChange(e.target.value)} />
</label>
);
}
function Tool({ m, project }: { m: MethodDescriptor; project: Project }) {
const d = useData();
const w = useWrite(project.id);
const [v, setV] = useState<Args>({});
const [busy, setBusy] = useState(false);
const [res, setRes] = useState<ToolResult | null>(null);
const [err, setErr] = useState<string | null>(null);
const props = Object.entries(m.args?.properties ?? {});
const required = new Set(m.args?.required ?? []);
const args = m.args ? cleanArgs(m.args, v) : {};
const missing = [...required].some(k => args[k] === undefined);
const read = async () => {
setBusy(true); setErr(null);
try { setRes(await api.call<ToolResult>(m.method, project.id, { args }, newJob(m.method))); } catch (e) { setErr(errorText(e)); setRes(null); }
setBusy(false);
};
return (
<div className="tool" data-testid={`tool-${m.method}`}>
<div className="row between">
<div className="stack" style={{ gap: 2 }}>
<span className="strong">{m.title}</span>
<span className="small muted">{m.description}</span>
</div>
<div className="row" style={{ gap: 6 }}>
<Chip tone="outline" dot={false}>{m.kind === 'read' ? t('tools.read') : t('tools.write')}</Chip>
{m.network && <Chip tone="outline" dot={false} icon="cloud">{t('tools.network')}</Chip>}
</div>
</div>
{props.length > 0 && (
<div className="tool-form">
{props.map(([k, s]) => <Field key={k} name={k} s={s} value={v[k]} required={required.has(k)} onChange={x => setV(p => ({ ...p, [k]: x }))} />)}
</div>
)}
<div className="actions">
{m.kind === 'read'
? <button className="btn btn-sm" disabled={busy || missing} aria-busy={busy || undefined} onClick={() => { void read(); }}><Icon name={busy ? 'refresh' : 'play'} className={busy ? 'spin' : undefined} />{t('tools.run')}</button>
: <WriteButton run={w} icon="play" disabled={missing} w={{ method: m.method, args, title: m.title, done: t('tools.done', { title: m.title }) }}>{t('tools.dryRun')}</WriteButton>}
</div>
{err && <ErrorNotice error={err} />}
{res && (
<div className="stack" style={{ gap: 4 }}>
<span className="small muted">{res.command} · {t('tools.exit', { code: res.exitCode })}</span>
<pre className="stack-trace" data-testid="tool-output">{res.output || res.error || (res.data !== undefined ? JSON.stringify(res.data, null, 2) : '')}</pre>
<button className="btn btn-sm btn-ghost" onClick={() => { navigator.clipboard?.writeText(res.output).then(() => d.toast({ kind: 'ok', title: t('tools.copied') }), () => {}); }}><Icon name="copy" />{t('tools.copy')}</button>
</div>
)}
</div>
);
}
export function ToolsTab({ project }: { project: Project }) {
const d = useData();
const [methods, setMethods] = useState<MethodDescriptor[] | null>(null);
const [err, setErr] = useState<string | null>(null);
const [open, setOpen] = useState<string>(TOOLBOX[0]!.id);
useEffect(() => {
api.call<{ methods: MethodDescriptor[] }>('system.methods').then(r => setMethods(r.methods), e => setErr(errorText(e)));
}, []);
const by = new Map((methods ?? []).map(m => [m.method, m] as const));
const copy = (c: string) => { navigator.clipboard?.writeText(c).then(() => d.toast({ kind: 'ok', title: t('tools.copied') }), () => {}); };
return (
<>
<Notice tone="info" icon="info">{t('tools.lead')}</Notice>
{err && <ErrorNotice error={err} />}
{!methods && !err && <Skeleton lines={4} />}
{methods && TOOLBOX.map(g => {
const ms = g.methods.map(x => by.get(x)).filter((x): x is MethodDescriptor => !!x);
const isOpen = open === g.id;
return (
<Card key={g.id} id={`tools-${g.id}`} title={t(`tools.g.${g.id}` as Key)} icon="sliders" sub={t('tools.count', { count: ms.length })}
actions={<button className="btn btn-sm btn-ghost" aria-expanded={isOpen} onClick={() => setOpen(isOpen ? '' : g.id)}><Icon name={isOpen ? 'chevronDown' : 'chevronRight'} />{isOpen ? t('tools.hide') : t('tools.show')}</button>}>
{isOpen && <div className="stack" style={{ gap: 14 }}>{ms.map(m => <Tool key={m.method} m={m} project={project} />)}</div>}
</Card>
);
})}
<Card title={t('tools.owner')} icon="lock" sub={t('tools.ownerSub')} id="tools-owner">
<div className="stack" style={{ gap: 8 }}>
{OWNER_STEPS.map(s => (
<div key={s.id} className="row between" data-testid={`owner-step-${s.id}`}>
<div className="stack" style={{ gap: 2 }}><span>{t(`tools.o.${s.id}` as Key)}</span><code className="small mono-id">{s.command}</code></div>
<button className="btn btn-sm btn-ghost" onClick={() => copy(s.command)}><Icon name="copy" />{t('tools.copy')}</button>
</div>
))}
</div>
</Card>
</>
);
}

View file

@ -807,6 +807,11 @@ h2[tabindex="-1"]:focus, h2[tabindex="-1"]:focus-visible { outline: none; }
.issue-row .chev.rot { transform: rotate(90deg); }
.ellipsis { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.issue-detail { display: grid; gap: 12px; padding: 4px 0; min-width: 0; }
/* More tools tab: one typed form per command */
.tool { display: grid; gap: 8px; padding-top: 12px; border-top: 1px solid var(--line); min-width: 0; }
.tool:first-child { border-top: 0; padding-top: 0; }
.tool-form { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 8px 12px; }
.tool pre { max-height: 260px; overflow: auto; }
.issue-title { overflow-wrap: anywhere; }
.stack-trace { margin: 0; max-height: 280px; overflow: auto; padding: 10px 12px; border-radius: 8px; background: var(--surface); border: 1px solid var(--line, var(--border)); font: 12px/1.5 var(--mono, ui-monospace, monospace); white-space: pre; }
.build-edit { display: grid; gap: 10px; padding: 4px 0; max-width: 760px; }