@hearthlog/tooling 0.0.0-stage → 0.1.1
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/LICENSE +235 -0
- package/bin/hearthlog.mjs +61 -0
- package/package.json +64 -3
- package/src/apply.ts +73 -0
- package/src/check/check-layers.ts +118 -0
- package/src/check/check-modules.ts +198 -0
- package/src/check/check-sdk-exports.ts +79 -0
- package/src/check/check-tenant-config.ts +208 -0
- package/src/cli/hearthlog.ts +212 -0
- package/src/cli/list-sites.ts +15 -0
- package/src/compose/compose-modules.ts +490 -0
- package/src/compose/module-scaffold.ts +195 -0
- package/src/deploy/artifact-compare.ts +93 -0
- package/src/deploy/artifact.ts +127 -0
- package/src/deploy/async-utils.ts +71 -0
- package/src/deploy/backup-tenants.ts +139 -0
- package/src/deploy/cloudflare-access.ts +789 -0
- package/src/deploy/copy-tenant.ts +395 -0
- package/src/deploy/dedicated-ledger.ts +77 -0
- package/src/deploy/deploy-ledger.ts +162 -0
- package/src/deploy/deploy-runner.ts +240 -0
- package/src/deploy/deploy-tenant.ts +170 -0
- package/src/deploy/deployment-security.ts +141 -0
- package/src/deploy/ensure-tenant.ts +988 -0
- package/src/deploy/event-policy.ts +23 -0
- package/src/deploy/frontend-dry-run.ts +18 -0
- package/src/deploy/ledger-credential.ts +48 -0
- package/src/deploy/ledger-scripts.ts +13 -0
- package/src/deploy/split-tenant.ts +295 -0
- package/src/dev/local-development.ts +288 -0
- package/src/dev/provision-local.ts +13 -0
- package/src/generate/build-generated.ts +166 -0
- package/src/generate/generate-brand-icons.ts +128 -0
- package/src/generate/generate-entities.ts +627 -0
- package/src/generate/generate-tenant.ts +161 -0
- package/src/host/core-routes.ts +39 -0
- package/src/host/write-host.ts +119 -0
- package/src/index.ts +8 -0
- package/src/init/check-module.ts +143 -0
- package/src/init/export-tenant.ts +124 -0
- package/src/init/init.ts +168 -0
- package/src/move.ts +189 -0
- package/src/plan.ts +160 -0
- package/src/public-workers/build-gallery-public.ts +114 -0
- package/src/public-workers/calendar-public-config.ts +27 -0
- package/src/public-workers/check-calendar-public-route.ts +41 -0
- package/src/public-workers/check-gallery-public-route.ts +43 -0
- package/src/public-workers/gallery-public-config.ts +22 -0
- package/src/receipt.ts +74 -0
- package/src/root.ts +16 -0
- package/src/security/audit-push.ts +141 -0
- package/src/security/forbidden-identities.ts +107 -0
- package/src/security/forbidden-init.ts +88 -0
- package/src/tenants/host-configs.ts +96 -0
- package/src/tenants/ledger-config.ts +57 -0
- package/src/tenants/lock-file.ts +45 -0
- package/src/tenants/module-lock.ts +58 -0
- package/src/tenants/tenant-config.ts +887 -0
- package/src/tenants/tenant-copy-plan.ts +119 -0
- package/src/tenants/tenant-schema.ts +171 -0
- package/src/tenants/tenant-wrangler.ts +37 -0
- package/src/tenants/wrangler-base.ts +50 -0
- package/src/tenants/wrangler-config.ts +266 -0
- package/src/vite/config.ts +310 -0
- package/src/vite/dev-plugins.ts +338 -0
- package/src/vite/vite-plugin-entity-gen.ts +70 -0
- package/src/vite/vite-plugin-tenant-manifest.ts +61 -0
- package/src/vite/vite-plugin-worker-assets.ts +42 -0
- package/templates/host/app.d.ts.txt +31 -0
- package/templates/host/microlighter.d.ts.txt +11 -0
- package/templates/module/ci.yml.tmpl +44 -0
- package/templates/module/gitignore.tmpl +7 -0
- package/templates/module/gitleaks.toml.tmpl +11 -0
- package/templates/module/release.yml.tmpl +53 -0
- package/templates/tenant/README.md.tmpl +13 -0
- package/templates/tenant/generated-package.json.tmpl +16 -0
- package/templates/tenant/github-workflows/deploy.yml.tmpl +111 -0
- package/templates/tenant/gitignore.tmpl +11 -0
- package/templates/tenant/package.json.tmpl +11 -0
- package/templates/tenant/patches/sqlocal@0.18.0.patch +18 -0
- package/templates/tenant/pnpm-workspace.yaml.tmpl +8 -0
- package/templates/tenant/tenants/__id__/tenant.json.tmpl +8 -0
- package/templates/tenant/tenants.json.tmpl +17 -0
- package/templates/tenant/tsconfig.json.tmpl +21 -0
- package/templates/tenant/vite.config.ts.tmpl +5 -0
- package/README.md +0 -3
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#!/usr/bin/env npx tsx
|
|
2
|
+
/**
|
|
3
|
+
* Tenant Code Generator
|
|
4
|
+
*
|
|
5
|
+
* Resolves one site from tenants.json and emits the runtime module the
|
|
6
|
+
* browser app consumes, plus the tenant-branded app shell. The site is
|
|
7
|
+
* selected with VITE_STOR_TENANT (default: "st").
|
|
8
|
+
*
|
|
9
|
+
* Usage: VITE_STOR_TENANT=bravo pnpm build:generated
|
|
10
|
+
*
|
|
11
|
+
* Generated files, in .generated/sites/<id>/:
|
|
12
|
+
* - tenant.ts (tenant module consumed by the app as $tenant/generated)
|
|
13
|
+
* - app.html (boot shell branded from src/app.template.html)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
17
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
18
|
+
|
|
19
|
+
import { siteDirectory } from "../compose/compose-modules.js";
|
|
20
|
+
import { packageDirectory } from "../host/write-host.js";
|
|
21
|
+
import {
|
|
22
|
+
loadTenantConfig,
|
|
23
|
+
siteById,
|
|
24
|
+
validateCrossSite,
|
|
25
|
+
REPOSITORY_ROOT,
|
|
26
|
+
TenantConfigError,
|
|
27
|
+
} from "../tenants/tenant-config.js";
|
|
28
|
+
import { generateHostConfigs } from "../tenants/host-configs.js";
|
|
29
|
+
import { generateTenantWranglerConfigs } from "../tenants/wrangler-config.js";
|
|
30
|
+
import { brandMarkMaskUrl, wordmarkParts } from "@hearthlog/contracts/tenant/brand";
|
|
31
|
+
import {
|
|
32
|
+
themeVariablesCss,
|
|
33
|
+
themeVariablesFor,
|
|
34
|
+
} from "@hearthlog/contracts/tenant/theme";
|
|
35
|
+
|
|
36
|
+
const APP_TEMPLATE_PATH = join(packageDirectory("@hearthlog/shell"), "src/app.template.html");
|
|
37
|
+
|
|
38
|
+
export interface GenerateTenantOptions {
|
|
39
|
+
outputPath?: string;
|
|
40
|
+
appOutputPath?: string;
|
|
41
|
+
generateWranglerConfigs?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function repositoryPath(path: string): string {
|
|
45
|
+
return isAbsolute(path) ? path : resolve(REPOSITORY_ROOT, path);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function generateTenant(
|
|
49
|
+
siteId: string,
|
|
50
|
+
options: GenerateTenantOptions = {},
|
|
51
|
+
): Promise<{ outputPath: string; appOutputPath: string; wranglerConfigs: string[] }> {
|
|
52
|
+
const outputPath = repositoryPath(
|
|
53
|
+
options.outputPath ?? join(siteDirectory(siteId), "tenant.ts"),
|
|
54
|
+
);
|
|
55
|
+
const appOutputPath = repositoryPath(
|
|
56
|
+
options.appOutputPath ?? join(siteDirectory(siteId), "app.html"),
|
|
57
|
+
);
|
|
58
|
+
const { sites, infrastructure } = await loadTenantConfig(undefined, { only: siteId });
|
|
59
|
+
validateCrossSite(sites);
|
|
60
|
+
const site = siteById(sites, siteId);
|
|
61
|
+
|
|
62
|
+
const content = `/**
|
|
63
|
+
* AUTO-GENERATED by packages/tooling/src/generate/generate-tenant.ts — do not edit.
|
|
64
|
+
*
|
|
65
|
+
* Source: tenants.json (site "${site.id}").
|
|
66
|
+
* Regenerate with: VITE_STOR_TENANT=${site.id} pnpm build:generated
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
export const TENANT_ID = "${site.id}" as const;
|
|
70
|
+
|
|
71
|
+
export const TENANT = {
|
|
72
|
+
id: "${site.id}",
|
|
73
|
+
ledgerAppName: "${site.ledgerAppName}",
|
|
74
|
+
title: ${JSON.stringify(site.title)},
|
|
75
|
+
home: "${site.home}",
|
|
76
|
+
features: ${JSON.stringify(site.features)} as const,
|
|
77
|
+
ai: ${JSON.stringify(site.ai)} as const,
|
|
78
|
+
developerNavVisible: ${site.developerNavVisible},
|
|
79
|
+
entitiesNavVisible: ${site.entitiesNavVisible},
|
|
80
|
+
dynamicEntities: ${site.dynamicEntities},
|
|
81
|
+
theme: ${JSON.stringify(site.theme)} as { accent?: string },
|
|
82
|
+
admin: ${site.admin},
|
|
83
|
+
hostname: ${JSON.stringify(site.hostname)},
|
|
84
|
+
kalApiOrigin: ${JSON.stringify(infrastructure.kalApiOrigin ?? "")}
|
|
85
|
+
} as const;
|
|
86
|
+
`;
|
|
87
|
+
|
|
88
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
89
|
+
await writeFile(outputPath, content, "utf8");
|
|
90
|
+
|
|
91
|
+
// Brand the boot shell so the pre-app skeleton matches the tenant. The
|
|
92
|
+
// template keeps explicit %%BRAND_*%% markers; everything else stays
|
|
93
|
+
// byte-identical across tenants.
|
|
94
|
+
const template = await readFile(APP_TEMPLATE_PATH, "utf8");
|
|
95
|
+
const lightTheme = themeVariablesFor(site.theme.accent, "light");
|
|
96
|
+
const darkTheme = themeVariablesFor(site.theme.accent, "dark");
|
|
97
|
+
const themeStyle = themeVariablesCss(lightTheme);
|
|
98
|
+
const brandable = template
|
|
99
|
+
.replaceAll("%%BRAND_MARK_MASK%%", brandMarkMaskUrl())
|
|
100
|
+
.replaceAll("%%BRAND_WORDMARK%%", wordmarkHtml(site.title))
|
|
101
|
+
.replaceAll("%%BRAND_TITLE%%", escapeHtml(site.title))
|
|
102
|
+
.replaceAll("%%BRAND_ID%%", escapeHtml(site.id))
|
|
103
|
+
.replaceAll("%%BRAND_THEME_COLOR%%", escapeHtml(lightTheme["--stor-theme-color"]))
|
|
104
|
+
.replaceAll("%%BRAND_DARK_THEME%%", escapeHtml(themeVariablesCss(darkTheme)))
|
|
105
|
+
.replaceAll("%%BRAND_THEME%%", escapeHtml(themeStyle));
|
|
106
|
+
if (/%%BRAND_[A-Z_]+%%/.test(brandable)) {
|
|
107
|
+
throw new TenantConfigError(
|
|
108
|
+
"app.template.html still contains unresolved %%BRAND_*%% markers",
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
await mkdir(dirname(appOutputPath), { recursive: true });
|
|
112
|
+
await writeFile(appOutputPath, brandable, "utf8");
|
|
113
|
+
|
|
114
|
+
// The site's Worker config in its own generated folder, and the Workers of its stack (the Ledger and the public
|
|
115
|
+
// calendar and Gallery Workers), from the tenant files.
|
|
116
|
+
const wranglerConfigs = options.generateWranglerConfigs === false
|
|
117
|
+
? []
|
|
118
|
+
: [
|
|
119
|
+
...(await generateTenantWranglerConfigs(site.id)),
|
|
120
|
+
...(await generateHostConfigs(site.deployment ? site.id : "shared")),
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
console.log(
|
|
124
|
+
`Generated tenant module for "${site.id}" (${site.features.length} ` +
|
|
125
|
+
`features, home ${site.home}, accent ${site.theme.accent ?? "default"}) ` +
|
|
126
|
+
`at ${outputPath}`,
|
|
127
|
+
);
|
|
128
|
+
if (wranglerConfigs.length > 0) {
|
|
129
|
+
console.log(
|
|
130
|
+
`Generated ${wranglerConfigs.length} tenant wrangler config(s): ` +
|
|
131
|
+
wranglerConfigs
|
|
132
|
+
.map((path) => path.replace(`${REPOSITORY_ROOT}/`, ""))
|
|
133
|
+
.join(", "),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
return { outputPath, appOutputPath, wranglerConfigs };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (process.env.STOR_SKIP_TENANT_GENERATION !== "1") {
|
|
140
|
+
await generateTenant(process.env.VITE_STOR_TENANT?.trim() || "st", {
|
|
141
|
+
generateWranglerConfigs: process.env.STOR_WRANGLER_CONFIGS !== "0",
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The tenant title with the brand mark in place of its first "s". */
|
|
146
|
+
function wordmarkHtml(title: string): string {
|
|
147
|
+
const parts = wordmarkParts(title);
|
|
148
|
+
if (!parts) return escapeHtml(title);
|
|
149
|
+
return (
|
|
150
|
+
`<span class="stor-wordmark" role="img" aria-label="${escapeHtml(title)}">` +
|
|
151
|
+
`${escapeHtml(parts.before)}<span class="stor-mark"></span>${escapeHtml(parts.after)}</span>`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function escapeHtml(value: string): string {
|
|
156
|
+
return value
|
|
157
|
+
.replaceAll("&", "&")
|
|
158
|
+
.replaceAll("<", "<")
|
|
159
|
+
.replaceAll(">", ">")
|
|
160
|
+
.replaceAll('"', """);
|
|
161
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host's own routes: the thin files every site shares. Each page renders a page of `@hearthlog/shell`,
|
|
3
|
+
* and each loader re-declares the shell's `load` (a plain `export { load } from` makes SvelteKit's static
|
|
4
|
+
* page-option analysis give up and import the module while building) next to the literal page options.
|
|
5
|
+
* `pnpm build:generated` writes them into `.generated/routes/`, beside the composed module routes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface RouteFile {
|
|
9
|
+
/** Relative to the routes directory. */
|
|
10
|
+
path: string;
|
|
11
|
+
content: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const CORE_ROUTE_FILES: readonly RouteFile[] = [
|
|
15
|
+
{ path: "+error.svelte", content: "<script lang=\"ts\">\n import ErrorPage from '@hearthlog/shell/pages/error/ErrorPage.svelte';\n</script>\n\n<ErrorPage />\n" },
|
|
16
|
+
{ path: "+layout.svelte", content: "<script lang=\"ts\">\n import '../app.css';\n import ShellLayout from '@hearthlog/shell/pages/layout/Layout.svelte';\n // The tenant's composed modules: `hearthlog compose` writes this file; the shell does not know them.\n import { attentionContributions, startModules } from './(modules)/startup';\n\n let { data, children } = $props();\n</script>\n\n<ShellLayout {data} {children} startup={{ attentionContributions, startModules }} />\n" },
|
|
17
|
+
{ path: "+layout.ts", content: "import { load as shellLoad } from '@hearthlog/shell/pages/layout/load.js';\n\nexport const load = shellLoad;\n\nexport const ssr = false;\nexport const prerender = false;\n" },
|
|
18
|
+
{ path: "+page.svelte", content: "<p role=\"status\">Opening your last section\u2026</p>\n" },
|
|
19
|
+
{ path: "+page.ts", content: "import { load as shellLoad } from '@hearthlog/shell/pages/redirects/start.js';\n\nexport const load = shellLoad;\n\nexport const ssr = false;\nexport const prerender = false;\n" },
|
|
20
|
+
{ path: "calendar/+page.svelte", content: "<script lang=\"ts\">\n import Page from '@hearthlog/shell/pages/calendar/Page.svelte';\n\n let { data } = $props();\n</script>\n\n<Page {data} />\n" },
|
|
21
|
+
{ path: "calendar/+page.ts", content: "import { load as shellLoad } from '@hearthlog/shell/pages/calendar/load.js';\n\nexport const load = shellLoad;\n" },
|
|
22
|
+
{ path: "developer/+page.svelte", content: "<script lang=\"ts\">\n import Page from '@hearthlog/shell/pages/developer/Page.svelte';\n\n let { data } = $props();\n</script>\n\n<Page {data} />\n" },
|
|
23
|
+
{ path: "developer/+page.ts", content: "import { load as shellLoad } from '@hearthlog/shell/pages/developer/load.js';\n\nexport const load = shellLoad;\n" },
|
|
24
|
+
{ path: "entities/+page.svelte", content: "<script lang=\"ts\">\n import Page from '@hearthlog/shell/pages/entities/Page.svelte';\n\n let { data } = $props();\n</script>\n\n<Page {data} />\n" },
|
|
25
|
+
{ path: "entities/+page.ts", content: "import { load as shellLoad } from '@hearthlog/shell/pages/entities/load.js';\n\nexport const load = shellLoad;\n" },
|
|
26
|
+
{ path: "entities/[type]/+page.svelte", content: "<script lang=\"ts\">\n import Page from '@hearthlog/shell/pages/entity/Page.svelte';\n\n let { data } = $props();\n</script>\n\n<Page {data} />\n" },
|
|
27
|
+
{ path: "entities/[type]/+page.ts", content: "import { load as shellLoad } from '@hearthlog/shell/pages/entity/load.js';\n\nexport const load = shellLoad;\n" },
|
|
28
|
+
{ path: "history/+page.svelte", content: "<svelte:head><title>Opening history</title></svelte:head>\n\n<p class=\"sr-only\" role=\"status\">Opening history in the sidebar\u2026</p>\n" },
|
|
29
|
+
{ path: "history/+page.ts", content: "import { load as shellLoad } from '@hearthlog/shell/pages/redirects/history.js';\n\nexport const load = shellLoad;\n" },
|
|
30
|
+
{ path: "today/+page.ts", content: "import { load as shellLoad } from '@hearthlog/shell/pages/redirects/today.js';\n\nexport const load = shellLoad;\n\nexport const ssr = false;\nexport const prerender = false;\n" },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/** The URL paths the core routes serve; no module may take one. */
|
|
34
|
+
export const CORE_ROUTE_PATHS: readonly string[] = [...new Set(
|
|
35
|
+
CORE_ROUTE_FILES.filter(({ path }) => /(^|\/)\+page\.(svelte|ts)$/.test(path)).map(({ path }) => {
|
|
36
|
+
const directory = path.replace(/\/?\+page\.(svelte|ts)$/, '');
|
|
37
|
+
return `/${directory}`;
|
|
38
|
+
}),
|
|
39
|
+
)].sort();
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env npx tsx
|
|
2
|
+
/**
|
|
3
|
+
* Write the host files every site shares into the gitignored `.generated/`, for the SvelteKit app:
|
|
4
|
+
*
|
|
5
|
+
* routes/ the core routes (a table in this package) and the site's own `src/routes/`;
|
|
6
|
+
* `pnpm build:generated` composes the site's module routes into `routes/(modules)/` afterwards
|
|
7
|
+
* app.css Tailwind, the design system's and the shell's styles, the enabled modules' stylesheets
|
|
8
|
+
* service-worker/ the entry SvelteKit bundles as the service worker (the code is `@hearthlog/shell`)
|
|
9
|
+
* app.d.ts, microlighter.d.ts ambient types
|
|
10
|
+
* tsconfig.json the paths a site's tsconfig extends (`$tenant/generated` is the site's tenant module)
|
|
11
|
+
* static/ the shell's core static files and the site's own `static/`, with only this site's icons
|
|
12
|
+
*
|
|
13
|
+
* Usage: pnpm build:generated (runs this after the catalog is written)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
17
|
+
import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
18
|
+
import { createRequire } from "node:module";
|
|
19
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
|
|
22
|
+
import { hostRoot } from "../root.js";
|
|
23
|
+
import { CORE_ROUTE_FILES } from "./core-routes.js";
|
|
24
|
+
|
|
25
|
+
const ROOT = hostRoot();
|
|
26
|
+
const GENERATED = join(ROOT, ".generated");
|
|
27
|
+
const TEMPLATES = resolve(dirname(fileURLToPath(import.meta.url)), "../../templates/host");
|
|
28
|
+
|
|
29
|
+
/** The directory of an installed package, found from the host so a site resolves its own copy. */
|
|
30
|
+
export function packageDirectory(name: string): string {
|
|
31
|
+
return dirname(createRequire(join(ROOT, "package.json")).resolve(`${name}/package.json`));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function renderAppCss(hasSiteSources: boolean): string {
|
|
35
|
+
return [
|
|
36
|
+
"/* AUTO-GENERATED by packages/tooling/src/host/write-host.ts. Do not edit. */",
|
|
37
|
+
"@import 'tailwindcss' source('./');",
|
|
38
|
+
"@import '@hearthlog/ui/styles/index.css';",
|
|
39
|
+
"@import '@hearthlog/shell/styles/index.css';",
|
|
40
|
+
"/* The enabled modules' stylesheets and Tailwind sources: written beside this file by the composer. */",
|
|
41
|
+
"@import './module-sources.generated.css';",
|
|
42
|
+
// The generated folder is gitignored, which Tailwind's automatic detection honours; name its routes.
|
|
43
|
+
"@source './routes';",
|
|
44
|
+
...(hasSiteSources ? ["@source '../src';"] : []),
|
|
45
|
+
"",
|
|
46
|
+
].join("\n");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function renderServiceWorkerEntry(): string {
|
|
50
|
+
return [
|
|
51
|
+
"// AUTO-GENERATED by packages/tooling/src/host/write-host.ts. Do not edit.",
|
|
52
|
+
"// SvelteKit bundles this reserved entry as the service worker; the code is the shell's.",
|
|
53
|
+
"import '@hearthlog/shell/service-worker/index.js';",
|
|
54
|
+
"",
|
|
55
|
+
].join("\n");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function filesIn(directory: string): string[] {
|
|
59
|
+
if (!existsSync(directory)) return [];
|
|
60
|
+
return readdirSync(directory, { withFileTypes: true }).filter((entry) => entry.name !== ".DS_Store").flatMap((entry) => {
|
|
61
|
+
const path = join(directory, entry.name);
|
|
62
|
+
return entry.isDirectory() ? filesIn(path) : [path];
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function writeRoutes(): Promise<void> {
|
|
67
|
+
const routes = join(GENERATED, "routes");
|
|
68
|
+
await rm(routes, { recursive: true, force: true });
|
|
69
|
+
const written = new Set<string>();
|
|
70
|
+
for (const { path, content } of CORE_ROUTE_FILES) {
|
|
71
|
+
await mkdir(dirname(join(routes, path)), { recursive: true });
|
|
72
|
+
await writeFile(join(routes, path), content, "utf8");
|
|
73
|
+
written.add(path);
|
|
74
|
+
}
|
|
75
|
+
const own = join(ROOT, "src", "routes");
|
|
76
|
+
for (const file of filesIn(own)) {
|
|
77
|
+
const path = relative(own, file).split(sep).join("/");
|
|
78
|
+
if (path.startsWith("(modules)/")) throw new Error(`src/routes/${path}: the (modules) group belongs to the composed module routes`);
|
|
79
|
+
if (written.has(path)) throw new Error(`src/routes/${path} collides with a core route`);
|
|
80
|
+
await mkdir(dirname(join(routes, path)), { recursive: true });
|
|
81
|
+
await cp(file, join(routes, path));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function writeStatic(siteId: string): Promise<void> {
|
|
86
|
+
const target = join(GENERATED, "static");
|
|
87
|
+
await rm(target, { recursive: true, force: true });
|
|
88
|
+
await cp(join(packageDirectory("@hearthlog/shell"), "static"), target, { recursive: true });
|
|
89
|
+
// The host's own `static/` is shared by every site; the site's `tenants/<id>/static/` (its icon set, any other
|
|
90
|
+
// brand file) lands on top of it at the same paths.
|
|
91
|
+
for (const own of [join(ROOT, "static"), join(ROOT, "tenants", siteId, "static")]) {
|
|
92
|
+
for (const file of filesIn(own)) {
|
|
93
|
+
const path = relative(own, file).split(sep).join("/");
|
|
94
|
+
await mkdir(dirname(join(target, path)), { recursive: true });
|
|
95
|
+
await cp(file, join(target, path));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function writeHost(siteId: string): Promise<void> {
|
|
101
|
+
await writeRoutes();
|
|
102
|
+
await writeFile(join(GENERATED, "app.css"), renderAppCss(existsSync(join(ROOT, "src")) && statSync(join(ROOT, "src")).isDirectory()), "utf8");
|
|
103
|
+
await mkdir(join(GENERATED, "service-worker"), { recursive: true });
|
|
104
|
+
await writeFile(join(GENERATED, "service-worker", "index.ts"), renderServiceWorkerEntry(), "utf8");
|
|
105
|
+
await writeFile(join(GENERATED, "service-worker", "tsconfig.json"), `${JSON.stringify({ extends: "$app/tsconfig/service-worker" }, null, "\t")}\n`, "utf8");
|
|
106
|
+
for (const name of ["app.d.ts", "microlighter.d.ts"]) {
|
|
107
|
+
await writeFile(join(GENERATED, name), await readFile(join(TEMPLATES, `${name}.txt`), "utf8"), "utf8");
|
|
108
|
+
}
|
|
109
|
+
await writeFile(join(GENERATED, "tsconfig.json"), `${JSON.stringify({ compilerOptions: { paths: { "$tenant/generated": [`./sites/${siteId}/tenant.ts`] } } }, null, "\t")}\n`, "utf8");
|
|
110
|
+
await writeStatic(siteId);
|
|
111
|
+
console.log(`Wrote the host files for ${siteId}.`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
115
|
+
writeHost(process.env.VITE_STOR_TENANT?.trim() || "st").catch((error: unknown) => {
|
|
116
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
117
|
+
process.exitCode = 1;
|
|
118
|
+
});
|
|
119
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { planDeployment, isolationProblems, renderPlan } from './plan.js';
|
|
2
|
+
export type { DeploymentPlan, PlanInput, PlanModule, PlannedResource, PlannedStep, StepId, TenantResources } from './plan.js';
|
|
3
|
+
export { newReceipt, parseReceipt, receiptProblems, recordStep, serializeReceipt, stepDone } from './receipt.js';
|
|
4
|
+
export type { Receipt } from './receipt.js';
|
|
5
|
+
export { ApplyError, runApply } from './apply.js';
|
|
6
|
+
export type { ApplyOptions, ApplyResult, StepExecutor } from './apply.js';
|
|
7
|
+
export { MoveError, moveApplication, sameChecksums } from './move.js';
|
|
8
|
+
export type { AdminCall, LogChecksum, MoveOptions, MoveReport } from './move.js';
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env npx tsx
|
|
2
|
+
/**
|
|
3
|
+
* `hearthlog check-module [<directory>] [--work <dir>] [--keep] [--overrides <file.json>]`
|
|
4
|
+
*
|
|
5
|
+
* Proves that a module repository composes into a site and passes its tests: it generates a throwaway site that
|
|
6
|
+
* enables the module (the starter `hearthlog init` writes, the module installed from the directory), installs it
|
|
7
|
+
* against the platform range, and builds it, which runs the composer, the boundary checks and the Ledger contract
|
|
8
|
+
* generation. Then it runs the module's own unit tests (`vitest`, from the module's directory) with the site's generated
|
|
9
|
+
* files standing in for `@hearthlog/generated`, which the platform packages import and only a site can write.
|
|
10
|
+
* `--overrides` is a JSON object of package name to `file:` tarball, used to run against a platform that is not
|
|
11
|
+
* published yet.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { spawnSync } from "node:child_process";
|
|
15
|
+
import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { createRequire } from "node:module";
|
|
18
|
+
import { dirname, join, resolve } from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
|
|
21
|
+
import { loadModuleAt } from "../compose/compose-modules.js";
|
|
22
|
+
import { renderSite } from "./init.js";
|
|
23
|
+
|
|
24
|
+
export interface CheckModuleOptions {
|
|
25
|
+
directory: string;
|
|
26
|
+
work?: string;
|
|
27
|
+
keep?: boolean;
|
|
28
|
+
overrides?: Record<string, string>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function run(command: string, args: string[], cwd: string): void {
|
|
32
|
+
const result = spawnSync(command, args, { cwd, stdio: "inherit" });
|
|
33
|
+
if (result.status !== 0) throw new Error(`${command} ${args.join(" ")} failed in ${cwd}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Writes the site into `work` and returns the module's package name. */
|
|
37
|
+
export function assembleCheckSite(options: Required<Pick<CheckModuleOptions, "directory" | "work">> & Pick<CheckModuleOptions, "overrides">): string {
|
|
38
|
+
const directory = resolve(options.directory);
|
|
39
|
+
const loaded = loadModuleAt(directory);
|
|
40
|
+
const name = loaded.manifest.package;
|
|
41
|
+
for (const [path, content] of renderSite({ directory: options.work, id: "st", modules: [name], available: [loaded] })) {
|
|
42
|
+
const target = join(options.work, path);
|
|
43
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
44
|
+
writeFileSync(target, content, "utf8");
|
|
45
|
+
}
|
|
46
|
+
const manifestPath = join(options.work, "package.json");
|
|
47
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { dependencies: Record<string, string> };
|
|
48
|
+
manifest.dependencies[name] = `file:${directory}`;
|
|
49
|
+
const overrides = options.overrides ?? {};
|
|
50
|
+
// A peer the module needs is the site's to provide; without a published platform it comes from the tarball.
|
|
51
|
+
const peers = Object.keys((JSON.parse(readFileSync(join(directory, "package.json"), "utf8")) as { peerDependencies?: Record<string, string> }).peerDependencies ?? {});
|
|
52
|
+
for (const [dependency, value] of Object.entries(overrides)) {
|
|
53
|
+
if (dependency !== name && (dependency in manifest.dependencies || peers.includes(dependency))) manifest.dependencies[dependency] = value;
|
|
54
|
+
}
|
|
55
|
+
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
56
|
+
if (Object.keys(overrides).length > 0) {
|
|
57
|
+
// pnpm 10 reads overrides from pnpm-workspace.yaml, not from package.json.
|
|
58
|
+
appendFileSync(join(options.work, "pnpm-workspace.yaml"), `\noverrides:\n${Object.entries(overrides).map(([dependency, value]) => ` '${dependency}': '${value}'`).join("\n")}\n`);
|
|
59
|
+
}
|
|
60
|
+
return name;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** What the SvelteKit modules the platform imports (`$app/*`) give a unit test: nothing that runs in a browser. */
|
|
64
|
+
const APP_STUB = `const noop = () => {};
|
|
65
|
+
export const goto = async () => {}, invalidate = async () => {}, invalidateAll = async () => {}, preloadData = async () => ({}), preloadCode = async () => {};
|
|
66
|
+
export const afterNavigate = noop, beforeNavigate = noop, onNavigate = noop, pushState = noop, replaceState = noop, disableScrollHandling = noop;
|
|
67
|
+
export const browser = false, dev = false, building = false, version = "test";
|
|
68
|
+
export const page = { url: new URL("http://localhost/"), params: {}, route: { id: null }, status: 200, error: null, data: {}, state: {}, form: null };
|
|
69
|
+
export const navigating = { current: null };
|
|
70
|
+
export const base = "", assets = "", resolve = (path) => path, resolveRoute = (path) => path;
|
|
71
|
+
`;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Vitest's configuration for a module's tests, run from the site so that its generated files are the ones used: the
|
|
75
|
+
* module's directory is the root, the platform source is transformed here (it ships as TypeScript), `$tenant/generated`
|
|
76
|
+
* is the site's tenant module, `$app/*` is a stub, and `cloudflare:workers` is the platform's stub. The platform
|
|
77
|
+
* packages import `@hearthlog/generated` through their own `imports` map, which no alias reaches, so the site's
|
|
78
|
+
* generated package is linked into the module's `node_modules` (see `linkGenerated`).
|
|
79
|
+
*/
|
|
80
|
+
export function testConfig(directory: string, work: string): string {
|
|
81
|
+
const alias: Array<{ find: string; replacement: string }> = [
|
|
82
|
+
{ find: "$tenant/generated", replacement: join(work, ".generated", "sites", "st", "tenant.ts") },
|
|
83
|
+
{ find: /^\$app\/.*/ as unknown as string, replacement: join(work, "app-stub.mjs") },
|
|
84
|
+
];
|
|
85
|
+
const stub = join(work, "node_modules", "@hearthlog", "cloudflare", "src", "testing", "cloudflare-workers.ts");
|
|
86
|
+
if (existsSync(stub)) alias.push({ find: "cloudflare:workers", replacement: stub });
|
|
87
|
+
const options = JSON.stringify({ root: directory, resolve: { alias } }, null, 2).replace('"find": {}', '"find": /^\\$app\\/.*/');
|
|
88
|
+
// The platform's `.svelte.ts` files use runes, which need the Svelte compiler.
|
|
89
|
+
return `import { svelte } from "@sveltejs/vite-plugin-svelte";\nconst options = ${options};\noptions.plugins = [svelte()];\noptions.test = { include: ["src/**/*.test.ts"], passWithNoTests: true, server: { deps: { inline: [/@hearthlog\\//] } } };\nexport default options;\n`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Links the site's generated package where the platform packages, installed under the module, look for it; returns the link. */
|
|
93
|
+
function linkGenerated(directory: string, work: string): string {
|
|
94
|
+
const scope = join(directory, "node_modules", "@hearthlog");
|
|
95
|
+
mkdirSync(scope, { recursive: true });
|
|
96
|
+
const link = join(scope, "generated");
|
|
97
|
+
rmSync(link, { recursive: true, force: true });
|
|
98
|
+
symlinkSync(join(work, ".generated"), link, "dir");
|
|
99
|
+
return link;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function checkModule(options: CheckModuleOptions): void {
|
|
103
|
+
const work = options.work ? resolve(options.work) : mkdtempSync(join(tmpdir(), "hearthlog-check-module-"));
|
|
104
|
+
try {
|
|
105
|
+
const name = assembleCheckSite({ directory: options.directory, work, overrides: options.overrides });
|
|
106
|
+
console.log(`Checking ${name} in a throwaway site at ${work}`);
|
|
107
|
+
run("pnpm", ["install", "--no-frozen-lockfile"], work);
|
|
108
|
+
run("pnpm", ["build"], work);
|
|
109
|
+
console.log(`${name} composes into a site and builds.`);
|
|
110
|
+
const config = join(work, "vitest.module.config.mjs");
|
|
111
|
+
writeFileSync(config, testConfig(resolve(options.directory), work));
|
|
112
|
+
writeFileSync(join(work, "app-stub.mjs"), APP_STUB);
|
|
113
|
+
const link = linkGenerated(resolve(options.directory), work);
|
|
114
|
+
try {
|
|
115
|
+
// Run from the site (its Vite configuration resolves relative to it) with the module's own vitest.
|
|
116
|
+
const vitest = join(dirname(createRequire(join(resolve(options.directory), "package.json")).resolve("vitest/package.json")), "vitest.mjs");
|
|
117
|
+
run(process.execPath, [vitest, "run", "--config", config], work);
|
|
118
|
+
} finally {
|
|
119
|
+
rmSync(link, { force: true });
|
|
120
|
+
}
|
|
121
|
+
console.log(`${name} passes its unit tests.`);
|
|
122
|
+
} finally {
|
|
123
|
+
if (!options.keep) rmSync(work, { recursive: true, force: true });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
128
|
+
try {
|
|
129
|
+
const argv = process.argv.slice(2);
|
|
130
|
+
const value = (flag: string) => (argv.includes(flag) ? argv[argv.indexOf(flag) + 1] : undefined);
|
|
131
|
+
const directory = argv.find((argument, index) => !argument.startsWith("--") && !["--work", "--overrides"].includes(argv[index - 1] ?? "")) ?? ".";
|
|
132
|
+
const overridesFile = value("--overrides");
|
|
133
|
+
checkModule({
|
|
134
|
+
directory,
|
|
135
|
+
work: value("--work"),
|
|
136
|
+
keep: argv.includes("--keep"),
|
|
137
|
+
overrides: overridesFile ? (JSON.parse(readFileSync(resolve(overridesFile), "utf8")) as Record<string, string>) : undefined,
|
|
138
|
+
});
|
|
139
|
+
} catch (error) {
|
|
140
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
141
|
+
process.exitCode = 1;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env npx tsx
|
|
2
|
+
/**
|
|
3
|
+
* `hearthlog export <id> --out <dir>` (root script `pnpm tenant:export -- <id> --out <dir>`)
|
|
4
|
+
*
|
|
5
|
+
* Write the repository of one tenant that owns its stack: what `hearthlog init` renders (package.json, Vite and
|
|
6
|
+
* TypeScript configuration, pnpm settings, workflow, `.gitignore`) plus the tenant's own `tenants/<id>/`. The shared
|
|
7
|
+
* account values the site read from this repository's `tenants.json` are written into the exported `deployment.json`
|
|
8
|
+
* (an own-stack site needs no `tenants.json`, and the shared block names other tenants' resources). Nothing from
|
|
9
|
+
* `.wrangler/` is copied: no token files, no receipts, no device settings. Like `module:export`, this creates no
|
|
10
|
+
* repository and touches no remote.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
14
|
+
import { cp, mkdir, writeFile } from "node:fs/promises";
|
|
15
|
+
import { dirname, join, resolve } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
|
|
18
|
+
import { loadModules, type LoadedModule } from "../compose/compose-modules.js";
|
|
19
|
+
import { hostRoot } from "../root.js";
|
|
20
|
+
import { renderSite } from "./init.js";
|
|
21
|
+
|
|
22
|
+
export interface ExportOptions {
|
|
23
|
+
/** The host directory (it holds `tenants/<id>/` and, for a shared block, `tenants.json`). */
|
|
24
|
+
root: string;
|
|
25
|
+
id: string;
|
|
26
|
+
out: string;
|
|
27
|
+
available?: readonly LoadedModule[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const readJson = (path: string): Record<string, unknown> => JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
|
31
|
+
|
|
32
|
+
/** The deployment file with the shared block's account values filled in where the site has none of its own. */
|
|
33
|
+
export function exportedDeployment(deployment: Record<string, unknown>, shared: Record<string, unknown> | undefined): Record<string, unknown> {
|
|
34
|
+
const result: Record<string, unknown> = { ...deployment };
|
|
35
|
+
const take = (key: string, value: unknown) => {
|
|
36
|
+
if (result[key] === undefined && value !== undefined) result[key] = value;
|
|
37
|
+
};
|
|
38
|
+
take("workersDevDomain", shared?.workersDevDomain);
|
|
39
|
+
if (typeof shared?.accessTeamDomain === "string") take("authentication", { provider: "cloudflare-access", teamDomain: shared.accessTeamDomain });
|
|
40
|
+
for (const key of ["ledgerAdminEmail", "invoiceRendererOrigin", "kalApiOrigin", "accessSessionDuration", "accessGlobalSessionDuration"]) take(key, shared?.[key]);
|
|
41
|
+
for (const key of ["workersDevDomain", "authentication", "ledgerAdminEmail"]) {
|
|
42
|
+
if (result[key] === undefined) throw new Error(`tenants/<id>/deployment.json has no ${key} and the shared block has none to give`);
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The site's definition with the shared `defaults` it relied on written in, so the repository needs no `tenants.json`. */
|
|
48
|
+
export function exportedTenant(tenant: Record<string, unknown>, defaults: Record<string, unknown> | undefined): Record<string, unknown> {
|
|
49
|
+
const result: Record<string, unknown> = { ...tenant };
|
|
50
|
+
for (const [key, value] of Object.entries(defaults ?? {})) {
|
|
51
|
+
if (result[key] === undefined) result[key] = value;
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readme(title: string, id: string): string {
|
|
57
|
+
return `# ${title}
|
|
58
|
+
|
|
59
|
+
The Hearthlog site \`${id}\`, on its own stack. \`tenants/${id}/\` holds what it enables and the stack it owns
|
|
60
|
+
(\`tenant.json\`, \`deployment.json\`, \`modules.lock.json\`, \`static/\`); everything else the app needs is written by
|
|
61
|
+
\`pnpm build:generated\` into \`.generated/\`.
|
|
62
|
+
|
|
63
|
+
\`\`\`bash
|
|
64
|
+
pnpm install
|
|
65
|
+
pnpm dev # builds the generated files, then starts Vite
|
|
66
|
+
pnpm build # a static build in build/
|
|
67
|
+
pnpm hearthlog plan --site ${id} # read-only: resources, steps, problems
|
|
68
|
+
\`\`\`
|
|
69
|
+
|
|
70
|
+
Deploys run from \`.github/workflows/deploy.yml\` in the protected \`${id}\` environment. To run \`hearthlog\` against the
|
|
71
|
+
stack from this machine (plan with the real account, \`apply\`), copy the site's \`.wrangler/tenants/${id}/\` token files
|
|
72
|
+
into this checkout's \`.wrangler/\`; they are never exported or committed.
|
|
73
|
+
`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function exportTenant(options: ExportOptions): Promise<string[]> {
|
|
77
|
+
const { root, id } = options;
|
|
78
|
+
const out = resolve(options.out);
|
|
79
|
+
const tenantDirectory = join(root, "tenants", id);
|
|
80
|
+
if (!existsSync(join(tenantDirectory, "tenant.json"))) throw new Error(`There is no site "${id}" in ${root}`);
|
|
81
|
+
if (!existsSync(join(tenantDirectory, "deployment.json"))) throw new Error(`${id} is on the shared stack (no deployment.json): move it to its own stack first`);
|
|
82
|
+
if (existsSync(out) && readdirSync(out).length > 0) throw new Error(`${out} is not empty`);
|
|
83
|
+
|
|
84
|
+
const sharedFile = join(root, "tenants.json");
|
|
85
|
+
const sharedBlock = existsSync(sharedFile) ? readJson(sharedFile) : {};
|
|
86
|
+
const shared = sharedBlock.infrastructure as Record<string, unknown> | undefined;
|
|
87
|
+
const deployment = exportedDeployment(readJson(join(tenantDirectory, "deployment.json")), shared);
|
|
88
|
+
const tenant = exportedTenant(readJson(join(tenantDirectory, "tenant.json")), sharedBlock.defaults as Record<string, unknown> | undefined);
|
|
89
|
+
const title = typeof tenant.title === "string" ? tenant.title : id;
|
|
90
|
+
const lock = readJson(join(tenantDirectory, "modules.lock.json")) as { modules: Array<{ package: string }> };
|
|
91
|
+
|
|
92
|
+
const written: string[] = [];
|
|
93
|
+
const put = async (path: string, content: string) => {
|
|
94
|
+
const target = join(out, path);
|
|
95
|
+
await mkdir(dirname(target), { recursive: true });
|
|
96
|
+
await writeFile(target, content, "utf8");
|
|
97
|
+
written.push(path);
|
|
98
|
+
};
|
|
99
|
+
for (const [path, content] of renderSite({ directory: out, id, title, modules: lock.modules.map((module) => module.package), available: options.available ?? loadModules() })) {
|
|
100
|
+
// The tenant's own files replace the starter's, and an own-stack site needs no tenants.json.
|
|
101
|
+
if (path === "tenants.json" || path.startsWith(`tenants/${id}/`)) continue;
|
|
102
|
+
await put(path, path === "README.md" ? readme(title, id) : content);
|
|
103
|
+
}
|
|
104
|
+
await mkdir(join(out, "tenants", id), { recursive: true });
|
|
105
|
+
await cp(tenantDirectory, join(out, "tenants", id), { recursive: true, filter: (source) => !/(deployment|tenant)\.json$|\.DS_Store$/.test(source) });
|
|
106
|
+
await put(`tenants/${id}/tenant.json`, `${JSON.stringify(tenant, null, 2)}\n`);
|
|
107
|
+
await put(`tenants/${id}/deployment.json`, `${JSON.stringify(deployment, null, 2)}\n`);
|
|
108
|
+
return written;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
112
|
+
try {
|
|
113
|
+
const argv = process.argv.slice(2);
|
|
114
|
+
const flag = argv.indexOf("--out");
|
|
115
|
+
const out = flag >= 0 ? argv[flag + 1] : undefined;
|
|
116
|
+
const id = argv.find((argument, index) => !argument.startsWith("--") && index !== flag + 1);
|
|
117
|
+
if (!id || !out) throw new Error("Usage: hearthlog export <id> --out <dir>");
|
|
118
|
+
const written = await exportTenant({ root: hostRoot(), id, out });
|
|
119
|
+
console.log(`Wrote the repository of ${id} to ${resolve(out)} (${written.length} files plus tenants/${id}/). Next: cd there, pnpm install, pnpm build.`);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
122
|
+
process.exitCode = 1;
|
|
123
|
+
}
|
|
124
|
+
}
|