@topolo/cli 0.4.9 → 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 +306 -15
  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,13 @@ 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
+ }
5822
5829
  function normalizeFirstPartyAppDir(value) {
5823
5830
  const requested = String(value).trim().replace(/\\/g, "/").replace(/^\.\//, "");
5824
5831
  const appDir = requested.startsWith("apps/") ? requested.slice("apps/".length) : requested;
@@ -5927,7 +5934,9 @@ function toPascalCase(value) {
5927
5934
  return value.split(/[-_]/).filter(Boolean).map((part) => acronyms.has(part.toLowerCase()) ? part.toUpperCase() : part.slice(0, 1).toUpperCase() + part.slice(1)).join("");
5928
5935
  }
5929
5936
  function toTitle(value) {
5930
- 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(" ");
5931
5940
  }
5932
5941
  function json(value) {
5933
5942
  return JSON.stringify(value, null, 2);
@@ -6033,6 +6042,9 @@ function packageJson(application, target, options) {
6033
6042
  devDependencies["@types/react-dom"] = "^19.2.2";
6034
6043
  devDependencies["@vitejs/plugin-react"] = "^4.7.0";
6035
6044
  if (target === "platform") {
6045
+ devDependencies.autoprefixer = "10.5.0";
6046
+ devDependencies.postcss = "8.5.6";
6047
+ devDependencies.tailwindcss = "3.4.19";
6036
6048
  for (const packageName of PLATFORM_BROWSER_TOPOLO_PACKAGES) {
6037
6049
  dependencies[packageName] = topoloPackageVersion(packageName, target);
6038
6050
  }
@@ -6196,13 +6208,16 @@ function readme(application, target, options) {
6196
6208
  "- `@topolo-io/app-funnel` provides the generated public application funnel/landing surface.",
6197
6209
  "- `@topolo-io/app-shell` provides the generated route/auth bootstrap, product shell, login, callback, and loading surfaces.",
6198
6210
  "- `@topolo-io/app-launcher` provides shared app switching and handoff helpers.",
6199
- "- `@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.",
6200
6212
  "- `@topolo-io/auth-client` owns browser auth, callback exchange, token refresh, and logout.",
6201
6213
  "- `@topolo-io/api-key-management`, `@topolo-io/chat-widget`, `@topolo-io/notifications`, and `@topolo-io/resource-context` complete the AppShell capability baseline.",
6202
6214
  "- Every Topolo-owned browser dependency is emitted at `latest`; product-domain packages are added only when the app actually adopts that domain.",
6203
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.",
6204
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.",
6205
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.",
6206
6221
  "- `vite.config.ts` includes the standard React plugin plus React runtime dedupe so linked Topolo packages resolve one browser React runtime."
6207
6222
  );
6208
6223
  if (options.api) {
@@ -6299,6 +6314,8 @@ function agentRules(application, target, options) {
6299
6314
  "- Start from `staging`, preserve other agents' work, and do not revert unrelated local changes.",
6300
6315
  "- Make contained changes at the real ownership boundary; do not patch around shared runtime, auth, launcher, shell, or resource-context issues locally.",
6301
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.",
6302
6319
  ...options.api ? ["- API code should stay on `@topolo-io/worker-runtime`, derive auth context from verified credentials, and preserve Nexus usage monitoring."] : [],
6303
6320
  ...hasScaffoldResources(options.resources) ? ["- Keep Cloudflare resource bindings in Wrangler, CloudControl, Env typing, docs, and README verification output in sync."] : [],
6304
6321
  "",
@@ -6649,7 +6666,36 @@ function mainTsx() {
6649
6666
  ""
6650
6667
  ].join("\n");
6651
6668
  }
6652
- 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
+ }
6653
6699
  return [
6654
6700
  "/* Local app styles. Design tokens come from @topolo-io/design-tokens (imported in main.tsx). */",
6655
6701
  "/* Use hsl(var(--token)) for colors so light/dark themes flip from the shared shell. */",
@@ -6855,20 +6901,41 @@ export const APP_DISPLAY_NAME = '${escapeSingleQuoted(appName)}';
6855
6901
  `;
6856
6902
  }
6857
6903
  function platformAppRoutesTsx(experience) {
6858
- 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
+ `;
6859
6924
  return `import type { AppRoute } from '@topolo-io/app-shell';
6925
+ import { LayoutDashboard, ListTree } from '@topolo-io/ui-kit/icons';
6860
6926
  import { HOME_LABEL, HOME_PATH, NAVIGATION_MODE } from './app-config';
6861
6927
  import GettingStartedRoute from './routes/GettingStartedRoute';
6862
6928
  import HomeRoute from './routes/HomeRoute';
6929
+ ${starterImports}
6863
6930
 
6864
6931
  export function createAppRoutes(appId: string): AppRoute[] {
6865
6932
  return [
6866
6933
  {
6867
6934
  path: HOME_PATH,
6868
6935
  ${nav} loading: { shell: NAVIGATION_MODE, content: 'workspace', title: HOME_LABEL },
6869
- element: <HomeRoute />,
6936
+ element: ${homeElement},
6870
6937
  },
6871
- {
6938
+ ${starterRoutes} {
6872
6939
  path: '/getting-started',
6873
6940
  loading: { shell: NAVIGATION_MODE, content: 'card-grid', title: 'Getting started' },
6874
6941
  element: <GettingStartedRoute appId={appId} />,
@@ -6877,7 +6944,8 @@ ${nav} loading: { shell: NAVIGATION_MODE, content: 'workspace', title: HOME
6877
6944
  }
6878
6945
  `;
6879
6946
  }
6880
- function platformHomeRouteTsx() {
6947
+ function platformHomeRouteTsx(uiTemplate) {
6948
+ if (uiTemplate === "operations") return platformOperationsHomeRouteTsx();
6881
6949
  return `import { TopoloAppPage, TopoloAppPageHeader, useAuth } from '@topolo-io/app-shell';
6882
6950
  import { IntroTour } from '@topolo-io/tour';
6883
6951
  import { useTranslation } from '@topolo-io/ui-kit';
@@ -6913,6 +6981,148 @@ export default function HomeRoute() {
6913
6981
  }
6914
6982
  `;
6915
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
+ }
6916
7126
  function platformGettingStartedRouteTsx() {
6917
7127
  return `import { TopoloAppPage, useAuth } from '@topolo-io/app-shell';
6918
7128
  import { GettingStarted, type OnboardingGuideStep } from '@topolo-io/onboarding';
@@ -7866,7 +8076,7 @@ function topoloConformanceWorkflow(options) {
7866
8076
  " - name: Install generated application",
7867
8077
  ` if: steps.app-token.outputs.token != '' || env.HAS_TOPOLO_CI_REPO_TOKEN == 'true'`,
7868
8078
  ` working-directory: ${appPath}`,
7869
- " run: pnpm install --frozen-lockfile",
8079
+ " run: pnpm install --no-frozen-lockfile",
7870
8080
  "",
7871
8081
  " - name: Run generated application quality gates",
7872
8082
  ` if: steps.app-token.outputs.token != '' || env.HAS_TOPOLO_CI_REPO_TOKEN == 'true'`,
@@ -8804,7 +9014,8 @@ ${coverageThresholds}
8804
9014
  function platformBrowserContractTest(application, experience) {
8805
9015
  const required = experience.workspaceSwitcher === "required";
8806
9016
  const enabled = experience.workspaceSwitcher !== "off";
8807
- 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();`;
8808
9019
  return `import { describe, expect, it, vi } from 'vitest';
8809
9020
 
8810
9021
  vi.mock('../src/app-identity', () => ({
@@ -8825,12 +9036,52 @@ describe('generated application contract', () => {
8825
9036
  });
8826
9037
 
8827
9038
  it('keeps Getting Started out of product navigation', () => {
8828
- 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')!;
8829
9042
  expect(home.path).toBe('${escapeSingleQuoted(experience.homePath)}');
8830
9043
  ${navExpectation}
8831
9044
  expect(gettingStarted.path).toBe('/getting-started');
8832
9045
  expect(gettingStarted.nav).toBeUndefined();
8833
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
+ });
8834
9085
  });
8835
9086
  `;
8836
9087
  }
@@ -8988,6 +9239,7 @@ function applicationScaffoldPayload(application, options = {}) {
8988
9239
  homeLabel: normalizeHomeLabel(options.homeLabel),
8989
9240
  navigationMode: normalizeNavigationMode(options.navigationMode),
8990
9241
  workspaceSwitcher: normalizeWorkspaceSwitcherMode(options.workspaceSwitcher),
9242
+ uiTemplate: normalizeUiTemplate(options.uiTemplate),
8991
9243
  workspaceLabel: tenancy === "household" ? "Households" : "Workspaces",
8992
9244
  tagline: String(options.tagline || "").trim() || `Manage your work in ${toTitle(name)}.`
8993
9245
  };
@@ -9267,9 +9519,21 @@ function scaffoldFiles(options) {
9267
9519
  "does not fork the shared shell chrome",
9268
9520
  "keeps app-specific layout scoped under generated classes"
9269
9521
  ],
9270
- content: stylesCss()
9522
+ content: stylesCss(target)
9271
9523
  }] : [],
9272
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
+ },
9273
9537
  {
9274
9538
  path: `${browserBase}/src/app-config.ts`,
9275
9539
  purpose: "Product route, navigation, context-switcher, and entry-surface configuration.",
@@ -9286,8 +9550,28 @@ function scaffoldFiles(options) {
9286
9550
  path: `${browserBase}/src/routes/HomeRoute.tsx`,
9287
9551
  purpose: "Focused authenticated home product surface.",
9288
9552
  requiredSignals: ["uses shared page primitives", "uses translation-safe product copy"],
9289
- content: platformHomeRouteTsx()
9553
+ content: platformHomeRouteTsx(options.payload.experience.uiTemplate)
9290
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
+ ],
9291
9575
  {
9292
9576
  path: `${browserBase}/src/routes/GettingStartedRoute.tsx`,
9293
9577
  purpose: "Focused role-aware onboarding route reached from the account menu.",
@@ -9570,6 +9854,12 @@ export default defineConfig({
9570
9854
  requiredSignals: ["proves API proxying", "proves non-API browser fallback"],
9571
9855
  content: platformWebWorkerTest(application, options.api)
9572
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
+ },
9573
9863
  {
9574
9864
  path: `${browserBase}/src/worker.ts`,
9575
9865
  purpose: "Browser Worker that serves assets and forwards API paths when an API target exists.",
@@ -10420,7 +10710,7 @@ function registerPlatformCommand(program2) {
10420
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(
10421
10711
  "--surface-model <surfaceModel>",
10422
10712
  "First-party surface model: web_app, mobile_container, mobile_module, or messaging_surface"
10423
- ).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(
10424
10714
  "--provision",
10425
10715
  "Provision the generated first-party app into Topolo Developers and Auth using the authenticated Topolo admin session"
10426
10716
  ).option(
@@ -10460,6 +10750,7 @@ function registerPlatformCommand(program2) {
10460
10750
  homeLabel: opts.homeLabel,
10461
10751
  navigationMode: opts.navigationMode,
10462
10752
  workspaceSwitcher: opts.workspaceSwitcher,
10753
+ uiTemplate: opts.uiTemplate,
10463
10754
  tagline: opts.tagline
10464
10755
  });
10465
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.9",
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",