diff --git a/README.md b/README.md index db5453e..16416d6 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,20 @@ that an LLM can read, diff, and safely modify without breaking the runtime. | [`packages/expense/`](./packages/expense) | Employee expense & reimbursement β€” multi-line reports, category policy, amount-tiered approval, reimbursement tracking | βœ… v0 | 4011 | [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/objectstack-ai/templates/tree/main/packages/expense) | | `packages/sales-pipeline/` | Lite CRM (leads, opportunities, accounts) | 🚧 planned | 4007 | β€” | +### Run them all at once + +Want every template in **one runtime, one app launcher** β€” as if you installed +them all together from the marketplace? Use the `all` aggregator: + +```bash +pnpm dev:all # builds every template, composes them, boots on :4000 +``` + +It compiles each template's artifact into a single **environment** bundle and +serves it (admin: `admin@objectos.ai` / `admin123`). See +[`packages/all/`](./packages/all) for how composition happens at the +environment layer without breaking the one-app-per-package rule (ADR-0019). + > **StackBlitz tip:** every template runs in the browser via `@objectstack/driver-sqlite-wasm` (sql.js). Local dev still uses `better-sqlite3` (listed as an optional dependency) for full native speed. > Looking for a full reference app? See [hotcrm](https://github.com/objectstack-ai/hotcrm) β€” the production-grade CRM built on the same engine. diff --git a/package.json b/package.json index 4a4a56e..41c1819 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "scripts": { "dev": "pnpm --filter @objectlab/todo dev", + "dev:all": "pnpm -r --filter=!@objectlab/all build && pnpm --filter @objectlab/all dev", "typecheck": "pnpm -r typecheck", "build": "pnpm -r build", "test": "pnpm -r test", diff --git a/packages/all/README.md b/packages/all/README.md new file mode 100644 index 0000000..5d1c89e --- /dev/null +++ b/packages/all/README.md @@ -0,0 +1,96 @@ +# `@objectlab/all` β€” install everything from the marketplace + +A throwaway **workspace** that runs *every* template in this repo in **one +runtime**, behind **one app launcher** β€” as if you had installed all of them +together from the marketplace. + +It is not a template. It ships no objects of its own. It is an +**environment compiler**: it gathers the compiled artifact of each template and +composes them into a single environment bundle the runtime serves verbatim. + +```bash +# from the repo root β€” build every template first +pnpm -r build + +# then, from this package +pnpm --filter @objectlab/all dev # compile + boot all apps on :4000 +``` + +Open and you'll find all nine apps +(`todo`, `compliance`, `content`, `contracts`, `expense`, `helpdesk`, `hr`, +`procurement`, `pm`) side-by-side, each with its own namespace and seed data. + +Seeded admin: `admin@objectos.ai` / `admin123`. + +## How it works + +This piggy-backs on the runtime's **real** local-install mechanism. When you +install an App from the marketplace, `MarketplaceInstallLocalPlugin` writes it to +`.objectstack/installed-packages/.json` (a wrapper +`{ packageId, manifestId, version, manifest, … }` whose `manifest` is the App's +full compiled artifact) and rehydrates it on boot. The compile command reads +that same folder and composes everything into one static environment artifact. + +``` +packages/*/dist/objectstack.json ← each template, compiled & self-contained + β”‚ (1) install β†’ wrapper entry, runtime's exact format + β–Ό +.objectstack/installed-packages/.json ← the REAL local-install folder + β”‚ (2) compile (scripts/compile-marketplace.mjs) + β–Ό +dist/objectstack.json ← ONE environment artifact: apps[9] + β”‚ (3) serve + β–Ό +objectstack dev all --artifact dist/objectstack.json +``` + +1. **install** β€” populate `.objectstack/installed-packages/` from the workspace + templates, in the runtime's exact wrapper format, so the folder is + indistinguishable from a real marketplace install. Already-present entries are + left untouched β€” if you installed an App via the marketplace UI (or dropped a + compiled artifact into the folder), it's picked up as-is. +2. **compile** β€” unwrap each installed entry to its artifact, concatenate all + metadata (objects, apps, views, flows, hooks, data…), de-duplicate + environment-level singletons (`roles` / `permissions` by name, `requires` as a + set), and synthesize one environment manifest with `apps[N]`. +3. **serve** β€” `objectstack dev all --artifact …` loads the JSON directly and + boots a single runtime hosting every app. + +Scripts: + +| Command | Does | +|---|---| +| `pnpm compile` | (re)build `dist/objectstack.json` from the installed store | +| `pnpm dev` | compile, then boot on `:4000` with a fresh ephemeral DB + seeded admin | +| `pnpm start` | boot the already-compiled artifact (no recompile, persistent DB) | + +## Why this does not break ADR-0019 ("one app per package") + +ADR-0019 forbids an **authored package** (`type: 'app'`) from defining more than +one app β€” the banned "suite contains apps" shape β€” and `defineStack()` enforces +it via `validateSingleApp`. + +That rule governs **authoring a package**. It does **not** govern the +**environment** a tenant runs: an environment legitimately hosts many +independently-installed apps, each keeping its own namespace (the runtime keys +namespaces as `Map>`). The cloud control plane does +exactly this when it compiles a tenant environment from its installed packages. + +So we compose at the **environment layer** and emit the merged artifact +directly. We never wrap the result in `defineStack()`; the CLI's `compile`/serve +path validates with `ObjectStackDefinitionSchema` (schema only), not the +`defineStack` wrapper β€” so the single-app and namespace-prefix gates correctly +do not apply. This is the same reason `composeStacks()` exists in +`@objectstack/spec`: composition is an environment-assembly primitive, not a +package-authoring one. + +## Caveats + +- **Generic role/permission names collide.** `todo` and `content` both ship a + `lead` / `contributor` role and permission; the compiler keeps the first and + shadows the rest (logged on compile). Fine for a "see everything" dev + environment; for production isolation each app would namespace its roles. +- **Throwaway, not a product.** This package is for local exploration, demos, + and cross-template QA β€” not something you publish to the marketplace. +- Rebuild a template (`pnpm --filter @objectlab/ build`) then + `pnpm --filter @objectlab/all compile` to pick up its changes. diff --git a/packages/all/package.json b/packages/all/package.json new file mode 100644 index 0000000..ec5db48 --- /dev/null +++ b/packages/all/package.json @@ -0,0 +1,27 @@ +{ + "name": "@objectlab/all", + "version": "0.1.0", + "description": "Install-everything workspace β€” composes every ObjectStack template into one runtime, as if installed together from the marketplace.", + "license": "Apache-2.0", + "private": true, + "type": "module", + "scripts": { + "compile": "node scripts/compile-marketplace.mjs", + "dev": "pnpm run compile && objectstack dev all --artifact dist/objectstack.json -p 4000 --fresh", + "start": "objectstack dev all --artifact dist/objectstack.json -p 4000" + }, + "dependencies": { + "@objectstack/account": "^7.4.1", + "@objectstack/cli": "^7.4.1", + "@objectstack/driver-sqlite-wasm": "^7.4.1", + "@objectstack/runtime": "^7.4.1", + "sql.js": "^1.14.1" + }, + "optionalDependencies": { + "better-sqlite3": "^12.10.0" + }, + "engines": { + "node": ">=20" + }, + "packageManager": "pnpm@10.33.0" +} diff --git a/packages/all/scripts/compile-marketplace.mjs b/packages/all/scripts/compile-marketplace.mjs new file mode 100644 index 0000000..9e7c3d8 --- /dev/null +++ b/packages/all/scripts/compile-marketplace.mjs @@ -0,0 +1,224 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// Marketplace environment compiler β€” compile whatever is installed locally. +// +// When you install Apps from the marketplace, the runtime's +// `MarketplaceInstallLocalPlugin` persists each one to disk: +// +// /.objectstack/installed-packages/.json +// +// …as a wrapper entry `{ packageId, manifestId, version, manifest, … }` where +// `manifest` is the App's full compiled artifact. The runtime rehydrates these +// at boot. This command does the offline equivalent: it reads that same folder +// and composes every installed App into ONE environment artifact +// (`dist/objectstack.json`) that `objectstack dev all --artifact …` serves as a +// single runtime hosting every app β€” "install everything from the marketplace". +// +// Two verbs, both idempotent: +// β€’ install β€” populate `.objectstack/installed-packages/` from the workspace +// templates, in the EXACT wrapper format the runtime writes, so +// the folder is indistinguishable from a real local install. +// (Skipped automatically if the folder is already populated β€” +// e.g. you installed via the marketplace UI.) +// β€’ compile β€” read every installed entry and compose β†’ dist/objectstack.json +// +// WHY THIS IS NOT A PROTOCOL VIOLATION (ADR-0019) +// ----------------------------------------------- +// ADR-0019 bans an *authored package* (`type:'app'`) from defining more than one +// app (the "suite contains apps" shape), enforced by `defineStack`'s +// `validateSingleApp`. That rule governs *authoring a package*. It does NOT +// govern the *environment* a tenant runs: an environment legitimately hosts many +// independently-installed Apps, each keeping its own namespace (the runtime keys +// namespaces as `Map>`) β€” which is exactly what the +// rehydrate-at-boot path above already does. We compose at the ENVIRONMENT layer +// and emit the merged artifact directly; we never wrap it in `defineStack()`, +// and the CLI's serve path validates with `ObjectStackDefinitionSchema` (schema +// only), so the single-app / namespace-prefix gates correctly do not apply. + +import { + readFileSync, + writeFileSync, + mkdirSync, + readdirSync, + existsSync, +} from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ALL_DIR = resolve(HERE, '..'); // packages/all (the runtime cwd for `dev all`) +const PACKAGES_DIR = resolve(ALL_DIR, '..'); // packages + +// The real on-disk location the runtime's MarketplaceInstallLocalPlugin uses: +// storageDir = resolve(process.cwd(), '.objectstack/installed-packages') +const INSTALLED_DIR = join(ALL_DIR, '.objectstack', 'installed-packages'); +const OUT = join(ALL_DIR, 'dist', 'objectstack.json'); + +// Mirror MarketplaceInstallLocalPlugin.safeFilename so files we write are +// byte-identical to a real local install. +const safeFilename = (manifestId) => `${manifestId.replace(/[^a-zA-Z0-9._-]/g, '_')}.json`; + +// Arrays concatenated at the environment layer (mirrors `composeStacks`' +// CONCAT_ARRAY_FIELDS, minus the singletons we de-dupe below). +const CONCAT_FIELDS = [ + 'translations', 'objectExtensions', 'objects', 'apps', 'views', 'pages', + 'dashboards', 'reports', 'actions', 'themes', 'flows', 'jobs', + 'emailTemplates', 'sharingRules', 'policies', 'apis', 'webhooks', 'agents', + 'skills', 'hooks', 'mappings', 'analyticsCubes', 'connectors', 'datasources', + 'portals', 'data', +]; + +// Environment-level singletons keyed by `name`. Two installed apps can each ship +// a generic "lead"/"contributor" role/permission β€” keep the first, shadow the +// rest, so the runtime never double-registers. +const DEDUP_BY_NAME = ['roles', 'permissions']; + +const log = (msg) => process.stdout.write(`${msg}\n`); + +/** + * `install` β€” populate `.objectstack/installed-packages/` from the workspace + * templates, in the runtime's wrapper format. No-op for templates already + * present (so a real marketplace install is never clobbered). + */ +function installFromWorkspace() { + mkdirSync(INSTALLED_DIR, { recursive: true }); + const installed = []; + for (const name of readdirSync(PACKAGES_DIR).sort()) { + if (name === 'all') continue; + const artifactPath = join(PACKAGES_DIR, name, 'dist', 'objectstack.json'); + if (!existsSync(artifactPath)) { + log(` Β· skip ${name} (no dist/objectstack.json β€” run \`pnpm -r build\` first)`); + continue; + } + const artifact = JSON.parse(readFileSync(artifactPath, 'utf8')); + const manifestId = artifact?.manifest?.id ?? `app.objectstack.template.${name}`; + const dest = join(INSTALLED_DIR, safeFilename(manifestId)); + if (existsSync(dest)) { + installed.push(`${name} (already installed)`); + continue; + } + const entry = { + packageId: manifestId, + versionId: artifact?.manifest?.version ?? '0.0.0', + manifestId, + version: artifact?.manifest?.version ?? '0.0.0', + manifest: artifact, // the App's full compiled artifact + installedAt: '1970-01-01T00:00:00.000Z', // fixed β†’ deterministic compile + installedBy: 'compile-marketplace', + withSampleData: false, + }; + writeFileSync(dest, `${JSON.stringify(entry, null, 2)}\n`, 'utf8'); + installed.push(name); + } + return installed; +} + +/** Read every installed entry; unwrap to the App's full artifact. */ +function readInstalledArtifacts() { + if (!existsSync(INSTALLED_DIR)) return []; + const out = []; + for (const file of readdirSync(INSTALLED_DIR).filter((f) => f.endsWith('.json')).sort()) { + let entry; + try { + entry = JSON.parse(readFileSync(join(INSTALLED_DIR, file), 'utf8')); + } catch { + log(` ! ${file}: invalid JSON, skipped`); + continue; + } + // Accept either the runtime wrapper ({ manifest: }) or a bare + // artifact dropped straight into the folder. + const artifact = entry?.manifest?.objects || entry?.manifest?.apps ? entry.manifest : entry; + const source = artifact?.manifest?.namespace || entry?.manifestId || file.replace(/\.json$/, ''); + out.push({ source, artifact }); + } + return out; +} + +/** `compile` β€” compose installed Apps into one environment artifact. */ +function compile(store) { + const env = { + manifest: { + id: 'app.objectstack.environment.all', + name: 'All Templates', + version: '0.1.0', + type: 'app', + description: + 'Local environment with every installed App composed together β€” the "install everything from the marketplace" workspace.', + }, + }; + + const requires = new Set(); + const supportedLocales = new Set(); + let defaultLocale = 'en'; + const dedup = Object.fromEntries(DEDUP_BY_NAME.map((k) => [k, new Map()])); + const objectOwner = new Map(); + + for (const { artifact, source } of store) { + for (const obj of artifact.objects ?? []) { + if (objectOwner.has(obj.name)) { + throw new Error( + `object '${obj.name}' is defined by both '${objectOwner.get(obj.name)}' and '${source}'. ` + + `Apps must prefix every object with their namespace (see TEMPLATE_GUIDE.md).`, + ); + } + objectOwner.set(obj.name, source); + } + + for (const field of CONCAT_FIELDS) { + const value = artifact[field]; + if (Array.isArray(value) && value.length > 0) (env[field] ??= []).push(...value); + } + + for (const field of DEDUP_BY_NAME) { + for (const item of artifact[field] ?? []) { + if (!dedup[field].has(item.name)) dedup[field].set(item.name, item); + else log(` ! ${field.slice(0, -1)} '${item.name}' from '${source}' shadowed (already installed)`); + } + } + + for (const token of artifact.requires ?? []) requires.add(token); + + if (artifact.i18n) { + if (artifact.i18n.defaultLocale) defaultLocale = artifact.i18n.defaultLocale; + for (const locale of artifact.i18n.supportedLocales ?? []) supportedLocales.add(locale); + } + } + + for (const field of DEDUP_BY_NAME) { + const items = [...dedup[field].values()]; + if (items.length > 0) env[field] = items; + } + if (requires.size > 0) env.requires = [...requires]; + + supportedLocales.add(defaultLocale); + env.i18n = { defaultLocale, supportedLocales: [...supportedLocales], fallbackLocale: defaultLocale }; + + return env; +} + +// ── main ──────────────────────────────────────────────────────────── +log('β–Ά Compiling marketplace environment (install-all)\n'); + +const justInstalled = installFromWorkspace(); +if (justInstalled.length > 0) log(` installed-packages: ${justInstalled.join(', ')}`); + +const store = readInstalledArtifacts(); +if (store.length === 0) { + log('\nβœ— No installed packages found in .objectstack/installed-packages/.'); + log(' Build the templates first: pnpm -r build'); + log(' Or install Apps via the marketplace, then re-run this command.'); + process.exit(1); +} + +const env = compile(store); +mkdirSync(dirname(OUT), { recursive: true }); +writeFileSync(OUT, `${JSON.stringify(env, null, 2)}\n`); + +log(''); +log(`βœ“ Composed ${store.length} apps Β· ${env.objects?.length ?? 0} objects Β· ${env.flows?.length ?? 0} flows`); +log(` apps: ${(env.apps ?? []).map((a) => a.name).join(', ')}`); +log(` installed-packages: ${INSTALLED_DIR.replace(`${PACKAGES_DIR}/`, '')}`); +log(` β†’ ${OUT.replace(`${PACKAGES_DIR}/`, '')}`); +log(''); +log('Run it: objectstack dev all --artifact dist/objectstack.json -p 4000 --fresh'); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8be3121..d69c176 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,6 +15,28 @@ importers: specifier: ^6.0.3 version: 6.0.3 + packages/all: + dependencies: + '@objectstack/account': + specifier: ^7.4.1 + version: 7.4.1 + '@objectstack/cli': + specifier: ^7.4.1 + version: 7.4.1(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@objectstack/core@7.4.1(ai@6.0.191(zod@4.4.3)))(@opentelemetry/api@1.9.1)(ai@6.0.191(zod@4.4.3))(better-call@1.3.5(zod@4.4.3))(better-sqlite3@12.10.0)(jose@6.2.3)(kysely@0.28.17)(mongodb@7.2.0)(nanostores@1.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@objectstack/driver-sqlite-wasm': + specifier: ^7.4.1 + version: 7.4.1(ai@6.0.191(zod@4.4.3))(better-sqlite3@12.10.0) + '@objectstack/runtime': + specifier: ^7.4.1 + version: 7.4.1(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(ai@6.0.191(zod@4.4.3))(better-call@1.3.5(zod@4.4.3))(better-sqlite3@12.10.0)(jose@6.2.3)(kysely@0.28.17)(mongodb@7.2.0)(nanostores@1.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + sql.js: + specifier: ^1.14.1 + version: 1.14.1 + optionalDependencies: + better-sqlite3: + specifier: ^12.10.0 + version: 12.10.0 + packages/compliance: dependencies: '@objectstack/account':