@topolo/cli 0.4.8 → 0.5.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.
Files changed (2) hide show
  1. package/dist/index.js +318 -17
  2. package/package.json +7 -2
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import { platform as platform2 } from "os";
9
9
  import { createInterface } from "readline/promises";
10
10
  import { stdin as input } from "process";
11
11
 
12
- // node_modules/@topolo/sdk/dist/index.js
12
+ // ../topolo-sdk/dist/index.js
13
13
  function applyAuthHeaders(headers, credential) {
14
14
  if (credential.kind === "api_key") {
15
15
  headers.set("X-Api-Key", credential.apiKey);
@@ -3504,7 +3504,7 @@ function createTopolo(options) {
3504
3504
  };
3505
3505
  }
3506
3506
 
3507
- // node_modules/@topolo/sdk/dist/node/credential-store.js
3507
+ // ../topolo-sdk/dist/node/credential-store.js
3508
3508
  import { mkdir, readFile, writeFile, chmod } from "fs/promises";
3509
3509
  import { existsSync } from "fs";
3510
3510
  import { homedir, platform } from "os";
@@ -5819,6 +5819,22 @@ function normalizeWorkspaceSwitcherMode(value) {
5819
5819
  if (mode === "required" || mode === "optional" || mode === "off") return mode;
5820
5820
  throw new Error("Invalid workspace switcher mode. Use required, optional, or off.");
5821
5821
  }
5822
+ function normalizeUiTemplate(value) {
5823
+ const template = String(value || "operations").trim();
5824
+ if (template === "operations" || template === "index" || template === "workbench" || template === "minimal") {
5825
+ return template;
5826
+ }
5827
+ throw new Error("Invalid UI template. Use operations, index, workbench, or minimal.");
5828
+ }
5829
+ function normalizeFirstPartyAppDir(value) {
5830
+ const requested = String(value).trim().replace(/\\/g, "/").replace(/^\.\//, "");
5831
+ const appDir = requested.startsWith("apps/") ? requested.slice("apps/".length) : requested;
5832
+ const segments = appDir.split("/");
5833
+ if (!appDir || appDir.startsWith("/") || appDir.startsWith("apps/") || segments.some((segment) => !segment || segment === "." || segment === "..")) {
5834
+ throw new Error("Invalid first-party app directory. Use a repository name such as TopoloForms or apps/TopoloForms.");
5835
+ }
5836
+ return appDir;
5837
+ }
5822
5838
  function normalizeTarget(value) {
5823
5839
  if (!value) return "platform";
5824
5840
  if (value === "platform" || value === "developer") return value;
@@ -5918,7 +5934,9 @@ function toPascalCase(value) {
5918
5934
  return value.split(/[-_]/).filter(Boolean).map((part) => acronyms.has(part.toLowerCase()) ? part.toUpperCase() : part.slice(0, 1).toUpperCase() + part.slice(1)).join("");
5919
5935
  }
5920
5936
  function toTitle(value) {
5921
- return value.replace(/^topolo[-_\s]*/i, "Topolo ").split(/[-_\s]+/).filter(Boolean).map((part) => part.slice(0, 1).toUpperCase() + part.slice(1)).join(" ");
5937
+ const withoutVendorPrefix = value.replace(/^topolo[-_\s]+/i, "");
5938
+ const source = withoutVendorPrefix.trim() ? withoutVendorPrefix : value;
5939
+ return source.split(/[-_\s]+/).filter(Boolean).map((part) => part.slice(0, 1).toUpperCase() + part.slice(1)).join(" ");
5922
5940
  }
5923
5941
  function json(value) {
5924
5942
  return JSON.stringify(value, null, 2);
@@ -6024,6 +6042,9 @@ function packageJson(application, target, options) {
6024
6042
  devDependencies["@types/react-dom"] = "^19.2.2";
6025
6043
  devDependencies["@vitejs/plugin-react"] = "^4.7.0";
6026
6044
  if (target === "platform") {
6045
+ devDependencies.autoprefixer = "10.5.0";
6046
+ devDependencies.postcss = "8.5.6";
6047
+ devDependencies.tailwindcss = "3.4.19";
6027
6048
  for (const packageName of PLATFORM_BROWSER_TOPOLO_PACKAGES) {
6028
6049
  dependencies[packageName] = topoloPackageVersion(packageName, target);
6029
6050
  }
@@ -6187,13 +6208,16 @@ function readme(application, target, options) {
6187
6208
  "- `@topolo-io/app-funnel` provides the generated public application funnel/landing surface.",
6188
6209
  "- `@topolo-io/app-shell` provides the generated route/auth bootstrap, product shell, login, callback, and loading surfaces.",
6189
6210
  "- `@topolo-io/app-launcher` provides shared app switching and handoff helpers.",
6190
- "- `@topolo-io/ui-kit` provides primitive account, command palette, icon, transition, and bug reporter UI.",
6211
+ "- `@topolo-io/ui-kit` provides every control, table, panel, workbench, inspector, icon, transition, and bug reporter primitive used by the generated product surface.",
6191
6212
  "- `@topolo-io/auth-client` owns browser auth, callback exchange, token refresh, and logout.",
6192
6213
  "- `@topolo-io/api-key-management`, `@topolo-io/chat-widget`, `@topolo-io/notifications`, and `@topolo-io/resource-context` complete the AppShell capability baseline.",
6193
6214
  "- Every Topolo-owned browser dependency is emitted at `latest`; product-domain packages are added only when the app actually adopts that domain.",
6194
6215
  "- `.npmrc` routes private `@topolo-io` packages to GitHub Packages without storing credentials; public `@topolo` packages remain on npm. Local and CI environments must provide a GitHub Packages token.",
6195
6216
  "- Generated first-party browser apps start on the shared landing page at `/`, use the shared login page at `/login`, and complete sign-in on `/auth/callback` before entering the configured authenticated home.",
6196
6217
  "- The workspace selector renders in the top utility bar; Getting Started is an account-menu action and is not part of the left product rail.",
6218
+ "- The default `operations` UI template separates dashboard, CRUD index, detail workbench, and inspector concerns. Use `--ui-template index`, `--ui-template workbench`, or `--ui-template minimal` only when that product shape is intentional.",
6219
+ "- Every visible product navigation item includes a canonical icon so the shared sidebar remains usable when collapsed.",
6220
+ "- `styles.css` contains only Tailwind directives; app-local controls, semantic tables, or replacement design-system CSS fail the generated UI contract test.",
6197
6221
  "- `vite.config.ts` includes the standard React plugin plus React runtime dedupe so linked Topolo packages resolve one browser React runtime."
6198
6222
  );
6199
6223
  if (options.api) {
@@ -6290,6 +6314,8 @@ function agentRules(application, target, options) {
6290
6314
  "- Start from `staging`, preserve other agents' work, and do not revert unrelated local changes.",
6291
6315
  "- Make contained changes at the real ownership boundary; do not patch around shared runtime, auth, launcher, shell, or resource-context issues locally.",
6292
6316
  "- Browser code should stay on the shared landing/login/callback/AppShell baseline generated by the scaffold.",
6317
+ "- Keep every visible product navigation item on a canonical `@topolo-io/ui-kit/icons` icon so collapsed navigation remains complete.",
6318
+ "- Use shared UI-kit controls, tables, panels, workbenches, and inspectors; the generated UI contract intentionally rejects app-local native controls and tables.",
6293
6319
  ...options.api ? ["- API code should stay on `@topolo-io/worker-runtime`, derive auth context from verified credentials, and preserve Nexus usage monitoring."] : [],
6294
6320
  ...hasScaffoldResources(options.resources) ? ["- Keep Cloudflare resource bindings in Wrangler, CloudControl, Env typing, docs, and README verification output in sync."] : [],
6295
6321
  "",
@@ -6640,7 +6666,36 @@ function mainTsx() {
6640
6666
  ""
6641
6667
  ].join("\n");
6642
6668
  }
6643
- function stylesCss() {
6669
+ function tailwindConfigCjs() {
6670
+ return [
6671
+ "const { createTopoloTailwindConfig } = require('@topolo-io/design-tokens/tailwind.config.cjs');",
6672
+ "",
6673
+ "module.exports = createTopoloTailwindConfig({",
6674
+ " content: [",
6675
+ " './apps/web/index.html',",
6676
+ " './apps/web/src/**/*.{js,jsx,ts,tsx}',",
6677
+ " ],",
6678
+ "});",
6679
+ ""
6680
+ ].join("\n");
6681
+ }
6682
+ function postcssConfigCjs() {
6683
+ return [
6684
+ "const path = require('node:path');",
6685
+ "",
6686
+ "module.exports = {",
6687
+ " plugins: {",
6688
+ " tailwindcss: { config: path.join(__dirname, 'tailwind.config.cjs') },",
6689
+ " autoprefixer: {},",
6690
+ " },",
6691
+ "};",
6692
+ ""
6693
+ ].join("\n");
6694
+ }
6695
+ function stylesCss(target) {
6696
+ if (target === "platform") {
6697
+ return ["@tailwind base;", "@tailwind components;", "@tailwind utilities;", ""].join("\n");
6698
+ }
6644
6699
  return [
6645
6700
  "/* Local app styles. Design tokens come from @topolo-io/design-tokens (imported in main.tsx). */",
6646
6701
  "/* Use hsl(var(--token)) for colors so light/dark themes flip from the shared shell. */",
@@ -6846,20 +6901,41 @@ export const APP_DISPLAY_NAME = '${escapeSingleQuoted(appName)}';
6846
6901
  `;
6847
6902
  }
6848
6903
  function platformAppRoutesTsx(experience) {
6849
- const nav = experience.navigationMode === "none" ? "" : " nav: { id: 'home', label: HOME_LABEL },\n";
6904
+ const nav = experience.navigationMode === "none" ? "" : " nav: { id: 'home', label: HOME_LABEL, icon: LayoutDashboard },\n";
6905
+ const recordsNav = experience.navigationMode === "none" ? "" : " nav: { id: 'records', label: 'Records', icon: ListTree },\n";
6906
+ const starterImports = experience.uiTemplate === "minimal" ? "" : "import RecordDetailRoute from './routes/RecordDetailRoute';\nimport RecordsRoute from './routes/RecordsRoute';\n";
6907
+ const homeElement = experience.uiTemplate === "index" ? "<RecordsRoute />" : experience.uiTemplate === "workbench" ? "<RecordDetailRoute />" : "<HomeRoute />";
6908
+ const starterRoutes = experience.uiTemplate === "minimal" ? "" : experience.uiTemplate === "operations" ? ` {
6909
+ path: '/records',
6910
+ ${recordsNav} loading: { shell: NAVIGATION_MODE, content: 'table', title: 'Records' },
6911
+ element: <RecordsRoute />,
6912
+ },
6913
+ {
6914
+ path: '/records/:recordId/:section',
6915
+ loading: { shell: NAVIGATION_MODE, content: 'split', title: 'Record' },
6916
+ element: <RecordDetailRoute />,
6917
+ },
6918
+ ` : ` {
6919
+ path: '/records/:recordId/:section',
6920
+ loading: { shell: NAVIGATION_MODE, content: 'split', title: 'Record' },
6921
+ element: <RecordDetailRoute />,
6922
+ },
6923
+ `;
6850
6924
  return `import type { AppRoute } from '@topolo-io/app-shell';
6925
+ import { LayoutDashboard, ListTree } from '@topolo-io/ui-kit/icons';
6851
6926
  import { HOME_LABEL, HOME_PATH, NAVIGATION_MODE } from './app-config';
6852
6927
  import GettingStartedRoute from './routes/GettingStartedRoute';
6853
6928
  import HomeRoute from './routes/HomeRoute';
6929
+ ${starterImports}
6854
6930
 
6855
6931
  export function createAppRoutes(appId: string): AppRoute[] {
6856
6932
  return [
6857
6933
  {
6858
6934
  path: HOME_PATH,
6859
6935
  ${nav} loading: { shell: NAVIGATION_MODE, content: 'workspace', title: HOME_LABEL },
6860
- element: <HomeRoute />,
6936
+ element: ${homeElement},
6861
6937
  },
6862
- {
6938
+ ${starterRoutes} {
6863
6939
  path: '/getting-started',
6864
6940
  loading: { shell: NAVIGATION_MODE, content: 'card-grid', title: 'Getting started' },
6865
6941
  element: <GettingStartedRoute appId={appId} />,
@@ -6868,7 +6944,8 @@ ${nav} loading: { shell: NAVIGATION_MODE, content: 'workspace', title: HOME
6868
6944
  }
6869
6945
  `;
6870
6946
  }
6871
- function platformHomeRouteTsx() {
6947
+ function platformHomeRouteTsx(uiTemplate) {
6948
+ if (uiTemplate === "operations") return platformOperationsHomeRouteTsx();
6872
6949
  return `import { TopoloAppPage, TopoloAppPageHeader, useAuth } from '@topolo-io/app-shell';
6873
6950
  import { IntroTour } from '@topolo-io/tour';
6874
6951
  import { useTranslation } from '@topolo-io/ui-kit';
@@ -6904,6 +6981,148 @@ export default function HomeRoute() {
6904
6981
  }
6905
6982
  `;
6906
6983
  }
6984
+ function platformOperationsHomeRouteTsx() {
6985
+ return `import { TopoloAppPage, TopoloAppPageHeader, useAuth } from '@topolo-io/app-shell';
6986
+ import { Button, ListRow, TopoloMetricStrip, TopoloOperationalPanel } from '@topolo-io/ui-kit';
6987
+ import { IntroTour } from '@topolo-io/tour';
6988
+ import { useNavigate } from 'react-router-dom';
6989
+ import { APP_NAME, HOME_LABEL, HOME_PATH, PRODUCT_TAGLINE } from '../app-config';
6990
+ import { STARTER_RECORDS } from './starter-records';
6991
+
6992
+ // STARTER TEMPLATE: replace the sample metrics and activity with product data.
6993
+ export default function HomeRoute() {
6994
+ const { user } = useAuth();
6995
+ const navigate = useNavigate();
6996
+ const active = STARTER_RECORDS.filter((record) => record.status === 'active').length;
6997
+ return (
6998
+ <TopoloAppPage width="none" data-tour="home-page">
6999
+ <TopoloAppPageHeader eyebrow={APP_NAME} title={HOME_LABEL} description={PRODUCT_TAGLINE} actions={<Button onClick={() => navigate('/records')}>Open records</Button>} />
7000
+ <TopoloMetricStrip metrics={[
7001
+ { id: 'total', label: 'Total records', value: STARTER_RECORDS.length },
7002
+ { id: 'active', label: 'Active', value: active, tone: 'success' },
7003
+ { id: 'attention', label: 'Needs attention', value: STARTER_RECORDS.length - active, tone: 'warning' },
7004
+ ]} />
7005
+ <TopoloOperationalPanel title="Recent records" description="Keep operational summaries focused; detailed CRUD belongs in the index and workbench routes." action={<Button variant="secondary" onClick={() => navigate('/records')}>View index</Button>}>
7006
+ <div className="grid gap-1">{STARTER_RECORDS.slice(0, 3).map((record) => <ListRow key={record.id} title={record.name} subtitle={record.owner} meta={record.updatedAt} onSelect={() => navigate(\`/records/\${record.id}/overview\`)} />)}</div>
7007
+ </TopoloOperationalPanel>
7008
+ <IntroTour appName={APP_NAME} role={user?.role} onOpenGettingStarted={() => navigate('/getting-started')} extraSteps={[{
7009
+ id: 'home-page', target: '[data-tour="home-page"]', title: HOME_LABEL, content: PRODUCT_TAGLINE,
7010
+ placement: 'bottom' as const, beforeStep: () => navigate(HOME_PATH),
7011
+ }]} />
7012
+ </TopoloAppPage>
7013
+ );
7014
+ }
7015
+ `;
7016
+ }
7017
+ function platformStarterDataTs() {
7018
+ return `// STARTER TEMPLATE: replace this file with the app's real API-backed domain model.
7019
+ export type StarterRecordStatus = 'active' | 'draft' | 'paused';
7020
+
7021
+ export interface StarterRecord {
7022
+ id: string;
7023
+ name: string;
7024
+ status: StarterRecordStatus;
7025
+ owner: string;
7026
+ summary: string;
7027
+ updatedAt: string;
7028
+ }
7029
+
7030
+ export const STARTER_RECORDS: StarterRecord[] = [
7031
+ { id: 'starter-record-1', name: 'First starter record', status: 'active', owner: 'Product team', summary: 'Replace this sample with one app-specific concept.', updatedAt: 'Today' },
7032
+ { id: 'starter-record-2', name: 'Second starter record', status: 'draft', owner: 'Operations', summary: 'Use the index for discovery and the workbench for focused editing.', updatedAt: 'Yesterday' },
7033
+ { id: 'starter-record-3', name: 'Third starter record', status: 'paused', owner: 'Workspace admin', summary: 'Keep unrelated workflows on separate routes.', updatedAt: '3 days ago' },
7034
+ ];
7035
+ `;
7036
+ }
7037
+ function platformRecordsRouteTsx() {
7038
+ return `import { useMemo, useState, type FormEvent } from 'react';
7039
+ import { TopoloAppDataTable, TopoloAppEmptyState, TopoloAppFilterBar, TopoloAppPage, TopoloAppPageHeader, TopoloAppToolbar } from '@topolo-io/app-shell';
7040
+ import { Badge, Button, FormField, Input, SearchInput, Select, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, TopoloRightPanel } from '@topolo-io/ui-kit';
7041
+ import { Plus } from '@topolo-io/ui-kit/icons';
7042
+ import { useNavigate } from 'react-router-dom';
7043
+ import { STARTER_RECORDS, type StarterRecord, type StarterRecordStatus } from './starter-records';
7044
+
7045
+ // STARTER TEMPLATE: keep the index focused on discovery, filtering, and create entry.
7046
+ export default function RecordsRoute() {
7047
+ const navigate = useNavigate();
7048
+ const [records, setRecords] = useState<StarterRecord[]>(STARTER_RECORDS);
7049
+ const [query, setQuery] = useState('');
7050
+ const [status, setStatus] = useState('current');
7051
+ const [createOpen, setCreateOpen] = useState(false);
7052
+ const visible = useMemo(() => records.filter((record) => {
7053
+ if (status !== 'current' && status !== 'all' && record.status !== status) return false;
7054
+ return !query || record.name.toLowerCase().includes(query.toLowerCase());
7055
+ }), [query, records, status]);
7056
+
7057
+ function createRecord(event: FormEvent<HTMLFormElement>) {
7058
+ event.preventDefault();
7059
+ const data = new FormData(event.currentTarget);
7060
+ const name = String(data.get('name') || '').trim();
7061
+ if (!name) return;
7062
+ const record: StarterRecord = {
7063
+ id: \`starter-record-\${Date.now()}\`, name, status: 'draft', owner: String(data.get('owner') || 'Workspace team'),
7064
+ summary: String(data.get('summary') || 'Replace this starter form with domain fields.'), updatedAt: 'Just now',
7065
+ };
7066
+ setRecords((current) => [record, ...current]);
7067
+ setCreateOpen(false);
7068
+ }
7069
+
7070
+ return (
7071
+ <TopoloAppPage width="none">
7072
+ <TopoloAppPageHeader eyebrow="Starter CRUD" title="Records" description="A canonical index route with shared controls, shared tables, and a separate creation panel." actions={<Button leftIcon={<Plus size={16} />} onClick={() => setCreateOpen(true)}>New record</Button>} />
7073
+ <TopoloAppToolbar><TopoloAppFilterBar>
7074
+ <SearchInput className="w-64" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search records" aria-label="Search records" />
7075
+ <Select className="w-44" value={status} onChange={(event) => setStatus(event.target.value)} aria-label="Record status"><option value="current">Current</option><option value="active">Active</option><option value="draft">Draft</option><option value="paused">Paused</option><option value="all">All</option></Select>
7076
+ </TopoloAppFilterBar><span className="text-sm text-muted-foreground">{visible.length} result{visible.length === 1 ? '' : 's'}</span></TopoloAppToolbar>
7077
+ {visible.length ? <TopoloAppDataTable aria-label="Records"><Table>
7078
+ <TableHeader><TableRow><TableHead>Name</TableHead><TableHead>Status</TableHead><TableHead>Owner</TableHead><TableHead>Updated</TableHead></TableRow></TableHeader>
7079
+ <TableBody>{visible.map((record) => <TableRow className="cursor-pointer" key={record.id} tabIndex={0} onClick={() => navigate(\`/records/\${record.id}/overview\`)} onKeyDown={(event) => { if (event.key === 'Enter') navigate(\`/records/\${record.id}/overview\`); }}>
7080
+ <TableCell><span className="grid gap-0.5"><strong>{record.name}</strong><small className="text-xs text-muted-foreground">{record.id}</small></span></TableCell>
7081
+ <TableCell><RecordStatus status={record.status} /></TableCell><TableCell>{record.owner}</TableCell><TableCell>{record.updatedAt}</TableCell>
7082
+ </TableRow>)}</TableBody>
7083
+ </Table></TopoloAppDataTable> : <TopoloAppEmptyState title="No records match" description="Adjust the filters or create a record." action={<Button onClick={() => setCreateOpen(true)}>Create record</Button>} />}
7084
+ <TopoloRightPanel open={createOpen} onOpenChange={setCreateOpen} title="Create record" description="Replace these starter fields with the smallest valid domain create flow." footer={<Button type="submit" form="starter-create-record">Create record</Button>}>
7085
+ <form id="starter-create-record" className="grid gap-5" onSubmit={createRecord}><FormField label="Name"><Input name="name" required autoFocus /></FormField><FormField label="Owner"><Input name="owner" placeholder="Workspace team" /></FormField><FormField label="Summary"><Input name="summary" placeholder="What this record represents" /></FormField></form>
7086
+ </TopoloRightPanel>
7087
+ </TopoloAppPage>
7088
+ );
7089
+ }
7090
+
7091
+ export function RecordStatus({ status }: { status: StarterRecordStatus }) {
7092
+ const tone = status === 'active' ? 'success' : status === 'paused' ? 'warning' : 'info';
7093
+ return <Badge tone={tone}>{status}</Badge>;
7094
+ }
7095
+ `;
7096
+ }
7097
+ function platformRecordDetailRouteTsx() {
7098
+ return `import { TopoloAppEmptyState } from '@topolo-io/app-shell';
7099
+ import { Button, CopyField, Tabs, TabsList, TabsTrigger, TopoloCommandBar, TopoloInspectorPanel, TopoloKeyValueRow, TopoloOperationalPanel, TopoloWorkbench } from '@topolo-io/ui-kit';
7100
+ import { useNavigate, useParams } from 'react-router-dom';
7101
+ import { RecordStatus } from './RecordsRoute';
7102
+ import { STARTER_RECORDS } from './starter-records';
7103
+
7104
+ const sections = ['overview', 'activity', 'settings'] as const;
7105
+
7106
+ // STARTER TEMPLATE: one selected record, one focused workflow, one inspector. The product sidebar remains the only left rail.
7107
+ export default function RecordDetailRoute() {
7108
+ const navigate = useNavigate();
7109
+ const { recordId, section: rawSection } = useParams();
7110
+ const record = STARTER_RECORDS.find((item) => item.id === recordId) ?? STARTER_RECORDS[0];
7111
+ const section = sections.includes(rawSection as (typeof sections)[number]) ? rawSection! : 'overview';
7112
+ if (!record) return <TopoloAppEmptyState title="Record not found" description="Return to the index and choose another record." action={<Button onClick={() => navigate('/records')}>Back to records</Button>} />;
7113
+ return (
7114
+ <div className="-m-6 min-w-0 max-md:-m-4"><TopoloWorkbench layout="split" className="min-h-[calc(100dvh-4rem)] gap-0 overflow-hidden bg-background">
7115
+ <main className="min-w-0 overflow-y-auto p-6 max-md:p-4">
7116
+ <TopoloCommandBar eyebrow="Starter record" title={record.name} description={record.summary} meta={<RecordStatus status={record.status} />} actions={<Button>Edit record</Button>} />
7117
+ <Tabs className="my-5" value={section} onValueChange={(next) => navigate(\`/records/\${record.id}/\${next}\`)}><TabsList>{sections.map((item) => <TabsTrigger key={item} value={item}>{item}</TabsTrigger>)}</TabsList></Tabs>
7118
+ <TopoloOperationalPanel title={section === 'overview' ? 'Record overview' : section === 'activity' ? 'Record activity' : 'Record settings'} description="Replace this panel with one focused domain workflow. Do not combine unrelated concepts on this route."><TopoloKeyValueRow label="Owner" value={record.owner} /><TopoloKeyValueRow label="Updated" value={record.updatedAt} /></TopoloOperationalPanel>
7119
+ </main>
7120
+ <TopoloInspectorPanel className="m-4 ml-0 max-lg:m-4" title="Record context" description="Keep selected-record metadata and secondary actions here." sticky><TopoloKeyValueRow label="Status" value={<RecordStatus status={record.status} />} /><TopoloKeyValueRow label="Owner" value={record.owner} /><CopyField label="Record ID" value={record.id} /></TopoloInspectorPanel>
7121
+ </TopoloWorkbench></div>
7122
+ );
7123
+ }
7124
+ `;
7125
+ }
6907
7126
  function platformGettingStartedRouteTsx() {
6908
7127
  return `import { TopoloAppPage, useAuth } from '@topolo-io/app-shell';
6909
7128
  import { GettingStarted, type OnboardingGuideStep } from '@topolo-io/onboarding';
@@ -7857,7 +8076,7 @@ function topoloConformanceWorkflow(options) {
7857
8076
  " - name: Install generated application",
7858
8077
  ` if: steps.app-token.outputs.token != '' || env.HAS_TOPOLO_CI_REPO_TOKEN == 'true'`,
7859
8078
  ` working-directory: ${appPath}`,
7860
- " run: pnpm install --frozen-lockfile",
8079
+ " run: pnpm install --no-frozen-lockfile",
7861
8080
  "",
7862
8081
  " - name: Run generated application quality gates",
7863
8082
  ` if: steps.app-token.outputs.token != '' || env.HAS_TOPOLO_CI_REPO_TOKEN == 'true'`,
@@ -8795,7 +9014,8 @@ ${coverageThresholds}
8795
9014
  function platformBrowserContractTest(application, experience) {
8796
9015
  const required = experience.workspaceSwitcher === "required";
8797
9016
  const enabled = experience.workspaceSwitcher !== "off";
8798
- const navExpectation = experience.navigationMode === "none" ? " expect(home.nav).toBeUndefined();" : ` expect(home.nav).toEqual({ id: 'home', label: '${escapeSingleQuoted(experience.homeLabel)}' });`;
9017
+ const navExpectation = experience.navigationMode === "none" ? " expect(home.nav).toBeUndefined();" : ` expect(home.nav).toMatchObject({ id: 'home', label: '${escapeSingleQuoted(experience.homeLabel)}' });
9018
+ expect(home.nav?.icon).toBeDefined();`;
8799
9019
  return `import { describe, expect, it, vi } from 'vitest';
8800
9020
 
8801
9021
  vi.mock('../src/app-identity', () => ({
@@ -8816,12 +9036,52 @@ describe('generated application contract', () => {
8816
9036
  });
8817
9037
 
8818
9038
  it('keeps Getting Started out of product navigation', () => {
8819
- const [home, gettingStarted] = createAppRoutes('app_test');
9039
+ const routes = createAppRoutes('app_test');
9040
+ const home = routes.find((route) => route.path === '${escapeSingleQuoted(experience.homePath)}')!;
9041
+ const gettingStarted = routes.find((route) => route.path === '/getting-started')!;
8820
9042
  expect(home.path).toBe('${escapeSingleQuoted(experience.homePath)}');
8821
9043
  ${navExpectation}
8822
9044
  expect(gettingStarted.path).toBe('/getting-started');
8823
9045
  expect(gettingStarted.nav).toBeUndefined();
8824
9046
  });
9047
+
9048
+ it('keeps every visible route reachable when a sidebar is collapsed', () => {
9049
+ for (const route of createAppRoutes('app_test').filter((candidate) => candidate.nav)) {
9050
+ expect(route.nav?.icon).toBeDefined();
9051
+ }
9052
+ });
9053
+ });
9054
+ `;
9055
+ }
9056
+ function platformUiKitContractTest() {
9057
+ return `import { readFileSync, readdirSync, statSync } from 'node:fs';
9058
+ import { join } from 'node:path';
9059
+ import { describe, expect, it } from 'vitest';
9060
+
9061
+ function sourceFiles(root: string): string[] {
9062
+ return readdirSync(root).flatMap((entry) => {
9063
+ const path = join(root, entry);
9064
+ return statSync(path).isDirectory() ? sourceFiles(path) : path.endsWith('.tsx') ? [path] : [];
9065
+ });
9066
+ }
9067
+
9068
+ describe('generated shared UI contract', () => {
9069
+ const sourceRoot = join(process.cwd(), 'src');
9070
+ const source = sourceFiles(sourceRoot).map((path) => readFileSync(path, 'utf8')).join('\\n');
9071
+
9072
+ it('does not hand-roll controls or semantic tables in first-party routes', () => {
9073
+ expect(source).not.toMatch(/<(button|input|select|textarea|table|thead|tbody|tr|th|td)(\\s|>)/);
9074
+ });
9075
+
9076
+ it('keeps app-local CSS limited to the shared Tailwind pipeline', () => {
9077
+ expect(readFileSync(join(sourceRoot, 'styles.css'), 'utf8').trim()).toBe('@tailwind base;\\n@tailwind components;\\n@tailwind utilities;');
9078
+ });
9079
+
9080
+ it('scans app source and shared packages through the canonical token config', () => {
9081
+ const config = readFileSync(join(process.cwd(), 'tailwind.config.cjs'), 'utf8');
9082
+ expect(config).toContain('createTopoloTailwindConfig');
9083
+ expect(config).toContain("./apps/web/src/**/*.{js,jsx,ts,tsx}");
9084
+ });
8825
9085
  });
8826
9086
  `;
8827
9087
  }
@@ -8970,7 +9230,8 @@ function applicationScaffoldPayload(application, options = {}) {
8970
9230
  }
8971
9231
  const ownerType = target === "platform" ? "topolo_first_party" : "third_party";
8972
9232
  const distributionModel = target === "platform" ? "marketplace" : "organization_internal";
8973
- const appDir = options.appDir ?? catalogApplication?.appDir ?? (target === "platform" ? `Topolo${toPascalCase(id)}` : `topolo-${slug}`);
9233
+ const requestedAppDir = options.appDir ?? catalogApplication?.appDir ?? (target === "platform" ? `Topolo${toPascalCase(id)}` : `topolo-${slug}`);
9234
+ const appDir = target === "platform" ? normalizeFirstPartyAppDir(requestedAppDir) : requestedAppDir;
8974
9235
  const name = options.name ?? catalogApplication?.name ?? `topolo-${slug}`;
8975
9236
  const packageName = options.packageName ?? catalogApplication?.packageName ?? `topolo-${slug}`;
8976
9237
  const experience = {
@@ -8978,6 +9239,7 @@ function applicationScaffoldPayload(application, options = {}) {
8978
9239
  homeLabel: normalizeHomeLabel(options.homeLabel),
8979
9240
  navigationMode: normalizeNavigationMode(options.navigationMode),
8980
9241
  workspaceSwitcher: normalizeWorkspaceSwitcherMode(options.workspaceSwitcher),
9242
+ uiTemplate: normalizeUiTemplate(options.uiTemplate),
8981
9243
  workspaceLabel: tenancy === "household" ? "Households" : "Workspaces",
8982
9244
  tagline: String(options.tagline || "").trim() || `Manage your work in ${toTitle(name)}.`
8983
9245
  };
@@ -9257,9 +9519,21 @@ function scaffoldFiles(options) {
9257
9519
  "does not fork the shared shell chrome",
9258
9520
  "keeps app-specific layout scoped under generated classes"
9259
9521
  ],
9260
- content: stylesCss()
9522
+ content: stylesCss(target)
9261
9523
  }] : [],
9262
9524
  ...target === "platform" && options.browser ? [
9525
+ {
9526
+ path: `${browserBase}/tailwind.config.cjs`,
9527
+ purpose: "Canonical shared-token Tailwind content configuration.",
9528
+ requiredSignals: ["extends the shared Topolo token config", "scans app source correctly when Vite starts from the repository root"],
9529
+ content: tailwindConfigCjs()
9530
+ },
9531
+ {
9532
+ path: `${browserBase}/postcss.config.cjs`,
9533
+ purpose: "PostCSS pipeline for shared Topolo utility classes.",
9534
+ requiredSignals: ["loads the generated Tailwind config", "runs autoprefixer"],
9535
+ content: postcssConfigCjs()
9536
+ },
9263
9537
  {
9264
9538
  path: `${browserBase}/src/app-config.ts`,
9265
9539
  purpose: "Product route, navigation, context-switcher, and entry-surface configuration.",
@@ -9276,8 +9550,28 @@ function scaffoldFiles(options) {
9276
9550
  path: `${browserBase}/src/routes/HomeRoute.tsx`,
9277
9551
  purpose: "Focused authenticated home product surface.",
9278
9552
  requiredSignals: ["uses shared page primitives", "uses translation-safe product copy"],
9279
- content: platformHomeRouteTsx()
9553
+ content: platformHomeRouteTsx(options.payload.experience.uiTemplate)
9280
9554
  },
9555
+ ...options.payload.experience.uiTemplate === "minimal" ? [] : [
9556
+ {
9557
+ path: `${browserBase}/src/routes/starter-records.ts`,
9558
+ purpose: "Clearly marked replaceable starter records for the generated UI template.",
9559
+ requiredSignals: ["keeps sample data outside route components", "is explicitly marked for replacement"],
9560
+ content: platformStarterDataTs()
9561
+ },
9562
+ {
9563
+ path: `${browserBase}/src/routes/RecordsRoute.tsx`,
9564
+ purpose: "Focused CRUD index template built exclusively from shared UI primitives.",
9565
+ requiredSignals: ["uses shared table, search, select, button, and right-panel primitives", "keeps creation separate from the index"],
9566
+ content: platformRecordsRouteTsx()
9567
+ },
9568
+ {
9569
+ path: `${browserBase}/src/routes/RecordDetailRoute.tsx`,
9570
+ purpose: "Focused record workbench template with collection rail and inspector.",
9571
+ requiredSignals: ["uses the shared workbench and inspector primitives", "keeps detail sections URL-addressable"],
9572
+ content: platformRecordDetailRouteTsx()
9573
+ }
9574
+ ],
9281
9575
  {
9282
9576
  path: `${browserBase}/src/routes/GettingStartedRoute.tsx`,
9283
9577
  purpose: "Focused role-aware onboarding route reached from the account menu.",
@@ -9560,6 +9854,12 @@ export default defineConfig({
9560
9854
  requiredSignals: ["proves API proxying", "proves non-API browser fallback"],
9561
9855
  content: platformWebWorkerTest(application, options.api)
9562
9856
  },
9857
+ {
9858
+ path: `${browserBase}/test/ui-kit-contract.test.ts`,
9859
+ purpose: "Executable guard against hand-rolled first-party controls and broken shared styling.",
9860
+ requiredSignals: ["rejects native controls and semantic tables in generated routes", "proves canonical Tailwind scanning and zero app-local design CSS"],
9861
+ content: platformUiKitContractTest()
9862
+ },
9563
9863
  {
9564
9864
  path: `${browserBase}/src/worker.ts`,
9565
9865
  purpose: "Browser Worker that serves assets and forwards API paths when an API target exists.",
@@ -10407,10 +10707,10 @@ function registerPlatformCommand(program2) {
10407
10707
  const platformApps = platform3.command("apps").description("Scaffold and operate first-party Topolo platform and personal applications");
10408
10708
  platformApps.command("scaffold <application>").description(
10409
10709
  "Generate a first-party Topolo scaffold for the platform or personal portfolio."
10410
- ).option("--json", "Output JSON").option("--no-json", "Output human-readable checklist").option("--app-dir <path>", "Application directory under apps/").option("--name <name>", "Application display/package source name").option("--package-name <name>", "package.json name").option("--production-url <url>", "Production browser URL").option("--service <id>", "Stable SDK/Auth app id for API-backed apps").option("--portfolio <portfolio>", "First-party portfolio: platform or personal").option(
10710
+ ).option("--json", "Output JSON").option("--no-json", "Output human-readable checklist").option("--app-dir <path>", "Application repo name or apps/<repo> path").option("--name <name>", "Application display/package source name").option("--package-name <name>", "package.json name").option("--production-url <url>", "Production browser URL").option("--service <id>", "Stable SDK/Auth app id for API-backed apps").option("--portfolio <portfolio>", "First-party portfolio: platform or personal").option(
10411
10711
  "--surface-model <surfaceModel>",
10412
10712
  "First-party surface model: web_app, mobile_container, mobile_module, or messaging_surface"
10413
- ).option("--container-app-id <appId>", "Container app id when --surface-model mobile_module is used").option("--profile <profile>", "First-party scaffold profile such as spatial-assets").option("--with-d1", "Declare a D1 binding in the first-party scaffold").option("--with-r2", "Declare an R2 binding in the first-party scaffold").option("--with-queue", "Declare a Queue binding in the first-party scaffold").option("--with-workflow", "Declare a Workflow binding in the first-party scaffold").option("--with-container-processing", "Declare container-processing metadata in CloudControl").option("--home-path <path>", "Authenticated home route (default: /dashboard)").option("--home-label <label>", "Home navigation label (default: Dashboard)").option("--navigation-mode <mode>", "Product navigation: sidebar, topbar, or none").option("--workspace-switcher <mode>", "Workspace context selector: required, optional, or off").option("--tagline <text>", "Product-facing landing and home description").option("--write", "Write the generated scaffold files to disk").option("--repo-root <path>", "Directory where scaffold files should be written").option("--force", "Overwrite existing scaffold files when --write is set").option("--with-api", "Include the API/service baseline").option("--no-api", "Do not include API/service requirements").option("--no-browser", "Do not include browser-shell requirements").option(
10713
+ ).option("--container-app-id <appId>", "Container app id when --surface-model mobile_module is used").option("--profile <profile>", "First-party scaffold profile such as spatial-assets").option("--with-d1", "Declare a D1 binding in the first-party scaffold").option("--with-r2", "Declare an R2 binding in the first-party scaffold").option("--with-queue", "Declare a Queue binding in the first-party scaffold").option("--with-workflow", "Declare a Workflow binding in the first-party scaffold").option("--with-container-processing", "Declare container-processing metadata in CloudControl").option("--home-path <path>", "Authenticated home route (default: /dashboard)").option("--home-label <label>", "Home navigation label (default: Dashboard)").option("--navigation-mode <mode>", "Product navigation: sidebar, topbar, or none").option("--workspace-switcher <mode>", "Workspace context selector: required, optional, or off").option("--ui-template <template>", "Shared UI starter: operations, index, workbench, or minimal").option("--tagline <text>", "Product-facing landing and home description").option("--write", "Write the generated scaffold files to disk").option("--repo-root <path>", "Directory where scaffold files should be written").option("--force", "Overwrite existing scaffold files when --write is set").option("--with-api", "Include the API/service baseline").option("--no-api", "Do not include API/service requirements").option("--no-browser", "Do not include browser-shell requirements").option(
10414
10714
  "--provision",
10415
10715
  "Provision the generated first-party app into Topolo Developers and Auth using the authenticated Topolo admin session"
10416
10716
  ).option(
@@ -10450,6 +10750,7 @@ function registerPlatformCommand(program2) {
10450
10750
  homeLabel: opts.homeLabel,
10451
10751
  navigationMode: opts.navigationMode,
10452
10752
  workspaceSwitcher: opts.workspaceSwitcher,
10753
+ uiTemplate: opts.uiTemplate,
10453
10754
  tagline: opts.tagline
10454
10755
  });
10455
10756
  if (opts.workflowOnly && !payload.ciWorkflow) {
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@topolo/cli",
3
- "version": "0.4.8",
3
+ "version": "0.5.0",
4
+ "topoloPublishedVersion": "0.5.0",
4
5
  "description": "Command-line interface for Topolo developers, operators, and agents, with internal first-party scaffolding available under the topolo platform namespace.",
5
6
  "type": "module",
6
7
  "bin": {
@@ -11,10 +12,14 @@
11
12
  "skills"
12
13
  ],
13
14
  "scripts": {
15
+ "build:sdk": "pnpm --dir ../topolo-sdk build",
16
+ "prebuild": "pnpm run build:sdk",
14
17
  "build": "tsup",
15
18
  "dev": "tsup --watch",
19
+ "pretypecheck": "pnpm run build:sdk",
16
20
  "typecheck": "tsc --noEmit",
17
21
  "start": "node dist/index.js",
22
+ "pretest": "pnpm run build:sdk",
18
23
  "test": "vitest run",
19
24
  "test:watch": "vitest",
20
25
  "coverage": "vitest run --coverage"
@@ -23,7 +28,7 @@
23
28
  "commander": "^12.1.0"
24
29
  },
25
30
  "devDependencies": {
26
- "@topolo/sdk": "latest",
31
+ "@topolo/sdk": "file:../topolo-sdk",
27
32
  "@types/node": "^20.12.0",
28
33
  "@vitest/coverage-v8": "^2.1.0",
29
34
  "tsup": "^8.0.0",