@topolo/cli 0.1.10 → 0.1.12
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/README.md +19 -6
- package/dist/index.js +1977 -78
- package/package.json +2 -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
|
+
// ../../../../../Users/ashes/Projects/topolo/PlatformApplications/packages/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);
|
|
@@ -1938,11 +1938,11 @@ async function pollUntilApproved(params) {
|
|
|
1938
1938
|
throw new Error("Device code expired. Run `topolo auth login` again.");
|
|
1939
1939
|
}
|
|
1940
1940
|
function sleep(ms) {
|
|
1941
|
-
return new Promise((
|
|
1941
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
1942
1942
|
}
|
|
1943
1943
|
|
|
1944
1944
|
// src/version.ts
|
|
1945
|
-
var CLI_VERSION = "0.1.
|
|
1945
|
+
var CLI_VERSION = "0.1.12";
|
|
1946
1946
|
|
|
1947
1947
|
// src/client.ts
|
|
1948
1948
|
async function buildClient(options) {
|
|
@@ -2304,13 +2304,26 @@ function enrichFinding(application, finding2, evidence) {
|
|
|
2304
2304
|
});
|
|
2305
2305
|
}
|
|
2306
2306
|
if (shellEvidence.length === 0) return finding2;
|
|
2307
|
+
if (shellEvidence.includes("local=account-menu")) {
|
|
2308
|
+
return mergeFinding(finding2, {
|
|
2309
|
+
status: "needs_review",
|
|
2310
|
+
reason: "An app-local account menu implementation is visible locally. Account dropdown chrome must come from TopoloAppShell or TopoloAccountMenu, with app-specific actions passed as additive menu items.",
|
|
2311
|
+
evidence: shellEvidence,
|
|
2312
|
+
nextActions: [
|
|
2313
|
+
"Replace app-local UserMenu, user-menu, or accountMenuOpen dropdown code with TopoloAccountMenu",
|
|
2314
|
+
"Use TopoloAppShell accountMenuItems for app-specific account actions",
|
|
2315
|
+
"Keep sign-out, app launcher, command palette, theme, and support actions on the shared account-menu surface"
|
|
2316
|
+
]
|
|
2317
|
+
});
|
|
2318
|
+
}
|
|
2307
2319
|
if (!shellEvidence.includes("source=TopoloAppShell")) {
|
|
2308
2320
|
return mergeFinding(finding2, {
|
|
2309
2321
|
status: "needs_review",
|
|
2310
|
-
reason: "Shared UI-kit or primitive shell usage is visible locally, but authenticated browser apps must use TopoloAppShell so launcher,
|
|
2322
|
+
reason: "Shared UI-kit or primitive shell usage is visible locally, but authenticated browser apps must use TopoloAppShell so launcher, TopoloAccountMenu, command, theme, and bug-reporting chrome stay on the shared platform surface.",
|
|
2311
2323
|
evidence: shellEvidence,
|
|
2312
2324
|
nextActions: [
|
|
2313
2325
|
"Use TopoloAppShell for authenticated first-party app layouts",
|
|
2326
|
+
"Use TopoloAccountMenu when a partial shell still needs account chrome",
|
|
2314
2327
|
"Keep launcher, account menu, command palette, theme toggle, and bug reporter on the shared shell surface",
|
|
2315
2328
|
"Keep mobile and desktop shell behavior aligned with the platform UI kit"
|
|
2316
2329
|
]
|
|
@@ -2322,6 +2335,7 @@ function enrichFinding(application, finding2, evidence) {
|
|
|
2322
2335
|
evidence: shellEvidence,
|
|
2323
2336
|
nextActions: [
|
|
2324
2337
|
"Use TopoloAppShell for authenticated first-party app layouts",
|
|
2338
|
+
"Pass app-specific account actions through accountMenuItems instead of app-local dropdowns",
|
|
2325
2339
|
"Keep launcher, account menu, command palette, theme toggle, and bug reporter on the shared shell surface",
|
|
2326
2340
|
"Keep mobile and desktop shell behavior aligned with the platform UI kit"
|
|
2327
2341
|
]
|
|
@@ -2373,14 +2387,14 @@ function enrichFinding(application, finding2, evidence) {
|
|
|
2373
2387
|
function collectEvidence(repoRoot, application) {
|
|
2374
2388
|
const appRoot = path.join(repoRoot, "PlatformApplications", application.appDir);
|
|
2375
2389
|
const packageJsonPath = path.join(appRoot, "package.json");
|
|
2376
|
-
const
|
|
2390
|
+
const packageJson2 = readPackageJson(packageJsonPath);
|
|
2377
2391
|
const cloudcontrolPath = path.join(appRoot, "topolo.cloudcontrol.json");
|
|
2378
2392
|
const systems = matchingSystems(repoRoot, application);
|
|
2379
2393
|
return {
|
|
2380
2394
|
appRoot,
|
|
2381
2395
|
appExists: isDirectory(appRoot),
|
|
2382
2396
|
packageJsonPath,
|
|
2383
|
-
packageJson,
|
|
2397
|
+
packageJson: packageJson2,
|
|
2384
2398
|
packageJsons: collectPackageJsons(appRoot),
|
|
2385
2399
|
cloudcontrolPath,
|
|
2386
2400
|
hasCloudControl: isFile(cloudcontrolPath),
|
|
@@ -2435,8 +2449,12 @@ function sharedShellEvidence(evidence) {
|
|
|
2435
2449
|
if (text.includes("@topolo/ui-kit")) items.push("source=@topolo/ui-kit");
|
|
2436
2450
|
if (text.includes("TopoloShell")) items.push("source=TopoloShell");
|
|
2437
2451
|
if (text.includes("TopoloAppShell")) items.push("source=TopoloAppShell");
|
|
2452
|
+
if (text.includes("TopoloAccountMenu")) items.push("source=TopoloAccountMenu");
|
|
2438
2453
|
if (text.includes("TopoloLauncher")) items.push("source=TopoloLauncher");
|
|
2439
2454
|
if (text.includes("AppSwitcher")) items.push("source=AppSwitcher");
|
|
2455
|
+
if (/\baccountMenuOpen\b|\bsetAccountMenuOpen\b|\buserMenuOpen\b|\bUserMenu\b|user-menu/.test(text)) {
|
|
2456
|
+
items.push("local=account-menu");
|
|
2457
|
+
}
|
|
2440
2458
|
return unique(items);
|
|
2441
2459
|
}
|
|
2442
2460
|
function hasDocumentationSurface(application, evidence) {
|
|
@@ -2601,8 +2619,8 @@ function collectPackageJsons(root) {
|
|
|
2601
2619
|
continue;
|
|
2602
2620
|
}
|
|
2603
2621
|
if (!entry.isFile() || entry.name !== "package.json") continue;
|
|
2604
|
-
const
|
|
2605
|
-
if (
|
|
2622
|
+
const packageJson2 = readPackageJson(entryPath);
|
|
2623
|
+
if (packageJson2) records.push({ path: entryPath, packageJson: packageJson2 });
|
|
2606
2624
|
}
|
|
2607
2625
|
};
|
|
2608
2626
|
visit(root);
|
|
@@ -2691,38 +2709,121 @@ function isFile(filePath) {
|
|
|
2691
2709
|
}
|
|
2692
2710
|
|
|
2693
2711
|
// src/commands/app-scaffold.ts
|
|
2712
|
+
import { constants } from "fs";
|
|
2713
|
+
import { access, mkdir as mkdir2, writeFile as writeFile2 } from "fs/promises";
|
|
2714
|
+
import { dirname as dirname2, relative, resolve } from "path";
|
|
2715
|
+
var CLOUDFLARE_ACCOUNT_ID = "49ef1ba682ad8cfd720c86699ae17521";
|
|
2716
|
+
var CURRENT_VERIFICATION_DATE = "2026-04-23";
|
|
2694
2717
|
function applicationScaffoldPayload(application, options = {}) {
|
|
2718
|
+
const target = normalizeTarget(options.target);
|
|
2719
|
+
const portfolio = target === "platform" ? normalizePortfolio(options.portfolio) : null;
|
|
2695
2720
|
const id = normalizeApplicationId(application);
|
|
2721
|
+
const slug = toSlug(id);
|
|
2696
2722
|
const catalogApplication = APPLICATIONS[id];
|
|
2697
2723
|
const browser = options.browser !== false;
|
|
2698
|
-
const api = options.api
|
|
2699
|
-
const
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
const
|
|
2724
|
+
const api = options.api ?? target === "platform";
|
|
2725
|
+
const audience = normalizeAudience(
|
|
2726
|
+
options.audience,
|
|
2727
|
+
target === "platform" ? portfolio : null
|
|
2728
|
+
);
|
|
2729
|
+
const tenancy = normalizeTenancy(options.tenancy, audience);
|
|
2730
|
+
const surfaceModel = normalizeSurfaceModel(options.surfaceModel);
|
|
2731
|
+
const containerAppId = normalizeContainerAppId(options.containerAppId, surfaceModel);
|
|
2732
|
+
const ownerType = target === "platform" ? "topolo_first_party" : "third_party";
|
|
2733
|
+
const distributionModel = target === "platform" ? "marketplace" : "organization_internal";
|
|
2734
|
+
const appDir = options.appDir ?? catalogApplication?.appDir ?? (target === "platform" ? `Topolo${toPascalCase(id)}` : `topolo-${slug}`);
|
|
2735
|
+
const name = options.name ?? catalogApplication?.name ?? `topolo-${slug}`;
|
|
2736
|
+
const packageName = options.packageName ?? catalogApplication?.packageName ?? `topolo-${slug}`;
|
|
2737
|
+
const defaultServiceId = target === "platform" ? `srv_topolo_${id}` : id;
|
|
2738
|
+
const serviceId = api ? options.service ?? (target === "platform" ? defaultServiceId : catalogApplication?.services[0] ?? defaultServiceId) : null;
|
|
2739
|
+
const productionUrl = browser ? options.productionUrl ?? catalogApplication?.productionUrl ?? `https://${slug}.topolo.app` : null;
|
|
2704
2740
|
const scopes = scaffoldScopes({ browser, api });
|
|
2741
|
+
const ciWorkflowPath = target === "platform" ? ".github/workflows/topolo-conformance.yml" : null;
|
|
2742
|
+
const ciWorkflow = target === "platform" ? topoloConformanceWorkflow({ id, appDir }) : null;
|
|
2743
|
+
const applicationPayload = {
|
|
2744
|
+
id,
|
|
2745
|
+
name,
|
|
2746
|
+
appDir,
|
|
2747
|
+
packageName,
|
|
2748
|
+
productionUrl,
|
|
2749
|
+
serviceId
|
|
2750
|
+
};
|
|
2705
2751
|
return {
|
|
2706
2752
|
version: APPLICATION_REQUIREMENTS_VERSION,
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2753
|
+
target,
|
|
2754
|
+
ownerType,
|
|
2755
|
+
portfolio,
|
|
2756
|
+
audience,
|
|
2757
|
+
tenancy,
|
|
2758
|
+
surfaceModel,
|
|
2759
|
+
containerAppId,
|
|
2760
|
+
distributionModel,
|
|
2761
|
+
application: applicationPayload,
|
|
2715
2762
|
scopes,
|
|
2716
2763
|
requirements: APPLICATION_REQUIREMENTS.filter(
|
|
2717
2764
|
(requirement) => requirement.appliesTo.some((scope) => scopes.includes(scope))
|
|
2718
2765
|
),
|
|
2719
|
-
packages: scaffoldPackages({ browser, api }),
|
|
2720
|
-
files: scaffoldFiles({
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2766
|
+
packages: scaffoldPackages({ browser, api, target }),
|
|
2767
|
+
files: scaffoldFiles({
|
|
2768
|
+
payload: {
|
|
2769
|
+
ownerType,
|
|
2770
|
+
portfolio,
|
|
2771
|
+
audience,
|
|
2772
|
+
tenancy,
|
|
2773
|
+
surfaceModel,
|
|
2774
|
+
containerAppId,
|
|
2775
|
+
distributionModel
|
|
2776
|
+
},
|
|
2777
|
+
application: applicationPayload,
|
|
2778
|
+
target,
|
|
2779
|
+
browser,
|
|
2780
|
+
api,
|
|
2781
|
+
ciWorkflow
|
|
2782
|
+
}),
|
|
2783
|
+
commands: scaffoldCommands({
|
|
2784
|
+
id,
|
|
2785
|
+
target,
|
|
2786
|
+
audience,
|
|
2787
|
+
tenancy,
|
|
2788
|
+
portfolio
|
|
2789
|
+
}),
|
|
2790
|
+
ciWorkflowPath,
|
|
2791
|
+
ciWorkflow
|
|
2724
2792
|
};
|
|
2725
2793
|
}
|
|
2794
|
+
async function writeApplicationScaffold(payload, options = {}) {
|
|
2795
|
+
const root = resolve(options.repoRoot ?? process.cwd());
|
|
2796
|
+
const plannedFiles = payload.files.map((file) => ({
|
|
2797
|
+
file,
|
|
2798
|
+
absolutePath: resolveInside(root, file.path)
|
|
2799
|
+
}));
|
|
2800
|
+
const existingFiles = [];
|
|
2801
|
+
for (const planned of plannedFiles) {
|
|
2802
|
+
if (await pathExists(planned.absolutePath)) {
|
|
2803
|
+
existingFiles.push(planned.file.path);
|
|
2804
|
+
}
|
|
2805
|
+
}
|
|
2806
|
+
if (existingFiles.length > 0 && !options.force) {
|
|
2807
|
+
throw new Error(
|
|
2808
|
+
[
|
|
2809
|
+
"Refusing to overwrite existing scaffold files:",
|
|
2810
|
+
...existingFiles.map((filePath) => `- ${filePath}`),
|
|
2811
|
+
"Pass --force to overwrite these files."
|
|
2812
|
+
].join("\n")
|
|
2813
|
+
);
|
|
2814
|
+
}
|
|
2815
|
+
const files = [];
|
|
2816
|
+
for (const planned of plannedFiles) {
|
|
2817
|
+
const existed = await pathExists(planned.absolutePath);
|
|
2818
|
+
await mkdir2(dirname2(planned.absolutePath), { recursive: true });
|
|
2819
|
+
await writeFile2(planned.absolutePath, planned.file.content, "utf8");
|
|
2820
|
+
files.push({
|
|
2821
|
+
path: planned.file.path,
|
|
2822
|
+
status: existed ? "overwritten" : "created"
|
|
2823
|
+
});
|
|
2824
|
+
}
|
|
2825
|
+
return { root, files };
|
|
2826
|
+
}
|
|
2726
2827
|
function scaffoldScopes(options) {
|
|
2727
2828
|
const scopes = ["all"];
|
|
2728
2829
|
if (options.browser) scopes.push("browser");
|
|
@@ -2732,6 +2833,9 @@ function scaffoldScopes(options) {
|
|
|
2732
2833
|
return scopes;
|
|
2733
2834
|
}
|
|
2734
2835
|
function scaffoldPackages(options) {
|
|
2836
|
+
if (options.target === "developer") {
|
|
2837
|
+
return [];
|
|
2838
|
+
}
|
|
2735
2839
|
const packages = /* @__PURE__ */ new Set();
|
|
2736
2840
|
if (options.browser) {
|
|
2737
2841
|
packages.add("@topolo/ui-kit");
|
|
@@ -2744,52 +2848,206 @@ function scaffoldPackages(options) {
|
|
|
2744
2848
|
return Array.from(packages);
|
|
2745
2849
|
}
|
|
2746
2850
|
function scaffoldFiles(options) {
|
|
2747
|
-
const
|
|
2851
|
+
const { application, target } = options;
|
|
2852
|
+
const slug = toSlug(application.id);
|
|
2853
|
+
const base = target === "platform" ? `PlatformApplications/${application.appDir}` : application.appDir;
|
|
2748
2854
|
const files = [
|
|
2749
2855
|
{
|
|
2750
2856
|
path: `${base}/package.json`,
|
|
2751
2857
|
purpose: "Package metadata and verification commands.",
|
|
2752
2858
|
requiredSignals: [
|
|
2753
2859
|
"name matches the packageName in the SDK application catalog",
|
|
2754
|
-
"scripts include build plus typecheck
|
|
2755
|
-
]
|
|
2860
|
+
"scripts include build plus typecheck where the stack supports them"
|
|
2861
|
+
],
|
|
2862
|
+
content: packageJson(application, target, { browser: options.browser, api: options.api })
|
|
2863
|
+
},
|
|
2864
|
+
{
|
|
2865
|
+
path: `${base}/README.md`,
|
|
2866
|
+
purpose: "Starter README with install, run, and verification commands.",
|
|
2867
|
+
requiredSignals: [
|
|
2868
|
+
"documents the generated app target",
|
|
2869
|
+
"points maintainers to the shared Topolo packages used by the scaffold"
|
|
2870
|
+
],
|
|
2871
|
+
content: readme(application, target, {
|
|
2872
|
+
api: options.api,
|
|
2873
|
+
audience: options.payload.audience,
|
|
2874
|
+
tenancy: options.payload.tenancy,
|
|
2875
|
+
portfolio: options.payload.portfolio
|
|
2876
|
+
})
|
|
2877
|
+
},
|
|
2878
|
+
{
|
|
2879
|
+
path: `${base}/.gitignore`,
|
|
2880
|
+
purpose: "Generated-app ignore rules.",
|
|
2881
|
+
requiredSignals: [
|
|
2882
|
+
"ignores dependency installs and build output",
|
|
2883
|
+
"keeps local environment files out of source control"
|
|
2884
|
+
],
|
|
2885
|
+
content: gitignore()
|
|
2886
|
+
},
|
|
2887
|
+
{
|
|
2888
|
+
path: `${base}/tsconfig.json`,
|
|
2889
|
+
purpose: "TypeScript configuration for the generated app.",
|
|
2890
|
+
requiredSignals: [
|
|
2891
|
+
"uses strict mode",
|
|
2892
|
+
"includes browser DOM types for React and Worker types for API-backed apps"
|
|
2893
|
+
],
|
|
2894
|
+
content: tsconfigJson(options.api)
|
|
2895
|
+
},
|
|
2896
|
+
{
|
|
2897
|
+
path: `${base}/vite.config.ts`,
|
|
2898
|
+
purpose: "Vite browser build configuration.",
|
|
2899
|
+
requiredSignals: [
|
|
2900
|
+
"builds the authenticated browser surface",
|
|
2901
|
+
"keeps generated apps on the standard React/Vite baseline"
|
|
2902
|
+
],
|
|
2903
|
+
content: viteConfig()
|
|
2756
2904
|
},
|
|
2757
2905
|
{
|
|
2758
|
-
path: `${base}/
|
|
2759
|
-
purpose: "
|
|
2906
|
+
path: `${base}/index.html`,
|
|
2907
|
+
purpose: "Browser entry document.",
|
|
2760
2908
|
requiredSignals: [
|
|
2761
|
-
"
|
|
2762
|
-
"
|
|
2763
|
-
]
|
|
2909
|
+
"mounts the React app at #root",
|
|
2910
|
+
"uses the generated application display name"
|
|
2911
|
+
],
|
|
2912
|
+
content: indexHtml(application.name)
|
|
2764
2913
|
},
|
|
2765
2914
|
{
|
|
2766
|
-
path: `${base}
|
|
2767
|
-
purpose: "
|
|
2915
|
+
path: `${base}/src/main.tsx`,
|
|
2916
|
+
purpose: "React app entrypoint.",
|
|
2768
2917
|
requiredSignals: [
|
|
2769
|
-
"
|
|
2770
|
-
"
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
]
|
|
2918
|
+
"renders the generated app component",
|
|
2919
|
+
"imports the generated app stylesheet"
|
|
2920
|
+
],
|
|
2921
|
+
content: mainTsx()
|
|
2774
2922
|
},
|
|
2775
2923
|
{
|
|
2776
|
-
path:
|
|
2777
|
-
purpose: "
|
|
2924
|
+
path: `${base}/src/env.d.ts`,
|
|
2925
|
+
purpose: "Vite environment variable typing.",
|
|
2778
2926
|
requiredSignals: [
|
|
2779
|
-
|
|
2780
|
-
"
|
|
2781
|
-
|
|
2782
|
-
|
|
2927
|
+
"loads vite/client import.meta.env types",
|
|
2928
|
+
"declares the Topolo environment variables consumed by the starter"
|
|
2929
|
+
],
|
|
2930
|
+
content: envDts(options.api)
|
|
2783
2931
|
},
|
|
2784
2932
|
{
|
|
2785
|
-
path:
|
|
2786
|
-
purpose: "
|
|
2933
|
+
path: `${base}/src/styles.css`,
|
|
2934
|
+
purpose: "Minimal app-local styles outside the shared Topolo shell.",
|
|
2787
2935
|
requiredSignals: [
|
|
2788
|
-
"
|
|
2789
|
-
"
|
|
2790
|
-
]
|
|
2936
|
+
"does not fork the shared shell chrome",
|
|
2937
|
+
"keeps app-specific layout scoped under generated classes"
|
|
2938
|
+
],
|
|
2939
|
+
content: stylesCss()
|
|
2791
2940
|
}
|
|
2792
2941
|
];
|
|
2942
|
+
if (target === "platform") {
|
|
2943
|
+
files.push(
|
|
2944
|
+
{
|
|
2945
|
+
path: `${base}/topolo.cloudcontrol.json`,
|
|
2946
|
+
purpose: "CloudControl source of truth for deploy targets, routes, bindings, and CI metadata.",
|
|
2947
|
+
requiredSignals: [
|
|
2948
|
+
"production URLs and Cloudflare targets are explicit",
|
|
2949
|
+
"resource bindings and required secrets are declared without secret values"
|
|
2950
|
+
],
|
|
2951
|
+
content: cloudcontrolJson(application, { api: options.api })
|
|
2952
|
+
},
|
|
2953
|
+
{
|
|
2954
|
+
path: `${base}/.github/workflows/topolo-conformance.yml`,
|
|
2955
|
+
purpose: "PR/push gate that runs the local Topolo app audit with docs evidence.",
|
|
2956
|
+
requiredSignals: [
|
|
2957
|
+
"checks out this repo under its PlatformApplications app directory",
|
|
2958
|
+
"checks out TopoloDocs under PlatformApplications/TopoloDocs",
|
|
2959
|
+
"checks out topolo-sdk and TopoloCli from main so the gate does not wait for an npm publish",
|
|
2960
|
+
"runs topolo apps audit with --local and --fail-on needs_review"
|
|
2961
|
+
],
|
|
2962
|
+
content: options.ciWorkflow ?? ""
|
|
2963
|
+
},
|
|
2964
|
+
{
|
|
2965
|
+
path: `PlatformApplications/TopoloDocs/src/content/systems/topolo-${slug}.json`,
|
|
2966
|
+
purpose: "Canonical system registry entry.",
|
|
2967
|
+
requiredSignals: [
|
|
2968
|
+
`repo_paths includes ${base}`,
|
|
2969
|
+
"doc_paths includes internal handbook coverage",
|
|
2970
|
+
"security_assurance records the auth, tenancy, deployment, and verification boundary"
|
|
2971
|
+
],
|
|
2972
|
+
content: systemJson(application, {
|
|
2973
|
+
api: options.api,
|
|
2974
|
+
audience: options.payload.audience,
|
|
2975
|
+
tenancy: options.payload.tenancy,
|
|
2976
|
+
portfolio: options.payload.portfolio
|
|
2977
|
+
})
|
|
2978
|
+
},
|
|
2979
|
+
{
|
|
2980
|
+
path: `PlatformApplications/TopoloDocs/src/content/internal/apps/${slug}.mdx`,
|
|
2981
|
+
purpose: "Internal operator handbook.",
|
|
2982
|
+
requiredSignals: [
|
|
2983
|
+
"documents ownership, routes, auth boundary, deployment, operations, and verification",
|
|
2984
|
+
"last_verified is updated when behavior changes"
|
|
2985
|
+
],
|
|
2986
|
+
content: internalDocs(application, {
|
|
2987
|
+
api: options.api,
|
|
2988
|
+
audience: options.payload.audience,
|
|
2989
|
+
tenancy: options.payload.tenancy,
|
|
2990
|
+
portfolio: options.payload.portfolio
|
|
2991
|
+
})
|
|
2992
|
+
},
|
|
2993
|
+
{
|
|
2994
|
+
path: `PlatformApplications/TopoloDocs/src/content/public/applications/${slug}.mdx`,
|
|
2995
|
+
purpose: "Public application overview.",
|
|
2996
|
+
requiredSignals: [
|
|
2997
|
+
"documents the product surface without internal-only details",
|
|
2998
|
+
"links back to the canonical system registry entry"
|
|
2999
|
+
],
|
|
3000
|
+
content: publicDocs(application, {
|
|
3001
|
+
api: options.api,
|
|
3002
|
+
audience: options.payload.audience,
|
|
3003
|
+
tenancy: options.payload.tenancy,
|
|
3004
|
+
portfolio: options.payload.portfolio
|
|
3005
|
+
})
|
|
3006
|
+
}
|
|
3007
|
+
);
|
|
3008
|
+
if (options.api && application.serviceId) {
|
|
3009
|
+
files.push({
|
|
3010
|
+
path: `PlatformApplications/TopoloAuth/catalog/service-manifests/${application.serviceId}.json`,
|
|
3011
|
+
purpose: "Auth service manifest for first-party permissions, role bundles, and API-key scopes.",
|
|
3012
|
+
requiredSignals: [
|
|
3013
|
+
"serviceId matches the generated Worker service id",
|
|
3014
|
+
"permissions, role bundles, and API-key scopes exist before the app is catalog-visible",
|
|
3015
|
+
"systems metadata maps the service back to the generated TopoloDocs entries"
|
|
3016
|
+
],
|
|
3017
|
+
content: serviceManifestJson(application, {
|
|
3018
|
+
tenancy: options.payload.tenancy
|
|
3019
|
+
})
|
|
3020
|
+
});
|
|
3021
|
+
}
|
|
3022
|
+
} else {
|
|
3023
|
+
files.push(
|
|
3024
|
+
{
|
|
3025
|
+
path: `${base}/topolo.app.json`,
|
|
3026
|
+
purpose: "Third-party Topolo application manifest.",
|
|
3027
|
+
requiredSignals: [
|
|
3028
|
+
"declares the external app id, callback URLs, and requested platform scopes",
|
|
3029
|
+
"does not include internal Topolo CloudControl or canonical-docs ownership"
|
|
3030
|
+
],
|
|
3031
|
+
content: developerManifest(application, {
|
|
3032
|
+
api: options.api,
|
|
3033
|
+
audience: options.payload.audience,
|
|
3034
|
+
tenancy: options.payload.tenancy,
|
|
3035
|
+
surfaceModel: options.payload.surfaceModel,
|
|
3036
|
+
containerAppId: options.payload.containerAppId,
|
|
3037
|
+
distributionModel: options.payload.distributionModel
|
|
3038
|
+
})
|
|
3039
|
+
},
|
|
3040
|
+
{
|
|
3041
|
+
path: `${base}/.env.example`,
|
|
3042
|
+
purpose: "Developer environment template for local auth configuration.",
|
|
3043
|
+
requiredSignals: [
|
|
3044
|
+
"names the Topolo Auth URL",
|
|
3045
|
+
"keeps OAuth client ids and service ids out of source control by default"
|
|
3046
|
+
],
|
|
3047
|
+
content: envExample(application)
|
|
3048
|
+
}
|
|
3049
|
+
);
|
|
3050
|
+
}
|
|
2793
3051
|
if (options.browser) {
|
|
2794
3052
|
files.push({
|
|
2795
3053
|
path: `${base}/src/App.tsx`,
|
|
@@ -2797,8 +3055,13 @@ function scaffoldFiles(options) {
|
|
|
2797
3055
|
requiredSignals: [
|
|
2798
3056
|
"imports shared TopoloAppShell from @topolo/ui-kit",
|
|
2799
3057
|
"uses @topolo/auth-client for app-origin auth flows",
|
|
2800
|
-
"keeps launcher and account-menu behavior on the shared platform surface"
|
|
2801
|
-
|
|
3058
|
+
"keeps launcher and account-menu behavior on the shared platform surface",
|
|
3059
|
+
"passes app-specific account actions through accountMenuItems or TopoloAccountMenu"
|
|
3060
|
+
],
|
|
3061
|
+
content: appTsx(application, target, {
|
|
3062
|
+
audience: options.payload.audience,
|
|
3063
|
+
portfolio: options.payload.portfolio
|
|
3064
|
+
})
|
|
2802
3065
|
});
|
|
2803
3066
|
}
|
|
2804
3067
|
if (options.api) {
|
|
@@ -2807,9 +3070,10 @@ function scaffoldFiles(options) {
|
|
|
2807
3070
|
path: `${base}/wrangler.toml`,
|
|
2808
3071
|
purpose: "Cloudflare Worker runtime configuration.",
|
|
2809
3072
|
requiredSignals: [
|
|
2810
|
-
"bindings match topolo.cloudcontrol.json",
|
|
3073
|
+
"bindings match topolo.cloudcontrol.json or topolo.app.json",
|
|
2811
3074
|
"secrets are referenced by name only"
|
|
2812
|
-
]
|
|
3075
|
+
],
|
|
3076
|
+
content: wranglerToml(application, target)
|
|
2813
3077
|
},
|
|
2814
3078
|
{
|
|
2815
3079
|
path: `${base}/src/worker.ts`,
|
|
@@ -2818,18 +3082,34 @@ function scaffoldFiles(options) {
|
|
|
2818
3082
|
"uses @topolo/auth-middleware or Auth validation for protected routes",
|
|
2819
3083
|
"derives organization from the credential rather than client-supplied orgId",
|
|
2820
3084
|
"emits or propagates X-Topolo-Request-Id and X-Topolo-Client"
|
|
2821
|
-
]
|
|
3085
|
+
],
|
|
3086
|
+
content: workerTs(application, target, {
|
|
3087
|
+
audience: options.payload.audience,
|
|
3088
|
+
tenancy: options.payload.tenancy
|
|
3089
|
+
})
|
|
2822
3090
|
}
|
|
2823
3091
|
);
|
|
2824
3092
|
}
|
|
2825
3093
|
return files;
|
|
2826
3094
|
}
|
|
2827
|
-
function scaffoldCommands(
|
|
3095
|
+
function scaffoldCommands(options) {
|
|
3096
|
+
const scaffoldId = toSlug(options.id);
|
|
3097
|
+
if (options.target === "developer") {
|
|
3098
|
+
const audienceFlags = options.audience === "personal" ? " --audience personal --tenancy household" : "";
|
|
3099
|
+
return [
|
|
3100
|
+
`topolo apps scaffold ${scaffoldId}${audienceFlags} --write --repo-root .`,
|
|
3101
|
+
`cd topolo-${scaffoldId}`,
|
|
3102
|
+
"npm install",
|
|
3103
|
+
"npm run typecheck",
|
|
3104
|
+
"npm run build"
|
|
3105
|
+
];
|
|
3106
|
+
}
|
|
3107
|
+
const portfolioFlags = options.portfolio === "personal" ? " --portfolio personal" : "";
|
|
2828
3108
|
return [
|
|
2829
|
-
`topolo apps scaffold ${
|
|
3109
|
+
`topolo platform apps scaffold ${scaffoldId}${portfolioFlags} --write --repo-root /Users/ashes/Projects/topolo --json`,
|
|
2830
3110
|
"topolo apps requirements --json",
|
|
2831
|
-
`topolo apps audit ${id} --local --repo-root /Users/ashes/Projects/topolo --fail-on needs_review --json`,
|
|
2832
|
-
`topolo apps audit ${id} --local --repo-root /Users/ashes/Projects/topolo --fail-on partial --json`
|
|
3111
|
+
`topolo apps audit ${options.id} --local --repo-root /Users/ashes/Projects/topolo --fail-on needs_review --json`,
|
|
3112
|
+
`topolo apps audit ${options.id} --local --repo-root /Users/ashes/Projects/topolo --fail-on partial --json`
|
|
2833
3113
|
];
|
|
2834
3114
|
}
|
|
2835
3115
|
function topoloConformanceWorkflow(options) {
|
|
@@ -2904,18 +3184,1448 @@ function topoloConformanceWorkflow(options) {
|
|
|
2904
3184
|
` node PlatformApplications/TopoloCli/dist/index.js apps audit ${options.id} --local --repo-root "$GITHUB_WORKSPACE" --fail-on needs_review --json`
|
|
2905
3185
|
].join("\n");
|
|
2906
3186
|
}
|
|
3187
|
+
function packageJson(application, target, options) {
|
|
3188
|
+
const dependencies = {};
|
|
3189
|
+
const devDependencies = {
|
|
3190
|
+
"@types/node": "^20.12.0",
|
|
3191
|
+
typescript: "^5.4.0",
|
|
3192
|
+
vite: "^5.4.0"
|
|
3193
|
+
};
|
|
3194
|
+
const scripts = {
|
|
3195
|
+
dev: "vite --host 0.0.0.0",
|
|
3196
|
+
build: "vite build",
|
|
3197
|
+
preview: "vite preview --host 0.0.0.0",
|
|
3198
|
+
typecheck: "tsc --noEmit"
|
|
3199
|
+
};
|
|
3200
|
+
if (options.browser) {
|
|
3201
|
+
dependencies.react = "^18.3.1";
|
|
3202
|
+
dependencies["react-dom"] = "^18.3.1";
|
|
3203
|
+
devDependencies["@types/react"] = "^18.3.3";
|
|
3204
|
+
devDependencies["@types/react-dom"] = "^18.3.0";
|
|
3205
|
+
devDependencies["@vitejs/plugin-react"] = "^4.7.0";
|
|
3206
|
+
if (target === "platform") {
|
|
3207
|
+
dependencies["@topolo/auth-client"] = topoloPackageVersion("@topolo/auth-client", target);
|
|
3208
|
+
dependencies["@topolo/ui-kit"] = topoloPackageVersion("@topolo/ui-kit", target);
|
|
3209
|
+
}
|
|
3210
|
+
}
|
|
3211
|
+
if (options.api) {
|
|
3212
|
+
if (target === "platform") {
|
|
3213
|
+
dependencies["@topolo/auth-middleware"] = topoloPackageVersion("@topolo/auth-middleware", target);
|
|
3214
|
+
dependencies["@topolo/sdk"] = topoloPackageVersion("@topolo/sdk", target);
|
|
3215
|
+
}
|
|
3216
|
+
devDependencies.wrangler = "^4.40.2";
|
|
3217
|
+
scripts.deploy = "npm run build && wrangler deploy";
|
|
3218
|
+
}
|
|
3219
|
+
return `${json({
|
|
3220
|
+
name: application.packageName,
|
|
3221
|
+
version: "0.1.0",
|
|
3222
|
+
private: true,
|
|
3223
|
+
type: "module",
|
|
3224
|
+
scripts,
|
|
3225
|
+
dependencies,
|
|
3226
|
+
devDependencies
|
|
3227
|
+
})}
|
|
3228
|
+
`;
|
|
3229
|
+
}
|
|
3230
|
+
function topoloPackageVersion(packageName, target) {
|
|
3231
|
+
if (target === "developer") {
|
|
3232
|
+
if (packageName === "@topolo/auth-client") return "^1.0.0";
|
|
3233
|
+
return "^0.1.0";
|
|
3234
|
+
}
|
|
3235
|
+
const folder = packageName === "@topolo/sdk" ? "topolo-sdk" : packageName.replace("@topolo/", "topolo-");
|
|
3236
|
+
return `file:../packages/${folder}`;
|
|
3237
|
+
}
|
|
3238
|
+
function readme(application, target, options) {
|
|
3239
|
+
const displayName = toTitle(application.name);
|
|
3240
|
+
const targetLabel = target === "platform" ? options.portfolio === "personal" ? "first-party Topolo personal application" : "first-party Topolo platform application" : options.audience === "personal" ? "third-party Topolo personal application" : "third-party Topolo business application";
|
|
3241
|
+
const lines = [
|
|
3242
|
+
`# ${displayName}`,
|
|
3243
|
+
"",
|
|
3244
|
+
`Generated ${targetLabel} scaffold for Topolo.`,
|
|
3245
|
+
"",
|
|
3246
|
+
`Audience: ${options.audience}`,
|
|
3247
|
+
`Tenancy: ${options.tenancy}`,
|
|
3248
|
+
...target === "platform" && options.portfolio ? [`Portfolio: ${options.portfolio}`] : [],
|
|
3249
|
+
"",
|
|
3250
|
+
"## Development",
|
|
3251
|
+
"",
|
|
3252
|
+
"```bash",
|
|
3253
|
+
"npm install",
|
|
3254
|
+
"npm run typecheck",
|
|
3255
|
+
"npm run build",
|
|
3256
|
+
"npm run dev",
|
|
3257
|
+
"```",
|
|
3258
|
+
""
|
|
3259
|
+
];
|
|
3260
|
+
if (target === "platform") {
|
|
3261
|
+
lines.push(
|
|
3262
|
+
"## Shared Topolo Surface",
|
|
3263
|
+
"",
|
|
3264
|
+
"- `@topolo/ui-kit` provides the shared shell, launcher, account menu, command palette, and bug reporter.",
|
|
3265
|
+
"- `@topolo/auth-client` owns browser auth, callback exchange, token refresh, and logout.",
|
|
3266
|
+
"- 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 workspace shell.",
|
|
3267
|
+
"- `vite.config.ts` includes the standard React plugin plus React runtime dedupe so linked Topolo packages resolve one browser React runtime."
|
|
3268
|
+
);
|
|
3269
|
+
if (options.api) {
|
|
3270
|
+
lines.push("- `@topolo/auth-middleware` validates Worker requests through Topolo Auth.");
|
|
3271
|
+
}
|
|
3272
|
+
} else {
|
|
3273
|
+
lines.push(
|
|
3274
|
+
"## Topolo Developer Surface",
|
|
3275
|
+
"",
|
|
3276
|
+
"- `topolo.app.json` declares callback URLs, requested scopes, and runtime shape for Topolo Developers.",
|
|
3277
|
+
"- Browser sign-in uses the public Topolo OAuth authorization-code + PKCE flow.",
|
|
3278
|
+
`- The generated app is seeded as a ${options.audience}/${options.tenancy} developer application.`,
|
|
3279
|
+
"- The starter intentionally avoids private first-party packages so it can install outside the Topolo monorepo."
|
|
3280
|
+
);
|
|
3281
|
+
}
|
|
3282
|
+
if (target === "platform") {
|
|
3283
|
+
lines.push(
|
|
3284
|
+
"",
|
|
3285
|
+
"## Platform Registration",
|
|
3286
|
+
"",
|
|
3287
|
+
"This scaffold includes CloudControl metadata, TopoloDocs system coverage, and a Topolo app conformance workflow."
|
|
3288
|
+
);
|
|
3289
|
+
} else {
|
|
3290
|
+
lines.push(
|
|
3291
|
+
"",
|
|
3292
|
+
"## Developer Registration",
|
|
3293
|
+
"",
|
|
3294
|
+
"`topolo.app.json` is the app manifest used to register callback URLs, requested scopes, and local development metadata with Topolo."
|
|
3295
|
+
);
|
|
3296
|
+
}
|
|
3297
|
+
return `${lines.join("\n")}
|
|
3298
|
+
`;
|
|
3299
|
+
}
|
|
3300
|
+
function tsconfigJson(api) {
|
|
3301
|
+
return `${json({
|
|
3302
|
+
compilerOptions: {
|
|
3303
|
+
target: "ES2022",
|
|
3304
|
+
useDefineForClassFields: true,
|
|
3305
|
+
lib: api ? ["ES2022", "DOM", "DOM.Iterable", "WebWorker"] : ["ES2022", "DOM", "DOM.Iterable"],
|
|
3306
|
+
allowJs: false,
|
|
3307
|
+
skipLibCheck: true,
|
|
3308
|
+
esModuleInterop: true,
|
|
3309
|
+
allowSyntheticDefaultImports: true,
|
|
3310
|
+
strict: true,
|
|
3311
|
+
forceConsistentCasingInFileNames: true,
|
|
3312
|
+
module: "ESNext",
|
|
3313
|
+
moduleResolution: "Bundler",
|
|
3314
|
+
resolveJsonModule: true,
|
|
3315
|
+
isolatedModules: true,
|
|
3316
|
+
noEmit: true,
|
|
3317
|
+
jsx: "react-jsx",
|
|
3318
|
+
types: ["vite/client"]
|
|
3319
|
+
},
|
|
3320
|
+
include: ["src"],
|
|
3321
|
+
references: []
|
|
3322
|
+
})}
|
|
3323
|
+
`;
|
|
3324
|
+
}
|
|
3325
|
+
function gitignore() {
|
|
3326
|
+
return [
|
|
3327
|
+
"node_modules/",
|
|
3328
|
+
"dist/",
|
|
3329
|
+
".wrangler/",
|
|
3330
|
+
".env",
|
|
3331
|
+
".env.*",
|
|
3332
|
+
"!.env.example",
|
|
3333
|
+
""
|
|
3334
|
+
].join("\n");
|
|
3335
|
+
}
|
|
3336
|
+
function envDts(api) {
|
|
3337
|
+
const lines = [
|
|
3338
|
+
'/// <reference types="vite/client" />',
|
|
3339
|
+
"",
|
|
3340
|
+
"interface ImportMetaEnv {",
|
|
3341
|
+
" readonly VITE_TOPOLO_AUTH_URL?: string;",
|
|
3342
|
+
" readonly VITE_TOPOLO_CLIENT_ID?: string;",
|
|
3343
|
+
" readonly VITE_TOPOLO_REDIRECT_URI?: string;",
|
|
3344
|
+
" readonly VITE_TOPOLO_SCOPES?: string;",
|
|
3345
|
+
" readonly VITE_TOPOLO_SERVICE_ID?: string;"
|
|
3346
|
+
];
|
|
3347
|
+
if (api) {
|
|
3348
|
+
lines.push(" readonly VITE_TOPOLO_API_URL?: string;");
|
|
3349
|
+
}
|
|
3350
|
+
lines.push(
|
|
3351
|
+
"}",
|
|
3352
|
+
"",
|
|
3353
|
+
"interface ImportMeta {",
|
|
3354
|
+
" readonly env: ImportMetaEnv;",
|
|
3355
|
+
"}",
|
|
3356
|
+
""
|
|
3357
|
+
);
|
|
3358
|
+
return lines.join("\n");
|
|
3359
|
+
}
|
|
3360
|
+
function viteConfig() {
|
|
3361
|
+
return [
|
|
3362
|
+
"import { defineConfig } from 'vite';",
|
|
3363
|
+
"import react from '@vitejs/plugin-react';",
|
|
3364
|
+
"",
|
|
3365
|
+
"export default defineConfig({",
|
|
3366
|
+
" plugins: [react()],",
|
|
3367
|
+
" resolve: {",
|
|
3368
|
+
" dedupe: ['react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime'],",
|
|
3369
|
+
" },",
|
|
3370
|
+
" build: { outDir: 'dist' },",
|
|
3371
|
+
"});",
|
|
3372
|
+
""
|
|
3373
|
+
].join("\n");
|
|
3374
|
+
}
|
|
3375
|
+
function indexHtml(name) {
|
|
3376
|
+
return [
|
|
3377
|
+
"<!doctype html>",
|
|
3378
|
+
'<html lang="en">',
|
|
3379
|
+
" <head>",
|
|
3380
|
+
' <meta charset="UTF-8" />',
|
|
3381
|
+
' <meta name="viewport" content="width=device-width, initial-scale=1.0" />',
|
|
3382
|
+
` <title>${escapeHtml(toTitle(name))}</title>`,
|
|
3383
|
+
" </head>",
|
|
3384
|
+
" <body>",
|
|
3385
|
+
' <div id="root"></div>',
|
|
3386
|
+
' <script type="module" src="/src/main.tsx"></script>',
|
|
3387
|
+
" </body>",
|
|
3388
|
+
"</html>",
|
|
3389
|
+
""
|
|
3390
|
+
].join("\n");
|
|
3391
|
+
}
|
|
3392
|
+
function mainTsx() {
|
|
3393
|
+
return [
|
|
3394
|
+
"import React from 'react';",
|
|
3395
|
+
"import { createRoot } from 'react-dom/client';",
|
|
3396
|
+
"import App from './App';",
|
|
3397
|
+
"import './styles.css';",
|
|
3398
|
+
"",
|
|
3399
|
+
"const root = document.getElementById('root');",
|
|
3400
|
+
"",
|
|
3401
|
+
"if (!root) {",
|
|
3402
|
+
" throw new Error('Missing #root element.');",
|
|
3403
|
+
"}",
|
|
3404
|
+
"",
|
|
3405
|
+
"createRoot(root).render(",
|
|
3406
|
+
" <React.StrictMode>",
|
|
3407
|
+
" <App />",
|
|
3408
|
+
" </React.StrictMode>,",
|
|
3409
|
+
");",
|
|
3410
|
+
""
|
|
3411
|
+
].join("\n");
|
|
3412
|
+
}
|
|
3413
|
+
function stylesCss() {
|
|
3414
|
+
return [
|
|
3415
|
+
":root {",
|
|
3416
|
+
" color-scheme: light dark;",
|
|
3417
|
+
" font-family:",
|
|
3418
|
+
' Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;',
|
|
3419
|
+
" background: #f7f8fb;",
|
|
3420
|
+
" color: #182032;",
|
|
3421
|
+
"}",
|
|
3422
|
+
"",
|
|
3423
|
+
"* {",
|
|
3424
|
+
" box-sizing: border-box;",
|
|
3425
|
+
"}",
|
|
3426
|
+
"",
|
|
3427
|
+
"body {",
|
|
3428
|
+
" margin: 0;",
|
|
3429
|
+
" background: #f7f8fb;",
|
|
3430
|
+
"}",
|
|
3431
|
+
"",
|
|
3432
|
+
"button,",
|
|
3433
|
+
"input,",
|
|
3434
|
+
"select,",
|
|
3435
|
+
"textarea {",
|
|
3436
|
+
" font: inherit;",
|
|
3437
|
+
"}",
|
|
3438
|
+
"",
|
|
3439
|
+
".topolo-starter-workspace,",
|
|
3440
|
+
".topolo-starter-card-stack {",
|
|
3441
|
+
" display: grid;",
|
|
3442
|
+
" gap: 18px;",
|
|
3443
|
+
"}",
|
|
3444
|
+
"",
|
|
3445
|
+
".topolo-starter-card h2 {",
|
|
3446
|
+
" margin: 0 0 12px;",
|
|
3447
|
+
" font-size: 24px;",
|
|
3448
|
+
" line-height: 1.2;",
|
|
3449
|
+
"}",
|
|
3450
|
+
"",
|
|
3451
|
+
".topolo-starter-card p {",
|
|
3452
|
+
" margin: 0;",
|
|
3453
|
+
" color: #526071;",
|
|
3454
|
+
" line-height: 1.55;",
|
|
3455
|
+
"}",
|
|
3456
|
+
"",
|
|
3457
|
+
".topolo-starter-card {",
|
|
3458
|
+
" border: 1px solid rgba(24, 32, 50, 0.12);",
|
|
3459
|
+
" border-radius: 8px;",
|
|
3460
|
+
" padding: 20px;",
|
|
3461
|
+
" background: #ffffff;",
|
|
3462
|
+
"}",
|
|
3463
|
+
""
|
|
3464
|
+
].join("\n");
|
|
3465
|
+
}
|
|
3466
|
+
function appTsx(application, target, options) {
|
|
3467
|
+
if (target === "developer") return developerAppTsx(application);
|
|
3468
|
+
return platformAppTsx(application, options);
|
|
3469
|
+
}
|
|
3470
|
+
function platformAppTsx(application, options) {
|
|
3471
|
+
const appName = toTitle(application.name);
|
|
3472
|
+
const serviceId = application.serviceId ?? application.id;
|
|
3473
|
+
const tagline = options.audience === "personal" ? "Topolo personal app" : "Topolo workspace";
|
|
3474
|
+
const workspaceTitle = options.audience === "personal" ? "Personal app" : "Workspace";
|
|
3475
|
+
return [
|
|
3476
|
+
"import { useEffect, useMemo, useState } from 'react';",
|
|
3477
|
+
"import { createBrowserAuthModule, type AuthUser } from '@topolo/auth-client';",
|
|
3478
|
+
"import { LandingPage, LoginPage, TopoloAppShell, TopoloAuthFlowScreen, TopoloLoadingScreen, type TopoloAppShellUser } from '@topolo/ui-kit';",
|
|
3479
|
+
"",
|
|
3480
|
+
"type NavItem = 'home';",
|
|
3481
|
+
"type AppRoute = { kind: 'root' } | { kind: 'login' } | { kind: 'callback' };",
|
|
3482
|
+
"",
|
|
3483
|
+
`const APP_ID = '${application.id}';`,
|
|
3484
|
+
`const APP_NAME = '${escapeSingleQuoted(appName)}';`,
|
|
3485
|
+
`const DEFAULT_SERVICE_ID = '${serviceId}';`,
|
|
3486
|
+
"const AUTH_URL = import.meta.env.VITE_TOPOLO_AUTH_URL ?? 'https://auth.topolo.app';",
|
|
3487
|
+
"const SERVICE_ID = import.meta.env.VITE_TOPOLO_SERVICE_ID ?? DEFAULT_SERVICE_ID;",
|
|
3488
|
+
"const LANDING_CONFIG = {",
|
|
3489
|
+
" app_name: APP_NAME,",
|
|
3490
|
+
` app_tagline: '${escapeSingleQuoted(tagline)}',`,
|
|
3491
|
+
" hero_title: `Open ${APP_NAME} in Topolo`,",
|
|
3492
|
+
" hero_subtitle: 'Shared landing, login, callback, auth, and workspace shell wiring are already included in the generated baseline.',",
|
|
3493
|
+
" hero_cta_text: 'Sign in',",
|
|
3494
|
+
" hero_cta_url: '/login',",
|
|
3495
|
+
" cta_title: 'Start from the shared platform entry flow',",
|
|
3496
|
+
" cta_subtitle: 'Unauthenticated routes stay on the shared Topolo surfaces before users enter the signed workspace shell.',",
|
|
3497
|
+
"};",
|
|
3498
|
+
"const LOGIN_CONFIG = {",
|
|
3499
|
+
" app_name: APP_NAME,",
|
|
3500
|
+
` app_tagline: '${escapeSingleQuoted(tagline)}',`,
|
|
3501
|
+
" hero_title: `Sign in to ${APP_NAME}`,",
|
|
3502
|
+
" hero_subtitle: 'Use Topolo Auth to open the shared workspace shell for this application.',",
|
|
3503
|
+
" authenticated_home_path: '/',",
|
|
3504
|
+
"};",
|
|
3505
|
+
"",
|
|
3506
|
+
"const auth = createBrowserAuthModule({",
|
|
3507
|
+
" authServiceUrl: AUTH_URL,",
|
|
3508
|
+
" serviceId: SERVICE_ID,",
|
|
3509
|
+
" devSessionKey: `${SERVICE_ID}_session`,",
|
|
3510
|
+
" devSessionStorageEnabled: import.meta.env.DEV,",
|
|
3511
|
+
" callbackPath: '/auth/callback',",
|
|
3512
|
+
" debug: import.meta.env.DEV,",
|
|
3513
|
+
"});",
|
|
3514
|
+
"",
|
|
3515
|
+
"function getAccessToken(): string | null {",
|
|
3516
|
+
" return auth.accessTokenMemory.get() ?? auth.accessTokenSession.get();",
|
|
3517
|
+
"}",
|
|
3518
|
+
"",
|
|
3519
|
+
"function toShellUser(user: AuthUser | null): TopoloAppShellUser | null {",
|
|
3520
|
+
" if (!user) return null;",
|
|
3521
|
+
" return {",
|
|
3522
|
+
" name: user.name ?? user.email,",
|
|
3523
|
+
" email: user.email,",
|
|
3524
|
+
" role: user.role,",
|
|
3525
|
+
" organizationName: user.organization?.name ?? user.orgSlug ?? null,",
|
|
3526
|
+
" organizationId: user.organization?.id ?? user.orgId ?? null,",
|
|
3527
|
+
" };",
|
|
3528
|
+
"}",
|
|
3529
|
+
"",
|
|
3530
|
+
"export default function App() {",
|
|
3531
|
+
" const route = getRoute();",
|
|
3532
|
+
" const [user, setUser] = useState<AuthUser | null>(null);",
|
|
3533
|
+
" const [loading, setLoading] = useState(true);",
|
|
3534
|
+
" const [authError, setAuthError] = useState('');",
|
|
3535
|
+
" const [loginAttempted, setLoginAttempted] = useState(false);",
|
|
3536
|
+
"",
|
|
3537
|
+
" useEffect(() => {",
|
|
3538
|
+
" let active = true;",
|
|
3539
|
+
" const syncAuth = async () => {",
|
|
3540
|
+
" setLoading(true);",
|
|
3541
|
+
" setAuthError('');",
|
|
3542
|
+
"",
|
|
3543
|
+
" if (route.kind === 'callback') {",
|
|
3544
|
+
" const result = await auth.authClient.handleAuthCallbackAsync(window.location.href);",
|
|
3545
|
+
" if (!active) return;",
|
|
3546
|
+
" if (!result.authenticated) {",
|
|
3547
|
+
" setAuthError(result.error || 'Authentication failed.');",
|
|
3548
|
+
" setLoading(false);",
|
|
3549
|
+
" return;",
|
|
3550
|
+
" }",
|
|
3551
|
+
" setUser(result.user ?? null);",
|
|
3552
|
+
" window.location.replace('/');",
|
|
3553
|
+
" return;",
|
|
3554
|
+
" }",
|
|
3555
|
+
"",
|
|
3556
|
+
" const nextUser = await auth.authClient.initialize();",
|
|
3557
|
+
" if (!active) return;",
|
|
3558
|
+
" setUser(nextUser);",
|
|
3559
|
+
" setLoading(false);",
|
|
3560
|
+
" };",
|
|
3561
|
+
"",
|
|
3562
|
+
" void syncAuth().catch((error) => {",
|
|
3563
|
+
" if (!active) return;",
|
|
3564
|
+
" setAuthError(errorMessage(error));",
|
|
3565
|
+
" setLoading(false);",
|
|
3566
|
+
" });",
|
|
3567
|
+
" return () => {",
|
|
3568
|
+
" active = false;",
|
|
3569
|
+
" };",
|
|
3570
|
+
" }, [route.kind]);",
|
|
3571
|
+
"",
|
|
3572
|
+
" useEffect(() => {",
|
|
3573
|
+
" if (!loading && user && route.kind === 'login') {",
|
|
3574
|
+
" window.location.replace('/');",
|
|
3575
|
+
" }",
|
|
3576
|
+
" }, [loading, route.kind, user]);",
|
|
3577
|
+
"",
|
|
3578
|
+
" const shellUser = useMemo(() => toShellUser(user), [user]);",
|
|
3579
|
+
"",
|
|
3580
|
+
" if (route.kind === 'callback') {",
|
|
3581
|
+
" if (loading) {",
|
|
3582
|
+
" return (",
|
|
3583
|
+
" <TopoloLoadingScreen",
|
|
3584
|
+
" appId={APP_ID}",
|
|
3585
|
+
" serviceId={SERVICE_ID}",
|
|
3586
|
+
" appName={APP_NAME}",
|
|
3587
|
+
' subtitle="Completing secure sign-in..."',
|
|
3588
|
+
" />",
|
|
3589
|
+
" );",
|
|
3590
|
+
" }",
|
|
3591
|
+
"",
|
|
3592
|
+
" return (",
|
|
3593
|
+
" <TopoloAuthFlowScreen",
|
|
3594
|
+
" appName={APP_NAME}",
|
|
3595
|
+
" appId={APP_ID}",
|
|
3596
|
+
" serviceId={SERVICE_ID}",
|
|
3597
|
+
' status="error"',
|
|
3598
|
+
' title="Authentication failed"',
|
|
3599
|
+
" message={authError || `We could not complete sign-in for ${APP_NAME}.`}",
|
|
3600
|
+
' actionLabel="Back to sign in"',
|
|
3601
|
+
" onAction={() => window.location.replace('/login')}",
|
|
3602
|
+
" />",
|
|
3603
|
+
" );",
|
|
3604
|
+
" }",
|
|
3605
|
+
"",
|
|
3606
|
+
" if (loading) {",
|
|
3607
|
+
" return (",
|
|
3608
|
+
" <TopoloLoadingScreen",
|
|
3609
|
+
" appId={APP_ID}",
|
|
3610
|
+
" serviceId={SERVICE_ID}",
|
|
3611
|
+
" appName={APP_NAME}",
|
|
3612
|
+
" subtitle={`Loading ${APP_NAME}...`}",
|
|
3613
|
+
" />",
|
|
3614
|
+
" );",
|
|
3615
|
+
" }",
|
|
3616
|
+
"",
|
|
3617
|
+
" if (!user) {",
|
|
3618
|
+
" if (route.kind === 'login') {",
|
|
3619
|
+
" return (",
|
|
3620
|
+
" <LoginPage",
|
|
3621
|
+
" serviceId={SERVICE_ID}",
|
|
3622
|
+
" authApiUrl={AUTH_URL}",
|
|
3623
|
+
" config={LOGIN_CONFIG}",
|
|
3624
|
+
" isLoading={loginAttempted}",
|
|
3625
|
+
" onLogin={async (provider) => {",
|
|
3626
|
+
" setLoginAttempted(true);",
|
|
3627
|
+
" try {",
|
|
3628
|
+
" await auth.authClient.login(provider || 'topolo');",
|
|
3629
|
+
" } catch {",
|
|
3630
|
+
" setLoginAttempted(false);",
|
|
3631
|
+
" }",
|
|
3632
|
+
" }}",
|
|
3633
|
+
" onPasswordLogin={async (request) => {",
|
|
3634
|
+
" const result = await auth.authClient.loginWithPassword(request);",
|
|
3635
|
+
" if (!result.authenticated) {",
|
|
3636
|
+
" return result;",
|
|
3637
|
+
" }",
|
|
3638
|
+
" setUser(result.user ?? null);",
|
|
3639
|
+
" window.location.replace('/');",
|
|
3640
|
+
" return undefined;",
|
|
3641
|
+
" }}",
|
|
3642
|
+
" />",
|
|
3643
|
+
" );",
|
|
3644
|
+
" }",
|
|
3645
|
+
"",
|
|
3646
|
+
" return (",
|
|
3647
|
+
" <LandingPage",
|
|
3648
|
+
" serviceId={SERVICE_ID}",
|
|
3649
|
+
" authApiUrl={AUTH_URL}",
|
|
3650
|
+
" config={LANDING_CONFIG}",
|
|
3651
|
+
" onCTAClick={() => window.location.assign('/login')}",
|
|
3652
|
+
" />",
|
|
3653
|
+
" );",
|
|
3654
|
+
" }",
|
|
3655
|
+
"",
|
|
3656
|
+
" if (route.kind === 'login') {",
|
|
3657
|
+
" return (",
|
|
3658
|
+
" <TopoloLoadingScreen",
|
|
3659
|
+
" appId={APP_ID}",
|
|
3660
|
+
" serviceId={SERVICE_ID}",
|
|
3661
|
+
" appName={APP_NAME}",
|
|
3662
|
+
' subtitle="Opening your workspace..."',
|
|
3663
|
+
" />",
|
|
3664
|
+
" );",
|
|
3665
|
+
" }",
|
|
3666
|
+
"",
|
|
3667
|
+
" return (",
|
|
3668
|
+
" <TopoloAppShell<NavItem>",
|
|
3669
|
+
" appId={APP_ID}",
|
|
3670
|
+
" serviceId={SERVICE_ID}",
|
|
3671
|
+
" appName={APP_NAME}",
|
|
3672
|
+
" user={shellUser}",
|
|
3673
|
+
' activeNavItemId="home"',
|
|
3674
|
+
` navItems={[{ id: 'home', label: '${escapeSingleQuoted(workspaceTitle)}' }]}`,
|
|
3675
|
+
" onNavItemSelect={() => undefined}",
|
|
3676
|
+
" eyebrow={APP_NAME}",
|
|
3677
|
+
` title="${escapeDoubleQuoted(workspaceTitle)}"`,
|
|
3678
|
+
" authUrl={AUTH_URL}",
|
|
3679
|
+
" getAccessToken={getAccessToken}",
|
|
3680
|
+
" onSignOut={async () => {",
|
|
3681
|
+
" await auth.authClient.logout();",
|
|
3682
|
+
" window.location.assign('/login');",
|
|
3683
|
+
" }}",
|
|
3684
|
+
" commandPlaceholder={`Search ${APP_NAME}`}",
|
|
3685
|
+
" bugReporterAppName={APP_NAME}",
|
|
3686
|
+
" enableCommandPalette",
|
|
3687
|
+
" enableBugReporter",
|
|
3688
|
+
" showThemeToggle",
|
|
3689
|
+
" >",
|
|
3690
|
+
' <div className="topolo-starter-workspace">',
|
|
3691
|
+
' <section className="topolo-starter-card">',
|
|
3692
|
+
" <h2>Shared workspace baseline</h2>",
|
|
3693
|
+
" <p>",
|
|
3694
|
+
" This scaffold starts on the shared landing and login surfaces, completes sign-in on the shared callback path, and renders the signed workspace inside the shared Topolo shell.",
|
|
3695
|
+
" </p>",
|
|
3696
|
+
" </section>",
|
|
3697
|
+
" </div>",
|
|
3698
|
+
" </TopoloAppShell>",
|
|
3699
|
+
" );",
|
|
3700
|
+
"}",
|
|
3701
|
+
"",
|
|
3702
|
+
"function getRoute(): AppRoute {",
|
|
3703
|
+
" const pathname = window.location.pathname.replace(/\\/+$/, '') || '/';",
|
|
3704
|
+
" if (pathname === '/login') return { kind: 'login' };",
|
|
3705
|
+
" if (pathname === '/auth/callback') return { kind: 'callback' };",
|
|
3706
|
+
" return { kind: 'root' };",
|
|
3707
|
+
"}",
|
|
3708
|
+
"",
|
|
3709
|
+
"function errorMessage(error: unknown): string {",
|
|
3710
|
+
" if (error instanceof Error && error.message) return error.message;",
|
|
3711
|
+
" return 'Unexpected authentication error.';",
|
|
3712
|
+
"}",
|
|
3713
|
+
""
|
|
3714
|
+
].join("\n");
|
|
3715
|
+
}
|
|
3716
|
+
function developerAppTsx(application) {
|
|
3717
|
+
const appName = toTitle(application.name);
|
|
3718
|
+
const defaultClientId = `${toSlug(application.id)}_local_client`;
|
|
3719
|
+
return [
|
|
3720
|
+
"import { useEffect, useMemo, useState } from 'react';",
|
|
3721
|
+
"",
|
|
3722
|
+
"type AuthState = 'ready' | 'missing_client' | 'authorizing' | 'authenticated' | 'error';",
|
|
3723
|
+
"",
|
|
3724
|
+
`const APP_NAME = '${escapeSingleQuoted(appName)}';`,
|
|
3725
|
+
"const AUTH_URL = import.meta.env.VITE_TOPOLO_AUTH_URL ?? 'https://auth.topolo.app';",
|
|
3726
|
+
`const CLIENT_ID = import.meta.env.VITE_TOPOLO_CLIENT_ID || '${defaultClientId}';`,
|
|
3727
|
+
"const REDIRECT_URI = import.meta.env.VITE_TOPOLO_REDIRECT_URI || `${window.location.origin}/auth/callback`;",
|
|
3728
|
+
"const REQUESTED_SCOPES = (import.meta.env.VITE_TOPOLO_SCOPES || 'identity:read organization:read').trim();",
|
|
3729
|
+
"",
|
|
3730
|
+
"export default function App() {",
|
|
3731
|
+
" const [authState, setAuthState] = useState<AuthState>(() => CLIENT_ID ? 'ready' : 'missing_client');",
|
|
3732
|
+
" const [message, setMessage] = useState<string | null>(null);",
|
|
3733
|
+
" const [tokenPreview, setTokenPreview] = useState<string | null>(null);",
|
|
3734
|
+
"",
|
|
3735
|
+
" const callbackCode = useMemo(() => new URLSearchParams(window.location.search).get('code'), []);",
|
|
3736
|
+
" const callbackError = useMemo(() => new URLSearchParams(window.location.search).get('error'), []);",
|
|
3737
|
+
"",
|
|
3738
|
+
" useEffect(() => {",
|
|
3739
|
+
" if (callbackError) {",
|
|
3740
|
+
" setAuthState('error');",
|
|
3741
|
+
" setMessage(callbackError);",
|
|
3742
|
+
" return;",
|
|
3743
|
+
" }",
|
|
3744
|
+
" if (!callbackCode) return;",
|
|
3745
|
+
"",
|
|
3746
|
+
" setAuthState('authorizing');",
|
|
3747
|
+
" void exchangeAuthorizationCode(callbackCode)",
|
|
3748
|
+
" .then((tokens) => {",
|
|
3749
|
+
" sessionStorage.setItem(`${CLIENT_ID}:topolo_access_token`, tokens.access_token);",
|
|
3750
|
+
" if (tokens.refresh_token) {",
|
|
3751
|
+
" sessionStorage.setItem(`${CLIENT_ID}:topolo_refresh_token`, tokens.refresh_token);",
|
|
3752
|
+
" }",
|
|
3753
|
+
" setTokenPreview(`${tokens.access_token.slice(0, 18)}...`);",
|
|
3754
|
+
" setAuthState('authenticated');",
|
|
3755
|
+
" window.history.replaceState({}, document.title, '/');",
|
|
3756
|
+
" })",
|
|
3757
|
+
" .catch((error: Error) => {",
|
|
3758
|
+
" setAuthState('error');",
|
|
3759
|
+
" setMessage(error.message);",
|
|
3760
|
+
" });",
|
|
3761
|
+
" }, [callbackCode, callbackError]);",
|
|
3762
|
+
"",
|
|
3763
|
+
" return (",
|
|
3764
|
+
' <main className="topolo-starter-login">',
|
|
3765
|
+
' <section className="topolo-starter-login__panel">',
|
|
3766
|
+
" <h1>{APP_NAME}</h1>",
|
|
3767
|
+
" <p>Public Topolo OAuth starter using authorization-code + PKCE.</p>",
|
|
3768
|
+
" {authState === 'missing_client' && (",
|
|
3769
|
+
" <p>Set VITE_TOPOLO_CLIENT_ID after registering this app in Topolo Developers.</p>",
|
|
3770
|
+
" )}",
|
|
3771
|
+
" {authState === 'authenticated' && (",
|
|
3772
|
+
" <p>Topolo sign-in complete. Access token: {tokenPreview}</p>",
|
|
3773
|
+
" )}",
|
|
3774
|
+
" {authState === 'error' && <p>{message}</p>}",
|
|
3775
|
+
` <button type="button" disabled={!CLIENT_ID || authState === 'authorizing'} onClick={() => void startTopoloLogin()}>`,
|
|
3776
|
+
" {authState === 'authorizing' ? 'Completing sign-in' : 'Sign in with Topolo'}",
|
|
3777
|
+
" </button>",
|
|
3778
|
+
" </section>",
|
|
3779
|
+
" </main>",
|
|
3780
|
+
" );",
|
|
3781
|
+
"}",
|
|
3782
|
+
"",
|
|
3783
|
+
"async function startTopoloLogin(): Promise<void> {",
|
|
3784
|
+
" const verifier = randomString(64);",
|
|
3785
|
+
" const challenge = await pkceChallenge(verifier);",
|
|
3786
|
+
" const state = randomString(32);",
|
|
3787
|
+
" sessionStorage.setItem(`${CLIENT_ID}:topolo_pkce_verifier`, verifier);",
|
|
3788
|
+
" sessionStorage.setItem(`${CLIENT_ID}:topolo_oauth_state`, state);",
|
|
3789
|
+
"",
|
|
3790
|
+
" const authorizeUrl = new URL('/api/developer-oauth/authorize', AUTH_URL);",
|
|
3791
|
+
" authorizeUrl.searchParams.set('response_type', 'code');",
|
|
3792
|
+
" authorizeUrl.searchParams.set('client_id', CLIENT_ID);",
|
|
3793
|
+
" authorizeUrl.searchParams.set('redirect_uri', REDIRECT_URI);",
|
|
3794
|
+
" authorizeUrl.searchParams.set('scope', REQUESTED_SCOPES);",
|
|
3795
|
+
" authorizeUrl.searchParams.set('state', state);",
|
|
3796
|
+
" authorizeUrl.searchParams.set('code_challenge', challenge);",
|
|
3797
|
+
" authorizeUrl.searchParams.set('code_challenge_method', 'S256');",
|
|
3798
|
+
" window.location.assign(authorizeUrl.toString());",
|
|
3799
|
+
"}",
|
|
3800
|
+
"",
|
|
3801
|
+
"async function exchangeAuthorizationCode(code: string): Promise<{ access_token: string; refresh_token?: string }> {",
|
|
3802
|
+
" const params = new URLSearchParams(window.location.search);",
|
|
3803
|
+
" const expectedState = sessionStorage.getItem(`${CLIENT_ID}:topolo_oauth_state`);",
|
|
3804
|
+
" if (expectedState && params.get('state') !== expectedState) {",
|
|
3805
|
+
" throw new Error('OAuth state mismatch. Restart sign-in.');",
|
|
3806
|
+
" }",
|
|
3807
|
+
" const verifier = sessionStorage.getItem(`${CLIENT_ID}:topolo_pkce_verifier`);",
|
|
3808
|
+
" if (!verifier) throw new Error('Missing PKCE verifier. Restart sign-in.');",
|
|
3809
|
+
"",
|
|
3810
|
+
" const tokenUrl = new URL('/api/developer-oauth/token', AUTH_URL);",
|
|
3811
|
+
" const response = await fetch(tokenUrl, {",
|
|
3812
|
+
" method: 'POST',",
|
|
3813
|
+
" headers: { 'Content-Type': 'application/json' },",
|
|
3814
|
+
" body: JSON.stringify({",
|
|
3815
|
+
" grant_type: 'authorization_code',",
|
|
3816
|
+
" client_id: CLIENT_ID,",
|
|
3817
|
+
" code,",
|
|
3818
|
+
" redirect_uri: REDIRECT_URI,",
|
|
3819
|
+
" code_verifier: verifier,",
|
|
3820
|
+
" }),",
|
|
3821
|
+
" });",
|
|
3822
|
+
" const payload = await response.json().catch(() => null);",
|
|
3823
|
+
" if (!response.ok || !payload?.access_token) {",
|
|
3824
|
+
" throw new Error(payload?.error_description || payload?.error || 'Token exchange failed.');",
|
|
3825
|
+
" }",
|
|
3826
|
+
" return payload;",
|
|
3827
|
+
"}",
|
|
3828
|
+
"",
|
|
3829
|
+
"function randomString(length: number): string {",
|
|
3830
|
+
" const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';",
|
|
3831
|
+
" const bytes = crypto.getRandomValues(new Uint8Array(length));",
|
|
3832
|
+
" return Array.from(bytes, (byte) => alphabet[byte % alphabet.length]).join('');",
|
|
3833
|
+
"}",
|
|
3834
|
+
"",
|
|
3835
|
+
"async function pkceChallenge(verifier: string): Promise<string> {",
|
|
3836
|
+
" const data = new TextEncoder().encode(verifier);",
|
|
3837
|
+
" const digest = await crypto.subtle.digest('SHA-256', data);",
|
|
3838
|
+
" return base64Url(new Uint8Array(digest));",
|
|
3839
|
+
"}",
|
|
3840
|
+
"",
|
|
3841
|
+
"function base64Url(bytes: Uint8Array): string {",
|
|
3842
|
+
" let binary = '';",
|
|
3843
|
+
" for (const byte of bytes) binary += String.fromCharCode(byte);",
|
|
3844
|
+
" return btoa(binary).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/g, '');",
|
|
3845
|
+
"}",
|
|
3846
|
+
""
|
|
3847
|
+
].join("\n");
|
|
3848
|
+
}
|
|
3849
|
+
function wranglerToml(application, target) {
|
|
3850
|
+
const slug = toSlug(application.id);
|
|
3851
|
+
const workerName = target === "platform" ? `topolo-${slug}` : application.packageName;
|
|
3852
|
+
const routeHost = application.productionUrl ? new URL(application.productionUrl).host : null;
|
|
3853
|
+
const lines = [
|
|
3854
|
+
`name = "${workerName}"`,
|
|
3855
|
+
'main = "src/worker.ts"',
|
|
3856
|
+
`compatibility_date = "${CURRENT_VERIFICATION_DATE}"`,
|
|
3857
|
+
'compatibility_flags = ["nodejs_compat"]'
|
|
3858
|
+
];
|
|
3859
|
+
if (target === "platform") {
|
|
3860
|
+
lines.push(
|
|
3861
|
+
`account_id = "${CLOUDFLARE_ACCOUNT_ID}"`,
|
|
3862
|
+
"workers_dev = false"
|
|
3863
|
+
);
|
|
3864
|
+
}
|
|
3865
|
+
lines.push(
|
|
3866
|
+
"",
|
|
3867
|
+
"[observability]",
|
|
3868
|
+
"enabled = true",
|
|
3869
|
+
"",
|
|
3870
|
+
"[assets]",
|
|
3871
|
+
'directory = "dist"',
|
|
3872
|
+
'binding = "ASSETS"',
|
|
3873
|
+
'not_found_handling = "single-page-application"',
|
|
3874
|
+
'run_worker_first = ["/api/*"]'
|
|
3875
|
+
);
|
|
3876
|
+
if (target === "platform" && routeHost) {
|
|
3877
|
+
lines.push(
|
|
3878
|
+
"",
|
|
3879
|
+
"[[routes]]",
|
|
3880
|
+
`pattern = "${routeHost}"`,
|
|
3881
|
+
"custom_domain = true"
|
|
3882
|
+
);
|
|
3883
|
+
}
|
|
3884
|
+
lines.push(
|
|
3885
|
+
"",
|
|
3886
|
+
"[vars]",
|
|
3887
|
+
'AUTH_BASE_URL = "https://auth.topolo.app"',
|
|
3888
|
+
`SERVICE_ID = "${application.serviceId ?? application.id}"`,
|
|
3889
|
+
""
|
|
3890
|
+
);
|
|
3891
|
+
return lines.join("\n");
|
|
3892
|
+
}
|
|
3893
|
+
function workerTs(application, target, _options) {
|
|
3894
|
+
if (target === "developer") return developerWorkerTs(application);
|
|
3895
|
+
return platformWorkerTs(application);
|
|
3896
|
+
}
|
|
3897
|
+
function platformWorkerTs(application) {
|
|
3898
|
+
const serviceId = application.serviceId ?? application.id;
|
|
3899
|
+
return [
|
|
3900
|
+
"import { verifyRequest } from '@topolo/auth-middleware';",
|
|
3901
|
+
"",
|
|
3902
|
+
"export interface Env {",
|
|
3903
|
+
" AUTH_BASE_URL?: string;",
|
|
3904
|
+
" SERVICE_ID?: string;",
|
|
3905
|
+
" TOPOLO_SERVICE_API_KEY?: string;",
|
|
3906
|
+
"}",
|
|
3907
|
+
"",
|
|
3908
|
+
"const CLIENT_HEADER = 'topolo-worker-starter';",
|
|
3909
|
+
"",
|
|
3910
|
+
"export default {",
|
|
3911
|
+
" async fetch(request: Request, env: Env): Promise<Response> {",
|
|
3912
|
+
" const url = new URL(request.url);",
|
|
3913
|
+
"",
|
|
3914
|
+
" if (url.pathname === '/api/health') {",
|
|
3915
|
+
" return json({ ok: true, service: env.SERVICE_ID ?? DEFAULT_SERVICE_ID }, { status: 200 }, request);",
|
|
3916
|
+
" }",
|
|
3917
|
+
"",
|
|
3918
|
+
" const auth = await verifyRequest(request, {",
|
|
3919
|
+
" authBaseUrl: env.AUTH_BASE_URL,",
|
|
3920
|
+
" serviceId: env.SERVICE_ID ?? DEFAULT_SERVICE_ID,",
|
|
3921
|
+
" serviceApiKey: env.TOPOLO_SERVICE_API_KEY,",
|
|
3922
|
+
" });",
|
|
3923
|
+
"",
|
|
3924
|
+
" if (!auth.ok) {",
|
|
3925
|
+
" return json({ error: auth.error }, { status: auth.status }, request);",
|
|
3926
|
+
" }",
|
|
3927
|
+
"",
|
|
3928
|
+
" if (url.pathname === '/api/bootstrap') {",
|
|
3929
|
+
" return json({",
|
|
3930
|
+
" user: auth.user,",
|
|
3931
|
+
" organization: auth.organization,",
|
|
3932
|
+
" }, { status: 200 }, request);",
|
|
3933
|
+
" }",
|
|
3934
|
+
"",
|
|
3935
|
+
" return json({ error: 'not_found' }, { status: 404 }, request);",
|
|
3936
|
+
" },",
|
|
3937
|
+
"};",
|
|
3938
|
+
"",
|
|
3939
|
+
`const DEFAULT_SERVICE_ID = '${serviceId}';`,
|
|
3940
|
+
"",
|
|
3941
|
+
"function json(body: unknown, init: ResponseInit, request: Request): Response {",
|
|
3942
|
+
" const headers = new Headers(init.headers);",
|
|
3943
|
+
" headers.set('Content-Type', 'application/json');",
|
|
3944
|
+
" headers.set('X-Topolo-Client', CLIENT_HEADER);",
|
|
3945
|
+
" headers.set('X-Topolo-Request-Id', request.headers.get('X-Topolo-Request-Id') ?? crypto.randomUUID());",
|
|
3946
|
+
" return new Response(JSON.stringify(body), { ...init, headers });",
|
|
3947
|
+
"}",
|
|
3948
|
+
""
|
|
3949
|
+
].join("\n");
|
|
3950
|
+
}
|
|
3951
|
+
function developerWorkerTs(application) {
|
|
3952
|
+
const serviceId = application.serviceId ?? application.id;
|
|
3953
|
+
return [
|
|
3954
|
+
"export interface Env {",
|
|
3955
|
+
" AUTH_BASE_URL?: string;",
|
|
3956
|
+
" SERVICE_ID?: string;",
|
|
3957
|
+
"}",
|
|
3958
|
+
"",
|
|
3959
|
+
"const CLIENT_HEADER = 'topolo-developer-starter';",
|
|
3960
|
+
`const DEFAULT_SERVICE_ID = '${serviceId}';`,
|
|
3961
|
+
"",
|
|
3962
|
+
"export default {",
|
|
3963
|
+
" async fetch(request: Request, env: Env): Promise<Response> {",
|
|
3964
|
+
" const url = new URL(request.url);",
|
|
3965
|
+
"",
|
|
3966
|
+
" if (url.pathname === '/api/health') {",
|
|
3967
|
+
" return json({ ok: true, service: env.SERVICE_ID ?? DEFAULT_SERVICE_ID }, { status: 200 }, request);",
|
|
3968
|
+
" }",
|
|
3969
|
+
"",
|
|
3970
|
+
" const auth = await verifyTopoloRequest(request, env);",
|
|
3971
|
+
" if (!auth.ok) {",
|
|
3972
|
+
" return json({ error: auth.error }, { status: auth.status }, request);",
|
|
3973
|
+
" }",
|
|
3974
|
+
"",
|
|
3975
|
+
" if (url.pathname === '/api/bootstrap') {",
|
|
3976
|
+
" return json({",
|
|
3977
|
+
" user: auth.user,",
|
|
3978
|
+
" organization: auth.organization,",
|
|
3979
|
+
" }, { status: 200 }, request);",
|
|
3980
|
+
" }",
|
|
3981
|
+
"",
|
|
3982
|
+
" return json({ error: 'not_found' }, { status: 404 }, request);",
|
|
3983
|
+
" },",
|
|
3984
|
+
"};",
|
|
3985
|
+
"",
|
|
3986
|
+
"async function verifyTopoloRequest(request: Request, env: Env): Promise<",
|
|
3987
|
+
" | { ok: true; user: unknown; organization: unknown }",
|
|
3988
|
+
" | { ok: false; status: number; error: string }",
|
|
3989
|
+
"> {",
|
|
3990
|
+
" const authHeader = request.headers.get('Authorization') || '';",
|
|
3991
|
+
" const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;",
|
|
3992
|
+
" if (!token) return { ok: false, status: 401, error: 'Bearer token required' };",
|
|
3993
|
+
"",
|
|
3994
|
+
" try {",
|
|
3995
|
+
" const authBaseUrl = env.AUTH_BASE_URL || 'https://auth.topolo.app';",
|
|
3996
|
+
" const response = await fetch(`${authBaseUrl}/validate`, {",
|
|
3997
|
+
" method: 'POST',",
|
|
3998
|
+
" headers: {",
|
|
3999
|
+
" Authorization: `Bearer ${token}`,",
|
|
4000
|
+
" 'X-Service-ID': env.SERVICE_ID ?? DEFAULT_SERVICE_ID,",
|
|
4001
|
+
" },",
|
|
4002
|
+
" });",
|
|
4003
|
+
"",
|
|
4004
|
+
" const payload = await response.json().catch(() => null);",
|
|
4005
|
+
" if (!response.ok) return { ok: false, status: 401, error: 'Token validation failed' };",
|
|
4006
|
+
" const user = payload?.data?.user;",
|
|
4007
|
+
" if (!user) return { ok: false, status: 401, error: 'Invalid auth response' };",
|
|
4008
|
+
" return { ok: true, user, organization: payload?.data?.organization ?? null };",
|
|
4009
|
+
" } catch {",
|
|
4010
|
+
" return { ok: false, status: 503, error: 'Topolo Auth unavailable' };",
|
|
4011
|
+
" }",
|
|
4012
|
+
"}",
|
|
4013
|
+
"",
|
|
4014
|
+
"function json(body: unknown, init: ResponseInit, request: Request): Response {",
|
|
4015
|
+
" const headers = new Headers(init.headers);",
|
|
4016
|
+
" headers.set('Content-Type', 'application/json');",
|
|
4017
|
+
" headers.set('X-Topolo-Client', CLIENT_HEADER);",
|
|
4018
|
+
" headers.set('X-Topolo-Request-Id', request.headers.get('X-Topolo-Request-Id') ?? crypto.randomUUID());",
|
|
4019
|
+
" return new Response(JSON.stringify(body), { ...init, headers });",
|
|
4020
|
+
"}",
|
|
4021
|
+
""
|
|
4022
|
+
].join("\n");
|
|
4023
|
+
}
|
|
4024
|
+
function cloudcontrolJson(application, options) {
|
|
4025
|
+
const slug = toSlug(application.id);
|
|
4026
|
+
const host = application.productionUrl ? new URL(application.productionUrl).host : `${slug}.topolo.app`;
|
|
4027
|
+
const deployTargets = options.api ? [
|
|
4028
|
+
{
|
|
4029
|
+
kind: "worker",
|
|
4030
|
+
environment: "production",
|
|
4031
|
+
target_name: `topolo-${slug}`,
|
|
4032
|
+
cloudflare_account_id: CLOUDFLARE_ACCOUNT_ID,
|
|
4033
|
+
config_path: "wrangler.toml",
|
|
4034
|
+
build_command: "npm run build",
|
|
4035
|
+
output_dir: "dist",
|
|
4036
|
+
production_branch: "main",
|
|
4037
|
+
healthcheck_path: "/api/health",
|
|
4038
|
+
domains: [host],
|
|
4039
|
+
routes: [host],
|
|
4040
|
+
api_url: application.productionUrl,
|
|
4041
|
+
service_id: application.serviceId
|
|
4042
|
+
}
|
|
4043
|
+
] : [
|
|
4044
|
+
{
|
|
4045
|
+
kind: "pages",
|
|
4046
|
+
environment: "production",
|
|
4047
|
+
target_name: `topolo-${slug}`,
|
|
4048
|
+
cloudflare_account_id: CLOUDFLARE_ACCOUNT_ID,
|
|
4049
|
+
config_path: null,
|
|
4050
|
+
build_command: "npm run build",
|
|
4051
|
+
output_dir: "dist",
|
|
4052
|
+
production_branch: "main",
|
|
4053
|
+
healthcheck_path: null,
|
|
4054
|
+
domains: [host],
|
|
4055
|
+
routes: [host],
|
|
4056
|
+
api_url: null
|
|
4057
|
+
}
|
|
4058
|
+
];
|
|
4059
|
+
return `${json({
|
|
4060
|
+
name: `topolo-${slug}`,
|
|
4061
|
+
description: `Canonical documentation for ${toTitle(application.name)} lives in PlatformApplications/TopoloDocs.`,
|
|
4062
|
+
deploy_method: options.api ? "wrangler" : "cloudflare_pages",
|
|
4063
|
+
ci_provider: "github_actions",
|
|
4064
|
+
repo_identity: {
|
|
4065
|
+
provider: "github",
|
|
4066
|
+
owner: "Topolo-io",
|
|
4067
|
+
repo: `topolo-${slug}`,
|
|
4068
|
+
remote_url: `git@github.com:Topolo-io/topolo-${slug}.git`,
|
|
4069
|
+
default_branch: "main"
|
|
4070
|
+
},
|
|
4071
|
+
environments: application.productionUrl ? [
|
|
4072
|
+
{
|
|
4073
|
+
name: "production",
|
|
4074
|
+
url: application.productionUrl,
|
|
4075
|
+
branch: "main",
|
|
4076
|
+
status: "unknown"
|
|
4077
|
+
}
|
|
4078
|
+
] : [],
|
|
4079
|
+
deploy_targets: deployTargets,
|
|
4080
|
+
resource_bindings: [],
|
|
4081
|
+
ci_workflows: [
|
|
4082
|
+
{
|
|
4083
|
+
provider: "github_actions",
|
|
4084
|
+
workflow_name: "topolo-conformance.yml",
|
|
4085
|
+
path: ".github/workflows/topolo-conformance.yml",
|
|
4086
|
+
branch: null,
|
|
4087
|
+
required: true
|
|
4088
|
+
}
|
|
4089
|
+
],
|
|
4090
|
+
ci_targets: [],
|
|
4091
|
+
tags: ["application", "cloudflare", "typescript"],
|
|
4092
|
+
cloudflare_account_id: CLOUDFLARE_ACCOUNT_ID
|
|
4093
|
+
})}
|
|
4094
|
+
`;
|
|
4095
|
+
}
|
|
4096
|
+
function systemJson(application, options) {
|
|
4097
|
+
const slug = toSlug(application.id);
|
|
4098
|
+
const suiteLabel = options.audience === "personal" ? "personal" : "platform";
|
|
4099
|
+
const summaryLabel = options.audience === "personal" ? "first-party Topolo personal application" : "first-party Topolo platform application";
|
|
4100
|
+
return `${json({
|
|
4101
|
+
id: `topolo-${slug}`,
|
|
4102
|
+
name: toTitle(application.name),
|
|
4103
|
+
slug: `topolo-${slug}`,
|
|
4104
|
+
kind: "application",
|
|
4105
|
+
summary: `${toTitle(application.name)} is a ${summaryLabel} scaffolded on shared Auth, UI Kit, CloudControl, and conformance contracts.`,
|
|
4106
|
+
lifecycle: "planned",
|
|
4107
|
+
last_verified: CURRENT_VERIFICATION_DATE,
|
|
4108
|
+
owners: ["platform-engineering"],
|
|
4109
|
+
repo_paths: [`PlatformApplications/${application.appDir}`],
|
|
4110
|
+
service_ids: options.api && application.serviceId ? [application.serviceId] : [],
|
|
4111
|
+
visibility: "internal",
|
|
4112
|
+
api_contract: {
|
|
4113
|
+
type: options.api ? "worker" : "none",
|
|
4114
|
+
source: options.api ? `PlatformApplications/${application.appDir}/src/worker.ts` : `PlatformApplications/${application.appDir}/src/App.tsx`,
|
|
4115
|
+
notes: options.api ? "Protected Worker routes validate Topolo Auth credentials and derive organization context from the credential. The browser baseline starts on shared landing and login routes, completes auth on /auth/callback, and dedupes React when linked Topolo packages are installed locally." : "Browser-only application surface using shared Topolo Auth landing/login/callback routes, shared shell packages, and React runtime dedupe."
|
|
4116
|
+
},
|
|
4117
|
+
primary_hosts: application.productionUrl ? [application.productionUrl] : [],
|
|
4118
|
+
doc_paths: [`internal/apps/${slug}`, `public/applications/${slug}`],
|
|
4119
|
+
security_assurance: {
|
|
4120
|
+
risk_tier: "internal",
|
|
4121
|
+
auth_boundary: "Topolo Auth through @topolo/auth-client for browser sessions and @topolo/auth-middleware for Worker requests.",
|
|
4122
|
+
tenant_isolation: options.tenancy === "household" ? "household_scoped" : "organization_scoped",
|
|
4123
|
+
external_inputs: ["browser", ...options.api ? ["api"] : []],
|
|
4124
|
+
sensitive_data: ["identity", "org_data"],
|
|
4125
|
+
security_review_status: "not_started",
|
|
4126
|
+
last_security_review: CURRENT_VERIFICATION_DATE,
|
|
4127
|
+
pentest_status: "not_started",
|
|
4128
|
+
evidence_doc: `internal/apps/${slug}`
|
|
4129
|
+
},
|
|
4130
|
+
dependencies: options.audience === "personal" ? ["topolo-auth"] : ["topolo-auth", "topolo-platform"],
|
|
4131
|
+
tags: ["application", "cloudflare", "typescript", suiteLabel]
|
|
4132
|
+
})}
|
|
4133
|
+
`;
|
|
4134
|
+
}
|
|
4135
|
+
function internalDocs(application, options) {
|
|
4136
|
+
const slug = toSlug(application.id);
|
|
4137
|
+
const title = toTitle(application.name);
|
|
4138
|
+
const suiteLabel = options.audience === "personal" ? "personal" : "platform";
|
|
4139
|
+
const tenantLabel = options.tenancy === "household" ? "household" : "organization";
|
|
4140
|
+
return [
|
|
4141
|
+
"---",
|
|
4142
|
+
`title: "${escapeFrontmatter(title)} Handbook"`,
|
|
4143
|
+
`summary: "Internal handbook for ${escapeFrontmatter(title)}."`,
|
|
4144
|
+
'owners: ["platform-engineering"]',
|
|
4145
|
+
'status: "planned"',
|
|
4146
|
+
`last_verified: "${CURRENT_VERIFICATION_DATE}"`,
|
|
4147
|
+
"source_repos:",
|
|
4148
|
+
` - "PlatformApplications/${application.appDir}"`,
|
|
4149
|
+
' - "PlatformApplications/TopoloDocs"',
|
|
4150
|
+
'audience: "internal"',
|
|
4151
|
+
"tags:",
|
|
4152
|
+
` - "${slug}"`,
|
|
4153
|
+
' - "application"',
|
|
4154
|
+
"system_ids:",
|
|
4155
|
+
` - "topolo-${slug}"`,
|
|
4156
|
+
' - "topolo-auth"',
|
|
4157
|
+
"related_docs:",
|
|
4158
|
+
` - "/systems/topolo-${slug}"`,
|
|
4159
|
+
` - "/applications/${slug}"`,
|
|
4160
|
+
"---",
|
|
4161
|
+
"",
|
|
4162
|
+
"## What It Is",
|
|
4163
|
+
"",
|
|
4164
|
+
`${title} is a first-party Topolo ${suiteLabel} application scaffolded from the shared first-party application contract.`,
|
|
4165
|
+
"",
|
|
4166
|
+
"## Architecture",
|
|
4167
|
+
"",
|
|
4168
|
+
"The generated browser surface uses `@topolo/ui-kit` for the shared landing page, shared login page, auth callback state, and shared signed shell. `vite.config.ts` includes the standard React plugin plus React runtime dedupe so linked Topolo packages resolve a single browser React runtime.",
|
|
4169
|
+
"",
|
|
4170
|
+
"## Runtime Surfaces",
|
|
4171
|
+
"",
|
|
4172
|
+
`- \`PlatformApplications/${application.appDir}/src/App.tsx\``,
|
|
4173
|
+
"- public landing route: `/`",
|
|
4174
|
+
"- public sign-in route: `/login`",
|
|
4175
|
+
"- auth callback route: `/auth/callback`",
|
|
4176
|
+
...options.api ? [`- \`PlatformApplications/${application.appDir}/src/worker.ts\``] : [],
|
|
4177
|
+
`- \`PlatformApplications/${application.appDir}/topolo.cloudcontrol.json\``,
|
|
4178
|
+
...options.api && application.serviceId ? [`- \`PlatformApplications/TopoloAuth/catalog/service-manifests/${application.serviceId}.json\``] : [],
|
|
4179
|
+
"",
|
|
4180
|
+
"## API Reference",
|
|
4181
|
+
"",
|
|
4182
|
+
options.api ? "The generated API baseline exposes `/api/health` and `/api/bootstrap`. `/api/bootstrap` requires Topolo Auth validation and returns the verified user plus organization context." : "This generated baseline is browser-only and does not expose a callable API service.",
|
|
4183
|
+
"",
|
|
4184
|
+
"## Auth and Permissions",
|
|
4185
|
+
"",
|
|
4186
|
+
options.api ? `Browser sessions and Worker requests must be validated through Topolo Auth under service id \`${application.serviceId}\`. Worker routes derive organization context from the verified credential.` : "Browser sessions must be established through Topolo Auth and rendered inside the shared Topolo shell.",
|
|
4187
|
+
"",
|
|
4188
|
+
"## Data Ownership",
|
|
4189
|
+
"",
|
|
4190
|
+
`The generated scaffold stores no durable application data yet. Future domain tables, Durable Objects, queues, KV namespaces, R2 buckets, and external provider records must be scoped to the authenticated ${tenantLabel} context at the backend boundary.`,
|
|
4191
|
+
"",
|
|
4192
|
+
"## Deployments",
|
|
4193
|
+
"",
|
|
4194
|
+
application.productionUrl ? `The planned production host is \`${application.productionUrl}\`. Deployment metadata starts in \`topolo.cloudcontrol.json\` and must stay aligned with CloudControl before live release.` : "No production host has been assigned yet. Deployment metadata must be completed before live release.",
|
|
4195
|
+
"",
|
|
4196
|
+
"## Failure Modes",
|
|
4197
|
+
"",
|
|
4198
|
+
"- Auth service registration drifts from the generated Worker `SERVICE_ID`.",
|
|
4199
|
+
"- CloudControl metadata does not match the eventual Cloudflare project or route.",
|
|
4200
|
+
"- linked Topolo packages resolve a second React runtime and the browser shell renders blank instead of mounting the shared surfaces.",
|
|
4201
|
+
"- Domain data is added without an organization-scoped backend boundary.",
|
|
4202
|
+
"",
|
|
4203
|
+
"## Debugging",
|
|
4204
|
+
"",
|
|
4205
|
+
"- run `npm run typecheck` and `npm run build` in the application directory",
|
|
4206
|
+
"- run `topolo apps audit <app> --local --repo-root /Users/ashes/Projects/topolo --fail-on needs_review --json` after SDK catalog registration",
|
|
4207
|
+
"- inspect `topolo.cloudcontrol.json` before deployment changes",
|
|
4208
|
+
...options.api && application.serviceId ? [`- inspect \`PlatformApplications/TopoloAuth/catalog/service-manifests/${application.serviceId}.json\` when permission grants fail`] : [],
|
|
4209
|
+
"",
|
|
4210
|
+
"## Change Log / Verification",
|
|
4211
|
+
"",
|
|
4212
|
+
`- Scaffold generated on ${CURRENT_VERIFICATION_DATE} from \`${options.portfolio ? "topolo platform apps scaffold" : "topolo apps scaffold"}\`.`,
|
|
4213
|
+
"- `npm run typecheck`",
|
|
4214
|
+
"- `npm run build`",
|
|
4215
|
+
"- `npm run validate` in TopoloDocs",
|
|
4216
|
+
""
|
|
4217
|
+
].join("\n");
|
|
4218
|
+
}
|
|
4219
|
+
function publicDocs(application, options) {
|
|
4220
|
+
const slug = toSlug(application.id);
|
|
4221
|
+
const title = toTitle(application.name);
|
|
4222
|
+
const suiteLabel = options.audience === "personal" ? "personal" : "business";
|
|
4223
|
+
const tenantLabel = options.tenancy === "household" ? "household" : "organization";
|
|
4224
|
+
return [
|
|
4225
|
+
"---",
|
|
4226
|
+
`title: "${escapeFrontmatter(title)}"`,
|
|
4227
|
+
`summary: "Public overview of ${escapeFrontmatter(title)} in the Topolo application suite."`,
|
|
4228
|
+
'owners: ["platform-engineering"]',
|
|
4229
|
+
'status: "planned"',
|
|
4230
|
+
`last_verified: "${CURRENT_VERIFICATION_DATE}"`,
|
|
4231
|
+
"source_repos:",
|
|
4232
|
+
` - "PlatformApplications/${application.appDir}"`,
|
|
4233
|
+
'audience: "public"',
|
|
4234
|
+
"tags:",
|
|
4235
|
+
` - "${slug}"`,
|
|
4236
|
+
' - "application"',
|
|
4237
|
+
"system_ids:",
|
|
4238
|
+
` - "topolo-${slug}"`,
|
|
4239
|
+
' - "topolo-auth"',
|
|
4240
|
+
"related_docs:",
|
|
4241
|
+
` - "/systems/topolo-${slug}"`,
|
|
4242
|
+
` - "/internal/apps/${slug}"`,
|
|
4243
|
+
"---",
|
|
4244
|
+
"",
|
|
4245
|
+
"## What It Is",
|
|
4246
|
+
"",
|
|
4247
|
+
`${title} is part of the Topolo ${suiteLabel} application suite.`,
|
|
4248
|
+
"",
|
|
4249
|
+
"## Architecture",
|
|
4250
|
+
"",
|
|
4251
|
+
"The application uses the shared Topolo landing page, shared login page, auth callback handling, and shared Topolo shell from the outset.",
|
|
4252
|
+
"",
|
|
4253
|
+
"## Runtime Surfaces",
|
|
4254
|
+
"",
|
|
4255
|
+
application.productionUrl ? `The planned production host is \`${application.productionUrl}\`, with public entry at \`/\`, sign-in at \`/login\`, and callback completion at \`/auth/callback\`.` : "The production host has not been assigned yet.",
|
|
4256
|
+
"",
|
|
4257
|
+
"## API Reference",
|
|
4258
|
+
"",
|
|
4259
|
+
options.api ? "The generated API baseline exposes authenticated workspace routes under `/api/*` and validates requests through Topolo Auth." : "This generated baseline is browser-only and does not expose a callable API service.",
|
|
4260
|
+
"",
|
|
4261
|
+
"## Auth and Permissions",
|
|
4262
|
+
"",
|
|
4263
|
+
options.api && application.serviceId ? `Signed browser and API requests use Topolo Auth through service id \`${application.serviceId}\`.` : "Signed browser requests use Topolo Auth and the shared Topolo shell.",
|
|
4264
|
+
"",
|
|
4265
|
+
"## Data Ownership",
|
|
4266
|
+
"",
|
|
4267
|
+
`The generated baseline has no durable domain data yet. Future data must be ${tenantLabel}-scoped at the backend boundary.`,
|
|
4268
|
+
"",
|
|
4269
|
+
"## Deployments",
|
|
4270
|
+
"",
|
|
4271
|
+
application.productionUrl ? `The planned production host is \`${application.productionUrl}\`.` : "The production host has not been assigned yet.",
|
|
4272
|
+
"",
|
|
4273
|
+
"## Failure Modes",
|
|
4274
|
+
"",
|
|
4275
|
+
"- service registration and app routing can drift while the product is still planned",
|
|
4276
|
+
"- organization-scoped data models are not implemented until domain work begins",
|
|
4277
|
+
"",
|
|
4278
|
+
"## Debugging",
|
|
4279
|
+
"",
|
|
4280
|
+
"- use the matching internal handbook for operational details",
|
|
4281
|
+
"- verify the system registry entry before deployment or service-registration changes",
|
|
4282
|
+
"",
|
|
4283
|
+
"## Change Log / Verification",
|
|
4284
|
+
"",
|
|
4285
|
+
`- Scaffold generated on ${CURRENT_VERIFICATION_DATE} from \`topolo apps scaffold\`.`,
|
|
4286
|
+
""
|
|
4287
|
+
].join("\n");
|
|
4288
|
+
}
|
|
4289
|
+
function serviceManifestJson(application, options) {
|
|
4290
|
+
const slug = toSlug(application.id);
|
|
4291
|
+
const title = toTitle(application.name);
|
|
4292
|
+
const permissionPrefix = application.id.replace(/[^a-z0-9_]/g, "_");
|
|
4293
|
+
const resourceLabel = options.tenancy === "household" ? "household" : "workspace";
|
|
4294
|
+
return `${json({
|
|
4295
|
+
serviceId: application.serviceId,
|
|
4296
|
+
systems: [
|
|
4297
|
+
{
|
|
4298
|
+
systemId: `topolo-${slug}`,
|
|
4299
|
+
name: title,
|
|
4300
|
+
repoPaths: [`PlatformApplications/${application.appDir}`],
|
|
4301
|
+
docPaths: [`applications/${slug}`, `internal/apps/${slug}`]
|
|
4302
|
+
}
|
|
4303
|
+
],
|
|
4304
|
+
permissions: [
|
|
4305
|
+
{
|
|
4306
|
+
id: `perm_${permissionPrefix}_workspace_read`,
|
|
4307
|
+
name: "workspace:read",
|
|
4308
|
+
description: `View ${title} ${resourceLabel} data`,
|
|
4309
|
+
resourcePattern: null
|
|
4310
|
+
},
|
|
4311
|
+
{
|
|
4312
|
+
id: `perm_${permissionPrefix}_workspace_write`,
|
|
4313
|
+
name: "workspace:write",
|
|
4314
|
+
description: `Manage ${title} ${resourceLabel} data`,
|
|
4315
|
+
resourcePattern: null
|
|
4316
|
+
}
|
|
4317
|
+
],
|
|
4318
|
+
apiKeyScopes: [
|
|
4319
|
+
{
|
|
4320
|
+
id: `aks_${permissionPrefix}_workspace_read`,
|
|
4321
|
+
name: "workspace.read",
|
|
4322
|
+
description: `View ${title} ${resourceLabel} data`,
|
|
4323
|
+
resourcePattern: null
|
|
4324
|
+
},
|
|
4325
|
+
{
|
|
4326
|
+
id: `aks_${permissionPrefix}_workspace_write`,
|
|
4327
|
+
name: "workspace.write",
|
|
4328
|
+
description: `Manage ${title} ${resourceLabel} data`,
|
|
4329
|
+
resourcePattern: null
|
|
4330
|
+
}
|
|
4331
|
+
],
|
|
4332
|
+
roleBundles: {
|
|
4333
|
+
admin: ["workspace:read", "workspace:write"],
|
|
4334
|
+
member: ["workspace:read", "workspace:write"],
|
|
4335
|
+
guest: ["workspace:read"],
|
|
4336
|
+
super_admin: ["workspace:read", "workspace:write"]
|
|
4337
|
+
}
|
|
4338
|
+
})}
|
|
4339
|
+
`;
|
|
4340
|
+
}
|
|
4341
|
+
function developerManifest(application, options) {
|
|
4342
|
+
return `${json({
|
|
4343
|
+
schema: "https://topolo.app/schemas/topolo-app-manifest.v1.json",
|
|
4344
|
+
id: application.id,
|
|
4345
|
+
name: toTitle(application.name),
|
|
4346
|
+
type: "third_party",
|
|
4347
|
+
audience: options.audience,
|
|
4348
|
+
tenancy: options.tenancy,
|
|
4349
|
+
distribution_model: options.distributionModel,
|
|
4350
|
+
surface_model: options.surfaceModel,
|
|
4351
|
+
container_app_id: options.containerAppId,
|
|
4352
|
+
package_name: application.packageName,
|
|
4353
|
+
service_id: options.api ? application.serviceId : null,
|
|
4354
|
+
auth: {
|
|
4355
|
+
provider: "topolo",
|
|
4356
|
+
flow: "authorization_code_pkce",
|
|
4357
|
+
callback_path: "/auth/callback",
|
|
4358
|
+
local_callback_url: "http://localhost:5173/auth/callback",
|
|
4359
|
+
production_callback_url: application.productionUrl ? `${application.productionUrl.replace(/\/$/, "")}/auth/callback` : null,
|
|
4360
|
+
requested_scopes: options.api ? ["identity:read", "organization:read", `${application.id}:read`] : ["identity:read", "organization:read"]
|
|
4361
|
+
},
|
|
4362
|
+
runtime: {
|
|
4363
|
+
browser: true,
|
|
4364
|
+
api: options.api
|
|
4365
|
+
}
|
|
4366
|
+
})}
|
|
4367
|
+
`;
|
|
4368
|
+
}
|
|
4369
|
+
function envExample(application) {
|
|
4370
|
+
return [
|
|
4371
|
+
"VITE_TOPOLO_AUTH_URL=https://auth.topolo.app",
|
|
4372
|
+
"VITE_TOPOLO_CLIENT_ID=",
|
|
4373
|
+
"VITE_TOPOLO_REDIRECT_URI=http://localhost:5173/auth/callback",
|
|
4374
|
+
"VITE_TOPOLO_SCOPES=identity:read organization:read",
|
|
4375
|
+
`VITE_TOPOLO_SERVICE_ID=${application.serviceId ?? application.id}`,
|
|
4376
|
+
""
|
|
4377
|
+
].join("\n");
|
|
4378
|
+
}
|
|
4379
|
+
function normalizeTarget(value) {
|
|
4380
|
+
if (!value) return "platform";
|
|
4381
|
+
if (value === "platform" || value === "developer") return value;
|
|
4382
|
+
throw new Error("Invalid scaffold target. Use platform or developer.");
|
|
4383
|
+
}
|
|
4384
|
+
function normalizePortfolio(value) {
|
|
4385
|
+
if (!value) return "platform";
|
|
4386
|
+
if (value === "platform" || value === "personal") return value;
|
|
4387
|
+
throw new Error("Invalid scaffold portfolio. Use platform or personal.");
|
|
4388
|
+
}
|
|
4389
|
+
function normalizeAudience(value, portfolio) {
|
|
4390
|
+
if (!value) {
|
|
4391
|
+
return portfolio === "personal" ? "personal" : "business";
|
|
4392
|
+
}
|
|
4393
|
+
if (value === "business" || value === "personal") {
|
|
4394
|
+
return value;
|
|
4395
|
+
}
|
|
4396
|
+
throw new Error("Invalid scaffold audience. Use business or personal.");
|
|
4397
|
+
}
|
|
4398
|
+
function normalizeTenancy(value, audience) {
|
|
4399
|
+
const resolved = value ?? (audience === "personal" ? "household" : "organization");
|
|
4400
|
+
if (resolved !== "organization" && resolved !== "household") {
|
|
4401
|
+
throw new Error("Invalid scaffold tenancy. Use organization or household.");
|
|
4402
|
+
}
|
|
4403
|
+
if (audience === "business" && resolved !== "organization") {
|
|
4404
|
+
throw new Error("Business scaffolds must use organization tenancy.");
|
|
4405
|
+
}
|
|
4406
|
+
if (audience === "personal" && resolved !== "household") {
|
|
4407
|
+
throw new Error("Personal scaffolds must use household tenancy.");
|
|
4408
|
+
}
|
|
4409
|
+
return resolved;
|
|
4410
|
+
}
|
|
4411
|
+
function normalizeSurfaceModel(value) {
|
|
4412
|
+
if (!value) return "web_app";
|
|
4413
|
+
if (value === "web_app" || value === "mobile_container" || value === "mobile_module" || value === "messaging_surface") {
|
|
4414
|
+
return value;
|
|
4415
|
+
}
|
|
4416
|
+
throw new Error(
|
|
4417
|
+
"Invalid scaffold surface model. Use web_app, mobile_container, mobile_module, or messaging_surface."
|
|
4418
|
+
);
|
|
4419
|
+
}
|
|
4420
|
+
function normalizeContainerAppId(value, surfaceModel) {
|
|
4421
|
+
const trimmed = String(value || "").trim();
|
|
4422
|
+
if (surfaceModel === "mobile_module") {
|
|
4423
|
+
if (!trimmed) {
|
|
4424
|
+
throw new Error("Mobile-module scaffolds require --container-app-id.");
|
|
4425
|
+
}
|
|
4426
|
+
return trimmed;
|
|
4427
|
+
}
|
|
4428
|
+
return trimmed || null;
|
|
4429
|
+
}
|
|
2907
4430
|
function normalizeApplicationId(application) {
|
|
2908
|
-
const
|
|
2909
|
-
|
|
2910
|
-
|
|
4431
|
+
const raw = application.trim().toLowerCase();
|
|
4432
|
+
const normalized = raw.replace(/-/g, "_");
|
|
4433
|
+
if (Object.prototype.hasOwnProperty.call(APPLICATIONS, raw)) {
|
|
4434
|
+
return raw;
|
|
2911
4435
|
}
|
|
2912
|
-
if (
|
|
2913
|
-
|
|
4436
|
+
if (Object.prototype.hasOwnProperty.call(APPLICATIONS, normalized)) {
|
|
4437
|
+
return normalized;
|
|
2914
4438
|
}
|
|
2915
|
-
|
|
4439
|
+
if (/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(raw) || /^[a-z0-9](?:[a-z0-9_]*[a-z0-9])?$/.test(raw)) {
|
|
4440
|
+
return normalized;
|
|
4441
|
+
}
|
|
4442
|
+
throw new Error(
|
|
4443
|
+
"Application id must be kebab-case or snake_case using lowercase letters, numbers, hyphens, and underscores."
|
|
4444
|
+
);
|
|
4445
|
+
}
|
|
4446
|
+
function toSlug(value) {
|
|
4447
|
+
return value.replace(/_/g, "-");
|
|
2916
4448
|
}
|
|
2917
4449
|
function toPascalCase(value) {
|
|
2918
|
-
return value.split(
|
|
4450
|
+
return value.split(/[-_]/).filter(Boolean).map((part) => part.slice(0, 1).toUpperCase() + part.slice(1)).join("");
|
|
4451
|
+
}
|
|
4452
|
+
function toTitle(value) {
|
|
4453
|
+
return value.replace(/^topolo[-_\s]*/i, "Topolo ").split(/[-_\s]+/).filter(Boolean).map((part) => part.slice(0, 1).toUpperCase() + part.slice(1)).join(" ");
|
|
4454
|
+
}
|
|
4455
|
+
function json(value) {
|
|
4456
|
+
return JSON.stringify(value, null, 2);
|
|
4457
|
+
}
|
|
4458
|
+
async function pathExists(path2) {
|
|
4459
|
+
try {
|
|
4460
|
+
await access(path2, constants.F_OK);
|
|
4461
|
+
return true;
|
|
4462
|
+
} catch {
|
|
4463
|
+
return false;
|
|
4464
|
+
}
|
|
4465
|
+
}
|
|
4466
|
+
function resolveInside(root, filePath) {
|
|
4467
|
+
const resolved = resolve(root, filePath);
|
|
4468
|
+
const relativePath = relative(root, resolved);
|
|
4469
|
+
if (relativePath.startsWith("..") || relativePath === "" || relativePath.startsWith("/")) {
|
|
4470
|
+
throw new Error(`Scaffold file path escapes the target root: ${filePath}`);
|
|
4471
|
+
}
|
|
4472
|
+
return resolved;
|
|
4473
|
+
}
|
|
4474
|
+
function escapeHtml(value) {
|
|
4475
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
4476
|
+
}
|
|
4477
|
+
function escapeSingleQuoted(value) {
|
|
4478
|
+
return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
4479
|
+
}
|
|
4480
|
+
function escapeDoubleQuoted(value) {
|
|
4481
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
4482
|
+
}
|
|
4483
|
+
function escapeFrontmatter(value) {
|
|
4484
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
4485
|
+
}
|
|
4486
|
+
|
|
4487
|
+
// src/commands/app-scaffold-provision.ts
|
|
4488
|
+
var DEFAULT_DEVELOPERS_ORIGIN = "https://developers.topolo.app";
|
|
4489
|
+
var DEVELOPERS_SERVICE_ID = "svc_topolo_developers";
|
|
4490
|
+
async function provisionApplicationScaffold(payload) {
|
|
4491
|
+
if (!payload.application.productionUrl) {
|
|
4492
|
+
throw new Error(
|
|
4493
|
+
"Scaffold provisioning requires a browser-capable application with a production URL."
|
|
4494
|
+
);
|
|
4495
|
+
}
|
|
4496
|
+
if (payload.target === "platform" && !payload.application.serviceId) {
|
|
4497
|
+
throw new Error(
|
|
4498
|
+
"Platform scaffold provisioning requires a service id. Re-run without --no-api or pass --service."
|
|
4499
|
+
);
|
|
4500
|
+
}
|
|
4501
|
+
const credential = await refreshCredentialIfNeeded();
|
|
4502
|
+
if (!credential) {
|
|
4503
|
+
throw new Error(
|
|
4504
|
+
"topolo apps scaffold --provision requires an authenticated Topolo session. Run `topolo auth login` first."
|
|
4505
|
+
);
|
|
4506
|
+
}
|
|
4507
|
+
const client = await buildClient();
|
|
4508
|
+
if (!client) {
|
|
4509
|
+
throw new Error(
|
|
4510
|
+
"Could not build a Topolo client for scaffold provisioning. Run `topolo auth login` first."
|
|
4511
|
+
);
|
|
4512
|
+
}
|
|
4513
|
+
const identity = await client.client.introspect();
|
|
4514
|
+
const config = await readConfig();
|
|
4515
|
+
const serviceUrls = resolveServiceUrls(config.serviceUrls) ?? {};
|
|
4516
|
+
const developersOrigin = sanitizeOrigin(serviceUrls.developers ?? DEFAULT_DEVELOPERS_ORIGIN);
|
|
4517
|
+
const endpoint = payload.target === "platform" ? "/api/developer-console/admin/first-party-app-scaffolds" : "/api/developer-console/apps/scaffold";
|
|
4518
|
+
const headers = new Headers({
|
|
4519
|
+
"Content-Type": "application/json",
|
|
4520
|
+
"X-Service-ID": DEVELOPERS_SERVICE_ID,
|
|
4521
|
+
"X-Topolo-Client": `topolo-cli/${CLI_VERSION}`
|
|
4522
|
+
});
|
|
4523
|
+
const agentName = resolveAgentName(config.agentName);
|
|
4524
|
+
if (agentName) {
|
|
4525
|
+
headers.set("X-Topolo-Agent", agentName);
|
|
4526
|
+
}
|
|
4527
|
+
if (credential.kind === "api_key") {
|
|
4528
|
+
headers.set("X-Api-Key", credential.apiKey);
|
|
4529
|
+
} else {
|
|
4530
|
+
headers.set("Authorization", `Bearer ${credential.accessToken}`);
|
|
4531
|
+
}
|
|
4532
|
+
const response = await fetch(`${developersOrigin}${endpoint}`, {
|
|
4533
|
+
method: "POST",
|
|
4534
|
+
headers,
|
|
4535
|
+
body: JSON.stringify(buildProvisioningRequest(payload))
|
|
4536
|
+
});
|
|
4537
|
+
const envelope = await response.json().catch(() => null);
|
|
4538
|
+
if (!response.ok || !envelope?.success || !envelope.data) {
|
|
4539
|
+
throw new Error(
|
|
4540
|
+
resolveProvisioningError(envelope, response.status, payload.target)
|
|
4541
|
+
);
|
|
4542
|
+
}
|
|
4543
|
+
const app = pickRecord(envelope.data.app);
|
|
4544
|
+
const service = pickRecord(envelope.data.service);
|
|
4545
|
+
const authConfig = pickRecord(envelope.data.authConfig);
|
|
4546
|
+
if (!app) {
|
|
4547
|
+
throw new Error("Developers provisioning did not return an app record.");
|
|
4548
|
+
}
|
|
4549
|
+
const appId = readRequiredString(app.id, "Developers provisioning did not return an app id.");
|
|
4550
|
+
return {
|
|
4551
|
+
target: payload.target,
|
|
4552
|
+
actor: {
|
|
4553
|
+
kind: identity.kind,
|
|
4554
|
+
userId: identity.user.id,
|
|
4555
|
+
email: identity.user.email,
|
|
4556
|
+
role: identity.user.role,
|
|
4557
|
+
organizationId: identity.organization?.id ?? null,
|
|
4558
|
+
organizationSlug: identity.organization?.slug ?? null
|
|
4559
|
+
},
|
|
4560
|
+
developersOrigin,
|
|
4561
|
+
developersApp: {
|
|
4562
|
+
id: appId,
|
|
4563
|
+
slug: readString(app.slug),
|
|
4564
|
+
listingStatus: readString(app.listingStatus),
|
|
4565
|
+
organizationId: readString(app.organizationId)
|
|
4566
|
+
},
|
|
4567
|
+
authService: service ? {
|
|
4568
|
+
id: readRequiredString(
|
|
4569
|
+
service.id,
|
|
4570
|
+
"Auth registration did not return a service id."
|
|
4571
|
+
),
|
|
4572
|
+
status: readString(service.status),
|
|
4573
|
+
baseUrl: readString(service.base_url) ?? readString(service.baseUrl)
|
|
4574
|
+
} : null,
|
|
4575
|
+
authConfig: authConfig ? {
|
|
4576
|
+
login: authConfig.login === true,
|
|
4577
|
+
landing: authConfig.landing === true,
|
|
4578
|
+
appUi: authConfig.appUi === true
|
|
4579
|
+
} : null
|
|
4580
|
+
};
|
|
4581
|
+
}
|
|
4582
|
+
function buildProvisioningRequest(payload) {
|
|
4583
|
+
return {
|
|
4584
|
+
target: payload.target,
|
|
4585
|
+
ownerType: payload.ownerType,
|
|
4586
|
+
portfolio: payload.portfolio,
|
|
4587
|
+
audience: payload.audience,
|
|
4588
|
+
tenancy: payload.tenancy,
|
|
4589
|
+
surfaceModel: payload.surfaceModel,
|
|
4590
|
+
containerAppId: payload.containerAppId,
|
|
4591
|
+
distributionModel: payload.distributionModel,
|
|
4592
|
+
applicationId: payload.application.id,
|
|
4593
|
+
appDir: payload.application.appDir,
|
|
4594
|
+
packageName: payload.application.packageName,
|
|
4595
|
+
name: payload.application.name,
|
|
4596
|
+
productionUrl: payload.application.productionUrl,
|
|
4597
|
+
serviceId: payload.application.serviceId
|
|
4598
|
+
};
|
|
4599
|
+
}
|
|
4600
|
+
function resolveProvisioningError(envelope, status, target) {
|
|
4601
|
+
const message = (typeof envelope?.error === "string" ? envelope.error : envelope?.error?.message) || envelope?.message || `Scaffold provisioning failed with status ${status}.`;
|
|
4602
|
+
if (status === 401 || status === 403) {
|
|
4603
|
+
if (target === "platform") {
|
|
4604
|
+
return `${message} Platform scaffold provisioning requires a Topolo platform-admin session with Developers commerce access.`;
|
|
4605
|
+
}
|
|
4606
|
+
return `${message} Developer scaffold provisioning requires a signed-in Topolo developer workspace session.`;
|
|
4607
|
+
}
|
|
4608
|
+
return message;
|
|
4609
|
+
}
|
|
4610
|
+
function sanitizeOrigin(value) {
|
|
4611
|
+
return value.replace(/\/+$/, "");
|
|
4612
|
+
}
|
|
4613
|
+
function pickRecord(value) {
|
|
4614
|
+
return value && typeof value === "object" ? value : null;
|
|
4615
|
+
}
|
|
4616
|
+
function readString(value) {
|
|
4617
|
+
if (typeof value !== "string") {
|
|
4618
|
+
return null;
|
|
4619
|
+
}
|
|
4620
|
+
const trimmed = value.trim();
|
|
4621
|
+
return trimmed ? trimmed : null;
|
|
4622
|
+
}
|
|
4623
|
+
function readRequiredString(value, message) {
|
|
4624
|
+
const resolved = readString(value);
|
|
4625
|
+
if (!resolved) {
|
|
4626
|
+
throw new Error(message);
|
|
4627
|
+
}
|
|
4628
|
+
return resolved;
|
|
2919
4629
|
}
|
|
2920
4630
|
|
|
2921
4631
|
// src/commands/apps.ts
|
|
@@ -2979,21 +4689,46 @@ function registerAppsCommand(program2) {
|
|
|
2979
4689
|
}
|
|
2980
4690
|
});
|
|
2981
4691
|
apps.command("scaffold <application>").description(
|
|
2982
|
-
"Generate
|
|
2983
|
-
).option("--json", "Output JSON").option("--no-json", "Output human-readable checklist").option("--app-dir <path>", "Application directory under PlatformApplications").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 service id for API-backed apps").option("--
|
|
4692
|
+
"Generate a third-party Topolo application scaffold for business or personal/household use."
|
|
4693
|
+
).option("--json", "Output JSON").option("--no-json", "Output human-readable checklist").option("--app-dir <path>", "Application directory under PlatformApplications").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 service id for API-backed apps").option("--audience <audience>", "Developer app audience: business or personal").option("--tenancy <tenancy>", "Developer app tenancy: organization or household").option(
|
|
4694
|
+
"--surface-model <surfaceModel>",
|
|
4695
|
+
"Developer app surface model: web_app, mobile_container, mobile_module, or messaging_surface"
|
|
4696
|
+
).option("--container-app-id <appId>", "Container app id when --surface-model mobile_module is used").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 API/service files for developer scaffolds").option("--no-api", "Do not include API/service requirements").option("--no-browser", "Do not include browser-shell requirements").option(
|
|
4697
|
+
"--provision",
|
|
4698
|
+
"Provision the generated app into the current Topolo developer workspace using the authenticated session"
|
|
4699
|
+
).action(async (application, opts) => {
|
|
2984
4700
|
const mode = opts.json === false || process.argv.includes("--no-json") ? "pretty" : resolveOutputMode(opts.json);
|
|
2985
4701
|
try {
|
|
4702
|
+
const api = resolveScaffoldApiOption(opts.api, opts.withApi);
|
|
2986
4703
|
const payload = applicationScaffoldPayload(application, {
|
|
2987
4704
|
appDir: opts.appDir,
|
|
2988
4705
|
name: opts.name,
|
|
2989
4706
|
packageName: opts.packageName,
|
|
2990
4707
|
productionUrl: opts.productionUrl,
|
|
2991
4708
|
service: opts.service,
|
|
2992
|
-
api
|
|
2993
|
-
browser: opts.browser
|
|
4709
|
+
api,
|
|
4710
|
+
browser: opts.browser,
|
|
4711
|
+
target: "developer",
|
|
4712
|
+
audience: resolveScaffoldAudience(opts.audience),
|
|
4713
|
+
tenancy: resolveScaffoldTenancy(opts.tenancy),
|
|
4714
|
+
surfaceModel: resolveScaffoldSurfaceModel(opts.surfaceModel),
|
|
4715
|
+
containerAppId: opts.containerAppId
|
|
2994
4716
|
});
|
|
2995
|
-
|
|
2996
|
-
|
|
4717
|
+
if (opts.provision && !opts.write) {
|
|
4718
|
+
throw new Error("--provision requires --write so remote developer records match written scaffold files.");
|
|
4719
|
+
}
|
|
4720
|
+
const writeResult = opts.write ? await writeApplicationScaffold(payload, { repoRoot: opts.repoRoot, force: opts.force }) : null;
|
|
4721
|
+
const provisionResult = opts.provision ? await provisionApplicationScaffold(payload) : null;
|
|
4722
|
+
const output = {
|
|
4723
|
+
...payload,
|
|
4724
|
+
...writeResult ? { write: writeResult } : {},
|
|
4725
|
+
...provisionResult ? { provision: provisionResult } : {}
|
|
4726
|
+
};
|
|
4727
|
+
emit(
|
|
4728
|
+
output,
|
|
4729
|
+
mode,
|
|
4730
|
+
() => formatScaffold(payload, writeResult, provisionResult)
|
|
4731
|
+
);
|
|
2997
4732
|
} catch (err) {
|
|
2998
4733
|
fail(err, mode);
|
|
2999
4734
|
}
|
|
@@ -3154,9 +4889,17 @@ function formatAudit(payload) {
|
|
|
3154
4889
|
}
|
|
3155
4890
|
return lines.join("\n");
|
|
3156
4891
|
}
|
|
3157
|
-
function formatScaffold(payload) {
|
|
4892
|
+
function formatScaffold(payload, writeResult = null, provisionResult = null) {
|
|
3158
4893
|
const lines = [
|
|
3159
4894
|
`Version: ${payload.version}`,
|
|
4895
|
+
`Target: ${payload.target}`,
|
|
4896
|
+
`Owner: ${payload.ownerType}`,
|
|
4897
|
+
`Audience: ${payload.audience}`,
|
|
4898
|
+
`Tenancy: ${payload.tenancy}`,
|
|
4899
|
+
`Portfolio: ${payload.portfolio ?? "-"}`,
|
|
4900
|
+
`Surface: ${payload.surfaceModel}`,
|
|
4901
|
+
`Container: ${payload.containerAppId ?? "-"}`,
|
|
4902
|
+
`Distribution: ${payload.distributionModel}`,
|
|
3160
4903
|
`Application: ${payload.application.id}`,
|
|
3161
4904
|
`Directory: ${payload.application.appDir}`,
|
|
3162
4905
|
`Package: ${payload.application.packageName}`,
|
|
@@ -3177,9 +4920,72 @@ function formatScaffold(payload) {
|
|
|
3177
4920
|
for (const command of payload.commands) {
|
|
3178
4921
|
lines.push(` - ${command}`);
|
|
3179
4922
|
}
|
|
3180
|
-
lines.push(`CI workflow: ${payload.ciWorkflowPath}`);
|
|
4923
|
+
lines.push(`CI workflow: ${payload.ciWorkflowPath ?? "-"}`);
|
|
4924
|
+
if (writeResult) {
|
|
4925
|
+
lines.push(`Written root: ${writeResult.root}`);
|
|
4926
|
+
lines.push("Written files:");
|
|
4927
|
+
for (const file of writeResult.files) {
|
|
4928
|
+
lines.push(` - ${file.status}: ${file.path}`);
|
|
4929
|
+
}
|
|
4930
|
+
}
|
|
4931
|
+
if (provisionResult) {
|
|
4932
|
+
lines.push("Provisioning:");
|
|
4933
|
+
lines.push(` - target: ${provisionResult.target}`);
|
|
4934
|
+
lines.push(
|
|
4935
|
+
` - actor: ${provisionResult.actor.email} (${provisionResult.actor.role}, org=${provisionResult.actor.organizationSlug ?? provisionResult.actor.organizationId ?? "-"})`
|
|
4936
|
+
);
|
|
4937
|
+
lines.push(` - developers origin: ${provisionResult.developersOrigin}`);
|
|
4938
|
+
lines.push(
|
|
4939
|
+
` - developers app: ${provisionResult.developersApp.id} (listing=${provisionResult.developersApp.listingStatus ?? "-"}, org=${provisionResult.developersApp.organizationId ?? "-"})`
|
|
4940
|
+
);
|
|
4941
|
+
if (provisionResult.authService) {
|
|
4942
|
+
lines.push(
|
|
4943
|
+
` - auth service: ${provisionResult.authService.id} (status=${provisionResult.authService.status ?? "-"}, url=${provisionResult.authService.baseUrl ?? "-"})`
|
|
4944
|
+
);
|
|
4945
|
+
}
|
|
4946
|
+
if (provisionResult.authConfig) {
|
|
4947
|
+
lines.push(
|
|
4948
|
+
` - auth config: login=${provisionResult.authConfig.login} landing=${provisionResult.authConfig.landing} app_ui=${provisionResult.authConfig.appUi}`
|
|
4949
|
+
);
|
|
4950
|
+
}
|
|
4951
|
+
}
|
|
3181
4952
|
return lines.join("\n");
|
|
3182
4953
|
}
|
|
4954
|
+
function resolveScaffoldAudience(value) {
|
|
4955
|
+
if (!value) return void 0;
|
|
4956
|
+
const normalized = value.trim().toLowerCase();
|
|
4957
|
+
if (normalized === "business" || normalized === "personal") {
|
|
4958
|
+
return normalized;
|
|
4959
|
+
}
|
|
4960
|
+
throw new Error("Invalid --audience value. Use business or personal.");
|
|
4961
|
+
}
|
|
4962
|
+
function resolveScaffoldTenancy(value) {
|
|
4963
|
+
if (!value) return void 0;
|
|
4964
|
+
const normalized = value.trim().toLowerCase();
|
|
4965
|
+
if (normalized === "organization" || normalized === "household") {
|
|
4966
|
+
return normalized;
|
|
4967
|
+
}
|
|
4968
|
+
throw new Error("Invalid --tenancy value. Use organization or household.");
|
|
4969
|
+
}
|
|
4970
|
+
function resolveScaffoldSurfaceModel(value) {
|
|
4971
|
+
if (!value) return void 0;
|
|
4972
|
+
const normalized = value.trim().toLowerCase();
|
|
4973
|
+
if (normalized === "web_app" || normalized === "mobile_container" || normalized === "mobile_module" || normalized === "messaging_surface") {
|
|
4974
|
+
return normalized;
|
|
4975
|
+
}
|
|
4976
|
+
throw new Error(
|
|
4977
|
+
"Invalid --surface-model value. Use web_app, mobile_container, mobile_module, or messaging_surface."
|
|
4978
|
+
);
|
|
4979
|
+
}
|
|
4980
|
+
function resolveScaffoldApiOption(api, withApi) {
|
|
4981
|
+
const noApi = process.argv.includes("--no-api");
|
|
4982
|
+
if (withApi && noApi) {
|
|
4983
|
+
throw new Error("--with-api cannot be combined with --no-api.");
|
|
4984
|
+
}
|
|
4985
|
+
if (withApi) return true;
|
|
4986
|
+
if (noApi || api === false) return false;
|
|
4987
|
+
return void 0;
|
|
4988
|
+
}
|
|
3183
4989
|
function normalizeFailOn(value) {
|
|
3184
4990
|
const normalized = value.trim().toLowerCase().replace(/-/g, "_");
|
|
3185
4991
|
if (normalized === "missing" || normalized === "needs_review" || normalized === "partial") {
|
|
@@ -3193,6 +4999,98 @@ function statusesAtOrWorse(threshold) {
|
|
|
3193
4999
|
return ["missing", "needs_review", "partial"];
|
|
3194
5000
|
}
|
|
3195
5001
|
|
|
5002
|
+
// src/commands/platform.ts
|
|
5003
|
+
function registerPlatformCommand(program2) {
|
|
5004
|
+
const platform3 = program2.command("platform").description("Internal Topolo first-party platform and personal app commands");
|
|
5005
|
+
const platformApps = platform3.command("apps").description("Scaffold and operate first-party Topolo platform and personal applications");
|
|
5006
|
+
platformApps.command("scaffold <application>").description(
|
|
5007
|
+
"Generate a first-party Topolo scaffold for the platform or personal portfolio."
|
|
5008
|
+
).option("--json", "Output JSON").option("--no-json", "Output human-readable checklist").option("--app-dir <path>", "Application directory under PlatformApplications").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 service id for API-backed apps").option("--portfolio <portfolio>", "First-party portfolio: platform or personal").option(
|
|
5009
|
+
"--surface-model <surfaceModel>",
|
|
5010
|
+
"First-party surface model: web_app, mobile_container, mobile_module, or messaging_surface"
|
|
5011
|
+
).option("--container-app-id <appId>", "Container app id when --surface-model mobile_module is used").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(
|
|
5012
|
+
"--provision",
|
|
5013
|
+
"Provision the generated first-party app into Topolo Developers and Auth using the authenticated Topolo admin session"
|
|
5014
|
+
).option("--workflow-only", "Output only the generated GitHub Actions workflow in human mode").action(async (application, opts) => {
|
|
5015
|
+
const mode = opts.json === false || process.argv.includes("--no-json") ? "pretty" : resolveOutputMode(opts.json);
|
|
5016
|
+
try {
|
|
5017
|
+
await requirePlatformAdminSession();
|
|
5018
|
+
if (opts.workflowOnly && opts.write) {
|
|
5019
|
+
throw new Error("--workflow-only cannot be used with --write.");
|
|
5020
|
+
}
|
|
5021
|
+
const api = resolveScaffoldApiOption(opts.api, opts.withApi);
|
|
5022
|
+
const payload = applicationScaffoldPayload(application, {
|
|
5023
|
+
appDir: opts.appDir,
|
|
5024
|
+
name: opts.name,
|
|
5025
|
+
packageName: opts.packageName,
|
|
5026
|
+
productionUrl: opts.productionUrl,
|
|
5027
|
+
service: opts.service,
|
|
5028
|
+
api,
|
|
5029
|
+
browser: opts.browser,
|
|
5030
|
+
target: "platform",
|
|
5031
|
+
portfolio: resolvePlatformPortfolio(opts.portfolio),
|
|
5032
|
+
surfaceModel: resolvePlatformSurfaceModel(opts.surfaceModel),
|
|
5033
|
+
containerAppId: opts.containerAppId
|
|
5034
|
+
});
|
|
5035
|
+
if (opts.workflowOnly && !payload.ciWorkflow) {
|
|
5036
|
+
throw new Error("--workflow-only is only available for first-party scaffolds.");
|
|
5037
|
+
}
|
|
5038
|
+
if (opts.provision && !opts.write) {
|
|
5039
|
+
throw new Error("--provision requires --write so remote first-party records match written scaffold files.");
|
|
5040
|
+
}
|
|
5041
|
+
const writeResult = opts.write ? await writeApplicationScaffold(payload, { repoRoot: opts.repoRoot, force: opts.force }) : null;
|
|
5042
|
+
const provisionResult = opts.provision ? await provisionApplicationScaffold(payload) : null;
|
|
5043
|
+
const output = opts.workflowOnly ? { application: payload.application, ciWorkflowPath: payload.ciWorkflowPath, ciWorkflow: payload.ciWorkflow } : {
|
|
5044
|
+
...payload,
|
|
5045
|
+
...writeResult ? { write: writeResult } : {},
|
|
5046
|
+
...provisionResult ? { provision: provisionResult } : {}
|
|
5047
|
+
};
|
|
5048
|
+
emit(
|
|
5049
|
+
output,
|
|
5050
|
+
mode,
|
|
5051
|
+
() => opts.workflowOnly ? payload.ciWorkflow ?? "" : formatScaffold(payload, writeResult, provisionResult)
|
|
5052
|
+
);
|
|
5053
|
+
} catch (err) {
|
|
5054
|
+
fail(err, mode);
|
|
5055
|
+
}
|
|
5056
|
+
});
|
|
5057
|
+
}
|
|
5058
|
+
function resolvePlatformPortfolio(value) {
|
|
5059
|
+
if (!value) return void 0;
|
|
5060
|
+
const normalized = value.trim().toLowerCase();
|
|
5061
|
+
if (normalized === "platform" || normalized === "personal") {
|
|
5062
|
+
return normalized;
|
|
5063
|
+
}
|
|
5064
|
+
throw new Error("Invalid --portfolio value. Use platform or personal.");
|
|
5065
|
+
}
|
|
5066
|
+
function resolvePlatformSurfaceModel(value) {
|
|
5067
|
+
if (!value) return void 0;
|
|
5068
|
+
const normalized = value.trim().toLowerCase();
|
|
5069
|
+
if (normalized === "web_app" || normalized === "mobile_container" || normalized === "mobile_module" || normalized === "messaging_surface") {
|
|
5070
|
+
return normalized;
|
|
5071
|
+
}
|
|
5072
|
+
throw new Error(
|
|
5073
|
+
"Invalid --surface-model value. Use web_app, mobile_container, mobile_module, or messaging_surface."
|
|
5074
|
+
);
|
|
5075
|
+
}
|
|
5076
|
+
function isPlatformAdminIdentity(identity) {
|
|
5077
|
+
return String(identity?.user?.role || "").trim().toLowerCase() === "super_admin" && String(identity?.organization?.slug || "").trim().toLowerCase() === "admin";
|
|
5078
|
+
}
|
|
5079
|
+
async function requirePlatformAdminSession() {
|
|
5080
|
+
const client = await buildClient();
|
|
5081
|
+
if (!client) {
|
|
5082
|
+
throw new Error(
|
|
5083
|
+
"`topolo platform ...` requires an authenticated Topolo platform-admin session. Run `topolo auth login` first."
|
|
5084
|
+
);
|
|
5085
|
+
}
|
|
5086
|
+
const identity = await client.client.introspect();
|
|
5087
|
+
if (!isPlatformAdminIdentity(identity)) {
|
|
5088
|
+
throw new Error(
|
|
5089
|
+
"`topolo platform ...` is restricted to Topolo platform admins in the Auth `admin` organization."
|
|
5090
|
+
);
|
|
5091
|
+
}
|
|
5092
|
+
}
|
|
5093
|
+
|
|
3196
5094
|
// src/commands/services.ts
|
|
3197
5095
|
function registerServicesCommand(program2) {
|
|
3198
5096
|
program2.command("services").description("List every Topolo service the SDK can route to").option("--json", "Output JSON", true).option("--no-json", "Output human-readable table").action((opts) => {
|
|
@@ -3231,13 +5129,14 @@ function formatRows2(rows) {
|
|
|
3231
5129
|
// src/index.ts
|
|
3232
5130
|
var program = new Command();
|
|
3233
5131
|
program.name("topolo").description(
|
|
3234
|
-
"Topolo
|
|
5132
|
+
"Topolo CLI. Public commands are scoped to the authenticated organization or developer workspace, and first-party Topolo scaffolding lives under the internal `topolo platform` namespace."
|
|
3235
5133
|
).version(CLI_VERSION);
|
|
3236
5134
|
registerAuthCommands(program);
|
|
3237
5135
|
registerWhoamiCommand(program);
|
|
3238
5136
|
registerCrmCommands(program);
|
|
3239
5137
|
registerApiCommand(program);
|
|
3240
5138
|
registerAppsCommand(program);
|
|
5139
|
+
registerPlatformCommand(program);
|
|
3241
5140
|
registerServicesCommand(program);
|
|
3242
5141
|
program.parseAsync(process.argv).catch((err) => {
|
|
3243
5142
|
process.stderr.write(`Unexpected error: ${err instanceof Error ? err.message : String(err)}
|