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:
parent
ce1b1edcb8
commit
a95aa6dd49
7 changed files with 231 additions and 3 deletions
|
|
@ -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`]);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
140
apps/desktop/src/screens/tabs/Tools.tsx
Normal file
140
apps/desktop/src/screens/tabs/Tools.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue