@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.
- package/dist/index.js +318 -17
- 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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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).
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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": "
|
|
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",
|